diff --git a/docs_assets/og/home.png b/docs_assets/og/home.png index 3d2b7e17..a10a74f9 100644 Binary files a/docs_assets/og/home.png and b/docs_assets/og/home.png differ diff --git a/scripts/sitediagrams.py b/scripts/sitediagrams.py index 4d5d5f15..b108ed96 100644 --- a/scripts/sitediagrams.py +++ b/scripts/sitediagrams.py @@ -42,12 +42,20 @@ PLACEHOLDER = re.compile(r"(?m)^([ \t]*)[ PLACEHOLDER_ANYWHERE = re.compile(r"") FONT = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" -TITLE_SIZE = 14 -TEXT_SIZE = 12.5 -LINE = 17 -PAD = 10 +# 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. @@ -58,13 +66,20 @@ 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 sans-serif string: 0.56 em a character, 0.6 bold.""" - return len(text) * size * (0.6 if bold else 0.56) + """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.""" + """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(): @@ -111,10 +126,11 @@ 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)}' + f'text-anchor="{anchor}" fill="{fill}"{klass}{style}>{escape(value)}' ) @@ -167,13 +183,19 @@ def _line(points: list[tuple[float, float]], head: bool = True) -> list[str]: return out -def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]: +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) @@ -187,7 +209,7 @@ def _draw_box(box: Box, x: float, y: float, width: float, height: float, if box.number: out.extend(_badge(x + PAD, y + PAD, box.number)) tx += _badge_side(box.number) + 8 - cy = y + PAD + 13 + cy = y + PAD + 15 for line in title_lines: out.append(_text(tx, cy, line, TITLE_SIZE, True)) cy += LINE @@ -216,9 +238,10 @@ def flow(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float, 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, _)) in enumerate(zip(boxes, measured)): + for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)): x = 1 + index * (width + gap) - out.extend(_draw_box(box, x, 1, width, height, title_lines, body)) + # 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)])) @@ -239,8 +262,8 @@ def columns(boxes: list[Box], vertical: bool, total: float) -> tuple[float, floa 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, _)) in enumerate(zip(boxes, measured)): - out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, height, title_lines, body)) + 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 @@ -296,13 +319,28 @@ def registry() -> dict: 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".""" + others = len(setups()) - 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 list_registered_platforms, load_platform_config + from common import load_platform_config out: dict[str, tuple[str, str, str | None]] = {} - for name in sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True)): + 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) @@ -335,64 +373,70 @@ def emulator_names() -> dict[str, str]: def _bios_chain_boxes() -> list[Box]: - active = sum(1 for entry in registry().values() if entry.get("status") == "active") - frontends = [_display(platform) for platform, _ in CHAIN_EXAMPLES] - emulators = [emulator_names()[emulator] for _, emulator in CHAIN_EXAMPLES] - folders = [f"{_display(platform)}: {_folder(platform)}" for platform, _ in CHAIN_EXAMPLES] + platform, _emulator = CHAIN_EXAMPLES[0] return [ Box("Your device", ["A PC, a Steam Deck, a handheld or a TV box"], number="1"), - Box("Frontend", [ - "The menu you pick a game from:", - f"{', '.join(frontends)} and {active - len(frontends)} more", - ], number="2"), - Box("Emulator", [ - "Starts the game and reads its BIOS:", - ", ".join(emulators), - ], number="3"), - Box("BIOS folder", folders + ["The pack or the installer fills it"], + 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]: - detected = sum(1 for entry in registry().values() if entry.get("install", {}).get("detect")) return [ - Box("Run one command", ["The same line on Linux, macOS, Windows and handhelds"], number="1"), - Box("Find the setup", [f"Looks for {detected} frontends and their BIOS folders"], number="2"), - Box("Read the folder", ["Hashes the files already there"], number="3"), - Box("Download the rest", ["Only what is missing or different"], number="4"), - Box("Check each file", ["Its SHA-1 must match before it is saved"], accent=True, number="5"), + 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 file's name and place count. Its content is not read."), + "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 _mode_boxes() -> list[Box]: +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(): - names = sorted(by_mode.get(mode, []), key=str.casefold) - count = f"{len(names)} platform" + ("" if len(names) == 1 else "s") - boxes.append(Box(title, [text, f"{count}: " + ", ".join(names)])) + count = len(_modes().get(mode, [])) + boxes.append(Box(title, [text, f"{count} platform" + ("" if count == 1 else "s")])) return boxes def _mode_summary() -> str: - parts = [] - for mode, (title, _) in MODE_TEXT.items(): - names = sorted( - (display for display, m, _ in platform_modes().values() if m == mode), key=str.casefold - ) - parts.append(f"{title}: {', '.join(names)}.") - return " ".join(parts) + 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. @@ -529,21 +573,20 @@ def _figures() -> dict[str, Figure]: "bios-chain": Figure( "bios-chain", "How a BIOS file is used", - "A device runs a frontend, the frontend starts an emulator, and the emulator " - "reads BIOS files from one folder. The pack or the installer fills that folder.", - "The frontend is the menu, the emulator runs the game, and the BIOS folder is the " - "one place both look. Filling that folder is all a pack does.", + "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 installer does", - "Install flow: run the command, the installer finds your setup, reads the BIOS " - "folder, downloads what is missing or different, then checks each file before " + "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: flow(install(), False, WIDE), + lambda: grid(install(), 2, WIDE), lambda: flow(install(), True, NARROW), ), "verification-modes": Figure( @@ -551,7 +594,7 @@ def _figures() -> dict[str, Figure]: "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.", + "platform can be refused on another. " + _mode_summary(), lambda: columns(_mode_boxes(), False, WIDE), lambda: columns(_mode_boxes(), True, NARROW), ), @@ -689,9 +732,174 @@ def localize_images(markdown: str, page: Path) -> str: return IMG_SRC.sub(swap, IMAGE_LINK.sub(swap, markdown)) +# Installer commands on wiki pages. The page keeps the Linux line, readable +# where the Markdown is read as is, and the site shows it in one tab per +# system, the Windows form written by the same function as the setup pages'. + +OS_TABS = re.compile( + r"(?m)^(?P[ \t]*)[ \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.""" - return resolve_images(localize_images(markdown, page)) + 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 @@ -711,8 +919,7 @@ def og_card_svg(section: str, logo_png: bytes) -> str: import base64 title, tagline = OG_SECTIONS[section] - active = sum(1 for entry in registry().values() if entry.get("status") == "active") - tagline = tagline.format(frontends=f"RetroArch, Batocera, EmuDeck and {active - 3} more") + tagline = tagline.format(frontends=setups_named(["RetroArch", "Batocera", "EmuDeck"])) logo = base64.b64encode(logo_png).decode("ascii") body = [ '', diff --git a/tests/test_site_images.py b/tests/test_site_images.py index 2084bc56..969550cc 100644 --- a/tests/test_site_images.py +++ b/tests/test_site_images.py @@ -96,8 +96,7 @@ class PlaceholdersResolve(unittest.TestCase): class DiagramsMatchTheData(unittest.TestCase): def test_every_platform_appears_under_its_mode(self): - boxes = sitediagrams._mode_boxes() - text = " ".join(" ".join(box.lines) for box in boxes) + text = sitediagrams.figures()["verification-modes"].caption for display, mode, _ in sitediagrams.platform_modes().values(): with self.subTest(platform=display): self.assertIn(mode, sitediagrams.MODE_TEXT) diff --git a/wiki/getting-started.md b/wiki/getting-started.md deleted file mode 100644 index ec50eee6..00000000 --- a/wiki/getting-started.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -level: start -nav: false ---- - -# Getting started - -Three pages take you from nothing to games that start. - -Get your files - -## What is a BIOS? - -[What is a BIOS?](what-are-bios-files.md) tells you which consoles need one. - -## Install - -[Get your files](../which-pack.md) gives the command and the ZIP for your -setup. [First install, step by step](first-install.md) shows each step. - -## Check your folder - -[When a game does not start](troubleshooting.md#checking-what-you-installed) -shows how to check your BIOS folder.