diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml new file mode 100644 index 0000000..de2532e --- /dev/null +++ b/.github/workflows/docs-quality.yml @@ -0,0 +1,51 @@ +name: Docs quality + +on: + pull_request: + branches: + - main + push: + branches: + - main + paths: + - "site-docs/**" + - "zensical.toml" + - "requirements-docs.txt" + - "scripts/check_docs.py" + - "tests/test_check_docs.py" + - "Taskfile.yml" + - "CONTRIBUTING.md" + - ".github/workflows/docs-quality.yml" + - ".github/workflows/deploy-docs.yml" + +permissions: + contents: read + +concurrency: + group: docs-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + docs_quality: + name: Docs quality + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.11" + + - name: Install docs dependencies + run: python -m pip install -r requirements-docs.txt + + - name: Test documentation checker + run: python -m unittest tests.test_check_docs -v + + - name: Build site + run: zensical build --strict + + - name: Check generated documentation + run: python scripts/check_docs.py site diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b352063..4988af9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,17 @@ coverage. Issue #32 defines the target scope. - Watch the required CI checks on the pull request and run the current CI checks before requesting review. +## Documentation checks + +The public site uses the pinned Zensical dependency. Create or reuse `.venv`, then install the docs dependency and run the same checks as CI: + +```sh +.venv/bin/python -m pip install -r requirements-docs.txt +PATH=".venv/bin:$PATH" task docs:check +``` + +The generated-site checker validates local links and basic accessibility from files on disk. It makes no external network requests and needs no deployment credentials. + ## Getting help - See [SUPPORT.md](SUPPORT.md) for how to ask questions and get help. diff --git a/Taskfile.yml b/Taskfile.yml index 8fa538d..bd0d473 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -21,3 +21,10 @@ tasks: desc: 🎬 Run the ffmpegfyMe interactive downloader cmds: - docker compose run --rm ffmpegfy-me + + docs:check: + desc: 📚 Build and check public documentation + cmds: + - zensical build --strict + - python -m unittest tests.test_check_docs -v + - python scripts/check_docs.py site diff --git a/scripts/check_docs.py b/scripts/check_docs.py new file mode 100644 index 0000000..8907205 --- /dev/null +++ b/scripts/check_docs.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Check generated documentation links and basic accessibility without network access.""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +class PageParser(HTMLParser): + def __init__(self, relative_path: Path) -> None: + super().__init__(convert_charrefs=True) + self.relative_path = relative_path + self.errors: list[str] = [] + self.ids: set[str] = set() + self.links: list[tuple[int, str, str]] = [] + self.language = "" + self.title_parts: list[str] = [] + self.title_depth = 0 + self.h1_count = 0 + self.main_count = 0 + self.labelled_ids: set[str] = set() + self.controls: list[tuple[int, dict[str, str]]] = [] + self.label_depth = 0 + self.interactive: list[dict[str, object]] = [] + + def handle_starttag(self, tag: str, attributes: list[tuple[str, str | None]]) -> None: + attrs = {name: value or "" for name, value in attributes} + line = self.getpos()[0] + + if tag == "html": + self.language = attrs.get("lang", "").strip() + if tag == "title": + self.title_depth += 1 + if tag == "h1": + self.h1_count += 1 + if tag == "main": + self.main_count += 1 + if attrs.get("id"): + self.ids.add(attrs["id"]) + if tag == "a" and attrs.get("name"): + self.ids.add(attrs["name"]) + + if tag == "label": + self.label_depth += 1 + if attrs.get("for"): + self.labelled_ids.add(attrs["for"]) + if self.label_depth and tag in {"input", "select", "textarea"} and attrs.get("id"): + self.labelled_ids.add(attrs["id"]) + + if tag == "img" and "alt" not in attrs: + self.errors.append(f"{self.relative_path}:{line}: image is missing alt text") + + if tag in {"input", "select", "textarea"}: + hidden = "hidden" in attrs or attrs.get("aria-hidden", "").lower() == "true" + hidden_input = tag == "input" and attrs.get("type", "text").lower() == "hidden" + if not hidden and not hidden_input: + self.controls.append((line, attrs)) + + if tag in {"a", "link", "script", "img"}: + for attribute in ("href", "src"): + value = attrs.get(attribute, "").strip() + if value: + self.links.append((line, attribute, value)) + + generated_code_anchor = tag == "a" and attrs.get("id", "").startswith("__codelineno-") + is_interactive = tag == "button" or (tag == "a" and bool(attrs.get("href")) and not generated_code_anchor) + if is_interactive: + self.interactive.append( + { + "tag": tag, + "line": line, + "named": bool(attrs.get("aria-label", "").strip() or attrs.get("aria-labelledby", "").strip() or attrs.get("title", "").strip()), + "text": [], + } + ) + if tag == "img" and attrs.get("alt"): + for item in self.interactive: + item["text"].append(attrs["alt"]) + + def handle_endtag(self, tag: str) -> None: + if tag == "title" and self.title_depth: + self.title_depth -= 1 + if tag == "label" and self.label_depth: + self.label_depth -= 1 + if tag in {"a", "button"}: + for index in range(len(self.interactive) - 1, -1, -1): + if self.interactive[index]["tag"] == tag: + item = self.interactive.pop(index) + label = "".join(item["text"]).strip() + if not item["named"] and not label: + self.errors.append( + f"{self.relative_path}:{item['line']}: {tag} has no accessible name" + ) + break + + def handle_data(self, data: str) -> None: + if self.title_depth: + self.title_parts.append(data) + for item in self.interactive: + item["text"].append(data) + + def finish(self) -> list[str]: + if not self.language: + self.errors.append(f"{self.relative_path}: document is missing an html lang attribute") + if not "".join(self.title_parts).strip(): + self.errors.append(f"{self.relative_path}: document is missing a non-empty title") + if not self.h1_count: + self.errors.append(f"{self.relative_path}: document is missing an h1 heading") + if not self.main_count: + self.errors.append(f"{self.relative_path}: document is missing a main landmark") + + for line, attrs in self.controls: + if not any(attrs.get(name, "").strip() for name in ("aria-label", "aria-labelledby")): + control_id = attrs.get("id", "") + if not control_id or control_id not in self.labelled_ids: + tag = attrs.get("type", "control") if "type" in attrs else "control" + self.errors.append( + f"{self.relative_path}:{line}: {tag} control has no accessible label" + ) + return self.errors + + +def _resolve_target(root: Path, page: Path, value: str, base_path: str) -> tuple[Path | None, str]: + parsed = urlsplit(value) + if parsed.scheme or parsed.netloc: + return None, "" + + path = unquote(parsed.path) + if not path: + target = root / page + elif path.startswith("/"): + site_path = path.lstrip("/") + prefix = base_path.strip("/") + if prefix and (site_path == prefix or site_path.startswith(prefix + "/")): + site_path = site_path[len(prefix):].lstrip("/") + target = root / site_path + else: + target = root / page.parent / path + + target = target.resolve() + root = root.resolve() + try: + target.relative_to(root) + except ValueError: + return target, unquote(parsed.fragment) + + if target.is_dir(): + target = target / "index.html" + elif not target.exists() and not target.suffix: + target = target / "index.html" + return target, unquote(parsed.fragment) + + +def check_site(site_dir: Path, base_path: str = "") -> list[str]: + root = site_dir.resolve() + if not root.is_dir(): + return [f"site directory does not exist: {site_dir}"] + + html_files = sorted(root.rglob("*.html")) + if not html_files: + return [f"no generated HTML pages found in {site_dir}"] + + scans: dict[Path, PageParser] = {} + errors: list[str] = [] + for html_file in html_files: + relative_path = html_file.relative_to(root) + parser = PageParser(relative_path) + parser.feed(html_file.read_text(encoding="utf-8")) + scans[relative_path] = parser + errors.extend(parser.finish()) + + for relative_path, parser in scans.items(): + for line, attribute, value in parser.links: + target, fragment = _resolve_target(root, relative_path, value, base_path) + if target is None: + continue + try: + target_relative = target.relative_to(root) + except ValueError: + errors.append(f"{relative_path}:{line}: internal {attribute} target escapes the generated site: {value}") + continue + if not target.is_file(): + errors.append(f"{relative_path}:{line}: internal {attribute} target does not exist: {value}") + continue + if fragment and target.suffix.lower() == ".html": + target_scan = scans.get(target_relative) + if target_scan is None: + target_scan = PageParser(target_relative) + target_scan.feed(target.read_text(encoding="utf-8")) + scans[target_relative] = target_scan + if fragment not in target_scan.ids: + errors.append(f"{relative_path}:{line}: internal fragment does not exist: {value}") + + return sorted(errors) + + +def _configured_base_path() -> str: + config_path = Path(__file__).resolve().parents[1] / "zensical.toml" + try: + project = tomllib.loads(config_path.read_text(encoding="utf-8"))["project"] + return urlsplit(project.get("site_url", "")).path.rstrip("/") + except (OSError, KeyError, tomllib.TOMLDecodeError): + return "" + + +def main() -> int: + argument_parser = argparse.ArgumentParser(description=__doc__) + argument_parser.add_argument("site_dir", nargs="?", type=Path, default=Path("site")) + argument_parser.add_argument("--base-path", default=None) + args = argument_parser.parse_args() + + base_path = args.base_path if args.base_path is not None else _configured_base_path() + errors = check_site(args.site_dir, base_path) + if errors: + print("Documentation quality checks failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + page_count = sum(1 for _ in args.site_dir.rglob("*.html")) + print(f"Documentation quality checks passed: {page_count} HTML pages, local links, and basic accessibility.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_docs.py b/tests/test_check_docs.py new file mode 100644 index 0000000..75982df --- /dev/null +++ b/tests/test_check_docs.py @@ -0,0 +1,116 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts.check_docs import check_site + + +class CheckDocsTests(unittest.TestCase): + def setUp(self) -> None: + self._temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self._temporary_directory.cleanup) + self.site_dir = Path(self._temporary_directory.name) + + def write_page(self, relative_path: str, content: str) -> None: + path = self.site_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + @staticmethod + def accessible_page(body: str) -> str: + return ( + 'Example' + f"

Example

{body}
" + ) + + def test_accepts_valid_local_links_and_external_links(self) -> None: + self.write_page( + "index.html", + self.accessible_page( + 'Guide' + 'External' + '' + '' + '' + '' + '' + ), + ) + self.write_page( + "guide/index.html", + 'Guide' + '

Guide

', + ) + + self.assertEqual(check_site(self.site_dir), []) + + def test_rejects_missing_internal_target(self) -> None: + self.write_page("index.html", self.accessible_page('Missing')) + + errors = check_site(self.site_dir) + + self.assertTrue(any("internal href target does not exist" in error for error in errors)) + + def test_rejects_unnamed_navigation_link(self) -> None: + self.write_page("index.html", self.accessible_page(' ')) + self.write_page( + "guide/index.html", + 'Guide' + '

Guide

', + ) + + errors = check_site(self.site_dir) + + self.assertTrue(any("a has no accessible name" in error for error in errors)) + + def test_rejects_missing_fragment(self) -> None: + self.write_page("index.html", self.accessible_page('Guide')) + self.write_page( + "guide/index.html", + 'Guide' + '

Guide

', + ) + + errors = check_site(self.site_dir) + + self.assertTrue(any("internal fragment does not exist" in error for error in errors)) + + def test_rejects_basic_accessibility_failures(self) -> None: + self.write_page( + "index.html", + '

Example

' + '' + '
', + ) + + errors = check_site(self.site_dir) + + self.assertTrue(any("html lang" in error for error in errors)) + self.assertTrue(any("non-empty title" in error for error in errors)) + self.assertTrue(any("missing alt text" in error for error in errors)) + self.assertTrue(any("button has no accessible name" in error for error in errors)) + self.assertTrue(any("control has no accessible label" in error for error in errors)) + + def test_rejects_paths_that_escape_site_directory(self) -> None: + self.write_page("index.html", self.accessible_page('Outside')) + + errors = check_site(self.site_dir) + + self.assertTrue(any("escapes the generated site" in error for error in errors)) + + def test_rejects_missing_required_page_structure(self) -> None: + self.write_page("index.html", 'Example') + + errors = check_site(self.site_dir) + + self.assertTrue(any("missing an h1 heading" in error for error in errors)) + self.assertTrue(any("missing a main landmark" in error for error in errors)) + + def test_rejects_missing_site_directory(self) -> None: + missing = self.site_dir / "missing" + + self.assertTrue(any("site directory does not exist" in error for error in check_site(missing))) + + +if __name__ == "__main__": + unittest.main()