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