diff --git a/.claude/skills/theme-creator/SKILL.md b/.claude/skills/theme-creator/SKILL.md
index 8613fef..33eaa62 100644
--- a/.claude/skills/theme-creator/SKILL.md
+++ b/.claude/skills/theme-creator/SKILL.md
@@ -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
---
```
diff --git a/.claude/skills/theme-creator/references/template-variables.md b/.claude/skills/theme-creator/references/template-variables.md
index 8b2ca71..b85d49b 100644
--- a/.claude/skills/theme-creator/references/template-variables.md
+++ b/.claude/skills/theme-creator/references/template-variables.md
@@ -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 `
` wrapping a nested `
`). 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, `` collapsible, floating sidebar) — use them as references.
+- `post.reading_time` — string like `"3 min read"`.
+- `post.url` — canonical URL path (`/posts/`).
diff --git a/src/squishmark/models/content.py b/src/squishmark/models/content.py
index 9caf07a..e0286d8 100644
--- a/src/squishmark/models/content.py
+++ b/src/squishmark/models/content.py
@@ -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):
@@ -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."""
@@ -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)
diff --git a/src/squishmark/services/markdown.py b/src/squishmark/services/markdown.py
index b1affbb..641d6a3 100644
--- a/src/squishmark/services/markdown.py
+++ b/src/squishmark/services/markdown.py
@@ -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
- ],
- 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
+ ],
+ output_format="html",
+ )
def parse_frontmatter(self, content: str) -> tuple[FrontMatter, str]:
"""
@@ -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 ``
``
+ 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 ``
``, 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
def get_pygments_css(self) -> str:
"""Generate Pygments CSS for the configured style."""
@@ -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")
@@ -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,
@@ -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")
diff --git a/tests/test_markdown.py b/tests/test_markdown.py
index a4e999f..ffbf513 100644
--- a/tests/test_markdown.py
+++ b/tests/test_markdown.py
@@ -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
@@ -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
@@ -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
@@ -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}"
@@ -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 `
`. 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 `
` 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 = """---
diff --git a/themes/blue-tech/post.html b/themes/blue-tech/post.html
index b911484..691b28c 100644
--- a/themes/blue-tech/post.html
+++ b/themes/blue-tech/post.html
@@ -44,6 +44,13 @@