1212
1313from __future__ import annotations
1414
15+ import re
1516from pathlib import Path
1617
1718# Both scripts live in this directory, which Python puts on sys.path[0] when
2122
2223ROOT = Path (__file__ ).parent .parent .parent
2324
25+ # A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not
26+ # a page path (same classifier as llms_txt.py; a `://` test would misread
27+ # scheme-only URIs as pages).
28+ _EXTERNAL = re .compile (r"[a-zA-Z][a-zA-Z0-9+.-]*:" )
2429
25- def _missing_nav_pages (nav : list , docs_dir : Path ) -> list [str ]:
26- """Collect nav page references that don't exist under the docs dir.
2730
28- Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken
29- link without any diagnostic, even under --strict — MkDocs aborted the
30- build. Validating here keeps that guarantee: the concrete config never
31- leaves this script referencing a page that isn't there.
32- """
33- missing : list [str ] = []
31+ def _nav_pages (nav : list ) -> set [str ]:
32+ """Collect every local page reference in the nav (external links excluded)."""
33+ pages : set [str ] = set ()
3434 for entry in nav :
3535 value = next (iter (entry .values ())) if isinstance (entry , dict ) else entry
3636 if isinstance (value , list ):
37- missing .extend (_missing_nav_pages (value , docs_dir ))
38- elif "://" not in value and not (docs_dir / value ).is_file ():
39- missing .append (value )
40- return missing
37+ pages |= _nav_pages (value )
38+ elif not _EXTERNAL .match (value ):
39+ pages .add (value )
40+ return pages
41+
42+
43+ def _validate_nav (nav : list , docs_dir : Path ) -> None :
44+ """Fail on nav/page drift in either direction.
45+
46+ Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken
47+ link without any diagnostic even under --strict, and publishes a page
48+ that no nav entry reaches as unreachable orphan HTML; MkDocs aborted the
49+ build on both (--strict with `validation.omitted_files: warn`).
50+ Validating here keeps those guarantees. The generated `api/` tree is
51+ exempt from the orphan check: its nav is spliced in from the same
52+ generator that writes the files, so it cannot drift.
53+ """
54+ pages = _nav_pages (nav )
55+ if missing := sorted (page for page in pages if not (docs_dir / page ).is_file ()):
56+ raise SystemExit (f"build_config: nav references pages that don't exist under docs/: { missing } " )
57+ # Dot-directories (e.g. `.overrides` theme files) are not pages: the site
58+ # builder ignores them, so the orphan check must too.
59+ relative = (page .relative_to (docs_dir ) for page in docs_dir .rglob ("*.md" ))
60+ on_disk = {page .as_posix () for page in relative if not any (part .startswith ("." ) for part in page .parts )}
61+ if orphaned := sorted (page for page in on_disk - pages if not page .startswith ("api/" )):
62+ raise SystemExit (f"build_config: pages under docs/ that no nav entry reaches: { orphaned } " )
4163
4264
4365def build_config () -> None :
@@ -53,8 +75,7 @@ def build_config() -> None:
5375 else :
5476 raise SystemExit ("build_config: no 'API Reference' entry found in mkdocs.yml nav" )
5577
56- if missing := _missing_nav_pages (config ["nav" ], ROOT / "docs" ):
57- raise SystemExit (f"build_config: nav references pages that don't exist under docs/: { missing } " )
78+ _validate_nav (config ["nav" ], ROOT / "docs" )
5879
5980 output = ROOT / "mkdocs.gen.yml"
6081 output .write_text (yaml .safe_dump (config , sort_keys = False , allow_unicode = True ), encoding = "utf-8" )
0 commit comments