Skip to content

Commit 0487d9b

Browse files
authored
Add checks for broken docs urls (#6448)
* Add checks for broken docs urls * updates * combine ci and be more verbose * move from regex
1 parent 0e04fb1 commit 0487d9b

3 files changed

Lines changed: 400 additions & 0 deletions

File tree

docs/app/.github/workflows/integration_tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ jobs:
6363

6464
- name: Export the website
6565
run: reflex export
66+
67+
- name: Validate /docs links against generated sitemap
68+
run: uv run python scripts/check_doc_links.py
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""Validate /docs/* markdown links against the generated sitemap.xml.
2+
3+
For every .md file under the docs tree, parse it with reflex-docgen's
4+
markdown parser and verify every `[text](/docs/...)` link:
5+
6+
1. The URL path contains no underscores (URLs use hyphens).
7+
2. After stripping the `/docs` prefix, the path exists in sitemap.xml.
8+
9+
Using the real markdown AST means links inside fenced code blocks are
10+
correctly ignored, reference-style and multi-line links are caught, and
11+
escapes/edge cases are handled the same way the docs site renders them.
12+
13+
Run after building the frontend so .web/public/sitemap.xml is present:
14+
15+
cd docs/app
16+
uv run reflex export --frontend-only --no-zip
17+
uv run python scripts/check_doc_links.py
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import sys
24+
import xml.etree.ElementTree as ET
25+
from collections.abc import Iterator
26+
from pathlib import Path
27+
from urllib.parse import urlparse
28+
29+
from reflex_docgen.markdown import (
30+
Block,
31+
BoldSpan,
32+
DirectiveBlock,
33+
HeadingBlock,
34+
ImageSpan,
35+
ItalicSpan,
36+
LinkSpan,
37+
ListBlock,
38+
QuoteBlock,
39+
Span,
40+
StrikethroughSpan,
41+
TableBlock,
42+
TextBlock,
43+
parse_document,
44+
)
45+
46+
SITEMAP_NS = {"sm": "https://www.sitemaps.org/schemas/sitemap/0.9"}
47+
SKIP_DIRS = {".web", "node_modules", "__pycache__", ".git", ".venv", "dist", "build"}
48+
49+
50+
def _normalize(path: str) -> str:
51+
path = path.split("#", 1)[0].split("?", 1)[0]
52+
if not path.startswith("/"):
53+
path = "/" + path
54+
return path.rstrip("/") or "/"
55+
56+
57+
def _strip_docs_prefix(path: str) -> str:
58+
"""Drop a leading `/docs` segment so both deployment styles compare equal."""
59+
if path == "/docs":
60+
return "/"
61+
if path.startswith("/docs/"):
62+
return path[len("/docs") :]
63+
return path
64+
65+
66+
def load_sitemap_paths(sitemap_path: Path) -> set[str]:
67+
"""Return the set of normalized URL paths declared in sitemap.xml."""
68+
tree = ET.parse(sitemap_path)
69+
paths: set[str] = set()
70+
for loc in tree.getroot().findall("sm:url/sm:loc", SITEMAP_NS):
71+
if loc.text is None:
72+
continue
73+
path = urlparse(loc.text.strip()).path
74+
paths.add(_strip_docs_prefix(_normalize(path)))
75+
return paths
76+
77+
78+
def iter_md_files(md_root: Path) -> Iterator[Path]:
79+
"""Yield .md files under md_root, skipping build/vendor directories."""
80+
for path in md_root.rglob("*.md"):
81+
if any(part in SKIP_DIRS for part in path.relative_to(md_root).parts):
82+
continue
83+
yield path
84+
85+
86+
def _walk_spans(spans: tuple[Span, ...]) -> Iterator[LinkSpan]:
87+
"""Recursively yield every LinkSpan inside a span tree."""
88+
for span in spans:
89+
if isinstance(span, LinkSpan):
90+
yield span
91+
yield from _walk_spans(span.children)
92+
elif isinstance(span, (BoldSpan, ItalicSpan, StrikethroughSpan, ImageSpan)):
93+
yield from _walk_spans(span.children)
94+
95+
96+
def _walk_blocks(blocks: tuple[Block, ...]) -> Iterator[LinkSpan]:
97+
"""Recursively yield every LinkSpan in a block tree, skipping CodeBlock."""
98+
for block in blocks:
99+
if isinstance(block, (HeadingBlock, TextBlock)):
100+
yield from _walk_spans(block.children)
101+
elif isinstance(block, ListBlock):
102+
for item in block.items:
103+
yield from _walk_blocks(item.children)
104+
elif isinstance(block, (QuoteBlock, DirectiveBlock)):
105+
yield from _walk_blocks(block.children)
106+
elif isinstance(block, TableBlock):
107+
for row in (block.header, *block.rows):
108+
for cell in row.cells:
109+
yield from _walk_spans(cell.children)
110+
111+
112+
def _line_for(text: str, target: str, cursor: int) -> tuple[int, int]:
113+
"""Locate the next occurrence of `](target)` after cursor.
114+
115+
Returns ``(line_number, new_cursor)``. If the link is reference-style
116+
(no `](target)` in source), falls back to scanning for `]: target`.
117+
Returns ``line_number == 0`` if the target can't be located.
118+
"""
119+
needle = "](" + target
120+
pos = text.find(needle, cursor)
121+
if pos == -1:
122+
# Reference-style links resolve to the same target but live in
123+
# a `[label]: target` definition further down the file.
124+
pos = text.find("]: " + target, cursor)
125+
if pos == -1:
126+
return 0, cursor
127+
return text.count("\n", 0, pos) + 1, pos + len(needle)
128+
129+
130+
def check(md_root: Path, sitemap_path: Path) -> list[str]:
131+
"""Return a list of human-readable error strings.
132+
133+
Prints a per-link trail and a summary so CI logs make it obvious which
134+
files were scanned and which links were validated.
135+
"""
136+
if not sitemap_path.is_file():
137+
return [
138+
f"sitemap.xml not found at {sitemap_path}. "
139+
"Build the frontend first (e.g. `uv run reflex export --frontend-only --no-zip`)."
140+
]
141+
142+
valid_paths = load_sitemap_paths(sitemap_path)
143+
print(f"Loaded {len(valid_paths)} URLs from sitemap {sitemap_path}")
144+
145+
md_files = list(iter_md_files(md_root))
146+
if not md_files:
147+
return [f"No .md files found under {md_root}. Check --md-root."]
148+
print(f"Scanning {len(md_files)} markdown file(s) under {md_root}")
149+
150+
errors: list[str] = []
151+
links_checked = 0
152+
for md_file in md_files:
153+
try:
154+
text = md_file.read_text(encoding="utf-8")
155+
except OSError:
156+
continue
157+
try:
158+
doc = parse_document(text)
159+
except Exception as exc:
160+
errors.append(f"{md_file}: failed to parse markdown ({exc})")
161+
continue
162+
163+
cursor = 0
164+
for link in _walk_blocks(doc.blocks):
165+
target = link.target
166+
if not (target == "/docs" or target.startswith("/docs/")):
167+
continue
168+
169+
line_no, cursor = _line_for(text, target, cursor)
170+
location = f"{md_file}:{line_no}" if line_no else str(md_file)
171+
links_checked += 1
172+
173+
path_only = _normalize(target)
174+
sitemap_key = _strip_docs_prefix(path_only)
175+
has_underscore = "_" in path_only
176+
in_sitemap = sitemap_key in valid_paths
177+
status = "OK" if (in_sitemap and not has_underscore) else "FAIL"
178+
print(f" [{status:<4}] {location} -> {target}")
179+
180+
if has_underscore:
181+
errors.append(
182+
f"{location}: link contains an underscore (use hyphens): {target!r}"
183+
)
184+
if not in_sitemap:
185+
errors.append(
186+
f"{location}: {target!r} -> {sitemap_key!r} not found in sitemap"
187+
)
188+
189+
print(f"Checked {links_checked} /docs link(s) across {len(md_files)} file(s).")
190+
return errors
191+
192+
193+
def main() -> int:
194+
parser = argparse.ArgumentParser(description=__doc__)
195+
here = Path(__file__).resolve().parent
196+
parser.add_argument(
197+
"--md-root",
198+
type=Path,
199+
default=here.parent.parent,
200+
help="Root directory containing .md docs (default: ../..).",
201+
)
202+
parser.add_argument(
203+
"--sitemap",
204+
type=Path,
205+
default=here.parent / ".web" / "public" / "sitemap.xml",
206+
help="Path to sitemap.xml (default: ../.web/public/sitemap.xml).",
207+
)
208+
args = parser.parse_args()
209+
210+
errors = check(args.md_root.resolve(), args.sitemap.resolve())
211+
if errors:
212+
print(f"Found {len(errors)} broken /docs link(s):", file=sys.stderr)
213+
for err in errors:
214+
print(f" {err}", file=sys.stderr)
215+
return 1
216+
print("All /docs links resolve against sitemap.xml.")
217+
return 0
218+
219+
220+
if __name__ == "__main__":
221+
sys.exit(main())

0 commit comments

Comments
 (0)