Files
khosra/internal/render/chrome.go
T
Claude Opus 5andbdeshi 4396303771 add khosra demo, and give the site a front page
The demo writes a whole site root that exercises every feature: two languages with
a fallback, a series with ordered chapters, a gallery, a figure, an include,
extras, tags across sections, a slug with an alias, an undated page, a draft, a
template override, static files and site.yaml. It generates its filler rather than
copying stored files, because nothing in this repository is content (ADR-0011) — and
that makes it a test of the engine rather than a fixture: anything khosra can do
that the demo cannot express is a gap.

Two things found by generating and then serving it, which is the whole point:

`khosra check` reported the demo's own series as mixing ordered and unordered
members. It was right — the chapter bodies *described* `order: 10` while the
frontmatter never carried it. The checker caught its own author.

And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but
"engine-owned" was never given an answer, so a visitor to the site's own address got
nothing. The root now lists every bundle, newest first, paginated like any other
listing, and 404s only when nothing is published. A hand-written home page stays a
separate decision, recorded as such.

Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean,
and every URL the demo promises answers — including the alias redirecting, the draft
hidden, and the front page rendering through the site's *own* template override.
2026-07-31 19:54:23 +06:00

111 lines
3.9 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"},
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
"everything": {"en": "Everything", "bn": "সবকিছু"},
"first": {"en": "First", "bn": "প্রথম"},
"last": {"en": "Last", "bn": "শেষ"},
}
// 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)
}