Skip to content

Commit 0fc3242

Browse files
committed
docs: replace the llms_txt link regexes with a total classifier
Every markdown link target is now classified in one place instead of pattern-matched by shape, so no link can dodge validation by its spelling: scheme-prefixed targets (including mailto:) are external, targets inside fenced blocks or code spans are illustrative, and everything else must resolve to a file under docs/ (containment enforced, directory-style and absolute targets rejected, assets rewritten to absolute URLs). Fences are scanned line-based per CommonMark (closer may be longer than the opener, unclosed fences run to EOF) and code spans cannot cross a blank line, so a stray backtick cannot exempt later paragraphs from validation. Frontmatter is stripped the way the old hook's meta extraction did, restoring title and description frontmatter support in llms.txt. Output is byte-identical for the current docs corpus.
1 parent b0dc3d9 commit 0fc3242

1 file changed

Lines changed: 124 additions & 41 deletions

File tree

scripts/docs/llms_txt.py

Lines changed: 124 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
`servers/tools/index.md`), which is what the llms.txt links point at.
1111
- `llms-full.txt`: every prose page concatenated for single-fetch consumption.
1212
13-
Page markdown is the source markdown with `--8<--` snippet includes resolved
14-
(so the `docs_src/` code examples appear inline) and relative links rewritten
15-
to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs
16-
with no prose source, so they are linked as rendered HTML from an Optional
17-
section instead of being embedded.
13+
Page markdown is the source markdown with YAML frontmatter stripped, `--8<--`
14+
snippet includes resolved (so the `docs_src/` code examples appear inline) and
15+
relative links rewritten to absolute URLs. The API reference pages under
16+
`api/` are mkdocstrings stubs with no prose source, so they are linked as
17+
rendered HTML from an Optional section instead of being embedded.
1818
1919
Usage:
2020
python scripts/docs/llms_txt.py --site-dir site
@@ -26,6 +26,7 @@
2626
import posixpath
2727
import re
2828
from pathlib import Path, PurePosixPath
29+
from typing import Any
2930

3031
import yaml
3132

@@ -43,11 +44,26 @@
4344
]
4445

4546
_SNIPPET_LINE = re.compile(r'^(?P<indent>[ \t]*)--8<-- "(?P<path>[^"\n]+)"$', flags=re.MULTILINE)
46-
_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))')
47-
# Relative link/image targets with a non-markdown file extension. Zensical's
48-
# link validation only covers .md targets (a missing image builds green even
49-
# under --strict; MkDocs failed the build), so these are validated here.
50-
_ASSET_LINK = re.compile(r'\]\(([^)\s#]+\.(?!md[)#\s])[a-zA-Z0-9]{1,4})(?:#[^)\s]*)?( +"[^"]*")?\)')
47+
# Every markdown link/image target: `](target#anchor "title")`. Each target is
48+
# classified in `_rewrite_links` — there is deliberately no shape-based
49+
# pre-filter here, so no link can dodge validation by its spelling. Zensical's
50+
# own link validation only covers .md targets (a missing image or
51+
# directory-style link builds green even under --strict; MkDocs failed the
52+
# build), so everything else is validated here.
53+
_LINK = re.compile(r'(\]\()([^)\s]+?)(#[^)\s]*)?( +"[^"]*")?(\))')
54+
# A scheme-prefixed target (https:, mailto:, tel:, ...) is external — the
55+
# `://` shorthand misses scheme-only URIs like mailto:.
56+
_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:")
57+
# Fenced code blocks and inline code spans: their content is inert in the
58+
# rendered HTML, so links inside them are illustrative text, neither validated
59+
# nor rewritten. Fences are matched line-based in `_code_intervals` (closer at
60+
# least as long as the opener, unclosed runs to EOF, per CommonMark) and spans
61+
# only in the text between fences; a span cannot cross a blank line, so a
62+
# stray unpaired backtick cannot swallow the paragraphs (and links) after it.
63+
_FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})")
64+
_CODE_SPAN = re.compile(r"(?s)(?<!`)(`+)(?!`)((?:(?!\n[ \t]*\n).)+?)(?<!`)\1(?!`)")
65+
# A leading YAML frontmatter block, as MkDocs/Zensical parse it (mkdocs.utils.meta).
66+
_FRONTMATTER = re.compile(r"\A---[ \t]*\n(?P<block>.*?)^(?:---|\.\.\.)[ \t]*(?:\n|\Z)", flags=re.MULTILINE | re.DOTALL)
5167

5268

5369
class _BuildError(Exception):
@@ -66,20 +82,43 @@ def _page_url(src_uri: str) -> str:
6682
return _dest_md_uri(src_uri).removesuffix("index.md")
6783

6884

