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).
169 lines
No EOL
14 KiB
Cheetah
169 lines
No EOL
14 KiB
Cheetah
{{/*
|
||
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}} |