thermograph/frontend/server/internal/content/funcmap.go

178 lines
6.3 KiB
Go
Raw Normal View History

frontend: rewrite the SSR content service in Go Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth here -- every route fetches from the backend's /content/* API, so this is I/O-bound glue with no hard-porting wall; the risk was always in reproducing the rendering exactly, not the language. 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 (frontend/tests/fixtures/) and every one of the 11 routes compared byte-for-byte. The only surviving differences after that process are insignificant inter-tag whitespace and one attribute where Go's stricter escaper HTML-encodes an apostrophe Jinja left literal (functionally identical in every browser) -- confirmed programmatically by normalizing whitespace and unescaping before diffing, not by eyeballing. That process caught defects unit tests alone would have missed, because map[string]any has no compile-time field check: - Render-context keys were snake_case throughout (content.py's Jinja convention, ported verbatim) while the templates -- written independently -- read PascalCase fields. A missing map key doesn't error in html/template, it silently renders empty, so this was invisible in every status code and every "it built" signal: title, meta description, canonical URL, OpenGraph tags, the homepage's entire ranked list, and the brand-tag/nav-active state were all blank across every page. Fixed by renaming every key to match each template's own header comment (the authoritative per-page field contract) and, where an API struct's exported fields already matched what a template needed (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the struct straight through instead of hand-rewrapping it in a map -- removes a whole layer of future drift risk, not just this instance of it. - Three pages 500'd outright: `.ToolHref` needed a fully-composed href string, not the bare "lat,lon" fragment the handlers were building; the all-time-records table needed the raw contentapi.AllTimeRecords struct, not a re-wrapped map. - JSON-LD was being double-encoded: `<script type="application/ld+json">` is JAVASCRIPT context to html/template's contextual escaper regardless of the script's `type` attribute, so a template.HTML-typed value placed there gets re-escaped as a quoted JS string instead of emitted raw -- the entire structured-data payload shipped as a JSON string containing JSON, which no crawler would parse as the intended object. Needed template.JS instead, the type that actually means "trusted JS source." The glossary term page's JSON-LD was simply never built at all (the Jinja original assembled it inline in the template rather than through content.py's context dict, and that got lost in translation) -- added. - html/template silently strips literal HTML comments AND JavaScript comments from the parsed output (verified in isolation, zero template actions involved) -- confirmed as real engine behavior, not a bug in either port, so both need a FuncMap function returning template.HTML / template.JS respectively to survive parsing rather than a literal `<!-- -->` or `//` in the template source. Packaging: multi-stage Go build, final image alpine (not distroless -- the Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two defects caught before they reached a host: - The Swarm stack overrides `entrypoint:` with no `command:`, which drops the image's own CMD entirely (Docker/Swarm semantics, not merged) -- env-entrypoint.sh then fell through to its hardcoded `exec uvicorn app:app` fallback, which doesn't exist in this image. Every deploy would have exited 127. Fixed with an explicit `command:` on the stack's frontend service, and corrected the shim's stale comment claiming CMD passes through automatically. - `COPY --chown=thermograph` resolves the group by NAME at copy time; Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so the classic (non-BuildKit) Docker builder -- which this CI runner falls back to, since it installs plain `docker.io` with no buildx plugin -- failed outright. Fixed with an explicit group and numeric --chown. Verification: go build/vet/test -race clean across all packages; the Docker image builds and passes its embedded go test step under both BuildKit and the classic builder; shellcheck 0 findings on the one script touched; rebased onto current main (the ERA5 lake stack landed on both main and dev during this work -- confirmed additive, no overlap with frontend/daemon).
2026-07-24 00:49:49 +00:00
package content
import (
"fmt"
"html/template"
"strings"
"unicode/utf16"
"thermograph/frontend/internal/config"
"thermograph/frontend/internal/format"
)
// FuncMap builds the template helper set (the Jinja globals/filters
// content.py registered on its Environment). Pass it to render.New at boot —
// html/template resolves names at parse time.
//
// Unit-dependent helpers (temp, temp_bare) take the active unit as their
// FIRST argument: the Python scoped it in a ContextVar, but html/template
// FuncMaps are engine-global, so per-request state must arrive through the
// call. Every page context carries the active unit under the "unit" key
// (same value as "unit_default"), so template call sites are
// {{temp $.unit .high_f}} where Jinja had {{ temp(m.high_f) }}.
func FuncMap(cfg config.Config) template.FuncMap {
return template.FuncMap{
// temp / temp_bare / temp_class accept the loosely-typed values the
// templates hand them: nullable *float64 payload fields, plain
// float64s, and int literals (Jinja's {{ temp(-10) }}).
"temp": func(unit, f any) template.HTML {
return format.Temp(unitArg(unit), floatArg(f))
},
"temp_bare": func(unit, f any) template.HTML {
return format.TempBare(unitArg(unit), floatArg(f))
},
"temp_class": func(f any) string {
return format.TempClass(floatArg(f))
},
// The `ordinal` filter: {{ c.percentile|ordinal }} -> {{ordinal .percentile}}.
"ordinal": format.PctOrdinal,
// Search-engine ownership-verification <meta> tags, from env (empty
// when unset). No backend dependency — the same two variables the
// Python read, resolved once into config.
"head_verify": func() template.HTML {
return headVerifyHTML(cfg.GoogleVerify, cfg.BingVerify)
},
// Jinja's |tojson (glossary_term.html.j2's inline JSON-LD): JSON with
// the HTML-unsafe characters escaped so it can sit inside <script>.
"tojson": toJSON,
// A visible HTML comment that survives html/template's parser. A
// LITERAL <!-- --> in a .tmpl file does NOT reach the output --
// html/template strips real HTML comments while parsing (verified:
// a template consisting of only `<p>a</p><!-- x --><p>b</p>` renders
// as `<p>a</p><p>b</p>`). Marking the string template.HTML makes it a
// trusted content INSERTION rather than markup the parser interprets,
// so it passes through untouched. Only ever called with developer-
// authored literals in the templates themselves, never request data.
"comment": func(s string) template.HTML {
return template.HTML("<!-- " + s + " -->")
},
// The same problem as `comment`, one layer down: html/template's
// contextual escaper ALSO strips JavaScript `//`/`/* */` comments from
// <script> bodies while parsing (verified the same way: an inline
// script containing only a `// comment` line and a `var` statement
// loses the comment line entirely on render, with zero {{ }} actions
// anywhere nearby). Needs template.JS specifically, not template.HTML
// -- inside a <script> context html/template treats template.HTML as
// untrusted and re-escapes it as a quoted JS value (the same bug
// class the JSONLDStr sites hit); template.JS is what means "emit
// this JS source as-is".
"jscomment": func(s string) template.JS {
return template.JS("// " + s)
},
}
}
// unitArg coerces the template-side unit value ("" / "C" / "F", as string or
// format.Unit) — the context stores it as a plain string for rendering the
// data-unit-default attribute.
func unitArg(v any) format.Unit {
switch u := v.(type) {
case format.Unit:
return u
case string:
return format.Unit(u)
case nil:
return ""
}
return ""
}
// floatArg coerces template arguments to the nullable float the format
// helpers take: nil / nil *float64 mean "missing" (rendered as the em-dash),
// numeric literals are Go ints inside templates.
func floatArg(v any) *float64 {
switch x := v.(type) {
case nil:
return nil
case *float64:
return x
case float64:
return &x
case int:
f := float64(x)
return &f
}
return nil
}
// headVerifyHTML is the port of content.py's head_verify_html: one <meta>
// per configured verification token, joined with the newline + two-space
// indent the Jinja Markup join produced. Token values are attribute-escaped
// exactly like markupsafe's Markup.format did.
func headVerifyHTML(google, bing string) template.HTML {
var metas []string
if google != "" {
metas = append(metas, fmt.Sprintf(`<meta name="google-site-verification" content="%s">`,
template.HTMLEscapeString(google)))
}
if bing != "" {
metas = append(metas, fmt.Sprintf(`<meta name="msvalidate.01" content="%s">`,
template.HTMLEscapeString(bing)))
}
return template.HTML(strings.Join(metas, "\n "))
}
// toJSON mirrors Jinja's |tojson filter (jinja2.utils.htmlsafe_json_dumps):
// json.dumps with ensure_ascii (every non-ASCII rune as a backslash-uXXXX
// escape, surrogate pairs beyond the BMP), then the HTML-unsafe characters
// <, >, & and ' replaced with their backslash-u00XX escapes so the result is
// safe inside a <script> block. Byte-for-byte the same output for the string
// values the templates feed it.
func toJSON(v any) (template.HTML, error) {
s, ok := v.(string)
if !ok {
// The templates only |tojson strings (term, page_description). Fail
// loudly if that changes rather than guessing at dict key order.
return "", fmt.Errorf("tojson: unsupported type %T", v)
}
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '<', '>', '&', '\'':
// Jinja's htmlsafe_json_dumps post-replaces these four with
// their backslash-u00XX escapes so the JSON can sit inside a
// <script> block without ever closing it.
fmt.Fprintf(&b, `\u%04x`, r)
default:
switch {
case r < 0x20:
fmt.Fprintf(&b, `\u%04x`, r)
case r < 0x80: // ASCII (incl. DEL) passes through, like json.dumps
b.WriteRune(r)
case r <= 0xffff:
fmt.Fprintf(&b, `\u%04x`, r)
default:
hi, lo := utf16.EncodeRune(r)
fmt.Fprintf(&b, `\u%04x\u%04x`, hi, lo)
}
}
}
b.WriteByte('"')
return template.HTML(b.String()), nil
}