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
708 lines
27 KiB
Go
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
|
|
}
|