// Package pages is the shared page-template machinery for the custom services
// of a self-hosted SourceHut instance (compare, dolt, cover, bench, tokens).
//
// Every one of those services grew the same web/templates.go: discover the page
// templates next to a shared layout, parse one template set per page, refuse a
// page that defines no "content", execute into a buffer and only then write the
// response, and render one error page for every status the surface refuses
// with. The copies drifted in the ways copies do — one kept a hand-maintained
// list of page names instead of reading the directory, one wrote the template
// error into the response body, one skipped the content check entirely — and
// each drift is a different way to serve a viewer a page that is silently
// wrong. This package is the one copy, and it keeps the two refusals the
// donors argued for at length:
//
// - A page that defines no "content" is a startup error. Executed, it would
// render the chrome around an empty hole and answer 200, and a blank page
// is the one failure a viewer cannot report usefully. This is also why the
// layout must invoke the hole with {{template "content" .}} and never with
// {{block "content" .}}: `block` defines the name it invokes, which would
// hand every page an empty default at once and silently disarm the check.
//
// - A render goes into a buffer first. html/template writes as it evaluates,
// so executing straight into the ResponseWriter commits the status line and
// however many kilobytes of chrome were already produced before reaching
// the expression that fails. Buffering costs one page of memory and turns
// that into a clean 500.
//
// Usage, at startup:
//
// set, err := pages.Load(tmplFS, pages.Options{Funcs: myHelpers})
//
// and in a handler:
//
// if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil {
// log.Printf("web: %s %s: %v", r.Method, r.URL.Path, err)
// }
//
// Render answers the response itself in every case, so a returned error is for
// the log and never for a second answer — see Render.
//
// What stays in the services: the mapping from their own domain sentinels onto
// HTTP statuses (each service's `fail`). Two surfaces of one service must agree
// about which object exists, and that agreement is a property of that service's
// domain, not of this package.
package pages
import (
"bytes"
"errors"
"fmt"
"html/template"
"io/fs"
"maps"
"net/http"
"strings"
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
)
// Defaults for Options, and the file extension a page or partial is recognised
// by. The extension is not configurable: every service in this family writes
// .html, and a second spelling would only make the discovery rule harder to
// read than the list it replaces.
const (
DefaultDir = "templates"
DefaultLayout = "layout.html"
DefaultPartialPrefix = "_"
DefaultContentBlock = "content"
pageExt = ".html"
)
// ErrNoContent is returned by Load for a page template that defines no content
// block. It is a sentinel rather than a bare string because it is the one Load
// failure that is a template-authoring mistake and not a build or packaging
// one, and a service that wants to say so in its startup message needs to tell
// them apart.
var ErrNoContent = errors.New("pages: no content block")
// ErrUnknownPage is returned by Render when the name is not in the set. It is
// always a bug in the calling package — the set is built from the files that
// exist — so it is worth recognising in a log filter.
var ErrUnknownPage = errors.New("pages: unknown page")
// internalServerError is the body of every 500 this package writes itself. It
// says nothing, deliberately: the error it stands for names templates, fields
// and payload types, and the dolt donor published exactly that string to the
// browser.
const internalServerError = "internal server error"
// Options configures Load. The zero value is the layout of every service in
// this family: templates/layout.html, partials prefixed with '_', a "content"
// block, and the shared chrome helpers.
type Options struct {
// Dir is the directory inside the FS holding the layout, the partials and
// the pages. Defaults to DefaultDir.
Dir string
// Layout is the outer chrome every page is executed through, by file name.
// Defaults to DefaultLayout.
Layout string
// PartialPrefix marks the files in Dir that are fragments rather than
// pages. Defaults to DefaultPartialPrefix.
//
// A partial is parsed into *every* page's set, not only into the pages that
// invoke it today: a partial known only to the pages that used it on the
// day it was written is a lookup failure on the page that needs it next.
PartialPrefix string
// ContentBlock is the one block a page must define. Defaults to
// DefaultContentBlock.
ContentBlock string
// Funcs are the service's own template helpers. They are merged over
// chrome.Funcs, in that order, so the shared partials always find the
// helpers they were written against and a service can still shadow one
// deliberately rather than by accident of map ordering.
Funcs template.FuncMap
}
// withDefaults fills the unset fields. It works on a copy: Load must not
// rewrite the caller's struct, which is usually a literal at a call site that
// documents what the service actually chose.
func (o Options) withDefaults() Options {
if o.Dir == "" {
o.Dir = DefaultDir
}
if o.Layout == "" {
o.Layout = DefaultLayout
}
if o.PartialPrefix == "" {
o.PartialPrefix = DefaultPartialPrefix
}
if o.ContentBlock == "" {
o.ContentBlock = DefaultContentBlock
}
return o
}
// funcs is the merged helper map, chrome's first and the service's on top.
func (o Options) funcs() template.FuncMap {
m := chrome.Funcs()
maps.Copy(m, o.Funcs)
return m
}
// A Set maps a page name — the template file's name without its extension — to
// the template set that renders it: the layout, the shared chrome partials, the
// shipped error partial, every local partial, and that one page's content.
//
// Every page gets its own set rather than all of them sharing one, because each
// defines "content" and a shared set would let the last one parsed win.
type Set map[string]*template.Template
// Load parses one template set per content page found in the FS.
//
// Pages are discovered from the directory rather than listed in a slice, so
// adding templates/whatever.html is the whole registration of a page. The
// alternative — the static list the compare donor kept — is one more edit to
// forget, and forgetting it yields a page that 500s with "unknown template"
// while the file sits right there in the tree.
//
// A page that defines no content block is refused here, at startup, for the
// reason given in the package doc.
//
// The set always has an "error" page: the one this package ships (error.html),
// unless the FS carries an error.html of its own, which then wins whole. Either
// way the "srht-error" partial is parsed into every set, so a service that
// wants its own error page around the standard body can invoke it rather than
// copy it.
//
// It takes the FS rather than an embed.FS so a test can hand it a bad tree: the
// refusals above are the whole point of this function and none of them is
// reachable through a service's own embedded templates, which are exactly the
// files it ships and is expected to keep valid.
func Load(fsys fs.FS, opts Options) (Set, error) {
opts = opts.withDefaults()
funcs := opts.funcs()
entries, err := fs.ReadDir(fsys, opts.Dir)
if err != nil {
return nil, fmt.Errorf("pages: read %s: %w", opts.Dir, err)
}
var pageNames, partials []string
layoutFound := false
for _, e := range entries {
name := e.Name()
switch {
case e.IsDir() || !strings.HasSuffix(name, pageExt):
continue
case name == opts.Layout:
layoutFound = true
case strings.HasPrefix(name, opts.PartialPrefix):
partials = append(partials, opts.Dir+"/"+name)
default:
pageNames = append(pageNames, name)
}
}
if !layoutFound {
// Reported here rather than left to ParseFS, whose message for a pattern
// that matches nothing does not mention that the missing file is the
// layout every page is executed through.
return nil, fmt.Errorf("pages: no layout %s in %s", opts.Layout, opts.Dir)
}
if len(pageNames) == 0 {
// Only reachable if an embed pattern stops matching, which is a build
// change and not a runtime condition; it is checked because the symptom
// otherwise is a daemon that starts happily and 500s on every route.
return nil, fmt.Errorf("pages: no page templates in %s", opts.Dir)
}
set := make(Set, len(pageNames)+1)
for _, page := range pageNames {
defines, err := opts.definesContent(fsys, page, funcs)
if err != nil {
return nil, err
}
if !defines {
return nil, fmt.Errorf("pages: template %s defines no %q block: %w",
page, opts.ContentBlock, ErrNoContent)
}
t, err := opts.base(funcs)
if err != nil {
return nil, err
}
// The layout first and the page last, so that a page's content wins over
// any default the layout may carry for it: text/template keeps the last
// definition of a name it parses.
files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
files = append(files, opts.Dir+"/"+page)
if _, err := t.ParseFS(fsys, files...); err != nil {
return nil, fmt.Errorf("pages: parse template %s: %w", page, err)
}
set[strings.TrimSuffix(page, pageExt)] = t
}
if _, ok := set[ErrorPage]; !ok {
t, err := opts.base(funcs)
if err != nil {
return nil, err
}
files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
if _, err := t.ParseFS(fsys, files...); err != nil {
return nil, fmt.Errorf("pages: parse the layout for the error page: %w", err)
}
if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPageFile); err != nil {
return nil, fmt.Errorf("pages: parse the shipped error page: %w", err)
}
set[ErrorPage] = t
}
return set, nil
}
// base is the empty set every page starts from: the funcs, the shared chrome
// partials, and this package's own.
//
// The chrome partials go in through chrome.Attach rather than
// chrome.MustAttach: a parse failure in somebody else's module is still a
// startup error the daemon should report with a sentence naming what it was
// doing, and Load already returns an error for everything else that can go
// wrong here.
func (o Options) base(funcs template.FuncMap) (*template.Template, error) {
t, err := chrome.Attach(template.New(o.Layout).Funcs(funcs))
if err != nil {
return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err)
}
if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPartialFile); err != nil {
return nil, fmt.Errorf("pages: parse the shared error partial: %w", err)
}
return t, nil
}
// definesContent reports whether a page's own file defines the content block.
//
// It parses the page alone, without the layout and without the partials, and
// that separate parse is the whole of what this function is for. The obvious
// check — looking "content" up in the assembled set — only works while
// layout.html spells its hole {{template "content" .}} and its neighbours are
// {{block "head" .}} and {{block "scripts" .}}: `block` *defines* the name it
// invokes, so the day somebody makes the three consistent — a tidying edit no
// reviewer would question — Lookup starts finding the layout's own empty
// default on every page and the guard silently stops guarding. What comes back
// then is the failure it exists to prevent: the chrome around an empty hole,
// answered 200. Parsed on its own a page has only what it defines itself, and
// no edit to the layout can reach that.
//
// The cost is one extra parse per page, once, at startup.
func (o Options) definesContent(fsys fs.FS, page string, funcs template.FuncMap) (bool, error) {
t := template.New(page).Funcs(funcs)
if _, err := t.ParseFS(fsys, o.Dir+"/"+page); err != nil {
return false, fmt.Errorf("pages: parse template %s: %w", page, err)
}
return t.Lookup(o.ContentBlock) != nil, nil
}
// Render executes a page and writes it.
//
// The execution goes into a buffer first, and that is the whole point of this
// function: a template that fails halfway has otherwise already written a
// partial page under a 200 that cannot be taken back, and a viewer cannot tell
// half a document from a page that is genuinely that short. On some of these
// surfaces the missing half is the one carrying a secret that will never be
// shown again.
//
// Render answers the response in every case, and the contract that follows from
// that is the important half of this comment: **a returned error means the
// response has already been answered**, so the caller must log it and nothing
// else. Handing it back to a `fail` that renders an error page would either
// write a second response over a committed one, or — when the failure is in the
// error page itself — recurse until the stack runs out. It is returned rather
// than logged here because this package has no opinion about the caller's
// logger, and the callers have three between them.
//
// What is answered on failure is a bare 500 carrying a fixed string. The dolt
// donor wrote "template render error: "+err.Error() into the body instead,
// which publishes template names, field paths and whatever the payload's
// String method produces to whoever asked for the page.
func (s Set) Render(w http.ResponseWriter, status int, name string, data any) error {
t, ok := s[name]
if !ok {
// A page name that is not a template is a bug in the calling package —
// the set is built from the files that exist — so it is answered as the
// 500 it is.
http.Error(w, internalServerError, http.StatusInternalServerError)
return fmt.Errorf("pages: page %q: %w", name, ErrUnknownPage)
}
// t.Execute and not ExecuteTemplate(layout): Load names every set after the
// layout it parses, so t *is* the layout, and naming it again here would be
// a second place for Options.Layout to be spelled.
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
http.Error(w, internalServerError, http.StatusInternalServerError)
return fmt.Errorf("pages: execute template %q: %w", name, err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if _, err := buf.WriteTo(w); err != nil {
// The viewer hung up mid-response. Nothing left to answer with and the
// read is already done, so this is a log line — which is what every
// error out of here is.
return fmt.Errorf("pages: write the %d page: %w", status, err)
}
return nil
}