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.
106 lines
3.5 KiB
Go
106 lines
3.5 KiB
Go
package render
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"khosra/internal/content"
|
|
)
|
|
|
|
// Chrome is every word the engine puts on a page that the author did not write, in each language it
|
|
// knows (ADR-0034). Keyed by phrase then language, so both forms sit side by side and a missing one is
|
|
// visible while reading rather than at request time.
|
|
//
|
|
// A phrase may carry %s placeholders, filled in order by the caller. Templates reach these through the
|
|
// `t`, `num` and `day` functions; nothing here is content, and content never comes from here.
|
|
var chrome = map[string]map[string]string{
|
|
"newer": {"en": "Newer", "bn": "নতুন"},
|
|
"older": {"en": "Older", "bn": "পুরোনো"},
|
|
"empty": {"en": "Nothing here yet.", "bn": "এখনও কিছু নেই।"},
|
|
"page-of": {"en": "Page %s of %s", "bn": "পৃষ্ঠা %s / %s"},
|
|
"position": {"en": "%s of %s", "bn": "%s / %s"},
|
|
}
|
|
|
|
// months are Gregorian month names per language, indexed by [time.Month]-1.
|
|
var months = map[string][]string{
|
|
"en": {"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December"},
|
|
"bn": {"জানুয়ারি", "ফেব্রুয়ারি", "মার্চ", "এপ্রিল", "মে", "জুন",
|
|
"জুলাই", "আগস্ট", "সেপ্টেম্বর", "অক্টোবর", "নভেম্বর", "ডিসেম্বর"},
|
|
}
|
|
|
|
// digits are the decimal digits of a script, indexed 0-9. A language absent here uses ASCII.
|
|
var digits = map[string][]rune{
|
|
"bn": []rune("০১২৩৪৫৬৭৮৯"),
|
|
}
|
|
|
|
// funcs are the chrome helpers a template may call. Registered before parsing, so a site root's override
|
|
// blocks may use them too.
|
|
var funcs = template.FuncMap{"t": text, "num": numerals, "day": day}
|
|
|
|
// text is one chrome phrase in a language, with any %s placeholders filled.
|
|
//
|
|
// It cannot fail: an unknown language falls back to the default locale and an unknown phrase returns its
|
|
// own key, because a missing translation must never blank a page or break a render (invariant 1).
|
|
func text(lang, key string, args ...string) string {
|
|
forms := chrome[key]
|
|
phrase, ok := forms[lang]
|
|
if !ok {
|
|
phrase = forms[content.DefaultLang]
|
|
}
|
|
if phrase == "" {
|
|
return key
|
|
}
|
|
if len(args) == 0 {
|
|
return phrase
|
|
}
|
|
filled := make([]any, len(args))
|
|
for i, a := range args {
|
|
filled[i] = a
|
|
}
|
|
return fmt.Sprintf(phrase, filled...)
|
|
}
|
|
|
|
// numerals is an integer in the script of a language: 12 in English, ১২ in Bengali.
|
|
func numerals(lang string, n int) string {
|
|
return localiseDigits(lang, strconv.Itoa(n))
|
|
}
|
|
|
|
// day is a date as a reader of that language would read it — "8 March 2026", "৮ মার্চ ২০২৬".
|
|
//
|
|
// One path for every language rather than [time.Time.Format] for the default locale and a hand-built
|
|
// string for the rest, so English cannot drift from the others. The machine-readable form belongs in a
|
|
// datetime attribute and stays ASCII (ADR-0034).
|
|
func day(lang string, t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
names, ok := months[lang]
|
|
if !ok {
|
|
names = months[content.DefaultLang]
|
|
}
|
|
return strings.Join([]string{
|
|
numerals(lang, t.Day()),
|
|
names[t.Month()-1],
|
|
numerals(lang, t.Year()),
|
|
}, " ")
|
|
}
|
|
|
|
// localiseDigits swaps ASCII digits for a script's own, leaving everything else alone.
|
|
func localiseDigits(lang, s string) string {
|
|
set, ok := digits[lang]
|
|
if !ok {
|
|
return s
|
|
}
|
|
out := []rune(s)
|
|
for i, r := range out {
|
|
if r >= '0' && r <= '9' {
|
|
out[i] = set[r-'0']
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|