85+
def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
86+
"""Split a leading YAML frontmatter block from a page (mirrors mkdocs.utils.meta).
87+
88+
Hand-rolled deliberately: mkdocs is only a transitive dependency of this
89+
toolchain, so the pipeline must not import it. The hook this replaced ran
90+
post-frontmatter-extraction, so renditions never contained frontmatter and
91+
`meta` fed the page title and llms.txt description. A leading block that
92+
isn't a YAML mapping is page content, not frontmatter; an empty block is
93+
frontmatter with no meta.
94+
"""
95+
match = _FRONTMATTER.match(text)
96+
if match is None:
97+
return {}, text
98+
try:
99+
meta = yaml.safe_load(match["block"])
100+
except yaml.YAMLError:
101+
return {}, text
102+
if meta is not None and not isinstance(meta, dict):
103+
return {}, text
104+
return meta or {}, text[match.end() :].lstrip("\n")
105+
106+
69107
def _collect_pages(items: list, prose: dict[str, str | None]) -> list[str]:
70108
"""Collect the prose pages under a nav subtree, in nav order.
71109
72110
Records each page in `prose` (src_uri -> nav title, or `None` to fall
73111
back to the page's H1). This is the single owner of the prose-page rule:
74-
a page entry counts when it ends in .md and is not part of the generated
75-
API reference.
112+
a page entry counts when it is a local .md path (external URLs render as
113+
outbound nav links and are omitted, as the MkDocs pipeline did) and is not
114+
part of the generated API reference.
76115
"""
77116
pages: list[str] = []
78117
for entry in items:
79118
title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry)
80119
if isinstance(value, list):
81120
pages.extend(_collect_pages(value, prose))
82-
elif value.endswith(".md") and not value.startswith("api/"):
121+
elif not _EXTERNAL.match(value) and value.endswith(".md") and not value.startswith("api/"):
83122
prose[value] = title
84123
pages.append(value)
85124
return pages
@@ -128,40 +167,77 @@ def include(match: re.Match[str]) -> str:
128167
return resolved
129168

130169

170+
def _code_intervals(markdown: str) -> list[tuple[int, int]]:
171+
"""The character spans of fenced code blocks and inline code spans."""
172+
fences: list[tuple[int, int]] = []
173+
opener = ""
174+
start = offset = 0
175+
for line in markdown.splitlines(keepends=True):
176+
if not opener:
177+
if match := _FENCE.match(line):
178+
opener, start = match[1], offset
179+
elif (stripped := line.strip()).startswith(opener) and set(stripped) == {opener[0]}:
180+
fences.append((start, offset + len(line)))
181+
opener = ""
182+
offset += len(line)
183+
if opener:
184+
fences.append((start, len(markdown)))
185+
186+
intervals = list(fences)
187+
previous_end = 0
188+
for fence_start, fence_end in [*fences, (len(markdown), len(markdown))]:
189+
segment = markdown[previous_end:fence_start]
190+
intervals += [(previous_end + m.start(), previous_end + m.end()) for m in _CODE_SPAN.finditer(segment)]
191+
previous_end = fence_end
192+
return intervals
193+
194+
131195
def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, str | None]) -> str:
132196
src_dir = posixpath.dirname(src_uri)
133-
134-
for match in _ASSET_LINK.finditer(markdown):
135-
target = match.group(1)
136-
if "://" in target:
137-
continue
138-
if not (DOCS / posixpath.normpath(posixpath.join(src_dir, target))).exists():
139-
raise _BuildError(f"llms_txt: cannot resolve asset link target {target!r} in {src_uri}")
197+
code = _code_intervals(markdown)
140198

141199
def rewrite(match: re.Match[str]) -> str:
142200
opening, target, anchor, title, closing = match.groups()
143-
if "://" in target:
144-
return match.group(0)
201+
if target.startswith("#") or _EXTERNAL.match(target):
202+
return match.group(0) # in-page anchor or external URL (https:, mailto:, ...)
203+
if any(start <= match.start() < end for start, end in code):
204+
return match.group(0) # illustrative link inside a code block/span
205+
if target.startswith("/"):
206+
raise _BuildError(f"llms_txt: absolute link target {target!r} in {src_uri}: link the .md source instead")
145207
linked = posixpath.normpath(posixpath.join(src_dir, target))
146-
if not (DOCS / linked).exists():
208+
if linked == ".." or linked.startswith("../"):
209+
raise _BuildError(f"llms_txt: link target {target!r} in {src_uri} escapes docs/")
210+
if (DOCS / linked).is_dir():
211+
raise _BuildError(
212+
f"llms_txt: directory-style link target {target!r} in {src_uri}: link the page's .md source instead"
213+
)
214+
if not (DOCS / linked).is_file():
147215
raise _BuildError(f"llms_txt: cannot resolve link target {target!r} in {src_uri}")
148-
# Pages without a markdown rendition (the api/ stubs) link to their HTML instead.
149-
url = _dest_md_uri(linked) if linked in prose else _page_url(linked)
216+
if linked.endswith(".md"):
217+
# Pages without a markdown rendition (the api/ stubs) link to their HTML instead.
218+
url = _dest_md_uri(linked) if linked in prose else _page_url(linked)
219+
else:
220+
url = linked # assets are published at their docs-relative path
150221
return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}"
151222

