diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1ce5aa8..985f62f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,3 +26,21 @@ jobs: - name: Detect broken links run: archbee broken-links + + docs-validator: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Archbee's own broken-links check above confirms a link's target + # file exists, but not that a #fragment resolves to a real heading. + - name: Check anchor fragments + run: python3 tools/validate_docs.py --check anchor + + # Stricter than Archbee's own check for image references: resolves + # them the way Archbee actually does (see tools/validate_docs.py) + # instead of just checking the target file exists. + - name: Check internal link and image paths + run: python3 tools/validate_docs.py --check path diff --git a/tools/test_validate_docs.py b/tools/test_validate_docs.py new file mode 100644 index 0000000..960c0b4 --- /dev/null +++ b/tools/test_validate_docs.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from validate_docs import ( + Severity, + check_links, + dedupe_slugs, + extract_headings, + extract_link_refs, + github_slug, + resolve_image_target, + resolve_path_part, + run, + split_fragment, + unfenced_lines, +) + + +class SlugTest(unittest.TestCase): + def test_plain_heading(self) -> None: + self.assertEqual(github_slug("How to contribute"), "how-to-contribute") + + def test_strips_punctuation_without_replacing_with_hyphen(self) -> None: + # Matches the real heading "### How archbee.json works" on + # docs/resources/docs/About-Docs.md, linked as #how-archbeejson-works. + self.assertEqual(github_slug("How archbee.json works"), "how-archbeejson-works") + + def test_strips_emoji_and_collapses_whitespace(self) -> None: + self.assertEqual(github_slug("✅ Tasks tracker"), "tasks-tracker") + + def test_markdown_link_in_heading_uses_link_text(self) -> None: + self.assertEqual(github_slug("[Hardware](./hardware/About-Hardware.md)"), "hardware") + + def test_inline_code_in_heading_keeps_content(self) -> None: + # The heading's own "-i" flag keeps its hyphen, so the space before it + # turns into a second one -- GitHub doesn't collapse repeated hyphens. + self.assertEqual(github_slug("`ethtool -i wlxb06b11673ade`"), "ethtool--i-wlxb06b11673ade") + + def test_archbee_directive_in_heading_is_dropped(self) -> None: + self.assertEqual( + github_slug(':inlineImage[]{src="/files/icons/apple-logo.png"} macOS'), "macos" + ) + + def test_bold_and_italic_markers_are_dropped(self) -> None: + self.assertEqual(github_slug("**Bold** and _italic_ text"), "bold-and-italic-text") + + +class DedupeSlugsTest(unittest.TestCase): + def test_first_occurrence_unsuffixed_rest_incrementing(self) -> None: + self.assertEqual( + dedupe_slugs(["setup", "usage", "setup", "setup"]), + ["setup", "usage", "setup-1", "setup-2"], + ) + + def test_no_duplicates_unaffected(self) -> None: + self.assertEqual(dedupe_slugs(["a", "b", "c"]), ["a", "b", "c"]) + + +class UnfencedLinesTest(unittest.TestCase): + def test_skips_fenced_code_block(self) -> None: + text = ( + "Intro text\n" + "```markdown\n" + "![Caption text](/files/pics/your-image.png)\n" + "```\n" + "Real content\n" + ) + lines = unfenced_lines(text) + self.assertEqual([line for _, line in lines], ["Intro text", "Real content"]) + + def test_heading_inside_fence_is_not_extracted(self) -> None: + text = ( + "```markdown\n" + ":::ExpandableHeading\n" + "### Section title\n" + ":::\n" + "```\n" + "\n" + ":::ExpandableHeading\n" + "### Section title\n" + ":::\n" + ) + lines = unfenced_lines(text) + headings = extract_headings(lines) + self.assertEqual(len(headings), 1) + self.assertEqual(headings[0].slug, "section-title") + + def test_hash_lines_inside_fence_are_not_headings(self) -> None: + # Real case from docs/dev-log/3.md: C #define macros inside a fenced + # block look like level-1 ATX headings if fencing isn't respected. + text = ( + "```c\n" + "#define I2C_HAPTIC_PLAY_EFFECT_BIT (15)\n" + "```\n" + "## Real heading\n" + ) + headings = extract_headings(unfenced_lines(text)) + self.assertEqual([h.text for h in headings], ["Real heading"]) + + +class FragmentSplitTest(unittest.TestCase): + def test_no_fragment(self) -> None: + self.assertEqual(split_fragment("./Other-Page.md"), ("./Other-Page.md", None)) + + def test_with_fragment(self) -> None: + self.assertEqual( + split_fragment("./Other-Page.md#some-section"), ("./Other-Page.md", "some-section") + ) + + def test_bare_fragment(self) -> None: + self.assertEqual(split_fragment("#known-issues"), ("", "known-issues")) + + +class ResolvePathPartTest(unittest.TestCase): + def setUp(self) -> None: + self.docs_root = Path("/repo/docs").resolve() + self.current_file = (self.docs_root / "testing" / "About-Testing.md").resolve() + + def test_same_page_variants_resolve_to_none(self) -> None: + for value in ("", ".", "./"): + with self.subTest(value=value): + self.assertIsNone(resolve_path_part(value, self.current_file, self.docs_root)) + + def test_absolute_path_resolves_against_docs_root(self) -> None: + target = resolve_path_part("/files/pics/foo.png", self.current_file, self.docs_root) + self.assertEqual(target, self.docs_root / "files" / "pics" / "foo.png") + + def test_relative_path_resolves_against_current_file_dir(self) -> None: + target = resolve_path_part("Style-guide.md", self.current_file, self.docs_root) + self.assertEqual(target, self.docs_root / "testing" / "Style-guide.md") + + def test_malformed_absolute_dot_does_not_resolve_to_a_page(self) -> None: + # The real bug on docs/testing/About-Testing.md: `/.#comment-on-an-open-task` + # instead of `./#comment-on-an-open-task`. + target = resolve_path_part("/.", self.current_file, self.docs_root) + self.assertEqual(target, self.docs_root) + self.assertNotEqual(target, self.current_file) + + +class ResolveImageTargetTest(unittest.TestCase): + def test_dotdot_relative_hit_is_used_as_is(self) -> None: + # docs/hardware/GPIO-Modules.md's real image reference: "../" only + # makes sense relative to the referencing file's own directory, and + # it resolves on the first try, so the docs-root fallback never + # kicks in. + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = Path(tmp_str) / "docs" + (docs_root / "files" / "pics").mkdir(parents=True) + (docs_root / "files" / "pics" / "walkie-talkie-module.png").write_text("x") + current_file = docs_root / "hardware" / "GPIO-Modules.md" + current_file.parent.mkdir(parents=True) + target = resolve_image_target( + "../files/pics/walkie-talkie-module.png", current_file, docs_root + ) + self.assertEqual(target, docs_root / "files" / "pics" / "walkie-talkie-module.png") + + def test_falls_back_to_docs_root_when_file_relative_resolution_misses(self) -> None: + # The real false positive on docs/cpu-software/How-to-install-linux-image.md: + # a bare relative path with no leading slash and no "../" doesn't + # resolve next to the referencing page, but Archbee renders it fine + # live by falling back to a docs-root-relative lookup. + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = Path(tmp_str) / "docs" + (docs_root / "files" / "pics").mkdir(parents=True) + (docs_root / "files" / "pics" / "rk3576_maskrom_mode.jpg").write_text("x") + current_file = docs_root / "cpu-software" / "How-to-install-linux-image.md" + current_file.parent.mkdir(parents=True) + target = resolve_image_target( + "files/pics/rk3576_maskrom_mode.jpg", current_file, docs_root + ) + self.assertEqual(target, docs_root / "files" / "pics" / "rk3576_maskrom_mode.jpg") + + def test_missing_from_both_locations_stays_broken(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = Path(tmp_str) / "docs" + current_file = docs_root / "cpu-software" / "How-to-install-linux-image.md" + current_file.parent.mkdir(parents=True) + target = resolve_image_target( + "files/pics/does-not-exist.jpg", current_file, docs_root + ) + assert target is not None + self.assertFalse(target.is_file()) + + def test_leading_slash_path_is_unaffected(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = Path(tmp_str) / "docs" + (docs_root / "files" / "pics").mkdir(parents=True) + (docs_root / "files" / "pics" / "foo.png").write_text("x") + current_file = docs_root / "testing" / "About-Testing.md" + current_file.parent.mkdir(parents=True) + target = resolve_image_target("/files/pics/foo.png", current_file, docs_root) + self.assertEqual(target, docs_root / "files" / "pics" / "foo.png") + + +class ArchbeeImageParsingTest(unittest.TestCase): + def test_extracts_src_from_image_directive(self) -> None: + line = '::Image[]{src="/files/pics/foo.jpg" size="80" position="flex-start"}' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0].raw_url, "/files/pics/foo.jpg") + self.assertTrue(refs[0].is_image) + + def test_extracts_src_from_inline_image_directive(self) -> None: + line = 'Press :inlineImage[]{src="/files/pics/ui/button.png" alt caption} to continue.' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual(len(refs), 1) + self.assertEqual(refs[0].raw_url, "/files/pics/ui/button.png") + + def test_extracts_src_with_extra_metadata_attributes(self) -> None: + # Real shape: Archbee adds sha/initialPath/githubPath/width/height + # after src, and the docs corpus has 3 real instances where src is + # missing its leading slash while githubPath still has it. + line = ( + '::Image[]{src="files/pics/rk3576_maskrom_mode.jpg" size="80" ' + 'githubPath="docs/files/pics/rk3576_maskrom_mode.jpg" width="2658"}' + ) + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + self.assertEqual(refs[0].raw_url, "files/pics/rk3576_maskrom_mode.jpg") + + def test_plain_markdown_image_and_archbee_directive_both_found(self) -> None: + line = '![Alt](/files/pics/a.png) and :inlineImage[]{src="/files/pics/b.png" alt}' + refs = extract_link_refs(Path("docs/x.md"), [(1, line)]) + urls = sorted(r.raw_url for r in refs) + self.assertEqual(urls, ["/files/pics/a.png", "/files/pics/b.png"]) + + +class CheckLinksIntegrationTest(unittest.TestCase): + def _write_docs(self, tmp: Path, files: dict[str, str]) -> Path: + docs_root = tmp / "docs" + for rel, content in files.items(): + path = docs_root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return docs_root + + def test_flags_anchor_that_does_not_match_any_heading(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "mechanics/About-Mechanics.md": "### Contribute a third-party module\n", + "dev-log/4.md": ( + "See the [contribution guide]" + "(../mechanics/About-Mechanics.md#contributing-a-third-party-module).\n" + ), + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + anchor_findings = [f for f in findings if f.check == "anchor"] + self.assertEqual(len(anchor_findings), 1) + self.assertEqual(anchor_findings[0].severity, Severity.ERROR) + self.assertIn("contributing-a-third-party-module", anchor_findings[0].message) + + def test_correct_anchor_produces_no_finding(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "mechanics/About-Mechanics.md": "### Contribute a third-party module\n", + "dev-log/4.md": ( + "See the [contribution guide]" + "(../mechanics/About-Mechanics.md#contribute-a-third-party-module).\n" + ), + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(findings, []) + + def test_bare_relative_image_path_resolves_against_docs_root(self) -> None: + # The real false positive on docs/cpu-software/How-to-install-linux-image.md: + # an ::Image[] directive with a bare relative "src" (no leading + # slash) that doesn't sit next to files/pics/ in the same + # directory. Archbee still renders this fine live, resolving it + # against the docs root, so this must not be flagged. + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "cpu-software/How-to-install-linux-image.md": ( + '::Image[]{src="files/pics/rk3576_maskrom_mode.jpg"}\n' + ), + "files/pics/rk3576_maskrom_mode.jpg": "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_dotdot_relative_image_still_resolves_next_to_referencing_file(self) -> None: + # docs/hardware/GPIO-Modules.md's real, currently-correct pattern: an + # explicit "../" that only makes sense relative to the referencing + # file's own directory. This must keep working, not fall through to + # the docs-root fallback. + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "hardware/GPIO-Modules.md": ( + "![Walkie-talkie module structural diagram]" + "(../files/pics/walkie-talkie-module.png)\n" + ), + "files/pics/walkie-talkie-module.png": "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_genuinely_missing_image_is_still_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "cpu-software/How-to-install-linux-image.md": ( + "![Missing](files/pics/does-not-exist.jpg)\n" + ), + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + path_findings = [f for f in findings if f.check == "path"] + self.assertEqual(len(path_findings), 1) + self.assertIn("files/pics/does-not-exist.jpg", path_findings[0].message) + + def test_placeholder_path_inside_fence_is_not_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "resources/docs/About-Docs.md": ( + "To use an image, reference it like this:\n\n" + "```markdown\n" + "![Caption text](/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( + Path(tmp_str), + {"Welcome.md": "![Banner](https://cdn.flipper.net/banner.jpg)\n"}, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(findings, []) + + def test_same_page_fragment_resolves_against_own_headings(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "testing/About-Testing.md": ( + "### Comment on an open task\n\n" + "See [comments on open task](./#comment-on-an-open-task).\n" + ) + }, + ) + md_files = sorted(docs_root.rglob("*.md")) + findings = check_links(md_files, docs_root, docs_root.parent) + self.assertEqual(findings, []) + + def test_malformed_same_page_slash_dot_link_is_flagged(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = self._write_docs( + Path(tmp_str), + { + "testing/About-Testing.md": ( + "### Comment on an open task\n\n" + "See [comments on open task](/.#comment-on-an-open-task).\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") + + +class RunEndToEndTest(unittest.TestCase): + def test_run_combines_anchor_and_path_findings(self) -> None: + with tempfile.TemporaryDirectory() as tmp_str: + docs_root = Path(tmp_str) / "docs" + docs_root.mkdir() + (docs_root / "A.md").write_text( + "[broken anchor](#nope)\n![broken path](/files/pics/nope.png)\n", + encoding="utf-8", + ) + findings = run(docs_root, docs_root.parent) + checks = {f.check for f in findings} + self.assertEqual(checks, {"anchor", "path"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_docs.py b/tools/validate_docs.py new file mode 100644 index 0000000..92941b8 --- /dev/null +++ b/tools/validate_docs.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +""" +Check the Flipper One docs for broken anchors and broken internal paths. + +Archbee's own CI (`archbee validate` / `archbee broken-links`, see +.github/workflows/validate.yml) checks the sidebar structure and confirms +that link targets exist, but it doesn't resolve `#fragment` anchors against +the target page's real headings. This script covers that gap, plus a path +check that's stricter about image references than Archbee's own check: + +1. Anchor check: for every internal link with a `#fragment`, resolve the + 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 + 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). + +Each check can be run on its own with `--check anchor` / `--check path`, so +CI can wire them up as separate steps and name precisely which one failed. + +Fenced code blocks are skipped by both checks. The contribution-guide pages +use placeholder paths like `your-image.png` as syntax examples inside +` ```markdown ` fences, and those aren't real broken links. + +Usage: + python3 tools/validate_docs.py # scan docs/, human output + python3 tools/validate_docs.py --check anchor # anchors only + python3 tools/validate_docs.py --check path # paths only + python3 tools/validate_docs.py --docs-root docs +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from urllib.parse import unquote + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_DOCS_ROOT = REPO_ROOT / "docs" + + +class Severity(str, Enum): + ERROR = "error" + WARNING = "warning" + + +_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$") + +# `[text](url "title")` and `![alt](url "title")`. The URL itself is +# assumed to contain no whitespace and no unescaped `)`, which holds for +# every link in this repo (checked against the actual corpus). +_MD_LINK_OR_IMAGE_RE = re.compile( + r"(?P!?)\[(?P[^\]]*)\]\((?P[^\s)]+)(?:\s+(?:\"[^\"]*\"|'[^']*'))?\)" +) +# Archbee's `::Image[]{src="..." ...}` and `:inlineImage[]{src="..." ...}`. +_ARCHBEE_IMAGE_RE = re.compile( + r"::?(?:Image|inlineImage)\[[^\]]*\]\{[^}]*?src=\"(?P[^\"]*)\"[^}]*\}" +) + +# 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. +_DIRECTIVE_RE = re.compile(r":{1,2}[A-Za-z][\w-]*\[[^\]]*\](?:\{[^}]*\})?") +_MD_IMAGE_ONLY_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)") +_MD_LINK_TEXT_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)") +_INLINE_CODE_RE = re.compile(r"`([^`]*)`") +_BOLD_STAR_RE = re.compile(r"\*\*(.+?)\*\*") +_BOLD_UNDER_RE = re.compile(r"__(.+?)__") +_ITALIC_STAR_RE = re.compile(r"(? str: + return f"{self.severity.value.upper():7} {self.path}:{self.line} [{self.check}] {self.message}" + + +@dataclass(frozen=True) +class Heading: + line: int + level: int + text: str + slug: str + + +@dataclass(frozen=True) +class PageInfo: + lines: list[tuple[int, str]] + headings: list[Heading] + slugs: frozenset[str] + + +@dataclass(frozen=True) +class LinkRef: + source: Path + line: int + raw_url: str + is_image: bool + + +def unfenced_lines(text: str) -> list[tuple[int, str]]: + """Return (1-based line number, line text) pairs, skipping fenced code blocks.""" + result: list[tuple[int, str]] = [] + in_fence = False + fence_char = "" + fence_len = 0 + for lineno, line in enumerate(text.splitlines(), start=1): + match = _FENCE_RE.match(line) + if match: + marker = match.group(1) + if not in_fence: + in_fence = True + fence_char = marker[0] + fence_len = len(marker) + continue + if marker[0] == fence_char and len(marker) >= fence_len: + in_fence = False + continue + if not in_fence: + result.append((lineno, line)) + return result + + +def heading_plain_text(raw: str) -> str: + """Strip Markdown/Archbee syntax from a heading, leaving the rendered text.""" + text = _DIRECTIVE_RE.sub("", raw) + text = _MD_IMAGE_ONLY_RE.sub("", text) + text = _MD_LINK_TEXT_RE.sub(r"\1", text) + text = _INLINE_CODE_RE.sub(r"\1", text) + text = _BOLD_STAR_RE.sub(r"\1", text) + text = _BOLD_UNDER_RE.sub(r"\1", text) + text = _ITALIC_STAR_RE.sub(r"\1", text) + text = _ITALIC_UNDER_RE.sub(r"\1", text) + return text + + +def github_slug(heading_text: str) -> str: + """Compute the anchor slug GitHub-flavored Markdown would give this heading. + + Lowercase, drop anything that isn't a letter/digit/underscore/space/hyphen, + collapse whitespace, then turn spaces into hyphens. Doesn't include the + numeric `-1`, `-2`, ... suffix GitHub adds for repeated headings on the + same page -- see `dedupe_slugs` for that. + """ + plain = heading_plain_text(heading_text).lower() + plain = _NON_SLUG_RE.sub("", plain) + plain = re.sub(r"\s+", " ", plain).strip() + return plain.replace(" ", "-") + + +def dedupe_slugs(raw_slugs: list[str]) -> list[str]: + """Apply GitHub's duplicate-heading suffixing: first occurrence bare, then -1, -2, ...""" + seen: dict[str, int] = {} + result: list[str] = [] + for slug in raw_slugs: + count = seen.get(slug, 0) + seen[slug] = count + 1 + result.append(slug if count == 0 else f"{slug}-{count}") + return result + + +def extract_headings(lines: list[tuple[int, str]]) -> list[Heading]: + raw: list[tuple[int, int, str]] = [] + for lineno, line in lines: + match = _HEADING_RE.match(line) + if match: + raw.append((lineno, len(match.group(1)), match.group(2))) + slugs = dedupe_slugs([github_slug(text) for _, _, text in raw]) + return [ + Heading(line=lineno, level=level, text=text, slug=slug) + for (lineno, level, text), slug in zip(raw, slugs) + ] + + +def load_page(path: Path) -> PageInfo: + text = path.read_text(encoding="utf-8") + lines = unfenced_lines(text) + headings = extract_headings(lines) + return PageInfo(lines=lines, headings=headings, slugs=frozenset(h.slug for h in headings)) + + +def extract_link_refs(source: Path, lines: list[tuple[int, str]]) -> list[LinkRef]: + refs: list[LinkRef] = [] + for lineno, line in lines: + for match in _MD_LINK_OR_IMAGE_RE.finditer(line): + refs.append( + LinkRef(source, lineno, match.group("url"), is_image=bool(match.group("bang"))) + ) + for match in _ARCHBEE_IMAGE_RE.finditer(line): + refs.append(LinkRef(source, lineno, match.group("src"), is_image=True)) + return refs + + +def is_external(url: str) -> bool: + """True for anything with a URI scheme (http://, https://, mailto:, ...).""" + return bool(_SCHEME_RE.match(url)) + + +def split_fragment(url: str) -> tuple[str, str | None]: + if "#" not in url: + return url, None + path_part, _, frag = url.partition("#") + return path_part, unquote(frag) if frag else None + + +def resolve_path_part(path_part: str, current_file: Path, docs_root: Path) -> Path | None: + """Resolve a link's path portion (without the fragment) to an absolute path. + + Returns None when `path_part` refers to the current page itself + (empty, ".", or "./") -- the same-page convention used across these docs. + """ + if path_part in ("", ".", "./"): + return None + if path_part.startswith("/"): + base, rel = docs_root, path_part[1:] + else: + base, rel = current_file.parent, path_part + return (base / rel).resolve() + + +def resolve_image_target(path_part: str, current_file: Path, docs_root: Path) -> Path | None: + """Resolve an image reference the way Archbee actually resolves it. + + Regular internal links resolve relative to the referencing file, and + that's right for images too when the reference already works out that + way -- docs/hardware/GPIO-Modules.md's plain Markdown image uses + "../files/pics/walkie-talkie-module.png", which only makes sense + relative to its own directory, and it renders fine live. + + But three `::Image[]{src="files/pics/..."}` directives on + docs/cpu-software/How-to-install-linux-image.md give a bare relative + path with no leading slash and no "../" -- under file-relative + resolution that looks for a "files/pics/" folder next to the page + itself, which doesn't exist, but the images render fine live because + Archbee falls back to resolving the same path against the docs root. + So: try the normal file-relative resolution first, and only if that + doesn't find a file, retry relative to the docs root before giving up. + """ + target = resolve_path_part(path_part, current_file, docs_root) + if target is not None and not target.is_file() and not path_part.startswith("/"): + root_relative = (docs_root / path_part).resolve() + if root_relative.is_file(): + return root_relative + return target + + +def display_path(path: Path, repo_root: Path) -> str: + try: + return str(path.relative_to(repo_root)) + except ValueError: + return str(path) + + +def find_markdown_files(docs_root: Path) -> list[Path]: + return sorted(docs_root.rglob("*.md")) + + +def check_links(md_files: list[Path], docs_root: Path, repo_root: Path) -> list[Finding]: + findings: list[Finding] = [] + page_cache: dict[Path, PageInfo] = {} + + def get_page(path: Path) -> PageInfo: + if path not in page_cache: + page_cache[path] = load_page(path) + return page_cache[path] + + for md_file in md_files: + page = get_page(md_file) + source = display_path(md_file, repo_root) + for ref in extract_link_refs(Path(source), page.lines): + if is_external(ref.raw_url): + continue + path_part, fragment = split_fragment(ref.raw_url) + if ref.is_image: + target = resolve_image_target(path_part, md_file, docs_root) + else: + target = resolve_path_part(path_part, md_file, docs_root) + + if target is None: + target_page: PageInfo | None = page + target_display = source + else: + if not target.is_file(): + findings.append( + Finding( + Severity.ERROR, + "path", + Path(source), + ref.line, + f"target '{ref.raw_url}' does not resolve to an existing file " + f"(resolved: {display_path(target, repo_root)})", + ) + ) + continue + target_display = display_path(target, repo_root) + target_page = get_page(target) if target.suffix == ".md" else None + + if fragment is None or ref.is_image or target_page is None: + continue + if fragment not in target_page.slugs: + known = ", ".join(sorted(target_page.slugs)) or "none" + findings.append( + Finding( + Severity.ERROR, + "anchor", + Path(source), + ref.line, + f"fragment '#{fragment}' not found in {target_display} " + f"(known headings: {known})", + ) + ) + return findings + + +def run(docs_root: Path, repo_root: Path) -> list[Finding]: + md_files = find_markdown_files(docs_root) + findings = check_links(md_files, docs_root, repo_root) + findings.sort(key=lambda f: (str(f.path), f.line, f.check, f.severity.value)) + return findings + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--docs-root", type=Path, default=DEFAULT_DOCS_ROOT, help="Directory to scan (default: docs/)." + ) + parser.add_argument( + "--check", + choices=("anchor", "path"), + default=None, + help="Run only this check instead of both. Lets CI wire the anchor " + "check and the path check into their own steps.", + ) + args = parser.parse_args(argv) + + docs_root: Path = args.docs_root.resolve() + repo_root: Path = docs_root.parent + + findings = run(docs_root, repo_root) + if args.check is not None: + findings = [f for f in findings if f.check == args.check] + for finding in findings: + print(finding) + + errors = [f for f in findings if f.severity is Severity.ERROR] + file_count = len(find_markdown_files(docs_root)) + print(f"\n{len(errors)} error(s) across {file_count} files scanned.") + + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main())