give the author three controls the engine was deciding alone

`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.
This commit is contained in:
Claude Opus 5
2026-08-01 23:08:49 +06:00
committed by bdeshi
parent d1c9179d6c
commit b5be77498e
14 changed files with 353 additions and 97 deletions
+1
View File
@@ -69,6 +69,7 @@ func runServe() {
if err != nil { if err != nil {
fatal("cannot prepare the theme", err) 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 // 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). // it should be impossible to enable by fumbling an argument (ADR-0024).
interval := pollInterval(*dev == "on", *poll) interval := pollInterval(*dev == "on", *poll)
+28
View File
@@ -61,3 +61,31 @@ func TestTheTypographerLeavesATableAlone(t *testing.T) {
t.Errorf("the delimiter row was smartened instead of parsed:\n%s", got) 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{`<h2 id="stable-anchor">A Reworded Heading</h2>`, `<h2 id="derived-instead">`} {
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)
}
}
+13 -3
View File
@@ -93,6 +93,7 @@ readable by templates (ADR-0002). Never add a required field.
| `view` | string | Per-bundle View override (Arc 2) | | `view` | string | Per-bundle View override (Arc 2) |
| `styles` / `scripts` | []string | Page-specific assets, relative to the bundle | | `styles` / `scripts` | []string | Page-specific assets, relative to the bundle |
| `lang` | string | Explicit language when the filename cannot carry it | | `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 ## 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^` | | Superscript | `10^6^` |
| Highlight | `==marked==` | | Highlight | `==marked==` |
| Abbreviations | `*[HTML]: HyperText Markup Language` on its own line; every whole-word use expands | | 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 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 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. 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 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. 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 **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 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). 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. 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 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. engine assigned, so indentation is a CSS decision.
+25
View File
@@ -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. 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 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. 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 `<hr>` 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.
+5 -5
View File
@@ -21,11 +21,11 @@ table owns.
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) | | `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/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/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/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/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/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/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/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) | | `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 `-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
URLs go absolute (ADR-0039). 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 `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. 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 | | 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 `<title>` 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 root listing's `<title>` 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) | | 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 | | `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 |
| 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 | | 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 | | 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 ## Open questions
+82 -78
View File
@@ -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: 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`. 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) - check.go:16 func runCheck(args []string)
- main.go:24 func main() - main.go:24 func main()
- main.go:45 func runServe() - main.go:45 func runServe()
- main.go:106 func pollInterval(dev bool, chosen time.Duration) time.Duration - main.go:107 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:120 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:137 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:163 func given(name string) bool
- main.go:174 func defaultCache() string - main.go:175 func defaultCache() string
- main.go:184 func fatal(msg string, err error) - main.go:185 func fatal(msg string, err error)
- new.go:12 func runNew(args []string) - new.go:12 func runNew(args []string)
- wire.go:17 func extenders(partial render.Partial) []goldmark.Extender - 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:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error - 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:22 var containerKind = ast.NewNodeKind("ShortcodeContainer")
- containers.go:21 type container struct - containers.go:24 type container struct
- containers.go:30 func (n *container) Kind() ast.NodeKind { return containerKind } - containers.go:33 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 func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- containers.go:35 type containers struct{} - containers.go:38 type containers struct{}
- containers.go:37 func (containers) Trigger() []byte { return []byte{' '} } - containers.go:40 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:42 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:56 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:65 func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
- containers.go:64 func (containers) CanInterruptParagraph() bool { return true } - containers.go:67 func (containers) CanInterruptParagraph() bool { return true }
- containers.go:66 func (containers) CanAcceptIndentedLine() bool { return false } - containers.go:69 func (containers) CanAcceptIndentedLine() bool { return false }
- containers.go:72 type bodies struct - containers.go:75 type bodies struct
- containers.go:76 func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) - containers.go:79 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: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:19 const iconFragment = "icon"
- icons.go:27 type icons struct{} - icons.go:27 type icons struct{}
- icons.go:29 func (icons) Trigger() []byte { return []byte{' '} } - 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:102 type includes struct
- shortcodes.go:106 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) - 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:130 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:161 func pending(doc *ast.Document) []*node - shortcodes.go:164 func pending(doc *ast.Document) []*node
- shortcodes.go:179 var kind = ast.NewNodeKind("Shortcode") - shortcodes.go:182 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:182 type node struct - shortcodes.go:185 type node struct
- shortcodes.go:198 func (n *node) Kind() ast.NodeKind { return kind } - shortcodes.go:201 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 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:203 type blocks struct{} - shortcodes.go:206 type blocks struct{}
- shortcodes.go:205 func (blocks) Trigger() []byte { return []byte{' '} } - shortcodes.go:208 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:210 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:241 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:268 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:272 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:271 func (blocks) CanInterruptParagraph() bool { return true } - shortcodes.go:274 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:273 func (blocks) CanAcceptIndentedLine() bool { return false } - shortcodes.go:276 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:276 type fragments struct - shortcodes.go:279 type fragments struct
- shortcodes.go:280 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) - shortcodes.go:283 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:293 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:321 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) - shortcodes.go:356 func argument(s string) (key, value, rest string, ok bool)
- toc.go:15 const tocName = "toc" - toc.go:16 const tocName = "toc"
- toc.go:22 type tables struct{} - toc.go:23 type tables struct{}
- toc.go:24 func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) - toc.go:25 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:53 func deeper(all []render.Heading, depth string) []render.Heading
- toc.go:67 func headingID(heading *ast.Heading) string - toc.go:69 func headingText(heading *ast.Heading, source []byte) string
- toc.go:78 var _ parser.ASTTransformer = tables{} - toc.go:84 func headingID(heading *ast.Heading) string
- toc.go:95 var _ parser.ASTTransformer = tables{}
## internal/ext/watch — 133 lines + 114 test ## 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:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool - 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:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = 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 - chrome.go:98 func localiseDigits(lang, s string) string
- render.go:26 var themeFS embed.FS - render.go:26 var themeFS embed.FS
- render.go:30 type Renderer struct - render.go:30 type Renderer struct
- render.go:49 type parsedTheme struct - render.go:52 type parsedTheme struct
- render.go:63 type Partial func(name string, data Fragment) ([]byte, error) - render.go:66 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:66 type Fragment struct - render.go:69 type Fragment struct
- render.go:80 type Heading struct - render.go:83 type Heading struct
- render.go:86 type Picture struct - render.go:89 type Picture struct
- render.go:103 type Origin struct - render.go:106 type Origin struct
- render.go:112 var originKey = parser.NewContextKey() - render.go:115 var originKey = parser.NewContextKey()
- render.go:115 func OriginFrom(pc parser.Context) (Origin, bool) - render.go:118 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:122 func WithOrigin(pc parser.Context, origin Origin) - render.go:125 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:138 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:169 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:194 func (r *Renderer) head(title, lang, canonical string) head - render.go:197 func (r *Renderer) head(title, lang, canonical string) head
- render.go:211 func (r *Renderer) absolute(path string) string - render.go:214 func (r *Renderer) absolute(path string) string
- render.go:219 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections } - render.go:222 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:231 func (r *Renderer) Refresh() error - render.go:228 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
- render.go:242 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) - render.go:240 func (r *Renderer) Refresh() error
- render.go:261 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error) - render.go:251 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:283 func readStyle(siteFS fs.FS) (template.CSS, error) - render.go:270 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:301 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error) - render.go:292 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:322 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error) - render.go:310 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:340 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error) - render.go:331 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:377 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error) - render.go:349 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:396 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error) - render.go:393 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:423 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence - render.go:412 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:450 func (r *Renderer) item(b content.Bundle, lang string) Item - render.go:439 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- 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:466 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:477 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error) - 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:16 type head struct
- view.go:36 type Page struct - view.go:36 type Page struct
- view.go:55 type Sequence 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:111 type Item struct
- view.go:122 type Alternate 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 asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+1 -1
View File
@@ -107,7 +107,7 @@ Each picture carries:
| `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order | | `::gallery` | `gallery` | `.Pictures` — every picture beside the bundle, in filename order |
| `:name:` | `icon` | `.Args.name` — the name as written, nothing else | | `:name:` | `icon` | `.Args.name` — the name as written, nothing else |
| `:::note{title=…}``:::` | `note` | `.Args.title`, `.Body` — the rendered content | | `:::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 | | `:::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 | | `:::aside{title=…}``:::` | `aside` | `.Args.title`, `.Body`. Beside the text where there is room, in the flow where there is not |
@@ -2,6 +2,7 @@
title: Notes on Water title: Notes on Water
date: 2026-03-25 date: 2026-03-25
tags: [monsoon, journal] tags: [monsoon, journal]
include: merge
--- ---
The finished piece. Below, a part of it lives in a separate file and is included here: 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 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. 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. This bundle also has an `extras/` directory, so the theme offers a link to it at the foot of the page.
::toc ::toc
## Readings ## Readings {#gauge-readings}
The page has a footnote of its own[^page], numbered from one independently of the fragment's. The page has a footnote of its own[^page], numbered from one independently of the fragment's.
+51
View File
@@ -2,8 +2,11 @@ package shortcodes
import ( import (
"bytes" "bytes"
"fmt"
"html/template" "html/template"
"io/fs"
"log/slog" "log/slog"
"path"
"strings" "strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@@ -115,3 +118,51 @@ func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node,
} }
return ast.WalkSkipChildren, nil 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))
}
+4 -1
View File
@@ -146,7 +146,10 @@ func (in includes) convert(name string, pc parser.Context) ([]byte, error) {
if err != nil { if err != nil {
return nil, err 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) render.WithOrigin(inner, origin)
inner.Set(nested, true) inner.Set(nested, true)
inner.Set(includedAs, name) inner.Set(includedAs, name)
+95 -1
View File
@@ -6,6 +6,7 @@ import (
"testing/fstest" "testing/fstest"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
gmext "github.com/yuin/goldmark/extension"
"khosra/internal/content" "khosra/internal/content"
"khosra/internal/render" "khosra/internal/render"
@@ -53,11 +54,16 @@ func wired(t *testing.T, siteFS fstest.MapFS) *render.Renderer {
fsys = siteFS fsys = siteFS
} }
r, err := render.New(fsys, content.Settings{}, func(p render.Partial) []goldmark.Extender { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
r.Compose(Merge)
return r return r
} }
@@ -391,3 +397,91 @@ func TestATableOfContentsWithNoHeadingsRendersNothing(t *testing.T) {
t.Errorf("the page must survive:\n%s", got) 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)
}
}
}
+18 -1
View File
@@ -2,6 +2,7 @@ package shortcodes
import ( import (
"bytes" "bytes"
"strconv"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser" "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 return ast.WalkContinue, nil
}) })
for _, call := range calls { 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 // 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. // inside another link is not markup a browser accepts.
func headingText(heading *ast.Heading, source []byte) string { func headingText(heading *ast.Heading, source []byte) string {
+19 -3
View File
@@ -42,6 +42,9 @@ type Renderer struct {
sections func() []string sections func() []string
// siteFS is kept only so Refresh can reparse what New parsed. // siteFS is kept only so Refresh can reparse what New parsed.
siteFS fs.FS 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. // 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 // 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). // contents: the anchor has to exist before a theme can link to it (ADR-0058).
r.md = goldmark.New(goldmark.WithExtensions(extensions...), r.md = goldmark.New(goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions(parser.WithAutoHeadingID()), goldmark.WithParserOptions(parser.WithAutoHeadingID(), parser.WithHeadingAttribute()),
goldmark.WithRendererOptions(html.WithUnsafe())) goldmark.WithRendererOptions(html.WithUnsafe()))
return r, nil 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. // 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 } 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 // 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. // 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 // 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). // own directory (ADR-0031: through the rooted filesystem, never a joined path).
pc := parser.NewContext() 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 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) return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
} }
title := b.Title title := b.Title
+6 -3
View File
@@ -57,6 +57,7 @@ func exampleSite(t *testing.T) http.Handler {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
r.Compose(shortcodes.Merge)
site := content.NewSite(bundles) site := content.NewSite(bundles)
r.Navigation(site.Sections) r.Navigation(site.Sections)
return Handler(Fixed(site), r, fsys, nil, settings) 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>"}, expect: []string{`<aside class="admonition warn">`, `<p class="admonition-title">Calibration</p>`, "<em>emphasis</em>"},
absent: []string{":::"}}, absent: []string{":::"}},
{what: "a table of contents links the page's own headings", path: "/writing/notes-on-water/", code: 200, {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"`}}, expect: []string{`<nav class="toc">`, `<a href="#gauge-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, {what: "a merging bundle has one footnote list, numbered straight through", path: "/writing/notes-on-water/", code: 200,
expect: []string{`id="fn:1"`, `id="_method-fn:1"`, `href="#_method-fn:1"`}}, 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, {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/"`}}, expect: []string{`href="/writing/notes-on-water/extras/"`}},
{what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200, {what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200,