feat(content): expose auto-generated TOC as post.toc#84
Merged
Conversation
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>
There was a problem hiding this comment.
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 intoPost.toc(with frontmatter opt-out). - Extend
FrontMatter/Postmodels 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 inMarkdownService._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]}"
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #49.
Summary
python-markdown'sTocExtensionwas already registered (markdown.py:87) — the output was just being discarded. This PR threads it through to themes viapost.toc.Changes
services/markdown.py—render_markdown()now returnstuple[str, str](html, toc).parse_postpulls the TOC;parse_pagediscards it (pages don't carry a TOC per the issue framing).models/content.py—FrontMatter.toc: bool = True(per-post opt-out viatoc: falsein frontmatter);Post.toc: str = ""(rendered fragment).<details>collapsible, closed by defaulttoc: falseopt-out works, parse_page doesn't carry tocWhy three different theme treatments?
The engine just exposes
post.tocas 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 errorstoc: falsefrontmatter opt-out (no TOC renders)HeadingAnchorExtensionalready wires those)🤖 Generated with Claude Code