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 "&", 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.
This commit is contained in:
parent
1727ebf60f
commit
ab1a4590e0
6 changed files with 258 additions and 74 deletions
4
paths.py
4
paths.py
|
|
@ -35,6 +35,10 @@ DATA_DIR = os.path.join(REPO_DIR, "data")
|
||||||
LOGS_DIR = os.path.join(REPO_DIR, "logs")
|
LOGS_DIR = os.path.join(REPO_DIR, "logs")
|
||||||
FRONTEND_DIR = os.path.join(REPO_DIR, "frontend")
|
FRONTEND_DIR = os.path.join(REPO_DIR, "frontend")
|
||||||
TEMPLATES_DIR = os.path.join(BACKEND_DIR, "templates")
|
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 /
|
# Bundled reference data shipped alongside the code (generated by gen_cities /
|
||||||
# gen_flavor), not runtime state — hence under backend/, not data/.
|
# gen_flavor), not runtime state — hence under backend/, not data/.
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,8 @@ alembic==1.14.0
|
||||||
pywebpush==2.0.0
|
pywebpush==2.0.0
|
||||||
# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py).
|
# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py).
|
||||||
PyNaCl==1.5.0
|
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
|
jinja2==3.1.6
|
||||||
|
# Structured SSR copy (glossary, static-page SEO meta) — see
|
||||||
|
# backend/web/content_loader.py and content/.
|
||||||
|
PyYAML==6.0.3
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,32 @@ def test_hub_glossary_about(client):
|
||||||
assert client.get(f"{B}/about").status_code == 200
|
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'])}</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):
|
def test_footer_has_no_leaked_jinja_comment(client):
|
||||||
# A comment whose prose contained a literal "#}" closed early and leaked
|
# A comment whose prose contained a literal "#}" closed early and leaked
|
||||||
# "wrapper. #}" into the footer. Guard every base-template page against a
|
# "wrapper. #}" into the footer. Guard every base-template page against a
|
||||||
|
|
|
||||||
139
tests/web/test_content_loader.py
Normal file
139
tests/web/test_content_loader.py
Normal file
|
|
@ -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 <b>markup</b>.
|
||||||
|
- 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 <b>markup</b>.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
|
@ -25,6 +25,7 @@ from data import city_events
|
||||||
from data import climate
|
from data import climate
|
||||||
from data import grading
|
from data import grading
|
||||||
from data import grid
|
from data import grid
|
||||||
|
from web import content_loader
|
||||||
from web import homepage
|
from web import homepage
|
||||||
import paths
|
import paths
|
||||||
from web.views import OBS_COLS
|
from web.views import OBS_COLS
|
||||||
|
|
@ -797,74 +798,19 @@ def hub_page(request: Request) -> Response:
|
||||||
"n_countries": len(groups),
|
"n_countries": len(groups),
|
||||||
"canonical_path": "/climate",
|
"canonical_path": "/climate",
|
||||||
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
|
"breadcrumb": [("Home", f"{BASE}/"), ("Climate", None)],
|
||||||
"page_title": "City climate pages: averages, records and how today compares",
|
"page_title": PAGES["hub"]["title"],
|
||||||
"page_description": ("Browse climate pages for hundreds of cities worldwide: average temperatures by "
|
"page_description": PAGES["hub"]["description"],
|
||||||
"month, all-time records, and how today's weather compares to local history."),
|
|
||||||
}
|
}
|
||||||
return _respond_html(request, "hub.html.j2", **ctx)
|
return _respond_html(request, "hub.html.j2", **ctx)
|
||||||
|
|
||||||
|
|
||||||
# Weather-terms glossary. Each entry: slug -> (term, short definition, body HTML).
|
# Weather-terms glossary, loaded from content/glossary.yaml (see content_loader.py).
|
||||||
GLOSSARY: dict[str, dict] = {
|
GLOSSARY: dict[str, dict] = content_loader.load_glossary()
|
||||||
"climate-normal": {
|
|
||||||
"term": "Climate normal",
|
# Static-page SEO title/description, loaded from content/pages.yaml. The
|
||||||
"short": "The typical value of a weather metric for a place and time of year, averaged over decades.",
|
# per-city/per-month pages build theirs dynamically from city data instead —
|
||||||
"body": "A <b>climate normal</b> is the long-term average of a weather variable (say, the daily high) "
|
# see their own page_title/page_description assignments above.
|
||||||
"for a specific place and time of year. Thermograph builds each day's normal from every "
|
PAGES: dict[str, dict] = content_loader.load_pages()
|
||||||
"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 <b>percentile</b> 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 <b>temperature anomaly</b> 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": "<b>Feels-like</b> (apparent temperature) combines air temperature with humidity and wind. In heat "
|
|
||||||
"it uses the <a href=\"{base}/glossary/heat-index\">heat index</a>; in cold it uses "
|
|
||||||
"<a href=\"{base}/glossary/wind-chill\">wind chill</a>. Thermograph grades feels-like against its "
|
|
||||||
"own local history, so “Near Record” means extreme <i>for that place</i>.",
|
|
||||||
},
|
|
||||||
"heat-index": {
|
|
||||||
"term": "Heat index",
|
|
||||||
"short": "How hot it feels when humidity is factored into the air temperature.",
|
|
||||||
"body": "The <b>heat index</b> 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 "
|
|
||||||
"<a href=\"{base}/glossary/feels-like\">feels-like</a> 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": "<b>Wind chill</b> 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 "
|
|
||||||
"<a href=\"{base}/glossary/feels-like\">feels-like</a>.",
|
|
||||||
},
|
|
||||||
"humidity": {
|
|
||||||
"term": "Absolute humidity",
|
|
||||||
"short": "The actual mass of water vapor in the air, in grams per cubic meter.",
|
|
||||||
"body": "Thermograph reports <b>absolute humidity</b> (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 <b>reanalysis</b> 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 <b>ERA5</b> reanalysis (via Open-Meteo), which is why it works for any point on Earth.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _glossary_body(entry: dict) -> str:
|
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()],
|
"terms": [{"slug": s, **e} for s, e in GLOSSARY.items()],
|
||||||
"canonical_path": "/glossary",
|
"canonical_path": "/glossary",
|
||||||
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
|
"breadcrumb": [("Home", f"{BASE}/"), ("Glossary", None)],
|
||||||
"page_title": "Weather & climate glossary: heat index, feels-like, percentile, and more",
|
"page_title": PAGES["glossary_index"]["title"],
|
||||||
"page_description": ("Plain-language definitions of weather and climate terms: climate normal, percentile, "
|
"page_description": PAGES["glossary_index"]["description"],
|
||||||
"temperature anomaly, feels-like, heat index, wind chill, humidity, and reanalysis."),
|
|
||||||
}
|
}
|
||||||
return _respond_html(request, "glossary.html.j2", **ctx)
|
return _respond_html(request, "glossary.html.j2", **ctx)
|
||||||
|
|
||||||
|
|
@ -902,9 +847,8 @@ def about_page(request: Request) -> Response:
|
||||||
ctx = {
|
ctx = {
|
||||||
"canonical_path": "/about",
|
"canonical_path": "/about",
|
||||||
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
|
"breadcrumb": [("Home", f"{BASE}/"), ("About", None)],
|
||||||
"page_title": "About Thermograph: how the weather grades are calculated",
|
"page_title": PAGES["about"]["title"],
|
||||||
"page_description": ("How Thermograph works: ~45 years of ERA5 climate history, a ±7-day seasonal "
|
"page_description": PAGES["about"]["description"],
|
||||||
"window, and empirical percentiles that grade each day relative to its own location."),
|
|
||||||
}
|
}
|
||||||
return _respond_html(request, "about.html.j2", **ctx)
|
return _respond_html(request, "about.html.j2", **ctx)
|
||||||
|
|
||||||
|
|
@ -913,9 +857,8 @@ def privacy_page(request: Request) -> Response:
|
||||||
ctx = {
|
ctx = {
|
||||||
"canonical_path": "/privacy",
|
"canonical_path": "/privacy",
|
||||||
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
|
"breadcrumb": [("Home", f"{BASE}/"), ("Privacy", None)],
|
||||||
"page_title": "Privacy | Thermograph",
|
"page_title": PAGES["privacy"]["title"],
|
||||||
"page_description": ("What Thermograph does and does not collect: no tracking, no ads, "
|
"page_description": PAGES["privacy"]["description"],
|
||||||
"no account required, and location that never leaves your browser."),
|
|
||||||
}
|
}
|
||||||
return _respond_html(request, "privacy.html.j2", **ctx)
|
return _respond_html(request, "privacy.html.j2", **ctx)
|
||||||
|
|
||||||
|
|
|
||||||
69
web/content_loader.py
Normal file
69
web/content_loader.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue