// 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 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 }