From 77358111e6cba0c80d7a2303c592a9946df8e60b Mon Sep 17 00:00:00 2001 From: Brendan Smith Date: Tue, 1 Sep 2026 10:26:39 -0400 Subject: [PATCH] Harden docs build after Zensical migration Generate CLI reference pages when missing rather than shipping dead nav links, fail the build on broken links, and check the explicit nav covers every page. - Make build/run depend on src/command-line-reference. The directory is gitignored, so a fresh clone built 84 nav entries as raw .md hrefs against pages that were never generated, with no warnings, exit 0. - Add --strict to zensical build so broken links and anchors fail instead of warning and exiting 0. - Add scripts/check_nav_complete.py and wire it into CI. Zensical does not validate nav, and the literate-nav globs that used to pick up new pages are gone. - Use uv run python for generate_cli_nav.py, add encoding="utf-8", note that Zensical ignores unknown plugins, ignore __pycache__. --- .github/workflows/ci_cd.yml | 2 + .gitignore | 1 + Makefile | 23 ++++++++-- mkdocs.yml | 5 ++ scripts/check_nav_complete.py | 86 +++++++++++++++++++++++++++++++++++ scripts/generate_cli_nav.py | 4 +- 6 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 scripts/check_nav_complete.py diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index b6441b4..def750b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -28,6 +28,8 @@ jobs: - name: CLI docs run: make cli-docs + - name: Check nav is complete + run: make check-nav - name: Build run: APPPACK_VERSION=$(apppack version) make build - uses: actions/upload-artifact@v5 diff --git a/.gitignore b/.gitignore index 4da52df..26d5ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /public/ /src/command-line-reference/ +__pycache__/ diff --git a/Makefile b/Makefile index 32783d4..680753c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,15 @@ +CLI_DOCS_DIR := src/command-line-reference + .PHONY: build -build: - SITE_URL=https://docs.apppack.io/ uv run zensical build +build: $(CLI_DOCS_DIR) + SITE_URL=https://docs.apppack.io/ uv run zensical build --strict + +# The CLI reference pages are gitignored, so a fresh clone has none. Generate +# them when the directory is missing, but leave an existing one alone -- CI runs +# `make cli-docs` explicitly and this avoids a second docgen there. Run +# `make cli-docs` to force a refresh against a newer CLI. +$(CLI_DOCS_DIR): + $(MAKE) cli-docs .PHONY: deploy deploy: @@ -14,9 +23,13 @@ clean: .PHONY: cli-docs cli-docs: - apppack docgen --directory src/command-line-reference - python3 scripts/generate_cli_nav.py + apppack docgen --directory $(CLI_DOCS_DIR) + uv run python scripts/generate_cli_nav.py + +.PHONY: check-nav +check-nav: + uv run python scripts/check_nav_complete.py .PHONY: run -run: +run: $(CLI_DOCS_DIR) uv run zensical serve diff --git a/mkdocs.yml b/mkdocs.yml index efb589c..87decdf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,11 @@ markdown_extensions: emoji_index: !!python/name:zensical.extensions.emoji.twemoji emoji_generator: !!python/name:zensical.extensions.emoji.to_svg - tables +# Zensical silently ignores plugins it does not implement -- it collects the +# names without validating them, so an unsupported plugin is a no-op rather +# than an error. That is how mkdocs-literate-nav failed quietly during the +# migration (the nav fell back to auto-generation). When upgrading Zensical, +# expect "the plugin does nothing" as the failure mode, not a build error. plugins: - search - macros diff --git a/scripts/check_nav_complete.py b/scripts/check_nav_complete.py new file mode 100644 index 0000000..9ff83cd --- /dev/null +++ b/scripts/check_nav_complete.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Verify every page under src/ is reachable from the nav in mkdocs.yml. + +Zensical builds pages that are missing from `nav:` without complaining: they +get a URL and land in sitemap.xml and the search index, but nothing links to +them. Its config module is explicit that this will not be validated upstream +for now ("we only support validation of links right now, as navigation will +change significantly"), and the literate-nav directory globs that used to pick +up new pages automatically are gone, so we check it here instead. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent +CONFIG = ROOT / "mkdocs.yml" +DOCS = ROOT / "src" + +# mkdocs.yml carries `!!python/name:` tags, which a plain YAML safe-load +# rejects, so pull the nav block out textually instead of parsing the file. +NAV_START = re.compile(r"^nav:\s*$") +TOP_LEVEL_KEY = re.compile(r"^[^\s#]") +MD_PATH = re.compile(r"[\w./-]+\.md") + + +def nav_pages() -> set[str]: + lines = CONFIG.read_text(encoding="utf-8").splitlines() + try: + start = next(i for i, line in enumerate(lines) if NAV_START.match(line)) + except StopIteration: + sys.exit(f"No `nav:` block found in {CONFIG}") + + pages = set() + for line in lines[start + 1 :]: + # The nav block runs until the next top-level key. + if TOP_LEVEL_KEY.match(line): + break + pages.update(MD_PATH.findall(line)) + return pages + + +def main() -> int: + referenced = nav_pages() + if not referenced: + print(f"No pages referenced in the `nav:` block of {CONFIG}", file=sys.stderr) + return 1 + + on_disk = {str(p.relative_to(DOCS)) for p in DOCS.rglob("*.md")} + missing = sorted(on_disk - referenced) + if missing: + print( + f"{len(missing)} page(s) exist under {DOCS.relative_to(ROOT)}/ but are " + f"not in the `nav:` block of {CONFIG.name}, so nothing links to them:", + file=sys.stderr, + ) + for page in missing: + print(f" - {page}", file=sys.stderr) + print( + "\nAdd them to `nav:` in mkdocs.yml. Command line reference pages are " + "generated -- run `make cli-docs` instead of adding them by hand.", + file=sys.stderr, + ) + return 1 + + stale = sorted(referenced - on_disk) + if stale: + print( + f"{len(stale)} page(s) are in the `nav:` block of {CONFIG.name} but do " + f"not exist on disk, so the nav links to them are dead:", + file=sys.stderr, + ) + for page in stale: + print(f" - {page}", file=sys.stderr) + print( + "\nIf these are command line reference pages, run `make cli-docs`.", + file=sys.stderr, + ) + return 1 + + print(f"All {len(on_disk)} pages under src/ are referenced in the nav.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_cli_nav.py b/scripts/generate_cli_nav.py index 3ff4113..9ba46a2 100644 --- a/scripts/generate_cli_nav.py +++ b/scripts/generate_cli_nav.py @@ -23,7 +23,7 @@ def main() -> int: print(f"No pages found in {CLI_DOCS}. Run `apppack docgen` first.", file=sys.stderr) return 1 - lines = CONFIG.read_text().splitlines() + lines = CONFIG.read_text(encoding="utf-8").splitlines() try: begin = next(i for i, l in enumerate(lines) if l.strip() == BEGIN) end = next(i for i, l in enumerate(lines) if l.strip() == END) @@ -34,7 +34,7 @@ def main() -> int: indent = " " * (len(lines[begin]) - len(lines[begin].lstrip())) entries = [f"{indent}- command-line-reference/{name}" for name in pages] lines[begin + 1 : end] = entries - CONFIG.write_text("\n".join(lines) + "\n") + CONFIG.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"Wrote {len(pages)} command line reference nav entries to {CONFIG}") return 0