forked from modelcontextprotocol/python-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild_config.py
More file actions
239 lines (195 loc) · 10.8 KB
/
Copy pathbuild_config.py
File metadata and controls
239 lines (195 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""Produce the concrete Zensical build config from `mkdocs.yml`.
Zensical builds from `mkdocs.yml` directly, but it has no equivalent of
mkdocs-literate-nav: the "API Reference" navigation has to be materialised
as explicit entries. This script regenerates the `docs/api/` tree (via
gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced
in — that generated file is what `zensical build`/`serve` consumes.
With `--lang CODE` it writes `mkdocs.CODE.gen.yml` for one translated site
instead: built from the tree `scripts/docs/translations.py stage` assembled
under `.build/i18n/CODE/docs/` into `site/CODE/`, with no API reference of its
own (its nav entry links the English one) and nav titles taken from the staged
pages (the headings `stage` recorded beside the tree). Every config, English
included, carries the language switcher (`extra.alternate`) built from
`i18n/languages.yml`, which this module also loads for the translation tool.
Usage:
python scripts/docs/build_config.py [--lang CODE]
"""
from __future__ import annotations
import argparse
import json
import posixpath
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# Both scripts live in this directory, which Python puts on sys.path[0] when
# `build_config.py` is run directly (its documented invocation).
import gen_ref_pages
import yaml
from gen_ref_pages import NavItem
ROOT = Path(__file__).parent.parent.parent
LANGUAGES_FILE = "i18n/languages.yml"
# A language site carries no API reference; its nav entry links the English
# one (a sibling site one level up), which opens on the first package's index.
API_REFERENCE_URL = "../api/mcp/"
# A nav value with a URL scheme (https:, mailto:, ...), a leading `/`, or a
# leading `../` (out of this site) is a link, not a page under docs_dir.
_LINK = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:|/|\.\./")
@dataclass(frozen=True)
class Language:
"""One translated site from `i18n/languages.yml`."""
code: str
name: str
theme: str
hreflang: str
@dataclass(frozen=True)
class Registry:
"""The parsed `i18n/languages.yml`."""
model: str
exclude: list[str]
languages: list[Language]
def load_registry(root: Path = ROOT) -> Registry:
"""Parse `i18n/languages.yml` under repository `root`.
Raises:
ValueError: The file is missing, unparsable, or not the shape of `Registry`.
"""
try:
raw = yaml.safe_load((root / LANGUAGES_FILE).read_text(encoding="utf-8"))
languages = [Language(**entry) for entry in raw["languages"]]
return Registry(str(raw["model"]), [str(pattern) for pattern in raw["exclude"]], languages)
except (OSError, yaml.YAMLError, TypeError, KeyError) as exc:
raise ValueError(f"{LANGUAGES_FILE}: {exc!r}") from exc
def staged_docs_dir(code: str, root: Path = ROOT) -> Path:
"""Where `translations.py stage` assembles a language's docs tree before its site is built."""
return root / ".build" / "i18n" / code / "docs"
def staged_titles_file(code: str, root: Path = ROOT) -> Path:
"""Where `translations.py stage` records each staged page's `#` heading text, keyed by page path."""
return root / ".build" / "i18n" / code / "titles.json"
def nav_page_paths(nav: list[NavItem]) -> list[str]:
"""Every local page path in the nav, depth first in nav order (link entries excluded)."""
paths: list[str] = []
for entry in nav:
value = next(iter(entry.values())) if isinstance(entry, dict) else entry
if isinstance(value, list):
paths.extend(nav_page_paths(value))
elif not _LINK.match(value):
paths.append(value)
return paths
def language_nav(nav: list[NavItem], titles: dict[str, str]) -> list[NavItem]:
"""The nav of a language site: every title taken from the staged pages (`titles` maps page path to H1).
Page labels are dropped, so Zensical titles each page from its (translated)
H1, and a section is titled with the H1 of the index page that leads it, so
the sidebar cannot drift from the pages. A link entry, and a section that
does not lead with a titled page, keeps its English label.
"""
entries: list[NavItem] = []
for entry in nav:
if isinstance(entry, str):
entries.append(entry)
continue
((label, value),) = entry.items()
if isinstance(value, list):
title = titles.get(value[0]) if value and isinstance(value[0], str) else None
entries.append({title or label: language_nav(value, titles)})
else:
entries.append({label: value} if _LINK.match(value) else value)
return entries
def alternate(languages: list[Language], lang: str | None = None) -> list[dict[str, str]]:
"""The `extra.alternate` switcher of the English site, or with `lang` of that language site.
Each label leads with the site's code (`ja - 日本語`). Links are relative
to the site being built (English one level up from a language site), so
the theme's `url` filter makes them page-relative and a mirror keeps working.
"""
up = "" if lang is None else "../"
entries = [{"name": "en - English", "link": up or "./", "lang": "en"}]
entries += [{"name": f"{o.code} - {o.name}", "link": f"{up}{o.code}/", "lang": o.hreflang} for o in languages]
return entries
def _api_entry(nav: list[NavItem]) -> dict[str, str | list[NavItem]]:
"""The `mkdocs.yml` placeholder entry the API reference is spliced into."""
for entry in nav:
if isinstance(entry, dict) and "API Reference" in entry:
return entry
raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav")
def _validate_nav(nav: list[NavItem], docs_dir: Path) -> None:
"""Fail on nav/page drift in either direction.
Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken
link without any diagnostic even under --strict, and publishes a page
that no nav entry reaches as unreachable orphan HTML; MkDocs aborted the
build on both (--strict with `validation.omitted_files: warn`).
Validating here keeps those guarantees. The generated `api/` tree is
exempt from the orphan check: its nav is spliced in from the same
generator that writes the files, so it cannot drift.
"""
pages = set(nav_page_paths(nav))
# Containment before existence: `docs_dir / page` would happily resolve
# a `../` escape against the wrong root.
if escaping := sorted(page for page in pages if posixpath.normpath(page).startswith("..")):
raise SystemExit(f"build_config: nav references pages outside {docs_dir}: {escaping}")
if missing := sorted(page for page in pages if not (docs_dir / page).is_file()):
raise SystemExit(f"build_config: nav references pages that don't exist under {docs_dir}: {missing}")
# Dot-directories (e.g. `.overrides` theme files) are not pages: the site
# builder ignores them, so the orphan check must too.
relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md"))
on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)}
if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")):
raise SystemExit(f"build_config: pages under {docs_dir} that no nav entry reaches: {orphaned}")
def build_config(lang: str | None = None, root: Path = ROOT) -> Path:
"""Write the English config, or with `lang` that language site's config; returns the file written.
`root` is the repository the config is read from and written to (a
scratch tree in tests); the English API reference is always generated
from this checkout's `src/`.
"""
config: dict[str, Any] = yaml.safe_load((root / "mkdocs.yml").read_text(encoding="utf-8"))
try:
# No registry yet means English is the only site there is.
no_languages = lang is None and not (root / LANGUAGES_FILE).is_file()
languages = [] if no_languages else load_registry(root).languages
except ValueError as exc:
raise SystemExit(f"build_config: {exc}") from exc
if languages: # a switcher listing English alone is noise
config.setdefault("extra", {})["alternate"] = alternate(languages, lang)
if lang is None:
api_nav: list[NavItem] = gen_ref_pages.generate()
if not api_nav:
raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?")
_api_entry(config["nav"])["API Reference"] = api_nav
docs_dir = root / "docs"
output = root / "mkdocs.gen.yml"
else:
language = next((candidate for candidate in languages if candidate.code == lang), None)
if language is None:
raise SystemExit(f"build_config: unknown language {lang!r} (see {LANGUAGES_FILE})")
docs_dir, titles_file = staged_docs_dir(lang, root), staged_titles_file(lang, root)
try: # written last by `stage`, so its presence means the tree beside it is complete
titles: dict[str, str] = json.loads(titles_file.read_text(encoding="utf-8"))
except OSError as exc:
raise SystemExit(
f"build_config: cannot read {titles_file} (run translations.py stage --lang {lang})"
) from exc
_api_entry(config["nav"])["API Reference"] = API_REFERENCE_URL
config["nav"] = language_nav(config["nav"], titles)
# No API reference on a language site, so no mkdocstrings pass either.
plugins: list[str | dict[str, Any]] = config["plugins"]
config["plugins"] = [p for p in plugins if (next(iter(p)) if isinstance(p, dict) else p) != "mkdocstrings"]
# A stored translation can name a `docs_src` file an English change has since renamed:
# that block renders empty under the outdated notice instead of stopping the build.
extensions: list[str | dict[str, Any]] = config["markdown_extensions"]
for extension in extensions:
if isinstance(extension, dict) and extension.get("pymdownx.snippets"):
extension["pymdownx.snippets"]["check_paths"] = False
config["theme"]["language"] = language.theme
# Zensical resolves docs_dir/site_dir against the config file and
# rejects absolute paths.
config["docs_dir"] = docs_dir.relative_to(root).as_posix()
config["site_dir"] = f"site/{lang}"
config["site_url"] = config["site_url"].rstrip("/") + f"/{lang}/"
output = root / f"mkdocs.{lang}.gen.yml"
_validate_nav(config["nav"], docs_dir)
output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8")
return output
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--lang", metavar="CODE", help="write the config of this language site instead of English")
build_config(parser.parse_args().lang)
if __name__ == "__main__":
main()