Files
ThothII/scripts/verify-public-docs.py
T
2026-09-15 14:37:29 +02:00

111 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Verify the public manual boundary, including generated pages and search."""
import argparse
import json
from pathlib import Path
from urllib.parse import unquote, urlsplit
import yaml
PUBLIC_ASSETS = {
"stylesheets/extra.css",
"javascripts/layout-init.js",
"install/examples/thothii-installation.local.yaml",
"install/examples/thothii-installation.server.yaml",
"install/examples/workspace-bindings.env.example",
}
INTERNAL_DIRS = {
"adr", "agents", "architecture", "contracts", "maintenance", "plans",
"reports", "research", "testing",
}
INTERNAL_PAGES = {
"disambiguazione-iniziale.md", "gestione-memory.md", "installazione-docker-4-contesti.md",
"install/authentication-upstream.md", "operations/docker-refresh.md",
"operations/shell-and-localization.md",
"operations/compose-reference.md", "operations/public-docs-publication.md",
}
def nav_pages(value):
if isinstance(value, str):
yield value
elif isinstance(value, list):
for item in value:
yield from nav_pages(item)
elif isinstance(value, dict):
for item in value.values():
yield from nav_pages(item)
def output_path(source):
path = Path(source)
if path.suffix != ".md":
return path
return path.with_suffix("") / "index.html" if path.name != "index.md" else path.with_suffix(".html")
def check(root, site):
# BaseLoader reads configuration without executing custom Python YAML tags.
config = yaml.load((root / "mkdocs.yml").read_text(), Loader=yaml.BaseLoader)
pages = list(nav_pages(config["nav"]))
if len(pages) != len(set(pages)):
raise ValueError("duplicate public navigation page")
for page in pages:
path = Path(page)
if path.is_absolute() or ".." in path.parts or path.suffix != ".md":
raise ValueError(f"invalid public page: {page}")
if (path.parts[0] in INTERNAL_DIRS or page in INTERNAL_PAGES
or page.startswith("operations/server-")):
raise ValueError(f"internal page in public navigation: {page}")
if not (root / "docs" / page).is_file():
raise ValueError(f"missing public source: {page}")
rules = config.get("exclude_docs", "").splitlines()
if not rules or rules[0] != "*" or any(not r.startswith("!/") for r in rules[1:]):
raise ValueError("public docs must use deny-by-default exclusions and explicit exceptions")
published = [r[2:] for r in rules[1:]]
if len(published) != len(set(published)) or set(published) != set(pages) | PUBLIC_ASSETS:
raise ValueError("publication exceptions must match nav pages and approved assets exactly")
for source in published:
if not (root / "docs" / source).is_file():
raise ValueError(f"missing public source: {source}")
if not (site / output_path(source)).is_file():
raise ValueError(f"missing generated public file: {source}")
for source in (root / "docs").rglob("*"):
if not source.is_file():
continue
relative = source.relative_to(root / "docs").as_posix()
if relative not in published and (site / output_path(relative)).exists():
raise ValueError(f"internal file leaked into site: {relative}")
expected_urls = {
"" if page == "index.md" else str(output_path(page).parent).replace("\\", "/") + "/"
for page in pages
}
search = json.loads((site / "search/search_index.json").read_text())
indexed = set()
for entry in search["docs"]:
location = unquote(urlsplit(entry["location"]).path)
if location not in expected_urls:
raise ValueError(f"non-public page in search index: {location}")
indexed.add(location)
if indexed != expected_urls:
raise ValueError("search index does not cover exactly the public pages")
return len(pages)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--site-dir", type=Path)
args = parser.parse_args()
root = args.root.resolve()
try:
count = check(root, args.site_dir or root / "site")
except (ValueError, KeyError, OSError, yaml.YAMLError) as error:
raise SystemExit(f"public docs verification failed: {error}") from error
print(f"Public documentation boundary passed: {count} pages; internal files and search excluded")