#!/usr/bin/env python3 """Substitute {{stats.*}} placeholders in a rendered Quarto site. Runs as a Quarto post-render step for any site in this repo. Working on the built HTML rather than on the .qmd source means one mechanism covers every context a number appears in: body prose, attribute values such as iframe title=, and the nodes inside inline SVG mockups. A span-and-hydrate approach would reach the first two but not the third, and would flash empty before scripts run. Placeholder syntax: {{stats.key}} e.g. {{stats.stars}} {{stats.vol2_ch.fault_tolerance}} An unresolved placeholder is a build failure. Shipping a literal "{{stats.foo}}" to a reader is worse than any stale number, so a typo in a key stops the build instead of leaking markup. """ from __future__ import annotations import json import os import re import sys from pathlib import Path # Shared across every Quarto site in the repo. The values themselves stay in # one place: site/config/stats-cache.json, written by site/scripts/build_stats.py # and refreshed on a schedule by .github/workflows/site-refresh-stats.yml. A # second cache would mean two numbers for one fact. REPO_ROOT = Path(__file__).resolve().parent.parent.parent CACHE_PATH = REPO_ROOT / "site" / "config" / "stats-cache.json" # Quarto runs post-render from the project directory and names the output # directory in the environment, so the same script serves any site without # knowing which one invoked it. _out = os.environ.get("QUARTO_PROJECT_OUTPUT_DIR") BUILD_DIR = (Path.cwd() / _out).resolve() if _out else (Path.cwd() / "_build").resolve() PLACEHOLDER = re.compile(r"\{\{stats\.([A-Za-z0-9_.]+)\}\}") def main() -> int: if not CACHE_PATH.is_file(): print(f"error: {CACHE_PATH} missing; run build_stats.py first", file=sys.stderr) return 1 if not BUILD_DIR.is_dir(): print(f"error: {BUILD_DIR} missing; nothing rendered", file=sys.stderr) return 1 cache = json.loads(CACHE_PATH.read_text(encoding="utf-8")) display: dict[str, str] = cache.get("display", {}) generated: str = cache.get("generated", "") unresolved: dict[str, set[str]] = {} substitutions = 0 touched = 0 # search.json carries the same prose as the pages, extracted before this # step runs. Skipping it leaves raw {{stats.*}} tokens in the site search # index, so a reader searching "stars" gets the placeholder as a result. targets = sorted( set(BUILD_DIR.rglob("*.html")) | set(BUILD_DIR.rglob("*.json")) ) for path in targets: original = path.read_text(encoding="utf-8") if "{{stats." not in original: continue # Values are substituted into JSON string literals, so they must be # escaped the way JSON expects rather than dropped in raw. is_json = path.suffix == ".json" missing: set[str] = set() def replace(match: re.Match[str]) -> str: key = match.group(1) if key not in display: missing.add(key) return match.group(0) value = display[key] if is_json: return json.dumps(value)[1:-1] # Wrap body-text values so the page can refresh them at load time # from stats.json. Values inside an attribute or an SVG node # cannot carry a wrapper element, so those stay static; they are # decorative or metadata rather than the figures a reader reads. start = match.start() in_attr = original.rfind("<", 0, start) > original.rfind(">", 0, start) in_svg_text = original.rfind(" original.rfind("{value}' updated, count = PLACEHOLDER.subn(replace, original) if missing: unresolved[str(path.relative_to(BUILD_DIR))] = missing if count: path.write_text(updated, encoding="utf-8") substitutions += count touched += 1 # Publish the values at a stable URL. A scheduled job can refresh this one # file without rebuilding the site, and every page load picks it up. (BUILD_DIR / "stats.json").write_text( json.dumps({"generated": generated, "display": display}, indent=2, sort_keys=True) + "\n", encoding="utf-8") print(f"Injected {substitutions} stat value(s) across {touched} page(s); " f"wrote stats.json", file=sys.stderr) if unresolved: print("\nerror: unresolved stat placeholders", file=sys.stderr) for page, keys in sorted(unresolved.items()): for key in sorted(keys): print(f" {page}: {{{{stats.{key}}}}}", file=sys.stderr) print("\nAdd the key in build_stats.py or fix the typo in the .qmd.", file=sys.stderr) return 1 return 0 if __name__ == "__main__": if os.environ.get("TAXONOMY_ONLY") == "1": sys.exit(0) sys.exit(main())