Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ jobs:
"lighthouse@${LIGHTHOUSE_VERSION}"
npx --no-install playwright install --with-deps chromium

- name: Validate embed source pages
run: python scripts/check_embed_sources.py

- name: Build site and capture Hugo duration
id: hugo-build
run: |
Expand Down
75 changes: 75 additions & 0 deletions scripts/check_embed_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Validate that every embed page's source_page resolves to an existing data page.

A `content/embeds/*` page renders a chart from another page referenced by its
`source_page` front matter. If that target page is missing, the Hugo build aborts
(`layouts/embeds/single.html` and `layouts/shortcodes/observatory-chart.html`
call `errorf`). This check catches a dangling reference before the build, so the
failure surfaces in CI rather than in the production deploy (see issue #627).
"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent

SOURCE_PAGE_PATTERN = re.compile(
r"""^\s*source_page\s*[:=]\s*["']?(?P<value>[^"'\n]+?)["']?\s*$""",
re.MULTILINE,
)


def _source_page(document: str) -> str | None:
match = SOURCE_PAGE_PATTERN.search(document)
return match.group("value").strip() if match else None


def _resolves(content_root: Path, source_page: str) -> bool:
relative = source_page.strip("/")
# NFR-012: an embed's source_page must be a data page under content/data/.
if relative != "data" and not relative.startswith("data/"):
return False
target = content_root / relative
return (target / "index.md").is_file() or (target / "_index.md").is_file()


def check_embed_sources(root: Path = ROOT) -> list[str]:
"""Return a list of human-readable problems, empty when all embeds resolve."""
content_root = root / "content"
problems: list[str] = []

for embed in sorted(content_root.glob("embeds/*/index.md")):
source_page = _source_page(embed.read_text(encoding="utf-8"))
relative_embed = embed.relative_to(root).as_posix()
if not source_page:
problems.append(f"{relative_embed}: missing source_page")
continue
if not _resolves(content_root, source_page):
problems.append(
f"{relative_embed}: source_page does not resolve to a content/data page: "
f"{source_page}"
)

return problems


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default=ROOT, type=Path, help="Repository root")
args = parser.parse_args(argv)
Comment thread
jmservera marked this conversation as resolved.

problems = check_embed_sources(Path(args.root))
for problem in problems:
print(f"::error::{problem}", file=sys.stderr)
if problems:
return 1
print("All embed source_page references resolve.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
60 changes: 60 additions & 0 deletions tests/test_embed_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

import tempfile
from pathlib import Path

from scripts.check_embed_sources import check_embed_sources

ROOT = Path(__file__).resolve().parent.parent


def test_repository_embed_sources_all_resolve() -> None:
assert check_embed_sources(ROOT) == []


def _write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")


def test_missing_source_page_is_reported() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_write(root / "content/embeds/chart/index.md", '+++\ntitle = "Chart"\n+++\n')
problems = check_embed_sources(root)
assert any("missing source_page" in problem for problem in problems)


def test_dangling_source_page_is_reported() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_write(
root / "content/embeds/chart/index.md",
'+++\nsource_page = "/data/does-not-exist/"\n+++\n',
)
problems = check_embed_sources(root)
assert any("does not resolve" in problem for problem in problems)


def test_resolving_source_page_passes() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_write(
root / "content/embeds/chart/index.md",
'+++\nsource_page = "/data/live/"\n+++\n',
)
_write(root / "content/data/live/index.md", '+++\ntitle = "Live"\n+++\n')
assert check_embed_sources(root) == []


def test_non_data_source_page_is_reported() -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
_write(
root / "content/embeds/chart/index.md",
'+++\nsource_page = "/weekly/2026/w22/"\n+++\n',
)
# The referenced page exists, but it is not a content/data page.
_write(root / "content/weekly/2026/w22/index.md", '+++\ntitle = "W22"\n+++\n')
problems = check_embed_sources(root)
assert any("does not resolve to a content/data page" in problem for problem in problems)