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
+18
View File
@@ -1094,3 +1094,21 @@ under `merge` a fragment's Markdown is no longer contained: an unclosed code fen
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.
## ADR-0067 — A fragment receives the language, because it supplies words of its own
Date: 2026-08-01 · Status: accepted (extends the theme contract additively; `Fragment` gains `Lang` and
`Origin` carries it)
Decision: `Fragment.Lang` is the language being served, captured on each call at parse time. The reference
theme uses it to label an untitled admonition (`note`, `warn`, `tip`), an untitled panel (`details`) and a
contents list (`contents`), all from the engine's phrase table. Words the author wrote are still never
touched.
Why: three fragments added this session need a word the author did not write — a `:::warn` with no title gave
the reader nothing to say it was a warning, and the contents list had no label at all, which is an
accessibility gap as well as an untranslated one. `t` needs a language and `Fragment` had none, so those
words could only ever have been hardcoded English on a site that serves Bengali. Captured at parse time
rather than passed at render time because a node renderer never receives the parse context, which is the
same constraint that put pictures and headings on the node.
Consequence: cheap — five phrase keys, and a fragment can now say anything the chrome table can. Expensive —
every call node carries a language it mostly does not use, and a theme adding a *new* word of its own still
cannot add a phrase key, which waits for the settings cascade exactly as it did before.
Revisit if: a site wants to override a phrase, which is the cascade and not this.
+4 -4
View File
@@ -22,10 +22,10 @@ table owns.
| `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/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/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) |
| `internal/render/view.go` | the theme contract in Go, and now actually all of it: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Partial`, `Fragment` (with `Body`, `Headings` and `Lang` ADR-0064, ADR-0065, ADR-0067), `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), including the words a shortcode fragment supplies when the author gives none (ADR-0067) |
| `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, 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/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 (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/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) |
@@ -40,7 +40,7 @@ table owns.
| `cmd/khosra/main.go` | flags (including `-poll`, zero to stop watching), wiring, startup, the derivative pass, and the atomic swaps a change goes through — theme in the watcher's callback, index in `rebuilder`. `main` dispatches subcommands, `runServe` assembles the server, `rebuilder` is used at startup and on every change alike |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature |
| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end |
| `*_test.go` | table-driven, one file per source file`shortcodes` has one each for icons, containers and the contents list; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding, extras, change detection, what a page can reach, the root listing, and the example site end to end |
Serves a listing of everything at `/` (ADR-0050), a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
+85 -85
View File
@@ -175,38 +175,38 @@ doc.go 8 · scaffold.go 94
- scaffold.go:76 func titleFrom(key string) string
- scaffold.go:85 func mkdirAll(root *os.Root, dir string) error
## internal/ext/shortcodes — 1019 lines + 639 test
## internal/ext/shortcodes — 1034 lines + 691 test
containers.go 168 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go 374 · toc.go 95
containers.go 173 · doc.go 7 · icons.go 129 · images.go 250 · shortcodes.go 380 · toc.go 95
- containers.go:22 var containerKind = ast.NewNodeKind("ShortcodeContainer")
- containers.go:24 type container struct
- containers.go:33 func (n *container) Kind() ast.NodeKind { return containerKind }
- containers.go:35 func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- containers.go:38 type containers struct{}
- containers.go:40 func (containers) Trigger() []byte { return []byte{' '} }
- containers.go:42 func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- containers.go:56 func (containers) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State
- containers.go:65 func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
- containers.go:67 func (containers) CanInterruptParagraph() bool { return true }
- containers.go:69 func (containers) CanAcceptIndentedLine() bool { return false }
- containers.go:75 type bodies struct
- containers.go:79 func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- 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)
- containers.go:34 func (n *container) Kind() ast.NodeKind { return containerKind }
- containers.go:36 func (n *container) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- containers.go:39 type containers struct{}
- containers.go:41 func (containers) Trigger() []byte { return []byte{' '} }
- containers.go:43 func (containers) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- containers.go:61 func (containers) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State
- containers.go:70 func (containers) Close(node ast.Node, reader text.Reader, pc parser.Context) {}
- containers.go:72 func (containers) CanInterruptParagraph() bool { return true }
- containers.go:74 func (containers) CanAcceptIndentedLine() bool { return false }
- containers.go:80 type bodies struct
- containers.go:84 func (b bodies) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- containers.go:108 func (f fragments) renderContainer(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- containers.go:136 func Merge(src []byte, origin render.Origin) []byte
- containers.go:165 func included(origin render.Origin, name string) ([]byte, error)
- icons.go:19 const iconFragment = "icon"
- icons.go:27 type icons struct{}
- icons.go:29 func (icons) Trigger() []byte { return []byte{' '} }
- icons.go:31 func (icons) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node
- icons.go:49 var iconKind = ast.NewNodeKind("ShortcodeIcon")
- icons.go:51 type iconNode struct
- icons.go:57 func (n *iconNode) Kind() ast.NodeKind { return iconKind }
- icons.go:59 func (n *iconNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- icons.go:63 func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- icons.go:83 func iconName(line []byte) (string, int, bool)
- icons.go:113 func isNameByte(c byte, first bool) bool
- icons.go:125 func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }
- icons.go:52 var iconKind = ast.NewNodeKind("ShortcodeIcon")
- icons.go:54 type iconNode struct
- icons.go:61 func (n *iconNode) Kind() ast.NodeKind { return iconKind }
- icons.go:63 func (n *iconNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- icons.go:67 func (f fragments) renderIcon(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- icons.go:87 func iconName(line []byte) (string, int, bool)
- icons.go:117 func isNameByte(c byte, first bool) bool
- icons.go:129 func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }
- images.go:31 var widths = []int{480, 960, 1440}
- images.go:38 func Derive(siteFS fs.FS, cacheDir string) (int, error)
- images.go:76 func derive(data []byte, name, cacheDir string) (int, error)
@@ -233,21 +233,21 @@ containers.go 168 · doc.go 7 · icons.go 125 · images.go 250 · shortcodes.go
- shortcodes.go:164 func pending(doc *ast.Document) []*node
- shortcodes.go:182 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:185 type node struct
- shortcodes.go:201 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:203 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:206 type blocks struct{}
- shortcodes.go:208 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:210 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:241 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:268 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:272 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:274 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:276 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:279 type fragments struct
- shortcodes.go:283 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:293 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:321 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:356 func argument(s string) (key, value, rest string, ok bool)
- shortcodes.go:204 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:206 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:209 type blocks struct{}
- shortcodes.go:211 func (blocks) Trigger() []byte { return []byte{' '} }
- shortcodes.go:213 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:247 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:274 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:278 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:280 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:282 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:285 type fragments struct
- shortcodes.go:289 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:299 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:327 func parse(line, prefix string) (name string, args map[string]string, ok bool)
- shortcodes.go:362 func argument(s string) (key, value, rest string, ok bool)
- toc.go:16 const tocName = "toc"
- toc.go:23 type tables struct{}
- toc.go:25 func (tables) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
@@ -266,59 +266,59 @@ doc.go 8 · watch.go 125
- watch.go:90 func record(sum hash.Hash, p string, d fs.DirEntry, err error) error
- watch.go:113 func dropping(name string) bool
## internal/render — 739 lines + 489 test
## internal/render — 749 lines + 489 test
chrome.go 110 · render.go 499 · view.go 130
chrome.go 115 · render.go 449 · view.go 185
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
- chrome.go:41 var digits = map[string][]rune{
- chrome.go:47 var funcs = template.FuncMap{"t" text, "num" numerals, "day" day}
- chrome.go:53 func text(lang, key string, args ...string) string
- chrome.go:73 func numerals(lang string, n int) string
- chrome.go:82 func day(lang string, t time.Time) string
- chrome.go:98 func localiseDigits(lang, s string) string
- chrome.go:38 var months = map[string][]string{
- chrome.go:46 var digits = map[string][]rune{
- chrome.go:52 var funcs = template.FuncMap{"t" text, "num" numerals, "day" day}
- chrome.go:58 func text(lang, key string, args ...string) string
- chrome.go:78 func numerals(lang string, n int) string
- chrome.go:87 func day(lang string, t time.Time) string
- chrome.go:103 func localiseDigits(lang, s string) string
- render.go:26 var themeFS embed.FS
- render.go:30 type Renderer struct
- render.go:52 type parsedTheme struct
- render.go:66 type Partial func(name string, data Fragment) ([]byte, error)
- render.go:69 type Fragment struct
- render.go:83 type Heading struct
- render.go:89 type Picture struct
- render.go:106 type Origin struct
- render.go:115 var originKey = parser.NewContextKey()
- render.go:118 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:125 func WithOrigin(pc parser.Context, origin Origin)
- render.go:138 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:169 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:197 func (r *Renderer) head(title, lang, canonical string) head
- render.go:214 func (r *Renderer) absolute(path string) string
- render.go:222 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:228 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
- render.go:240 func (r *Renderer) Refresh() error
- render.go:251 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:270 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:292 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:310 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:331 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:349 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:393 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:412 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:439 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:466 func (r *Renderer) item(b content.Bundle, lang string) Item
- 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:36 type Page struct
- view.go:55 type Sequence struct
- view.go:71 type Extras struct
- view.go:82 type Selected struct
- view.go:91 type List struct
- view.go:105 type Group struct
- view.go:111 type Item struct
- view.go:122 type Alternate struct
- render.go:65 var originKey = parser.NewContextKey()
- render.go:68 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:75 func WithOrigin(pc parser.Context, origin Origin)
- render.go:88 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:119 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:147 func (r *Renderer) head(title, lang, canonical string) head
- render.go:164 func (r *Renderer) absolute(path string) string
- render.go:172 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:178 func (r *Renderer) Compose(rewrite func(src []byte, origin Origin) []byte) { r.compose = rewrite }
- render.go:190 func (r *Renderer) Refresh() error
- render.go:201 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:220 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:242 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:260 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:281 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:299 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:343 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:362 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:389 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:416 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:421 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:443 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- view.go:17 type head struct
- view.go:37 type Page struct
- view.go:56 type Sequence struct
- view.go:72 type Extras struct
- view.go:83 type Selected struct
- view.go:92 type List struct
- view.go:106 type Group struct
- view.go:112 type Item struct
- view.go:123 type Alternate struct
- view.go:135 type Partial func(name string, data Fragment) ([]byte, error)
- view.go:138 type Fragment struct
- view.go:154 type Heading struct
- view.go:160 type Picture struct
- view.go:177 type Origin struct
## internal/web — 734 lines + 1625 test
## internal/web — 734 lines + 1627 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+7 -1
View File
@@ -67,7 +67,7 @@ hardcodes English: the words the engine supplies are the engine's to localise (A
| `{{day .Lang .Date}}` | a date as that language reads it — `8 March 2026`, `৮ মার্চ ২০২৬`; empty for a zero date |
Phrase keys today: `newer`, `older`, `empty`, `page-of` (two arguments), `position` (two arguments), `first`,
`last`, `extras`, `back-to-page`. An
`last`, `extras`, `back-to-page`, `contents`, `note`, `warn`, `tip`, `details`. An
unknown language falls back to the default locale and an unknown key returns itself, so a missing
translation can never blank a page or fail a render.
@@ -90,6 +90,7 @@ Every fragment receives the same two fields (ADR-0037):
|---|---|
| `.Args` | the call's `key=value` pairs, exactly as the author wrote them |
| `.Pictures` | images the *engine* gathered: one for a figure, many for a gallery, none when the call names nothing it recognises (ADR-0042) |
| `.Lang` | the language being served, so a fragment can localise words of its own through `t` (ADR-0067) |
| `.Body` | a container call's content, already rendered to HTML. Empty for every leaf call (ADR-0064) |
| `.Headings` | the document's headings for a `::toc` call: `.Level`, `.Text`, `.ID`. Empty for every other call (ADR-0065) |
@@ -111,6 +112,11 @@ Each picture carries:
| `:::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 |
**A fragment supplying its own words must localise them.** `.Lang` is there for exactly that: the reference
theme labels an untitled admonition with `{{t .Lang "warn"}}` and a contents list with `{{t .Lang "contents"}}`,
so a Bengali page reads সতর্কতা and সূচিপত্র rather than English (ADR-0067). Words the *author* wrote —
`.Args.title`, `.Body` — are never touched.
A **container** call wraps content: `:::name{…}`, a body, then `:::`. It renders through the fragment of that
name with `.Body` already HTML, and one level only — a `:::` inside closes the one it is in. A theme with no
template for the kind renders nothing, and the engine writes the body out unwrapped, so an unknown kind costs
+7
View File
@@ -1,7 +1,14 @@
{{define "main" -}}
<h1>{{.Title}}</h1>
{{/* A site's own words are its own to translate: `t` reaches the engine's phrase table, not this file's
sentences, so a template needing its own text branches on the language it was given. */}}
{{- if eq .Lang "bn"}}
<p><em>এই তালিকাটি সাইটের নিজের টেমপ্লেট থেকে তৈরি, যা এমবেড করা টেমপ্লেটের পরে পড়া হয়। এটি একটি নামযুক্ত ব্লক
বদলে দেয় এবং বাকি নথিটি উত্তরাধিকারসূত্রে পায়।</em></p>
{{- else}}
<p><em>This listing is rendered by the site's own template, parsed after the embedded one. It redefines a
single named block and inherits the whole document around it.</em></p>
{{- end}}
{{- if .Items}}
{{- range .Items}}
<article class="entry">
+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)
}
}
+5
View File
@@ -26,6 +26,11 @@ var chrome = map[string]map[string]string{
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
"everything": {"en": "Everything", "bn": "সবকিছু"},
"first": {"en": "First", "bn": "প্রথম"},
"contents": {"en": "Contents", "bn": "সূচিপত্র"},
"note": {"en": "Note", "bn": "দ্রষ্টব্য"},
"warn": {"en": "Warning", "bn": "সতর্কতা"},
"tip": {"en": "Tip", "bn": "পরামর্শ"},
"details": {"en": "Details", "bn": "বিস্তারিত"},
"last": {"en": "Last", "bn": "শেষ"},
}
+1 -51
View File
@@ -61,56 +61,6 @@ type parsedTheme struct {
style template.CSS
}
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
// because markup belongs to the theme and a feature must not write any (ADR-0036).
type Partial func(name string, data Fragment) ([]byte, error)
// Fragment is what a fragment template receives (ADR-0037, widened by ADR-0042).
type Fragment struct {
// Args are the call's key="value" pairs, exactly as written. Escaping is the template's.
Args map[string]string
// Pictures are what the engine gathered rather than the author wrote: one for a figure, many for a
// gallery, none when the call names nothing a picture. Kept apart from Args so a supplied value can never
// be mistaken for an authored one.
Pictures []Picture
// Body is a container call's content, already rendered. Empty for a leaf call (ADR-0064).
Body template.HTML
// Headings are the document's headings, for a call that builds a table of contents (ADR-0065).
Headings []Heading
}
// Heading is one heading in the document, with the id an anchor links to (ADR-0065).
type Heading struct {
Level int
Text, ID string
}
// Picture is one image a fragment can render (ADR-0042).
type Picture struct {
// Src is the author's own file, relative to the bundle. A browser that ignores Srcset still gets the
// picture that was put there.
Src string
// Srcset offers the derivatives, closed by the original at its own width; empty when the picture is
// already small enough that no derivative was worth making.
Srcset string
// Width and Height are the original's intrinsic size, so a page can reserve the box before the bytes
// arrive. Zero when the file could not be read.
Width, Height int
}
// Origin tells a feature which bundle is being rendered, so a path in a call can resolve relative to it.
//
// Features read it from the parser context with OriginFrom. It carries the site's fs.FS rather than a
// directory name alone, because every read goes through the rooted filesystem and never a joined path
// (ADR-0031).
type Origin struct {
// Dir is the bundle's directory, relative to the site root: "content/comics/the-long-monsoon".
Dir string
// Files is the site root. Nil when the renderer was built without one, in which case a feature that
// needs files degrades rather than guessing.
Files fs.FS
}
// originKey identifies the Origin in a parse. Unexported, so the typed accessor is the only way in.
var originKey = parser.NewContextKey()
@@ -350,7 +300,7 @@ 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()
origin := Origin{Dir: path.Dir(b.Path), Files: r.files}
origin := Origin{Dir: path.Dir(b.Path), Files: r.files, Lang: served}
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).
+9 -5
View File
@@ -22,6 +22,8 @@
{{- end}}
{{- end}}
{{/* A kind with no title of its own is labelled by the engine's own words, which are localised: the reader
of a Bengali page should not be told "Warning" in English (ADR-0067). */}}
{{/* One template for the whole set: which names exist is the theme's business, never the engine's
(ADR-0063). Unicode here, so the reference theme ships no sprite, no font and no asset — a theme
wanting drawn icons redefines this block and emits <use> against its own sprite. An unknown name
@@ -40,15 +42,17 @@
markup is the theme's and the engine supplies only the name, the arguments and the rendered body
(ADR-0064). `.Body` is already HTML. A theme with no template for a kind renders nothing, and the
engine writes the body out unwrapped rather than losing it. */}}
{{define "note"}}<aside class="admonition note">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
{{define "warn"}}<aside class="admonition warn">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
{{define "tip"}}<aside class="admonition tip">{{with .Args.title}}<p class="admonition-title">{{.}}</p>{{end}}{{.Body}}</aside>{{end}}
{{define "note"}}<aside class="admonition note"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "note"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{define "warn"}}<aside class="admonition warn"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "warn"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{define "tip"}}<aside class="admonition tip"><p class="admonition-title">{{with .Args.title}}{{.}}{{else}}{{t $.Lang "tip"}}{{end}}</p>{{.Body}}</aside>{{end}}
{{/* A flat list with a level class per entry, so nesting is a CSS decision rather than a markup one: the
engine supplies depth and the theme decides whether to indent (ADR-0065). An entry with no id is
skipped rather than linked nowhere. */}}
{{define "toc"}}
{{- if .Headings}}<nav class="toc"><ol>
{{- if .Headings}}<nav class="toc" aria-label="{{t .Lang "contents"}}">
<p class="toc-title">{{t .Lang "contents"}}</p>
<ol>
{{- range .Headings}}{{if .ID}}
<li class="toc-{{.Level}}"><a href="#{{.ID}}">{{.Text}}</a></li>
{{- end}}{{end}}
@@ -60,7 +64,7 @@
`group` become mutually exclusive through the native `name` attribute, which is what tabs are. A
browser too old for grouping simply opens them independently — the content is never hidden. */}}
{{define "details"}}<details{{with .Args.group}} name="{{.}}"{{end}}{{if .Args.open}} open{{end}}>
<summary>{{with .Args.summary}}{{.}}{{else}}{{.Args.title}}{{end}}</summary>
<summary>{{with .Args.summary}}{{.}}{{else}}{{with $.Args.title}}{{.}}{{else}}{{t $.Lang "details"}}{{end}}{{end}}</summary>
{{.Body}}</details>{{end}}
{{/* A margin note: beside the text where there is room, in the flow where there is not. CSS only. */}}
+1
View File
@@ -32,6 +32,7 @@ aside.side { border-left: 3px solid #d8d5cd; border-radius: 0; padding: 0 0 0 1r
mark { background: #fbf1a9; color: #16161a; padding: 0 0.15em; }
nav.toc { border-top: 1px solid #d8d5cd; border-bottom: 1px solid #d8d5cd; padding: 0.5rem 0; margin: 1.5rem 0; font-size: 0.95em; }
nav.toc ol { list-style: none; padding-left: 0; margin: 0; }
.toc-title { font-weight: 600; margin: 0 0 0.35rem; }
nav.toc .toc-3 { padding-left: 1rem; }
nav.toc .toc-4 { padding-left: 2rem; }
@media (prefers-color-scheme: dark) {
+55
View File
@@ -6,6 +6,7 @@ package render
import (
"html/template"
"io/fs"
"time"
"khosra/internal/content"
@@ -128,3 +129,57 @@ type Alternate struct {
// rendered page: using URL sent a reader from a local server to the canonical host (ADR-0049).
Path string
}
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
// because markup belongs to the theme and a feature must not write any (ADR-0036).
type Partial func(name string, data Fragment) ([]byte, error)
// Fragment is what a fragment template receives (ADR-0037, widened by ADR-0042).
type Fragment struct {
// Args are the call's key="value" pairs, exactly as written. Escaping is the template's.
Args map[string]string
// Pictures are what the engine gathered rather than the author wrote: one for a figure, many for a
// gallery, none when the call names nothing a picture. Kept apart from Args so a supplied value can never
// be mistaken for an authored one.
Pictures []Picture
// Lang is the language being served, so a fragment can localise its own words through `t` (ADR-0067).
Lang string
// Body is a container call's content, already rendered. Empty for a leaf call (ADR-0064).
Body template.HTML
// Headings are the document's headings, for a call that builds a table of contents (ADR-0065).
Headings []Heading
}
// Heading is one heading in the document, with the id an anchor links to (ADR-0065).
type Heading struct {
Level int
Text, ID string
}
// Picture is one image a fragment can render (ADR-0042).
type Picture struct {
// Src is the author's own file, relative to the bundle. A browser that ignores Srcset still gets the
// picture that was put there.
Src string
// Srcset offers the derivatives, closed by the original at its own width; empty when the picture is
// already small enough that no derivative was worth making.
Srcset string
// Width and Height are the original's intrinsic size, so a page can reserve the box before the bytes
// arrive. Zero when the file could not be read.
Width, Height int
}
// Origin tells a feature which bundle is being rendered, so a path in a call can resolve relative to it.
//
// Features read it from the parser context with OriginFrom. It carries the site's fs.FS rather than a
// directory name alone, because every read goes through the rooted filesystem and never a joined path
// (ADR-0031).
type Origin struct {
// Dir is the bundle's directory, relative to the site root: "content/comics/the-long-monsoon".
Dir string
// Files is the site root. Nil when the renderer was built without one, in which case a feature that
// needs files degrades rather than guessing.
Files fs.FS
// Lang is the language of the variant being rendered, so a feature can hand it to a fragment (ADR-0067).
Lang string
}
+3 -1
View File
@@ -102,6 +102,8 @@ var exampleFeatures = []featureCase{
expect: []string{`href="/bn/posts/first-light/" hreflang="bn"`, `hreflang="bn" href="http://localhost:8080/bn/`}},
{what: "a Bengali variant localises chrome", path: "/bn/posts/first-light/", code: 200,
expect: []string{`lang="bn"`, "প্রথম আলো"}},
{what: "a site's own template supplies its own words in each language", path: "/bn/posts/", code: 200,
expect: []string{"সাইটের নিজের টেমপ্লেট", "পুরোনো"}, absent: []string{"rendered by the site's own template"}},
{what: "a missing variant falls back and says which it served", path: "/bn/posts/only-english/", code: 200,
expect: []string{"Only in English", `rel="canonical" href="http://localhost:8080/posts/only-english/"`}},
{what: "a bundle that exists only in Bengali is still served", path: "/pages/bengali-only/", code: 200,
@@ -143,7 +145,7 @@ 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="#gauge-readings">Readings</a>`, `class="toc-2"`}},
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,