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
+51
View File
@@ -2,8 +2,11 @@ package shortcodes
import (
"bytes"
"fmt"
"html/template"
"io/fs"
"log/slog"
"path"
"strings"
"github.com/yuin/goldmark"
@@ -115,3 +118,51 @@ func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node,
}
return ast.WalkSkipChildren, nil
}
// Merge splices every `::include{file=…}` line in src with the file it names, before anything is parsed.
//
// The other half of ADR-0066. A bundle asking for `include: merge` wants one document rather than a page of
// embedded ones, and the only way to get that from goldmark is to hand it one source: footnotes then collect
// at the end of the page as they always do, an abbreviation defined anywhere reaches everywhere, and ids need
// no namespacing because nothing was numbered twice.
//
// One pass, so a fragment's own include is left as text — the same one level `embed` allows, enforced here by
// not looking again rather than by a flag.
func Merge(src []byte, origin render.Origin) []byte {
if origin.Files == nil {
return src
}
var out bytes.Buffer
for rest := src; len(rest) > 0; {
line, remainder, found := bytes.Cut(rest, []byte("\n"))
rest = remainder
name, args, ok := parse(string(line), opener)
if !ok || name != "include" {
out.Write(line)
if found {
out.WriteByte('\n')
}
continue
}
body, err := included(origin, args["file"])
if err != nil {
slog.Error("skipping include", "file", args["file"], "err", err)
continue
}
out.Write(body)
out.WriteByte('\n')
}
return out.Bytes()
}
// included reads one fragment, refusing a name that would leave the bundle — the same rule the embedded path
// enforces, and for the same reason: an include must not publish a template or a dotfile.
func included(origin render.Origin, name string) ([]byte, error) {
if name == "" {
return nil, fmt.Errorf("include needs a file argument")
}
if strings.Contains(name, "..") {
return nil, fmt.Errorf("include stays inside its bundle: %s", name)
}
return fs.ReadFile(origin.Files, path.Join(origin.Dir, name))
}
+4 -1
View File
@@ -146,7 +146,10 @@ func (in includes) convert(name string, pc parser.Context) ([]byte, error) {
if err != nil {
return nil, err
}
inner := parser.NewContext()
// The parent's id set, so a heading repeated across fragments is suffixed rather than duplicated: two
// `## Description`s become `#description` and `#description-1` (ADR-0066). Without this each fragment
// numbers from scratch and the page carries the same id three times.
inner := parser.NewContext(parser.WithIDs(pc.IDs()))
render.WithOrigin(inner, origin)
inner.Set(nested, true)
inner.Set(includedAs, name)
+95 -1
View File
@@ -6,6 +6,7 @@ import (
"testing/fstest"
"github.com/yuin/goldmark"
gmext "github.com/yuin/goldmark/extension"
"khosra/internal/content"
"khosra/internal/render"
@@ -53,11 +54,16 @@ func wired(t *testing.T, siteFS fstest.MapFS) *render.Renderer {
fsys = siteFS
}
r, err := render.New(fsys, content.Settings{}, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{New(p)}
// Footnotes too: how they land is half of what the merge flag decides (ADR-0066).
return []goldmark.Extender{
gmext.NewFootnote(gmext.WithFootnoteIDPrefixFunction(FootnotePrefix)),
New(p),
}
})
if err != nil {
t.Fatal(err)
}
r.Compose(Merge)
return r
}
@@ -391,3 +397,91 @@ func TestATableOfContentsWithNoHeadingsRendersNothing(t *testing.T) {
t.Errorf("the page must survive:\n%s", got)
}
}
// mergeFS is a page composed from two fragments, each carrying a footnote.
func mergeFS(extra string) fstest.MapFS {
return fstest.MapFS{
"content/posts/composed/index.md": {Data: []byte("---\ntitle: Composed\n" + extra + "---\n" +
"Own note[^page].\n\n::include{file=_one.md}\n\n::include{file=_two.md}\n\n[^page]: Page.\n")},
"content/posts/composed/_one.md": {Data: []byte("First[^a].\n\n[^a]: A.\n")},
"content/posts/composed/_two.md": {Data: []byte("Second[^b].\n\n[^b]: B.\n")},
}
}
// The reason the flag exists: a page built from several files should have one endnote list, at the end
// (ADR-0066).
func TestMergeGivesThePageOneFootnoteList(t *testing.T) {
got := bundle(t, mergeFS("include: merge\n"), "posts/composed")
if n := strings.Count(got, `class="footnotes"`); n != 1 {
t.Errorf("want one endnote list, got %d:\n%s", n, got)
}
for _, want := range []string{`id="fn:1"`, `id="fn:2"`, `id="fn:3"`} {
if !strings.Contains(got, want) {
t.Errorf("notes should number straight through the page: missing %q\n%s", want, got)
}
}
// Nothing needs namespacing once there is only one document.
if strings.Contains(got, "_one-fn:") {
t.Errorf("a merged fragment's ids are the page's:\n%s", got)
}
}
// The default is untouched, which is what makes the flag safe to add.
func TestWithoutTheFlagEachFragmentKeepsItsOwnNotes(t *testing.T) {
got := bundle(t, mergeFS(""), "posts/composed")
if n := strings.Count(got, `class="footnotes"`); n != 3 {
t.Errorf("embed is still one list per document, got %d:\n%s", n, got)
}
if !strings.Contains(got, "_one-fn:1") {
t.Errorf("embedded fragments still namespace their ids:\n%s", got)
}
}
func TestMergeRefusesToLeaveTheBundle(t *testing.T) {
fsys := fstest.MapFS{
"content/posts/p/index.md": {Data: []byte("---\ntitle: P\ninclude: merge\n---\n::include{file=../../../secret.md}\n")},
"secret.md": {Data: []byte("SECRET\n")},
}
if got := bundle(t, fsys, "posts/p"); strings.Contains(got, "SECRET") {
t.Errorf("a merging include must stay inside its bundle:\n%s", got)
}
}
func TestTheContentsListHonoursADepth(t *testing.T) {
got := body(t, wired(t, nil), "::toc{depth=2}\n\n## Kept\n\n### Dropped\n\n## Also kept\n")
for _, want := range []string{`href="#kept"`, `href="#also-kept"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `href="#dropped"`) {
t.Errorf("a heading below the depth should not be listed:\n%s", got)
}
if !strings.Contains(got, `<h3 id="dropped">`) {
t.Errorf("the heading itself still renders:\n%s", got)
}
}
// Heading ids must be unique whichever include model is in use: three `## Description`s on one page is a
// page with three identical anchors, and every link to them lands on the first (ADR-0066).
func TestRepeatedHeadingsAreSuffixedNotDuplicated(t *testing.T) {
fs := func(extra string) fstest.MapFS {
return fstest.MapFS{
"content/posts/c/index.md": {Data: []byte("---\ntitle: C\n" + extra + "---\n" +
"## Description\n\n::include{file=_one.md}\n\n::include{file=_two.md}\n")},
"content/posts/c/_one.md": {Data: []byte("## Description\n\nOne.\n")},
"content/posts/c/_two.md": {Data: []byte("## Description\n\nTwo.\n")},
}
}
for _, model := range []string{"", "include: merge\n"} {
got := bundle(t, fs(model), "posts/c")
for _, want := range []string{`id="description"`, `id="description-1"`, `id="description-2"`} {
if !strings.Contains(got, want) {
t.Errorf("model %q is missing %q:\n%s", model, want, got)
}
}
if n := strings.Count(got, `id="description"`); n != 1 {
t.Errorf("model %q repeated the bare id %d times:\n%s", model, n, got)
}
}
}
+18 -1
View File
@@ -2,6 +2,7 @@ package shortcodes
import (
"bytes"
"strconv"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
@@ -43,10 +44,26 @@ func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context
return ast.WalkContinue, nil
})
for _, call := range calls {
call.headings = found
call.headings = deeper(found, call.args["depth"])
}
}
// deeper drops headings below the depth the call asked for. Absent or unreadable means every level, because a
// contents list that silently shortened itself would be worse than a long one.
func deeper(all []render.Heading, depth string) []render.Heading {
limit, err := strconv.Atoi(depth)
if err != nil || limit < 1 {
return all
}
kept := make([]render.Heading, 0, len(all))
for _, h := range all {
if h.Level <= limit {
kept = append(kept, h)
}
}
return kept
}
// headingText is the heading's words without any markup it carries: a contents entry is a label, and a link
// inside another link is not markup a browser accepts.
func headingText(heading *ast.Heading, source []byte) string {