diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 938c241fd..db91b02d7 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -32,6 +32,7 @@ import urllib.error
import urllib.parse
import urllib.request
import zipfile
+from collections import Counter
from collections.abc import Callable, Iterator
from datetime import datetime, timezone
from pathlib import Path
@@ -5392,6 +5393,17 @@ def _places_lines(places: list[Place]) -> list[str]:
]
+def _places_markdown(places: list[Place]) -> list[str]:
+ """_places_lines in Markdown, for a card written as a list: the places
+ as code spans, which the glossary and the line breaks treat as code."""
+ by_place: dict[tuple[str, ...], list[str]] = {}
+ for name, href, dests in places:
+ by_place.setdefault(tuple(dests), []).append(f"[{name}]({href})")
+ ordered = sorted(by_place.items(), key=lambda item: (not item[0], -len(item[1]), item[0]))
+ return [(" and ".join(_code(d) for d in dests) + ": " if dests else "") + ", ".join(links)
+ for dests, links in ordered]
+
+
def _inline_card_html(text: str) -> str:
"""A card value written in Markdown (code spans, links), as the HTML a
fact list holds. HTML already in the value is kept."""
@@ -5658,8 +5670,11 @@ def _system_file_card(
for label, value in (("SHA1", sha1_full), ("MD5", md5_full),
("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))):
if value and value != "unknown":
- facts.append((label, [f'{html.escape(str(value))}'],
- False))
+ # Starlight writes the value bare: its cell carries the hash
+ # style and the copy button (_fact_list).
+ shown = (html.escape(str(value)) if markup.starlight()
+ else f'{html.escape(str(value))}')
+ facts.append((label, [shown], False))
if not markup.starlight():
# The size yardstick pages are cut by: the list a card was when the
@@ -5667,26 +5682,109 @@ def _system_file_card(
lines.extend(search_excluded([f"- {label}: {', '.join(values)}"
for label, values, _searched in facts]))
else:
- # A grid of facts, label then value: two bullet lists of code chips
- # read as a dump. What the search skips is one list with one
- # attribute: on each row it was 77 KB of the Sony page.
- read = [fact for fact in facts if fact[2] and searchable]
- skipped = [fact for fact in facts if fact not in read]
- for group, attributes in ((read, ""), (skipped, f" {skip}")):
- if group:
- lines.append(f'
')
- # One value cell per label: the grid pairs each dt with one dd.
- # A searched value keeps its label out: the excerpt ran the two
- # together, "On the setup's listAmberELEC".
- label_skip = f" {skip}" if not attributes else ""
- lines.extend(f"
{label}
"
- + " ".join(_inline_card_html(v) for v in values) + "
"
- for label, values, _searched in group)
- lines.append("
")
+ lines.extend(_card_fact_groups(facts, sha1_full, searchable))
lines.extend(["", "", ""])
return lines
+# What a system card shows at a glance: who lists the file and who reads it.
+# Its fingerprints and the rest open on demand, so a page of 250 cards reads
+# as a list of files, not a wall of hashes.
+_CARD_KEY_FACTS = ("On the setup's list", "Packed for its emulators by", "Emulators")
+_CARD_HASHES = ("SHA1", "MD5", "CRC32", "SHA256")
+# The words a card's fold names its other facts by.
+_CARD_MORE_WORDS = {"Romset": "romset", "Collection path": "collection path",
+ "Catalogued": "catalogues"}
+# A SHA1 shown short: as long as the card's own address (sha1-<12 hex>).
+SHORT_HASH = 12
+
+
+def _listed_words(words: list[str]) -> str:
+ """"a", "a and b", "a, b and c", first letter capital."""
+ text = words[0] if len(words) == 1 else f"{', '.join(words[:-1])} and {words[-1]}"
+ return text[:1].upper() + text[1:]
+
+
+def _fact_list(facts: list[tuple[str, list[str], bool]], skip: str,
+ copy: dict[str, str] | None = None) -> list[str]:
+ """Facts as a grid, label then value, one value cell per label.
+
+ A label in *copy* holds one hash: its cell takes the hash style, and
+ the site script a copy button (site.ts). *copy* gives the cell's
+ classes. One element, where a span in the cell was one more per hash.
+ """
+ if not facts:
+ return []
+ copy = copy or {}
+
+ def cell(label: str, values: list[str]) -> str:
+ if label in copy:
+ return f'
{values[0]}
'
+ return "
" + " ".join(_inline_card_html(v) for v in values) + "
"
+
+ return [f'
',
+ *(f"
{label}
{cell(label, values)}" for label, values, _searched in facts),
+ "
"]
+
+
+def card_fold(summary: str, body: list[str], skip: bool = True) -> list[str]:
+ """A fold of a file card: closed, its summary naming what it holds.
+
+ Native details: no script needed, and the text inside stays in the page
+ (and in the search, unless *skip*). No class: the card styles its folds,
+ and a class was 21 bytes on each of 250 cards."""
+ attribute = f" {markup.search_exclude()}" if skip else ""
+ summary_line = f"{html.escape(summary, quote=False)}"
+ # Each tag on a line of its own, or MDX reads the fold as a paragraph
+ # left open. Markdown inside also takes a blank line around it.
+ gap = [] if body and body[0].startswith("<") else [""]
+ return [f"", summary_line, *gap, *body, *gap, ""]
+
+
+def _card_fact_groups(facts: list[tuple[str, list[str], bool]], sha1: str,
+ searchable: bool) -> list[str]:
+ """A system card's facts under Starlight: the key ones open, a short
+ SHA1 first, then the hashes and the rest in one closed fold.
+
+ The search skips all of them, as it did when they were one list: on
+ each row the attribute was 77 KB of the Sony page. Hashes are found by
+ files/?hash=, which the search box offers for a pasted hash.
+ """
+ skip = f" {markup.search_exclude()}"
+ key = [fact for fact in facts if fact[0] in _CARD_KEY_FACTS]
+ hashes = [fact for fact in facts if fact[0] in _CARD_HASHES]
+ more = [fact for fact in facts if fact not in key and fact not in hashes]
+ searched = [fact for fact in facts if fact[2] and searchable]
+ lines: list[str] = []
+ if searched:
+ # No fact is searched today. One that is keeps its label out: the
+ # excerpt ran the two together, "On the setup's listAmberELEC".
+ lines += ['
',
+ *(f"
{label}
"
+ + " ".join(_inline_card_html(v) for v in values) + "
"
+ for label, values, _searched in searched),
+ "
"]
+ key = [fact for fact in key if fact not in searched]
+ more = [fact for fact in more if fact not in searched]
+ glance = []
+ if sha1 and sha1 != "unknown":
+ # The first characters, enough to tell two dumps apart at a glance.
+ # Its button copies the whole value, read from the fold below: the
+ # value twice was 54 bytes more on each card.
+ glance.append(("SHA1", [html.escape(sha1[:SHORT_HASH])], False))
+ lines += _fact_list(glance + key, skip, {"SHA1": "rb-hash"})
+ # One fold: two were four more elements on each of 250 cards.
+ folded = hashes + more
+ if folded:
+ words = (["hashes"] if hashes else []) + [
+ _CARD_MORE_WORDS.get(label, label.lower()) for label, _values, _searched in more]
+ # On one line: nothing reads it as Markdown, and a page cut by size
+ # holds its cards in the same bytes.
+ lines += card_fold(_listed_words(words),
+ ["".join(_fact_list(folded, "", dict.fromkeys(_CARD_HASHES, "rb-hash")))])
+ return lines
+
+
SEARCH_TABLE_BYTES = 3000
@@ -7635,15 +7733,18 @@ def _render_emulator_file(
if fmax:
bounds.append(f"max {_fmt_size(fmax)}")
details.append(f"Size: {', '.join(bounds)}")
+ # Starlight folds the hashes and gives each a copy button.
+ copyable = markup.starlight()
if fsha1:
- details.append(f"SHA1: {_hash_spans(fsha1)}")
+ details.append(f"SHA1: {_hash_spans(fsha1, copyable)}")
if fmd5:
- details.append(f"MD5: {_hash_spans(fmd5)}")
+ details.append(f"MD5: {_hash_spans(fmd5, copyable)}")
if fcrc32:
crcs = fcrc32 if isinstance(fcrc32, list) else [fcrc32]
- details.append("CRC32: " + ", ".join(f"`{crc}`" for crc in crcs))
+ details.append("CRC32: " + ", ".join(
+ markup.hash_value(crc, True) if copyable else f"`{crc}`" for crc in crcs))
if fsha256:
- details.append(f"SHA256: {_hash_spans(fsha256)}")
+ details.append(f"SHA256: {_hash_spans(fsha256, copyable)}")
if fadler32:
# Printed as the downloads spell a hash, one per code span: the list
# repr read `['0x66f334fe', ...]`. Dolphin hashes its DSP ROMs over
@@ -7710,7 +7811,14 @@ def _render_emulator_file(
if copy:
details.append(_held_copy_line(copy, consumers))
plat_links = _platform_links(sha1, consumers) if sha1 else []
- if plat_links:
+ if plat_links and markup.starlight():
+ # Grouped by where the setups put the file, as the system card
+ # says it: twelve setups repeating scph5502.bin took twelve
+ # names and twelve paths where a few say it. One sentence a
+ # place: a list or a line break was one more element a place.
+ places = _places_markdown(_platform_places(sha1, consumers))
+ details.append("Platforms: " + ". ".join(places))
+ elif plat_links:
details.append(f"Platforms: {', '.join(plat_links)}")
if dest and dest != fname and dest != fpath:
@@ -7726,6 +7834,11 @@ def _render_emulator_file(
if size_range:
details.append(f"Size range: {size_range}")
+ if markup.starlight():
+ lines.extend(_emulator_card_folds(f, details, fnote, profile, in_repo, contents,
+ data_href))
+ lines.extend(["", "", ""])
+ return lines
if details:
for d in details:
lines.append(f"- {d}")
@@ -7738,26 +7851,7 @@ def _render_emulator_file(
f"- Not collectable: {_escape_tags(str(f['unsourceable']))} "
"([acknowledged gaps](../gaps.md#acknowledged-gaps))"
)
- if contents:
- # Every member with the hashes the emulator checks: MAME and FBNeo
- # verify the members, never the archive, so a member's crc32 is the
- # value a reader compares a set against.
- lines.append(f"- Contents ({len(contents)} members):")
- for c in contents:
- if isinstance(c, dict):
- parts = []
- if c.get("description"):
- parts.append(_escape_tags(str(c["description"])))
- if c.get("size"):
- parts.append(_fmt_size(c["size"]))
- if c.get("crc32"):
- parts.append(f"CRC32 `{c['crc32']}`")
- if c.get("sha1"):
- parts.append(f"SHA1 `{c['sha1']}`")
- extra = f": {', '.join(parts)}" if parts else ""
- lines.append(f" - `{c.get('name', '')}`{extra}")
- else:
- lines.append(f" - {c}")
+ lines.extend(_contents_lines(contents))
lines[body_start:] = _search_skipped_apart(lines[body_start:])
lines.append("")
lines.append("")
@@ -7765,6 +7859,148 @@ def _render_emulator_file(
return lines
+def _contents_lines(contents: list) -> list[str]:
+ """Every member with the hashes the emulator checks: MAME and FBNeo
+ verify the members, never the archive, so a member's crc32 is the
+ value a reader compares a set against."""
+ if not contents:
+ return []
+ lines = [f"- Contents ({len(contents)} members):"]
+ for c in contents:
+ if isinstance(c, dict):
+ parts = []
+ if c.get("description"):
+ parts.append(_escape_tags(str(c["description"])))
+ if c.get("size"):
+ parts.append(_fmt_size(c["size"]))
+ if c.get("crc32"):
+ parts.append(f"CRC32 `{c['crc32']}`")
+ if c.get("sha1"):
+ parts.append(f"SHA1 `{c['sha1']}`")
+ extra = f": {', '.join(parts)}" if parts else ""
+ lines.append(f" - `{c.get('name', '')}`{extra}")
+ else:
+ lines.append(f" - {c}")
+ return lines
+
+
+# What an emulator card shows at a glance: where the file goes, how big it
+# is, which setups list it and which copy answers it. Fingerprints and the
+# reading details open on demand.
+_ENTRY_KEY_FACTS = ("Path:", "Standalone path:", "Read from:", "Found by:", "System:",
+ "Size:", "Aliases:", "Collection copy:", "Collection copies:",
+ "Platforms", "Destination:")
+_ENTRY_HASHES = ("SHA1:", "MD5:", "CRC32:", "SHA256:", "Adler32")
+# The words the fold of an emulator card names its facts by.
+_ENTRY_MORE_WORDS = (
+ ("Priority", "priority"), ("Fast boot", "fast boot"), ("Validation", "validation"),
+ ("On a failed", "failed check"), ("Installed by the platform's own package", "packages"),
+ ("Not in the platform's package", "packages"), ("Never opened", "unread setups"),
+ ("Asked for by", "source"), ("Source", "source"), ("Config key", "config key"),
+ ("Pattern", "pattern"), ("Region check", "region check"), ("Size note", "size note"),
+ ("Size range", "size range"),
+)
+
+
+def _more_words(lines: list[str]) -> list[str]:
+ """The words a fold names its facts by, once each, in their order."""
+ words: list[str] = []
+ for line in lines:
+ text = re.sub(r"<[^>]+>", "", line.removeprefix("- "))
+ word = next((w for prefix, w in _ENTRY_MORE_WORDS if text.startswith(prefix)),
+ text.split(":", 1)[0].lower())
+ if word not in words:
+ words.append(word)
+ return words
+
+
+def _short_hash_line(f: dict) -> str:
+ """The entry's strongest declared hash, cut to SHORT_HASH characters,
+ with a copy button for the whole value. "" when it declares none."""
+ from hashing import parse_hash_list # noqa: PLC0415
+
+ for field, label in (("sha1", "SHA1"), ("md5", "MD5"), ("sha256", "SHA256"), ("crc32", "CRC32")):
+ values = parse_hash_list(f.get(field) or "")
+ if values:
+ value = str(values[0])
+ more = f" and {len(values) - 1} more" if len(values) > 1 else ""
+ return (f'{label}: '
+ f"{html.escape(value[:SHORT_HASH])}{more}")
+ return ""
+
+
+def _emulator_card_folds(
+ f: dict, details: list[str], fnote: object, profile: dict, in_repo: bool,
+ contents: list, data_href: str,
+) -> list[str]:
+ """An emulator card's body under Starlight: the key facts and the notes
+ open, the hashes and the rest in one closed fold.
+
+ Lines the search reads and lines it skips are kept in two runs: mixed,
+ each change of side was one more list and one more block, five lists
+ on a card of a dozen lines.
+ """
+ def skipped(line: str) -> bool:
+ return f"- {line}".startswith(SEARCH_SKIPPED)
+
+ key = [d for d in details if d.startswith(_ENTRY_KEY_FACTS)]
+ hashes = [d for d in details if d.startswith(_ENTRY_HASHES)]
+ more = [d for d in details if d not in key and d not in hashes]
+ short = _short_hash_line(f)
+ shut_open = [d for d in key if skipped(d)]
+ sized = next((i for i, d in enumerate(shut_open) if d.startswith("Size:")), None)
+ if short and sized is not None:
+ # On the size's line: what tells this dump from another, one list
+ # item fewer on each card.
+ shut_open[sized] += ", " + short.replace(": ", " ", 1)
+ elif short:
+ shut_open.insert(0, short)
+ # What the search reads first, the notes included, then what it skips:
+ # one list each.
+ open_lines = [d for d in key if not skipped(d)]
+ if fnote:
+ open_lines.append(data_root_words(
+ _escape_tags(link_prose_citations(profile, str(fnote))), data_href))
+ if not in_repo and f.get("unsourceable"):
+ # The badge alone sent readers after a dump that cannot exist, and the
+ # reason was only on the gaps page, which the card did not link.
+ open_lines.append(
+ f"Not collectable: {_escape_tags(str(f['unsourceable']))} "
+ "([acknowledged gaps](../gaps.md#acknowledged-gaps))"
+ )
+ lines = [f"- {d}" for d in open_lines]
+ shut_fold = [f"- {d}" for d in hashes + [m for m in more if skipped(m)]]
+ read_fold = [f"- {m}" for m in more if not skipped(m)]
+ # The members of an archive, a list of their own under the fold's
+ # summary, which counts them: a "Contents" item holding them was two
+ # more elements and 44 bytes on each of 200 MAME cards.
+ members = [f"* {line.removeprefix(' - ')}" for line in _contents_lines(contents)[1:]]
+ fold: list[str] = []
+ if shut_fold or read_fold or members:
+ words = (["hashes"] if hashes else []) + _more_words(
+ [line for line in shut_fold + read_fold if not line.startswith(
+ tuple(f"- {h}" for h in _ENTRY_HASHES))])
+ words = words if len(words) <= 3 else [*words[:3], "more"]
+ if members:
+ words.append(_count(len(members), "member"))
+ hidden_part = [*shut_fold, "", *members] if members else shut_fold
+ body = (hidden_part if not read_fold else [
+ "", markup.block_open("div", f'class="rb-search-skip" {markup.search_exclude()}'),
+ "", *hidden_part, "", "", "", *read_fold])
+ fold = card_fold(_listed_words(words), body, skip=False)
+ # A fold the search skips whole sits in the skipped block before it, with
+ # no attribute of its own: on a page of 200 MAME cards the attribute, the
+ # class and a block each were 12 KB.
+ hidden = bool(fold) and not read_fold
+ shut = [f"- {d}" for d in shut_open] + (["", *fold] if hidden else [])
+ if shut:
+ lines += ["", markup.block_open("div", f'class="rb-search-skip" {markup.search_exclude()}'),
+ "", *shut, "", "", ""]
+ if fold and not hidden:
+ lines += ["", *fold, ""]
+ return lines
+
+
def _held_copy_line(copy: tuple[str, str], consumers: FileConsumers) -> str:
"""Where a copy the database does not index sits: a ZIP member under
bios/, or a file or tree of a data directory the packs fetch."""
@@ -8350,6 +8586,85 @@ def _ps_quote(value: str) -> str:
return value if re.fullmatch(r"[\w.+-]+", value) else "'" + value.replace("'", "''") + "'"
+# The OS tabs of a system's install box: labels the site's other command
+# tabs use, so the reader's choice carries from page to page.
+INSTALL_PICKER_OSES = ("windows", "macos", "linux", "steamdeck")
+
+
+def _install_args(platform: str, system: str, shell: str) -> str:
+ quoted = _ps_quote(system) if shell == "powershell" else shlex.quote(system)
+ return f"--platform {platform} --system {quoted}"
+
+
+def _install_picker(declared: list[tuple[str, str]]) -> list[str]:
+ """One command, for the setup the reader picks, under the OS tabs.
+
+ Every setup's command for a console was written out, twice: 12 long
+ lines for the PSP, about 360 page elements once highlighted. The
+ picker shows one and the script (site.ts) rewrites it for the setup
+ chosen, remembered from page to page. Without the script the picker
+ stays hidden and the plain list of every command shows, open.
+ """
+ def attribute(text: str) -> str:
+ # Quotes kept as written: a system id quoted for the shell ('Neo Geo').
+ return html.escape(text, quote=False).replace('"', """)
+
+ names = Counter(platform for platform, _system in declared)
+ options = "".join(
+ f'"
+ for platform, system in declared)
+ lead = declared[0]
+ args = {key: _install_args(*lead, TERMINALS[key][2]) for key in INSTALL_PICKER_OSES}
+ return [
+ "Each platform names the system its own way.",
+ "",
+ '
",
+ "",
+ # Each command as inline code, no highlighting: the list is the
+ # fallback and the reference, and highlighted it was most of the
+ # box's elements.
+ '',
+ "Every setup's command",
+ "",
+ *_command_list("Terminal (Linux, macOS, Steam Deck):", declared, "sh"),
+ *_command_list("PowerShell (Windows):", declared, "powershell"),
+ "",
+ ]
+
+
+def _command_list(label: str, declared: list[tuple[str, str]], shell: str) -> list[str]:
+ """Every setup's command for one shell, a line each, with a copy button.
+
+ Inline HTML code, so no break element is added at each slash. A break
+ point follows the URL's scheme: the Markdown around the code made the
+ bare URL a link inside the command, and a scheme with nothing after it
+ is no link. The break adds no character to a copy."""
+ def command(platform: str, system: str) -> str:
+ text = html.escape(install_command(shell, _install_args(platform, system, shell)), quote=False)
+ return text.replace("://", "://")
+
+ # One HTML line: the glossary leaves HTML alone, and it marked a word
+ # inside a command.
+ names = Counter(platform for platform, _system in declared)
+ items = "".join(
+ f"
'
+ for platform, system in declared)
+ return [label, "", f"
{items}
", ""]
+
+
def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]:
"""Each platform's command for one console, by the system id it gives it,
for a terminal and for PowerShell, each under its setup's name: RetroBat
@@ -8367,6 +8682,8 @@ def _console_install_block(console: str, declared: list[tuple[str, str]]) -> lis
# the files of Other arcade files".
summary = ("Install these files" if console.casefold().endswith(" files")
else f"Install the files of {console}")
+ if markup.starlight() and len(declared) > 1:
+ return search_excluded(markup.details("tip", summary, _install_picker(declared)))
return search_excluded(markup.details("tip", summary, [
"Each platform names the system its own way.",
"",
@@ -8485,8 +8802,9 @@ def _render_structured_blocks(profile: dict) -> list[str]:
return lines
-def _hash_spans(value: str | list) -> str:
- """Each hash a profile accepts, in full.
+def _hash_spans(value: str | list, copy: bool = False) -> str:
+ """Each hash a profile accepts, in full, each with a copy button when
+ *copy*.
Cut to 12 characters with the rest in a title attribute, 208 hashes
that only a profile declares were out of reach of the site search: it
@@ -8495,7 +8813,7 @@ def _hash_spans(value: str | list) -> str:
from hashing import parse_hash_list # noqa: PLC0415
return ", ".join(
- markup.hash_value(v) for v in parse_hash_list(value)
+ markup.hash_value(v, copy) for v in parse_hash_list(value)
)
diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py
index a2a24f2d1..2660f04b9 100644
--- a/scripts/sitemarkup.py
+++ b/scripts/sitemarkup.py
@@ -156,13 +156,15 @@ def image(alt: str, src: str, **attributes: str) -> str:
return f''
-def hash_value(value: object) -> str:
+def hash_value(value: object, copy: bool = False) -> str:
"""A hash printed whole, in the hash style. One element under Starlight:
- a span around a code span was 1,400 extra elements on a page of cards."""
+ a span around a code span was 1,400 extra elements on a page of cards.
+ *copy* marks it for the copy button the site script adds after it."""
text = str(value).replace("`", "'")
if _engine == "mkdocs":
return f'`{text}`'
- return f'{html.escape(text, quote=False)}'
+ marker = ' data-copy=""' if copy else ""
+ return f'{html.escape(text, quote=False)}'
def block_open(tag: str, attributes: str = "") -> str:
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 242753992..7e068a04d 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -5049,7 +5049,7 @@ class CollectionCardsCarryFullHashes(unittest.TestCase):
"crc32": "504086c1", "sha256": "b" * 64}
text = "\n".join(_system_file_card(entry, FileConsumers({}, {})))
for value in (entry["sha1"], entry["md5"], entry["crc32"], entry["sha256"]):
- self.assertIn(f'{value}', text)
+ self.assertIn(f'
{value}
', text)
diff --git a/tests/test_site_density_cards.py b/tests/test_site_density_cards.py
new file mode 100644
index 000000000..adf141516
--- /dev/null
+++ b/tests/test_site_density_cards.py
@@ -0,0 +1,302 @@
+"""File cards and install boxes read at a glance (density round, 2026-10-27).
+
+A system page wrote every setup's install command for each console, twice:
+12 long lines for the PSP. A file card showed four full hashes, the
+collection path and the catalogue records before the next file. Now a box
+shows one command for the setup picked, and a card shows the name, the
+size, the download, a short SHA1, the setups and the emulators. The rest
+sits in closed folds, every fact still on the page.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import types
+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 as gs # noqa: E402
+import sitemarkup as markup # noqa: E402
+from cross_reference import FileConsumers # noqa: E402
+
+DIST = REPO_ROOT / "website" / "dist"
+SITE_TS = REPO_ROOT / "website" / "src" / "scripts" / "site.ts"
+
+SHA1 = "30a33f3651f8e1b0967a5dfe6ff714baebcce41a"
+MD5 = "866855cc330b9b95cc69135fb7b41d38"
+CRC32 = "7b57fa78"
+SHA256 = "3ab7fcf6956d0281ada94d501fc6d1cfa8fc034f139e25fed6c829f723d92a95"
+
+
+class _Visible(HTMLParser):
+ """Words shown before any fold is opened: text outside closed details
+ (their summary counts), hidden elements, scripts and styles."""
+
+ VOID = {"br", "img", "input", "wbr", "hr", "meta", "link", "source"}
+
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.stack: list[tuple[str, object]] = []
+ self.hidden = 0
+ self.words: list[str] = []
+
+ def handle_starttag(self, tag: str, attrs: list) -> None:
+ if tag in self.VOID:
+ return
+ names = dict(attrs)
+ if tag == "summary" and self.stack and self.stack[-1] == ("details", True):
+ self.hidden -= 1
+ self.stack.append((tag, "shown"))
+ return
+ hides = tag in ("script", "style") or "hidden" in names or (
+ tag == "details" and "open" not in names)
+ self.hidden += hides
+ self.stack.append((tag, hides))
+
+ def handle_endtag(self, tag: str) -> None:
+ while self.stack:
+ name, hides = self.stack.pop()
+ if hides == "shown":
+ self.hidden += 1
+ elif hides:
+ self.hidden -= 1
+ if name == tag:
+ break
+
+ def handle_data(self, data: str) -> None:
+ if not self.hidden:
+ self.words += data.split()
+
+
+def visible_words(html_text: str) -> list[str]:
+ parser = _Visible()
+ parser.feed(html_text)
+ return parser.words
+
+
+def _main(page: str) -> str:
+ text = (DIST / page / "index.html").read_text(encoding="utf-8")
+ return text[text.index("")]
+
+
+def _card_consumers(sha1: str) -> types.SimpleNamespace:
+ return types.SimpleNamespace(
+ platforms={sha1: {"batocera", "retroarch"}}, emulators={sha1: {"ppsspp"}},
+ platform_systems={sha1: {"batocera": {"sony-psp"}, "retroarch": {"sony-psp"}}},
+ platform_destinations={sha1: {"batocera": {"psp/ppge_atlas.zim"},
+ "retroarch": {"PPSSPP/ppge_atlas.zim"}}},
+ installed={sha1: {"knulli": {"PPSSPP/ppge_atlas.zim"}}},
+ emulator_entries={}, emulator_files={},
+ )
+
+
+def _card() -> dict:
+ return {"name": "ppge_atlas.zim", "sha1": SHA1, "md5": MD5, "crc32": CRC32,
+ "sha256": SHA256, "size": 666539,
+ "path": "bios/Sony/PlayStation Portable/ppge_atlas.zim",
+ "provenance": {"tosec": {"name": "ppge_atlas (PPSSPP).zim"}}}
+
+
+class ASystemCardShowsTheAnswerFirst(unittest.TestCase):
+ def setUp(self) -> None:
+ self.lines = gs._system_file_card(_card(), _card_consumers(SHA1))
+ self.text = "\n".join(self.lines)
+ self.fold = self.text[self.text.index("")]
+ self.shown = self.text.replace(self.fold, "")
+
+ def test_the_open_part(self) -> None:
+ for fact in ("ppge_atlas.zim", ">650.9 KB<", ">Download",
+ "
On the setup's list
", "
Packed for its emulators by
",
+ "
Emulators
", f'
SHA1
{SHA1[:12]}
'):
+ self.assertIn(fact, self.shown)
+
+ def test_every_hash_whole_in_one_fold_with_the_rest(self) -> None:
+ for label, value in (("SHA1", SHA1), ("MD5", MD5), ("CRC32", CRC32), ("SHA256", SHA256)):
+ self.assertIn(f'
{label}
{value}
', self.fold)
+ shown = " ".join(visible_words(self.text))
+ for value in (MD5, CRC32, SHA256, SHA1):
+ self.assertNotIn(value, shown)
+ self.assertIn("
Collection path
", self.fold)
+ self.assertIn("
Catalogued
", self.fold)
+ self.assertEqual(self.text.count("Hashes, collection path and catalogues", self.fold)
+
+ def test_the_fold_is_out_of_the_search(self) -> None:
+ self.assertTrue(self.fold.startswith("\n"))
+
+ def test_no_fact_is_lost(self) -> None:
+ # The yardstick list a page is cut by holds every fact: the
+ # Starlight card holds each of its values.
+ with markup.using("mkdocs"):
+ yardstick = "\n".join(gs._system_file_card(_card(), _card_consumers(SHA1)))
+ for label, values in re.findall(r"(?m)^- ([^:]+): (.*)$", yardstick):
+ self.assertIn(f"