web/worker: add a process-level liveness heartbeat #80

Merged
admin_emi merged 18 commits from fix/web-worker-heartbeat into dev 2026-07-25 04:13:50 +00:00
52 changed files with 6671 additions and 55 deletions

View file

@ -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"

View file

@ -33,6 +33,7 @@ services:
THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://127.0.0.1:1
THERMOGRAPH_BASE: "/"
THERMOGRAPH_ENABLE_NOTIFIER: "0"
THERMOGRAPH_ENABLE_HEARTBEAT: "0"
PORT: "8137"
WORKERS: "1"
ports:

View file

@ -28,6 +28,7 @@ _TMP = tempfile.mkdtemp(prefix="thermograph-tests-")
# start the background notifier thread during tests. Set before db is imported.
os.environ.setdefault("THERMOGRAPH_ACCOUNTS_DB", os.path.join(_TMP, "accounts.sqlite"))
os.environ.setdefault("THERMOGRAPH_ENABLE_NOTIFIER", "0")
os.environ.setdefault("THERMOGRAPH_ENABLE_HEARTBEAT", "0")
# web/app.py (repo-split Stage 4) fails loud at import if this is unset -- tests
# never actually hit a frontend-owned path through the live proxy (that's
# frontend_ssr/tests' job), so an unreachable placeholder is fine here.

View file

@ -258,6 +258,49 @@ def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
assert df.height == climate._to_frame(_om_daily(3)).height
def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
"""The Open-Meteo fallback must not grade the cell's in-progress local day: its
daily high/low is only a partial aggregate of the hours elapsed so far (a cool
morning would read as a record-low high). Past days and future forecast days
survive; today per the UTC offset Open-Meteo reports for a timezone=auto
request is dropped, matching the MET path's diurnal-coverage gate."""
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident)
local_today = (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(seconds=offset)).date()
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
daily = {
"time": [d.isoformat() for d in days],
"temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value
"temperature_2m_min": [60.0, 61.0, 57.0, 62.0],
"precipitation_sum": [0.0, 0.0, 0.0, 0.0],
}
payload = {"utc_offset_seconds": offset, "daily": daily}
class Resp:
def json(self): return payload
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
got = climate._fetch_recent_forecast_om(
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
assert local_today not in got # partial today dropped
assert local_today - datetime.timedelta(days=1) in got # yesterday kept
assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept
assert len(got) == 3
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
"""No UTC offset reported -> skip the in-progress-day guard rather than guess a
date, so the bundle passes through as before (only the usual null-day filter)."""
payload = {"daily": _om_daily(3)} # no utc_offset_seconds
class Resp:
def json(self): return payload
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
df = climate._fetch_recent_forecast_om({"center_lat": 1.0, "center_lon": 2.0})
assert df.height == climate._to_frame(_om_daily(3)).height
def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path):
"""With every source down (the NASA + MET Norway primary and the Open-Meteo
fallback), an existing (stale) cache is served rather than failing and it is NOT

View file

@ -0,0 +1,91 @@
"""Process-level liveness heartbeat for web/worker (app.py _heartbeat_loop /
_start_heartbeat) the fix for ProdWebContainerSilent / ProdWorkerContainerSilent
false-firing on container-stdout silence that isn't a real liveness signal for
either role."""
import json
import time
import pytest
from fastapi.testclient import TestClient
from core import audit
from web import app as appmod
@pytest.fixture(autouse=True)
def _reset_heartbeat_thread():
# _HEARTBEAT_THREAD is a module-level singleton guard (mirrors notify.py's own
# _STOP/_THREAD pattern) -- reset it around every test so one test starting the
# thread doesn't leave _start_heartbeat() a no-op for the next.
appmod._HEARTBEAT_STOP.clear()
appmod._HEARTBEAT_THREAD = None
yield
appmod._HEARTBEAT_STOP.set()
appmod._HEARTBEAT_THREAD = None
@pytest.mark.parametrize("role", ["web", "worker", "all"])
def test_heartbeat_loop_tags_the_beat_with_role(monkeypatch, role):
monkeypatch.setattr(appmod, "ROLE", role)
calls = []
monkeypatch.setattr(audit, "log_heartbeat", lambda daemon, interval_s: calls.append((daemon, interval_s)))
monkeypatch.setattr(appmod.metrics, "record_heartbeat", lambda daemon, interval_s: None)
# Setting the stop event first means .wait() returns True immediately, so the
# loop beats exactly once (the pre-loop beat) and exits without blocking.
appmod._HEARTBEAT_STOP.set()
appmod._heartbeat_loop()
assert calls == [(role, appmod.HEARTBEAT_INTERVAL)]
def test_start_heartbeat_respects_the_disable_flag(monkeypatch):
monkeypatch.setenv("THERMOGRAPH_ENABLE_HEARTBEAT", "0")
started = []
monkeypatch.setattr(appmod.threading, "Thread", lambda **kw: started.append(kw))
appmod._start_heartbeat()
assert appmod._HEARTBEAT_THREAD is None
assert started == []
def test_start_heartbeat_is_idempotent(monkeypatch):
monkeypatch.delenv("THERMOGRAPH_ENABLE_HEARTBEAT", raising=False)
starts = []
class _FakeThread:
def __init__(self, **kw):
starts.append(kw)
def start(self):
pass
monkeypatch.setattr(appmod.threading, "Thread", _FakeThread)
appmod._start_heartbeat()
appmod._start_heartbeat()
# A second call is a no-op once a thread handle already exists -- mirrors
# notify.start()'s own idempotency, so a double-entry into the lifespan
# (or a stray manual call) can't spawn a second beat loop.
assert len(starts) == 1
def test_app_boot_emits_a_heartbeat_within_one_interval(monkeypatch, tmp_path):
monkeypatch.setattr(appmod, "ROLE", "web")
monkeypatch.setattr(appmod, "HEARTBEAT_INTERVAL", 0.01)
monkeypatch.setattr(audit, "HEARTBEAT_DIR", str(tmp_path))
monkeypatch.delenv("THERMOGRAPH_ENABLE_HEARTBEAT", raising=False)
with TestClient(appmod.app):
# The pre-loop beat fires synchronously on thread start; a short sleep is
# just slack for the thread to actually get scheduled. The loop itself
# keeps running on HEARTBEAT_INTERVAL until lifespan shutdown sets the
# stop event, so there's nothing to join here.
time.sleep(0.2)
files = list(tmp_path.glob("heartbeat-*.jsonl"))
assert files, "expected a heartbeat-*.jsonl file to be written during app lifespan"
lines = [json.loads(line) for line in files[0].read_text().splitlines() if line]
assert any(rec["daemon"] == "web" and rec["tag"] == "heartbeat" for rec in lines)

View file

@ -154,6 +154,39 @@ def _should_run_notifier() -> bool:
return ROLE in ("worker", "all") and singleton.claim_leader()
_HEARTBEAT_STOP = threading.Event()
_HEARTBEAT_THREAD: threading.Thread | None = None
HEARTBEAT_INTERVAL = float(os.environ.get("THERMOGRAPH_HEARTBEAT_INTERVAL", "300")) # 5 min
def _heartbeat_loop() -> None:
metrics.record_heartbeat(ROLE, HEARTBEAT_INTERVAL)
audit.log_heartbeat(ROLE, HEARTBEAT_INTERVAL)
while not _HEARTBEAT_STOP.wait(HEARTBEAT_INTERVAL):
metrics.record_heartbeat(ROLE, HEARTBEAT_INTERVAL)
audit.log_heartbeat(ROLE, HEARTBEAT_INTERVAL)
def _start_heartbeat() -> None:
"""Process-level liveness beat, tagged daemon=ROLE, independent of whether this
process also runs the notifier. web's and worker's own stdout is near-silent by
design (real logging goes to the app-json file source; worker's Docker/stdout
stream is dropped entirely by Alloy), so container-log-silence alerting can't
tell a live process from a dead one for either role this heartbeat is the
signal observability actually alerts on instead. Deliberately unguarded across
every uvicorn worker process and every autoscaled replica: a beat is a single
local file append (no upstream quota, no DB, no lock), so duplicate beats are
harmless, and only the process actually being dead stops them see
_should_run_notifier's leader election for the contrasting case where
duplication would be a real cost."""
global _HEARTBEAT_THREAD
if os.environ.get("THERMOGRAPH_ENABLE_HEARTBEAT", "1") == "0" or _HEARTBEAT_THREAD is not None:
return
_HEARTBEAT_STOP.clear()
_HEARTBEAT_THREAD = threading.Thread(target=_heartbeat_loop, name=f"heartbeat-{ROLE}", daemon=True)
_HEARTBEAT_THREAD.start()
@contextlib.asynccontextmanager
async def _lifespan(app):
# Warm the local place-name index (/suggest's typo tolerance) in the
@ -167,6 +200,7 @@ async def _lifespan(app):
await db.create_db_and_tables()
places.start_loading()
threading.Thread(target=_neighbor_warmer, name="neighbor-warmer", daemon=True).start()
_start_heartbeat()
# Background subscription evaluator. Its periodic sweep fetches upstream on a timer
# (not per request), so with multiple workers — or multiple *hosts* under Swarm —
# it must run in exactly one: elect a leader (see singleton.claim_leader, which
@ -189,6 +223,7 @@ async def _lifespan(app):
yield
audit.log_activity("app.stop", {"role": ROLE, "pid": os.getpid()})
notify.stop()
_HEARTBEAT_STOP.set()
await _frontend_client.aclose()

29
frontend/.dockerignore Normal file
View file

@ -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

View file

@ -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"]

59
frontend/server/README.md Normal file
View file

@ -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.

5
frontend/server/go.mod Normal file
View file

@ -0,0 +1,5 @@
module thermograph/frontend
go 1.26
require gopkg.in/yaml.v3 v3.0.1

4
frontend/server/go.sum Normal file
View file

@ -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=

View file

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

View file

@ -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)
}
}

View file

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

View file

@ -0,0 +1,892 @@
// 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"
"time"
"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, "<urlset") {
t.Error("missing <urlset")
}
for _, frag := range []string{
"/climate/" + testSlug + "</loc>",
"/climate/" + testSlug + "/" + testMonth + "</loc>",
"/climate/" + testSlug + "/records</loc>",
} {
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("<url><loc>http://testserver/thermograph/</loc><lastmod>%s</lastmod>"+
"<changefreq>daily</changefreq><priority>1.0</priority></url>", 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 != `<span class="temp" data-temp-f="44.5">7°C</span>` {
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) != `<span class="temp" data-temp-f="79.4">26°C</span>` {
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 != `<span class="temp" data-temp-f="64.3">18°C</span> to <span class="temp" data-temp-f="79.8">27°C</span>` {
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)
}
}
// --- fetchWithCity -----------------------------------------------------------
func TestFetchWithCityHappyPath(t *testing.T) {
api := &fakeAPI{
city: func(slug, _ string) (*contentapi.CityPayload, error) {
return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug, Name: "Testville"}}, nil
},
}
primary, city, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
if err != nil {
t.Fatal(err)
}
if primary != "primary-value" {
t.Errorf("primary = %q", primary)
}
if city.Slug != "testville" || city.Name != "Testville" {
t.Errorf("city = %+v", city)
}
}
// When only primary fails, its error must win even though City() also ran
// (and, here, succeeded) -- matching the old sequential code's behavior,
// where City() was never even called once primary had already failed.
func TestFetchWithCityPrimaryErrorWins(t *testing.T) {
primaryErr := notFound("no such month")
api := &fakeAPI{
city: func(slug, _ string) (*contentapi.CityPayload, error) {
return &contentapi.CityPayload{City: contentapi.CityInfo{Slug: slug}}, nil
},
}
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
if err != primaryErr {
t.Errorf("err = %v, want the primary error", err)
}
}
// When primary succeeds but City() fails, City's error must surface -- same
// as the old sequential code (it called City() second and returned whatever
// that call did).
func TestFetchWithCityCityErrorSurfaces(t *testing.T) {
cityErr := notFound("no such city")
api := &fakeAPI{
city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
}
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "primary-value", nil })
if err != cityErr {
t.Errorf("err = %v, want the city error", err)
}
}
// When BOTH fail, primary's error still wins -- the same priority as the
// single-failure case above, just with both goroutines erroring.
func TestFetchWithCityBothErrorPrimaryWins(t *testing.T) {
primaryErr, cityErr := notFound("primary"), notFound("city")
api := &fakeAPI{
city: func(_, _ string) (*contentapi.CityPayload, error) { return nil, cityErr },
}
_, _, err := fetchWithCity(api, "testville", func() (string, error) { return "", primaryErr })
if err != primaryErr {
t.Errorf("err = %v, want the primary error (city error must not win)", err)
}
}
// Proves the two calls actually run CONCURRENTLY rather than sequentially --
// deterministically, via a rendezvous, not by timing (which would be flaky
// under CI load). Each fake blocks until BOTH have started; if fetchWithCity
// ran them sequentially, the second would never start until the first
// returned, and this would deadlock and fail on the test's own timeout.
func TestFetchWithCityRunsConcurrently(t *testing.T) {
started := make(chan struct{}, 2)
release := make(chan struct{})
rendezvous := func() {
started <- struct{}{}
<-release
}
api := &fakeAPI{
city: func(_, _ string) (*contentapi.CityPayload, error) {
rendezvous()
return &contentapi.CityPayload{}, nil
},
}
done := make(chan struct{})
go func() {
fetchWithCity(api, "testville", func() (string, error) {
rendezvous()
return "", nil
})
close(done)
}()
// Both goroutines must reach the rendezvous before either can proceed --
// only possible if they were launched concurrently.
for range 2 {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for both calls to start -- they are not running concurrently")
}
}
close(release)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("fetchWithCity did not return after release")
}
}
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) != `<span class="temp" data-temp-f="57.9">14°C</span>` {
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 `<p>a</p><!-- x --><p>b</p>` renders as
// `<p>a</p><p>b</p>`) -- 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 != "<!-- hello -->" {
t.Errorf("comment(%q) = %q", "hello", got)
}
tmpl, err := template.New("t").Funcs(fm).Parse(`<p>a</p>{{comment "x"}}<p>b</p>`)
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 != `<p>a</p><!-- x --><p>b</p>` {
t.Errorf("comment through a real template = %q", got)
}
}
// html/template ALSO strips JavaScript `//` line comments from <script>
// bodies, independent of and in addition to the HTML-comment stripping
// TestCommentSurvivesParsing guards -- verified with zero {{ }} actions
// anywhere near the comment, so it isn't specific to how a value gets
// interpolated. jscomment needs template.JS, not template.HTML: inside
// <script>, html/template treats template.HTML as an untrusted value and
// re-escapes it as a quoted JS string (the same failure mode the JSONLDStr
// sites hit before being fixed to template.JS).
func TestJSCommentSurvivesParsing(t *testing.T) {
fm := FuncMap(testConfig())
jscomment := fm["jscomment"].(func(string) template.JS)
if got := jscomment("hello"); got != "// hello" {
t.Errorf("jscomment(%q) = %q", "hello", got)
}
tmpl, err := template.New("t").Funcs(fm).Parse("<script>\nvar x = 1;\n\n{{jscomment \"x\"}}\nvar y = 2;\n</script>")
if err != nil {
t.Fatal(err)
}
var buf strings.Builder
if err := tmpl.Execute(&buf, nil); err != nil {
t.Fatal(err)
}
want := "<script>\nvar x = 1;\n\n// x\nvar y = 2;\n</script>"
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 := "<meta name=\"google-site-verification\" content=\"gtok\">\n " +
"<meta name=\"msvalidate.01\" content=\"btok\">"
if string(got) != want {
t.Errorf("head_verify: %q", got)
}
// Attribute escaping matches markupsafe.
if !strings.Contains(string(headVerifyHTML(`a"b`, "")), "a&#34;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<b>&'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 != `<span class="temp" data-temp-f="-10.0">-23°C</span>` {
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")
}
}

View file

@ -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 <meta> 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 <script>.
"tojson": toJSON,
// A visible HTML comment that survives html/template's parser. A
// LITERAL <!-- --> in a .tmpl file does NOT reach the output --
// html/template strips real HTML comments while parsing (verified:
// a template consisting of only `<p>a</p><!-- x --><p>b</p>` renders
// as `<p>a</p><p>b</p>`). Marking the string template.HTML makes it a
// trusted content INSERTION rather than markup the parser interprets,
// so it passes through untouched. Only ever called with developer-
// authored literals in the templates themselves, never request data.
"comment": func(s string) template.HTML {
return template.HTML("<!-- " + s + " -->")
},
// The same problem as `comment`, one layer down: html/template's
// contextual escaper ALSO strips JavaScript `//`/`/* */` comments from
// <script> bodies while parsing (verified the same way: an inline
// script containing only a `// comment` line and a `var` statement
// loses the comment line entirely on render, with zero {{ }} actions
// anywhere nearby). Needs template.JS specifically, not template.HTML
// -- inside a <script> context html/template treats template.HTML as
// untrusted and re-escapes it as a quoted JS value (the same bug
// class the JSONLDStr sites hit); template.JS is what means "emit
// this JS source as-is".
"jscomment": func(s string) template.JS {
return template.JS("// " + s)
},
}
}
// unitArg coerces the template-side unit value ("" / "C" / "F", as string or
// format.Unit) — the context stores it as a plain string for rendering the
// data-unit-default attribute.
func unitArg(v any) format.Unit {
switch u := v.(type) {
case format.Unit:
return u
case string:
return format.Unit(u)
case nil:
return ""
}
return ""
}
// floatArg coerces template arguments to the nullable float the format
// helpers take: nil / nil *float64 mean "missing" (rendered as the em-dash),
// numeric literals are Go ints inside templates.
func floatArg(v any) *float64 {
switch x := v.(type) {
case nil:
return nil
case *float64:
return x
case float64:
return &x
case int:
f := float64(x)
return &f
}
return nil
}
// headVerifyHTML is the port of content.py's head_verify_html: one <meta>
// per configured verification token, joined with the newline + two-space
// indent the Jinja Markup join produced. Token values are attribute-escaped
// exactly like markupsafe's Markup.format did.
func headVerifyHTML(google, bing string) template.HTML {
var metas []string
if google != "" {
metas = append(metas, fmt.Sprintf(`<meta name="google-site-verification" content="%s">`,
template.HTMLEscapeString(google)))
}
if bing != "" {
metas = append(metas, fmt.Sprintf(`<meta name="msvalidate.01" content="%s">`,
template.HTMLEscapeString(bing)))
}
return template.HTML(strings.Join(metas, "\n "))
}
// toJSON mirrors Jinja's |tojson filter (jinja2.utils.htmlsafe_json_dumps):
// json.dumps with ensure_ascii (every non-ASCII rune as a backslash-uXXXX
// escape, surrogate pairs beyond the BMP), then the HTML-unsafe characters
// <, >, & and ' replaced with their backslash-u00XX escapes so the result is
// safe inside a <script> block. Byte-for-byte the same output for the string
// values the templates feed it.
func toJSON(v any) (template.HTML, error) {
s, ok := v.(string)
if !ok {
// The templates only |tojson strings (term, page_description). Fail
// loudly if that changes rather than guessing at dict key order.
return "", fmt.Errorf("tojson: unsupported type %T", v)
}
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '<', '>', '&', '\'':
// Jinja's htmlsafe_json_dumps post-replaces these four with
// their backslash-u00XX escapes so the JSON can sit inside a
// <script> block without ever closing it.
fmt.Fprintf(&b, `\u%04x`, r)
default:
switch {
case r < 0x20:
fmt.Fprintf(&b, `\u%04x`, r)
case r < 0x80: // ASCII (incl. DEL) passes through, like json.dumps
b.WriteRune(r)
case r <= 0xffff:
fmt.Fprintf(&b, `\u%04x`, r)
default:
hi, lo := utf16.EncodeRune(r)
fmt.Fprintf(&b, `\u%04x\u%04x`, hi, lo)
}
}
}
b.WriteByte('"')
return template.HTML(b.String()), nil
}

View file

@ -0,0 +1,708 @@
package content
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"thermograph/frontend/internal/contentapi"
"thermograph/frontend/internal/contentdata"
"thermograph/frontend/internal/format"
)
// metricKeyByLabel maps a record row's display label back to its metric key
// for the format dispatch (content.py's _METRIC_KEY_BY_LABEL). An unknown
// label is a payload-contract break and 500s, like the Python's KeyError.
var metricKeyByLabel = map[string]string{
"High": "tmax", "Low": "tmin", "Feels-like": "feels",
"Humidity": "humid", "Wind": "wind", "Gust": "gust", "Precip": "precip",
}
// --- shared ctx builders -----------------------------------------------------
//
// Where an contentapi.* struct's exported field names already match what a
// template needs (checked against internal/render/templates' own header
// comments — the authoritative contract), it is passed straight through
// rather than re-wrapped in a map: one fewer hand-typed key string to drift,
// and Go's ordinary pointer/struct nil semantics ({{if .X}}, auto-deref on
// field access) already do the right thing. Only genuinely COMPUTED display
// values (temp spans, range-bar geometry, tier classes) still build a map.
// findMonth returns the fmtMonths entry with the given slug, or nil (the
// template guards every use with {{if .Warmest}} etc., matching Python's
// next((m for m in months if m["slug"] == slug), None)).
func findMonth(months []map[string]any, slug string) any {
for _, m := range months {
if m["Slug"] == slug {
return m
}
}
return nil
}
// fmtMonths is content.py's _fmt_months: each payload month plus its
// display-formatted high/low/precip and range-bar geometry. Name/Slug/HighF/
// LowF/RangeLoF/RangeHiF/PrecipF are read directly off the payload elsewhere
// in the template (e.g. {{$.Fmt.TempClass .HighF}}), so they ride along
// alongside the computed High/Low/Precip/Bar rather than being dropped.
func fmtMonths(unit format.Unit, months []contentapi.CityMonth) []map[string]any {
out := make([]map[string]any, 0, len(months))
for _, m := range months {
out = append(out, map[string]any{
"Name": m.Name, "Slug": m.Slug,
"HighF": m.HighF, "LowF": m.LowF,
"RangeLoF": m.RangeLoF, "RangeHiF": m.RangeHiF,
"PrecipF": m.PrecipF,
"High": format.Temp(unit, m.HighF),
"Low": format.Temp(unit, m.LowF),
"Precip": format.Precip(unit, m.PrecipF),
"Bar": format.MonthRangeBar(m.RangeLoF, m.RangeHiF),
})
}
return out
}
// --- per-city climate page ---------------------------------------------------
// CityPage renders /climate/{slug} (content.py's city_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Months,
// Warmest, Coldest, Wettest, Records, Today, ToolHref, Flavor, Event,
// CompareURL, Breadcrumb, CanonicalPath, PageTitle, PageDescription,
// JSONLDStr (+ the shared keys respondHTML adds).
func (h *Handlers) CityPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
j, err := h.api.City(slug, origin(r))
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.cityCtx(j)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "city.html.tmpl", unit, ctx)
}
// cityCtx builds the render context — split from the handler so the port's
// behaviour tests can assert on it without the (separately-owned) templates.
func (h *Handlers) cityCtx(j *contentapi.CityPayload) (map[string]any, format.Unit, error) {
// The backend decides the page's display unit (its own F_COUNTRIES
// logic); month/records pages derive it from the country code instead —
// preserved as-is from the Python.
unit := format.Unit(j.DefaultUnit)
months := fmtMonths(unit, j.Months)
var today any
if j.TodayVsNormal != nil {
cards := make([]map[string]any, 0, len(j.TodayVsNormal.Cards))
for _, c := range j.TodayVsNormal.Cards {
cards = append(cards, map[string]any{
"Label": c.Label, "Metric": c.Metric, "ValueF": c.ValueF,
"Percentile": c.Percentile, "Grade": c.Grade, "Cls": c.Cls,
"Value": format.Fmt(unit, c.Metric, c.ValueF),
})
}
today = map[string]any{"Date": j.TodayVsNormal.Date, "Cards": cards}
}
jsonld, err := compactJSONLD(j.JSONLD)
if err != nil {
return nil, unit, err
}
// Flavor/Event: contentapi.Flavor/Event's own fields (Extract/Title/URL,
// Text/URL) already match what the template reads, so dereference-or-nil
// is enough — no map wrapping. A typed-nil *Flavor stored in the ctx
// directly would make {{.Flavor.Extract}} hard-error on a nil pointer
// (unlike a plain missing map key, which degrades to empty); dereferencing
// first avoids that and still renders {{if .Flavor}} correctly (Go
// template truthiness on a nil `any` is false; on a struct value, true).
var flavor, event any
if j.Flavor != nil {
flavor = *j.Flavor
}
if j.Event != nil {
event = *j.Event
}
ctx := map[string]any{
"Section": "climate",
"City": j.City, "Display": j.Display, "Name": j.City.Name,
"YearRange": j.YearRange, "NYears": j.NYears,
"Months": months,
"Warmest": findMonth(months, j.WarmestMonthSlug),
"Coldest": findMonth(months, j.ColdestMonthSlug),
"Wettest": findMonth(months, j.WettestMonthSlug),
"Records": j.AllTimeRecords, "Today": today,
// The full href, not just the "lat,lon" fragment: composing it here
// (rather than {{.Base}}/#{{.ToolHash}} in the template) avoids
// html/template's URL-context escaper %-escaping the comma.
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, j.City.Lat, j.City.Lon),
"Flavor": flavor,
"Event": event,
"CompareURL": fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, j.City.Lat, j.City.Lon),
"Breadcrumb": j.Breadcrumb,
"CanonicalPath": j.CanonicalPath,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
// template.JS, NOT template.HTML: this value is interpolated inside
// <script type="application/ld+json">...</script>. html/template's
// contextual escaper treats <script> bodies as JAVASCRIPT context
// regardless of the script's `type` attribute — template.HTML's trust
// guarantee only covers HTML markup context, so inside <script> it
// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
// quoted JS string with every inner quote backslash-escaped, i.e. the
// whole JSON-LD payload doubly wrapped in a string literal instead of
// being emitted as the raw JSON object search engines expect.
// template.JS is the type that means "trusted JS source, emit as-is".
"JSONLDStr": template.JS(jsonld),
}
return ctx, unit, nil
}
// fetchWithCity runs a page's primary API call and the shared City lookup
// concurrently instead of sequentially: both are independent single-slug
// fetches (City never depends on primary's result), so waiting for one to
// fully round-trip before even starting the other was pure latency with
// nothing to show for it. Concurrent instead cuts that leg to roughly
// whichever call is slower.
//
// If primary fails, its error wins even when City also fails or hasn't
// finished — matching the old sequential code's behavior exactly (it never
// even called City() once primary had already failed). The one real
// trade-off: City() is now ALWAYS launched, even on a request that's about
// to 404 from primary, so an invalid slug costs one extra (wasted, cheap)
// backend lookup it previously skipped. Worth it: a bad slug is the rare
// path; a good one is the common path this speeds up.
func fetchWithCity[T any](api API, slug string, primary func() (T, error)) (T, contentapi.CityInfo, error) {
var (
pVal T
pErr error
cPay *contentapi.CityPayload
cErr error
group sync.WaitGroup
)
group.Add(2)
go func() {
defer group.Done()
pVal, pErr = primary()
}()
go func() {
defer group.Done()
cPay, cErr = api.City(slug, "") // no origin: the Python called city(slug) bare here too
}()
group.Wait()
if pErr != nil {
return pVal, contentapi.CityInfo{}, pErr
}
if cErr != nil {
var zero T
return zero, contentapi.CityInfo{}, cErr
}
return pVal, cPay.City, nil
}
// --- month page --------------------------------------------------------------
// MonthPage renders /climate/{slug}/{month} (content.py's month_page).
//
// ctx keys: Section, City, Display, Name, MonthName, MonthSlug,
// YearRange, NYears, AvgHigh, AvgLow, AvgHighCls, AvgLowCls, Stats,
// Records, ToolHref, CompareURL, Prev, Next, Breadcrumb, CanonicalPath,
// PageTitle, PageDescription. `Stats` entries are {Label, Value} maps (the
// Jinja iterated (label, value) tuples).
func (h *Handlers) MonthPage(w http.ResponseWriter, r *http.Request) {
slug, month := r.PathValue("slug"), r.PathValue("month")
j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.MonthPayload, error) {
return h.api.CityMonth(slug, month)
})
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.monthCtx(j, city)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "month.html.tmpl", unit, ctx)
}
// monthCtx builds the month page's render context (split out for tests).
func (h *Handlers) monthCtx(j *contentapi.MonthPayload, city contentapi.CityInfo) (map[string]any, format.Unit, error) {
slug, month := city.Slug, j.MonthSlug
unit := format.UnitForCountry(city.CountryCode)
var stats []map[string]any
addStat := func(label string, value template.HTML) {
stats = append(stats, map[string]any{"Label": label, "Value": value})
}
rangeStat := func(pair []*float64) (template.HTML, error) {
if len(pair) != 2 {
return "", fmt.Errorf("month %s/%s: typical range has %d entries, want 2", slug, month, len(pair))
}
lo, hi := format.Temp(unit, pair[0]), format.Temp(unit, pair[1])
return template.HTML(string(lo) + " to " + string(hi)), nil
}
if j.AvgHighF != nil {
addStat("Average high", format.Temp(unit, j.AvgHighF))
v, err := rangeStat(j.TypicalHighRangeF)
if err != nil {
return nil, unit, err
}
addStat("Typical high range", v)
}
if j.AvgLowF != nil {
addStat("Average low", format.Temp(unit, j.AvgLowF))
v, err := rangeStat(j.TypicalLowRangeF)
if err != nil {
return nil, unit, err
}
addStat("Typical low range", v)
}
if j.AvgPrecipF != nil {
addStat("Average daily precipitation", format.Precip(unit, j.AvgPrecipF))
}
records := make([]map[string]any, 0, len(j.Records))
for _, rec := range j.Records {
metric, ok := metricKeyByLabel[rec.Label]
if !ok {
return nil, unit, fmt.Errorf("month %s/%s: unknown record label %q", slug, month, rec.Label)
}
records = append(records, map[string]any{
"Label": rec.Label, "HighTag": rec.HighTag, "LowTag": rec.LowTag,
"High": format.Fmt(unit, metric, rec.HighF), "HighDate": rec.HighDate,
"Low": format.Fmt(unit, metric, rec.LowF), "LowDate": rec.LowDate,
})
}
display := city.Name
if j.Display != nil { // j.get("display", city["name"])
display = *j.Display
}
// Prev/Next: MonthLink's Name/Slug fields already match what the template
// reads; dereference-or-nil for the same reason as Flavor/Event above — a
// typed-nil *MonthLink stored as `any` would hard-error on field access
// where a plain nil degrades to empty.
var prev, next any
if j.Prev != nil {
prev = *j.Prev
}
if j.Next != nil {
next = *j.Next
}
ctx := map[string]any{
"Section": "climate", "City": city, "Display": display, "Name": city.Name,
"MonthName": j.MonthName, "MonthSlug": j.MonthSlug,
"YearRange": j.YearRange, "NYears": j.NYears,
"AvgHigh": format.Temp(unit, j.AvgHighF), "AvgLow": format.Temp(unit, j.AvgLowF),
"AvgHighCls": format.TempClass(j.AvgHighF), "AvgLowCls": format.TempClass(j.AvgLowF),
"Stats": stats, "Records": records,
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, city.Lat, city.Lon),
"CompareURL": fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, city.Lat, city.Lon),
"Prev": prev, "Next": next,
"Breadcrumb": j.Breadcrumb,
"CanonicalPath": j.CanonicalPath,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
}
return ctx, unit, nil
}
// --- records page ------------------------------------------------------------
// fmtExtreme / fmtPeriodSide: content.py's _fmt_extreme/_fmt_period_extremes —
// {'warm': {'value_f','date'}|None, 'cold': …} becomes the {'Cls','Txt',
// 'Date'} shape the "extremes" template block reads. Stays a computed map
// (unlike Prev/Flavor/etc.): the raw contentapi.Extreme struct has no Cls/Txt
// fields — those are derived display values, not API passthrough.
func fmtExtreme(unit format.Unit, e *contentapi.Extreme) any {
if e == nil {
return nil
}
v := e.ValueF
return map[string]any{
"Txt": format.Temp(unit, &v), "Cls": format.TempClass(&v), "Date": e.Date,
}
}
func fmtPeriodSide(unit format.Unit, s contentapi.PeriodSide) map[string]any {
return map[string]any{"Warm": fmtExtreme(unit, s.Warm), "Cold": fmtExtreme(unit, s.Cold)}
}
// RecordsPage renders /climate/{slug}/records (content.py's records_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Rows,
// Monthly, Seasonal, Hemisphere, AllTime, CanonicalPath, Breadcrumb,
// PageTitle, PageDescription, JSONLDStr. Row maps carry Label, High,
// HighDate, HighF, Low, LowDate, LowF — HighF/LowF only for
// temperature metrics, nil otherwise, as the Python set them.
func (h *Handlers) RecordsPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
o := origin(r)
j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.RecordsPayload, error) {
return h.api.CityRecords(slug, o)
})
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.recordsCtx(j, city)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "records.html.tmpl", unit, ctx)
}
// recordsCtx builds the records page's render context (split out for tests).
func (h *Handlers) recordsCtx(j *contentapi.RecordsPayload, city contentapi.CityInfo) (map[string]any, format.Unit, error) {
unit := format.UnitForCountry(city.CountryCode)
rows := make([]map[string]any, 0, len(j.Rows))
for _, rec := range j.Rows {
metric, ok := metricKeyByLabel[rec.Label]
if !ok {
return nil, unit, fmt.Errorf("records %s: unknown row label %q", city.Slug, rec.Label)
}
// The precip row's "low" is the longest dry spell, not a value — the
// backend signals it with dry_streak_days and the date the spell began.
var low any
lowDate := derefOr(rec.LowDate, "")
if rec.DryStreakDays != nil {
low = fmt.Sprintf("%d-day dry spell", *rec.DryStreakDays)
if lowDate == "" {
lowDate = "—" // r["low_date"] or "—"
}
} else {
low = format.Fmt(unit, metric, rec.LowF)
}
isTemp := metric == "tmax" || metric == "tmin" || metric == "feels"
var highF, lowF any
if isTemp {
highF, lowF = rec.HighF, rec.LowF
}
rows = append(rows, map[string]any{
"Label": rec.Label,
"High": format.Fmt(unit, metric, rec.HighF), "HighDate": rec.HighDate,
"HighF": highF,
"Low": low, "LowDate": lowDate,
"LowF": lowF,
})
}
monthly := make([]map[string]any, 0, len(j.Monthly))
for _, m := range j.Monthly {
monthly = append(monthly, map[string]any{
"Name": m.Name, "Slug": m.Slug,
"High": fmtPeriodSide(unit, m.High), "Low": fmtPeriodSide(unit, m.Low),
})
}
seasonal := make([]map[string]any, 0, len(j.Seasonal))
for _, s := range j.Seasonal {
seasonal = append(seasonal, map[string]any{
"Name": s.Name, "Span": s.Span,
"High": fmtPeriodSide(unit, s.High), "Low": fmtPeriodSide(unit, s.Low),
})
}
jsonld, err := compactJSONLD(j.JSONLD)
if err != nil {
return nil, unit, err
}
ctx := map[string]any{
"Section": "climate", "City": city,
"Display": derefOr(j.Display, city.Name), "Name": city.Name,
"YearRange": j.YearRange, "NYears": j.NYears,
"Rows": rows, "Monthly": monthly, "Seasonal": seasonal,
"Hemisphere": j.Hemisphere, "AllTime": j.AllTimeRecords,
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, city.Lat, city.Lon),
"CanonicalPath": j.CanonicalPath,
"Breadcrumb": j.Breadcrumb,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
// template.JS, NOT template.HTML: this value is interpolated inside
// <script type="application/ld+json">...</script>. html/template's
// contextual escaper treats <script> bodies as JAVASCRIPT context
// regardless of the script's `type` attribute — template.HTML's trust
// guarantee only covers HTML markup context, so inside <script> it
// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
// quoted JS string with every inner quote backslash-escaped, i.e. the
// whole JSON-LD payload doubly wrapped in a string literal instead of
// being emitted as the raw JSON object search engines expect.
// template.JS is the type that means "trusted JS source, emit as-is".
"JSONLDStr": template.JS(jsonld),
}
return ctx, unit, nil
}
func derefOr(s *string, fallback string) string {
if s != nil {
return *s
}
return fallback
}
// --- hub / glossary / about / privacy ---------------------------------------
// HubPage renders /climate (content.py's hub_page).
//
// ctx keys: Section, Groups, NCities, NCountries, CanonicalPath,
// Breadcrumb, PageTitle, PageDescription. NOTE: the Python's `groups` was
// a dict {country: cities} iterated with .items() — insertion-ordered. Go
// maps don't preserve order, so Groups is the ordered []contentapi.HubCountry
// slice straight from the payload — HubCountry/HubCity's own fields already
// match what the template reads, so this needs no rebuilding at all.
func (h *Handlers) HubPage(w http.ResponseWriter, r *http.Request) {
hub, err := h.api.Hub()
if err != nil {
h.apiError(w, err)
return
}
h.respondHTML(w, r, "hub.html.tmpl", "", h.hubCtx(hub))
}
// hubCtx builds the hub page's render context (split out for tests).
func (h *Handlers) hubCtx(hub *contentapi.HubPayload) map[string]any {
return map[string]any{
"Section": "climate",
"Groups": hub.Countries,
"NCities": hub.NCities,
"NCountries": hub.NCountries,
"CanonicalPath": "/climate",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Climate", "")},
"PageTitle": h.pages["hub"].Title,
"PageDescription": h.pages["hub"].Description,
}
}
// GlossaryIndex renders /glossary. ctx keys: Terms (Slug/Term/Short/Body, in
// file order — contentdata.GlossaryTerm's own fields already match, so the
// list passes straight through), CanonicalPath, Breadcrumb, PageTitle,
// PageDescription.
func (h *Handlers) GlossaryIndex(w http.ResponseWriter, r *http.Request) {
ctx := map[string]any{
"Terms": h.glossary.Terms,
"CanonicalPath": "/glossary",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Glossary", "")},
"PageTitle": h.pages["glossary_index"].Title,
"PageDescription": h.pages["glossary_index"].Description,
}
h.respondHTML(w, r, "glossary.html.tmpl", "", ctx)
}
// GlossaryTerm renders /glossary/{term}. ctx keys: Term, Body (raw HTML with
// {base} substituted per-request, the Python's _glossary_body), Others
// (Slug/Term of every other entry), CanonicalPath, Breadcrumb, PageTitle,
// PageDescription, JSONLDStr (a DefinedTerm object built inline here — the
// Jinja original interpolated term|tojson/page_description|tojson/base_url
// directly into the block rather than routing through content.py's ctx dict,
// so this port has to build the same bytes in Go instead).
func (h *Handlers) GlossaryTerm(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("term")
entry, ok := h.glossary.Get(slug)
if !ok {
writeDetail(w, http.StatusNotFound, "Unknown term.")
return
}
ctx, err := h.glossaryTermCtx(slug, entry, origin(r)+h.cfg.Base)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "glossary_term.html.tmpl", "", ctx)
}
// glossaryTermJSONLDPrefix/suffix bracket the term page's DefinedTerm
// object exactly as the Jinja original wrote it inline in the template
// (glossary_term.html.j2's {% block jsonld %}) rather than through
// content.py's ctx dict:
//
// {"@context":"https://schema.org","@type":"DefinedTerm","name":<tojson term>,
// "description":<tojson short>,"inDefinedTermSet":"<base_url>/glossary"}
const (
glossaryTermJSONLDPrefix = `{"@context":"https://schema.org","@type":"DefinedTerm","name":`
glossaryTermJSONLDMiddle = `,"description":`
glossaryTermJSONLDSuffix = `,"inDefinedTermSet":"`
)
// glossaryTermCtx builds the term page's render context (split out for
// tests). baseURL is origin(r)+cfg.Base — the request-scoped value
// respondHTML also stamps as ctx["BaseURL"], needed a step early here because
// it feeds the DefinedTerm JSON-LD, which must be ready before render.
func (h *Handlers) glossaryTermCtx(slug string, entry contentdata.GlossaryTerm, baseURL string) (map[string]any, error) {
others := make([]contentdata.GlossaryTerm, 0, len(h.glossary.Terms)-1)
for _, t := range h.glossary.Terms {
if t.Slug != slug {
others = append(others, t)
}
}
// tojson both string fields exactly like Jinja's |tojson filter did
// (toJSON is the same helper glossary_term.html.tmpl's inline JSON-LD
// used to use directly — reused here since this build now happens in Go).
name, err := toJSON(entry.Term)
if err != nil {
return nil, err
}
desc, err := toJSON(entry.Short)
if err != nil {
return nil, err
}
jsonld := glossaryTermJSONLDPrefix + string(name) +
glossaryTermJSONLDMiddle + string(desc) +
glossaryTermJSONLDSuffix + baseURL + `/glossary"}`
return map[string]any{
"Term": entry.Term,
"Body": template.HTML(strings.ReplaceAll(entry.Body, "{base}", h.cfg.Base)),
"CanonicalPath": "/glossary/" + slug,
"Breadcrumb": []contentapi.Crumb{
crumb("Home", h.cfg.Base+"/"),
crumb("Glossary", h.cfg.Base+"/glossary"),
crumb(entry.Term, ""),
},
"PageTitle": fmt.Sprintf("%s: what it means | Thermograph", entry.Term),
"PageDescription": entry.Short,
"Others": others,
"JSONLDStr": template.JS(jsonld), // see the JSONLDStr comment on cityCtx for why JS, not HTML
}, nil
}
// AboutPage renders /about.
func (h *Handlers) AboutPage(w http.ResponseWriter, r *http.Request) {
ctx := map[string]any{
"CanonicalPath": "/about",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("About", "")},
"PageTitle": h.pages["about"].Title,
"PageDescription": h.pages["about"].Description,
}
h.respondHTML(w, r, "about.html.tmpl", "", ctx)
}
// PrivacyPage renders /privacy.
func (h *Handlers) PrivacyPage(w http.ResponseWriter, r *http.Request) {
ctx := map[string]any{
"CanonicalPath": "/privacy",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Privacy", "")},
"PageTitle": h.pages["privacy"].Title,
"PageDescription": h.pages["privacy"].Description,
}
h.respondHTML(w, r, "privacy.html.tmpl", "", ctx)
}
// --- homepage ----------------------------------------------------------------
// homeTitle/homeDescription are the literal strings the Jinja original wrote
// directly into home.html.j2's {% block title/description %} overrides,
// never routed through content.py's ctx dict — see the comment at homeCtx's
// call site for why this port sets them in Go instead.
const (
homeTitle = "Thermograph: how unusual is your weather?"
homeDescription = "See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile."
)
// homeJSONLDPrefix is content.py's _HOME_JSONLD serialized exactly as
// json.dumps() with default separators emitted it (spaces after ':' and ',',
// key order preserved, True -> true) — only the request-dependent "url" is
// appended at render time. Kept as a literal so the bytes cannot drift from
// what the Python shipped.
const homeJSONLDPrefix = `{"@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": `
func homeJSONLD(url string) (string, error) {
// json.dumps escaping for the one dynamic value (Python didn't escape
// HTML-significant characters, so the encoder must not either).
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(url); err != nil {
return "", err
}
return homeJSONLDPrefix + strings.TrimRight(buf.String(), "\n") + "}", nil
}
// HomePage renders / (content.py's home_page).
//
// ctx keys: Section, BrandTag, MainClass, PageTitle, PageDescription,
// CanonicalPath, Unusual, Stale, Ranked, Cities, JSONLDStr.
// contentapi.HomeUnusual/HomeRanked/HomeCity's own
// fields already match what the template reads, so Ranked/Cities pass
// straight through and only Unusual needs dereference-or-nil (a *HomeUnusual
// stored typed-nil in the ctx would hard-error on field access; nil `any`
// degrades to empty instead). Ranked's Lat/Lon stay json.Number so the
// backend's exact decimal text prints into the /day#lat=…&lon=… fragment.
func (h *Handlers) HomePage(w http.ResponseWriter, r *http.Request) {
hp, err := h.api.Home()
if err != nil {
h.apiError(w, err)
return
}
ctx, err := h.homeCtx(hp, origin(r))
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "home.html.tmpl", "", ctx)
}
// homeCtx builds the homepage render context (split out for tests). o is the
// request origin, baked into the JSON-LD url.
func (h *Handlers) homeCtx(hp *contentapi.HomePayload, o string) (map[string]any, error) {
var unusual any
if hp.Unusual != nil {
unusual = *hp.Unusual
}
jsonld, err := homeJSONLD(o + h.cfg.Base + "/")
if err != nil {
return nil, err
}
ctx := map[string]any{
"Section": "home",
"BrandTag": "p", // the hero headline owns the sole h1; the brand degrades to <p>
"MainClass": "",
// The Jinja original overrode base.html.j2's {% block title/description
// %} with these exact literals directly in home.html.j2 -- content.py's
// home_page never set page_title/page_description in its ctx dict
// either. This port's base is split into sequential chunks rather than
// Jinja blocks (see render.go), so there is no override point once
// base_head_start has already emitted <title>; setting them here is
// the equivalent for this one route.
"PageTitle": homeTitle,
"PageDescription": homeDescription,
"CanonicalPath": "/",
"Unusual": unusual,
"Stale": hp.Stale,
"Ranked": hp.Ranked,
"Cities": hp.Cities,
// template.JS, NOT template.HTML: this value is interpolated inside
// <script type="application/ld+json">...</script>. html/template's
// contextual escaper treats <script> bodies as JAVASCRIPT context
// regardless of the script's `type` attribute — template.HTML's trust
// guarantee only covers HTML markup context, so inside <script> it
// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
// quoted JS string with every inner quote backslash-escaped, i.e. the
// whole JSON-LD payload doubly wrapped in a string literal instead of
// being emitted as the raw JSON object search engines expect.
// template.JS is the type that means "trusted JS source, emit as-is".
"JSONLDStr": template.JS(jsonld),
}
return ctx, nil
}

View file

@ -0,0 +1,119 @@
package content
import (
"net/http"
"strings"
)
// RobotsTxt is the port of content.py's robots_txt — the body is built the
// same way, byte for byte.
func (h *Handlers) RobotsTxt(w http.ResponseWriter, r *http.Request) {
base := h.cfg.Base
baseURL := origin(r) + base
body := "User-agent: *\n" +
"Allow: /\n" +
"Disallow: " + base + "/api/\n" +
"Disallow: " + base + "/alerts\n" +
"Sitemap: " + baseURL + "/sitemap.xml\n"
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(body))
}
// SitemapXML is the port of content.py's sitemap_xml: one <url> line per
// backend sitemap entry, <lastmod> pinned to the per-process boot date.
// ~2.35 MB and crawled repeatedly — the short Cache-Control saves every
// crawler hit within the window a full Sitemap() call plus the string build,
// on top of the response body itself (the backend client's TTL cache absorbs
// the rest).
func (h *Handlers) SitemapXML(w http.ResponseWriter, r *http.Request) {
baseURL := origin(r) + h.cfg.Base
entries, err := h.api.Sitemap()
if err != nil {
h.apiError(w, err)
return
}
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
for _, e := range entries {
b.WriteString("\n<url><loc>")
b.WriteString(baseURL)
b.WriteString(e.Path)
b.WriteString("</loc><lastmod>")
b.WriteString(h.bootDate)
b.WriteString("</lastmod><changefreq>")
b.WriteString(e.Changefreq)
b.WriteString("</changefreq><priority>")
b.WriteString(e.Priority)
b.WriteString("</priority></url>")
}
b.WriteString("\n</urlset>")
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write([]byte(b.String()))
}
// registerIndexNow wires the IndexNow key file. IndexNow verification works
// by serving a file at /<key>.txt — the key isn't just response *content*,
// it's baked into the route *path*, which the mux needs at registration
// time. That used to mean an eager, unretried key fetch at boot — so if the
// backend was unreachable (down, mid-restart, a network blip) the frontend
// would never finish booting at all. That's exactly the coupling the
// repo-split removed: frontend and backend deploy asynchronously, so
// "backend happens to be briefly unreachable" must be a normal, survivable
// condition at frontend boot, not a crash.
//
// So: try the eager fetch once (preserving the plain static route for the
// overwhelmingly common case where the backend IS reachable at boot). If it
// fails, log a warning and fall back to a route that lazily (re)fetches the
// key on each request via the client's TTL cache — once the backend comes
// back up the correct key starts being served automatically, no frontend
// restart required, and no request ever gets a permanently wrong/empty key.
//
// Mux mechanics differ from Starlette here: Go's patterns cannot express the
// Python's "/{token}.txt" suffix wildcard, so the lazy fallback claims the
// whole single-segment slot ({BASE}/{token}) and forwards anything that
// isn't a .txt request to the static file server it displaced (explicit page
// routes still win on specificity).
func (h *Handlers) registerIndexNow(mux *http.ServeMux, static http.Handler) {
base := h.cfg.Base
key, err := h.api.IndexNowKey()
if err == nil {
mux.HandleFunc("GET "+base+"/"+key+".txt", func(w http.ResponseWriter, r *http.Request) {
writeKey(w, key)
})
return
}
h.log.Printf("indexnow_key fetch failed at boot (backend unreachable?) -- "+
"continuing boot without it; falling back to lazy per-request lookup at "+
"/<key>.txt so the frontend doesn't depend on backend liveness to start up: %v", err)
mux.HandleFunc("GET "+base+"/{token}", func(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
if !strings.HasSuffix(token, ".txt") {
// Not a key-file request: this pattern displaced the static
// subtree for single-segment paths, so hand it back.
if static != nil {
static.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
return
}
key, err := h.api.IndexNowKey()
if err != nil {
writeDetail(w, http.StatusServiceUnavailable, "backend unavailable")
return
}
if strings.TrimSuffix(token, ".txt") != key {
writeDetail(w, http.StatusNotFound, "Not Found")
return
}
writeKey(w, key)
})
}
func writeKey(w http.ResponseWriter, key string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(key + "\n"))
}

View file

@ -0,0 +1,329 @@
// Package contentapi is the HTTP client for the backend's SSR content API
// (backend/api/content_routes.py) — the Go port of api_client.py, mirroring
// its behavior exactly:
//
// - A short TTL cache sits in front of every call: climate data changes at
// most hourly (the archive top-up + hourly recent-forecast refresh), so
// without this a page-view burst on one city would cost one backend round
// trip per request instead of one per cache window. This is in addition to
// (not instead of) the backend's own derived-store/ETag caching — this
// cache saves the network hop entirely; the backend's saves the recompute.
// - Bounded LRU: City()/CityRecords() fold the browser-facing origin
// (client-controlled via Host/X-Forwarded-Host) into the cache key, so an
// unbounded map is a cheap memory-exhaustion vector — spoof enough
// distinct Host values and the cache grows forever.
// - Per-key single-flight: without it, N concurrent requests that miss the
// same cache key (cold start, or right after TTL expiry on a hot city)
// each fire their own backend call — a thundering herd. One lock per
// in-flight key makes every caller but the first block on, then reuse,
// that first caller's result instead of duplicating the request.
//
// Note on ETag / If-None-Match: this internal client never sends conditional
// requests — the Python client didn't either. The ETag half of the caching
// contract lives on the SERVING side (the W/"sha1…" ETags this service stamps
// on rendered HTML, see internal/render) and in the browser (static/cache.js's
// If-None-Match refetch of /api/v2/cell). Between this process and the backend
// the freshness mechanism is purely the TTL cache below.
package contentapi
import (
"container/list"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// DefaultCacheMaxEntries mirrors api_client.py's _CACHE_MAX_ENTRIES.
const DefaultCacheMaxEntries = 2048
// StatusError is a non-2xx backend response — the analogue of
// httpx.HTTPStatusError. Callers map StatusCode straight through (the
// backend's 404 unknown-city and 503 warming become the page's own status);
// any other error from a Client method never got a response at all (a backend
// blip or restart) and maps to a 503, same as content.py's _raise_api_error.
type StatusError struct {
StatusCode int
Body string // response body text, forwarded as the error detail
URL string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("backend returned %d for %s", e.StatusCode, e.URL)
}
// Options configures New. BaseURL is required; zero values elsewhere take the
// documented defaults.
type Options struct {
// BaseURL is THERMOGRAPH_API_BASE_INTERNAL (e.g. http://backend:8137).
BaseURL string
// BasePrefix is the backend's own THERMOGRAPH_BASE, normalized like
// config.Config.Base ("" or "/x"). Both processes are always configured
// with the same value in every real deployment, so reusing frontend's
// keeps this client correct under a sub-path deployment instead of only
// working when BASE is empty/root.
BasePrefix string
// APIVersion is the pinned content-API version (default "v2"). See the
// API-version pinning contract in frontend CLAUDE.md before changing.
APIVersion string
// TTL is the response-cache lifetime (THERMOGRAPH_SSR_CACHE_TTL);
// default 600s.
TTL time.Duration
// MaxCacheEntries caps the LRU (default DefaultCacheMaxEntries).
MaxCacheEntries int
// HTTPClient overrides the default pooled client (tests). The default
// mirrors api_client.py's httpx.Client: 10s total timeout, up to 100
// connections with 20 kept alive.
HTTPClient *http.Client
}
// Client is safe for concurrent use.
type Client struct {
baseURL string
basePrefix string
apiVersion string
ttl time.Duration
maxEntries int
http *http.Client
mu sync.Mutex
cache map[string]*list.Element
order *list.List // front = most recently used
inflightMu sync.Mutex
inflight map[string]*sync.Mutex
}
type cacheEntry struct {
key string
ts time.Time
data any
}
// New builds a Client. It does not touch the network.
func New(o Options) *Client {
if o.APIVersion == "" {
o.APIVersion = "v2"
}
if o.TTL == 0 {
o.TTL = 600 * time.Second
}
if o.MaxCacheEntries == 0 {
o.MaxCacheEntries = DefaultCacheMaxEntries
}
hc := o.HTTPClient
if hc == nil {
hc = &http.Client{
Timeout: 10 * time.Second, // httpx.Client(timeout=10.0)
Transport: &http.Transport{
MaxConnsPerHost: 100, // httpx.Limits(max_connections=100, …)
MaxIdleConnsPerHost: 20, // …max_keepalive_connections=20
},
}
}
return &Client{
baseURL: strings.TrimRight(o.BaseURL, "/"),
basePrefix: o.BasePrefix,
apiVersion: o.APIVersion,
ttl: o.TTL,
maxEntries: o.MaxCacheEntries,
http: hc,
cache: make(map[string]*list.Element),
order: list.New(),
inflight: make(map[string]*sync.Mutex),
}
}
func (c *Client) cacheGet(key string) any {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.cache[key]
if !ok {
return nil
}
ent := el.Value.(*cacheEntry)
if time.Since(ent.ts) >= c.ttl {
c.order.Remove(el)
delete(c.cache, key)
return nil
}
c.order.MoveToFront(el)
return ent.data
}
func (c *Client) cachePut(key string, data any) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.cache[key]; ok {
ent := el.Value.(*cacheEntry)
ent.ts, ent.data = time.Now(), data
c.order.MoveToFront(el)
} else {
c.cache[key] = c.order.PushFront(&cacheEntry{key: key, ts: time.Now(), data: data})
}
for len(c.cache) > c.maxEntries {
oldest := c.order.Back() // evict least-recently-used
c.order.Remove(oldest)
delete(c.cache, oldest.Value.(*cacheEntry).key)
}
}
// get GETs {BaseURL}{BasePrefix}{path}, cached for TTL. decode turns the
// response body into the typed payload that gets cached and returned.
//
// origin (e.g. "https://thermograph.org") is the ORIGINAL browser-facing
// request's origin, not this internal call's — forwarded as Host/
// X-Forwarded-Proto so the backend's own _origin(request) (which already
// prefers those headers) builds jsonld "url" fields against the public origin
// instead of this internal http://backend:8137-style address. Folded into the
// cache key: harmless when there's one canonical origin (the default
// same-domain prod topology), and correct when there's more than one. Empty
// string means "no origin to forward".
func (c *Client) get(path, origin string, decode func([]byte) (any, error)) (any, error) {
cacheKey := path
if origin != "" {
cacheKey = origin + "|" + path
}
if hit := c.cacheGet(cacheKey); hit != nil {
return hit, nil
}
c.inflightMu.Lock()
lock, ok := c.inflight[cacheKey]
if !ok {
lock = &sync.Mutex{}
c.inflight[cacheKey] = lock
}
c.inflightMu.Unlock()
lock.Lock()
defer lock.Unlock()
// Someone else may have filled it while we waited on the flight lock.
if hit := c.cacheGet(cacheKey); hit != nil {
return hit, nil
}
// Mirror the Python's finally: the in-flight entry goes away whether the
// fetch succeeded or not — errors are never cached.
defer func() {
c.inflightMu.Lock()
delete(c.inflight, cacheKey)
c.inflightMu.Unlock()
}()
body, err := c.fetch(path, origin)
if err != nil {
return nil, err
}
data, err := decode(body)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
c.cachePut(cacheKey, data)
return data, nil
}
func (c *Client) fetch(path, origin string) ([]byte, error) {
url := c.baseURL + c.basePrefix + path
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if origin != "" {
// origin.partition("://"): everything after the separator is the host
// (empty if the separator is missing, same as the Python).
proto, host, _ := strings.Cut(origin, "://")
req.Host = host
req.Header.Set("X-Forwarded-Proto", proto)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err // transport-level: callers map to 503
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &StatusError{StatusCode: resp.StatusCode, Body: string(body), URL: url}
}
return body, nil
}
// decodeInto adapts a typed unmarshal to get's decode callback.
func decodeInto[T any](body []byte) (any, error) {
v := new(T)
if err := json.Unmarshal(body, v); err != nil {
return nil, err
}
return v, nil
}
// Hub returns the climate-hub payload (/content/hub).
func (c *Client) Hub() (*HubPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/hub", c.apiVersion), "", decodeInto[HubPayload])
if err != nil {
return nil, err
}
return v.(*HubPayload), nil
}
// Sitemap returns every sitemap entry (/content/sitemap).
func (c *Client) Sitemap() ([]SitemapEntry, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/sitemap", c.apiVersion), "", decodeInto[[]SitemapEntry])
if err != nil {
return nil, err
}
return *v.(*[]SitemapEntry), nil
}
// IndexNowKey returns the IndexNow verification key (/content/indexnow-key).
func (c *Client) IndexNowKey() (string, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/indexnow-key", c.apiVersion), "", decodeInto[indexNowPayload])
if err != nil {
return "", err
}
return v.(*indexNowPayload).Key, nil
}
// Home returns the homepage payload (/content/home).
func (c *Client) Home() (*HomePayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/home", c.apiVersion), "", decodeInto[HomePayload])
if err != nil {
return nil, err
}
return v.(*HomePayload), nil
}
// City returns one city's climate-page payload. A *StatusError with
// StatusCode 404 (unknown city) or 503 (warming) maps straight through to the
// page response, same status codes the old in-process code raised. origin ""
// omits the Host/X-Forwarded-Proto forwarding.
func (c *Client) City(slug, origin string) (*CityPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s", c.apiVersion, slug), origin, decodeInto[CityPayload])
if err != nil {
return nil, err
}
return v.(*CityPayload), nil
}
// CityMonth returns one city-month payload.
func (c *Client) CityMonth(slug, month string) (*MonthPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/month/%s", c.apiVersion, slug, month), "", decodeInto[MonthPayload])
if err != nil {
return nil, err
}
return v.(*MonthPayload), nil
}
// CityRecords returns one city's records-page payload.
func (c *Client) CityRecords(slug, origin string) (*RecordsPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/records", c.apiVersion, slug), origin, decodeInto[RecordsPayload])
if err != nil {
return nil, err
}
return v.(*RecordsPayload), nil
}

View file

@ -0,0 +1,251 @@
package contentapi
import (
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
func newTestClient(t *testing.T, handler http.Handler, opts Options) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
opts.BaseURL = srv.URL
return New(opts), srv
}
func TestHappyPathAndCaching(t *testing.T) {
var hits atomic.Int64
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path != "/api/v2/content/hub" {
t.Errorf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"n_cities": 2, "n_countries": 1,
"countries": [{"country": "France", "cities": [
{"slug": "paris-fr", "name": "Paris", "display": "Paris, France"}]}]}`))
}), Options{})
h, err := c.Hub()
if err != nil {
t.Fatalf("Hub: %v", err)
}
if h.NCities != 2 || len(h.Countries) != 1 || h.Countries[0].Cities[0].Slug != "paris-fr" {
t.Errorf("unexpected payload: %+v", h)
}
// Second call inside the TTL is served from cache — no new backend hit.
if _, err := c.Hub(); err != nil {
t.Fatalf("Hub (cached): %v", err)
}
if got := hits.Load(); got != 1 {
t.Errorf("backend hits = %d, want 1 (TTL cache)", got)
}
}
func TestNon2xxIsStatusError(t *testing.T) {
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"detail":"Unknown city."}`))
}), Options{})
_, err := c.City("nowhere", "")
var se *StatusError
if !errors.As(err, &se) {
t.Fatalf("want *StatusError, got %v", err)
}
if se.StatusCode != 404 {
t.Errorf("StatusCode = %d, want 404", se.StatusCode)
}
if se.Body != `{"detail":"Unknown city."}` {
t.Errorf("Body = %q", se.Body)
}
// Errors are never cached: the next call must reach the backend again.
if _, err := c.City("nowhere", ""); err == nil {
t.Fatal("expected the error to repeat, not a cached success")
}
}
func TestNotModifiedIsStatusError(t *testing.T) {
// This client never sends If-None-Match (the Python didn't either), so a
// 304 is an out-of-contract response: it must surface as an error, never
// be cached as data.
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if inm := r.Header.Get("If-None-Match"); inm != "" {
t.Errorf("client sent If-None-Match %q; it must not send conditional requests", inm)
}
w.WriteHeader(http.StatusNotModified)
}), Options{})
_, err := c.Home()
var se *StatusError
if !errors.As(err, &se) {
t.Fatalf("want *StatusError, got %v", err)
}
if se.StatusCode != 304 {
t.Errorf("StatusCode = %d, want 304", se.StatusCode)
}
}
func TestTimeoutSurfacesAsTransportError(t *testing.T) {
block := make(chan struct{})
defer close(block)
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-block
}), Options{HTTPClient: &http.Client{Timeout: 50 * time.Millisecond}})
_, err := c.Home()
if err == nil {
t.Fatal("expected a timeout error")
}
var se *StatusError
if errors.As(err, &se) {
t.Fatalf("timeout must be a transport error (503 to callers), not StatusError %d", se.StatusCode)
}
}
func TestTTLExpiry(t *testing.T) {
var hits atomic.Int64
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Write([]byte(`{"key":"abc123"}`))
}), Options{TTL: 30 * time.Millisecond})
if k, err := c.IndexNowKey(); err != nil || k != "abc123" {
t.Fatalf("IndexNowKey = %q, %v", k, err)
}
c.IndexNowKey() // cached
time.Sleep(40 * time.Millisecond)
c.IndexNowKey() // expired -> refetch
if got := hits.Load(); got != 2 {
t.Errorf("backend hits = %d, want 2 (one refetch after TTL)", got)
}
}
func TestLRUEvictionBounded(t *testing.T) {
c := New(Options{BaseURL: "http://unused", MaxCacheEntries: 3})
for _, k := range []string{"k0", "k1", "k2"} {
c.cachePut(k, k)
}
// Touch k0 so it's most-recently-used; k1 becomes the eviction candidate.
if c.cacheGet("k0") == nil {
t.Fatal("k0 should be present")
}
c.cachePut("k3", "k3")
if c.cacheGet("k1") != nil {
t.Error("k1 should have been evicted (least-recently-used)")
}
for _, k := range []string{"k0", "k2", "k3"} {
if c.cacheGet(k) == nil {
t.Errorf("%s should have survived", k)
}
}
if n := len(c.cache); n != 3 {
t.Errorf("cache size = %d, want 3", n)
}
}
func TestSingleFlightDedupesConcurrentMisses(t *testing.T) {
// N concurrent misses on the same key must reach the backend once, not N
// times — every caller gets the same result either way.
var hits atomic.Int64
release := make(chan struct{})
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
<-release
w.Write([]byte(`{"unusual": null, "stale": false, "ranked": [], "cities": []}`))
}), Options{})
const n = 5
var wg sync.WaitGroup
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
_, errs[i] = c.Home()
}()
}
time.Sleep(100 * time.Millisecond) // let every goroutine reach the flight lock
close(release)
wg.Wait()
if got := hits.Load(); got != 1 {
t.Errorf("backend hits = %d, want exactly 1 for %d concurrent misses", got, n)
}
for i, err := range errs {
if err != nil {
t.Errorf("caller %d: %v", i, err)
}
}
c.inflightMu.Lock()
inflight := len(c.inflight)
c.inflightMu.Unlock()
if inflight != 0 {
t.Errorf("inflight map should be empty after completion, has %d entries", inflight)
}
}
func TestOriginForwardedAsHostAndProto(t *testing.T) {
var gotHost, gotProto string
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHost, gotProto = r.Host, r.Header.Get("X-Forwarded-Proto")
w.Write([]byte(`{"city":{"slug":"x","name":"X","admin1":"","country":"","country_code":"GB",` +
`"lat":1,"lon":2,"population":3},"display":"X","title":"t","year_range":[1980,2025],` +
`"n_years":45,"months":[],"warmest_month_slug":"","coldest_month_slug":"",` +
`"wettest_month_slug":"","all_time_records":{},"today_vs_normal":null,"flavor":null,` +
`"event":null,"default_unit":"C","breadcrumb":[],"canonical_path":"/climate/x",` +
`"page_title":"","page_description":"","jsonld":{}}`))
}), Options{})
if _, err := c.City("x", "https://thermograph.org"); err != nil {
t.Fatalf("City: %v", err)
}
if gotHost != "thermograph.org" {
t.Errorf("Host = %q, want thermograph.org", gotHost)
}
if gotProto != "https" {
t.Errorf("X-Forwarded-Proto = %q, want https", gotProto)
}
}
func TestOriginIsPartOfCacheKey(t *testing.T) {
var hits atomic.Int64
body := `{"city":{"slug":"x","name":"X","admin1":"","country":"","country_code":"GB",` +
`"lat":1,"lon":2,"population":3},"display":"X","title":"t","year_range":[1980,2025],` +
`"n_years":45,"months":[],"warmest_month_slug":"","coldest_month_slug":"",` +
`"wettest_month_slug":"","all_time_records":{},"today_vs_normal":null,"flavor":null,` +
`"event":null,"default_unit":"C","breadcrumb":[],"canonical_path":"/climate/x",` +
`"page_title":"","page_description":"","jsonld":{}}`
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Write([]byte(body))
}), Options{})
c.City("x", "https://a.example")
c.City("x", "https://b.example") // distinct origin -> distinct cache entry
c.City("x", "https://a.example") // cached
if got := hits.Load(); got != 2 {
t.Errorf("backend hits = %d, want 2 (origin folded into the cache key)", got)
}
}
func TestBasePrefixPrependedToEveryPath(t *testing.T) {
var gotPath string
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Write([]byte(`{"key":"k"}`))
}), Options{BasePrefix: "/thermograph"})
if _, err := c.IndexNowKey(); err != nil {
t.Fatalf("IndexNowKey: %v", err)
}
if gotPath != "/thermograph/api/v2/content/indexnow-key" {
t.Errorf("path = %q, want the backend's own THERMOGRAPH_BASE prefix applied", gotPath)
}
}

View file

@ -0,0 +1,281 @@
package contentapi
import "encoding/json"
// Payload types for the backend's /content/* endpoints, shaped after the
// captured fixtures in frontend/tests/fixtures/*.json and the field accesses in
// content.py + templates/*.j2. The backend (backend/api/content_payloads.py,
// backend/api/homepage.py) owns these shapes — page_title / canonical_path /
// breadcrumb / jsonld are all computed there, never here.
//
// Conventions:
// - *float64 / *string / *int for anything the Python checked `is not None`
// or that the fixtures show as nullable.
// - json.RawMessage for jsonld: the Python re-serialized the parsed object
// with separators=(",", ":") and dict order preserved — in Go, re-marshaling
// a map would SORT the keys and break golden-diff parity, so keep the raw
// bytes and compact them (json.Compact) at render time instead.
// - json.Number for values templates print raw (home ranked lat/lon): it
// preserves the backend's exact decimal text, dodging any float-formatting
// drift between Python's repr and Go's strconv.
// Crumb is one breadcrumb element; the terminal crumb has a null href.
type Crumb struct {
Name string `json:"name"`
Href *string `json:"href"`
}
// --- /content/hub -----------------------------------------------------------
type HubPayload struct {
NCities int `json:"n_cities"`
NCountries int `json:"n_countries"`
Countries []HubCountry `json:"countries"`
}
type HubCountry struct {
Country string `json:"country"`
Cities []HubCity `json:"cities"`
}
type HubCity struct {
Slug string `json:"slug"`
Name string `json:"name"`
Display string `json:"display"`
}
// --- /content/sitemap -------------------------------------------------------
type SitemapEntry struct {
Path string `json:"path"`
Changefreq string `json:"changefreq"`
Priority string `json:"priority"`
}
// --- /content/indexnow-key --------------------------------------------------
type indexNowPayload struct {
Key string `json:"key"`
}
// --- /content/home ----------------------------------------------------------
type HomePayload struct {
Unusual *HomeUnusual `json:"unusual"`
Stale bool `json:"stale"`
Ranked []HomeRanked `json:"ranked"`
Cities []HomeCity `json:"cities"`
}
// HomeUnusual is the hero card's pick (home.html.j2). Built by
// backend/api/homepage.py's _grade_city + the is_default flag.
type HomeUnusual struct {
Cls string `json:"cls"`
Display string `json:"display"`
Grade string `json:"grade"`
MetricLabel string `json:"metric_label"`
Percentile float64 `json:"percentile"` // rendered via the ordinal filter
WindowLabel string `json:"window_label"`
IsDefault bool `json:"is_default"`
Date string `json:"date"`
}
// HomeRanked is one "Unusual right now" strip card.
type HomeRanked struct {
Cls string `json:"cls"`
Display string `json:"display"`
// Lat/Lon are printed verbatim into the /day#lat=…&lon=… fragment —
// json.Number keeps the backend's exact text (round(x, 4) in Python).
Lat json.Number `json:"lat"`
Lon json.Number `json:"lon"`
Value *float64 `json:"value"` // temp(r.value)
GradeLabel string `json:"grade_label"`
MetricLabel string `json:"metric_label"`
PctLabel string `json:"pct_label"`
Normal *float64 `json:"normal"` // `is not none` in the template
DateLabel *string `json:"date_label"` // null when the reading is today's
}
type HomeCity struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
// --- /content/city/{slug} ---------------------------------------------------
type CityPayload struct {
City CityInfo `json:"city"`
Display string `json:"display"`
Title string `json:"title"`
YearRange []int `json:"year_range"`
NYears int `json:"n_years"`
Months []CityMonth `json:"months"`
WarmestMonthSlug string `json:"warmest_month_slug"`
ColdestMonthSlug string `json:"coldest_month_slug"`
WettestMonthSlug string `json:"wettest_month_slug"`
AllTimeRecords AllTimeRecords `json:"all_time_records"`
TodayVsNormal *TodayVsNormal `json:"today_vs_normal"`
Flavor *Flavor `json:"flavor"`
Event *Event `json:"event"`
DefaultUnit string `json:"default_unit"`
Breadcrumb []Crumb `json:"breadcrumb"`
CanonicalPath string `json:"canonical_path"`
PageTitle string `json:"page_title"`
PageDescription string `json:"page_description"`
JSONLD json.RawMessage `json:"jsonld"`
}
type CityInfo struct {
Slug string `json:"slug"`
Name string `json:"name"`
Admin1 string `json:"admin1"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Lat float64 `json:"lat"` // formatted %.5f / %.4f by the handlers
Lon float64 `json:"lon"`
Population int `json:"population"`
}
type CityMonth struct {
Name string `json:"name"`
Slug string `json:"slug"`
HighF *float64 `json:"high_f"`
LowF *float64 `json:"low_f"`
RangeLoF *float64 `json:"range_lo_f"`
RangeHiF *float64 `json:"range_hi_f"`
PrecipF *float64 `json:"precip_f"`
}
// AllTimeRecords maps metric -> extremes; templates access fields by name
// (records.tmax.max …) and nil-check each metric, so pointers, not a map.
type AllTimeRecords struct {
Tmax *MetricRecord `json:"tmax"`
Tmin *MetricRecord `json:"tmin"`
Feels *MetricRecord `json:"feels"`
Fmax *MetricRecord `json:"fmax"`
Fmin *MetricRecord `json:"fmin"`
Humid *MetricRecord `json:"humid"`
Wind *MetricRecord `json:"wind"`
Gust *MetricRecord `json:"gust"`
Precip *MetricRecord `json:"precip"`
}
type MetricRecord struct {
Max *float64 `json:"max"`
MaxDate string `json:"max_date"`
Min *float64 `json:"min"`
MinDate string `json:"min_date"`
}
type TodayVsNormal struct {
Date string `json:"date"`
Cards []TodayCard `json:"cards"`
}
type TodayCard struct {
Label string `json:"label"`
Metric string `json:"metric"` // key into format's fmt() dispatch
ValueF *float64 `json:"value_f"`
Percentile *float64 `json:"percentile"` // `is not none` in city.html.j2
Grade string `json:"grade"`
Cls string `json:"cls"`
}
type Flavor struct {
Extract string `json:"extract"`
Title string `json:"title"`
URL string `json:"url"`
}
type Event struct {
Text string `json:"text"`
URL string `json:"url"`
}
// --- /content/city/{slug}/month/{month} -------------------------------------
type MonthPayload struct {
MonthName string `json:"month_name"`
MonthSlug string `json:"month_slug"`
YearRange []int `json:"year_range"`
NYears int `json:"n_years"`
AvgHighF *float64 `json:"avg_high_f"`
AvgLowF *float64 `json:"avg_low_f"`
TypicalHighRangeF []*float64 `json:"typical_high_range_f"` // [lo, hi]
TypicalLowRangeF []*float64 `json:"typical_low_range_f"`
AvgPrecipF *float64 `json:"avg_precip_f"`
Records []MonthRecord `json:"records"`
Prev *MonthLink `json:"prev"`
Next *MonthLink `json:"next"`
// Display is optional in the payload; content.py fell back to the city
// name (j.get("display", city["name"])) — nil means "use the fallback".
Display *string `json:"display"`
Breadcrumb []Crumb `json:"breadcrumb"`
CanonicalPath string `json:"canonical_path"`
PageTitle string `json:"page_title"`
PageDescription string `json:"page_description"`
}
type MonthRecord struct {
Label string `json:"label"` // maps to a metric via the display layer's label table
HighF *float64 `json:"high_f"`
HighDate string `json:"high_date"`
HighTag string `json:"high_tag"`
LowF *float64 `json:"low_f"`
LowDate string `json:"low_date"`
LowTag string `json:"low_tag"`
}
type MonthLink struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
// --- /content/city/{slug}/records -------------------------------------------
type RecordsPayload struct {
YearRange []int `json:"year_range"`
NYears int `json:"n_years"`
Rows []RecordRow `json:"rows"`
AllTimeRecords AllTimeRecords `json:"all_time_records"`
Monthly []PeriodRecords `json:"monthly"`
Seasonal []PeriodRecords `json:"seasonal"`
Hemisphere string `json:"hemisphere"`
Display *string `json:"display"` // same fallback as MonthPayload.Display
Breadcrumb []Crumb `json:"breadcrumb"`
CanonicalPath string `json:"canonical_path"`
PageTitle string `json:"page_title"`
PageDescription string `json:"page_description"`
JSONLD json.RawMessage `json:"jsonld"`
}
type RecordRow struct {
Label string `json:"label"`
HighF *float64 `json:"high_f"`
HighDate string `json:"high_date"`
LowF *float64 `json:"low_f"`
LowDate *string `json:"low_date"` // content.py: r["low_date"] or "—"
DryStreakDays *int `json:"dry_streak_days"`
}
// PeriodRecords is one monthly or seasonal block; Slug is set for monthly
// entries, Span for seasonal ones.
type PeriodRecords struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
Span string `json:"span,omitempty"`
High PeriodSide `json:"high"`
Low PeriodSide `json:"low"`
}
// PeriodSide is the {'warm': …|None, 'cold': …|None} pair records.html.j2's
// extremes() macro reads (after display formatting).
type PeriodSide struct {
Warm *Extreme `json:"warm"`
Cold *Extreme `json:"cold"`
}
type Extreme struct {
ValueF float64 `json:"value_f"`
Date string `json:"date"`
}

View file

@ -0,0 +1,129 @@
// Package contentdata loads the structured SSR copy (glossary, static-page
// SEO meta) from frontend/content/*.yaml — the Go port of content_loader.py.
// Validated at load time so a malformed content file fails loudly at boot
// rather than silently rendering a blank/broken page.
//
// The YAML files are shared data, consumed by the Python service before this
// port and committed alongside it — they are not rewritten to JSON for the
// rewrite, which is why this package carries the module's one non-stdlib
// dependency (gopkg.in/yaml.v3; the files use block/folded scalars and
// comments, well beyond anything worth hand-parsing).
package contentdata
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// GlossaryTerm is one glossary entry, in file order. Body is raw HTML copy;
// its "{base}" placeholder is substituted by the page handler (the Python did
// it per-request in _glossary_body), not here.
type GlossaryTerm struct {
Slug string `yaml:"slug"`
Term string `yaml:"term"`
Short string `yaml:"short"`
Body string `yaml:"body"`
}
// Glossary preserves file order (the index page lists terms in the order the
// YAML declares them — Python dicts kept insertion order) and offers slug
// lookup for the per-term page.
type Glossary struct {
Terms []GlossaryTerm
bySlug map[string]int
}
// Get returns the entry for slug, or ok=false (the per-term page's 404).
func (g *Glossary) Get(slug string) (GlossaryTerm, bool) {
i, ok := g.bySlug[slug]
if !ok {
return GlossaryTerm{}, false
}
return g.Terms[i], true
}
// Page is one static page's SEO title/description.
type Page struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
}
// LoadGlossary reads contentDir/glossary.yaml. It errors if any entry is
// missing a required field, has the wrong shape, or repeats a slug — a bad
// content edit fails loudly (at boot) rather than rendering a blank glossary
// card. Same contract as content_loader.load_glossary.
func LoadGlossary(contentDir string) (*Glossary, error) {
path := filepath.Join(contentDir, "glossary.yaml")
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc struct {
Terms []map[string]string `yaml:"terms"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if len(doc.Terms) == 0 {
return nil, fmt.Errorf("%s: 'terms' must be a non-empty list", path)
}
g := &Glossary{bySlug: make(map[string]int, len(doc.Terms))}
for i, entry := range doc.Terms {
slug := entry["slug"]
if slug == "" {
return nil, fmt.Errorf("%s: terms[%d] must be a mapping with a 'slug' key", path, i)
}
var missing []string
for _, f := range []string{"term", "short", "body"} {
if entry[f] == "" {
missing = append(missing, f)
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("%s: term '%s' is missing %v", path, slug, missing)
}
if _, dup := g.bySlug[slug]; dup {
return nil, fmt.Errorf("%s: duplicate slug '%s'", path, slug)
}
g.bySlug[slug] = len(g.Terms)
g.Terms = append(g.Terms, GlossaryTerm{
Slug: slug, Term: entry["term"], Short: entry["short"], Body: entry["body"],
})
}
return g, nil
}
// LoadPages reads contentDir/pages.yaml: page key -> {title, description}.
// Same fail-loud contract as LoadGlossary.
func LoadPages(contentDir string) (map[string]Page, error) {
path := filepath.Join(contentDir, "pages.yaml")
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc struct {
Pages map[string]Page `yaml:"pages"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if len(doc.Pages) == 0 {
return nil, fmt.Errorf("%s: 'pages' must be a non-empty mapping", path)
}
for key, p := range doc.Pages {
var missing []string
if p.Title == "" {
missing = append(missing, "title")
}
if p.Description == "" {
missing = append(missing, "description")
}
if len(missing) > 0 {
return nil, fmt.Errorf("%s: page '%s' is missing %v", path, key, missing)
}
}
return doc.Pages, nil
}

View file

@ -0,0 +1,111 @@
package contentdata
import (
"os"
"path/filepath"
"testing"
)
func writeContent(t *testing.T, name, body string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
func TestLoadGlossaryOrderAndLookup(t *testing.T) {
dir := writeContent(t, "glossary.yaml", `
terms:
- slug: b-term
term: B
short: short b
body: |-
Body <b>b</b> with {base} link.
- slug: a-term
term: A
short: short a
body: body a
`)
g, err := LoadGlossary(dir)
if err != nil {
t.Fatalf("LoadGlossary: %v", err)
}
// File order, not alphabetical — the index page renders in this order.
if len(g.Terms) != 2 || g.Terms[0].Slug != "b-term" || g.Terms[1].Slug != "a-term" {
t.Errorf("order wrong: %+v", g.Terms)
}
e, ok := g.Get("b-term")
if !ok || e.Term != "B" || e.Body != "Body <b>b</b> with {base} link." {
t.Errorf("Get(b-term) = %+v, %v", e, ok)
}
if _, ok := g.Get("missing"); ok {
t.Error("Get(missing) should report not-found")
}
}
func TestLoadGlossaryFailsLoud(t *testing.T) {
cases := map[string]string{
"empty terms": "terms: []\n",
"missing field": "terms:\n- slug: x\n term: X\n short: s\n", // no body
"missing slug": "terms:\n- term: X\n short: s\n body: b\n",
"duplicate slug": "terms:\n- {slug: x, term: X, short: s, body: b}\n- {slug: x, term: Y, short: s, body: b}\n",
"empty required": "terms:\n- {slug: x, term: X, short: '', body: b}\n", // falsy value fails, like the Python's `not entry.get(f)`
}
for name, body := range cases {
if _, err := LoadGlossary(writeContent(t, "glossary.yaml", body)); err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestLoadPages(t *testing.T) {
dir := writeContent(t, "pages.yaml", `
pages:
about:
title: "About | X"
description: >-
Folded
description.
`)
p, err := LoadPages(dir)
if err != nil {
t.Fatalf("LoadPages: %v", err)
}
if p["about"].Title != "About | X" || p["about"].Description != "Folded description." {
t.Errorf("pages = %+v", p)
}
if _, err := LoadPages(writeContent(t, "pages.yaml", "pages: {}\n")); err == nil {
t.Error("empty pages mapping should fail loud")
}
if _, err := LoadPages(writeContent(t, "pages.yaml", "pages:\n about: {title: T}\n")); err == nil {
t.Error("missing description should fail loud")
}
}
// Smoke-check against the real committed content files (frontend/content/),
// when present — the same files the Python loader validated at import.
func TestLoadRealContentFiles(t *testing.T) {
dir := filepath.Join("..", "..", "..", "content")
if _, err := os.Stat(filepath.Join(dir, "glossary.yaml")); err != nil {
t.Skipf("real content dir not available: %v", err)
}
g, err := LoadGlossary(dir)
if err != nil {
t.Fatalf("LoadGlossary(real): %v", err)
}
if len(g.Terms) == 0 {
t.Error("real glossary should have terms")
}
p, err := LoadPages(dir)
if err != nil {
t.Fatalf("LoadPages(real): %v", err)
}
for _, key := range []string{"about", "privacy", "hub", "glossary_index"} {
if _, ok := p[key]; !ok {
t.Errorf("real pages.yaml missing key %q (content.py reads it)", key)
}
}
}

View file

@ -0,0 +1,71 @@
// Percentile grading bands, ported EXACTLY from backend/data/grading.py —
// the cross-repo display contract both this domain's CLAUDE.md and the
// backend's call out. The backend is the source of truth for the tier names
// and thresholds; an off-by-one on any boundary paints a tier a different
// colour than the label the API returned. A test in bands_test.go re-parses
// the backend's grading.py (when the monorepo checkout is present) and
// asserts these tables have not drifted.
package format
// Band is one percentile tier: Threshold is the tier's LOWER bound; a tier
// spans [lower, next-lower) — lower-inclusive, upper-exclusive — except the
// TOP tier, which is strict (pct > threshold): see BandFor.
type Band struct {
Threshold float64
Label string
Class string
}
// TempBands maps a percentile to (grade label, css class) for temperature.
// Higher percentile = warmer. 9 symmetric tiers around the middle 40-60%
// "Normal", escalating to "Near Record" at both edges. Boundaries sit on
// multiples of 5 (plus the 1/99 record edges). Source of truth:
// backend/data/grading.py TEMP_BANDS.
var TempBands = []Band{
{99, "Near Record", "rec-hot"}, // >99 extreme high (danger)
{90, "Very High", "very-hot"}, // 90-99
{75, "High", "hot"}, // 75-90
{60, "Above Normal", "warm"}, // 60-75
{40, "Normal", "normal"}, // 40-60 (the middle)
{25, "Below Normal", "cool"}, // 25-40
{10, "Low", "cold"}, // 10-25
{1, "Very Low", "very-cold"}, // 1-10
{0, "Near Record", "rec-cold"}, // <1 extreme low (danger)
}
// RainBands grades precipitation among days with ANY rain (> 0) in the
// seasonal window — a "rain percentile". Rain is one-directional (heavier =
// more extreme), so these 8 tiers are sequential light->heavy, using the SAME
// cut points as temperature. Dry days (no rain at all) are handled separately
// (the "dry" class, colored by dry streak in the UI). Same [lower, upper)
// convention as TempBands. The eight tiers fill the wet-2..wet-9 colour ramp
// with no gap. Source of truth: backend/data/grading.py RAIN_BANDS.
var RainBands = []Band{
{99, "Extreme", "wet-9"}, // >99 heaviest rain for the season (darkest)
{95, "Severe", "wet-8"}, // 95-99 (top half of the old Very Heavy)
{90, "Very Heavy", "wet-7"}, // 90-95 (lower half)
{60, "Heavy", "wet-6"}, // 60-90
{40, "Typical", "wet-5"}, // 40-60
{25, "Brisk", "wet-4"}, // 25-40
{10, "Light", "wet-3"}, // 10-25
{0, "Trace", "wet-2"}, // <10 the lightest measurable rain
}
// BandFor places a percentile on a band table — the port of grading.py's
// _band. The top tier is strict (pct > its threshold): "Near Record" high
// means strictly beyond the 99th percentile — the top <1% — mirroring the
// strictly-below-1st bottom tier (its lower neighbor already catches
// pct >= 1). Everything in between stays lower-inclusive, upper-exclusive.
func BandFor(pct float64, bands []Band) (label, class string) {
top := bands[0]
if pct > top.Threshold {
return top.Label, top.Class
}
for _, b := range bands[1:] {
if pct >= b.Threshold {
return b.Label, b.Class
}
}
last := bands[len(bands)-1]
return last.Label, last.Class
}

View file

@ -0,0 +1,138 @@
package format
import (
"os"
"path/filepath"
"regexp"
"strconv"
"testing"
)
// Boundary tests derived from the BACKEND's grading.py (the source of truth
// for tier names/thresholds — see the cross-repo contract in CLAUDE.md).
// Each threshold is asserted AT the boundary, not just around it, because the
// side each comparison is inclusive on is exactly what an off-by-one breaks:
// the top tier is strict (pct > 99), every other bound is lower-INCLUSIVE.
//
// Note: the Python frontend itself carried no band table (labels/classes
// arrive pre-computed from the content API); these are ported for the Go
// side's display parity per the same contract the backend enforces.
func TestTempBandBoundaries(t *testing.T) {
cases := []struct {
pct float64
wantLabel string
wantClass string
}{
{99.01, "Near Record", "rec-hot"}, // strictly above 99 only
{99, "Very High", "very-hot"}, // AT 99: NOT Near Record (top tier is strict)
{90, "Very High", "very-hot"}, // lower-inclusive
{89.99, "High", "hot"},
{75, "High", "hot"},
{74.99, "Above Normal", "warm"},
{60, "Above Normal", "warm"},
{59.99, "Normal", "normal"},
{40, "Normal", "normal"},
{39.99, "Below Normal", "cool"},
{25, "Below Normal", "cool"},
{24.99, "Low", "cold"},
{10, "Low", "cold"},
{9.99, "Very Low", "very-cold"},
{1, "Very Low", "very-cold"},
{0.99, "Near Record", "rec-cold"}, // strictly below 1
{0, "Near Record", "rec-cold"},
{-1, "Near Record", "rec-cold"}, // out-of-range falls to the bottom tier
{100, "Near Record", "rec-hot"},
}
for _, c := range cases {
label, class := BandFor(c.pct, TempBands)
if label != c.wantLabel || class != c.wantClass {
t.Errorf("BandFor(%v, TempBands) = (%q, %q), want (%q, %q)",
c.pct, label, class, c.wantLabel, c.wantClass)
}
}
}
func TestRainBandBoundaries(t *testing.T) {
cases := []struct {
pct float64
wantLabel string
wantClass string
}{
{99.01, "Extreme", "wet-9"}, // strictly above 99 only
{99, "Severe", "wet-8"}, // AT 99: NOT Extreme
{95, "Severe", "wet-8"},
{94.99, "Very Heavy", "wet-7"},
{90, "Very Heavy", "wet-7"},
{89.99, "Heavy", "wet-6"},
{60, "Heavy", "wet-6"},
{59.99, "Typical", "wet-5"},
{40, "Typical", "wet-5"},
{39.99, "Brisk", "wet-4"},
{25, "Brisk", "wet-4"},
{24.99, "Light", "wet-3"},
{10, "Light", "wet-3"},
{9.99, "Trace", "wet-2"},
{0, "Trace", "wet-2"},
{100, "Extreme", "wet-9"},
}
for _, c := range cases {
label, class := BandFor(c.pct, RainBands)
if label != c.wantLabel || class != c.wantClass {
t.Errorf("BandFor(%v, RainBands) = (%q, %q), want (%q, %q)",
c.pct, label, class, c.wantLabel, c.wantClass)
}
}
}
// parseBackendBands extracts a band table from backend/data/grading.py by
// name, so the Go copies cannot silently drift from the source of truth.
func parseBackendBands(t *testing.T, src []byte, name string) []Band {
t.Helper()
block := regexp.MustCompile(`(?s)` + name + `\s*=\s*\[(.*?)\]`).FindSubmatch(src)
if block == nil {
t.Fatalf("could not find %s in grading.py", name)
}
rows := regexp.MustCompile(`\(\s*(\d+)\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)`).FindAllSubmatch(block[1], -1)
var out []Band
for _, r := range rows {
thr, err := strconv.ParseFloat(string(r[1]), 64)
if err != nil {
t.Fatalf("bad threshold in %s: %v", name, err)
}
out = append(out, Band{Threshold: thr, Label: string(r[2]), Class: string(r[3])})
}
if len(out) == 0 {
t.Fatalf("no bands parsed for %s", name)
}
return out
}
// TestBandsMatchBackend re-parses grading.py and asserts thresholds, labels,
// classes AND ordering are identical. Skips when the monorepo backend
// checkout isn't present (the tables above remain the committed contract).
func TestBandsMatchBackend(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "backend", "data", "grading.py")
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("backend checkout not present (%v) — parity asserted only against the committed copy", err)
}
for _, tc := range []struct {
name string
ours []Band
}{
{"TEMP_BANDS", TempBands},
{"RAIN_BANDS", RainBands},
} {
theirs := parseBackendBands(t, raw, tc.name)
if len(theirs) != len(tc.ours) {
t.Errorf("%s: backend has %d tiers, ours has %d", tc.name, len(theirs), len(tc.ours))
continue
}
for i := range theirs {
if theirs[i] != tc.ours[i] {
t.Errorf("%s[%d]: backend %+v != ours %+v", tc.name, i, theirs[i], tc.ours[i])
}
}
}
}

View file

@ -0,0 +1,362 @@
// Package format is unit-aware display formatting for the SSR templates —
// the Go port of format.py (itself ported from backend/web/content.py,
// unchanged in behavior: still markup-wrapped spans for the templates,
// operating on the raw floats the content API returns).
//
// One deliberate structural change from the Python: format.py scoped the
// active display unit in a ContextVar (`unit_scope`) so template globals
// could read it implicitly. html/template FuncMaps are engine-global and
// parsed once, so an ambient mutable unit would be a data race across
// requests — instead every unit-dependent function here takes the Unit as an
// explicit first argument, and the page handlers thread it through the render
// context (the reasoning the Python gave for the ContextVar — "a silently
// wrong unit at the one site someone forgets" — is answered in Go by the
// compiler: forgetting the argument fails the build, not the user).
package format
import (
"encoding/json"
"fmt"
"html/template"
"math"
"strconv"
"strings"
)
// Unit is the active display unit: "C", "F", or "" (unset — the Python's
// ContextVar default None). Anything other than "C" formats as Fahrenheit,
// exactly like the Python's `_UNIT.get() == "C"` checks; "" additionally
// means "no data-unit-default attribute on <body>".
type Unit string
// FCountries is the set of countries that actually use Fahrenheit. Mirrors
// backend/api/content_payloads.py's F_COUNTRIES / frontend static/units.js's
// F_REGIONS — a test asserts the backend copy stays identical (the backend
// has its own test asserting the same across its surfaces).
var FCountries = map[string]bool{
"US": true, "PR": true, "GU": true, "VI": true, "AS": true, "MP": true,
"UM": true, "BS": true, "BZ": true, "KY": true, "PW": true, "FM": true,
"MH": true, "LR": true,
}
// Unit-conversion factors (format.py's MM_PER_IN / KMH_PER_MPH).
const (
MMPerIn = 25.4
KmhPerMph = 1.609344
)
// tempTiers are the absolute-temperature colour tiers (°F upper bounds), the
// same 9 tiers the interactive grader uses (format.py's _TEMP_TIERS). Note
// these are ABSOLUTE °F cut points for painting cells — distinct from the
// percentile TEMP_BANDS in bands.go, which grade a day against its own
// history.
var tempTiers = []struct {
upper float64
class string
}{
{20, "rec-cold"}, {32, "very-cold"}, {45, "cold"}, {58, "cool"},
{70, "normal"}, {80, "warm"}, {90, "hot"}, {100, "very-hot"},
}
// Shared axis for the month range bars (format.py's _AXIS_LO/_AXIS_HI).
const (
axisLo = -10.0
axisHi = 115.0
)
// UnitForCountry maps an ISO country code to its display unit: "F" for the
// Fahrenheit-using set, "C" everywhere else (empty/unknown codes are "C").
func UnitForCountry(code string) Unit {
if FCountries[strings.ToUpper(code)] {
return "F"
}
return "C"
}
// RoundHalfUp rounds like JS's Math.round — floor(x + 0.5) — not Go's
// math.Round (which rounds halves away from zero) nor Python's round()
// (halves to even), so the server and climate.js's repaint never visibly
// disagree. Negative halves round UP (-0.5 -> 0), matching Math.round.
func RoundHalfUp(x float64) int {
return int(math.Floor(x + 0.5))
}
// cFromF converts °F to whole °C, rounding half-up (format.py's _c).
func cFromF(f float64) int {
return RoundHalfUp((f - 32) * 5 / 9)
}
// shown is the integer temperature to display in the active unit.
func shown(u Unit, f float64) int {
if u == "C" {
return cFromF(f)
}
return RoundHalfUp(f)
}
// letter is the displayed unit letter — "C" only when the active unit is
// Celsius, "F" otherwise (including the unset unit, like the Python).
func letter(u Unit) string {
if u == "C" {
return "C"
}
return "F"
}
// Temp renders a temperature span: the data-temp-f attribute always carries
// the raw °F value (one decimal) for the client-side converter, while the
// visible text is in the active unit. nil renders the em-dash placeholder.
func Temp(u Unit, f *float64) template.HTML {
if f == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f">%d°%s</span>`,
*f, shown(u, *f), letter(u)))
}
// TempBare is Temp without the unit letter (and with data-bare for the
// converter): the range strip prints "58°71°".
func TempBare(u Unit, f *float64) template.HTML {
if f == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="temp" data-temp-f="%.1f" data-bare>%d°</span>`,
*f, shown(u, *f)))
}
// TempText is the plain-text temperature ("71°F"), no span.
func TempText(u Unit, f *float64) string {
if f == nil {
return "—"
}
return fmt.Sprintf("%d°%s", shown(u, *f), letter(u))
}
// Precip renders a precipitation span; data-precip-in carries the raw inches
// (three decimals) for the converter.
func Precip(u Unit, v *float64) template.HTML {
if v == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="precip" data-precip-in="%.3f">%s</span>`,
*v, PrecipText(u, v)))
}
// PrecipText is the plain-text precipitation: whole millimetres for Celsius
// locales, hundredths of an inch otherwise.
func PrecipText(u Unit, v *float64) string {
if v == nil {
return "—"
}
if u == "C" {
return fmt.Sprintf("%d mm", RoundHalfUp(*v*MMPerIn))
}
return fmt.Sprintf("%.2f in", *v)
}
// Wind renders a wind-speed span; data-wind-mph carries the raw mph.
func Wind(u Unit, v *float64) template.HTML {
if v == nil {
return "—"
}
return template.HTML(fmt.Sprintf(`<span class="wind" data-wind-mph="%.1f">%s</span>`,
*v, WindText(u, v)))
}
// WindText is the plain-text wind speed: km/h for Celsius locales, mph
// otherwise — whole numbers both ways.
func WindText(u Unit, v *float64) string {
if v == nil {
return "—"
}
if u == "C" {
return fmt.Sprintf("%d km/h", RoundHalfUp(*v*KmhPerMph))
}
return fmt.Sprintf("%d mph", RoundHalfUp(*v))
}
// tempMetrics are the metric keys formatted as temperatures (format.py's
// _TEMP_METRICS).
var tempMetrics = map[string]bool{"tmax": true, "tmin": true, "feels": true}
// Fmt dispatches a metric value to its formatter (format.py's fmt):
// temperatures and precip get their converter spans, humidity is a plain
// absolute figure, and everything else (wind, gust) formats as wind speed.
func Fmt(u Unit, metric string, v *float64) template.HTML {
if v == nil {
return "—"
}
if tempMetrics[metric] {
return Temp(u, v)
}
switch metric {
case "precip":
return Precip(u, v)
case "humid":
return template.HTML(fmt.Sprintf("%.1f g/m³", *v))
}
return Wind(u, v) // wind, gust
}
// TempClass is the diverging-palette tier name for an absolute Fahrenheit
// temperature (or "none" for a missing value). Each tier's bound is an
// EXCLUSIVE upper edge (f < upper), so exactly 20°F is "very-cold", not
// "rec-cold"; at or above the last bound is "rec-hot".
func TempClass(f *float64) string {
if f == nil {
return "none"
}
for _, t := range tempTiers {
if *f < t.upper {
return t.class
}
}
return "rec-hot"
}
// PctOrdinal renders a percentile as a display ordinal: 66.4 -> "66th",
// 99.6 -> "99th". Floored into 1..99 — an empirical percentile is a rank
// against the sample, so rounding can land on 100 (or 0), and "100th
// percentile" reads as a measurement error rather than "as extreme as it has
// ever been"; the band label ("Near Record") carries the how-extreme part.
//
// Mirrors backend/data/grading.py's pct_ordinal, which stays canonical for
// the in-process surfaces (and static/shared.js's pctOrd client-side); this
// is the SSR-side copy, same formula. floor(x + 0.5), NOT round-half-even:
// Python's round() gives 16 for 16.5 while JavaScript's Math.round gives 17 —
// the same reading would then read "16th" server-side and "17th" client-side.
//
// A non-numeric or missing value renders the em-dash placeholder, like the
// Python's caught TypeError/ValueError (NaN included: math.floor raised
// ValueError on it). Infinities clamp into the 1..99 range (the Python would
// have crashed on OverflowError; they cannot occur in a percentile).
func PctOrdinal(pct any) string {
f, ok := toFloat(pct)
if !ok || math.IsNaN(f) {
return "—"
}
var n int
switch {
case math.IsInf(f, 1):
n = 99
case math.IsInf(f, -1):
n = 1
default:
n = RoundHalfUp(f)
}
n = min(99, max(1, n))
suffix := "th"
if !(10 <= n%100 && n%100 <= 20) {
switch n % 10 {
case 1:
suffix = "st"
case 2:
suffix = "nd"
case 3:
suffix = "rd"
}
}
return strconv.Itoa(n) + suffix
}
// toFloat coerces the loosely-typed values templates hand the ordinal filter
// (payload float64s, nullable *float64s, json.Number, template int literals,
// strings — everything Python's float(pct) accepted).
func toFloat(v any) (float64, bool) {
switch x := v.(type) {
case nil:
return 0, false
case float64:
return x, true
case float32:
return float64(x), true
case *float64:
if x == nil {
return 0, false
}
return *x, true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
f, err := x.Float64()
return f, err == nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f, err == nil
}
return 0, false
}
// AsFloatPtr coerces the loosely-typed values templates hand the formatter:
// nullable *float64 payload fields, plain float64s, and int/float literals
// (the template port writes {{.Fmt.Temp -10.0}} where Jinja had temp(-10)).
// nil (and a nil *float64) mean "missing" — rendered as the em-dash.
func AsFloatPtr(v any) *float64 {
switch x := v.(type) {
case nil:
return nil
case *float64:
return x
case float64:
return &x
case int:
f := float64(x)
return &f
}
return nil
}
// Formatter is the request-scoped, unit-bound formatter the render data
// carries as .Fmt — the Go stand-in for format.py's ContextVar unit scope.
// html/template FuncMaps are engine-global, so the per-request unit rides on
// the data instead, and templates call {{.Fmt.Temp .HighF}} where Jinja had
// {{ temp(m.high_f) }}.
type Formatter struct {
Unit Unit
}
// NewFormatter binds a formatter to the page's active unit ("" behaves as
// Fahrenheit, like the Python's unset ContextVar).
func NewFormatter(u Unit) *Formatter { return &Formatter{Unit: u} }
// Temp is the template-facing Temp (accepts *float64 fields and literals).
func (f *Formatter) Temp(v any) template.HTML { return Temp(f.Unit, AsFloatPtr(v)) }
// TempBare is the template-facing TempBare.
func (f *Formatter) TempBare(v any) template.HTML { return TempBare(f.Unit, AsFloatPtr(v)) }
// TempClass is the template-facing TempClass (unit-independent — absolute °F
// tiers — but lives on the formatter so templates have one formatting seam).
func (f *Formatter) TempClass(v any) string { return TempClass(AsFloatPtr(v)) }
// RangeBar is the geometry for one month's low->high bar on the shared
// -10..115°F axis: left offset + width as percentages, and the tier colour at
// each end. Left/Width are pre-formatted strings (Python rendered
// round(x, 1) floats, which always print with one decimal — "42.0", not
// "42" — so formatting here keeps the rendered bytes identical).
type RangeBar struct {
Left string // e.g. "48.8"
Width string // e.g. "12.4"; never below "2.0" (a sliver stays visible)
C1 string // tier colour at the low end (from the UNCLAMPED low)
C2 string // tier colour at the high end
}
// MonthRangeBar computes the bar, or nil when either end is missing
// (format.py's range_bar). The endpoints are clamped into the axis for
// geometry, but the colours come from the raw values.
func MonthRangeBar(lowF, highF *float64) *RangeBar {
if lowF == nil || highF == nil {
return nil
}
lo := math.Max(axisLo, math.Min(axisHi, *lowF))
hi := math.Max(axisLo, math.Min(axisHi, *highF))
const span = axisHi - axisLo
return &RangeBar{
Left: strconv.FormatFloat((lo-axisLo)/span*100, 'f', 1, 64),
Width: strconv.FormatFloat(math.Max(2.0, (hi-lo)/span*100), 'f', 1, 64),
C1: TempClass(lowF),
C2: TempClass(highF),
}
}

View file

@ -0,0 +1,257 @@
package format
import (
"encoding/json"
"math"
"os"
"path/filepath"
"regexp"
"testing"
)
func fp(v float64) *float64 { return &v }
func TestRoundHalfUp(t *testing.T) {
// floor(x+0.5): JS Math.round parity, incl. the half-to-even divergence
// (16.5 -> 17, where Python's round() says 16) and negative halves
// rounding UP (-0.5 -> 0), both asserted so a future "simplification" to
// math.Round (halves away from zero: -0.5 -> -1) fails here.
cases := []struct {
in float64
want int
}{
{16.5, 17}, {16.4, 16}, {16.6, 17}, {-0.5, 0}, {-0.6, -1},
{-1.5, -1}, {0, 0}, {0.5, 1}, {2.5, 3}, {99.5, 100},
}
for _, c := range cases {
if got := RoundHalfUp(c.in); got != c.want {
t.Errorf("RoundHalfUp(%v) = %d, want %d", c.in, got, c.want)
}
}
}
func TestUnitForCountry(t *testing.T) {
cases := []struct {
code string
want Unit
}{
{"US", "F"}, {"us", "F"}, {"LR", "F"}, {"BS", "F"},
{"GB", "C"}, {"", "C"}, {"XX", "C"}, {"USA", "C"},
}
for _, c := range cases {
if got := UnitForCountry(c.code); got != c.want {
t.Errorf("UnitForCountry(%q) = %q, want %q", c.code, got, c.want)
}
}
}
// TestFCountriesMatchesBackend re-parses the backend's F_COUNTRIES (the
// source of truth per both CLAUDE.md files) and asserts our set is identical.
// Skips when the monorepo backend checkout isn't present.
func TestFCountriesMatchesBackend(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "backend", "api", "content_payloads.py")
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("backend checkout not present (%v) — parity asserted only against the committed copy", err)
}
m := regexp.MustCompile(`(?s)F_COUNTRIES\s*=\s*frozenset\(\{(.*?)\}\)`).FindSubmatch(raw)
if m == nil {
t.Fatalf("could not find F_COUNTRIES in %s", path)
}
codes := regexp.MustCompile(`"([A-Z]{2})"`).FindAllSubmatch(m[1], -1)
if len(codes) == 0 {
t.Fatalf("no codes parsed from %s", path)
}
backend := map[string]bool{}
for _, c := range codes {
backend[string(c[1])] = true
}
if len(backend) != len(FCountries) {
t.Errorf("F_COUNTRIES size mismatch: backend %d vs ours %d", len(backend), len(FCountries))
}
for code := range backend {
if !FCountries[code] {
t.Errorf("backend has %q, ours does not", code)
}
}
for code := range FCountries {
if !backend[code] {
t.Errorf("ours has %q, backend does not", code)
}
}
}
func TestTempSpans(t *testing.T) {
// Exact rendered bytes — the golden-diff contract.
if got := Temp("F", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">71°F</span>` {
t.Errorf("Temp F: %q", got)
}
// Celsius display converts the visible number but data-temp-f keeps °F.
if got := Temp("C", fp(71.1)); got != `<span class="temp" data-temp-f="71.1">22°C</span>` {
t.Errorf("Temp C: %q", got)
}
// Unset unit behaves as Fahrenheit (Python's ContextVar default None).
if got := Temp("", fp(58.0)); got != `<span class="temp" data-temp-f="58.0">58°F</span>` {
t.Errorf("Temp unset: %q", got)
}
if got := Temp("C", nil); got != "—" {
t.Errorf("Temp nil: %q", got)
}
if got := TempBare("C", fp(58.4)); got != `<span class="temp" data-temp-f="58.4" data-bare>15°</span>` {
t.Errorf("TempBare: %q", got)
}
if got := TempBare("F", nil); got != "—" {
t.Errorf("TempBare nil: %q", got)
}
if got := TempText("C", fp(32.0)); got != "0°C" {
t.Errorf("TempText: %q", got)
}
// Negative + conversion: -40 is where the scales meet.
if got := TempText("C", fp(-40.0)); got != "-40°C" {
t.Errorf("TempText -40: %q", got)
}
// Round-half-up of the converted value: 31.1°F -> -0.5°C -> 0 (JS parity).
if got := TempText("C", fp(31.1)); got != "0°C" {
t.Errorf("TempText 31.1F: %q", got)
}
}
func TestPrecipAndWind(t *testing.T) {
if got := Precip("F", fp(0.1)); got != `<span class="precip" data-precip-in="0.100">0.10 in</span>` {
t.Errorf("Precip F: %q", got)
}
// 0.1 in * 25.4 = 2.54 mm -> rounds half-up to 3.
if got := Precip("C", fp(0.1)); got != `<span class="precip" data-precip-in="0.100">3 mm</span>` {
t.Errorf("Precip C: %q", got)
}
if got := Precip("C", nil); got != "—" {
t.Errorf("Precip nil: %q", got)
}
if got := PrecipText("F", fp(0.005)); got != "0.01 in" {
t.Errorf("PrecipText: %q", got)
}
if got := Wind("F", fp(24.7)); got != `<span class="wind" data-wind-mph="24.7">25 mph</span>` {
t.Errorf("Wind F: %q", got)
}
// 24.7 mph * 1.609344 = 39.75... km/h -> 40.
if got := Wind("C", fp(24.7)); got != `<span class="wind" data-wind-mph="24.7">40 km/h</span>` {
t.Errorf("Wind C: %q", got)
}
if got := Wind("F", nil); got != "—" {
t.Errorf("Wind nil: %q", got)
}
}
func TestFmtDispatch(t *testing.T) {
if got := Fmt("F", "tmax", fp(97.5)); got != `<span class="temp" data-temp-f="97.5">98°F</span>` {
t.Errorf("Fmt tmax: %q", got)
}
if got := Fmt("F", "precip", fp(1.0)); got != `<span class="precip" data-precip-in="1.000">1.00 in</span>` {
t.Errorf("Fmt precip: %q", got)
}
// Humidity is unit-independent: absolute g/m³ either way.
if got := Fmt("C", "humid", fp(16.0)); got != "16.0 g/m³" {
t.Errorf("Fmt humid: %q", got)
}
if got := Fmt("F", "gust", fp(38.0)); got != `<span class="wind" data-wind-mph="38.0">38 mph</span>` {
t.Errorf("Fmt gust: %q", got)
}
if got := Fmt("F", "wind", nil); got != "—" {
t.Errorf("Fmt nil: %q", got)
}
}
func TestTempClassBoundaries(t *testing.T) {
// Upper bounds are EXCLUSIVE (f < upper): the value AT each boundary
// belongs to the tier above it.
cases := []struct {
f float64
want string
}{
{-100, "rec-cold"}, {19.9, "rec-cold"},
{20, "very-cold"}, {31.9, "very-cold"},
{32, "cold"}, {44.9, "cold"},
{45, "cool"}, {57.9, "cool"},
{58, "normal"}, {69.9, "normal"},
{70, "warm"}, {79.9, "warm"},
{80, "hot"}, {89.9, "hot"},
{90, "very-hot"}, {99.9, "very-hot"},
{100, "rec-hot"}, {130, "rec-hot"},
}
for _, c := range cases {
if got := TempClass(&c.f); got != c.want {
t.Errorf("TempClass(%v) = %q, want %q", c.f, got, c.want)
}
}
if got := TempClass(nil); got != "none" {
t.Errorf("TempClass(nil) = %q, want none", got)
}
}
func TestPctOrdinal(t *testing.T) {
cases := []struct {
in any
want string
}{
{66.4, "66th"},
{99.6, "99th"}, // rounds to 100, clamps to 99 — "100th percentile" is never shown
{100.0, "99th"},
{0.0, "1st"}, // rounds to 0, clamps to 1
{0.2, "1st"},
{1.0, "1st"},
{2.0, "2nd"},
{3.0, "3rd"},
{4.0, "4th"},
{11.0, "11th"}, // teens are always "th"
{12.0, "12th"},
{13.0, "13th"},
{21.0, "21st"},
{22.0, "22nd"},
{23.0, "23rd"},
{16.5, "17th"}, // floor(x+0.5): JS parity, NOT Python round-half-even (16)
{50.5, "51st"},
{fp(42.0), "42nd"}, // nullable payload pointer
{json.Number("88"), "88th"},
{"60", "60th"}, // Python float("60") accepted strings
{nil, "—"},
{(*float64)(nil), "—"},
{"garbage", "—"},
{math.NaN(), "—"}, // Python: math.floor(nan) raised ValueError -> "—"
}
for _, c := range cases {
if got := PctOrdinal(c.in); got != c.want {
t.Errorf("PctOrdinal(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMonthRangeBar(t *testing.T) {
if MonthRangeBar(nil, fp(70)) != nil || MonthRangeBar(fp(50), nil) != nil {
t.Fatal("missing endpoint must yield nil bar")
}
// 51.3..61.1 on the -10..115 axis: left (51.3+10)/125*100 = 49.04 -> "49.0",
// width (61.1-51.3)/125*100 = 7.84 -> "7.8".
b := MonthRangeBar(fp(51.3), fp(61.1))
if b.Left != "49.0" || b.Width != "7.8" {
t.Errorf("bar geometry: left %q width %q", b.Left, b.Width)
}
if b.C1 != "cool" || b.C2 != "normal" {
t.Errorf("bar colours: %q %q", b.C1, b.C2)
}
// Width floor: a degenerate range still paints a 2.0%% sliver, and the
// formatted floor keeps Python's "2.0" (str of a rounded float), not "2".
b = MonthRangeBar(fp(60), fp(60.5))
if b.Width != "2.0" {
t.Errorf("min width: %q", b.Width)
}
// Endpoints clamp into the axis for geometry, but the tier colours come
// from the raw values (a -40 low is still rec-cold even though the bar
// starts at the axis edge).
b = MonthRangeBar(fp(-40), fp(120))
if b.Left != "0.0" || b.Width != "100.0" {
t.Errorf("clamped geometry: left %q width %q", b.Left, b.Width)
}
if b.C1 != "rec-cold" || b.C2 != "rec-hot" {
t.Errorf("clamped colours: %q %q", b.C1, b.C2)
}
}

View file

@ -0,0 +1,137 @@
// Package handlers is the Go port of app.py's own route layer: the
// interactive tool's SPA shells (app.py's _page) and static-asset serving
// with the short Cache-Control revalidation window. The server-rendered
// content pages, robots.txt/sitemap.xml and the IndexNow key file live in
// internal/content (the content.py port), exactly as the two modules split
// the work in Python.
//
// Wiring (see main.go): content.Handlers.Register takes this package's
// Static() handler so its lazy IndexNow fallback route can hand non-key
// requests back to the file server; Register here adds the shells, the
// static catch-all under BASE, and the bare-BASE 307 redirect.
package handlers
import (
"html/template"
"io"
"log/slog"
"net/http"
)
// Options configures the app.py-layer routes.
type Options struct {
// Base is config.Base: "" or "/x" (THERMOGRAPH_BASE, normalized).
Base string
// StaticDir holds every static asset, including the SPA shell HTML files.
StaticDir string
// GoogleVerify / BingVerify feed the search-console <meta> tags injected
// into the SPA shells (content.head_verify_html read the same env vars;
// the SSR pages get theirs via the template FuncMap's head_verify — the
// two copies must keep emitting identical markup).
GoogleVerify string
BingVerify string
// Log defaults to slog.Default().
Log *slog.Logger
}
// Server holds the state the shell/static handlers share. Safe for
// concurrent use.
type Server struct {
base string
staticDir string
headVerify template.HTML
log *slog.Logger
}
// New builds the Server. It does not touch the filesystem — shell files are
// read lazily on first request, like the Python's _page.
func New(o Options) *Server {
log := o.Log
if log == nil {
log = slog.Default()
}
return &Server{
base: o.Base,
staticDir: o.StaticDir,
headVerify: headVerifyHTML(o.GoogleVerify, o.BingVerify),
log: log,
}
}
// Register attaches the SPA-shell routes, the static catch-all and the
// bare-BASE redirect. Go 1.22 mux precedence (most-specific pattern wins)
// replaces Starlette's registration-order rule — the explicit content routes
// registered elsewhere beat the catch-all no matter who registers first.
// "GET" patterns serve HEAD too, covering the Python's methods=["GET","HEAD"].
func (s *Server) Register(mux *http.ServeMux) {
// The SPA shells for the interactive tool (app.py's _page routes).
// /alerts deliberately serves subscriptions.html.
for _, sh := range []struct{ path, file string }{
{"/calendar", "calendar.html"},
{"/day", "day.html"},
{"/score", "score.html"},
{"/compare", "compare.html"},
{"/legend", "legend.html"},
{"/alerts", "subscriptions.html"},
} {
mux.HandleFunc("GET "+s.base+sh.path, s.shellHandler(sh.file))
}
// Everything else under BASE (app.js, style.css, nav.js, manifest,
// favicons, …) is a static asset. Patterns must start with "/", so at the
// root (Base == "") this registers at "/" — the explicit routes still win
// under the 1.22 precedence rules, replacing Starlette's mount-last order.
mux.Handle("GET "+s.base+"/", s.Static())
if s.base != "" {
// Starlette's redirect_slashes answered the bare base path with a 307
// to BASE+"/" (the only slash redirect the Python actually emitted —
// every other trailing-slash miss fell into the static mount's 404).
// Registered explicitly so the mux's implicit 301 doesn't apply.
base := s.base
mux.HandleFunc("GET "+base, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, base+"/", http.StatusTemporaryRedirect)
})
}
}
// Static returns the static-asset handler (also handed to
// content.Handlers.Register for its lazy IndexNow fallback delegation).
func (s *Server) Static() http.Handler {
return http.HandlerFunc(s.serveStatic)
}
// originOf is content.py's _origin, which app.py's _page deliberately reused
// rather than a simpler duplicate: the shells are reached both directly
// (Caddy) and through backend's internal proxy fallback, and only this
// version prefers X-Forwarded-Host over Host — required for the proxied case
// to resolve the real browser-facing host instead of this internal hop's own
// address.
func originOf(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
}
func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) {
s.log.Error("internal error", "path", r.URL.Path, "err", err)
writePlain(w, http.StatusInternalServerError, "Internal Server Error")
}
// writePlain mirrors Starlette's PlainTextResponse: text/plain with charset,
// nothing appended to the body.
func writePlain(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}

View file

@ -0,0 +1,277 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"thermograph/frontend/internal/handlers"
)
// newStaticDir builds a throwaway static dir with the SPA shells and assets
// the tests poke at.
func newStaticDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
shell := "<!doctype html>\n<html><head>\n<title>t</title>\n" +
`<meta property="og:url" content="__ORIGIN__/calendar">` +
"\n</head><body>__ORIGIN__</body></html>\n"
for _, f := range []string{"calendar.html", "score.html", "compare.html", "legend.html", "subscriptions.html"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte(shell), 0o644); err != nil {
t.Fatal(err)
}
}
// day.html deliberately missing: the missing-shell test uses /day.
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('hi')\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "icons"), 0o755); err != nil {
t.Fatal(err)
}
return dir
}
func newMux(t *testing.T, base string, opt func(*handlers.Options)) *http.ServeMux {
t.Helper()
o := handlers.Options{Base: base, StaticDir: newStaticDir(t)}
if opt != nil {
opt(&o)
}
mux := http.NewServeMux()
handlers.New(o).Register(mux)
return mux
}
func get(mux *http.ServeMux, method, target string, hdr map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, target, nil)
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
return w
}
// --- SPA shells (app.py's _page) ---------------------------------------------
func TestShellServesWithOriginFilledIn(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
w := get(mux, "GET", "http://example.com/thermograph/calendar", nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Errorf("content-type = %q", ct)
}
body := w.Body.String()
if strings.Contains(body, "__ORIGIN__") {
t.Error("__ORIGIN__ placeholder not substituted")
}
if !strings.Contains(body, `content="http://example.com/thermograph/calendar"`) {
t.Errorf("origin prefix missing from og:url: %s", body)
}
if w.Header().Get("ETag") == "" {
t.Error("no ETag on shell response")
}
// HEAD serves the same route with an empty body (methods=["GET","HEAD"]).
h := get(mux, "HEAD", "http://example.com/thermograph/calendar", nil)
if h.Code != http.StatusOK || h.Body.Len() != 0 {
t.Errorf("HEAD: status = %d, body = %d bytes", h.Code, h.Body.Len())
}
}
func TestShellETagExactMatch304(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
first := get(mux, "GET", "http://example.com/thermograph/legend", nil)
etag := first.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag")
}
// Exact match -> 304 with the ETag and no body (app.py's _not_modified).
w := get(mux, "GET", "http://example.com/thermograph/legend", map[string]string{"If-None-Match": etag})
if w.Code != http.StatusNotModified {
t.Fatalf("status = %d, want 304", w.Code)
}
if w.Header().Get("ETag") != etag {
t.Error("304 must carry the ETag")
}
if w.Body.Len() != 0 {
t.Error("304 must have no body")
}
// The Python compared the raw header — a list containing the ETag does
// NOT match (unlike the content pages' comma-set semantics).
w = get(mux, "GET", "http://example.com/thermograph/legend",
map[string]string{"If-None-Match": `W/"other", ` + etag})
if w.Code != http.StatusOK {
t.Errorf("list If-None-Match: status = %d, want 200 (exact-match semantics)", w.Code)
}
}
func TestShellPerOriginMemo(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
a := get(mux, "GET", "http://a.example/thermograph/score", nil)
b := get(mux, "GET", "http://b.example/thermograph/score", nil)
if !strings.Contains(a.Body.String(), "http://a.example/thermograph") {
t.Error("first origin not substituted")
}
if !strings.Contains(b.Body.String(), "http://b.example/thermograph") {
t.Error("second origin not substituted (memo leaked across origins?)")
}
if a.Header().Get("ETag") == b.Header().Get("ETag") {
t.Error("different origins must get different ETags")
}
}
func TestShellForwardedHeadersWinOverHost(t *testing.T) {
// The proxied topology: Host is the internal hop, X-Forwarded-* is the
// browser-facing origin — the forwarded values must win (content._origin).
mux := newMux(t, "/thermograph", nil)
w := get(mux, "GET", "http://frontend:8080/thermograph/compare", map[string]string{
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "thermograph.org",
})
if !strings.Contains(w.Body.String(), "https://thermograph.org/thermograph") {
t.Errorf("forwarded origin not used: %s", w.Body.String())
}
}
func TestShellHeadVerifyInjection(t *testing.T) {
mux := newMux(t, "/thermograph", func(o *handlers.Options) {
o.GoogleVerify = `g"tok&en`
o.BingVerify = "bing-tok"
})
w := get(mux, "GET", "http://example.com/thermograph/calendar", nil)
body := w.Body.String()
want := "<head>\n " +
`<meta name="google-site-verification" content="g&#34;tok&amp;en">` + "\n " +
`<meta name="msvalidate.01" content="bing-tok">`
if !strings.Contains(body, want) {
t.Errorf("verification metas not injected after <head>:\n%s", body)
}
}
func TestShellMissingFileIs500(t *testing.T) {
mux := newMux(t, "/thermograph", nil) // day.html not written
w := get(mux, "GET", "http://example.com/thermograph/day", nil)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", w.Code)
}
}
// --- static assets (app.py's _CachedStaticFiles mount) -----------------------
func TestStaticAssetServes(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
w := get(mux, "GET", "http://example.com/thermograph/app.js", nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=300" {
t.Errorf("Cache-Control = %q", cc)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("content-type = %q", ct)
}
// Conditional refetch: the stat-derived ETag answers with an empty 304
// that still carries the Cache-Control window.
etag := w.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag on static response")
}
c := get(mux, "GET", "http://example.com/thermograph/app.js", map[string]string{"If-None-Match": etag})
if c.Code != http.StatusNotModified {
t.Fatalf("conditional status = %d, want 304", c.Code)
}
if cc := c.Header().Get("Cache-Control"); cc != "public, max-age=300" {
t.Errorf("304 Cache-Control = %q", cc)
}
}
func TestStaticMissingIs404(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
w := get(mux, "GET", "http://example.com/thermograph/nope.css", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d", w.Code)
}
if w.Body.String() != "Not Found" {
t.Errorf("body = %q, want Starlette's plain Not Found", w.Body.String())
}
}
func TestStaticIndexHTMLIs404(t *testing.T) {
// index.html must not linger behind the static mount as indexable
// duplicate content now that / is server-rendered — and Go's FileServer
// would 301 it to "./" even when absent, so the custom handler must 404
// (tests/unit/test_pages.py::test_old_static_index_is_gone).
mux := newMux(t, "/thermograph", nil)
w := get(mux, "GET", "http://example.com/thermograph/index.html", nil)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (no redirect, no listing)", w.Code)
}
}
func TestStaticDirectoryIs404(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
for _, target := range []string{
"http://example.com/thermograph/icons", // existing directory
"http://example.com/thermograph/icons/", // trailing slash
"http://example.com/thermograph/app.js/",
} {
if w := get(mux, "GET", target, nil); w.Code != http.StatusNotFound {
t.Errorf("%s: status = %d, want 404", target, w.Code)
}
}
}
func TestStaticTraversalIs404(t *testing.T) {
dir := newStaticDir(t)
// A secret OUTSIDE the static dir must be unreachable even when the
// handler is invoked directly with an uncleaned path (the content
// package's lazy IndexNow route delegates without a mux in front).
if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.txt"), []byte("s"), 0o644); err != nil {
t.Fatal(err)
}
static := handlers.New(handlers.Options{Base: "/thermograph", StaticDir: dir}).Static()
r := httptest.NewRequest("GET", "http://example.com/ignored", nil)
r.URL.Path = "/thermograph/../secret.txt" // bypass URL parsing/cleaning
w := httptest.NewRecorder()
static.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// --- bare-BASE redirect + root-base topology ---------------------------------
func TestBareBaseRedirects307(t *testing.T) {
mux := newMux(t, "/thermograph", nil)
w := get(mux, "GET", "http://example.com/thermograph", nil)
if w.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want 307 (Starlette redirect_slashes)", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/thermograph/" {
t.Errorf("Location = %q", loc)
}
}
func TestRootBaseTopology(t *testing.T) {
// The deployed clean-root topology: THERMOGRAPH_BASE=/ -> Base == "".
mux := newMux(t, "", nil)
if w := get(mux, "GET", "http://example.com/app.js", nil); w.Code != http.StatusOK {
t.Errorf("/app.js: status = %d", w.Code)
}
w := get(mux, "GET", "http://example.com/calendar", nil)
if w.Code != http.StatusOK {
t.Fatalf("/calendar: status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), `content="http://example.com/calendar"`) {
t.Errorf("origin prefix at root base wrong: %s", w.Body.String())
}
}

View file

@ -0,0 +1,131 @@
package handlers
import (
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"thermograph/frontend/internal/render"
)
// headVerifyHTML is content.head_verify_html for the SPA shells: the
// search-engine ownership-verification <meta> tags, from env (empty when
// unset). The SSR pages carry the identical markup via the template FuncMap's
// head_verify entry (internal/content owns that copy); the shells need it
// here because their HTML never passes through the template engine.
// template.HTMLEscapeString emits the same five entities markupsafe escaped
// (&amp; &lt; &gt; &#39; &#34;).
func headVerifyHTML(google, bing string) template.HTML {
var metas []string
if google != "" {
metas = append(metas, `<meta name="google-site-verification" content="`+
template.HTMLEscapeString(google)+`">`)
}
if bing != "" {
metas = append(metas, `<meta name="msvalidate.01" content="`+
template.HTMLEscapeString(bing)+`">`)
}
return template.HTML(strings.Join(metas, "\n "))
}
// shellMemoMax bounds the per-origin memo. The origin is client-controlled
// (Host/X-Forwarded-Host), so an unbounded map is the same cheap
// memory-exhaustion vector api_client.py's LRU cap closed — the Python's
// _by_origin dict had no bound (one canonical origin in every real topology
// made it moot); a flat cap keeps that property for legit traffic and just
// resets the memo under abuse instead of growing forever.
const shellMemoMax = 1024
// shell is one SPA-shell route's state — the Go port of app.py's _page():
// the file (and the verification <meta> tags, both constant for the process's
// lifetime) is read and prepped once, not on every request; only the
// __ORIGIN__ substitution actually varies per request, and even that repeats
// across requests (one canonical origin in the common topology), so the
// substituted HTML + its ETag are memoized per origin instead of
// re-read-and-re-sha1'd every time.
type shell struct {
srv *Server
file string
mu sync.Mutex
template string // "" = not loaded yet; a failed read retries next request
byOrigin map[string]shellEntry
}
type shellEntry struct {
html string
etag string
}
// shellHandler builds the handler for one SPA-shell HTML page (the
// interactive tool's calendar/day/score/compare/legend/alerts views). It
// serves the file with its __ORIGIN__ placeholder (the link-preview/Open
// Graph tags) filled in — preview crawlers need absolute URLs, and the host
// differs between LAN and prod. originOf (not a simpler duplicate) matters
// here: this route is reached both directly (Caddy) and through backend's
// proxy fallback, and only that version prefers X-Forwarded-Host over Host —
// required for the proxied case to resolve the real browser-facing host
// instead of this internal hop's own address.
func (s *Server) shellHandler(file string) http.HandlerFunc {
sh := &shell{srv: s, file: file, byOrigin: make(map[string]shellEntry)}
return sh.serve
}
// load reads and preps the shell file; the caller holds sh.mu. Search-engine
// verification <meta> tags (same source as the SSR content pages) are folded
// in here, so the interactive tool's own pages carry them too.
func (sh *shell) load() (string, error) {
if sh.template != "" {
return sh.template, nil
}
raw, err := os.ReadFile(filepath.Join(sh.srv.staticDir, sh.file))
if err != nil {
return "", err
}
html := string(raw)
if verify := string(sh.srv.headVerify); verify != "" {
html = strings.Replace(html, "<head>", "<head>\n "+verify, 1)
}
sh.template = html
return html, nil
}
func (sh *shell) serve(w http.ResponseWriter, r *http.Request) {
originPrefix := originOf(r) + sh.srv.base
sh.mu.Lock()
ent, ok := sh.byOrigin[originPrefix]
if !ok {
tpl, err := sh.load()
if err != nil {
sh.mu.Unlock()
sh.srv.serverError(w, r, err)
return
}
html := strings.ReplaceAll(tpl, "__ORIGIN__", originPrefix)
ent = shellEntry{html: html, etag: render.ETag([]byte(html))}
if len(sh.byOrigin) >= shellMemoMax {
clear(sh.byOrigin)
}
sh.byOrigin[originPrefix] = ent
}
sh.mu.Unlock()
// app.py's _page compared If-None-Match to the ETag verbatim (no
// comma-splitting) — NotModifiedExact keeps that precise behavior.
if render.NotModifiedExact(r.Header.Get("If-None-Match"), ent.etag) {
w.Header().Set("ETag", ent.etag)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", ent.etag)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.WriteString(w, ent.html)
}
}

View file

@ -0,0 +1,62 @@
package handlers
import (
"fmt"
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
// staticCacheControl mirrors app.py's _STATIC_CACHE_CONTROL: static assets
// aren't content-hashed in their filenames, so this has to stay short — it's
// a revalidation window, not a long-lived immutable cache. Still saves a full
// body re-transfer (the ETag/Last-Modified pair otherwise forces a
// conditional GET on every navigation) for anything fetched again within it.
// Stamped on every static file response, including 304s.
const staticCacheControl = "public, max-age=300"
// serveStatic is the catch-all under BASE — the Go analogue of app.py's
// _CachedStaticFiles mount, matching Starlette's StaticFiles semantics rather
// than http.FileServer's (which would 301 */index.html to the directory and
// render directory listings; Starlette 404s both —
// tests/unit/test_pages.py::test_old_static_index_is_gone pins the index.html
// case, keeping it from lingering as indexable duplicate content behind the
// server-rendered homepage).
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, s.base)
// Trailing-slash paths never name a file; Starlette 404'd them (the
// mount swallowed everything under BASE, so redirect_slashes never ran
// for them).
if strings.HasSuffix(rel, "/") {
writePlain(w, http.StatusNotFound, "Not Found")
return
}
// Rooted Clean cannot escape the static dir ("/../x" cleans to "/x");
// the mux has already cleaned the request path, this is belt and braces
// for handlers invoked directly (the content package's lazy IndexNow
// route delegates here without re-cleaning).
rel = path.Clean("/" + strings.TrimPrefix(rel, "/"))
full := filepath.Join(s.staticDir, filepath.FromSlash(strings.TrimPrefix(rel, "/")))
info, err := os.Stat(full)
if err != nil || info.IsDir() {
writePlain(w, http.StatusNotFound, "Not Found")
return
}
f, err := os.Open(full)
if err != nil {
writePlain(w, http.StatusNotFound, "Not Found")
return
}
defer f.Close()
w.Header().Set("Cache-Control", staticCacheControl)
// Starlette's StaticFiles stamped a stat-derived ETag (mtime+size hash);
// same recipe here so If-None-Match revalidation keeps costing an empty
// 304 instead of a body re-transfer. Headers set before ServeContent
// survive on its 304/206 paths, Cache-Control included.
w.Header().Set("ETag", fmt.Sprintf("\"%x-%x\"", info.ModTime().UnixNano(), info.Size()))
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}

View file

@ -0,0 +1,136 @@
// Package render wires the HTML template engine: an embed.FS of templates
// (self-contained binary — no template files to ship beside it), a Render
// helper, and the FuncMap seam the template/format port fills in.
//
// The templates themselves are owned elsewhere (the Jinja -> html/template
// port); this package only establishes HOW they are parsed and executed:
//
// - Files live in internal/render/templates/*.tmpl and are named by base
// filename (html/template's ParseFS convention), e.g. "city.html.tmpl".
// Jinja's {% extends %}/{% block %} maps to {{define}}/{{template}} with a
// shared base layout; each page template {{define}}s the blocks the base
// references and is executed by its own file name.
// - Formatting helpers arrive via the FuncMap passed to New. html/template
// FuncMaps are engine-global, so anything unit-scoped (the Python used a
// ContextVar the handlers set per request — format.py's unit_scope) must
// NOT be a bare FuncMap closure over mutable state; either pre-format in
// the handler (content.py already did for most values) or pass a
// formatter value in the render data and call its methods.
//
// It also owns the response-side ETag behavior ported from content.py's
// _respond_html and app.py's _page: a weak SHA-1 ETag over the rendered
// bytes, and the two If-None-Match comparison flavors the Python had.
package render
import (
"bytes"
"crypto/sha1"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"strings"
)
//go:embed templates
var templatesFS embed.FS
// Engine holds the parsed template set. Templates only change on a redeploy
// (a fresh process), so everything is parsed exactly once, at New — the Go
// analogue of the Jinja environment's auto_reload=False.
type Engine struct {
t *template.Template
}
// New parses every embedded template with the given FuncMap. Call it once at
// boot with the full FuncMap — the function NAMES must all be registered
// before parsing, because html/template resolves them at parse time.
//
// The FuncMap keys the templates rely on (the Jinja globals/filters
// content.py registered; implementations come from the format port):
//
// "temp" func(f *float64) template.HTML — <span class="temp" …>N°F</span>
// "temp_bare" func(f *float64) template.HTML — bare-degree variant
// "temp_class" func(f *float64) string — diverging-palette tier name
// "ordinal" func(pct any) string — percentile -> "66th", floored into 1..99
// "head_verify" func() template.HTML — search-console <meta> tags
// "comment" func(s string) template.HTML — a visible <!-- --> that
// survives parsing (html/template strips literal HTML
// comments; wrapping the string template.HTML inserts it as
// trusted content instead of markup the parser interprets)
// "jscomment" func(s string) template.JS — a visible "// ..." line
// that survives inside <script> (html/template ALSO strips
// literal JS comments there; needs template.JS specifically,
// not template.HTML, which gets re-escaped as a JS value)
func New(funcs template.FuncMap) (*Engine, error) {
t, err := template.New("").Funcs(funcs).ParseFS(templatesFS, "templates/*.tmpl")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Engine{t: t}, nil
}
// Render executes template `name` (its base filename, e.g. "city.html.tmpl")
// into w.
func (e *Engine) Render(w io.Writer, name string, data any) error {
return e.t.ExecuteTemplate(w, name, data)
}
// RenderBytes executes into memory. Page handlers need the full body anyway
// (the ETag is a hash of the rendered HTML), so this is the primary entry.
func (e *Engine) RenderBytes(name string, data any) ([]byte, error) {
var buf bytes.Buffer
if err := e.t.ExecuteTemplate(&buf, name, data); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// ETag computes the weak ETag the Python stamped on every rendered page:
// W/"<first 20 hex chars of sha1(body)>".
func ETag(body []byte) string {
sum := sha1.Sum(body)
return `W/"` + hex.EncodeToString(sum[:])[:20] + `"`
}
// NotModifiedAny reports whether the If-None-Match header value matches etag
// under content.py's _respond_html semantics: the header is split on commas
// and each candidate whitespace-trimmed (a browser may send several).
func NotModifiedAny(ifNoneMatch, etag string) bool {
if ifNoneMatch == "" {
return false
}
for _, cand := range strings.Split(ifNoneMatch, ",") {
if strings.TrimSpace(cand) == etag {
return true
}
}
return false
}
// NotModifiedExact reports whether the header exactly equals etag — app.py's
// _page (SPA shells) compared the raw header, no splitting. Kept separate so
// the port preserves each route's precise behavior.
func NotModifiedExact(ifNoneMatch, etag string) bool {
return ifNoneMatch != "" && ifNoneMatch == etag
}
// WriteHTML writes a rendered page with its ETag, answering a matching
// If-None-Match with an empty 304 (content.py's comma-set semantics). The
// Content-Type matches the Python service's exactly.
func WriteHTML(w http.ResponseWriter, r *http.Request, body []byte) {
etag := ETag(body)
h := w.Header()
h.Set("ETag", etag)
if NotModifiedAny(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
h.Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
w.Write(body)
}
}

View file

@ -0,0 +1,102 @@
package render
import (
"html/template"
"net/http/httptest"
"testing"
)
// stubFuncMap satisfies every function name the embedded templates reference
// (see New's doc comment for the full list) with the minimum needed to parse.
// The real implementations live in package content (funcmap.go), which itself
// imports render — so render's own test cannot import content without a
// cycle, and stands up its own stand-in instead. This only proves the
// template set PARSES against the real function names; content_test.go (via
// content.FuncMap) is what proves they render correctly.
func stubFuncMap() template.FuncMap {
return template.FuncMap{
"temp": func(unit, f any) template.HTML { return "" },
"temp_bare": func(unit, f any) template.HTML { return "" },
"temp_class": func(f any) string { return "" },
"ordinal": func(pct any) string { return "" },
"head_verify": func() template.HTML { return "" },
"tojson": func(v any) (template.HTML, error) { return "", nil },
"comment": func(s string) template.HTML { return "" },
"jscomment": func(s string) template.JS { return "" },
}
}
func TestETagFormat(t *testing.T) {
// Same construction as the Python: W/"sha1(body)[:20]".
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
got := ETag([]byte("hello"))
want := `W/"aaf4c61ddcc5e8a2dabe"`
if got != want {
t.Errorf("ETag = %s, want %s", got, want)
}
}
func TestNotModifiedAnySplitsOnCommas(t *testing.T) {
etag := `W/"abc"`
if !NotModifiedAny(`W/"zzz", W/"abc"`, etag) {
t.Error("comma-separated candidate list should match")
}
if !NotModifiedAny(`W/"abc"`, etag) {
t.Error("single candidate should match")
}
if NotModifiedAny("", etag) {
t.Error("empty header must not match")
}
if NotModifiedAny(`W/"zzz"`, etag) {
t.Error("non-matching header must not match")
}
}
func TestNotModifiedExact(t *testing.T) {
etag := `W/"abc"`
if !NotModifiedExact(`W/"abc"`, etag) {
t.Error("exact match should match")
}
// app.py's _page compared the raw header — a comma list is NOT split there.
if NotModifiedExact(`W/"zzz", W/"abc"`, etag) {
t.Error("exact comparison must not split on commas")
}
}
func TestWriteHTML(t *testing.T) {
body := []byte("<html>x</html>")
etag := ETag(body)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
WriteHTML(rec, req, body)
if rec.Code != 200 || rec.Body.String() != string(body) {
t.Errorf("fresh GET: code=%d body=%q", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Errorf("Content-Type = %q", got)
}
if got := rec.Header().Get("ETag"); got != etag {
t.Errorf("ETag header = %q, want %q", got, etag)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest("GET", "/", nil)
req.Header.Set("If-None-Match", etag)
WriteHTML(rec, req, body)
if rec.Code != 304 {
t.Errorf("conditional GET: code = %d, want 304", rec.Code)
}
if rec.Body.Len() != 0 {
t.Error("304 must have an empty body")
}
if got := rec.Header().Get("ETag"); got != etag {
t.Errorf("304 must still carry the ETag, got %q", got)
}
}
func TestEngineParsesEmbeddedTemplates(t *testing.T) {
if _, err := New(stubFuncMap()); err != nil {
t.Fatalf("New: %v", err)
}
}

View file

@ -0,0 +1,30 @@
{{/* Ported from templates/about.html.j2. Extra data: .Breadcrumb.
temp(60) renders through the request-scoped formatter (no unit scope on
this page in Python → °F). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}<article class="climate-page">
<h1>About Thermograph</h1>
<p class="lede">Thermograph answers one question for any point on Earth: <b>how unusual is this
weather?</b> Instead of an absolute temperature, it shows where a day falls in that exact
location's own climate history.</p>
<h2>Where the data comes from</h2>
<p>The ~45-year daily record (high, low, feels-like, humidity, wind, gust and precipitation)
comes from the <b>ERA5 reanalysis</b> (ECMWF), served via <a href="https://open-meteo.com/" rel="nofollow">Open-Meteo</a>.
Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's
no weather station, anywhere on the ~4&nbsp;sq&nbsp;mi grid.</p>
<h2>How a day is graded</h2>
<p>For each metric and date, Thermograph builds a reference distribution from every historical day
within <b>&plusmn;7 days of that day-of-year</b>, a 15-day seasonal window across all years. The
observed value is placed on that distribution as an <a href="{{.Base}}/glossary/percentile">empirical
percentile</a>, then mapped to a grade from “Below Normal” through “High” to “Near Record”.</p>
<p>Everything is <b>relative to the location</b>: a {{.Fmt.Temp 60.0}} day can be “Above Normal” in one place and
“Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy”
means heavy for a rainy day <i>here</i>. See the <a href="{{.Base}}/legend">grade guide</a> for the full scale.</p>
<h2>Freshness</h2>
<p>The historical archive updates as new days are added; recent and forecast conditions refresh hourly.
City climate pages show the latest recorded day graded against the local normal.</p>
<p><a class="cta" href="{{.Base}}/">Open the weather grader →</a></p>
</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,169 @@
{{/*
Ported from templates/base.html.j2 (Jinja, trim_blocks + lstrip_blocks +
keep_trailing_newline=False). The Jinja layout used {% extends %}/{% block %};
in a single html/template parse set a per-page {{define "title"}} would
collide across the 10 pages, so the layout is instead split into sequential
chunks that every page template stitches together in order:
base_head_start doctype .. the manifest <link>. title/description/
canonical/og:* are data-driven (.PageTitle,
.PageDescription, .CanonicalPath) — the og:title /
og:url duplication Jinja did via self.title() falls
out for free.
[page head_extra] inline in the page file (home: leaflet css)
base_head_end the style.css <link>
[page jsonld] inline in the page file (city/records/glossary_term/home)
base_body_start </head> .. <body> header + nav .. <main ...>
[page content]
base_body_end </main> .. footer
[page body_scripts] base_scripts_default, or the page's own (home)
base_tail digest/ios-install scripts, </body></html>
Whitespace mirrors the Jinja env's exact output byte-for-byte (golden-diff
contract): control tags on their own line vanished entirely under
trim_blocks/lstrip_blocks, so here the equivalent actions are glued to the
start of the following content line; keep_trailing_newline=False means the
document ends at </html> with no final newline (see base_tail).
HTML comments that must SURVIVE into the output go through the `comment`
FuncMap helper, because html/template strips literal <!-- --> comments.
Data contract (all pages): .Base .AssetBase .AssetBaseURL .BaseURL
.UnitDefault .Section .BrandTag ("h1"; home passes "p") .MainClass
("content"; home passes "") .PageTitle .PageDescription .CanonicalPath
.Fmt (unit-bound formatter: Temp/TempBare/TempClass) and, on pages that have
one, .Breadcrumb []contentapi.Crumb.
*/}}
{{define "base_head_start" -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{head_verify}}
<title>{{.PageTitle}}</title>
<meta name="description" content="{{.PageDescription}}" />
<meta name="theme-color" content="#f0803c" />
{{comment `iOS/Android home-screen install: run standalone (no browser chrome) when
added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push.`}}
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Thermograph" />
<link rel="canonical" href="{{.BaseURL}}{{.CanonicalPath}}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="{{.PageTitle}}" />
<meta property="og:description" content="{{.PageDescription}}" />
<meta property="og:url" content="{{.BaseURL}}{{.CanonicalPath}}" />
<meta property="og:image" content="{{.AssetBaseURL}}/logo.png?v=3" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Thermograph logo" />
<meta name="twitter:card" content="summary" />
<link rel="icon" href="{{.AssetBase}}/favicon.svg?v=3" type="image/svg+xml" />
<link rel="icon" href="{{.AssetBase}}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
<link rel="icon" href="{{.AssetBase}}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
<link rel="icon" href="{{.AssetBase}}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="{{.AssetBase}}/apple-touch-icon.png?v=3" />
<link rel="manifest" href="{{.AssetBase}}/manifest.webmanifest" />
{{end}}
{{define "base_head_end" -}}
<link rel="stylesheet" href="{{.AssetBase}}/style.css" />
{{end}}
{{/* The brand lockup (logo svg + wordmark) shared by both brand-tag variants
below. */}}
{{define "brand_link"}}<a href="{{.Base}}/"><span class="logo"><svg viewBox="0 0 512 512" width="28" height="28" aria-hidden="true"><rect x="32" y="32" width="448" height="448" rx="96" fill="#10141B" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="4"/><rect x="115" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="211" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="259" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="307" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="355" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="403" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="211" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="259" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="307" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="355" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="403" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="67" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="163" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="211" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="259" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="307" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="355" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="403" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="67" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="211" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="403" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="67" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="115" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="211" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="307" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="67" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="115" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="307" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="355" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="67" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="115" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="163" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="211" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="259" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="307" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="355" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="67" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="115" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="163" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="211" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="259" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="307" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="355" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="67" y="403" width="42" height="42" rx="10" fill="#2166ac"/><rect x="67" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="307" width="42" height="42" rx="10" fill="#92c5de"/><rect x="115" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="163" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="211" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="259" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="307" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="403" y="115" width="42" height="42" rx="10" fill="#dd6b52"/><rect x="403" y="67" width="42" height="42" rx="10" fill="#8f0e20"/></svg></span>Thermograph</a>{{end}}
{{define "base_body_start" -}}
</head>
<body{{if .UnitDefault}} data-unit-default="{{.UnitDefault}}"{{end}}>
<header>
<div class="brand">
<div>{{/* The brand is the page's h1 everywhere EXCEPT the homepage, where the
hero headline owns the sole h1 and the brand degrades to a <p>. The
.brand h1, .brand .site-name selector pair in style.css keeps the
lockup looking identical either way. (The Jinja original wrote
<{{ brand_tag|default('h1') }} …>; html/template forbids actions in
tag names, hence two literal branches around one shared lockup.) */}}
{{if eq .BrandTag "p"}}<p class="site-name">{{template "brand_link" .}}</p>{{else}}<h1 class="site-name">{{template "brand_link" .}}</h1>{{end}}
<p class="tag">How unusual is the weather? Graded against ~45 years of local climate history.</p>
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">{{/* data-view marks the links nav.js rewrites to carry the current
location hash, so moving between views keeps the place you picked.
Climate and Alerts deliberately opt out (they aren't cell-scoped).
Inert on the SEO pages, which never load nav.js. */}}
<nav class="view-nav" aria-label="Views">
<a href="{{.Base}}/" data-view="map"{{if eq .Section "home"}} class="active"{{end}}>Weekly</a>
<a href="{{.AssetBase}}/calendar" data-view="calendar">Calendar</a>
<a href="{{.AssetBase}}/day" data-view="day">Day Detail</a>
<a href="{{.AssetBase}}/compare" data-view="compare">Compare</a>
<a href="{{.AssetBase}}/score" data-view="score"{{if eq .Section "score"}} class="active"{{end}}>Score</a>
<a href="{{.Base}}/climate"{{if eq .Section "climate"}} class="active"{{end}}>Climate</a>
<a href="{{.AssetBase}}/alerts">Alerts</a>
</nav>
</div>
</details>
</div>
</header>
<main{{if .MainClass}} class="{{.MainClass}}"{{end}}>
{{end}}
{{define "base_body_end" -}}
</main>
<footer class="site-footer">{{/* The compact digest signup is the site-wide footer pattern, not a
homepage-only surface: every page is a possible entry point.
Hidden for now; restore by uncommenting the form block below
(kept verbatim from the Jinja original — {{ asset_base }} becomes
{{.AssetBase}} when it comes back):
<form class="digest-form digest-compact" method="post" action="{{ asset_base }}/digest"
data-digest aria-labelledby="footer-digest-label">
<label id="footer-digest-label" for="footer-digest-email">Your city's weather, graded — monthly</label>
<div class="digest-row">
<input id="footer-digest-email" name="email" type="email" required
autocomplete="email" placeholder="you@example.com" />
<button type="submit">Get the digest</button>
</div>
<p class="digest-note muted">No spam, no sharing your address, unsubscribe anytime.</p>
<p class="digest-status" role="status" aria-live="polite" hidden></p>
</form>
*/}}
<nav aria-label="Footer">
<a href="{{.Base}}/climate">City climates</a>
<a href="{{.Base}}/glossary">Weather glossary</a>
<a href="{{.Base}}/about">About &amp; methodology</a>
<a href="{{.Base}}/privacy">Privacy</a>
<a href="{{.Base}}/">Open the tool</a>
</nav>
<p class="muted">Free. No ads. No tracking. No account needed. Built by one person in the open.</p>
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local
climate history. Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway.</p>
<p class="muted">Made by <a href="https://emigriffith.dev">emigriffith.dev</a>
<a href="mailto:emerytgriffith@gmail.com" aria-label="Email" title="emerytgriffith@gmail.com">@</a></p>
</footer>
{{end}}
{{define "base_scripts_default" -}}
{{comment `Header parity with the interactive pages: the °F/°C toggle, account + bell,
and the client-side temperature converter. All self-contained modules.`}}
<script type="module" src="{{.AssetBase}}/units.js"></script>
<script type="module" src="{{.AssetBase}}/account.js"></script>
<script type="module" src="{{.AssetBase}}/climate.js"></script>
{{end}}
{{define "base_tail" -}}
<script type="module" src="{{.AssetBase}}/digest.js"></script>
<script type="module" src="{{.AssetBase}}/ios-install.js"></script>
</body>
</html>{{end}}
{{/* Shared breadcrumb nav (each Jinja page repeated this line verbatim; the
output is identical, so it is factored here). The separator is emitted
BEFORE every non-first crumb — same byte stream as Jinja's
`{% if not loop.last %}` after-each-but-last. Crumb.Href is *string
(terminal crumb: null). */}}
{{define "breadcrumb" -}}
<nav class="breadcrumb" aria-label="Breadcrumb">
{{range $i, $c := .Breadcrumb}}{{if $i}} <span class="sep"></span> {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}<span>{{$c.Name}}</span>{{end}}{{end -}}
</nav>
{{end}}

View file

@ -0,0 +1,101 @@
{{/* Ported from templates/city.html.j2. Extra data (built by the handler
inside unit_scope(default_unit), as content.py's city_page did):
.Breadcrumb; .City (needs .Slug); .Display/.Name string;
.YearRange [2]int; .NYears int;
.Months []{Name, Slug string; HighF, LowF, RangeLoF, RangeHiF *float64;
High, Low, Precip template.HTML;
Bar *{Left, Width string; C1, C2 string}}
— Bar.Left/Width are STRINGS pre-formatted to Python's repr of
round(x, 1) ("36.0", not "36"): Go's %v drops the ".0" and the golden
diff would catch it;
.Warmest/.Coldest/.Wettest *month (entries of .Months, or nil);
.Records contentapi.AllTimeRecords (raw floats; .Fmt.Temp in-template);
.Today *{Date string; Cards []{Label, Grade, Cls string;
Value template.HTML; Percentile *float64}};
.Flavor *{Extract string; URL string}; .Event *{Text string; URL string};
.ToolHref string (full "{base}/#{lat:.5f},{lon:.5f}" href — composed
inline, html/template's fragment urlEscaper would %-escape the comma);
.CompareURL string ("{base}/compare#loc={lat:.4f},{lon:.4f}");
.JSONLDStr template.JS (compacted raw payload bytes — the |safe site;
trusted backend-owned JSON-LD, never re-marshaled through a Go map). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}<script type="application/ld+json">{{.JSONLDStr}}</script>{{template "base_body_start" .}}{{template "breadcrumb" .}}
<article class="climate-page">
<h1>{{.Display}} climate</h1>
<p class="lede">Average temperatures, precipitation and all-time records for <b>{{.Display}}</b>,
with every day graded against ~{{.NYears}} years of local climate history
({{index .YearRange 0}}{{index .YearRange 1}}).{{if and .Warmest .Coldest}} Its warmest month is
<b>{{.Warmest.Name}}</b>, averaging a high of {{.Warmest.High}}, and its coldest is
<b>{{.Coldest.Name}}</b>, with an average low of {{.Coldest.Low}}.{{end}}</p>
{{if .Flavor}} <p class="city-blurb">{{.Flavor.Extract}}{{if .Flavor.URL}}
<a class="blurb-src" href="{{.Flavor.URL}}" rel="nofollow">via Wikipedia</a>{{end}}</p>
{{end}}
{{if .Today}} <section class="climate-today">
<h2>How today compares</h2>
<p class="muted">Latest recorded day: {{.Today.Date}}. Each reading placed on {{.Display}}'s
own ±7-day seasonal distribution.</p>
<div class="today-cards">
{{range .Today.Cards}} <div class="today-card" style="border-left-color: var(--{{.Cls}})">
<span class="tc-label">{{.Label}}</span>
<span class="tc-value">{{.Value}}</span>
{{if .Percentile}}<span class="tc-grade">{{.Grade}} · {{ordinal .Percentile}} pct</span>
{{else}}<span class="tc-grade">{{.Grade}}</span>{{end}} </div>
{{end}} </div>
<p><a class="cta" href="{{.ToolHref}}">Open {{.Name}} in the live weather grader →</a></p>
</section>
{{end}}
{{if .Event}} <section class="city-event">
<h2>Notable weather in {{.Name}}</h2>
<p>{{.Event.Text}}{{if .Event.URL}} <a href="{{.Event.URL}}" rel="nofollow">Read&nbsp;more&nbsp;→</a>{{end}}</p>
</section>
{{end}}
<section class="climate-normals">
<h2>{{.Name}} average temperatures by month</h2>
<p>Typical daily high and low, and average precipitation, for each month in {{.Display}},
based on {{index .YearRange 0}}{{index .YearRange 1}} records.
{{if .Wettest}}The wettest month is <b>{{.Wettest.Name}}</b> ({{.Wettest.Precip}} on an average day).{{end}}</p>
<div class="table-wrap">
<table class="normals-table">
<thead><tr><th>Month</th><th>Avg high</th><th>Avg low</th><th>Avg precip</th></tr></thead>
<tbody>
{{range .Months}} <tr>
<td><a href="{{$.Base}}/climate/{{$.City.Slug}}/{{.Slug}}">{{.Name}}</a></td>
<td class="t-cell t-{{$.Fmt.TempClass .HighF}}">{{.High}}</td>
<td class="t-cell t-{{$.Fmt.TempClass .LowF}}">{{.Low}}</td>
<td>{{.Precip}}</td>
</tr>
{{end}} </tbody>
</table>
</div>
<div class="range-strip" aria-hidden="true">
{{range .Months}} <div class="range-row">
<span class="range-month">{{slice .Name 0 3}}</span>
<span class="range-track">
{{if .Bar}}<span class="range-fill" style="left:{{.Bar.Left}}%;width:{{.Bar.Width}}%;--c1:var(--{{.Bar.C1}});--c2:var(--{{.Bar.C2}})"></span>{{end}} </span>
{{if and .RangeLoF .RangeHiF}} <span class="range-vals">{{$.Fmt.TempBare .RangeLoF}}{{$.Fmt.TempBare .RangeHiF}}</span>
{{else}}<span class="range-vals">—</span>{{end}} </div>
{{end}} </div>
<p class="range-note muted">Each bar spans the 10th-percentile daily low to the 90th-percentile daily
high (the range most days fall within), coloured cold&nbsp;→&nbsp;hot on a shared
{{.Fmt.Temp -10.0}} to {{.Fmt.Temp 115.0}} scale, so the whole year's rhythm reads at a glance.</p>
</section>
<section class="city-travel">
<h2>Thinking of visiting {{.Name}}?</h2>
<p>Trips live and die by the weather. See how {{.Name}}'s comfort stacks up against where
you live. {{.Name}} is pre-loaded, just add your city.</p>
<p><a class="cta" href="{{.CompareURL}}">Compare {{.Name}}'s comfort with your city →</a></p>
</section>
{{if or .Records.Tmax .Records.Tmin}} <section class="climate-records">
<h2>Record extremes</h2>
<ul>
{{if .Records.Tmax}}<li><b>Hottest day on record:</b> {{.Fmt.Temp .Records.Tmax.Max}} on {{.Records.Tmax.MaxDate}}</li>{{end}}{{if .Records.Tmin}}<li><b>Coldest day on record:</b> {{.Fmt.Temp .Records.Tmin.Min}} on {{.Records.Tmin.MinDate}}</li>{{end}} </ul>
<p><a href="{{.Base}}/climate/{{.City.Slug}}/records">All {{.Name}} weather records →</a></p>
</section>
{{end}}
<p class="climate-foot muted">Percentiles compare each day to {{.Display}}'s own history, so grades are
relative to this location: a “Near Record” day here isn't the same temperature as elsewhere.
<a href="{{.AssetBase}}/legend">How the grades work →</a></p>
</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,10 @@
{{/* Ported from templates/glossary.html.j2. Extra data: .Breadcrumb,
.Terms []{Slug, Term, Short string} (glossary.yaml file order). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}<article class="climate-page">
<h1>Weather &amp; climate glossary</h1>
<p class="lede">Plain-language definitions of the terms Thermograph uses to grade the weather.</p>
{{range .Terms}} <div class="glossary-term">
<h2><a href="{{$.Base}}/glossary/{{.Slug}}">{{.Term}}</a></h2>
<p>{{.Short}}</p>
</div>
{{end}}</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,21 @@
{{/* Ported from templates/glossary_term.html.j2. Extra data:
.Breadcrumb; .Term string; .Others []{Slug, Term string};
.JSONLDStr template.JS — the handler builds the WHOLE DefinedTerm object
(the Jinja original interpolated `term|tojson` / `page_description|tojson`
/ base_url inline; Go's JS-context escaping differs byte-wise from
markupsafe's tojson, so the handler reproduces Python htmlsafe-tojson —
ensure_ascii \uXXXX escapes plus <,>,&,' → <,>,&,' —
and template.JS passes it through untouched);
.Body template.HTML — glossary yaml `body` with {base} substituted. This
is the Jinja `| safe` site: the content is trusted, committed copy from
frontend/content/glossary.yaml, not user or API input. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}<script type="application/ld+json">{{.JSONLDStr}}</script>{{template "base_body_start" .}}{{template "breadcrumb" .}}<article class="climate-page">
<h1>{{.Term}}</h1>
<p class="lede">{{.Body}}</p>
<p><a class="cta" href="{{.Base}}/">See it live: grade any location's weather →</a></p>
<div class="climate-foot">
<h2>More terms</h2>
<ul class="city-links">
{{range .Others}}<li><a href="{{$.Base}}/glossary/{{.Slug}}">{{.Term}}</a></li>{{end}} </ul>
</div>
</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,182 @@
{{/* Ported from templates/home.html.j2.
The Weekly view — the product itself — with the distribution surfaces built
around it. Everything structural is server-rendered so crawlers and no-JS
readers get a complete page; app.js hydrates the live numbers in place.
The interactive controls below (#find-btn, #date-input, #panel, #results, …)
are app.js's DOM contract, carried over verbatim from the old static
frontend/index.html. Do not rename these ids.
Section / BrandTag ("p") / MainClass ("") come from the route handler's
render data, not the template — the SEO pages already pass Section that way.
Extra data: .Unusual *contentapi.HomeUnusual; .Stale bool;
.Ranked []contentapi.HomeRanked (Lat/Lon are json.Number, printed raw);
.Cities []contentapi.HomeCity;
.JSONLDStr template.JS — _HOME_JSONLD + "url" appended, serialized like
Python's plain json.dumps: ", "/": " separators, ensure_ascii=True. */}}{{template "base_head_start" .}} <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
{{template "base_head_end" .}} <script type="application/ld+json">
{{.JSONLDStr}}
</script>
{{template "base_body_start" .}}{{/* --- Hero ------------------------------------------------------------
The grade card is the LCP element: its frame is server-rendered and its
slots are sized here, so hydrating the live numbers causes no layout
shift. Band colour is always paired with the band word — colour is never
the only signal. */}} <section id="hero" class="hero">
<div class="hero-copy">
<h1>How unusual is your weather?</h1>
<p class="hero-sub">Any day, anywhere on Earth, graded against 45 years of that place's own history.</p>
</div>
<div class="hero-card">{{/* --cat carries the band colour, the same convention .normal-card uses,
so the band token stays the single source of truth. */}}
<div class="grade-card" id="hero-grade" aria-live="polite"
{{if .Unusual}}style="--cat: var(--{{.Unusual.Cls}})"{{end}}>
<p class="grade-where" id="hero-where">
{{- if .Unusual -}}
Today in {{.Unusual.Display}}
{{- else -}}
Today
{{- end -}}
</p>
<p class="grade-band" id="hero-band">{{if .Unusual}}{{.Unusual.Grade}}{{else}}—{{end}}</p>
<p class="grade-detail" id="hero-detail">
{{- if .Unusual -}}
{{.Unusual.MetricLabel}} in the {{ordinal .Unusual.Percentile}} percentile for {{.Unusual.WindowLabel}}
{{- else -}}
Pick a place to see how unusual its weather is today.
{{- end -}}
</p>
{{if and .Unusual .Unusual.IsDefault}} <p class="grade-why muted">the most unusual weather we're tracking{{if .Stale}} · as of {{.Unusual.Date}}{{end}}</p>
{{end}} </div>
<div class="hero-actions">{{/* app.js hides the locate button outside a secure context, where the
browser refuses geolocation and it could only ever fail. */}}
<button type="button" id="hero-locate" class="btn-primary">Use my location</button>
<button type="button" id="hero-pick" class="btn-ghost">Pick on the map</button>
</div>
<p class="hero-locate-msg" id="hero-locate-msg" role="status" aria-live="polite" hidden></p>
<p class="hero-jump"><a href="#panel">See your week ↓</a></p>
</div>
</section>
<section class="controls">
<div class="find-bar">
<button type="button" id="find-btn" class="find-btn"></button>
<span id="loc-label" class="loc-label" hidden></span>
</div>
<div class="date-row">
<label>Target date
<input id="date-input" type="date" />
</label>
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
<span class="hint"><a href="{{.AssetBase}}/legend">What do the grades mean? →</a></span>
</div>
</section>
<section id="panel" class="panel panel-full">
<div class="placeholder" id="placeholder">
<p>Find a location to begin.</p>
<p class="muted">Thermograph fetches decades of daily highs, lows, and precipitation
for your ~4&nbsp;sq&nbsp;mi cell, builds a &plusmn;7-day seasonal distribution for each
day of the year, and grades recent weather by where it falls on that distribution.</p>
</div>
<div id="results" hidden></div>
</section>
{{/* Sits outside #panel on purpose: app.js rewrites results.innerHTML
wholesale on every grade, so anything inside it would be destroyed. */}} <p class="stance-line muted">
Free. No ads. No tracking. No account needed.
<a href="{{.Base}}/about">How we work →</a>
</p>
{{/* --- Unusual right now ----------------------------------------------
Scroll-snap row, no JS carousel. Both tails are represented whenever a
cold-tail city qualifies — two-sided honesty is product identity. */}}{{if .Ranked}} <section class="unusual-strip" aria-labelledby="records-title">
<h2 id="records-title">Unusual right now</h2>
<ul class="unusual-row">
{{range .Ranked}} <li class="unusual-card" style="--cat: var(--{{.Cls}})">
<a href="{{$.AssetBase}}/day#lat={{.Lat}}&amp;lon={{.Lon}}"
data-event="home.records_click">
<span class="unusual-city">{{.Display}}</span>{{/* The reading itself, then what it is and what it should be —
a percentile means little without the value it describes. */}}
<span class="unusual-value">{{$.Fmt.Temp .Value}}</span>
<span class="unusual-band">{{.GradeLabel}}</span>
<span class="unusual-meta">
{{.MetricLabel}} &middot; {{.PctLabel}} pct
{{- if .Normal}} &middot; normal {{$.Fmt.Temp .Normal}}{{end}}
{{- if .DateLabel}} &middot; {{.DateLabel}}{{end}} </span>
</a>
</li>
{{end}} </ul>
<p class="unusual-more"><a href="{{.Base}}/climate">See all records →</a></p>
</section>
{{end}}
{{/* --- How it works ---------------------------------------------------- */}} <section class="how-it-works" aria-labelledby="how-title">
<h2 id="how-title">How it works</h2>
<ol class="how-steps">
<li>We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.</li>
<li>Today gets compared to the same two weeks of the year, every year back to 1980.</li>
<li>The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.</li>
</ol>
<p class="muted">
<a href="{{.AssetBase}}/legend">Full methodology →</a>
&middot; Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway
</p>
</section>
{{/* --- Explore --------------------------------------------------------- */}} <section class="explore" aria-labelledby="explore-title">
<h2 id="explore-title">Explore</h2>{{/* Each card takes a colour from the grade ramp rather than a decorative
palette, so the section reads as part of the product's own scale.
Calendar carries the ramp itself — it IS the heatmap. */}}
<div class="explore-cards">
<a class="explore-card" href="{{.AssetBase}}/calendar" data-event="home.nav_calendar"
style="--cat: var(--warm)">
<span class="explore-name">Calendar</span>
<span class="explore-ramp" aria-hidden="true">{{/* Jinja looped over the nine tier
names here; unrolled so the CSS custom-property references stay literal. */}}
<i style="background: var(--rec-cold)"></i>
<i style="background: var(--very-cold)"></i>
<i style="background: var(--cold)"></i>
<i style="background: var(--cool)"></i>
<i style="background: var(--normal)"></i>
<i style="background: var(--warm)"></i>
<i style="background: var(--hot)"></i>
<i style="background: var(--very-hot)"></i>
<i style="background: var(--rec-hot)"></i>
</span>
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
</a>
<a class="explore-card" href="{{.AssetBase}}/compare" data-event="home.nav_compare"
style="--cat: var(--normal)">
<span class="explore-name">Compare</span>
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
</a>
<a class="explore-card" href="{{.AssetBase}}/day" data-event="home.nav_day"
style="--cat: var(--cool)">
<span class="explore-name">Day</span>
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
</a>
<a class="explore-card" href="{{.AssetBase}}/alerts" data-event="home.nav_alerts"
style="--cat: var(--rec-hot)">
<span class="explore-name">Alerts</span>
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
</a>
</div>
</section>
{{/* --- City hub links --------------------------------------------------
Real HTML links funnelling homepage authority into the ~1000-page
/climate surface. */}} <section class="city-hub" aria-labelledby="cities-title">
<h2 id="cities-title">Climate context for 1,000 cities</h2>
<ul class="city-chips">
{{range .Cities}} <li><a href="{{$.Base}}/climate/{{.Slug}}" data-event="home.nav_city">{{.Name}}</a></li>
{{end}} </ul>
<p><a href="{{.Base}}/climate" data-event="home.nav_cities">Browse all cities →</a></p>
</section>
{{template "base_body_end" .}} <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="{{.AssetBase}}/app.js"></script>{{/* The records strip renders real temperatures server-side as .temp spans, so
it needs the same converter the SEO pages use or they'd stay in °F while
the toggle says °C. Both import units.js, which the module loader dedupes. */}}
<script type="module" src="{{.AssetBase}}/climate.js"></script>
{{template "base_tail" .}}

View file

@ -0,0 +1,80 @@
{{/* Ported from templates/hub.html.j2. Extra data: .Breadcrumb;
.NCities/.NCountries int; .Groups — MUST be the ordered
[]contentapi.HubCountry slice from the hub payload, never a Go map
(template range over a map sorts keys; Python's dict kept the backend's
order, and the golden diff would catch the reorder). */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}<article class="climate-page">
<h1>City climates</h1>
<p class="lede">Average temperatures by month, all-time records, and how today compares, for
{{.NCities}} cities across {{.NCountries}} countries. Every day is graded against that
location's own ~45 years of climate history.</p>
<div class="hub-search">
<input type="search" id="hub-q" placeholder="Search a city or country…" autocomplete="off"
aria-label="Search cities" />
</div>
<ul class="hub-results" id="hub-results" hidden></ul>
<div id="hub-directory">
{{range .Groups}} <details class="hub-country">
<summary>{{.Country}} <span class="hub-count">{{len .Cities}}</span></summary>
<ul class="city-links">
{{range .Cities}}<li><a href="{{$.Base}}/climate/{{.Slug}}">{{.Name}}</a></li>{{end}} </ul>
</details>
{{end}} </div>
</article>
<script>
(function () {
var q = document.getElementById("hub-q");
var results = document.getElementById("hub-results");
var directory = document.getElementById("hub-directory");
if (!q || !results || !directory) return;
{{jscomment "Build a flat, alphabetical index from the crawlable directory links."}}
var index = [];
directory.querySelectorAll("details.hub-country").forEach(function (d) {
var country = (d.querySelector("summary").firstChild.textContent || "").trim();
d.querySelectorAll(".city-links a").forEach(function (a) {
index.push({ name: a.textContent, country: country, href: a.getAttribute("href") });
});
});
index.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
function esc(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function render() {
var term = q.value.trim().toLowerCase();
if (!term) {
results.hidden = true;
results.innerHTML = "";
directory.hidden = false;
return;
}
directory.hidden = true;
results.hidden = false;
var matches = index.filter(function (e) {
return e.name.toLowerCase().indexOf(term) >= 0 ||
e.country.toLowerCase().indexOf(term) >= 0;
});
if (!matches.length) {
results.innerHTML = '<li class="hub-empty muted">No cities match “' + esc(q.value.trim()) + "”.</li>";
return;
}
results.innerHTML = matches.slice(0, 300).map(function (e) {
return '<li><a href="' + e.href + '">' + esc(e.name) + "</a>" +
'<span class="hub-r-country">' + esc(e.country) + "</span></li>";
}).join("") + (matches.length > 300
? '<li class="hub-empty muted">…and ' + (matches.length - 300) + " more. Keep typing to narrow.</li>"
: "");
}
q.addEventListener("input", render);
})();
</script>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,60 @@
{{/* Ported from templates/month.html.j2. Extra data (all display values are
pre-formatted by the handler inside the city's unit scope, as in Python):
.Breadcrumb; .City (needs .Slug); .Display/.Name/.MonthName string;
.YearRange [2]int; .AvgHigh/.AvgLow template.HTML (temp spans);
.AvgHighCls/.AvgLowCls string (temp_class names);
.Stats []{Label string; Value template.HTML};
.Records []{Label, HighTag, LowTag string; High, Low template.HTML;
HighDate, LowDate string};
.ToolHref string — the full "{base}/#{lat:.5f},{lon:.5f}" href, built by
the handler: composed inline (Jinja's {{ base }}/#{{ tool_hash }}) the
fragment would hit html/template's urlEscaper and %-escape the comma;
.CompareURL string; .Prev/.Next {Slug, Name string}. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}
<article class="climate-page">
<h1>Weather in {{.Display}} in {{.MonthName}}</h1>
<p class="lede">In an average {{.MonthName}}, {{.Name}} sees a daily high around
<b class="t-inline t-{{.AvgHighCls}}">{{.AvgHigh}}</b> and a low around
<b class="t-inline t-{{.AvgLowCls}}">{{.AvgLow}}</b>, based on
{{index .YearRange 0}}{{index .YearRange 1}} of local climate records.</p>
<table class="normals-table" style="max-width:520px">
<tbody>
{{range .Stats}} <tr><th style="width:55%">{{.Label}}</th><td>{{.Value}}</td></tr>
{{end}} </tbody>
</table>
{{if .Records}} <h2>{{.MonthName}} records</h2>
<p class="muted">The most extreme {{.MonthName}} on record for each metric, across
{{index .YearRange 0}}{{index .YearRange 1}}. For rainfall, the wettest and driest are by
total {{.MonthName}} accumulation.</p>
<div class="records-grid">
{{range .Records}} <div class="record-card">
<h3 class="rc-metric">{{.Label}}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{{.HighTag}}</span>
<span class="rc-val">{{.High}}</span>
<span class="rc-date">{{.HighDate}}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{{.LowTag}}</span>
<span class="rc-val">{{.Low}}</span>
<span class="rc-date">{{.LowDate}}</span>
</div>
</div>
{{end}} </div>
{{end}}
<p><a class="cta" href="{{.ToolHref}}">See {{.Name}}'s live weather grade →</a></p>
<div class="travel-note">
<p>Visiting <b>{{.Name}}</b> in {{.MonthName}}? See how its comfort stacks up against
where you live.</p>
<a class="cta" href="{{.CompareURL}}">Compare {{.Name}} with your city →</a>
</div>
<p class="climate-foot muted">
<a href="{{.Base}}/climate/{{.City.Slug}}/{{.Prev.Slug}}"> {{.Prev.Name}}</a> ·
<a href="{{.Base}}/climate/{{.City.Slug}}">{{.Name}} climate overview</a> ·
<a href="{{.Base}}/climate/{{.City.Slug}}/{{.Next.Slug}}">{{.Next.Name}} </a>
</p>
</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,31 @@
{{/* Ported from templates/privacy.html.j2. Extra data: .Breadcrumb. */}}{{template "base_head_start" .}}{{template "base_head_end" .}}{{template "base_body_start" .}}{{template "breadcrumb" .}}<article class="climate-page">
<h1>Privacy</h1>
<p class="lede">Thermograph is free, has no ads, and needs no account. It is built so that
there is very little about you to collect in the first place.</p>
<h2>Your location</h2>
<p>Thermograph never looks up your location from your IP address. The only way the site
learns where you are is if you press <b>Use my location</b>, which asks your browser for
permission. You can decline, and picking a place on the map works just as well.</p>
<p>Whichever way you choose a place, the coordinates are sent to the server only as part of
the ordinary request for that grid square's climate data, and are not logged beyond the
normal web-server request handling every site does. The place you picked is remembered in
your own browser's local storage, not on the server, so clearing your browser data
forgets it.</p>
<h2>Analytics</h2>
<p>There is no analytics library, no cookies for tracking, and no per-visitor identifier.
The server keeps simple aggregate counts (how many people opened a page or tapped a
button, and which site referred them) with nothing tying any of it to an individual.</p>
<h2>Email</h2>
<p>If you sign up for the monthly digest, your address is used for that digest and nothing
else. It is never sold or shared, and every message can unsubscribe you.</p>
<h2>Accounts</h2>
<p>An account is optional and only exists to hold weather alerts you have asked for. It
stores your email address and the places you are watching.</p>
<p class="muted">Questions: see <a href="{{.Base}}/about">About &amp; methodology</a>.</p>
</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

View file

@ -0,0 +1,95 @@
{{/* Ported from templates/records.html.j2. Extra data (formatted by the
handler inside unit_for_country scope, as in Python):
.Breadcrumb; .City (needs .Slug); .Display/.Name string; .YearRange [2]int;
.Rows []{Label string; High, Low template.HTML; HighDate, LowDate string}
— Low is either the formatted metric value or the "N-day dry spell"
text, LowDate already "—"-defaulted (content.py records_page);
.Monthly []{Name, Slug string; High, Low side} and
.Seasonal []{Name, Span string; High, Low side} where a side is
{Warm, Cold *{Cls string; Txt template.HTML; Date string}};
.Hemisphere string; .AllTime contentapi.AllTimeRecords (raw floats —
.Fmt.Temp runs in the template, like Jinja's temp());
.ToolHref string (full "{base}/#{lat:.5f},{lon:.5f}" — composed inline the
fragment comma would be %-escaped by html/template's urlEscaper);
.JSONLDStr template.JS (compacted raw payload bytes — the |safe site;
trusted backend-owned JSON-LD, never re-marshaled through a Go map). */}}
{{/* A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted
chips with the date each occurred. Used for the daytime-high and overnight-low
columns of the month and season tables. (Jinja macro `extremes`.) */}}
{{define "extremes" -}}
{{if and .Warm .Cold -}}
<div class="rec-ext"><span class="rec-arrow up" aria-hidden="true">▲</span><span class="t-inline t-{{.Warm.Cls}}">{{.Warm.Txt}}</span><span class="rec-on">{{.Warm.Date}}</span></div>
<div class="rec-ext"><span class="rec-arrow down" aria-hidden="true">▼</span><span class="t-inline t-{{.Cold.Cls}}">{{.Cold.Txt}}</span><span class="rec-on">{{.Cold.Date}}</span></div>
{{else -}}
—{{end}}{{end}}{{template "base_head_start" .}}{{template "base_head_end" .}}<script type="application/ld+json">{{.JSONLDStr}}</script>{{template "base_body_start" .}}{{template "breadcrumb" .}}
<article class="climate-page">
<h1>{{.Display}} weather records</h1>
<p class="lede">Record high and low temperatures for <b>{{.Display}}</b>, by month, by season, and
all-time, with the dates they occurred, across {{index .YearRange 0}}{{index .YearRange 1}} of daily
climate history.{{if and .AllTime.Tmax .AllTime.Tmin}} Its hottest day on record reached
{{.Fmt.Temp .AllTime.Tmax.Max}} on {{.AllTime.Tmax.MaxDate}}; its coldest fell to
{{.Fmt.Temp .AllTime.Tmin.Min}} on {{.AllTime.Tmin.MinDate}}.{{end}}</p>
<section class="climate-records-section">
<h2>Records by month</h2>
<p>The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in
each calendar month, so each column shows its record high <i>and</i> its record low. Tap a
month for its full averages and typical range.</p>
<div class="table-wrap">
<table class="normals-table records-table records-ext">
<thead><tr><th>Month</th><th>Daytime high</th><th>Overnight low</th></tr></thead>
<tbody>
{{range .Monthly}} <tr>
<td><a href="{{$.Base}}/climate/{{$.City.Slug}}/{{.Slug}}">{{.Name}}</a></td>
<td>{{template "extremes" .High}}</td>
<td>{{template "extremes" .Low}}</td>
</tr>
{{end}} </tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>Records by season</h2>
<p>The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each
meteorological season ({{.Hemisphere}} Hemisphere, each a three-month span).</p>
<div class="table-wrap">
<table class="normals-table records-table records-ext">
<thead><tr><th>Season</th><th>Daytime high</th><th>Overnight low</th></tr></thead>
<tbody>
{{range .Seasonal}} <tr>
<td>{{.Name}} <span class="season-span">({{.Span}})</span></td>
<td>{{template "extremes" .High}}</td>
<td>{{template "extremes" .Low}}</td>
</tr>
{{end}} </tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>All-time records</h2>
<p>The most extreme value {{.Name}} has recorded for each metric across its entire history.</p>
<div class="records-grid">
{{range .Rows}} <div class="record-card">
<h3 class="rc-metric">{{.Label}}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{{if eq .Label "Precip"}}Wettest{{else}}Highest{{end}}</span>
<span class="rc-val">{{.High}}</span>
<span class="rc-date">{{.HighDate}}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{{if eq .Label "Precip"}}Driest{{else}}Lowest{{end}}</span>
<span class="rc-val">{{.Low}}</span>
<span class="rc-date">{{.LowDate}}</span>
</div>
</div>
{{end}} </div>
<p class="records-note muted">For rainfall, the “driest” figure is the <b>longest run of consecutive
days without measurable rain</b>, dated to when that dry spell began.</p>
</section>
<p><a class="cta" href="{{.ToolHref}}">Grade {{.Name}}'s weather now →</a></p>
<p class="climate-foot muted"><a href="{{.Base}}/climate/{{.City.Slug}}"> Back to {{.Name}} climate</a></p>
</article>
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}

174
frontend/server/main.go Normal file
View file

@ -0,0 +1,174 @@
// thermograph-frontend: the SSR frontend service — server-rendered content
// pages (climate hub / per-city / month / records / glossary / about), the
// interactive tool's SPA shells, and every static asset. All climate data
// comes from the backend's content API over HTTP (internal/contentapi); this
// process holds no data of its own.
//
// Go port of app.py: configuration, the route wiring (internal/content for
// the content.py routes, internal/handlers for the SPA shells + static
// assets), and the process lifecycle uvicorn used to own (listen, access
// logs, graceful shutdown).
package main
import (
"context"
"errors"
"log/slog"
"mime"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"thermograph/frontend/internal/config"
"thermograph/frontend/internal/content"
"thermograph/frontend/internal/contentapi"
"thermograph/frontend/internal/contentdata"
"thermograph/frontend/internal/handlers"
"thermograph/frontend/internal/render"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
// Fail loud at boot on bad configuration or content (missing backend URL,
// garbage TTL, malformed glossary/pages YAML) — the Python raised at
// import for the same cases: a missing backend URL or a bad content edit
// should break the boot, not silently 500 on the first request.
cfg, err := config.Load()
if err != nil {
fatal(logger, "config", err)
}
client := contentapi.New(contentapi.Options{
BaseURL: cfg.APIBaseInternal,
BasePrefix: cfg.Base, // backend runs under the same THERMOGRAPH_BASE
APIVersion: cfg.APIVersion,
TTL: cfg.SSRCacheTTL,
})
glossary, err := contentdata.LoadGlossary(cfg.ContentDir)
if err != nil {
fatal(logger, "glossary", err)
}
pages, err := contentdata.LoadPages(cfg.ContentDir)
if err != nil {
fatal(logger, "pages", err)
}
// Templates parse once, at boot, with the full FuncMap (html/template
// resolves function names at parse time) — the Go analogue of the Jinja
// environment's auto_reload=False.
engine, err := render.New(content.FuncMap(cfg))
if err != nil {
fatal(logger, "templates", err)
}
// The PWA manifest is served as a static file; register its media type so
// the file server labels it correctly (not in Go's default mime table).
if err := mime.AddExtensionType(".webmanifest", "application/manifest+json"); err != nil {
fatal(logger, "mime", err)
}
// app.py-layer routes: SPA shells + static assets + the bare-BASE redirect.
app := handlers.New(handlers.Options{
Base: cfg.Base,
StaticDir: cfg.StaticDir,
GoogleVerify: cfg.GoogleVerify,
BingVerify: cfg.BingVerify,
Log: logger,
})
// content.py-layer routes. content.New takes a *log.Logger; bridge it into
// slog so its lines (IndexNow boot warning, render failures) land on
// stdout structured like everything else.
contentHandlers, err := content.New(cfg, client, engine, glossary, pages,
slog.NewLogLogger(logger.Handler(), slog.LevelWarn))
if err != nil {
fatal(logger, "content", err)
}
mux := http.NewServeMux()
// Liveness probe: the process is up and serving. Deliberately does no I/O
// — no call to the backend — so it stays cheap and reliable for a tight
// healthcheck interval. Readiness (backend reachable) is a separate concern.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
// Register order does not matter for precedence (most-specific pattern
// wins), but content registration performs the eager IndexNow key fetch —
// same point in boot as the Python's content.register(app). Its lazy
// fallback route needs the static handler to delegate non-key requests to.
contentHandlers.Register(mux, app.Static())
app.Register(mux)
srv := &http.Server{
Addr: net.JoinHostPort("", cfg.Port), // 0.0.0.0:PORT, like uvicorn --host 0.0.0.0
Handler: accessLog(logger, mux),
ReadHeaderTimeout: 10 * time.Second,
}
// Graceful shutdown on SIGINT/SIGTERM: stop accepting, drain in-flight
// requests, then exit — what uvicorn did for the Python process.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
logger.Info("thermograph-frontend listening", "port", cfg.Port, "base", cfg.Base)
errCh <- srv.ListenAndServe()
}()
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
fatal(logger, "serve", err)
}
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown", "err", err)
os.Exit(1)
}
logger.Info("shut down cleanly")
}
}
func fatal(logger *slog.Logger, stage string, err error) {
logger.Error(stage, "err", err)
os.Exit(1)
}
// accessLog is the uvicorn access log's replacement: one structured line per
// request on stdout.
func accessLog(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"dur_ms", time.Since(start).Milliseconds(),
)
})
}
// statusRecorder captures the response status for the access log.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (rec *statusRecorder) WriteHeader(status int) {
rec.status = status
rec.ResponseWriter.WriteHeader(status)
}

View file

@ -247,9 +247,17 @@ fi
# next deploy of a tag that has it picks it up with no further action.
case " ${TARGETS[*]} " in
*" daemon "*)
daemon_img="$(docker compose config --images daemon 2>/dev/null | head -1 || true)"
if [ -n "$daemon_img" ] \
&& ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then
# Built directly from the same vars docker-compose.yml's `daemon.image:`
# interpolates (REGISTRY_HOST/BACKEND_IMAGE_PATH/BACKEND_IMAGE_TAG),
# NOT via `docker compose config --images daemon`: that command does not
# actually filter to the named service (confirmed live on Compose
# v5.3.1 -- it prints every service's image, one per line, in file
# order) so `| head -1` silently grabbed db's image instead. The probe
# then always found no daemon binary in a Postgres image and dropped
# daemon from EVERY backend deploy, regardless of what the real backend
# image contained -- reproduced and confirmed against beta directly.
daemon_img="${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph/backend}:${BACKEND_IMAGE_TAG}"
if ! docker run --rm --entrypoint sh "$daemon_img" -c 'test -x /usr/local/bin/thermograph-daemon' 2>/dev/null; then
echo "==> $daemon_img predates the daemon binary; rolling without the daemon service this run"
kept=()
for t in "${TARGETS[@]}"; do

View file

@ -41,6 +41,15 @@ Do this **before** the DNS + Caddy step below — Caddy's reverse_proxy target
(`127.0.0.1:3080`) needs the `forgejo` service actually listening first, or
its first health check just fails harmlessly until it is.
This stack has **no auto-deploy trigger** — nothing in `.forgejo/workflows/`
redeploys it on push. A change to `docker-stack.yml` only takes effect once
someone re-runs `docker stack deploy` by hand on the manager (prod).
`db`/`forgejo` both carry `resources.limits` (defaults: db 1 CPU/1g, forgejo 2
CPU/2g — several times observed steady-state usage), overridable with
`FORGEJO_DB_CPUS`/`FORGEJO_DB_MEMORY`/`FORGEJO_CPUS`/`FORGEJO_MEMORY` env vars
before `docker stack deploy`, same convention as the app stack.
## DNS + TLS: reusing beta's existing Caddy, not a second reverse proxy
Forgejo is pinned to beta (`role=forge`) — but beta is also **today's live
@ -98,6 +107,62 @@ See that script's header for exactly what it replaces (the pre-Forgejo GitHub
self-hosted runner on this same machine) and why it registers with two
labels where there used to be two separate runners.
`config.yaml`'s `runner.capacity` is raised from the tool's default of 1 to 8
(override with `CAPACITY=`) — a single PR push fires `pr-build`,
`secrets-guard`, and `shell-lint` simultaneously (3 independent workflows,
no `needs:` between them), so capacity 1 serializes work that could run in
parallel, and even capacity 3 (an earlier, undocumented hand-tune) leaves
`build-backend`/`build-frontend`/`validate-observability` queued behind
those three before they get a slot. The desktop has 16 cores / 34GB free
today; 8 concurrent jobs is comfortable headroom without starving LAN dev's
own compose stack.
**Adding more runner capacity should mean raising this number, or adding a
second runner on the desktop itself — not putting a runner on prod or
beta.** `container.docker_host: automount` gives job containers the *host's*
Docker socket; on prod or beta that would mean any CI job has root-equivalent
access to whatever else is running there (the live app stack, or Forgejo
itself). An earlier revision of this stack actually did run the runner as a
Swarm-hosted container on beta and was deliberately reverted to the desktop
for this reason — see the note at the top of `docker-stack.yml`.
## Custom CI job image (`ci-runner/`)
`ci-runner/Dockerfile` still bases on `node:20-bookworm` — Node is a hard
requirement, not leftover: Forgejo's runner executes `actions/checkout@v4`
(used by every workflow) as `node dist/index.js` *inside the job container*,
regardless of whether the workflow itself uses npm/node. (v1 of this image
tried a Node-free Debian-slim base and broke every job's checkout step —
`node: executable file not found` — within a minute of going live; reverted
immediately.) What it actually fixes: every `docker`-labeled build-push job
currently re-installs the Docker CLI on each run (`apt-get install
docker.io`), which pulls in the classic builder rather than BuildKit (the
classic builder mishandles `COPY --chown=<name>` group resolution — a real
bug hit during the frontend Go rewrite). `ci-runner` adds `docker-ce-cli` +
`docker-buildx-plugin` (BuildKit) on top of the same Node base, plus
`git`/`python3`/`python3-yaml` for the other jobs that need them
(`shell-lint`, `observability-validate`).
Current tag: `git.thermograph.org/emi/thermograph/ci-runner:v2` (`v1` is
broken — do not register any runner against it). Rebuild/push (requires a PAT
with `write:package` scope — the embedded git-remote token lacks it, same
requirement documented in `backend-build-push.yml`):
```bash
docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
docker push git.thermograph.org/emi/thermograph/ci-runner:vN
```
`register-lan-runner.sh`'s `LABELS` default points at the current tag, so
fresh registrations pick it up automatically. The live runner is cut over by
editing the `labels` array in `~/forgejo-runner/.runner` on the desktop (same
runner id/token, no re-registration needed) and restarting the service —
**verify a real job runs green under the new image before relying on it**,
same way v1's break was caught. Only after that verification should the
now-redundant `apt-get install docker.io` / `python3-yaml` steps be removed
from the workflows that had them — removing them first would break every job
still running on the stock `node:20-bookworm` image.
## Why Postgres here and not the Thermograph app's TimescaleDB
Separate instance, separate network (`forgejo_net`, not the app's compose

View file

@ -0,0 +1,46 @@
# Job-container image for the LAN Forgejo Actions runner's `docker`-labeled
# jobs (see ../register-lan-runner.sh) — used by every backend/frontend
# build-push, deploy, and validation workflow that needs `docker build`.
#
# Base stays node:20-bookworm, NOT a Node-free slim image (v1 of this file
# tried that and broke every job: `actions/checkout@v4` is a JS action that
# Forgejo's runner execs as `node dist/index.js` *inside the job container*,
# so a Node runtime is a hard requirement regardless of whether the workflow
# itself uses npm/node — this repo's own workflows don't, but the checkout
# step every one of them starts with does).
#
# What this image actually fixes: every workflow using the `docker` label
# currently re-provisions the Docker CLI on each run via `apt-get install
# docker.io` — that step costs ~15-20s per job and, more importantly,
# installs the CLASSIC Docker builder rather than BuildKit, which mishandles
# `COPY --chown=<name>` group resolution on images without a matching system
# group (a real bug hit during the frontend Go rewrite). Installing
# docker-buildx-plugin here makes BuildKit the default, closing that bug
# class, and skips the per-job install entirely.
#
# Build/push (manual — see ../README.md for when to rebuild):
# docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN \
# infra/deploy/forgejo/ci-runner
# docker push git.thermograph.org/emi/thermograph/ci-runner:vN
#
# Cutting over the live runner to a new tag means editing the `labels` array
# in ~/forgejo-runner/.runner on the runner host directly (same runner
# id/token, no re-registration needed) and updating register-lan-runner.sh's
# LABELS default so future re-provisioning picks it up too.
FROM node:20-bookworm
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends \
ca-certificates curl gnupg git python3 python3-yaml \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends docker-ce-cli docker-buildx-plugin \
&& rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/docker.list
RUN docker --version && docker buildx version && git --version \
&& node --version \
&& python3 -c "import yaml; print('PyYAML', yaml.__version__)"

View file

@ -48,6 +48,10 @@ services:
deploy:
placement:
constraints: [node.labels.role == forge]
resources:
limits:
cpus: "${FORGEJO_DB_CPUS:-1}"
memory: ${FORGEJO_DB_MEMORY:-1g}
restart_policy:
condition: on-failure
@ -131,6 +135,10 @@ services:
deploy:
placement:
constraints: [node.labels.role == forge]
resources:
limits:
cpus: "${FORGEJO_CPUS:-2}"
memory: ${FORGEJO_MEMORY:-2g}
restart_policy:
condition: on-failure

View file

@ -26,7 +26,7 @@ set -euo pipefail
FORGEJO_URL="${1:?usage: $0 <forgejo_url> <registration_token>}"
TOKEN="${2:?}"
RUNNER_DIR="${RUNNER_DIR:-$HOME/forgejo-runner}"
LABELS="${LABELS:-docker:docker://node:20-bookworm,thermograph-lan}"
LABELS="${LABELS:-docker:docker://git.thermograph.org/emi/thermograph/ci-runner:v2,thermograph-lan}"
echo "==> Stopping and disabling the old GitHub Actions runner service, if present"
systemctl --user stop github-actions-runner 2>/dev/null || true
@ -62,11 +62,15 @@ echo "==> Registering with $FORGEJO_URL (label: $LABELS)"
--name "thermograph-lan-$(hostname -s)" \
--labels "$LABELS"
echo "==> Runner config (docker.sock automount, so docker:-labeled jobs like"
echo " build-push.yml can actually run 'docker build/push' -- generated"
echo " config only overridden on the one setting that matters here)"
echo "==> Runner config (docker.sock automount so docker:-labeled jobs like"
echo " build-push.yml can actually run 'docker build/push'; capacity raised"
echo " from the default of 1 -- a single PR push fires 3+ independent"
echo " workflows (pr-build, secrets-guard, shell-lint) simultaneously, so"
echo " anything less than that serializes jobs that could run in parallel)"
CAPACITY="${CAPACITY:-8}"
./forgejo-runner generate-config \
| sed 's/docker_host: "-"/docker_host: "automount"/' \
| sed -e 's/docker_host: "-"/docker_host: "automount"/' \
-e "s/capacity: 1/capacity: ${CAPACITY}/" \
> "${RUNNER_DIR}/config.yaml"
echo "==> systemd --user unit"

View file

@ -34,9 +34,14 @@ if [ -f "$ENV_FILE" ]; then
done < "$ENV_FILE"
fi
# Hand off: the backend image has a real entrypoint script; the frontend
# image is plain-CMD (docker passes that CMD to us as $@ when the stack
# overrides the entrypoint) -- exec whichever this image actually is.
# Hand off: the backend image has a real entrypoint script and takes that
# branch. Anything else needs an explicit `command:` in the stack yml's
# service block -- overriding `entrypoint:` here drops the image's own CMD
# entirely (Docker/Swarm does not merge them), so `$@` is empty unless the
# stack file supplies one. The frontend service does exactly that. The
# uvicorn fallback below is what ran, silently, whenever that expectation
# didn't hold; it is Python-specific and kept only as a loud, familiar-looking
# failure for a misconfigured non-Python image, not a real handoff path.
if [ -x /app/deploy/entrypoint.sh ]; then
exec /app/deploy/entrypoint.sh "$@"
elif [ "$#" -gt 0 ]; then

View file

@ -245,6 +245,12 @@ services:
frontend:
image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:?required}
entrypoint: ["/host/env-entrypoint.sh"]
# REQUIRED, not cosmetic: overriding `entrypoint:` with no `command:` drops
# the image's own CMD entirely (Docker/Swarm semantics, not merged) --
# env-entrypoint.sh then sees zero args and falls through to its hardcoded
# `exec uvicorn app:app` fallback, which no longer exists in this (Go)
# image. Without this line the frontend task exits 127 on every deploy.
command: ["/usr/local/bin/thermograph-frontend"]
environment:
THERMOGRAPH_BASE: /
PORT: 8080

View file

@ -8,8 +8,8 @@
# FRONTEND_IMAGE_TAG. This replaces the earlier Stage-4 model where both
# containers shared the single emi/thermograph/app image and
# THERMOGRAPH_SERVICE_ROLE picked the process; the split Dockerfiles now start
# the right process directly (frontend `uvicorn app:app`, backend
# entrypoint.sh), so a backend deploy and a frontend deploy are fully
# the right process directly (frontend the thermograph-frontend Go binary,
# backend entrypoint.sh), so a backend deploy and a frontend deploy are fully
# independent -- deploy.sh rolls one service without touching the other's tag.
# Both get their own loopback-published port since prod/beta's host Caddy
# path-splits directly to each; backend also gets a reverse-proxy fallback to
@ -256,9 +256,9 @@ services:
restart: unless-stopped
frontend:
# Frontend's OWN image, published by thermograph-frontend's build-push.yml
# from its own Dockerfile (which starts `uvicorn app:app` directly -- no
# THERMOGRAPH_SERVICE_ROLE process-picking anymore). Independent
# Frontend's OWN image, published by frontend-build-push.yml from
# frontend/Dockerfile (which starts the thermograph-frontend Go binary
# directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent
# FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag.
image: ${REGISTRY_HOST:-git.thermograph.org}/${FRONTEND_IMAGE_PATH:-emi/thermograph/frontend}:${FRONTEND_IMAGE_TAG:-local}
# Its own register() fetches the IndexNow key from backend at boot -- must