Item 2 of the order of work. An author writes `../day-01.en.md` — the path an editor preview resolves — and the engine emits `/posts/day-01/`. The larger effect is durability. Resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. The demo proves it: `../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote the filename and got the slugged address. This is the engine altering authored markup, which ADR-0045 polices, so the test that matters is what it declines to touch. Fourteen cases must survive exactly as written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working — a bundle's assets already resolve because its URL mirrors its directory, so rewriting them would break what works. Nine rewrite cases sit beside them. Key derivation goes through content.KeyFromName, exported for this: the language-suffix rule is the part that would drift between two copies, so it lives in one place while the five lines of joining are duplicated in check. khosra check now reports a relative .md link resolving to no bundle, as fatal — verified by mistyping one and watching exit 1. Only the .md form: an extensionless relative path may be an asset, and a checker that calls a working link broken gets ignored wholesale. Two debts this change paid rather than deferred. render.go reached the file-length advisory, so theme parsing moved to theme.go — 414 and 105 lines, one topic each, since parsing runs per rebuild and rendering runs per request. Not a _helpers.go shard. And the demo's coverage test bound its renderer with a *copy* of the rebuilder's wiring, so it missed this feature entirely while the real binary served it correctly. Navigation had already drifted the same way. Both now call one bind(), which is exactly what ADR-0072 was written about — and the test failing is the only reason the copy was found. Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is in ext where it belongs. 18 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
8.3 KiB
Go
198 lines
8.3 KiB
Go
// 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
|
|
// harness/theme-contract.md describes exactly this and nothing else.
|
|
|
|
package render
|
|
|
|
import (
|
|
"html/template"
|
|
"io/fs"
|
|
"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
|
|
// Sections are the site's sections, for navigation: only .Title and .URL are set. Empty until the engine is
|
|
// told where to find them, which cmd does at wiring time (ADR-0049).
|
|
Sections []Item
|
|
}
|
|
|
|
// 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
|
|
// Tags are this bundle's own terms, each with the URL of its listing. Empty when it carries none.
|
|
Tags []Item
|
|
// ExtrasURL links this bundle's supporting files, empty when it has none — so a theme can offer them
|
|
// without guessing whether they exist (ADR-0047).
|
|
ExtrasURL string
|
|
// Assets is the theme's own markup for everything this page's shortcodes and `use:` asked for, already
|
|
// rendered and deduplicated. Emit it in `<head>`. Empty for a page that asked for nothing, which is
|
|
// nearly all of them (ADR-0079).
|
|
Assets template.HTML
|
|
// Styles and Scripts are URLs of this bundle's own CSS and JS, built by the engine because a theme must
|
|
// not construct an address. Empty unless frontmatter named files beside the body.
|
|
Styles, Scripts []string
|
|
}
|
|
|
|
// 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 is absolute when the site declares a base, because hreflang is read by machines that resolve nothing
|
|
// against the page.
|
|
URL string
|
|
// Path is the same address, root-relative — what a *visible* language link wants. Found by looking at a
|
|
// rendered page: using URL sent a reader from a local server to the canonical host (ADR-0049).
|
|
Path string
|
|
}
|
|
|
|
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
|
|
// because markup belongs to the theme and a feature must not write any (ADR-0036).
|
|
type Partial func(name string, data Fragment) ([]byte, error)
|
|
|
|
// Fragment is what a fragment template receives (ADR-0037, widened by ADR-0042).
|
|
type Fragment struct {
|
|
// Args are the call's key="value" pairs, exactly as written. Escaping is the template's.
|
|
Args map[string]string
|
|
// Pictures are what the engine gathered rather than the author wrote: one for a figure, many for a
|
|
// gallery, none when the call names nothing a picture. Kept apart from Args so a supplied value can never
|
|
// be mistaken for an authored one.
|
|
Pictures []Picture
|
|
// Lang is the language being served, so a fragment can localise its own words through `t` (ADR-0067).
|
|
Lang string
|
|
// Body is a container call's content, already rendered. Empty for a leaf call (ADR-0064).
|
|
Body template.HTML
|
|
// Headings are the document's headings, for a call that builds a table of contents (ADR-0065).
|
|
Headings []Heading
|
|
}
|
|
|
|
// Heading is one heading in the document, with the id an anchor links to (ADR-0065).
|
|
type Heading struct {
|
|
Level int
|
|
Text, ID string
|
|
}
|
|
|
|
// Picture is one image a fragment can render (ADR-0042).
|
|
type Picture struct {
|
|
// Src is the author's own file, relative to the bundle. A browser that ignores Srcset still gets the
|
|
// picture that was put there.
|
|
Src string
|
|
// Srcset offers the derivatives, closed by the original at its own width; empty when the picture is
|
|
// already small enough that no derivative was worth making.
|
|
Srcset string
|
|
// Width and Height are the original's intrinsic size, so a page can reserve the box before the bytes
|
|
// arrive. Zero when the file could not be read.
|
|
Width, Height int
|
|
}
|
|
|
|
// Origin tells a feature which bundle is being rendered, so a path in a call can resolve relative to it.
|
|
//
|
|
// Features read it from the parser context with OriginFrom. It carries the site's fs.FS rather than a
|
|
// directory name alone, because every read goes through the rooted filesystem and never a joined path
|
|
// (ADR-0031).
|
|
type Origin struct {
|
|
// Dir is the bundle's directory, relative to the site root: "content/comics/the-long-monsoon".
|
|
Dir string
|
|
// Files is the site root. Nil when the renderer was built without one, in which case a feature that
|
|
// needs files degrades rather than guessing.
|
|
Files fs.FS
|
|
// Lang is the language of the variant being rendered, so a feature can hand it to a fragment (ADR-0067).
|
|
Lang string
|
|
// Resolve reports the URL a bundle key is served at in a language, and false when no bundle has that key.
|
|
// It exists so a feature can turn a path on disk into an address without knowing what a route is: a slug
|
|
// moves the address and never the key (ADR-0035), so only the index can answer. Nil when the renderer was
|
|
// built without one, in which case a feature that needs it does nothing.
|
|
Resolve func(key, lang string) (url string, ok bool)
|
|
}
|