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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 35 additions & 12 deletions scripts/check_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand All @@ -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"):
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions tests/test_check_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<link rel="stylesheet" href="/assets/site.css">'
'<a href="/">Root</a>'
'<a href="/ffmpegfyMe/guide/">Guide</a>'
),
)
self.write_page("assets/site.css", "body {}")
self.write_page(
"guide/index.html",
'<!doctype html><html lang="en"><head><title>Guide</title></head>'
'<body><main><h1>Guide</h1></main></body></html>',
)

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('<a href="missing/">Missing</a>'))

Expand Down Expand Up @@ -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(
'<label for="other">Region <input id="region" type="text"></label>'
),
)

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('<label>Region <input type="text"></label>'),
)

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(
'<label>Region <input type="text"><input type="text"></label>'
),
)

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(
'<label for="region">Region</label><input id="region" type="text">'
),
)

self.assertEqual(check_site(self.site_dir), [])

def test_rejects_control_adjacent_to_label(self) -> None:
self.write_page(
"index.html",
self.accessible_page('<label>Region</label><input type="text">'),
)

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('<a href="../../outside.html">Outside</a>'))

Expand Down
Loading