Skip to content

Commit f978b68

Browse files
committed
docs: close nav containment, HTML-comment, and whitespace link gaps
Nav validation rejects entries that are absolute or escape docs/ before checking existence (a ../ value resolved against the wrong root and an escaping entry would write its llms rendition outside the built site); llms_txt applies the same containment when collecting prose pages. Block HTML comments join fences and code spans as inert content, so commented-out prose is neither validated nor rewritten. Link openings tolerate the whitespace CommonMark allows after '(' - previously ']( page.md)' and ']( <x>)' dodged both the classifier and the angle-form rejection. And check_crossrefs fails up front on a missing site directory instead of scanning zero pages as a clean site.
1 parent 8acb8dd commit f978b68

3 files changed

Lines changed: 24 additions & 5 deletions

File tree

scripts/docs/build_config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from __future__ import annotations
1414

15+
import posixpath
1516
import re
1617
from pathlib import Path
1718

@@ -52,6 +53,10 @@ def _validate_nav(nav: list, docs_dir: Path) -> None:
5253
generator that writes the files, so it cannot drift.
5354
"""
5455
pages = _nav_pages(nav)
56+
# Containment before existence: `docs_dir / page` would happily resolve
57+
# an absolute value or a `../` escape against the wrong root.
58+
if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")):
59+
raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}")
5560
if missing := sorted(page for page in pages if not (docs_dir / page).is_file()):
5661
raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}")
5762
# Dot-directories (e.g. `.overrides` theme files) are not pages: the site

scripts/docs/check_crossrefs.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,18 @@ def _origin(url: str) -> str:
130130

131131
def main() -> None:
132132
parser = argparse.ArgumentParser(description=__doc__)
133-
parser.add_argument("--site-dir", default="site", help="The built site directory to scan.")
133+
parser.add_argument("--site-dir", default=str(ROOT / "site"), help="The built site directory to scan.")
134134
args = parser.parse_args()
135135

136+
site_dir = Path(args.site_dir)
137+
# rglob on a missing directory yields nothing, which would read as a
138+
# clean site (or a bogus inventory failure); fail up front instead.
139+
if not site_dir.is_dir():
140+
raise SystemExit(f"check_crossrefs: {site_dir} not found (run the build first)")
141+
136142
unlinked = _inventory_origins()
137143
failures: list[str] = []
138-
for page in sorted(Path(args.site_dir).rglob("*.html")):
144+
for page in sorted(site_dir.rglob("*.html")):
139145
html = page.read_text(encoding="utf-8")
140146
if unlinked and "autorefs-external" in html:
141147
for tag in _EXTERNAL_REF.finditer(html):

scripts/docs/llms_txt.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,17 @@
5050
# own link validation only covers .md targets (a missing image or
5151
# directory-style link builds green even under --strict; MkDocs failed the
5252
# build), so everything else is validated here.
53-
_LINK = re.compile(r'(\]\()([^)\s]+?)(#[^)\s]*)?( +(?:"[^"]*"|\'[^\']*\'|\([^()]*\)))?(\))')
53+
_LINK = re.compile(r'(\]\([ \t]*)([^)\s]+?)(#[^)\s]*)?( +(?:"[^"]*"|\'[^\']*\'|\([^()]*\)))?([ \t]*\))')
5454
# CommonMark forms the classifier deliberately rejects rather than models:
5555
# angle-bracket destinations `](<target>)` and reference-style definitions
5656
# `[label]: target` (footnote definitions `[^label]:` are a different,
5757
# supported syntax). Either would otherwise dodge validation by its spelling;
5858
# failing loud keeps the guarantee without modelling unused syntax.
59-
_ANGLE_LINK = re.compile(r"\]\(<")
59+
_ANGLE_LINK = re.compile(r"\]\([ \t]*<")
6060
_REF_DEFINITION = re.compile(r"^[ \t]*\[(?!\^)[^\]]+\]:", flags=re.MULTILINE)
61+
# Block HTML comments are inert in rendered output: python-markdown passes
62+
# them through verbatim, so commented-out prose must not be validated.
63+
_HTML_COMMENT = re.compile(r"<!--.*?-->", flags=re.DOTALL)
6164
# A scheme-prefixed target (https:, mailto:, tel:, ...) is external — the
6265
# `://` shorthand misses scheme-only URIs like mailto:.
6366
_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
@@ -132,6 +135,10 @@ def _collect_pages(items: list, prose: dict[str, str | None]) -> list[str]:
132135
if isinstance(value, list):
133136
pages.extend(_collect_pages(value, prose))
134137
elif not _EXTERNAL.match(value) and value.endswith(".md") and not value.startswith("api/"):
138+
# Contained values only: an escaping entry would write its
139+
# rendition outside the built site.
140+
if value.startswith("/") or posixpath.normpath(value).startswith(".."):
141+
raise _BuildError(f"llms_txt: nav entry {value!r} escapes docs/")
135142
prose[value] = title
136143
pages.append(value)
137144
return pages
@@ -218,7 +225,8 @@ def _code_intervals(markdown: str) -> list[tuple[int, int]]:
218225
previous_end = 0
219226
for fence_start, fence_end in [*fences, (len(markdown), len(markdown))]:
220227
segment = markdown[previous_end:fence_start]
221-
intervals += [(previous_end + m.start(), previous_end + m.end()) for m in _CODE_SPAN.finditer(segment)]
228+
for pattern in (_CODE_SPAN, _HTML_COMMENT):
229+
intervals += [(previous_end + m.start(), previous_end + m.end()) for m in pattern.finditer(segment)]
222230
previous_end = fence_end
223231
return intervals
224232

0 commit comments

Comments
 (0)