thermograph/paths.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

46 lines
2 KiB
Python

"""Canonical filesystem locations, resolved once from the repo root.
Every path the backend needs (the climate cache, the accounts DB, logs, the
Jinja templates, the frontend static dir, the bundled city data) is derived
here from the repository root rather than from each module's own ``__file__``.
That keeps the locations stable no matter how deep in the package tree a module
lives, so moving a module never silently re-points the cache or the accounts DB.
Callers that support an environment override (e.g. ``THERMOGRAPH_ACCOUNTS_DB``)
keep doing so; these are only the defaults.
"""
import os
_HERE = os.path.dirname(os.path.abspath(__file__))
def _repo_root() -> str:
"""Walk up from this file until a directory holds both ``backend/`` and
``frontend/`` — the repository root, in every checkout (dev, prod, worktree)."""
d = _HERE
while True:
if os.path.isdir(os.path.join(d, "backend")) and os.path.isdir(os.path.join(d, "frontend")):
return d
parent = os.path.dirname(d)
if parent == d:
raise RuntimeError(
"cannot locate the repo root (no ancestor has sibling backend/ and frontend/)"
)
d = parent
REPO_DIR = _repo_root()
BACKEND_DIR = os.path.join(REPO_DIR, "backend")
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/.
CITIES_JSON = os.path.join(BACKEND_DIR, "cities.json")
CITIES_FLAVOR_JSON = os.path.join(BACKEND_DIR, "cities_flavor.json")