frontend: rewrite the SSR content service in Go (#28)
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth -- every route fetches from the backend's /content/* API. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures and every route compared byte-for-byte, confirmed programmatically. That process caught defects unit tests alone missed, since map[string]any has no compile-time field check: - Render-context keys were snake_case throughout while the templates read PascalCase fields. A missing map key doesn't error, it silently renders empty -- title, meta description, canonical URL, OpenGraph tags, and the homepage's entire ranked list were blank on every page despite every route returning 200. Fixed by renaming every key to match each template's own documented field contract, and passing API structs straight through wherever their fields already matched (removes a whole layer of future drift risk). - Three pages 500'd: ToolHref needed a composed href, not a bare "lat,lon" fragment; the records table needed the raw API struct. - JSON-LD was double-encoded: <script type="application/ld+json"> is JAVASCRIPT context to html/template's escaper regardless of the script's type attribute, so template.HTML gets re-escaped as a quoted JS string. Needed template.JS. The glossary term page's JSON-LD was never built at all -- added. - html/template silently strips literal HTML and JS comments from parsed output (verified in isolation) -- both need a FuncMap function returning template.HTML/template.JS to survive. Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the Swarm stack's entrypoint override with no explicit command drops the image's CMD entirely (every deploy would have exited 127), and COPY --chown by name fails under the classic Docker builder on Alpine. Both fixed. go build/vet/test -race clean; docker build passes its embedded test step under both BuildKit and the classic builder; shellcheck 0 findings.
This commit is contained in:
parent
755886e244
commit
92e74c585a
42 changed files with 6211 additions and 47 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"
|
||||
|
|
|
|||
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
|
||||
}
|
||||
781
frontend/server/internal/content/content_test.go
Normal file
781
frontend/server/internal/content/content_test.go
Normal file
|
|
@ -0,0 +1,781 @@
|
|||
// Behaviour tests for the content.py port, driven by the same committed
|
||||
// backend fixtures the Python suite used (frontend/tests/fixtures/*.json,
|
||||
// captured by scripts/capture-fixtures.sh) and mirroring the assertions of
|
||||
// frontend/tests/unit/test_rendering.py that belong to this layer. Full
|
||||
// rendered-page assertions (200 + HTML structure) live with the template
|
||||
// port / golden-diff phase — here the templates are placeholders, so these
|
||||
// tests target the routes' status behavior and the exact render contexts the
|
||||
// handlers build.
|
||||
package content
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"thermograph/frontend/internal/config"
|
||||
"thermograph/frontend/internal/contentapi"
|
||||
"thermograph/frontend/internal/contentdata"
|
||||
"thermograph/frontend/internal/format"
|
||||
"thermograph/frontend/internal/render"
|
||||
)
|
||||
|
||||
const (
|
||||
testSlug = "london-england-gb" // captured into tests/fixtures/ (GB -> Celsius)
|
||||
testMonth = "july"
|
||||
fakeKey = "test0000indexnow0000key000000000"
|
||||
)
|
||||
|
||||
// fixturesDir / contentDir reach the repo's committed test fixtures and SSR
|
||||
// copy from this package directory.
|
||||
var (
|
||||
fixturesDir = filepath.Join("..", "..", "..", "tests", "fixtures")
|
||||
contentDir = filepath.Join("..", "..", "..", "content")
|
||||
)
|
||||
|
||||
func loadFixture[T any](t *testing.T, name string) *T {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(fixturesDir, name+".json"))
|
||||
if err != nil {
|
||||
t.Fatalf("fixture %s: %v", name, err)
|
||||
}
|
||||
out := new(T)
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
t.Fatalf("fixture %s: %v", name, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fakeAPI is the Go analogue of conftest.py's monkeypatched api_client.
|
||||
type fakeAPI struct {
|
||||
hub func() (*contentapi.HubPayload, error)
|
||||
sitemap func() ([]contentapi.SitemapEntry, error)
|
||||
indexNowKey func() (string, error)
|
||||
home func() (*contentapi.HomePayload, error)
|
||||
city func(slug, origin string) (*contentapi.CityPayload, error)
|
||||
cityMonth func(slug, month string) (*contentapi.MonthPayload, error)
|
||||
cityRecords func(slug, origin string) (*contentapi.RecordsPayload, error)
|
||||
}
|
||||
|
||||
func (f *fakeAPI) Hub() (*contentapi.HubPayload, error) { return f.hub() }
|
||||
func (f *fakeAPI) Sitemap() ([]contentapi.SitemapEntry, error) { return f.sitemap() }
|
||||
func (f *fakeAPI) IndexNowKey() (string, error) { return f.indexNowKey() }
|
||||
func (f *fakeAPI) Home() (*contentapi.HomePayload, error) { return f.home() }
|
||||
func (f *fakeAPI) City(slug, origin string) (*contentapi.CityPayload, error) {
|
||||
return f.city(slug, origin)
|
||||
}
|
||||
func (f *fakeAPI) CityMonth(slug, month string) (*contentapi.MonthPayload, error) {
|
||||
return f.cityMonth(slug, month)
|
||||
}
|
||||
func (f *fakeAPI) CityRecords(slug, origin string) (*contentapi.RecordsPayload, error) {
|
||||
return f.cityRecords(slug, origin)
|
||||
}
|
||||
|
||||
func notFound(detail string) error {
|
||||
return &contentapi.StatusError{
|
||||
StatusCode: 404,
|
||||
Body: fmt.Sprintf(`{"detail":%q}`, detail),
|
||||
URL: "http://backend.invalid/x",
|
||||
}
|
||||
}
|
||||
|
||||
var errUnreachable = errors.New("connect: connection refused")
|
||||
|
||||
// fixtureAPI wires every method to the committed fixtures, exactly like the
|
||||
// Python `client` fixture's monkeypatching (unknown slug/month -> a backend
|
||||
// 404 StatusError).
|
||||
func fixtureAPI(t *testing.T) *fakeAPI {
|
||||
t.Helper()
|
||||
city := loadFixture[contentapi.CityPayload](t, "city")
|
||||
month := loadFixture[contentapi.MonthPayload](t, "month")
|
||||
records := loadFixture[contentapi.RecordsPayload](t, "records")
|
||||
home := loadFixture[contentapi.HomePayload](t, "home")
|
||||
hub := loadFixture[contentapi.HubPayload](t, "hub")
|
||||
sitemap := loadFixture[[]contentapi.SitemapEntry](t, "sitemap")
|
||||
return &fakeAPI{
|
||||
hub: func() (*contentapi.HubPayload, error) { return hub, nil },
|
||||
sitemap: func() ([]contentapi.SitemapEntry, error) { return *sitemap, nil },
|
||||
indexNowKey: func() (string, error) { return fakeKey, nil },
|
||||
home: func() (*contentapi.HomePayload, error) { return home, nil },
|
||||
city: func(slug, origin string) (*contentapi.CityPayload, error) {
|
||||
if slug != testSlug {
|
||||
return nil, notFound("Unknown city.")
|
||||
}
|
||||
return city, nil
|
||||
},
|
||||
cityMonth: func(slug, m string) (*contentapi.MonthPayload, error) {
|
||||
if slug != testSlug || m != testMonth {
|
||||
return nil, notFound("Unknown month.")
|
||||
}
|
||||
return month, nil
|
||||
},
|
||||
cityRecords: func(slug, origin string) (*contentapi.RecordsPayload, error) {
|
||||
if slug != testSlug {
|
||||
return nil, notFound("Unknown city.")
|
||||
}
|
||||
return records, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig() config.Config {
|
||||
return config.Config{
|
||||
APIBaseInternal: "http://backend.invalid",
|
||||
APIVersion: "v2",
|
||||
Base: "/thermograph", // matches THERMOGRAPH_BASE in the Python conftest
|
||||
AssetBase: "/thermograph",
|
||||
GoogleVerify: "",
|
||||
BingVerify: "",
|
||||
}
|
||||
}
|
||||
|
||||
func newHandlers(t *testing.T, api API) *Handlers {
|
||||
t.Helper()
|
||||
cfg := testConfig()
|
||||
engine, err := render.New(FuncMap(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("render.New: %v", err)
|
||||
}
|
||||
glossary, err := contentdata.LoadGlossary(contentDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadGlossary: %v", err)
|
||||
}
|
||||
pages, err := contentdata.LoadPages(contentDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPages: %v", err)
|
||||
}
|
||||
h, err := New(cfg, api, engine, glossary, pages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func newMux(t *testing.T, api API) *http.ServeMux {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
static := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("STATIC")) // stands in for main.go's file server
|
||||
})
|
||||
h := newHandlers(t, api)
|
||||
h.Register(mux, static)
|
||||
// main.go ALSO registers a subtree static catch-all ("GET {base}/", via
|
||||
// handlers.Server.Register) on the same mux — Register's own doc comment
|
||||
// notes this. Only the LAZY IndexNow fallback claims the single-segment
|
||||
// slot itself and forwards non-.txt requests to `static`; the EAGER path
|
||||
// (key fetched successfully at boot) registers just the exact key-file
|
||||
// route and relies on that separate subtree registration for everything
|
||||
// else. Mirror both here, or the eager-route test exercises a mux with no
|
||||
// handler for e.g. app.js at all — a gap in this test double, not in
|
||||
// production routing.
|
||||
mux.Handle("GET "+h.cfg.Base+"/", static)
|
||||
return mux
|
||||
}
|
||||
|
||||
func get(mux *http.ServeMux, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "http://testserver"+path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// --- robots / sitemap --------------------------------------------------------
|
||||
|
||||
func TestRobotsTxt(t *testing.T) {
|
||||
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/robots.txt")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
want := "User-agent: *\n" +
|
||||
"Allow: /\n" +
|
||||
"Disallow: /thermograph/api/\n" +
|
||||
"Disallow: /thermograph/alerts\n" +
|
||||
"Sitemap: http://testserver/thermograph/sitemap.xml\n"
|
||||
if rec.Body.String() != want {
|
||||
t.Errorf("body:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSitemapListsCityURLs(t *testing.T) {
|
||||
rec := get(newMux(t, fixtureAPI(t)), "/thermograph/sitemap.xml")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "<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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
669
frontend/server/internal/content/pages.go
Normal file
669
frontend/server/internal/content/pages.go
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
package content
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// --- 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, err := h.api.CityMonth(slug, month)
|
||||
if err != nil {
|
||||
h.apiError(w, err)
|
||||
return
|
||||
}
|
||||
cp, err := h.api.City(slug, "") // no origin: the Python called city(slug) bare here
|
||||
if err != nil {
|
||||
h.apiError(w, err)
|
||||
return
|
||||
}
|
||||
ctx, unit, err := h.monthCtx(j, cp.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")
|
||||
j, err := h.api.CityRecords(slug, origin(r))
|
||||
if err != nil {
|
||||
h.apiError(w, err)
|
||||
return
|
||||
}
|
||||
cp, err := h.api.City(slug, "") // no origin, same as month_page
|
||||
if err != nil {
|
||||
h.apiError(w, err)
|
||||
return
|
||||
}
|
||||
ctx, unit, err := h.recordsCtx(j, cp.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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -238,6 +238,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