let a shortcode fragment speak the reader's language

Three fragments added this session needed a word the author did not write: an
untitled :::warn told the reader nothing about being a warning, an untitled
panel fell back to whatever the browser calls <details>, and the contents list
had no label at all — an accessibility gap as much as an untranslated one. None
of them could be fixed, because `t` needs a language and Fragment had none, so
those words could only ever have been English on a site that serves Bengali.

Fragment gains Lang, captured on each call at parse time — a node renderer never
receives the parse context, the same constraint that put pictures and headings
on the node. Five phrase keys follow, and a Bengali page now reads সূচিপত্র,
সতর্কতা and বিস্তারিত where an English one reads Contents, Warning and Details.

The demo's own list.html was the better example of the problem and now shows the
answer: a site's own sentences are not in the engine's phrase table, so a
template needing its own words branches on the language it was given. That is
what theme-contract.md has always told a theme to do, demonstrated rather than
asserted, and a case proves the Bengali listing carries no English.

Two files crossed the size advisory on the way. render.go shed the contract
types to view.go, where state.md already claimed they lived and where the file's
own header said they belonged; shortcodes_test.go split to mirror its sources,
which the one-file-per-source convention already asked for. Both are pure moves.
This commit is contained in:
Claude Opus 5
2026-08-01 23:09:03 +06:00
committed by bdeshi
parent b5be77498e
commit 7136f6e2d9
18 changed files with 455 additions and 340 deletions
+7 -2
View File
@@ -25,6 +25,7 @@ type container struct {
ast.BaseBlock
name string
args map[string]string
lang string
// body is the content, rendered before the fragment is asked for anything. Filled in by the transformer,
// for the same reason an include is: a node renderer never receives the parse context.
body []byte
@@ -46,7 +47,11 @@ func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (
return nil, parser.NoChildren
}
reader.Advance(seg.Len() - 1)
return &container{name: name, args: args}, parser.HasChildren
call := &container{name: name, args: args}
if origin, ok := render.OriginFrom(pc); ok {
call.lang = origin.Lang
}
return call, parser.HasChildren
}
// Continue reads the body until a line that is nothing but the fence.
@@ -105,7 +110,7 @@ func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node,
return ast.WalkContinue, nil
}
call := n.(*container)
out, err := f.partial(call.name, render.Fragment{Args: call.args, Body: template.HTML(call.body)})
out, err := f.partial(call.name, render.Fragment{Args: call.args, Lang: call.lang, Body: template.HTML(call.body)})
if err != nil {
slog.Error("skipping container", "name", call.name, "err", err)
out = nil
+118
View File
@@ -0,0 +1,118 @@
package shortcodes
import (
"strings"
"testing"
"testing/fstest"
)
func TestAContainerWrapsItsRenderedBody(t *testing.T) {
got := body(t, wired(t, nil), ":::note{title=\"Read this\"}\nA body with *emphasis* and a [link](/posts/).\n\nTwo paragraphs.\n:::\n\nAfter.\n")
for _, want := range []string{
`<aside class="admonition note">`, `<p class="admonition-title">Read this</p>`,
"<em>emphasis</em>", `href="/posts/"`, "Two paragraphs.", "</aside>",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if !strings.Contains(got, "After.") {
t.Errorf("the page continues after the fence:\n%s", got)
}
if strings.Contains(got, ":::") {
t.Errorf("the fences are syntax, not content:\n%s", got)
}
}
// A theme with no template for a kind renders nothing. Losing an author's paragraphs because of that would
// be far worse than an unstyled aside (ADR-0064).
func TestAnUnknownContainerKeepsItsBody(t *testing.T) {
got := body(t, wired(t, nil), ":::nosuchkind\nThis body must survive.\n:::\n")
if !strings.Contains(got, "This body must survive.") {
t.Errorf("the body must survive an unknown kind:\n%s", got)
}
if strings.Contains(got, "nosuchkind") {
t.Errorf("the kind is not content:\n%s", got)
}
}
func TestAContainerDoesNotSwallowTheRestOfThePage(t *testing.T) {
// An unclosed fence ends with the document rather than eating a later one.
got := body(t, wired(t, nil), ":::note\nInside.\n\nStill inside.\n")
if !strings.Contains(got, "Inside.") || !strings.Contains(got, "Still inside.") {
t.Errorf("an unclosed container keeps its content:\n%s", got)
}
}
// 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)
}
}
// 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)
}
}
}
// 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")},
}
}
+5 -1
View File
@@ -40,6 +40,9 @@ func (icons) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.No
// The source text is kept so a name the theme does not know can be written back exactly as the author
// typed it, rather than vanishing from the middle of a sentence.
n := &iconNode{name: name, literal: string(line[:width])}
if origin, ok := render.OriginFrom(pc); ok {
n.lang = origin.Lang
}
block.Advance(width)
return n
}
@@ -52,6 +55,7 @@ type iconNode struct {
ast.BaseInline
name string
literal string
lang string
}
func (n *iconNode) Kind() ast.NodeKind { return iconKind }
@@ -65,7 +69,7 @@ func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, enter
return ast.WalkContinue, nil
}
call := n.(*iconNode)
out, err := f.partial(iconFragment, render.Fragment{Args: map[string]string{"name": call.name}})
out, err := f.partial(iconFragment, render.Fragment{Args: map[string]string{"name": call.name}, Lang: call.lang})
if err != nil {
slog.Error("skipping icon", "name", call.name, "err", err)
out = nil
+48
View File
@@ -0,0 +1,48 @@
package shortcodes
import (
"strings"
"testing"
)
func TestIconsRenderThroughTheThemeAndNeverEatProse(t *testing.T) {
got := body(t, wired(t, nil), "Careful :warn: and :note: here.\n")
for _, want := range []string{"⚠️", "️"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// The colon is the commonest punctuation in technical prose. Each of these would be a silent edit to
// someone's sentence (ADR-0063).
for _, prose := range []string{
"Times 10:30:15 exactly.",
"Pairs key:value:pair here.",
"Note: this matters.",
"See https://x.example/ for more.",
"A ratio of 3:4:5.",
} {
out := body(t, wired(t, nil), prose+"\n")
if !strings.Contains(out, prose) {
t.Errorf("prose was edited: %q became:\n%s", prose, out)
}
}
}
// A theme that does not know a name renders nothing, and the engine puts the author's text back rather than
// dropping a word out of the middle of a sentence.
func TestAnUnknownIconKeepsItsText(t *testing.T) {
got := body(t, wired(t, nil), "Before :nosuchicon: after.\n")
if !strings.Contains(got, ":nosuchicon:") {
t.Errorf("an unknown icon must keep its literal text:\n%s", got)
}
if !strings.Contains(got, "Before") || !strings.Contains(got, "after.") {
t.Errorf("the sentence around it must survive:\n%s", got)
}
}
func TestAnIconInCodeIsLiteral(t *testing.T) {
got := body(t, wired(t, nil), "Write `:warn:` to get one.\n")
if strings.Contains(got, "⚠️") {
t.Errorf("a code span is the author's literal text:\n%s", got)
}
}
+7 -1
View File
@@ -196,6 +196,9 @@ type node struct {
isContent bool
// headings are the document's, gathered for a `::toc` call (ADR-0065).
headings []render.Heading
// lang is captured at parse time, because a fragment localises its own words and a node renderer has no
// parse context to ask (ADR-0067).
lang string
}
func (n *node) Kind() ast.NodeKind { return kind }
@@ -215,6 +218,9 @@ func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.
}
reader.Advance(seg.Len() - 1)
n := &node{name: name, args: args}
if origin, ok := render.OriginFrom(pc); ok {
n.lang = origin.Lang
}
switch name {
case "gallery":
// Reading the filesystem happens here, where the parse context says which bundle this is; the
@@ -302,7 +308,7 @@ func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering
return ast.WalkContinue, nil
}
out, err := f.partial(call.name, render.Fragment{
Args: call.args, Pictures: call.pictures, Headings: call.headings})
Args: call.args, Pictures: call.pictures, Headings: call.headings, Lang: call.lang})
if err != nil {
slog.Error("skipping shortcode", "name", call.name, "err", err)
return ast.WalkContinue, nil
+24 -189
View File
@@ -288,200 +288,35 @@ func TestASiteRedefinesOneFragment(t *testing.T) {
}
}
func TestIconsRenderThroughTheThemeAndNeverEatProse(t *testing.T) {
got := body(t, wired(t, nil), "Careful :warn: and :note: here.\n")
for _, want := range []string{"⚠️", "️"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// The colon is the commonest punctuation in technical prose. Each of these would be a silent edit to
// someone's sentence (ADR-0063).
for _, prose := range []string{
"Times 10:30:15 exactly.",
"Pairs key:value:pair here.",
"Note: this matters.",
"See https://x.example/ for more.",
"A ratio of 3:4:5.",
} {
out := body(t, wired(t, nil), prose+"\n")
if !strings.Contains(out, prose) {
t.Errorf("prose was edited: %q became:\n%s", prose, out)
}
}
}
// A theme that does not know a name renders nothing, and the engine puts the author's text back rather than
// dropping a word out of the middle of a sentence.
func TestAnUnknownIconKeepsItsText(t *testing.T) {
got := body(t, wired(t, nil), "Before :nosuchicon: after.\n")
if !strings.Contains(got, ":nosuchicon:") {
t.Errorf("an unknown icon must keep its literal text:\n%s", got)
}
if !strings.Contains(got, "Before") || !strings.Contains(got, "after.") {
t.Errorf("the sentence around it must survive:\n%s", got)
}
}
func TestAnIconInCodeIsLiteral(t *testing.T) {
got := body(t, wired(t, nil), "Write `:warn:` to get one.\n")
if strings.Contains(got, "⚠️") {
t.Errorf("a code span is the author's literal text:\n%s", got)
}
}
func TestAContainerWrapsItsRenderedBody(t *testing.T) {
got := body(t, wired(t, nil), ":::note{title=\"Read this\"}\nA body with *emphasis* and a [link](/posts/).\n\nTwo paragraphs.\n:::\n\nAfter.\n")
for _, want := range []string{
`<aside class="admonition note">`, `<p class="admonition-title">Read this</p>`,
"<em>emphasis</em>", `href="/posts/"`, "Two paragraphs.", "</aside>",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
if !strings.Contains(got, "After.") {
t.Errorf("the page continues after the fence:\n%s", got)
}
if strings.Contains(got, ":::") {
t.Errorf("the fences are syntax, not content:\n%s", got)
}
}
// A theme with no template for a kind renders nothing. Losing an author's paragraphs because of that would
// be far worse than an unstyled aside (ADR-0064).
func TestAnUnknownContainerKeepsItsBody(t *testing.T) {
got := body(t, wired(t, nil), ":::nosuchkind\nThis body must survive.\n:::\n")
if !strings.Contains(got, "This body must survive.") {
t.Errorf("the body must survive an unknown kind:\n%s", got)
}
if strings.Contains(got, "nosuchkind") {
t.Errorf("the kind is not content:\n%s", got)
}
}
func TestAContainerDoesNotSwallowTheRestOfThePage(t *testing.T) {
// An unclosed fence ends with the document rather than eating a later one.
got := body(t, wired(t, nil), ":::note\nInside.\n\nStill inside.\n")
if !strings.Contains(got, "Inside.") || !strings.Contains(got, "Still inside.") {
t.Errorf("an unclosed container keeps its content:\n%s", got)
}
}
func TestTheTableOfContentsListsHeadingsBelowTheCall(t *testing.T) {
got := body(t, wired(t, nil), "::toc\n\n## First heading\n\nProse.\n\n### Nested with *emphasis*\n\n## Second\n")
for _, want := range []string{
`<nav class="toc">`,
`<li class="toc-2"><a href="#first-heading">First heading</a></li>`,
`<li class="toc-3">`, `href="#second"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// A contents entry is a label: markup inside a heading would put a link inside a link.
if strings.Contains(got, "<a href=\"#nested-with-emphasis\">Nested with <em>") {
t.Errorf("an entry should carry the words, not the markup:\n%s", got)
}
if !strings.Contains(got, `<h2 id="first-heading">`) {
t.Errorf("the headings themselves still render with their anchors:\n%s", got)
}
}
func TestATableOfContentsWithNoHeadingsRendersNothing(t *testing.T) {
got := body(t, wired(t, nil), "::toc\n\nJust prose, no headings.\n")
if strings.Contains(got, "<nav class=\"toc\">") {
t.Errorf("an empty contents list is worse than none:\n%s", got)
}
if !strings.Contains(got, "Just prose") {
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) {
// A fragment supplies words of its own when the author gives none, and those words are the engine's, so they
// localise (ADR-0067). A Bengali page must not be told "Warning" in English.
func TestAFragmentLocalisesItsOwnWords(t *testing.T) {
src := "::toc\n\n## One\n\n:::warn\nNo title.\n:::\n\n:::details\nNo summary.\n:::\n"
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")},
"content/posts/p.en.md": {Data: []byte("---\ntitle: EN\n---\n" + src)},
"content/posts/p.bn.md": {Data: []byte("---\ntitle: BN\n---\n" + src)},
}
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)
for lang, want := range map[string][]string{
"en": {"Contents", "Warning", "Details"},
"bn": {"সূচিপত্র", "সতর্কতা", "বিস্তারিত"},
} {
bundles, err := content.Scan(fsys)
if err != nil {
t.Fatal(err)
}
}
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")},
site := content.NewSite(bundles)
b, served, ok := site.Lookup("posts/p", lang)
if !ok || served != lang {
t.Fatalf("no %s variant of posts/p", lang)
}
}
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)
out, err := wired(t, fsys).Bundle(b, served, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, phrase := range want {
if !strings.Contains(string(out), phrase) {
t.Errorf("the %s page is missing %q:\n%s", lang, phrase, out)
}
}
if n := strings.Count(got, `id="description"`); n != 1 {
t.Errorf("model %q repeated the bare id %d times:\n%s", model, n, got)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package shortcodes
import (
"strings"
"testing"
)
func TestTheTableOfContentsListsHeadingsBelowTheCall(t *testing.T) {
got := body(t, wired(t, nil), "::toc\n\n## First heading\n\nProse.\n\n### Nested with *emphasis*\n\n## Second\n")
for _, want := range []string{
`<nav class="toc"`,
`<li class="toc-2"><a href="#first-heading">First heading</a></li>`,
`<li class="toc-3">`, `href="#second"`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q:\n%s", want, got)
}
}
// A contents entry is a label: markup inside a heading would put a link inside a link.
if strings.Contains(got, "<a href=\"#nested-with-emphasis\">Nested with <em>") {
t.Errorf("an entry should carry the words, not the markup:\n%s", got)
}
if !strings.Contains(got, `<h2 id="first-heading">`) {
t.Errorf("the headings themselves still render with their anchors:\n%s", got)
}
}
func TestATableOfContentsWithNoHeadingsRendersNothing(t *testing.T) {
got := body(t, wired(t, nil), "::toc\n\nJust prose, no headings.\n")
if strings.Contains(got, "<nav class=\"toc\"") {
t.Errorf("an empty contents list is worse than none:\n%s", got)
}
if !strings.Contains(got, "Just prose") {
t.Errorf("the page must survive:\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)
}
}