allow configuration of some code generation details within Markdown converter

This commit is contained in:
Amy G. Bowersox 2024-08-11 23:52:49 -06:00
parent 72a2ea1ab5
commit 8c4ab0c4d5
4 changed files with 205 additions and 59 deletions

View File

@ -6,6 +6,41 @@ prefix = "/"
# If true, generate relative URLs for all internal URLs. Default is false.
relative = false
[classnames]
# CSS class to use for an invalid reference.
invalid-reference = "invalid-reference"
# CSS class to use for an Obsidian link.
obsidian-link = "obsidian-link"
[callout-icons]
# The default callout icon.
_default = "pencil"
# Specific callout icons for other callouts.
abstract = "clipboard-list"
attention = "triangle-alert"
bug = "bug"
caution = "triangle-alert"
check = "check"
cite = "quote"
danger = "zap"
done = "check"
error = "zap"
example = "list"
fail = "x"
failure = "x"
faq = "circle-help"
help = "circle-help"
info = "info"
missing = "x"
question = "circle-help"
quote = "quote"
success = "check"
summary = "clipboard-list"
tip = "flame"
tldr = "clipboard-list"
todo = "circle-check"
warning = "triangle-alert"
[templates]
# The template directory name under the Obsidian vault. Default is ".dragonglass.tmpl".
directory = ".dragonglass.tmpl"

View File

