Skip to content

Commit a05482d

Browse files
committed
docs: catch the failure modes zensical builds green
Unresolvable cross-references render as literal bracket text without a diagnostic, even under --strict, and link validation skips non-markdown targets entirely (a missing image ships silently; MkDocs failed both). Grep the built site for the literal-bracket signature after the build, and validate relative asset targets while llms_txt walks the prose. Also fail when a generated package index is missing from llms.txt's Optional section, so adding a package to gen_ref_pages cannot silently under-publish.
1 parent eb1ffae commit a05482d

2 files changed

Lines changed: 63 additions & 30 deletions

File tree

scripts/docs/build.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,15 @@ cd "$SCRIPT_DIR/../.."
2323
uv sync --frozen --group docs
2424
uv run --frozen --no-sync python scripts/docs/build_config.py
2525
uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict
26+
27+
# Zensical renders an unresolvable [`name`][identifier] cross-reference as
28+
# literal bracket text and stays green even under --strict (mkdocs-autorefs
29+
# used to warn, and strict mode failed). The generated API index relies on
30+
# such references, so catch the failure mode here.
31+
if grep -rn --include='*.html' -F '[<code>' site/ > /dev/null; then
32+
echo "error: unresolved cross-references rendered as literal text:" >&2
33+
grep -rn --include='*.html' -Fo -m 1 '[<code>' site/ | head -20 >&2
34+
exit 1
35+
fi
36+
2637
uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site

scripts/docs/llms_txt.py

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/).
22
33
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/`:
66
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,
88
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.
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
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
1616
with no prose source, so they are linked as rendered HTML from an Optional
1717
section instead of being embedded.
1818
@@ -44,57 +44,66 @@
4444

4545
_SNIPPET_LINE = re.compile(r'^(?P<indent>[ \t]*)--8<-- "(?P<path>[^"\n]+)"$', flags=re.MULTILINE)
4646
_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]*)?( +"[^"]*")?\)')
4751

4852

4953
class _BuildError(Exception):
5054
"""A recoverable problem that should fail the docs build with a clear message."""
5155

5256

5357
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`)."""
5559
path = PurePosixPath(src_uri)
5660
directory = path.parent if path.stem == "index" else path.parent / path.stem
5761
return "index.md" if directory == PurePosixPath(".") else f"{directory}/index.md"
5862

5963

6064
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)."""
6266
return _dest_md_uri(src_uri).removesuffix("index.md")
6367

6468

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+
6588
def _walk_nav(nav: list, prose: dict[str, str | None], sections: list[tuple[str, list[str]]]) -> list[str]:
6689
"""Split the nav into a flat list of top-level pages and titled sections.
6790
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`.
7193
"""
7294
top_level: list[str] = []
7395
for entry in nav:
7496
title, value = next(iter(entry.items())) if isinstance(entry, dict) else (None, entry)
7597
if isinstance(value, list):
76-
pages = _section_pages(value, prose)
98+
pages = _collect_pages(value, prose)
7799
if pages:
78100
assert title is not None
79101
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))
83104
return top_level
84105

85106

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-
98107
def _resolve_snippets(markdown: str, src_uri: str) -> str:
99108
def include(match: re.Match[str]) -> str:
100109
indent, path = match["indent"], match["path"]
@@ -122,6 +131,13 @@ def include(match: re.Match[str]) -> str:
122131
def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, str | None]) -> str:
123132
src_dir = posixpath.dirname(src_uri)
124133

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+
125141
def rewrite(match: re.Match[str]) -> str:
126142
opening, target, anchor, title, closing = match.groups()
127143
if "://" in target:
@@ -180,6 +196,12 @@ def generate(site_dir: Path) -> None:
180196
index.append("")
181197

182198
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)}")
183205
for src_uri, title, description in _OPTIONAL_PAGES:
184206
if not (DOCS / src_uri).exists():
185207
raise _BuildError(f"llms_txt: optional page {src_uri} not found (run gen_ref_pages first)")

0 commit comments

Comments
 (0)