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
1919Usage:
2020 python scripts/docs/llms_txt.py --site-dir site
2626import posixpath
2727import re
2828from pathlib import Path , PurePosixPath
29+ from typing import Any
2930
3031import yaml
3132
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
5369class _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+
69107def _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+
131195def _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
164237def 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
225308if __name__ == "__main__" :
0 commit comments