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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,7 @@ func exampleSite(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Compose(shortcodes.Merge)
|
||||
site := content.NewSite(bundles)
|
||||
r.Navigation(site.Sections)
|
||||
return Handler(Fixed(site), r, fsys, nil, settings)
|
||||
@@ -142,9 +143,11 @@ var exampleFeatures = []featureCase{
|
||||
expect: []string{`<aside class="admonition warn">`, `<p class="admonition-title">Calibration</p>`, "<em>emphasis</em>"},
|
||||
absent: []string{":::"}},
|
||||
{what: "a table of contents links the page's own headings", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<nav class="toc">`, `<a href="#readings">Readings</a>`, `class="toc-2"`}},
|
||||
{what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`id="fn:1"`, `id="_method-fn:1"`, `href="#_method-fn:1"`}},
|
||||
expect: []string{`<nav class="toc">`, `<a href="#gauge-readings">Readings</a>`, `class="toc-2"`}},
|
||||
{what: "a merging bundle has one footnote list, numbered straight through", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`id="fn:1"`, `id="fn:2"`}, absent: []string{"_method-fn:", `class="footnotes"><hr><ol><li id="fn:2"`}},
|
||||
{what: "a heading may declare an anchor that outlives its wording", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`<h2 id="gauge-readings">`, `href="#gauge-readings"`}},
|
||||
{what: "a page offers its extras only when it has them", path: "/writing/notes-on-water/", code: 200,
|
||||
expect: []string{`href="/writing/notes-on-water/extras/"`}},
|
||||
{what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200,
|
||||
|
||||
Reference in New Issue
Block a user