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
1 change: 1 addition & 0 deletions .claude/skills/theme-creator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Individual posts and pages can override the theme or template via frontmatter:
title: My Special Post
theme: terminal # Render this post with the terminal theme
template: custom.html # Use custom.html instead of post.html
toc: false # Suppress auto-generated post.toc for this post
---
```

Expand Down
8 changes: 8 additions & 0 deletions .claude/skills/theme-creator/references/template-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,11 @@
| `page.html` | `page` (Page), `notes` (list) |
| `404.html` | Global context only |
| `admin/admin.html` | `user` (dict), `analytics` (dict), `notes` (list[NoteResponse]), `cache_size` (int) |

## Useful Post fields

`post.html` is the rendered post body. A few less-obvious fields:

- `post.toc` — auto-generated table-of-contents HTML (a `<div class="toc">` wrapping a nested `<ul>`). Empty string when the post has no headings, or when the post sets `toc: false` in frontmatter. Themes opt in to render it; the three bundled themes show three different treatments (inline card, `<details>` collapsible, floating sidebar) — use them as references.
- `post.reading_time` — string like `"3 min read"`.
- `post.url` — canonical URL path (`/posts/<slug>`).
31 changes: 30 additions & 1 deletion src/squishmark/models/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import re
from typing import Any, Literal

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator

_TOC_TRUE_STRINGS = {"true", "yes", "on", "1", "t", "y"}
_TOC_FALSE_STRINGS = {"false", "no", "off", "0", "f", "n"}


class FrontMatter(BaseModel):
Expand All @@ -23,10 +26,35 @@ class FrontMatter(BaseModel):
image: str | None = None # Featured image URL (used for og:image)
visibility: Literal["public", "unlisted", "hidden"] = "public"
nav_order: int | None = None # Explicit ordering for navbar
toc: bool = True # Per-post opt-out for auto-generated table of contents

# Allow extra fields for extensibility
model_config = {"extra": "allow"}

@field_validator("toc", mode="before")
@classmethod
def _coerce_toc(cls, v: Any) -> Any:
"""Coerce null / unrecognized values to the default (True).

YAML allows ``toc: null``, ``toc:`` (empty), and arbitrary strings.
Pydantic's strict bool parser rejects those with ValidationError,
which would crash the whole post-loading path (a 500) for a stylistic
frontmatter field. Default to showing the TOC and move on.
"""
if v is None:
return True
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return bool(v)
if isinstance(v, str):
normalized = v.strip().lower()
if normalized in _TOC_TRUE_STRINGS:
return True
if normalized in _TOC_FALSE_STRINGS:
return False
return True # unrecognized type/value — fall back to default


class Post(BaseModel):
"""A blog post with parsed content."""
Expand All @@ -38,6 +66,7 @@ class Post(BaseModel):
description: str = ""
content: str = "" # Raw markdown
html: str = "" # Rendered HTML
toc: str = "" # Rendered TOC fragment (HTML); empty when disabled or no headings
draft: bool = False
featured: bool = False
featured_order: int | None = None # Explicit ordering (lower = first)
Expand Down
79 changes: 48 additions & 31 deletions src/squishmark/services/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,29 +69,33 @@ class MarkdownService:

def __init__(self, pygments_style: str = "github-dark") -> None:
self.pygments_style = pygments_style
self._md: markdown.Markdown | None = None

def _get_markdown_instance(self) -> markdown.Markdown:
"""Get or create the markdown instance with extensions."""
if self._md is None:
self._md = markdown.Markdown(
extensions=[
"extra", # Tables, footnotes, attr_list, etc.
FencedCodeExtension(),
CodeHiliteExtension(
css_class="highlight",
linenums=False,
guess_lang=False,
pygments_formatter=LabeledFormatter,
),
TocExtension(permalink=False),
HeadingAnchorExtension(),
"smarty", # Smart quotes
"nl2br", # Newlines to <br>
],
output_format="html",
)
return self._md

def _build_markdown_instance(self) -> markdown.Markdown:
"""Build a fresh ``markdown.Markdown`` instance with the project's extensions.

A new instance is built per render because ``md.toc`` / ``md.toc_tokens``
are instance-level side effects of ``convert()``. Reusing one instance
would force callers to read those attributes between renders with no
intervening ``convert()`` — a fragile coupling — and an unrelated future
caller could easily snapshot the wrong TOC. Build cost is sub-millisecond.
"""
return markdown.Markdown(
extensions=[
"extra", # Tables, footnotes, attr_list, etc.
FencedCodeExtension(),
CodeHiliteExtension(
css_class="highlight",
linenums=False,
guess_lang=False,
pygments_formatter=LabeledFormatter,
),
TocExtension(permalink=False),
HeadingAnchorExtension(),
"smarty", # Smart quotes
"nl2br", # Newlines to <br>
],
output_format="html",
)

def parse_frontmatter(self, content: str) -> tuple[FrontMatter, str]:
"""
Expand Down Expand Up @@ -129,19 +133,29 @@ def parse_frontmatter(self, content: str) -> tuple[FrontMatter, str]:
except yaml.YAMLError:
return FrontMatter(), remaining_content

def render_markdown(self, content: str) -> str:
def render_markdown(self, content: str) -> tuple[str, str]:
"""
Render markdown content to HTML.
Render markdown content to HTML and extract its table of contents.

Args:
content: Markdown content (without frontmatter)

Returns:
Rendered HTML string
Tuple of (rendered HTML, TOC HTML fragment). TOC is the ``<div class="toc">``
block emitted by python-markdown's ``TocExtension``. Returns an empty
string when the document has no headings — python-markdown otherwise
emits a non-empty wrapper around an empty ``<ul>``, which would defeat
``{% if post.toc %}`` checks in templates.
"""
md = self._get_markdown_instance()
md.reset()
return md.convert(content)
md = self._build_markdown_instance()
html = md.convert(content)
# ``toc_tokens`` is python-markdown's official structured TOC API and
# is reliably empty for headingless content, unlike ``toc`` which
# always contains an outer wrapper div. ``getattr`` because both
# attributes are added dynamically by ``TocExtension`` (Pyright can't see them).
toc_tokens = getattr(md, "toc_tokens", None)
toc = getattr(md, "toc", "") if toc_tokens else ""
return html, toc
Comment thread
x3ek marked this conversation as resolved.

def get_pygments_css(self) -> str:
"""Generate Pygments CSS for the configured style."""
Expand All @@ -160,7 +174,7 @@ def parse_post(self, path: str, content: str) -> Post:
Parsed Post object
"""
frontmatter, markdown_content = self.parse_frontmatter(content)
html = self.render_markdown(markdown_content)
html, toc = self.render_markdown(markdown_content)
html = rewrite_image_urls(html, path)

# Extract slug from path (e.g., "posts/2026-01-15-hello-world.md" -> "hello-world")
Expand All @@ -184,6 +198,7 @@ def parse_post(self, path: str, content: str) -> Post:
description=description,
content=markdown_content,
html=html,
toc=toc if frontmatter.toc else "",
draft=frontmatter.draft,
featured=frontmatter.featured,
featured_order=frontmatter.featured_order,
Expand All @@ -205,7 +220,9 @@ def parse_page(self, path: str, content: str) -> Page:
Parsed Page object
"""
frontmatter, markdown_content = self.parse_frontmatter(content)
html = self.render_markdown(markdown_content)
# Pages don't carry a TOC by design — discard render_markdown's toc
# output so Page never gains a TOC field by accident.
html, _toc = self.render_markdown(markdown_content)
html = rewrite_image_urls(html, path)

# Extract slug from path (e.g., "pages/about.md" -> "about")
Expand Down
132 changes: 127 additions & 5 deletions tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_render_markdown(markdown_service):
"""Test markdown rendering."""
content = "# Hello World\n\nThis is **bold** and *italic*."

html = markdown_service.render_markdown(content)
html, _toc = markdown_service.render_markdown(content)

# Verify the HTML output contains expected elements
# The TOC extension adds id and permalink to headings
Expand All @@ -65,7 +65,7 @@ def hello():
print("Hello, World!")
```
"""
html = markdown_service.render_markdown(content)
html, _toc = markdown_service.render_markdown(content)

assert "highlight" in html
assert "def" in html
Expand All @@ -80,7 +80,7 @@ def test_render_code_block_no_label_for_text(markdown_service):
Plain text content
```
"""
html = markdown_service.render_markdown(content)
html, _toc = markdown_service.render_markdown(content)

