frontend: rewrite the SSR content service in Go #28

Merged
admin_emi merged 1 commit from frontend-go into main 2026-07-24 00:53:49 +00:00
Owner

Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth here — every route fetches from the backend's
/content/* API, so this is I/O-bound glue with no hard-porting wall; the
risk was always in reproducing the rendering exactly, not the language.

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 (frontend/tests/fixtures/) and every one of the 11 routes
compared byte-for-byte. The only surviving differences after that process
are insignificant inter-tag whitespace and one attribute where Go's stricter
escaper HTML-encodes an apostrophe Jinja left literal (functionally identical
in every browser) — confirmed programmatically, by normalizing whitespace
and unescaping before diffing, not by eyeballing.

That process caught defects unit tests alone would have missed, because
map[string]any has no compile-time field check:

  • Render-context keys were snake_case throughout (content.py's Jinja
    convention, ported verbatim) while the templates — written independently —
    read PascalCase fields. A missing map key doesn't error in html/template,
    it silently renders empty, so this was invisible in every status code and
    every "it built" signal: title, meta description, canonical URL, OpenGraph
    tags, the homepage's entire ranked list, and the brand-tag/nav-active state
    were all blank across every page. Fixed by renaming every key to match each
    template's own header comment (the authoritative per-page field contract)
    and, where an API struct's exported fields already matched what a template
    needed (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, …),
    passing the struct straight through instead of hand-rewrapping it in a map
    — removes a whole layer of future drift risk, not just this instance of it.
  • Three pages 500'd outright: .ToolHref needed a fully-composed href
    string, not the bare "lat,lon" fragment the handlers were building; the
    all-time-records table needed the raw contentapi.AllTimeRecords struct,
    not a re-wrapped map.
  • JSON-LD was being double-encoded: <script type="application/ld+json">
    is JAVASCRIPT context to html/template's contextual escaper regardless of
    the script's type attribute, so a template.HTML-typed value placed there
    gets re-escaped as a quoted JS string instead of emitted raw — the entire
    structured-data payload shipped as a JSON string containing JSON, which no
    crawler would parse as the intended object. Needed template.JS instead,
    the type that actually means "trusted JS source." The glossary term page's
    JSON-LD was simply never built at all (the Jinja original assembled it
    inline in the template rather than through content.py's context dict, and
    that got lost in translation) — added.
  • html/template silently strips literal HTML comments and JavaScript
    comments
    from the parsed output (verified in isolation, zero template
    actions involved) — confirmed as real engine behavior, not a bug in either
    port, so both need a FuncMap function returning template.HTML /
    template.JS respectively to survive parsing rather than a literal
    <!-- --> or // in the template source.

Packaging

Multi-stage Go build, final image alpine (not distroless — the Swarm stack's
env-entrypoint.sh shim needs bash), 187MB → 22.6MB. Two defects caught
before they reached a host:

  • The Swarm stack overrides entrypoint: with no command:, which drops the
    image's own CMD entirely (Docker/Swarm semantics, not merged) —
    env-entrypoint.sh then fell through to its hardcoded exec uvicorn app:app fallback, which doesn't exist in this image. Every deploy would
    have exited 127.
    Fixed with an explicit command: on the stack's frontend
    service, and corrected the shim's stale comment claiming CMD passes through
    automatically.
  • COPY --chown=thermograph resolves the group by name at copy time;
    Alpine's adduser -S with no -G doesn't create a same-named group, so the
    classic (non-BuildKit) Docker builder — which this CI runner falls back to,
    since it installs plain docker.io with no buildx plugin — failed outright.
    Fixed with an explicit group and numeric --chown.

Verification

  • go build/vet/test -race clean across all packages.
  • The Docker image builds and passes its embedded go test step under
    both BuildKit and the classic builder.
  • shellcheck 0 findings on the one script touched (env-entrypoint.sh).
  • Rebased onto current main — the ERA5 lake stack landed on both main and
    dev during this work; confirmed additive, no overlap with frontend/daemon.

Note on the process

This PR's implementation was interrupted mid-run and picked back up from
whatever had already landed on disk. The gaps above were found by then
actually exercising the rendered output against the Python original, rather
than trusting green tests — the tests were passing throughout every one of
these defects, since they asserted against the same (wrong) shape the
handlers produced.

Ports `frontend/` (Jinja2/FastAPI, ~1180 LOC) to Go with `html/template`. No climate math, no DB, no auth here — every route fetches from the backend's `/content/*` API, so this is I/O-bound glue with no hard-porting wall; the risk was always in reproducing the rendering exactly, not the language. ## 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 (`frontend/tests/fixtures/`) and every one of the 11 routes compared **byte-for-byte**. The only surviving differences after that process are insignificant inter-tag whitespace and one attribute where Go's stricter escaper HTML-encodes an apostrophe Jinja left literal (functionally identical in every browser) — confirmed **programmatically**, by normalizing whitespace and unescaping before diffing, not by eyeballing. That process caught defects unit tests alone would have missed, because `map[string]any` has no compile-time field check: - **Render-context keys were snake_case throughout** (content.py's Jinja convention, ported verbatim) while the templates — written independently — read PascalCase fields. A missing map key doesn't error in `html/template`, it silently renders empty, so this was invisible in every status code and every "it built" signal: title, meta description, canonical URL, OpenGraph tags, the homepage's entire ranked list, and the brand-tag/nav-active state were all blank across every page. Fixed by renaming every key to match each template's own header comment (the authoritative per-page field contract) and, where an API struct's exported fields already matched what a template needed (`contentapi.CityInfo`, `Crumb`, `HomeRanked`, `HubCountry`, …), passing the struct straight through instead of hand-rewrapping it in a map — removes a whole layer of future drift risk, not just this instance of it. - **Three pages 500'd outright**: `.ToolHref` needed a fully-composed href string, not the bare `"lat,lon"` fragment the handlers were building; the all-time-records table needed the raw `contentapi.AllTimeRecords` struct, not a re-wrapped map. - **JSON-LD was being double-encoded**: `<script type="application/ld+json">` is JAVASCRIPT context to `html/template`'s contextual escaper regardless of the script's `type` attribute, so a `template.HTML`-typed value placed there gets re-escaped as a quoted JS string instead of emitted raw — the entire structured-data payload shipped as a JSON string *containing* JSON, which no crawler would parse as the intended object. Needed `template.JS` instead, the type that actually means "trusted JS source." The glossary term page's JSON-LD was simply never built at all (the Jinja original assembled it inline in the template rather than through content.py's context dict, and that got lost in translation) — added. - **`html/template` silently strips literal HTML comments *and* JavaScript comments** from the parsed output (verified in isolation, zero template actions involved) — confirmed as real engine behavior, not a bug in either port, so both need a FuncMap function returning `template.HTML` / `template.JS` respectively to survive parsing rather than a literal `<!-- -->` or `//` in the template source. ## Packaging Multi-stage Go build, final image alpine (not distroless — the Swarm stack's `env-entrypoint.sh` shim needs bash), **187MB → 22.6MB**. Two defects caught before they reached a host: - The Swarm stack overrides `entrypoint:` with no `command:`, which drops the image's own CMD entirely (Docker/Swarm semantics, not merged) — `env-entrypoint.sh` then fell through to its hardcoded `exec uvicorn app:app` fallback, which doesn't exist in this image. **Every deploy would have exited 127.** Fixed with an explicit `command:` on the stack's frontend service, and corrected the shim's stale comment claiming CMD passes through automatically. - `COPY --chown=thermograph` resolves the group by *name* at copy time; Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so the classic (non-BuildKit) Docker builder — which this CI runner falls back to, since it installs plain `docker.io` with no buildx plugin — failed outright. Fixed with an explicit group and numeric `--chown`. ## Verification - `go build`/`vet`/`test -race` clean across all packages. - The Docker image builds and passes its embedded `go test` step under **both** BuildKit and the classic builder. - `shellcheck` 0 findings on the one script touched (`env-entrypoint.sh`). - Rebased onto current `main` — the ERA5 lake stack landed on both `main` and `dev` during this work; confirmed additive, no overlap with frontend/daemon. ## Note on the process This PR's implementation was interrupted mid-run and picked back up from whatever had already landed on disk. The gaps above were found by then actually exercising the rendered output against the Python original, rather than trusting green tests — the tests were passing throughout every one of these defects, since they asserted against the same (wrong) shape the handlers produced.
admin_emi added 1 commit 2026-07-24 00:52:06 +00:00
frontend: rewrite the SSR content service in Go
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 7s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 7s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m0s
PR build (required check) / gate (pull_request) Successful in 2s
9eecfc8eef
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template.
No climate math, no DB, no auth here -- every route fetches from the
backend's /content/* API, so this is I/O-bound glue with no hard-porting
wall; the risk was always in reproducing the rendering exactly, not the
language.

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
(frontend/tests/fixtures/) and every one of the 11 routes compared
byte-for-byte. The only surviving differences after that process are
insignificant inter-tag whitespace and one attribute where Go's stricter
escaper HTML-encodes an apostrophe Jinja left literal (functionally
identical in every browser) -- confirmed programmatically by normalizing
whitespace and unescaping before diffing, not by eyeballing.

That process caught defects unit tests alone would have missed, because
map[string]any has no compile-time field check:

- Render-context keys were snake_case throughout (content.py's Jinja
  convention, ported verbatim) while the templates -- written
  independently -- read PascalCase fields. A missing map key doesn't
  error in html/template, it silently renders empty, so this was invisible
  in every status code and every "it built" signal: title, meta
  description, canonical URL, OpenGraph tags, the homepage's entire ranked
  list, and the brand-tag/nav-active state were all blank across every
  page. Fixed by renaming every key to match each template's own header
  comment (the authoritative per-page field contract) and, where an
  API struct's exported fields already matched what a template needed
  (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the
  struct straight through instead of hand-rewrapping it in a map --
  removes a whole layer of future drift risk, not just this instance of it.
- Three pages 500'd outright: `.ToolHref` needed a fully-composed href
  string, not the bare "lat,lon" fragment the handlers were building; the
  all-time-records table needed the raw contentapi.AllTimeRecords struct,
  not a re-wrapped map.
- JSON-LD was being double-encoded: `<script type="application/ld+json">`
  is JAVASCRIPT context to html/template's contextual escaper regardless
  of the script's `type` attribute, so a template.HTML-typed value placed
  there gets re-escaped as a quoted JS string instead of emitted raw --
  the entire structured-data payload shipped as a JSON string containing
  JSON, which no crawler would parse as the intended object. Needed
  template.JS instead, the type that actually means "trusted JS source."
  The glossary term page's JSON-LD was simply never built at all (the
  Jinja original assembled it inline in the template rather than through
  content.py's context dict, and that got lost in translation) -- added.
- html/template silently strips literal HTML comments AND JavaScript
  comments from the parsed output (verified in isolation, zero template
  actions involved) -- confirmed as real engine behavior, not a bug in
  either port, so both need a FuncMap function returning template.HTML /
  template.JS respectively to survive parsing rather than a literal
  `<!-- -->` or `//` in the template source.

Packaging: multi-stage Go build, final image alpine (not distroless -- the
Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two
defects caught before they reached a host:
- The Swarm stack overrides `entrypoint:` with no `command:`, which drops
  the image's own CMD entirely (Docker/Swarm semantics, not merged) --
  env-entrypoint.sh then fell through to its hardcoded `exec uvicorn
  app:app` fallback, which doesn't exist in this image. Every deploy
  would have exited 127. Fixed with an explicit `command:` on the stack's
  frontend service, and corrected the shim's stale comment claiming CMD
  passes through automatically.
- `COPY --chown=thermograph` resolves the group by NAME at copy time;
  Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so
  the classic (non-BuildKit) Docker builder -- which this CI runner falls
  back to, since it installs plain `docker.io` with no buildx plugin --
  failed outright. Fixed with an explicit group and numeric --chown.

Verification: go build/vet/test -race clean across all packages; the
Docker image builds and passes its embedded go test step under both
BuildKit and the classic builder; shellcheck 0 findings on the one script
touched; rebased onto current main (the ERA5 lake stack landed on both
main and dev during this work -- confirmed additive, no overlap with
frontend/daemon).
admin_emi merged commit 92e74c585a into main 2026-07-24 00:53:49 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Jinemi/thermograph#28
No description provided.