From 539d8f5eb05eff752076d40d259859435369479b Mon Sep 17 00:00:00 2001 From: Xeek <6032840+x3ek@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:44:33 -0500 Subject: [PATCH 1/2] fix(markdown): wrap heading text in anchor link instead of appending # marker Replaces TocExtension permalink='#' with a custom HeadingAnchorTreeprocessor that wraps heading text in . Updates all three bundled themes to style .heading-anchor instead of .headerlink. Closes #68. Co-Authored-By: Claude Sonnet 4.6 --- src/squishmark/services/markdown.py | 37 +++++++++++++++++++++++++++- tests/test_markdown.py | 33 +++++-------------------- themes/blue-tech/static/style.css | 29 ++++++---------------- themes/default/static/style.css | 12 +++++++++ themes/terminal/static/css/style.css | 18 +++++--------- 5 files changed, 68 insertions(+), 61 deletions(-) diff --git a/src/squishmark/services/markdown.py b/src/squishmark/services/markdown.py index 4a7f0bc..a681b20 100644 --- a/src/squishmark/services/markdown.py +++ b/src/squishmark/services/markdown.py @@ -2,9 +2,12 @@ import datetime import re +import xml.etree.ElementTree as etree from typing import Any import markdown +import markdown.treeprocessors +from markdown.extensions import Extension from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.fenced_code import FencedCodeExtension from markdown.extensions.toc import TocExtension @@ -14,6 +17,37 @@ from squishmark.services.url_rewriter import rewrite_image_urls +class HeadingAnchorTreeprocessor(markdown.treeprocessors.Treeprocessor): + """Wraps heading text in a self-referencing anchor link.""" + + def run(self, root: etree.Element) -> None: + for heading in root.iter(): + if heading.tag not in {"h1", "h2", "h3", "h4", "h5", "h6"}: + continue + heading_id = heading.get("id") + if not heading_id: + continue + + # Collect all children (text + inline elements) into a new anchor + anchor = etree.Element("a") + anchor.set("href", f"#{heading_id}") + anchor.set("class", "heading-anchor") + anchor.text = heading.text + for child in list(heading): + anchor.append(child) + + # Replace heading contents with the anchor + heading.text = None + for child in list(heading): + heading.remove(child) + heading.append(anchor) + + +class HeadingAnchorExtension(Extension): + def extendMarkdown(self, md: markdown.Markdown) -> None: + md.treeprocessors.register(HeadingAnchorTreeprocessor(md), "heading_anchor", 1) + + class LabeledFormatter(HtmlFormatter): """Displays language labels on code blocks using Pygments' filename feature.""" @@ -47,7 +81,8 @@ def _get_markdown_instance(self) -> markdown.Markdown: guess_lang=False, pygments_formatter=LabeledFormatter, ), - TocExtension(permalink="#"), + TocExtension(permalink=False), + HeadingAnchorExtension(), "smarty", # Smart quotes "nl2br", # Newlines to
], diff --git a/tests/test_markdown.py b/tests/test_markdown.py index f5f3039..1a3b0b1 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -157,35 +157,14 @@ def test_extract_slug(markdown_service): assert slug_no_date == "about" -def test_heading_anchor_uses_hash(markdown_service): - """Heading permalinks should use # not pilcrow.""" - from html.parser import HTMLParser - +def test_heading_text_is_anchor_link(markdown_service): + """Heading text should be wrapped in a self-referencing anchor link.""" html = markdown_service.render_markdown("## Hello World") - # Parse the HTML to check the headerlink anchor's visible text specifically, - # since '#' also appears in href fragments like href="#hello-world". - class AnchorTextExtractor(HTMLParser): - def __init__(self): - super().__init__() - self.in_headerlink = False - self.headerlink_text = "" - - def handle_starttag(self, tag, attrs): - if tag == "a" and ("class", "headerlink") in attrs: - self.in_headerlink = True - - def handle_data(self, data): - if self.in_headerlink: - self.headerlink_text += data - - def handle_endtag(self, tag): - if tag == "a": - self.in_headerlink = False - - parser = AnchorTextExtractor() - parser.feed(html) - assert parser.headerlink_text == "#", f"Expected headerlink text to be '#', got '{parser.headerlink_text}'" + assert 'class="heading-anchor"' in html, f"Expected heading-anchor class, got: {html}" + assert 'href="#hello-world"' in html, f"Expected href to heading id, got: {html}" + assert "headerlink" not in html, f"Old headerlink should be gone, got: {html}" + assert "#
" not in html, f"Bare # marker should not appear, got: {html}" assert "\u00b6" not in html # should NOT use pilcrow diff --git a/themes/blue-tech/static/style.css b/themes/blue-tech/static/style.css index 5877f46..1b32b79 100644 --- a/themes/blue-tech/static/style.css +++ b/themes/blue-tech/static/style.css @@ -474,31 +474,18 @@ img { .post-content h3, .page-content h3 { font-size: 1.5rem; } .post-content h4, .page-content h4 { font-size: 1.25rem; } -/* Hide anchor links by default, show on hover */ -.post-content .headerlink, -.page-content .headerlink { - opacity: 0; - margin-left: 0.5rem; - color: var(--color-text-muted); +/* Heading anchor links */ +.post-content .heading-anchor, +.page-content .heading-anchor { + color: inherit; text-decoration: none; - transition: opacity var(--transition); -} - -.post-content h1:hover .headerlink, -.post-content h2:hover .headerlink, -.post-content h3:hover .headerlink, -.post-content h4:hover .headerlink, -.page-content h1:hover .headerlink, -.page-content h2:hover .headerlink, -.page-content h3:hover .headerlink, -.page-content h4:hover .headerlink { - opacity: 0.5; } -.post-content .headerlink:hover, -.page-content .headerlink:hover { - opacity: 1; +.post-content .heading-anchor:hover, +.page-content .heading-anchor:hover { color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 0.2em; } .post-content p, diff --git a/themes/default/static/style.css b/themes/default/static/style.css index 301ba0c..82d3abd 100644 --- a/themes/default/static/style.css +++ b/themes/default/static/style.css @@ -464,6 +464,18 @@ a:hover { margin-bottom: var(--spacing-unit); } +.post-content .heading-anchor, +.page-content .heading-anchor { + color: inherit; + text-decoration: none; +} + +.post-content .heading-anchor:hover, +.page-content .heading-anchor:hover { + text-decoration: underline; + text-underline-offset: 0.2em; +} + .post-content a, .page-content a { color: var(--color-link); diff --git a/themes/terminal/static/css/style.css b/themes/terminal/static/css/style.css index 6b2642b..6bae5b2 100644 --- a/themes/terminal/static/css/style.css +++ b/themes/terminal/static/css/style.css @@ -420,22 +420,16 @@ img { :is(.post-content, .page-content) h3 { font-size: 1.5rem; } :is(.post-content, .page-content) h4 { font-size: 1.25rem; } -/* Headerlinks */ -:is(.post-content, .page-content) .headerlink { - opacity: 0; - margin-left: 0.5rem; - color: var(--color-text-muted); +/* Heading anchor links */ +:is(.post-content, .page-content) .heading-anchor { + color: inherit; text-decoration: none; - transition: opacity var(--transition); -} - -:is(.post-content, .page-content) :is(h1, h2, h3, h4):hover .headerlink { - opacity: 0.5; } -:is(.post-content, .page-content) .headerlink:hover { - opacity: 1; +:is(.post-content, .page-content) .heading-anchor:hover { color: var(--color-blue); + text-decoration: underline; + text-underline-offset: 0.2em; } :is(.post-content, .page-content) p { From 9cf13e2e3e0fded35bed94f9d8ffcb7741ee8dc9 Mon Sep 17 00:00:00 2001 From: Xeek <6032840+x3ek@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:30:45 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(markdown):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20nested=20anchors,=20CSS=20cascade,=20test=20cove?= =?UTF-8?q?rage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip headings that already contain descendants to avoid invalid nested anchors - Move .heading-anchor rules after general link rules in all three themes so the cascade works correctly - Add explicit assertion that heading text is inside the anchor element - Add test for linked headings not getting wrapped Co-Authored-By: Claude Sonnet 4.6 --- src/squishmark/services/markdown.py | 3 +++ tests/test_markdown.py | 13 +++++++++++++ themes/blue-tech/static/style.css | 28 ++++++++++++++-------------- themes/default/static/style.css | 24 ++++++++++++------------ themes/terminal/static/css/style.css | 24 ++++++++++++------------ 5 files changed, 54 insertions(+), 38 deletions(-) diff --git a/src/squishmark/services/markdown.py b/src/squishmark/services/markdown.py index a681b20..0bc939c 100644 --- a/src/squishmark/services/markdown.py +++ b/src/squishmark/services/markdown.py @@ -27,6 +27,9 @@ def run(self, root: etree.Element) -> None: heading_id = heading.get("id") if not heading_id: continue + # Skip headings that already contain a link to avoid nested tags + if any(child.tag == "a" for child in heading.iter() if child is not heading): + continue # Collect all children (text + inline elements) into a new anchor anchor = etree.Element("a") diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 1a3b0b1..a4e999f 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -166,6 +166,19 @@ def test_heading_text_is_anchor_link(markdown_service): assert "headerlink" not in html, f"Old headerlink should be gone, got: {html}" assert "#" not in html, f"Bare # marker should not appear, got: {html}" assert "\u00b6" not in html # should NOT use pilcrow + # Verify heading text is actually inside the anchor, not just present somewhere + assert 'Hello World' in html, ( + f"Expected heading text wrapped in anchor, got: {html}" + ) + + +def test_heading_with_link_is_not_wrapped(markdown_service): + """Headings that already contain a link should not get a wrapping anchor.""" + html = markdown_service.render_markdown("## [Docs](https://example.com)") + + assert "heading-anchor" not in html, f"Should not wrap linked heading, got: {html}" + # The existing link should still be present and valid + assert 'href="https://example.com"' in html def test_parse_post_rewrites_images(markdown_service): diff --git a/themes/blue-tech/static/style.css b/themes/blue-tech/static/style.css index 1b32b79..455478c 100644 --- a/themes/blue-tech/static/style.css +++ b/themes/blue-tech/static/style.css @@ -474,20 +474,6 @@ img { .post-content h3, .page-content h3 { font-size: 1.5rem; } .post-content h4, .page-content h4 { font-size: 1.25rem; } -/* Heading anchor links */ -.post-content .heading-anchor, -.page-content .heading-anchor { - color: inherit; - text-decoration: none; -} - -.post-content .heading-anchor:hover, -.page-content .heading-anchor:hover { - color: var(--color-accent); - text-decoration: underline; - text-underline-offset: 0.2em; -} - .post-content p, .page-content p { margin-bottom: 1.5rem; @@ -505,6 +491,20 @@ img { color: var(--color-link-hover); } +/* Heading anchor links — after general link rules to win the cascade */ +.post-content .heading-anchor, +.page-content .heading-anchor { + color: inherit; + text-decoration: none; +} + +.post-content .heading-anchor:hover, +.page-content .heading-anchor:hover { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 0.2em; +} + .post-content ul, .post-content ol, .page-content ul, diff --git a/themes/default/static/style.css b/themes/default/static/style.css index 82d3abd..3a493b8 100644 --- a/themes/default/static/style.css +++ b/themes/default/static/style.css @@ -464,18 +464,6 @@ a:hover { margin-bottom: var(--spacing-unit); } -.post-content .heading-anchor, -.page-content .heading-anchor { - color: inherit; - text-decoration: none; -} - -.post-content .heading-anchor:hover, -.page-content .heading-anchor:hover { - text-decoration: underline; - text-underline-offset: 0.2em; -} - .post-content a, .page-content a { color: var(--color-link); @@ -489,6 +477,18 @@ a:hover { text-decoration: none; } +.post-content .heading-anchor, +.page-content .heading-anchor { + color: inherit; + text-decoration: none; +} + +.post-content .heading-anchor:hover, +.page-content .heading-anchor:hover { + text-decoration: underline; + text-underline-offset: 0.2em; +} + .post-content ul, .post-content ol, .page-content ul, diff --git a/themes/terminal/static/css/style.css b/themes/terminal/static/css/style.css index 6bae5b2..d92d389 100644 --- a/themes/terminal/static/css/style.css +++ b/themes/terminal/static/css/style.css @@ -420,18 +420,6 @@ img { :is(.post-content, .page-content) h3 { font-size: 1.5rem; } :is(.post-content, .page-content) h4 { font-size: 1.25rem; } -/* Heading anchor links */ -:is(.post-content, .page-content) .heading-anchor { - color: inherit; - text-decoration: none; -} - -:is(.post-content, .page-content) .heading-anchor:hover { - color: var(--color-blue); - text-decoration: underline; - text-underline-offset: 0.2em; -} - :is(.post-content, .page-content) p { margin-bottom: 1.5rem; } @@ -446,6 +434,18 @@ img { color: var(--color-green); } +/* Heading anchor links — after general link rules to win the cascade */ +:is(.post-content, .page-content) .heading-anchor { + color: inherit; + text-decoration: none; +} + +:is(.post-content, .page-content) .heading-anchor:hover { + color: var(--color-blue); + text-decoration: underline; + text-underline-offset: 0.2em; +} + :is(.post-content, .page-content) :is(ul, ol) { margin-bottom: 1.5rem; padding-left: 1.5rem;