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. # If true, generate relative URLs for all internal URLs. Default is false.
relative = 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] [templates]
# The template directory name under the Obsidian vault. Default is ".dragonglass.tmpl". # The template directory name under the Obsidian vault. Default is ".dragonglass.tmpl".
directory = ".dragonglass.tmpl" directory = ".dragonglass.tmpl"

View File

@ -19,7 +19,35 @@ DEFAULT_TEMPLATE_DIRECTORY = ".dragonglass.tmpl"
DEFAULT_TEMPLATE_NAME = "default.html" DEFAULT_TEMPLATE_NAME = "default.html"
""" The default stylesheet name.""" """ The default stylesheet name."""
DEFAULT_STYLESHEET_NAME = "dragonglass.css" 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: class Context:
""" """
@ -98,3 +126,29 @@ class Context:
if not self.template_dir.is_dir(): if not self.template_dir.is_dir():
return DEFAULT_TEMPLATE_NAME return DEFAULT_TEMPLATE_NAME
return node.metadata.get("template", self._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 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): class MetaStripper(Extension):
""" """
An extension that strips the metadata off the front of Obsidian pages, as it's already been parsed in an 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. 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]: 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. Markdown link processing to handle Obsidian internal links as well as external links.
""" """
def __init__(self, context: Context, **kwargs: dict[str, Any]) -> None: 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) super(ObsidianLinks, self).__init__(**kwargs)
self._context = context self._context = context
@property @property
def obsidian_link_classname(self) -> str: def obsidian_link_classname(self) -> str:
return 'obsidian-link' """Returns the classname for Obsidian links."""
return self._context.get_classname('obsidian-link')
@property @property
def invalid_reference_classname(self) -> str: 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]: 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 contents = contents.replace(r'\|','|') # handle case where we're inside tables
text = None text = None
t = contents.split('|') t = contents.split('|')
@ -337,11 +339,34 @@ class ObsidianLinks(Extension):
return None, text return None, text
class ObsidianLinksProc(InlineProcessor): class ObsidianLinksProc(InlineProcessor):
"""Processor that handles Obsidian links, [[page]]."""
def __init__(self, pattern: str, md: markdown.Markdown, extref: Any) -> None: 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) super(ObsidianLinks.ObsidianLinksProc, self).__init__(pattern, md)
self._extref = extref self._extref = extref
def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # noqa: N802 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)) link, text = self._extref._parse_reference(m.group(1))
if link is None: if link is None:
el = etree.Element('span') el = etree.Element('span')
@ -355,11 +380,34 @@ class ObsidianLinks(Extension):
return el, m.start(0), m.end(0) return el, m.start(0), m.end(0)
class GenericLinksProc(InlineProcessor): class GenericLinksProc(InlineProcessor):
"""Processor that handles generic links, [link text](url)."""
def __init__(self, pattern: str, md: markdown.Markdown, extref: Any) -> None: 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) super(ObsidianLinks.GenericLinksProc, self).__init__(pattern, md)
self._extref = extref self._extref = extref
def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # noqa: N802 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) text = m.group(1)
link = m.group(2) link = m.group(2)
if link.startswith('<') and link.endswith('>'): # handle whitespace encoding 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. bool: ``True`` if this processor can handle the block, ``False`` if not.
""" """
if self._extref.LIST_START in block: if self._extref.LIST_START in block:
logger.debug("DETECT")
return True return True
return False return False
@ -664,7 +711,6 @@ class ObsidianLists(Extension):
blocks.insert(0, chunk[p + len(self._extref.LIST_END) + 1:]) blocks.insert(0, chunk[p + len(self._extref.LIST_END) + 1:])
list_lines = chunk[:p].rstrip().split('\n') list_lines = chunk[:p].rstrip().split('\n')
assert len(list_lines) > 0 assert len(list_lines) > 0
logger.debug(f"*** Found list: {list_lines}")
_, excess = self._build_list(parent, list_lines) _, excess = self._build_list(parent, list_lines)
if len(excess) > 0: if len(excess) > 0:
blocks.insert(0, '\n'.join(excess)) blocks.insert(0, '\n'.join(excess))
@ -862,36 +908,36 @@ class ObsidianStyleFootnotes(FootnoteExtension):
class ObsidianStyleBlockquotes(Extension): 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): class ObsidianBlockQuote(BlockQuoteProcessor):
CALLOUT = re.compile(r'^\[!([a-z]+)\]([-+])?(?:[ ]+(.*))?') CALLOUT = re.compile(r'^\[!([a-z]+)\]([-+])?(?:[ ]+(.*))?')
CALLOUT_DEFICON = 'pencil'
CALLOUT_ICONS = { def __init__(self, parser: BlockParser, extref: Any) -> None:
'abstract': 'clipboard-list', super(ObsidianStyleBlockquotes.ObsidianBlockQuote, self).__init__(parser)
'attention': 'triangle-alert', self._extref = extref
'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 normal_blockquote(self, parent: etree.Element, block: str) -> None: def normal_blockquote(self, parent: etree.Element, block: str) -> None:
sibling = self.lastChild(parent) sibling = self.lastChild(parent)
@ -908,7 +954,7 @@ class ObsidianStyleBlockquotes(Extension):
self.parser.state.reset() self.parser.state.reset()
def callout_block(self, parent: etree.Element, lines: list[str]) -> None: 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) callout_type = m.group(1)
folding = m.group(2) folding = m.group(2)
title = m.group(3) title = m.group(3)
@ -923,7 +969,7 @@ class ObsidianStyleBlockquotes(Extension):
title_div = etree.SubElement(base_div, 'div', {'class': 'callout-title'}) title_div = etree.SubElement(base_div, 'div', {'class': 'callout-title'})
icon_div = etree.SubElement(title_div, 'div', {'class': 'callout-icon'}) icon_div = etree.SubElement(title_div, 'div', {'class': 'callout-icon'})
etree.SubElement(icon_div, 'span', 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 = etree.SubElement(title_div, 'div', {'class': 'callout-title-inner'})
inner_title_div.text = title inner_title_div.text = title
if folding: if folding:
@ -933,7 +979,6 @@ class ObsidianStyleBlockquotes(Extension):
fold_div = etree.SubElement(title_div, 'div', {'class': baseclass}) fold_div = etree.SubElement(title_div, 'div', {'class': baseclass})
etree.SubElement(fold_div, 'span', etree.SubElement(fold_div, 'span',
{'data-lucide': 'chevron-right' if folding == '-' else 'chevron-down'}) {'data-lucide': 'chevron-right' if folding == '-' else 'chevron-down'})
lines.pop(0)
if len(lines) > 0: if len(lines) > 0:
content_div = etree.SubElement(base_div, 'div', {'class': 'callout-content'}) content_div = etree.SubElement(base_div, 'div', {'class': 'callout-content'})
if folding: if folding:
@ -955,11 +1000,10 @@ class ObsidianStyleBlockquotes(Extension):
# Remove `> ` from beginning of each line. # Remove `> ` from beginning of each line.
lines = [self.clean(line) for line in block[m.start():].split('\n')] lines = [self.clean(line) for line in block[m.start():].split('\n')]
callout = (self.CALLOUT.match(lines[0]) is not None) callout = (self.CALLOUT.match(lines[0]) is not None)
block = '\n'.join(lines)
if callout: if callout:
self.callout_block(parent, lines) self.callout_block(parent, lines)
else: else:
self.normal_blockquote(parent, block) self.normal_blockquote(parent, '\n'.join(lines))
class CalloutLinesProcessor(BlockProcessor): class CalloutLinesProcessor(BlockProcessor):
def test(self, parent: etree.Element, block: str) -> bool: def test(self, parent: etree.Element, block: str) -> bool:
@ -984,7 +1028,7 @@ class ObsidianStyleBlockquotes(Extension):
return True return True
def extendMarkdown(self, md) -> None: 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), md.parser.blockprocessors.register(ObsidianStyleBlockquotes.CalloutLinesProcessor(md.parser),
'callout-text', 11) 'callout-text', 11)
@ -1015,7 +1059,7 @@ def create_markdown_parser(context: Context) -> markdown.Markdown:
MetaStripper(), MetaStripper(),
ObsidianComments(), ObsidianComments(),
ObsidianStyleFootnotes(SUPERSCRIPT_TEXT='[{}]', SEPARATOR='-'), ObsidianStyleFootnotes(SUPERSCRIPT_TEXT='[{}]', SEPARATOR='-'),
ObsidianStyleBlockquotes(), ObsidianStyleBlockquotes(context),
ObsidianImages(context), ObsidianImages(context),
ObsidianLinks(context), ObsidianLinks(context),
ObsidianLists(), ObsidianLists(),

View File

@ -7,12 +7,18 @@ from jinja2 import Environment
from .config import Context 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.""" """Template data for the default stylesheet."""
STYLESHEET_DATA = """/* Dragonglass default CSS file - ensure all generated HTML pages reference this */ STYLESHEET_DATA = """/* Dragonglass default CSS file - ensure all generated HTML pages reference this */
a { a {
color: #8a5cf5; color: #8a5cf5;
} }
.invalid-reference { .{{ invalid_reference }} {
color: #ad8df8; color: #ad8df8;
text-decoration: underline; text-decoration: underline;
text-decoration-color: #e6ddfd; 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. tenv (Environment): Template engine used to render the default stylesheet data.
dest_dir (Path): The destination directory to write the stylesheet to. 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 to_file = dest_dir / ctxt.default_stylesheet
tmpl = tenv.from_string(STYLESHEET_DATA) tmpl = tenv.from_string(STYLESHEET_DATA)
data = tmpl.render({}) data = tmpl.render(vars)
with to_file.open("wt") as f: with to_file.open("wt") as f:
f.write(data) f.write(data)