assert "highlight" in html
assert "filename" not in html
Expand Down Expand Up @@ -159,7 +159,7 @@ def test_extract_slug(markdown_service):

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")
html, _toc = markdown_service.render_markdown("## Hello World")

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}"
Expand All @@ -174,13 +174,135 @@ def test_heading_text_is_anchor_link(markdown_service):

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)")
html, _toc = 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_render_markdown_returns_toc_for_multi_heading_content(markdown_service):
"""render_markdown should return a non-empty TOC fragment when there are headings."""
content = "## First Section\n\nSome text.\n\n## Second Section\n\nMore text.\n\n### Subsection\n\nDetail."
html, toc = markdown_service.render_markdown(content)

assert html # sanity
assert toc
assert "first-section" in toc
assert "second-section" in toc
assert "subsection" in toc


def test_render_markdown_toc_wrapper_class_is_stable(markdown_service):
"""python-markdown wraps the TOC list in `<div class="toc">`. Several theme
CSS selectors depend on that class name (e.g. terminal's `.post-toc > .toc > ul`).
If python-markdown ever changes the wrapper class, those selectors silently
stop matching; this test pins the contract so a regression fails loudly.
"""
_html, toc = markdown_service.render_markdown("## Heading\n\nBody.")
assert 'class="toc"' in toc


