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:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user