@ -19,7 +19,35 @@ DEFAULT_TEMPLATE_DIRECTORY = ".dragonglass.tmpl"
DEFAULT_TEMPLATE_NAME = "default.html"
""" The default stylesheet name."""
DEFAULT_STYLESHEET_NAME = "dragonglass.css"
"""The default callout icon."""
CALLOUT_DEFICON = 'pencil'
"""The default callout icons."""
CALLOUT_ICONS = {
'abstract': 'clipboard-list',
'attention': 'triangle-alert',
'bug': 'bug',
'caution': 'triangle-alert',
'check': 'check',
'cite': 'quote',
'danger': 'zap',
'done': 'check',
'error': 'zap',
'example': 'list',
'fail': 'x',
'failure': 'x',
'faq': 'circle-help',
'help': 'circle-help',
'info': 'info',
'missing': 'x',
'question': 'circle-help',
'quote': 'quote',
'success': 'check',
'summary': 'clipboard-list',
'tip': 'flame',
'tldr': 'clipboard-list',
'todo': 'circle-check',
'warning': 'triangle-alert'
}
class Context:
"""
@ -98,3 +126,29 @@ class Context:
if not self.template_dir.is_dir():
return DEFAULT_TEMPLATE_NAME
return node.metadata.get("template", self._default_template_name)
def get_classname(self, classname: str) -> str:
"""
Return a classname configured in the configuration file.
Args:
classname (str): The key for the classname to get, which is also the default value.
Returns:
str: the configured classname, or the ``classname`` argument itself if nothing was configured.
"""
classnames_section = self.config.get("classnames", {})
return classnames_section.get(classname, classname)
def get_callout_iconname(self, callout: str) -> str:
"""
Return the name for a Lucide icon that represents a particular callout type.
Args:
callout (str): The type of the callout in question.
Returns:
str: The name of an icon, or a default icon name.
"""
callout_icons = self.config.get('callout-icons', {})
return callout_icons.get(callout, CALLOUT_ICONS.get(callout, callout_icons.get('_default', CALLOUT_DEFICON)))

View File

@ -73,23 +73,6 @@ def sanitize_reference(s: str) -> str:
return rc
def find_extension(md: markdown.Markdown, cls: type[Extension]) -> Extension | None:
"""
Locate a registered extension in the Markdown parser.
Args:
md (markdown.Markdown): The Markdown parser to look through.
cls (type): The class of the extension to be retrieved.
Returns:
Extension: The retrieved extension, or ``None`` if it was not found.
"""
for ex in md.registeredExtensions:
if isinstance(ex, cls):
return ex
return None
class MetaStripper(Extension):
"""
An extension that strips the metadata off the front of Obsidian pages, as it's already been parsed in an
@ -145,7 +128,7 @@ class ObsidianImages(Extension):
"""
Returns the CSS class name for an invalid reference in the text.
"""
return 'invalid-reference'
return self._context.get_classname('invalid-reference')
def _parse_dimensions(self, s: str) -> tuple[str, int, int]:
"""
@ -306,18 +289,37 @@ class ObsidianLinks(Extension):
Markdown link processing to handle Obsidian internal links as well as external links.
"""
def __init__(self, context: Context, **kwargs: dict[str, Any]) -> None:
"""
Initialize the ObsidianLinks extension.
Args:
context (Context): Context object that contains configuration information.
**kwargs (dict[str, Any]: Additional configuration information.
"""
super(ObsidianLinks, self).__init__(**kwargs)
self._context = context
@property
def obsidian_link_classname(self) -> str:
return 'obsidian-link'
"""Returns the classname for Obsidian links."""
return self._context.get_classname('obsidian-link')
@property
def invalid_reference_classname(self) -> str:
return 'invalid-reference'
"""Returns the classname for invalid references."""
return self._context.get_classname('invalid-reference')
def _parse_reference(self, contents: str) -> tuple[str | None, str]:
"""
Parse a reference and break it into link target and title.
Args:
contents (str): Contents of a link reference to look up.
Returns:
str: The link target, or ``None`` if the link is invalid.
str: The link title to be used.
"""
contents = contents.replace(r'\|','|') # handle case where we're inside tables
text = None
t = contents.split('|')
@ -337,11 +339,34 @@ class ObsidianLinks(Extension):
return None, text
class ObsidianLinksProc(InlineProcessor):
"""Processor that handles Obsidian links, [[page]]."""
def __init__(self, pattern: str, md: markdown.Markdown, extref: Any) -> None:
"""
Initialize the ObsidianLinksProc.
Args:
pattern (str): Regular expression pattern to be matched by this processor.
md (markdown.Markdown): Reference to the Markdown parser.
extref (ObsidianLinks): Backreference to the outer object.
"""
super(ObsidianLinks.ObsidianLinksProc, self).__init__(pattern, md)
self._extref = extref
def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # noqa: N802
"""
Handles a match on the reference for this processor.
Args:
m (re.Match[str]): The regular expression match data.
data (str): The entire block of text surrounding the pattern, as a multi-line string.
Returns:
el (etree.Element): The new HTML element being added to the tree, or ``None`` if the match was rejected.
int: The index of the first character in ``data`` that was "consumed" by the pattern, or ``None``
if the match was rejected,
int: The index of the first character in ``data`` that was *not* consumed by the pattern, or ``None``
if the match was rejected.
"""
link, text = self._extref._parse_reference(m.group(1))
if link is None:
el = etree.Element('span')
@ -355,11 +380,34 @@ class ObsidianLinks(Extension):
return el, m.start(0), m.end(0)
class GenericLinksProc(InlineProcessor):
"""Processor that handles generic links, [link text](url)."""
def __init__(self, pattern: str, md: markdown.Markdown, extref: Any) -> None:
"""
Initialize the GenericLinksProc.
Args:
pattern (str): Regular expression pattern to be matched by this processor.
md (markdown.Markdown): Reference to the Markdown parser.
extref (ObsidianLinks): Backreference to the outer object.
"""
super(ObsidianLinks.GenericLinksProc, self).__init__(pattern, md)
self._extref = extref
def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # noqa: N802
"""
Handles a match on the reference for this processor.
Args:
m (re.Match[str]): The regular expression match data.
data (str): The entire block of text surrounding the pattern, as a multi-line string.
Returns:
el (etree.Element): The new HTML element being added to the tree, or ``None`` if the match was rejected.
int: The index of the first character in ``data`` that was "consumed" by the pattern, or ``None``
if the match was rejected,
int: The index of the first character in ``data`` that was *not* consumed by the pattern, or ``None``
if the match was rejected.
"""
text = m.group(1)
link = m.group(2)
if link.startswith('<') and link.endswith('>'): # handle whitespace encoding
@ -635,7 +683,6 @@ class ObsidianLists(Extension):
bool: ``True`` if this processor can handle the block, ``False`` if not.
"""
if self._extref.LIST_START in block:
logger.debug("DETECT")
return True
return False
@ -664,7 +711,6 @@ class ObsidianLists(Extension):
blocks.insert(0, chunk[p + len(self._extref.LIST_END) + 1:])
list_lines = chunk[:p].rstrip().split('\n')
assert len(list_lines) > 0
logger.debug(f"*** Found list: {list_lines}")
_, excess = self._build_list(parent, list_lines)
if len(excess) > 0:
blocks.insert(0, '\n'.join(excess))
@ -862,36 +908,36 @@ class ObsidianStyleFootnotes(FootnoteExtension):
class ObsidianStyleBlockquotes(Extension):
"""Extension that handles both blockquotes and callouts in the Obsidian style."""
def __init__(self, context: Context, **kwargs: dict[str, Any]) -> None:
"""
Initialize the ObsidianStyleBlockquotes extension.
Args:
context (Context): Context object that contains configuration information.
**kwargs (dict[str, Any]: Additional configuration information.
"""
super(ObsidianStyleBlockquotes, self).__init__(**kwargs)
self._context = context
def _callout_iconname(self, callout: str) -> str:
"""
Returns the Lucide icon name for a specific type of callout.
Args:
callout (str): The callout type.
Returns:
The icon name to use.
"""
return self._context.get_callout_iconname(callout)
class ObsidianBlockQuote(BlockQuoteProcessor):
CALLOUT = re.compile(r'^\[!([a-z]+)\]([-+])?(?:[ ]+(.*))?')
CALLOUT_DEFICON = 'pencil'
CALLOUT_ICONS = {
'abstract': 'clipboard-list',
'attention': 'triangle-alert',
'bug': 'bug',
'caution': 'triangle-alert',
'check': 'check',
'cite': 'quote',
'danger': 'zap',
'done': 'check',
'error': 'zap',
'example': 'list',
'fail': 'x',
'failure': 'x',
'faq': 'circle-help',
'help': 'circle-help',
'info': 'info',
'missing': 'x',
'question': 'circle-help',
'quote': 'quote',
'success': 'check',
'summary': 'clipboard-list',
'tip': 'flame',
'tldr': 'clipboard-list',
'todo': 'circle-check',
'warning': 'triangle-alert'
}
def __init__(self, parser: BlockParser, extref: Any) -> None:
super(ObsidianStyleBlockquotes.ObsidianBlockQuote, self).__init__(parser)
self._extref = extref
def normal_blockquote(self, parent: etree.Element, block: str) -> None:
sibling = self.lastChild(parent)
@ -908,7 +954,7 @@ class ObsidianStyleBlockquotes(Extension):
self.parser.state.reset()
def callout_block(self, parent: etree.Element, lines: list[str]) -> None:
m = self.CALLOUT.match(lines[0])
m = self.CALLOUT.match(lines.pop(0))
callout_type = m.group(1)
folding = m.group(2)
title = m.group(3)
@ -923,7 +969,7 @@ class ObsidianStyleBlockquotes(Extension):
title_div = etree.SubElement(base_div, 'div', {'class': 'callout-title'})
icon_div = etree.SubElement(title_div, 'div', {'class': 'callout-icon'})
etree.SubElement(icon_div, 'span',
{'data-lucide': self.CALLOUT_ICONS.get(callout_type, self.CALLOUT_DEFICON)})
{'data-lucide': self._extref._callout_iconname(callout_type)})
inner_title_div = etree.SubElement(title_div, 'div', {'class': 'callout-title-inner'})
inner_title_div.text = title
if folding:
@ -933,7 +979,6 @@ class ObsidianStyleBlockquotes(Extension):
fold_div = etree.SubElement(title_div, 'div', {'class': baseclass})
etree.SubElement(fold_div, 'span',
{'data-lucide': 'chevron-right' if folding == '-' else 'chevron-down'})
lines.pop(0)
if len(lines) > 0:
content_div = etree.SubElement(base_div, 'div', {'class': 'callout-content'})
if folding:
@ -955,11 +1000,10 @@ class ObsidianStyleBlockquotes(Extension):
# Remove `> ` from beginning of each line.
lines = [self.clean(line) for line in block[m.start():].split('\n')]
callout = (self.CALLOUT.match(lines[0]) is not None)
block = '\n'.join(lines)
if callout:
self.callout_block(parent, lines)
else:
self.normal_blockquote(parent, block)
self.normal_blockquote(parent, '\n'.join(lines))
class CalloutLinesProcessor(BlockProcessor):
def test(self, parent: etree.Element, block: str) -> bool:
@ -984,7 +1028,7 @@ class ObsidianStyleBlockquotes(Extension):
return True
def extendMarkdown(self, md) -> None:
md.parser.blockprocessors.register(ObsidianStyleBlockquotes.ObsidianBlockQuote(md.parser), 'quote', 20)
md.parser.blockprocessors.register(ObsidianStyleBlockquotes.ObsidianBlockQuote(md.parser, self), 'quote', 20)
md.parser.blockprocessors.register(ObsidianStyleBlockquotes.CalloutLinesProcessor(md.parser),
'callout-text', 11)
@ -1015,7 +1059,7 @@ def create_markdown_parser(context: Context) -> markdown.Markdown:
MetaStripper(),
ObsidianComments(),
ObsidianStyleFootnotes(SUPERSCRIPT_TEXT='[{}]', SEPARATOR='-'),
ObsidianStyleBlockquotes(),
ObsidianStyleBlockquotes(context),
ObsidianImages(context),
ObsidianLinks(context),
ObsidianLists(),

View File

@ -7,12 +7,18 @@ from jinja2 import Environment
from .config import Context
"""Class names we want to look up in the configuration file."""
KNOWN_CLASSNAMES = [
'invalid-reference',
'obsidian-link'
]
"""Template data for the default stylesheet."""
STYLESHEET_DATA = """/* Dragonglass default CSS file - ensure all generated HTML pages reference this */
a {
color: #8a5cf5;
}
.invalid-reference {
.{{ invalid_reference }} {
color: #ad8df8;
text-decoration: underline;
text-decoration-color: #e6ddfd;
@ -222,8 +228,15 @@ def write_default_stylesheet(ctxt: Context, tenv: Environment, dest_dir: Path) -
tenv (Environment): Template engine used to render the default stylesheet data.
dest_dir (Path): The destination directory to write the stylesheet to.
"""
vars = {}
# Fill in all the class names as variables.
for n in KNOWN_CLASSNAMES:
vname = n.replace("-", '_')
vars[vname] = ctxt.get_classname(n)
to_file = dest_dir / ctxt.default_stylesheet
tmpl = tenv.from_string(STYLESHEET_DATA)
data = tmpl.render({})
data = tmpl.render(vars)
with to_file.open("wt") as f:
f.write(data)