From b24fc06e51518a0fceed70625816f6caa225ab9f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:01:49 +0100 Subject: [PATCH] feat: download button on every file card The Apple II page named its start files with no visible way to get one: the download sat after the collection path, fourth of a card's facts. Each card now carries a Download button in its title, each start-line name links to its card, and a card held on another part page is reached by its permanent link. --- scripts/generate_site.py | 49 +++++++++++++++++++++++++--- tests/test_site_r2_systems.py | 13 ++++---- tests/test_site_r3_systems.py | 13 ++++---- tests/test_site_r4_systems.py | 11 +++++-- tests/test_site_r5_systems.py | 55 +++++++++++++++++++++++++++++--- website/src/styles/retrobios.css | 18 ++++++++++- 6 files changed, 133 insertions(+), 26 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 575770981..2c7ff7c60 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3007,6 +3007,26 @@ def _front_matter(markdown: str) -> dict: return data if isinstance(data, dict) else {} +_SHA1_BY_PREFIX: dict[str, str] = {} + + +def _cards_elsewhere(markdown: str) -> str: + """A start line links each file to its card. A console split over part + pages keeps its cards on the parts, so a card this page does not hold + is reached by its permanent link instead.""" + links = set(re.findall(r"\]\(#(sha1-[0-9a-f]{12})\)", markdown)) + away = {ident for ident in links if f'id="{ident}"' not in markdown} + if not away: + return markdown + if not _SHA1_BY_PREFIX: + db = load_database(str(_REPO_ROOT / "database.json")) + _SHA1_BY_PREFIX.update({sha1[:12]: sha1 for sha1 in db["files"]}) + for ident in away: + full = _SHA1_BY_PREFIX[ident.removeprefix("sha1-")] + markdown = markdown.replace(f"](#{ident})", f"]({markup.site_href(f'files/?sha1={full}')})") + return markdown + + def decorate_markdown_pages(docs: Path) -> dict[str, str]: """Add per-page descriptions and machine-readable structured data. @@ -3026,7 +3046,7 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]: # Written in its final form by an earlier pass and rewritten by # nothing since: a second pass would find no title to read. continue - markdown = prepare_page(original, relative) + markdown = _cards_elsewhere(prepare_page(original, relative)) for image_id in unresolved_images(markdown): print(f" WARNING: {relative}: no diagram named {image_id!r}", file=sys.stderr) stripped = _undecorated(markdown) @@ -5477,8 +5497,14 @@ def _system_file_card( # emulator, platform or pack line and looked unfinished. unread = (f' No reader' if not (emus or plats or installed) else "") + # The download leads the card: as a "(download)" after the collection + # path, fourth of the facts, a reader of the Apple II page found none. + download = ( + f' Download' + ) if f.get("path") and markup.starlight() else "" lines.append(_card_title( - f'{_code(name)} {size}{unread}{permalink}', + f'{_code(name)} {size}{unread}{download}{permalink}', anchor, searchable, )) lines.append("") @@ -5509,8 +5535,10 @@ def _system_file_card( # The card named a file, its hashes and provenance, but not where to # get it: a user missing scph5501.bin found the reference and no way # to the bytes. - facts.append(("Collection path", - [f"{_code(f['path'])} ([download]({_fetch_url(f['path'])}))"], False)) + path = _code(f["path"]) + if not markup.starlight(): + path += f" ([download]({_fetch_url(f['path'])}))" + facts.append(("Collection path", [path], False)) catalogued = [line.split(": ", 1)[1] for line in _provenance_lines(f.get("provenance") or {}, "../")] if catalogued: @@ -6196,7 +6224,18 @@ def _start( sha1 = sha1s.get(visible) if sha1 and sha1 not in order: order.append(sha1) - return lines, order + return [_link_held_names(line, sha1s, held) for line in lines], order + + +def _link_held_names(line: str, sha1s: dict[str, str], held: set[str]) -> str: + """Each file the start line names leads to its card, where it downloads: + the Apple II line named eight files and none was a way to get one.""" + def link(match: re.Match) -> str: + sha1 = sha1s.get(match[1], "") + return f"[{match[0]}](#sha1-{sha1[:12]})" if sha1 in held else match[0] + # A name inside a link already (a "Keep it zipped" archive) stays as + # is, and so does the name a file is read under, "(as `d2fdc.zip`)". + return re.sub(r"(? None: self.assertEqual(gs._start_with(self.files, self.consumers, section=DS), [ "**No file needed** with emu-a or emu-b. For full accuracy: " - "`bios7.bin`, `bios9.bin`.", ""]) + "[`bios7.bin`](#sha1-777777777777), [`bios9.bin`](#sha1-999999999999).", ""]) def test_the_dsi_section_names_its_file(self) -> None: lines = gs._start_with(self.files, self.consumers, section=("Nintendo", "Nintendo DSi")) - self.assertEqual(lines, ["**Start with**: `dsi_bios7.bin`.", ""]) + self.assertEqual(lines, ["**Start with**: [`dsi_bios7.bin`](#sha1-dddddddddddd).", ""]) def test_more_than_three_emulators_are_counted(self) -> None: hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"} consumers = _consumers({"7" * 40: [(f"emu-{n}", hle) for n in "abcd"]}) self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS)[0], "**No file needed**: none of its 4 emulators requires one. " - "For full accuracy: `NDS_Bios7.bin`.") + "For full accuracy: [`NDS_Bios7.bin`](#sha1-777777777777).") def test_a_shipped_file_is_no_start(self) -> None: consumers = _consumers({"7" * 40: [("emu-a", {"required": True, "bundled": True, @@ -108,7 +108,7 @@ class TheNameSetupsWrite(unittest.TestCase): destinations={"a" * 40: {"p1": {"gba_bios.bin"}, "p2": {"bios/gba_bios.bin"}, "p3": {"GBA_bios.rom"}}}) self.assertEqual(gs._start_with([f], consumers, section=("Nintendo", "Game Boy Advance")), - ["**Start with**: `gba_bios.bin`.", ""]) + ["**Start with**: [`gba_bios.bin`](#sha1-aaaaaaaaaaaa).", ""]) def test_no_setup_then_the_emulators_name(self) -> None: f = _file("x.rom", "b", "bios/Nintendo/Game Boy Advance/x.rom") @@ -142,7 +142,7 @@ class ArcadeSetsAreNamedWhereverFiled(unittest.TestCase): link = f"[`neogeo.zip`](/retrobios/files/?sha1={'2' * 40})" self.assertEqual(lines[0], f"**Start with** {link}. Keep it zipped. " "mame0, mame1 and mame2 read it.") - self.assertIn("Geolith reads `aes.zip` instead.", lines) + self.assertIn("Geolith reads [`aes.zip`](#sha1-111111111111) instead.", lines) def test_another_consoles_file_or_an_emulators_folder_is_left_out(self) -> None: text = "\n".join(gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo"))) @@ -164,7 +164,8 @@ class ArcadeSetsAreNamedWhereverFiled(unittest.TestCase): for i in range(8)] consumers = _consumers({f["sha1"]: [("emu", {"required": True})] for f in files}) self.assertEqual(gs._start_with(files, consumers, first_below=False)[0], - "**Start with**: `rom0.bin`, `rom1.bin`, `rom2.bin` and 5 more files.") + "**Start with**: [`rom0.bin`](#sha1-aaaaaaaaaaaa), [`rom1.bin`](#sha1-bbbbbbbbbbbb), " + "[`rom2.bin`](#sha1-cccccccccccc) and 5 more files.") class CardsSayWhichReleaseTheSetMatches(unittest.TestCase): diff --git a/tests/test_site_r4_systems.py b/tests/test_site_r4_systems.py index 75c5a9c2b..edb919f9d 100644 --- a/tests/test_site_r4_systems.py +++ b/tests/test_site_r4_systems.py @@ -29,6 +29,11 @@ DIST = REPO_ROOT / "website" / "dist" CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs" +def _unlinked(text: str) -> str: + """A start line's wording: each file it names links to its card.""" + return re.sub(r"\[(`[^`]*`)\]\([^)]*\)", r"\1", text) + + def _file(name: str, char: str, path: str = "") -> dict: return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096, "path": path or f"bios/Nintendo/Game Boy/{name}"} @@ -73,7 +78,7 @@ class AFileFewRequireIsNoStartFile(unittest.TestCase): consumers = _consumers({"1" * 40: [ ("emux_gb", {"name": "dmg_boot.bin", "required": True}), ("gambatte", optional), ("mgba", optional), ("sameboy", optional)]}) - line = gs._start_with([boot], consumers)[0] + line = _unlinked(gs._start_with([boot], consumers)[0]) self.assertTrue(line.startswith("**No file needed** with "), line) self.assertRegex(line, r"Only \S.* requires `dmg_boot.bin`\.$") @@ -82,7 +87,7 @@ class AFileFewRequireIsNoStartFile(unittest.TestCase): required = {"name": "scph5501.bin", "required": True} consumers = _consumers({"2" * 40: [ ("a", required), ("b", required), ("c", {"required": False, "hle_fallback": True})]}) - self.assertEqual(gs._start_with([bios], consumers)[0], "**Start with**: `scph5501.bin`.") + self.assertEqual(_unlinked(gs._start_with([bios], consumers)[0]), "**Start with**: `scph5501.bin`.") def test_an_entry_read_for_a_system_with_no_section_is_dropped(self) -> None: sample = _file("ym2608_adpcm_rom.bin", "3", "bios/Nintendo/NES/ym2608_adpcm_rom.bin") @@ -131,7 +136,7 @@ class TheNeoGeoOpensOnItsSet(unittest.TestCase): "5" * 40: [("ares", {"name": "neo-epo.sp1", "required": True, "system": "snk-neogeo"})], }) - lines = gs._start_with([neogeo, epo], consumers, section=("SNK", "Neo Geo")) + lines = [_unlinked(line) for line in gs._start_with([neogeo, epo], consumers, section=("SNK", "Neo Geo"))] self.assertTrue(lines[0].startswith("**Start with** `neogeo.zip`. Keep it zipped. ")) self.assertIn("ares reads `neo-epo.sp1` instead.", lines) cards = gs._console_cards("SNK", [epo, neogeo], consumers, set(), diff --git a/tests/test_site_r5_systems.py b/tests/test_site_r5_systems.py index a8662903d..dec05b638 100644 --- a/tests/test_site_r5_systems.py +++ b/tests/test_site_r5_systems.py @@ -52,11 +52,17 @@ def _consumers(reads: dict[str, list[tuple[str, dict]]]) -> types.SimpleNamespac ) +def _unlinked(text: str) -> str: + """The wording of a start line: each file it names links to its card, + which EachFileDownloadsFromItsCard holds.""" + return re.sub(r"\[(`[^`]*`)\]\([^)]*\)", r"\1", text) + + def _page(stem: str) -> str: path = SYSTEMS / f"{stem}.md" if not path.is_file(): raise unittest.SkipTest(f"systems/{stem}.md not generated") - return path.read_text(encoding="utf-8") + return _unlinked(path.read_text(encoding="utf-8")) def _start_line(text: str) -> str: @@ -83,7 +89,7 @@ class AStartFileIsNoOnesExtra(unittest.TestCase): "1" * 40: [(e, {"name": "naomi.zip", "required": True}) for e, _ in both + extra], "2" * 40: both + extra, }) - self.assertEqual(gs._start_with([naomi, hod2], consumers), + self.assertEqual([_unlinked(line) for line in gs._start_with([naomi, hod2], consumers)], ["**Start with**: `naomi.zip`.", ""]) def test_the_naomi_page_opens_on_naomi_zip(self) -> None: @@ -95,7 +101,7 @@ class AStartFileIsNoOnesExtra(unittest.TestCase): "3" * 40: [(e, {"name": "bios7.bin", "required": True}) for e in ("a", "b")], "4" * 40: [(e, {"name": "bios9.bin", "required": True}) for e in ("a", "b")], }) - self.assertEqual(gs._start_with([bios7, bios9], consumers)[0], + self.assertEqual(_unlinked(gs._start_with([bios7, bios9], consumers)[0]), "**Start with**: `bios7.bin`, `bios9.bin`.") def test_some_files_each_is_not_every_file_for_one(self) -> None: @@ -121,7 +127,7 @@ class OtherNamesForOneSlotReadAsAlternatives(unittest.TestCase): for e in ("mesen", "nestopia", "fceumm")], "6" * 40: [("fbneo", {"name": "fdsbios.zip", "required": True})], }) - self.assertEqual(gs._start_with([disk, fds], consumers)[0], + self.assertEqual(_unlinked(gs._start_with([disk, fds], consumers)[0]), "**Start with**: `disksys.rom`. FinalBurn Neo reads `fdsbios.zip` instead.") def test_a_card_read_under_another_name_says_both(self) -> None: @@ -131,7 +137,7 @@ class OtherNamesForOneSlotReadAsAlternatives(unittest.TestCase): "7" * 40: [("c", {"name": "short.rom", "required": True})], }) self.assertIn("reads `long name (1986).bin` (as `short.rom`) instead.", - gs._start_with([disk, card], consumers)[0]) + _unlinked(gs._start_with([disk, card], consumers)[0])) def test_a_regional_line_names_one_file_per_reader(self) -> None: mega_cd = _page("sega").split("{ #mega-cd }", 1)[1].split("\n## ", 1)[0] @@ -293,5 +299,44 @@ class OneLabelOnceOnABoardPage(unittest.TestCase): []) +class EachFileDownloadsFromItsCard(unittest.TestCase): + """The Apple II page named eight start files and gave no visible way to + get one: the download was a "(download)" after the collection path, + fourth of a card's facts.""" + + def test_the_card_title_holds_the_download(self) -> None: + from cross_reference import FileConsumers # noqa: PLC0415 + + f = _file("apple2-asoft-auto.rom", "3", "bios/Apple/Apple II/apple2-asoft-auto.rom") + title = next(line for line in gs._system_file_card(f, FileConsumers({"files": {}})) + if "rb-file-title" in line) + self.assertIn('class="rb-download" href="https://raw.githubusercontent.com/Abdess/' + 'retrobios/main/bios/Apple/Apple%20II/apple2-asoft-auto.rom"', title) + self.assertIn('aria-label="Download apple2-asoft-auto.rom"', title) + facts = "\n".join(gs._system_file_card(f, FileConsumers({"files": {}}))) + self.assertNotIn("(download)", facts) + + def test_a_start_name_leads_to_its_card(self) -> None: + line = gs._link_held_names("- **Apple II**: `a.rom`, `b.rom`.", + {"a.rom": "a" * 40, "b.rom": "b" * 40}, {"a" * 40}) + self.assertEqual(line, "- **Apple II**: [`a.rom`](#sha1-aaaaaaaaaaaa), `b.rom`.") + + def test_a_card_on_another_part_is_reached_by_its_permalink(self) -> None: + full = next(iter(json.loads((REPO_ROOT / "database.json").read_text())["files"])) + here = f'[`x.rom`](#sha1-{full[:12]})' + away = gs._cards_elsewhere(here) + self.assertEqual(away, f"[`x.rom`](/retrobios/files/?sha1={full})") + held = here + f'\n

' + self.assertEqual(gs._cards_elsewhere(held), held) + + def test_every_start_name_on_the_built_apple_page_links(self) -> None: + page = next(SYSTEMS.glob("apple.md*"), None) + if page is None: + self.skipTest("site not generated") + section = page.read_text(encoding="utf-8").split("## Apple II {", 1)[1] + start = section.split("