diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d9a81e8..ebb7f43 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -33,6 +33,11 @@ jobs: run: pip install -r requirements-docs.txt - name: Build (strict) run: mkdocs build --strict --site-dir site + - name: Docs site actually contains its documents + # `mkdocs build --strict` succeeded for months while publishing empty + # pages: all nine snippet includes used a `../` path that pymdownx + # refuses, and check_paths:false made the refusal silent. + run: python scripts/check_docs_site.py --site site - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site diff --git a/docs/architecture.md b/docs/architecture.md index a7815ed..a80de5b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1 +1 @@ ---8<-- "../ARCHITECTURE.md" +--8<-- "ARCHITECTURE.md" diff --git a/docs/changelog.md b/docs/changelog.md index 11fa8a5..786b75d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1 +1 @@ ---8<-- "../CHANGELOG.md" +--8<-- "CHANGELOG.md" diff --git a/docs/code-of-conduct.md b/docs/code-of-conduct.md index d468a9d..01f2ea2 100644 --- a/docs/code-of-conduct.md +++ b/docs/code-of-conduct.md @@ -1 +1 @@ ---8<-- "../CODE_OF_CONDUCT.md" +--8<-- "CODE_OF_CONDUCT.md" diff --git a/docs/contributing.md b/docs/contributing.md index 3f2d90d..ea38c9b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1 +1 @@ ---8<-- "../CONTRIBUTING.md" +--8<-- "CONTRIBUTING.md" diff --git a/docs/index.md b/docs/index.md index 1ed80e8..d84327e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,4 +39,4 @@ devrepro doctor # full read-only diagnostic scan - [Interoperability](interop.md) — how we relate to Nix, mise, Devbox, devenv… - [Roadmap](roadmap.md) — where we're going ---8<-- "../README.md" +--8<-- "README.md" diff --git a/docs/interop.md b/docs/interop.md index 7038158..8592436 100644 --- a/docs/interop.md +++ b/docs/interop.md @@ -1 +1 @@ ---8<-- "../INTEROP.md" +--8<-- "INTEROP.md" diff --git a/docs/product-gaps.md b/docs/product-gaps.md index 7d0add4..9a9e8a5 100644 --- a/docs/product-gaps.md +++ b/docs/product-gaps.md @@ -1 +1 @@ ---8<-- "../PRODUCT_GAPS.md" +--8<-- "PRODUCT_GAPS.md" diff --git a/docs/roadmap.md b/docs/roadmap.md index 57aad72..40fbfee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1 +1 @@ ---8<-- "../ROADMAP.md" +--8<-- "ROADMAP.md" diff --git a/docs/security.md b/docs/security.md index b6cb6d8..a8ada70 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1 +1 @@ ---8<-- "../SECURITY.md" +--8<-- "SECURITY.md" diff --git a/mkdocs.yml b/mkdocs.yml index 1ddee8b..3826d11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,8 +52,8 @@ markdown_extensions: - admonition - pymdownx.superfences - pymdownx.snippets: - base_path: docs - check_paths: false + base_path: . + check_paths: true - tables - toc: permalink: true @@ -61,3 +61,18 @@ markdown_extensions: validation: omitted_files: warn absolute_links: warn + links: + # Root documents (README, ARCHITECTURE, CONTRIBUTING...) are included into + # this site by snippet and carry links written relative to the repository + # root, where they are correct and where most people read them. Inside the + # site those targets sit outside the docs tree, so mkdocs reports them as + # not-found and --strict aborts. + # + # Downgraded to info deliberately, and paired with `check_paths: true` + # above, which is the check that actually matters: a snippet that cannot be + # found now fails the build. It previously did not. With check_paths off, + # all nine includes in this site silently resolved to nothing, and the + # published pages for Architecture, Roadmap, Contributing, Security, + # Interop, Product gaps, Changelog and Code of conduct were empty while the + # build stayed green. + not_found: info diff --git a/scripts/check_docs_site.py b/scripts/check_docs_site.py new file mode 100644 index 0000000..f47fb1b --- /dev/null +++ b/scripts/check_docs_site.py @@ -0,0 +1,94 @@ +"""Assert the built docs site actually contains its documents. + +`mkdocs build --strict` passing does not mean the site has content in it. This +project's site was green while publishing empty pages: every one of its nine +`--8<--` snippet includes used a `../` path, which pymdownx refuses because it +escapes `base_path`, and `check_paths: false` made that refusal silent. The +Architecture page shipped with 68 words of navigation chrome and none of +ARCHITECTURE.md. + +A build that succeeds while producing nothing is the same defect this +repository has fixed twice elsewhere -- a CI step named for a check it never +performed. So the site is checked for content, not just for exit status. + + python scripts/check_docs_site.py # build, then verify + python scripts/check_docs_site.py --site DIR # verify an existing build +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +#: A page carrying only nav chrome lands around 60-70 words. Real documents +#: here run to hundreds. 120 sits clear of the chrome and below the shortest +#: genuine page, so it catches an empty include without tuning per page. +MIN_WORDS = 120 + +TAG = re.compile(r"<[^>]+>") + + +def words_in(html: str) -> int: + body = html.split("", 1)[0] + return len(TAG.sub(" ", body).split()) + + +def build(into: Path) -> None: + result = subprocess.run( # noqa: S603 - fixed argv, no shell, no user input + [sys.executable, "-m", "mkdocs", "build", "--strict", "--site-dir", str(into)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print(result.stderr or result.stdout, file=sys.stderr) + raise SystemExit("mkdocs build failed") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--site", type=Path, default=None) + args = parser.parse_args() + + with tempfile.TemporaryDirectory() as tmp: + site = args.site or Path(tmp) / "site" + if args.site is None: + build(site) + + pages = sorted(site.rglob("index.html")) + if not pages: + print("no pages were built", file=sys.stderr) + return 1 + + thin: list[tuple[str, int]] = [] + for page in pages: + name = page.parent.relative_to(site).as_posix() or "(home)" + if name.startswith(("assets", "search")): + continue + count = words_in(page.read_text(encoding="utf-8", errors="replace")) + if count < MIN_WORDS: + thin.append((name, count)) + + if thin: + print( + f"{len(thin)} page(s) built with almost no content -- a snippet " + "include is probably resolving to nothing:", + file=sys.stderr, + ) + for name, count in thin: + print(f" {name}: {count} words", file=sys.stderr) + return 1 + + print(f"ok {len(pages)} pages built, all carrying real content") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())