Skip to content

Commit 8acb8dd

Browse files
committed
docs: tighten the prose extractor and link classifier edge cases
check_crossrefs: only block-level tags separate extracted text - inline tags flow, so a subscript like **tools**[`0`] keeps the word character that exempts it - and the block set covers the def-list/details/table tags the enabled extensions emit. A br separates text without resetting the skip state (it is legal inside code and implies no closed element). llms_txt: link titles accept all three CommonMark quoting forms (parenthesized titles previously made the whole link invisible to the classifier); reference-style definitions are rejected in every valid spelling (no space after colon, next-line destination, indented inside admonition bodies) while footnote definitions stay supported; and one _prose_h1 helper now owns both the title fallback and the H1 strip for llms-full.txt, skipping code blocks (snippet pointer lines) and matching only real ATX headings, so the two can no longer disagree.
1 parent d90f709 commit 8acb8dd

2 files changed

Lines changed: 62 additions & 17 deletions

File tree

scripts/docs/check_crossrefs.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,21 @@
5858
class _ProseTextExtractor(HTMLParser):
5959
"""Collect text outside <pre>/<code>/<script>/<style> elements.
6060
61-
A skipped element leaves a `\\x00` mark and every other tag boundary
62-
breaks the text with a newline, so bracket sequences cannot be
63-
synthesized by joining text from unrelated elements (`x]</td><td>[y`)
64-
while sequences genuinely adjacent in one text node stay intact. A
65-
block-level tag resets the skip state, so an unclosed inline `<code>`
66-
in authored raw HTML cannot hide the rest of the page.
61+
A skipped element leaves a `\\x00` mark. Block-level tag boundaries break
62+
the text with a newline, so bracket sequences cannot be synthesized by
63+
joining text from unrelated blocks (`x]</td><td>[y`); inline tags break
64+
nothing, because their text genuinely flows within one block — a
65+
subscript like `**tools**[`0`]` must extract as `tools[\\x00]` so the
66+
word-character carve-out in `_UNRESOLVED` still applies. A block-level
67+
tag also resets the skip state, so an unclosed inline `<code>` in
68+
authored raw HTML cannot hide the rest of the page.
6769
"""
6870

6971
_SKIP = frozenset({"pre", "code", "script", "style"})
7072
_BLOCK = frozenset(
71-
{"article", "blockquote", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "section", "table", "td", "th"}
73+
{"article", "blockquote", "caption", "dd", "details", "div", "dl", "dt", "figcaption", "figure"}
74+
| {"h1", "h2", "h3", "h4", "h5", "h6", "hr", "li", "ol", "p", "section", "summary"}
75+
| {"table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul"}
7276
)
7377

7478
def __init__(self) -> None:
@@ -84,14 +88,17 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
8488
elif tag in self._BLOCK:
8589
self._skip_depth = 0
8690
self.chunks.append("\n")
87-
elif not self._skip_depth:
91+
elif tag == "br" and not self._skip_depth:
92+
# A line break separates text but implies nothing about open
93+
# elements (`<br>` is legal inside `<code>`), so unlike block
94+
# tags it must not reset the skip state.
8895
self.chunks.append("\n")
8996

9097
def handle_endtag(self, tag: str) -> None:
9198
if tag in self._SKIP:
9299
if self._skip_depth:
93100
self._skip_depth -= 1
94-
elif not self._skip_depth:
101+
elif tag in self._BLOCK and not self._skip_depth:
95102
self.chunks.append("\n")
96103

97104
def handle_data(self, data: str) -> None:

scripts/docs/llms_txt.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@
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'(\]\()([^)\s]+?)(#[^)\s]*)?( +(?:"[^"]*"|\'[^\']*\'|\([^()]*\)))?(\))')
54+
# CommonMark forms the classifier deliberately rejects rather than models:
55+
# angle-bracket destinations `](<target>)` and reference-style definitions
56+
# `[label]: target` (footnote definitions `[^label]:` are a different,
57+
# supported syntax). Either would otherwise dodge validation by its spelling;
58+
# failing loud keeps the guarantee without modelling unused syntax.
59+
_ANGLE_LINK = re.compile(r"\]\(<")
60+
_REF_DEFINITION = re.compile(r"^[ \t]*\[(?!\^)[^\]]+\]:", flags=re.MULTILINE)
5461
# A scheme-prefixed target (https:, mailto:, tel:, ...) is external — the
5562
# `://` shorthand misses scheme-only URIs like mailto:.
5663
_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
@@ -60,6 +67,12 @@
6067
# least as long as the opener, unclosed runs to EOF, per CommonMark) and spans
6168
# only in the text between fences; a span cannot cross a blank line, so a
6269
# stray unpaired backtick cannot swallow the paragraphs (and links) after it.
70+
# Known approximations of the renderer's block model: 4-space-indented
71+
# content is treated as prose, because in this corpus indentation is
72+
# admonition/list body whose links must stay validated — a link in a true
73+
# indented code block is over-validated (fails loud or gets rewritten in the
74+
# rendition), never under-validated; and span pairing is bounded by blank
75+
# lines rather than full block structure.
6376
_FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})")
6477
_CODE_SPAN = re.compile(r"(?s)(?<!`)(`+)(?!`)((?:(?!\n[ \t]*\n).)+?)(?<!`)\1(?!`)")
6578
# A leading YAML frontmatter block, as MkDocs/Zensical parse it (mkdocs.utils.meta).
@@ -167,6 +180,24 @@ def include(match: re.Match[str]) -> str:
167180
return resolved
168181

