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.