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
268 lines
11 KiB
Go
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
|
|
}
|