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
+19 -3
View File
@@ -42,6 +42,9 @@ type Renderer struct {
sections func() []string
// siteFS is kept only so Refresh can reparse what New parsed.
siteFS fs.FS
// compose may rewrite a body before it is parsed, for a bundle that asks its includes to be merged
// (ADR-0066). Set at wiring time like sections, and never called otherwise.
compose func(src []byte, origin Origin) []byte
}
// parsedTheme is one snapshot of the theme: the sets a request executes, and the stylesheet the shell inlines.
@@ -154,7 +157,7 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
// Heading IDs are a parser option rather than an extension, and they are the engine's half of a table of
// contents: the anchor has to exist before a theme can link to it (ADR-0058).
r.md = goldmark.New(goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithParserOptions(parser.WithAutoHeadingID(), parser.WithHeadingAttribute()),
goldmark.WithRendererOptions(html.WithUnsafe()))
return r, nil
}
@@ -218,6 +221,12 @@ func (r *Renderer) absolute(path string) string {
// sections exist. A callback rather than a slice, because content changes and a copy would go stale.
func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
// Compose registers the rewrite a merging bundle's body goes through before it is parsed (ADR-0066).
//
// A seam rather than a call, for the same reason extend is one: only cmd knows which features exist, and
// splicing source files together is a feature's work, not the renderer's.
func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
// Refresh reparses the theme and swaps it in, so a running server picks up an edited template the same way it
// picks up edited content (ADR-0055). Called once per rebuild, off the request path.
//
@@ -341,9 +350,16 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
// The parse carries which bundle it is, so a feature can resolve a path in a call against the bundle's
// own directory (ADR-0031: through the rooted filesystem, never a joined path).
pc := parser.NewContext()
WithOrigin(pc, Origin{Dir: path.Dir(b.Path), Files: r.files})
origin := Origin{Dir: path.Dir(b.Path), Files: r.files}
WithOrigin(pc, origin)
// `include: merge` asks for one document rather than a page of embedded ones, so the fragments are
// spliced in before the parse and their footnotes, abbreviations and headings become the page's (ADR-0066).
source := b.Body
if kind, _ := b.Extra["include"].(string); kind == "merge" && r.compose != nil {
source = r.compose(source, origin)
}
var body bytes.Buffer
if err := r.md.Convert(b.Body, &body, parser.WithContext(pc)); err != nil {
if err := r.md.Convert(source, &body, parser.WithContext(pc)); err != nil {
return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
}
title := b.Title