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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-26 21:01:49 +01:00
1 parent 0c77ef074e
commit b24fc06e51
6 files changed
+133 -26

No files matched your search

+44 -5
View File
@@ -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' <span class="rb-badge rb-badge-muted" {skip}>No reader</span>'
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' <a class="rb-download" href="{html.escape(_fetch_url(f["path"]))}" '
f'aria-label="Download {html.escape(name)}" {skip}>Download</a>'
) if f.get("path") and markup.starlight() else ""
lines.append(_card_title(
f'{_code(name)} <span class="rb-badge rb-badge-muted">{size}</span>{unread}{permalink}',
f'{_code(name)} <span class="rb-badge rb-badge-muted">{size}</span>{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"(?<!\[)(?<!\(as )`([^`]*)`(?!\]\()", link, line)
def _group_lines(
+7 -6
View File
@@ -100,17 +100,17 @@ class TheConsoleBiosComesFirst(unittest.TestCase):
def test_the_section_opens_on_the_bios_by_region(self):
lines = gs._start_with(self.files, self.required_only())
self.assertEqual(lines[0], "**Start with** the BIOS for the region of your games:")
self.assertIn("- **North America**: `scph5501.bin`", lines)
self.assertIn("- **Japan**: `scph5500.bin`", lines)
self.assertLess(lines.index("- **North America**: `scph5501.bin`"),
lines.index("- **Japan**: `scph5500.bin`"))
self.assertIn("- **North America**: [`scph5501.bin`](#sha1-444444444444)", lines)
self.assertIn("- **Japan**: [`scph5500.bin`](#sha1-555555555555)", lines)
self.assertLess(lines.index("- **North America**: [`scph5501.bin`](#sha1-444444444444)"),
lines.index("- **Japan**: [`scph5500.bin`](#sha1-555555555555)"))
def test_regions_needing_the_same_file_share_a_line(self):
consumers = _consumers({"4" * 40: [
{"required": True, "region": ["north-america", "latin-america", "brazil"]}]})
lines = gs._start_with([_file("scph5501.bin", "4")], consumers)
# Brazil says nothing Latin America does not.
self.assertIn("- **North America, Latin America**: `scph5501.bin`", lines)
self.assertIn("- **North America, Latin America**: [`scph5501.bin`](#sha1-444444444444)", lines)
def test_a_long_list_names_the_first_and_counts_the_rest(self):
files = [_file(f"rom{i}.bin", "abcdefgh"[i]) for i in range(8)]
@@ -120,7 +120,8 @@ class TheConsoleBiosComesFirst(unittest.TestCase):
consumers.emulator_files = {"emu": [{"required": True}] * len(files)}
consumers.emulator_entries = {f["sha1"]: {"emu": i} for i, f in enumerate(files)}
self.assertEqual(gs._start_with(files, consumers)[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. "
"They come first below.")
def test_no_required_file_no_line(self):
+7 -6
View File
@@ -80,18 +80,18 @@ class TheSectionStartsWithItsOwnConsole(unittest.TestCase):
def test_a_console_its_emulators_run_without_says_so(self) -> 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):
+8 -3
View File
@@ -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(),
+50 -5
View File
@@ -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<h3 id="sha1-{full[:12]}" class="rb-file-title">'
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("<section", 1)[0]
self.assertIn("[`apple2-asoft-auto.rom`](#sha1-33a24f5489ba)", start)
if __name__ == "__main__":
unittest.main()
+17 -1
View File
@@ -99,7 +99,7 @@
}
/* Links and focus */
.sl-markdown-content a:not(.rb-button):not(.rb-badge):not(.rb-permalink) {
.sl-markdown-content a:not(.rb-button):not(.rb-badge):not(.rb-permalink):not(.rb-download) {
color: var(--rb-link);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.14em;
@@ -486,6 +486,22 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
.rb-permalink { opacity: 1; }
}
.rb-download {
display: inline-flex;
align-items: center;
min-height: 32px;
margin-left: 0.5rem;
padding: 0.15rem 0.7rem;
border: 1px solid var(--rb-link);
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
color: var(--rb-link);
text-decoration: none;
vertical-align: middle;
}
.rb-download:hover { background: var(--sl-color-accent-low); }
/* The facts of a file card: label column, value column, one column on a phone. */
.sl-markdown-content dl.rb-facts {
display: grid;