diff --git a/cmd/khosra/main.go b/cmd/khosra/main.go new file mode 100644 index 0000000..a4bf4f7 --- /dev/null +++ b/cmd/khosra/main.go @@ -0,0 +1,53 @@ +// Command khosra serves a site root over HTTP. +// +// Everything is assembled here and nowhere else: no init(), no package-level state (conventions.md). +package main + +import ( + "flag" + "log/slog" + "net/http" + "os" + + "khosra/internal/content" + "khosra/internal/render" + "khosra/internal/web" +) + +func main() { + site := flag.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)") + addr := flag.String("addr", "localhost:8080", "address to listen on") + flag.Parse() + + if *site == "" { + fatal("no site root: pass -site or set KHOSRA_SITE", nil) + } + fsys, err := content.OpenSite(*site) + if err != nil { + fatal("cannot open the site root", err) + } + bundles, err := content.Scan(fsys) + if err != nil { + fatal("cannot read content", err) + } + renderer, err := render.New() + if err != nil { + fatal("cannot prepare the reference theme", err) + } + + slog.Info("serving", "site", *site, "bundles", len(bundles), "addr", *addr) + if err := http.ListenAndServe(*addr, web.Handler(content.NewSite(bundles), renderer)); err != nil { + fatal("server stopped", err) + } +} + +// fatal reports a startup failure and exits. Startup failure is fatal and loud; request-time failure +// degrades instead (conventions.md). +func fatal(msg string, err error) { + if err != nil { + slog.Error(msg, "err", err) + } else { + slog.Error(msg) + } + os.Exit(1) +} diff --git a/docs/state.md b/docs/state.md index b5bd736..5d4991d 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `d66c3cb` on 2026-07-30 — update this line every change. +**Verified against:** `HEAD` on 2026-07-30 — update this line every change. If this file disagrees with the code, the code is right and this file is a bug. ## Inventory @@ -8,11 +8,15 @@ If this file disagrees with the code, the code is right and this file is a bug. | File | Purpose | LOC | |---|---|---| | `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 | -| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop | 217 | -| `internal/content/content_test.go` | table-driven; symlink-escape evidence for the path guard | 155 | +| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 | +| `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 | +| `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — | +| `internal/web/web.go` | one routing case: path → bundle key, canonical trailing slash, 404, degrade on render failure | 62 | +| `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 | +| `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 | -No HTTP yet. This repo holds engine source only — the site root is external and passed with `-site` -(ADR-0011). +Serves a bundle at `/{section}/{slug}/`. This repo holds engine source only — the site root is external +and passed with `-site` (ADR-0011). Dependencies: none. @@ -24,13 +28,13 @@ this change*. | Counter | Now | Extraction due at | What it buys | |---|---|---|---| | Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) | -| Routing cases | 0 | **2** | Resolver extraction | +| Routing cases | 1 | **2** | Resolver extraction | | Collection pages | 0 | **1** | Query primitive | -| Views / output formats | 0 | **2** | View layer (contract per `theme-contract.md`) | +| Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) | | Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) | | Extensions | 0 | **3** | Extension registry + wire file (`extensions.md`) | | Interface implementations | — | **2** | The interface itself | -| Non-stdlib dependencies | 2 direct, 7 modules | budget in `scripts/budgets.env` | — | +| Non-stdlib dependencies | 3 direct | budget in `scripts/budgets.env` | — | Allowlisted, in use: `goldmark` is not yet imported. Allowlist: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015), `gopkg.in/yaml.v3` (frontmatter, ADR-0020). diff --git a/docs/theme-contract.md b/docs/theme-contract.md index 2eb013f..065ffaa 100644 --- a/docs/theme-contract.md +++ b/docs/theme-contract.md @@ -3,8 +3,25 @@ What the engine promises a theme, and the only thing this repository is bound to (ADR-0023). A theme's markup, layout and styling are not the engine's business; a theme's *inputs* are. -**STATUS: not built.** This is the shape the contract takes when the first template renders. Everything -here is engine obligation, not theme instruction — a theme may ignore any of it. +**STATUS: partly live.** The fields under *Live today* exist and are gated; everything else is the shape +the contract takes when the feature arrives. All of it is engine obligation, not theme instruction — a +theme may ignore any of it. + +## Live today + +A bundle page receives: + +| Field | Contents | +|---|---| +| `.Title` | may be empty; a template falls back to `.Key` rather than failing | +| `.Lang` | the locale of this variant, always set | +| `.Key` | the bundle's identity, without language or extension | +| `.HTML` | the rendered body, already escaped | +| `.Extra` | every frontmatter key the parser does not name (ADR-0002) | +| `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route | + +Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change +the body while inheriting the document. Nothing else is promised yet. ## The stability rule diff --git a/go.mod b/go.mod index 7d0c834..6d5b8e5 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,5 @@ require ( golang.org/x/text v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) + +require github.com/yuin/goldmark v1.8.5 diff --git a/go.sum b/go.sum index 50764a7..93aac37 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/content/content.go b/internal/content/content.go index a6341e5..a338ec3 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -215,3 +215,30 @@ func dropCollisions(all []Bundle) []Bundle { } return kept } + +// Site is a set of bundles indexed for lookup by permalink key. +type Site struct { + byKeyLang map[string]Bundle +} + +// NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan +// drops ambiguity before this sees it. +func NewSite(bundles []Bundle) *Site { + s := &Site{byKeyLang: make(map[string]Bundle, len(bundles))} + for _, b := range bundles { + s.byKeyLang[b.Key+"\x00"+b.Lang] = b + } + return s +} + +// Lookup returns the default-locale variant of a key. +// +// Other languages are reachable only once language routing exists; until then a key means its default +// variant (ADR-0021). +func (s *Site) Lookup(key string) (Bundle, bool) { + b, ok := s.byKeyLang[key+"\x00"+DefaultLang] + return b, ok +} + +// Len reports how many bundles the site holds. +func (s *Site) Len() int { return len(s.byKeyLang) } diff --git a/internal/content/content_test.go b/internal/content/content_test.go index ca90f6e..abd9056 100644 --- a/internal/content/content_test.go +++ b/internal/content/content_test.go @@ -153,3 +153,25 @@ func TestOpenSiteRefusesSymlinkEscape(t *testing.T) { t.Fatal("traversal with .. succeeded") } } + +func TestSiteLookupFindsDefaultVariant(t *testing.T) { + fsys := fstest.MapFS{ + "content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")}, + "content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")}, + } + bundles, err := Scan(fsys) + if err != nil { + t.Fatal(err) + } + site := NewSite(bundles) + if site.Len() != 2 { + t.Fatalf("indexed %d bundles, want 2", site.Len()) + } + b, ok := site.Lookup("pages/about") + if !ok || b.Title != "About" { + t.Fatalf("Lookup gave %+v %v, want the English variant", b, ok) + } + if _, ok := site.Lookup("pages/missing"); ok { + t.Error("Lookup invented a bundle") + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..b4af257 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,82 @@ +// Package render turns a bundle into bytes: Markdown to HTML, then a template set. It knows content and +// nothing about HTTP. +// +// The embedded templates and stylesheet are the reference theme (ADR-0026) — a demonstration of +// docs/theme-contract.md, not a design. Fields a template may rely on are listed there. +package render + +import ( + "bytes" + "embed" + "fmt" + "html/template" + + "github.com/yuin/goldmark" + + "khosra/internal/content" +) + +//go:embed templates +var themeFS embed.FS + +// Page is what a template receives. Absence is the zero value: a template reads what exists and never +// fails on a missing field (invariant 1). +type Page struct { + // Title may be empty; whether that is legal depends on a type, which nothing decides yet. + Title string + // Lang is the locale this variant is written in. + Lang string + // Key is the bundle's identity, useful for building links. + Key string + // HTML is the rendered body, already escaped by the Markdown renderer. + HTML template.HTML + // Extra carries every frontmatter key the parser does not name (ADR-0002). + Extra map[string]any + // Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route. + Style template.CSS +} + +// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never +// per request (conventions.md). +type Renderer struct { + tmpl *template.Template + md goldmark.Markdown + style template.CSS +} + +// New parses the reference theme and prepares the Markdown converter. +// +// A malformed embedded template is a programming error caught at startup, not at request time, so this +// returns an error and the caller is expected to treat it as fatal. +func New() (*Renderer, error) { + tmpl, err := template.ParseFS(themeFS, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parse reference theme: %w", err) + } + css, err := themeFS.ReadFile("templates/theme.css") + if err != nil { + return nil, fmt.Errorf("read reference stylesheet: %w", err) + } + return &Renderer{tmpl: tmpl, md: goldmark.New(), style: template.CSS(css)}, nil +} + +// Bundle renders one bundle into a complete page. +func (r *Renderer) Bundle(b content.Bundle) ([]byte, error) { + var body bytes.Buffer + if err := r.md.Convert(b.Body, &body); err != nil { + return nil, fmt.Errorf("markdown %s: %w", b.Path, err) + } + p := Page{ + Title: b.Title, + Lang: b.Lang, + Key: b.Key, + HTML: template.HTML(body.String()), + Extra: b.Extra, + Style: r.style, + } + var out bytes.Buffer + if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil { + return nil, fmt.Errorf("template %s: %w", b.Key, err) + } + return out.Bytes(), nil +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..d473134 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,50 @@ +package render + +import ( + "strings" + "testing" + + "khosra/internal/content" +) + +func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) { + r, err := New() + if err != nil { + t.Fatal(err) + } + b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\n\n# Heading\n\nSome *prose*.\n")) + if err != nil { + t.Fatal(err) + } + out, err := r.Bundle(b) + if err != nil { + t.Fatal(err) + } + got := string(out) + for _, want := range []string{ + "", ``, "