All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m0s
PR build (required check) / gate (pull_request) Successful in 2s
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).
177 lines
6.3 KiB
Go
177 lines
6.3 KiB
Go
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
|
|
}
|