diff --git a/paths.py b/paths.py
index 52f89f7..864faad 100644
--- a/paths.py
+++ b/paths.py
@@ -35,6 +35,10 @@ DATA_DIR = os.path.join(REPO_DIR, "data")
LOGS_DIR = os.path.join(REPO_DIR, "logs")
FRONTEND_DIR = os.path.join(REPO_DIR, "frontend")
TEMPLATES_DIR = os.path.join(BACKEND_DIR, "templates")
+# Structured SSR copy (glossary, static-page SEO meta) — see
+# backend/web/content_loader.py and docs/README.md. A sibling of backend/ and
+# frontend/, not under either: it's the seed of a future thermograph-copy repo.
+CONTENT_DIR = os.path.join(REPO_DIR, "content")
# Bundled reference data shipped alongside the code (generated by gen_cities /
# gen_flavor), not runtime state — hence under backend/, not data/.
diff --git a/requirements.txt b/requirements.txt
index 079aa15..987dc06 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,5 +17,8 @@ alembic==1.14.0
pywebpush==2.0.0
# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py).
PyNaCl==1.5.0
-# Server-rendered SEO content pages (see backend/content.py, templates/).
+# Server-rendered SEO content pages (see backend/web/content.py, templates/).
jinja2==3.1.6
+# Structured SSR copy (glossary, static-page SEO meta) — see
+# backend/web/content_loader.py and content/.
+PyYAML==6.0.3
diff --git a/tests/web/test_content.py b/tests/web/test_content.py
index ecd9d8f..4c85ee9 100644
--- a/tests/web/test_content.py
+++ b/tests/web/test_content.py
@@ -197,6 +197,32 @@ def test_hub_glossary_about(client):
assert client.get(f"{B}/about").status_code == 200
+def test_static_page_titles_come_from_content_yaml(client):
+ """These four pages' SEO title/description are loaded from content/pages.yaml
+ (content_loader.load_pages) rather than hardcoded — assert the rendered
+
still carries the expected text, so the extraction is provably
+ render-unchanged, not just "the file parses". Jinja autoescapes the
+ {{ page_title }} interpolation, so compare against the same markupsafe
+ escape the template applies (glossary_index's "&" round-trips through
+ it as "&" both before and after this extraction — a pre-existing
+ render quirk this test intentionally preserves, not fixes)."""
+ from markupsafe import escape
+ from web import content
+ for path, key in ((f"{B}/climate", "hub"), (f"{B}/glossary", "glossary_index"),
+ (f"{B}/about", "about"), (f"{B}/privacy", "privacy")):
+ html = client.get(path).text
+ assert f"{escape(content.PAGES[key]['title'])}" in html, path
+
+
+def test_glossary_terms_come_from_content_yaml(client):
+ from web import content
+ html = client.get(f"{B}/glossary").text
+ for entry in content.GLOSSARY.values():
+ assert entry["term"] in html
+ term_html = client.get(f"{B}/glossary/percentile").text
+ assert content.GLOSSARY["percentile"]["short"] in term_html
+
+
def test_footer_has_no_leaked_jinja_comment(client):
# A comment whose prose contained a literal "#}" closed early and leaked
# "wrapper. #}" into the footer. Guard every base-template page against a
diff --git a/tests/web/test_content_loader.py b/tests/web/test_content_loader.py
new file mode 100644
index 0000000..4a172c2
--- /dev/null
+++ b/tests/web/test_content_loader.py
@@ -0,0 +1,139 @@
+"""Tests for content_loader.py: schema validation (fail loud on a malformed
+content/ file) and that the real glossary.yaml/pages.yaml load correctly."""
+import pytest
+
+from web import content_loader
+
+
+def _write(tmp_path, name, text):
+ path = tmp_path / name
+ path.write_text(text, encoding="utf-8")
+ return str(path)
+
+
+# --- load_glossary: schema validation --------------------------------------
+
+def test_load_glossary_rejects_non_mapping_top_level(tmp_path):
+ path = _write(tmp_path, "g.yaml", "- just\n- a\n- list\n")
+ with pytest.raises(ValueError, match="top-level mapping"):
+ content_loader.load_glossary(path)
+
+
+def test_load_glossary_rejects_missing_terms_key(tmp_path):
+ path = _write(tmp_path, "g.yaml", "not_terms: []\n")
+ with pytest.raises(ValueError, match="non-empty list"):
+ content_loader.load_glossary(path)
+
+
+def test_load_glossary_rejects_empty_terms(tmp_path):
+ path = _write(tmp_path, "g.yaml", "terms: []\n")
+ with pytest.raises(ValueError, match="non-empty list"):
+ content_loader.load_glossary(path)
+
+
+def test_load_glossary_rejects_missing_required_field(tmp_path):
+ path = _write(tmp_path, "g.yaml", """
+terms:
+ - slug: foo
+ term: Foo
+ short: A short definition.
+""")
+ with pytest.raises(ValueError, match="foo.*body"):
+ content_loader.load_glossary(path)
+
+
+def test_load_glossary_rejects_blank_required_field(tmp_path):
+ path = _write(tmp_path, "g.yaml", """
+terms:
+ - slug: foo
+ term: Foo
+ short: ""
+ body: Full body.
+""")
+ with pytest.raises(ValueError, match="foo.*short"):
+ content_loader.load_glossary(path)
+
+
+def test_load_glossary_rejects_duplicate_slug(tmp_path):
+ path = _write(tmp_path, "g.yaml", """
+terms:
+ - slug: foo
+ term: Foo
+ short: s1
+ body: b1
+ - slug: foo
+ term: Foo Again
+ short: s2
+ body: b2
+""")
+ with pytest.raises(ValueError, match="duplicate slug"):
+ content_loader.load_glossary(path)
+
+
+def test_load_glossary_accepts_well_formed_content(tmp_path):
+ path = _write(tmp_path, "g.yaml", """
+terms:
+ - slug: foo
+ term: Foo
+ short: A short definition.
+ body: A full body with markup.
+ - slug: bar
+ term: Bar
+ short: Another short definition.
+ body: Another full body.
+""")
+ glossary = content_loader.load_glossary(path)
+ assert list(glossary) == ["foo", "bar"] # file order preserved
+ assert glossary["foo"] == {
+ "term": "Foo", "short": "A short definition.", "body": "A full body with markup.",
+ }
+
+
+# --- load_pages: schema validation ------------------------------------------
+
+def test_load_pages_rejects_missing_pages_key(tmp_path):
+ path = _write(tmp_path, "p.yaml", "not_pages: {}\n")
+ with pytest.raises(ValueError, match="non-empty mapping"):
+ content_loader.load_pages(path)
+
+
+def test_load_pages_rejects_missing_required_field(tmp_path):
+ path = _write(tmp_path, "p.yaml", """
+pages:
+ about:
+ title: About
+""")
+ with pytest.raises(ValueError, match="about.*description"):
+ content_loader.load_pages(path)
+
+
+def test_load_pages_accepts_well_formed_content(tmp_path):
+ path = _write(tmp_path, "p.yaml", """
+pages:
+ about:
+ title: About Us
+ description: A description.
+""")
+ pages = content_loader.load_pages(path)
+ assert pages == {"about": {"title": "About Us", "description": "A description."}}
+
+
+# --- the real content/ files ------------------------------------------------
+
+def test_real_glossary_loads_and_matches_the_rendered_site():
+ """The module-level GLOSSARY content.py loads at import IS content_loader's
+ output — this just re-asserts the real file is well-formed and non-trivial,
+ since a broken content/glossary.yaml would already have failed every other
+ test at collection time (content.py loads it at import)."""
+ glossary = content_loader.load_glossary()
+ assert len(glossary) >= 8
+ assert "percentile" in glossary
+ for slug, entry in glossary.items():
+ assert entry["term"] and entry["short"] and entry["body"], slug
+
+
+def test_real_pages_loads_the_four_static_pages():
+ pages = content_loader.load_pages()
+ assert set(pages) == {"about", "privacy", "hub", "glossary_index"}
+ for key, entry in pages.items():
+ assert entry["title"] and entry["description"], key
diff --git a/web/content.py b/web/content.py
index 34a22eb..1aba1df 100644
--- a/web/content.py
+++ b/web/content.py
@@ -25,6 +25,7 @@ from data import city_events
from data import climate
from data import grading
from data import grid
+from web import content_loader
from web import homepage
import paths
from web.views import OBS_COLS
@@ -797,74 +798,19 @@ def hub_page(request: Request) -> Response:
"n_countries": len(groups),
"canonical_path": "/climate",
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
- "page_title": "City climate pages: averages, records and how today compares",
- "page_description": ("Browse climate pages for hundreds of cities worldwide: average temperatures by "
- "month, all-time records, and how today's weather compares to local history."),
+ "page_title": PAGES["hub"]["title"],
+ "page_description": PAGES["hub"]["description"],
}
return _respond_html(request, "hub.html.j2", **ctx)
-# Weather-terms glossary. Each entry: slug -> (term, short definition, body HTML).
-GLOSSARY: dict[str, dict] = {
- "climate-normal": {
- "term": "Climate normal",
- "short": "The typical value of a weather metric for a place and time of year, averaged over decades.",
- "body": "A climate normal is the long-term average of a weather variable (say, the daily high) "
- "for a specific place and time of year. Thermograph builds each day's normal from every "
- "historical day within ±7 days of that day-of-year, across ~45 years, so a day is judged "
- "against its own season, not a single annual average.",
- },
- "percentile": {
- "term": "Percentile",
- "short": "Where a value ranks within a distribution: the 90th percentile is warmer than 90% of days.",
- "body": "A percentile says where a value falls within a range of past values. If today's high is at "
- "the 97th percentile, only about 3% of comparable days in this location's history were warmer. "
- "Thermograph grades every day by its percentile against the local ±7-day seasonal distribution.",
- },
- "temperature-anomaly": {
- "term": "Temperature anomaly",
- "short": "How far a temperature departs from normal: the difference from the long-term average.",
- "body": "A temperature anomaly is how much warmer or colder it is than the local normal for the "
- "time of year. Thermograph expresses the same idea as a percentile and a grade (from “Below "
- "Normal” to “Near Record”), so an anomaly is easy to read at a glance for any location.",
- },
- "feels-like": {
- "term": "Feels-like temperature",
- "short": "What the air actually feels like once humidity and wind are accounted for.",
- "body": "Feels-like (apparent temperature) combines air temperature with humidity and wind. In heat "
- "it uses the heat index; in cold it uses "
- "wind chill. Thermograph grades feels-like against its "
- "own local history, so “Near Record” means extreme for that place.",
- },
- "heat-index": {
- "term": "Heat index",
- "short": "How hot it feels when humidity is factored into the air temperature.",
- "body": "The heat index is the apparent temperature on a hot, humid day: high humidity slows sweat "
- "evaporation, so it feels hotter than the thermometer reads. Thermograph folds it into the "
- "feels-like metric and grades how unusual it is locally.",
- },
- "wind-chill": {
- "term": "Wind chill",
- "short": "How cold it feels when wind is factored into the air temperature.",
- "body": "Wind chill is the apparent temperature on a cold, windy day: wind strips away body heat, so "
- "it feels colder than the air temperature. It's the cold-weather half of "
- "feels-like.",
- },
- "humidity": {
- "term": "Absolute humidity",
- "short": "The actual mass of water vapor in the air, in grams per cubic meter.",
- "body": "Thermograph reports absolute humidity (g/m³), the real amount of water vapor in the "
- "air, rather than relative humidity, which shifts with temperature. Graded against local history, "
- "it shows genuinely muggy or unusually dry days.",
- },
- "reanalysis": {
- "term": "Reanalysis (ERA5)",
- "short": "A gridded, physically-consistent record of past weather for anywhere on Earth.",
- "body": "A reanalysis blends historical observations with a weather model to produce a consistent "
- "record of past conditions everywhere, even where no station exists. Thermograph's ~45-year history "
- "comes from ECMWF's ERA5 reanalysis (via Open-Meteo), which is why it works for any point on Earth.",
- },
-}
+# Weather-terms glossary, loaded from content/glossary.yaml (see content_loader.py).
+GLOSSARY: dict[str, dict] = content_loader.load_glossary()
+
+# Static-page SEO title/description, loaded from content/pages.yaml. The
+# per-city/per-month pages build theirs dynamically from city data instead —
+# see their own page_title/page_description assignments above.
+PAGES: dict[str, dict] = content_loader.load_pages()
def _glossary_body(entry: dict) -> str:
@@ -876,9 +822,8 @@ def glossary_index(request: Request) -> Response:
"terms": [{"slug": s, **e} for s, e in GLOSSARY.items()],
"canonical_path": "/glossary",
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
- "page_title": "Weather & climate glossary: heat index, feels-like, percentile, and more",
- "page_description": ("Plain-language definitions of weather and climate terms: climate normal, percentile, "
- "temperature anomaly, feels-like, heat index, wind chill, humidity, and reanalysis."),
+ "page_title": PAGES["glossary_index"]["title"],
+ "page_description": PAGES["glossary_index"]["description"],
}
return _respond_html(request, "glossary.html.j2", **ctx)
@@ -902,9 +847,8 @@ def about_page(request: Request) -> Response:
ctx = {
"canonical_path": "/about",
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
- "page_title": "About Thermograph: how the weather grades are calculated",
- "page_description": ("How Thermograph works: ~45 years of ERA5 climate history, a ±7-day seasonal "
- "window, and empirical percentiles that grade each day relative to its own location."),
+ "page_title": PAGES["about"]["title"],
+ "page_description": PAGES["about"]["description"],
}
return _respond_html(request, "about.html.j2", **ctx)
@@ -913,9 +857,8 @@ def privacy_page(request: Request) -> Response:
ctx = {
"canonical_path": "/privacy",
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
- "page_title": "Privacy | Thermograph",
- "page_description": ("What Thermograph does and does not collect: no tracking, no ads, "
- "no account required, and location that never leaves your browser."),
+ "page_title": PAGES["privacy"]["title"],
+ "page_description": PAGES["privacy"]["description"],
}
return _respond_html(request, "privacy.html.j2", **ctx)
diff --git a/web/content_loader.py b/web/content_loader.py
new file mode 100644
index 0000000..b49ce0c
--- /dev/null
+++ b/web/content_loader.py
@@ -0,0 +1,69 @@
+"""Loads structured SSR copy (glossary, static-page SEO meta) from content/ —
+see docs/README.md. Validated at load time so a malformed content file fails
+loudly at import rather than silently rendering a blank/broken page.
+
+Separate from cities_flavor.json (backend/cities_flavor.json, machine-generated
+from Wikipedia by gen_flavor.py) and the Jinja templates themselves
+(backend/templates/*.html.j2, which still own page structure/markup) — this
+covers only the two content classes that are pure static data with no embedded
+template logic: the glossary, and each static page's SEO title/description.
+"""
+import os
+
+import yaml
+
+import paths
+
+_GLOSSARY_PATH = os.path.join(paths.CONTENT_DIR, "glossary.yaml")
+_PAGES_PATH = os.path.join(paths.CONTENT_DIR, "pages.yaml")
+
+_REQUIRED_GLOSSARY_FIELDS = ("term", "short", "body")
+_REQUIRED_PAGE_FIELDS = ("title", "description")
+
+
+def _load_yaml(path: str) -> dict:
+ with open(path, encoding="utf-8") as f:
+ data = yaml.safe_load(f)
+ if not isinstance(data, dict):
+ raise ValueError(f"{path}: expected a top-level mapping, got {type(data).__name__}")
+ return data
+
+
+def load_glossary(path: str = _GLOSSARY_PATH) -> "dict[str, dict]":
+ """slug -> {term, short, body}, in file order. Raises ValueError if any
+ entry is missing a required field, has the wrong shape, or repeats a slug —
+ a bad content edit fails loudly (at content.py's module-level import,
+ normally) rather than rendering a blank glossary card."""
+ data = _load_yaml(path)
+ terms = data.get("terms")
+ if not isinstance(terms, list) or not terms:
+ raise ValueError(f"{path}: 'terms' must be a non-empty list")
+ glossary: "dict[str, dict]" = {}
+ for i, entry in enumerate(terms):
+ if not isinstance(entry, dict) or "slug" not in entry:
+ raise ValueError(f"{path}: terms[{i}] must be a mapping with a 'slug' key")
+ slug = entry["slug"]
+ missing = [f for f in _REQUIRED_GLOSSARY_FIELDS if not entry.get(f)]
+ if missing:
+ raise ValueError(f"{path}: term '{slug}' is missing {missing}")
+ if slug in glossary:
+ raise ValueError(f"{path}: duplicate slug '{slug}'")
+ glossary[slug] = {f: entry[f] for f in _REQUIRED_GLOSSARY_FIELDS}
+ return glossary
+
+
+def load_pages(path: str = _PAGES_PATH) -> "dict[str, dict]":
+ """page key -> {title, description}. Same fail-loud contract as load_glossary."""
+ data = _load_yaml(path)
+ pages = data.get("pages")
+ if not isinstance(pages, dict) or not pages:
+ raise ValueError(f"{path}: 'pages' must be a non-empty mapping")
+ result: "dict[str, dict]" = {}
+ for key, entry in pages.items():
+ if not isinstance(entry, dict):
+ raise ValueError(f"{path}: page '{key}' must be a mapping")
+ missing = [f for f in _REQUIRED_PAGE_FIELDS if not entry.get(f)]
+ if missing:
+ raise ValueError(f"{path}: page '{key}' is missing {missing}")
+ result[key] = {f: entry[f] for f in _REQUIRED_PAGE_FIELDS}
+ return result