From 1663d178cafb786719d3b728c58d6de9415b136f Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Wed, 14 Aug 2024 22:50:05 -0600 Subject: [PATCH] added linking support to headers --- src/dragonglass/mparse.py | 105 ++++++++++++++++++++++++++++++++++++-- src/dragonglass/style.py | 4 ++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/dragonglass/mparse.py b/src/dragonglass/mparse.py index 997358a..5479f55 100644 --- a/src/dragonglass/mparse.py +++ b/src/dragonglass/mparse.py @@ -45,6 +45,7 @@ COMMENT_MARKER = '%%' # Tags pattern OBSTAG_PATTERN = r'#([a-zA-Z0-9/_-]+)' + def is_proper_url(s: str) -> bool: """ Checks to see if a string is a "proper" URL. @@ -75,6 +76,19 @@ def sanitize_reference(s: str) -> str: return rc +def make_hash_value(s: str) -> str: + """ + Create a secure hash value for the passed-in string. + + Args: + s (str): The string to be hashed. + + Returns: + A secure hash value for the string, expressed as hex digits. + """ + return hashlib.sha1(bytes(s, 'utf-8')).hexdigest() + + class MetaStripper(Extension): """ An extension that strips the metadata off the front of Obsidian pages, as it's already been parsed in an @@ -110,6 +124,82 @@ class MetaStripper(Extension): md.preprocessors.register(MetaStripper.MetaStripperProc(md), 'metastripper', PRIO_BASE) +class ObsidianHeaders(Extension): + """An extension that augments the existing header processing.""" + + class HashHeaderProcessor(BlockProcessor): + """ Process Hash Headers. """ + + # Detect a header at start of any line in block + RE = re.compile(r'(?:^|\n)(?P#{1,6})(?P
(?:\\.|[^\\])*?)#*(?:\n|$)') + + def test(self, parent: etree.Element, block: str) -> bool: + """ + Tests to see whether the current block can be handled by this processor. + + Args: + parent (etree.Element): The current parent element. + block (str): The current block to be tested. + + Returns: + bool: ``True`` if this processor can handle the block, ``False`` if not. + """ + return bool(self.RE.search(block)) + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + """ + Processes the text in the current block and adds elements to the parent element. + + Args: + parent (etree.Element): Parent element to add new subelements to. + blocks (list[str]): Blocks of text to be processed. The first block in this list is the one for which + the ``test`` method returned ``True``. This method should remove any parsed text from the + ``block`` list before it returns. + + Returns: + bool: ``True`` if text was parsed by this method, ``False`` if not. + """ + block = blocks.pop(0) + m = self.RE.search(block) + if m: + before = block[:m.start()] # All lines before header + after = block[m.end():] # All lines after header + if before: + # As the header was not the first line of the block and the + # lines before the header must be parsed first, + # recursively parse this lines as a block. + self.parser.parseBlocks(parent, [before]) + + # Get the header text and its hash. + hdr_text = m.group('header').strip() + hval = make_hash_value(hdr_text) + + # Create header (with anchor) using named groups from RE + a = etree.SubElement(parent, 'a', {'class': 'hdrref', 'name': f"hdr-{hval}"}) + h = etree.SubElement(a, 'h%d' % len(m.group('level'))) + h.text = hdr_text + if after: + # Insert remaining lines as first block for future parsing. + if self.parser.state.isstate('looselist'): + # This is a weird edge case where a header is a child of a loose list + # and there is no blank line after the header. To ensure proper + # parsing, the line(s) after need to be detabbed. See #1443. + after = self.looseDetab(after) + blocks.insert(0, after) + else: # pragma: no cover + # This should never happen, but just in case... + logger.warn("We've got a problem header: %r" % block) + + def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 + """ + Registers the header processor with the Markdown parser. + + Args: + md (markdown.Markdown): The Markdown parser to register the patterns with. + """ + md.parser.blockprocessors.register(ObsidianHeaders.HashHeaderProcessor(md.parser), 'hashheader', 70) + + class ObsidianImages(Extension): """An extension that supports image tags the way Obsidian handles them.""" __DIMS = re.compile(r'(.*)\|[ ]*(\d+)(?:[ ]*x[ ]*(\d+))?') @@ -328,6 +418,11 @@ class ObsidianLinks(Extension): if len(t) > 1: text = t[1] contents = t[0].rstrip('\\') + t = contents.split('#') + hashloc = None + if len(t) > 1: + hashloc = make_hash_value(t[1].strip()) + contents = t[0] assert self._context.src_index is not None node, _ = self._context.src_index.lookup(contents) @@ -336,8 +431,11 @@ class ObsidianLinks(Extension): if node: # record a backlink from the current node to the found one node.backlinks.add(self._context.current_node) - return node.link_target(self._context.url_prefix, - self._context.current_node if self._context.relative_links else None), text + link = node.link_target(self._context.url_prefix, + self._context.current_node if self._context.relative_links else None) + if hashloc: + link = f"{link}#hdr-{hashloc}" + return link, text return None, text class ObsidianLinksProc(InlineProcessor): @@ -856,7 +954,7 @@ class ObsidianStyleFootnotes(FootnoteExtension): if m_inline: footnote = m_inline.group(1).strip() - id = hashlib.sha1(bytes(f"{footnote}{self._inlines_nonce}", 'utf-8')).hexdigest() + id = make_hash_value(f"{footnote}{self._inlines_nonce}") self._inlines_nonce += 1 self.footnotes.setFootnote(id, footnote) blocks.insert(0, block[:m_inline.start(0)].rstrip() @@ -1114,6 +1212,7 @@ def create_markdown_parser(context: Context) -> markdown.Markdown: 'tables', MetaStripper(), ObsidianComments(), + ObsidianHeaders(), ObsidianStyleFootnotes(SUPERSCRIPT_TEXT='[{}]', SEPARATOR='-'), ObsidianStyleBlockquotes(context), ObsidianImages(context), diff --git a/src/dragonglass/style.py b/src/dragonglass/style.py index a4f7c66..c9c99f7 100644 --- a/src/dragonglass/style.py +++ b/src/dragonglass/style.py @@ -18,6 +18,10 @@ STYLESHEET_DATA = """/* Dragonglass default CSS file - ensure all generated HTML a { color: #8a5cf5; } +a.hdrref { + color: inherit; + text-decoration: none; +} .{{ invalid_reference }} { color: #ad8df8; text-decoration: underline;