diff --git a/docs/decisions.md b/docs/decisions.md index f63c6f2..97cbc11 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/state.md b/docs/state.md index fe3fb43..afe0d32 100644 --- a/docs/state.md +++ b/docs/state.md @@ -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 diff --git a/docs/surface.md b/docs/surface.md index 4085ada..af3c071 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -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 diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 7f7f5df..922f97b 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -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 `
` 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 diff --git a/examples/demo-site/templates/list.html b/examples/demo-site/templates/list.html index d73cd8a..f8e7657 100644 --- a/examples/demo-site/templates/list.html +++ b/examples/demo-site/templates/list.html @@ -1,7 +1,14 @@ {{define "main" -}}

{{.Title}}

+{{/* 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"}} +

এই তালিকাটি সাইটের নিজের টেমপ্লেট থেকে তৈরি, যা এমবেড করা টেমপ্লেটের পরে পড়া হয়। এটি একটি নামযুক্ত ব্লক +বদলে দেয় এবং বাকি নথিটি উত্তরাধিকারসূত্রে পায়।

+{{- else}}

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.

+{{- end}} {{- if .Items}} {{- range .Items}}
diff --git a/internal/ext/shortcodes/containers.go b/internal/ext/shortcodes/containers.go index 04bd344..7d27bad 100644 --- a/internal/ext/shortcodes/containers.go +++ b/internal/ext/shortcodes/containers.go @@ -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 diff --git a/internal/ext/shortcodes/containers_test.go b/internal/ext/shortcodes/containers_test.go new file mode 100644 index 0000000..2aab6d9 --- /dev/null +++ b/internal/ext/shortcodes/containers_test.go @@ -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{ + `", + } { + 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")}, + } +} diff --git a/internal/ext/shortcodes/icons.go b/internal/ext/shortcodes/icons.go index 21d2635..66f219c 100644 --- a/internal/ext/shortcodes/icons.go +++ b/internal/ext/shortcodes/icons.go @@ -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 diff --git a/internal/ext/shortcodes/icons_test.go b/internal/ext/shortcodes/icons_test.go new file mode 100644 index 0000000..965f76b --- /dev/null +++ b/internal/ext/shortcodes/icons_test.go @@ -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) + } +} diff --git a/internal/ext/shortcodes/shortcodes.go b/internal/ext/shortcodes/shortcodes.go index b570b97..2956e35 100644 --- a/internal/ext/shortcodes/shortcodes.go +++ b/internal/ext/shortcodes/shortcodes.go @@ -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 diff --git a/internal/ext/shortcodes/shortcodes_test.go b/internal/ext/shortcodes/shortcodes_test.go index 96898f1..039afa8 100644 --- a/internal/ext/shortcodes/shortcodes_test.go +++ b/internal/ext/shortcodes/shortcodes_test.go @@ -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{ - `", - } { - 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{ - `
{{end}} {{/* A margin note: beside the text where there is room, in the flow where there is not. CSS only. */}} diff --git a/internal/render/templates/theme.css b/internal/render/templates/theme.css index 02f9488..4e2b651 100644 --- a/internal/render/templates/theme.css +++ b/internal/render/templates/theme.css @@ -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) { diff --git a/internal/render/view.go b/internal/render/view.go index c8f5a75..9c5c1f5 100644 --- a/internal/render/view.go +++ b/internal/render/view.go @@ -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 +} diff --git a/internal/web/example_test.go b/internal/web/example_test.go index 087167f..a75c894 100644 --- a/internal/web/example_test.go +++ b/internal/web/example_test.go @@ -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{`