localise chrome, smooth prose, in English and Bengali

The engine now owns the words it puts on a page that the author did not write
(ADR-0034). `internal/render/chrome.go` holds the phrase table, Gregorian month
names and decimal digits per language, keyed phrase-then-language so both forms
sit side by side and a half-translated row is visible while reading. Templates
reach it through `t`, `num` and `day`, registered before parsing so a site
override's blocks may call them too.

The reference theme stops hardcoding English: "Newer", "Page 2 of 2" and every
date now come from the table, while `datetime` attributes stay ASCII because a
parser reads them.

Authored text gets goldmark's typographer and nothing else — quotes, dashes and
ellipses smoothed, code spans untouched because it works on the parsed tree. It
is a parser option rather than a function over a page, so the transforms counter
does not move; state.md now says why, so the next reader does not miscount.

Deliberately absent: relative dates, which need a validity window that only the
cache entry will have, and body widow prevention, which cannot be done safely by
a pass over rendered HTML.
This commit is contained in:
Claude Opus 5
2026-07-30 03:24:29 +06:00
committed by bdeshi
parent 50b7764f3a
commit 2edb7ff6e5
8 changed files with 283 additions and 17 deletions
+10 -2
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"khosra/internal/content"
)
@@ -129,7 +130,12 @@ func New(siteFS fs.FS) (*Renderer, error) {
if err != nil {
return nil, err
}
return &Renderer{page: page, list: list, md: goldmark.New(), style: css}, nil
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
// not move the transforms counter.
md := goldmark.New(goldmark.WithExtensions(extension.Typographer))
return &Renderer{page: page, list: list, md: md, style: css}, nil
}
// parseSet builds one kind of page: the embedded base and block, then the site's versions of exactly
@@ -140,7 +146,9 @@ func New(siteFS fs.FS) (*Renderer, error) {
// site template into every set would let a listing's "main" leak into bundle pages, which is the
// collision per-kind sets exist to prevent.
func parseSet(siteFS fs.FS, kind string) (*template.Template, error) {
set, err := template.ParseFS(themeFS, "templates/base.html", kind)
// Funcs are attached before anything is parsed, so the chrome helpers are available to a site
// override's blocks as well as the embedded ones (ADR-0034).
set, err := template.New("theme").Funcs(funcs).ParseFS(themeFS, "templates/base.html", kind)
if err != nil {
return nil, fmt.Errorf("parse embedded: %w", err)
}