import argparse import functools import html import http.server import json import posixpath import re import shutil import threading import time import urllib.parse from pathlib import Path DOCS_DIR = Path(__file__).resolve().parent SITE_DIR = DOCS_DIR / "_site" TEMPLATE_FILE = DOCS_DIR / "template.html" EXCLUDE_DIRS = {"_site", "__pycache__"} REPO_URL = "https://github.com/commaai/openpilot/" # (title, target) pairs. target is a page path or an absolute URL. # A None target marks a section header. NAV: list[tuple[str, str | None]] = [ ("What is openpilot?", "index.md"), ("How-to", None), ("Turn the speed blue", "how-to/turn-the-speed-blue.md"), ("Connect to a comma 3X or four", "how-to/connect-to-comma.md"), ("Add support for a car", "how-to/car-port.md"), ("Concepts", None), ("Logs", "concepts/logs.md"), ("Safety", "concepts/safety.md"), ("Glossary", "concepts/glossary.md"), ("Contributing", None), ("Feedback", "contributing/feedback.md"), ("Roadmap", "contributing/roadmap.md"), ("Contributing Guide →", "https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md"), ("Links", None), ("Blog →", "https://blog.comma.ai"), ("Bounties →", "https://comma.ai/bounties"), ("GitHub →", "https://github.com/commaai"), ("Discord →", "https://discord.comma.ai"), ("X →", "https://x.com/comma_ai"), ] GLOSSARY_DESCRIPTIONS = { "onroad": "openpilot's system state while ignition is on.", "offroad": "openpilot's system state while ignition is off.", "route": "A route is a recording of an onroad session.", "segment": "Routes are split into one minute chunks called segments.", "comma connect": "The web viewer for all your routes; check it out at [connect.comma.ai](https://connect.comma.ai).", "panda": "The secondary processor on the device that implements the functional safety and directly talks to the car over CAN. See the [panda repo](https://github.com/commaai/panda).", "comma four": "The latest hardware by comma.ai for running openpilot. More info at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four).", } GLOSSARY_PAGE = "concepts/glossary.md" GLOSSARY_ROUTE = GLOSSARY_PAGE.removesuffix(".md") GLOSSARY_SKIP = frozenset("a code h1 h2 h3 h4 h5 h6 kbd pre script style".split()) _ENTITY = re.compile(r"&(?:#x?[0-9a-fA-F]+|[a-zA-Z]+);") _LIST = re.compile(r"^(\s*)([*+-]|\d+\.)\s+(.*)$") _HEADING = re.compile(r"^(#{1,6})\s+(.*)$") _HR = re.compile(r"^(-{3,}|\*{3,}|_{3,})$") _ATTR_URL = re.compile(r"""(?P
\b(?:href|src)=(?P["']))(?P.*?)(?P=q)""") _VOID = frozenset("br img hr meta link input".split()) _URL = re.compile(r"https?://[^\s<>\[\]\"']+") _ADMONITION = re.compile(r"^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$", re.I) def page_route(path: str) -> str: path = path.removesuffix(".md") return posixpath.dirname(path) or "." if posixpath.basename(path) == "index" else path def page_href(current: str, target: str) -> str: route = posixpath.relpath(page_route(target), page_route(current)) return ("." if route == "." else route) + "/" def rewrite_relative_url(value: str, page: str) -> str | None: url = urllib.parse.urlparse(value) if value.startswith(("#", "/")) or url.scheme or url.netloc or not url.path: return None target = posixpath.normpath(posixpath.join(posixpath.dirname(page), url.path)) if target == ".." or target.startswith("../"): return None path = page_href(page, target) if target.endswith(".md") else posixpath.relpath(target, page_route(page)) return url._replace(path=path).geturl() def rewrite_html_urls(fragment: str, page: str) -> str: def repl(m: re.Match[str]) -> str: r = rewrite_relative_url(m.group("url"), page) return m.group(0) if r is None else f'{m.group("pre")}{r}{m.group("q")}' return _ATTR_URL.sub(repl, fragment) def esc(text: str, attr: bool = False) -> str: held: list[str] = [] def hold(m: re.Match[str]) -> str: held.append(m.group(0)) return f"\0{len(held) - 1}\0" return re.sub(r"\0(\d+)\0", lambda m: held[int(m.group(1))], html.escape(_ENTITY.sub(hold, text), quote=attr)) def clean_tooltip(description: str) -> str: text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", description) return re.sub(r"\s+", " ", re.sub(r"[*_~]", "", re.sub(r"`([^`]+)`", r"\1", text))).strip() def glossary_slug(label: str) -> str: return label.replace(" ", "-").replace("_", "-").lower() GLOSSARY_TERMS = [(glossary_slug(l), re.compile(rf"(?**{l}**: {d}' for l, d in GLOSSARY_DESCRIPTIONS.items()) def inject_glossary(body: str, page: str) -> str: if page == GLOSSARY_PAGE: return body route = "." if page == "index.md" else page.removesuffix(".md") base, seen, out, skip, depth = f"{posixpath.relpath(GLOSSARY_ROUTE, route)}/#", set(), [], None, 0 for part in re.split(r"(<[^>]+>)", body): if not part: continue if part.startswith("<"): out.append(part) if part.startswith("") or tag in _VOID if closing and skip == tag and depth: depth -= 1 skip = None if not depth else skip elif not closing and not void: skip, depth = (tag, 1) if skip is None else (skip, depth + (skip == tag)) continue if depth: out.append(part) continue cur, text = 0, part while True: best = None for order, (slug, pat, tip) in enumerate(GLOSSARY_TERMS): if slug in seen or (found := pat.search(text, cur)) is None: continue cand = (found.start(), found.start() - found.end(), order, slug, tip, found.end(), found.group(0)) if best is None or cand[:3] < best[:3]: best = cand if best is None: out.append(text[cur:]) break start, _, _, slug, tip, end, matched = best out.append(text[cur:start]) out.append( f'' + f'{matched}' + f'{esc(tip)}' ) seen.add(slug) cur = end return "".join(out) def slugify(text: str) -> str: text = html.unescape(re.sub(r"<[^>]+>", "", text)).lower() return re.sub(r"[-\s]+", "-", re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)).strip("-") def _parse_link(text: str, start: int) -> tuple[str, str, int] | None: if start >= len(text) or text[start] != "[": return None depth, i = 0, start while i < len(text): depth += (text[i] == "[") - (text[i] == "]") if text[i] == "]" and depth == 0: label = text[start + 1 : i] if i + 1 >= len(text) or text[i + 1] != "(": return None j, dp = i + 2, 1 while j < len(text) and dp: dp += (text[j] == "(") - (text[j] == ")") j += 1 return None if dp else (label, text[i + 2 : j - 1], j) i += 1 return None def autolink_plain(text: str) -> str: parts: list[str] = [] last = 0 for m in _URL.finditer(text): start = m.start() if start > 0 and text[start - 1].isalnum(): continue parts.append(esc(text[last:start])) url = m.group(0).rstrip(".,;:!?)]") parts.append(f'{esc(url)}') last = start + len(url) parts.append(esc(text[last:])) return "".join(parts) def render_inline(text: str, page: str) -> str: out, i, n = [], 0, len(text) while i < n: if text[i] == "\n" and i >= 2 and text[i - 2 : i] == " " and out and out[-1].endswith(" "): out[-1] = out[-1][:-2] out.append("
\n") i += 1 continue if text[i] == "`" and (end := text.find("`", i + 1)) != -1: out.append(f"{esc(text[i + 1 : end])}") i = end + 1 continue if text[i] == "!" and i + 1 < n and text[i + 1] == "[" and (p := _parse_link(text, i + 1)): label, url, end = p src = rewrite_relative_url(url, page) or url out.append(f'') i = end continue if text[i] == "[" and (p := _parse_link(text, i)): label, url, end = p href = rewrite_relative_url(url, page) or url out.append(f'{render_inline(label, page)}') i = end continue if text[i] == "<": if text.startswith("", i + 4) end = n if end < 0 else end + 3 out.append(rewrite_html_urls(text[i:end], page)) i = end continue if m := re.match(r"<[^>]+>", text[i:]): out.append(rewrite_html_urls(m.group(0), page)) i += len(m.group(0)) continue if (text.startswith("**", i) or text.startswith("__", i)) and (end := text.find(text[i : i + 2], i + 2)) != -1: out.append(f"{render_inline(text[i + 2 : end], page)}") i = end + 2 continue if text[i] in "*_" and i + 1 < n and text[i + 1] not in " \t\n" and (end := text.find(text[i], i + 1)) > i + 1: out.append(f"{render_inline(text[i + 1 : end], page)}") i = end + 1 continue j = i + 1 while j < n and text[j] not in "`[ list[str]: return [c.strip() for c in line.strip().removeprefix("|").removesuffix("|").split("|")] def _is_sep(line: str) -> bool: return "|" in line and all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in _trow(line)) def _align(sep: str) -> str: s = sep.strip() if s.startswith(":") and s.endswith(":"): return ' style="text-align: center;"' if s.endswith(":"): return ' style="text-align: right;"' if s.startswith(":"): return ' style="text-align: left;"' return "" def _list_info(line: str) -> tuple[int, str, str] | None: m = _LIST.match(line) return None if not m else (len(m.group(1)) // 4, "ol" if m.group(2)[-1] == "." else "ul", m.group(3)) def _render_blocks(text: str, page: str) -> str: lines, out, i, n = text.splitlines(), [], 0, 0 n = len(lines) while i < n: line, s = lines[i], lines[i].strip() if not s: i += 1 continue if s.startswith("```"): lang, body = s[3:].strip(), [] i += 1 while i < n and not lines[i].strip().startswith("```"): body.append(lines[i]) i += 1 if i < n: i += 1 code = html.escape("\n".join(body) + ("\n" if body else "")) cls = f' class="language-{html.escape(lang)}"' if lang else "" out.append(f"
") continue if m := _HEADING.match(s): content, level = m.group(2).rstrip("#").strip(), len(m.group(1)) sid = slugify(content) out.append(f'{code}{render_inline(content, page)}# ') i += 1 continue if _HR.fullmatch(s): out.append("
") i += 1 continue if "|" in line and i + 1 < n and _is_sep(lines[i + 1]): headers, aligns = _trow(line), [_align(c) for c in _trow(lines[i + 1])] i += 2 rows = [] while i < n and "|" in lines[i] and lines[i].strip(): rows.append(_trow(lines[i])) i += 1 parts = ( ["", "", "
"])) continue if _list_info(line): items: list[tuple[int, str, list[str]]] = [] while i < n: if not lines[i].strip(): if i + 1 < n and _list_info(lines[i + 1]): i += 1 continue break info = _list_info(lines[i]) if not info: break level, kind, body = info chunk = [body] i += 1 while i < n and lines[i].strip() and _list_info(lines[i]) is None: t = lines[i].strip() if t.startswith(("```", ">")) or _HEADING.match(t) or _HR.fullmatch(t): break chunk.append(lines[i]) i += 1 items.append((level, kind, chunk)) def render_list(items: list[tuple[int, str, list[str]]], start: int, min_level: int) -> tuple[str, int]: if start >= len(items) or items[start][0] < min_level: return "", start kind, chunks, idx = items[start][1], [f"<{items[start][1]}>"], start while idx < len(items) and items[idx][0] >= min_level: level, ikind, body_lines = items[idx] if level > min_level: nested, idx = render_list(items, idx, level) chunks[-1] = (chunks[-1][:-5] + nested + "") if chunks[-1].endswith("") else chunks[-1] + nested continue if ikind != kind: chunks += [f"{kind}>", f"<{ikind}>"] kind = ikind idx += 1 body = render_inline("\n".join(body_lines), page) nested = "" if idx < len(items) and items[idx][0] > min_level: nested, idx = render_list(items, idx, min_level + 1) chunks.append(f""] + [f" ", "", ""] ) for row in rows: parts.append("{render_inline(h, page)} " for j, h in enumerate(headers)] + ["") for j in range(len(headers)): parts.append(f" ") out.append("\n".join(parts + ["", "{render_inline(row[j] if j < len(row) else '', page)} ") parts.append("{body}{nested}\n " if nested else f"{body} ") chunks.append(f"{kind}>") return "\n".join(chunks), idx out.append(render_list(items, 0, items[0][0])[0]) continue if s.startswith(">"): q = [] while i < n and lines[i].strip().startswith(">"): q.append(re.sub(r"^>\s?", "", lines[i].strip())) i += 1 m = _ADMONITION.match(q[0].strip()) if q else None if m: kind = m.group(1).lower() title = m.group(1).capitalize() body = _render_blocks("\n".join(q[1:]), page) out.append(f'\n') else: out.append(f"{title}
\n{body}\n\n") continue if s.startswith("{render_inline(chr(10).join(q), page)}
\n