From ab1a4590e0f8c4be93b06c882b97bdf76584b858 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Mon, 20 Jul 2026 18:21:11 -0700 Subject: [PATCH] Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weather-terms glossary (9 entries) and four static pages' SEO title/ description were hardcoded directly in web/content.py - a 1019-line module that also owns all SSR rendering logic - mixed in with code that changes on a completely different cadence and for different reasons. Add content/glossary.yaml and content/pages.yaml (a new repo-root content/ tree, a sibling of backend/ and frontend/ paths.py resolves the same way - the seed of a future thermograph-copy repo per the architecture decision doc's own §4) plus web/content_loader.py: a small loader that validates each file's shape at load time (required fields present and non-empty, no duplicate glossary slugs) and fails loudly on a malformed edit rather than rendering a blank glossary card or an empty . content.py's GLOSSARY dict and the about/privacy/hub/glossary_index page_title/description literals now come from the loader. Scope: only content that is genuinely pure static data with no embedded template logic. cities_flavor.json (Wikipedia extracts, already its own generated file) and the homepage's title/description (embedded in home.html.j2 as Jinja block overrides, a heavily-tested product-critical template) are deliberately left as they are - a future pass, not required for this one. UI microcopy bound to frontend logic stays in frontend/, per the doc's own line between content and frontend. Verified: content/glossary.yaml generated programmatically from the live GLOSSARY dict (not hand-transcribed) and round-tripped byte-for-byte identical against it; content/pages.yaml's four entries checked field-by- field against the original hardcoded strings. Full backend suite green (362 passed, 4 skipped) with zero existing test changes needed beyond one assertion made escaping-aware (a pre-existing Jinja double-escape quirk on the one title containing "&", intentionally preserved not fixed). Built and booted the real Docker image: content/ present at /app/content, and curled /glossary, /glossary/percentine, /about, /privacy from inside the running container - all four render with the exact expected title text. --- paths.py | 4 + requirements.txt | 5 +- tests/web/test_content.py | 26 ++++++ tests/web/test_content_loader.py | 139 +++++++++++++++++++++++++++++++ web/content.py | 89 ++++---------------- web/content_loader.py | 69 +++++++++++++++ 6 files changed, 258 insertions(+), 74 deletions(-) create mode 100644 tests/web/test_content_loader.py create mode 100644 web/content_loader.py 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 + <title> 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 "&amp;" 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"<title>{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