thermograph/frontend/server/internal/render/render.go

137 lines
5.5 KiB
Go
Raw Normal View History

frontend: rewrite the SSR content service in Go (#28) Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth -- every route fetches from the backend's /content/* API. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures and every route compared byte-for-byte, confirmed programmatically. That process caught defects unit tests alone missed, since map[string]any has no compile-time field check: - Render-context keys were snake_case throughout while the templates read PascalCase fields. A missing map key doesn't error, it silently renders empty -- title, meta description, canonical URL, OpenGraph tags, and the homepage's entire ranked list were blank on every page despite every route returning 200. Fixed by renaming every key to match each template's own documented field contract, and passing API structs straight through wherever their fields already matched (removes a whole layer of future drift risk). - Three pages 500'd: ToolHref needed a composed href, not a bare "lat,lon" fragment; the records table needed the raw API struct. - JSON-LD was double-encoded: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00
// Package render wires the HTML template engine: an embed.FS of templates
// (self-contained binary — no template files to ship beside it), a Render
// helper, and the FuncMap seam the template/format port fills in.
//
// The templates themselves are owned elsewhere (the Jinja -> html/template
// port); this package only establishes HOW they are parsed and executed:
//
// - Files live in internal/render/templates/*.tmpl and are named by base
// filename (html/template's ParseFS convention), e.g. "city.html.tmpl".
// Jinja's {% extends %}/{% block %} maps to {{define}}/{{template}} with a
// shared base layout; each page template {{define}}s the blocks the base
// references and is executed by its own file name.
// - Formatting helpers arrive via the FuncMap passed to New. html/template
// FuncMaps are engine-global, so anything unit-scoped (the Python used a
// ContextVar the handlers set per request — format.py's unit_scope) must
// NOT be a bare FuncMap closure over mutable state; either pre-format in
// the handler (content.py already did for most values) or pass a
// formatter value in the render data and call its methods.
//
// It also owns the response-side ETag behavior ported from content.py's
// _respond_html and app.py's _page: a weak SHA-1 ETag over the rendered
// bytes, and the two If-None-Match comparison flavors the Python had.
package render
import (
"bytes"
"crypto/sha1"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"strings"
)
//go:embed templates
var templatesFS embed.FS
// Engine holds the parsed template set. Templates only change on a redeploy
// (a fresh process), so everything is parsed exactly once, at New — the Go
// analogue of the Jinja environment's auto_reload=False.
type Engine struct {
t *template.Template
}
// New parses every embedded template with the given FuncMap. Call it once at
// boot with the full FuncMap — the function NAMES must all be registered
// before parsing, because html/template resolves them at parse time.
//
// The FuncMap keys the templates rely on (the Jinja globals/filters
// content.py registered; implementations come from the format port):
//
// "temp" func(f *float64) template.HTML — <span class="temp" …>N°F</span>
// "temp_bare" func(f *float64) template.HTML — bare-degree variant
// "temp_class" func(f *float64) string — diverging-palette tier name
// "ordinal" func(pct any) string — percentile -> "66th", floored into 1..99
// "head_verify" func() template.HTML — search-console <meta> tags
// "comment" func(s string) template.HTML — a visible <!-- --> that
// survives parsing (html/template strips literal HTML
// comments; wrapping the string template.HTML inserts it as
// trusted content instead of markup the parser interprets)
// "jscomment" func(s string) template.JS — a visible "// ..." line
// that survives inside <script> (html/template ALSO strips
// literal JS comments there; needs template.JS specifically,
// not template.HTML, which gets re-escaped as a JS value)
func New(funcs template.FuncMap) (*Engine, error) {
t, err := template.New("").Funcs(funcs).ParseFS(templatesFS, "templates/*.tmpl")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Engine{t: t}, nil
}
// Render executes template `name` (its base filename, e.g. "city.html.tmpl")
// into w.
func (e *Engine) Render(w io.Writer, name string, data any) error {
return e.t.ExecuteTemplate(w, name, data)
}
// RenderBytes executes into memory. Page handlers need the full body anyway
// (the ETag is a hash of the rendered HTML), so this is the primary entry.
func (e *Engine) RenderBytes(name string, data any) ([]byte, error) {
var buf bytes.Buffer
if err := e.t.ExecuteTemplate(&buf, name, data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ETag computes the weak ETag the Python stamped on every rendered page:
// W/"<first 20 hex chars of sha1(body)>".
func ETag(body []byte) string {
sum := sha1.Sum(body)
return `W/"` + hex.EncodeToString(sum[:])[:20] + `"`
}
// NotModifiedAny reports whether the If-None-Match header value matches etag
// under content.py's _respond_html semantics: the header is split on commas
// and each candidate whitespace-trimmed (a browser may send several).
func NotModifiedAny(ifNoneMatch, etag string) bool {
if ifNoneMatch == "" {
return false
}
for _, cand := range strings.Split(ifNoneMatch, ",") {
if strings.TrimSpace(cand) == etag {
return true
}
}
return false
}
// NotModifiedExact reports whether the header exactly equals etag — app.py's
// _page (SPA shells) compared the raw header, no splitting. Kept separate so
// the port preserves each route's precise behavior.
func NotModifiedExact(ifNoneMatch, etag string) bool {
return ifNoneMatch != "" && ifNoneMatch == etag
}
// WriteHTML writes a rendered page with its ETag, answering a matching
// If-None-Match with an empty 304 (content.py's comma-set semantics). The
// Content-Type matches the Python service's exactly.
func WriteHTML(w http.ResponseWriter, r *http.Request, body []byte) {
etag := ETag(body)
h := w.Header()
h.Set("ETag", etag)
if NotModifiedAny(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
h.Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
w.Write(body)
}
}