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:
parent
74a242ae7f
commit
4fc8882c02
3 changed files with 1185 additions and 612 deletions
107
docs/extra.css
107
docs/extra.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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")
|
||||
Loading…
Reference in a new issue