Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/docs-quality.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
231 changes: 231 additions & 0 deletions scripts/check_docs.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading