feat: list platform files as table rows

Each file of a platform list is one row: name, folder, status chip, a short hash on a button that copies it whole, and a details cell. The details hold every declared hash, the size, the served copy and the readers. A reference's role is one word (glue, port, upstream, build) defined once in a legend instead of a phrase after every reference.

Visible words: Batocera 2,433 to 2,332, AmberELEC 1,301 to 1,299, RomM 1,607 to 1,596, RetroArch 2,207 to 2,088, RetroDECK arcade part 2 19,127 to 2,104. Rows weigh more Markdown, so 38 more systems move to pages of their own under the existing budgets.
This commit is contained in:
Abdessamad Derraz committed 2026-10-27 20:44:05 +01:00
1 parent 74b49e8c04
commit 20dc488e41
7 files changed
+535 -85

No files matched your search

+193 -80
View File
@@ -3253,13 +3253,16 @@ PLATFORM_REFERENCE_BUDGET = 250_000
def _file_runs(body: list[str], budget: int) -> list[list[str]]:
"""A system's file list in runs of at most ``budget`` characters.
The lines before the first file (counts, emulators) open every run. A
file is never cut from its hash and evidence lines.
The lines before the first file (counts, emulators, a table's header)
open every run. A file is never cut from its hash and evidence lines,
and a table row is one line.
"""
first = next((i for i, line in enumerate(body) if line.startswith("- ")), len(body))
rows = next((i + 1 for i, line in enumerate(body) if line.startswith("|--")), None)
first = rows if rows is not None else next(
(i for i, line in enumerate(body) if line.startswith("- ")), len(body))
head, items = body[:first], []
for line in body[first:]:
if line.startswith("- ") or not items:
if line.startswith(("- ", "| ")) or not items:
items.append([line])
else:
items[-1].append(line)
@@ -3423,6 +3426,139 @@ def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
return found
PLATFORM_FILE_HEADER = [
"| File | Path | Status | Hash | Details |",
"|---|---|---|---|---|",
]
# The characters of a hash a row shows, as many as the served copy's sha1.
SHORT_HASH = 8
# The hash a row shows first, when the list does not say which it checks.
_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
# An untested entry whose reason is a hash the bytes do not match.
_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
def _status_chip(f: dict) -> str:
"""The row's status as a chip, its word and its colour saying the same."""
status = f.get("status")
if status == "ok":
word, tone = "OK", "success"
elif status == "missing":
word, tone = "Missing", "danger"
elif status == "untested":
word = "Wrong hash" if _WRONG_HASH.search(str(f.get("reason") or "")) else "Untested"
tone = "warning"
else:
word, tone = str(status), "muted"
return f'<span class="rb-badge rb-badge-{tone}">{word}</span>'
def _copy_hash(algorithm: str, value: str | list) -> str:
"""A declared hash cut to SHORT_HASH characters on a button that copies it whole.
A list of accepted values (Recalbox) shows its first and how many more."""
from hashing import parse_hash_list # noqa: PLC0415
values = parse_hash_list(value)
if not values:
return ""
first = html.escape(values[0])
more = f" +{len(values) - 1}" if len(values) > 1 else ""
return (f'<button type="button" class="rb-copy" data-copy="{first}" '
f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}">'
f'{first[:SHORT_HASH]}</button>{more}')
def _cell(text: str) -> str:
"""Text for one cell of a Markdown table: a pipe would end the cell."""
return text.replace("|", "\\|")
_MARKDOWN_PUNCTUATION = re.compile(r"([\\`*_\[\]~])")
_CODE_LINK = re.compile(r"\[`([^`]+)`\]\(")
_SHORT_SHA1 = re.compile(r"\(sha1 `([0-9a-f]+)`\)")
def _plain(text: str) -> str:
"""Text written as itself in Markdown: no emphasis, link or tag."""
return _MARKDOWN_PUNCTUATION.sub(r"\\\1", html.escape(text, quote=False))
def _uncoded(text: str) -> str:
"""A linked path as plain link text, a short sha1 as plain text.
Each code element is one more element of the page. With one in every
name, path and reference, Batocera's page measured 10,479 elements, over
validate_site's budget of 10,000. The link and the value say the same
without it.
"""
text = _CODE_LINK.sub(lambda m: f"[{_plain(m[1])}](", text)
return _SHORT_SHA1.sub(r"(sha1 \1)", text)
def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
launched: list[str] | None = None) -> str:
"""One file of a platform's list as a table row: its name, where it goes,
its status and a short hash at a glance. The details hold every hash the
platform declares, the size, the copy that serves it and its readers.
"""
fname = f["name"]
hashes = {
"sha1": cfg_entry.get("sha1", f.get("sha1", "")),
"md5": cfg_entry.get("md5", f.get("expected_md5", "")),
# The crc32 is what System.dat and clrmamepro print. RetroDECK
# declares sha256 too (dsifirmware.bin).
"crc32": str(cfg_entry.get("crc32") or ""),
"sha256": str(cfg_entry.get("sha256") or ""),
}
size = cfg_entry.get("size", f.get("size", 0))
shown = next((alg for alg in (hash_type, *_ROW_HASHES) if alg in hashes and hashes[alg]), "")
facts: list[str] = []
# One code element per value: Recalbox lists accepted md5s joined by
# commas, and each is a value a reader compares a dump with.
facts.extend(f"{alg.upper()}: {_hash_spans(hashes[alg])}"
for alg in ("sha1", "md5", "crc32", "sha256") if hashes[alg])
# The size closes the hash lines: a line of its own was one more
# element per row.
if size and facts:
facts[-1] += f" ({_fmt_size(size)})"
elif size:
facts.append(f"Size: {_fmt_size(size)}")
if f.get("status") == "untested" and f.get("reason"):
facts.append(f"Not confirmed: {f['reason']}")
# Not every entry is firmware: the platform may list a game's data or a
# file no core it runs opens.
kind = {"game_data": "Game data, not firmware",
"unread": "No core the platform runs opens it"}.get(f.get("category", ""))
if kind:
facts.append(kind)
served = _served_line(f)
if served:
facts.append(_uncoded(served))
if f.get("category_evidence"):
facts.append(_uncoded(_category_evidence_md(f["category_evidence"], launched)))
# Where the file goes: three GameCube IPL.bin differ only by GC/USA,
# GC/EUR and GC/JAP. A destination that ends with the name shows its
# folder only, the name being in the cell before it. Both are plain
# text set in the code font by the table's style: a code element in
# each cell was two more elements per row.
destination = str(cfg_entry.get("destination") or "")
if destination == fname or destination.endswith("/" + fname):
destination = destination[:len(destination) - len(fname)]
summary = f'<summary aria-label="Details of {html.escape(fname)}">Details</summary>'
details = (f'<details class="rb-row-details">{summary}{"<br>".join(facts)}</details>'
if facts else "")
cells = [
_plain(fname),
_plain(destination),
_status_chip(f),
_copy_hash(shown, hashes[shown]) if shown else "",
details,
]
return "| " + " | ".join(_cell(cell) for cell in cells) + " |"
def _render_platform_systems(
by_system: dict, config_files: dict, emulator_files: dict,
platform: tuple[str, str] | None = None, moved: dict[str, str] | None = None,
@@ -3457,81 +3593,20 @@ def _render_platform_systems(
body.append(f"Emulators: {emu_links}")
body.append("")
# File listing. The sort is stable, so same-named entries keep the
# order verify reported them in, which is the declaration order.
# One row per file, the evidence in its details. The sort is stable,
# so same-named entries keep the order verify reported them in,
# which is the declaration order.
body.extend(PLATFORM_FILE_HEADER)
seen_names: dict[str, int] = {}
for f in sorted(files, key=lambda x: x["name"]):
status = f["status"]
fname = f["name"]
declared = config_files.get((sys_id, fname), [])
nth = seen_names.get(fname, 0)
seen_names[fname] = nth + 1
cfg_entry = declared[nth] if nth < len(declared) else {}
sha1 = cfg_entry.get("sha1", f.get("sha1", ""))
md5 = cfg_entry.get("md5", f.get("expected_md5", ""))
size = cfg_entry.get("size", f.get("size", 0))
if status == "ok":
status_display = "OK"
elif status == "untested":
reason = f.get("reason", "")
status_display = f"untested: {reason}" if reason else "untested"
elif status == "missing":
status_display = "**missing**"
else:
status_display = status
# Not every entry is firmware: the platform may list a game's data
# or a file no core it runs opens.
kind = {"game_data": "game data",
"unread": "no core the platform runs opens it"}.get(f.get("category", ""))
if kind:
status_display += f", {kind}"
size_str = _fmt_size(size) if size else ""
details = [status_display]
if size_str:
details.append(size_str)
# Where the file goes, when its name alone does not say: three
# GameCube IPL.bin differ only by GC/USA, GC/EUR and GC/JAP.
destination = str(cfg_entry.get("destination") or "")
where = f" at `{destination}`" if destination and destination != fname else ""
body.append(f"- `{fname}`{where} - {', '.join(details)}")
item_line = len(body) - 1
facts: list[str] = []
# Every hash the platform declares, whole: the crc32 is what
# System.dat and clrmamepro print, and pasting it found nothing
# for a pinned file the collection holds under other bytes.
crc32 = str(cfg_entry.get("crc32") or "")
# RetroDECK declares sha256 too: dsifirmware.bin's was in the CSV
# exports only, and pasting it into the search found nothing.
sha256 = str(cfg_entry.get("sha256") or "")
if sha1 or md5 or crc32 or sha256:
hash_parts = []
# One code span per value: Recalbox lists accepted md5s joined
# by commas, and the search indexed the joined run as a single
# word, so the second and later values were never found.
if sha1:
hash_parts.append(f"SHA1: {_hash_spans(sha1)}")
if md5:
hash_parts.append(f"MD5: {_hash_spans(md5)}")
if crc32:
hash_parts.append(f"CRC32: `{crc32}`")
if sha256:
hash_parts.append(f"SHA256: `{sha256}`")
facts.append(" | ".join(hash_parts))
served = _served_line(f)
if served:
facts.append(served)
if f.get("category_evidence"):
facts.append(_category_evidence_md(
f["category_evidence"], launched.get(fname.rsplit("/", 1)[-1].casefold())))
# One line each, hashes, the copy that serves it, its readers: as
# continuation lines they ran into one paragraph.
if facts:
body[item_line] += "\\"
body.extend(f" {fact}" + ("\\" if n < len(facts) - 1 else "")
for n, fact in enumerate(facts))
body.append(_platform_file_row(
f, cfg_entry, str((config or {}).get("hash_type") or ""),
launched.get(fname.rsplit("/", 1)[-1].casefold())))
# Measured as the Markdown the split was tuned on, a collapsible
# block indented by four, so both engines split the same systems.
@@ -3591,15 +3666,18 @@ def _render_platform_systems(
*([""] + [", ".join(
f"[part {i}]({other}.md)" if other != stem else f"part {i}"
for i, other in enumerate(stems, 1))] if len(runs) > 1 else []),
"", "## Files", "",
"", "## Files", "", *markup.details("note", ROLE_LEGEND_TITLE, [ROLE_LEGEND]),
*search_excluded(run),
"", _date_line(),
]) + "\n"
where = (f"[a page of their own]({stems[0]}.md)" if len(stems) == 1 else
"pages of their own: " + ", ".join(
f"[part {i}]({stem}.md)" for i, stem in enumerate(stems, 1)))
# The count and status a folded system shows in its summary,
# linked to the page that lists the files.
tag = "OK" if ok_count == total else _count(total - ok_count, "issue")
count = f"{_count(total, 'file')} ({tag})"
lines.extend([
f"{_count(total, 'file')}, {ok_count} verified: listed on {where}.", "",
f"[{count}]({stems[0]}.md)" if len(stems) == 1 else f"{count}: " + ", ".join(
f"[part {i}]({stem}.md)" for i, stem in enumerate(stems, 1)),
"",
])
continue
# The section's heading stays searchable, its file list with every
@@ -3635,6 +3713,40 @@ def _site_profiles() -> dict[str, dict]:
return load_emulator_profiles(str(_REPO_ROOT / "emulators"))
# Whose code a source reference cites, in one word: a platform page names
# each role once in ROLE_LEGEND. Written after every reference, "(emulator
# code, libretro port)" ran through Batocera's 14,000 words.
ROLE_WORDS = {
"libretro glue": "glue",
"emulator code, libretro port": "port",
"upstream emulator": "upstream",
"this build": "build",
}
ROLE_LEGEND_TITLE = "Source reference words"
ROLE_LEGEND = (
"In a file's details, a word before its source references says whose code they are. "
"**glue** means libretro glue, **port** means emulator code in the libretro port, "
"**upstream** means the upstream emulator and **build** means this build's own code."
)
def _refs_by_role(profile: dict, value: str | dict | None) -> str:
"""A source_ref's parts grouped by whose code they cite, each role named
once by its ROLE_WORDS word: `glue a.c:1, b.c:2 and port c.c:3`."""
from sitesources import source_ref_items
texts = []
for label, items in source_ref_items(profile, value):
by_role: dict[str, list[str]] = {}
for part, role in items:
by_role.setdefault(role, []).append(part)
text = " and ".join(
(f"{ROLE_WORDS.get(role, role)} " if role else "") + ", ".join(parts)
for role, parts in by_role.items())
texts.append(f"**{label}:** {text}" if label else text)
return ", ".join(texts)
def _category_evidence_md(evidence: dict, launched: list[str] | None = None) -> str:
"""What establishes an entry's category, as the page shows it.
@@ -3650,7 +3762,7 @@ def _category_evidence_md(evidence: dict, launched: list[str] | None = None) ->
# plain code with the role word ("source") left in front.
profile = profiles.get(reader["emulator"], {})
refs = [
(f"`{e['name']}` at {_source_ref_markdown(profile, e['source_ref'])}"
(f"`{e['name']}` at {_refs_by_role(profile, e['source_ref'])}"
if e.get("source_ref") else f"`{e['name']}`")
+ (" (by name)" if e.get("by") == "name" else "")
for e in reader.get("entries", [])
@@ -4173,8 +4285,7 @@ def generate_platform_page(
"",
level_label("reference"),
"",
"For checking the work: the counts, where the list comes from, and "
"every file with its hashes.",
"For checking the work: counts, the list's source and each file's hashes.",
"",
*(line for note in (GUIDES[name].reference_notes if name in GUIDES else ())
for line in (note, "")),
@@ -4305,6 +4416,8 @@ def generate_platform_page(
f"| <span data-pagefind-ignore>{ok_count}/{total}</span> | {status} | {emu_str} |"
)
lines.append("")
if by_system:
lines.extend(markup.details("note", ROLE_LEGEND_TITLE, [ROLE_LEGEND]))
lines.extend(
_render_platform_systems(by_system, config_files, emulator_files,
+3 -1
View File
@@ -340,7 +340,9 @@ class APlatformPageSaysWhereEachFileGoes(unittest.TestCase):
lines = generate_site._render_platform_systems(
{"nintendo-gamecube": files}, {("nintendo-gamecube", "IPL.bin"): declared}, {})
for region in regions:
self.assertIn(f"- `IPL.bin` at `dolphin-emu/Sys/GC/{region}/IPL.bin` - OK",
# The folder: the name is in the cell before it.
self.assertIn(f"| IPL.bin | dolphin-emu/Sys/GC/{region}/ | "
'<span class="rb-badge rb-badge-success">OK</span> |',
"\n".join(lines))
+244
View File
@@ -0,0 +1,244 @@
"""A platform page lists one table row per file, the evidence on demand.
Batocera, AmberELEC and RomM ran to 14,000 to 16,000 words: every file
spelled its full MD5, the copy serving it and each source reference of
each reader, with "(emulator code, libretro port)" after every one. A row
now shows the name, the folder, the status and a short hash; its details
hold every hash, the served copy and the readers, the role of each
reference named by one word that a legend defines once.
"""
from __future__ import annotations
import re
import sys
import unittest
from html.parser import HTMLParser
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import generate_site # noqa: E402
_SKIPPED = frozenset({"script", "style", "template"})
_VOID = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "source", "track", "wbr"})
class _Words(HTMLParser):
"""Words in <main>: all of them, and those a reader sees with every
details block closed (a closed block shows its summary only)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.stack: list[tuple[str, bool]] = []
self.visible = 0
self.total = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag not in _VOID:
closed = tag == "details" and "open" not in dict(attrs)
self.stack.append((tag, closed))
def handle_endtag(self, tag: str) -> None:
for index in range(len(self.stack) - 1, -1, -1):
if self.stack[index][0] == tag:
del self.stack[index:]
return
def _counted(self) -> bool:
names = {name for name, _ in self.stack}
return "main" in names and not names & _SKIPPED
def _shown(self) -> bool:
for index, (_name, closed) in enumerate(self.stack):
if closed:
rest = self.stack[index + 1:]
if not rest or rest[0][0] != "summary":
return False
return True
def handle_data(self, data: str) -> None:
if not self._counted():
return
count = len(data.split())
self.total += count
if self._shown():
self.visible += count
def page_words(html: str) -> tuple[int, int]:
"""(visible words, all words) in a page's <main>."""
parser = _Words()
parser.feed(html)
return parser.visible, parser.total
class VisibleWordsAreCounted(unittest.TestCase):
def test_a_closed_block_shows_its_summary_only(self):
page = ("<main><p>one two</p><details><summary>three</summary>four five"
"<details open><summary>six</summary>seven</details></details>"
"<details open><summary>eight</summary>nine</details>"
"<script>no</script></main><p>outside</p>")
self.assertEqual(page_words(page), (5, 9))
class ARowPerFile(unittest.TestCase):
def _row(self, f: dict, entry: dict, hash_type: str = "md5") -> str:
return generate_site._platform_file_row(f, entry, hash_type)
def test_the_row_at_a_glance(self):
md5, sha1 = "a" * 32, "b" * 40
row = self._row({"name": "bios_a.bin", "status": "ok", "size": 2048},
{"name": "bios_a.bin", "md5": md5, "sha1": sha1, "crc32": "0badf00d",
"destination": "bios/bios_a.bin"})
cells = re.split(r"(?<!\\)\|", row)[1:-1]
self.assertEqual(len(cells), len(generate_site.PLATFORM_FILE_HEADER[0].split("|")) - 2)
name, folder, status, short, details = (cell.strip() for cell in cells)
self.assertEqual(name, r"bios\_a.bin")
# The folder: the name is in the cell before it.
self.assertEqual(folder, "bios/")
self.assertEqual(status, '<span class="rb-badge rb-badge-success">OK</span>')
# The hash the platform checks, short, on a button that copies it whole.
self.assertIn(f'data-copy="{md5}"', short)
self.assertIn(f">{md5[:8]}</button>", short)
# The accessible name holds the visible text (WCAG 2.5.3).
self.assertIn(f'aria-label="Copy MD5 {md5[:8]}"', short)
# Every hash whole in the details, with the size.
for value in (md5, sha1, "0badf00d"):
self.assertIn(value, details)
self.assertIn("2.0 KB", details)
self.assertTrue(details.startswith('<details class="rb-row-details">'
'<summary aria-label="Details of bios_a.bin">Details</summary>'))
def test_status_words(self):
cases = {
("missing", ""): ("Missing", "danger"),
("untested", "declared hash contradicted by the local file"): ("Wrong hash", "warning"),
("untested", "expected 1234... got 5678..."): ("Wrong hash", "warning"),
("untested", "bios/x read error"): ("Untested", "warning"),
}
for (status, reason), (word, tone) in cases.items():
row = self._row({"name": "x", "status": status, "reason": reason}, {"md5": "c" * 32})
self.assertIn(f'<span class="rb-badge rb-badge-{tone}">{word}</span>', row)
if reason:
self.assertIn(f"Not confirmed: {reason}", row)
def test_accepted_values_all_in_the_details(self):
values = ["1" * 32, "2" * 32, "3" * 32]
row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)})
self.assertIn(f'data-copy="{values[0]}"', row)
self.assertIn("</button> +2", row)
for value in values:
self.assertIn(f'<code class="rb-hash">{value}</code>', row)
def test_a_destination_under_another_name_stays_whole(self):
row = self._row({"name": "a.bin", "status": "ok"}, {"destination": "x/b.bin"})
self.assertIn("| a.bin | x/b.bin |", row)
def test_a_pipe_does_not_end_the_cell(self):
row = self._row({"name": "a|b", "status": "ok"}, {})
self.assertIn(r"a\|b", row)
self.assertEqual(len(re.split(r"(?<!\\)\|", row)), 7)
class ReferencesNameTheirRoleOnce(unittest.TestCase):
PROFILE = {"type": "libretro", "source": "https://example.org/port",
"upstream": "https://example.org/emu"}
REFS = "source libretro/libretro.c:1, source src/a.c:2, source src/b.c:3, upstream src/c.c:4"
def test_grouped_by_role(self):
text = generate_site._refs_by_role(self.PROFILE, self.REFS)
self.assertEqual(text, "glue `libretro/libretro.c:1` and port `src/a.c:2`, `src/b.c:3` "
"and upstream `src/c.c:4`")
for word in generate_site.ROLE_WORDS.values():
self.assertIn(f"**{word}**", generate_site.ROLE_LEGEND)
def test_other_pages_keep_the_role_after_each_reference(self):
text = generate_site._source_ref_markdown(self.PROFILE, self.REFS)
self.assertEqual(text.count("(emulator code, libretro port)"), 2)
self.assertIn("`src/c.c:4` (upstream emulator)", text)
def _unescaped(text: str) -> str:
return re.sub(r"\\([\\`*_\[\]~|])", r"\1", text)
@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json absent")
class NothingIsLost(unittest.TestCase):
"""Every fact a file row carried is on the page or its parts: each
declared hash whole, the served copy, every reader's references."""
@classmethod
def setUpClass(cls) -> None:
from common import load_database
from generate_readme import compute_coverage
db = load_database(str(REPO_ROOT / "database.json"))
cls.pages = {}
for name in ("batocera", "retroarch"):
cov = compute_coverage(name, str(REPO_ROOT / "platforms"), db)
moved: dict[str, str] = {}
page = generate_site.generate_platform_page(name, cov, {}, {}, moved)
cls.pages[name] = (cov, page, moved)
def test_every_fact_is_there(self):
for name, (cov, page, moved) in self.pages.items():
text = _unescaped("\n".join([page, *moved.values()]))
systems = {d.get("system") for d in cov["details"]}
for sys_id, system in cov["config"].get("systems", {}).items():
if sys_id not in systems:
continue
for entry in system.get("files", []):
for algorithm in ("sha1", "md5", "crc32", "sha256"):
for value in re.split(r"[,\s]+", str(entry.get(algorithm) or "")):
if value:
self.assertIn(value, text, (name, entry.get("name"), algorithm))
for detail in cov["details"]:
self.assertIn(detail["name"], text)
if detail.get("path") and detail.get("status") != "missing":
self.assertIn(detail["path"], text, (name, detail["name"]))
readers = (detail.get("category_evidence") or {}).get("read_by", [])
# Cores of other systems that share the name are named, not
# cited, when a core of this system reads the file.
running = [reader for reader in readers if reader.get("runs_system")]
for reader in running or readers:
for url in re.findall(r"\]\((https://[^)]+)\)", generate_site._category_evidence_md(
{"read_by": [reader]})):
self.assertIn(url, text, (name, detail["name"], reader["emulator"]))
def test_the_role_is_a_word_defined_once(self):
for name, (_cov, page, moved) in self.pages.items():
for text in (page, *moved.values()):
self.assertNotIn("(emulator code, libretro port)", text, name)
self.assertEqual(text.count(generate_site.ROLE_LEGEND), 1, name)
# Visible words (outside closed details) measured on the built site,
# 2026-10-27. A ceiling is the measured value plus 5 %, never above the
# value before the rows were tables (Batocera 2,433, AmberELEC 1,301, RomM
# 1,607, RetroArch 2,207, the largest main page 2,459, the largest part
# 19,127: RetroDECK's arcade list).
PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192}
MAIN_PAGE_CEILING = 2459
PART_PAGE_CEILING = 2306
_SITE = REPO_ROOT / "website" / "dist" / "platforms"
@unittest.skipUnless(_SITE.is_dir(), "website/dist not built")
class VisibleWordsStayUnderTheirCeiling(unittest.TestCase):
def test_platform_pages(self):
from common import list_registered_platforms
mains = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
for page in sorted(_SITE.glob("*/index.html")):
name = page.parent.name
visible, _total = page_words(page.read_text(encoding="utf-8"))
ceiling = PAGE_CEILINGS.get(name, MAIN_PAGE_CEILING if name in mains else PART_PAGE_CEILING)
self.assertLessEqual(visible, ceiling, name)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -377,7 +377,7 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase):
path.read_text(encoding="utf-8") for path in sitepages.pages()
if sitepages.name(path) == "platforms/retroarch.md"
or sitepages.name(path).startswith("platforms/retroarch-"))
self.assertIn("CRC32: `91e93107`", text)
self.assertIn('CRC32: <code class="rb-hash">91e93107</code>', text)
class PlainHouseStyle(unittest.TestCase):
+32 -2
View File
@@ -1,8 +1,8 @@
// Progressive enhancement of the generated pages: table captions, filters
// and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file
// hashed on this device), moved cards, a hash typed in the search, glossary
// terms on a tap, the OS tab of the visitor's system, the current page in
// the sidebar, and the 404 page's hints.
// terms on a tap, hash copy buttons, the OS tab of the visitor's system, the
// current page in the sidebar, and the 404 page's hints.
// Which tables stack on a phone is decided when the page is built
// (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint.
@@ -931,6 +931,35 @@ const glossaryTerms = () => {
document.addEventListener('click', close);
};
// A hash cut short on a button (button.rb-copy): a click copies the whole
// value from data-copy, says so to a screen reader, and marks the button
// for two seconds. The full value is also in the row's details, so a
// browser without the clipboard API loses nothing.
const copyButtons = () => {
if (!document.querySelector('button.rb-copy')) return;
let status: HTMLElement | null = null;
document.addEventListener('click', async (event) => {
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('button.rb-copy');
const value = button?.dataset.copy;
if (!button || !value || !navigator.clipboard) return;
try {
await navigator.clipboard.writeText(value);
} catch {
// Clipboard refused (no permission, insecure context): nothing copied.
return;
}
if (!status) {
status = document.createElement('p');
status.className = 'rb-visually-hidden';
status.setAttribute('role', 'status');
document.body.append(status);
}
status.textContent = `Copied ${value}`;
button.dataset.copied = '';
setTimeout(() => delete button.dataset.copied, 2000);
});
};
// The 404 page: a search button, and the list a moved page belongs to.
const notFound = () => {
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
@@ -952,6 +981,7 @@ enhanceTables();
breakPaths();
hashSearch();
glossaryTerms();
copyButtons();
osTabs();
showCurrentPage();
notFound();
+61
View File
@@ -551,6 +551,67 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
/* A platform file row: a short hash on a button that copies it whole
(site.ts), and the row's evidence in a details cell. */
.sl-markdown-content button.rb-copy {
display: inline-flex;
align-items: center;
gap: 0.3em;
min-height: 24px;
padding: 0 0.35em;
border: 1px solid var(--rb-border-strong);
border-radius: 3px;
background: var(--rb-surface);
color: var(--sl-color-white);
font: 0.8125rem/1.4 var(--sl-font-mono);
cursor: pointer;
white-space: nowrap;
}
.sl-markdown-content button.rb-copy::after {
content: '';
width: 0.9em;
height: 0.9em;
background: var(--rb-text-secondary);
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 1h8a2 2 0 0 1 2 2v8h-2V3H5zM3 5h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v7h7V7z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.sl-markdown-content button.rb-copy[data-copied]::after {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 11.2 2.8 8 1.4 9.4 6 14l8.6-8.6-1.4-1.4z'/%3E%3C/svg%3E");
background: var(--rb-ok-fg);
}
.sl-markdown-content button.rb-copy:hover { border-color: var(--rb-link); }
/* The file name and its destination, written as plain text to spare an
element per cell. */
.sl-markdown-content table:has(:is(button.rb-copy, details.rb-row-details)) td:is(:nth-child(1), :nth-child(2)) {
font-family: var(--sl-font-mono);
font-size: 0.8125rem;
overflow-wrap: anywhere;
}
.sl-markdown-content details.rb-row-details {
margin: 0;
padding: 0;
border: 0;
}
.sl-markdown-content details.rb-row-details > summary {
min-height: 24px;
color: var(--rb-link);
font-size: 0.875rem;
cursor: pointer;
}
.sl-markdown-content details.rb-row-details[open] {
font-size: 0.8125rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
/* Open beside four narrow columns, the evidence keeps a readable width:
the other cells give up their side padding for it. */
@media (min-width: 50rem) {
.sl-markdown-content table:has(details.rb-row-details) :is(th, td) { padding-inline: 0.5rem; }
/* A long name or path wraps before the table outgrows the column. */
.sl-markdown-content table:has(details.rb-row-details) td:first-child { min-width: 9rem; }
.sl-markdown-content table:has(details.rb-row-details) td:nth-child(2) { min-width: 4rem; }
.sl-markdown-content details.rb-row-details[open] { min-width: 15rem; }
}
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
Caution and danger keep their colour, as a 1 px border all round. */
.sl-markdown-content .starlight-aside {
+1 -1
View File
@@ -61,7 +61,7 @@ Every module in `tests/`, by the area it guards.
| Real-condition campaign | synthetic profiles, records and content folders, a fake host for load, disk, clock and commands, and whole passes against a libretro core built from C when a compiler and strace exist | `test_campaign` |
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
| 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_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `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_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `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_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `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_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `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_density`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture