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