// Package render parses the embedded templates once, at startup, and // executes them into a buffer before writing anything to the client. // // Two decisions worth keeping when this is seeded into a real project: // // - Every template is parsed in New. A template that does not compile // is a process that does not start, rather than a 500 the first time // someone visits the page it broke. // - Execution goes to a buffer first. A template that fails halfway // through would otherwise have already written a 200 and half a // page, and the error could no longer be reported as one. package render import ( "bytes" "fmt" "html/template" "io" "io/fs" "net/http" "path" "sort" "strings" "time" "github.com/dustin/go-humanize" "go.uber.org/fx" "sneak.berlin/go/simplexcalc/internal/database" "sneak.berlin/go/simplexcalc/internal/globals" "sneak.berlin/go/simplexcalc/templates" ) // baseTemplate is the outer document every page is rendered through. const baseTemplate = "base" // Params defines dependencies for Renderer. type Params struct { fx.In Globals *globals.Globals } // Renderer holds one compiled template set per page. type Renderer struct { pages map[string]*template.Template globals *globals.Globals started time.Time } // Page is the data every template can rely on, embedded by the // page-specific types below so that promoted fields keep the templates // free of a data-envelope prefix. type Page struct { AppName string Version string Buildarch string Uptime string // CSRFField is the hidden input gorilla/csrf validates. It is // template.HTML because it is markup this process generated, not // input; nothing user-supplied is ever assigned to it. CSRFField template.HTML } // IndexPage is the data for index.html. type IndexPage struct { Page WidgetCount int Widgets []database.Widget } // ErrorPage is the data for error.html. type ErrorPage struct { Page Status int Message string } // funcs are the template helpers. Deliberately few: logic belongs in // the handler, where it can be tested without parsing HTML. func funcs() template.FuncMap { return template.FuncMap{ // bytes renders a byte count the way an operator reads one. "bytes": func(n int64) string { if n < 0 { return "-" } return humanize.IBytes(uint64(n)) }, // since renders a timestamp as "3 minutes ago". "since": humanize.Time, } } // New compiles every page template against the base document and the // partials. // //nolint:revive // lc parameter is required by fx even if unused. func New(lc fx.Lifecycle, params Params) (*Renderer, error) { r := &Renderer{ pages: map[string]*template.Template{}, globals: params.Globals, started: time.Now(), } shared, pages, err := split(templates.FS) if err != nil { return nil, err } for _, page := range pages { // Each page gets its own set: pages define blocks of the same // names ("title", "content"), so parsing them all into one // template would leave whichever was parsed last defining both // for everybody. set := template.New(baseTemplate).Funcs(funcs()) set, err = set.ParseFS(templates.FS, append(append([]string{}, shared...), page)...) if err != nil { return nil, fmt.Errorf("parsing template %s: %w", page, err) } r.pages[path.Base(page)] = set } if len(r.pages) == 0 { return nil, errNoTemplates } return r, nil } // split separates the embedded set into the files every page needs // (the base document and the partials) and the page templates // themselves. func split(fsys fs.FS) ([]string, []string, error) { partials, err := fs.Glob(fsys, "partials/*.html") if err != nil { return nil, nil, fmt.Errorf("globbing partials: %w", err) } top, err := fs.Glob(fsys, "*.html") if err != nil { return nil, nil, fmt.Errorf("globbing templates: %w", err) } var pages []string shared := append([]string{}, partials...) for _, f := range top { if strings.TrimSuffix(path.Base(f), ".html") == baseTemplate { shared = append(shared, f) continue } pages = append(pages, f) } sort.Strings(shared) sort.Strings(pages) return shared, pages, nil } // NewPage returns the common data, filled in from build-time globals // and the request's CSRF field. func (r *Renderer) NewPage(csrfField template.HTML) Page { return Page{ AppName: r.globals.Appname, Version: r.globals.Version, Buildarch: r.globals.Buildarch, Uptime: time.Since(r.started).Round(time.Second).String(), CSRFField: csrfField, } } // Execute renders a page into w. It buffers first: see the package // comment. func (r *Renderer) Execute(w io.Writer, name string, data any) error { set, ok := r.pages[name] if !ok { return fmt.Errorf("%w: %s", errUnknownTemplate, name) } var buf bytes.Buffer err := set.ExecuteTemplate(&buf, baseTemplate, data) if err != nil { return fmt.Errorf("executing template %s: %w", name, err) } _, err = buf.WriteTo(w) if err != nil { return fmt.Errorf("writing rendered template %s: %w", name, err) } return nil } // HTML renders a page to an http.ResponseWriter with the given status. // A render failure after the buffer succeeded cannot happen, so the // status written here is always the status the client sees. func (r *Renderer) HTML( w http.ResponseWriter, status int, name string, data any, ) error { var buf bytes.Buffer err := r.Execute(&buf, name, data) if err != nil { return err } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, err = buf.WriteTo(w) if err != nil { return fmt.Errorf("writing response: %w", err) } return nil } // Names returns the compiled page names, sorted. Tests use it to assert // that every embedded page really compiled. func (r *Renderer) Names() []string { names := make([]string, 0, len(r.pages)) for name := range r.pages { names = append(names, name) } sort.Strings(names) return names }