152-
return _MD_LINK.sub(rewrite, markdown)
223+
return _LINK.sub(rewrite, markdown)
153224

154225

155-
def _title(src_uri: str, nav_title: str | None, body: str) -> str:
226+
def _title(src_uri: str, nav_title: str | None, meta: dict[str, Any], body: str) -> str:
156227
if nav_title is not None:
157228
return nav_title
229+
if isinstance(meta_title := meta.get("title"), str):
230+
return meta_title
158231
match = re.search(r"^\s*# (.+)$", body, flags=re.MULTILINE)
159232
if match is None:
160-
raise _BuildError(f"llms_txt: page {src_uri} has no nav title and no H1")
233+
raise _BuildError(f"llms_txt: page {src_uri} has no nav title, no title frontmatter, and no H1")
161234
return match.group(1).strip()
162235

163236

164237
def generate(site_dir: Path) -> None:
238+
if not (DOCS / "api").is_dir():
239+
raise _BuildError("llms_txt: docs/api not found (run gen_ref_pages first)")
240+
165241
config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8"))
166242
site_url = config["site_url"].rstrip("/") + "/"
167243

@@ -171,8 +247,9 @@ def generate(site_dir: Path) -> None:
171247
ordered: list[tuple[str, list[str]]] = ([("Docs", top_level)] if top_level else []) + sections
172248

173249
rendered: dict[str, str] = {}
250+
metas: dict[str, dict[str, Any]] = {}
174251
for src_uri in prose:
175-
markdown = (DOCS / src_uri).read_text(encoding="utf-8")
252+
metas[src_uri], markdown = _split_frontmatter((DOCS / src_uri).read_text(encoding="utf-8"))
176253
markdown = _resolve_snippets(markdown, src_uri)
177254
rendered[src_uri] = _rewrite_links(markdown, src_uri, site_url, prose)
178255

@@ -186,25 +263,29 @@ def generate(site_dir: Path) -> None:
186263
(site_dir / md_uri).parent.mkdir(parents=True, exist_ok=True)
187264
(site_dir / md_uri).write_text(markdown, encoding="utf-8")
188265

189-
title = _title(src_uri, prose[src_uri], markdown)
190-
index.append(f"- [{title}]({site_url}{md_uri})")
266+
title = _title(src_uri, prose[src_uri], metas[src_uri], markdown)
267+
description = metas[src_uri].get("description")
268+
tail = f": {description}" if description else ""
269+
index.append(f"- [{title}]({site_url}{md_uri}){tail}")
191270

192-
body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown)
193-
if not h1_found:
194-
raise _BuildError(f"llms_txt: page {src_uri} does not start with an H1")
271+
# Strip a leading H1 when present: `full` re-titles every page.
272+
body = re.sub(r"\A\s*# .+\n", "", markdown)
195273
full += [f"# {title}", "", f"Source: {site_url}{_page_url(src_uri)}", "", body.strip(), ""]
196274
index.append("")
197275

198276
index += ["## Optional", ""]
199-
# Every generated package index must be listed: a package added to
200-
# gen_ref_pages.PACKAGES without an entry here would be published on the
201-
# site but silently missing from llms.txt.
277+
# _OPTIONAL_PAGES must match the generated package indexes exactly: a
278+
# package added to gen_ref_pages.PACKAGES without an entry here would be
279+
# published on the site but silently missing from llms.txt, and a stale
280+
# entry would link a page that no longer exists.
202281
generated = {f"api/{path.name}/index.md" for path in (DOCS / "api").iterdir() if path.is_dir()}
203-
if unlisted := generated - {src_uri for src_uri, _, _ in _OPTIONAL_PAGES}:
204-
raise _BuildError(f"llms_txt: generated package indexes missing from _OPTIONAL_PAGES: {sorted(unlisted)}")
282+
listed = {src_uri for src_uri, _, _ in _OPTIONAL_PAGES}
283+
if generated != listed:
284+
raise _BuildError(
285+
f"llms_txt: _OPTIONAL_PAGES out of sync with docs/api:"
286+
f" missing {sorted(generated - listed)}, stale {sorted(listed - generated)}"
287+
)
205288
for src_uri, title, description in _OPTIONAL_PAGES:
206-
if not (DOCS / src_uri).exists():
207-
raise _BuildError(f"llms_txt: optional page {src_uri} not found (run gen_ref_pages first)")
208289
index.append(f"- [{title}]({site_url}{_page_url(src_uri)}): {description}")
209290
index.append("")
210291

@@ -220,6 +301,8 @@ def main() -> None:
220301
generate(Path(args.site_dir))
221302
except _BuildError as exc:
222303
raise SystemExit(str(exc)) from exc
304+
except OSError as exc:
305+
raise SystemExit(f"llms_txt: {exc}") from exc
223306

224307

225308
if __name__ == "__main__":

0 commit comments

Comments
 (0)