69 lines
3 KiB
Python
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
|