diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
index 7b8e950..7ab6799 100644
--- a/.forgejo/workflows/build.yml
+++ b/.forgejo/workflows/build.yml
@@ -35,24 +35,24 @@ jobs:
- name: Build
run: docker build -t thermograph-${{ inputs.domain }}:ci ${{ inputs.domain }}/
- # Run the hermetic test tier INSIDE the image we just built (each image
- # ships tests/ + every runtime dep via COPY . /app/): the exact
+ # Run the hermetic test tier INSIDE the image we just built (the backend
+ # image ships tests/ + every runtime dep via COPY . /app/): the exact
# interpreter/deps that ship, none of the runner-environment quirks that
- # sank the old host-side boot check. Backend runs its full (hermetic)
- # suite; frontend runs only its unit tier -- its integration tier
- # (frontend/tests/integration/, needs a live backend container) stays a
- # local `make`/scripts concern, see frontend/scripts/backend-for-tests.sh.
+ # sank the old host-side boot check. Backend only: the frontend image is
+ # a Go binary with no interpreter or toolchain to test with -- its vet +
+ # test suite runs in frontend/Dockerfile's builder stage instead, so the
+ # Build step above already fails when the Go tests do; its integration
+ # tier (needs a live backend container) stays a local `make`/scripts
+ # concern, see frontend/scripts/backend-for-tests.sh.
# requirements-dev only layers pytest on top, so the install is tiny.
- # -u 0: the images' default user can't write to site-packages.
+ # -u 0: the image's default user can't write to site-packages.
# --entrypoint sh: backend's entrypoint is alembic-migrate + uvicorn --
# without the override the test command is swallowed as entrypoint args
- # and the container just boots the server forever (harmless no-op for
- # frontend, which has no entrypoint). unset THERMOGRAPH_BASE: the images
- # bake the prod root-mount (/), which moves every route off the
- # /thermograph prefix the tests are written against.
- - name: Run tests in the built image
+ # and the container just boots the server forever. unset
+ # THERMOGRAPH_BASE: the image bakes the prod root-mount (/), which moves
+ # every route off the /thermograph prefix the tests are written against.
+ - name: Run tests in the built image (backend only)
+ if: inputs.domain == 'backend'
run: |
- target=tests
- if [ "${{ inputs.domain }}" = "frontend" ]; then target=tests/unit; fi
- docker run --rm -u 0 --entrypoint sh thermograph-${{ inputs.domain }}:ci \
- -c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest $target -q"
+ docker run --rm -u 0 --entrypoint sh thermograph-backend:ci \
+ -c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests -q"
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..6da5f36
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,29 @@
+# The build only COPYs server/, static/ and content/ -- everything else just
+# bloats the context upload. Venvs especially: an untracked .venv silently
+# swallowed by a broad COPY tripled the backend image once (Stage 2); keep
+# them excluded even though no COPY should reach them.
+.git
+.gitignore
+.venv/
+.venv-test/
+__pycache__/
+**/__pycache__
+*.pyc
+.pytest_cache/
+**/node_modules
+tests/
+# ...except the golden fixtures the Go build stage copies in to run
+# internal/content's tests (see the Dockerfile's COPY tests/fixtures step).
+!tests/fixtures/
+!tests/fixtures/**
+tools/
+templates/
+scripts/
+*.py
+requirements.txt
+requirements-dev.txt
+docker-compose.test.yml
+Dockerfile
+.dockerignore
+Makefile
+*.md
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 6270314..692ecb9 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,40 +1,91 @@
# Thermograph frontend: server-rendered content pages, the interactive tool's
# SPA shells, and every static asset. Split from the monorepo (repo-split
-# Stage 7). No migrations, no DB, no pre-boot logic -- a plain shell-form CMD
-# is enough (unlike backend, no separate entrypoint script needed).
-FROM python:3.12-slim
+# Stage 7), rewritten as a Go service (server/). No migrations, no DB, no
+# pre-boot logic -- a plain exec-form CMD is enough (unlike backend, no
+# separate entrypoint script needed).
+#
+# Multi-stage: the golang builder runs vet + the full Go test suite before
+# building, so every published image provably passed the hermetic tier with
+# the exact toolchain that compiled the shipping binary (this replaces the
+# old in-image pytest step in .forgejo/workflows/build.yml -- the runtime
+# image carries no toolchain to test with). The final stage is Alpine, not
+# distroless: the Swarm stack (infra/deploy/stack/thermograph-stack.yml)
+# bind-mounts a bash entrypoint shim (env-entrypoint.sh) over this image's
+# entrypoint, so bash must exist inside the container; curl serves the
+# HEALTHCHECK, same line as ever.
+FROM golang:1.26 AS builder
-RUN apt-get update \
- && apt-get install -y --no-install-recommends curl \
- && rm -rf /var/lib/apt/lists/*
+WORKDIR /src
-COPY requirements.txt /tmp/requirements.txt
-RUN pip install --no-cache-dir -r /tmp/requirements.txt
+# Module graph first so the download layer caches across source-only changes.
+COPY server/go.mod server/go.sum ./
+RUN go mod download
-COPY . /app/
+COPY server/ ./
-RUN useradd --system --create-home --uid 10001 thermograph \
- && chown -R thermograph:thermograph /app
+# internal/content's tests read two directories the same three-levels-up
+# relative path away from the test file's own package dir (go test always
+# runs with cwd set there): the committed golden fixtures (frontend/tests/
+# fixtures/*.json — the same set the Python golden-diff comparison used) and
+# the SSR copy (frontend/content/*.yaml, content_loader.go's LoadGlossary
+# etc.). This stage only copies server/ into /src (so /src has no "frontend/"
+# parent to climb to), which is why both land at container-root paths here
+# instead — same three-levels-up relationship the tests' relative paths
+# expect, just anchored differently.
+COPY tests/fixtures /tests/fixtures
+COPY content /content
+
+RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
+
+# Static binary: CGO off (no libc dependency on Alpine), -trimpath for
+# reproducible paths, -s -w to strip debug info the container never uses.
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
+ -o /out/thermograph-frontend .
+
+FROM alpine:3.22
+
+# bash: required by the Swarm stack's env-entrypoint.sh shim (see above).
+# curl: the HEALTHCHECK below (pulls in ca-certificates as a dependency).
+RUN apk add --no-cache bash curl
+
+# Same uid as the Python image: 10001 is the uid infra provisions readable
+# secrets for (deploy-stack.sh installs /etc/thermograph/stack.env
+# uid-10001-readable) -- do not change it. Explicit group (Alpine's `adduser
+# -S` with no -G falls back to an existing system group, not a same-named
+# one -- a bare `--chown=thermograph` below then has no "thermograph" group
+# to resolve, which the classic (non-BuildKit) builder rejects outright).
+RUN addgroup -S -g 10001 thermograph \
+ && adduser -S -u 10001 -G thermograph -h /home/thermograph thermograph
+
+COPY --from=builder /out/thermograph-frontend /usr/local/bin/thermograph-frontend
+
+# The binary embeds its HTML templates (server/internal/render); static/ and
+# content/ stay on disk, resolved relative to the working directory (see
+# server/internal/config: StaticDir="static", ContentDir="content"), so /app
+# mirrors the repo layout the config expects. Read-only at runtime -- the
+# service is stateless and holds no data of its own.
+#
+# Numeric --chown, not the name: needs no /etc/passwd|group lookup at COPY
+# time, so it works identically under BuildKit and the classic builder (the
+# CI runner installs plain `docker.io`, no buildx plugin, so a build there
+# silently uses the classic builder unless BuildKit is forced).
+COPY --chown=10001:10001 static/ /app/static/
+COPY --chown=10001:10001 content/ /app/content/
USER thermograph
WORKDIR /app
+# No WORKERS knob anymore: uvicorn needed a process count, the Go server
+# handles concurrency in one process. (The stack/compose files never set it
+# for frontend, so nothing references it.)
ENV PORT=8080 \
- WORKERS=1 \
- THERMOGRAPH_BASE=/ \
- PYTHONUNBUFFERED=1
+ THERMOGRAPH_BASE=/
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1
-# Worker count is env-driven (mirrors thermograph-backend's Dockerfile/
-# entrypoint WORKERS pattern) so infra can raise it later with no code
-# change. Default of 1 preserves today's exact behavior (no --workers was
-# ever passed before). api_client's TTL cache and register()'s IndexNow-key
-# handling are both in-process only -- multiple workers just each keep their
-# own independent cache, and the IndexNow key is fetched fresh FROM the
-# backend by every worker's own boot, so there's no cross-worker state to
-# diverge -- safe to raise whenever infra wants the throughput.
-CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} --workers ${WORKERS:-1}
+# Exec form, no shell wrapper: the Swarm shim receives this CMD as $@ and
+# execs the binary directly; PID 1 gets SIGTERM and shuts down gracefully.
+CMD ["/usr/local/bin/thermograph-frontend"]
diff --git a/frontend/server/README.md b/frontend/server/README.md
new file mode 100644
index 0000000..9eb1425
--- /dev/null
+++ b/frontend/server/README.md
@@ -0,0 +1,59 @@
+# thermograph-frontend (Go)
+
+The SSR frontend service, ported from the Python implementation one directory
+up (`app.py` / `content.py` / `api_client.py` / `format.py`): server-rendered
+content pages, the interactive tool's SPA shells, and every static asset. It
+is I/O-bound glue over the backend's `/content/*` JSON API — no climate maths,
+no database, no auth.
+
+## Layout
+
+ go.mod module thermograph/frontend (Go 1.26)
+ main.go config + mux + static + graceful shutdown
+ internal/config/ every env var the service reads (same names,
+ defaults and required/optional split as the
+ Python — see config.go's field docs)
+ internal/contentapi/ backend /content/* client: TTL cache, bounded
+ LRU, per-key single-flight, origin forwarding;
+ typed payloads in types.go
+ internal/render/ html/template engine over an embed.FS +
+ response ETag helpers (W/"sha1[:20]",
+ If-None-Match handling)
+ internal/render/templates/ the page templates (embedded; *.tmpl)
+ internal/contentdata/ glossary.yaml / pages.yaml loader (fail-loud
+ validation, file order preserved)
+
+Dependencies: stdlib plus `gopkg.in/yaml.v3` — the committed SSR copy in
+`frontend/content/*.yaml` is shared with the rest of the repo and uses block/
+folded scalars, so a YAML parser is genuinely required.
+
+## Run locally
+
+ cd frontend/server
+ go build -o thermograph-frontend .
+ cd .. # static/ and content/ resolve relative to the working dir
+ THERMOGRAPH_API_BASE_INTERNAL=http://127.0.0.1:8137 \
+ THERMOGRAPH_BASE=/thermograph \
+ ./server/thermograph-frontend
+
+`THERMOGRAPH_API_BASE_INTERNAL` is required — the boot fails loudly without it,
+same as the Python raised at import. Other env vars (all optional):
+`THERMOGRAPH_BASE` (default `/thermograph`; the image sets `/`),
+`THERMOGRAPH_API_VERSION` (default `v2` — bump only per the API-version pinning
+contract in `frontend/CLAUDE.md`), `THERMOGRAPH_API_BASE_PUBLIC`,
+`THERMOGRAPH_SSR_CACHE_TTL` (seconds, default 600),
+`THERMOGRAPH_GOOGLE_VERIFY` / `THERMOGRAPH_BING_VERIFY`, and `PORT`
+(default 8080).
+
+The process expects `static/` and `content/` in its working directory
+(`frontend/` locally, `/app` in the image). Templates are embedded in the
+binary; static assets and the YAML copy are read from disk.
+
+## Test / verify
+
+ cd frontend/server
+ go build ./... && go vet ./... && go test ./...
+
+The deployed binary is `/usr/local/bin/thermograph-frontend` inside the
+`emi/thermograph/frontend` image; the image name, `frontend-*` CI workflows and
+deploy path are unchanged from the Python service.
diff --git a/frontend/server/go.mod b/frontend/server/go.mod
new file mode 100644
index 0000000..ab8cdd4
--- /dev/null
+++ b/frontend/server/go.mod
@@ -0,0 +1,5 @@
+module thermograph/frontend
+
+go 1.26
+
+require gopkg.in/yaml.v3 v3.0.1
diff --git a/frontend/server/go.sum b/frontend/server/go.sum
new file mode 100644
index 0000000..a62c313
--- /dev/null
+++ b/frontend/server/go.sum
@@ -0,0 +1,4 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/frontend/server/internal/config/config.go b/frontend/server/internal/config/config.go
new file mode 100644
index 0000000..cab29fc
--- /dev/null
+++ b/frontend/server/internal/config/config.go
@@ -0,0 +1,127 @@
+// Package config reads every environment variable the SSR frontend consumes.
+//
+// The names, defaults and required/optional split mirror the Python service
+// exactly (app.py, api_client.py, content.py, paths.py, and the Dockerfile /
+// infra/docker-compose.yml PORT wiring). Do not invent new variable names —
+// the deploy path (compose + /etc/thermograph.env) sets these and only these.
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Config is the fully-resolved runtime configuration.
+type Config struct {
+ // APIBaseInternal is THERMOGRAPH_API_BASE_INTERNAL — the backend's URL on
+ // the compose-internal network (e.g. http://backend:8137). REQUIRED: the
+ // Python client raised RuntimeError at import when unset ("a missing
+ // backend URL should break the boot, not silently 500 on the first
+ // request") — Load returns an error and main exits, same philosophy.
+ APIBaseInternal string
+
+ // APIVersion is THERMOGRAPH_API_VERSION (default "v2") — the single pin
+ // point for the backend content-API version this service speaks. Bump only
+ // in lockstep with a verified backend /api/version check (see
+ // frontend CLAUDE.md, "API-version pinning contract").
+ APIVersion string
+
+ // Base is THERMOGRAPH_BASE normalized the way content.py normalized it:
+ // strip "/" from both ends, then "/"+rest, or "" when the variable is set
+ // to "/" (the deployed clean-root topology — the Dockerfile sets "/").
+ // Default when unset: "/thermograph" (LAN dev under a sub-path).
+ Base string
+
+ // AssetBase is THERMOGRAPH_API_BASE_PUBLIC with any trailing "/" removed,
+ // falling back to Base when empty — the browser-facing base for static
+ // asset / SPA-shell URLs in templates. Empty var = today's same-origin
+ // default, where those references stay relative (ASSET_BASE == BASE).
+ AssetBase string
+
+ // SSRCacheTTL is THERMOGRAPH_SSR_CACHE_TTL in seconds (default 600) — the
+ // content-API response-cache TTL in the backend client. The Python parsed
+ // it with float(env or 600): an empty string means the default, a present
+ // but unparsable value crashed the boot — Load mirrors both.
+ SSRCacheTTL time.Duration
+
+ // GoogleVerify / BingVerify are THERMOGRAPH_GOOGLE_VERIFY /
+ // THERMOGRAPH_BING_VERIFY, whitespace-trimmed; empty = no verification
+ // tag emitted.
+ GoogleVerify string
+ BingVerify string
+
+ // Port is PORT (default "8080"). The Python process itself never read it —
+ // the Dockerfile's `uvicorn --port ${PORT}` did — but it is the one knob
+ // infra uses to move the listen port, so the Go binary reads the same name.
+ Port string
+
+ // StaticDir / ContentDir are where static assets and the structured SSR
+ // copy (glossary.yaml / pages.yaml) live. The Python resolved these from
+ // its own source location (paths.py); a compiled binary has no source
+ // directory, so these default to "static" and "content" relative to the
+ // working directory — run from frontend/ locally, /app in the image (the
+ // Dockerfile must COPY static/ and content/ there and keep WORKDIR /app).
+ StaticDir string
+ ContentDir string
+}
+
+// Load resolves the configuration from the process environment.
+func Load() (Config, error) {
+ return load(os.LookupEnv)
+}
+
+// load is Load with an injectable environment, for tests.
+func load(getenv func(string) (string, bool)) (Config, error) {
+ get := func(name, dflt string) string {
+ if v, ok := getenv(name); ok {
+ return v
+ }
+ return dflt
+ }
+
+ var cfg Config
+
+ cfg.APIBaseInternal, _ = getenv("THERMOGRAPH_API_BASE_INTERNAL")
+ if cfg.APIBaseInternal == "" {
+ return cfg, fmt.Errorf("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)")
+ }
+
+ cfg.APIVersion = get("THERMOGRAPH_API_VERSION", "v2")
+
+ // content.py / api_client.py: os.environ.get("THERMOGRAPH_BASE",
+ // "/thermograph").strip("/"), then "/"+base if base else "".
+ base := strings.Trim(get("THERMOGRAPH_BASE", "/thermograph"), "/")
+ if base != "" {
+ cfg.Base = "/" + base
+ }
+
+ // content.py: os.environ.get("THERMOGRAPH_API_BASE_PUBLIC", "").rstrip("/") or BASE.
+ cfg.AssetBase = strings.TrimRight(get("THERMOGRAPH_API_BASE_PUBLIC", ""), "/")
+ if cfg.AssetBase == "" {
+ cfg.AssetBase = cfg.Base
+ }
+
+ // api_client.py: float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600).
+ ttlRaw := get("THERMOGRAPH_SSR_CACHE_TTL", "600")
+ if ttlRaw == "" {
+ ttlRaw = "600"
+ }
+ ttlSecs, err := strconv.ParseFloat(ttlRaw, 64)
+ if err != nil {
+ // The Python raised ValueError at import for a garbage value — fail
+ // loud here too rather than silently running with a wrong TTL.
+ return cfg, fmt.Errorf("THERMOGRAPH_SSR_CACHE_TTL: %w", err)
+ }
+ cfg.SSRCacheTTL = time.Duration(ttlSecs * float64(time.Second))
+
+ cfg.GoogleVerify = strings.TrimSpace(get("THERMOGRAPH_GOOGLE_VERIFY", ""))
+ cfg.BingVerify = strings.TrimSpace(get("THERMOGRAPH_BING_VERIFY", ""))
+
+ cfg.Port = get("PORT", "8080")
+ cfg.StaticDir = "static"
+ cfg.ContentDir = "content"
+ return cfg, nil
+}
diff --git a/frontend/server/internal/config/config_test.go b/frontend/server/internal/config/config_test.go
new file mode 100644
index 0000000..416caac
--- /dev/null
+++ b/frontend/server/internal/config/config_test.go
@@ -0,0 +1,137 @@
+package config
+
+import (
+ "testing"
+ "time"
+)
+
+func envFrom(m map[string]string) func(string) (string, bool) {
+ return func(k string) (string, bool) {
+ v, ok := m[k]
+ return v, ok
+ }
+}
+
+func TestDefaults(t *testing.T) {
+ cfg, err := load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://backend:8137",
+ }))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if cfg.APIBaseInternal != "http://backend:8137" {
+ t.Errorf("APIBaseInternal = %q", cfg.APIBaseInternal)
+ }
+ if cfg.APIVersion != "v2" {
+ t.Errorf("APIVersion = %q, want v2", cfg.APIVersion)
+ }
+ if cfg.Base != "/thermograph" {
+ t.Errorf("Base = %q, want /thermograph", cfg.Base)
+ }
+ if cfg.AssetBase != "/thermograph" {
+ t.Errorf("AssetBase = %q, want /thermograph (falls back to Base)", cfg.AssetBase)
+ }
+ if cfg.SSRCacheTTL != 600*time.Second {
+ t.Errorf("SSRCacheTTL = %v, want 10m", cfg.SSRCacheTTL)
+ }
+ if cfg.GoogleVerify != "" || cfg.BingVerify != "" {
+ t.Errorf("verify tokens should default empty, got %q / %q", cfg.GoogleVerify, cfg.BingVerify)
+ }
+ if cfg.Port != "8080" {
+ t.Errorf("Port = %q, want 8080", cfg.Port)
+ }
+ if cfg.StaticDir != "static" || cfg.ContentDir != "content" {
+ t.Errorf("StaticDir/ContentDir = %q/%q", cfg.StaticDir, cfg.ContentDir)
+ }
+}
+
+func TestAPIBaseInternalRequired(t *testing.T) {
+ if _, err := load(envFrom(nil)); err == nil {
+ t.Fatal("expected an error when THERMOGRAPH_API_BASE_INTERNAL is unset (fail-loud boot)")
+ }
+ if _, err := load(envFrom(map[string]string{"THERMOGRAPH_API_BASE_INTERNAL": ""})); err == nil {
+ t.Fatal("expected an error when THERMOGRAPH_API_BASE_INTERNAL is empty")
+ }
+}
+
+func TestBaseNormalization(t *testing.T) {
+ cases := []struct{ raw, want string }{
+ {"/", ""}, // deployed clean-root topology (Dockerfile sets "/")
+ {"", ""}, // set-but-empty strips to nothing too
+ {"/thermograph", "/thermograph"},
+ {"thermograph/", "/thermograph"},
+ {"/a/b/", "/a/b"},
+ }
+ for _, c := range cases {
+ cfg, err := load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://b",
+ "THERMOGRAPH_BASE": c.raw,
+ }))
+ if err != nil {
+ t.Fatalf("load(%q): %v", c.raw, err)
+ }
+ if cfg.Base != c.want {
+ t.Errorf("Base(%q) = %q, want %q", c.raw, cfg.Base, c.want)
+ }
+ }
+}
+
+func TestAssetBase(t *testing.T) {
+ cfg, err := load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://b",
+ "THERMOGRAPH_API_BASE_PUBLIC": "https://api.example.org/",
+ }))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if cfg.AssetBase != "https://api.example.org" {
+ t.Errorf("AssetBase = %q, want trailing slash stripped", cfg.AssetBase)
+ }
+}
+
+func TestSSRCacheTTL(t *testing.T) {
+ // Empty string means the default (Python: float(env or 600)).
+ cfg, err := load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://b",
+ "THERMOGRAPH_SSR_CACHE_TTL": "",
+ }))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if cfg.SSRCacheTTL != 600*time.Second {
+ t.Errorf("empty TTL = %v, want 10m", cfg.SSRCacheTTL)
+ }
+
+ cfg, err = load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://b",
+ "THERMOGRAPH_SSR_CACHE_TTL": "2.5",
+ }))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if cfg.SSRCacheTTL != 2500*time.Millisecond {
+ t.Errorf("TTL 2.5 = %v, want 2.5s", cfg.SSRCacheTTL)
+ }
+
+ // Garbage crashed the Python boot (ValueError at import) — error here too.
+ if _, err = load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://b",
+ "THERMOGRAPH_SSR_CACHE_TTL": "ten minutes",
+ })); err == nil {
+ t.Fatal("expected an error for an unparsable TTL")
+ }
+}
+
+func TestVerifyTokensTrimmed(t *testing.T) {
+ cfg, err := load(envFrom(map[string]string{
+ "THERMOGRAPH_API_BASE_INTERNAL": "http://b",
+ "THERMOGRAPH_GOOGLE_VERIFY": " g-token ",
+ "THERMOGRAPH_BING_VERIFY": "\tb-token\n",
+ }))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if cfg.GoogleVerify != "g-token" || cfg.BingVerify != "b-token" {
+ t.Errorf("tokens not trimmed: %q / %q", cfg.GoogleVerify, cfg.BingVerify)
+ }
+}
diff --git a/frontend/server/internal/content/content.go b/frontend/server/internal/content/content.go
new file mode 100644
index 0000000..5f68392
--- /dev/null
+++ b/frontend/server/internal/content/content.go
@@ -0,0 +1,268 @@
+// 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
+}
diff --git a/frontend/server/internal/content/content_test.go b/frontend/server/internal/content/content_test.go
new file mode 100644
index 0000000..ef90a28
--- /dev/null
+++ b/frontend/server/internal/content/content_test.go
@@ -0,0 +1,781 @@
+// Behaviour tests for the content.py port, driven by the same committed
+// backend fixtures the Python suite used (frontend/tests/fixtures/*.json,
+// captured by scripts/capture-fixtures.sh) and mirroring the assertions of
+// frontend/tests/unit/test_rendering.py that belong to this layer. Full
+// rendered-page assertions (200 + HTML structure) live with the template
+// port / golden-diff phase — here the templates are placeholders, so these
+// tests target the routes' status behavior and the exact render contexts the
+// handlers build.
+package content
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thermograph/frontend/internal/config"
+ "thermograph/frontend/internal/contentapi"
+ "thermograph/frontend/internal/contentdata"
+ "thermograph/frontend/internal/format"
+ "thermograph/frontend/internal/render"
+)
+
+const (
+ testSlug = "london-england-gb" // captured into tests/fixtures/ (GB -> Celsius)
+ testMonth = "july"
+ fakeKey = "test0000indexnow0000key000000000"
+)
+
+// fixturesDir / contentDir reach the repo's committed test fixtures and SSR
+// copy from this package directory.
+var (
+ fixturesDir = filepath.Join("..", "..", "..", "tests", "fixtures")
+ contentDir = filepath.Join("..", "..", "..", "content")
+)
+
+func loadFixture[T any](t *testing.T, name string) *T {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join(fixturesDir, name+".json"))
+ if err != nil {
+ t.Fatalf("fixture %s: %v", name, err)
+ }
+ out := new(T)
+ if err := json.Unmarshal(raw, out); err != nil {
+ t.Fatalf("fixture %s: %v", name, err)
+ }
+ return out
+}
+
+// fakeAPI is the Go analogue of conftest.py's monkeypatched api_client.
+type fakeAPI struct {
+ hub func() (*contentapi.HubPayload, error)
+ sitemap func() ([]contentapi.SitemapEntry, error)
+ indexNowKey func() (string, error)
+ home func() (*contentapi.HomePayload, error)
+ city func(slug, origin string) (*contentapi.CityPayload, error)
+ cityMonth func(slug, month string) (*contentapi.MonthPayload, error)
+ cityRecords func(slug, origin string) (*contentapi.RecordsPayload, error)
+}
+
+func (f *fakeAPI) Hub() (*contentapi.HubPayload, error) { return f.hub() }
+func (f *fakeAPI) Sitemap() ([]contentapi.SitemapEntry, error) { return f.sitemap() }
+func (f *fakeAPI) IndexNowKey() (string, error) { return f.indexNowKey() }
+func (f *fakeAPI) Home() (*contentapi.HomePayload, error) { return f.home() }
+func (f *fakeAPI) City(slug, origin string) (*contentapi.CityPayload, error) {
+ return f.city(slug, origin)
+}
+func (f *fakeAPI) CityMonth(slug, month string) (*contentapi.MonthPayload, error) {
+ return f.cityMonth(slug, month)
+}
+func (f *fakeAPI) CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) {
+ return f.cityRecords(slug, origin)
+}
+
+func notFound(detail string) error {
+ return &contentapi.StatusError{
+ StatusCode: 404,
+ Body: fmt.Sprintf(`{"detail":%q}`, detail),
+ URL: "http://backend.invalid/x",
+ }
+}
+
+var errUnreachable = errors.New("connect: connection refused")
+
+// fixtureAPI wires every method to the committed fixtures, exactly like the
+// Python `client` fixture's monkeypatching (unknown slug/month -> a backend
+// 404 StatusError).
+func fixtureAPI(t *testing.T) *fakeAPI {
+ t.Helper()
+ city := loadFixture[contentapi.CityPayload](t, "city")
+ month := loadFixture[contentapi.MonthPayload](t, "month")
+ records := loadFixture[contentapi.RecordsPayload](t, "records")
+ home := loadFixture[contentapi.HomePayload](t, "home")
+ hub := loadFixture[contentapi.HubPayload](t, "hub")
+ sitemap := loadFixture[[]contentapi.SitemapEntry](t, "sitemap")
+ return &fakeAPI{
+ hub: func() (*contentapi.HubPayload, error) { return hub, nil },
+ sitemap: func() ([]contentapi.SitemapEntry, error) { return *sitemap, nil },
+ indexNowKey: func() (string, error) { return fakeKey, nil },
+ home: func() (*contentapi.HomePayload, error) { return home, nil },
+ city: func(slug, origin string) (*contentapi.CityPayload, error) {
+ if slug != testSlug {
+ return nil, notFound("Unknown city.")
+ }
+ return city, nil
+ },
+ cityMonth: func(slug, m string) (*contentapi.MonthPayload, error) {
+ if slug != testSlug || m != testMonth {
+ return nil, notFound("Unknown month.")
+ }
+ return month, nil
+ },
+ cityRecords: func(slug, origin string) (*contentapi.RecordsPayload, error) {
+ if slug != testSlug {
+ return nil, notFound("Unknown city.")
+ }
+ return records, nil
+ },
+ }
+}
+
+func testConfig() config.Config {
+ return config.Config{
+ APIBaseInternal: "http://backend.invalid",
+ APIVersion: "v2",
+ Base: "/thermograph", // matches THERMOGRAPH_BASE in the Python conftest
+ AssetBase: "/thermograph",
+ GoogleVerify: "",
+ BingVerify: "",
+ }
+}
+
+func newHandlers(t *testing.T, api API) *Handlers {
+ t.Helper()
+ cfg := testConfig()
+ engine, err := render.New(FuncMap(cfg))
+ if err != nil {
+ t.Fatalf("render.New: %v", err)
+ }
+ glossary, err := contentdata.LoadGlossary(contentDir)
+ if err != nil {
+ t.Fatalf("LoadGlossary: %v", err)
+ }
+ pages, err := contentdata.LoadPages(contentDir)
+ if err != nil {
+ t.Fatalf("LoadPages: %v", err)
+ }
+ h, err := New(cfg, api, engine, glossary, pages, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return h
+}
+
+func newMux(t *testing.T, api API) *http.ServeMux {
+ t.Helper()
+ mux := http.NewServeMux()
+ static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write([]byte("STATIC")) // stands in for main.go's file server
+ })
+ h := newHandlers(t, api)
+ h.Register(mux, static)
+ // main.go ALSO registers a subtree static catch-all ("GET {base}/", via
+ // handlers.Server.Register) on the same mux — Register's own doc comment
+ // notes this. Only the LAZY IndexNow fallback claims the single-segment
+ // slot itself and forwards non-.txt requests to `static`; the EAGER path
+ // (key fetched successfully at boot) registers just the exact key-file
+ // route and relies on that separate subtree registration for everything
+ // else. Mirror both here, or the eager-route test exercises a mux with no
+ // handler for e.g. app.js at all — a gap in this test double, not in
+ // production routing.
+ mux.Handle("GET "+h.cfg.Base+"/", static)
+ return mux
+}
+
+func get(mux *http.ServeMux, path string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest("GET", "http://testserver"+path, nil)
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ return rec
+}
+
+// --- robots / sitemap --------------------------------------------------------
+
+func TestRobotsTxt(t *testing.T) {
+ rec := get(newMux(t, fixtureAPI(t)), "/thermograph/robots.txt")
+ if rec.Code != 200 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ want := "User-agent: *\n" +
+ "Allow: /\n" +
+ "Disallow: /thermograph/api/\n" +
+ "Disallow: /thermograph/alerts\n" +
+ "Sitemap: http://testserver/thermograph/sitemap.xml\n"
+ if rec.Body.String() != want {
+ t.Errorf("body:\n%s", rec.Body.String())
+ }
+}
+
+func TestSitemapListsCityURLs(t *testing.T) {
+ rec := get(newMux(t, fixtureAPI(t)), "/thermograph/sitemap.xml")
+ if rec.Code != 200 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "",
+ "/climate/" + testSlug + "/" + testMonth + "",
+ "/climate/" + testSlug + "/records",
+ } {
+ if !strings.Contains(body, frag) {
+ t.Errorf("missing %q", frag)
+ }
+ }
+ if ct := rec.Header().Get("Content-Type"); ct != "application/xml" {
+ t.Errorf("Content-Type %q", ct)
+ }
+ if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=300" {
+ t.Errorf("Cache-Control %q", cc)
+ }
+ // One full entry, byte-exact: loc + boot-date lastmod + changefreq + priority.
+ h := newHandlers(t, fixtureAPI(t))
+ wantURL := fmt.Sprintf("http://testserver/thermograph/%s"+
+ "daily1.0", h.bootDate)
+ if !strings.Contains(body, wantURL) {
+ t.Errorf("missing exact entry %q", wantURL)
+ }
+}
+
+// --- error mapping -----------------------------------------------------------
+
+func TestUnknownCityAndMonthAre404(t *testing.T) {
+ mux := newMux(t, fixtureAPI(t))
+ for _, path := range []string{
+ "/thermograph/climate/nope-not-a-city",
+ "/thermograph/climate/" + testSlug + "/nonemonth",
+ "/thermograph/climate/nope-not-a-city/records",
+ } {
+ if rec := get(mux, path); rec.Code != 404 {
+ t.Errorf("%s -> %d, want 404", path, rec.Code)
+ }
+ }
+}
+
+func TestGlossaryUnknownTermIs404(t *testing.T) {
+ rec := get(newMux(t, fixtureAPI(t)), "/thermograph/glossary/not-a-term")
+ if rec.Code != 404 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if got := rec.Body.String(); got != `{"detail":"Unknown term."}` {
+ t.Errorf("body %q", got)
+ }
+}
+
+// TestBackendUnreachableMapsTo503 is the port of the Python suite's
+// backend-down resilience cases: a transport-level failure (no response at
+// all) must become a clean 503, not a raw 500.
+func TestBackendUnreachableMapsTo503(t *testing.T) {
+ api := fixtureAPI(t)
+ api.city = func(_, _ string) (*contentapi.CityPayload, error) { return nil, errUnreachable }
+ api.cityMonth = func(_, _ string) (*contentapi.MonthPayload, error) { return nil, errUnreachable }
+ api.cityRecords = func(_, _ string) (*contentapi.RecordsPayload, error) { return nil, errUnreachable }
+ api.hub = func() (*contentapi.HubPayload, error) { return nil, errUnreachable }
+ api.home = func() (*contentapi.HomePayload, error) { return nil, errUnreachable }
+ api.sitemap = func() ([]contentapi.SitemapEntry, error) { return nil, errUnreachable }
+ mux := newMux(t, api)
+ for _, path := range []string{
+ "/thermograph/climate/" + testSlug,
+ "/thermograph/climate/" + testSlug + "/july",
+ "/thermograph/climate/" + testSlug + "/records",
+ "/thermograph/climate",
+ "/thermograph/",
+ "/thermograph/sitemap.xml",
+ } {
+ rec := get(mux, path)
+ if rec.Code != 503 {
+ t.Errorf("%s -> %d, want 503", path, rec.Code)
+ }
+ if body := rec.Body.String(); body != `{"detail":"backend unavailable"}` {
+ t.Errorf("%s body %q", path, body)
+ }
+ }
+}
+
+// A backend 404/503 status passes straight through with its own detail text.
+func TestBackendStatusPassesThrough(t *testing.T) {
+ rec := get(newMux(t, fixtureAPI(t)), "/thermograph/climate/nope-not-a-city")
+ if rec.Code != 404 {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Unknown city.") {
+ t.Errorf("detail not forwarded: %q", rec.Body.String())
+ }
+}
+
+// --- IndexNow ----------------------------------------------------------------
+
+func TestIndexNowEagerRoute(t *testing.T) {
+ mux := newMux(t, fixtureAPI(t))
+ rec := get(mux, "/thermograph/"+fakeKey+".txt")
+ if rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
+ t.Errorf("eager key file: %d %q", rec.Code, rec.Body.String())
+ }
+ // With the eager route registered there is no {token} catch-all: other
+ // single-segment paths stay with the static file server.
+ if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
+ t.Errorf("static delegation broken: %q", rec.Body.String())
+ }
+}
+
+func TestIndexNowLazyFallback(t *testing.T) {
+ api := fixtureAPI(t)
+ down := true
+ api.indexNowKey = func() (string, error) {
+ if down {
+ return "", errUnreachable
+ }
+ return fakeKey, nil
+ }
+ mux := newMux(t, api) // Register's eager fetch fails -> lazy route
+
+ // Backend still down: the key file answers 503 (never a wrong/empty key).
+ if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 503 {
+ t.Errorf("while down: %d", rec.Code)
+ }
+ // Backend back up: the correct key serves with no frontend restart.
+ down = false
+ if rec := get(mux, "/thermograph/"+fakeKey+".txt"); rec.Code != 200 || rec.Body.String() != fakeKey+"\n" {
+ t.Errorf("after recovery: %d %q", rec.Code, rec.Body.String())
+ }
+ // A wrong token is 404, and non-.txt single-segment paths fall through to
+ // the static handler the lazy route displaced.
+ if rec := get(mux, "/thermograph/wrongkey.txt"); rec.Code != 404 {
+ t.Errorf("wrong token: %d", rec.Code)
+ }
+ if rec := get(mux, "/thermograph/app.js"); rec.Body.String() != "STATIC" {
+ t.Errorf("static delegation broken: %q", rec.Body.String())
+ }
+ // Explicit page routes still beat the {token} pattern on specificity.
+ if rec := get(mux, "/thermograph/robots.txt"); !strings.HasPrefix(rec.Body.String(), "User-agent:") {
+ t.Errorf("robots displaced by token route: %q", rec.Body.String())
+ }
+}
+
+// --- ctx builders ------------------------------------------------------------
+
+func TestCityCtx(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ j := loadFixture[contentapi.CityPayload](t, "city")
+ ctx, unit, err := h.cityCtx(j)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unit != "C" { // GB city -> Celsius (test_city_page_renders_structure)
+ t.Errorf("unit %q", unit)
+ }
+ months := ctx["Months"].([]map[string]any)
+ if len(months) != 12 {
+ t.Fatalf("months: %d", len(months))
+ }
+ // January high 44.5°F -> 7°C, formatted as the converter span.
+ if got := months[0]["High"].(template.HTML); got != `7°C` {
+ t.Errorf("january high: %q", got)
+ }
+ if bar := months[0]["Bar"]; bar == nil {
+ t.Error("january bar missing")
+ }
+ warmest := ctx["Warmest"].(map[string]any)
+ if warmest["Slug"] != "july" {
+ t.Errorf("warmest %v", warmest["Slug"])
+ }
+ if ctx["Coldest"].(map[string]any)["Slug"] != "february" ||
+ ctx["Wettest"].(map[string]any)["Slug"] != "january" {
+ t.Error("coldest/wettest lookup broken")
+ }
+ // Full href, base + "#lat,lon" — not just the bare "lat,lon" fragment, so
+ // the template never has to concatenate it (html/template's URL-context
+ // escaper would %-escape the comma if it did).
+ if ctx["ToolHref"] != "/thermograph/#51.50853,-0.12574" {
+ t.Errorf("ToolHref %v", ctx["ToolHref"])
+ }
+ if ctx["CompareURL"] != "/thermograph/compare#loc=51.5085,-0.1257" {
+ t.Errorf("CompareURL %v", ctx["CompareURL"])
+ }
+ // JSON-LD: compacted raw bytes, key order preserved (starts with @context).
+ jsonld := string(ctx["JSONLDStr"].(template.JS))
+ if !strings.HasPrefix(jsonld, `{"@context":"https://schema.org"`) {
+ t.Errorf("JSONLDStr prefix: %.60s", jsonld)
+ }
+ if !strings.Contains(jsonld, `"@type":"Dataset"`) {
+ t.Error("JSONLDStr lost the Dataset block") // test_city_page_renders_structure
+ }
+ if strings.Contains(jsonld, ": ") {
+ t.Error("JSONLDStr not compact")
+ }
+ // Today cards: pre-formatted value; percentile stays raw for |ordinal.
+ today := ctx["Today"].(map[string]any)
+ card := today["Cards"].([]map[string]any)[0]
+ if card["Value"].(template.HTML) != `26°C` {
+ t.Errorf("today card value: %q", card["Value"])
+ }
+ if format.PctOrdinal(card["Percentile"]) != "89th" {
+ t.Errorf("card percentile ordinal: %v", card["Percentile"])
+ }
+ if ctx["Display"] != j.Display || ctx["Name"] != "London" {
+ t.Error("display/name broken")
+ }
+ // Breadcrumb: the raw []contentapi.Crumb straight off the payload (its own
+ // Name/Href fields already match what the "breadcrumb" template reads).
+ // Terminal crumb has nil Href ({{if $c.Href}} -> false).
+ bc := ctx["Breadcrumb"].([]contentapi.Crumb)
+ if len(bc) == 0 || bc[len(bc)-1].Href != nil {
+ t.Errorf("breadcrumb terminal href: %v", bc)
+ }
+}
+
+func TestMonthCtx(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ j := loadFixture[contentapi.MonthPayload](t, "month")
+ city := loadFixture[contentapi.CityPayload](t, "city").City
+ ctx, unit, err := h.monthCtx(j, city)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unit != "C" {
+ t.Errorf("unit %q", unit)
+ }
+ stats := ctx["Stats"].([]map[string]any)
+ labels := make([]string, len(stats))
+ for i, s := range stats {
+ labels[i] = s["Label"].(string)
+ }
+ want := []string{"Average high", "Typical high range", "Average low", "Typical low range", "Average daily precipitation"}
+ if strings.Join(labels, "|") != strings.Join(want, "|") {
+ t.Errorf("stat labels: %v", labels)
+ }
+ // "X to Y" concatenation: temp(lo) + " to " + temp(hi).
+ rng := string(stats[1]["Value"].(template.HTML))
+ if rng != `18°C to 27°C` {
+ t.Errorf("typical high range: %q", rng)
+ }
+ // display falls back to the city name when the payload has none
+ // (j.get("display", city["name"])).
+ if j.Display == nil && ctx["Display"] != "London" {
+ t.Errorf("display fallback: %v", ctx["Display"])
+ }
+ // Record label -> metric dispatch: Humidity is a bare g/m³ figure.
+ recs := ctx["Records"].([]map[string]any)
+ var humid map[string]any
+ for _, r := range recs {
+ if r["Label"] == "Humidity" {
+ humid = r
+ }
+ }
+ if humid == nil || humid["High"].(template.HTML) != "16.0 g/m³" {
+ t.Errorf("humidity record: %v", humid)
+ }
+ if ctx["AvgHighCls"] != "warm" { // 71.1°F -> [70, 80)
+ t.Errorf("AvgHighCls %v", ctx["AvgHighCls"])
+ }
+ // Prev/Next: the raw contentapi.MonthLink (dereferenced), not a map — its
+ // own Name/Slug fields already match what the template reads.
+ if prev := ctx["Prev"].(contentapi.MonthLink); prev.Slug != "june" {
+ t.Errorf("prev %v", prev)
+ }
+}
+
+func TestMonthCtxUnknownLabelIsError(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ j := loadFixture[contentapi.MonthPayload](t, "month")
+ city := loadFixture[contentapi.CityPayload](t, "city").City
+ j.Records[0].Label = "Bogus"
+ if _, _, err := h.monthCtx(j, city); err == nil {
+ t.Error("unknown record label must error (Python KeyError -> 500)")
+ }
+}
+
+func TestRecordsCtx(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ j := loadFixture[contentapi.RecordsPayload](t, "records")
+ city := loadFixture[contentapi.CityPayload](t, "city").City
+ ctx, unit, err := h.recordsCtx(j, city)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if unit != "C" {
+ t.Errorf("unit %q", unit)
+ }
+ rows := ctx["Rows"].([]map[string]any)
+ var precip, high map[string]any
+ for _, r := range rows {
+ switch r["Label"] {
+ case "Precip":
+ precip = r
+ case "High":
+ high = r
+ }
+ }
+ // The precip low is the dry streak, not a formatted value.
+ if precip["Low"] != "33-day dry spell" {
+ t.Errorf("dry spell: %v", precip["Low"])
+ }
+ if precip["LowDate"] != "1995-07-31" {
+ t.Errorf("dry spell date: %v", precip["LowDate"])
+ }
+ // HighF/LowF only ride along for temperature metrics.
+ if precip["HighF"] != nil {
+ t.Errorf("precip HighF should be nil: %v", precip["HighF"])
+ }
+ if high["HighF"] == nil {
+ t.Error("temp HighF missing")
+ }
+ // Monthly extremes carry the {Txt, Cls, Date} shape the extremes() macro
+ // reads; 57.9°F sits in the [45, 58) "cool" tier — boundary-adjacent.
+ warm := ctx["Monthly"].([]map[string]any)[0]["High"].(map[string]any)["Warm"].(map[string]any)
+ if warm["Cls"] != "cool" || warm["Date"] != "2022-01-01" {
+ t.Errorf("monthly warm extreme: %v", warm)
+ }
+ if warm["Txt"].(template.HTML) != `14°C` {
+ t.Errorf("monthly warm txt: %q", warm["Txt"])
+ }
+ if ctx["Hemisphere"] != j.Hemisphere {
+ t.Error("hemisphere lost")
+ }
+ // ToolHref: the full href, same composition as city/month.
+ if ctx["ToolHref"] != fmt.Sprintf("/thermograph/#%.5f,%.5f", city.Lat, city.Lon) {
+ t.Errorf("ToolHref %v", ctx["ToolHref"])
+ }
+}
+
+func TestHubCtx(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ hub := loadFixture[contentapi.HubPayload](t, "hub")
+ ctx := h.hubCtx(hub)
+ if ctx["NCities"] != 1000 || ctx["NCountries"] != 124 {
+ t.Errorf("counts: %v %v", ctx["NCities"], ctx["NCountries"])
+ }
+ // Groups is the raw []contentapi.HubCountry slice, preserving the
+ // backend's country order (the Python dict was insertion-ordered; a Go
+ // map would shuffle every render) — HubCountry/HubCity's own fields
+ // already match what the template reads, so no rebuilding at all.
+ groups := ctx["Groups"].([]contentapi.HubCountry)
+ if groups[0].Country != "Afghanistan" {
+ t.Errorf("group order lost: %v", groups[0].Country)
+ }
+ first := groups[0].Cities[0]
+ if first.Slug != "kabul-af" || first.Name != "Kabul" {
+ t.Errorf("city entry: %v", first)
+ }
+ if ctx["PageTitle"] == "" || ctx["CanonicalPath"] != "/climate" {
+ t.Error("hub meta broken")
+ }
+}
+
+func TestHomeCtxJSONLD(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ hp := loadFixture[contentapi.HomePayload](t, "home")
+ ctx, err := h.homeCtx(hp, "http://example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Byte-exact against Python's json.dumps({**_HOME_JSONLD, "url": ...})
+ // (default separators — spaces preserved — and insertion order).
+ want := `{"@context": "https://schema.org", "@type": "WebApplication", "name": "Thermograph", ` +
+ `"applicationCategory": "WeatherApplication", "operatingSystem": "Web, iOS, Android", ` +
+ `"isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, ` +
+ `"description": "How unusual is your weather? Any day, anywhere on Earth, graded against 45 years ` +
+ `of that place's own history.", "url": "http://example.test/thermograph/"}`
+ if got := string(ctx["JSONLDStr"].(template.JS)); got != want {
+ t.Errorf("home jsonld:\n got %s\nwant %s", got, want)
+ }
+ if ctx["BrandTag"] != "p" || ctx["MainClass"] != "" || ctx["Section"] != "home" {
+ t.Error("home ctx flags broken")
+ }
+ // The captured fixture has no hero pick; nil must stay nil (template's
+ // {{if .Unusual}} falsiness), not a zero-valued struct.
+ if ctx["Unusual"] != nil {
+ t.Errorf("unusual: %v", ctx["Unusual"])
+ }
+ // Cities is the raw []contentapi.HomeCity slice (Slug/Name already match).
+ if len(ctx["Cities"].([]contentapi.HomeCity)) != 12 {
+ t.Error("city chips lost")
+ }
+}
+
+func TestHomeRankedPreservesLatLonText(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ v := 100.4
+ hp := &contentapi.HomePayload{Ranked: []contentapi.HomeRanked{{
+ Cls: "rec-hot", Display: "Testville", Lat: "51.5074", Lon: "-0.1000",
+ Value: &v, GradeLabel: "Near Record", MetricLabel: "High", PctLabel: "99",
+ }}}
+ ctx, err := h.homeCtx(hp, "http://x")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Ranked is the raw []contentapi.HomeRanked slice (Cls/Display/Lat/Lon/
+ // Value/... already match what the template reads).
+ r := ctx["Ranked"].([]contentapi.HomeRanked)[0]
+ // json.Number prints its raw text — "-0.1000" must not become "-0.1".
+ if fmt.Sprint(r.Lat) != "51.5074" || fmt.Sprint(r.Lon) != "-0.1000" {
+ t.Errorf("lat/lon text: %v %v", r.Lat, r.Lon)
+ }
+ if r.DateLabel != nil {
+ t.Errorf("DateLabel should be nil: %v", r.DateLabel)
+ }
+}
+
+func TestGlossaryCtx(t *testing.T) {
+ h := newHandlers(t, fixtureAPI(t))
+ first := h.glossary.Terms[0]
+ ctx, err := h.glossaryTermCtx(first.Slug, first, "http://testserver/thermograph")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // {base} substitution happens per-request in the body, like the Python's
+ // _glossary_body.
+ body := string(ctx["Body"].(template.HTML))
+ if strings.Contains(body, "{base}") {
+ t.Error("body {base} placeholder not substituted")
+ }
+ if strings.Contains(first.Body, "{base}") && !strings.Contains(body, "/thermograph") {
+ t.Error("body {base} not replaced with the configured base")
+ }
+ if ctx["PageTitle"] != first.Term+": what it means | Thermograph" {
+ t.Errorf("PageTitle: %v", ctx["PageTitle"])
+ }
+ // Others is the raw []contentdata.GlossaryTerm slice (Slug/Term already
+ // match what the template reads).
+ others := ctx["Others"].([]contentdata.GlossaryTerm)
+ if len(others) != len(h.glossary.Terms)-1 {
+ t.Errorf("others: %d", len(others))
+ }
+ for _, o := range others {
+ if o.Slug == first.Slug {
+ t.Error("others must exclude the current term")
+ }
+ }
+ // JSONLDStr: a raw DefinedTerm object, not a JSON-encoded STRING
+ // containing one (the template.HTML-vs-template.JS bug this guards
+ // against would wrap the whole thing in quotes with escaped internals).
+ jsonld := ctx["JSONLDStr"]
+ js, ok := jsonld.(template.JS)
+ if !ok {
+ t.Fatalf("JSONLDStr type = %T, want template.JS", jsonld)
+ }
+ want := `{"@context":"https://schema.org","@type":"DefinedTerm","name":"` + first.Term +
+ `","description":"` + first.Short + `","inDefinedTermSet":"http://testserver/thermograph/glossary"}`
+ if string(js) != want {
+ t.Errorf("JSONLDStr:\n got %s\nwant %s", js, want)
+ }
+}
+
+// --- FuncMap helpers ---------------------------------------------------------
+
+// html/template strips literal comments while parsing (verified
+// directly: a template of only `a
b
` renders as
+// `a
b
`) -- comment must wrap its output template.HTML to insert
+// it as trusted content rather than markup, so a visible comment written in a
+// .tmpl file actually survives into what ships. Checked both as a bare
+// function call and end to end through a real html/template parse+execute,
+// since the function alone proves nothing about whether html/template's
+// parser then strips it back out.
+func TestCommentSurvivesParsing(t *testing.T) {
+ fm := FuncMap(testConfig())
+ comment := fm["comment"].(func(string) template.HTML)
+ if got := comment("hello"); got != "" {
+ t.Errorf("comment(%q) = %q", "hello", got)
+ }
+
+ tmpl, err := template.New("t").Funcs(fm).Parse(`a
{{comment "x"}}b
`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := buf.String(); got != `a
b
` {
+ t.Errorf("comment through a real template = %q", got)
+ }
+}
+
+// html/template ALSO strips JavaScript `//` line comments from ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, nil); err != nil {
+ t.Fatal(err)
+ }
+ want := ""
+ if got := buf.String(); got != want {
+ t.Errorf("jscomment through a real template:\n got %q\nwant %q", got, want)
+ }
+}
+
+func TestHeadVerifyHTML(t *testing.T) {
+ if headVerifyHTML("", "") != "" {
+ t.Error("no tokens -> empty")
+ }
+ got := headVerifyHTML("gtok", "btok")
+ want := "\n " +
+ ""
+ if string(got) != want {
+ t.Errorf("head_verify: %q", got)
+ }
+ // Attribute escaping matches markupsafe.
+ if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a"b") {
+ t.Error("token not attribute-escaped")
+ }
+}
+
+func TestToJSON(t *testing.T) {
+ // u builds a backslash-u escape the way json.dumps/htmlsafe_json_dumps
+ // emit them (lowercase hex, four digits).
+ u := func(r rune) string { return fmt.Sprintf(`\u%04x`, r) }
+ cases := []struct {
+ in, want string
+ }{
+ {"Feels-like temperature", `"Feels-like temperature"`},
+ // Jinja's htmlsafe |tojson escapes the HTML-unsafe <, >, &, '.
+ {`a&'c`, `"a` + u('<') + `b` + u('>') + u('&') + u('\'') + `c"`},
+ // ...and ensure_ascii turns non-ASCII into escapes.
+ {"50°F", `"50` + u('°') + `F"`},
+ {"±7", `"` + u('±') + `7"`},
+ }
+ for _, c := range cases {
+ got, err := toJSON(c.in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != c.want {
+ t.Errorf("toJSON(%q) = %s, want %s", c.in, got, c.want)
+ }
+ }
+}
+
+func TestFuncMapHelpers(t *testing.T) {
+ fm := FuncMap(testConfig())
+ temp := fm["temp"].(func(any, any) template.HTML)
+ // Jinja's {{ temp(-10) }} literal (int) and a nullable payload pointer.
+ if got := temp("C", -10); got != `-23°C` {
+ t.Errorf("temp int literal: %q", got)
+ }
+ if got := temp("", (*float64)(nil)); got != "—" {
+ t.Errorf("temp nil pointer: %q", got)
+ }
+ tc := fm["temp_class"].(func(any) string)
+ if tc(nil) != "none" {
+ t.Error("temp_class nil")
+ }
+ ord := fm["ordinal"].(func(any) string)
+ if ord(89.1) != "89th" {
+ t.Error("ordinal")
+ }
+}
diff --git a/frontend/server/internal/content/funcmap.go b/frontend/server/internal/content/funcmap.go
new file mode 100644
index 0000000..c1ec52c
--- /dev/null
+++ b/frontend/server/internal/content/funcmap.go
@@ -0,0 +1,177 @@
+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 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 . html/template's
+ // contextual escaper treats . html/template's
+ // contextual escaper treats . html/template's
+ // contextual escaper treats
+
+
+{{end}}
+{{define "base_tail" -}}
+
+
+