+{{if .Bundle.Title}}{{.Bundle.Title}}{{else}}{{.Bundle.Key}}{{end}} · {{t .Lang "extras"}}
+{{t .Lang "back-to-page"}}
+{{- if .Entries}}
+
+{{- else}}
+{{t .Lang "empty"}}
+{{- end}}
+{{- with .Selected}}
+
+{{.Name}}
+{{- if .HTML}}
+{{.HTML}}
+{{- else if eq .Kind "image"}}
+
+{{- else}}
+{{.Name}}
+{{- end}}
+
+{{- end}}
+
+{{- end}}
diff --git a/internal/render/view.go b/internal/render/view.go
new file mode 100644
index 0000000..4c2d278
--- /dev/null
+++ b/internal/render/view.go
@@ -0,0 +1,117 @@
+// The types in this file are the theme contract in Go: what a template receives, nothing about how it is
+// produced. Split from render.go when that file crossed the size warning — the seam was already here, since
+// docs/theme-contract.md describes exactly this and nothing else.
+
+package render
+
+import (
+ "html/template"
+ "time"
+
+ "khosra/internal/content"
+)
+
+// head is what every kind of page shares: the document shell the base template needs. Absence is the
+// zero value — a template reads what exists and never fails on a missing field (invariant 1).
+type head struct {
+ // Title may be empty for a bundle; a listing always has one.
+ Title string
+ // Lang is the locale being served.
+ Lang string
+ // Canonical is the permalink of what was actually served, which differs from the URL requested when
+ // the fallback chain supplied another language (ADR-0009).
+ Canonical string
+ // Alternates lists every language this key exists in, for hreflang.
+ Alternates []Alternate
+ // Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
+ Style template.CSS
+ // Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
+ Site content.Settings
+}
+
+// Page is one bundle rendered.
+type Page struct {
+ head
+ // Key is the bundle's identity, useful for building links.
+ Key string
+ // HTML is the rendered body.
+ HTML template.HTML
+ // Extra carries every frontmatter key the parser does not name (ADR-0002).
+ Extra map[string]any
+ // Sequence is the series this page sits in, nil when it sits in none.
+ Sequence *Sequence
+}
+
+// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
+// (ADR-0033).
+type Sequence struct {
+ // Title is the series' title, empty when the landing page omits one; URL is its permalink.
+ Title, URL string
+ // Members are every entry in reading order — ascending, unlike a dated listing.
+ Members []Item
+ // Index is this page's 1-based position, zero when this page is the series landing itself. Count is
+ // how many members there are.
+ Index, Count int
+ // Prev and Next are the neighbours in reading order, nil at the ends and on the landing page. Prev is
+ // the *earlier* entry, the opposite sense of a listing's PrevURL.
+ Prev, Next *Item
+ // First and Last are the ends of the series, set whenever it has members.
+ First, Last *Item
+}
+
+// Extras is a bundle's supporting files, browsable (ADR-0047).
+type Extras struct {
+ head
+ // Bundle is the page these files belong to, so a theme has its title and a way back.
+ Bundle Item
+ // Entries is the whole tree, sorted by path.
+ Entries []content.Entry
+ // Selected is the entry the URL named, nil on the bare listing.
+ Selected *Selected
+}
+
+// Selected is the entry a request picked out of the tree.
+type Selected struct {
+ content.Entry
+ // HTML is the rendered file for a markdown or text entry, empty for anything else.
+ HTML template.HTML
+ // RawURL always works: it is how a theme embeds an image, links a PDF, or offers a download.
+ RawURL string
+}
+
+// List is a collection page: the result of a Query, one page of it.
+type List struct {
+ head
+ // Items are the entries on this page, in the Query's order.
+ Items []Item
+ // Page is 1-based; Pages is the total, at least 1 even when empty.
+ Page, Pages int
+ // PrevURL and NextURL are empty at the ends. Newer is "prev" because the order is newest first.
+ PrevURL, NextURL string
+ // Groups is the same entries partitioned by section, set alongside Items for a tag listing. Both shapes
+ // are offered because which one to show is the theme's decision, not the engine's (ADR-0046).
+ Groups []Group
+}
+
+// Group is a named run of entries within a listing.
+type Group struct {
+ Name string
+ Items []Item
+}
+
+// Item is one entry in a listing.
+type Item struct {
+ Title string
+ Key string
+ URL string
+ Date time.Time
+ // Section is the entry's top-level section, so a flat listing can label where an entry came from without
+ // the engine deciding that it must be grouped (ADR-0046).
+ Section string
+}
+
+// Alternate is one language a bundle exists in.
+type Alternate struct {
+ Lang string
+ URL string
+}
diff --git a/internal/web/extras.go b/internal/web/extras.go
new file mode 100644
index 0000000..39fbbee
--- /dev/null
+++ b/internal/web/extras.go
@@ -0,0 +1,93 @@
+package web
+
+import (
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "path"
+ "strings"
+
+ "khosra/internal/content"
+ "khosra/internal/render"
+)
+
+// serveExtras answers a bundle's supporting files: the tree, one entry selected, or an entry's raw bytes.
+//
+// The bundle is looked up first, so an unpublished bundle hides its extras exactly as it hides its assets and
+// its body — one rule, one place (ADR-0024).
+func serveExtras(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
+ siteFS fs.FS, res resolution) bool {
+ if siteFS == nil {
+ return false
+ }
+ key, live := site.KeyFor(res.key)
+ if !live {
+ return false
+ }
+ b, served, found := site.Lookup(key, res.lang)
+ if !found {
+ return false
+ }
+ assets, hasAssets := b.Assets()
+ if !hasAssets {
+ return false
+ }
+ entries := content.Extras(siteFS, b)
+ if len(entries) == 0 {
+ return false
+ }
+ if res.entry == "" {
+ return renderExtras(w, r, b, served, entries, nil)
+ }
+
+ entry, ok := find(entries, res.entry)
+ if !ok || entry.IsDir {
+ return false
+ }
+ name := path.Join(assets, content.ExtrasDir, entry.Path)
+ // ?raw is a representation of the same entry rather than a different one, which is why it is a parameter
+ // and not another path (ADR-0047).
+ if req.URL.Query().Has("raw") {
+ http.ServeFileFS(w, req, siteFS, name)
+ return true
+ }
+ selected := &render.Selected{Entry: entry, RawURL: content.ExtrasURL(b.Route, served, entry.Path) + "?raw"}
+ if entry.Kind == "markdown" || entry.Kind == "text" {
+ data, err := fs.ReadFile(siteFS, name)
+ if err != nil {
+ slog.Error("extras entry unreadable", "path", name, "err", err)
+ } else if html, err := r.RenderText(entry.Kind, data); err != nil {
+ slog.Error("extras entry unrenderable", "path", name, "err", err)
+ } else {
+ selected.HTML = html
+ }
+ }
+ return renderExtras(w, r, b, served, entries, selected)
+}
+
+// renderExtras writes the listing, degrading like every other render failure.
+func renderExtras(w http.ResponseWriter, r *render.Renderer, b content.Bundle, served string,
+ entries []content.Entry, selected *render.Selected) bool {
+ out, err := r.Extras(b, served, entries, selected)
+ if err != nil {
+ slog.Error("extras failed", "key", b.Key, "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return true
+ }
+ write(w, out, b.Key+"/"+content.ExtrasDir)
+ return true
+}
+
+// find locates an entry by its path within the tree.
+//
+// Chosen from the enumeration rather than probed on disk: an entry a request names has to be one the listing
+// showed, so a path that walks out of the tree is not found rather than refused.
+func find(entries []content.Entry, want string) (content.Entry, bool) {
+ want = strings.TrimSuffix(want, "/")
+ for _, e := range entries {
+ if e.Path == want {
+ return e, true
+ }
+ }
+ return content.Entry{}, false
+}
diff --git a/internal/web/extras_test.go b/internal/web/extras_test.go
new file mode 100644
index 0000000..48ea7da
--- /dev/null
+++ b/internal/web/extras_test.go
@@ -0,0 +1,155 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "khosra/internal/content"
+ "khosra/internal/render"
+)
+
+func extrasFS() fstest.MapFS {
+ return fstest.MapFS{
+ "content/writing/story/index.md": {Data: []byte("---\ntitle: A Story\ndate: 2026-01-01\n---\nThe story itself.\n")},
+ "content/writing/story/extras/notes.md": {Data: []byte("## Notes\n\nWith *emphasis*.\n")},
+ "content/writing/story/extras/log.txt": {Data: []byte("day one: