web/worker: add a process-level liveness heartbeat (#80)
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
This commit is contained in:
parent
72ad6f300f
commit
a4ecb51401
52 changed files with 6671 additions and 55 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
91
backend/tests/web/test_heartbeat.py
Normal file
91
backend/tests/web/test_heartbeat.py
Normal 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)
|
||||
|
|
@ -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
29
frontend/.dockerignore
Normal 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
|
||||
|
|
@ -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
59
frontend/server/README.md
Normal 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
5
frontend/server/go.mod
Normal 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
4
frontend/server/go.sum
Normal 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=
|
||||
127
frontend/server/internal/config/config.go
Normal file
127
frontend/server/internal/config/config.go
Normal 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
|
||||
}
|
||||
137
frontend/server/internal/config/config_test.go
Normal file
137
frontend/server/internal/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
268
frontend/server/internal/content/content.go
Normal file
268
frontend/server/internal/content/content.go
Normal 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
|
||||
}
|
||||
892
frontend/server/internal/content/content_test.go
Normal file
892
frontend/server/internal/content/content_test.go
Normal 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"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")
|
||||
}
|
||||
}
|
||||
177
frontend/server/internal/content/funcmap.go
Normal file
177
frontend/server/internal/content/funcmap.go
Normal 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
|
||||
}
|
||||
708
frontend/server/internal/content/pages.go
Normal file
708
frontend/server/internal/content/pages.go
Normal 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
|
||||
}
|
||||
119
frontend/server/internal/content/seo.go
Normal file
119
frontend/server/internal/content/seo.go
Normal 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"))
|
||||
}
|
||||
329
frontend/server/internal/contentapi/client.go
Normal file
329
frontend/server/internal/contentapi/client.go
Normal 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
|
||||
}
|
||||
251
frontend/server/internal/contentapi/client_test.go
Normal file
251
frontend/server/internal/contentapi/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
281
frontend/server/internal/contentapi/types.go
Normal file
281
frontend/server/internal/contentapi/types.go
Normal 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"`
|
||||
}
|
||||
129
frontend/server/internal/contentdata/loader.go
Normal file
129
frontend/server/internal/contentdata/loader.go
Normal 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
|
||||
}
|
||||
111
frontend/server/internal/contentdata/loader_test.go
Normal file
111
frontend/server/internal/contentdata/loader_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
frontend/server/internal/format/bands.go
Normal file
71
frontend/server/internal/format/bands.go
Normal 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
|
||||
}
|
||||
138
frontend/server/internal/format/bands_test.go
Normal file
138
frontend/server/internal/format/bands_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
362
frontend/server/internal/format/format.go
Normal file
362
frontend/server/internal/format/format.go
Normal 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),
|
||||
}
|
||||
}
|
||||
257
frontend/server/internal/format/format_test.go
Normal file
257
frontend/server/internal/format/format_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
137
frontend/server/internal/handlers/handlers.go
Normal file
137
frontend/server/internal/handlers/handlers.go
Normal 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)
|
||||
}
|
||||
277
frontend/server/internal/handlers/handlers_test.go
Normal file
277
frontend/server/internal/handlers/handlers_test.go
Normal 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"tok&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())
|
||||
}
|
||||
}
|
||||
131
frontend/server/internal/handlers/shells.go
Normal file
131
frontend/server/internal/handlers/shells.go
Normal 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
|
||||
// (& < > ' ").
|
||||
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)
|
||||
}
|
||||
}
|
||||
62
frontend/server/internal/handlers/static.go
Normal file
62
frontend/server/internal/handlers/static.go
Normal 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)
|
||||
}
|
||||
136
frontend/server/internal/render/render.go
Normal file
136
frontend/server/internal/render/render.go
Normal 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)
|
||||
}
|
||||
}
|
||||
102
frontend/server/internal/render/render_test.go
Normal file
102
frontend/server/internal/render/render_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
30
frontend/server/internal/render/templates/about.html.tmpl
Normal file
30
frontend/server/internal/render/templates/about.html.tmpl
Normal 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 sq 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>±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" .}}
|
||||
169
frontend/server/internal/render/templates/base.html.tmpl
Normal file
169
frontend/server/internal/render/templates/base.html.tmpl
Normal 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 & 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 · NASA POWER · 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}}
|
||||
101
frontend/server/internal/render/templates/city.html.tmpl
Normal file
101
frontend/server/internal/render/templates/city.html.tmpl
Normal 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 more →</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 → 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" .}}
|
||||
10
frontend/server/internal/render/templates/glossary.html.tmpl
Normal file
10
frontend/server/internal/render/templates/glossary.html.tmpl
Normal 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 & 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" .}}
|
||||
|
|
@ -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" .}}
|
||||
182
frontend/server/internal/render/templates/home.html.tmpl
Normal file
182
frontend/server/internal/render/templates/home.html.tmpl
Normal 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 sq mi cell, builds a ±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}}&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}} · {{.PctLabel}} pct
|
||||
{{- if .Normal}} · normal {{$.Fmt.Temp .Normal}}{{end}}
|
||||
{{- if .DateLabel}} · {{.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>
|
||||
· Data: ERA5 via Open-Meteo · NASA POWER · 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" .}}
|
||||
80
frontend/server/internal/render/templates/hub.html.tmpl
Normal file
80
frontend/server/internal/render/templates/hub.html.tmpl
Normal 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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" .}}
|
||||
60
frontend/server/internal/render/templates/month.html.tmpl
Normal file
60
frontend/server/internal/render/templates/month.html.tmpl
Normal 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" .}}
|
||||
31
frontend/server/internal/render/templates/privacy.html.tmpl
Normal file
31
frontend/server/internal/render/templates/privacy.html.tmpl
Normal 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 & methodology</a>.</p>
|
||||
</article>
|
||||
{{template "base_body_end" .}}{{template "base_scripts_default" .}}{{template "base_tail" .}}
|
||||
95
frontend/server/internal/render/templates/records.html.tmpl
Normal file
95
frontend/server/internal/render/templates/records.html.tmpl
Normal 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
174
frontend/server/main.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
infra/deploy/forgejo/ci-runner/Dockerfile
Normal file
46
infra/deploy/forgejo/ci-runner/Dockerfile
Normal 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__)"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue