build a table of contents from the document's headings

`::toc` renders through a `toc` fragment receiving level, text and the id
goldmark already assigns. The engine collects, the theme decides whether that is
a list, a sidebar or nothing — the reference theme emits a flat <ol> with a
level class per entry, so indentation is CSS rather than markup.

A theme cannot enumerate headings, because templates cannot parse HTML, so the
engine is the only thing that can supply them. Collection lives in ext; only the
Heading type and one Fragment field are core.

That is what needed the ceiling: CORE_LOC_MAX 2800 -> 2850, the second raise.
ADR-0041 said to read a second raise as evidence something belongs in ext, and
the test was applied rather than waived. feed.go and discover.go are the
features that should leave core, and they cannot, because an ext feature cannot
own a route until the extension registry exists — which the counter says buys
nothing yet. The thing that should move is blocked on a different decision, so
the honest fix was the ceiling and an ADR saying exactly that. When routes
become ownable, they leave and this comes back down.

Entry text is the heading's words with markup stripped: a link inside a link is
not markup a browser accepts. An entry whose heading has no id is skipped rather
than linked nowhere, and a page with no headings renders no nav at all.

core 2804/2850, ext 1825/2000, 34 gates green, 0 warnings.
This commit is contained in:
Claude Opus 5
2026-08-01 22:09:14 +06:00
committed by bdeshi
parent 78b8c51dff
commit 8bfacc7e98
15 changed files with 242 additions and 62 deletions
+8 -5
View File
@@ -68,11 +68,14 @@ into an author's prose. It worked and it had tests; `text-wrap: pretty` does it
content, so the feature was deleted (ADR-0045). If a feature only rearranges how something looks, expect the
agent to push back toward CSS or a template.
**The core ceiling moved once, on purpose.** `CORE_LOC_MAX` went 2000 → 2800 (ADR-0041) because the original
figure was costed before any code existed and never budgeted what `cmd/` would hold — `check`, `new`, `-dev`,
change detection. Treat a *second* raise as evidence that something belongs in `internal/ext/` instead: the
two ceilings exist so that "core stops growing, ext rises" is observable, and that stops being true the moment
leaves are allowed into core.
**The core ceiling has moved twice, both on purpose.** `CORE_LOC_MAX` went 2000 → 2800 (ADR-0041) because the
original figure was costed before any code existed and never budgeted what `cmd/` would hold — `check`, `new`,
`-dev`, change detection. It went 2800 → 2850 (ADR-0065) for eight lines of theme contract, and ADR-0041's own
test — read a second raise as evidence something belongs in `internal/ext/` — was applied rather than waived:
`feed.go` and `discover.go` are the features that should leave, and they cannot, because an `ext` feature
cannot own a **route** until the extension registry exists. When it does, they leave and this ceiling should
come back down rather than stay as headroom. The two ceilings exist so that "core stops growing, ext rises" is
observable, and that stops being true the moment leaves are allowed into core.
**Context is a budget, and three pieces of it are mechanical.** The limit on this project is how much
work fits in a session, so `docs/context-economy.md` holds the reading, searching and reporting
+4
View File
@@ -386,6 +386,10 @@ and logs it — one typo does not take a page down (ADR-0029).
Shortcodes run on site-root content only (ADR-0003), never on anything untrusted.
`::toc` renders a table of contents from the document's headings (ADR-0065). The author places it; the theme
decides what it looks like. An entry carries the heading's level, its words without markup, and the id the
engine assigned, so indentation is a CSS decision.
**Icons** are the one inline call: `:warn:` renders through the theme's `icon` fragment (ADR-0063). A name
starts with a letter and holds letters, digits, hyphens and underscores, and neither colon may touch an
alphanumeric — so `10:30:15`, `key:value:pair`, `Note: this` and a URL are all left alone, and a code span is
+21
View File
@@ -1048,3 +1048,24 @@ warning inside a note; every kind needs its own template, so a theme with twelve
twelve; and the body is rendered before the fragment sees it, so a fragment cannot choose *not* to render it.
Revisit if: nesting is genuinely wanted — which needs a fence length rule like CommonMark's code fences, not
a deeper parser.
## ADR-0065 — `::toc` hands headings to the theme, and the core ceiling moves to 2850
Date: 2026-08-01 · Status: accepted (second raise of `CORE_LOC_MAX`, 2800 → 2850; the first was ADR-0041)
Decision: `::toc` renders through a `toc` fragment receiving `.Headings` — level, text and the id goldmark
assigned (ADR-0058). The engine collects; the theme decides whether that is a list, a sidebar, or nothing.
The reference theme emits a flat `<ol>` with a level class per entry. `CORE_LOC_MAX` becomes 2850 to fit the
eight lines of contract this needs.
Why: a theme cannot enumerate headings — templates cannot parse HTML — so the engine is the only thing that
can supply them, and `Fragment` is the seam it already has. Collection lives in `internal/ext` and only the
type and the field are core. ADR-0041 said to read a second raise as evidence something belongs in `ext`, and
it does: `feed.go` and `discover.go` are features by any reading. They cannot move, because an `ext` feature
cannot own a **route** until the extension registry exists, which the counter in `state.md` says buys nothing
yet. So the thing that should leave core is blocked on a different decision, and pretending eight lines of
contract fit would have been the dishonest fix.
Consequence: cheap — a contents list is a template, entries carry depth so indentation is CSS, and an entry
whose heading has no id is skipped rather than linked nowhere. Expensive — the author places the call, so a
theme cannot put a contents list in a sidebar on every page; that wants `Page.Headings`, which is more core
and can be argued for on its own. Entry text is the heading's words with markup stripped, because a link
inside a link is not markup a browser accepts.
Revisit if: the registry arrives and routes become ownable — at which point feeds and discovery leave core
and this ceiling should come back down rather than stay as headroom.
+2 -2
View File
@@ -22,10 +22,10 @@ table owns.
| `internal/content/settings.go` | `site.yaml`: the site's own declarations (`base`, `title`) and absolute-URL building (ADR-0039) |
| `internal/content/site.go` | the indexed site: lookup with language fallback, aliases, `Query` and `Run`, sections, `Sequence`, `Everything`, slug routes, publication visibility |
| `internal/render/render.go` | goldmark with the typographer, per-kind template sets with site override, the render methods. The parsed sets plus the stylesheet are one snapshot behind an `atomic.Pointer`; `Refresh` is the only thing that replaces it, so every page serves one theme (ADR-0055, ADR-0056). Heading ids are a parser option set here (ADR-0058), and this is the one renderer that enables raw HTML (ADR-0060) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment` (with `Body` since ADR-0064), `Picture`, `Origin` |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment` (with `Body` and `Headings`, ADR-0064, ADR-0065), `Heading`, `Picture`, `Origin` |
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/templates/` | reference theme, complete (six icon names map to Unicode, no assets — ADR-0063): `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `theme.css` (ADR-0026, ADR-0049) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063), and `containers.go`: `:::name{…}``:::` wrapping a rendered body (ADR-0064) |
| `internal/ext/shortcodes/` | first feature: `::name{key=value}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044). `FootnotePrefix` namespaces an included file's footnote ids (ADR-0058). Directive syntax since ADR-0059, plus `icons.go`: `:name:` inline, rendered by the theme's one `icon` fragment (ADR-0063), `containers.go`: `:::name{…}``:::` wrapping a rendered body (ADR-0064), and `toc.go`: the document's headings for a `::toc` call (ADR-0065) |
| `internal/ext/notation/` | the inline marks CommonMark lacks: `~sub~`, `^sup^`, `==mark==`, and `~~strike~~`, which it owns so a single tilde can mean subscript (ADR-0061). `abbr.go` adds `*[TERM]:` definitions and the pass that expands them (ADR-0062) |
| `internal/ext/scaffold/` | writes one draft directory bundle into a site root through `os.Root`: never an overwrite |
| `internal/ext/watch/` | polls `content/` and `templates/` on an interval it is given, ignores editor droppings, and reports a settled change (ADR-0022, ADR-0048, ADR-0056). `site.yaml` is deliberately not fingerprinted (ADR-0055) |
+60 -53
View File
@@ -175,9 +175,9 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 866 lines + 515 test
## internal/ext/shortcodes — 948 lines + 545 test
containers.go 117 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 367
containers.go 117 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 371 · toc.go 78
- containers.go:19 var containerKind = ast.NewNodeKind("ShortcodeContainer")
- containers.go:21 type container struct
@@ -221,31 +221,37 @@ containers.go 117 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go
- shortcodes.go:37 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:41 type extension struct
- shortcodes.go:50 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:68 var nested = parser.NewContextKey()
- shortcodes.go:73 var includedAs = parser.NewContextKey()
- shortcodes.go:75 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:83 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:101 type includes struct
- shortcodes.go:105 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:129 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:160 func pending(doc *ast.Document) []*node
- shortcodes.go:178 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:181 type node struct
- shortcodes.go:195 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:197 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:200 type blocks struct{}
- shortcodes.go:202 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:204 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:235 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:262 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:266 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:268 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:270 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:273 type fragments struct
- shortcodes.go:277 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:287 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:314 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:349 func argument(s string) (key, value, rest string, ok bool)
- shortcodes.go:69 var nested = parser.NewContextKey()
- shortcodes.go:74 var includedAs = parser.NewContextKey()
- shortcodes.go:76 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:84 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:102 type includes struct
- shortcodes.go:106 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:130 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:161 func pending(doc *ast.Document) []*node
- shortcodes.go:179 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:182 type node struct
- shortcodes.go:198 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:200 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:203 type blocks struct{}
- shortcodes.go:205 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:207 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:238 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:265 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:269 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:271 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:273 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:276 type fragments struct
- shortcodes.go:280 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:290 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:318 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:353 func argument(s string) (key, value, rest string, ok bool)
- toc.go:15 const tocName = "toc"
- toc.go:22 type tables struct{}
- toc.go:24 func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- toc.go:52 func headingText(heading *ast.Heading, source []byte) string
- toc.go:67 func headingID(heading *ast.Heading) string
- toc.go:78 var _ parser.ASTTransformer = tables{}
## internal/ext/watch — 133 lines + 114 test
@@ -257,9 +263,9 @@ doc.go 8 · watch.go 125
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 715 lines + 489 test
## internal/render — 723 lines + 489 test
chrome.go 110 · render.go 475 · view.go 130
chrome.go 110 · render.go 483 · view.go 130
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
@@ -274,29 +280,30 @@ chrome.go 110 · render.go 475 · view.go 130
- render.go:49 type parsedTheme struct
- render.go:63 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:66 type Fragment struct
- render.go:78 type Picture struct
- render.go:95 type Origin struct
- render.go:104 var originKey = parser.NewContextKey()
- render.go:107 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:114 func WithOrigin(pc parser.Context, origin Origin)
- render.go:127 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:158 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:186 func (r *Renderer) head(title, lang, canonical string) head
- render.go:203 func (r *Renderer) absolute(path string) string
- render.go:211 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:223 func (r *Renderer) Refresh() error
- render.go:234 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:253 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:275 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:293 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:314 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:332 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:369 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:388 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:415 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:442 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:447 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:469 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:80 type Heading struct
- render.go:86 type Picture struct
- render.go:103 type Origin struct
- render.go:112 var originKey = parser.NewContextKey()
- render.go:115 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:122 func WithOrigin(pc parser.Context, origin Origin)
- render.go:135 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:166 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:194 func (r *Renderer) head(title, lang, canonical string) head
- render.go:211 func (r *Renderer) absolute(path string) string
- render.go:219 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:231 func (r *Renderer) Refresh() error
- render.go:242 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:261 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:283 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:301 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:322 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:340 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:377 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:396 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:423 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:450 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:455 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:477 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:16 type head struct
- view.go:36 type Page struct
- view.go:55 type Sequence struct
@@ -307,7 +314,7 @@ chrome.go 110 · render.go 475 · view.go 130
- view.go:111 type Item struct
- view.go:122 type Alternate struct
## internal/web — 734 lines + 1613 test
## internal/web — 734 lines + 1615 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+2
View File
@@ -91,6 +91,7 @@ Every fragment receives the same two fields (ADR-0037):
| `.Args` | the call's `key=value` pairs, exactly as the author wrote them |
| `.Pictures` | images the *engine* gathered: one for a figure, many for a gallery, none when the call names nothing it recognises (ADR-0042) |
| `.Body` | a container call's content, already rendered to HTML. Empty for every leaf call (ADR-0064) |
| `.Headings` | the document's headings for a `::toc` call: `.Level`, `.Text`, `.ID`. Empty for every other call (ADR-0065) |
Each picture carries:
@@ -106,6 +107,7 @@ Each picture carries:
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `:name:` | `icon` | `.Args.name` — the name as written, nothing else |
| `:::note{title=…}``:::` | `note` | `.Args.title`, `.Body` — the rendered content |
| `::toc` | `toc` | `.Headings` — every heading in the document, in order |
A **container** call wraps content: `:::name{…}`, a body, then `:::`. It renders through the fragment of that
name with `.Body` already HTML, and one level only — a `:::` inside closes the one it is in. A theme with no
@@ -12,6 +12,8 @@ and appears in no listing. An included file cannot itself include — one level,
This bundle also has an `extras/` directory, so the theme offers a link to it at the foot of the page.
::toc
## The dialect
The page has a footnote of its own[^page], a table, a definition list and ~~a struck phrase~~.
+5 -1
View File
@@ -57,6 +57,7 @@ func (e extension) Extend(md goldmark.Markdown) {
parser.WithASTTransformers(
util.Prioritized(includes{md: md}, 100),
util.Prioritized(bodies{md: md}, 150),
util.Prioritized(tables{}, 160),
),
parser.WithInlineParsers(util.Prioritized(icons{}, 500)),
)
@@ -190,6 +191,8 @@ type node struct {
// isContent marks a call whose output is content, so a failure renders nothing rather than falling
// through to a fragment lookup and reporting a missing template that was never expected to exist.
isContent bool
// headings are the document's, gathered for a `::toc` call (ADR-0065).
headings []render.Heading
}
func (n *node) Kind() ast.NodeKind { return kind }
@@ -295,7 +298,8 @@ func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering
}
return ast.WalkContinue, nil
}
out, err := f.partial(call.name, render.Fragment{Args: call.args, Pictures: call.pictures})
out, err := f.partial(call.name, render.Fragment{
Args: call.args, Pictures: call.pictures, Headings: call.headings})
if err != nil {
slog.Error("skipping shortcode", "name", call.name, "err", err)
return ast.WalkContinue, nil
@@ -361,3 +361,33 @@ func TestAContainerDoesNotSwallowTheRestOfThePage(t *testing.T) {
t.Errorf("an unclosed container keeps its content:\n%s", got)
}
}
func TestTheTableOfContentsListsHeadingsBelowTheCall(t *testing.T) {
got := body(t, wired(t, nil), "::toc\n\n## First heading\n\nProse.\n\n### Nested with *emphasis*\n\n## Second\n")
for _, want := range []string{
`<nav class="toc">`,
`<li class="toc-2"><a href="#first-heading">First heading</a></li>`,
`<li class="toc-3">`, `href="#second"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// A contents entry is a label: markup inside a heading would put a link inside a link.
if strings.Contains(got, "<a href=\"#nested-with-emphasis\">Nested with <em>") {
t.Errorf("an entry should carry the words, not the markup:\n%s", got)
}
if !strings.Contains(got, `<h2 id="first-heading">`) {
t.Errorf("the headings themselves still render with their anchors:\n%s", got)
}
}
func TestATableOfContentsWithNoHeadingsRendersNothing(t *testing.T) {
got := body(t, wired(t, nil), "::toc\n\nJust prose, no headings.\n")
if strings.Contains(got, "<nav class=\"toc\">") {
t.Errorf("an empty contents list is worse than none:\n%s", got)
}
if !strings.Contains(got, "Just prose") {
t.Errorf("the page must survive:\n%s", got)
}
}
+78
View File
@@ -0,0 +1,78 @@
package shortcodes
import (
"bytes"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"khosra/internal/render"
)
// tocName is the call that asks for a table of contents. Placed by the author rather than by the theme,
// because the engine hands headings to a fragment and a fragment only runs where a call is (ADR-0065).
const tocName = "toc"
// tables fills every `::toc` call with the document's headings.
//
// A transformer, because the headings have to exist before the call can be told about them, and because ids
// are assigned during the parse — a block parser running at the call's own line would see none of the
// document below it.
type tables struct{}
func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
var calls []*node
var found []render.Heading
source := reader.Source()
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if call, is := n.(*node); is && call.name == tocName {
calls = append(calls, call)
return ast.WalkContinue, nil
}
if heading, is := n.(*ast.Heading); is {
found = append(found, render.Heading{
Level: heading.Level,
Text: headingText(heading, source),
ID: headingID(heading),
})
}
return ast.WalkContinue, nil
})
for _, call := range calls {
call.headings = found
}
}
// headingText is the heading's words without any markup it carries: a contents entry is a label, and a link
// inside another link is not markup a browser accepts.
func headingText(heading *ast.Heading, source []byte) string {
var out bytes.Buffer
_ = ast.Walk(heading, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if t, is := n.(*ast.Text); is {
out.Write(t.Segment.Value(source))
}
}
return ast.WalkContinue, nil
})
return out.String()
}
// headingID is the anchor goldmark assigned (ADR-0058). Empty when a heading somehow has none, which a theme
// can skip rather than linking nowhere.
func headingID(heading *ast.Heading) string {
id, ok := heading.AttributeString("id")
if !ok {
return ""
}
if raw, is := id.([]byte); is {
return string(raw)
}
return ""
}
var _ parser.ASTTransformer = tables{}
+8
View File
@@ -72,6 +72,14 @@ type Fragment struct {
Pictures []Picture
// Body is a container call's content, already rendered. Empty for a leaf call (ADR-0064).
Body template.HTML
// Headings are the document's headings, for a call that builds a table of contents (ADR-0065).
Headings []Heading
}
// Heading is one heading in the document, with the id an anchor links to (ADR-0065).
type Heading struct {
Level int
Text, ID string
}
// Picture is one image a fragment can render (ADR-0042).
+12
View File
@@ -43,3 +43,15 @@
{{define "note"}}<aside class="admonition note">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
{{define "warn"}}<aside class="admonition warn">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
{{define "tip"}}<aside class="admonition tip">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
{{/* A flat list with a level class per entry, so nesting is a CSS decision rather than a markup one: the
engine supplies depth and the theme decides whether to indent (ADR-0065). An entry with no id is
skipped rather than linked nowhere. */}}
{{define "toc"}}
{{- if .Headings}}<nav class="toc"><ol>
{{- range .Headings}}{{if .ID}}
<li class="toc-{{.Level}}"><a href="#{{.ID}}">{{.Text}}</a></li>
{{- end}}{{end}}
</ol></nav>
{{- end}}
{{- end}}
+3
View File
@@ -24,6 +24,9 @@ dd { margin-left: 1.25rem; }
.footnotes { font-size: 0.9em; }
.admonition { border-left: 3px solid #d8d5cd; border-radius: 0; padding: 0.25rem 0 0.25rem 1rem; margin: 1.5rem 0; }
.admonition-title { font-weight: 600; margin: 0 0 0.5rem; }
nav.toc ol { list-style: none; padding-left: 0; }
nav.toc .toc-3 { padding-left: 1rem; }
nav.toc .toc-4 { padding-left: 2rem; }
@media (prefers-color-scheme: dark) {
html { color: #e8e6e1; background: #16161a; }
a { color: #8ab4dd; }
+2
View File
@@ -127,6 +127,8 @@ var exampleFeatures = []featureCase{
{what: "an abbreviation expands and its definition line renders nothing", path: "/writing/notes-on-water/", code: 200,
expect: []string{`<abbr title="National Institute of Water and Atmospheric Research">NIWA</abbr>`},
absent: []string{"*[NIWA]"}},
{what: "a table of contents links the page's own headings", path: "/writing/notes-on-water/", code: 200,
expect: []string{`<nav class="toc">`, `<a href="#the-dialect">The dialect</a>`, `class="toc-2"`}},
{what: "a container renders its body through the theme fragment", path: "/writing/notes-on-water/", code: 200,
expect: []string{`<aside class="admonition warn">`, `<p class="admonition-title">Calibration</p>`,
"<em>emphasis</em>", "H<sub>2</sub>O"}, absent: []string{":::"}},
+5 -1
View File
@@ -5,7 +5,7 @@
# code worse — sharding a coherent file into a `_helpers.go` turns it green while creating the package
# CLAUDE.md rule 3.6 bans. Total mass cannot be gamed by moving code between files; file length can.
CORE_LOC_MAX=2800 # cmd/ + internal/{content,render,web} + repo root, non-test .go (ADR-0041)
CORE_LOC_MAX=2850 # cmd/ + internal/{content,render,web} + repo root, non-test .go (ADR-0041, ADR-0065)
EXT_LOC_MAX=2000 # internal/ext/ — composition, grows after the core freezes
FILE_LOC_WARN=500 # any single .go file — advisory
FUNC_LOC_WARN=60 # any single function — advisory
@@ -22,6 +22,10 @@ CLAUDE_LOC_MAX=150 # CLAUDE.md only — the one file billed on every turn (A
# Feeds, sitemap, OpenGraph, shortcodes, image sizing, galleries, paging, indieweb ~900-1200 → EXT
# 2000, deliberately loose since that is where growth belongs. Reaching CORE asks what to delete;
# reaching EXT asks whether a template would have done it — only .go lines count here.
# Raised a second time, 2800 → 2850 (ADR-0065), for eight lines of theme contract. ADR-0041 said to read a
# second raise as evidence something belongs in `ext`: it is, and the candidates are `feed.go` and
# `discover.go`, which cannot move until a feature can own a route. That is the registry's trigger, not this
# feature's problem, and the honest fix was not to pretend the contract fits.
#
# CLAUDE_LOC_MAX is a different kind of budget: every other ceiling here bills once, when someone reads
# the code, while CLAUDE.md is re-sent on every turn of every session. 150 leaves ~20 lines of headroom