-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmkdocs_hooks.py
More file actions
383 lines (331 loc) · 18.9 KB
/
Copy pathmkdocs_hooks.py
File metadata and controls
383 lines (331 loc) · 18.9 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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
"""MkDocs build hooks. Wired via `hooks:` in mkdocs.yml. Three jobs:
1. on_files — synthesise `tests/unit-tests.md` + `tests/scenario-tests.md` into
MkDocs' virtual file tree from the test files (via `_test_metadata.py`, the
same parser the CLI generator + MoonDeck use). So the inventory pages are NOT
committed to the repo and can't drift — rebuilt from source every build. The
per-effect `[Tests]` links in the catalog pages point at these pages; kept as
plain one-line links so the effect cards stay compact (a link, not a case dump).
2. on_page_markdown — repoint links that escape docs/ into repo files
(`../src/*.h`, `../CLAUDE.md`, `../scripts/*`) to absolute GitHub blob URLs,
since the site can't host `src/` (pre-Doxide) and a relative link would 404.
3. on_post_build (serve-only) — stage the web installer into the built site under
/install/ so the local preview mirrors production's single-origin layout.
"""
import os
import re
from pathlib import Path
# scripts/docs/ is on sys.path when mkdocs runs from repo root; import the shared parser.
import sys
_HERE = Path(__file__).resolve().parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
from _test_metadata import ( # noqa: E402
ROOT,
collect_unit_files,
collect_scenario_files,
)
from generate_test_docs import render_unit_tests, render_scenarios # noqa: E402
import gen_api # noqa: E402 (same dir, on sys.path via _HERE above)
# ---- source-link rewrite: docs link OUT to repo files the site can't host ----
# The blob base for links that escape docs/ into the repo (src/, scripts/, test/,
# CLAUDE.md, README.md, web-installer/). The site can't serve these — src/ isn't
# published — so a relative link 404s on the deployed site. Rewrite to an absolute
# GitHub blob URL so "source [Foo.h]" resolves everywhere (locally-served preview +
# moonmodules.org). A `.h` link for a module that HAS a generated technical page is
# repointed at that in-site page instead (see _API_MODULES / on_page_markdown); only
# files with no generated page fall through to the GitHub blob URL.
_BLOB_BASE = "https://github.com/MoonModules/projectMM/blob/main"
# {module stem: domain} for every module that got a generated technical page this
# build, e.g. {"Control": "core", "FireEffect": "light"} — populated by on_files,
# read by the link retargeter to build the domain-nested target path. Empty when the
# doxygen/moxygen toolchain is absent (so links fall back to GitHub, unchanged).
_API_MODULES: dict[str, str] = {}
# Repo top-level dirs/files a doc may link into but the site doesn't host.
_OUT_OF_DOCS = ("src/", "scripts/", "test/", "esp32/", "web-installer/",
".github/", "CLAUDE.md", "README.md", "library.json", "CMakeLists.txt")
# A markdown link into a repo file the site doesn't host. Two authored shapes, both
# common in the transient history/plans + backlog notes:
# ../../src/foo — climbs out of docs/ with `../`
# src/foo — repo-ROOT-relative, no `../` (resolves to a nonexistent docs/src/foo)
# The pattern accepts an optional `../` run, then the href; _sub sorts out which case.
_OUT_LINK_RE = re.compile(r'\]\((?P<href>(?:\.\./)*(?P<rest>[^)#]+)(?P<frag>#[^)]*)?)\)')
# The repo-root-relative prefixes a bare (no-`../`) link must start with to be treated
# as an out-of-docs source link. `docs/` is included because a plan that links
# `docs/plan.md` / `docs/architecture-light.md` (files that never existed or were
# renamed) still resolves outside the current page and 404s — send it to GitHub.
_ROOT_REL_PREFIXES = _OUT_OF_DOCS + ("docs/",)
def _rewrite_out_of_docs_links(markdown: str, src_uri: str) -> str:
"""Rewrite links that climb out of docs/ into repo files → absolute GitHub blob
URLs, resolved against the page's own location so any `../` depth is handled.
src_uri is the page's path under docs/ (e.g. 'moonmodules/light/effects/effects.md')."""
# The page's directory within docs/ — the anchor `../` hops resolve against.
page_dir = Path("docs") / src_uri
page_dir = page_dir.parent
# Depth from this page up to docs/ (so a `.h` link can be repointed at the
# in-site generated API page with the right relative `../` count).
up = "../" * len(Path(src_uri).parent.parts) if str(Path(src_uri).parent) != "." else ""
def _sub(m: re.Match) -> str:
href = m.group("href")
frag = m.group("frag") or ""
rel = href.split("#", 1)[0]
# A `.h` whose module has a generated technical page → point at the in-site
# page, not GitHub. The generated reference IS the source view we want. The
# target nests by domain (core/light), so use the recorded domain. EXCEPT a
# `#L<n>` line-number fragment: that only resolves on GitHub's blob view (the
# generated page has no line anchors), so such links fall through to the blob
# rewrite below.
stem = Path(rel).stem
is_line_anchor = re.match(r'#L\d', frag)
if rel.endswith(".h") and stem in _API_MODULES and not is_line_anchor:
domain = _API_MODULES[stem]
return f"]({up}moonmodules/{domain}/moxygen/{stem}.md{frag})"
# Resolve against the page dir to a repo-relative path (the correct case).
target = os.path.normpath(str(page_dir / rel)).replace(os.sep, "/")
if target.startswith(_OUT_OF_DOCS) and not target.startswith("docs/"):
return f"]({_BLOB_BASE}/{target}{frag})"
# Fallback for links authored relative to the REPO ROOT (common in the
# transient history/plans + backlog notes, written before their file sat
# this deep under docs/): strip leading `../` and see if the remainder is
# itself an out-of-docs repo path. Catches `../../scripts/x.py` from a
# docs/history/plans/ page, which resolves to a nonexistent docs/scripts/x.py,
# AND a bare `src/x.js` / `docs/plan.md` with no `../` at all (same intent,
# written root-relative) — both 404 in-site, so send them to the GitHub blob.
stripped = re.sub(r'^(?:\.\./)+', '', rel)
if stripped.startswith(_ROOT_REL_PREFIXES):
return f"]({_BLOB_BASE}/{stripped}{frag})"
return m.group(0) # in-docs link (incl. ../ into another docs page) — leave it
return _OUT_LINK_RE.sub(_sub, markdown)
# ---- MkDocs hooks ----
# ---- catalog-page table transform: render prose ### blocks as a MoonLight-style table ----
# The consolidated catalog pages whose ### effect/modifier/layout blocks are rendered
# as a table (source stays authored as readable prose blocks; the table is build-time).
_CATALOG_PAGES = {
"moonmodules/light/effects/effects.md",
"moonmodules/light/modifiers/modifiers.md",
"moonmodules/light/layouts/layouts.md",
"moonmodules/light/drivers/drivers.md",
# Summary pages for the non-catalog module groups use the same ### -block → table
# transform, one common authoring process for every summary page (supporting rows
# just leave the preview/controls columns blank).
"moonmodules/core/supporting/supporting.md",
"moonmodules/core/ui/ui.md",
"moonmodules/light/supporting/supporting.md",
}
_H3_RE = re.compile(r'^###\s+(?P<title>.+?)\s*$')
_H2_RE = re.compile(r'^##\s+') # any level-2 heading = section boundary
_ANCHOR_RE = re.compile(r'^<a id="(?P<id>[^"]+)"></a>\s*$')
_IMG_RE = re.compile(r'^<img\b.*?>\s*$')
_PARAM_RE = re.compile(r'^-\s+')
_ORIGIN_RE = re.compile(r'^Origin:\s*(?P<body>.+?)\s*$')
_TESTS_RE = re.compile(r'^\[Tests\]\((?P<href>[^)]+)\)\s*$')
_DETAIL_RE = re.compile(r'^Detail:\s*(?P<body>.+?)\s*$') # `Detail: [Foo.md](..) · …` → Links column
_DETAILS_RE = re.compile(r'^##\s+(?P<name>.+?)\s+—\s+details\s*$')
def _cell(s: str) -> str:
"""A markdown-table cell: collapse newlines to <br> so multi-line content
survives inside a single pipe cell."""
return s.replace("\n", "<br>")
def _slug(text: str) -> str:
"""The heading anchor id MkDocs generates for a `## Heading`, so a hand-built
`#anchor` link resolves. Delegates to Python-Markdown's OWN toc slugify (the exact
function MkDocs' toc uses) rather than re-implementing it — a hand-rolled mimic
drifts on unicode (accent-stripping), consecutive separators, and decoration.
e.g. 'LED output — details' -> 'led-output-details'. `markdown` is always present
in the build env (it's a MkDocs dependency)."""
from markdown.extensions.toc import slugify
return slugify(text, "-")
def _emit_row(b: dict, details_names: set) -> str:
"""One 4-column table row for a parsed ### block."""
# Col 1: anchored name (so a help #anchor lands on the row) + description. A
# merged card (e.g. the LED-output drivers) carries several ids so each old
# per-driver anchor still resolves onto the one row.
anchor_spans = "".join(f'<span id="{a}"></span>' for a in b.get("anchors", []))
# Name in a distinct styled span, description below in a muted span — so the two
# read as title + subtitle rather than one run-on line (styled in extra.css).
name = f'{anchor_spans}<span class="mm-name">{b["title"]}</span>'
desc = " ".join(b["desc"])
col1 = _cell(f'{name}<br><span class="mm-desc">{desc}</span>' if desc else name)
# Col 2: GIF/image (strip the hand-authored width/height so CSS sizes it to
# fill the column), or a placeholder.
if b["img"]:
img = re.sub(r'\s+(width|height)="[^"]*"', "", b["img"])
col2 = img.replace("<img ", '<img class="mm-preview" ', 1)
else:
# No image: a neutral dash reads correctly both for a catalog module still
# awaiting a gif and a supporting module that has no visual by nature.
col2 = "—"
# Col 3: parameters, one per line. Split each at the em-dash into the name part
# (before — keeps its `code` chips, styled accent) and the description (after —
# greyed via .mm-pdesc, matching the muted module description). Params without a
# dash render whole in the name part.
col3_parts = []
for p in b["params"]:
p = re.sub(r'^-\s+', '', p)
head, sep, tail = p.partition(" — ")
if sep:
col3_parts.append(f'<span class="mm-param">{head}'
f' — <span class="mm-pdesc">{tail}</span></span>')
else:
col3_parts.append(f'<span class="mm-param">{p}</span>')
col3 = "".join(col3_parts) if col3_parts else "—"
# Col 4: everything a reader clicks OUT to — so the description column is pure
# prose. Tests + detail-page link(s) + source/attribution + a ⌄ details anchor.
links = []
if b["tests"]:
links.append(f"[Tests]({b['tests']})")
if b["detail"]:
links.append(b["detail"]) # the `Detail: [Foo.md](..) · …` line
if b["origin"]:
links.append(b["origin"]) # carries `source [Foo.h](...)` + attribution
# The module's display name without the trailing ` 💫 · dim` decoration, e.g.
# "LED output 💫 · wire" -> "LED output". A `## <that name> — details` section
# below the table (if present) is linked as `⌄ details`, anchored to MkDocs'
# own slug for that heading (`<name> — details` -> `<name>-details`).
name = re.split(r'\s+[💫🦅🐙📊🌙⚡️·]', b["title"])[0].strip()
if name in details_names:
links.append(f"[⌄ details](#{_slug(name + ' — details')})")
# Wrap so the plain attribution text greys (.mm-links); the actual links keep
# their accent link colour (Material's `a` styling wins over the grey).
col4 = f'<span class="mm-links">{_cell("<br>".join(links))}</span>' if links else "—"
return f"| {col1} | {col2} | {col3} | {col4} |"
def _render_catalog_table(markdown: str) -> str:
"""Render each run of consecutive `### ` blocks as a 4-column table, section by
section. Everything else — the page H1/lead, `## Group` headers, the trailing
`## Source` list, `## <Name> — details` sections, stray prose between blocks —
passes through verbatim, so a table only ever contains genuine module blocks
(a `##` heading closes the current table). Source .md stays authored as prose."""
lines = markdown.split("\n")
details_names = {_DETAILS_RE.match(l).group("name") for l in lines if _DETAILS_RE.match(l)}
out = []
rows = [] # accumulated table rows for the current run
cur = None # block being parsed
pending_anchors = [] # <a id> lines seen before the next ### (a merged card can carry several)
def flush_block():
nonlocal cur
if cur is not None:
rows.append(_emit_row(cur, details_names))
cur = None
def flush_table():
"""Emit the accumulated rows as one wrapped table, then reset."""
if rows:
out.append('<div class="mm-catalog-wrap" markdown="1">')
out.append("")
out.append("| Name | Preview | Parameters | Links |")
out.append("|------|---------|------------|-------|")
out.extend(rows)
out.append("")
out.append("</div>")
out.append("")
rows.clear()
for ln in lines:
if _ANCHOR_RE.match(ln):
pending_anchors.append(_ANCHOR_RE.match(ln).group("id"))
continue
if _H3_RE.match(ln): # start a new block (same table run)
flush_block()
cur = {"anchors": pending_anchors, "title": _H3_RE.match(ln).group("title"),
"desc": [], "img": None, "params": [], "origin": None,
"tests": None, "detail": None}
pending_anchors = []
continue
if _H2_RE.match(ln): # section boundary: close block + table
flush_block()
flush_table()
for a in pending_anchors: # stray anchors before a ## — keep them
out.append(f'<a id="{a}"></a>')
pending_anchors = []
out.append(ln)
continue
if cur is not None: # inside a block: classify the line
if _IMG_RE.match(ln):
cur["img"] = ln.strip()
elif _PARAM_RE.match(ln):
cur["params"].append(ln.strip())
elif _ORIGIN_RE.match(ln):
cur["origin"] = _ORIGIN_RE.match(ln).group("body")
elif _TESTS_RE.match(ln):
cur["tests"] = _TESTS_RE.match(ln).group("href")
elif _DETAIL_RE.match(ln):
cur["detail"] = _DETAIL_RE.match(ln).group("body")
elif ln.strip():
cur["desc"].append(ln.strip())
# blank line inside a block is a separator — ignore
continue
# outside any block and not a heading: passthrough (intro, prose, Source items)
out.append(ln)
flush_block()
flush_table()
for a in pending_anchors:
out.append(f'<a id="{a}"></a>')
return "\n".join(out)
def on_files(files, config):
"""Inject build-time-generated pages into the virtual tree: the test inventories,
and (Phase 4b) a per-module API reference for each infrastructure module,
generated from its `///` source comments by gen_api (Doxygen → moxygen)."""
from mkdocs.structure.files import File
def _add(src_uri: str, content: str):
f = File.generated(config, src_uri, content=content)
existing = files.get_file_from_path(src_uri) # replace a same-URI file if any
if existing:
files.remove(existing)
files.append(f)
_add("tests/unit-tests.md", render_unit_tests(collect_unit_files()))
_add("tests/scenario-tests.md", render_scenarios(collect_scenario_files()))
# Source-generated technical pages. gen_api.generate() writes each to disk under
# docs/moonmodules/<domain>/moxygen/<Module>.md (gitignored, so a human can preview
# the .md directly) and returns {uri: markdown}. We still inject via _add so THIS
# build sees them (MkDocs' file scan ran before this hook, so the fresh writes
# aren't in `files` yet; _add de-dups a same-URI entry a later scan would find).
# Empty dict when doxygen/npx are absent (local without the toolchain) — the site
# still builds; the pages appear in CI. But if the tools ARE present and fail (npx
# fetch error, doxygen crash, near-empty output), generate() raises GenApiError,
# which propagates and fails the build — so a transient failure can't silently ship
# a docs site with zero API pages. Record {stem: domain} so on_page_markdown can
# retarget a `.h` link to the domain-nested page.
global _API_MODULES
api_pages = gen_api.generate()
# uri: 'moonmodules/<domain>/moxygen/<Stem>.md' → {'<Stem>': '<domain>'}
_API_MODULES = {
Path(uri).stem: Path(uri).parts[1] for uri in api_pages
}
for uri, md in api_pages.items():
_add(uri, md)
return files
def on_page_markdown(markdown, page, config, files):
"""Repoint out-of-docs source links to GitHub blob URLs, then (on the catalog
pages) render the prose ### blocks as a MoonLight-style 4-column table. Source
.md stays authored as readable blocks; the table is build-time only."""
markdown = _rewrite_out_of_docs_links(markdown, page.file.src_uri)
if page.file.src_uri in _CATALOG_PAGES:
markdown = _render_catalog_table(markdown)
return markdown
def on_post_build(config):
"""LOCAL PREVIEW ONLY: stage the web installer into the built site under
/install/, so the local docs preview mirrors the SINGLE-ORIGIN production
layout (docs at /, installer at /install/). Without it the docs preview
(:8422) 404s on the Flash link's /projectMM/install/ target — the installer
preview is a separate server on :8421, and a production-relative link can't
reach it. Copying it in makes every /install/ link resolve locally exactly
as deployed.
Gated to serve mode via MM_DOCS_SERVE (set by build_docs.py --serve): in CI,
release.yml does its OWN, more complete install/ staging (incl. the
releases/<tag>/ firmware binaries), so this must NOT run there and risk
overlaying the binary-less repo copy over it. Mirrors release.yml's
`cp -r web-installer/. pages/install/` + the install-picker*.js + library.json
siblings. Best-effort: absent files are skipped."""
import os
import shutil
if os.environ.get("MM_DOCS_SERVE") != "1":
return # CI / plain build — release.yml owns install/ staging
site = Path(config["site_dir"])
dst = site / "install"
src = ROOT / "web-installer"
if not src.is_dir():
return
dst.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
# The picker's JS halves live in src/ui/ (embedded in firmware too); CI stages
# them beside the installer. library.json feeds the installer its version.
for extra in ("src/ui/install-picker.js", "src/ui/install-picker-boards.js", "library.json"):
p = ROOT / extra
if p.is_file():
shutil.copy(p, dst / p.name)