settle the Markdown dialect, and namespace an include's footnotes

Tables, footnotes, definition lists, strikethrough and automatic heading ids.
Which dialect a site is written against is permanent, so ADR-0058 names the
whole set at once — including the four refused, each for a reason rather than a
taste: task lists publish nothing, linkify rewrites plain text into markup that
ADR-0034 forbids the engine to touch, CJK is the wrong script family for a
Bengali site, and the GFM bundle is a package deal for the first two.

Footnotes collided with includes, as the queue predicted but worse. An include
converts its file on its own bytes (ADR-0038), so goldmark numbered its notes
from one again and the page carried two id="fn:1"s — the parent's reference
jumped to the fragment's note. shortcodes.FootnotePrefix stamps the file name on
the nested document and hands it to goldmark's id-prefix function, so the
fragment gets _method-fn:1 and the page keeps fn:1.

Two things nothing tested before. The extender list ships from cmd/khosra, which
no package can import, so the dialect had never been rendered through the list
the binary actually uses — cmd/khosra/wire_test.go now does exactly that,
including that the typographer no longer eats a table's delimiter row. And the
demo carries the dialect and the footnote namespacing as cases, which caught
auto heading ids changing markup in three existing assertions.

The reference theme gains five lines: a rule under each table row, an indent for
definitions, smaller footnotes. core 2790/2800, ext 1058/2000, 34 gates green.
This commit is contained in:
Claude Opus 5
2026-08-01 20:38:59 +06:00
committed by bdeshi
parent 7f9ac3c412
commit 0465785e81
15 changed files with 266 additions and 60 deletions
+7
View File
@@ -2,6 +2,7 @@ package main
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/ext/shortcodes"
"khosra/internal/render"
@@ -13,7 +14,13 @@ import (
// It lives in cmd because nothing below it may know which features exist: internal/render, internal/web
// and internal/content must all build and serve with this list empty (conventions.md layering).
func extenders(partial render.Partial) []goldmark.Extender {
// Dialect before features: these four say what Markdown *means* here (ADR-0058), and shortcodes is
// khosra's own. Every one is parse-phase, so none of them moves the render-transform counter.
return []goldmark.Extender{
extension.Table,
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
extension.DefinitionList,
extension.Strikethrough,
shortcodes.New(partial),
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"strings"
"testing"
"khosra/internal/content"
"khosra/internal/render"
)
// The dialect is enabled in wire.go and nowhere else, so this is the only place it can be tested against the
// list the binary actually ships (ADR-0058). A test that rebuilt the list would be testing its own copy.
func TestTheDialectRendersWhatItPromises(t *testing.T) {
r, err := render.New(nil, content.Settings{}, extenders)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("posts/dialect.md", []byte("---\ntitle: Dialect\n---\n\n"+
"| Body | Spin |\n|------|------|\n| Rama | 0.6g |\n\n"+
"A claim[^1].\n\n[^1]: The evidence.\n\n"+
"Term\n: Definition\n\n"+
"~~struck~~ text.\n\n"+
"## Spin Gravity\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
got := string(out)
for _, want := range []string{
"<table>", "<th>Body</th>", "<td>0.6g</td>",
`id="fn:1"`, `class="footnote-ref"`,
"<dl>", "<dt>Term</dt>", "<dd>Definition</dd>",
"<del>struck</del>",
`<h2 id="spin-gravity">`,
} {
if !strings.Contains(got, want) {
t.Errorf("the dialect is missing %q\n%s", want, got)
}
}
}
// Typography must not reach a table's delimiter row: before Table was enabled the row stayed prose, and the
// typographer turned `|---|` into `|&mdash;|`. The parser has to claim the block first (ADR-0058).
func TestTheTypographerLeavesATableAlone(t *testing.T) {
r, err := render.New(nil, content.Settings{}, extenders)
if err != nil {
t.Fatal(err)
}
b, err := content.Parse("posts/t.md", []byte("---\ntitle: T\n---\n\n| a |\n|---|\n| 1 |\n"))
if err != nil {
t.Fatal(err)
}
out, err := r.Bundle(b, "en", []string{"en"}, nil)
if err != nil {
t.Fatal(err)
}
if got := string(out); strings.Contains(got, "&mdash;") {
t.Errorf("the delimiter row was smartened instead of parsed:\n%s", got)
}
}
+23
View File
@@ -306,6 +306,29 @@ edit lands promptly — unless `-poll` was passed, in which case the operator's
off by default and is not a bare boolean: revealing unpublished work should be impossible to enable by
fumbling an argument.
## The Markdown dialect
CommonMark, plus a named set of extensions and nothing else (ADR-0058). Every one is parse-phase, so none of
them is a render transform, and the list lives in `cmd/khosra/wire.go` where features are enabled.
| Enabled | Syntax |
|---|---|
| Tables | GFM pipe tables |
| Footnotes | `text[^1]`, then `[^1]: the note` |
| Definition lists | a term, then `: definition` on the next line |
| Strikethrough | `~~struck~~` |
| Heading ids | automatic, from the heading's text — the anchor a table of contents needs |
Deliberately absent, so their absence is a decision rather than an oversight: **task lists** (a note-taking
affordance, not a publishing one), **linkify** (it rewrites an author's plain text into markup, which is the
line ADR-0034 draws), **CJK line breaking** (wrong script family — it does nothing for Bengali), and the
**GFM bundle**, which would drag the first two in with the tables it is wanted for.
**Footnotes inside an included file** get ids namespaced by that file — `_method-fn:1` rather than `fn:1`
because an include is converted on its own bytes (ADR-0038) and would otherwise number from one all over
again, leaving two `id="fn:1"`s on the page and a reference that jumps to the wrong note. A consequence
worth knowing: the fragment's notes are listed where the include sits, not with the page's.
## Typography and localisation
The line is drawn by who wrote the words (ADR-0034).
+22
View File
@@ -904,3 +904,25 @@ commit body, not a value in the file. The check is also weaker on a squashed or
files move in the same commit by construction.
Revisit if: someone wants the reconciliation *moment* recorded rather than the currency, which is a
different fact and belongs in the arc retro log.
## ADR-0058 — The Markdown dialect is CommonMark plus five, named once
Date: 2026-08-01 · Status: accepted
Decision: tables, footnotes, definition lists, strikethrough and automatic heading ids are enabled; task
lists, linkify, CJK line breaking and the GFM bundle are refused. The list lives in `cmd/khosra/wire.go`
beside the features, and `content-model.md` carries the authored form. Footnote ids inside an included file
are namespaced by that file, through `shortcodes.FootnotePrefix`.
Why: "which Markdown dialect" is permanent — content written against it cannot be un-written — so it is
settled in one decision rather than admitted an extension per feature. The five chosen serve what this
engine is for: footnotes carry citations in technical writing and asides in fiction, tables carry
specifications, definition lists carry glossaries, heading ids are the half of a table of contents only the
engine can supply. The four refused each fail a test rather than a taste: task lists publish nothing,
linkify rewrites an author's plain text into markup that ADR-0034 says the engine may not touch, CJK is the
wrong script family for a Bengali site, and the bundle is a package deal for two of them. All five are
parse-phase, composing in goldmark's extender list, so the render-transform counter stays where it is.
Consequence: cheap — five lines where features are enabled, and a test beside the list that renders the
whole dialect through the shipped `extenders`, which nothing tested before. Expensive — the demo's example
test rebuilds that list by hand, because a package cannot import a `main`, so the two can drift; the
reference theme now has to style markup it never emitted before; and enabling tables changes how existing
content renders, since a `|` line that used to come out as prose is now parsed.
Revisit if: a sixth extension is wanted — which is a change to this decision and gets an ADR of its own,
not a quiet line in the list.
+6 -4
View File
@@ -21,15 +21,15 @@ table owns.
| `internal/content/extras.go` | a bundle's supporting files: enumeration, classification, and their URLs (ADR-0047) |
| `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) |
| `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 (ADR-0058) |
| `internal/render/view.go` | the theme contract in Go: `Page`, `List`, `Sequence`, `Extras`, `Item`, `Fragment`, `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/templates/` | reference theme, complete: `base.html` (shell, navigation, language links, feed and OpenGraph), `page.html` (bundle, sequence, tags, extras), `list.html`, `extras.html`, `shortcodes.html`, `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) |
| `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) |
| `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) |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`), and the Markdown dialect with it — tables, footnotes, definition lists, strikethrough (ADR-0058) |
| `internal/web/resolve.go` | URL → (key, lang, page, tag, feed, extras) or a canonical redirect |
| `internal/web/extras.go` | the extras route: listing, one entry selected, or `?raw` bytes, all behind the bundle lookup |
| `internal/web/asset.go` | files inside a bundle's own directory, looked up through the owning bundle so visibility can only ever inherit (ADR-0024) |
@@ -46,7 +46,8 @@ key (ADR-0035) — a paginated listing per section, tag listings global and
section-narrowed, sequence navigation and a series archive on any nested bundle, `static/` verbatim, a directory bundle's own files under its
URL, generated derivatives under `/derived/`, Atom feeds per site,
section and tag, a bundle's extras as a browsable tree, plus `/robots.txt` and `/sitemap.xml`.
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
Markdown is CommonMark plus tables, footnotes, definition lists, strikethrough and heading ids, and nothing
else (ADR-0058). Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new`
scaffolds a draft bundle into one. A running server notices changes under `content/` and `templates/` by
@@ -112,6 +113,7 @@ with a stated reason. A list nothing drains is a graveyard of known defects.
| Sequence resolution rescans the index on every bundle request — two passes over every key, each doing a `Lookup` | Measured at the same time as the pictures (ADR-0044): a whole page is ~63µs, so this is not what costs anything. Remembering it would be a cache with no measurement behind it | A page render exceeding a few milliseconds, which is also what would revive the parked cache model |
| The root listing's `<title>` repeats itself — "A Khosra Demo · A Khosra Demo" | Spotted 2026-08-01 by looking at the served page, not by any test: `base.html` joins page title and site title unconditionally, and at the root those are the same string. Cosmetic, and the fix is one `if` in a template — theme layer, not engine | The first time the reference theme is worked on (Phase G4 touches it), or sooner if a feed or OpenGraph title inherits the same doubling |
| The theme and the index are two separate `atomic.Pointer` stores, so a request landing between them sees a new theme with the previous index | Accepted 2026-08-01 with ADR-0056: both halves are internally coherent and the gap is microseconds, so no page is ever internally inconsistent — it is simply not a snapshot of the disk. Closing it means one pointer holding both, which changes `web.Handler`'s signature and 20 test construction sites | Anything that makes the gap observable — a request rate high enough to land in it, or a feature where content and theme must agree exactly (an export, where every page is generated in one pass) |
| `internal/web/example_test.go` rebuilds the extender list by hand, so it can drift from `cmd/khosra/wire.go` | A package cannot import a `main`, and `extensions.md` puts the list in `cmd` on purpose — nothing below it may know which features exist. Bounded today: the dialect's own test lives in `cmd/khosra/wire_test.go`, beside the real list, and the demo test fails loudly when the copy lags | The next change to the extender list, which must touch both — or a third copy appearing, which is the point at which the list wants a home a test can import |
| The picture memo is never evicted — one entry per picture on the site, for the life of the process | Correct for one author's site, and the alternative is an eviction policy nothing needs. It is keyed on size and modification time, so it cannot go stale, only grow | A site root large enough that memory matters, or a long-running process where pictures churn |
## Open questions
+54 -51
View File
@@ -6,9 +6,9 @@ Every top-level declaration in the engine, with its line. Read this before openi
file: it answers "where does X live" and "what is in this package" without the bodies. What each
file is *for* lives in `state.md`; why it is that way lives in `decisions.md`.
## cmd/khosra — 297 lines
## cmd/khosra — 304 lines + 63 test
check.go 45 · main.go 191 · new.go 42 · wire.go 19
check.go 45 · main.go 191 · new.go 42 · wire.go 26
- check.go:16 func runCheck(args []string)
- main.go:24 func main()
@@ -20,7 +20,7 @@ check.go 45 · main.go 191 · new.go 42 · wire.go 19
- main.go:174 func defaultCache() string
- main.go:184 func fatal(msg string, err error)
- new.go:12 func runNew(args []string)
- wire.go:15 func extenders(partial render.Partial) []goldmark.Extender
- wire.go:16 func extenders(partial render.Partial) []goldmark.Extender
## internal/content — 1042 lines + 537 test
@@ -122,9 +122,9 @@ 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 — 569 lines + 417 test
## internal/ext/shortcodes — 600 lines + 417 test
doc.go 7 · images.go 250 · shortcodes.go 312
doc.go 7 · images.go 250 · shortcodes.go 343
- images.go:31 var widths = []int{480, 960, 1440}
- images.go:38 func Derive(siteFS fs.FS, cacheDir string) (int, error)
@@ -138,32 +138,35 @@ doc.go 7 · images.go 250 · shortcodes.go 312
- images.go:224 func lossless(source string) bool
- images.go:234 func showable(name string) bool
- images.go:244 func derivable(name string) bool
- shortcodes.go:24 const
- shortcodes.go:33 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:37 type extension struct
- shortcodes.go:46 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:56 var nested = parser.NewContextKey()
- shortcodes.go:64 type includes struct
- shortcodes.go:68 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:88 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:118 func pending(doc *ast.Document) []*node
- shortcodes.go:136 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:139 type node struct
- shortcodes.go:153 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:155 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:158 type blocks struct{}
- shortcodes.go:160 func (blocks) Trigger() []byte { return []byte{'{'} }
- shortcodes.go:162 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:193 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:220 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:224 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:226 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:228 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:231 type fragments struct
- shortcodes.go:235 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:243 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:270 func parse(line string) (name string, args map[string]string, ok bool)
- shortcodes.go:297 func argument(s string) (key, value, rest string, ok bool)
- shortcodes.go:25 const
- shortcodes.go:34 func New(partial render.Partial) goldmark.Extender
- shortcodes.go:38 type extension struct
- shortcodes.go:47 func (e extension) Extend(md goldmark.Markdown)
- shortcodes.go:57 var nested = parser.NewContextKey()
- shortcodes.go:62 var includedAs = parser.NewContextKey()
- shortcodes.go:64 const footnoteKey = "khosra footnote-prefix"
- shortcodes.go:72 func FootnotePrefix(n ast.Node) []byte
- shortcodes.go:90 type includes struct
- shortcodes.go:94 func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context)
- shortcodes.go:118 func (in includes) convert(name string, pc parser.Context) ([]byte, error)
- shortcodes.go:149 func pending(doc *ast.Document) []*node
- shortcodes.go:167 var kind = ast.NewNodeKind("Shortcode")
- shortcodes.go:170 type node struct
- shortcodes.go:184 func (n *node) Kind() ast.NodeKind { return kind }
- shortcodes.go:186 func (n *node) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
- shortcodes.go:189 type blocks struct{}
- shortcodes.go:191 func (blocks) Trigger() []byte { return []byte{'{'} }
- shortcodes.go:193 func (blocks) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State)
- shortcodes.go:224 func gallery(pc parser.Context) []render.Picture
- shortcodes.go:251 func (blocks) Continue(n ast.Node, reader text.Reader, pc parser.Context) parser.State
- shortcodes.go:255 func (blocks) Close(n ast.Node, reader text.Reader, pc parser.Context) {}
- shortcodes.go:257 func (blocks) CanInterruptParagraph() bool { return true }
- shortcodes.go:259 func (blocks) CanAcceptIndentedLine() bool { return false }
- shortcodes.go:262 type fragments struct
- shortcodes.go:266 func (f fragments) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer)
- shortcodes.go:274 func (f fragments) render(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error)
- shortcodes.go:301 func parse(line string) (name string, args map[string]string, ok bool)
- shortcodes.go:328 func argument(s string) (key, value, rest string, ok bool)
## internal/ext/watch — 133 lines + 114 test
@@ -175,9 +178,9 @@ 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 — 707 lines + 461 test
## internal/render — 710 lines + 461 test
chrome.go 110 · render.go 467 · view.go 130
chrome.go 110 · render.go 470 · view.go 130
- chrome.go:19 var chrome = map[string]map[string]string{
- chrome.go:33 var months = map[string][]string{
@@ -198,23 +201,23 @@ chrome.go 110 · render.go 467 · view.go 130
- render.go:104 func OriginFrom(pc parser.Context) (Origin, bool)
- render.go:111 func WithOrigin(pc parser.Context, origin Origin)
- render.go:124 func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error)
- render.go:150 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:178 func (r *Renderer) head(title, lang, canonical string) head
- render.go:195 func (r *Renderer) absolute(path string) string
- render.go:203 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:215 func (r *Renderer) Refresh() error
- render.go:226 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:245 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:267 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:285 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:306 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:324 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:361 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:380 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:407 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:434 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:439 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:461 func (r *Renderer) execute(set *template.Template, data any, what string) ([]byte, error)
- render.go:153 func parseTheme(siteFS fs.FS) (*parsedTheme, error)
- render.go:181 func (r *Renderer) head(title, lang, canonical string) head
- render.go:198 func (r *Renderer) absolute(path string) string
- render.go:206 func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
- render.go:218 func (r *Renderer) Refresh() error
- render.go:229 func (r *Renderer) Partial(name string, data Fragment) ([]byte, error)
- render.go:248 func parseSet(siteFS fs.FS, names ...string) (*template.Template, error)
- render.go:270 func readStyle(siteFS fs.FS) (template.CSS, error)
- render.go:288 func (r *Renderer) Extras(b content.Bundle, served string, entries []content.Entry, selected *Selected) ([]byte, error)
- render.go:309 func (r *Renderer) RenderText(kind string, data []byte) (template.HTML, error)
- render.go:327 func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, seq *content.Sequence) ([]byte, error)
- render.go:364 func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:383 func (r *Renderer) Tag(section, slug, lang string, all []content.Bundle, page int) ([]byte, error)
- render.go:410 func (r *Renderer) sequence(seq *content.Sequence, lang string) *Sequence
- render.go:437 func (r *Renderer) item(b content.Bundle, lang string) Item
- render.go:442 func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle, page int, url func(int) string) (List, []content.Bundle)
- render.go:464 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
@@ -225,7 +228,7 @@ chrome.go 110 · render.go 467 · view.go 130
- view.go:111 type Item struct
- view.go:122 type Alternate struct
## internal/web — 734 lines + 1586 test
## internal/web — 734 lines + 1599 test
asset.go 58 · discover.go 71 · extras.go 93 · feed.go 125 · resolve.go 170 · web.go 217
+11
View File
@@ -148,6 +148,17 @@ text *lays out* is the theme's, done in CSS: the reference stylesheet sets `text
`text-wrap: balance` on headings, which is where widow and orphan control belongs (ADR-0045). The engine will
not insert characters into an author's prose to influence line breaking.
## Markup a body may now contain
`.HTML` is Markdown output, so the dialect decides which elements a theme has to be ready to style
(`content-model.md`). Beyond CommonMark's own, since ADR-0058: `<table>` with `<thead>`/`<tbody>`,
`<dl>`/`<dt>`/`<dd>`, `<del>`, and goldmark's footnote markup — `<sup id="fnref:N">` in the text and a
`<div class="footnotes">` carrying an `<ol>` of notes. Headings arrive with an `id`.
None of it is optional and none of it is configurable: a theme that styles none of these still renders a
correct page, which is the point of the contract. The reference stylesheet does the minimum — a rule under
each row, an indent for definitions, smaller footnotes — and nothing more (ADR-0026).
## The stability rule
Fields and names are **added, never renamed or removed**. Absence is always legal: a template reading a
@@ -2,3 +2,8 @@
Gauge readings, transcribed each evening. *Emphasis and links survive*, because an include is parsed as
Markdown rather than pasted as text.
A fragment may carry its own footnote[^gauge], and its ids are namespaced by the file it came from, so the
page around it keeps its own numbering.
[^gauge]: Read to the nearest millimetre.
@@ -11,3 +11,20 @@ That fragment starts with an underscore, so the scanner never treats it as a bun
and appears in no listing. An included file cannot itself include — one level, deliberately.
This bundle also has an `extras/` directory, so the theme offers a link to it at the foot of the page.
## The dialect
The page has a footnote of its own[^page], a table, a definition list and ~~a struck phrase~~.
| Gauge | Reading | Note |
|-------|---------|------|
| North | 41 mm | steady |
| South | 12 mm | falling |
Monsoon
: The season these readings belong to.
Gauge
: A calibrated vessel, read by eye.
[^page]: Numbered from one, independently of the fragment's.
+31
View File
@@ -16,6 +16,7 @@ import (
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"khosra/internal/content"
"khosra/internal/render"
)
@@ -55,6 +56,31 @@ func (e extension) Extend(md goldmark.Markdown) {
// nested marks a parse that is already inside an included file, so one level is all there is (ADR-0038).
var nested = parser.NewContextKey()
// includedAs carries the name of the file a nested parse is converting, and footnoteKey is where the same
// name lands on that parse's document once it starts. Two steps, because the parse context is what `convert`
// can reach and the document is what a renderer can reach.
var includedAs = parser.NewContextKey()
const footnoteKey = "khosra:footnote-prefix"
// FootnotePrefix namespaces a footnote's id by the included file it came from, and is handed to goldmark in
// `cmd/khosra/wire.go`.
//
// An included file is converted on its own bytes (ADR-0038), so goldmark numbers its footnotes from one all
// over again: without this a page carrying its own footnote and an included one has two `id="fn:1"`s, and the
// first reference jumps to the wrong note. Returns nothing for the page itself, which keeps the plain ids.
func FootnotePrefix(n ast.Node) []byte {
doc := n.OwnerDocument()
if doc == nil {
return nil
}
name, _ := doc.Meta()[footnoteKey].(string)
if name == "" {
return nil
}
return []byte(content.TagSlug(strings.TrimSuffix(name, path.Ext(name))) + "-")
}
// includes fills in each include call with the converted content of the file it names.
//
// A transformer, running after the parse, rather than a node renderer: converting needs the parse context to
@@ -67,6 +93,10 @@ type includes struct {
func (in includes) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
insideInclude := pc.Get(nested) != nil
if name, is := pc.Get(includedAs).(string); is {
// Stamped here rather than in convert, because the document does not exist until the parse begins.
doc.AddMeta(footnoteKey, name)
}
for _, call := range pending(doc) {
if insideInclude {
slog.Error("ignoring an include inside an included file", "file", call.args["file"])
@@ -107,6 +137,7 @@ func (in includes) convert(name string, pc parser.Context) ([]byte, error) {
inner := parser.NewContext()
render.WithOrigin(inner, origin)
inner.Set(nested, true)
inner.Set(includedAs, name)
var out bytes.Buffer
if err := in.md.Convert(data, &out, parser.WithContext(inner)); err != nil {
return nil, err
+1 -1
View File
@@ -184,7 +184,7 @@ func TestIncludeRendersTheFileBesideTheBundle(t *testing.T) {
}
got := bundle(t, fsys, "pages/about")
// Converted as Markdown, not pasted as text: the heading, emphasis and link prove it.
for _, want := range []string{"<h2>Included</h2>", "<em>emphasis</em>", `href="/posts/"`} {
for _, want := range []string{`<h2 id="included">Included</h2>`, "<em>emphasis</em>", `href="/posts/"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q — an include is Markdown, not a string:\n%s", want, got)
}
+4 -1
View File
@@ -139,7 +139,10 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
if extend != nil {
extensions = append(extensions, extend(r.Partial)...)
}
r.md = goldmark.New(goldmark.WithExtensions(extensions...))
// Heading IDs are a parser option rather than an extension, and they are the engine's half of a table of
// contents: the anchor has to exist before a theme can link to it (ADR-0058).
r.md = goldmark.New(goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions(parser.WithAutoHeadingID()))
return r, nil
}
+6
View File
@@ -17,9 +17,15 @@ ul.extras, ol.archive { padding-left: 1.25rem; }
.kind { color: #6b6b6b; font-size: 0.85em; }
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 0.75rem; }
.gallery figure { margin: 0; }
table { border-collapse: collapse; display: block; overflow-x: auto; }
th, td { border-bottom: 1px solid #d8d5cd; padding: 0.35rem 0.75rem 0.35rem 0; text-align: left; }
dt { font-weight: 600; margin-top: 0.75rem; }
dd { margin-left: 1.25rem; }
.footnotes { font-size: 0.9em; }
@media (prefers-color-scheme: dark) {
html { color: #e8e6e1; background: #16161a; }
a { color: #8ab4dd; }
pre { background: #22222a; }
.kind { color: #9a9a9a; }
th, td { border-bottom-color: #33333c; }
}
+15 -2
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/content"
"khosra/internal/ext/shortcodes"
@@ -41,8 +42,16 @@ func exampleSite(t *testing.T) http.Handler {
if _, err := shortcodes.Derive(fsys, t.TempDir()); err != nil {
t.Fatal(err)
}
// This list has to match cmd/khosra/wire.go, which a package cannot import because it is a main. Kept in
// step by hand, and by the dialect's own test living beside the list it ships (ADR-0058).
r, err := render.New(fsys, settings, func(p render.Partial) []goldmark.Extender {
return []goldmark.Extender{shortcodes.New(p)}
return []goldmark.Extender{
extension.Table,
extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(shortcodes.FootnotePrefix)),
extension.DefinitionList,
extension.Strikethrough,
shortcodes.New(p),
}
})
if err != nil {
t.Fatal(err)
@@ -107,8 +116,12 @@ var exampleFeatures = []featureCase{
expect: []string{`class="gallery"`, "10-grey.jpg", "srcset=", `src="40-line.svg"`},
absent: []string{`src="40-line.svg" srcset`}},
{what: "an include is parsed as Markdown, and its fragment has no page of its own", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<h2>Method</h2>", "<em>Emphasis and links survive</em>"}},
expect: []string{`<h2 id="method">Method</h2>`, "<em>Emphasis and links survive</em>"}},
{what: "a fragment is not a bundle", path: "/writing/notes-on-water/_method/", code: 404},
{what: "the dialect renders tables, definition lists and strikethrough", path: "/writing/notes-on-water/", code: 200,
expect: []string{"<table>", "<th>Gauge</th>", "<dl>", "<dt>Monsoon</dt>", "<del>a struck phrase</del>"}},
{what: "a fragment's footnote ids are namespaced, so the page's own keep working", path: "/writing/notes-on-water/", code: 200,
expect: []string{`id="fn:1"`, `id="_method-fn:1"`, `href="#_method-fn:1"`}},
{what: "a page offers its extras only when it has them", path: "/writing/notes-on-water/", code: 200,
expect: []string{`href="/writing/notes-on-water/extras/"`}},
{what: "the extras tree is classified", path: "/writing/notes-on-water/extras/", code: 200,
+1 -1
View File
@@ -91,7 +91,7 @@ func TestSelectingAnEntryRendersWhatItCanAndOffersTheRest(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/writing/story/extras/notes.md", nil))
body := rec.Body.String()
if !strings.Contains(body, "<h2>Notes</h2>") || !strings.Contains(body, "<em>emphasis</em>") {
if !strings.Contains(body, `<h2 id="notes">Notes</h2>`) || !strings.Contains(body, "<em>emphasis</em>") {
t.Errorf("markdown should be rendered:\n%s", body)
}
// The tree is still there: selecting is a link, and the page is a full re-render, so no JavaScript is needed.