From 4b7cf6501f10ba69c36541da1c5f2fe718d77c2d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:34:09 +0200 Subject: [PATCH 1/3] docs: open contributing on the upload --- CONTRIBUTING.md | 28 ++++++++++++++++------------ scripts/generate_readme.py | 28 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a70d00d..7f1dafdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,15 +2,23 @@ ## Add a BIOS file -One file needs no clone and no local setup: +Have a file the collection lacks? Add it from the browser. It is checked on arrival. -1. [Upload it into `bios/`](https://github.com/Abdess/retrobios/upload/main/bios) - from the browser, 25 MB at most per file. GitHub writes it to a fork and - opens the pull request. -2. CI hashes it and looks it up in every platform list and emulator profile. - For a file it recognises, it names the folder holding the other files of its - system. The report is in the summary of the pull request's Validation run. -3. The file can be moved to that folder in the same pull request. A file whose +[Upload it into `bios/`](https://github.com/Abdess/retrobios/upload/main/bios), 25 MB at most per file. GitHub +writes it to a fork and opens the pull request. + +The files most wanted are on [what the packs do not hold](https://abdess.github.io/retrobios/gaps/). The +[dump provenance](https://abdess.github.io/retrobios/provenance/) page lists catalogued dumps still +missing from the collection, with their hashes. A file matching one of those is +the most useful contribution. + +## What happens next + +1. CI hashes the file and looks it up in every platform list and emulator + profile. For a file it recognises, it names the folder holding the other + files of its system. The report is in the summary of the pull request's + Validation run. +2. The file can be moved to that folder in the same pull request. A file whose hash differs from the one already held under its name goes to that folder's `.variants/`. @@ -18,10 +26,6 @@ From a clone, the file goes to `bios/Manufacturer/Console/filename`, and `python scripts/validate_pr.py path/to/file` names that folder for a file still outside `bios/`. A file over the browser's 25 MB limit goes in that way. -The [dump provenance](https://abdess.github.io/retrobios/provenance/) page lists catalogued dumps still -missing from the collection, with their hashes. A file matching one of those is -the most useful contribution. - ## Add a platform 1. Write a scraper in `scripts/scraper/` (inherit `BaseScraper`) diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index 4870f441..cb5daaaa 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -843,15 +843,23 @@ def contributing_text( ## Add a BIOS file -One file needs no clone and no local setup: +Have a file the collection lacks? Add it from the browser. It is checked on arrival. -1. [Upload it into `bios/`]({UPLOAD_URL}) - from the browser, 25 MB at most per file. GitHub writes it to a fork and - opens the pull request. -2. CI hashes it and looks it up in every platform list and emulator profile. - For a file it recognises, it names the folder holding the other files of its - system. The report is in the summary of the pull request's Validation run. -3. The file can be moved to that folder in the same pull request. A file whose +[Upload it into `bios/`]({UPLOAD_URL}), 25 MB at most per file. GitHub +writes it to a fork and opens the pull request. + +The files most wanted are on [what the packs do not hold]({link("gaps")}). The +[dump provenance]({link("provenance")}) page lists catalogued dumps still +missing from the collection, with their hashes. A file matching one of those is +the most useful contribution. + +## What happens next + +1. CI hashes the file and looks it up in every platform list and emulator + profile. For a file it recognises, it names the folder holding the other + files of its system. The report is in the summary of the pull request's + Validation run. +2. The file can be moved to that folder in the same pull request. A file whose hash differs from the one already held under its name goes to that folder's `.variants/`. @@ -859,10 +867,6 @@ From a clone, the file goes to `bios/Manufacturer/Console/filename`, and `python scripts/validate_pr.py path/to/file` names that folder for a file still outside `bios/`. A file over the browser's 25 MB limit goes in that way. -The [dump provenance]({link("provenance")}) page lists catalogued dumps still -missing from the collection, with their hashes. A file matching one of those is -the most useful contribution. - ## Add a platform 1. Write a scraper in `scripts/scraper/` (inherit `BaseScraper`) From 235001babb057c34ff01d0d51a553be62f1e6350 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:34:13 +0200 Subject: [PATCH 2/3] feat: cite terminals, shares and hardware --- platforms/_guides.yml | 180 +++++++++++++++++++++++++++++++++--------- scripts/siteguides.py | 80 ++++++++++++++++--- scripts/sitemarkup.py | 31 ++++++++ 3 files changed, 242 insertions(+), 49 deletions(-) diff --git a/platforms/_guides.yml b/platforms/_guides.yml index 1b109bcf..d04de3e1 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -7,6 +7,10 @@ # `extract_to` for that platform, the one place that folder is written. # `run` names the terminals below, in the order the page shows them. # `pack_manual` is the manual part of the README inside the pack, plain text. +# `{folders}` in it is the folder list of the page. +# `runs_on` names the hardware in the project's own words, cited beside it. +# `terminal` is how this system opens a terminal, from its own documentation. +# `lead` comes before the folder table, `finish` closes the guide. # `logo_owner` and `logo_licence` credit the logo the registry links: it is # shown only to name the platform, with the licence of the repository the # file was taken from when that repository states one. @@ -16,15 +20,17 @@ install_base: https://raw.githubusercontent.com/Abdess/retrobios/main -# Is it safe: shown under every command on a guide page. +# Is it safe: shown on the home page and under every command on a guide page. +# Where it writes follows install.py: the BIOS folder, the frontend's save +# folder for the entries a manifest files under `root: save` +# (place_save_entries), and retrobios-backup beside the folder written to. trust_items: - It checks each file before saving it. -- It writes only in your BIOS folder. -- It never touches games or saves. -- It never moves or deletes files it does not know. +- It writes in your BIOS folder. For some emulators it also adds files to your save folder. +- It never changes or deletes a game or a save. - >- - It keeps any file it replaces in a `retrobios-backup` folder, beside your BIOS folder. - The same command with `--restore` puts it back. + It keeps any file it replaces in a `retrobios-backup` folder, next to your BIOS folder. + `--restore` puts it back. # Order of the groups on the picker, in the navigation and on the home page. # `anchor` is the section id on the device picker: two keep the ids the page @@ -33,7 +39,7 @@ trust_items: # and use its pack. device_groups: computer: - label: Computers and Steam Deck + label: Computers (Windows, Mac, Linux) and Steam Deck description: An emulator app on a computer or a Steam Deck. anchor: computers-and-steam-deck also_runs: @@ -44,12 +50,11 @@ device_groups: runs_on: Windows folder: The folder of the RetroArch it starts gaming-os: - label: Game systems for PCs and small boards - description: >- - A system that starts straight into a game menu, on a PC or a Raspberry Pi. + label: Raspberry Pi and mini PCs + description: A system that starts straight into a game menu. anchor: game-systems handheld: - label: Handhelds and TV boxes + label: Handhelds and TV boxes (Anbernic, Retroid, Miyoo) description: The system your handheld or TV box starts. anchor: handhelds also_runs: @@ -137,8 +142,7 @@ guides: more_folders_detected: true more_folders_note: LaunchBox and other launchers use the folder RetroArch reads. run: [windows, macos, linux, steamdeck, android] - notes: - - >- + lead: >- **Not sure?** In RetroArch, open **Settings > Directory > System/BIOS**. It shows the folder. reference_notes: @@ -161,21 +165,27 @@ guides: Option C: Manual (handheld / SD card) ------------------------------------- - Anbernic, Retroid, Miyoo, Trimui, etc.: 1. Connect your SD card to your PC - 2. Find the BIOS folder (usually BIOS/ or system/) + 2. Find the BIOS folder in the list below 3. Extract all files from this archive directly into that folder 4. Eject SD card and reboot your device - Common paths by device: - Anbernic (ArkOS/JELOS): BIOS/ - Retroid (RetroArch): RetroArch/system/ - Miyoo Mini (Onion OS): BIOS/ - Steam Deck (RetroArch): ~/.config/retroarch/system/ + Folders by device: + {folders} logo_owner: libretro logo_licence: repository licence GPL-3.0 batocera: - summary: A system that starts straight into a game menu. + summary: A system that starts straight into a game menu, on a PC, a Raspberry Pi or a handheld. + # batocera.org/download: x86-64 desktop PC and laptop, Raspberry, Steam Deck OLED, + # Anbernic and AYN handhelds. + runs_on: PCs, Raspberry Pi, Steam Deck, handhelds + # wiki.batocera.org/access_the_batocera_via_ssh: root@batocera.local, password + # linux, SSH on by default. wiki.batocera.org (file manager): xterm in the + # Applications menu of the F1 file manager, Xorg builds (x86_64) only. + terminal: >- + On a PC, press **F1** on the system list, then open **xterm** in the Applications + menu. From a computer, open a terminal over the network with `ssh + root@batocera.local`, password `linux`. Paste the line. Press Enter. folders: - where: On Batocera - where: From Windows, over your home network @@ -185,7 +195,8 @@ guides: run: [device] download_hint: From a computer, use the network folder above. pack_manual: | - The terminal is F1 from the Batocera menu, or SSH. + The terminal is xterm, in the Applications menu of the F1 file + manager (PC only), or SSH: ssh root@batocera.local, password linux. Option B: Manual (network share) -------------------------------- @@ -206,8 +217,15 @@ guides: logo_owner: Batocera logo_licence: repository licence MIT recalbox: - summary: >- - A system that starts straight into a game menu and sets up its emulators. + summary: A system that starts straight into a game menu, on a board, a PC or a handheld. + # recalbox.com: "TV, PC or handheld?", 13 supported boards and consoles. + runs_on: Boards, PCs, handhelds + # wiki.recalbox.com/en/tutorials/system/access/root-access-terminal-cli: Alt+F2 with + # a keyboard. SSH user root, password recalboxroot or the one set when flashing 10.0+. + terminal: >- + With a keyboard, press **Alt+F2**. From a computer, open a terminal over the network + with `ssh root@recalbox`, password `recalboxroot` or the one you set. Paste the line. + Press Enter. folders: - where: On Recalbox - where: From Windows, over your home network @@ -219,7 +237,6 @@ guides: -------------------------------- 1. On your PC, open the Recalbox network share: - Windows: \\RECALBOX\share\bios\ - - Mac/Linux: smb://recalbox/share/bios/ 2. Extract all files from this archive directly into the share Option C: Manual (SD card) @@ -258,14 +275,23 @@ guides: summary: Installs your emulators and adds your games to Steam. folders: - where: Steam Deck and Linux + # EmuDeck functions/helperFunctions.sh:89-99 (getSDPath, the card's mount point) + # and functions/jsonToBashVars.sh:128-132 (biosPath "$globPath/Emulation/bios"), + # dragoonDorise/EmuDeck dddb3022. + - where: Steam Deck, installed on the SD card + path: '`Emulation/bios/` on the card, under `/run/media/`' - where: Windows, by default path: '`C:\Emulation\bios\`' - where: macOS run: [steamdeck, linux, windows, macos] notes: - >- - The `bios` folder is inside the Emulation folder you chose in EmuDeck. If you chose - the SD card, it is on the card. + The `bios` folder is inside the Emulation folder you chose in EmuDeck. + # emudeck-electron src/renderer/translations/en.json:416-418 (b06bfcf5): the + # "Bios files checker". + finish: >- + EmuDeck's **Bios files checker** shows the files too. Return to Gaming Mode and start + a game. reference_notes: - >- The installer reads the Emulation folder from EmuDeck's settings (`emulationPath` @@ -280,13 +306,24 @@ guides: logo_owner: EmuDeck logo_licence: repository licence GPL-3.0 lakka: - summary: A system that starts straight into RetroArch. + summary: A system that starts straight into RetroArch, on a Raspberry Pi or a small PC. + # lakka.tv: "transforms a small computer", "a Raspberry Pi". + runs_on: Raspberry Pi, small computers folders: - where: On Lakka + # lakka.tv/doc/Accessing-Lakka-filesystem: Samba in Settings > Services, share System. + - where: From Windows, over your home network + path: '`\\LAKKA\System\`' run: [device] notes: - >- - You can also reach the folder from a computer, over your home network. + For the network folder, turn on **Samba** in Lakka's **Settings > Services**. + # lakka.tv/doc/Accessing-Lakka-command-line-interface: SSH in Settings > Services, + # user root, password root. + terminal: >- + Turn on **SSH**, a terminal over the network, in Lakka's **Settings > Services**. From + a computer, connect with `ssh root@lakka`, password `root`. Paste the line. Press + Enter. reference_notes: - The folder is reachable over SSH and over the network share. pack_manual: | @@ -296,7 +333,6 @@ guides: -------------------------- 1. On Lakka: Settings > Services > Samba Enable 2. On your PC, open the System share: \\LAKKA\System\ - (macOS and Linux: smb://lakka/System) 3. Extract all files from this archive directly into that share Option C: Via SSH @@ -365,12 +401,20 @@ guides: logo_licence: GitHub organisation avatar of rommapp rocknix: summary: A system for handhelds. + # rocknix.org: "an immutable Linux distribution for handheld gaming devices". + runs_on: Handhelds folders: - where: On ROCKNIX run: [device] notes: - >- You can also reach the folder from a computer, over your home network. + # rocknix.org/faqs and rocknix.org/play/add-games: SSH off by default, turned on in + # Network Settings, user root, the password shown in System Settings > Security. + terminal: >- + Turn on **SSH**, a terminal over the network, in **Network Settings**. From a + computer, connect as `root`, with the password shown in **System Settings > + Security**. Paste the line. Press Enter. reference_notes: - >- ROCKNIX runs on ARM and x86 handhelds, among them the RG35XX (H700) and the RG552 @@ -379,9 +423,15 @@ guides: logo_licence: GitHub organisation avatar of ROCKNIX amberelec: summary: A system for Anbernic handhelds. + # amberelec.org: "optimized for the RG351P/M/V/MP and RG552". + runs_on: Anbernic RG351 and RG552 folders: - where: On AmberELEC run: [device] + # amberelec.org/guides/getting-to-know-amberelec.html: host AmberELEC, root, amberelec. + terminal: >- + From a computer, open a terminal over the network with `ssh root@AmberELEC`, password + `amberelec`. Paste the line. Press Enter. reference_notes: - >- AmberELEC runs on the RG351P, RG351V, RG351MP, RG353, RG503 and RG552. Its list @@ -391,9 +441,20 @@ guides: logo_licence: GitHub organisation avatar of AmberELEC emuelec: summary: A system for TV boxes and some handhelds. + # EmuELEC wiki Supported-Devices.md: Amlogic boxes, Odroid-GO Advance and Super, + # GameForce Chi. + runs_on: TV boxes, Odroid Go handhelds folders: - where: On EmuELEC + # EmuELEC wiki (35b92c78) Backup-and-Recovery-options.md:4 (\\EMUELEC, roms share) + # and Bios.md (/storage/roms/bios). + - where: From Windows, over your home network + path: '`\\EMUELEC\roms\`' run: [device] + # EmuELEC wiki How-to-SSH-into-EmuELEC.md:38-44: root, password emuelec. + terminal: >- + From a computer, open a terminal over the network with `ssh root@emuelec`, password + `emuelec`. Paste the line. Press Enter. reference_notes: - >- EmuELEC runs on Amlogic TV boxes, the Odroid Go Advance, GameForce and RK356x devices. @@ -402,10 +463,24 @@ guides: logo_owner: EmuELEC logo_licence: GitHub organisation avatar of EmuELEC knulli: - summary: A system for handhelds, based on Batocera. + summary: A system for handhelds and bartops, based on Batocera. + # knulli.org: "retro gaming devices (handhelds, bartops, etc.)". + runs_on: Handhelds, bartops folders: - where: On KNULLI + # knulli.org/play/add-games/network-transfer: \\KNULLI, its share folder, Samba + # off by default. The share is /userdata, so bios is under it. + - where: From Windows, over your home network + path: '`\\KNULLI\share\bios\`' run: [device] + notes: + - >- + For the network folder, turn on **SAMBA** in **System Settings > Services**. + # knulli.org/configure/ssh: SSH in System Settings > Services, root, password linux. + terminal: >- + Turn on **SSH**, a terminal over the network, in **System Settings > Services**. From + a computer, connect with `ssh root@KNULLI`, password `linux`. Paste the line. Press + Enter. reference_notes: - >- KNULLI runs on handhelds among them the RG35XX family (H700) and the TrimUI Smart @@ -413,14 +488,24 @@ guides: logo_owner: KNULLI logo_licence: GitHub organisation avatar of knulli-cfw retropie: - summary: Installs RetroArch and other emulators on a Raspberry Pi. + summary: Installs RetroArch and other emulators on a Raspberry Pi or a PC. + # RetroPie docs index.md (175f5b15): Raspberry Pi, PC and Odroid. + runs_on: Raspberry Pi, PC, Odroid folders: - where: On the Raspberry Pi + # RetroPie-Setup scriptmodules/supplementary/samba.sh:49-52 (46128cd2) shares + # $home/RetroPie/BIOS as "bios", and docs/Transferring-Roms.md:29 names \\RETROPIE. + - where: From Windows, over your home network + path: '`\\RETROPIE\bios\`' run: [device] + # RetroPie docs/FAQ.md:193 (F4 in EmulationStation, its game menu), 175f5b15. The computer route + # is the network folder above. + terminal: >- + Press **F4** in the game menu for a terminal. Paste the line. Press Enter. notes: - >- - Use the RetroPie pack, not the RetroArch one. On RetroPie 4.8, Python is too old - for the command: use the pack there. + Use the RetroPie pack, not RetroArch's. The command does not run on RetroPie 4.8: use + the pack there. reference_notes: - >- RetroPie also installs standalone emulators (openMSX, PCSX2, XRoar, Amiberry and @@ -469,13 +554,26 @@ guides: misterfpga: summary: >- A board that recreates old consoles in hardware, not in software. + # mister-devel.github.io/MkDocs_MiSTer: "an FPGA board called the DE10-Nano". + runs_on: Its own board folders: - where: On the MiSTer SD card path: '`/media/fat/games/`, one subfolder per core' + # mister-devel.github.io/MkDocs_MiSTer/setup/games: run samba_on from the Scripts + # menu, then \\MiSTer\sdcard\. + - where: From Windows, over your home network + path: '`\\MiSTer\sdcard\games\`' run: [device] + # mister-devel.github.io/MkDocs_MiSTer/advanced/network: F9 for a Linux terminal, + # SSH user root, password 1. + terminal: >- + Press **F9** in the MiSTer menu and log in as `root`, password `1`. Or from a + computer, open a terminal over the network as `root`. Paste the line. Press Enter. notes: - >- Each file goes in its console's folder, like `/media/fat/games/3DO/boot.rom`. + - >- + For the network folder, run **samba_on** from the MiSTer Scripts menu. reference_notes: - >- MiSTer runs FPGA cores on a DE10-Nano board. Only the files the MiSTer BIOS database @@ -484,7 +582,9 @@ guides: logo_owner: MiSTer FPGA logo_licence: GitHub organisation avatar of MiSTer-devel minui: - summary: A simple game menu for handhelds. It runs from the SD card. + summary: A simple game menu for Anbernic, Miyoo and Trimui handhelds, on the SD card. + # shauninman/MinUI README (dbf89435) lines 3, 65-91. + runs_on: Anbernic, Miyoo, Trimui handhelds folders: - where: On the SD card path: '`Bios//`, one folder per console' @@ -506,8 +606,9 @@ guides: logo_owner: Shaun Inman logo_licence: GitHub avatar of shauninman, the author of MinUI nextui: - summary: >- - A game menu for handhelds, based on MinUI. It runs from the SD card. + summary: A game menu for TrimUI handhelds, based on MinUI, on the SD card. + # LoveRetro/NextUI README (5291344b) lines 37, 89-94. + runs_on: TrimUI Brick, Smart Pro folders: - where: On the SD card path: '`Bios//`, one folder per console' @@ -524,8 +625,9 @@ guides: logo_owner: LoveRetro logo_licence: GitHub organisation avatar of LoveRetro myminui: - summary: >- - A game menu for handhelds, based on MinUI. It runs from the SD card. + summary: A game menu for Anbernic and Miyoo handhelds, based on MinUI, on the SD card. + # Turro75/MyMinUI README (72ed4f3d) lines 9-35. + runs_on: Anbernic, Miyoo handhelds folders: - where: On the SD card path: '`Bios//`, one folder per console' diff --git a/scripts/siteguides.py b/scripts/siteguides.py index f0f4fe5b..991e1e21 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -38,7 +38,12 @@ class Guide: and open the Reference part of the page. `download_hint` is one more bullet under the pack download. `pack_manual` is the manual part of the README inside the pack. `logo_owner` and `logo_licence` credit the - logo the registry links. + logo the registry links. `runs_on` names the hardware, in the project's + own words, for its tile and its row. `terminal` replaces the shared + "On the device" text with how this system opens a terminal. `lead` comes + before the folder table, `finish` closes the guide. `{folders}` in + `pack_manual` is the folder list of the page, so the README and the page + cannot name two folders for one device. """ summary: str @@ -56,6 +61,10 @@ class Guide: pack_manual: str = "" logo_owner: str = "" logo_licence: str = "" + runs_on: str = "" + terminal: str = "" + lead: str = "" + finish: str = "" _GUIDE_KEYS = {f.name for f in fields(Guide)} @@ -130,7 +139,7 @@ def _guide(name: str, data: object, extract_to: str, terminals: dict) -> Guide: if key in data: values[key] = _texts(data[key], f"{where}.{key}") for key in ("dest", "download_hint", "more_folders_title", "more_folders_note", - "logo_owner", "logo_licence"): + "logo_owner", "logo_licence", "runs_on", "terminal", "lead", "finish"): if key in data: values[key] = _text(data[key], f"{where}.{key}") for key in ("on_card", "more_folders_detected"): @@ -213,6 +222,7 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: # The order the file is written in, which the pages do not depend on. guides = {name: guides[name] for name in raw_guides} return { + "registry": registry, "install_base": _text(data.get("install_base"), f"{path.name}: install_base"), "trust_items": _texts(data.get("trust_items"), f"{path.name}: trust_items"), "device_groups": groups, @@ -231,6 +241,43 @@ ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also TERMINALS: dict[str, tuple[str, str, str]] = _LOADED["terminals"] TRUST_ITEMS: tuple[str, ...] = _LOADED["trust_items"] GUIDES: dict[str, Guide] = _LOADED["guides"] +_REGISTRY: dict = _LOADED["registry"] + + +def detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: + """Named systems a platform runs on, handhelds that run RetroArch for one, + with the BIOS folder the installer writes on each, read from the + detection rules of the registry.""" + seen: dict[str, str] = {} + for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: + name, folder = rule.get("name"), rule.get("bios_path") + if name and folder and rule.get("installs", True) and name not in seen: + seen[name] = folder + return sorted(seen.items(), key=lambda item: item[0].casefold()) + + +def folder_list(name: str, registry: dict | None = None) -> list[tuple[str, str]]: + """Every folder a guide names, as (where, path): the common ones, the + rarer builds, and the systems the registry detects when the guide says so.""" + guide = GUIDES[name] + rows = [*guide.folders, *guide.more_folders] + if guide.more_folders_detected: + rows += [(system, f"`{folder}`") + for system, folder in detected_systems(registry or _REGISTRY, name)] + return rows + + +def _plain_folders(name: str) -> str: + rows = folder_list(name) + width = max(len(where) for where, _path in rows) + 1 + return "\n".join(f"{where + ':':<{width}} {path.replace('`', '')}" for where, path in rows) + + +def setup_count() -> int: + """Setups the site covers: every registered platform, archived ones + included, since each keeps its page, its pack and its command. The one + count every page states.""" + return len(GUIDES) def pack_manual(name: str) -> str: @@ -238,7 +285,12 @@ def pack_manual(name: str) -> str: guide = GUIDES.get(name) if not guide or not guide.pack_manual: return "" - return textwrap.indent(guide.pack_manual.rstrip("\n"), " ") + "\n\n" + text = guide.pack_manual.rstrip("\n") + if "{folders}" in text: + indent = next(line[: len(line) - len(line.lstrip())] + for line in text.splitlines() if "{folders}" in line) + text = text.replace("{folders}", textwrap.indent(_plain_folders(name), indent).lstrip()) + return textwrap.indent(text, " ") + "\n\n" def install_command(shell: str, args: str = "") -> str: @@ -254,21 +306,29 @@ def install_command(shell: str, args: str = "") -> str: return f"irm {INSTALL_BASE}/install.ps1 | iex" -def _fence(shell: str, command: str, indent: str) -> list[str]: - return [f"{indent}```{shell}", f"{indent}{command}", f"{indent}```"] +# The title over each command block, by the shell it is written for. +FENCE_TITLES = {"sh": "Terminal", "powershell": "PowerShell"} + + +def fence(shell: str, command: str, indent: str = "") -> list[str]: + """A command block with its title, as every command on the site is shown.""" + return [f'{indent}```{shell} title="{FENCE_TITLES[shell]}"', f"{indent}{command}", f"{indent}```"] def terminal_tabs( tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True, + how: dict[str, str] | None = None, ) -> list[str]: """Content tabs, one per place the command runs, each with how to open the terminal there. Tab labels are shared across the site, so the reader's - choice carries from page to page.""" + choice carries from page to page. *how* replaces the shared text of a tab + for one setup.""" items = [] for key in tabs: - label, how, shell = TERMINALS[key] - body = [how, ""] if steps else [] - items.append((label, body + _fence(shell, install_command(shell, args.get(key, "")), ""))) + label, shared, shell = TERMINALS[key] + text = (how or {}).get(key) or shared + body = [text, ""] if steps else [] + items.append((label, body + fence(shell, install_command(shell, args.get(key, ""))))) return markup.tabs(items, indent) @@ -287,7 +347,7 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]: def trust_box(prefix: str, indent: str = "") -> list[str]: - """Is it safe, under every command on a guide page: five plain facts.""" + """Is it safe, under every command on a guide page: the trust items.""" return markup.details("note", "Is it safe?", [ *(f"- {item}" for item in TRUST_ITEMS), "", diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py index 7218e880..207636bf 100644 --- a/scripts/sitemarkup.py +++ b/scripts/sitemarkup.py @@ -184,6 +184,37 @@ def card_grid(cards: list[tuple[str, list[str]]]) -> list[str]: return [*lines, "", ""] +def initials(name: str) -> str: + """Up to two capitals of a name, for a setup that has no logo: MyMinUI is MM.""" + capitals = [c for c in name if c.isupper()] + return "".join(capitals[:2]) or name[:2].upper() + + +def _tile_mark(name: str, logo: str) -> str: + if logo: + return f'' + # The same square as a logo, drawn on white like the logos are. + return ('') + + +def platform_tiles(tiles: list[tuple[str, str, str, str]]) -> list[str]: + """One link per setup: its logo (or its initials), its name and where it + runs. *tiles* are (name, target, logo, runs_on). + + One HTML line: a grid of links is a menu, not prose, and the Markdown + around it reads none of it.""" + links = "".join( + f'' + f"{_tile_mark(name, logo)}" + f'{html.escape(name)}' + f'{inline_html(runs_on)}' + for name, target, logo, runs_on in tiles + ) + return [f'
{links}
', ""] + + def steps(items: list[str]) -> list[str]: """An ordered list drawn as numbered steps.""" return [block_open("div", 'class="rb-steps"'), "", From 5cbdceef62a28995c4809a6b9cfe228644e6b40e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:34:13 +0200 Subject: [PATCH 3/3] feat: rework entry pages and setup guides --- scripts/generate_site.py | 489 ++++++++++++++++++++------------ tests/test_contributing_text.py | 2 +- tests/test_doc_surfaces.py | 2 +- tests/test_site_copy.py | 2 +- tests/test_site_exports.py | 7 +- tests/test_site_funnel.py | 11 +- tests/test_site_r2_entry.py | 320 +++++++++++++++++++++ wiki/testing-guide.md | 2 +- 8 files changed, 640 insertions(+), 195 deletions(-) create mode 100644 tests/test_site_r2_entry.py diff --git a/scripts/generate_site.py b/scripts/generate_site.py index f60c3d43..78178298 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -85,9 +85,15 @@ from siteguides import ( GROUP_ANCHORS, GUIDES, Guide, + FENCE_TITLES, INSTALL_BASE, TERMINALS, + TRUST_ITEMS, + detected_systems as _detected_systems, + fence, + folder_list, install_command, + setup_count, platform_args, terminal_tabs, trust_box, @@ -383,9 +389,18 @@ def _system_pages_from_data( def _timestamp() -> str: + """The build time for data files (stats, API records), ISO 8601.""" return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +def data_as_of(moment: datetime | None = None) -> str: + """The build date for a reader, in words, with the machine form beside it. + A raw ISO stamp ("2026-10-22T22:43:08Z") closed the home page.""" + moment = moment or datetime.now(timezone.utc) + return (f'") + + # Home page @@ -410,8 +425,36 @@ def _group_members(coverages: dict, registry: dict, group: str) -> list[str]: def _next_block(links: list[str]) -> list[str]: - """Where to go from here, at the end of a page.""" - return markup.aside("tip", "Next", [f"- {link}" for link in links]) + """Where to go from here, at the end of a page: a plain heading and a + list. A tinted callout with a side stripe said the same as the pager.""" + return ["## Next steps", "", *(f"- {link}" for link in links), ""] + + +def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "") -> list[str]: + """The setup tiles of one device group: logo, name and the hardware it runs on.""" + return markup.platform_tiles([ + (display[name], f"{prefix}platforms/{name}.md", + str(registry.get(name, {}).get("logo") or ""), + GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name])) + for name in members + ]) + + +def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> list[str]: + """Every setup as a tile, under the device group it belongs to. The + setups alone in their group share one row: "MiSTer FPGA" above + "MiSTer FPGA" said nothing.""" + lines: list[str] = [] + alone: list[str] = [] + for group, (label, _description) in DEVICE_GROUPS.items(): + members = _group_keys(registry, group, display) + if len(members) == 1: + alone += members + elif members: + lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix)] + if alone: + lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix)] + return lines def generate_home( @@ -426,11 +469,13 @@ def generate_home( The figures about the collection, the cards to the deeper pages and the call for help sat below the steps and doubled the page: they are on the - Explore index, one tab further.""" - registry = registry or {} - names = sorted((cov["platform"] for cov in coverages.values()), key=str.casefold) - named = [n for n in ("RetroArch", "Batocera", "EmuDeck") if n in names] - others = len(names) - len(named) + Explore index, one tab further. No date and no "next" box: the hero + buttons say where to go.""" + registry = registry or load_platform_registry(platforms_dir) + display = {key: load_platform_config(key, platforms_dir).get("platform", key) + for key in registry} + named = [n for n in ("RetroArch", "Batocera", "EmuDeck") if n in display.values()] + others = setup_count() - len(named) works_with = ", ".join(named) + (f" and {others} more" if others else "") lines = [ @@ -445,6 +490,9 @@ def generate_home( "", f"Free. No account. Works with {works_with}.", "", + "- A game says BIOS missing? [When a game does not start](wiki/troubleshooting.md)", + "- Have a file already? Reference: [Check it](files/index.md)", + "", "## Do I need it?", "", "- **Yes**, if your emulator says a BIOS is missing.", @@ -462,31 +510,14 @@ def generate_home( ]), "## Pick your device", "", - ] - lines += markup.card_grid([ - (label, [", ".join( - f"[{coverages[name]['platform']}](platforms/{name}.md)" - for name in _group_members(coverages, registry, group) - )]) - for group, (label, _description) in DEVICE_GROUPS.items() - if _group_members(coverages, registry, group) - ]) - lines += [ + *setup_picker(registry, display), "## Is it safe?", "", - "- It writes only in your BIOS folder.", - "- It checks every file before saving it.", - "- It keeps any file it replaces, in a `retrobios-backup` folder.", - "- It never touches your games or saves.", + *(f"- {item}" for item in TRUST_ITEMS), "", "[How the command is checked](wiki/faq.md#is-piping-the-installer-into-a-shell-safe). " - "[Is it legal?](wiki/faq.md#is-this-legal)", + "Details: [Is it legal?](wiki/legal.md)", "", - *_next_block([ - "[Get your files](which-pack.md)", - "[What is a BIOS?](wiki/what-are-bios-files.md)", - ]), - f'
Generated on {_timestamp()}.
', ] return "\n".join(lines) + "\n" @@ -522,7 +553,7 @@ def generate_explore_index(db: dict, coverages: dict, profiles: dict) -> str: markup.block_open("div", 'class="rb-stats"'), "", *_stat(f"{total_files:,}", COLLECTION_FILES_LABEL), - *_stat(str(len(coverages)), "Setups supported"), + *_stat(str(setup_count()), "Setups supported"), *_stat(str(emulator_count), "Emulators documented"), *_stat(_fmt_size(total_size), "Size of the collection"), "", @@ -557,15 +588,58 @@ def compute_stats(db: dict, coverages: dict, profiles: dict) -> dict: } +def _release_date(tag: str) -> str: + """The date a release tag names (v2026.10.10), in words, or empty.""" + match = re.fullmatch(r"v(\d{4})\.(\d{2})\.(\d{2})", tag or "") + if not match: + return "" + moment = datetime(int(match[1]), int(match[2]), int(match[3])) + return f"{moment.day} {moment.strftime('%B %Y')}" + + +def _pack_version(pack_name: str) -> str: + """The platform version a pack was built for, from its name + (Batocera_43.1_BIOS_Pack.zip is 43.1).""" + stem = pack_name.removesuffix("_BIOS_Pack.zip") + return stem.rsplit("_", 1)[1] if "_" in stem else "" + + +def release_line(platform_name: str, record: dict) -> list[str]: + """Which release the buttons serve, with its checksums and signature, and + the version gap when the page describes a newer platform than the pack.""" + tag = record.get("tag") + pack_name = release_record.pack_name_for(platform_name, record) + if not tag or not pack_name: + return [] + base = f"{REPO_URL}/releases/download/{tag}" + built = _release_date(tag) + lines = [ + f"Pack {tag}{f', built {built}' if built else ''}. " + f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) " + f"[signature]({base}/SHA256SUMS.txt.sig)", + ] + config = load_platform_config(platform_name, "platforms") + page_version = str(config.get("version") or "") + pack_version = _pack_version(pack_name) + if page_version and pack_version and page_version.lstrip("v") != pack_version.lstrip("v"): + display = config.get("platform", platform_name) + lines.append(f"This pack was built for {display} {pack_version}. The command " + f"installs the files for {display} {page_version}.") + return lines + + def _pack_buttons(platform_name: str, label: str = "Pack") -> str: - """Buttons to the files of the platform's pack in the recorded release.""" - return download_links( + """Buttons to the files of the platform's pack in the recorded release, + and the release they come from under them.""" + record = release_record.load_record() + buttons = download_links( platform_name, - release_record.load_record(), + record, REPO_URL, label, button=lambda text, url: markup.button(text, url, primary=True), ) + return "\n\n".join([buttons, *release_line(platform_name, record)]) def _released_files(platform_name: str) -> str: @@ -1451,6 +1525,7 @@ def _page_description(markdown: str, relative: Path, title: str) -> str: "provenance.md": "Hash-based comparison of RetroBIOS metadata with No-Intro, Redump and TOSEC catalog snapshots.", "explore.md": "Systems, emulators and installer options to explore, with the figures of the BIOS collection.", "which-pack.md": "Pick your device or setup and get its BIOS files: the folder they go in, one command that installs and checks them, or a ZIP pack to extract.", + "contributing.md": "Add a BIOS file the collection lacks from the browser, checked on arrival, or add a platform or an emulator profile.", } if key in specific: return specific[key] @@ -1485,7 +1560,9 @@ def _page_description(markdown: str, relative: Path, title: str) -> str: ): continue plain = _plain_markdown(stripped) - if len(plain) >= 35: + # A paragraph that ends on a colon introduces what follows: alone, + # it describes nothing ("One file needs no clone and no local setup:"). + if len(plain) >= 35 and not plain.endswith(":"): return _shorten(plain) return f"{title}: RetroBIOS source-traced retrogaming firmware reference." @@ -1997,7 +2074,8 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st members = _group_members(coverages, registry, group) if not members: continue - lines += [f"## {label}", "", description, ""] + # One setup: its card says what the group description would. + lines += [f"## {label}", "", *([description, ""] if len(members) > 1 else [])] lines += markup.card_grid([ (f"[{coverages[name]['platform']}]({name}.md)", [GUIDES[name].summary]) for name in members @@ -2008,7 +2086,7 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st "", level_label("reference"), "", - f"{len(coverages)} supported platforms with {total_present:,} verified files.", + f"{setup_count()} supported platforms with {total_present:,} verified files.", "", "| Platform | On its BIOS list | Files in the pack | Checked by | Status | Download |", "|----------|------:|------:|-----------|--------|----------|", @@ -2339,12 +2417,102 @@ def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[st ] +# Shown in the "On the device" tab of a guide that has no `terminal:` of its own. +DEVICE_TERMINAL_FALLBACK = "Or fill the folder from your computer, with the ZIP." + + +def _computer_first(name: str, guide: Guide) -> bool: + """A system that runs only on its own device, with a ZIP to download: a + beginner fills its folder from a computer more easily than from a + terminal on the TV. Without a ZIP the command is the only route.""" + pack = release_record.pack_for(name, release_record.load_record()) + return guide.run == ("device",) and bool(pack and pack.get("assets")) + + +def _ships_neogeo(name: str) -> bool: + """Whether the setup's files hold `neogeo.zip`, the archive a reader most + often unzips by mistake: arcade BIOS sets are read zipped.""" + manifest = _REPO_ROOT / "install" / f"{name}.json" + if not manifest.is_file(): + return False + files = json.loads(manifest.read_text(encoding="utf-8")).get("files", []) + return any(f.get("dest", "").rsplit("/", 1)[-1] == "neogeo.zip" for f in files) + + +def _guide_download(name: str, display: str, guide: Guide, extract_to: str | None, + heading: str) -> list[str]: + """The pack: its buttons, its size, and where it is unzipped.""" + lines = [heading, ""] + record = release_record.load_record() + pack = release_record.pack_for(name, record) + network = any(label.startswith("From ") for label, _path in guide.folders) + if pack and pack.get("assets"): + parts = len(pack["assets"]) + bullets = [ + f"{_fmt_size(pack['download_size'])} to download, " + f"{_fmt_size(pack['extracted_size'])} once unzipped." + ] + if parts > 1: + bullets.append("You need both parts." if parts == 2 else f"You need all {parts} parts.") + if _computer_first(name, guide) and network: + target = "the network folder above" + else: + target = f"`{extract_to}`" if extract_to else "your BIOS folder" + bullets.append(f"Unzip {'them all' if parts > 1 else 'it'} into {target}." + + (" Files like `neogeo.zip` stay zipped." if _ships_neogeo(name) else "")) + if guide.download_hint and not _computer_first(name, guide): + bullets.append(guide.download_hint) + lines += [ + _pack_buttons(name, f"Download {display} Pack"), + "", + *(f"- {b}" for b in bullets), + "", + ] + elif record.get("tag"): + lines += [ + f"No {display} ZIP yet. The command installs the same files.", + "", + ] + else: + lines += [_pack_buttons(name, f"Download {display} Pack"), ""] + return lines + + +def _guide_install(name: str, guide: Guide, heading: str) -> list[str]: + """The command, in a tab per place it runs, and whether it is safe.""" + lines = [ + heading, + "", + "The command finds the folder, downloads what is missing, and checks " + "each file.", + "", + ] + if guide.on_card: + lines += [ + "Put the SD card in a computer. On Windows, note its letter, like " + "**E:**. Put the card's path after `--dest`.", + "", + ] + device = guide.terminal or f"{TERMINALS['device'][1]} {DEVICE_TERMINAL_FALLBACK}" + return [ + *lines, + *terminal_tabs(guide.run, platform_args(name, guide), how={"device": device}), + *trust_box("../"), + ] + + def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: bool) -> list[str]: - """The guide part of a platform page: folder, command, pack, check, next.""" + """The guide part of a platform page: folder, command, pack, check, next. + + A system that runs only on its own device puts the computer route + (download, unzip into the network folder) before the command.""" guide = GUIDES[name] entry = registry.get(name, {}) extract_to = entry.get("extract_to") - lines = ["## Where the files go", "", *_folder_rows(guide.folders)] + lines = ["## Where the files go", ""] + if guide.lead: + lines += [guide.lead, ""] + lines += _folder_rows(guide.folders) if extract_to: lines.append(f"| Unzip the pack into | `{extract_to}` |") lines.append("") @@ -2360,54 +2528,19 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: "", *([guide.more_folders_note, ""] if guide.more_folders_note else []), ]) - lines += [ - *(["", ""] if guide.on_card else []), - "## Install", - "", - "The command finds the folder, downloads what is missing, and checks " - "each file.", - "", - ] if guide.on_card: + lines += ["", ""] + if _computer_first(name, guide): lines += [ - "Put the SD card in a computer. On Windows, note its letter, like " - "**E:**. Put the card's path after `--dest`.", - "", - ] - lines += [ - *terminal_tabs(guide.run, platform_args(name, guide)), - *trust_box("../"), - "## Download", - "", - ] - record = release_record.load_record() - pack = release_record.pack_for(name, record) - if pack and pack.get("assets"): - parts = len(pack["assets"]) - bullets = [ - f"{_fmt_size(pack['download_size'])} to download, " - f"{_fmt_size(pack['extracted_size'])} once unzipped." - ] - if parts > 1: - bullets.append("You need both parts." if parts == 2 else f"You need all {parts} parts.") - target = f"`{extract_to}`" if extract_to else "your BIOS folder" - bullets.append(f"Unzip {'them all' if parts > 1 else 'it'} into {target}.") - if guide.download_hint: - bullets.append(guide.download_hint) - lines += [ - _pack_buttons(name, f"Download {display} Pack"), - "", - *(f"- {b}" for b in bullets), - "", - ] - elif record.get("tag"): - lines += [ - f"No {display} pack in release {record['tag']} yet. The command above " - "installs the same files.", - "", + *_guide_download(name, display, guide, extract_to, + "## From your computer { #download }"), + *_guide_install(name, guide, "## On the device { #install }"), ] else: - lines += [_pack_buttons(name, f"Download {display} Pack"), ""] + lines += [ + *_guide_install(name, guide, "## Install"), + *_guide_download(name, display, guide, extract_to, "## Download"), + ] check_args = {key: f"{args} --check" for key, args in platform_args(name, guide).items()} lines += [ "## How to check", @@ -2419,6 +2552,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: "", _check_sentence(display, cov["mode"], cov["config"], no_list), "", + *([guide.finish, ""] if guide.finish else []), *_next_block([ "[First install, step by step](../wiki/first-install.md)", "[When a game does not start](../wiki/troubleshooting.md)", @@ -4313,8 +4447,7 @@ def install_commands(args: str = "", indent: str = "") -> list[str]: ("Windows (PowerShell):", "powershell"), ("Android (Termux, after `pkg install python` and `termux-setup-storage`):", "sh"), ): - lines += [f"{indent}{label}", "", f"{indent}```{shell}", - f"{indent}{install_command(shell, args)}", f"{indent}```", ""] + lines += [f"{indent}{label}", "", *fence(shell, install_command(shell, args), indent), ""] return lines @@ -4351,15 +4484,11 @@ def _platform_install_block(name: str, display: str, on_card: bool = False) -> l "", "Linux, macOS:", "", - "```sh", - install_command("sh", f"--platform {name} --dest /path/to/card"), - "```", + *fence("sh", install_command("sh", f"--platform {name} --dest /path/to/card")), "", "Windows (PowerShell, E: being the card):", "", - "```powershell", - install_command("powershell", f"--platform {name} --dest E:\\"), - "```", + *fence("powershell", install_command("powershell", f"--platform {name} --dest E:\\")), ], opened=True) return markup.details("tip", "Install with one command", [ f"The installer downloads the files of the {display} pack that are " @@ -4371,22 +4500,31 @@ def _platform_install_block(name: str, display: str, on_card: bool = False) -> l ], opened=True) +def _ps_quote(value: str) -> str: + """A PowerShell argument: bare when plain, single-quoted otherwise.""" + return value if re.fullmatch(r"[\w.+-]+", value) else "'" + value.replace("'", "''") + "'" + + def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]: - """Each platform's command for one console, by the system id it gives it.""" + """Each platform's command for one console, by the system id it gives it, + for a terminal and for PowerShell.""" if not declared: return [] - lines = [ - "Each platform names the system its own way. On Windows, run the " - "same options through `&([scriptblock]::Create((irm " - f"{INSTALL_BASE}/install.ps1)))`.", + sh = [install_command("sh", f"--platform {platform} --system {shlex.quote(system)}") + for platform, system in declared] + ps = [install_command("powershell", f"--platform {platform} --system {_ps_quote(system)}") + for platform, system in declared] + return search_excluded(markup.details("tip", f"Install the {console} files", [ + "Each platform names the system its own way.", "", - "```sh", - ] - lines += [ - install_command("sh", f"--platform {platform} --system {shlex.quote(system)}") - for platform, system in declared - ] - return search_excluded(markup.details("tip", f"Install the {console} files", [*lines, "```"])) + "Linux, macOS, Steam Deck:", + "", + *fence("sh", "\n".join(sh)), + "", + "Windows (PowerShell):", + "", + *fence("powershell", "\n".join(ps)), + ])) def _render_platform_details(profile: dict) -> list[str]: @@ -5949,9 +6087,9 @@ def generate_not_found() -> str: def reference_next() -> str: - return "\n" + "\n".join(markup.aside("tip", "Next", [ - "- [Reference index](wiki/index.md), every reference page in one list", - "- For contributors: [How to contribute](contributing.md)", + return "\n" + "\n".join(_next_block([ + "[Reference index](wiki/index.md), every reference page in one list", + "For contributors: [How to contribute](contributing.md)", ])) @@ -6171,26 +6309,13 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str: """The pack's parts and size, or the command where the release has none.""" pack = release_record.pack_for(name, record) if record.get("tag") and not (pack and pack.get("assets")): - return (f"Not in {record['tag']}: " - f"[use the command]({prefix}platforms/{name}.md#install)") + return f"No ZIP yet. [Use the command]({prefix}platforms/{name}.md#install)." display = load_platform_config(name, "platforms").get("platform", name) links = download_links(name, record, REPO_URL, display) size = pack.get("download_size") if pack else None return f"{links} ({_fmt_size(size)})" if size else links -def _detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: - """Named systems a platform runs on, handhelds that run RetroArch for one, - with the BIOS folder the installer writes on each, read from the - detection rules of the registry.""" - seen: dict[str, str] = {} - for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: - name, folder = rule.get("name"), rule.get("bios_path") - if name and folder and rule.get("installs", True) and name not in seen: - seen[name] = folder - return sorted(seen.items(), key=lambda item: item[0].casefold()) - - def _group_keys(registry: dict, group: str, display: dict[str, str]) -> list[str]: return sorted( (k for k in registry if registry[k].get("device_group") == group), @@ -6212,75 +6337,41 @@ def _own_root_folders(registry: dict) -> dict[str, str]: def generate_which_pack() -> str: - """The device picker: every registered platform, grouped by the device it - runs on, with its BIOS folder, its pack and its page. + """The device picker: every registered platform as a tile under the + device it runs on, then one table per device with its BIOS folder and + its ZIP, the console picker, and the command for a setup not listed. - The command comes first: it finds the setup by itself, and the tables - below it serve the reader who downloads a ZIP.""" + The tiles are the choice. A second set of cards, five tables and the + picker offered the same choice three times.""" registry = load_platform_registry("platforms") record = release_record.load_record() display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry} own_root = _own_root_folders(registry) + all_tabs = ("windows", "macos", "linux", "steamdeck", "android") lines = [ "# Get your files", "", - "Two ways to fill your BIOS folder.", - "", - "- **[One command](#quick-install)**: finds your folder and checks each " - "file.", - "- **[A ZIP file](#manual-download)**: download it, unzip it into your " - "BIOS folder.", - "", - "## One command { #quick-install }", - "", - "A **terminal** is a window where you type commands.", - "", - *terminal_tabs(("windows", "macos", "linux", "steamdeck", "android"), {}), - "On Batocera, Recalbox and other game systems, run it on the device. " - "Your setup's page shows how.", - "", - *trust_box(""), - "It then asks what to install. Press **Enter** for everything.", - "", - "```", - " Enter) install everything", - " i) only what the installed cores read", - " c) choose systems, cores and regions", - " q) quit", - "```", - "", - "**Check your folder later.** Add `--check`. It changes nothing.", - "", - *terminal_tabs(("windows", "macos", "linux", "steamdeck", "android"), - dict.fromkeys(TERMINALS, "--check"), steps=False), - *markup.details("note", "Fill an SD card from a computer", [ - "Name your setup and the BIOS folder on the card. For RetroArch on a " - "Nintendo Switch:", - "", - "```sh", - install_command( - "sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system"), - "```", - ]), - "Details: [All installer options](wiki/installer.md).", + "Pick your setup. Its page gives the folder, the command and the ZIP.", "", "## Pick your setup", "", + *setup_picker(registry, display), + "## Folders and ZIP files { #manual-download }", + "", + "- A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.", + "- Unzip them all into the BIOS folder of your setup. Put the files " + "straight in it, not in a new folder.", + "- Some files inside stay zipped, like `neogeo.zip`. Leave them as they are.", + "- **Unzip into** means the ZIP holds its own top folder. Unzip it there.", + "", ] - lines += markup.card_grid([ - (label, [f"[{', '.join(display[k] for k in members)}](#{GROUP_ANCHORS[group]})"]) - for group, (label, _description) in DEVICE_GROUPS.items() - for members in [_group_keys(registry, group, display)] - if members - ]) - for group, (label, description) in DEVICE_GROUPS.items(): + no_zip = False + for group, (label, _description) in DEVICE_GROUPS.items(): members = _group_keys(registry, group, display) if not members: continue lines += [ - f"## {label} {{ #{GROUP_ANCHORS[group]} }}", - "", - description, + f"### {label} {{ #{GROUP_ANCHORS[group]} }}", "", "| Setup | Runs on | BIOS folder | ZIP |", "|-------|---------|-------------|-----|", @@ -6289,9 +6380,11 @@ def generate_which_pack() -> str: guide = GUIDES[key] folder = (f"Unzip into `{own_root[key]}`" if key in own_root else _folder_cell(key, guide)) + pack = release_record.pack_for(key, record) + no_zip |= bool(record.get("tag") and not (pack and pack.get("assets"))) lines.append( f"| [{display[key]}](platforms/{key}.md) | " - f"{_runs_on(registry[key], guide)} | {folder} | " + f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | " f"{_pack_cell(key, record)} |" ) for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ()): @@ -6310,28 +6403,56 @@ def generate_which_pack() -> str: *(f"| {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |" for name, folder in _detected_systems(registry, "retroarch")), ]) + if no_zip: + lines += ["Setups added after the last release have no ZIP until the next one.", ""] lines += [ - "## Download a ZIP { #manual-download }", - "", - "1. Find your setup in the tables above.", - "2. Download its ZIP. A big one comes in parts, like " - "`Pack.part1of2.zip` and `Pack.part2of2.zip`. Download every part.", - "3. Unzip every part into the folder named in the table.", - "", - "A row that says **Unzip into** has a ZIP that holds its own top folder. " - "Unzip it there, not into the BIOS folder.", - "", f"The ZIPs come from the [latest release]({RELEASE_URL}). The command may " "install newer files.", "", "## Only some consoles?", "", - "Run the command and press **c** when it asks.", - "", - "Or pick them here, and get the files and the command.", + "Pick them here, and get the files and the command. Or run the command " + "below and press **c** when it asks.", "", "", "", + "## Not in the list? One command finds it { #quick-install }", + "", + "A **terminal** is a window where you type commands.", + "", + *terminal_tabs(all_tabs, {}), + "On Batocera, Recalbox and other game systems, run it on the device. " + "Your setup's page shows how.", + "", + "The command asks what to install. Press **Enter** for everything.", + "", + "```text title=\"What it asks\"", + " Enter) install everything", + " i) only what the installed cores read", + " c) choose systems, cores and regions", + " q) quit", + "```", + "", + *trust_box(""), + "**Check your folder later.** Add `--check`. It changes nothing.", + "", + *terminal_tabs(all_tabs, dict.fromkeys(TERMINALS, "--check"), steps=False), + *markup.details("note", "Fill an SD card from a computer", [ + "Name your setup and the BIOS folder on the card. For RetroArch on a " + "Nintendo Switch:", + "", + "Linux, macOS:", + "", + *fence("sh", install_command( + "sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system")), + "", + "Windows (PowerShell, E: being the card):", + "", + *fence("powershell", install_command( + "powershell", "--platform retroarch --dest E:\\retroarch\\cores\\system")), + ]), + "Details: [All installer options](wiki/installer.md).", + "", *_next_block([ "[First install, step by step](wiki/first-install.md)", "[When a game does not start](wiki/troubleshooting.md)", diff --git a/tests/test_contributing_text.py b/tests/test_contributing_text.py index ccfd04e2..eed4b794 100644 --- a/tests/test_contributing_text.py +++ b/tests/test_contributing_text.py @@ -22,7 +22,7 @@ import generate_site # noqa: E402 def _normalized(text: str) -> str: # The site ends each page with its next step in the reading order. That # is navigation, not contributing text, and a repository file has none. - text = text.split("\n\n:::tip[Next]\n", 1)[0].rstrip("\n") + text = text.split("\n## Next steps\n", 1)[0].rstrip("\n") text = re.sub(r"\]\([^)]*\)", "](link)", text) return "\n".join(text.splitlines()[1:]).replace("on this site", "on the documentation site") diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index a565740c..644f475f 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -179,7 +179,7 @@ class DownloadLinksNameTheFile(unittest.TestCase): row = next(line for line in page.splitlines() if line.startswith(f"| [{label}](")) with self.subTest(platform=key): self.assertNotIn("/releases/", row) - self.assertIn(f"Not in {RECORD['tag']}: [use the command](platforms/{key}.md#install)", row) + self.assertIn(f"No ZIP yet. [Use the command](platforms/{key}.md#install).", row) class SlicesLeaveWithTheirRelease(unittest.TestCase): diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py index eece3067..3c6258e4 100644 --- a/tests/test_site_copy.py +++ b/tests/test_site_copy.py @@ -224,7 +224,7 @@ def parse(markdown: str) -> Page: flush() tab = 0 continue - if "rb-button" in stripped or stripped.startswith("])(?:\\\\[^\s,)]+|smb://[^\s,)]+|~/[^\s,)]*|/[A-Za-z][^\s,)]*|[A-Z]:\\[^\s,)]*)" +) + + +def _page(name: str) -> str: + sitepages.require() + return sitepages.page(name).read_text(encoding="utf-8") + + +def _body(text: str) -> str: + """A page without its front matter.""" + return text.split("\n---\n", 1)[1] if text.startswith("---\n") else text + + +def _guide_part(text: str) -> str: + match = re.search(r"^## Reference\b", text, re.M) + return text[: match.start()] if match else text + + +class TrustItemsSayWhereItWrites(unittest.TestCase): + def test_no_only_in_the_bios_folder_while_a_manifest_writes_saves(self): + saves = [ + path.name for path in sorted((REPO_ROOT / "install").glob("*.json")) + if any(entry.get("root") == "save" + for entry in json.loads(path.read_text(encoding="utf-8")).get("files", [])) + ] + self.assertTrue(saves, "no manifest files an entry under root: save") + text = " ".join(siteguides.TRUST_ITEMS).lower() + self.assertNotIn("only in your bios folder", text) + self.assertIn("save folder", text) + self.assertIn("--restore", text) + + def test_home_shows_the_same_items(self): + home = _page("index.md") + for item in siteguides.TRUST_ITEMS: + self.assertIn(f"- {item}", home) + self.assertNotIn("only in your BIOS folder", home) + + +class NextStepsIsAPlainList(unittest.TestCase): + def test_the_helpers(self): + block = "\n".join(generate_site._next_block(["[A](a.md)", "[B](b.md)"])) + self.assertEqual(block, "## Next steps\n\n- [A](a.md)\n- [B](b.md)\n") + self.assertIn("## Next steps", generate_site.reference_next()) + self.assertNotIn(":::", generate_site.reference_next()) + + def test_entry_pages(self): + for name in ("which-pack.md", "contributing.md", "platforms/batocera.md", + "platforms/retroarch.md"): + with self.subTest(page=name): + text = _page(name) + self.assertNotIn(":::tip[Next]", text) + self.assertIn("## Next steps", text) + + def test_home_has_no_next_box_and_no_stamp(self): + home = _page("index.md") + self.assertNotIn("Next steps", home) + self.assertNotIn(":::tip", home) + self.assertNotIn("Generated on", home) + self.assertNotRegex(home, r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z") + + +class DatesInWords(unittest.TestCase): + def test_data_as_of(self): + moment = datetime(2026, 10, 22, 22, 43, 8, tzinfo=timezone.utc) + self.assertEqual( + generate_site.data_as_of(moment), + '', + ) + + def test_get_your_files_carries_no_date(self): + self.assertNotIn("Generated on", _page("which-pack.md")) + + +class OneSetupCount(unittest.TestCase): + def test_every_entry_page_states_the_registry_count(self): + count = len(REGISTRY) + self.assertEqual(siteguides.setup_count(), count) + self.assertIn(f"and {count - 3} more.", _page("index.md")) + self.assertIn(f'{count}', _page("explore.md")) + self.assertIn(f"{count} supported platforms", _page("platforms/index.md")) + + +class SetupTiles(unittest.TestCase): + def test_one_tile_per_setup_on_home_and_get_your_files(self): + for name in ("index.md", "which-pack.md"): + text = _page(name) + with self.subTest(page=name): + self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY)) + for key in REGISTRY: + self.assertIn(f'href="/retrobios/platforms/{key}/"', text) + + def test_get_your_files_offers_the_choice_once(self): + text = _page("which-pack.md") + self.assertEqual(text.count('class="rb-card"'), 0) + self.assertEqual(text.count("MM", tile) + tile = "".join(sitemarkup.platform_tiles([("RomM", "x.md", "logo.png", "A server")])) + self.assertIn('{display[members[0]]}", home) + + def test_hardware_is_named_and_cited(self): + self.assertIn("Raspberry Pi", siteguides.GUIDES["batocera"].summary) + self.assertIn("Raspberry Pi", siteguides.DEVICE_GROUPS["gaming-os"][0]) + self.assertIn("Anbernic", siteguides.DEVICE_GROUPS["handheld"][0]) + lines = GUIDES_YAML.splitlines() + for number, line in enumerate(lines): + if re.match(r" (runs_on|terminal):", line): + with self.subTest(line=line.strip()): + self.assertTrue(lines[number - 1].lstrip().startswith("#"), + "no source cited on the line above") + + +class GetYourFilesOrder(unittest.TestCase): + def test_choice_then_tables_then_picker_then_command(self): + text = _page("which-pack.md") + order = [text.index(mark) for mark in ( + "## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "Is it safe?", + )] + self.assertEqual(order, sorted(order)) + + def test_a_setup_after_the_release_says_no_zip_yet(self): + text = _page("which-pack.md") + self.assertNotIn("Not in v", text) + record = release_record.load_record() + missing = [k for k in REGISTRY if not release_record.pack_for(k, record)] + if record.get("tag") and missing: + self.assertIn("No ZIP yet. [Use the command]", text) + self.assertIn("Setups added after the last release have no ZIP until the next one.", + text) + + def test_zip_advice_answers_the_usual_mistakes(self): + text = _page("which-pack.md") + self.assertIn("Download every part", text) + self.assertIn("not in a new folder", text) + self.assertIn("`neogeo.zip`", text) + + +class GuideRoutes(unittest.TestCase): + def test_a_device_system_opens_on_the_computer_route(self): + text = _page("platforms/batocera.md") + self.assertLess(text.index("## From your computer"), text.index("## On the device")) + self.assertIn("{ #install }", "\n".join(generate_site._guide_install( + "batocera", siteguides.GUIDES["batocera"], "## On the device { #install }"))) + self.assertIn("Unzip them all into the network folder above", text) + + def test_a_computer_app_keeps_the_command_first(self): + text = _page("platforms/retroarch.md") + self.assertLess(text.index("## Install"), text.index("## Download")) + + def test_each_device_tab_says_how_to_open_its_terminal(self): + shared = siteguides.TERMINALS["device"][1] + for name, guide in siteguides.GUIDES.items(): + if "device" not in guide.run: + continue + with self.subTest(platform=name): + self.assertTrue(guide.terminal, "no terminal text of its own") + text = _guide_part(_page(f"platforms/{name}.md")) + self.assertIn(guide.terminal.split(".")[0], text) + self.assertNotIn(shared, text) + + def test_retroarch_names_the_menu_before_the_folders(self): + text = _page("platforms/retroarch.md") + self.assertLess(text.index("Settings > Directory > System/BIOS"), + text.index("| Where | BIOS folder |")) + + def test_emudeck_names_the_card_and_ends_the_job(self): + text = _guide_part(_page("platforms/emudeck.md")) + self.assertIn("installed on the SD card", text) + self.assertIn("Return to Gaming Mode", text) + self.assertNotIn("it is on the card", text) + + +class PackManualReadsThePageFolders(unittest.TestCase): + def test_every_path_in_a_pack_manual_is_a_folder_of_its_page(self): + for name, guide in siteguides.GUIDES.items(): + if not guide.pack_manual: + continue + known = " ".join(path.replace("`", "") for _w, path in siteguides.folder_list(name)) + known += " " + str(REGISTRY[name].get("extract_to") or "") + for path in ABSOLUTE_PATH.findall(siteguides.pack_manual(name)): + with self.subTest(platform=name, path=path): + self.assertIn(path.rstrip(".:"), known) + + def test_the_retroarch_manual_lists_the_detected_systems(self): + manual = siteguides.pack_manual("retroarch") + self.assertNotIn("{folders}", manual) + self.assertIn("/run/muos/storage/bios", manual) + self.assertIn("~/.var/app/org.libretro.RetroArch/config/retroarch/system/", manual) + + +class DownloadNamesTheRelease(unittest.TestCase): + def test_tag_date_checksums_and_signature(self): + record = release_record.load_record() + if not release_record.pack_for("batocera", record): + self.skipTest("release.json has no Batocera pack") + tag = record["tag"] + lines = generate_site.release_line("batocera", record) + self.assertTrue(lines[0].startswith(f"Pack {tag}, built ")) + self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt)", lines[0]) + self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt.sig)", lines[0]) + self.assertEqual(generate_site._release_date("v2026.10.10"), "10 October 2026") + + def test_a_newer_platform_than_the_pack_is_named(self): + record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": { + "assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}} + version = load_platform_config("retrobat", str(REPO_ROOT / "platforms"))["version"] + lines = generate_site.release_line("retrobat", record) + self.assertIn(f"built for RetroBat 1.0. The command installs the files for " + f"RetroBat {version}.", lines[-1]) + + +class CommandBlocks(unittest.TestCase): + def test_every_command_on_an_entry_page_has_a_title(self): + names = ["index.md", "which-pack.md", "contributing.md"] + [ + f"platforms/{key}.md" for key in REGISTRY] + for name in names: + with self.subTest(page=name): + self.assertIsNone(UNTITLED_FENCE.search(_guide_part(_page(name)))) + + def test_shell_lines_have_a_powershell_form(self): + text = _page("which-pack.md") + groups = re.findall(r"|", text, re.S) + commands = [group for group in groups if "install.sh | sh" in group] + self.assertGreaterEqual(len(commands), 3) + for group in commands: + self.assertIn("install.ps1", group) + block = "\n".join(generate_site._console_install_block( + "PlayStation", [("batocera", "psx"), ("recalbox", "psx")])) + self.assertEqual(block.count("install.sh | sh"), block.count("install.ps1))) ")) + self.assertIn('```powershell title="PowerShell"', block) + for key, guide in siteguides.GUIDES.items(): + if "windows" in guide.run: + with self.subTest(platform=key): + self.assertIn("install.ps1", _guide_part(_page(f"platforms/{key}.md"))) + + +class ContributingOpensOnTheAction(unittest.TestCase): + def test_both_generators_open_on_the_upload(self): + for text in (generate_readme.generate_contributing(), generate_site.generate_contributing()): + body = text.split("## Add a BIOS file", 1)[1] + self.assertTrue(body.lstrip().startswith("Have a file the collection lacks?")) + self.assertLess(body.index(generate_readme.UPLOAD_URL), body.index("## What happens next")) + self.assertLess(body.index("gaps"), body.index("## What happens next")) + self.assertLess(body.index("## What happens next"), body.index("CI hashes")) + + def test_description_is_a_sentence(self): + text = generate_site._page_description("", Path("contributing.md"), "Contributing") + self.assertTrue(text.endswith(".")) + fallback = generate_site._page_description( + "One file needs no clone and no local setup at all here:\n\n" + "A plain sentence that describes the page well enough.", + Path("other.md"), "Other") + self.assertEqual(fallback, "A plain sentence that describes the page well enough.") + + +class HomeLinksTheOtherTasks(unittest.TestCase): + def test_troubleshooting_file_check_and_legal(self): + home = _body(_page("index.md")) + for target in ("/retrobios/wiki/troubleshooting/", "/retrobios/files/", + "/retrobios/wiki/legal/"): + self.assertIn(f"]({target})", home) + self.assertLess(home.index("/retrobios/wiki/troubleshooting/"), home.index("## Do I need it?")) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 353e260c..b3f7a24f 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards. | Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | -| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_entry`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture