thermograph/frontend/server/internal/content/content.go
emi a4ecb51401
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +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
}