diff --git a/tools/test_validate_docs.py b/tools/test_validate_docs.py index 960c0b4..a26a678 100644 --- a/tools/test_validate_docs.py +++ b/tools/test_validate_docs.py @@ -229,6 +229,52 @@ def test_plain_markdown_image_and_archbee_directive_both_found(self) -> None: self.assertEqual(urls, ["/files/pics/a.png", "/files/pics/b.png"]) +class RawHtmlParsingTest(unittest.TestCase): + def test_extracts_src_from_html_img_in_table_cell(self) -> None: + # Real shape from docs/general/Controls.md, whose controls reference + # is an Archbee HTML table -- every icon on that page is referenced + # this way and never as Markdown. + line = '

' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0].raw_url, "/files/icons/controls/dpad.svg") + self.assertTrue(refs[0].is_image) + + def test_extracts_src_from_self_closing_html_img(self) -> None: + # Real shape from docs/cpu-software/FlipCTL.md. + line = '' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0].raw_url, "/files/pics/flipctl-in-terminal.png") + + def test_extracts_href_from_html_link(self) -> None: + # Real shape from docs/resources/docs/Markup-reference.md. + line = '

Jump to Tables

' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0].raw_url, "Markup-reference.md") + self.assertFalse(refs[0].is_image) + + def test_single_quoted_attributes_are_extracted(self) -> None: + line = " x" + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual( + sorted(r.raw_url for r in refs), + ["/files/pics/a.png", "/general/Controls.md"], + ) + + def test_several_html_images_on_one_line(self) -> None: + line = '' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual( + sorted(r.raw_url for r in refs), ["/files/pics/a.png", "/files/pics/b.png"] + ) + + def test_other_html_attributes_are_not_mistaken_for_a_reference(self) -> None: + line = '

Task

' + self.assertEqual(extract_link_refs(Path("docs/x.md"), [(1, line)]), []) + + class CheckLinksIntegrationTest(unittest.TestCase): def _write_docs(self, tmp: Path, files: dict[str, str]) -> Path: docs_root = tmp / "docs" @@ -346,6 +392,83 @@ def test_placeholder_path_inside_fence_is_not_flagged(self) -> None: findings = check_links(md_files, docs_root, docs_root.parent) self.assertEqual(findings, []) + def test_missing_html_image_is_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "general/Controls.md": ( + "

\"\"

\n" + ), + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].check, "path") + self.assertIn("/files/icons/controls/dpad.svg", findings[0].message) + + def test_existing_html_image_is_not_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "general/Controls.md": ( + "

\"\"

\n" + ), + "files/icons/controls/dpad.svg": "not-a-real-image-just-a-marker", + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(findings, []) + + def test_missing_html_link_target_is_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + {"resources/docs/Markup-reference.md": 'x\n'}, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].check, "path") + + def test_html_link_fragment_is_anchor_checked(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "general/Controls.md": "### Buttons\n", + "general/Other.md": ( + '

Buttons

\n' + ), + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].check, "anchor") + + def test_html_example_inside_fence_is_not_flagged(self) -> None: + # docs/resources/docs/Markup-reference.md documents raw HTML markup in + # fenced examples; those placeholder paths aren't real references. + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "resources/docs/Markup-reference.md": ( + "Reference an image like this:\n\n" + "```html\n" + '\n' + "```\n" + ), + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(findings, []) + def test_external_url_is_never_checked(self) -> None: with tempfile.TemporaryDirectory() as tmp_str: docs_root = self._write_docs( diff --git a/tools/validate_docs.py b/tools/validate_docs.py index 92941b8..d28515d 100644 --- a/tools/validate_docs.py +++ b/tools/validate_docs.py @@ -12,8 +12,9 @@ target page and confirm the fragment matches a GitHub-style slug of one of its headings. 2. Path check: for every internal link and image reference -- plain - Markdown and Archbee's `::Image[]{src="..."}` / `:inlineImage[]{src="..."}` - directives -- confirm the target file actually exists. Image references + Markdown, Archbee's `::Image[]{src="..."}` / `:inlineImage[]{src="..."}` + directives, and raw HTML `` / `` -- confirm + the target file actually exists. Image references are resolved the way Archbee resolves them: relative to the referencing page first, then falling back to a path relative to the docs root (see `resolve_image_target` for why both are tried). @@ -64,6 +65,17 @@ class Severity(str, Enum): _ARCHBEE_IMAGE_RE = re.compile( r"::?(?:Image|inlineImage)\[[^\]]*\]\{[^}]*?src=\"(?P[^\"]*)\"[^}]*\}" ) +# Raw HTML `` and ``. Pages built out of Archbee +# HTML tables reference their images this way and never as Markdown -- +# docs/general/Controls.md and docs/cpu-software/FlipCTL.md between them hold +# 15 such image references, so without these two patterns those paths would +# never be checked at all. +_HTML_IMAGE_RE = re.compile( + r"]*?\ssrc=(?P[\"'])(?P.*?)(?P=q)", re.IGNORECASE +) +_HTML_LINK_RE = re.compile( + r"]*?\shref=(?P[\"'])(?P.*?)(?P=q)", re.IGNORECASE +) # Used to strip Markdown/Archbee markup out of a heading before slugifying, # so the anchor is computed from the *rendered* text, same as GitHub does. @@ -205,6 +217,10 @@ def extract_link_refs(source: Path, lines: list[tuple[int, str]]) -> list[LinkRe ) for match in _ARCHBEE_IMAGE_RE.finditer(line): refs.append(LinkRef(source, lineno, match.group("src"), is_image=True)) + for match in _HTML_IMAGE_RE.finditer(line): + refs.append(LinkRef(source, lineno, match.group("src"), is_image=True)) + for match in _HTML_LINK_RE.finditer(line): + refs.append(LinkRef(source, lineno, match.group("href"), is_image=False)) return refs