thermograph/frontend/server/internal/content/pages.go
emi a4ecb51401
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +00:00

708 lines
27 KiB
Go

package content
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"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
}
// fetchWithCity runs a page's primary API call and the shared City lookup
// concurrently instead of sequentially: both are independent single-slug
// fetches (City never depends on primary's result), so waiting for one to
// fully round-trip before even starting the other was pure latency with
// nothing to show for it. Concurrent instead cuts that leg to roughly
// whichever call is slower.
//
// If primary fails, its error wins even when City also fails or hasn't
// finished — matching the old sequential code's behavior exactly (it never
// even called City() once primary had already failed). The one real
// trade-off: City() is now ALWAYS launched, even on a request that's about
// to 404 from primary, so an invalid slug costs one extra (wasted, cheap)
// backend lookup it previously skipped. Worth it: a bad slug is the rare
// path; a good one is the common path this speeds up.
func fetchWithCity[T any](api API, slug string, primary func() (T, error)) (T, contentapi.CityInfo, error) {
var (
pVal T
pErr error
cPay *contentapi.CityPayload
cErr error
group sync.WaitGroup
)
group.Add(2)
go func() {
defer group.Done()
pVal, pErr = primary()
}()
go func() {
defer group.Done()
cPay, cErr = api.City(slug, "") // no origin: the Python called city(slug) bare here too
}()
group.Wait()
if pErr != nil {
return pVal, contentapi.CityInfo{}, pErr
}
if cErr != nil {
var zero T
return zero, contentapi.CityInfo{}, cErr
}
return pVal, cPay.City, 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, city, err := fetchWithCity(h.api, slug, func() (*contentapi.MonthPayload, error) {
return h.api.CityMonth(slug, month)
})
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.monthCtx(j, 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")
o := origin(r)
j, city, err := fetchWithCity(h.api, slug, func() (*contentapi.RecordsPayload, error) {
return h.api.CityRecords(slug, o)
})
if err != nil {
h.apiError(w, err)
return
}
ctx, unit, err := h.recordsCtx(j, 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
}