Skip to content
Open
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
123 changes: 123 additions & 0 deletions tools/test_validate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<p><img src="/files/icons/controls/dpad.svg" alt=""></p>'
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 = '<img src="/files/pics/flipctl-in-terminal.png"/>'
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 = '<p><a href="Markup-reference.md">Jump to Tables</a></p>'
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 = "<img src='/files/pics/a.png'> <a href='/general/Controls.md'>x</a>"
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 = '<img src="/files/pics/a.png"><img src="/files/pics/b.png">'
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 = '<td align="left" colSpan="1"><p><strong>Task</strong></p></td>'
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"
Expand Down Expand Up @@ -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": (
"<p><img src=\"/files/icons/controls/dpad.svg\" alt=\"\"></p>\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": (
"<p><img src=\"/files/icons/controls/dpad.svg\" alt=\"\"></p>\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": '<a href="Gone.md">x</a>\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": (
'<p><a href="Controls.md#buttonz">Buttons</a></p>\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"
'<img src="/files/pics/your-image.png">\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(
Expand Down
20 changes: 18 additions & 2 deletions tools/validate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src="...">` / `<a href="...">` -- 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).
Expand Down Expand Up @@ -64,6 +65,17 @@ class Severity(str, Enum):
_ARCHBEE_IMAGE_RE = re.compile(
r"::?(?:Image|inlineImage)\[[^\]]*\]\{[^}]*?src=\"(?P<src>[^\"]*)\"[^}]*\}"
)
# Raw HTML `<img src="...">` and `<a href="...">`. 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"<img\b[^>]*?\ssrc=(?P<q>[\"'])(?P<src>.*?)(?P=q)", re.IGNORECASE
)
_HTML_LINK_RE = re.compile(
r"<a\b[^>]*?\shref=(?P<q>[\"'])(?P<href>.*?)(?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.
Expand Down Expand Up @@ -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


Expand Down