diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4988af9..15c84f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,9 @@ coverage. Issue #32 defines the target scope. ## 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: +The documentation checks require Python 3.11 or newer because the checker +uses `tomllib`. 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 diff --git a/scripts/check_docs.py b/scripts/check_docs.py index 8907205..7f4407b 100644 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -24,8 +24,8 @@ def __init__(self, relative_path: Path) -> None: 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.controls: list[tuple[int, dict[str, str], bool]] = [] + self.label_stack: list[tuple[str | None, bool]] = [] self.interactive: list[dict[str, object]] = [] def handle_starttag(self, tag: str, attributes: list[tuple[str, str | None]]) -> None: @@ -46,11 +46,10 @@ def handle_starttag(self, tag: str, attributes: list[tuple[str, str | None]]) -> 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"]) + label_for = attrs.get("for") if "for" in attrs else None + self.label_stack.append((label_for, False)) + if label_for: + self.labelled_ids.add(label_for) if tag == "img" and "alt" not in attrs: self.errors.append(f"{self.relative_path}:{line}: image is missing alt text") @@ -59,7 +58,13 @@ def handle_starttag(self, tag: str, attributes: list[tuple[str, str | None]]) -> 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)) + wrapped_by_label = False + if self.label_stack: + label_for, control_claimed = self.label_stack[-1] + if label_for is None and not control_claimed: + wrapped_by_label = True + self.label_stack[-1] = (label_for, True) + self.controls.append((line, attrs, wrapped_by_label)) if tag in {"a", "link", "script", "img"}: for attribute in ("href", "src"): @@ -85,8 +90,8 @@ def handle_starttag(self, tag: str, attributes: list[tuple[str, str | None]]) -> 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 == "label" and self.label_stack: + self.label_stack.pop() if tag in {"a", "button"}: for index in range(len(self.interactive) - 1, -1, -1): if self.interactive[index]["tag"] == tag: @@ -114,8 +119,10 @@ def finish(self) -> list[str]: 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")): + for line, attrs, wrapped_by_label in self.controls: + if not wrapped_by_label and 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" @@ -176,6 +183,22 @@ def check_site(site_dir: Path, base_path: str = "") -> list[str]: for relative_path, parser in scans.items(): for line, attribute, value in parser.links: + parsed = urlsplit(value) + path = unquote(parsed.path) + prefix = base_path.strip("/") + site_path = path.lstrip("/") + if ( + path.startswith("/") + and not parsed.scheme + and not parsed.netloc + and prefix + and site_path != prefix + and not site_path.startswith(prefix + "/") + ): + errors.append( + f"{relative_path}:{line}: internal {attribute} target is outside configured base path: {value}" + ) + continue target, fragment = _resolve_target(root, relative_path, value, base_path) if target is None: continue diff --git a/tests/test_check_docs.py b/tests/test_check_docs.py index 75982df..8242f01 100644 --- a/tests/test_check_docs.py +++ b/tests/test_check_docs.py @@ -44,6 +44,32 @@ def test_accepts_valid_local_links_and_external_links(self) -> None: self.assertEqual(check_site(self.site_dir), []) + def test_rejects_root_relative_links_outside_configured_base_path(self) -> None: + self.write_page( + "index.html", + self.accessible_page( + '' + 'Root' + 'Guide' + ), + ) + self.write_page("assets/site.css", "body {}") + self.write_page( + "guide/index.html", + 'Guide' + '

Guide

', + ) + + errors = check_site(self.site_dir, "/ffmpegfyMe") + + self.assertCountEqual( + errors, + [ + "index.html:1: internal href target is outside configured base path: /", + "index.html:1: internal href target is outside configured base path: /assets/site.css", + ], + ) + def test_rejects_missing_internal_target(self) -> None: self.write_page("index.html", self.accessible_page('Missing')) @@ -91,6 +117,61 @@ def test_rejects_basic_accessibility_failures(self) -> None: 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_control_nested_in_label_with_mismatched_for(self) -> None: + self.write_page( + "index.html", + self.accessible_page( + '' + ), + ) + + errors = check_site(self.site_dir) + + self.assertTrue(any("text control has no accessible label" in error for error in errors)) + + def test_accepts_label_wrapped_control_without_id(self) -> None: + self.write_page( + "index.html", + self.accessible_page(''), + ) + + self.assertEqual(check_site(self.site_dir), []) + + def test_only_first_control_is_implicitly_labelled(self) -> None: + self.write_page( + "index.html", + self.accessible_page( + '' + ), + ) + + errors = check_site(self.site_dir) + + self.assertEqual( + sum("text control has no accessible label" in error for error in errors), + 1, + ) + + def test_accepts_control_with_matching_explicit_label(self) -> None: + self.write_page( + "index.html", + self.accessible_page( + '' + ), + ) + + self.assertEqual(check_site(self.site_dir), []) + + def test_rejects_control_adjacent_to_label(self) -> None: + self.write_page( + "index.html", + self.accessible_page(''), + ) + + errors = check_site(self.site_dir) + + self.assertTrue(any("text 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'))