frontend: rewrite the SSR content service in Go #28
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Jinemi/thermograph#28
Loading…
Reference in a new issue
No description provided.
Delete branch "frontend-go"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ports
frontend/(Jinja2/FastAPI, ~1180 LOC) to Go withhtml/template. Noclimate 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; therisk 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 routescompared 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]anyhas no compile-time field check: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.
.ToolHrefneeded a fully-composed hrefstring, not the bare
"lat,lon"fragment the handlers were building; theall-time-records table needed the raw
contentapi.AllTimeRecordsstruct,not a re-wrapped map.
<script type="application/ld+json">is JAVASCRIPT context to
html/template's contextual escaper regardless ofthe script's
typeattribute, so atemplate.HTML-typed value placed theregets 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.JSinstead,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/templatesilently strips literal HTML comments and JavaScriptcomments 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.JSrespectively 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.shshim needs bash), 187MB → 22.6MB. Two defects caughtbefore they reached a host:
entrypoint:with nocommand:, which drops theimage's own CMD entirely (Docker/Swarm semantics, not merged) —
env-entrypoint.shthen fell through to its hardcodedexec uvicorn app:appfallback, which doesn't exist in this image. Every deploy wouldhave exited 127. Fixed with an explicit
command:on the stack's frontendservice, and corrected the shim's stale comment claiming CMD passes through
automatically.
COPY --chown=thermographresolves the group by name at copy time;Alpine's
adduser -Swith no-Gdoesn't create a same-named group, so theclassic (non-BuildKit) Docker builder — which this CI runner falls back to,
since it installs plain
docker.iowith no buildx plugin — failed outright.Fixed with an explicit group and numeric
--chown.Verification
go build/vet/test -raceclean across all packages.go teststep underboth BuildKit and the classic builder.
shellcheck0 findings on the one script touched (env-entrypoint.sh).main— the ERA5 lake stack landed on bothmainanddevduring 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.