mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-11-09 10:39:41 -06:00
128 lines
5.0 KiB
Python
Executable File
128 lines
5.0 KiB
Python
Executable File
#!/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 <text>
|
|
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 <text> 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("<text", 0, start) > original.rfind("</text", 0, start)
|
|
if in_attr or in_svg_text:
|
|
return value
|
|
return f'<span data-stat="{key}">{value}</span>'
|
|
|
|
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())
|