Files
simplexcalc/internal/render/render.go
T
clawbot f8ce8cef83 Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after
script/rename simplexcalc.

Model: opus-5-5
2026-09-26 21:38:57 +00:00

241 lines
5.8 KiB
Go

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