Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/squishmark/services/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +17,40 @@
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
# Skip headings that already contain a link to avoid nested <a> 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")
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."""

Expand Down Expand Up @@ -47,7 +84,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 <br>
],
Expand Down
46 changes: 19 additions & 27 deletions tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,36 +157,28 @@ 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 "#</a>" 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 '<a class="heading-anchor" href="#hello-world">Hello World</a>' 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):
Expand Down
41 changes: 14 additions & 27 deletions themes/blue-tech/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -474,33 +474,6 @@ 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);
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;
color: var(--color-accent);
}

.post-content p,
.page-content p {
margin-bottom: 1.5rem;
Expand All @@ -518,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,
Expand Down
12 changes: 12 additions & 0 deletions themes/default/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -477,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,
Expand Down
30 changes: 12 additions & 18 deletions themes/terminal/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -420,24 +420,6 @@ 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);
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;
color: var(--color-blue);
}

:is(.post-content, .page-content) p {
margin-bottom: 1.5rem;
}
Expand All @@ -452,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;
Expand Down
Loading