From b5be77498eb0e5a49b1f7cfbe921def1c6ec5ee1 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 22:42:41 +0600 Subject: [PATCH] give the author three controls the engine was deciding alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `include: merge` in frontmatter splices a bundle's includes before the parse, so a page assembled from several files is one document: one footnote list at its end, numbered straight through, and an abbreviation defined anywhere reaching every part. Moving the rendered block afterwards would have meant editing goldmark's own markup; handing the parser one source gets the right answer from it instead. Without the flag nothing changes — each fragment stays its own document with namespaced ids, so no existing content re-renders. Heading ids are unique under either model. Merging gets that free, because one parse means one id set, but embedding did not: three `## Description`s across a page and its fragments produced three identical anchors, and every link to them landed on the first. A nested parse now shares the parent's id set, so the second becomes #description-1 — goldmark's own suffixing, reaching across files because they finally share the set it counts in. Auditing for other policies the author could not reach found two more. A heading may declare its anchor: `## Title {#stable-anchor}`. This is the one that mattered most and nobody had asked for it — a derived id changes when the text does, so rewording a heading silently broke every link to that anchor, which is indefensible in an engine whose first value is that published addresses are permanent. `::toc{depth=2}` shortens a contents list, because a theme cannot know per page how deep is useful and the author can. Deliberately not added: a typographer toggle, a per-picture "do not resample", icon overrides. No second user for any of them. The hand-copied wiring in example_test.go drifted for the third time this session — Compose this time, after the dialect and notation — each caught by a demo case rather than by the copy. The latent row is now marked due, with what moving the list would require. --- cmd/khosra/main.go | 1 + cmd/khosra/wire_test.go | 28 +++ docs/content-model.md | 16 +- docs/decisions.md | 25 +++ docs/state.md | 10 +- docs/surface.md | 160 +++++++++--------- docs/theme-contract.md | 2 +- .../writing/notes-on-water/index.en.md | 6 +- internal/ext/shortcodes/containers.go | 51 ++++++ internal/ext/shortcodes/shortcodes.go | 5 +- internal/ext/shortcodes/shortcodes_test.go | 96 ++++++++++- internal/ext/shortcodes/toc.go | 19 ++- internal/render/render.go | 22 ++- internal/web/example_test.go | 9 +- 14 files changed, 353 insertions(+), 97 deletions(-) diff --git a/cmd/khosra/main.go b/cmd/khosra/main.go index adb0c2c..e1cafec 100644 --- a/cmd/khosra/main.go +++ b/cmd/khosra/main.go @@ -69,6 +69,7 @@ func runServe() { if err != nil { fatal("cannot prepare the theme", err) } + renderer.Compose(shortcodes.Merge) // Never on by default and never a bare boolean flag: revealing unpublished work is a visibility change, and // it should be impossible to enable by fumbling an argument (ADR-0024). interval := pollInterval(*dev == "on", *poll) diff --git a/cmd/khosra/wire_test.go b/cmd/khosra/wire_test.go index e6fabf4..ab06b6a 100644 --- a/cmd/khosra/wire_test.go +++ b/cmd/khosra/wire_test.go @@ -61,3 +61,31 @@ func TestTheTypographerLeavesATableAlone(t *testing.T) { t.Errorf("the delimiter row was smartened instead of parsed:\n%s", got) } } + +// An explicit id is what keeps an anchor alive when a heading is reworded — the derived one changes with the +// text, and every link to it breaks silently (ADR-0066). +func TestAHeadingMayDeclareItsOwnId(t *testing.T) { + r, err := render.New(nil, content.Settings{}, extenders) + if err != nil { + t.Fatal(err) + } + b, err := content.Parse("posts/h.md", []byte("---\ntitle: H\n---\n\n"+ + "## A Reworded Heading {#stable-anchor}\n\n## Derived Instead\n\n## Set {a, b}\n")) + if err != nil { + t.Fatal(err) + } + out, err := r.Bundle(b, "en", []string{"en"}, nil) + if err != nil { + t.Fatal(err) + } + got := string(out) + for _, want := range []string{`

A Reworded Heading

`, `

`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } + // Braces that are not attribute syntax stay in the heading, or a title could not contain a set. + if !strings.Contains(got, "Set {a, b}") { + t.Errorf("braces that are not attributes are text:\n%s", got) + } +} diff --git a/docs/content-model.md b/docs/content-model.md index 4d4e983..0fcd215 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -93,6 +93,7 @@ readable by templates (ADR-0002). Never add a required field. | `view` | string | Per-bundle View override (Arc 2) | | `styles` / `scripts` | []string | Page-specific assets, relative to the bundle | | `lang` | string | Explicit language when the filename cannot carry it | +| `include` | string | `merge` splices this bundle's `::include` files into it before parsing, so the page is one document: one footnote list at the end, abbreviations reaching every part, every heading in the contents list. Absent or anything else means each fragment stays its own document (ADR-0066) | ## Post types @@ -321,9 +322,12 @@ them is a render transform, and the list lives in `cmd/khosra/wire.go` where fea | Superscript | `10^6^` | | Highlight | `==marked==` | | Abbreviations | `*[HTML]: HyperText Markup Language` on its own line; every whole-word use expands | -| Heading ids | automatic, from the heading's text — the anchor a table of contents needs | +| Heading ids | automatic from the heading's text, or declared: `## Title {#stable-anchor}` | -The last five are khosra's own (ADR-0061, ADR-0062), because goldmark's strikethrough claims a single tilde as well as +Declare an id when the anchor must outlive the wording: a derived id changes with the text, so rewording a +heading breaks every link to it (ADR-0066). Two headings with the same words get suffixed rather than +duplicated — `#description`, then `#description-1` — and that holds across included files under either +include model, because the fragments share the page's set of ids. The last five marks are khosra's own (ADR-0061, ADR-0062), because goldmark's strikethrough claims a single tilde as well as a double and would read `H~2~O` as struck text. **A subscript or superscript may not contain a space** — it holds a formula, not a phrase — which is what keeps `x^2 + y^2` prose. Its content is taken literally, so there is no emphasis inside one. @@ -376,6 +380,11 @@ rather than opening another, the same limit an include carries. `note`, `warn` a expandable panel, and tabs when siblings share a `group` — and `aside`, a margin note. A kind the theme does not define renders its body unwrapped rather than losing it. +**Merging instead of embedding.** With `include: merge` in frontmatter the fragments are spliced into the +source before anything is parsed, which is what a page assembled from several files usually wants: one +endnote list at the end rather than one per part (ADR-0066). The cost is that a fragment is no longer +contained — an unclosed code fence in a part affects the whole page, as textual inclusion always does. + **Migrating from the retired form.** `{{< name key="value" >}}` is no longer a call and renders as literal text. `khosra check` reports every one as fatal and names the replacement, so a site root is migrated by running it until it exits zero. The engine cannot rewrite a site root — that is the author's data (ADR-0011). @@ -387,7 +396,8 @@ 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 +`::toc` renders a table of contents from the document's headings, or `::toc{depth=2}` for the shallower ones +only (ADR-0065, ADR-0066). 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. diff --git a/docs/decisions.md b/docs/decisions.md index 7bea1a7..f63c6f2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1069,3 +1069,28 @@ and can be argued for on its own. Entry text is the heading's words with markup 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. + +## ADR-0066 — Three author controls: `include: merge`, an explicit heading id, and `::toc{depth}` +Date: 2026-08-01 · Status: accepted (adds a second include model beside ADR-0038's; that decision's +mechanism is unchanged and remains the default) +Decision: `include: merge` in frontmatter splices a bundle's `::include` lines with the files they name +**before** the parse, so the page is one document: footnotes collect at its end, an abbreviation defined +anywhere reaches everywhere, and every heading is in its contents list. Without the flag, nothing changes — +each fragment is still its own document with its ids namespaced. A heading may declare its own anchor, +`## Title {#stable-anchor}`. `::toc{depth=N}` lists headings no deeper than N. `render.Renderer.Compose` is +the seam the splice arrives through, set at wiring time like `Navigation`. +Why: composing a page from several files is one of the reasons to have includes at all, and under ADR-0038's +model the notes of each fragment render where it sits — an `
` and a numbered list halfway down the +article. Moving the rendered block afterwards would mean editing goldmark's own markup; splicing the source +instead gets the right answer from the parser rather than around it. A flag rather than a change of default, +because the embedded model contains the damage a malformed fragment does, and existing content must not +re-render differently. The heading id is the control that matters most: an id derived from the text changes +when the text does, so rewording a heading silently breaks every link to that anchor — unacceptable in an +engine whose first value is that published addresses are permanent. `depth` exists because a theme cannot +know per page how deep a contents list should go, and the author can. +Consequence: cheap — one endnote list where the author asks for it, permanent anchors, and shorter contents +lists, none of which changes a site that says nothing. Expensive — two include models to keep working, and +under `merge` a fragment's Markdown is no longer contained: an unclosed code fence in a part now affects the +whole page, which is what textual inclusion means everywhere it exists. +Revisit if: a third include model is wanted, which would be evidence the flag should have been an enum of +composition strategies rather than two paths. diff --git a/docs/state.md b/docs/state.md index 4f6e73e..fe3fb43 100644 --- a/docs/state.md +++ b/docs/state.md @@ -21,11 +21,11 @@ table owns. | `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) | | `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/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, declared or derived (ADR-0058, ADR-0066), and this is the one renderer that enables raw HTML (ADR-0060). `Compose` is the seam a merging bundle's splice arrives through | | `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` (figure, gallery, icon, note/warn/tip, details, aside, toc), `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), `containers.go`: `:::name{…}` … `:::` wrapping a rendered body (ADR-0064), and `toc.go`: the document's headings for a `::toc` call (ADR-0065) | +| `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, to a depth the call may set (ADR-0065, ADR-0066). `Merge` splices includes before the parse for a bundle that asks for it, and an embedded fragment parses against the page's id set so repeated headings are suffixed rather than duplicated (ADR-0066) | | `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,7 +60,7 @@ polls four times a second (ADR-0056). `-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph URLs go absolute (ADR-0039). -Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`, `slug`. Every other key in +Frontmatter the parser lifts today: `title`, `date`, `tags`, `aliases`, `order`, `slug`. `include: merge` is read from `Extra` by the renderer (ADR-0066). Every other key in `content-model.md`'s table — including `draft` and `type` — lands in `Extra` unread, so that table is the accepted format, not a list of what runs. @@ -118,8 +118,8 @@ with a stated reason. A list nothing drains is a graveyard of known defects. | Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model | | The root listing's `` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The first time the reference theme is worked on (Phase G4 touches it), or sooner if a feed or OpenGraph title inherits the same doubling | | The theme and the index are two separate `atomic.Pointer` stores, so a request landing between them sees a new theme with the previous index | Accepted 2026-08-01 with ADR-0056: both halves are internally coherent and the gap is microseconds, so no page is ever internally inconsistent — it is simply not a snapshot of the disk. Closing it means one pointer holding both, which changes `web.Handler`'s signature and 20 test construction sites | Anything that makes the gap observable — a request rate high enough to land in it, or a feature where content and theme must agree exactly (an export, where every page is generated in one pass) | -| `internal/web/example_test.go` rebuilds the extender list by hand, so it can drift from `cmd/khosra/wire.go` | A package cannot import a `main`, and `extensions.md` puts the list in `cmd` on purpose — nothing below it may know which features exist. Bounded today: the dialect's own test lives in `cmd/khosra/wire_test.go`, beside the real list, and the demo test fails loudly when the copy lags | The next change to the extender list, which must touch both — or a third copy appearing, which is the point at which the list wants a home a test can import | -| An included file's footnotes render where the include sits, so a long fragment puts an `<hr>` and a numbered list in the middle of the article | Spotted 2026-08-01 by looking at the served page, not by any test. It is ADR-0038's documented consequence — a fragment is converted on its own bytes, so its notes belong to it — and the ids are correctly namespaced (ADR-0058). Only the placement reads badly | A fragment long enough that the break is jarring, or a theme that wants one endnote list per page — which needs the include to contribute notes to the parent document rather than render its own | +| `internal/web/example_test.go` rebuilds the extender list by hand, so it can drift from `cmd/khosra/wire.go` | A package cannot import a `main`, and `extensions.md` puts the list in `cmd` on purpose — nothing below it may know which features exist. Bounded today: the dialect's own test lives in `cmd/khosra/wire_test.go`, beside the real list, and the demo test fails loudly when the copy lags | **Due.** It has now drifted three times in one session — the dialect, `notation`, and `Compose` — each caught by a demo case rather than by the copy itself. The next change to the wiring should move it somewhere a test can import, which needs the list to leave `cmd` without a package below it knowing which features exist | +| Under the default include model, a fragment's footnotes render where the include sits, so a long one puts an `<hr>` and a numbered list mid-article | Spotted 2026-08-01 by looking at the served page, not by any test. It is ADR-0038's documented consequence, and the ids are correctly namespaced (ADR-0058); only the placement reads badly. **An author who minds now says `include: merge`** (ADR-0066), which makes the page one document and puts every note at its end, so this is a default rather than a limit | The default itself proving wrong — a site where every composed page sets the flag, at which point the flag is the wrong way round | | The picture memo is never evicted — one entry per picture on the site, for the life of the process | Correct for one author's site, and the alternative is an eviction policy nothing needs. It is keyed on size and modification time, so it cannot go stale, only grow | A site root large enough that memory matters, or a long-running process where pictures churn | ## Open questions diff --git a/docs/surface.md b/docs/surface.md index aaff3d2..4085ada 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -6,19 +6,19 @@ Every top-level declaration in the engine, with its line. Read this before openi file: it answers "where does X live" and "what is in this package" without the bodies. What each file is *for* lives in `state.md`; why it is that way lives in `decisions.md`. -## cmd/khosra — 305 lines + 63 test +## cmd/khosra — 306 lines + 91 test -check.go 45 · main.go 191 · new.go 42 · wire.go 27 +check.go 45 · main.go 192 · new.go 42 · wire.go 27 - check.go:16 func runCheck(args []string) - main.go:24 func main() - main.go:45 func runServe() -- main.go:106 func pollInterval(dev bool, chosen time.Duration) time.Duration -- main.go:119 func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int) -- main.go:136 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int -- main.go:162 func given(name string) bool -- main.go:174 func defaultCache() string -- main.go:184 func fatal(msg string, err error) +- main.go:107 func pollInterval(dev bool, chosen time.Duration) time.Duration +- main.go:120 func watching(fsys fs.FS, every time.Duration, renderer *render.Renderer, rebuild func() int) +- main.go:137 func rebuilder(fsys fs.FS, cache string, reveal bool, live *atomic.Pointer[content.Site]) func() int +- main.go:163 func given(name string) bool +- main.go:175 func defaultCache() string +- main.go:185 func fatal(msg string, err error) - new.go:12 func runNew(args []string) - wire.go:17 func extenders(partial render.Partial) []goldmark.Extender @@ -175,24 +175,26 @@ 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 — 948 lines + 545 test +## internal/ext/shortcodes — 1019 lines + 639 test -containers.go 117 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 371 · toc.go 78 +containers.go 168 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 374 · toc.go 95 -- containers.go:19 var containerKind = ast.NewNodeKind("ShortcodeContainer") -- containers.go:21 type container struct -- containers.go:30 func (n *container) Kind() ast.NodeKind { return containerKind } -- containers.go:32 func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } -- containers.go:35 type containers struct{} -- containers.go:37 func (containers) Trigger() []byte { return []byte{' '} } -- containers.go:39 func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) -- containers.go:53 func (containers) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State -- containers.go:62 func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {} -- containers.go:64 func (containers) CanInterruptParagraph() bool { return true } -- containers.go:66 func (containers) CanAcceptIndentedLine() bool { return false } -- containers.go:72 type bodies struct -- containers.go:76 func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) -- containers.go:100 func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- containers.go:22 var containerKind = ast.NewNodeKind("ShortcodeContainer") +- containers.go:24 type container struct +- containers.go:33 func (n *container) Kind() ast.NodeKind { return containerKind } +- containers.go:35 func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } +- containers.go:38 type containers struct{} +- containers.go:40 func (containers) Trigger() []byte { return []byte{' '} } +- containers.go:42 func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) +- containers.go:56 func (containers) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State +- containers.go:65 func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {} +- containers.go:67 func (containers) CanInterruptParagraph() bool { return true } +- containers.go:69 func (containers) CanAcceptIndentedLine() bool { return false } +- containers.go:75 type bodies struct +- containers.go:79 func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) +- containers.go:103 func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- containers.go:131 func Merge(src []byte, origin render.Origin) []byte +- containers.go:160 func included(origin render.Origin, name string) ([]byte, error) - icons.go:19 const iconFragment = "icon" - icons.go:27 type icons struct{} - icons.go:29 func (icons) Trigger() []byte { return []byte{' '} } @@ -228,30 +230,31 @@ containers.go 117 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go - 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{} +- shortcodes.go:164 func pending(doc *ast.Document) []*node +- shortcodes.go:182 var kind = ast.NewNodeKind("Shortcode") +- shortcodes.go:185 type node struct +- shortcodes.go:201 func (n *node) Kind() ast.NodeKind { return kind } +- shortcodes.go:203 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } +- shortcodes.go:206 type blocks struct{} +- shortcodes.go:208 func (blocks) Trigger() []byte { return []byte{' '} } +- shortcodes.go:210 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) +- shortcodes.go:241 func gallery(pc parser.Context) []render.Picture +- shortcodes.go:268 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State +- shortcodes.go:272 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {} +- shortcodes.go:274 func (blocks) CanInterruptParagraph() bool { return true } +- shortcodes.go:276 func (blocks) CanAcceptIndentedLine() bool { return false } +- shortcodes.go:279 type fragments struct +- shortcodes.go:283 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) +- shortcodes.go:293 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) +- shortcodes.go:321 func parse(line, prefix string) (name string, args map[string]string, ok bool) +- shortcodes.go:356 func argument(s string) (key, value, rest string, ok bool) +- toc.go:16 const tocName = "toc" +- toc.go:23 type tables struct{} +- toc.go:25 func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) +- toc.go:53 func deeper(all []render.Heading, depth string) []render.Heading +- toc.go:69 func headingText(heading *ast.Heading, source []byte) string +- toc.go:84 func headingID(heading *ast.Heading) string +- toc.go:95 var _ parser.ASTTransformer = tables{} ## internal/ext/watch — 133 lines + 114 test @@ -263,9 +266,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 — 723 lines + 489 test +## internal/render — 739 lines + 489 test -chrome.go 110 · render.go 483 · view.go 130 +chrome.go 110 · render.go 499 · view.go 130 - chrome.go:19 var chrome = map[string]map[string]string{ - chrome.go:33 var months = map[string][]string{ @@ -277,33 +280,34 @@ chrome.go 110 · render.go 483 · view.go 130 - chrome.go:98 func localiseDigits(lang, s string) string - render.go:26 var themeFS embed.FS - render.go:30 type Renderer struct -- 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: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) +- render.go:52 type parsedTheme struct +- render.go:66 type Partial func(name string, data Fragment) ([]byte, error) +- render.go:69 type Fragment struct +- render.go:83 type Heading struct +- render.go:89 type Picture struct +- render.go:106 type Origin struct +- render.go:115 var originKey = parser.NewContextKey() +- render.go:118 func OriginFrom(pc parser.Context) (Origin, bool) +- render.go:125 func WithOrigin(pc parser.Context, origin Origin) +- render.go:138 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error) +- render.go:169 func parseTheme(siteFS fs.FS) (*parsedTheme, error) +- render.go:197 func (r *Renderer) head(title, lang, canonical string) head +- render.go:214 func (r *Renderer) absolute(path string) string +- render.go:222 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections } +- render.go:228 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite } +- render.go:240 func (r *Renderer) Refresh() error +- render.go:251 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) +- render.go:270 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error) +- render.go:292 func readStyle(siteFS fs.FS) (template.CSS, error) +- render.go:310 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error) +- render.go:331 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error) +- render.go:349 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error) +- render.go:393 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error) +- render.go:412 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error) +- render.go:439 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence +- render.go:466 func (r *Renderer) item(b content.Bundle, lang string) Item +- render.go:471 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle) +- render.go:493 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 @@ -314,7 +318,7 @@ chrome.go 110 · render.go 483 · view.go 130 - view.go:111 type Item struct - view.go:122 type Alternate struct -## internal/web — 734 lines + 1622 test +## internal/web — 734 lines + 1625 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 71c06bc..7f7f5df 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -107,7 +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 | +| `::toc{depth=N}` | `toc` | `.Headings` — the document's headings in order, no deeper than `depth` if given | | `:::details{summary=… group=… open=…}` … `:::` | `details` | `.Args`, `.Body`. Siblings sharing a `group` open one at a time, through `<details name>` and no script | | `:::aside{title=…}` … `:::` | `aside` | `.Args.title`, `.Body`. Beside the text where there is room, in the flow where there is not | 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 3c9b393..33d01dd 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 @@ -2,6 +2,7 @@ title: Notes on Water date: 2026-03-25 tags: [monsoon, journal] +include: merge --- The finished piece. Below, a part of it lives in a separate file and is included here: @@ -10,11 +11,14 @@ The finished piece. Below, a part of it lives in a separate file and is included That fragment starts with an underscore, so the scanner never treats it as a bundle: it has no URL of its own and appears in no listing. An included file cannot itself include — one level, deliberately. +This bundle declares `include: merge`, so the fragment is spliced in before anything is parsed: its footnote +is numbered with the page's and both appear in one list at the end, rather than one list per part. + This bundle also has an `extras/` directory, so the theme offers a link to it at the foot of the page. ::toc -## Readings +## Readings {#gauge-readings} The page has a footnote of its own[^page], numbered from one independently of the fragment's. diff --git a/internal/ext/shortcodes/containers.go b/internal/ext/shortcodes/containers.go index baa9205..04bd344 100644 --- a/internal/ext/shortcodes/containers.go +++ b/internal/ext/shortcodes/containers.go @@ -2,8 +2,11 @@ package shortcodes import ( "bytes" + "fmt" "html/template" + "io/fs" "log/slog" + "path" "strings" "github.com/yuin/goldmark" @@ -115,3 +118,51 @@ func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node, } return ast.WalkSkipChildren, nil } + +// Merge splices every `::include{file=…}` line in src with the file it names, before anything is parsed. +// +// The other half of ADR-0066. A bundle asking for `include: merge` wants one document rather than a page of +// embedded ones, and the only way to get that from goldmark is to hand it one source: footnotes then collect +// at the end of the page as they always do, an abbreviation defined anywhere reaches everywhere, and ids need +// no namespacing because nothing was numbered twice. +// +// One pass, so a fragment's own include is left as text — the same one level `embed` allows, enforced here by +// not looking again rather than by a flag. +func Merge(src []byte, origin render.Origin) []byte { + if origin.Files == nil { + return src + } + var out bytes.Buffer + for rest := src; len(rest) > 0; { + line, remainder, found := bytes.Cut(rest, []byte("\n")) + rest = remainder + name, args, ok := parse(string(line), opener) + if !ok || name != "include" { + out.Write(line) + if found { + out.WriteByte('\n') + } + continue + } + body, err := included(origin, args["file"]) + if err != nil { + slog.Error("skipping include", "file", args["file"], "err", err) + continue + } + out.Write(body) + out.WriteByte('\n') + } + return out.Bytes() +} + +// included reads one fragment, refusing a name that would leave the bundle — the same rule the embedded path +// enforces, and for the same reason: an include must not publish a template or a dotfile. +func included(origin render.Origin, name string) ([]byte, error) { + if name == "" { + return nil, fmt.Errorf("include needs a file argument") + } + if strings.Contains(name, "..") { + return nil, fmt.Errorf("include stays inside its bundle: %s", name) + } + return fs.ReadFile(origin.Files, path.Join(origin.Dir, name)) +} diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go index 25914f1..b570b97 100644 --- a/internal/ext/shortcodes/shortcodes.go +++ b/internal/ext/shortcodes/shortcodes.go @@ -146,7 +146,10 @@ func (in includes) convert(name string, pc parser.Context) ([]byte, error) { if err != nil { return nil, err } - inner := parser.NewContext() + // The parent's id set, so a heading repeated across fragments is suffixed rather than duplicated: two + // `## Description`s become `#description` and `#description-1` (ADR-0066). Without this each fragment + // numbers from scratch and the page carries the same id three times. + inner := parser.NewContext(parser.WithIDs(pc.IDs())) render.WithOrigin(inner, origin) inner.Set(nested, true) inner.Set(includedAs, name) diff --git a/internal/ext/shortcodes/shortcodes_test.go b/internal/ext/shortcodes/shortcodes_test.go index b84f0b9..96898f1 100644 --- a/internal/ext/shortcodes/shortcodes_test.go +++ b/internal/ext/shortcodes/shortcodes_test.go @@ -6,6 +6,7 @@ import ( "testing/fstest" "github.com/yuin/goldmark" + gmext "github.com/yuin/goldmark/extension" "khosra/internal/content" "khosra/internal/render" @@ -53,11 +54,16 @@ func wired(t *testing.T, siteFS fstest.MapFS) *render.Renderer { fsys = siteFS } r, err := render.New(fsys, content.Settings{}, func(p render.Partial) []goldmark.Extender { - return []goldmark.Extender{New(p)} + // Footnotes too: how they land is half of what the merge flag decides (ADR-0066). + return []goldmark.Extender{ + gmext.NewFootnote(gmext.WithFootnoteIDPrefixFunction(FootnotePrefix)), + New(p), + } }) if err != nil { t.Fatal(err) } + r.Compose(Merge) return r } @@ -391,3 +397,91 @@ func TestATableOfContentsWithNoHeadingsRendersNothing(t *testing.T) { t.Errorf("the page must survive:\n%s", got) } } + +// mergeFS is a page composed from two fragments, each carrying a footnote. +func mergeFS(extra string) fstest.MapFS { + return fstest.MapFS{ + "content/posts/composed/index.md": {Data: []byte("---\ntitle: Composed\n" + extra + "---\n" + + "Own note[^page].\n\n::include{file=_one.md}\n\n::include{file=_two.md}\n\n[^page]: Page.\n")}, + "content/posts/composed/_one.md": {Data: []byte("First[^a].\n\n[^a]: A.\n")}, + "content/posts/composed/_two.md": {Data: []byte("Second[^b].\n\n[^b]: B.\n")}, + } +} + +// The reason the flag exists: a page built from several files should have one endnote list, at the end +// (ADR-0066). +func TestMergeGivesThePageOneFootnoteList(t *testing.T) { + got := bundle(t, mergeFS("include: merge\n"), "posts/composed") + if n := strings.Count(got, `class="footnotes"`); n != 1 { + t.Errorf("want one endnote list, got %d:\n%s", n, got) + } + for _, want := range []string{`id="fn:1"`, `id="fn:2"`, `id="fn:3"`} { + if !strings.Contains(got, want) { + t.Errorf("notes should number straight through the page: missing %q\n%s", want, got) + } + } + // Nothing needs namespacing once there is only one document. + if strings.Contains(got, "_one-fn:") { + t.Errorf("a merged fragment's ids are the page's:\n%s", got) + } +} + +// The default is untouched, which is what makes the flag safe to add. +func TestWithoutTheFlagEachFragmentKeepsItsOwnNotes(t *testing.T) { + got := bundle(t, mergeFS(""), "posts/composed") + if n := strings.Count(got, `class="footnotes"`); n != 3 { + t.Errorf("embed is still one list per document, got %d:\n%s", n, got) + } + if !strings.Contains(got, "_one-fn:1") { + t.Errorf("embedded fragments still namespace their ids:\n%s", got) + } +} + +func TestMergeRefusesToLeaveTheBundle(t *testing.T) { + fsys := fstest.MapFS{ + "content/posts/p/index.md": {Data: []byte("---\ntitle: P\ninclude: merge\n---\n::include{file=../../../secret.md}\n")}, + "secret.md": {Data: []byte("SECRET\n")}, + } + if got := bundle(t, fsys, "posts/p"); strings.Contains(got, "SECRET") { + t.Errorf("a merging include must stay inside its bundle:\n%s", got) + } +} + +func TestTheContentsListHonoursADepth(t *testing.T) { + got := body(t, wired(t, nil), "::toc{depth=2}\n\n## Kept\n\n### Dropped\n\n## Also kept\n") + for _, want := range []string{`href="#kept"`, `href="#also-kept"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `href="#dropped"`) { + t.Errorf("a heading below the depth should not be listed:\n%s", got) + } + if !strings.Contains(got, `<h3 id="dropped">`) { + t.Errorf("the heading itself still renders:\n%s", got) + } +} + +// Heading ids must be unique whichever include model is in use: three `## Description`s on one page is a +// page with three identical anchors, and every link to them lands on the first (ADR-0066). +func TestRepeatedHeadingsAreSuffixedNotDuplicated(t *testing.T) { + fs := func(extra string) fstest.MapFS { + return fstest.MapFS{ + "content/posts/c/index.md": {Data: []byte("---\ntitle: C\n" + extra + "---\n" + + "## Description\n\n::include{file=_one.md}\n\n::include{file=_two.md}\n")}, + "content/posts/c/_one.md": {Data: []byte("## Description\n\nOne.\n")}, + "content/posts/c/_two.md": {Data: []byte("## Description\n\nTwo.\n")}, + } + } + for _, model := range []string{"", "include: merge\n"} { + got := bundle(t, fs(model), "posts/c") + for _, want := range []string{`id="description"`, `id="description-1"`, `id="description-2"`} { + if !strings.Contains(got, want) { + t.Errorf("model %q is missing %q:\n%s", model, want, got) + } + } + if n := strings.Count(got, `id="description"`); n != 1 { + t.Errorf("model %q repeated the bare id %d times:\n%s", model, n, got) + } + } +} diff --git a/internal/ext/shortcodes/toc.go b/internal/ext/shortcodes/toc.go index 72bf108..c0e31f6 100644 --- a/internal/ext/shortcodes/toc.go +++ b/internal/ext/shortcodes/toc.go @@ -2,6 +2,7 @@ package shortcodes import ( "bytes" + "strconv" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" @@ -43,10 +44,26 @@ func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context return ast.WalkContinue, nil }) for _, call := range calls { - call.headings = found + call.headings = deeper(found, call.args["depth"]) } } +// deeper drops headings below the depth the call asked for. Absent or unreadable means every level, because a +// contents list that silently shortened itself would be worse than a long one. +func deeper(all []render.Heading, depth string) []render.Heading { + limit, err := strconv.Atoi(depth) + if err != nil || limit < 1 { + return all + } + kept := make([]render.Heading, 0, len(all)) + for _, h := range all { + if h.Level <= limit { + kept = append(kept, h) + } + } + return kept +} + // 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 { diff --git a/internal/render/render.go b/internal/render/render.go index 9231206..cbaa6f5 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -42,6 +42,9 @@ type Renderer struct { sections func() []string // siteFS is kept only so Refresh can reparse what New parsed. siteFS fs.FS + // compose may rewrite a body before it is parsed, for a bundle that asks its includes to be merged + // (ADR-0066). Set at wiring time like sections, and never called otherwise. + compose func(src []byte, origin Origin) []byte } // parsedTheme is one snapshot of the theme: the sets a request executes, and the stylesheet the shell inlines. @@ -154,7 +157,7 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar // Heading IDs are a parser option rather than an extension, and they are the engine's half of a table of // contents: the anchor has to exist before a theme can link to it (ADR-0058). r.md = goldmark.New(goldmark.WithExtensions(extensions...), - goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithParserOptions(parser.WithAutoHeadingID(), parser.WithHeadingAttribute()), goldmark.WithRendererOptions(html.WithUnsafe())) return r, nil } @@ -218,6 +221,12 @@ func (r *Renderer) absolute(path string) string { // sections exist. A callback rather than a slice, because content changes and a copy would go stale. func (r *Renderer) Navigation(sections func() []string) { r.sections = sections } +// Compose registers the rewrite a merging bundle's body goes through before it is parsed (ADR-0066). +// +// A seam rather than a call, for the same reason extend is one: only cmd knows which features exist, and +// splicing source files together is a feature's work, not the renderer's. +func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite } + // Refresh reparses the theme and swaps it in, so a running server picks up an edited template the same way it // picks up edited content (ADR-0055). Called once per rebuild, off the request path. // @@ -341,9 +350,16 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se // The parse carries which bundle it is, so a feature can resolve a path in a call against the bundle's // own directory (ADR-0031: through the rooted filesystem, never a joined path). pc := parser.NewContext() - WithOrigin(pc, Origin{Dir: path.Dir(b.Path), Files: r.files}) + origin := Origin{Dir: path.Dir(b.Path), Files: r.files} + WithOrigin(pc, origin) + // `include: merge` asks for one document rather than a page of embedded ones, so the fragments are + // spliced in before the parse and their footnotes, abbreviations and headings become the page's (ADR-0066). + source := b.Body + if kind, _ := b.Extra["include"].(string); kind == "merge" && r.compose != nil { + source = r.compose(source, origin) + } var body bytes.Buffer - if err := r.md.Convert(b.Body, &body, parser.WithContext(pc)); err != nil { + if err := r.md.Convert(source, &body, parser.WithContext(pc)); err != nil { return nil, fmt.Errorf("markdown %s: %w", b.Path, err) } title := b.Title diff --git a/internal/web/example_test.go b/internal/web/example_test.go index 190199c..087167f 100644 --- a/internal/web/example_test.go +++ b/internal/web/example_test.go @@ -57,6 +57,7 @@ func exampleSite(t *testing.T) http.Handler { if err != nil { t.Fatal(err) } + r.Compose(shortcodes.Merge) site := content.NewSite(bundles) r.Navigation(site.Sections) return Handler(Fixed(site), r, fsys, nil, settings) @@ -142,9 +143,11 @@ var exampleFeatures = []featureCase{ expect: []string{`<aside class="admonition warn">`, `<p class="admonition-title">Calibration</p>`, "<em>emphasis</em>"}, absent: []string{":::"}}, {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="#readings">Readings</a>`, `class="toc-2"`}}, - {what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200, - expect: []string{`id="fn:1"`, `id="_method-fn:1"`, `href="#_method-fn:1"`}}, + expect: []string{`<nav class="toc">`, `<a href="#gauge-readings">Readings</a>`, `class="toc-2"`}}, + {what: "a merging bundle has one footnote list, numbered straight through", path: "/writing/notes-on-water/", code: 200, + expect: []string{`id="fn:1"`, `id="fn:2"`}, absent: []string{"_method-fn:", `class="footnotes"><hr><ol><li id="fn:2"`}}, + {what: "a heading may declare an anchor that outlives its wording", path: "/writing/notes-on-water/", code: 200, + expect: []string{`<h2 id="gauge-readings">`, `href="#gauge-readings"`}}, {what: "a page offers its extras only when it has them", path: "/writing/notes-on-water/", code: 200, expect: []string{`href="/writing/notes-on-water/extras/"`}}, {what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200,