thermograph/frontend/server/internal/content/content.go
emi 92e74c585a
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
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

268 lines
11 KiB
Go

// Package content is the Go port of content.py: the server-rendered,
// crawlable content pages (climate hub / per-city / month / records /
// glossary / about / privacy / homepage) plus robots.txt, sitemap.xml and the
// IndexNow key file. Every route handler fetches its data from the backend's
// SSR content API (internal/contentapi) — page_title / page_description /
// canonical_path / breadcrumb / jsonld are all computed by the backend
// (backend/api/content_payloads.py), never here.
//
// Render-context convention: handlers hand templates a map[string]any whose
// keys are the EXPORTED-Go-style PascalCase names the templates (see
// internal/render/templates) access — e.g. content.py's snake_case
// `year_range` context key is `YearRange` here. This does NOT mirror the
// Jinja context names verbatim: html/template's field/map-key resolution is
// case-sensitive with no fallback, and a map has no compile-time check for a
// wrong or stale key, so a mismatch fails SILENTLY (a missing string key
// renders empty, not an error — only an index/range over the resulting nil
// can panic). PascalCase was chosen to read as ordinary Go field access in
// the templates ({{.Display}}, not {{.display}}), and every template's own
// header comment documents the exact field set it expects — treat that
// comment as the authoritative contract when adding or renaming a key here.
// Nested display objects are maps/slices with the same PascalCase discipline.
//
// The one Jinja construct with no Go analogue is dict iteration order:
// Python dicts preserve insertion order, Go maps do not, so anything the
// templates iterate (hub country groups, glossary terms) is a SLICE here, in
// the same order the Python dict would have yielded.
package content
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
"thermograph/frontend/internal/config"
"thermograph/frontend/internal/contentapi"
"thermograph/frontend/internal/contentdata"
"thermograph/frontend/internal/format"
"thermograph/frontend/internal/render"
)
// API is the slice of contentapi.Client the handlers consume — an interface
// so tests can substitute fixture-backed fakes exactly the way the Python
// suite monkeypatched api_client (tests/conftest.py's `client` fixture).
// *contentapi.Client satisfies it.
type API interface {
Hub() (*contentapi.HubPayload, error)
Sitemap() ([]contentapi.SitemapEntry, error)
IndexNowKey() (string, error)
Home() (*contentapi.HomePayload, error)
City(slug, origin string) (*contentapi.CityPayload, error)
CityMonth(slug, month string) (*contentapi.MonthPayload, error)
CityRecords(slug, origin string) (*contentapi.RecordsPayload, error)
}
// requiredPages are the pages.yaml keys the handlers read (content.py's
// PAGES[...] lookups). Python failed lazily — a KeyError 500 on first hit of
// the page — but a missing key is a content-file bug that should break the
// boot, same philosophy as the loader's own fail-loud validation.
var requiredPages = []string{"hub", "glossary_index", "about", "privacy"}
// Handlers owns every content route. Construct with New, wire with Register.
type Handlers struct {
cfg config.Config
api API
engine *render.Engine
glossary *contentdata.Glossary
pages map[string]contentdata.Page
// bootDate is a stable per-process "content last built" date — crawlers
// should see sitemap <lastmod> change on a real rebuild (a fresh
// process), not churn on every request (content.py's _BOOT_DATE).
bootDate string
log *log.Logger
}
// New builds the handler set. The engine must have been constructed with
// FuncMap(cfg) (the template helpers close over the same config). logger may
// be nil (falls back to the standard logger).
func New(cfg config.Config, api API, engine *render.Engine, glossary *contentdata.Glossary, pages map[string]contentdata.Page, logger *log.Logger) (*Handlers, error) {
for _, key := range requiredPages {
if _, ok := pages[key]; !ok {
return nil, fmt.Errorf("pages.yaml: missing required page %q", key)
}
}
if logger == nil {
logger = log.Default()
}
return &Handlers{
cfg: cfg,
api: api,
engine: engine,
glossary: glossary,
pages: pages,
bootDate: time.Now().Format("2006-01-02"),
log: logger,
}, nil
}
// Register attaches every content route (the port of content.register).
// static is the same handler main.go mounts for static assets: the lazy
// IndexNow fallback route has to claim the whole single-segment pattern
// ({BASE}/{token} — Go's mux cannot express the Python's "/{token}.txt"
// suffix wildcard) and must hand non-.txt requests (app.js, style.css, …)
// back to the file server, so explicit page routes still win on precedence
// and everything else keeps serving. Go 1.22 mux precedence (most-specific
// pattern wins) replaces Starlette's registration-order rule; "GET" patterns
// serve HEAD too, covering the Python's methods=["GET", "HEAD"] routes.
func (h *Handlers) Register(mux *http.ServeMux, static http.Handler) {
base := h.cfg.Base
mux.HandleFunc("GET "+base+"/robots.txt", h.RobotsTxt)
mux.HandleFunc("GET "+base+"/sitemap.xml", h.SitemapXML)
h.registerIndexNow(mux, static)
if base == "" {
mux.HandleFunc("GET /{$}", h.HomePage)
} else {
mux.HandleFunc("GET "+base+"/{$}", h.HomePage)
}
mux.HandleFunc("GET "+base+"/about", h.AboutPage)
mux.HandleFunc("GET "+base+"/privacy", h.PrivacyPage)
mux.HandleFunc("GET "+base+"/glossary", h.GlossaryIndex)
mux.HandleFunc("GET "+base+"/glossary/{term}", h.GlossaryTerm)
mux.HandleFunc("GET "+base+"/climate", h.HubPage)
mux.HandleFunc("GET "+base+"/climate/{slug}", h.CityPage)
mux.HandleFunc("GET "+base+"/climate/{slug}/records", h.RecordsPage)
mux.HandleFunc("GET "+base+"/climate/{slug}/{month}", h.MonthPage)
}
// origin reconstructs the browser-facing origin of a request.
// x-forwarded-host takes precedence over Host: when reached via backend's
// internal proxy fallback (no Caddy in front — see _proxy_to_frontend in the
// backend's web/app.py), Host is the internal hop's own address, not the
// browser-facing one.
func origin(r *http.Request) string {
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
if r.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host // covers both the Host header and the URL netloc
}
return proto + "://" + host
}
// respondHTML renders a template and writes it with the weak-ETag /
// If-None-Match handling of content.py's _respond_html. It stamps the shared
// context keys every page gets (Base / AssetBase / AssetBaseURL / Origin /
// BaseURL / UnitDefault / Fmt) plus defaults for the keys base.html.j2 read
// through |default() or compared while possibly undefined (BrandTag,
// MainClass, Section) — Jinja tolerated undefined names, html/template's eq
// does not, so the defaults move here.
func (h *Handlers) respondHTML(w http.ResponseWriter, r *http.Request, tmpl string, unit format.Unit, ctx map[string]any) {
o := origin(r)
// og:image (an Open Graph tag, which the spec requires be absolute) is
// the one place a *static asset* reference needs an absolute URL always,
// not just when THERMOGRAPH_API_BASE_PUBLIC happens to be set —
// AssetBase is already absolute in that case, otherwise it's the same
// relative Base base_url itself is built from, so prefixing with this
// request's own origin recovers exactly today's behavior in the default
// topology.
assetBaseURL := h.cfg.AssetBase
if !isAbsoluteURL(assetBaseURL) {
assetBaseURL = o + assetBaseURL
}
ctx["Base"] = h.cfg.Base
ctx["AssetBase"] = h.cfg.AssetBase
ctx["AssetBaseURL"] = assetBaseURL
ctx["Origin"] = o
ctx["BaseURL"] = o + h.cfg.Base
// The request-scoped unit-bound formatter templates call as
// {{.Fmt.Temp v}} / {{.Fmt.TempBare v}} / {{.Fmt.TempClass v}} — set once
// here (not by each page's own ctx builder) since every page that embeds
// base.html.tmpl can reach it, and respondHTML already has the unit.
setDefault(ctx, "Fmt", format.NewFormatter(unit))
setDefault(ctx, "UnitDefault", string(unit)) // "" -> no data-unit-default attribute
setDefault(ctx, "BrandTag", "h1") // base.html.j2: brand_tag|default('h1')
setDefault(ctx, "MainClass", "content") // base.html.j2: main_class|default('content')
setDefault(ctx, "Section", "") // base.html.j2 compares it; undefined is not a Go option
body, err := h.engine.RenderBytes(tmpl, ctx)
if err != nil {
h.serverError(w, fmt.Errorf("render %s: %w", tmpl, err))
return
}
render.WriteHTML(w, r, body)
}
func setDefault(ctx map[string]any, key string, v any) {
if _, ok := ctx[key]; !ok {
ctx[key] = v
}
}
func isAbsoluteURL(s string) bool {
return len(s) >= 7 && (s[:7] == "http://" || (len(s) >= 8 && s[:8] == "https://"))
}
// crumb builds one locally-constructed breadcrumb element (the hub/glossary/
// about pages assemble their own trails; href nil marks the terminal crumb).
// contentapi.Crumb's own Name/Href fields are exactly what the "breadcrumb"
// template reads ({{$c.Name}}/{{$c.Href}}), so API-sourced breadcrumbs
// (j.Breadcrumb) pass straight through with no wrapping at all — this helper
// exists only for the trails a page assembles itself.
func crumb(name, href string) contentapi.Crumb {
c := contentapi.Crumb{Name: name}
if href != "" {
c.Href = &href
}
return c
}
// apiError is the port of content.py's _raise_api_error: the backend's 404
// (unknown city/month) and 503 (warming) map straight through — same status
// codes the pre-split in-process code raised. Any other error never got a
// response at all — a backend blip or restart — so it becomes a 503 too,
// rather than surfacing as a raw 500.
func (h *Handlers) apiError(w http.ResponseWriter, err error) {
var se *contentapi.StatusError
if errors.As(err, &se) {
writeDetail(w, se.StatusCode, se.Body)
return
}
writeDetail(w, http.StatusServiceUnavailable, "backend unavailable")
}
// writeDetail writes the {"detail": ...} JSON error body FastAPI's
// HTTPException produced, so error responses keep their shape across the
// rewrite (the backend's own error text rides through as the detail string,
// exactly like detail=e.response.text did).
func writeDetail(w http.ResponseWriter, status int, detail string) {
body, err := json.Marshal(map[string]string{"detail": detail})
if err != nil {
body = []byte(`{"detail":"error"}`)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(body)
}
// serverError is the unhandled-exception path (Jinja/template failures,
// malformed payloads): log it, answer a plain 500 like the Python framework
// did for an uncaught exception.
func (h *Handlers) serverError(w http.ResponseWriter, err error) {
h.log.Printf("content: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
// compactJSONLD re-emits the backend's jsonld bytes with insignificant
// whitespace removed — the Go analogue of json.dumps(j["jsonld"],
// ensure_ascii=False, separators=(",", ":")). Compacting the RAW bytes (not
// unmarshal + re-marshal) is what preserves the backend's key order; Go maps
// would sort keys and break golden-diff parity.
func compactJSONLD(raw json.RawMessage) (string, error) {
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
return "", fmt.Errorf("jsonld: %w", err)
}
return buf.String(), nil
}