thermograph/frontend/server/internal/content/pages.go
emi 92e74c585a
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
frontend: rewrite the SSR content service in Go (#28)
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth -- every route fetches from the backend's
/content/* API.

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
and every route compared byte-for-byte, confirmed programmatically. That
process caught defects unit tests alone missed, since map[string]any has
no compile-time field check:

- Render-context keys were snake_case throughout while the templates read
  PascalCase fields. A missing map key doesn't error, it silently renders
  empty -- title, meta description, canonical URL, OpenGraph tags, and the
  homepage's entire ranked list were blank on every page despite every
  route returning 200. Fixed by renaming every key to match each
  template's own documented field contract, and passing API structs
  straight through wherever their fields already matched (removes a whole
  layer of future drift risk).
- Three pages 500'd: ToolHref needed a composed href, not a bare
  "lat,lon" fragment; the records table needed the raw API struct.
- JSON-LD was double-encoded: <script type="application/ld+json"> is
  JAVASCRIPT context to html/template's escaper regardless of the
  script's type attribute, so template.HTML gets re-escaped as a quoted
  JS string. Needed template.JS. The glossary term page's JSON-LD was
  never built at all -- added.
- html/template silently strips literal HTML and JS comments from parsed
  output (verified in isolation) -- both need a FuncMap function
  returning template.HTML/template.JS to survive.

Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the
Swarm stack's entrypoint override with no explicit command drops the
image's CMD entirely (every deploy would have exited 127), and
COPY --chown by name fails under the classic Docker builder on Alpine.
Both fixed.

go build/vet/test -race clean; docker build passes its embedded test step
under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00

669 lines
26 KiB
Go

package content
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"thermograph/frontend/internal/contentapi"
"thermograph/frontend/internal/contentdata"
"thermograph/frontend/internal/format"
)
// metricKeyByLabel maps a record row's display label back to its metric key
// for the format dispatch (content.py's _METRIC_KEY_BY_LABEL). An unknown
// label is a payload-contract break and 500s, like the Python's KeyError.
var metricKeyByLabel = map[string]string{
"High": "tmax", "Low": "tmin", "Feels-like": "feels",
"Humidity": "humid", "Wind": "wind", "Gust": "gust", "Precip": "precip",
}
// --- shared ctx builders -----------------------------------------------------
//
// Where an contentapi.* struct's exported field names already match what a
// template needs (checked against internal/render/templates' own header
// comments — the authoritative contract), it is passed straight through
// rather than re-wrapped in a map: one fewer hand-typed key string to drift,
// and Go's ordinary pointer/struct nil semantics ({{if .X}}, auto-deref on
// field access) already do the right thing. Only genuinely COMPUTED display
// values (temp spans, range-bar geometry, tier classes) still build a map.
// findMonth returns the fmtMonths entry with the given slug, or nil (the
// template guards every use with {{if .Warmest}} etc., matching Python's
// next((m for m in months if m["slug"] == slug), None)).
func findMonth(months []map[string]any, slug string) any {
for _, m := range months {
if m["Slug"] == slug {
return m
}
}
return nil
}
// fmtMonths is content.py's _fmt_months: each payload month plus its
// display-formatted high/low/precip and range-bar geometry. Name/Slug/HighF/
// LowF/RangeLoF/RangeHiF/PrecipF are read directly off the payload elsewhere
// in the template (e.g. {{$.Fmt.TempClass .HighF}}), so they ride along
// alongside the computed High/Low/Precip/Bar rather than being dropped.
func fmtMonths(unit format.Unit, months []contentapi.CityMonth) []map[string]any {
out := make([]map[string]any, 0, len(months))
for _, m := range months {
out = append(out, map[string]any{
"Name": m.Name, "Slug": m.Slug,
"HighF": m.HighF, "LowF": m.LowF,
"RangeLoF": m.RangeLoF, "RangeHiF": m.RangeHiF,
"PrecipF": m.PrecipF,
"High": format.Temp(unit, m.HighF),
"Low": format.Temp(unit, m.LowF),
"Precip": format.Precip(unit, m.PrecipF),
"Bar": format.MonthRangeBar(m.RangeLoF, m.RangeHiF),
})
}
return out
}
// --- per-city climate page ---------------------------------------------------
// CityPage renders /climate/{slug} (content.py's city_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Months,
// Warmest, Coldest, Wettest, Records, Today, ToolHref, Flavor, Event,
// CompareURL, Breadcrumb, CanonicalPath, PageTitle, PageDescription,
// JSONLDStr (+ the shared keys respondHTML adds).
func (h *Handlers) CityPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
j, err := h.api.City(slug, origin(r))
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.cityCtx(j)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "city.html.tmpl", unit, ctx)
}
// cityCtx builds the render context — split from the handler so the port's
// behaviour tests can assert on it without the (separately-owned) templates.
func (h *Handlers) cityCtx(j *contentapi.CityPayload) (map[string]any, format.Unit, error) {
// The backend decides the page's display unit (its own F_COUNTRIES
// logic); month/records pages derive it from the country code instead —
// preserved as-is from the Python.
unit := format.Unit(j.DefaultUnit)
months := fmtMonths(unit, j.Months)
var today any
if j.TodayVsNormal != nil {
cards := make([]map[string]any, 0, len(j.TodayVsNormal.Cards))
for _, c := range j.TodayVsNormal.Cards {
cards = append(cards, map[string]any{
"Label": c.Label, "Metric": c.Metric, "ValueF": c.ValueF,
"Percentile": c.Percentile, "Grade": c.Grade, "Cls": c.Cls,
"Value": format.Fmt(unit, c.Metric, c.ValueF),
})
}
today = map[string]any{"Date": j.TodayVsNormal.Date, "Cards": cards}
}
jsonld, err := compactJSONLD(j.JSONLD)
if err != nil {
return nil, unit, err
}
// Flavor/Event: contentapi.Flavor/Event's own fields (Extract/Title/URL,
// Text/URL) already match what the template reads, so dereference-or-nil
// is enough — no map wrapping. A typed-nil *Flavor stored in the ctx
// directly would make {{.Flavor.Extract}} hard-error on a nil pointer
// (unlike a plain missing map key, which degrades to empty); dereferencing
// first avoids that and still renders {{if .Flavor}} correctly (Go
// template truthiness on a nil `any` is false; on a struct value, true).
var flavor, event any
if j.Flavor != nil {
flavor = *j.Flavor
}
if j.Event != nil {
event = *j.Event
}
ctx := map[string]any{
"Section": "climate",
"City": j.City, "Display": j.Display, "Name": j.City.Name,
"YearRange": j.YearRange, "NYears": j.NYears,
"Months": months,
"Warmest": findMonth(months, j.WarmestMonthSlug),
"Coldest": findMonth(months, j.ColdestMonthSlug),
"Wettest": findMonth(months, j.WettestMonthSlug),
"Records": j.AllTimeRecords, "Today": today,
// The full href, not just the "lat,lon" fragment: composing it here
// (rather than {{.Base}}/#{{.ToolHash}} in the template) avoids
// html/template's URL-context escaper %-escaping the comma.
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, j.City.Lat, j.City.Lon),
"Flavor": flavor,
"Event": event,
"CompareURL": fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, j.City.Lat, j.City.Lon),
"Breadcrumb": j.Breadcrumb,
"CanonicalPath": j.CanonicalPath,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
// template.JS, NOT template.HTML: this value is interpolated inside
// <script type="application/ld+json">...</script>. html/template's
// contextual escaper treats <script> bodies as JAVASCRIPT context
// regardless of the script's `type` attribute — template.HTML's trust
// guarantee only covers HTML markup context, so inside <script> it
// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
// quoted JS string with every inner quote backslash-escaped, i.e. the
// whole JSON-LD payload doubly wrapped in a string literal instead of
// being emitted as the raw JSON object search engines expect.
// template.JS is the type that means "trusted JS source, emit as-is".
"JSONLDStr": template.JS(jsonld),
}
return ctx, unit, nil
}
// --- month page --------------------------------------------------------------
// MonthPage renders /climate/{slug}/{month} (content.py's month_page).
//
// ctx keys: Section, City, Display, Name, MonthName, MonthSlug,
// YearRange, NYears, AvgHigh, AvgLow, AvgHighCls, AvgLowCls, Stats,
// Records, ToolHref, CompareURL, Prev, Next, Breadcrumb, CanonicalPath,
// PageTitle, PageDescription. `Stats` entries are {Label, Value} maps (the
// Jinja iterated (label, value) tuples).
func (h *Handlers) MonthPage(w http.ResponseWriter, r *http.Request) {
slug, month := r.PathValue("slug"), r.PathValue("month")
j, err := h.api.CityMonth(slug, month)
if err != nil {
h.apiError(w, err)
return
}
cp, err := h.api.City(slug, "") // no origin: the Python called city(slug) bare here
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.monthCtx(j, cp.City)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "month.html.tmpl", unit, ctx)
}
// monthCtx builds the month page's render context (split out for tests).
func (h *Handlers) monthCtx(j *contentapi.MonthPayload, city contentapi.CityInfo) (map[string]any, format.Unit, error) {
slug, month := city.Slug, j.MonthSlug
unit := format.UnitForCountry(city.CountryCode)
var stats []map[string]any
addStat := func(label string, value template.HTML) {
stats = append(stats, map[string]any{"Label": label, "Value": value})
}
rangeStat := func(pair []*float64) (template.HTML, error) {
if len(pair) != 2 {
return "", fmt.Errorf("month %s/%s: typical range has %d entries, want 2", slug, month, len(pair))
}
lo, hi := format.Temp(unit, pair[0]), format.Temp(unit, pair[1])
return template.HTML(string(lo) + " to " + string(hi)), nil
}
if j.AvgHighF != nil {
addStat("Average high", format.Temp(unit, j.AvgHighF))
v, err := rangeStat(j.TypicalHighRangeF)
if err != nil {
return nil, unit, err
}
addStat("Typical high range", v)
}
if j.AvgLowF != nil {
addStat("Average low", format.Temp(unit, j.AvgLowF))
v, err := rangeStat(j.TypicalLowRangeF)
if err != nil {
return nil, unit, err
}
addStat("Typical low range", v)
}
if j.AvgPrecipF != nil {
addStat("Average daily precipitation", format.Precip(unit, j.AvgPrecipF))
}
records := make([]map[string]any, 0, len(j.Records))
for _, rec := range j.Records {
metric, ok := metricKeyByLabel[rec.Label]
if !ok {
return nil, unit, fmt.Errorf("month %s/%s: unknown record label %q", slug, month, rec.Label)
}
records = append(records, map[string]any{
"Label": rec.Label, "HighTag": rec.HighTag, "LowTag": rec.LowTag,
"High": format.Fmt(unit, metric, rec.HighF), "HighDate": rec.HighDate,
"Low": format.Fmt(unit, metric, rec.LowF), "LowDate": rec.LowDate,
})
}
display := city.Name
if j.Display != nil { // j.get("display", city["name"])
display = *j.Display
}
// Prev/Next: MonthLink's Name/Slug fields already match what the template
// reads; dereference-or-nil for the same reason as Flavor/Event above — a
// typed-nil *MonthLink stored as `any` would hard-error on field access
// where a plain nil degrades to empty.
var prev, next any
if j.Prev != nil {
prev = *j.Prev
}
if j.Next != nil {
next = *j.Next
}
ctx := map[string]any{
"Section": "climate", "City": city, "Display": display, "Name": city.Name,
"MonthName": j.MonthName, "MonthSlug": j.MonthSlug,
"YearRange": j.YearRange, "NYears": j.NYears,
"AvgHigh": format.Temp(unit, j.AvgHighF), "AvgLow": format.Temp(unit, j.AvgLowF),
"AvgHighCls": format.TempClass(j.AvgHighF), "AvgLowCls": format.TempClass(j.AvgLowF),
"Stats": stats, "Records": records,
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, city.Lat, city.Lon),
"CompareURL": fmt.Sprintf("%s/compare#loc=%.4f,%.4f", h.cfg.Base, city.Lat, city.Lon),
"Prev": prev, "Next": next,
"Breadcrumb": j.Breadcrumb,
"CanonicalPath": j.CanonicalPath,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
}
return ctx, unit, nil
}
// --- records page ------------------------------------------------------------
// fmtExtreme / fmtPeriodSide: content.py's _fmt_extreme/_fmt_period_extremes —
// {'warm': {'value_f','date'}|None, 'cold': …} becomes the {'Cls','Txt',
// 'Date'} shape the "extremes" template block reads. Stays a computed map
// (unlike Prev/Flavor/etc.): the raw contentapi.Extreme struct has no Cls/Txt
// fields — those are derived display values, not API passthrough.
func fmtExtreme(unit format.Unit, e *contentapi.Extreme) any {
if e == nil {
return nil
}
v := e.ValueF
return map[string]any{
"Txt": format.Temp(unit, &v), "Cls": format.TempClass(&v), "Date": e.Date,
}
}
func fmtPeriodSide(unit format.Unit, s contentapi.PeriodSide) map[string]any {
return map[string]any{"Warm": fmtExtreme(unit, s.Warm), "Cold": fmtExtreme(unit, s.Cold)}
}
// RecordsPage renders /climate/{slug}/records (content.py's records_page).
//
// ctx keys: Section, City, Display, Name, YearRange, NYears, Rows,
// Monthly, Seasonal, Hemisphere, AllTime, CanonicalPath, Breadcrumb,
// PageTitle, PageDescription, JSONLDStr. Row maps carry Label, High,
// HighDate, HighF, Low, LowDate, LowF — HighF/LowF only for
// temperature metrics, nil otherwise, as the Python set them.
func (h *Handlers) RecordsPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
j, err := h.api.CityRecords(slug, origin(r))
if err != nil {
h.apiError(w, err)
return
}
cp, err := h.api.City(slug, "") // no origin, same as month_page
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.recordsCtx(j, cp.City)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "records.html.tmpl", unit, ctx)
}
// recordsCtx builds the records page's render context (split out for tests).
func (h *Handlers) recordsCtx(j *contentapi.RecordsPayload, city contentapi.CityInfo) (map[string]any, format.Unit, error) {
unit := format.UnitForCountry(city.CountryCode)
rows := make([]map[string]any, 0, len(j.Rows))
for _, rec := range j.Rows {
metric, ok := metricKeyByLabel[rec.Label]
if !ok {
return nil, unit, fmt.Errorf("records %s: unknown row label %q", city.Slug, rec.Label)
}
// The precip row's "low" is the longest dry spell, not a value — the
// backend signals it with dry_streak_days and the date the spell began.
var low any
lowDate := derefOr(rec.LowDate, "")
if rec.DryStreakDays != nil {
low = fmt.Sprintf("%d-day dry spell", *rec.DryStreakDays)
if lowDate == "" {
lowDate = "—" // r["low_date"] or "—"
}
} else {
low = format.Fmt(unit, metric, rec.LowF)
}
isTemp := metric == "tmax" || metric == "tmin" || metric == "feels"
var highF, lowF any
if isTemp {
highF, lowF = rec.HighF, rec.LowF
}
rows = append(rows, map[string]any{
"Label": rec.Label,
"High": format.Fmt(unit, metric, rec.HighF), "HighDate": rec.HighDate,
"HighF": highF,
"Low": low, "LowDate": lowDate,
"LowF": lowF,
})
}
monthly := make([]map[string]any, 0, len(j.Monthly))
for _, m := range j.Monthly {
monthly = append(monthly, map[string]any{
"Name": m.Name, "Slug": m.Slug,
"High": fmtPeriodSide(unit, m.High), "Low": fmtPeriodSide(unit, m.Low),
})
}
seasonal := make([]map[string]any, 0, len(j.Seasonal))
for _, s := range j.Seasonal {
seasonal = append(seasonal, map[string]any{
"Name": s.Name, "Span": s.Span,
"High": fmtPeriodSide(unit, s.High), "Low": fmtPeriodSide(unit, s.Low),
})
}
jsonld, err := compactJSONLD(j.JSONLD)
if err != nil {
return nil, unit, err
}
ctx := map[string]any{
"Section": "climate", "City": city,
"Display": derefOr(j.Display, city.Name), "Name": city.Name,
"YearRange": j.YearRange, "NYears": j.NYears,
"Rows": rows, "Monthly": monthly, "Seasonal": seasonal,
"Hemisphere": j.Hemisphere, "AllTime": j.AllTimeRecords,
"ToolHref": fmt.Sprintf("%s/#%.5f,%.5f", h.cfg.Base, city.Lat, city.Lon),
"CanonicalPath": j.CanonicalPath,
"Breadcrumb": j.Breadcrumb,
"PageTitle": j.PageTitle,
"PageDescription": j.PageDescription,
// template.JS, NOT template.HTML: this value is interpolated inside
// <script type="application/ld+json">...</script>. html/template's
// contextual escaper treats <script> bodies as JAVASCRIPT context
// regardless of the script's `type` attribute — template.HTML's trust
// guarantee only covers HTML markup context, so inside <script> it
// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
// quoted JS string with every inner quote backslash-escaped, i.e. the
// whole JSON-LD payload doubly wrapped in a string literal instead of
// being emitted as the raw JSON object search engines expect.
// template.JS is the type that means "trusted JS source, emit as-is".
"JSONLDStr": template.JS(jsonld),
}
return ctx, unit, nil
}
func derefOr(s *string, fallback string) string {
if s != nil {
return *s
}
return fallback
}
// --- hub / glossary / about / privacy ---------------------------------------
// HubPage renders /climate (content.py's hub_page).
//
// ctx keys: Section, Groups, NCities, NCountries, CanonicalPath,
// Breadcrumb, PageTitle, PageDescription. NOTE: the Python's `groups` was
// a dict {country: cities} iterated with .items() — insertion-ordered. Go
// maps don't preserve order, so Groups is the ordered []contentapi.HubCountry
// slice straight from the payload — HubCountry/HubCity's own fields already
// match what the template reads, so this needs no rebuilding at all.
func (h *Handlers) HubPage(w http.ResponseWriter, r *http.Request) {
hub, err := h.api.Hub()
if err != nil {
h.apiError(w, err)
return
}
h.respondHTML(w, r, "hub.html.tmpl", "", h.hubCtx(hub))
}
// hubCtx builds the hub page's render context (split out for tests).
func (h *Handlers) hubCtx(hub *contentapi.HubPayload) map[string]any {
return map[string]any{
"Section": "climate",
"Groups": hub.Countries,
"NCities": hub.NCities,
"NCountries": hub.NCountries,
"CanonicalPath": "/climate",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Climate", "")},
"PageTitle": h.pages["hub"].Title,
"PageDescription": h.pages["hub"].Description,
}
}
// GlossaryIndex renders /glossary. ctx keys: Terms (Slug/Term/Short/Body, in
// file order — contentdata.GlossaryTerm's own fields already match, so the
// list passes straight through), CanonicalPath, Breadcrumb, PageTitle,
// PageDescription.
func (h *Handlers) GlossaryIndex(w http.ResponseWriter, r *http.Request) {
ctx := map[string]any{
"Terms": h.glossary.Terms,
"CanonicalPath": "/glossary",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Glossary", "")},
"PageTitle": h.pages["glossary_index"].Title,
"PageDescription": h.pages["glossary_index"].Description,
}
h.respondHTML(w, r, "glossary.html.tmpl", "", ctx)
}
// GlossaryTerm renders /glossary/{term}. ctx keys: Term, Body (raw HTML with
// {base} substituted per-request, the Python's _glossary_body), Others
// (Slug/Term of every other entry), CanonicalPath, Breadcrumb, PageTitle,
// PageDescription, JSONLDStr (a DefinedTerm object built inline here — the
// Jinja original interpolated term|tojson/page_description|tojson/base_url
// directly into the block rather than routing through content.py's ctx dict,
// so this port has to build the same bytes in Go instead).
func (h *Handlers) GlossaryTerm(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("term")
entry, ok := h.glossary.Get(slug)
if !ok {
writeDetail(w, http.StatusNotFound, "Unknown term.")
return
}
ctx, err := h.glossaryTermCtx(slug, entry, origin(r)+h.cfg.Base)
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "glossary_term.html.tmpl", "", ctx)
}
// glossaryTermJSONLDPrefix/suffix bracket the term page's DefinedTerm
// object exactly as the Jinja original wrote it inline in the template
// (glossary_term.html.j2's {% block jsonld %}) rather than through
// content.py's ctx dict:
//
// {"@context":"https://schema.org","@type":"DefinedTerm","name":<tojson term>,
// "description":<tojson short>,"inDefinedTermSet":"<base_url>/glossary"}
const (
glossaryTermJSONLDPrefix = `{"@context":"https://schema.org","@type":"DefinedTerm","name":`
glossaryTermJSONLDMiddle = `,"description":`
glossaryTermJSONLDSuffix = `,"inDefinedTermSet":"`
)
// glossaryTermCtx builds the term page's render context (split out for
// tests). baseURL is origin(r)+cfg.Base — the request-scoped value
// respondHTML also stamps as ctx["BaseURL"], needed a step early here because
// it feeds the DefinedTerm JSON-LD, which must be ready before render.
func (h *Handlers) glossaryTermCtx(slug string, entry contentdata.GlossaryTerm, baseURL string) (map[string]any, error) {
others := make([]contentdata.GlossaryTerm, 0, len(h.glossary.Terms)-1)
for _, t := range h.glossary.Terms {
if t.Slug != slug {
others = append(others, t)
}
}
// tojson both string fields exactly like Jinja's |tojson filter did
// (toJSON is the same helper glossary_term.html.tmpl's inline JSON-LD
// used to use directly — reused here since this build now happens in Go).
name, err := toJSON(entry.Term)
if err != nil {
return nil, err
}
desc, err := toJSON(entry.Short)
if err != nil {
return nil, err
}
jsonld := glossaryTermJSONLDPrefix + string(name) +
glossaryTermJSONLDMiddle + string(desc) +
glossaryTermJSONLDSuffix + baseURL + `/glossary"}`
return map[string]any{
"Term": entry.Term,
"Body": template.HTML(strings.ReplaceAll(entry.Body, "{base}", h.cfg.Base)),
"CanonicalPath": "/glossary/" + slug,
"Breadcrumb": []contentapi.Crumb{
crumb("Home", h.cfg.Base+"/"),
crumb("Glossary", h.cfg.Base+"/glossary"),
crumb(entry.Term, ""),
},
"PageTitle": fmt.Sprintf("%s: what it means | Thermograph", entry.Term),
"PageDescription": entry.Short,
"Others": others,
"JSONLDStr": template.JS(jsonld), // see the JSONLDStr comment on cityCtx for why JS, not HTML
}, nil
}
// AboutPage renders /about.
func (h *Handlers) AboutPage(w http.ResponseWriter, r *http.Request) {
ctx := map[string]any{
"CanonicalPath": "/about",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("About", "")},
"PageTitle": h.pages["about"].Title,
"PageDescription": h.pages["about"].Description,
}
h.respondHTML(w, r, "about.html.tmpl", "", ctx)
}
// PrivacyPage renders /privacy.
func (h *Handlers) PrivacyPage(w http.ResponseWriter, r *http.Request) {
ctx := map[string]any{
"CanonicalPath": "/privacy",
"Breadcrumb": []contentapi.Crumb{crumb("Home", h.cfg.Base+"/"), crumb("Privacy", "")},
"PageTitle": h.pages["privacy"].Title,
"PageDescription": h.pages["privacy"].Description,
}
h.respondHTML(w, r, "privacy.html.tmpl", "", ctx)
}
// --- homepage ----------------------------------------------------------------
// homeTitle/homeDescription are the literal strings the Jinja original wrote
// directly into home.html.j2's {% block title/description %} overrides,
// never routed through content.py's ctx dict — see the comment at homeCtx's
// call site for why this port sets them in Go instead.
const (
homeTitle = "Thermograph: how unusual is your weather?"
homeDescription = "See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile."
)
// homeJSONLDPrefix is content.py's _HOME_JSONLD serialized exactly as
// json.dumps() with default separators emitted it (spaces after ':' and ',',
// key order preserved, True -> true) — only the request-dependent "url" is
// appended at render time. Kept as a literal so the bytes cannot drift from
// what the Python shipped.
const homeJSONLDPrefix = `{"@context": "https://schema.org", "@type": "WebApplication", ` +
`"name": "Thermograph", "applicationCategory": "WeatherApplication", ` +
`"operatingSystem": "Web, iOS, Android", "isAccessibleForFree": true, ` +
`"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, ` +
`"description": "How unusual is your weather? Any day, anywhere on Earth, ` +
`graded against 45 years of that place's own history.", "url": `
func homeJSONLD(url string) (string, error) {
// json.dumps escaping for the one dynamic value (Python didn't escape
// HTML-significant characters, so the encoder must not either).
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(url); err != nil {
return "", err
}
return homeJSONLDPrefix + strings.TrimRight(buf.String(), "\n") + "}", nil
}
// HomePage renders / (content.py's home_page).
//
// ctx keys: Section, BrandTag, MainClass, PageTitle, PageDescription,
// CanonicalPath, Unusual, Stale, Ranked, Cities, JSONLDStr.
// contentapi.HomeUnusual/HomeRanked/HomeCity's own
// fields already match what the template reads, so Ranked/Cities pass
// straight through and only Unusual needs dereference-or-nil (a *HomeUnusual
// stored typed-nil in the ctx would hard-error on field access; nil `any`
// degrades to empty instead). Ranked's Lat/Lon stay json.Number so the
// backend's exact decimal text prints into the /day#lat=…&lon=… fragment.
func (h *Handlers) HomePage(w http.ResponseWriter, r *http.Request) {
hp, err := h.api.Home()
if err != nil {
h.apiError(w, err)
return
}
ctx, err := h.homeCtx(hp, origin(r))
if err != nil {
h.serverError(w, err)
return
}
h.respondHTML(w, r, "home.html.tmpl", "", ctx)
}
// homeCtx builds the homepage render context (split out for tests). o is the
// request origin, baked into the JSON-LD url.
func (h *Handlers) homeCtx(hp *contentapi.HomePayload, o string) (map[string]any, error) {
var unusual any
if hp.Unusual != nil {
unusual = *hp.Unusual
}
jsonld, err := homeJSONLD(o + h.cfg.Base + "/")
if err != nil {
return nil, err
}
ctx := map[string]any{
"Section": "home",
"BrandTag": "p", // the hero headline owns the sole h1; the brand degrades to <p>
"MainClass": "",
// The Jinja original overrode base.html.j2's {% block title/description
// %} with these exact literals directly in home.html.j2 -- content.py's
// home_page never set page_title/page_description in its ctx dict
// either. This port's base is split into sequential chunks rather than
// Jinja blocks (see render.go), so there is no override point once
// base_head_start has already emitted <title>; setting them here is
// the equivalent for this one route.
"PageTitle": homeTitle,
"PageDescription": homeDescription,
"CanonicalPath": "/",
"Unusual": unusual,
"Stale": hp.Stale,
"Ranked": hp.Ranked,
"Cities": hp.Cities,
// template.JS, NOT template.HTML: this value is interpolated inside
// <script type="application/ld+json">...</script>. html/template's
// contextual escaper treats <script> bodies as JAVASCRIPT context
// regardless of the script's `type` attribute — template.HTML's trust
// guarantee only covers HTML markup context, so inside <script> it
// gets run through the JS-VALUE escaper anyway: JSON-encoded into a
// quoted JS string with every inner quote backslash-escaped, i.e. the
// whole JSON-LD payload doubly wrapped in a string literal instead of
// being emitted as the raw JSON object search engines expect.
// template.JS is the type that means "trusted JS source, emit as-is".
"JSONLDStr": template.JS(jsonld),
}
return ctx, nil
}