4"""Generate the Doxygen navigation trees, mirroring the on-disk layout.
6Doxygen renders markdown files (docs/*.md and every example README.md) as
7"pages", never as entries in the Files/directory tree -- so the sidebar's
8directory view is code-only, and the human-readable docs + per-app READMEs
9can only be reached through the "pages" tab. This script builds that pages
10tab to MIRROR THE ON-DISK DIRECTORY TREE instead of an invented taxonomy, so
11navigation reads the same way everywhere (browse by directory) and can never
14 1. "Documentation" -- the docs/ subtree. Every docs/*.md is a leaf and
15 every docs/<subdir> (adr, SOUP, qualification, reference, ...) is a
16 sub-node, recursively, exactly as they sit on disk. No doc-to-section
17 map: a new page or a new subdir just appears; a removed one just leaves.
19 2. "Examples" -- the examples/ subtree. Every app appears under its real
20 hardware-validation tier directory (hw_validated/hil, hw_validated/manual,
21 hw_pending, _unsupported, ra8p1_foundation, ...), each carrying the
22 one-line description from its own README. Moving an app between tiers on
23 disk moves it in the nav with no edit here.
25The only hand-maintained data left is a set of PRETTY-TITLE maps (tier and
26docs-subdir display names); every one degrades gracefully -- an unmapped
27directory falls back to a prettified name and still renders, never breaks and
28never lists a removed item.
30The generated .dox files are written under docs/generated/ (gitignored) and
31consumed by the main Doxyfile. build_docs.sh runs this before doxygen, so the
32navigation can never drift from the tree.
35from __future__
import annotations
41ROOT = pathlib.Path(__file__).resolve().parents[2]
42DOCS_DIR = ROOT /
"docs"
43EXAMPLES_DIR = ROOT /
"examples"
44OUT_DIR = DOCS_DIR /
"generated"
53def doxy_page_id(relpath: str) -> str:
54 """Doxygen page id for a markdown file.
56 Matches the Doxyfile's CASE_SENSE_NAMES=YES scheme: the "md_" prefix, the
57 path with '/' -> '_2' and '_' -> '__', and the original case preserved
58 (e.g. docs/ACRONYMS.md -> md_docs_2ACRONYMS). The '.md' extension is
59 dropped. Under CASE_SENSE_NAMES=NO doxygen would instead escape every
60 uppercase letter, so this mangler is coupled to that Doxyfile setting.
62 stem = str(pathlib.PurePosixPath(relpath).with_suffix(
""))
74def doxy_dir_id(relpath: str) -> str:
75 """Doxygen directory-page id: ``dir_`` + md5 of the directory path.
77 The path hashed is the STRIP_FROM_PATH-relative one WITH a trailing slash;
78 both details are load-bearing, since doxygen hashes exactly that string and
79 dropping the slash yields a valid-looking id that resolves to nothing.
81 Doxygen renders a directory's README.md as that directory page's detailed
82 description -- so this is where an example app's README actually shows,
83 alongside its source-file list (the empty 'README.md File Reference' page
84 is a dead end). Because the Doxyfile sets STRIP_FROM_PATH=., the path fed
85 to the hash is the plain repo-relative one, so the id is machine-independent
86 and matches the same page the Files directory tree links to.
88 key = relpath.rstrip(
"/") +
"/"
92 digest = hashlib.md5(key.encode(
"utf-8"), usedforsecurity=
False).hexdigest()
93 return "dir_" + digest
101SUBDIR_TITLES: dict[str, str] = {
102 "adr":
"Architecture Decision Records (adr)",
103 "formats":
"Binary format specifications (formats)",
104 "SOUP":
"SOUP component justifications (SOUP)",
105 "qualification":
"Qualification kit (qualification)",
106 "reference":
"Datasheet reference (reference)",
114DOCS_SKIP_DIRS = {
"generated",
"doxygen",
"doxygen_theme",
"badges",
"sbom"}
119TIER_TITLES: dict[str, str] = {
120 "ek_ra8d2":
"EK-RA8D2 (stock evaluation kit)",
121 "ek_ra8d2/hw_validated":
"Hardware-validated",
122 "ek_ra8d2/hw_validated/hil":
"Hardware-in-the-loop (HIL)",
123 "ek_ra8d2/hw_validated/c6":
"ESP32-C6 companion radio (just hil::c6)",
124 "ek_ra8d2/hil_needs_revalidation":
"HIL -- needs re-validation",
125 "ek_ra8d2/hw_validated/manual":
"Manual (jumper / button steps)",
126 "ek_ra8d2/hw_pending":
"Hardware-pending",
127 "ek_ra8d2/hw_pending/manual":
"Manual (jumper / button steps)",
128 "_unsupported":
"Needs external hardware",
129 "ra8p1_foundation":
"RA8P1 foundation",
131TIER_BRIEFS: dict[str, str] = {
132 "ek_ra8d2":
"Apps that run on a stock EK-RA8D2 v1 kit with no added parts.",
133 "ek_ra8d2/hil_needs_revalidation":
"Apps moved out of the HIL-passing set: "
134 "each is blocked by bench config, an SD reseat, absent external hardware, "
135 "or is under triage -- see the tier README.",
136 "ek_ra8d2/hw_validated/c6":
"Apps that talk to the ESP32-C6 companion radio "
137 "over esp-hosted. Hardware-validated, but on a DIP-switch configuration "
138 "(SW4-4 OFF) that excludes the default HIL pass -- run them with "
140 "_unsupported":
"Apps that need hardware not on the stock board "
141 "(motor driver, audio CODEC, external radios, ...).",
142 "ra8p1_foundation":
"Foundation apps for the RA8P1 variant (RA8D2 + Ethos-U55 NPU).",
146def prettify(name: str) -> str:
147 """Fallback display title for a directory with no entry in the title maps.
149 This is what makes the title maps optional rather than authoritative: a
150 tier or docs subdirectory added on disk renders as Title Case immediately,
151 so forgetting to add a pretty name degrades the label and never drops the
152 node out of the navigation.
154 return name.replace(
"_",
" ").strip().title()
157def clean_desc(text: str) -> str:
158 """Reduce a README paragraph to one safe, plain-text line."""
159 text = text.replace(
"`",
"").replace(
"**",
"").replace(
"*",
"")
165 close = text.find(
"]", i)
166 paren = text.find(
"(", close)
if close != -1
else -1
167 if close != -1
and paren == close + 1:
168 end = text.find(
")", paren)
170 out.append(text[i + 1 : close])
176 text =
" ".join(text.split())
178 if len(text) > limit:
179 cut = text[:limit].rsplit(
" ", 1)[0]
182 text = html.escape(text, quote=
False)
183 return text.replace(
"@",
"@").replace(
"\\",
"\")
186def read_app_desc(app_dir: pathlib.Path) -> str:
187 """Pull the one-line blurb for an example app out of its own README.
189 Takes the first prose paragraph AFTER the H1, which is where these READMEs
190 put their summary. The scan stops at a heading or a table row so an app
191 whose README opens with a status table straight after the title yields an
192 empty description rather than a row of pipes rendered as prose.
194 A missing README returns "" rather than raising: an app without one still
195 belongs in the navigation, just without a blurb.
197 readme = app_dir /
"README.md"
198 if not readme.is_file():
200 lines = readme.read_text(encoding=
"utf-8", errors=
"replace").splitlines()
203 while idx < len(lines)
and not lines[idx].startswith(
"# "):
207 while idx < len(lines)
and not lines[idx].strip():
210 while idx < len(lines)
and lines[idx].strip():
211 stripped = lines[idx].strip()
212 if stripped.startswith((
"#",
"|")):
214 para.append(stripped)
216 return clean_desc(
" ".join(para))
221 """One directory level of the examples/ tree, holding its apps and children.
223 The tree is built from the app directories found on disk, so a node exists
224 only because some app lives at or below it -- there is no declared tier
225 list to fall out of step with the repository.
228 def __init__(self, path: str) ->
None:
229 """Create an empty node for the tier at ``path`` relative to examples/.
231 The root is spelled "" rather than "." so that child paths concatenate
232 cleanly and the root's page id can be special-cased.
235 self.apps: list[tuple[str, str, str]] = []
236 self.children: dict[str, TierNode] = {}
238 def title(self) -> str:
239 """Display title for this tier, falling back to a prettified leaf name.
241 Lookup is on the node's FULL path, so two tiers sharing a leaf name at
242 different depths (``hw_validated/manual`` and ``hw_pending/manual``)
243 can be titled independently.
245 if self.path
in TIER_TITLES:
246 return TIER_TITLES[self.path]
247 return prettify(self.path.split(
"/")[-1])
249 def page_id(self) -> str:
250 """Doxygen ``@page`` id for this tier, unique and identifier-safe.
252 Every character that is neither alphanumeric nor an underscore is
253 replaced, so a tier directory containing a dot or a dash cannot emit an
254 id doxygen would silently truncate or reject.
257 return "ra8_examples"
258 safe = self.path.replace(
"/",
"_")
259 safe =
"".join(c
if (c.isalnum()
or c ==
"_")
else "_" for c
in safe)
260 return "ra8_ex_" + safe
263def build_example_tree() -> tuple[TierNode, int]:
264 """Discover every example app and assemble the tier tree it implies.
266 An app is defined as a directory containing ``src/main.c`` and a root
267 ``CMakeLists.txt``, which is what makes
268 the navigation self-maintaining: moving an app between tiers on disk moves
269 it in the sidebar with no edit here, and a deleted app simply stops being
270 found. Intermediate tier nodes are created on demand as each path is
271 walked, so only tiers that actually contain apps appear.
273 Returns the root node (whose own ``apps`` list holds any app sitting
274 directly under examples/) and the total app count, which the caller prints
275 in the page brief rather than recomputing.
279 mains = sorted(EXAMPLES_DIR.glob(
"**/src/main.c"))
281 app_dir = main_c.parent.parent
282 if not (app_dir /
"CMakeLists.txt").is_file():
284 tier_rel = app_dir.parent.relative_to(EXAMPLES_DIR).as_posix()
289 for seg
in [s
for s
in tier_rel.split(
"/")
if s]:
290 acc = f
"{acc}/{seg}" if acc
else seg
291 node = node.children.setdefault(acc, TierNode(acc))
292 app_rel = app_dir.relative_to(ROOT).as_posix()
296 node.apps.append((app_dir.name, doxy_dir_id(app_rel), read_app_desc(app_dir)))
301def emit_subpage_list(out: list[str], entries: list[tuple[str, str]]) ->
None:
302 """Emit @subpage links as an HTML bulleted list (one per line).
304 entries is a list of (page_id, suffix); suffix is appended after the link
305 (e.g. " (12 apps)") or "" for none.
309 out.append(
" * <ul>")
310 for pid, suffix
in entries:
311 out.append(f
" * <li>@subpage {pid}{suffix}</li>")
312 out.append(
" * </ul>")
315def emit_tier_page(node: TierNode, out: list[str]) ->
None:
316 """Append this tier's ``@page`` block to ``out``, then recurse into children.
318 Appends in place rather than returning, so one list accumulates the whole
319 tree in traversal order: a parent's page is emitted before its descendants,
320 which is the order doxygen needs to nest the subpage links correctly.
322 Apps are rendered as a table and child tiers as a bulleted subpage list, so
323 a tier holding both reads as sections rather than one mixed list.
325 brief = TIER_BRIEFS.get(node.path,
"")
327 out.append(f
" * @page {node.page_id()} {node.title()}")
329 out.append(f
" * @brief {brief}")
331 child_nodes = [node.children[k]
for k
in sorted(node.children)]
334 [(c.page_id(), f
" ({_count_apps(c)} apps)")
for c
in child_nodes],
336 if child_nodes
and node.apps:
339 out.append(f
" * @par {len(node.apps)} app(s) in this tier:")
340 out.append(
" * <table>")
341 out.append(
" * <tr><th>App</th><th>Description</th></tr>")
342 for name, fid, desc
in sorted(node.apps):
343 link = f
'<a href="{fid}.html"><code>{html.escape(name)}</code></a>'
344 out.append(f
" * <tr><td>{link}</td><td>{desc}</td></tr>")
345 out.append(
" * </table>")
348 for child
in child_nodes:
349 emit_tier_page(child, out)
352def _count_apps(node: TierNode) -> int:
353 return len(node.apps) + sum(_count_apps(c)
for c
in node.children.values())
356def gen_examples() -> str:
357 """Render the complete "Examples" navigation tree as one .dox file body.
359 Returns the file text, newline-terminated, ready to be written verbatim --
360 no caller-side assembly, so the generated file has exactly one author.
362 root, total = build_example_tree()
364 "// GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
367 " * @page ra8_examples Examples",
368 f
" * @brief All {total} example apps, browsable by the same board and",
369 " * hardware-validation tiers the repository uses on disk.",
371 " * Each app is a self-contained directory under `examples/` with its",
372 " * implementation and boot overrides under `src/`, public interfaces",
373 " * under `inc/`, and build/link configuration at its root. Pick a tier below;",
374 " * every app links to its README. Build any of them with",
375 " * `just apps::build <app>` or run it with `just apps::emulator::run <app>`.",
378 child_nodes = [root.children[k]
for k
in sorted(root.children)]
381 [(c.page_id(), f
" ({_count_apps(c)} apps)")
for c
in child_nodes],
385 for child
in child_nodes:
386 emit_tier_page(child, out)
387 return "\n".join(out) +
"\n"
391def _docs_subid(rel_under_docs: str) -> str:
392 """Stable @page id for a docs/ subdirectory, keyed by its full sub-path.
394 Keying on the full path (not just the leaf name) means two subdirs that
395 share a leaf name at different depths cannot collide. The distinct
396 "docsub" prefix keeps it clear of the "ra8_docs" root and of any page id.
398 safe =
"".join(c
if (c.isalnum()
or c ==
"_")
else "_" for c
in rel_under_docs)
399 return "ra8_docsub_" + safe
402def _walk_docs(dir_path: pathlib.Path, rel_under_docs: str) -> tuple[list[str], str |
None, str]:
403 """Emit the @page blocks for a docs/ subtree, mirroring it on disk.
405 Returns (page_blocks, subid, title). subid is None when the subtree holds
406 no narrative markdown at all, so the parent omits it -- a directory of only
407 images / JSON never becomes an empty nav node. Sub-directories are listed
408 before files, matching doxygen's own Files-tree convention.
411 (p
for p
in dir_path.glob(
"*.md")
if p.name.lower() !=
"readme.md"),
412 key=
lambda p: p.name.lower(),
415 (p
for p
in dir_path.iterdir()
if p.is_dir()
and p.name
not in DOCS_SKIP_DIRS),
416 key=
lambda p: p.name.lower(),
419 blocks: list[str] = []
420 child_nodes: list[tuple[str, str]] = []
421 for cd
in child_dirs:
422 crel = f
"{rel_under_docs}/{cd.name}" if rel_under_docs
else cd.name
423 cblocks, csubid, ctitle = _walk_docs(cd, crel)
424 if csubid
is not None:
425 child_nodes.append((csubid, ctitle))
426 blocks.extend(cblocks)
428 leaf_ids = [doxy_page_id(p.relative_to(ROOT).as_posix())
for p
in md_files]
429 if not leaf_ids
and not child_nodes:
432 subid = _docs_subid(rel_under_docs)
433 title = SUBDIR_TITLES.get(dir_path.name, prettify(dir_path.name))
434 page = [
"/**", f
" * @page {subid} {title}",
" *"]
435 entries = [(cid,
"")
for cid, _t
in child_nodes] + [(pid,
"")
for pid
in leaf_ids]
436 emit_subpage_list(page, entries)
440 return page + blocks, subid, title
443def _handwritten_doc_page_ids() -> list[str]:
444 """Discover hand-written ``@page`` ids in docs/*.dox.
446 Without this the hand-written pages would float at the top level of the
447 sidebar rather than nesting under Documentation, since doxygen parents a
448 page only where something ``@subpage``s it.
450 Only the docs/ root .dox files are scanned (never docs/generated/, which
451 this script owns). The ids are read straight from the files, so a page that
452 is renamed or deleted is picked up or dropped automatically -- nothing is
453 hardcoded, nothing dangles.
456 for dox
in sorted(DOCS_DIR.glob(
"*.dox"), key=
lambda p: p.name.lower()):
457 for line
in dox.read_text(encoding=
"utf-8", errors=
"replace").splitlines():
458 stripped = line.lstrip(
" *")
459 for tag
in (
"@page ",
"\\page "):
460 if stripped.startswith(tag):
461 pid = stripped[len(tag) :].split()[0]
467def gen_docs() -> str:
468 """Render the complete "Documentation" navigation tree as one .dox file body.
470 Both the generated per-file pages and the ids harvested from hand-written
471 .dox files are listed as children here, so the two kinds of page appear in
472 one tree rather than the hand-written ones floating at the top level.
474 Returns the file text, newline-terminated.
480 (p
for p
in DOCS_DIR.glob(
"*.md")
if p.name.lower() !=
"readme.md"),
481 key=
lambda p: p.name.lower(),
483 top_leaf_ids = [doxy_page_id(p.relative_to(ROOT).as_posix())
for p
in top_md]
484 top_leaf_ids += _handwritten_doc_page_ids()
487 (p
for p
in DOCS_DIR.iterdir()
if p.is_dir()
and p.name
not in DOCS_SKIP_DIRS),
488 key=
lambda p: p.name.lower(),
490 child_nodes: list[tuple[str, str]] = []
491 sub_blocks: list[str] = []
493 blocks, subid, _title = _walk_docs(sd, sd.name)
494 if subid
is not None:
495 child_nodes.append((subid, _title))
496 sub_blocks.extend(blocks)
499 "// GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
502 " * @page ra8_docs Documentation",
503 " * @brief The hand-written documentation, browsable exactly as it sits",
504 " * under docs/ on disk. (The API reference lives under Topics /",
505 " * Data Structures; the source tree lives under Files.)",
510 entries = [(subid,
"")
for subid, _t
in child_nodes]
511 entries += [(pid,
"")
for pid
in top_leaf_ids]
512 emit_subpage_list(out, entries)
515 out.extend(sub_blocks)
517 return "\n".join(out) +
"\n"
530COLLIDING_TOP_DIRS: dict[str, str] = {
531 "libs":
"Hand-written first-party libraries -- the drivers and substrates "
532 "the rest of the firmware builds on (HAL peripherals and register maps, "
533 "the ra8_core substrate, I/O fabric, security and TrustZone, the e-reader "
534 "stack, storage, networking, and board support). Vendored SOUP lives under "
535 "libs/third_party and is excluded from these docs.",
536 "port":
"Adapters between the vendored stacks under libs/third_party and "
537 "this firmware: ThreadX to the RA8 clock tree, NetX Duo to a link layer, "
538 "USBX to ra8_usb, LevelX to an ra8_fs block device, the Mbed TLS feature "
539 "set, NimBLE's HCI transport, esp-hosted, and a hosted POSIX filesystem "
540 "adapter. First-party code held to the full rule set; the stacks it "
542 "scripts":
"Developer and CI tooling: build, flash, and debug wrappers; "
543 "the HIL bench rig control (Tapo power, J-Link, and RTT); the OpenBao "
544 "secret client and root-of-trust key store; and the check_*.py quality "
545 "gates under scripts/checks that CI and the git hooks enforce.",
549def gen_dirs() -> str:
550 """Emit ``@dir`` blocks for top-level directories a bare name cannot reach.
552 Doxygen matches a directory reference by raw string suffix rather than by
553 path component, so top-level names such as ``libs``, ``port``, and
554 ``scripts`` can also match nested directories. Only colliding names need
555 this treatment; the rest keep their static blocks in docs/doxygen_dirs.dox.
557 Returns the file text, newline-terminated.
559 out = [
"// GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
""]
564 for name, brief
in COLLIDING_TOP_DIRS.items():
566 out.append(f
" * @dir {ROOT.name}/{name}")
567 out.append(f
" * @brief {brief}")
570 return "\n".join(out) +
"\n"
578PY_TOOL_DIRS = [
"scripts",
"tools",
"apps",
"libs",
"examples"]
579CONFIG_SKIP = {
"third_party",
"build",
"__pycache__",
"_deps",
"doxygen"}
582def python_module_symbols() -> list[str]:
583 """Collect the module STEMS of every first-party Python file, deduplicated.
585 Doxygen parses a .py file as a namespace named after its stem, and those
586 namespaces' classes then appear in the C firmware's Data Structures list.
587 Feeding these to EXCLUDE_SYMBOLS drops the symbols while leaving the files
588 themselves browsable under Files.
590 Stems, not paths: EXCLUDE_SYMBOLS matches symbol names, so two like-named
591 modules in different directories collapse to one entry -- which is correct
592 here, since both would produce the same namespace name.
594 mods: set[str] = set()
595 for top
in PY_TOOL_DIRS:
597 if not base.is_dir():
599 for py
in base.rglob(
"*.py"):
600 if any(part
in CONFIG_SKIP
for part
in py.parts):
606def gen_config() -> str:
607 """Render the EXCLUDE_SYMBOLS fragment the Doxyfile ``@INCLUDE``s.
609 Emitted as a Doxyfile fragment rather than edited into the Doxyfile so the
610 list regenerates with the tree; a new tooling module is excluded the next
611 time the docs build runs, with nothing to remember to update.
613 Returns the file text, newline-terminated.
615 mods = python_module_symbols()
617 "# GENERATED by scripts/gen/gen_doxygen_nav.py -- do not edit.",
618 "# Drop first-party Python tooling module symbols from the API so the",
619 "# C firmware's Data Structures / Topics lists stay clean. The .py files",
620 "# themselves remain listed and browsable under Files.",
621 "EXCLUDE_SYMBOLS = " +
" \\\n ".join(mods),
623 return "\n".join(lines) +
"\n"
627 """Write all four generated navigation files into docs/generated/.
629 build_docs.sh runs this immediately before doxygen, so the four files are
630 always regenerated from the current tree rather than read from a previous
631 build -- which is what makes stale navigation structurally impossible
632 rather than merely unlikely.
634 Output is written as ASCII, so a non-ASCII character reaching a README
635 blurb fails here loudly instead of producing mojibake in the rendered
636 site; the repo is ASCII-only by policy and this is where that is enforced
637 for generated navigation.
639 Returns 0; failures surface as exceptions rather than a status code, since
640 every one of them (unwritable output, non-ASCII input) is a build fault
641 with no partial-success reading.
643 OUT_DIR.mkdir(parents=
True, exist_ok=
True)
644 (OUT_DIR /
"nav_examples.dox").write_text(gen_examples(), encoding=
"ascii")
645 (OUT_DIR /
"nav_docs.dox").write_text(gen_docs(), encoding=
"ascii")
646 (OUT_DIR /
"nav_dirs.dox").write_text(gen_dirs(), encoding=
"ascii")
647 (OUT_DIR /
"nav_config.doxy").write_text(gen_config(), encoding=
"ascii")
649 f
"gen_doxygen_nav: wrote {OUT_DIR}/nav_examples.dox, nav_docs.dox, "
650 "nav_dirs.dox, nav_config.doxy"
655if __name__ ==
"__main__":
656 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.