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,13 @@
|
||||
package render
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// errNoTemplates means the embed matched nothing — a build that
|
||||
// produced a binary with no pages in it.
|
||||
errNoTemplates = errors.New("no page templates were embedded")
|
||||
|
||||
// errUnknownTemplate means a handler asked for a page that is not
|
||||
// in the embedded set: a typo, caught by that handler's test.
|
||||
errUnknownTemplate = errors.New("unknown template")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package render_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/database"
|
||||
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||
"sneak.berlin/go/simplexcalc/internal/render"
|
||||
"sneak.berlin/go/simplexcalc/templates"
|
||||
)
|
||||
|
||||
func newRenderer(t *testing.T) *render.Renderer {
|
||||
t.Helper()
|
||||
|
||||
r, err := render.New(nil, render.Params{
|
||||
Globals: &globals.Globals{
|
||||
Appname: "simplexcalc", Version: "test", Buildarch: "amd64",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("compiling templates: %v", err)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// TestEveryEmbeddedPageCompiles is the reason New parses everything at
|
||||
// startup: a template that does not compile must be a process that does
|
||||
// not start, and this is what proves the set is complete rather than
|
||||
// just non-empty.
|
||||
func TestEveryEmbeddedPageCompiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRenderer(t)
|
||||
|
||||
embedded, err := templates.FS.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("reading embedded templates: %v", err)
|
||||
}
|
||||
|
||||
want := 0
|
||||
|
||||
for _, e := range embedded {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".html") && e.Name() != "base.html" {
|
||||
want++
|
||||
}
|
||||
}
|
||||
|
||||
if want == 0 {
|
||||
t.Fatal("no page templates were embedded, so this test proves nothing")
|
||||
}
|
||||
|
||||
if got := len(r.Names()); got != want {
|
||||
t.Errorf("compiled %d pages (%v), embedded %d", got, r.Names(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexRendersEmbeddedContent renders the page the service serves
|
||||
// at /, with real data, and checks that the base document, both
|
||||
// partials and the page body all made it into one response.
|
||||
func TestIndexRendersEmbeddedContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRenderer(t)
|
||||
|
||||
data := render.IndexPage{
|
||||
Page: r.NewPage(template.HTML(`<input type="hidden" name="csrf" />`)),
|
||||
WidgetCount: 1,
|
||||
Widgets: []database.Widget{
|
||||
{
|
||||
ID: "abc", Name: "a widget", SizeBytes: 4096,
|
||||
CreatedAt: time.Now().Add(-time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := r.Execute(&buf, "index.html", data)
|
||||
if err != nil {
|
||||
t.Fatalf("rendering index: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"<!doctype html>", // base document
|
||||
`href="/static/css/style.css"`, // base document links the embedded asset
|
||||
"<nav>", // navbar partial
|
||||
"<footer>", // footer partial
|
||||
"a widget", // page data
|
||||
"4.0 KiB", // the bytes template func
|
||||
"ago", // the since template func
|
||||
`name="csrf"`, // the CSRF field was placed
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered page is missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLEscapesUserData: the name comes from a form, and html/template
|
||||
// is only a defence if the data actually goes through it.
|
||||
func TestHTMLEscapesUserData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRenderer(t)
|
||||
|
||||
data := render.IndexPage{
|
||||
Page: r.NewPage(""),
|
||||
WidgetCount: 1,
|
||||
Widgets: []database.Widget{
|
||||
{ID: "x", Name: `<script>alert(1)</script>`, CreatedAt: time.Now()},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := r.Execute(&buf, "index.html", data)
|
||||
if err != nil {
|
||||
t.Fatalf("rendering: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(buf.String(), "<script>alert(1)</script>") {
|
||||
t.Error("a widget name was rendered as live markup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagesDoNotShareBlocks: index.html and error.html both define
|
||||
// "title" and "content". Parsed into one set, the last one parsed would
|
||||
// define both for everybody, and every page would render as whichever
|
||||
// that was.
|
||||
func TestPagesDoNotShareBlocks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRenderer(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := r.Execute(&buf, "error.html", render.ErrorPage{
|
||||
Page: r.NewPage(""), Status: 404, Message: "gone fishing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rendering error page: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "gone fishing") {
|
||||
t.Errorf("error page did not render its own content:\n%s", out)
|
||||
}
|
||||
|
||||
if strings.Contains(out, "widgets (") {
|
||||
t.Error("the error page rendered the index's content block")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownTemplateIsAnError, rather than an empty 200.
|
||||
func TestUnknownTemplateIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRenderer(t)
|
||||
|
||||
err := r.Execute(&bytes.Buffer{}, "nope.html", nil)
|
||||
if err == nil {
|
||||
t.Fatal("rendering a template that does not exist must fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWritesStatusAndType checks the response contract, since the
|
||||
// handlers rely on it for every page they serve.
|
||||
func TestHTMLWritesStatusAndType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newRenderer(t)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := r.HTML(w, 404, "error.html", render.ErrorPage{
|
||||
Page: r.NewPage(""), Status: 404, Message: "no",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rendering: %v", err)
|
||||
}
|
||||
|
||||
if w.Code != 404 {
|
||||
t.Errorf("status = %d, want 404", w.Code)
|
||||
}
|
||||
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user