Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
docs: publish llms.txt and markdown renditions of the docs#3024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
8b97885e41829f6af8e07becc741File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,184 @@ | ||||||
| """Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). | ||||||
| The hook publishes three artifacts into the built site: | ||||||
| - `llms.txt`: a markdown index of the documentation, one link per page, | ||||||
| grouped by nav section. | ||||||
| - a `.md` rendition of every prose page next to its HTML (e.g. | ||||||
| `tutorial/tools/index.md`), which is what the llms.txt links point at. | ||||||
| - `llms-full.txt`: every prose page concatenated for single-fetch consumption. | ||||||
| Page markdown is the source markdown with `--8<--` snippet includes resolved | ||||||
| (so the `docs_src/` code examples appear inline) and relative links rewritten | ||||||
| to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs | ||||||
| with no markdown source, so they are linked as rendered HTML from an Optional | ||||||
| section instead of being embedded. | ||||||
| Incremental builds (`mkdocs build --dirty`) are rejected: they skip unmodified | ||||||
| pages, which would silently truncate the generated artifacts. | ||||||
| """ | ||||||
| from __future__ import annotations | ||||||
| import posixpath | ||||||
| import re | ||||||
| from dataclasses import dataclass, field | ||||||
| from pathlib import Path | ||||||
| from mkdocs.config.defaults import MkDocsConfig | ||||||
| from mkdocs.exceptions import PluginError | ||||||
| from mkdocs.structure.files import File, Files | ||||||
| from mkdocs.structure.nav import Navigation, Section | ||||||
| from mkdocs.structure.pages import Page | ||||||
| # Pages with no markdown source, linked as HTML under "## Optional". | ||||||
| _OPTIONAL_PAGES = [ | ||||||
| ("api/mcp/index.md", "mcp API reference", "Auto-generated API reference for the mcp package (rendered HTML)"), | ||||||
| ( | ||||||
| "api/mcp_types/index.md", | ||||||
| "mcp-types API reference", | ||||||
| "Auto-generated API reference for the mcp-types package (rendered HTML)", | ||||||
| ), | ||||||
| ] | ||||||
| _SNIPPET_LINE = re.compile(r'^(?P<indent>[ \t]*)--8<-- "(?P<path>[^"\n]+)"$', flags=re.MULTILINE) | ||||||
| _MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') | ||||||
| @dataclass | ||||||
| class _State: | ||||||
| page_markdown: dict[str, str] = field(default_factory=dict) | ||||||
| rendition_uris: set[str] = field(default_factory=set) | ||||||
| nav: Navigation | None = None | ||||||
| files: Files | None = None | ||||||
| _state = _State() | ||||||
| def _site_url(config: MkDocsConfig) -> str: | ||||||
| assert config.site_url is not None | ||||||
| return config.site_url.rstrip("/") + "/" | ||||||
| def _md_uri(file: File) -> str: | ||||||
| return re.sub(r"\.html$", ".md", file.dest_uri) | ||||||
| def on_config(config: MkDocsConfig) -> None: | ||||||
| # `mkdocs serve` rebuilds reuse the imported module; start each build clean. | ||||||
| _state.page_markdown.clear() | ||||||
| _state.rendition_uris.clear() | ||||||
| _state.nav = _state.files = None | ||||||
| def on_nav(nav: Navigation, config: MkDocsConfig, files: Files) -> None: | ||||||
| _state.nav = nav | ||||||
| _state.files = files | ||||||
| _state.rendition_uris.update(page.file.src_uri for page in nav.pages if not page.file.src_uri.startswith("api/")) | ||||||
| def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str | None: | ||||||
| if page.file.src_uri not in _state.rendition_uris: | ||||||
| return None | ||||||
| # Same anchor as the pymdownx.snippets `base_path` in mkdocs.yml. | ||||||
| repo_root = Path(config.config_file_path).parent | ||||||
| def include(match: re.Match[str]) -> str: | ||||||
| indent, path = match["indent"], match["path"] | ||||||
| # Mirror the snippets extension's restrict_base_path: reject paths | ||||||
| # that resolve outside the repo root. | ||||||
| resolved_path = (repo_root / path).resolve() | ||||||
| if not resolved_path.is_relative_to(repo_root.resolve()): | ||||||
| raise PluginError(f"llms_txt: snippet path {path!r} in {page.file.src_uri} escapes the repo root") | ||||||
| try: | ||||||
| content = resolved_path.read_text(encoding="utf-8").rstrip("\n") | ||||||
| except OSError as exc: | ||||||
| raise PluginError(f"llms_txt: cannot read snippet {path!r} in {page.file.src_uri}") from exc | ||||||
| # Keep a pointer to the embedded file so readers can find it on disk. | ||||||
| if path.endswith(".py"): | ||||||
| content = f"# {path}\n{content}" | ||||||
| if indent: | ||||||
| content = "\n".join(indent + line if line else line for line in content.split("\n")) | ||||||
| return content | ||||||
| resolved, substitutions = _SNIPPET_LINE.subn(include, markdown) | ||||||
| if substitutions != sum("--8<--" in line for line in markdown.splitlines()): | ||||||
| ||||||
| ifsubstitutions!=sum("--8<--"inlineforlineinmarkdown.splitlines()): | |
| ifre.search(r'^[ \t]*(?!;)-{1,}8<-{1,}(?:$|[ \t]+)', resolved, flags=re.MULTILINE): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one's deliberate. The guard's job is to make any marker-bearing line the hook didn't consume fail the build rather than ship as junk in the renditions — including malformed directives that a "does it look like a directive" regex would miss. The cost is that a page mentioning --8<-- literally fails the build, but no page does today, and the error names the page, so whoever hits it first can adjust the hook with the actual case in front of them. A separate detection regex can drift from the consume regex, which is exactly the silent gap this avoids.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parent comment was wrong here: the guard is intentionally checking for any marker-bearing line the hook didn’t consume, including malformed directives and literal --8<-- text. That keeps the detection in lockstep with the consume regex and avoids a silent drift gap.
Thanks for the feedback! I've saved this as a new learning to improve future reviews.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Snippet parsing is narrower than pymdownx snippets syntax, so valid include directives can fail the build. Expand the regex to accept documented inline marker variants.
Prompt for AI agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also deliberate — the docs only use whole-file includes of complete runnable examples from
docs_src/(the convention described in mkdocs.yml), so the hook supports exactly that pattern and fails the build with an error naming the page for anything else. Section syntax and the block form both die loudly rather than rendering wrong. If we ever adopt other variants, extending the hook then beats carrying an implementation of pymdownx's full semantics that nothing exercises.AI Disclaimer
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That whole-file-only boundary is intentional, so the parent comment was too broad here. This hook should keep failing loud on section/block variants rather than growing full pymdownx snippet semantics.