|
1 | 1 | """Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). |
2 | 2 |
|
3 | 3 | Zensical has no equivalent of MkDocs' build hooks, so this runs as a standalone |
4 | | -post-build step over the source tree (``mkdocs.yml`` + ``docs/``) and writes |
5 | | -three kinds of artifact into the built ``site/``: |
| 4 | +post-build step over the source tree (`mkdocs.yml` + `docs/`) and writes |
| 5 | +three kinds of artifact into the built `site/`: |
6 | 6 |
|
7 | | -- ``llms.txt``: a markdown index of the documentation, one link per page, |
| 7 | +- `llms.txt`: a markdown index of the documentation, one link per page, |
8 | 8 | grouped by nav section. |
9 | | -- a ``.md`` rendition of every prose page next to its HTML (e.g. |
10 | | - ``servers/tools/index.md``), which is what the llms.txt links point at. |
11 | | -- ``llms-full.txt``: every prose page concatenated for single-fetch consumption. |
| 9 | +- a `.md` rendition of every prose page next to its HTML (e.g. |
| 10 | + `servers/tools/index.md`), which is what the llms.txt links point at. |
| 11 | +- `llms-full.txt`: every prose page concatenated for single-fetch consumption. |
12 | 12 |
|
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 |
| 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 | 16 | with no prose source, so they are linked as rendered HTML from an Optional |
17 | 17 | section instead of being embedded. |
18 | 18 |
|
|
44 | 44 |
|
45 | 45 | _SNIPPET_LINE = re.compile(r'^(?P<indent>[ \t]*)--8<-- "(?P<path>[^"\n]+)"$', flags=re.MULTILINE) |
46 | 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 | 51 |
|
48 | 52 |
|
49 | 53 | class _BuildError(Exception): |
50 | 54 | """A recoverable problem that should fail the docs build with a clear message.""" |
51 | 55 |
|
52 | 56 |
|
53 | 57 | def _dest_md_uri(src_uri: str) -> str: |
54 | | - """Map a source page (``servers/tools.md``) to its built rendition (``servers/tools/index.md``).""" |
| 58 | + """Map a source page (`servers/tools.md`) to its built rendition (`servers/tools/index.md`).""" |
55 | 59 | path = PurePosixPath(src_uri) |
56 | 60 | directory = path.parent if path.stem == "index" else path.parent / path.stem |
57 | 61 | return "index.md" if directory == PurePosixPath(".") else f"{directory}/index.md" |
58 | 62 |
|
59 | 63 |
|
60 | 64 | def _page_url(src_uri: str) -> str: |
61 | | - """The directory URL of a page relative to the site root (``servers/tools/``, ``""`` for the home page).""" |
| 65 | + """The directory URL of a page relative to the site root (`servers/tools/`, `""` for the home page).""" |
62 | 66 | return _dest_md_uri(src_uri).removesuffix("index.md") |
63 | 67 |
|
64 | 68 |
|
| 69 | +def _collect_pages(items: list, prose: dict[str, str | None]) -> list[str]: |
| 70 | + """Collect the prose pages under a nav subtree, in nav order. |
| 71 | +
|
| 72 | + Records each page in `prose` (src_uri -> nav title, or `None` to fall |
| 73 | + 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. |
| 76 | + """ |
| 77 | + pages: list[str] = [] |
| 78 | + for entry in items: |
| 79 | + title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) |
| 80 | + if isinstance(value, list): |
| 81 | + pages.extend(_collect_pages(value, prose)) |
| 82 | + elif value.endswith(".md") and not value.startswith("api/"): |
| 83 | + prose[value] = title |
| 84 | + pages.append(value) |
| 85 | + return pages |
| 86 | + |
| 87 | + |
65 | 88 | def _walk_nav(nav: list, prose: dict[str, str | None], sections: list[tuple[str, list[str]]]) -> list[str]: |
66 | 89 | """Split the nav into a flat list of top-level pages and titled sections. |
67 | 90 |
|
68 | | - Populates ``prose`` (src_uri -> nav title, or ``None`` to fall back to the |
69 | | - page's H1) and ``sections`` ((title, [src_uri]) in nav order), and returns |
70 | | - the top-level page src_uris. API and section-index bare entries are skipped. |
| 91 | + Populates `sections` ((title, [src_uri]) in nav order) and returns the |
| 92 | + top-level page src_uris; page collection itself is `_collect_pages`. |
71 | 93 | """ |
72 | 94 | top_level: list[str] = [] |
73 | 95 | for entry in nav: |
74 | 96 | title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) |
75 | 97 | if isinstance(value, list): |
76 | | - pages = _section_pages(value, prose) |
| 98 | + pages = _collect_pages(value, prose) |
77 | 99 | if pages: |
78 | 100 | assert title is not None |
79 | 101 | sections.append((title, pages)) |
80 | | - elif value.endswith(".md") and not value.startswith("api/"): |
81 | | - prose[value] = title |
82 | | - top_level.append(value) |
| 102 | + else: |
| 103 | + top_level.extend(_collect_pages([entry], prose)) |
83 | 104 | return top_level |
84 | 105 |
|
85 | 106 |
|
86 | | -def _section_pages(items: list, prose: dict[str, str | None]) -> list[str]: |
87 | | - pages: list[str] = [] |
88 | | - for entry in items: |
89 | | - title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry) |
90 | | - if isinstance(value, list): |
91 | | - pages.extend(_section_pages(value, prose)) |
92 | | - elif value.endswith(".md") and not value.startswith("api/"): |
93 | | - prose[value] = title |
94 | | - pages.append(value) |
95 | | - return pages |
96 | | - |
97 | | - |
98 | 107 | def _resolve_snippets(markdown: str, src_uri: str) -> str: |
99 | 108 | def include(match: re.Match[str]) -> str: |
100 | 109 | indent, path = match["indent"], match["path"] |
@@ -122,6 +131,13 @@ def include(match: re.Match[str]) -> str: |
122 | 131 | def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, str | None]) -> str: |
123 | 132 | src_dir = posixpath.dirname(src_uri) |
124 | 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}") |
| 140 | + |
125 | 141 | def rewrite(match: re.Match[str]) -> str: |
126 | 142 | opening, target, anchor, title, closing = match.groups() |
127 | 143 | if "://" in target: |
@@ -180,6 +196,12 @@ def generate(site_dir: Path) -> None: |
180 | 196 | index.append("") |
181 | 197 |
|
182 | 198 | 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. |
| 202 | + 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)}") |
183 | 205 | for src_uri, title, description in _OPTIONAL_PAGES: |
184 | 206 | if not (DOCS / src_uri).exists(): |
185 | 207 | raise _BuildError(f"llms_txt: optional page {src_uri} not found (run gen_ref_pages first)") |
|
0 commit comments