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;