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
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user