Source browser

scripts/check_docs.py

Read the complete file below or download its original bytes. Line numbers provide stable links to a declaration or processing step.

File fingerprint
1#!/usr/bin/env python3
2"""Check the static documentation directory.
3
4This check follows local links, checks fragment targets and verifies document
5structure using the Python standard library. Browser review covers visual
6presentation and interactive accessibility.
7"""
8
9import argparse  # Read an optional documentation directory.
10from html.parser import HTMLParser  # Inspect links and document structure with the standard library.
11from pathlib import Path  # Resolve the published files and their relative links.
12from urllib.parse import unquote, urlsplit  # Separate local paths, fragments and external URLs.
13
14
15ROOT = Path(__file__).resolve().parents[1]  # Locate the source checkout containing this tool.
16
17
18class Page(HTMLParser):  # Collect the parts needed for structural and link checks.
19    def __init__(self, path):  # Start a fresh parser for one published page.
20        super().__init__(convert_charrefs=True)  # Decode HTML entities in attribute values.
21        self.path = path  # Retain the file for helpful error messages.
22        self.ids = set()  # Record valid fragment destinations on this page.
23        self.links = []  # Record links and embedded assets with their line numbers.
24        self.labels = []  # Record ARIA references to elements on the same page.
25        self.tags = set()  # Track the structural elements present in the document.
26        self.errors = []  # Collect failures across the complete site.
27        self.has_language = False  # Require an explicit document language.
28        self.has_viewport = False  # Require responsive sizing on narrow screens.
29
30    def handle_starttag(self, tag, attributes):  # Inspect an opening tag and its attributes.
31        attributes = dict(attributes)  # Make optional attribute lookups clear.
32        line = self.getpos()[0]  # Retain the source line for diagnostics.
33        self.tags.add(tag)  # Remember this document element.
34        identifier = attributes.get("id")  # Read any fragment destination.
35        if identifier:  # Check named destinations when a tag has an ID.
36            if identifier in self.ids:  # Reject ambiguous fragment destinations.
37                self.errors.append((line, "duplicate id " + identifier))  # Explain the conflicting destination.
38            self.ids.add(identifier)  # Retain the destination for incoming links.
39        if tag == "html":  # Read the language from the document root.
40            self.has_language = bool(attributes.get("lang"))  # Require a nonempty language tag.
41        if tag == "meta" and attributes.get("name", "").lower() == "viewport":  # Recognize responsive layout metadata.
42            self.has_viewport = True  # Mark this page as ready for mobile sizing.
43        if tag == "img" and "alt" not in attributes:  # Require text alternatives for all image elements.
44            self.errors.append((line, "image has no alt attribute"))  # Empty alt remains valid for decorative images.
45        for attribute in ("href", "src", "poster"):  # Follow both navigation links and displayed assets.
46            if attributes.get(attribute):  # Ignore absent and empty optional URLs.
47                self.links.append((line, attributes[attribute]))  # Retain the exact URL to check later.
48        for attribute in ("aria-labelledby", "aria-describedby"):  # Check references used by assistive technology.
49            for label in attributes.get(attribute, "").split():  # One attribute may name several local IDs.
50                self.labels.append((line, label))  # Retain each referenced label.
51
52    handle_startendtag = handle_starttag  # Inspect self-closing elements in the same way.
53
54
55def check(site):  # Validate the complete site and return all helpful diagnostics.
56    pages = {}  # Parse every page before resolving links between them.
57    errors = []  # Keep failures from all files together.
58    for path in sorted(site.rglob("*.html")):  # Visit every published HTML page in a stable order.
59        page = Page(path)  # Create an independent parser for this document.
60        page.feed(path.read_text(encoding="utf-8"))  # Inspect UTF-8 content with the standard HTML parser.
61        page.close()  # Finish parsing any remaining text.
62        pages[path.resolve()] = page  # Allow later fragment checks to find this page.
63        for tag in ("html", "head", "title", "body", "main", "h1"):  # Require a usable page title and main content landmark.
64            if tag not in page.tags:  # Identify a missing structural element.
65                page.errors.append((1, "missing " + tag + " element"))  # Describe the missing page structure.
66        if not page.has_language:  # Ensure screen readers can choose a document language.
67            page.errors.append((1, "missing document language"))  # Explain the missing language metadata.
68        if not page.has_viewport:  # Ensure the responsive stylesheet can size the page correctly.
69            page.errors.append((1, "missing viewport metadata"))  # Explain the missing mobile metadata.
70    for path, page in pages.items():  # Resolve links after all fragment destinations are known.
71        for line, label in page.labels:  # Check each accessibility label reference.
72            if label not in page.ids:  # Require the label to exist on this page.
73                page.errors.append((line, "missing ARIA label target " + label))  # Explain the broken accessibility reference.
74        for line, url in page.links:  # Inspect every navigation link and local asset.
75            parts = urlsplit(url)  # Separate the path from a fragment or query.
76            if parts.scheme or parts.netloc:  # External references are outside this offline check.
77                if parts.scheme.lower() == "javascript":  # Require ordinary navigation URLs.
78                    page.errors.append((line, "javascript URL in document navigation"))  # Request a normal link or button.
79                continue  # Keep external links as recorded references.
80            if parts.path.startswith("/"):  # Root paths fail when Pages serves a project below its repository name.
81                page.errors.append((line, "root-relative URL " + url))  # Identify links that escape the published directory.
82                continue  # Avoid interpreting the URL as an operating-system path.
83            target = (path.parent / unquote(parts.path)).resolve() if parts.path else path  # Resolve a local relative destination.
84            if target.is_dir():  # Match ordinary static-server directory navigation.
85                target = target / "index.html"  # Resolve the directory's entry document.
86            if target != site and site not in target.parents:  # Keep all published dependencies inside docs.
87                page.errors.append((line, "URL leaves the published directory: " + url))  # Explain the missing Pages dependency.
88            elif not target.is_file():  # Require every local page and asset to be present.
89                page.errors.append((line, "missing local target " + url))  # Identify the broken link or asset.
90            elif parts.fragment and target in pages:  # Check fragment IDs in linked HTML documents.
91                fragment = unquote(parts.fragment)  # Decode any URL-escaped fragment characters.
92                if fragment not in pages[target].ids:  # Require a matching destination ID.
93                    page.errors.append((line, "missing fragment " + url))  # Identify the broken section link.
94        errors.extend((path, line, message) for line, message in page.errors)  # Keep the filename beside each diagnostic.
95    if not (site / "index.html").is_file():  # Require the entry file expected by GitHub Pages.
96        errors.append((site, 1, "missing index.html entry page"))  # Require a landing page at the site root.
97    return pages, errors  # Return parsed pages for a summary and all detected problems.
98
99
100def main():  # Expose the validation as a small command-line tool.
101    parser = argparse.ArgumentParser(description=__doc__)  # Explain the offline validation behavior.
102    parser.add_argument("--site", type=Path, default=ROOT / "docs")  # Allow checking a copied site before deployment.
103    options = parser.parse_args()  # Read the selected published directory.
104    site = options.site.resolve()  # Compare canonical local paths consistently.
105    pages, errors = check(site)  # Read the complete site and report structural findings.
106    for path, line, message in errors:  # Print all failures in an editor-friendly format.
107        print("{}:{}: {}".format(path.relative_to(site) if site in path.parents else path, line, message))  # Show one precise problem per line.
108    if errors:  # Report failure to CMake or a future continuous-integration job.
109        raise SystemExit(1)  # Keep unsuccessful publication checks distinct from a valid site.
110    print("Checked {} HTML pages and their local links and assets".format(len(pages)))  # Summarize the completed structural check.
111
112
113if __name__ == "__main__":  # Run only when invoked as a command.
114    main()  # Validate the requested documentation directory.