169182

183+
def _in_code(code: list[tuple[int, int]], position: int) -> bool:
184+
"""Whether `position` falls inside any code interval."""
185+
return any(start <= position < end for start, end in code)
186+
187+
188+
def _prose_h1(markdown: str) -> re.Match[str] | None:
189+
"""The first ATX H1 outside code (at most 3 spaces of indent, per CommonMark).
190+
191+
Code-awareness matters: every resolved `.py` snippet starts with a
192+
`# path` pointer line that must never win over the page's real H1.
193+
"""
194+
code = _code_intervals(markdown)
195+
for match in re.finditer(r"^ {0,3}# (.+)$", markdown, flags=re.MULTILINE):
196+
if not _in_code(code, match.start()):
197+
return match
198+
return None
199+
200+
170201
def _code_intervals(markdown: str) -> list[tuple[int, int]]:
171202
"""The character spans of fenced code blocks and inline code spans."""
172203
fences: list[tuple[int, int]] = []
@@ -196,11 +227,17 @@ def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str,
196227
src_dir = posixpath.dirname(src_uri)
197228
code = _code_intervals(markdown)
198229

230+
rejected = ((_ANGLE_LINK, "angle-bracket link destination"), (_REF_DEFINITION, "reference-style link definition"))
231+
for pattern, form in rejected:
232+
for match in pattern.finditer(markdown):
233+
if not _in_code(code, match.start()):
234+
raise _BuildError(f"llms_txt: {form} in {src_uri} is not supported here; use a plain inline link")
235+
199236
def rewrite(match: re.Match[str]) -> str:
200237
opening, target, anchor, title, closing = match.groups()
201238
if target.startswith("#") or _EXTERNAL.match(target):
202239
return match.group(0) # in-page anchor or external URL (https:, mailto:, ...)
203-
if any(start <= match.start() < end for start, end in code):
240+
if _in_code(code, match.start()):
204241
return match.group(0) # illustrative link inside a code block/span
205242
if target.startswith("/"):
206243
raise _BuildError(f"llms_txt: absolute link target {target!r} in {src_uri}: link the .md source instead")
@@ -228,10 +265,9 @@ def _title(src_uri: str, nav_title: str | None, meta: dict[str, Any], body: str)
228265
return nav_title
229266
if isinstance(meta_title := meta.get("title"), str):
230267
return meta_title
231-
match = re.search(r"^\s*# (.+)$", body, flags=re.MULTILINE)
232-
if match is None:
233-
raise _BuildError(f"llms_txt: page {src_uri} has no nav title, no title frontmatter, and no H1")
234-
return match.group(1).strip()
268+
if match := _prose_h1(body):
269+
return match.group(1).strip()
270+
raise _BuildError(f"llms_txt: page {src_uri} has no nav title, no title frontmatter, and no H1")
235271

236272

237273
def generate(site_dir: Path) -> None:
@@ -268,8 +304,10 @@ def generate(site_dir: Path) -> None:
268304
tail = f": {description}" if description else ""
269305
index.append(f"- [{title}]({site_url}{md_uri}){tail}")
270306

271-
# Strip a leading H1 when present: `full` re-titles every page.
272-
body = re.sub(r"\A\s*# .+\n", "", markdown)
307+
# `full` re-titles every page, so drop its first prose H1 (the
308+
# same one `_title` falls back to).
309+
h1 = _prose_h1(markdown)
310+
body = markdown if h1 is None else markdown[: h1.start()] + markdown[h1.end() :]
273311
full += [f"# {title}", "", f"Source: {site_url}{_page_url(src_uri)}", "", body.strip(), ""]
274312
index.append("")
275313

0 commit comments

Comments
 (0)