diff --git a/cmd/khosra/example_test.go b/cmd/khosra/example_test.go index eb3fe45..ff647e9 100644 --- a/cmd/khosra/example_test.go +++ b/cmd/khosra/example_test.go @@ -46,7 +46,9 @@ func exampleSite(t *testing.T) http.Handler { t.Fatal(err) } site := content.NewSite(bundles) - r.Navigation(site.Sections) + // The same binding the binary uses, from the one function that does it — not a copy, which is how this + // test came to miss a feature that worked in the real server (ADR-0072). + bind(r, site) return web.Handler(web.Fixed(site, r), fsys, nil, settings, routes(fsys, settings, func() *content.Site { return site })) } @@ -141,6 +143,11 @@ var exampleFeatures = []featureCase{ expect: []string{`href="/pages/sandbox/sandbox.css"`, `src="/pages/sandbox/sandbox.js" defer`}}, {what: "the script exception reaches only the page that asked — every other page stays scriptless", path: "/pages/colophon/", code: 200, absent: []string{"../day-01.md") { + t.Errorf("a code span must stay literal:\n%s", got) + } +} diff --git a/internal/render/render.go b/internal/render/render.go index 8116925..6f25741 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -7,7 +7,6 @@ package render import ( "bytes" - "embed" "fmt" "html/template" "io/fs" @@ -23,9 +22,6 @@ import ( "khosra/internal/content" ) -//go:embed templates -var themeFS embed.FS - // Renderer holds the parsed theme and the Markdown converter. The theme is parsed once per rebuild and // swapped whole, never per request (conventions.md, ADR-0055). type Renderer struct { @@ -41,24 +37,14 @@ type Renderer struct { // sections reports the site's sections when asked. A callback, because sections change when content does and // the renderer must not hold a stale copy (ADR-0049). sections func() []string + // links resolves a bundle key to the URL it is served at, set per rebuild like sections because only the + // current index can answer it (ADR-0087). + links func(key, lang string) (string, bool) // compose may rewrite a body before it is parsed, for a bundle that asks its includes to be merged // (ADR-0066). Set at wiring time like sections, and never called otherwise. compose func(src []byte, origin Origin) []byte } -// parsedTheme is the sets a request executes, and the stylesheet the shell inlines. -type parsedTheme struct { - // Two sets, not one: base plus the block that kind of page defines. A single set would have two - // definitions of "main" fighting, which is why per-type sets are the shape (ADR-0019). - page *template.Template - list *template.Template - // partials are named fragments a feature renders through, so no feature decides markup (ADR-0036). - partials *template.Template - // extras is the set for a bundle's supporting-file listing. - extras *template.Template - style template.CSS -} - // originKey identifies the Origin in a parse. Unexported, so the typed accessor is the only way in. var originKey = parser.NewContextKey() @@ -141,34 +127,6 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar return r, nil } -// parseTheme parses every set the theme is made of, plus its stylesheet. -// -// Its own function because a running server parses the theme again on every rebuild (ADR-0055): startup and -// reparse must be the same code, or the theme a running site serves drifts from the one a fresh boot would. -func parseTheme(siteFS fs.FS) (*parsedTheme, error) { - page, err := parseSet(siteFS, "templates/base.html", "templates/page.html") - if err != nil { - return nil, fmt.Errorf("bundle templates: %w", err) - } - list, err := parseSet(siteFS, "templates/base.html", "templates/list.html") - if err != nil { - return nil, fmt.Errorf("listing templates: %w", err) - } - partials, err := parseSet(siteFS, "templates/shortcodes.html", "templates/shortcodes/*.html") - if err != nil { - return nil, fmt.Errorf("partial templates: %w", err) - } - extras, err := parseSet(siteFS, "templates/base.html", "templates/extras.html") - if err != nil { - return nil, fmt.Errorf("extras templates: %w", err) - } - css, err := readStyle(siteFS) - if err != nil { - return nil, err - } - return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, nil -} - // head builds the document shell every kind of page shares. // // canonical arrives as a path and leaves absolute when the site declared a base: a canonical link and an @@ -200,6 +158,12 @@ func (r *Renderer) absolute(path string) string { // sections exist. A callback rather than a slice, because content changes and a copy would go stale. func (r *Renderer) Navigation(sections func() []string) { r.sections = sections } +// Links registers how a bundle key becomes the URL it is served at. +// +// Set per rebuild beside Navigation, and for the same reason: only the index that exists now knows which +// route a key answers at, and a renderer holding a stale copy would emit addresses that used to work. +func (r *Renderer) Links(resolve func(key, lang string) (string, bool)) { r.links = resolve } + // Compose registers the rewrite a merging bundle's body goes through before it is parsed (ADR-0066). // // A seam rather than a call, for the same reason extend is one: only cmd knows which features exist, and @@ -243,55 +207,6 @@ func (r *Renderer) assets(names []string) template.HTML { return template.HTML(out.String()) } -// parseSet builds one set from the named embedded templates, then the site's versions of exactly those -// files parsed after them. -// -// Parse order is the whole mechanism — the last definition of a name wins — so a site redefines one -// named block and inherits the rest (ADR-0019). Only the files this set is built from are overlaid: -// overlaying every site template into every set would let a listing's "main" leak into bundle pages, -// which is the collision per-kind sets exist to prevent. -func parseSet(siteFS fs.FS, names ...string) (*template.Template, error) { - // Funcs are attached before anything is parsed, so the chrome helpers are available to a site - // override's blocks as well as the embedded ones (ADR-0034). A name may be a glob, which is how a - // directory of fragments is parsed after the single file it may replace (ADR-0071). - set, parsed := template.New("theme").Funcs(funcs), false - for _, from := range []fs.FS{themeFS, siteFS} { - if from == nil { - continue - } - for _, name := range names { - if matches, _ := fs.Glob(from, name); len(matches) == 0 { - continue - } - var err error - if set, err = set.ParseFS(from, name); err != nil { - return nil, fmt.Errorf("parse %s: %w", name, err) - } - parsed = true - } - } - // Nothing matched anywhere, which means a name the binary embeds has been renamed. A startup failure, - // because the alternative is an empty set and a template error on the first request. - if !parsed { - return nil, fmt.Errorf("no template matched %v", names) - } - return set, nil -} - -// readStyle prefers the site's stylesheet and falls back to the reference one. -func readStyle(siteFS fs.FS) (template.CSS, error) { - if siteFS != nil { - if data, err := fs.ReadFile(siteFS, "templates/theme.css"); err == nil { - return template.CSS(data), nil - } - } - data, err := themeFS.ReadFile("templates/theme.css") - if err != nil { - return "", fmt.Errorf("read reference stylesheet: %w", err) - } - return template.CSS(data), nil -} - // Extras renders a bundle's supporting files, with one entry selected or none. // // The engine enumerates, classifies and renders what it can; how a tree and a selected file look is the theme's @@ -340,7 +255,7 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se // The parse carries which bundle it is, so a feature can resolve a path in a call against the bundle's // own directory (ADR-0031: through the rooted filesystem, never a joined path). pc := parser.NewContext() - origin := Origin{Dir: path.Dir(b.Path), Files: r.files, Lang: served} + origin := Origin{Dir: path.Dir(b.Path), Files: r.files, Lang: served, Resolve: r.links} WithOrigin(pc, origin) // Fragments are spliced in before the parse, so their footnotes, abbreviations and headings are the // page's — one document, which is what composing a page from files nearly always wants. `include: embed` diff --git a/internal/render/theme.go b/internal/render/theme.go new file mode 100644 index 0000000..eba5cf0 --- /dev/null +++ b/internal/render/theme.go @@ -0,0 +1,105 @@ +// Building the theme: the embedded reference templates, the site's overlay of them, and the stylesheet. +// +// Split from render.go when that file reached the length advisory (conventions.md). Parsing a theme and +// rendering with one are two topics: this file runs once per rebuild, the other runs per request. +package render + +import ( + "embed" + "fmt" + "html/template" + "io/fs" +) + +//go:embed templates +var themeFS embed.FS + +// parsedTheme is the sets a request executes, and the stylesheet the shell inlines. +type parsedTheme struct { + // Two sets, not one: base plus the block that kind of page defines. A single set would have two + // definitions of "main" fighting, which is why per-type sets are the shape (ADR-0019). + page *template.Template + list *template.Template + // partials are named fragments a feature renders through, so no feature decides markup (ADR-0036). + partials *template.Template + // extras is the set for a bundle's supporting-file listing. + extras *template.Template + style template.CSS +} + +// parseTheme parses every set the theme is made of, plus its stylesheet. +// +// Its own function because a running server parses the theme again on every rebuild (ADR-0055): startup and +// reparse must be the same code, or the theme a running site serves drifts from the one a fresh boot would. +func parseTheme(siteFS fs.FS) (*parsedTheme, error) { + page, err := parseSet(siteFS, "templates/base.html", "templates/page.html") + if err != nil { + return nil, fmt.Errorf("bundle templates: %w", err) + } + list, err := parseSet(siteFS, "templates/base.html", "templates/list.html") + if err != nil { + return nil, fmt.Errorf("listing templates: %w", err) + } + partials, err := parseSet(siteFS, "templates/shortcodes.html", "templates/shortcodes/*.html") + if err != nil { + return nil, fmt.Errorf("partial templates: %w", err) + } + extras, err := parseSet(siteFS, "templates/base.html", "templates/extras.html") + if err != nil { + return nil, fmt.Errorf("extras templates: %w", err) + } + css, err := readStyle(siteFS) + if err != nil { + return nil, err + } + return &parsedTheme{page: page, list: list, partials: partials, extras: extras, style: css}, nil +} + +// parseSet builds one set from the named embedded templates, then the site's versions of exactly those +// files parsed after them. +// +// Parse order is the whole mechanism — the last definition of a name wins — so a site redefines one +// named block and inherits the rest (ADR-0019). Only the files this set is built from are overlaid: +// overlaying every site template into every set would let a listing's "main" leak into bundle pages, +// which is the collision per-kind sets exist to prevent. +func parseSet(siteFS fs.FS, names ...string) (*template.Template, error) { + // Funcs are attached before anything is parsed, so the chrome helpers are available to a site + // override's blocks as well as the embedded ones (ADR-0034). A name may be a glob, which is how a + // directory of fragments is parsed after the single file it may replace (ADR-0071). + set, parsed := template.New("theme").Funcs(funcs), false + for _, from := range []fs.FS{themeFS, siteFS} { + if from == nil { + continue + } + for _, name := range names { + if matches, _ := fs.Glob(from, name); len(matches) == 0 { + continue + } + var err error + if set, err = set.ParseFS(from, name); err != nil { + return nil, fmt.Errorf("parse %s: %w", name, err) + } + parsed = true + } + } + // Nothing matched anywhere, which means a name the binary embeds has been renamed. A startup failure, + // because the alternative is an empty set and a template error on the first request. + if !parsed { + return nil, fmt.Errorf("no template matched %v", names) + } + return set, nil +} + +// readStyle prefers the site's stylesheet and falls back to the reference one. +func readStyle(siteFS fs.FS) (template.CSS, error) { + if siteFS != nil { + if data, err := fs.ReadFile(siteFS, "templates/theme.css"); err == nil { + return template.CSS(data), nil + } + } + data, err := themeFS.ReadFile("templates/theme.css") + if err != nil { + return "", fmt.Errorf("read reference stylesheet: %w", err) + } + return template.CSS(data), nil +} diff --git a/internal/render/view.go b/internal/render/view.go index 8985cfb..dc4dae4 100644 --- a/internal/render/view.go +++ b/internal/render/view.go @@ -189,4 +189,9 @@ type Origin struct { 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) }