feat: round 3 components

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 11:02:27 +02:00
commit a152b6d5d9
39 files changed
+1128 -140

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+12 -12
View File
@@ -2,7 +2,7 @@
"note": "Each platform's own logo, owned by its project and shown only to name that platform. Copied once by scripts/fetch_site_images.py.",
"images": [
{
"file": "amberelec.png",
"file": "amberelec.webp",
"platform": "amberelec",
"source": "https://avatars.githubusercontent.com/u/102820909",
"owner": "AmberELEC",
@@ -16,28 +16,28 @@
"licence": "repository licence MIT"
},
{
"file": "bizhawk.png",
"file": "bizhawk.webp",
"platform": "bizhawk",
"source": "https://avatars.githubusercontent.com/u/11743303",
"owner": "TASVideos",
"licence": "GitHub organisation avatar of TASEmulators"
},
{
"file": "emudeck.png",
"file": "emudeck.webp",
"platform": "emudeck",
"source": "https://raw.githubusercontent.com/dragoonDorise/EmuDeck/main/icons/EmuDeck.png",
"owner": "EmuDeck",
"licence": "repository licence GPL-3.0"
},
{
"file": "emuelec.png",
"file": "emuelec.webp",
"platform": "emuelec",
"source": "https://avatars.githubusercontent.com/u/57574835",
"owner": "EmuELEC",
"licence": "GitHub organisation avatar of EmuELEC"
},
{
"file": "knulli.png",
"file": "knulli.webp",
"platform": "knulli",
"source": "https://avatars.githubusercontent.com/u/166268572",
"owner": "KNULLI",
@@ -51,21 +51,21 @@
"licence": "file from libretro/retroarch-assets, CC-BY-4.0"
},
{
"file": "minui.png",
"file": "minui.webp",
"platform": "minui",
"source": "https://avatars.githubusercontent.com/u/225589",
"owner": "Shaun Inman",
"licence": "GitHub avatar of shauninman, the author of MinUI"
},
{
"file": "misterfpga.png",
"file": "misterfpga.webp",
"platform": "misterfpga",
"source": "https://avatars.githubusercontent.com/u/29345272",
"owner": "MiSTer FPGA",
"licence": "GitHub organisation avatar of MiSTer-devel"
},
{
"file": "nextui.png",
"file": "nextui.webp",
"platform": "nextui",
"source": "https://avatars.githubusercontent.com/u/202386615",
"owner": "LoveRetro",
@@ -86,7 +86,7 @@
"licence": "repository licence GPL-3.0"
},
{
"file": "retrobat.png",
"file": "retrobat.webp",
"platform": "retrobat",
"source": "https://raw.githubusercontent.com/RetroBat-Official/retrobat/main/system/resources/retrobat_logo_notext.png",
"owner": "RetroBat",
@@ -100,21 +100,21 @@
"licence": "repository licence GPL-3.0"
},
{
"file": "retropie.png",
"file": "retropie.webp",
"platform": "retropie",
"source": "https://avatars.githubusercontent.com/u/11378204",
"owner": "RetroPie",
"licence": "GitHub organisation avatar of RetroPie"
},
{
"file": "rocknix.png",
"file": "rocknix.webp",
"platform": "rocknix",
"source": "https://avatars.githubusercontent.com/u/163176886",
"owner": "ROCKNIX",
"licence": "GitHub organisation avatar of ROCKNIX"
},
{
"file": "romm.png",
"file": "romm.webp",
"platform": "romm",
"source": "https://avatars.githubusercontent.com/u/164058018",
"owner": "RomM",
+12 -7
View File
@@ -8,8 +8,9 @@ into ``docs_assets/logos/`` and ``docs_assets/system-icons/``, which are
committed, and records where each came from in a ``sources.json`` beside
them. The site build only reads those copies (``sitediagrams.localize_images``).
Raster images are normalised to a square PNG (96 px for logos, 48 px for
system icons) so every tile has the same weight, and SVG is kept as published.
Raster logos are normalised to an 80 px square WebP, twice the 40 px they
are shown at, and system icons to a 48 px PNG, so every tile has the same
weight. SVG is kept as published.
Normalising needs Pillow. This is a maintenance tool run by hand when a
logo changes, not a build step: the build stays on the standard library
and PyYAML.
@@ -36,7 +37,7 @@ sys.path.insert(0, str(ROOT / "scripts"))
LOGO_DIR = ROOT / "docs_assets" / "logos"
ICON_DIR = ROOT / "docs_assets" / "system-icons"
LOGO_SIZE = 96
LOGO_SIZE = 80
ICON_SIZE = 48
AVATAR_HOST = "avatars.githubusercontent.com"
USER_AGENT = "retrobios-site-images"
@@ -47,7 +48,7 @@ def fetch(url: str) -> bytes:
return response.read()
def square_png(data: bytes, size: int) -> bytes:
def square_image(data: bytes, size: int, kind: str = "PNG") -> bytes:
try:
from PIL import Image
except ImportError as exc:
@@ -57,7 +58,11 @@ def square_png(data: bytes, size: int) -> bytes:
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
tile.paste(image, ((size - image.width) // 2, (size - image.height) // 2), image)
out = io.BytesIO()
tile.save(out, "PNG", optimize=True)
if kind == "WEBP":
# Quality 90: half the bytes of lossless, edges still clean at 40 px.
tile.save(out, "WEBP", quality=90, method=6)
else:
tile.save(out, "PNG", optimize=True)
return out.getvalue()
@@ -98,7 +103,7 @@ def fetch_logos() -> None:
if is_svg(data):
file_name, payload = f"{name}.svg", safe_svg(data, url)
else:
file_name, payload = f"{name}.png", square_png(data, LOGO_SIZE)
file_name, payload = f"{name}.webp", square_image(data, LOGO_SIZE, "WEBP")
for stale in LOGO_DIR.glob(f"{name}.*"):
if stale.name != file_name:
stale.unlink()
@@ -141,7 +146,7 @@ def fetch_icons() -> None:
continue
raise
file_name = f"{name}.png"
(ICON_DIR / file_name).write_bytes(square_png(data, ICON_SIZE))
(ICON_DIR / file_name).write_bytes(square_image(data, ICON_SIZE))
entries.append({"file": file_name, "source": url})
print(f" {file_name}")
write_manifest(
+10 -8
View File
@@ -84,8 +84,10 @@ class TablesStackWhenBuilt(unittest.TestCase):
"action": {"labels": ["A", "B", "C"], "rows": 40, "action": True},
"pair": {"labels": ["Where", "BIOS folder"], "rows": 3, "action": False},
"short-start": {"labels": ["System", "Runs", "BIOS folder"], "rows": 8, "action": False, "level": "start"},
"long-setup": {"labels": ["System", "Files", "Status", "Emulators"], "rows": 97, "action": False, "level": "setup"},
"long-setup": {"labels": ["System", "Files", "Status"], "rows": 97, "action": False, "level": "setup"},
"reference": {"labels": ["A", "B", "C"], "rows": 5, "action": False, "level": "reference"},
# Four columns do not fit a phone column, on any page (round 3).
"wide": {"labels": ["File", "Region", "Required", "Hash"], "rows": 105, "action": False, "level": "reference"},
}
script = (
f"import {{ stackKind }} from {json.dumps(PLUGIN.as_uri())};"
@@ -98,7 +100,7 @@ class TablesStackWhenBuilt(unittest.TestCase):
)
self.assertEqual(json.loads(result.stdout), {
"zip-header": "true", "action": "true", "pair": "kv",
"short-start": "true", "long-setup": None, "reference": None,
"short-start": "true", "long-setup": None, "reference": None, "wide": "true",
})
@@ -142,7 +144,7 @@ class SearchAnswersAHashAndShowsNoFileDump(unittest.TestCase):
self.assertIn("Look up this hash", source)
self.assertIn("files/?hash=${value}", source)
self.assertIn("endsWith('#_top')", source)
self.assertIn("tag.dataset.pagefindUiMeta === 'files'", source)
self.assertIn("UNSHOWN_FIELDS.has(tag.dataset.pagefindUiMeta ?? '')", source)
# Pagefind's display rules beat the hidden attribute without this.
self.assertIn("#starlight__search [hidden] { display: none !important; }", _read(COMPONENTS / "Header.astro"))
@@ -167,8 +169,8 @@ def _title_block(html: str) -> str:
class LevelBadgeOnlyWhereItAddsSomething(unittest.TestCase):
def test_badge_follows_the_crumb(self):
# A tab root has no crumb and keeps its badge.
self.assertIn('class="rb-level rb-level-setup', _title_block(_built("platforms")))
# A tab root: the header tab already says it (round 3, 2.1).
self.assertNotIn('class="rb-level ', _title_block(_built("platforms")))
# Under the "Start here" or "Your setup" crumb the badge repeated it.
for page in ("which-pack", "platforms/batocera"):
with self.subTest(page=page):
@@ -181,7 +183,7 @@ class TitleLogo(unittest.TestCase):
def test_front_matter_field(self):
self.assertIn("logo: z.string().optional()", _read(WEBSITE / "src" / "content.config.ts"))
title = _read(COMPONENTS / "PageTitle.astro")
self.assertIn('<img src={logoSrc} alt="" height="40" />', title)
self.assertIn('<img class="rb-title-mark" src={logoSrc} alt="" width={MARK} height={MARK} />', title)
class FooterSaysWhoAndWhen(unittest.TestCase):
@@ -200,10 +202,10 @@ class FooterSaysWhoAndWhen(unittest.TestCase):
class FindAFileGivesAVerdict(unittest.TestCase):
def test_lookup_renderer(self):
source = _read(SITE_TS)
self.assertIn("', held in the collection at '", source)
self.assertIn("'Held in the collection at '", source)
self.assertIn("['Setup', 'Name as declared', 'Destination', 'Hash matched', 'Required']", source)
self.assertIn("api/v1/platforms/${item.owner}.json", source)
self.assertIn("api/v1/systems/${slug}.json", source)
self.assertIn("api/v1/systems/${words.slice(0, size).join('-')}.json", source)
style = _read(COMPONENTS / "MarkdownContent.astro")
self.assertIn("max-width: 40rem;", style)
self.assertIn("width: 100%;", style)
+1 -1
View File
@@ -192,7 +192,7 @@ class TheIntegrationItems(unittest.TestCase):
with self.subTest(platform=key):
block = re.search(r'<div class="rb-title-logo[^"]*">.*?</div>', html, re.S)
self.assertIsNotNone(block)
self.assertRegex(block[0], r'<img src="/retrobios/assets/logos/')
self.assertRegex(block[0], r'<img\b[^>]*\bsrc="/retrobios/assets/logos/')
self.assertNotIn("align=", html.split("<main", 1)[-1])
def test_every_emulator_letter_lands_on_its_section(self):
+390
View File
@@ -0,0 +1,390 @@
"""Round 3, package 2: components and runtime.
Source checks always run. Checks on the built site read website/dist and
skip when no build is there. The hash functions and the table rule run
under node and skip without it.
"""
from __future__ import annotations
import hashlib
import json
import re
import shutil
import struct
import subprocess
import unittest
import zlib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
WEBSITE = REPO_ROOT / "website"
DIST = WEBSITE / "dist"
COMPONENTS = WEBSITE / "src" / "components"
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
CONFIG = WEBSITE / "astro.config.mjs"
PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs"
UNBROKEN = WEBSITE / "src" / "plugins" / "ec-unbroken-tokens.mjs"
LOGOS = REPO_ROOT / "docs_assets" / "logos"
# Shown at 40 px in a tile and beside a setup's title: twice that for a
# sharp mark on a dense screen.
LOGO_PIXELS = 80
TAB_ROOTS = ("", "platforms", "explore", "wiki", "contributing")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _built(page: str) -> str:
path = DIST / page / "index.html"
if not path.is_file():
raise unittest.SkipTest(f"no built site at {path}")
return _read(path)
def _title_block(html: str) -> str:
"""What PageTitle draws: from the main column to the end of the h1."""
end = html.index("</h1>", html.index('id="_top"'))
return html[html.rfind("<main", 0, end):end]
def _tables(html: str) -> list[str]:
return re.findall(r"<table\b.*?</table>", html, re.S)
def _headers(table: str) -> list[str]:
head = re.search(r"<thead>(.*?)</thead>", table, re.S)
cells = re.findall(r"<th\b[^>]*>(.*?)</th>", head.group(1), re.S) if head else []
return [re.sub(r"<[^>]+>", "", cell).strip() for cell in cells]
def _node_module(script: str) -> str:
node = shutil.which("node")
if not node:
raise unittest.SkipTest("node is missing")
result = subprocess.run(
[node, "--input-type=module-typescript", "-e", script],
capture_output=True, text=True, check=True, cwd=WEBSITE,
)
return result.stdout
def _webp_size(data: bytes) -> tuple[int, int]:
"""Width and height from a WebP header (VP8, VP8L or VP8X)."""
if data[:4] != b"RIFF" or data[8:12] != b"WEBP":
raise ValueError("not a WebP file")
chunk = data[12:16]
if chunk == b"VP8 ":
width, height = struct.unpack("<HH", data[26:30])
return width & 0x3FFF, height & 0x3FFF
if chunk == b"VP8L":
bits = int.from_bytes(data[21:25], "little")
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
if chunk == b"VP8X":
return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
raise ValueError(f"unknown WebP chunk {chunk!r}")
class TitleLogoOnAPlate(unittest.TestCase):
"""The RetroArch and Batocera marks are black: beside the title they
vanished on the dark theme, and the image had no width (Lighthouse
unsized-images, V3, T-F3)."""
def test_the_component_draws_the_tile_square(self):
title = _read(COMPONENTS / "PageTitle.astro")
self.assertIn('width={MARK} height={MARK}', title)
self.assertIn("const MARK = 40;", title)
style = title[title.index(".rb-title-mark {"):]
style = style[:style.index("}")]
for rule in ("width: 40px;", "height: 40px;", "padding: 3px;", "border: 1px solid var(--rb-border);",
"border-radius: 6px;", "background: #fff;", "object-fit: contain;"):
with self.subTest(rule=rule):
self.assertIn(rule, style)
def test_every_setup_page_has_a_sized_mark(self):
index = json.loads(_read(WEBSITE / "public" / "data" / "picker" / "index.json"))
for entry in index:
with self.subTest(setup=entry["platform"]):
block = _title_block(_built(f"platforms/{entry['platform']}"))
self.assertIn('class="rb-title-mark', block)
for image in re.findall(r"<img\b[^>]*>", block):
self.assertIn('width="40"', image)
self.assertIn('height="40"', image)
def test_a_setup_with_no_logo_gets_its_initials(self):
block = _title_block(_built("platforms/myminui"))
self.assertNotIn("<img", block)
self.assertRegex(block, r"<svg class=\"rb-title-mark[^>]*>.*?>MM</text>")
class NoLevelBadgeOnATabRoot(unittest.TestCase):
def test_tab_roots(self):
for page in TAB_ROOTS:
with self.subTest(page=page or "home"):
self.assertNotIn('class="rb-level ', _title_block(_built(page)))
class FooterLicenceMatchesTheLicenceFile(unittest.TestCase):
"""The footer said MIT covers the tooling. LICENSE names the metadata too."""
def test_footer(self):
self.assertIn("metadata", _read(REPO_ROOT / "LICENSE").split("MIT License")[0])
html = _built("data")
footer = re.sub(r"<[^>]+>", "", html[html.index('class="rb-footer'):])
self.assertIn("MIT for the tooling and metadata.", footer)
class WideTablesStackAndFiltersAreBuilt(unittest.TestCase):
"""Level 2 and 3 tables overflowed a phone (emulator summary, gaps,
verification modes, emulators index, data: V5, J8). The filter box,
added after paint, pushed the table down: CLS 0.066 on
gaps-files-to-source (T-F10)."""
def test_every_wide_table_stacks(self):
for page in ("emulators/duckstation", "gaps", "gaps-files-to-source", "emulators", "data",
"wiki/verification-modes"):
html = (DIST / page / "index.html")
if not html.is_file():
continue
for table in _tables(_read(html)):
if len(_headers(table)) < 4:
continue
with self.subTest(page=page, header=_headers(table)):
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
def test_long_tables_get_their_labels_at_runtime(self):
"""Labels on every cell of a long stacked table were 122 KB of
emulators/mame and took it over the 600,000-byte page budget."""
self.assertIn("if (kind === 'kv' || rows.length >= RECORD_ROWS) return;", _read(PLUGIN))
source = _read(SITE_TS)
self.assertIn("const labelCells = (table: HTMLTableElement) => {", source)
self.assertIn("labelCells(table);", source)
html = _built("emulators/mame")
self.assertLess(len(html.encode()), 600_000)
def test_the_filter_box_is_in_the_html(self):
html = _built("gaps-files-to-source")
boxes = re.findall(r'<div class="rb-table-filter">(.*?)</div>\s*<table', html, re.S)
self.assertTrue(boxes)
for box in boxes:
self.assertRegex(box, r'<label for="rb-table-filter-\d+">Filter [^<]+</label>')
self.assertRegex(box, r'<input id="rb-table-filter-\d+" type="search"')
self.assertRegex(box, r'aria-live="polite">[\d,]+ rows</span>')
source = _read(SITE_TS)
self.assertNotIn("controls.className = 'rb-table-filter'", source)
self.assertIn("controls?.matches('.rb-table-filter')", source)
def test_every_long_table_has_its_box(self):
html = _built("systems")
for table in _tables(html):
rows = len(re.findall(r"<tr\b", table.split("<tbody>", 1)[-1]))
if rows < 20:
continue
start = html.index(table)
self.assertIn('class="rb-table-filter"', html[max(0, start - 600):start])
def test_the_placeholder_names_the_columns(self):
"""Every filter offered "Name, platform, system, hash...", over the
console list too, which has no hash (V small)."""
self.assertNotIn("Name, platform, system, hash", _read(SITE_TS))
cases = [["File", "Region", "Required", "Hash"], ["Console", "Files"], ["System", "MD5", "Size"]]
out = _node_module(
f"import {{ filterPlaceholder }} from {json.dumps(PLUGIN.as_uri())};"
f"console.log(JSON.stringify({json.dumps(cases)}.map(filterPlaceholder)));"
)
self.assertEqual(json.loads(out), ["File, region, required, hash...", "Console, files...", "System, MD5, size..."])
html = _built("systems")
self.assertRegex(html, r'placeholder="Console[^"]*\.\.\."')
self.assertNotRegex(html, r'placeholder="[^"]*hash[^"]*"')
class HashLookupGivesOneVerdict(unittest.TestCase):
"""The lookup read "24 matches", "Setups that list it (12)" over 17 rows,
and emulators by id. ?md5= showed the empty form (N8, J7)."""
def test_parameters(self):
source = _read(SITE_TS)
self.assertIn("const HASH_PARAMS = ['hash', 'md5', 'crc32', 'sha256'];", source)
self.assertIn("HASH_PARAMS.map((key) => params.get(key))", source)
def test_counts_say_what_they_count(self):
source = _read(SITE_TS)
self.assertNotIn("'match' : 'matches'", source)
self.assertIn("count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}", source)
self.assertIn("count(setups.size, 'setup lists', 'setups list')", source)
self.assertIn("'file in the collection', 'files in the collection'", source)
def test_emulators_by_name(self):
source = _read(SITE_TS)
self.assertIn("json(`api/v1/emulators/${owner}.json`)", source)
self.assertIn("profile.emulator || profile.display_name || owner", source)
profile = json.loads(_read(DIST / "api" / "v1" / "emulators" / "duckstation.json")) \
if (DIST / "api" / "v1" / "emulators" / "duckstation.json").is_file() else None
if profile is None:
self.skipTest("no built site")
self.assertEqual(profile["items"][0]["profile"]["emulator"], "DuckStation")
def test_a_console_page_card_reads_its_manufacturer_file(self):
"""Every collection match on a console page (systems/sony-playstation/)
asked for api/v1/systems/sony-playstation.json, which does not exist:
the verdict lost the file's path."""
source = _read(SITE_TS)
self.assertIn("api/v1/systems/${words.slice(0, size).join('-')}.json", source)
api = DIST / "api" / "v1" / "systems"
if not api.is_dir():
self.skipTest("no built site")
self.assertFalse((api / "sony-playstation.json").exists())
self.assertTrue((api / "sony.json").is_file())
def test_no_match_leads_somewhere(self):
source = _read(SITE_TS)
self.assertIn("link('contributing/', 'add it')", source)
anchor = re.search(r"const WANTED = 'provenance/#([^']+)';", source)
self.assertIsNotNone(anchor)
self.assertIn(f'id="{anchor[1]}"', _built("provenance"))
self.assertNotIn("No file the site knows has this hash.", source)
class HashAFileHere(unittest.TestCase):
"""Find a file asked for a hash only: hashing meant another tool (J7)."""
def test_the_chooser(self):
source = _read(SITE_TS)
self.assertIn("input.type = 'file';", source)
self.assertIn("It is hashed on this device. Nothing is uploaded.", source)
self.assertIn("crypto.subtle.digest(name, buffer)", source)
self.assertIn("['SHA-1', 'SHA-256']", source)
self.assertIn("lookUp(status, result, [found.sha1, found.md5, found.crc32, found.sha256])", source)
def test_md5_and_crc32_match_python(self):
source = _read(SITE_TS)
block = source[source.index("// Hashing a file on this device"):source.index("type FileHashes")]
sizes = [0, 1, 55, 56, 63, 64, 65, 119, 120, 128, 1000, 70_001]
out = _node_module(block + (
f"console.log(JSON.stringify({json.dumps(sizes)}.map((n) => {{"
"const bytes = new Uint8Array(n);"
"for (let i = 0; i < n; i += 1) bytes[i] = (i * 31 + 7) & 0xff;"
"return [md5(bytes), crc32(bytes)]; })));"
))
for size, (md5, crc) in zip(sizes, json.loads(out)):
data = bytes((i * 31 + 7) & 0xFF for i in range(size))
with self.subTest(size=size):
self.assertEqual(md5, hashlib.md5(data).hexdigest())
self.assertEqual(crc, f"{zlib.crc32(data):08x}")
class SearchFoldsPagesUnderAHash(unittest.TestCase):
"""Under "Look up this hash" came 14 unrelated pages: Magnavox for
E490.WAV, Philips for cdi490a.zip (N8)."""
def test_fold(self):
source = _read(SITE_TS)
self.assertIn("toggle.textContent = 'Pages that mention these characters';", source)
self.assertIn("toggle.setAttribute('aria-expanded', 'false');", source)
header = _read(COMPONENTS / "Header.astro")
self.assertIn(
".pagefind-ui__drawer[data-rb-hash]:not([data-rb-pages-shown]) .pagefind-ui__results-area { display: none; }",
header,
)
def test_search_fields_are_weighted_and_never_shown(self):
config = _read(CONFIG)
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 },", config)
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);", _read(SITE_TS))
class OsTabsKeepTheChoiceInView(unittest.TestCase):
def test_the_row_scrolls_not_the_page(self):
source = _read(SITE_TS)
self.assertIn("list.scrollLeft", source)
self.assertNotIn("scrollIntoView", source)
self.assertIn("osTabs();", source)
class CommandsNeverBreakInsideAWord(unittest.TestCase):
"""`--` / `platform retroarch` at 1440 px, `--` / `system` and
`nintendo-` / `64dd` on a phone: a browser breaks a line after a hyphen,
whatever overflow-wrap says."""
def test_the_words_are_wrapped_and_the_text_kept(self):
# Shiki cuts `--platform` into two tokens: the span has to hold both.
line = {"type": "element", "tagName": "div", "properties": {"className": ["code"]}, "children": [
{"type": "element", "tagName": "span", "properties": {"style": "a"}, "children": [
{"type": "text", "value": "sh -s "}]},
{"type": "element", "tagName": "span", "properties": {"style": "b"}, "children": [
{"type": "text", "value": "--"}]},
{"type": "element", "tagName": "span", "properties": {"style": "c"}, "children": [
{"type": "text", "value": "platform retroarch --system nintendo-64dd"}]},
]}
out = json.loads(_node_module(
f"import {{ unbreakWords }} from {json.dumps(UNBROKEN.as_uri())};"
f"console.log(JSON.stringify(unbreakWords({json.dumps(line)})));"
))
def text(node: dict) -> str:
return node.get("value", "") + "".join(text(child) for child in node.get("children", []))
self.assertEqual("".join(text(n) for n in out), "sh -s --platform retroarch --system nintendo-64dd")
words = [text(n) for n in out if n.get("properties", {}).get("className") == ["rb-nb"]]
self.assertEqual(words, ["-s", "--platform", "--system", "nintendo-64dd"])
# The colours of each token survive the cut.
platform = next(n for n in out if text(n) == "--platform")
self.assertEqual([c["properties"]["style"] for c in platform["children"]], ["b", "c"])
def test_registered(self):
config = _read(CONFIG)
self.assertIn("import unbrokenTokens from './src/plugins/ec-unbroken-tokens.mjs';", config)
self.assertIn("plugins: [unbrokenTokens()],", config)
self.assertIn("baseStyles: `.${CLASS} { white-space: nowrap; }`", _read(UNBROKEN))
picker = _read(COMPONENTS / "FilePicker.astro")
self.assertIn("showCommand(cmd, `${INSTALL} --platform", picker)
self.assertIn(".rb-picker .rb-pick-cmd .rb-nb { white-space: nowrap; }", picker)
self.assertIn("navigator.clipboard?.writeText(cmd.textContent ?? '')", picker)
def test_copy_gives_the_rendered_text(self):
"""The copy button's text is the block's text: the spans add nothing.
Expressive Code leaves a leading comment line out of the copy."""
import html as html_lib
page = _built("platforms/retroarch")
figures = re.findall(r"<figure\b.*?</figure>", page, re.S)
self.assertTrue(figures)
self.assertIn('<span class="rb-nb">', page)
for figure in figures:
copied = re.search(r'data-code="([^"]*)"', figure)
if not copied:
continue
lines = re.findall(r'<div class="ec-line">(.*?)</div></div>', figure, re.S)
shown = "\n".join(html_lib.unescape(re.sub(r"<[^>]+>", "", line)) for line in lines)
code = html_lib.unescape(copied[1]).replace("\x7f", "\n")
with self.subTest(code=code[:60]):
self.assertTrue(shown.endswith(code), (shown, code))
class LogosAtTheirDisplaySize(unittest.TestCase):
"""136 KiB of the home page was 96 px PNG logos shown at 40 px (T-F7)."""
def test_raster_logos_are_small_webp(self):
manifest = json.loads(_read(LOGOS / "sources.json"))["images"]
self.assertTrue(manifest)
for entry in manifest:
path = LOGOS / entry["file"]
with self.subTest(file=entry["file"]):
self.assertTrue(path.is_file())
self.assertIn(path.suffix, (".webp", ".svg"))
if path.suffix == ".webp":
self.assertEqual(_webp_size(path.read_bytes()), (LOGO_PIXELS, LOGO_PIXELS))
self.assertEqual(sorted(p.name for p in LOGOS.glob("*.png")), [])
script = _read(REPO_ROOT / "scripts" / "fetch_site_images.py")
self.assertIn(f"LOGO_SIZE = {LOGO_PIXELS}", script)
self.assertIn('square_image(data, LOGO_SIZE, "WEBP")', script)
def test_pages_point_at_the_copies(self):
html = _built("platforms/emudeck")
self.assertIn('src="/retrobios/assets/logos/emudeck.webp"', html)
if __name__ == "__main__":
unittest.main()
+13 -5
View File
@@ -13,6 +13,7 @@ import starlightLinksValidator from 'starlight-links-validator';
import sidebar from './src/generated/sidebar.json' with { type: 'json' };
import lastmod from './src/generated/lastmod.json' with { type: 'json' };
import stackTables from './src/plugins/rehype-stack-tables.mjs';
import unbrokenTokens from './src/plugins/ec-unbroken-tokens.mjs';
const SITE = 'https://abdess.github.io';
const BASE = '/retrobios/';
@@ -84,11 +85,14 @@ export default defineConfig({
routeMiddleware: ['./src/routeData.ts'],
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
// Commands wrap instead of scrolling, so the end of a one-liner stays
// visible. A command block is titled (Terminal, PowerShell) and drawn
// without the three window dots. Starlight writes the dots into each
// theme after the global style overrides, so they are set here.
// visible, and never inside a word that holds a hyphen (`--platform`,
// `nintendo-64dd`: src/plugins/ec-unbroken-tokens.mjs). A command block
// is titled (Terminal, PowerShell) and drawn without the three window
// dots. Starlight writes the dots into each theme after the global
// style overrides, so they are set here.
expressiveCode: {
defaultProps: { wrap: true },
plugins: [unbrokenTokens()],
customizeTheme(theme) {
theme.styleOverrides.frames = { ...theme.styleOverrides.frames, terminalTitlebarDotsOpacity: '0' };
return theme;
@@ -102,14 +106,18 @@ export default defineConfig({
// ten times its text, so "dolphin" finds Dolphin, and the file names of
// a system page (its `files` field, written by sitestarlight.py) count
// half, so "scph5501" finds the Sony page without a file name winning
// every other query.
// every other query. Two fields name the page's subject in other words
// and weigh half a title: `aliases`, the ids setups file a system under
// (psx, dreamcast, gba), and `devices`, the handhelds and boxes a
// setup's own site names (R36S, Miyoo Mini). All three are attributes
// on an empty element, so no text of theirs reaches an excerpt.
pagefind: {
ranking: {
pageLength: 0.1,
termFrequency: 0.5,
termSaturation: 1.4,
termSimilarity: 9,
metaWeights: { title: 10, files: 0.5 },
metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 },
},
},
plugins: [
+22 -2
View File
@@ -66,6 +66,25 @@ const packs = Object.fromEntries(
const INSTALL = 'curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s --';
const MAX_ROWS = 500;
// A word that holds a hyphen (--platform, nintendo-64dd) never breaks
// inside: the same rule as the command blocks (ec-unbroken-tokens.mjs).
// Only spans are added, the text copied is the command itself.
const UNBROKEN = /(?<!\S)(?=\S*-)\S{1,48}(?!\S)/g;
const showCommand = (target: HTMLElement, command: string) => {
const nodes: (Node | string)[] = [];
let last = 0;
for (const match of command.matchAll(UNBROKEN)) {
nodes.push(command.slice(last, match.index));
const word = document.createElement('span');
word.className = 'rb-nb';
word.textContent = match[0];
nodes.push(word);
last = match.index! + match[0].length;
}
nodes.push(command.slice(last));
target.replaceChildren(...nodes);
};
const size = (bytes: number) =>
bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB`
@@ -107,7 +126,7 @@ const packs = Object.fromEntries(
? `${count(files.length)} files, ${size(total)}, for ${count(picked.length)} of ${count(data.systems.length + (data.other ? 1 : 0))} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
: 'Choose at least one system.';
output.hidden = !picked.length;
cmd.textContent = `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`;
showCommand(cmd, `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`);
tbody.replaceChildren(...files.slice(0, MAX_ROWS).map(([dest, bytes]) => {
const row = document.createElement('tr');
const where = document.createElement('td');
@@ -221,7 +240,8 @@ const packs = Object.fromEntries(
.rb-picker .rb-pick-systems-head { display: flex; gap: 0.5rem; align-items: center; }
.rb-picker .rb-pick-systems-head > * { margin: 0; }
.rb-picker .rb-pick-filter { width: 100%; margin: 0.5rem 0; }
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: anywhere; }
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: break-word; }
.rb-picker .rb-pick-cmd .rb-nb { white-space: nowrap; }
.rb-picker .rb-pick-result { margin-top: 1rem; font-weight: 600; }
.rb-picker fieldset {
flex-direction: column;
+1 -1
View File
@@ -29,7 +29,7 @@ const date = releaseDate(tag);
Packs from release <a href={`${REPO}/releases/tag/${tag}`}>{tag}</a>{date && <>, <time datetime={tag.slice(1).replaceAll('.', '-')}>{date}</time></>}.
</p>
<p class="rb-copyright">
MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup,
MIT for the tooling and metadata. BIOS and firmware files are third-party system software, preserved for personal backup,
archival and interoperability.
</p>
</div>
+17
View File
@@ -38,7 +38,24 @@ const topics = Astro.locals.starlightSidebarTopics?.topics ?? [];
border-radius: 0.5rem;
overflow-wrap: anywhere;
}
.rb-search-hash p { margin: 0; }
.rb-search-hash a { font-weight: 600; }
/* Under a hash, the pages that only mention its characters stay folded. */
.pagefind-ui__drawer[data-rb-hash]:not([data-rb-pages-shown]) .pagefind-ui__results-area { display: none; }
.rb-search-hash .rb-search-pages {
min-height: 24px;
margin-top: 0.5rem;
padding: 0;
border: 0;
background: none;
color: var(--sl-color-gray-2);
font: inherit;
font-size: var(--sl-text-sm);
text-decoration: underline;
cursor: pointer;
}
.rb-search-hash .rb-search-pages::before { content: '+ '; }
.rb-search-hash .rb-search-pages[aria-expanded='true']::before { content: '- '; }
/* Pagefind's own display rules would show what site.ts hides. */
#starlight__search [hidden] { display: none !important; }
@@ -30,4 +30,25 @@ const { searchWeight } = Astro.locals.starlightRoute.entry.data;
max-width: 40rem;
box-sizing: border-box;
}
/* The file chooser site.ts adds under the form, and the four hashes it
shows once the file is read. */
.sl-markdown-content .rb-file-pick { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; margin-top: 1rem; }
.sl-markdown-content .rb-file-pick > * { margin: 0; }
.sl-markdown-content .rb-file-pick label { font-weight: 600; }
.sl-markdown-content .rb-file-pick input { width: 100%; min-width: 0; font: inherit; }
.sl-markdown-content .rb-file-pick input::file-selector-button {
min-height: 44px;
margin-inline-end: 0.75rem;
padding: 0 1rem;
border: 1px solid var(--rb-border-strong);
border-radius: 4px;
background: var(--sl-color-bg);
color: var(--sl-color-text);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.sl-markdown-content .rb-file-pick-note { color: var(--sl-color-gray-3); font-size: var(--sl-text-sm); }
.sl-markdown-content .rb-file-pick-hashes code { overflow-wrap: anywhere; }
.sl-markdown-content .rb-file-pick-hashes[hidden] { display: none; }
</style>
+38 -10
View File
@@ -1,9 +1,16 @@
---
// Breadcrumbs, the level label and the title. The trail says which tab a
// deep page belongs to; the label says who the page is written for, and is
// left out where the first crumb already names that tab. A page with a
// `logo` (a path under the site root) shows it beside its title.
// deep page belongs to, and the label says who the page is written for. The
// label is left out where the header tab already says it: on a tab's own
// page, and where the first crumb names that tab. A page with a `logo` (a
// path under the site root) shows it beside its title, on a white square so
// a dark mark stays visible in the dark theme. A setup page with no logo file
// gets its initials in the same square, as its tile does (sitemarkup.py
// platform_tiles).
import setups from '../../public/data/picker/index.json';
const PAGE_TITLE_ID = '_top';
const MARK = 40;
const LABELS = {
start: 'Start here',
@@ -22,11 +29,19 @@ const TABS = {
contribute: 'Contribute',
} as const;
const { title, level, logo } = Astro.locals.starlightRoute.entry.data;
// Up to two capitals of a name: MyMinUI is MM (sitemarkup.initials).
const initials = (name: string): string => {
const capitals = [...name].filter((c) => c !== c.toLowerCase());
return capitals.slice(0, 2).join('') || name.slice(0, 2).toUpperCase();
};
const { id, data } = Astro.locals.starlightRoute.entry;
const { title, level, logo } = data;
const crumbs = Astro.locals.rbBreadcrumbs ?? [];
const badge = level && crumbs[0]?.label !== TABS[level] && crumbs[0]?.label !== LABELS[level];
const badge = level && crumbs.length > 0 && crumbs[0].label !== TABS[level] && crumbs[0].label !== LABELS[level];
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
const logoSrc = logo && (/^(https?:)?\//.test(logo) ? logo : base + logo);
const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platform}`);
---
{
@@ -42,9 +57,17 @@ const logoSrc = logo && (/^(https?:)?\//.test(logo) ? logo : base + logo);
}
{badge && <span class={`rb-level rb-level-${level}`}>{LABELS[level]}</span>}
{
logoSrc ? (
logoSrc || setup ? (
<div class="rb-title-logo">
<img src={logoSrc} alt="" height="40" />
{logoSrc ? (
<img class="rb-title-mark" src={logoSrc} alt="" width={MARK} height={MARK} />
) : (
<svg class="rb-title-mark" viewBox="0 0 40 40" width={MARK} height={MARK} aria-hidden="true">
<text x="20" y="25" text-anchor="middle" font-size="14" font-weight="700" fill="#2b2e5c">
{initials(setup.name)}
</text>
</svg>
)}
<h1 id={PAGE_TITLE_ID}>{title}</h1>
</div>
) : (
@@ -67,11 +90,16 @@ const logoSrc = logo && (/^(https?:)?\//.test(logo) ? logo : base + logo);
margin-top: 0.25rem;
}
.rb-title-logo h1 { margin-top: 0; }
.rb-title-logo img {
/* The tiles' square: white in both themes, a hairline, the mark inside. */
.rb-title-mark {
flex: none;
width: auto;
box-sizing: border-box;
width: 40px;
height: 40px;
max-width: 8rem;
padding: 3px;
border: 1px solid var(--rb-border);
border-radius: 6px;
background: #fff;
object-fit: contain;
}
</style>
@@ -0,0 +1,96 @@
// Command blocks wrap (astro.config.mjs, wrap: true) so the end of a
// one-liner stays visible. A browser may also break a line after a hyphen,
// which split `--platform` into `--` and `platform`, and `nintendo-64dd`
// into `nintendo-` and `64dd`: a reader who types the command reads two
// things. Each word that holds a hyphen is put in a span that does not
// break inside. Only spans are added: the text, the copy button's
// data-code and a selection copied by hand are unchanged.
//
// A word longer than LONGEST is left breakable: an address that long would
// push the block wider than a phone.
export const LONGEST = 48;
export const UNBROKEN = new RegExp(`(?<!\\S)(?=\\S*-)\\S{1,${LONGEST}}(?!\\S)`, 'g');
const CLASS = 'rb-nb';
const textOf = (node) => (node.type === 'text' ? node.value : (node.children ?? []).map(textOf).join(''));
// A token span holds one text node: it can be cut in two, both halves
// keeping its colours.
const cuttable = (node) =>
node.type === 'text' || (node.type === 'element' && node.children?.length === 1 && node.children[0].type === 'text');
const slice = (node, from, to) =>
node.type === 'text'
? { type: 'text', value: node.value.slice(from, to) }
: { ...node, properties: { ...node.properties }, children: [{ type: 'text', value: node.children[0].value.slice(from, to) }] };
// The children of *container*, with every hyphenated word wrapped in one
// unbreakable span. A word that crosses a node this cannot cut is left as is.
export const unbreakWords = (container) => {
const children = container.children ?? [];
const text = children.map(textOf).join('');
const ranges = [...text.matchAll(UNBROKEN)].map((match) => [match.index, match.index + match[0].length]);
if (!ranges.length) return children;
const cuts = [...new Set(ranges.flat())].sort((a, b) => a - b);
const pieces = [];
let position = 0;
for (const child of children) {
const end = position + textOf(child).length;
const inner = cuts.filter((cut) => cut > position && cut < end);
if (inner.length && cuttable(child)) {
let start = position;
for (const cut of [...inner, end]) {
pieces.push({ node: slice(child, start - position, cut - position), from: start, to: cut });
start = cut;
}
} else {
pieces.push({ node: child, from: position, to: end });
}
position = end;
}
const ends = new Map(ranges);
const out = [];
for (let index = 0; index < pieces.length; ) {
const end = ends.get(pieces[index].from);
if (end !== undefined && pieces[index].to > pieces[index].from) {
let last = index;
while (last < pieces.length && pieces[last].to < end) last += 1;
if (last < pieces.length && pieces[last].to === end) {
out.push({
type: 'element',
tagName: 'span',
properties: { className: [CLASS] },
children: pieces.slice(index, last + 1).map((piece) => piece.node),
});
index = last + 1;
continue;
}
}
out.push(pieces[index].node);
index += 1;
}
return out;
};
// The element of a rendered line that holds its tokens (div.code), else
// the line itself.
const tokensOf = (line) => {
for (const child of line.children ?? []) {
if (child.type === 'element' && String(child.properties?.className ?? '').split(/[\s,]+/).includes('code')) return child;
}
return line;
};
export default function unbrokenTokens() {
return {
name: 'rb-unbroken-tokens',
baseStyles: `.${CLASS} { white-space: nowrap; }`,
hooks: {
postprocessRenderedLine({ renderData }) {
const container = tokensOf(renderData.lineAst);
container.children = unbreakWords(container);
},
},
};
}
+97 -14
View File
@@ -1,15 +1,22 @@
// Tables a phone reads one row at a time, marked when the page is built so
// the stacked layout is there on first paint (the CSS is retrobios.css,
// table[data-rb-stack]). The rule reads the table, never the page that
// wrote it:
// Tables, settled when the page is built so the layout is right on first
// paint (the CSS is retrobios.css, table[data-rb-stack] and .rb-table-filter).
// The rule reads the table, never the page that wrote it.
//
// Which tables a phone reads one row at a time:
// - two columns: a list of label and value, data-rb-stack="kv";
// - four columns or more: too wide for a phone column, one block per row,
// each cell under its column name;
// - a row that ends in an action (a release download, a button, the link to
// a setup's install command) or a header that names a ZIP column: one
// block per row, each cell under its column name;
// - two columns: a list of label and value, data-rb-stack="kv";
// - on a level 0 or 1 page (`level: start` or `setup`), a table short enough
// to read as records (under 20 rows, the size from which site.ts adds a
// filter): one block per row. A longer one is a list scanned by column and
// keeps its scroll.
// block per row, so the action is never behind a horizontal scroll;
// - on a level 0 or 1 page (`level: start` or `setup`), a three-column table
// short enough to read as records (under RECORD_ROWS rows): one block per
// row. A longer one is a list scanned by column and keeps its scroll.
//
// A table of RECORD_ROWS rows or more gets its filter box here, above it.
// Written by site.ts once the page had painted, the box pushed the table
// down (CLS 0.066 on gaps-files-to-source). site.ts only wires it.
//
// Satteri runs hast plugins (the rehype stage of its pipeline): this one is
// registered in astro.config.mjs through satteri({ hastPlugins }).
@@ -17,7 +24,11 @@ const ACTION_HREF = /\/releases\/(download\/|latest\/?$)|#install$/;
const ACTION_CLASS = /\bclass=["'][^"']*\brb-button\b/;
const DOWNLOAD_HEADER = /^(zip|download)$/i;
const READER_LEVELS = new Set(['start', 'setup']);
const HEADING = /^h[1-6]$/;
export const RECORD_ROWS = 20;
export const WIDE_COLUMNS = 4;
// The column names a filter's placeholder offers, at most.
const PLACEHOLDER_COLUMNS = 4;
const elements = (node, tag, found = []) => {
for (const child of node.children ?? []) {
@@ -29,23 +40,87 @@ const elements = (node, tag, found = []) => {
return found;
};
const classes = (node) => String(node.properties?.className ?? '').split(/[\s,]+/);
const holdsAction = (node) =>
(node.children ?? []).some(
(child) =>
(child.type === 'element' &&
((child.tagName === 'a' && ACTION_HREF.test(String(child.properties?.href ?? ''))) ||
String(child.properties?.className ?? '').split(/[\s,]+/).includes('rb-button') ||
classes(child).includes('rb-button') ||
holdsAction(child))) ||
(child.type === 'raw' && ACTION_CLASS.test(child.value ?? '')),
);
export const stackKind = ({ labels, rows, action, level }) => {
if (labels.length === 2) return 'kv';
if (labels.length >= WIDE_COLUMNS) return 'true';
if (action || DOWNLOAD_HEADER.test(labels.at(-1) ?? '')) return 'true';
if (READER_LEVELS.has(level) && labels.length > 2 && rows < RECORD_ROWS) return 'true';
return null;
};
// "File, region, required, hash...": what the rows can be filtered by, in
// the table's own words. A word that is not capitalised prose (MD5, ZIP)
// keeps its case.
export const filterPlaceholder = (labels) => {
const words = labels.filter(Boolean).slice(0, PLACEHOLDER_COLUMNS);
if (!words.length) return 'Type to filter...';
const lowered = words.map((word, index) =>
index > 0 && /^[A-Z][a-z]/.test(word) ? word[0].toLowerCase() + word.slice(1) : word,
);
return `${lowered.join(', ')}...`;
};
// The heading a table sits under: the nearest one before it, looking up
// through the blocks that hold it, else the page title. site.ts names the
// table's scroll region the same way.
const headingBefore = (ctx, table, title) => {
for (let block = table; block; block = ctx.parent(block)) {
const parent = ctx.parent(block);
if (!parent) break;
const siblings = parent.children ?? [];
for (let index = (ctx.indexOf(block) ?? 0) - 1; index >= 0; index -= 1) {
const node = siblings[index];
if (node.type !== 'element') continue;
const heading = HEADING.test(node.tagName)
? node
: classes(node).includes('sl-heading-wrapper')
? (node.children ?? []).find((child) => child.type === 'element' && HEADING.test(child.tagName))
: undefined;
const text = heading && ctx.textContent(heading).trim();
if (text) return text;
}
}
return title || 'this table';
};
const filterBox = ({ id, heading, labels, rows }) => ({
type: 'element',
tagName: 'div',
properties: { className: ['rb-table-filter'] },
children: [
{
type: 'element',
tagName: 'label',
properties: { htmlFor: id },
children: [{ type: 'text', value: `Filter ${heading}` }],
},
{
type: 'element',
tagName: 'input',
properties: { id, type: 'search', autoComplete: 'off', placeholder: filterPlaceholder(labels) },
children: [],
},
{
type: 'element',
tagName: 'span',
properties: { className: ['rb-table-filter-count'], ariaLive: 'polite' },
children: [{ type: 'text', value: `${rows.toLocaleString('en')} rows` }],
},
],
});
export default function stackTables() {
return {
name: 'rb-stack-tables',
@@ -57,11 +132,19 @@ export default function stackTables() {
if (!head || !body) return;
const labels = elements(head, 'th').map((cell) => ctx.textContent(cell).trim());
const rows = elements(body, 'tr');
const level = ctx.data.astro?.frontmatter?.level;
const kind = stackKind({ labels, rows: rows.length, action: holdsAction(body), level });
const frontmatter = ctx.data.astro?.frontmatter ?? {};
if (rows.length >= RECORD_ROWS) {
ctx.data.rbFilters = (ctx.data.rbFilters ?? 0) + 1;
const heading = headingBefore(ctx, table, frontmatter.title);
ctx.insertBefore(table, filterBox({ id: `rb-table-filter-${ctx.data.rbFilters}`, heading, labels, rows: rows.length }));
}
const kind = stackKind({ labels, rows: rows.length, action: holdsAction(body), level: frontmatter.level });
if (!kind) return;
ctx.setProperty(table, 'dataRbStack', kind);
if (kind === 'kv') return;
// A long table's labels are written by site.ts when it nears the
// screen: on mame they were 122 KB of the page. The label is drawn
// in the room the cell already keeps for it, so nothing moves.
if (kind === 'kv' || rows.length >= RECORD_ROWS) return;
for (const row of rows) {
elements(row, 'td').forEach((cell, column) => {
if (labels[column] && cell.properties?.dataLabel === undefined) ctx.setProperty(cell, 'dataLabel', labels[column]);
+397 -79
View File
@@ -1,17 +1,22 @@
// Progressive enhancement of the generated pages: table captions, filters
// and keyboard scrolling, the hash lookup on files/, moved cards, a hash
// typed in the search, glossary terms on a tap, and the 404 page's hints.
// 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 tabs kept in view, 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.
const HASH_LENGTHS: Record<number, string> = { 8: 'CRC32', 32: 'MD5', 40: 'SHA1', 64: 'SHA256' };
const LAYER_LABELS: Record<string, string> = {
collection: 'In the collection',
'data-directory': 'In a data directory the packs ship',
platform: 'Declared by a platform',
emulator: 'Read by an emulator',
// One line of api/v1/lookup/. `system_name` is read when a record carries it.
type Item = {
algorithm: string;
layer: string;
name?: string;
value: string;
url: string;
owner?: string;
sha1?: string;
system_name?: string;
};
type Item = { algorithm: string; layer: string; name?: string; value: string; url: string; owner?: string };
const siteRoot = () => new URL(import.meta.env.BASE_URL.replace(/\/?$/, '/'), location.origin);
const normalizeHash = (raw: string) => raw.trim().toLowerCase().replace(/^0x/, '').replace(/\s+/g, '');
@@ -75,26 +80,17 @@ const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
}
};
const filterTable = (table: HTMLTableElement, index: number, heading: string) => {
// The filter box above a long table is written when the page is built
// (src/plugins/rehype-stack-tables.mjs), so the table never moves down once
// drawn. Here it only filters.
const filterTable = (table: HTMLTableElement) => {
const controls = table.previousElementSibling;
if (!controls?.matches('.rb-table-filter')) return;
const input = controls.querySelector('input');
const count = controls.querySelector('.rb-table-filter-count');
if (!input || !count || input.dataset.rbWired) return;
input.dataset.rbWired = 'true';
const rows = [...table.querySelectorAll<HTMLTableRowElement>('tbody tr')];
if (rows.length < 20) return;
const controls = document.createElement('div');
controls.className = 'rb-table-filter';
const id = `rb-table-filter-${index}`;
const label = document.createElement('label');
label.htmlFor = id;
label.textContent = `Filter ${heading}`;
const input = document.createElement('input');
input.id = id;
input.type = 'search';
input.autocomplete = 'off';
input.placeholder = 'Name, platform, system, hash...';
const count = document.createElement('span');
count.className = 'rb-table-filter-count';
count.setAttribute('aria-live', 'polite');
count.textContent = `${rows.length} rows`;
controls.append(label, input, count);
table.before(controls);
// Each row's text is read on the first keystroke, not on every page load.
let searchable: { row: HTMLTableRowElement; text: string }[] | null = null;
input.addEventListener('input', () => {
@@ -105,14 +101,31 @@ const filterTable = (table: HTMLTableElement, index: number, heading: string) =>
entry.row.hidden = !!query && !entry.text.includes(query);
if (!entry.row.hidden) visible += 1;
}
count.textContent = `${visible} of ${rows.length} rows`;
count.textContent = query
? `${visible.toLocaleString('en')} of ${rows.length.toLocaleString('en')} rows`
: `${rows.length.toLocaleString('en')} rows`;
});
};
// A stacked table of many rows leaves its column names out of the HTML
// (rehype-stack-tables.mjs): each cell gets its own here, as the table nears
// the screen. The name sits in the left room every stacked cell keeps, so
// writing it moves nothing.
const labelCells = (table: HTMLTableElement) => {
if (table.dataset.rbStack !== 'true') return;
const labels = [...table.querySelectorAll('thead th')].map((cell) => cell.textContent?.trim() ?? '');
for (const row of table.tBodies[0]?.rows ?? []) {
[...row.cells].forEach((cell, column) => {
if (labels[column] && !cell.dataset.label) cell.dataset.label = labels[column];
});
}
};
const enhanceTables = () => {
const tables = [...document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table')];
const enhance = (table: HTMLTableElement) => {
for (const cell of table.querySelectorAll('thead th')) if (!cell.hasAttribute('scope')) cell.setAttribute('scope', 'col');
labelCells(table);
const heading = tableName(table);
if (!table.querySelector('caption') && !table.closest('.rb-meta-card')) {
const caption = document.createElement('caption');
@@ -120,7 +133,7 @@ const enhanceTables = () => {
caption.textContent = heading;
table.prepend(caption);
}
filterTable(table, tables.indexOf(table), heading);
filterTable(table);
};
// A table is set up and measured when it nears the screen. Doing all of
// them at load relaid out every row of a 3,800-row page after its first
@@ -159,16 +172,100 @@ const enhanceTables = () => {
});
};
// Hashing a file on this device: SHA1 and SHA256 by Web Crypto, MD5 and
// CRC32 here, since Web Crypto has neither. The bytes never leave the page.
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
const crc32 = (bytes: Uint8Array): string => {
let crc = 0xffffffff;
for (let i = 0; i < bytes.length; i += 1) crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
};
const MD5_SHIFTS = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
const MD5_TABLE = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0);
const hexBytes = (bytes: Uint8Array) => [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
// RFC 1321, block by block over the file's own buffer: no padded copy of a
// 500 MB file.
const md5 = (bytes: Uint8Array): string => {
const state = new Int32Array([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476]);
const words = new Int32Array(16);
const block = (view: DataView, offset: number) => {
for (let i = 0; i < 16; i += 1) words[i] = view.getInt32(offset + i * 4, true);
let [a, b, c, d] = state;
for (let i = 0; i < 64; i += 1) {
const round = i >> 4;
const f = round === 0 ? (b & c) | (~b & d) : round === 1 ? (d & b) | (~d & c) : round === 2 ? b ^ c ^ d : c ^ (b | ~d);
const g = round === 0 ? i : round === 1 ? (5 * i + 1) % 16 : round === 2 ? (3 * i + 5) % 16 : (7 * i) % 16;
const shift = MD5_SHIFTS[round * 4 + (i % 4)];
const sum = (a + f + MD5_TABLE[i] + words[g]) | 0;
a = d;
d = c;
c = b;
b = (b + ((sum << shift) | (sum >>> (32 - shift)))) | 0;
}
state[0] += a;
state[1] += b;
state[2] += c;
state[3] += d;
};
const whole = bytes.length - (bytes.length % 64);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (let offset = 0; offset < whole; offset += 64) block(view, offset);
const tail = new Uint8Array(bytes.length - whole < 56 ? 64 : 128);
tail.set(bytes.subarray(whole));
tail[bytes.length - whole] = 0x80;
const tailView = new DataView(tail.buffer);
const bits = bytes.length * 8;
tailView.setUint32(tail.length - 8, bits >>> 0, true);
tailView.setUint32(tail.length - 4, Math.floor(bits / 2 ** 32), true);
for (let offset = 0; offset < tail.length; offset += 64) block(tailView, offset);
return hexBytes(new Uint8Array(state.buffer));
};
type FileHashes = { sha1: string; md5: string; crc32: string; sha256: string };
const hashFile = async (file: Blob): Promise<FileHashes> => {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const [sha1, sha256] = await Promise.all(
['SHA-1', 'SHA-256'].map(async (name) => hexBytes(new Uint8Array(await crypto.subtle.digest(name, buffer)))),
);
return { sha1, md5: md5(bytes), crc32: crc32(bytes), sha256 };
};
// files/?hash= reads one shard of the lookup, named by the first two hex
// characters of the value.
// characters of the value. ?md5=, ?crc32= and ?sha256= are read the same way.
const HASH_PARAMS = ['hash', 'md5', 'crc32', 'sha256'];
const lookupShard = (value: string): Promise<Item[]> =>
fetch(new URL(`api/v1/lookup/${value.slice(0, 2)}.json`, siteRoot()))
.then((response) => (response.ok ? response.json() : { items: [] }))
.then((shard) => shard.items || []);
const lookupValues = async (values: string[]): Promise<Item[]> => {
const prefixes = [...new Set(values.map((value) => value.slice(0, 2)))];
const shards = await Promise.all(prefixes.map(lookupShard));
const wanted = new Set(values);
return shards.flat().filter((item) => wanted.has(item.value));
};
type Held = { name: string; path: string; sha1: string; size: number };
type Declared = { name?: string; destination?: string; required?: boolean; [hash: string]: unknown };
type ProfileFile = { name?: string; region?: string | string[]; category?: string; [field: string]: unknown };
type Reader = { name: string; files: ProfileFile[] };
const json = (path: string) =>
fetch(new URL(path, siteRoot())).then((response) => (response.ok ? response.json() : Promise.reject(response.status)));
@@ -204,15 +301,33 @@ const strong = (text: string) => {
return element;
};
const count = (n: number, one: string, many: string) => `${n.toLocaleString('en')} ${n === 1 ? one : many}`;
const listed = (words: string[]) =>
words.length < 3 ? words.join(' and ') : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`;
// A region: value in words, as the system pages write it.
const REGION_WORDS: Record<string, string> = { uk: 'UK', 'asia-ntsc': 'Asia (NTSC)', 'asia-pal': 'Asia (PAL)' };
const regionWord = (region: string) =>
REGION_WORDS[region] ?? region.replace(/-/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
const CATEGORY_WORDS: Record<string, string> = { bios: 'BIOS', game_data: 'game data', bios_zip: 'BIOS ZIP' };
// The card a collection match points at, read from its manufacturer's file
// of api/v1/systems/: the path in the collection and the size.
const heldFile = async (item: Item & { sha1?: string }): Promise<Held | undefined> => {
// of api/v1/systems/: the path in the collection and the size. A console's
// own page (systems/sony-playstation/) is part of its manufacturer's file
// (systems/sony.json), found by dropping words from the end of the slug.
const heldFile = async (item: Item): Promise<Held | undefined> => {
const slug = /^systems\/([^/]+)\//.exec(item.url)?.[1];
if (!slug || !item.sha1) return undefined;
const data = await json(`api/v1/systems/${slug}.json`);
for (const files of Object.values<Held[]>(data.items?.[0]?.consoles ?? {})) {
const found = files.find((file) => file.sha1 === item.sha1);
if (found) return found;
const words = slug.split('-');
for (let size = words.length; size > 0; size -= 1) {
const data = await json(`api/v1/systems/${words.slice(0, size).join('-')}.json`).catch(() => undefined);
if (!data) continue;
for (const files of Object.values<Held[]>(data.items?.[0]?.consoles ?? {})) {
const found = files.find((file) => file.sha1 === item.sha1);
if (found) return found;
}
return undefined;
}
return undefined;
};
@@ -220,15 +335,40 @@ const heldFile = async (item: Item & { sha1?: string }): Promise<Held | undefine
// A setup's own entry for the value, from its contract in api/v1/platforms/:
// where the file goes and whether the setup requires it. Recalbox writes
// several MD5 in one field, separated by commas.
const declaredEntry = async (item: Item, value: string): Promise<Declared | undefined> => {
const declaredEntry = async (item: Item): Promise<Declared | undefined> => {
const system = decodeURIComponent(new URL(item.url, siteRoot()).hash.slice(1));
const data = await json(`api/v1/platforms/${item.owner}.json`);
const files: Declared[] = data.items?.[0]?.contract?.systems?.[system]?.files ?? [];
const holds = (entry: Declared) =>
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(value);
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value);
return files.find((entry) => holds(entry) && entry.name === item.name) ?? files.find(holds);
};
// An emulator by the name its page carries, with the entries of its profile.
const readerProfile = async (owner: string): Promise<Reader | undefined> => {
const data = await json(`api/v1/emulators/${owner}.json`).catch(() => undefined);
const profile = data?.items?.[0]?.profile;
if (!profile) return undefined;
return { name: String(profile.emulator || profile.display_name || owner), files: profile.files ?? [] };
};
// The entry of a profile that reads the value: by name and hash, else by hash.
const readerEntry = (reader: Reader | undefined, item: Item): ProfileFile | undefined => {
const holds = (entry: ProfileFile) =>
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value);
return reader?.files.find((entry) => holds(entry) && entry.name === item.name) ?? reader?.files.find(holds);
};
// One value every reader that says something agrees on, else nothing.
const agreed = (values: (string | undefined)[]): string | undefined => {
const said = new Set(values.filter((value): value is string => !!value));
return said.size === 1 ? [...said][0] : undefined;
};
// A name a setup writes in place of a file name: EmuDeck lists a hash under
// "system:md5", and any file that has it passes.
const anyName = (name: string) => !name || name.includes(':');
const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) => {
const table = document.createElement('table');
const head = table.createTHead().insertRow();
@@ -242,12 +382,10 @@ const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) =>
for (const { item, entry, setup } of rows) {
const row = body.insertRow();
const name = entry?.name ?? item.name ?? '';
// EmuDeck lists a hash with no file name: any file that has it passes.
const named = name && !name.includes(':');
const destination = entry?.destination;
const cells: (Node | string)[] = [
link(item.url, setup),
named ? code(name) : 'Any name',
anyName(name) ? 'Any name' : code(name),
destination ? code(destination) : '-',
item.algorithm.toUpperCase(),
entry?.required === undefined ? '-' : entry.required ? 'Yes' : 'No',
@@ -262,30 +400,83 @@ const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) =>
return table;
};
// The answer first: which file this is and where the collection holds it,
// then the setups that list it (where it goes, whether it is required),
// then the emulators that read it.
const renderMatches = async (status: HTMLElement, result: HTMLElement, value: string, items: Item[]) => {
// One declaration per setup, name and section: a value found by two of its
// hashes (a file hashed here) is still one line of the setup's list.
const distinct = (items: Item[], key: (item: Item) => string) => {
const seen = new Map<string, Item>();
for (const item of items) if (!seen.has(key(item))) seen.set(key(item), item);
return [...seen.values()];
};
// The answer first: which file this is, for which system and region, and
// how many setups list it under which names. Then where the collection
// holds it, the setups that list it (where it goes, whether it is
// required), and the emulators that read it, by name.
const renderMatches = async (status: HTMLElement, result: HTMLElement, items: Item[]) => {
const byLayer = (layer: string) => items.filter((item) => item.layer === layer);
const held = byLayer('collection');
const platforms = byLayer('platform');
const [files, entries, names] = await Promise.all([
const held = distinct(byLayer('collection'), (item) => item.sha1 ?? item.url);
const platforms = distinct(byLayer('platform'), (item) => `${item.owner}|${item.name}|${item.url}`);
const emulators = distinct(byLayer('emulator'), (item) => `${item.owner}|${item.name}|${item.url}`);
const owners = [...new Set(emulators.map((item) => item.owner ?? ''))];
const [files, entries, names, readers] = await Promise.all([
Promise.all(held.map((item) => heldFile(item).catch(() => undefined))),
Promise.all(platforms.map((item) => declaredEntry(item, value).catch(() => undefined))),
Promise.all(platforms.map((item) => declaredEntry(item).catch(() => undefined))),
json('data/picker/index.json').catch(() => []) as Promise<{ platform: string; name: string }[]>,
Promise.all(owners.map((owner) => readerProfile(owner))),
]);
const setupName = new Map(names.map((entry) => [entry.platform, entry.name]));
status.textContent = `${items.length.toLocaleString('en')} ${items.length === 1 ? 'match' : 'matches'} for ${HASH_LENGTHS[value.length]} ${value}.`;
const reader = new Map(owners.map((owner, index) => [owner, readers[index]]));
const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item));
const setups = new Set(platforms.map((item) => item.owner));
const counted = [
held.length ? count(held.length, 'file in the collection', 'files in the collection') : 'not in the collection',
...(platforms.length ? [`${count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}`] : []),
...(owners.length ? [count(owners.length, 'emulator that reads it', 'emulators that read it')] : []),
];
status.textContent = `Found: ${counted.join(', ')}.`;
const blocks: Node[] = [];
const category = agreed(readEntries.map((entry) => entry && CATEGORY_WORDS[String(entry.category ?? 'bios')]));
const regions = agreed(readEntries.map((entry) => {
const raw = entry?.region;
const list = Array.isArray(raw) ? raw : raw ? [String(raw)] : [];
return list.length ? listed(list.map(regionWord)) : undefined;
}));
held.forEach((item, index) => {
const file = files[index];
const name = file?.name ?? item.name ?? value;
const name = file?.name ?? item.name ?? item.value;
const system = item.system_name;
const what: (Node | string)[] = system
? [', ', link(item.url, system), category ? ` ${category}` : ' file']
: category ? [`, a ${category} file`] : [];
const others = new Map<string, Set<string>>();
for (const declared of platforms) {
const other = declared.name ?? '';
if (anyName(other) || other === name) continue;
if (!others.has(other)) others.set(other, new Set());
others.get(other)!.add(declared.owner ?? '');
}
const renamed = new Set([...others.values()].flatMap((set) => [...set]));
const otherNames = [...others.keys()].sort();
const named: (Node | string)[] = [];
otherNames.forEach((other, position) => {
if (position) named.push(position === otherNames.length - 1 ? ' and ' : ', ');
named.push(code(other));
});
const listing: (Node | string)[] = setups.size
? [
` ${count(setups.size, 'setup lists', 'setups list')} it`,
...(renamed.size
? [`, ${renamed.size.toLocaleString('en')} of them under the ${otherNames.length === 1 ? 'name' : 'names'} `, ...named]
: []),
'.',
]
: [];
blocks.push(
file
? paragraph('This is ', code(name), ', held in the collection at ', code(file.path), '.')
: paragraph('This is ', code(name), ', held in the collection.'),
paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...listing),
paragraph(
...(file ? ['SHA1 ', code(file.sha1), `, ${formatSize(file.size)}. `] : []),
...(file ? ['Held in the collection at ', code(file.path), '. SHA1 ', code(file.sha1), `, ${formatSize(file.size)}. `] : []),
link(item.url, 'Open its card'),
'.',
),
@@ -300,12 +491,11 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, value: st
row.append(link(item.url, code(item.name || item.value)));
list.append(row);
}
blocks.push(paragraph(strong(LAYER_LABELS['data-directory'])), list);
blocks.push(paragraph(strong('In a data directory the packs ship')), list);
}
if (platforms.length) {
const owners = new Set(platforms.map((item) => item.owner));
blocks.push(
paragraph(strong(`Setups that list it (${owners.size})`)),
paragraph(strong('Setups that list it: '), `${count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}`),
setupTable(platforms.map((item, index) => ({
item,
entry: entries[index],
@@ -313,20 +503,96 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, value: st
}))),
);
}
const readers = byLayer('emulator');
if (readers.length) {
if (owners.length) {
const list = document.createElement('ul');
for (const item of readers) {
emulators.forEach((item) => {
const row = document.createElement('li');
row.append(link(item.url, item.owner ?? item.name ?? ''));
if (item.name && item.name !== held[0]?.name) row.append(' as ', code(item.name));
row.append(link(item.url, reader.get(item.owner ?? '')?.name ?? item.owner ?? item.name ?? ''));
if (item.name && item.name !== (files[0]?.name ?? held[0]?.name)) row.append(' as ', code(item.name));
list.append(row);
}
blocks.push(paragraph(strong(`${LAYER_LABELS.emulator} (${readers.length})`)), list);
});
blocks.push(paragraph(strong(`Emulators that read it (${owners.length.toLocaleString('en')})`)), list);
}
result.replaceChildren(...blocks);
};
const WANTED = 'provenance/#wanted-catalogued-dumps-this-project-does-not-have';
// Nothing the site knows has the value: where a dump of one's own can go.
const renderNoMatch = (status: HTMLElement, result: HTMLElement) => {
status.textContent = 'No file, setup or emulator the site knows has this hash.';
result.replaceChildren(
paragraph('If you dumped it yourself, ', link('contributing/', 'add it'), '.'),
paragraph('The ', link(WANTED, 'wanted list'), ' names the catalogued dumps the collection still lacks.'),
);
};
const lookUp = (status: HTMLElement, result: HTMLElement, values: string[]) => {
status.textContent = 'Looking it up...';
result.replaceChildren();
return lookupValues(values)
.then((matches) => (matches.length ? renderMatches(status, result, matches) : renderNoMatch(status, result)))
.catch(() => {
status.textContent = 'The file index could not be read.';
});
};
// A file chosen on files/ is hashed here, its four hashes shown, and all
// four looked up: a setup may list only the MD5.
const MAX_HASHED = 512 * 1024 ** 2;
const fileHasher = (status: HTMLElement, result: HTMLElement) => {
const form = document.querySelector('form.rb-file-lookup');
if (!form) return;
const box = document.createElement('div');
box.className = 'rb-file-pick';
const label = document.createElement('label');
label.htmlFor = 'rb-file';
label.textContent = 'Or choose the file';
const input = document.createElement('input');
input.type = 'file';
input.id = 'rb-file';
const note = paragraph('It is hashed on this device. Nothing is uploaded.');
note.className = 'rb-file-pick-note';
const hashes = document.createElement('p');
hashes.className = 'rb-file-pick-hashes';
hashes.hidden = true;
box.append(label, input, note, hashes);
form.after(box);
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
hashes.hidden = true;
if (file.size > MAX_HASHED) {
status.textContent = `This page hashes files up to ${formatSize(MAX_HASHED)}. ${file.name} is ${formatSize(file.size)}.`;
result.replaceChildren();
return;
}
status.textContent = `Hashing ${file.name}...`;
result.replaceChildren();
let found: FileHashes;
try {
found = await hashFile(file);
} catch {
status.textContent = `${file.name} could not be read.`;
return;
}
hashes.replaceChildren(
strong(file.name), ` ${formatSize(file.size)}`,
...(['sha1', 'md5', 'crc32', 'sha256'] as const).flatMap((algorithm) => [
document.createElement('br'), `${algorithm.toUpperCase()} `, code(found[algorithm]),
]),
);
hashes.hidden = false;
const field = document.getElementById('rb-hash') as HTMLInputElement | null;
if (field) field.value = found.sha1;
const url = new URL(location.href);
url.search = `?hash=${found.sha1}`;
history.replaceState(null, '', url);
await lookUp(status, result, [found.sha1, found.md5, found.crc32, found.sha256]);
});
};
// files/?sha1=<sha1> is a file's content address and opens its card
// wherever it is now; files/?hash= also takes an MD5, CRC32 or SHA256 and
// lists every page that names the value.
@@ -336,9 +602,11 @@ const resolveFileAddress = () => {
const result = document.createElement('div');
result.id = 'rb-file-lookup-result';
status.after(result);
fileHasher(status, result);
const params = new URLSearchParams(location.search);
const permalink = params.has('sha1');
const value = normalizeHash(params.get('sha1') || params.get('hash') || '');
const raw = params.get('sha1') || HASH_PARAMS.map((key) => params.get(key)).find(Boolean) || '';
const value = normalizeHash(raw);
if (!value) return;
const input = document.getElementById('rb-hash') as HTMLInputElement | null;
if (input) input.value = value;
@@ -347,18 +615,22 @@ const resolveFileAddress = () => {
status.textContent = 'A hash is 8 (CRC32), 32 (MD5), 40 (SHA1) or 64 (SHA256) hexadecimal characters.';
return;
}
if (!prefix && !permalink) {
lookUp(status, result, [value]);
return;
}
status.textContent = 'Looking it up...';
lookupShard(value)
.then((items) => {
const matches = items.filter((item) =>
prefix ? item.algorithm === 'sha1' && item.value.startsWith(value) : item.value === value);
const cards = matches.filter((item) => item.layer === 'collection' || item.layer === 'data-directory');
if (permalink && new Set(cards.map((item) => item.url)).size === 1) {
if (new Set(cards.map((item) => item.url)).size === 1) {
location.replace(new URL(cards[0].url, siteRoot()));
} else if (matches.length) {
return renderMatches(status, result, prefix ? matches[0].value : value, prefix ? cards : matches);
return renderMatches(status, result, prefix ? cards : matches);
} else {
status.textContent = 'No file the site knows has this hash.';
renderNoMatch(status, result);
}
})
.catch(() => {
@@ -383,32 +655,52 @@ const followMovedCard = () => {
};
// The site search holds no hashes. A hash typed in it gets "Look up this
// hash" as its first result, and Enter opens the lookup.
const hashLookupRow = (drawer: Element, value: string) => {
// hash" as its first result, and Enter opens the lookup. The pages Pagefind
// finds for the same characters (a file name that holds them, a word that
// starts like them) are no answer: they stay folded under a button that
// says what they are.
const hashLookupRow = (drawer: HTMLElement, value: string) => {
let row = drawer.querySelector<HTMLElement>(':scope > .rb-search-hash');
if (!isHash(value)) {
row?.remove();
delete drawer.dataset.rbHash;
delete drawer.dataset.rbPagesShown;
return;
}
drawer.dataset.rbHash = '';
const target = new URL(`files/?hash=${value}`, siteRoot()).href;
if (row?.querySelector('a')?.href === target) return;
if (!row) {
row = document.createElement('p');
row = document.createElement('div');
row.className = 'rb-search-hash';
drawer.prepend(row);
}
const target = new URL(`files/?hash=${value}`, siteRoot()).href;
if (row.querySelector('a')?.href === target) return;
delete drawer.dataset.rbPagesShown;
const anchor = document.createElement('a');
anchor.href = target;
anchor.append('Look up this hash');
row.replaceChildren(anchor, ` ${HASH_LENGTHS[value.length]} `, code(value));
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'rb-search-pages';
toggle.setAttribute('aria-expanded', 'false');
toggle.textContent = 'Pages that mention these characters';
toggle.addEventListener('click', () => {
const open = toggle.getAttribute('aria-expanded') !== 'true';
toggle.setAttribute('aria-expanded', String(open));
if (open) drawer.dataset.rbPagesShown = '';
else delete drawer.dataset.rbPagesShown;
});
row.replaceChildren(paragraph(anchor, ` ${HASH_LENGTHS[value.length]} `, code(value)), toggle);
};
// Pagefind lists a page's title twice: once for the page and once for the
// anchor of its h1 (#_top). The second title is hidden, its excerpt kept.
// The `files` field of a system page weighs its file names in the ranking
// and is never shown: a result is not a list of 120 names. Pagefind reuses
// The `files`, `aliases` and `devices` fields weigh in the ranking and are
// never shown: a result is not a list of 120 names. Pagefind reuses
// its nodes from one query to the next, so both are worked out again on
// every change.
const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);
const tidyResults = (root: Element) => {
for (const title of root.querySelectorAll<HTMLElement>('.pagefind-ui__result-nested > .pagefind-ui__result-title')) {
title.hidden = !!title.querySelector('a')?.getAttribute('href')?.endsWith('#_top');
@@ -416,7 +708,7 @@ const tidyResults = (root: Element) => {
for (const tags of root.querySelectorAll<HTMLElement>('.pagefind-ui__result-tags')) {
let shown = 0;
for (const tag of tags.querySelectorAll<HTMLElement>('.pagefind-ui__result-tag')) {
tag.hidden = tag.dataset.pagefindUiMeta === 'files';
tag.hidden = UNSHOWN_FIELDS.has(tag.dataset.pagefindUiMeta ?? '');
if (!tag.hidden) shown += 1;
}
tags.hidden = shown === 0;
@@ -429,7 +721,7 @@ const hashSearch = () => {
const refresh = () => {
tidyResults(root);
const input = root.querySelector<HTMLInputElement>('input');
const drawer = root.querySelector('.pagefind-ui__drawer');
const drawer = root.querySelector<HTMLElement>('.pagefind-ui__drawer');
if (input && drawer) hashLookupRow(drawer, normalizeHash(input.value));
};
new MutationObserver(refresh).observe(root, {
@@ -457,6 +749,31 @@ const hashSearch = () => {
);
};
// The OS tabs scroll sideways on a phone instead of wrapping to a second
// row (retrobios.css): the chosen one is kept in view, on load and on a
// change, by scrolling the row and never the page.
const keepTabInView = (tab: Element) => {
const list = tab.closest('[role="tablist"]');
if (!list) return;
const row = list.getBoundingClientRect();
const box = tab.getBoundingClientRect();
if (box.left < row.left) list.scrollLeft -= row.left - box.left;
else if (box.right > row.right) list.scrollLeft += box.right - row.right;
};
const osTabs = () => {
const selected = () => document.querySelectorAll('starlight-tabs [role="tab"][aria-selected="true"]').forEach(keepTabInView);
if (!document.querySelector('starlight-tabs')) return;
const later = () => requestAnimationFrame(selected);
for (const type of ['click', 'keyup']) {
document.addEventListener(type, (event) => {
if ((event.target as Element | null)?.closest?.('starlight-tabs [role="tab"]')) later();
});
}
if (document.readyState === 'complete') later();
else addEventListener('load', later, { once: true });
};
// A glossary term shows its definition on hover; a tap or Enter shows it
// on a phone or to a keyboard, and Escape or a tap elsewhere closes it.
const glossaryTerms = () => {
@@ -505,4 +822,5 @@ followMovedCard();
enhanceTables();
hashSearch();
glossaryTerms();
osTabs();
notFound();
+1 -1
View File
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
| 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_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_css`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `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_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_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture