"""Diagrams for the site, written as SVG from the repository's own data.
Each diagram is a function of the registry, the platform files, the
pipeline's step list or the install manifests, so it changes when the data
does and never needs redrawing by hand. Output is deterministic: sorted
inputs, fixed number formatting, ids derived from the diagram's name.
A page asks for a diagram with an HTML comment on a line of its own::
``resolve_images`` replaces the comment with the figure. The SVG is inline
so its text takes the page font and its colours come from the theme
variables in ``docs_assets/extra.css``: it follows the palette toggle, which
an ```` cannot. Presentation attributes give the same drawing a
readable default when the SVG is opened on its own.
Every figure carries two layouts, wide and narrow, and the stylesheet shows
one: a 720-unit drawing scaled to a phone would print 9 px text.
"""
from __future__ import annotations
import ast
import json
import re
import sys
import urllib.parse
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import sitemarkup as markup # noqa: E402
PLACEHOLDER = re.compile(r"(?m)^([ \t]*)[ \t]*$")
PLACEHOLDER_ANYWHERE = re.compile(r"")
FONT = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
# The stylesheet sets the page font on every , so a path takes the
# mono font from an inline style, which outranks it.
MONO_STYLE = "font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)"
WIDE = 720
NARROW = 340
# The wide drawing is scaled to a column about 700 px wide: these sizes read
# at 14 px of body text there, the size of the prose around the figure.
COLUMN = 700
TEXT_SIZE = round(14 * WIDE / COLUMN, 1)
TITLE_SIZE = round(15 * WIDE / COLUMN, 1)
LINE = 20
PAD = 12
# A box holds a title and at most this many lines: the rest is the caption's.
MAX_BOX_LINES = 3
# Default colours for an SVG read outside the page. The stylesheet replaces
# them on the site through the rb-d-* classes.
INK = "#1f2233"
FILL = "#f5f6fa"
STROKE = "#8c92ac"
ACCENT = "#3b3f7a"
MUTED = "#546e7a"
class Mono(str):
"""A path or a name typed as is: drawn in the mono font on a line of its
own, never broken."""
def _width(text: str, size: float, bold: bool = False) -> float:
"""Rough advance of a string: 0.56 em a character, 0.6 bold or mono."""
return len(text) * size * (0.6 if bold or isinstance(text, Mono) else 0.56)
def wrap(text: str, width: float, size: float, bold: bool = False) -> list[str]:
"""Greedy word wrap against the estimated advance. A Mono line stays whole."""
if isinstance(text, Mono):
return [text]
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if current and _width(candidate, size, bold) > width:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
return lines
def _n(value: float) -> str:
text = f"{value:.1f}"
return text[:-2] if text.endswith(".0") else text
@dataclass
class Box:
title: str
lines: list[str] = field(default_factory=list)
accent: bool = False
number: str = ""
@dataclass
class Figure:
"""One diagram: what it shows, said twice (alt and caption), and drawn twice."""
image_id: str
title: str
desc: str
caption: str
wide: Callable[[], tuple[float, float, list[str]]]
narrow: Callable[[], tuple[float, float, list[str]]]
# Drawing primitives
def _text(x: float, y: float, value: str, size: float = TEXT_SIZE,
bold: bool = False, anchor: str = "start", cls: str = "") -> str:
weight = ' font-weight="600"' if bold else ""
klass = f' class="{cls}"' if cls else ""
style = f' style="{MONO_STYLE}"' if isinstance(value, Mono) else ""
fill = MUTED if cls == "rb-d-muted" else ("#ffffff" if cls == "rb-d-numtext" else INK)
return (
f'{escape(value)}'
)
def _rect(x: float, y: float, w: float, h: float, accent: bool = False) -> str:
cls = "rb-d-box rb-d-accent" if accent else "rb-d-box"
stroke = ACCENT if accent else STROKE
width = 2 if accent else 1
return (
f''
)
def _badge_side(number: str) -> float:
return max(20.0, _width(number, 11, True) + 10)
def _badge(x: float, y: float, number: str) -> list[str]:
side = _badge_side(number)
return [
f'',
_text(x + side / 2, y + 14, number, 11, True, "middle", "rb-d-numtext"),
]
def _line(points: list[tuple[float, float]], head: bool = True) -> list[str]:
"""A connector through *points*, with a drawn arrowhead at the end.
The head is a small triangle, never a text glyph.
"""
path = " ".join(
f"{'M' if index == 0 else 'L'}{_n(x)} {_n(y)}" for index, (x, y) in enumerate(points)
)
out = [
f''
]
if head and len(points) >= 2:
(x1, y1), (x2, y2) = points[-2], points[-1]
if x1 == x2:
sign = 1 if y2 > y1 else -1
tri = [(x2, y2), (x2 - 5, y2 - 8 * sign), (x2 + 5, y2 - 8 * sign)]
else:
sign = 1 if x2 > x1 else -1
tri = [(x2, y2), (x2 - 8 * sign, y2 - 5), (x2 - 8 * sign, y2 + 5)]
out.append(
''
)
return out
def box_lines(box: Box, width: float) -> tuple[list[str], list[str]]:
"""The title and body lines of *box* drawn *width* wide, as wrapped."""
inner = width - 2 * PAD
title_lines = wrap(box.title, inner - (_badge_side(box.number) + 8 if box.number else 0),
TITLE_SIZE, True)
body: list[str] = []
for line in box.lines:
body.extend(wrap(line, inner, TEXT_SIZE))
return title_lines, body
def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]:
title_lines, body = box_lines(box, width)
height = PAD + len(title_lines) * LINE + (6 if body else 0) + len(body) * LINE + PAD
if box.number:
height = max(height, PAD * 2 + 20)
return title_lines, body, height + 2
def _draw_box(box: Box, x: float, y: float, width: float, height: float,
title_lines: list[str], body: list[str]) -> list[str]:
out = [_rect(x, y, width, height, box.accent)]
tx = x + PAD
if box.number:
out.extend(_badge(x + PAD, y + PAD, box.number))
tx += _badge_side(box.number) + 8
cy = y + PAD + 15
for line in title_lines:
out.append(_text(tx, cy, line, TITLE_SIZE, True))
cy += LINE
cy += 6
for line in body:
out.append(_text(x + PAD, cy, line, TEXT_SIZE, cls="rb-d-muted"))
cy += LINE
return out
def flow(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float, list[str]]:
"""Boxes joined in order: a row on a wide screen, a column on a narrow one."""
out: list[str] = []
if vertical:
width = total
y = 1.0
for index, box in enumerate(boxes):
title_lines, body, height = _box_height(box, width - 2)
out.extend(_draw_box(box, 1, y, width - 2, height, title_lines, body))
y += height
if index < len(boxes) - 1:
out.extend(_line([(width / 2, y), (width / 2, y + 22)]))
y += 24
return width, y + 1, out
gap = 26.0
width = (total - gap * (len(boxes) - 1) - 2) / len(boxes)
measured = [_box_height(box, width) for box in boxes]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)):
x = 1 + index * (width + gap)
# A box is as tall as its lines, centred on the row the arrows run along.
out.extend(_draw_box(box, x, 1 + (height - own) / 2, width, own, title_lines, body))
if index < len(boxes) - 1:
mid = 1 + height / 2
out.extend(_line([(x + width, mid), (x + width + gap - 1, mid)]))
return total, height + 2, out
def columns(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float, list[str]]:
"""Side-by-side boxes with no order between them."""
out: list[str] = []
if vertical:
y = 1.0
for box in boxes:
title_lines, body, height = _box_height(box, total - 2)
out.extend(_draw_box(box, 1, y, total - 2, height, title_lines, body))
y += height + 12
return total, y - 11, out
gap = 16.0
width = (total - gap * (len(boxes) - 1) - 2) / len(boxes)
measured = [_box_height(box, width) for box in boxes]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)):
out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, own, title_lines, body))
return total, height + 2, out
def grid(boxes: list[Box], per_row: int, total: float) -> tuple[float, float, list[str]]:
"""Ordered boxes in rows, each row joined to the next by an elbow line."""
out: list[str] = []
gap_x, gap_y = 18.0, 30.0
width = (total - gap_x * (per_row - 1) - 2) / per_row
rows = [boxes[i:i + per_row] for i in range(0, len(boxes), per_row)]
y = 1.0
previous_end: tuple[float, float] | None = None
for row in rows:
measured = [_box_height(box, width) for box in row]
height = max(m[2] for m in measured)
for index, (box, (title_lines, body, _)) in enumerate(zip(row, measured)):
x = 1 + index * (width + gap_x)
if previous_end is not None and index == 0:
px, py = previous_end
out.extend(_line([(px, py), (px, py + gap_y / 2), (x + width / 2, py + gap_y / 2),
(x + width / 2, y)]))
out.extend(_draw_box(box, x, y, width, height, title_lines, body))
if index < len(row) - 1:
out.extend(_line([(x + width, y + height / 2), (x + width + gap_x - 1, y + height / 2)]))
previous_end = (x + width / 2, y + height)
y += height + gap_y
return total, y - gap_y + 1, out
def svg(figure: Figure, layout: str, suffix: str) -> str:
"""One layout of *figure* as a standalone, accessible SVG element."""
width, height, body = (figure.wide if layout == "wide" else figure.narrow)()
base = f"rb-fig-{figure.image_id}{suffix}-{layout}"
return (
f'"
)
# Data
@lru_cache(maxsize=None)
def registry() -> dict:
import yaml
with open(ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle:
return yaml.safe_load(handle).get("platforms", {})
@lru_cache(maxsize=None)
def setups() -> tuple[str, ...]:
"""The registered setups, archived ones included: each has its own page,
pack and file list, so every count of setups on the site is this one."""
from common import list_registered_platforms
return tuple(sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True)))
def setups_named(named: list[str]) -> str:
"""*named*, then how many other setups there are: "A, B and 15 more"."""
from siteguides import setup_count
others = setup_count() - len(named)
return ", ".join(named) + (f" and {others} more" if others else "")
@lru_cache(maxsize=None)
def platform_modes() -> dict[str, tuple[str, str, str | None]]:
"""name -> (display name, verification mode, hash rule) for every platform."""
from common import load_platform_config
out: dict[str, tuple[str, str, str | None]] = {}
for name in setups():
config = load_platform_config(name, str(ROOT / "platforms"))
rule = registry().get(name, {}).get("hash_rule") or config.get("hash_rule")
out[name] = (config.get("platform", name), config.get("verification_mode", ""), rule)
return out
def _display(name: str) -> str:
return platform_modes()[name][0]
def _folder(name: str) -> str:
return str(registry()[name].get("extract_to", "")).strip()
# Frontends and emulators shown in the explainer, each pair one a user meets
# together. Tests check that the platform runs that emulator.
CHAIN_EXAMPLES = (("batocera", "duckstation"), ("emudeck", "pcsx2"), ("retroarch", "beetle_psx"))
@lru_cache(maxsize=None)
def emulator_names() -> dict[str, str]:
import yaml
names: dict[str, str] = {}
for _, emulator in CHAIN_EXAMPLES:
with open(ROOT / "emulators" / f"{emulator}.yml", encoding="utf-8") as handle:
label = str(yaml.safe_load(handle).get("emulator", emulator))
names[emulator] = re.sub(r"\s*\(.*\)$", "", label)
return names
def _bios_chain_boxes() -> list[Box]:
platform, _emulator = CHAIN_EXAMPLES[0]
return [
Box("Your device", ["A PC, a Steam Deck, a handheld or a TV box"], number="1"),
Box("Setup", ["The menu you pick a game from"], number="2"),
Box("Emulator", ["Starts the game and reads its BIOS"], number="3"),
Box("BIOS folder", [f"{_display(platform)} reads", Mono(_folder(platform))],
accent=True, number="4"),
]
def _bios_chain_caption() -> str:
"""The examples the boxes leave out, read from the registry and profiles."""
named = setups_named([_display(platform) for platform, _ in CHAIN_EXAMPLES])
pairs = ", ".join(
f"{_display(platform)} starts {emulator_names()[emulator]}"
for platform, emulator in CHAIN_EXAMPLES
)
folders = ", ".join(f"{_display(platform)} {_folder(platform)}" for platform, _ in CHAIN_EXAMPLES)
return (
f"Setups: {named}. Examples: {pairs}. "
f"BIOS folders: {folders}. The ZIP or the command fills that folder."
)
def _install_boxes() -> list[Box]:
return [
Box("Finds your setup", ["And the folder it reads"], number="1"),
Box("Looks at your files", ["The ones already in the folder"], number="2"),
Box("Downloads the rest", ["Only what is missing or different"], number="3"),
Box("Checks each file", ["Before saving it"], accent=True, number="4"),
]
MODE_TEXT = {
"existence": ("Name only", "Only the name and place count."),
"md5": ("MD5 fingerprint", "The content must match an MD5 the platform lists."),
"sha1": ("SHA-1 fingerprint", "The content must match a SHA-1 the platform lists."),
}
def _modes() -> dict[str, list[str]]:
"""Mode -> the platforms checking that way, RomM's size rule named."""
by_mode: dict[str, list[str]] = {mode: [] for mode in MODE_TEXT}
for display, mode, rule in platform_modes().values():
label = f"{display} (size, then any hash)" if rule == "size_and_any_hash" else display
by_mode.setdefault(mode, []).append(label)
return {mode: sorted(names, key=str.casefold) for mode, names in by_mode.items()}
def _mode_boxes() -> list[Box]:
"""Each way of checking and how many platforms use it. The names are in
the caption: eleven of them do not fit in three lines of a box."""
boxes = []
for mode, (title, text) in MODE_TEXT.items():
count = len(_modes().get(mode, []))
boxes.append(Box(title, [text, f"{count} platform" + ("" if count == 1 else "s")]))
return boxes
def _mode_summary() -> str:
return " ".join(
f"{title}: {', '.join(_modes().get(mode, []))}."
for mode, (title, _) in MODE_TEXT.items()
)
# MinUI names a BIOS folder after the tag in parentheses of the ROM folder.
# These are three of the tags its launch scripts read.
MINUI_EXAMPLES = (("Sony PlayStation", "PS"), ("Game Boy Advance", "GBA"), ("Sega Genesis", "MD"))
@lru_cache(maxsize=None)
def minui_files() -> dict[str, list[str]]:
manifest = json.loads((ROOT / "install" / "minui.json").read_text(encoding="utf-8"))
by_tag: dict[str, list[str]] = {}
for entry in manifest.get("files", []):
parts = str(entry.get("dest", "")).split("/")
if len(parts) == 3 and parts[0] == "Bios":
by_tag.setdefault(parts[1], []).append(parts[2])
return {tag: sorted(names, key=str.casefold) for tag, names in by_tag.items()}
def _minui_wide() -> tuple[float, float, list[str]]:
out: list[str] = []
left_x, right_x, row = 1.0, 430.0, 22.0
out.append(_text(left_x, 16, "Roms/", TITLE_SIZE, True))
out.append(_text(right_x, 16, "Bios/", TITLE_SIZE, True))
y = 30.0
for name, tag in MINUI_EXAMPLES:
files = minui_files().get(tag, [])
shown = files[:2] + ([f"and {len(files) - 2} more"] if len(files) > 2 else [])
height = max(row + 8, 14 + row * (1 + len(shown)))
label = f"{name} ({tag})/"
out.append(_rect(left_x + 14, y, 250, row + 8))
out.append(_text(left_x + 24, y + 19, label, TEXT_SIZE, True))
out.append(_rect(right_x + 14, y, 250, height))
out.append(_text(right_x + 24, y + 19, f"{tag}/", TEXT_SIZE, True))
for index, file_name in enumerate(shown):
out.append(_text(right_x + 40, y + 19 + row * (index + 1), file_name, TEXT_SIZE,
cls="rb-d-muted"))
out.extend(_line([(left_x + 264, y + (row + 8) / 2), (right_x + 13, y + (row + 8) / 2)]))
out.append(_text((left_x + 264 + right_x + 14) / 2, y + (row + 8) / 2 - 6,
f"tag {tag}", 11, anchor="middle", cls="rb-d-muted"))
y += height + 14
return WIDE, y - 12, out
def _minui_narrow() -> tuple[float, float, list[str]]:
out: list[str] = []
y = 1.0
for name, tag in MINUI_EXAMPLES:
files = minui_files().get(tag, [])
out.append(_rect(1, y, NARROW - 2, 30))
out.append(_text(11, y + 20, f"Roms/{name} ({tag})/", TEXT_SIZE, True))
out.extend(_line([(NARROW / 2, y + 30), (NARROW / 2, y + 52)]))
out.append(_text(NARROW / 2 + 10, y + 45, f"tag {tag}", 11, cls="rb-d-muted"))
y += 54
detail = ""
if len(files) == 1:
detail = files[0]
elif files:
detail = f"{len(files)} files, such as {files[0]}"
height = 30 + (LINE if detail else 0)
out.append(_rect(1, y, NARROW - 2, height, accent=True))
out.append(_text(11, y + 20, f"Bios/{tag}/", TEXT_SIZE, True))
if detail:
out.append(_text(11, y + 20 + LINE, detail, TEXT_SIZE, cls="rb-d-muted"))
y += height + 18
return NARROW, y - 17, out
def _pipeline_boxes() -> list[Box]:
"""The steps pipeline.py runs, read from its STEP_LABELS literal."""
tree = ast.parse((ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8"))
for node in ast.walk(tree):
target = getattr(node, "target", None) or (getattr(node, "targets", [None]) or [None])[0]
if isinstance(target, ast.Name) and target.id == "STEP_LABELS":
labels = ast.literal_eval(node.value)
break
else:
raise RuntimeError("STEP_LABELS not found in scripts/pipeline.py")
boxes = []
for label in labels.values():
number, title = label.split(" ", 1)
title = title.replace("mame", "MAME").replace("fbneo", "FBNeo")
boxes.append(Box(title[0].upper() + title[1:], number=number))
return boxes
def _layers(vertical: bool) -> tuple[float, float, list[str]]:
shared = Box("_shared.yml", ["Curated groups shared between platforms"])
platform = Box("Platform files", ["Scraped from each platform"])
profiles = Box("Emulator profiles", ["Read from emulator source code"])
pack = Box("Pack generation", ["Platform baseline plus what its cores load"], accent=True)
zip_box = Box("One ZIP pack per platform")
out: list[str] = []
def place(box: Box, x: float, y: float, w: float) -> tuple[float, float, float, float]:
title_lines, body, h = _box_height(box, w)
out.extend(_draw_box(box, x, y, w, h, title_lines, body))
return x, y, w, h
if not vertical:
px, py, pw, ph = place(platform, 1, 1, 200)
sx, sy, sw, sh = place(shared, 1, py + ph + 26, 200)
ex, ey, ew, eh = place(profiles, 1, sy + sh + 26, 200)
out.extend(_line([(sx + sw / 2, sy), (px + pw / 2, py + ph)]))
out.extend(_line([(sx + sw / 2, sy + sh), (ex + ew / 2, ey)]))
_, _, pack_h = _box_height(pack, 200)
gx, gy, gw, gh = place(pack, 270, sy + (sh - pack_h) / 2, 200)
out.extend(_line([(px + pw, py + ph / 2), (240, py + ph / 2), (240, gy + 20), (gx - 1, gy + 20)]))
out.extend(_line([(ex + ew, ey + eh / 2), (240, ey + eh / 2), (240, gy + gh - 20),
(gx - 1, gy + gh - 20)]))
zx, zy, zw, zh = place(zip_box, 518, gy + (gh - 42) / 2, 200)
out.extend(_line([(gx + gw, gy + gh / 2), (zx - 1, gy + gh / 2)]))
return WIDE, max(ey + eh, gy + gh) + 2, out
w = NARROW - 2
half = (w - 14) / 2
sx, sy, sw, sh = place(shared, 1, 1, w)
px, py, pw, ph = place(platform, 1, sy + sh + 26, half)
ex, ey, ew, eh = place(profiles, 15 + half, py, half)
out.extend(_line([(px + pw / 2, sy + sh), (px + pw / 2, py)]))
out.extend(_line([(ex + ew / 2, sy + sh), (ex + ew / 2, ey)]))
bottom = max(py + ph, ey + eh)
gx, gy, gw, gh = place(pack, 1, bottom + 26, w)
out.extend(_line([(px + pw / 2, py + ph), (px + pw / 2, gy)]))
out.extend(_line([(ex + ew / 2, ey + eh), (ex + ew / 2, gy)]))
zx, zy, zw, zh = place(zip_box, 1, gy + gh + 26, w)
out.extend(_line([(NARROW / 2, gy + gh), (NARROW / 2, zy)]))
return NARROW, zy + zh + 2, out
def _figures() -> dict[str, Figure]:
chain = _bios_chain_boxes
install = _install_boxes
return {
"bios-chain": Figure(
"bios-chain",
"How a BIOS file is used",
"A device runs a setup, the setup starts an emulator, and the emulator "
"reads BIOS files from one folder. The ZIP or the command fills that folder.",
_bios_chain_caption(),
lambda: flow(chain(), False, WIDE),
lambda: flow(chain(), True, NARROW),
),
"install-flow": Figure(
"install-flow",
"What the command does",
"The command finds your setup and its BIOS folder, looks at the files already "
"there, downloads what is missing or different, then checks each file before "
"saving it.",
"Running the command again repeats the same steps and downloads only what changed.",
lambda: grid(install(), 2, WIDE),
lambda: flow(install(), True, NARROW),
),
"verification-modes": Figure(
"verification-modes",
"How each platform checks a file",
"Three ways platforms check a BIOS file. " + _mode_summary(),
"Each platform's own check, read from its source code. A file that passes on one "
"platform can be refused on another. " + _mode_summary(),
lambda: columns(_mode_boxes(), False, WIDE),
lambda: columns(_mode_boxes(), True, NARROW),
),
"minui-card": Figure(
"minui-card",
"MinUI card layout",
"SD card folders: the tag in parentheses of each Roms folder, such as PS, names "
"the matching folder under Bios.",
"The folder under Bios takes the tag in parentheses of the ROM folder, not its "
"full name. Files listed are the ones the MinUI pack puts there.",
_minui_wide,
_minui_narrow,
),
"pipeline": Figure(
"pipeline",
"Pipeline steps",
"The pipeline runs its steps in order: "
+ ", ".join(f"{b.number} {b.title}" for b in _pipeline_boxes()) + ".",
"Read from the step list in scripts/pipeline.py, numbered as the run prints them.",
lambda: grid(_pipeline_boxes(), 4, WIDE),
lambda: grid(_pipeline_boxes(), 2, NARROW),
),
"data-layers": Figure(
"data-layers",
"Three layers of data",
"Platform files and emulator profiles both feed pack generation, _shared.yml "
"adds curated groups to both, and pack generation writes one ZIP per platform.",
"The platform's list is the baseline, the profiles add what the cores load.",
lambda: _layers(False),
lambda: _layers(True),
),
}
@lru_cache(maxsize=None)
def figures() -> dict[str, Figure]:
return _figures()
IMAGE_IDS = ("bios-chain", "install-flow", "verification-modes", "minui-card", "pipeline", "data-layers")
# Entities for the characters Markdown reads as syntax, for a figure that
# lands indented (in a list, a tab or an admonition), where it is parsed as
# inline text rather than kept as a raw HTML block.
_INLINE_ESCAPES = str.maketrans({"_": "_", "*": "*", "`": "`", "[": "[", "]": "]"})
def figure_html(image_id: str, occurrence: int = 1, inline: bool = False) -> str:
figure = figures()[image_id]
suffix = "" if occurrence == 1 else f"-{occurrence}"
drawings = svg(figure, "wide", suffix) + svg(figure, "narrow", suffix)
caption = escape(figure.caption)
anchor = f"rb-fig-{image_id}{suffix}"
if inline:
html = (
f''
f'{drawings}{caption}'
)
return html.translate(_INLINE_ESCAPES)
return (
f'\n'
f"{drawings}\n"
f"{caption}\n"
""
)
def resolve_images(markdown: str) -> str:
"""Replace each image placeholder line with its figure.
An unknown id is left in place: validate_site reports the comment in the
built page, and the test of placeholders names it.
"""
if "[ \t]*\n"
r"(?P=indent)```(?:sh|bash)[^\n]*\n(?P.*?)\n(?P=indent)```[ \t]*$",
re.S,
)
OS_TAB_KEYS = ("windows", "macos", "linux", "steamdeck")
FENCE_TITLES = {"sh": "Terminal", "powershell": "PowerShell"}
def _installer_args(body: str) -> str:
"""The options of a one-line install command, continuation lines joined."""
from siteguides import INSTALL_BASE
line = " ".join(part.strip().rstrip("\\").strip() for part in body.splitlines())
match = re.fullmatch(rf"curl -fsSL {re.escape(INSTALL_BASE)}/install\.sh \| sh(?: -s -- (.+))?", line)
if not match:
raise ValueError(f"an os-tabs block holds no install command: {line!r}")
return match.group(1) or ""
def expand_commands(markdown: str) -> str:
"""Each install command marked `` as Windows, macOS,
Linux and Steam Deck tabs. `` gives the
Windows tab its own options, for a path written another way there."""
if "os-tabs" not in markdown:
return markdown
from siteguides import TERMINALS, install_command
def replace(match: re.Match[str]) -> str:
indent = match.group("indent")
args = _installer_args(match.group("body"))
items = []
for key in OS_TAB_KEYS:
label, _how, shell = TERMINALS[key]
own = match.group("windows") if key == "windows" and match.group("windows") else args
items.append((label, [f'```{shell} title="{FENCE_TITLES[shell]}"',
install_command(shell, own), "```"]))
return "\n".join(markup.tabs(items, indent)).rstrip("\n")
return OS_TABS.sub(replace, markdown)
# The installer's options at a glance, read from its argument parser.
OPTIONS_MARKER = re.compile(r"(?m)^[ \t]*$")
_SEE_LINK = re.compile(r"\(#([a-z0-9-]+)\)")
def _string(node: ast.expr, constants: dict[str, str]) -> str:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
parts = []
for value in node.values:
if isinstance(value, ast.FormattedValue) and isinstance(value.value, ast.Name):
parts.append(constants[value.value.id])
else:
parts.append(_string(value, constants))
return "".join(parts)
raise ValueError(f"install.py: an option help that is not a string: {ast.unparse(node)}")
@lru_cache(maxsize=None)
def installer_options() -> tuple[tuple[str, str], ...]:
"""(usage, help) for each option of install.py, in the order --help lists them."""
tree = ast.parse((ROOT / "install.py").read_text(encoding="utf-8"))
constants = {
node.targets[0].id: node.value.value
for node in tree.body
if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name)
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
}
main = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "main")
options = []
for node in ast.walk(main):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "add_argument"):
continue
flags = [arg.value for arg in node.args if isinstance(arg, ast.Constant)]
keywords = {kw.arg: kw.value for kw in node.keywords}
action = keywords.get("action")
takes_value = not (isinstance(action, ast.Constant)
and action.value in ("store_true", "store_false", "version"))
metavar = ""
if takes_value:
given = keywords.get("metavar")
metavar = given.value if isinstance(given, ast.Constant) else flags[0].lstrip("-").upper()
usage = ", ".join(f"{flag} {metavar}".strip() for flag in flags)
options.append((usage, _string(keywords["help"], constants) if "help" in keywords else ""))
# argparse's own, added to every parser.
options.append(("--help, -h", "show this help message and exit"))
return tuple(options)
def _sentences(text: str) -> str:
"""A help string as sentences: capitalised, clauses split, a full stop."""
parts = [part.strip() for part in text.split("; ")]
text = ". ".join(part[0].upper() + part[1:] for part in parts if part)
text = re.sub(r"(? list[str]:
"""One row per option: what it does, and the section that says more,
the one the option's row in the page's own Options table sends to."""
sections: dict[str, str] = {}
for line in markdown.splitlines():
flag = re.match(r"\| `(--[a-z-]+)", line)
see = _SEE_LINK.search(line)
if flag and see:
sections[flag.group(1)] = see.group(1)
headings = {
re.sub(r"[^a-z0-9 -]", "", title.lower()).strip().replace(" ", "-"): title
for title in re.findall(r"(?m)^##+ (.+?)(?:\s*\{[^}]*\})?\s*$", markdown)
}
rows = ["| Option | What it does | More |", "|---|---|---|"]
for usage, help_text in installer_options():
anchor = sections.get(usage.split()[0].rstrip(","))
more = f"[{headings.get(anchor, anchor)}](#{anchor})" if anchor else ""
rows.append(f"| `{usage}` | {_sentences(help_text)} | {more} |")
return rows
def expand_options(markdown: str) -> str:
if "installer-options" not in markdown:
return markdown
return OPTIONS_MARKER.sub(lambda _m: "\n".join(options_table(markdown)), markdown)
# Example hashes in the documentation stay out of search, so a hash typed in
# the search box finds the file card that holds it, not a page quoting it.
HASH_SPAN = re.compile(r"`[^`\n]*\b(?:[0-9a-f]{64}|[0-9a-f]{40}|[0-9a-f]{32})\b[^`\n]*`")
HASH_TEXT = re.compile(r"\b(?:[0-9a-f]{64}|[0-9a-f]{40}|[0-9a-f]{32})\b")
FENCE = re.compile(r"(?m)^(?P[ \t]*)(?P```|~~~)[^\n]*\n.*?\n(?P=indent)(?P=mark)[ \t]*$", re.S)
def hide_hashes(markdown: str) -> str:
"""Wrap each code block and code span quoting a hash in a search exclusion."""
if not HASH_TEXT.search(markdown):
return markdown
attribute = markup.search_exclude()
pieces: list[str] = []
position = 0
for match in FENCE.finditer(markdown):
pieces.append(HASH_SPAN.sub(lambda m: f"{m.group(0)}",
markdown[position:match.start()]))
block = match.group(0)
if HASH_TEXT.search(block):
indent = match.group("indent")
block = f"{indent}
\n\n{block}\n\n{indent}
"
pieces.append(block)
position = match.end()
pieces.append(HASH_SPAN.sub(lambda m: f"{m.group(0)}",
markdown[position:]))
return "".join(pieces)
def prepare_page(markdown: str, page: Path) -> str:
"""Everything this module does to a page before it is decorated."""
markdown = expand_options(expand_commands(resolve_images(localize_images(markdown, page))))
if page.parts[:1] == ("wiki",):
markdown = hide_hashes(markdown)
return markdown
# Sharing cards: one 1200x630 card per section of the site, rendered to PNG
# once by scripts/make_og_cards.py and committed under docs_assets/og/.
OG_SECTIONS: dict[str, tuple[str, str]] = {
"home": ("BIOS and firmware packs", "Checked file by file, for {frontends}"),
"start": ("Start here", "What BIOS files are, and how to install them with one command"),
"setup": ("Your setup", "Where the files go on each device and frontend"),
"explore": ("Systems and emulators", "Which files each console and emulator reads"),
"reference": ("Reference", "Verification modes, gaps, provenance and the data API"),
"contribute": ("Contribute", "Adding files, emulator profiles and platforms"),
}
def og_card_svg(section: str, logo_png: bytes) -> str:
import base64
title, tagline = OG_SECTIONS[section]
tagline = tagline.format(frontends=setups_named(["RetroArch", "Batocera", "EmuDeck"]))
logo = base64.b64encode(logo_png).decode("ascii")
body = [
'',
'',
f'',
'RetroBIOS',
f'{escape(title)}',
]
for index, line in enumerate(wrap(tagline, 1000, 34)):
body.append(
f'{escape(line)}'
)
body.append('abdess.github.io/retrobios')
return (
'"
)
def write_svgs(directory: Path) -> list[Path]:
"""Each diagram's wide layout as a file, for reading outside the site."""
directory.mkdir(parents=True, exist_ok=True)
written = []
for image_id in IMAGE_IDS:
path = directory / f"{image_id}.svg"
path.write_text(svg(figures()[image_id], "wide", "") + "\n", encoding="utf-8")
written.append(path)
return written
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Write the site diagrams as SVG files.", allow_abbrev=False
)
parser.add_argument("--output-dir", default="tmp/diagrams", help="default: %(default)s")
args = parser.parse_args()
for path in write_svgs(Path(args.output_dir)):
print(path)
if __name__ == "__main__":
from common import run_cli
run_cli(main)