Skip to content

Commit 67d7593

Browse files
authored
docs: publish llms.txt and markdown renditions of the docs (#3024)
1 parent 8d0f928 commit 67d7593

3 files changed

Lines changed: 193 additions & 1 deletion

File tree

docs/hooks/llms_txt.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/).
2+
3+
The hook publishes three artifacts into the built site:
4+
5+
- `llms.txt`: a markdown index of the documentation, one link per page,
6+
grouped by nav section.
7+
- a `.md` rendition of every prose page next to its HTML (e.g.
8+
`tutorial/tools/index.md`), which is what the llms.txt links point at.
9+
- `llms-full.txt`: every prose page concatenated for single-fetch consumption.
10+
11+
Page markdown is the source markdown with `--8<--` snippet includes resolved
12+
(so the `docs_src/` code examples appear inline) and relative links rewritten
13+
to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs
14+
with no markdown source, so they are linked as rendered HTML from an Optional
15+
section instead of being embedded.
16+
17+
Incremental builds (`mkdocs build --dirty`) are rejected: they skip unmodified
18+
pages, which would silently truncate the generated artifacts.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import posixpath
24+
import re
25+
from dataclasses import dataclass, field
26+
from pathlib import Path
27+
28+
from mkdocs.config.defaults import MkDocsConfig
29+
from mkdocs.exceptions import PluginError
30+
from mkdocs.structure.files import File, Files
31+
from mkdocs.structure.nav import Navigation, Section
32+
from mkdocs.structure.pages import Page
33+
34+
# Pages with no markdown source, linked as HTML under "## Optional".
35+
_OPTIONAL_PAGES = [
36+
("api/mcp/index.md", "mcp API reference", "Auto-generated API reference for the mcp package (rendered HTML)"),
37+
(
38+
"api/mcp_types/index.md",
39+
"mcp-types API reference",
40+
"Auto-generated API reference for the mcp-types package (rendered HTML)",
41+
),
42+
]
43+
44+
_SNIPPET_LINE = re.compile(r'^(?P<indent>[ \t]*)--8<-- "(?P<path>[^"\n]+)"$', flags=re.MULTILINE)
45+
_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))')
46+
47+
48+
@dataclass
49+
class _State:
50+
page_markdown: dict[str, str] = field(default_factory=dict)
51+
rendition_uris: set[str] = field(default_factory=set)
52+
nav: Navigation | None = None
53+
files: Files | None = None
54+
55+
56+
_state = _State()
57+
58+
59+
def _site_url(config: MkDocsConfig) -> str:
60+
assert config.site_url is not None
61+
return config.site_url.rstrip("/") + "/"
62+
63+
64+
def _md_uri(file: File) -> str:
65+
return re.sub(r"\.html$", ".md", file.dest_uri)
66+
67+
68+
def on_config(config: MkDocsConfig) -> None:
69+
# `mkdocs serve` rebuilds reuse the imported module; start each build clean.
70+
_state.page_markdown.clear()
71+
_state.rendition_uris.clear()
72+
_state.nav = _state.files = None
73+
74+
75+
def on_nav(nav: Navigation, config: MkDocsConfig, files: Files) -> None:
76+
_state.nav = nav
77+
_state.files = files
78+
_state.rendition_uris.update(page.file.src_uri for page in nav.pages if not page.file.src_uri.startswith("api/"))
79+
80+
81+
def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str | None:
82+
if page.file.src_uri not in _state.rendition_uris:
83+
return None
84+
85+
# Same anchor as the pymdownx.snippets `base_path` in mkdocs.yml.
86+
repo_root = Path(config.config_file_path).parent
87+
88+
def include(match: re.Match[str]) -> str:
89+
indent, path = match["indent"], match["path"]
90+
# Mirror the snippets extension's restrict_base_path: reject paths
91+
# that resolve outside the repo root.
92+
resolved_path = (repo_root / path).resolve()
93+
if not resolved_path.is_relative_to(repo_root.resolve()):
94+
raise PluginError(f"llms_txt: snippet path {path!r} in {page.file.src_uri} escapes the repo root")
95+
try:
96+
content = resolved_path.read_text(encoding="utf-8").rstrip("\n")
97+
except OSError as exc:
98+
raise PluginError(f"llms_txt: cannot read snippet {path!r} in {page.file.src_uri}") from exc
99+
# Keep a pointer to the embedded file so readers can find it on disk.
100+
if path.endswith(".py"):
101+
content = f"# {path}\n{content}"
102+
if indent:
103+
content = "\n".join(indent + line if line else line for line in content.split("\n"))
104+
return content
105+
106+
resolved, substitutions = _SNIPPET_LINE.subn(include, markdown)
107+
if substitutions != sum("--8<--" in line for line in markdown.splitlines()):
108+
raise PluginError(f"llms_txt: unresolved snippet include in {page.file.src_uri}")
109+
110+
site_url = _site_url(config)
111+
src_dir = posixpath.dirname(page.file.src_uri)
112+
113+
def rewrite(match: re.Match[str]) -> str:
114+
opening, target, anchor, title, closing = match.groups()
115+
if "://" in target:
116+
return match.group(0)
117+
linked = files.get_file_from_path(posixpath.normpath(posixpath.join(src_dir, target)))
118+
if linked is None:
119+
raise PluginError(f"llms_txt: cannot resolve link target {target!r} in {page.file.src_uri}")
120+
# Pages without a markdown rendition (the api/ stubs) link to their HTML instead.
121+
url = _md_uri(linked) if linked.src_uri in _state.rendition_uris else linked.url
122+
return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}"
123+
124+
_state.page_markdown[page.file.src_uri] = _MD_LINK.sub(rewrite, resolved)
125+
return None
126+
127+
128+
def _section_pages(section: Section) -> list[Page]:
129+
pages: list[Page] = []
130+
for child in section.children:
131+
if isinstance(child, Page) and child.file.src_uri in _state.rendition_uris:
132+
pages.append(child)
133+
elif isinstance(child, Section):
134+
pages.extend(_section_pages(child))
135+
return pages
136+
137+
138+
def on_post_build(config: MkDocsConfig) -> None:
139+
assert _state.nav is not None and _state.files is not None
140+
missing = _state.rendition_uris - _state.page_markdown.keys()
141+
if missing:
142+
raise PluginError(f"llms_txt: pages skipped this build (is this a --dirty build?): {sorted(missing)}")
143+
144+
site_dir = Path(config.site_dir)
145+
site_url = _site_url(config)
146+
147+
top_level = [
148+
item for item in _state.nav.items if isinstance(item, Page) and item.file.src_uri in _state.rendition_uris
149+
]
150+
sections: list[tuple[str, list[Page]]] = [("Docs", top_level)] if top_level else []
151+
for item in _state.nav.items:
152+
if isinstance(item, Section):
153+
pages = _section_pages(item)
154+
if pages:
155+
sections.append((item.title, pages))
156+
157+
index = [f"# {config.site_name}", "", f"> {config.site_description}", ""]
158+
full: list[str] = []
159+
for title, pages in sections:
160+
index += [f"## {title}", ""]
161+
for page in pages:
162+
markdown = _state.page_markdown[page.file.src_uri]
163+
(site_dir / _md_uri(page.file)).write_text(markdown, encoding="utf-8")
164+
165+
description = page.meta.get("description")
166+
tail = f": {description}" if description else ""
167+
index.append(f"- [{page.title}]({site_url}{_md_uri(page.file)}){tail}")
168+
169+
body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown)
170+
if not h1_found:
171+
raise PluginError(f"llms_txt: page {page.file.src_uri} does not start with an H1")
172+
full += [f"# {page.title}", "", f"Source: {page.canonical_url}", "", body.strip(), ""]
173+
index.append("")
174+
175+
index += ["## Optional", ""]
176+
for src_uri, title, description in _OPTIONAL_PAGES:
177+
linked = _state.files.get_file_from_path(src_uri)
178+
if linked is None:
179+
raise PluginError(f"llms_txt: optional page {src_uri} not found")
180+
index.append(f"- [{title}]({site_url}{linked.url}): {description}")
181+
index.append("")
182+
183+
(site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8")
184+
(site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8")

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,6 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
9191
* The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time.
9292
* Migrating from v1? Start with the **[Migration Guide](migration.md)**.
9393
* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source.
94+
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
95+
[llms.txt](https://py.sdk.modelcontextprotocol.io/v2/llms.txt) is an index of the pages, and
96+
[llms-full.txt](https://py.sdk.modelcontextprotocol.io/v2/llms-full.txt) contains every page in a single file.

mkdocs.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,12 @@ markdown_extensions:
115115
- pymdownx.superfences
116116
# Code examples live as complete, importable, tested files under `docs_src/`
117117
# and are included into pages with `--8<-- "docs_src/<chapter>/tutorialNNN.py"`
118-
# (resolved against the repo root, the extension's default base_path).
118+
# (resolved against the repo root regardless of the build's working
119+
# directory; the extension's default base_path is the CWD).
119120
# `check_paths: true` + `strict: true` turn a renamed/deleted example into a
120121
# build failure instead of a silently empty code block.
121122
- pymdownx.snippets:
123+
base_path: !relative $config_dir
122124
check_paths: true
123125
- pymdownx.tilde
124126
- pymdownx.inlinehilite
@@ -146,6 +148,9 @@ watch:
146148
- src
147149
- docs_src
148150

151+
hooks:
152+
- docs/hooks/llms_txt.py
153+
149154
plugins:
150155
- search
151156
- social:

0 commit comments

Comments
 (0)