The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
241 lines
5.8 KiB
Go
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
|
|
}
|