docs: replace MkDocs with Zensical (#3073)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-07-10 13:48:46 +02:00 • committed by GitHub
commit 4fc8882c02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1185 additions and 612 deletions

View file

@ -1,12 +1,42 @@
/* Sidebar hierarchy + density for MkDocs Material 9.7.x.
All rules scoped to the desktop sidebar breakpoint (>= 76.25em), matching
Material's own scoping for navigation.sections, so the mobile drill-down
drawer keeps stock styling. Colors use Material tokens, so the light and
slate schemes both work without extra palette handling. */
/* Sidebar hierarchy + density for Zensical's UI (Material-compatible md-*
DOM, but different stock spacing: nav links are 8px-radius pills with
7px 16px padding). All rules scoped to the desktop sidebar breakpoint
(>= 76.25em) so the mobile drill-down drawer keeps stock styling. Colors
use the md-* tokens, so the light and slate schemes both work without
extra palette handling. */
@media screen and (min-width: 76.25em) {
/* Section labels: smaller, uppercase, letter-spaced, muted. Covers both the
clickable index-page headers and the bare API Reference label. */
/* The sidebar is one coordinate system derived from the pill inset:
every row — page links, group rows, section labels — is a direct
.md-nav__link child of its item with the same 10px horizontal padding,
so all text shares one column, and hover/active pills always paint
10px of breathing room inside the scroll container (never clipped).
The padding lives on the elements Zensical paints hover/active pills
on (.md-nav__link[href] anchors and [for] labels — leaf links, bare
section labels, and the inner anchor of an .md-nav__container
wrapper); wrappers stay geometry-neutral, as stock. The 10px inset
also stays >= the 0.4rem pill radius, so the corner curve never
crowds the text. Vertical rhythm has a single knob: the nav list's
flex gap (stock 0.2rem reads airy; 2px matches the density the site
shipped with on Material, ~30px row pitch). */
.md-sidebar--primary .md-nav__list {
gap: 2px;
}
.md-sidebar--primary .md-nav__item > .md-nav__link:not(.md-nav__container),
.md-sidebar--primary .md-nav__container > .md-nav__link {
padding: 3px 10px;
margin: 0;
}
.md-sidebar--primary .md-nav__item > .md-nav__container {
padding: 0;
margin: 0;
}
/* Section labels: typography only — geometry comes from the row rule
above, so no specificity coordination is needed. */
.md-sidebar--primary .md-nav__item--section {
margin: 0.8em 0;
}
.md-sidebar--primary .md-nav__item--section > .md-nav__link {
font-size: 0.62rem;
font-weight: 700;
@ -15,39 +45,19 @@
color: var(--md-default-fg-color--light);
}
/* Indent section children and hang a guide line. Material outdents section
children with [dir=ltr] ... margin-left: -0.6rem, which is why they sit
flush under the header; restoring the margin re-exposes the stock 0.6rem
list padding. The logical property ties Material's physical one at
(0,3,0) specificity and wins on source order (extra_css loads last),
while staying direction-agnostic. */
/* Guide lines: 12px from the item box = 2px right of the label text
(which sits at box + 10px pill inset); children indent past them. */
.md-sidebar--primary .md-nav__item--section > .md-nav {
margin-inline-start: 0.1rem;
margin-inline-start: 12px;
border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest);
}
/* Same guide line for collapsible groups inside the API Reference subtree
(section items also carry --nested, so exclude them). */
.md-sidebar--primary .md-nav__item--nested:not(.md-nav__item--section) > .md-nav {
margin-inline-start: 12px;
border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest);
}
/* Tighten vertical rhythm (stock: 0.625em link margins, 1.25em sections).
The child combinator keeps this off anchors inside md-nav__container,
which carry their own margin-top: 0 stock rule. */
.md-sidebar--primary .md-nav__item > .md-nav__link {
margin-top: 0.45em;
}
.md-sidebar--primary .md-nav__item--section {
margin: 1em 0;
}
.md-sidebar--primary .md-nav__item--section > .md-nav__link {
margin-top: 0;
}
/* The current page stands out from its siblings. 700 because Material only
loads Inter at 300/400/700; a 600 would silently substitute the 700 face
anyway, but render lighter on the system-font fallback stack. */
/* The current page stands out from its siblings (on top of the stock
pill highlight). 700 because only Inter 300/400/700 are loaded. */
.md-sidebar--primary .md-nav__link--active {
font-weight: 700;
}
@ -60,10 +70,33 @@
}
}
/* Headings: Material's 300-weight light-gray defaults read washed out; use
the full foreground color and a solid weight instead. 700, not 600: the
Google Fonts request only carries Inter 300/400/700 (see the nav__link
note above). */
/* Dark scheme: Zensical's slate canvas is near-black (hsla(225,15%,5%)),
harsher than the Material slate this site shipped with; restore that
blue-grey. Code blocks and other surfaces keep Zensical's own tokens. */
@media screen {
[data-md-color-scheme="slate"] {
--md-default-bg-color: #1e2129;
}
}
/* Inline code inside admonitions: the chip token is an absolute dark
surface designed for the page canvas, so on a tinted admonition panel it
sits as an opaque slab (Zensical's own docs share this bug). Re-tint it
tone-on-tone instead — translucent foreground, composited over whatever
the panel color is — the same pattern Starlight and Docusaurus ship for
code inside callouts. Prose chips keep the block-matching dark surface;
block code inside admonitions keeps its own surface too. The first
declaration is the fallback where color-mix is unsupported. */
.md-typeset .admonition :not(pre) > code,
.md-typeset details :not(pre) > code {
background-color: var(--md-default-fg-color--lightest);
background-color: color-mix(in srgb, currentcolor 11%, transparent);
color: inherit;
}
/* Headings: the 300-weight light-gray defaults read washed out; use the
full foreground color and a solid weight instead. 700, not 600: only
Inter 300/400/700 are loaded (see the nav__link note above). */
.md-typeset h1,
.md-typeset h2 {
font-weight: 700;

View file

@ -1,40 +0,0 @@
"""Generate the code reference pages and navigation."""
from pathlib import Path
import mkdocs_gen_files
nav = mkdocs_gen_files.Nav()
root = Path(__file__).parent.parent.parent
src = root / "src"
# `src/mcp-types` is a distribution directory, not an import package, so each
# package's dotted module path is taken relative to its own parent: deriving it
# from `src/` would emit the unimportable `mcp-types.mcp_types.*`.
for package in (src / "mcp", src / "mcp-types" / "mcp_types"):
base = package.parent
for path in sorted(package.rglob("*.py")):
module_path = path.relative_to(base).with_suffix("")
doc_path = path.relative_to(base).with_suffix(".md")
full_doc_path = Path("api", doc_path)
parts = tuple(module_path.parts)
if parts[-1] == "__init__":
parts = parts[:-1]
doc_path = doc_path.with_name("index.md")
full_doc_path = full_doc_path.with_name("index.md")
elif parts[-1].startswith("_"):
continue
nav[parts] = doc_path.as_posix()
with mkdocs_gen_files.open(full_doc_path, "w") as fd:
ident = ".".join(parts)
fd.write(f"::: {ident}")
mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root))
with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file:
nav_file.writelines(nav.build_literate_nav())

