diff --git a/HARNESS.md b/HARNESS.md
index 4a3c104..8bb2b59 100644
--- a/HARNESS.md
+++ b/HARNESS.md
@@ -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
diff --git a/docs/content-model.md b/docs/content-model.md
index e80dca5..8100588 100644
--- a/docs/content-model.md
+++ b/docs/content-model.md
@@ -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
diff --git a/docs/decisions.md b/docs/decisions.md
index 8a02af5..7bea1a7 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -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 `
` 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.
diff --git a/docs/state.md b/docs/state.md
index 2f528f7..36a0d36 100644
--- a/docs/state.md
+++ b/docs/state.md
@@ -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) |
diff --git a/docs/surface.md b/docs/surface.md
index 3385b72..ad1cc7a 100644
--- a/docs/surface.md
+++ b/docs/surface.md
@@ -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
diff --git a/docs/theme-contract.md b/docs/theme-contract.md
index 047cf61..34d83ca 100644
--- a/docs/theme-contract.md
+++ b/docs/theme-contract.md
@@ -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
diff --git a/examples/demo-site/content/writing/notes-on-water/index.en.md b/examples/demo-site/content/writing/notes-on-water/index.en.md
index a32220a..fe89d18 100644
--- a/examples/demo-site/content/writing/notes-on-water/index.en.md
+++ b/examples/demo-site/content/writing/notes-on-water/index.en.md
@@ -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~~.
diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go
index 5d7f95b..25914f1 100644
--- a/internal/ext/shortcodes/shortcodes.go
+++ b/internal/ext/shortcodes/shortcodes.go
@@ -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
diff --git a/internal/ext/shortcodes/shortcodes_test.go b/internal/ext/shortcodes/shortcodes_test.go
index 6b7a695..b84f0b9 100644
--- a/internal/ext/shortcodes/shortcodes_test.go
+++ b/internal/ext/shortcodes/shortcodes_test.go
@@ -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{
+ `