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
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).
101 lines
No EOL
6.1 KiB
Cheetah
101 lines
No EOL
6.1 KiB
Cheetah
{{/* 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" .}} |