thermograph/web/content_loader.py
Emi Griffith ab1a4590e0 Extract the glossary and static-page SEO copy into a schema-validated content/ tree (#241)
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 <title>. 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 "&amp;", 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.
2026-07-21 01:21:11 +00:00

69 lines
3 KiB
Python

"""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