Public Access
/dev/ has Debug Builds and the Debug Console (builds and the token, the console and its protocol, files and screenshots, driving the UI, crashes and Safe Mode, the command reference), Build, test and release (including how an update works), the architecture decisions and the milestone plans. Generated from the repository by site/tools/gen_dev_docs.py: the ADRs, the milestones, the README's sections, and the command reference, read from the firmware's own `help` text. The pages are committed (Zola cannot read outside its folder); the Site workflow checks they are current, and now also runs when src/main.cpp changes. M0, M1 and CONTEXT.md are not published. README: the gnss commands that the table lacked. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
203 lines
9.0 KiB
Python
203 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Writes the developer pages of the site from the repository's own documents (docs/milestones/W1.md, phase 4).
|
|
|
|
python3 site/tools/gen_dev_docs.py writes site/content/dev/... (committed, so the server only runs `zola build`)
|
|
python3 site/tools/gen_dev_docs.py --check changes nothing; exits 1 if a generated page is out of date (CI)
|
|
|
|
Zola cannot read a file outside its own folder, so the pages are generated and committed. What is generated:
|
|
decisions/ one page for each docs/adr/*.md
|
|
milestones/ one page for each docs/milestones/*.md
|
|
build/build-and-test, build/flash sections of README.md
|
|
debug/commands the commands the firmware's `help` prints (parsed from src/main.cpp), then README's table of them
|
|
Every generated page says where it comes from; edit that file, not the page. Hand-written pages sit next to them.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
SITE = HERE.parent
|
|
REPO = SITE.parent
|
|
OUT = SITE / "content" / "dev"
|
|
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
|
|
|
|
MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1"] # in the order they were done
|
|
# Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish.
|
|
# They stay in the repository.
|
|
|
|
|
|
def plain(md, limit=230):
|
|
"""One line of plain text from the start of some Markdown, for a description."""
|
|
text = re.sub(r"`([^`]*)`", r"\1", md)
|
|
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
|
text = re.sub(r"\*{1,3}", "", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
text = text[:1].upper() + text[1:]
|
|
if len(text) <= limit:
|
|
return text
|
|
cut = text[:limit].rsplit(" ", 1)[0].rstrip(",;:")
|
|
return cut + "…"
|
|
|
|
|
|
def front(title, description, weight, source, tag=None, template=None):
|
|
lines = ["+++", f"title = {json.dumps(title, ensure_ascii=False)}", f"description = {json.dumps(description, ensure_ascii=False)}", f"weight = {weight}"]
|
|
if template:
|
|
lines.append(f'template = "{template}"')
|
|
lines += ["", "[extra]", "docs = true", f"source = {json.dumps(source)}"]
|
|
if tag:
|
|
lines.append(f"tag = {json.dumps(tag, ensure_ascii=False)}")
|
|
lines += ["+++", ""]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def relink(md, source):
|
|
"""Links between the repository's documents become links between the site's pages, or to Gitea."""
|
|
here = Path(source).parent
|
|
|
|
def fix(m):
|
|
label, target = m.group(1), m.group(2)
|
|
if re.match(r"[a-z]+:|#|/", target):
|
|
return m.group(0)
|
|
path, _, frag = target.partition("#")
|
|
full = os.path.normpath(here / path)
|
|
frag = "#" + frag if frag else ""
|
|
m_adr = re.fullmatch(r"docs/adr/(.+)\.md", full)
|
|
m_ms = re.fullmatch(r"docs/milestones/(.+)\.md", full)
|
|
if m_adr:
|
|
return f"[{label}](/dev/decisions/{m_adr.group(1)}/{frag})"
|
|
if m_ms:
|
|
return f"[{label}](/dev/milestones/{m_ms.group(1).lower()}/{frag})"
|
|
if full == "CONTEXT.md":
|
|
return f"[{label}](/dev/glossary/{frag})"
|
|
return f"[{label}]({REPO_URL}/src/branch/main/{full}{frag})"
|
|
|
|
return re.sub(r"\[([^\]]*)\]\(([^)\s]+)\)", fix, md)
|
|
|
|
|
|
def title_and_body(text):
|
|
m = re.match(r"#\s+(.+)\n", text)
|
|
return (m.group(1).strip(), text[m.end():].lstrip("\n")) if m else ("", text)
|
|
|
|
|
|
def first_paragraph(body):
|
|
for block in re.split(r"\n\s*\n", body):
|
|
b = block.strip()
|
|
if b and not b.startswith(("#", "|", "```", "- ", "1.", "![")):
|
|
return b
|
|
return ""
|
|
|
|
|
|
def goal_or_first(body):
|
|
m = re.search(r"\*\*Goal:\*\*\s*(.+?)(?:\n\s*\n|\Z)", body, re.S)
|
|
return m.group(1) if m else first_paragraph(re.sub(r"^\*\*Status:\*\*.*\n", "", body, flags=re.M))
|
|
|
|
|
|
def sections(readme):
|
|
"""README's `## ` sections by heading, each with its heading line and everything up to the next `## `."""
|
|
out, name, buf = {}, None, []
|
|
for line in readme.splitlines(keepends=True):
|
|
if line.startswith("## "):
|
|
if name:
|
|
out[name] = "".join(buf).rstrip() + "\n"
|
|
name, buf = line[3:].strip(), [line]
|
|
elif name:
|
|
buf.append(line)
|
|
if name:
|
|
out[name] = "".join(buf).rstrip() + "\n"
|
|
return out
|
|
|
|
|
|
def help_text():
|
|
"""The two lists `help` prints: for every build, and for Debug Builds only, from kHelp in src/main.cpp."""
|
|
src = (REPO / "src" / "main.cpp").read_text()
|
|
m = re.search(r"static const char\* const kHelp =(.*?)\n\s*;", src, re.S)
|
|
if not m:
|
|
sys.exit("gen_dev_docs: kHelp not found in src/main.cpp")
|
|
common, debug, in_debug = [], [], False
|
|
for line in m.group(1).splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith("#ifdef RORO_DEBUG"):
|
|
in_debug = True
|
|
elif stripped.startswith("#endif"):
|
|
in_debug = False
|
|
else:
|
|
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', line):
|
|
(debug if in_debug else common).append(lit.replace("\\n", "\n").replace('\\"', '"'))
|
|
return "".join(common).rstrip("\n"), "".join(debug).rstrip("\n")
|
|
|
|
|
|
def safe_mode_commands():
|
|
src = (REPO / "src" / "main.cpp").read_text()
|
|
m = re.search(r"static bool safeModeCommand\(const String& line\) \{(.*?)\n\}", src, re.S)
|
|
if not m:
|
|
sys.exit("gen_dev_docs: safeModeCommand not found in src/main.cpp")
|
|
body = m.group(1)
|
|
exact = re.findall(r'line == "([^"]+)"', body)
|
|
prefix = re.findall(r'line\.startsWith\("([^"]+)"\)', body)
|
|
return exact, prefix
|
|
|
|
|
|
def build():
|
|
pages = {}
|
|
|
|
for path in sorted((REPO / "docs" / "adr").glob("*.md")):
|
|
title, body = title_and_body(path.read_text())
|
|
number = path.stem[:4]
|
|
source = f"docs/adr/{path.name}"
|
|
pages[f"decisions/{path.stem}.md"] = front(title, plain(first_paragraph(body)), int(number), source, tag=f"ADR {number}") + relink(body, source)
|
|
|
|
for i, code in enumerate(MILESTONES):
|
|
path = REPO / "docs" / "milestones" / f"{code}.md"
|
|
title, body = title_and_body(path.read_text())
|
|
title = re.sub(rf"^{code}\s*[—:-]\s*", "", title)
|
|
source = f"docs/milestones/{path.name}"
|
|
pages[f"milestones/{code.lower()}.md"] = front(title, plain(goal_or_first(body)), (i + 1) * 10, source, tag=code) + relink(body, source)
|
|
|
|
readme = sections((REPO / "README.md").read_text())
|
|
pages["build/build-and-test.md"] = (
|
|
front("Build, test and release", "Docker is the only tool you need. How the firmware is built, how the host tests run, and what CI does on a pull request and on a tag.", 1, "README.md", tag="Build")
|
|
+ relink("\n".join(readme[k] for k in ("Requirements", "Build and test (local CI)", "CI and releases")), "README.md"))
|
|
pages["build/flash.md"] = (
|
|
front("Flash and update", "Put the firmware on a Cardputer over USB, then update it over Wi-Fi or from the SD card, and from the project's releases.", 2, "README.md", tag="Flash")
|
|
+ relink("\n".join(readme[k] for k in ("Flash", "Firmware Updates over Wi-Fi (OTA)")), "README.md"))
|
|
|
|
common, debug = help_text()
|
|
exact, prefix = safe_mode_commands()
|
|
safe = ", ".join(f"`{c}`" for c in exact) + ", and anything starting with " + ", ".join(f"`{p.strip()}`" for p in prefix)
|
|
dev = readme["Development aids"].split("### Debug Builds and the Debug Console")[0]
|
|
dev = re.sub(r"^## Development aids\n", "", dev).strip() + "\n"
|
|
pages["debug/commands.md"] = (
|
|
front("Command reference", "Every command the firmware understands, over USB serial or the Debug Console: what `help` prints, then what each one does.", 30, "src/main.cpp and README.md", tag="Reference")
|
|
+ "## What `help` prints\n\n"
|
|
+ "The firmware's own list, read from `src/main.cpp`. These work in every build, over USB serial:\n\n```\n" + common + "\n```\n\n"
|
|
+ "A **Debug Build** adds these (the last ones, marked *Debug Console only*, exist only over Wi-Fi, where [`rdbg.py`](/dev/debug/files-and-screens/) speaks them):\n\n```\n" + debug + "\n```\n\n"
|
|
+ f"In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: {safe}. Anything else answers `not available in Safe Mode`.\n\n"
|
|
+ "## What they do\n\n" + dev)
|
|
return pages
|
|
|
|
|
|
def main():
|
|
check = "--check" in sys.argv
|
|
pages = build()
|
|
stale = []
|
|
for rel, text in sorted(pages.items()):
|
|
path = OUT / rel
|
|
have = path.read_text() if path.exists() else None
|
|
if have != text:
|
|
stale.append(rel)
|
|
if not check:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
if check:
|
|
for rel in stale:
|
|
print(f"gen_dev_docs: content/dev/{rel} is out of date: run site/tools/gen_dev_docs.py and commit the result")
|
|
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} out of date")
|
|
sys.exit(1 if stale else 0)
|
|
print(f"gen_dev_docs: {len(pages)} pages, {len(stale)} written")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|