From 8c4ab0c4d53062a9599fd272cd78213d3e218445 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sun, 11 Aug 2024 23:52:49 -0600 Subject: [PATCH] allow configuration of some code generation details within Markdown converter --- doc/configuration.toml | 35 +++++++++ src/dragonglass/config.py | 56 +++++++++++++- src/dragonglass/mparse.py | 156 ++++++++++++++++++++++++-------------- src/dragonglass/style.py | 17 ++++- 4 files changed, 205 insertions(+), 59 deletions(-) diff --git a/doc/configuration.toml b/doc/configuration.toml index 46ba3f0..f62001f 100644 --- a/doc/configuration.toml +++ b/doc/configuration.toml @@ -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" diff --git a/src/dragonglass/config.py b/src/dragonglass/config.py index c3c7d65..fe6235e 100644 --- a/src/dragonglass/config.py +++ b/src/dragonglass/config.py @@ -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))) diff --git a/src/dragonglass/mparse.py b/src/dragonglass/mparse.py index eb07e86..0fb9efc 100644 --- a/src/dragonglass/mparse.py +++ b/src/dragonglass/mparse.py @@ -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(), diff --git a/src/dragonglass/style.py b/src/dragonglass/style.py index e4f52d2..0665659 100644 --- a/src/dragonglass/style.py +++ b/src/dragonglass/style.py @@ -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)