// 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 }