mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-04 17:13:46 -06:00
feat: platform file rows that open full width
Each file is a details whose summary is the row: name, folder when it is not the bare name, status chip. The short hash sits on one copy button beside it, since a summary is itself a control. Opened, the evidence runs across the list. A list with no folder drops the column. Code spans in the evidence are plain text. The lists split by the share of a page they render to (elements against 10,000, bytes against 600,000) instead of Markdown length. With its parts, Batocera goes from 12 pages, 18,854 elements and 1.21 MB to 10 pages, 13,671 and 1.11 MB. A part that folds back into its platform page redirects there, from the record in website/src/data/platform-parts.json.
This commit is contained in:
1 parent
20dc488e41
commit
e7d33caa3f
10 files changed
+440
-192
No files matched your search
+162
-108
@@ -3238,44 +3238,35 @@ def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -
|
|||||||
return f"{page.rstrip()}\n\n{table}"
|
return f"{page.rstrip()}\n\n{table}"
|
||||||
|
|
||||||
|
|
||||||
# A platform system whose file list passes this much Markdown gets a page
|
# A platform's file lists are split by the share of a page they render to,
|
||||||
# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page.
|
# in thousandths: their elements against validate_site's MAX_PAGE_ELEMENTS,
|
||||||
PLATFORM_SECTION_BUDGET = 200_000
|
# their bytes against the 600,000 of test_site_r2_gate, whichever is
|
||||||
# A system's own page in runs of at most this much Markdown: RetroDECK's
|
# larger. Measured in Markdown characters, the lists split anew whenever
|
||||||
# arcade list, each file with its served copy and pinned readers, rendered
|
# their markup changed: rows written as tables moved 38 more systems to
|
||||||
# 1.4 MB of HTML on one page.
|
# pages of their own.
|
||||||
PLATFORM_PART_BUDGET = 350_000
|
PAGE_ELEMENTS = 10_000
|
||||||
# The Markdown of a platform page's file lists, past which the largest
|
PAGE_BYTES = 600_000
|
||||||
|
# A system whose list passes this share gets a page of its own.
|
||||||
|
PLATFORM_SECTION_BUDGET = 400
|
||||||
|
# A system's own page in runs of at most this share.
|
||||||
|
PLATFORM_PART_BUDGET = 700
|
||||||
|
# The share of a platform page's file lists, past which the largest
|
||||||
# systems move to pages of their own.
|
# systems move to pages of their own.
|
||||||
PLATFORM_REFERENCE_BUDGET = 250_000
|
PLATFORM_REFERENCE_BUDGET = 600
|
||||||
|
|
||||||
|
|
||||||
def _file_runs(body: list[str], budget: int) -> list[list[str]]:
|
# Every part page a platform list has had, with the system it held:
|
||||||
"""A system's file list in runs of at most ``budget`` characters.
|
# platforms/batocera-atari-st.md -> platforms/batocera.md#atari-st. When a
|
||||||
|
# budget or a list changes, a part can fold back into its platform page,
|
||||||
|
# and its address then redirects there instead of ending on the 404 page.
|
||||||
|
PLATFORM_PARTS_FILE = (Path(__file__).resolve().parent.parent
|
||||||
|
/ "website" / "src" / "data" / "platform-parts.json")
|
||||||
|
|
||||||
The lines before the first file (counts, emulators, a table's header)
|
|
||||||
open every run. A file is never cut from its hash and evidence lines,
|
def _published_platform_parts() -> dict[str, str]:
|
||||||
and a table row is one line.
|
if not PLATFORM_PARTS_FILE.is_file():
|
||||||
"""
|
return {}
|
||||||
rows = next((i + 1 for i, line in enumerate(body) if line.startswith("|--")), None)
|
return json.loads(PLATFORM_PARTS_FILE.read_text(encoding="utf-8"))
|
||||||
first = rows if rows is not None else next(
|
|
||||||
(i for i, line in enumerate(body) if line.startswith("- ")), len(body))
|
|
||||||
head, items = body[:first], []
|
|
||||||
for line in body[first:]:
|
|
||||||
if line.startswith(("- ", "| ")) or not items:
|
|
||||||
items.append([line])
|
|
||||||
else:
|
|
||||||
items[-1].append(line)
|
|
||||||
runs: list[list[str]] = [[]]
|
|
||||||
size = 0
|
|
||||||
for item in items:
|
|
||||||
weight = sum(len(line) + 1 for line in item)
|
|
||||||
if runs[-1] and size + weight > budget:
|
|
||||||
runs.append([])
|
|
||||||
size = 0
|
|
||||||
runs[-1].extend(item)
|
|
||||||
size += weight
|
|
||||||
return [head + run for run in runs]
|
|
||||||
|
|
||||||
|
|
||||||
# A list moved off a reference page (gaps, provenance) is cut in parts of
|
# A list moved off a reference page (gaps, provenance) is cut in parts of
|
||||||
@@ -3283,13 +3274,18 @@ def _file_runs(body: list[str], budget: int) -> list[list[str]]:
|
|||||||
LIST_PART_BUDGET = 300_000
|
LIST_PART_BUDGET = 300_000
|
||||||
|
|
||||||
|
|
||||||
def _blocks_in_runs(blocks: list[list[str]], budget: int) -> list[list[str]]:
|
def _characters(lines: list[str]) -> int:
|
||||||
"""Blocks (table rows, folded lists) in runs of at most ``budget``
|
return sum(len(line) + 1 for line in lines)
|
||||||
characters, a block never cut."""
|
|
||||||
|
|
||||||
|
def _blocks_in_runs(blocks: list[list[str]], budget: int,
|
||||||
|
weigh: Callable[[list[str]], int] = _characters) -> list[list[str]]:
|
||||||
|
"""Blocks (table rows, folded lists) in runs of at most ``budget``, by
|
||||||
|
default in characters, a block never cut."""
|
||||||
runs: list[list[str]] = [[]]
|
runs: list[list[str]] = [[]]
|
||||||
size = 0
|
size = 0
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
weight = sum(len(line) + 1 for line in block)
|
weight = weigh(block)
|
||||||
if runs[-1] and size + weight > budget:
|
if runs[-1] and size + weight > budget:
|
||||||
runs.append([])
|
runs.append([])
|
||||||
size = 0
|
size = 0
|
||||||
@@ -3426,16 +3422,15 @@ def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
PLATFORM_FILE_HEADER = [
|
|
||||||
"| File | Path | Status | Hash | Details |",
|
|
||||||
"|---|---|---|---|---|",
|
|
||||||
]
|
|
||||||
# The characters of a hash a row shows, as many as the served copy's sha1.
|
# The characters of a hash a row shows, as many as the served copy's sha1.
|
||||||
SHORT_HASH = 8
|
SHORT_HASH = 8
|
||||||
# The hash a row shows first, when the list does not say which it checks.
|
# The hash a row shows first, when the list does not say which it checks.
|
||||||
_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
|
_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
|
||||||
# An untested entry whose reason is a hash the bytes do not match.
|
# An untested entry whose reason is a hash the bytes do not match.
|
||||||
_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
|
_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
|
||||||
|
# A list this long gets a filter box above it, as a table of RECORD_ROWS
|
||||||
|
# rows does (website/src/plugins/rehype-stack-tables.mjs).
|
||||||
|
FILTER_ROWS = 20
|
||||||
|
|
||||||
|
|
||||||
def _status_chip(f: dict) -> str:
|
def _status_chip(f: dict) -> str:
|
||||||
@@ -3450,12 +3445,13 @@ def _status_chip(f: dict) -> str:
|
|||||||
tone = "warning"
|
tone = "warning"
|
||||||
else:
|
else:
|
||||||
word, tone = str(status), "muted"
|
word, tone = str(status), "muted"
|
||||||
return f'<span class="rb-badge rb-badge-{tone}">{word}</span>'
|
return f'<span class="rb-badge rb-badge-{tone}">{html.escape(word)}</span>'
|
||||||
|
|
||||||
|
|
||||||
def _copy_hash(algorithm: str, value: str | list) -> str:
|
def _copy_hash(algorithm: str, value: str | list) -> str:
|
||||||
"""A declared hash cut to SHORT_HASH characters on a button that copies it whole.
|
"""A declared hash cut to SHORT_HASH characters on a button that copies
|
||||||
A list of accepted values (Recalbox) shows its first and how many more."""
|
it whole. A list of accepted values (Recalbox) copies its first. Outside
|
||||||
|
a form a button submits nothing, so it carries no type."""
|
||||||
from hashing import parse_hash_list # noqa: PLC0415
|
from hashing import parse_hash_list # noqa: PLC0415
|
||||||
|
|
||||||
values = parse_hash_list(value)
|
values = parse_hash_list(value)
|
||||||
@@ -3463,43 +3459,68 @@ def _copy_hash(algorithm: str, value: str | list) -> str:
|
|||||||
return ""
|
return ""
|
||||||
first = html.escape(values[0])
|
first = html.escape(values[0])
|
||||||
more = f" +{len(values) - 1}" if len(values) > 1 else ""
|
more = f" +{len(values) - 1}" if len(values) > 1 else ""
|
||||||
return (f'<button type="button" class="rb-copy" data-copy="{first}" '
|
return (f'<button class="rb-copy" data-copy="{first}" '
|
||||||
f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}">'
|
f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}{more}">'
|
||||||
f'{first[:SHORT_HASH]}</button>{more}')
|
f'{first[:SHORT_HASH]}{more}</button>')
|
||||||
|
|
||||||
|
|
||||||
def _cell(text: str) -> str:
|
# Characters Markdown or MDX would read inside the raw HTML of a row: an
|
||||||
"""Text for one cell of a Markdown table: a pipe would end the cell."""
|
# MDX page parses the text of its HTML as Markdown, a .md page does not.
|
||||||
return text.replace("|", "\\|")
|
_INERT = str.maketrans({"*": "*", "_": "_", "`": "`", "[": "[", "]": "]"})
|
||||||
|
_INLINE_MD = re.compile(
|
||||||
|
r"\[(?P<label>(?:`[^`]*`|[^\]`])+)\]\((?P<url>[^)\s]+)\)"
|
||||||
|
r"|`(?P<code>[^`]+)`"
|
||||||
|
r"|\*\*(?P<bold>[^*]+)\*\*"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_MARKDOWN_PUNCTUATION = re.compile(r"([\\`*_\[\]~])")
|
def _text_html(text: str) -> str:
|
||||||
_CODE_LINK = re.compile(r"\[`([^`]+)`\]\(")
|
"""Plain text as HTML that neither Markdown nor MDX rewrites."""
|
||||||
_SHORT_SHA1 = re.compile(r"\(sha1 `([0-9a-f]+)`\)")
|
return html.escape(html.unescape(text), quote=False).translate(_INERT)
|
||||||
|
|
||||||
|
|
||||||
def _plain(text: str) -> str:
|
def _inline_html(markdown: str) -> str:
|
||||||
"""Text written as itself in Markdown: no emphasis, link or tag."""
|
"""The inline Markdown of a row's evidence (links, code, bold) as HTML.
|
||||||
return _MARKDOWN_PUNCTUATION.sub(r"\\\1", html.escape(text, quote=False))
|
|
||||||
|
|
||||||
|
Code spans become plain text and a linked path loses its code element:
|
||||||
def _uncoded(text: str) -> str:
|
one per reference and per reader was the bulk of the elements of a
|
||||||
"""A linked path as plain link text, a short sha1 as plain text.
|
platform page, and the words say the same without them.
|
||||||
|
|
||||||
Each code element is one more element of the page. With one in every
|
|
||||||
name, path and reference, Batocera's page measured 10,479 elements, over
|
|
||||||
validate_site's budget of 10,000. The link and the value say the same
|
|
||||||
without it.
|
|
||||||
"""
|
"""
|
||||||
text = _CODE_LINK.sub(lambda m: f"[{_plain(m[1])}](", text)
|
out: list[str] = []
|
||||||
return _SHORT_SHA1.sub(r"(sha1 \1)", text)
|
position = 0
|
||||||
|
for match in _INLINE_MD.finditer(markdown):
|
||||||
|
out.append(_text_html(markdown[position:match.start()]))
|
||||||
|
if match["url"]:
|
||||||
|
out.append(f'<a href="{html.escape(match["url"])}">'
|
||||||
|
f'{_text_html(match["label"].replace("`", ""))}</a>')
|
||||||
|
elif match["code"]:
|
||||||
|
out.append(_text_html(match["code"]))
|
||||||
|
else:
|
||||||
|
out.append(f"<strong>{_text_html(match['bold'])}</strong>")
|
||||||
|
position = match.end()
|
||||||
|
out.append(_text_html(markdown[position:]))
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_path(fname: str, cfg_entry: dict) -> str:
|
||||||
|
"""Where the file goes, when its name alone does not say: three GameCube
|
||||||
|
IPL.bin differ only by GC/USA, GC/EUR and GC/JAP. A destination that is
|
||||||
|
the name says nothing, and one that ends with it shows its folder."""
|
||||||
|
destination = str(cfg_entry.get("destination") or "")
|
||||||
|
if destination.endswith("/" + fname):
|
||||||
|
return destination[:len(destination) - len(fname)]
|
||||||
|
return "" if destination == fname else destination
|
||||||
|
|
||||||
|
|
||||||
def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
||||||
launched: list[str] | None = None) -> str:
|
launched: list[str] | None = None) -> str:
|
||||||
"""One file of a platform's list as a table row: its name, where it goes,
|
"""One file of a platform's list as one row, written as HTML.
|
||||||
its status and a short hash at a glance. The details hold every hash the
|
|
||||||
platform declares, the size, the copy that serves it and its readers.
|
The row is a details element whose summary holds the name, the folder
|
||||||
|
when there is one and the status, followed by the short hash on its copy
|
||||||
|
button: a button cannot sit in a summary, which is itself a control.
|
||||||
|
The details hold every hash the platform declares, the size, the copy
|
||||||
|
that serves it and its readers, across the width of the list.
|
||||||
"""
|
"""
|
||||||
fname = f["name"]
|
fname = f["name"]
|
||||||
hashes = {
|
hashes = {
|
||||||
@@ -3525,7 +3546,7 @@ def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
|||||||
elif size:
|
elif size:
|
||||||
facts.append(f"Size: {_fmt_size(size)}")
|
facts.append(f"Size: {_fmt_size(size)}")
|
||||||
if f.get("status") == "untested" and f.get("reason"):
|
if f.get("status") == "untested" and f.get("reason"):
|
||||||
facts.append(f"Not confirmed: {f['reason']}")
|
facts.append(_text_html(f"Not confirmed: {f['reason']}"))
|
||||||
# Not every entry is firmware: the platform may list a game's data or a
|
# Not every entry is firmware: the platform may list a game's data or a
|
||||||
# file no core it runs opens.
|
# file no core it runs opens.
|
||||||
kind = {"game_data": "Game data, not firmware",
|
kind = {"game_data": "Game data, not firmware",
|
||||||
@@ -3534,29 +3555,53 @@ def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
|||||||
facts.append(kind)
|
facts.append(kind)
|
||||||
served = _served_line(f)
|
served = _served_line(f)
|
||||||
if served:
|
if served:
|
||||||
facts.append(_uncoded(served))
|
facts.append(_inline_html(served))
|
||||||
if f.get("category_evidence"):
|
if f.get("category_evidence"):
|
||||||
facts.append(_uncoded(_category_evidence_md(f["category_evidence"], launched)))
|
facts.append(_inline_html(_category_evidence_md(f["category_evidence"], launched)))
|
||||||
|
|
||||||
# Where the file goes: three GameCube IPL.bin differ only by GC/USA,
|
path = _row_path(fname, cfg_entry)
|
||||||
# GC/EUR and GC/JAP. A destination that ends with the name shows its
|
summary = (_text_html(fname) + (f"<code>{_text_html(path)}</code>" if path else "")
|
||||||
# folder only, the name being in the cell before it. Both are plain
|
+ _status_chip(f))
|
||||||
# text set in the code font by the table's style: a code element in
|
copy = _copy_hash(shown, hashes[shown]) if shown else ""
|
||||||
# each cell was two more elements per row.
|
return (f'<details class="rb-file"><summary>{summary}</summary>'
|
||||||
destination = str(cfg_entry.get("destination") or "")
|
f'{"<br>".join(facts)}</details>{copy}')
|
||||||
if destination == fname or destination.endswith("/" + fname):
|
|
||||||
destination = destination[:len(destination) - len(fname)]
|
|
||||||
summary = f'<summary aria-label="Details of {html.escape(fname)}">Details</summary>'
|
def _platform_file_list(rows: list[str], has_path: bool, anchor: str) -> list[str]:
|
||||||
details = (f'<details class="rb-row-details">{summary}{"<br>".join(facts)}</details>'
|
"""A system's rows as one HTML block, with a filter box when long.
|
||||||
if facts else "")
|
|
||||||
cells = [
|
The list drops its path column when no row has one, so a blank cell
|
||||||
_plain(fname),
|
never reads as missing data.
|
||||||
_plain(destination),
|
"""
|
||||||
_status_chip(f),
|
lines: list[str] = []
|
||||||
_copy_hash(shown, hashes[shown]) if shown else "",
|
if len(rows) >= FILTER_ROWS:
|
||||||
details,
|
field = f"rb-files-filter-{anchor}"
|
||||||
]
|
lines += [
|
||||||
return "| " + " | ".join(_cell(cell) for cell in cells) + " |"
|
'<div class="rb-table-filter">'
|
||||||
|
f'<label for="{field}">Filter files</label>'
|
||||||
|
f'<input id="{field}" type="search" autocomplete="off" placeholder="File, path, status, hash...">'
|
||||||
|
f'<span class="rb-table-filter-count" aria-live="polite">{len(rows):,} rows</span></div>',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
kind = "rb-files rb-files-path" if has_path else "rb-files"
|
||||||
|
return [*lines, f'<div class="{kind}">', *rows, "</div>"]
|
||||||
|
|
||||||
|
|
||||||
|
# The elements a line puts on the page: its HTML tags, and the links and
|
||||||
|
# code spans of the Markdown before a list.
|
||||||
|
_ELEMENT = re.compile(r"<[a-zA-Z]|\]\(|`[^`]+`")
|
||||||
|
|
||||||
|
|
||||||
|
def _element_weight(lines: list[str]) -> int:
|
||||||
|
"""How many elements the lines render to."""
|
||||||
|
return sum(len(_ELEMENT.findall(line)) for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_share(lines: list[str]) -> float:
|
||||||
|
"""The share of a page the lines render to, in thousandths: the rows
|
||||||
|
are HTML already, so their length is their weight in bytes."""
|
||||||
|
return max(_element_weight(lines) * 1000 / PAGE_ELEMENTS,
|
||||||
|
_characters(lines) * 1000 / PAGE_BYTES)
|
||||||
|
|
||||||
|
|
||||||
def _render_platform_systems(
|
def _render_platform_systems(
|
||||||
@@ -3586,17 +3631,18 @@ def _render_platform_systems(
|
|||||||
|
|
||||||
sys_emus = _system_emulators(sys_id, emulator_files, by_system_declared.get(sys_id))
|
sys_emus = _system_emulators(sys_id, emulator_files, by_system_declared.get(sys_id))
|
||||||
anchor = sys_id.replace(" ", "-")
|
anchor = sys_id.replace(" ", "-")
|
||||||
body = [f"{ok_count}/{total} files verified"]
|
head = [f"{ok_count}/{total} files verified"]
|
||||||
if sys_emus:
|
if sys_emus:
|
||||||
emu_links = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus))
|
emu_links = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus))
|
||||||
body[-1] += "\\"
|
head[-1] += "\\"
|
||||||
body.append(f"Emulators: {emu_links}")
|
head.append(f"Emulators: {emu_links}")
|
||||||
body.append("")
|
head.append("")
|
||||||
|
|
||||||
# One row per file, the evidence in its details. The sort is stable,
|
# One row per file, the evidence in its details. The sort is stable,
|
||||||
# so same-named entries keep the order verify reported them in,
|
# so same-named entries keep the order verify reported them in,
|
||||||
# which is the declaration order.
|
# which is the declaration order.
|
||||||
body.extend(PLATFORM_FILE_HEADER)
|
rows: list[str] = []
|
||||||
|
has_path = False
|
||||||
seen_names: dict[str, int] = {}
|
seen_names: dict[str, int] = {}
|
||||||
for f in sorted(files, key=lambda x: x["name"]):
|
for f in sorted(files, key=lambda x: x["name"]):
|
||||||
fname = f["name"]
|
fname = f["name"]
|
||||||
@@ -3604,23 +3650,23 @@ def _render_platform_systems(
|
|||||||
nth = seen_names.get(fname, 0)
|
nth = seen_names.get(fname, 0)
|
||||||
seen_names[fname] = nth + 1
|
seen_names[fname] = nth + 1
|
||||||
cfg_entry = declared[nth] if nth < len(declared) else {}
|
cfg_entry = declared[nth] if nth < len(declared) else {}
|
||||||
body.append(_platform_file_row(
|
has_path = has_path or bool(_row_path(fname, cfg_entry))
|
||||||
|
rows.append(_platform_file_row(
|
||||||
f, cfg_entry, str((config or {}).get("hash_type") or ""),
|
f, cfg_entry, str((config or {}).get("hash_type") or ""),
|
||||||
launched.get(fname.rsplit("/", 1)[-1].casefold())))
|
launched.get(fname.rsplit("/", 1)[-1].casefold())))
|
||||||
|
body = head + _platform_file_list(rows, has_path, anchor)
|
||||||
|
|
||||||
# Measured as the Markdown the split was tuned on, a collapsible
|
# Measured in the share of a page the list renders to.
|
||||||
# block indented by four, so both engines split the same systems.
|
size = _page_share(body)
|
||||||
size = sum(len(line) + 1 for line in body)
|
|
||||||
if use_collapsible:
|
if use_collapsible:
|
||||||
status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue")
|
status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue")
|
||||||
title = f"{_count(total, 'file')} ({status_tag})"
|
block = markup.details("note", f"{_count(total, 'file')} ({status_tag})", body)
|
||||||
size += len(f'??? note "{title}"') + 2 + 4 * sum(1 for line in body if line)
|
|
||||||
block = markup.details("note", title, body)
|
|
||||||
while block and not block[-1]:
|
while block and not block[-1]:
|
||||||
block.pop()
|
block.pop()
|
||||||
else:
|
else:
|
||||||
block = body
|
block = body
|
||||||
sections.append({"sys_id": sys_id, "anchor": anchor, "body": body, "block": block,
|
sections.append({"sys_id": sys_id, "anchor": anchor, "head": head, "rows": rows,
|
||||||
|
"has_path": has_path, "block": block,
|
||||||
"size": size, "total": total, "ok": ok_count})
|
"size": size, "total": total, "ok": ok_count})
|
||||||
|
|
||||||
# A list longer than its budget goes to a page of its own, then the
|
# A list longer than its budget goes to a page of its own, then the
|
||||||
@@ -3639,7 +3685,7 @@ def _render_platform_systems(
|
|||||||
kept -= sec["size"]
|
kept -= sec["size"]
|
||||||
|
|
||||||
for sec in sections:
|
for sec in sections:
|
||||||
sys_id, anchor, body = sec["sys_id"], sec["anchor"], sec["body"]
|
sys_id, anchor = sec["sys_id"], sec["anchor"]
|
||||||
total, ok_count = sec["total"], sec["ok"]
|
total, ok_count = sec["total"], sec["ok"]
|
||||||
# Every system is a heading with the same anchor in both layouts:
|
# Every system is a heading with the same anchor in both layouts:
|
||||||
# emulator cards link here, and the site search splits a page at its
|
# emulator cards link here, and the site search splits a page at its
|
||||||
@@ -3649,7 +3695,9 @@ def _render_platform_systems(
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
if moved is not None and platform and sys_id in own_page:
|
if moved is not None and platform and sys_id in own_page:
|
||||||
key, display = platform
|
key, display = platform
|
||||||
runs = _file_runs(body, PLATFORM_PART_BUDGET)
|
runs = [sec["head"] + _platform_file_list(run, sec["has_path"], anchor)
|
||||||
|
for run in _blocks_in_runs([[row] for row in sec["rows"]],
|
||||||
|
PLATFORM_PART_BUDGET, _page_share)]
|
||||||
stems = [f"{key}-{anchor}" + (f"-{n}" if n > 1 else "")
|
stems = [f"{key}-{anchor}" + (f"-{n}" if n > 1 else "")
|
||||||
for n in range(1, len(runs) + 1)]
|
for n in range(1, len(runs) + 1)]
|
||||||
for n, (stem, run) in enumerate(zip(stems, runs), 1):
|
for n, (stem, run) in enumerate(zip(stems, runs), 1):
|
||||||
@@ -11308,6 +11356,7 @@ def _generate(args: argparse.Namespace) -> None:
|
|||||||
)
|
)
|
||||||
if HANDHELD_GUIDE:
|
if HANDHELD_GUIDE:
|
||||||
write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
|
write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
|
||||||
|
published_parts = _published_platform_parts()
|
||||||
for name, cov in coverages.items():
|
for name, cov in coverages.items():
|
||||||
# The emulators this platform runs, not every one of the system.
|
# The emulators this platform runs, not every one of the system.
|
||||||
runs = resolve_platform_cores(cov["config"], profiles)
|
runs = resolve_platform_cores(cov["config"], profiles)
|
||||||
@@ -11322,6 +11371,9 @@ def _generate(args: argparse.Namespace) -> None:
|
|||||||
if stem in coverages:
|
if stem in coverages:
|
||||||
raise RuntimeError(f"a system page takes the platform {stem}'s address")
|
raise RuntimeError(f"a system page takes the platform {stem}'s address")
|
||||||
PART_PAGES.add(f"platforms/{stem}.md")
|
PART_PAGES.add(f"platforms/{stem}.md")
|
||||||
|
anchor = re.search(rf"\]\({re.escape(name)}\.md#([^)\s]+)\)", page)
|
||||||
|
if anchor:
|
||||||
|
published_parts[f"platforms/{stem}.md"] = f"platforms/{name}.md#{anchor[1]}"
|
||||||
# One console of one setup's list, a reference: "playstation
|
# One console of one setup's list, a reference: "playstation
|
||||||
# bios" found five setups' lists before the console's page.
|
# bios" found five setups' lists before the console's page.
|
||||||
# Weighed down, they still won on their title, which the page
|
# Weighed down, they still won on their title, which the page
|
||||||
@@ -11488,6 +11540,8 @@ def _generate(args: argparse.Namespace) -> None:
|
|||||||
stale += _sweep_top_pages(docs)
|
stale += _sweep_top_pages(docs)
|
||||||
|
|
||||||
print("Adding page metadata and structured data...")
|
print("Adding page metadata and structured data...")
|
||||||
|
write_if_changed(str(PLATFORM_PARTS_FILE),
|
||||||
|
json.dumps(dict(sorted(published_parts.items())), indent=1) + "\n")
|
||||||
lastmod = decorate_markdown_pages(docs)
|
lastmod = decorate_markdown_pages(docs)
|
||||||
|
|
||||||
nav = generate_nav(coverages, manufacturers, profiles, registry,
|
nav = generate_nav(coverages, manufacturers, profiles, registry,
|
||||||
@@ -11504,7 +11558,7 @@ def _generate(args: argparse.Namespace) -> None:
|
|||||||
json.dumps(sitestarlight.sidebar(nav, present, _overview_anchors(nav, docs)),
|
json.dumps(sitestarlight.sidebar(nav, present, _overview_anchors(nav, docs)),
|
||||||
indent=1) + "\n")
|
indent=1) + "\n")
|
||||||
write_if_changed(str(generated / "redirects.json"),
|
write_if_changed(str(generated / "redirects.json"),
|
||||||
json.dumps(sitestarlight.redirects(profiles, present), indent=1,
|
json.dumps(sitestarlight.redirects(profiles, present, published_parts), indent=1,
|
||||||
sort_keys=True) + "\n")
|
sort_keys=True) + "\n")
|
||||||
write_if_changed(str(generated / "lastmod.json"),
|
write_if_changed(str(generated / "lastmod.json"),
|
||||||
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
|
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
|
||||||
|
|||||||
@@ -809,19 +809,23 @@ MOVED_PAGES = {"wiki/getting-started.md": "which-pack.md"}
|
|||||||
_CORE_NAME = re.compile(r"[a-z0-9_-]+")
|
_CORE_NAME = re.compile(r"[a-z0-9_-]+")
|
||||||
|
|
||||||
|
|
||||||
def redirects(profiles: dict, present: set[str]) -> dict[str, str]:
|
def redirects(profiles: dict, present: set[str],
|
||||||
|
moved: dict[str, str] | None = None) -> dict[str, str]:
|
||||||
"""Old and alternative addresses, for src/generated/redirects.json.
|
"""Old and alternative addresses, for src/generated/redirects.json.
|
||||||
|
|
||||||
A page that moved, once its source is gone, and each name a profile's
|
A page that moved, once its source is gone, and each name a profile's
|
||||||
`cores:` gives the emulator (the buildbot's `mednafen_psx` for
|
`cores:` gives the emulator (the buildbot's `mednafen_psx` for
|
||||||
beetle_psx), to the profile's page. A name two profiles claim, or one
|
beetle_psx), to the profile's page. A name two profiles claim, or one
|
||||||
that is already a page's address, is left out. Addresses are written
|
that is already a page's address, is left out. Addresses are written
|
||||||
under the site root, without its base."""
|
under the site root, without its base. ``moved`` adds pages that moved
|
||||||
|
by generation, old page to new page, the new one with an optional
|
||||||
|
#fragment."""
|
||||||
def address(rel: str) -> str:
|
def address(rel: str) -> str:
|
||||||
return "/" + page_url(rel)[len(BASE):]
|
page, _, fragment = rel.partition("#")
|
||||||
|
return "/" + page_url(page)[len(BASE):] + (f"#{fragment}" if fragment else "")
|
||||||
|
|
||||||
out = {address(old): address(new) for old, new in MOVED_PAGES.items()
|
out = {address(old): address(new) for old, new in {**MOVED_PAGES, **(moved or {})}.items()
|
||||||
if old not in present and new in present}
|
if old not in present and new.partition("#")[0] in present}
|
||||||
pages = {rel.casefold() for rel in present}
|
pages = {rel.casefold() for rel in present}
|
||||||
claims: dict[str, set[str]] = {}
|
claims: dict[str, set[str]] = {}
|
||||||
for key, profile in profiles.items():
|
for key, profile in profiles.items():
|
||||||
|
|||||||
@@ -340,9 +340,9 @@ class APlatformPageSaysWhereEachFileGoes(unittest.TestCase):
|
|||||||
lines = generate_site._render_platform_systems(
|
lines = generate_site._render_platform_systems(
|
||||||
{"nintendo-gamecube": files}, {("nintendo-gamecube", "IPL.bin"): declared}, {})
|
{"nintendo-gamecube": files}, {("nintendo-gamecube", "IPL.bin"): declared}, {})
|
||||||
for region in regions:
|
for region in regions:
|
||||||
# The folder: the name is in the cell before it.
|
# The folder: the name is before it in the row.
|
||||||
self.assertIn(f"| IPL.bin | dolphin-emu/Sys/GC/{region}/ | "
|
self.assertIn(f"<summary>IPL.bin<code>dolphin-emu/Sys/GC/{region}/</code>"
|
||||||
'<span class="rb-badge rb-badge-success">OK</span> |',
|
'<span class="rb-badge rb-badge-success">OK</span></summary>',
|
||||||
"\n".join(lines))
|
"\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"""A platform page lists one table row per file, the evidence on demand.
|
"""A platform page lists one row per file, the evidence on demand.
|
||||||
|
|
||||||
Batocera, AmberELEC and RomM ran to 14,000 to 16,000 words: every file
|
Batocera, AmberELEC and RomM ran to 14,000 to 16,000 words: every file
|
||||||
spelled its full MD5, the copy serving it and each source reference of
|
spelled its full MD5, the copy serving it and each source reference of
|
||||||
each reader, with "(emulator code, libretro port)" after every one. A row
|
each reader, with "(emulator code, libretro port)" after every one. A row
|
||||||
now shows the name, the folder, the status and a short hash; its details
|
now shows the name, the folder, the status and a short hash. Opened, it
|
||||||
hold every hash, the served copy and the readers, the role of each
|
holds every hash, the served copy and the readers across the list, the
|
||||||
reference named by one word that a legend defines once.
|
role of each reference named by one word that a legend defines once.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -93,24 +94,34 @@ class ARowPerFile(unittest.TestCase):
|
|||||||
row = self._row({"name": "bios_a.bin", "status": "ok", "size": 2048},
|
row = self._row({"name": "bios_a.bin", "status": "ok", "size": 2048},
|
||||||
{"name": "bios_a.bin", "md5": md5, "sha1": sha1, "crc32": "0badf00d",
|
{"name": "bios_a.bin", "md5": md5, "sha1": sha1, "crc32": "0badf00d",
|
||||||
"destination": "bios/bios_a.bin"})
|
"destination": "bios/bios_a.bin"})
|
||||||
cells = re.split(r"(?<!\\)\|", row)[1:-1]
|
# The summary is the row: the name, its folder, the status.
|
||||||
self.assertEqual(len(cells), len(generate_site.PLATFORM_FILE_HEADER[0].split("|")) - 2)
|
self.assertIn('<details class="rb-file"><summary>bios_a.bin<code>bios/</code>'
|
||||||
name, folder, status, short, details = (cell.strip() for cell in cells)
|
'<span class="rb-badge rb-badge-success">OK</span></summary>', row)
|
||||||
self.assertEqual(name, r"bios\_a.bin")
|
# The hash the platform checks, short, on one button beside the
|
||||||
# The folder: the name is in the cell before it.
|
# summary (a summary is a control: no button inside it).
|
||||||
self.assertEqual(folder, "bios/")
|
details, button = row.split("</details>")
|
||||||
self.assertEqual(status, '<span class="rb-badge rb-badge-success">OK</span>')
|
self.assertNotIn("<button", details)
|
||||||
# The hash the platform checks, short, on a button that copies it whole.
|
self.assertEqual(button, f'<button class="rb-copy" data-copy="{md5}" '
|
||||||
self.assertIn(f'data-copy="{md5}"', short)
|
f'aria-label="Copy MD5 {md5[:8]}">{md5[:8]}</button>')
|
||||||
self.assertIn(f">{md5[:8]}</button>", short)
|
|
||||||
# The accessible name holds the visible text (WCAG 2.5.3).
|
|
||||||
self.assertIn(f'aria-label="Copy MD5 {md5[:8]}"', short)
|
|
||||||
# Every hash whole in the details, with the size.
|
# Every hash whole in the details, with the size.
|
||||||
for value in (md5, sha1, "0badf00d"):
|
for value in (md5, sha1, "0badf00d"):
|
||||||
self.assertIn(value, details)
|
self.assertIn(value, details)
|
||||||
self.assertIn("2.0 KB", details)
|
self.assertIn("(2.0 KB)", details)
|
||||||
self.assertTrue(details.startswith('<details class="rb-row-details">'
|
|
||||||
'<summary aria-label="Details of bios_a.bin">Details</summary>'))
|
def test_a_file_at_the_root_prints_no_path(self):
|
||||||
|
for destination in ("", "x.bin"):
|
||||||
|
row = self._row({"name": "x.bin", "status": "ok"}, {"destination": destination})
|
||||||
|
self.assertIn("<summary>x.bin<span", row)
|
||||||
|
|
||||||
|
def test_a_list_drops_the_path_column_when_no_row_has_one(self):
|
||||||
|
files = [{"name": "a.bin", "status": "ok"}, {"name": "b.bin", "status": "ok"}]
|
||||||
|
system = "nintendo-gamecube"
|
||||||
|
config = {(system, "a.bin"): [{"destination": "a.bin"}], (system, "b.bin"): [{}]}
|
||||||
|
text = "\n".join(generate_site._render_platform_systems({system: files}, config, {}))
|
||||||
|
self.assertIn('<div class="rb-files">', text)
|
||||||
|
config[(system, "b.bin")] = [{"destination": "sub/b.bin"}]
|
||||||
|
text = "\n".join(generate_site._render_platform_systems({system: files}, config, {}))
|
||||||
|
self.assertIn('<div class="rb-files rb-files-path">', text)
|
||||||
|
|
||||||
def test_status_words(self):
|
def test_status_words(self):
|
||||||
cases = {
|
cases = {
|
||||||
@@ -129,18 +140,20 @@ class ARowPerFile(unittest.TestCase):
|
|||||||
values = ["1" * 32, "2" * 32, "3" * 32]
|
values = ["1" * 32, "2" * 32, "3" * 32]
|
||||||
row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)})
|
row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)})
|
||||||
self.assertIn(f'data-copy="{values[0]}"', row)
|
self.assertIn(f'data-copy="{values[0]}"', row)
|
||||||
self.assertIn("</button> +2", row)
|
self.assertIn(f">{values[0][:8]} +2</button>", row)
|
||||||
for value in values:
|
for value in values:
|
||||||
self.assertIn(f'<code class="rb-hash">{value}</code>', row)
|
self.assertIn(f'<code class="rb-hash">{value}</code>', row)
|
||||||
|
|
||||||
def test_a_destination_under_another_name_stays_whole(self):
|
def test_markdown_never_reads_the_text(self):
|
||||||
row = self._row({"name": "a.bin", "status": "ok"}, {"destination": "x/b.bin"})
|
row = self._row({"name": "*a_b*[1].bin", "status": "ok"}, {})
|
||||||
self.assertIn("| a.bin | x/b.bin |", row)
|
self.assertIn("*a_b*[1].bin", row)
|
||||||
|
|
||||||
def test_a_pipe_does_not_end_the_cell(self):
|
def test_a_long_list_gets_a_filter(self):
|
||||||
row = self._row({"name": "a|b", "status": "ok"}, {})
|
rows = ["<details></details>"] * generate_site.FILTER_ROWS
|
||||||
self.assertIn(r"a\|b", row)
|
block = generate_site._platform_file_list(rows, False, "arcade")
|
||||||
self.assertEqual(len(re.split(r"(?<!\\)\|", row)), 7)
|
self.assertTrue(block[0].startswith('<div class="rb-table-filter"><label for="rb-files-filter-arcade">'))
|
||||||
|
self.assertNotIn("rb-table-filter", "".join(
|
||||||
|
generate_site._platform_file_list(rows[1:], False, "arcade")))
|
||||||
|
|
||||||
|
|
||||||
class ReferencesNameTheirRoleOnce(unittest.TestCase):
|
class ReferencesNameTheirRoleOnce(unittest.TestCase):
|
||||||
@@ -162,7 +175,7 @@ class ReferencesNameTheirRoleOnce(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
def _unescaped(text: str) -> str:
|
def _unescaped(text: str) -> str:
|
||||||
return re.sub(r"\\([\\`*_\[\]~|])", r"\1", text)
|
return html.unescape(text)
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json absent")
|
@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json absent")
|
||||||
@@ -222,7 +235,7 @@ class NothingIsLost(unittest.TestCase):
|
|||||||
# 19,127: RetroDECK's arcade list).
|
# 19,127: RetroDECK's arcade list).
|
||||||
PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192}
|
PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192}
|
||||||
MAIN_PAGE_CEILING = 2459
|
MAIN_PAGE_CEILING = 2459
|
||||||
PART_PAGE_CEILING = 2306
|
PART_PAGE_CEILING = 2219
|
||||||
|
|
||||||
_SITE = REPO_ROOT / "website" / "dist" / "platforms"
|
_SITE = REPO_ROOT / "website" / "dist" / "platforms"
|
||||||
|
|
||||||
@@ -235,10 +248,47 @@ class VisibleWordsStayUnderTheirCeiling(unittest.TestCase):
|
|||||||
mains = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
mains = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||||
for page in sorted(_SITE.glob("*/index.html")):
|
for page in sorted(_SITE.glob("*/index.html")):
|
||||||
name = page.parent.name
|
name = page.parent.name
|
||||||
visible, _total = page_words(page.read_text(encoding="utf-8"))
|
text = page.read_text(encoding="utf-8")
|
||||||
|
if 'http-equiv="refresh"' in text[:2000]:
|
||||||
|
continue
|
||||||
|
visible, _total = page_words(text)
|
||||||
ceiling = PAGE_CEILINGS.get(name, MAIN_PAGE_CEILING if name in mains else PART_PAGE_CEILING)
|
ceiling = PAGE_CEILINGS.get(name, MAIN_PAGE_CEILING if name in mains else PART_PAGE_CEILING)
|
||||||
self.assertLessEqual(visible, ceiling, name)
|
self.assertLessEqual(visible, ceiling, name)
|
||||||
|
|
||||||
|
|
||||||
|
# Each platform with its part pages, before the rows were rewritten: pages,
|
||||||
|
# elements (outside scripts and styles, as validate_site counts them) and
|
||||||
|
# bytes of HTML, on the built site of 2026-10-27.
|
||||||
|
BEFORE_WEIGHT = {
|
||||||
|
"batocera": (12, 18_854, 1_211_117),
|
||||||
|
"amberelec": (1, 7_928, 473_388),
|
||||||
|
"romm": (2, 9_561, 572_487),
|
||||||
|
"retroarch": (14, 21_503, 1_430_802),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_SITE.is_dir(), "website/dist not built")
|
||||||
|
class APlatformWeighsNoMoreThanBefore(unittest.TestCase):
|
||||||
|
"""The split budget measured Markdown length: rows written as a table
|
||||||
|
were longer Markdown, 38 systems moved to new part pages and Batocera
|
||||||
|
with its parts rose from 18,854 to 21,875 elements."""
|
||||||
|
|
||||||
|
def test_pages_elements_and_bytes(self):
|
||||||
|
from validate_site import _PageParser
|
||||||
|
|
||||||
|
for name, (pages, elements, size) in BEFORE_WEIGHT.items():
|
||||||
|
paths, weight = [], 0
|
||||||
|
for path in [_SITE / name / "index.html", *sorted(_SITE.glob(f"{name}-*/index.html"))]:
|
||||||
|
parser = _PageParser(path)
|
||||||
|
parser.feed(path.read_text(encoding="utf-8"))
|
||||||
|
# A part folded back into its platform page left a redirect.
|
||||||
|
if not parser.page.redirect:
|
||||||
|
paths.append(path)
|
||||||
|
weight += parser.page.elements
|
||||||
|
self.assertLessEqual(len(paths), pages, name)
|
||||||
|
self.assertLessEqual(weight, elements, name)
|
||||||
|
self.assertLessEqual(sum(path.stat().st_size for path in paths), size, name)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -158,16 +158,13 @@ class EachEntryNamesItsFile(unittest.TestCase):
|
|||||||
# Existence mode reports no path: nothing is claimed, held or not.
|
# Existence mode reports no path: nothing is claimed, held or not.
|
||||||
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
|
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
|
||||||
|
|
||||||
def test_a_long_system_list_is_cut_between_files(self):
|
def test_a_long_system_list_is_cut_between_rows_by_elements(self):
|
||||||
body = ["2/2 files verified", ""]
|
rows = [[f'<details class="rb-file"><summary>f{n}.bin</summary>x<br>y</details>']
|
||||||
for n in range(4):
|
for n in range(4)]
|
||||||
body += [f"- `f{n}.bin` - OK", " " + "x" * 100]
|
# Three elements a row: two rows fit in a budget of seven.
|
||||||
runs = generate_site._file_runs(body, 250)
|
runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight)
|
||||||
self.assertEqual(len(runs), 2)
|
self.assertEqual([len(run) for run in runs], [2, 2])
|
||||||
for run in runs:
|
self.assertEqual(sum(runs, []), sum(rows, []))
|
||||||
self.assertEqual(run[:2], body[:2])
|
|
||||||
self.assertTrue(run[2].startswith("- "))
|
|
||||||
self.assertEqual(sum(len(run) - 2 for run in runs), len(body) - 2)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoNamedIsNotCited(unittest.TestCase):
|
class AlsoNamedIsNotCited(unittest.TestCase):
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ class PlatformPagesLinkTheirSystemsAndEmulators(unittest.TestCase):
|
|||||||
|
|
||||||
def test_a_moved_list_opens_on_its_console_and_names_its_parent(self):
|
def test_a_moved_list_opens_on_its_console_and_names_its_parent(self):
|
||||||
# Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own.
|
# Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own.
|
||||||
files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(1200)]
|
files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(2000)]
|
||||||
moved: dict[str, str] = {}
|
moved: dict[str, str] = {}
|
||||||
generate_site._render_platform_systems(
|
generate_site._render_platform_systems(
|
||||||
{"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {})
|
{"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {})
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class APlatformSystemOverItsBudgetMoves(unittest.TestCase):
|
|||||||
config = {("arcade", f["name"]): [{"name": f["name"], "md5": "a" * 32}] for f in files}
|
config = {("arcade", f["name"]): [{"name": f["name"], "md5": "a" * 32}] for f in files}
|
||||||
moved: dict[str, str] = {}
|
moved: dict[str, str] = {}
|
||||||
budget = generate_site.PLATFORM_SECTION_BUDGET
|
budget = generate_site.PLATFORM_SECTION_BUDGET
|
||||||
generate_site.PLATFORM_SECTION_BUDGET = 500
|
generate_site.PLATFORM_SECTION_BUDGET = 5
|
||||||
try:
|
try:
|
||||||
lines = generate_site._render_platform_systems(
|
lines = generate_site._render_platform_systems(
|
||||||
{"arcade": files}, config, {}, ("deck", "Deck"), moved)
|
{"arcade": files}, config, {}, ("deck", "Deck"), moved)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"platforms/batocera-acorn-bbc-micro.md": "platforms/batocera.md#acorn-bbc-micro",
|
||||||
|
"platforms/batocera-apple-ii.md": "platforms/batocera.md#apple-ii",
|
||||||
|
"platforms/batocera-apple-macintosh-ii.md": "platforms/batocera.md#apple-macintosh-ii",
|
||||||
|
"platforms/batocera-archimedes.md": "platforms/batocera.md#archimedes",
|
||||||
|
"platforms/batocera-atari-st.md": "platforms/batocera.md#atari-st",
|
||||||
|
"platforms/batocera-commodore-amiga.md": "platforms/batocera.md#commodore-amiga",
|
||||||
|
"platforms/batocera-elektronika-bk.md": "platforms/batocera.md#elektronika-bk",
|
||||||
|
"platforms/batocera-microsoft-msx.md": "platforms/batocera.md#microsoft-msx",
|
||||||
|
"platforms/batocera-nintendo-ds.md": "platforms/batocera.md#nintendo-ds",
|
||||||
|
"platforms/batocera-sega-dreamcast-arcade.md": "platforms/batocera.md#sega-dreamcast-arcade",
|
||||||
|
"platforms/batocera-sony-playstation.md": "platforms/batocera.md#sony-playstation",
|
||||||
|
"platforms/emuelec-microsoft-msx.md": "platforms/emuelec.md#microsoft-msx",
|
||||||
|
"platforms/knulli-acorn-bbc-micro.md": "platforms/knulli.md#acorn-bbc-micro",
|
||||||
|
"platforms/knulli-apple-macintosh-ii.md": "platforms/knulli.md#apple-macintosh-ii",
|
||||||
|
"platforms/knulli-archimedes.md": "platforms/knulli.md#archimedes",
|
||||||
|
"platforms/knulli-atari-st.md": "platforms/knulli.md#atari-st",
|
||||||
|
"platforms/knulli-commodore-amiga.md": "platforms/knulli.md#commodore-amiga",
|
||||||
|
"platforms/knulli-sega-dreamcast-arcade.md": "platforms/knulli.md#sega-dreamcast-arcade",
|
||||||
|
"platforms/lakka-arcade.md": "platforms/lakka.md#arcade",
|
||||||
|
"platforms/lakka-commodore-amiga.md": "platforms/lakka.md#commodore-amiga",
|
||||||
|
"platforms/lakka-nec-pc-98.md": "platforms/lakka.md#nec-pc-98",
|
||||||
|
"platforms/lakka-nintendo-ds.md": "platforms/lakka.md#nintendo-ds",
|
||||||
|
"platforms/lakka-nintendo-gamecube.md": "platforms/lakka.md#nintendo-gamecube",
|
||||||
|
"platforms/lakka-nintendo-sgb.md": "platforms/lakka.md#nintendo-sgb",
|
||||||
|
"platforms/lakka-nintendo-snes.md": "platforms/lakka.md#nintendo-snes",
|
||||||
|
"platforms/lakka-scummvm.md": "platforms/lakka.md#scummvm",
|
||||||
|
"platforms/lakka-sega-dreamcast-arcade.md": "platforms/lakka.md#sega-dreamcast-arcade",
|
||||||
|
"platforms/lakka-sega-saturn.md": "platforms/lakka.md#sega-saturn",
|
||||||
|
"platforms/lakka-sinclair-zx-spectrum.md": "platforms/lakka.md#sinclair-zx-spectrum",
|
||||||
|
"platforms/lakka-sony-playstation-2.md": "platforms/lakka.md#sony-playstation-2",
|
||||||
|
"platforms/lakka-sony-playstation.md": "platforms/lakka.md#sony-playstation",
|
||||||
|
"platforms/recalbox-atari-st.md": "platforms/recalbox.md#atari-st",
|
||||||
|
"platforms/recalbox-commodore-amiga.md": "platforms/recalbox.md#commodore-amiga",
|
||||||
|
"platforms/recalbox-microsoft-msx.md": "platforms/recalbox.md#microsoft-msx",
|
||||||
|
"platforms/retroarch-arcade.md": "platforms/retroarch.md#arcade",
|
||||||
|
"platforms/retroarch-commodore-amiga.md": "platforms/retroarch.md#commodore-amiga",
|
||||||
|
"platforms/retroarch-nec-pc-engine.md": "platforms/retroarch.md#nec-pc-engine",
|
||||||
|
"platforms/retroarch-nintendo-ds.md": "platforms/retroarch.md#nintendo-ds",
|
||||||
|
"platforms/retroarch-nintendo-gamecube.md": "platforms/retroarch.md#nintendo-gamecube",
|
||||||
|
"platforms/retroarch-nintendo-sgb.md": "platforms/retroarch.md#nintendo-sgb",
|
||||||
|
"platforms/retroarch-nintendo-snes.md": "platforms/retroarch.md#nintendo-snes",
|
||||||
|
"platforms/retroarch-scummvm.md": "platforms/retroarch.md#scummvm",
|
||||||
|
"platforms/retroarch-sega-dreamcast-arcade.md": "platforms/retroarch.md#sega-dreamcast-arcade",
|
||||||
|
"platforms/retroarch-sega-saturn.md": "platforms/retroarch.md#sega-saturn",
|
||||||
|
"platforms/retroarch-sinclair-zx-spectrum.md": "platforms/retroarch.md#sinclair-zx-spectrum",
|
||||||
|
"platforms/retroarch-sony-playstation-2.md": "platforms/retroarch.md#sony-playstation-2",
|
||||||
|
"platforms/retroarch-sony-playstation.md": "platforms/retroarch.md#sony-playstation",
|
||||||
|
"platforms/retrobat-atari-st.md": "platforms/retrobat.md#atari-st",
|
||||||
|
"platforms/retrobat-commodore-amiga.md": "platforms/retrobat.md#commodore-amiga",
|
||||||
|
"platforms/retrobat-microsoft-msx.md": "platforms/retrobat.md#microsoft-msx",
|
||||||
|
"platforms/retrobat-nec-pc-engine.md": "platforms/retrobat.md#nec-pc-engine",
|
||||||
|
"platforms/retrobat-nintendo-ds.md": "platforms/retrobat.md#nintendo-ds",
|
||||||
|
"platforms/retrobat-sega-saturn.md": "platforms/retrobat.md#sega-saturn",
|
||||||
|
"platforms/retrodeck-arcade-2.md": "platforms/retrodeck.md#arcade",
|
||||||
|
"platforms/retrodeck-arcade-3.md": "platforms/retrodeck.md#arcade",
|
||||||
|
"platforms/retrodeck-arcade-4.md": "platforms/retrodeck.md#arcade",
|
||||||
|
"platforms/retrodeck-arcade.md": "platforms/retrodeck.md#arcade",
|
||||||
|
"platforms/retrodeck-atari-400-800.md": "platforms/retrodeck.md#atari-400-800",
|
||||||
|
"platforms/retrodeck-microsoft-msx.md": "platforms/retrodeck.md#microsoft-msx",
|
||||||
|
"platforms/retrodeck-nintendo-snes.md": "platforms/retrodeck.md#nintendo-snes",
|
||||||
|
"platforms/retrodeck-sega-saturn.md": "platforms/retrodeck.md#sega-saturn",
|
||||||
|
"platforms/retrodeck-sinclair-zx-spectrum.md": "platforms/retrodeck.md#sinclair-zx-spectrum",
|
||||||
|
"platforms/retrodeck-sony-playstation-2.md": "platforms/retrodeck.md#sony-playstation-2",
|
||||||
|
"platforms/retrodeck-sony-playstation.md": "platforms/retrodeck.md#sony-playstation",
|
||||||
|
"platforms/retropie-arcade.md": "platforms/retropie.md#arcade",
|
||||||
|
"platforms/retropie-commodore-amiga.md": "platforms/retropie.md#commodore-amiga",
|
||||||
|
"platforms/retropie-nec-pc-98.md": "platforms/retropie.md#nec-pc-98",
|
||||||
|
"platforms/retropie-nec-pc-engine.md": "platforms/retropie.md#nec-pc-engine",
|
||||||
|
"platforms/retropie-nintendo-ds.md": "platforms/retropie.md#nintendo-ds",
|
||||||
|
"platforms/retropie-nintendo-gamecube.md": "platforms/retropie.md#nintendo-gamecube",
|
||||||
|
"platforms/retropie-nintendo-sgb.md": "platforms/retropie.md#nintendo-sgb",
|
||||||
|
"platforms/retropie-nintendo-snes.md": "platforms/retropie.md#nintendo-snes",
|
||||||
|
"platforms/retropie-scummvm.md": "platforms/retropie.md#scummvm",
|
||||||
|
"platforms/retropie-sega-dreamcast-arcade.md": "platforms/retropie.md#sega-dreamcast-arcade",
|
||||||
|
"platforms/retropie-sega-saturn.md": "platforms/retropie.md#sega-saturn",
|
||||||
|
"platforms/retropie-sinclair-zx-spectrum.md": "platforms/retropie.md#sinclair-zx-spectrum",
|
||||||
|
"platforms/retropie-snk-neogeo-cd.md": "platforms/retropie.md#snk-neogeo-cd",
|
||||||
|
"platforms/retropie-sony-playstation-2.md": "platforms/retropie.md#sony-playstation-2",
|
||||||
|
"platforms/retropie-sony-playstation.md": "platforms/retropie.md#sony-playstation",
|
||||||
|
"platforms/romm-sony-playstation-2.md": "platforms/romm.md#sony-playstation-2"
|
||||||
|
}
|
||||||
@@ -931,6 +931,38 @@ const glossaryTerms = () => {
|
|||||||
document.addEventListener('click', close);
|
document.addEventListener('click', close);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A platform's file list (div.rb-files) is rows of a details and its copy
|
||||||
|
// button. The filter box written above a long one hides both of a row.
|
||||||
|
const filterFiles = () => {
|
||||||
|
for (const list of document.querySelectorAll<HTMLElement>('.rb-table-filter + .rb-files')) {
|
||||||
|
const controls = list.previousElementSibling!;
|
||||||
|
const input = controls.querySelector('input');
|
||||||
|
const count = controls.querySelector('.rb-table-filter-count');
|
||||||
|
if (!input || !count) continue;
|
||||||
|
const rows = [...list.querySelectorAll<HTMLElement>(':scope > details.rb-file')];
|
||||||
|
let searchable: { row: HTMLElement; text: string }[] | null = null;
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
searchable ??= rows.map((row) => ({
|
||||||
|
row,
|
||||||
|
text: `${row.querySelector('summary')?.textContent ?? ''} ${row.nextElementSibling?.matches('button.rb-copy') ? row.nextElementSibling.textContent : ''}`
|
||||||
|
.normalize('NFKD')
|
||||||
|
.toLocaleLowerCase(),
|
||||||
|
}));
|
||||||
|
const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
|
||||||
|
let visible = 0;
|
||||||
|
for (const { row, text } of searchable) {
|
||||||
|
row.hidden = !!query && !text.includes(query);
|
||||||
|
const button = row.nextElementSibling;
|
||||||
|
if (button?.matches('button.rb-copy')) (button as HTMLElement).hidden = row.hidden;
|
||||||
|
if (!row.hidden) visible += 1;
|
||||||
|
}
|
||||||
|
count.textContent = query
|
||||||
|
? `${visible.toLocaleString('en')} of ${rows.length.toLocaleString('en')} rows`
|
||||||
|
: `${rows.length.toLocaleString('en')} rows`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// A hash cut short on a button (button.rb-copy): a click copies the whole
|
// A hash cut short on a button (button.rb-copy): a click copies the whole
|
||||||
// value from data-copy, says so to a screen reader, and marks the button
|
// value from data-copy, says so to a screen reader, and marks the button
|
||||||
// for two seconds. The full value is also in the row's details, so a
|
// for two seconds. The full value is also in the row's details, so a
|
||||||
@@ -982,6 +1014,7 @@ breakPaths();
|
|||||||
hashSearch();
|
hashSearch();
|
||||||
glossaryTerms();
|
glossaryTerms();
|
||||||
copyButtons();
|
copyButtons();
|
||||||
|
filterFiles();
|
||||||
osTabs();
|
osTabs();
|
||||||
showCurrentPage();
|
showCurrentPage();
|
||||||
notFound();
|
notFound();
|
||||||
@@ -551,8 +551,56 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
|
|||||||
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
|
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
|
||||||
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
|
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
|
||||||
|
|
||||||
/* A platform file row: a short hash on a button that copies it whole
|
/* A platform's file list (generate_site._platform_file_list), on the
|
||||||
(site.ts), and the row's evidence in a details cell. */
|
pattern of the Tested block: each row a details whose summary is the
|
||||||
|
row (name, folder, status), its body the evidence across the whole
|
||||||
|
list. The short hash sits on its copy button (site.ts) beside the
|
||||||
|
summary, since a summary is itself a control. The details reaches under
|
||||||
|
the button column, so the row rule and an opened body run full width. */
|
||||||
|
.sl-markdown-content .rb-files {
|
||||||
|
--rb-hash-column: 6.75rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--rb-hash-column);
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--rb-border-strong);
|
||||||
|
}
|
||||||
|
.sl-markdown-content .rb-files > * { margin: 0; }
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file {
|
||||||
|
grid-column: 1;
|
||||||
|
margin-right: calc(-1 * var(--rb-hash-column));
|
||||||
|
padding: 0 0 0 1.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--rb-border);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file[open] { padding-bottom: 0.6rem; background: var(--rb-surface); }
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file > summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.25rem minmax(0, 1fr) 9rem 6rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
margin-left: -1.75rem;
|
||||||
|
padding: 0.3rem calc(var(--rb-hash-column) + 0.5rem) 0.3rem 0;
|
||||||
|
font: 0.8125rem/1.4 var(--sl-font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary { grid-template-columns: 1.25rem minmax(0, 1fr) 6rem; }
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file > summary:hover { background: var(--rb-surface); }
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file > summary::before { margin: 0; background-color: var(--rb-text-secondary); }
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 3; padding: 0; background: none; color: var(--rb-text-secondary); }
|
||||||
|
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: -2; justify-self: start; font-family: var(--sl-font); }
|
||||||
|
.sl-markdown-content .rb-files > button.rb-copy {
|
||||||
|
position: relative;
|
||||||
|
grid-column: 2;
|
||||||
|
align-self: start;
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.sl-markdown-content .rb-files > [hidden] { display: none; }
|
||||||
|
|
||||||
.sl-markdown-content button.rb-copy {
|
.sl-markdown-content button.rb-copy {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -561,7 +609,7 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
|
|||||||
padding: 0 0.35em;
|
padding: 0 0.35em;
|
||||||
border: 1px solid var(--rb-border-strong);
|
border: 1px solid var(--rb-border-strong);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--rb-surface);
|
background: var(--sl-color-bg);
|
||||||
color: var(--sl-color-white);
|
color: var(--sl-color-white);
|
||||||
font: 0.8125rem/1.4 var(--sl-font-mono);
|
font: 0.8125rem/1.4 var(--sl-font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -579,37 +627,17 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
|
|||||||
background: var(--rb-ok-fg);
|
background: var(--rb-ok-fg);
|
||||||
}
|
}
|
||||||
.sl-markdown-content button.rb-copy:hover { border-color: var(--rb-link); }
|
.sl-markdown-content button.rb-copy:hover { border-color: var(--rb-link); }
|
||||||
/* The file name and its destination, written as plain text to spare an
|
@media (max-width: 50rem) {
|
||||||
element per cell. */
|
/* On a phone the folder goes under the name, the status stays on the
|
||||||
.sl-markdown-content table:has(:is(button.rb-copy, details.rb-row-details)) td:is(:nth-child(1), :nth-child(2)) {
|
first line. A row with no folder prints nothing for it. */
|
||||||
font-family: var(--sl-font-mono);
|
.sl-markdown-content .rb-files > details.rb-file > summary,
|
||||||
font-size: 0.8125rem;
|
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary {
|
||||||
overflow-wrap: anywhere;
|
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
||||||
}
|
row-gap: 0.1rem;
|
||||||
.sl-markdown-content details.rb-row-details {
|
}
|
||||||
margin: 0;
|
.sl-markdown-content .rb-files > details.rb-file > summary::before { grid-row: 1; }
|
||||||
padding: 0;
|
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 2; grid-row: 2; }
|
||||||
border: 0;
|
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: 3; grid-row: 1; }
|
||||||
}
|
|
||||||
.sl-markdown-content details.rb-row-details > summary {
|
|
||||||
min-height: 24px;
|
|
||||||
color: var(--rb-link);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sl-markdown-content details.rb-row-details[open] {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
/* Open beside four narrow columns, the evidence keeps a readable width:
|
|
||||||
the other cells give up their side padding for it. */
|
|
||||||
@media (min-width: 50rem) {
|
|
||||||
.sl-markdown-content table:has(details.rb-row-details) :is(th, td) { padding-inline: 0.5rem; }
|
|
||||||
/* A long name or path wraps before the table outgrows the column. */
|
|
||||||
.sl-markdown-content table:has(details.rb-row-details) td:first-child { min-width: 9rem; }
|
|
||||||
.sl-markdown-content table:has(details.rb-row-details) td:nth-child(2) { min-width: 4rem; }
|
|
||||||
.sl-markdown-content details.rb-row-details[open] { min-width: 15rem; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
|
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
|
||||||
|
|||||||
Reference in new issue
Block a user