View file

@ -1,184 +0,0 @@
"""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.
`servers/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()):
raise PluginError(f"llms_txt: unresolved snippet include in {page.file.src_uri}")
site_url = _site_url(config)
src_dir = posixpath.dirname(page.file.src_uri)
def rewrite(match: re.Match[str]) -> str:
opening, target, anchor, title, closing = match.groups()
if "://" in target:
return match.group(0)
linked = files.get_file_from_path(posixpath.normpath(posixpath.join(src_dir, target)))
if linked is None:
raise PluginError(f"llms_txt: cannot resolve link target {target!r} in {page.file.src_uri}")
# Pages without a markdown rendition (the api/ stubs) link to their HTML instead.
url = _md_uri(linked) if linked.src_uri in _state.rendition_uris else linked.url
return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}"
_state.page_markdown[page.file.src_uri] = _MD_LINK.sub(rewrite, resolved)
return None
def _section_pages(section: Section) -> list[Page]:
pages: list[Page] = []
for child in section.children:
if isinstance(child, Page) and child.file.src_uri in _state.rendition_uris:
pages.append(child)
elif isinstance(child, Section):
pages.extend(_section_pages(child))
return pages
def on_post_build(config: MkDocsConfig) -> None:
assert _state.nav is not None and _state.files is not None
missing = _state.rendition_uris - _state.page_markdown.keys()
if missing:
raise PluginError(f"llms_txt: pages skipped this build (is this a --dirty build?): {sorted(missing)}")
site_dir = Path(config.site_dir)
site_url = _site_url(config)
top_level = [
item for item in _state.nav.items if isinstance(item, Page) and item.file.src_uri in _state.rendition_uris
]
sections: list[tuple[str, list[Page]]] = [("Docs", top_level)] if top_level else []
for item in _state.nav.items:
if isinstance(item, Section):
pages = _section_pages(item)
if pages:
sections.append((item.title, pages))
index = [f"# {config.site_name}", "", f"> {config.site_description}", ""]
full: list[str] = []
for title, pages in sections:
index += [f"## {title}", ""]
for page in pages:
markdown = _state.page_markdown[page.file.src_uri]
(site_dir / _md_uri(page.file)).write_text(markdown, encoding="utf-8")
description = page.meta.get("description")
tail = f": {description}" if description else ""
index.append(f"- [{page.title}]({site_url}{_md_uri(page.file)}){tail}")
body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown)
if not h1_found:
raise PluginError(f"llms_txt: page {page.file.src_uri} does not start with an H1")
full += [f"# {page.title}", "", f"Source: {page.canonical_url}", "", body.strip(), ""]
index.append("")
index += ["## Optional", ""]
for src_uri, title, description in _OPTIONAL_PAGES:
linked = _state.files.get_file_from_path(src_uri)
if linked is None:
raise PluginError(f"llms_txt: optional page {src_uri} not found")
index.append(f"- [{title}]({site_url}{linked.url}): {description}")
index.append("")
(site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8")
(site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8")