Skip to content

feat(content): expose auto-generated TOC as post.toc#84

Merged
x3ek merged 6 commits into
mainfrom
feat/49-auto-toc
May 23, 2026
Merged

feat(content): expose auto-generated TOC as post.toc#84
x3ek merged 6 commits into
mainfrom
feat/49-auto-toc

Conversation

@x3ek
Copy link
Copy Markdown
Contributor

@x3ek x3ek commented May 23, 2026

Closes #49.

Summary

python-markdown's TocExtension was already registered (markdown.py:87) — the output was just being discarded. This PR threads it through to themes via post.toc.

Changes

  • services/markdown.pyrender_markdown() now returns tuple[str, str] (html, toc). parse_post pulls the TOC; parse_page discards it (pages don't carry a TOC per the issue framing).
  • models/content.pyFrontMatter.toc: bool = True (per-post opt-out via toc: false in frontmatter); Post.toc: str = "" (rendered fragment).
  • All three bundled themes opt in to demonstrate the "theme decides presentation" principle:
    • default — inline accent-bordered card above the post body
    • blue-tech<details> collapsible, closed by default
    • terminal — floating right-side sticky sidebar anchored to the article column edge (hidden below 1140px viewport)
  • Tests — render returns toc, parse_post populates, frontmatter toc: false opt-out works, parse_page doesn't carry toc

Why three different theme treatments?

The engine just exposes post.toc as raw HTML — every UX decision (inline vs sidebar, collapsible, where to place it, when to render) belongs to the theme. Wiring all three to render it differently demonstrates that contract and gives theme authors three reference patterns to start from.

Test plan

  • python scripts/run-checks.py — 237 tests pass, ruff clean, 0 pyright errors
  • Playwright verified across all 3 themes with a post containing nested h2/h3 headings (Grandma's Intergalactic Gumbo)
  • Manually verified toc: false frontmatter opt-out (no TOC renders)
  • Verified anchor links jump to correct heading IDs (existing HeadingAnchorExtension already wires those)

🤖 Generated with Claude Code

Closes #49.

python-markdown's TocExtension was already registered in markdown.py:87 — its output was just being discarded. This PR threads it through:

- MarkdownService.render_markdown() now returns (html, toc) instead of just html. parse_post pulls the TOC; parse_page discards it (pages don't carry a TOC).

- FrontMatter gains 'toc: bool = True' for per-post opt-out; Post gains 'toc: str = ""' for the rendered fragment.

- All three bundled themes opt in to render post.toc, each with a different UX choice to demonstrate the theme-decides-presentation principle:

  - default: inline accent-bordered card above the post body

  - blue-tech: <details> collapsible, closed by default

  - terminal: floating right-side sticky sidebar (hidden below 1140px viewport)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR surfaces the existing python-markdown TocExtension output to themes by threading a rendered TOC fragment through the content pipeline as post.toc, with a per-post frontmatter opt-out (toc: false). It updates all bundled themes to demonstrate different TOC presentations and extends the markdown test suite accordingly.

Changes:

  • Update markdown rendering to return (html, toc) and plumb TOC into Post.toc (with frontmatter opt-out).
  • Extend FrontMatter/Post models to carry TOC configuration and the rendered TOC fragment.
  • Update bundled themes and add/adjust tests to cover TOC behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/squishmark/services/markdown.py Return TOC alongside HTML; populate post.toc in parse_post and discard for pages.
src/squishmark/models/content.py Add FrontMatter.toc opt-out and Post.toc field.
tests/test_markdown.py Update render_markdown call sites and add TOC-related tests.
themes/default/post.html Render post.toc above the post body when present.
themes/default/static/style.css Style TOC as an inline card.
themes/blue-tech/post.html Render post.toc inside a collapsed-by-default <details>.
themes/blue-tech/static/style.css Style the <details> TOC presentation.
themes/terminal/post.html Render post.toc as a floating sidebar <aside>.
themes/terminal/static/css/style.css Add floating TOC positioning/styling rules.
Comments suppressed due to low confidence (1)

tests/test_markdown.py:54

  • The comment here mentions the TOC extension adding a permalink, but TocExtension(permalink=False) is configured in MarkdownService._get_markdown_instance(). This is slightly misleading about what’s providing the self-link behavior.

— GitHub Copilot

    # Verify the HTML output contains expected elements
    # The TOC extension adds id and permalink to headings
    assert html.startswith("<h1"), f"Expected HTML to start with h1 tag, got: {html[:50]}"

Comment thread src/squishmark/services/markdown.py
Comment thread tests/test_markdown.py Outdated
Comment thread themes/terminal/static/css/style.css Outdated
Comment thread themes/terminal/static/css/style.css Outdated
x3ek and others added 5 commits May 23, 2026 09:18
Address Copilot review on PR #84:

1. render_markdown() now returns '' instead of '<div class="toc"><ul></ul></div>' when the document has no headings, so themes' '{% if post.toc %}' checks behave correctly.

2. Test asserts the empty-string contract directly rather than a substring absence.

3. Terminal CSS selectors '.post-toc > ul' and '.post-toc > ul > li::before' didn't account for python-markdown's '<div class="toc">' wrapper, so the top-level padding override and '›' marker never rendered. Selectors now traverse through '> .toc'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ari polish

Findings from an independent code review pass:

1. BLOCKER: terminal TOC overflowed the right edge of viewports between ~1140-1358px (TOC right edge = vw/2 + 679, needs vw ≥ 1359). Combined with body { overflow-x: hidden } the overflow was silently clipped. Breakpoint raised to 1359px with the math documented inline.

2. MAJOR: MarkdownService no longer caches a single Markdown instance across calls. md.toc / md.toc_tokens are instance-level side effects of convert(); a shared instance could race when render_markdown is invoked concurrently from FastAPI's sync-handler threadpool, pairing render A's html with render B's toc. Build cost is sub-millisecond.

3. MAJOR: gate empty-TOC normalization on md.toc_tokens (python-markdown's official structured API) instead of a fragile <li> substring check on the raw HTML output.

4. MAJOR a11y: default theme's TOC 'Contents' heading was an <h2>, polluting the post's screen-reader outline. Changed to <p> — the existing aria-label on the surrounding <aside> already provides the semantic label.

5. MINOR: Safari/WebKit's <summary>::-webkit-details-marker isn't suppressed by list-style: none. Added the vendor pseudo so blue-tech's collapsible doesn't show both the native ▶ and the custom ▸.

6. MINOR: one-line comment in parse_page explaining why TOC is discarded (the 'pages don't have TOCs' convention was previously only encoded in a test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second independent review pass on PR #84. Seven findings, all addressed:

1. MAJOR: Default theme TOC card used an undefined CSS variable (--color-bg-elevated) with a 3% black fallback. In dark mode this rendered invisible against the #0a0a0a body bg. Switched to the existing --color-bg-subtle, which is defined for both light and dark.

2. MAJOR: TOC anchor clicks scrolled headings flush to viewport top, where terminal and blue-tech's sticky nav (~54-60px) covered them. Added scroll-margin-top: 5rem to .post-content/.page-content h1-h4 in both themes so anchor jumps clear the nav.

3. MINOR: _build_markdown_instance docstring claimed FastAPI sync-handler threadpool — but all call sites are async def on the event loop. Rewrote the rationale to reflect the actual reason (md.toc/toc_tokens are convert() side effects; reusing one instance forces fragile read-immediately-after-convert coupling).

4. MINOR: Stale '1140px' comment in terminal/style.css contradicted the actual 1359px breakpoint a few lines below. Comment updated.

5. MINOR: blue-tech chevron transition ignores prefers-reduced-motion. Added a reduce-motion media query.

6. MINOR: blue-tech <summary> had no visible focus indicator for keyboard users. Added :focus-visible outline.

7. MINOR: No test asserted python-markdown still emits class="toc" wrapper. Several theme selectors depend on it; added a regression guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this fix, YAML frontmatter like 'toc: null', 'toc:' (empty), 'toc: maybe', or any non-bool/non-recognized-string value raised pydantic ValidationError out of FrontMatter(**data) in parse_frontmatter — which bubbled up as a 500 from the post route.

BeforeValidator on the toc field now coerces:

- None / null / missing values → True (default)

- bool, int, float → standard truthiness

- recognized strings ('yes'/'no'/'on'/'off'/'true'/'false'/'1'/'0'/etc.) → bool

- anything else → True (default, don't crash)

Scope: narrow fix on toc only. Other FrontMatter bool fields (draft, featured) carry the same crash-on-bad-input risk pre-existing this PR — worth a follow-up issue but out of scope for the TOC PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… opt-out

Two small additions so theme authors can discover the new TOC field:

- template-variables.md: brief 'Useful Post fields' section covering post.toc, post.reading_time, post.url — fields a theme author might want but won't see by reading just the per-template table.

- SKILL.md: added toc: false to the frontmatter override example next to theme/template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@x3ek x3ek merged commit 90c47c6 into main May 23, 2026
5 checks passed
@x3ek x3ek deleted the feat/49-auto-toc branch May 23, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add auto-generated table of contents for posts

2 participants