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{ + `