def test_render_markdown_returns_empty_toc_for_headingless_content(markdown_service):
"""A document with no headings yields an empty TOC string.

python-markdown emits a non-empty `<div class="toc"><ul></ul></div>` wrapper
when there are no headings; render_markdown normalizes that to "" so themes
can rely on a simple `{% if post.toc %}` check.
"""
_html, toc = markdown_service.render_markdown("Just a paragraph with no headings.")
assert toc == ""


def test_parse_post_populates_toc(markdown_service):
"""parse_post threads the rendered TOC onto post.toc by default."""
content = """---
title: Multi-section Post
---

## Intro

Hi.

## Body

There.
"""
post = markdown_service.parse_post("posts/2026-01-25-my-post.md", content)

assert post.toc
assert "intro" in post.toc
assert "body" in post.toc


def test_parse_post_toc_null_frontmatter_does_not_crash(markdown_service):
"""A malformed ``toc:`` value (null, empty, garbage) must not crash post loading.

Pydantic's strict bool would otherwise raise ValidationError out of
FrontMatter(**data), bubbling up as a 500 from the post route. The
BeforeValidator on FrontMatter.toc coerces invalid values to the default.
"""
for raw_value in ["null", "", "maybe", "[1, 2]", "{a: b}"]:
content = f"---\ntitle: Test\ntoc: {raw_value}\n---\n\n## Heading\n\nBody.\n"
post = markdown_service.parse_post("posts/2026-01-25-test.md", content)
# Should not raise; toc should fall back to default True (rendered).
assert post.toc != ""


def test_frontmatter_toc_accepts_yes_no_strings():
"""Strings like 'yes' / 'no' / 'on' / 'off' map to bool as expected.

Uses ``model_validate`` rather than the constructor because the inputs
are intentionally not statically typed as ``bool`` — that's the whole
point: YAML parses these as strings and we coerce them.
"""
from squishmark.models.content import FrontMatter

assert FrontMatter.model_validate({"toc": "yes"}).toc is True
assert FrontMatter.model_validate({"toc": "no"}).toc is False
assert FrontMatter.model_validate({"toc": "OFF"}).toc is False
assert FrontMatter.model_validate({"toc": "True"}).toc is True


def test_frontmatter_toc_null_defaults_to_true():
"""Explicit None defaults to True, matching the field default."""
from squishmark.models.content import FrontMatter

assert FrontMatter.model_validate({"toc": None}).toc is True


def test_parse_post_toc_disabled_via_frontmatter(markdown_service):
"""Frontmatter `toc: false` suppresses post.toc even when headings exist."""
content = """---
title: TOC-less Post
toc: false
---

## Heading

Body.
"""
post = markdown_service.parse_post("posts/2026-01-25-my-post.md", content)

assert post.toc == ""


def test_parse_page_does_not_set_post_toc(markdown_service):
"""Pages don't carry a TOC — only posts do (per the issue framing)."""
content = """---
title: About
---

## Section

Body.
"""
page = markdown_service.parse_page("pages/about.md", content)

# Page model has no toc field at all.
assert not hasattr(page, "toc") or getattr(page, "toc", "") == ""


def test_parse_post_rewrites_images(markdown_service):
"""Test that parse_post rewrites relative image URLs to static/."""
content = """---
Expand Down
7 changes: 7 additions & 0 deletions themes/blue-tech/post.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ <h1 class="post-title">{{ post.title }}</h1>
</aside>
{% endif %}

{% if post.toc %}
<details class="post-toc">
<summary>Contents</summary>
{{ post.toc | safe }}
</details>
{% endif %}

<div class="post-content">
{{ post.html | safe }}
</div>
Expand Down
Loading
Loading