mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -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}"
|
||||
|
||||
|
||||
# A platform system whose file list passes this much Markdown gets a page
|
||||
# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page.
|
||||
PLATFORM_SECTION_BUDGET = 200_000
|
||||
# A system's own page in runs of at most this much Markdown: RetroDECK's
|
||||
# arcade list, each file with its served copy and pinned readers, rendered
|
||||
# 1.4 MB of HTML on one page.
|
||||
PLATFORM_PART_BUDGET = 350_000
|
||||
# The Markdown of a platform page's file lists, past which the largest
|
||||
# A platform's file lists are split by the share of a page they render to,
|
||||
# in thousandths: their elements against validate_site's MAX_PAGE_ELEMENTS,
|
||||
# their bytes against the 600,000 of test_site_r2_gate, whichever is
|
||||
# larger. Measured in Markdown characters, the lists split anew whenever
|
||||
# their markup changed: rows written as tables moved 38 more systems to
|
||||
# pages of their own.
|
||||
PAGE_ELEMENTS = 10_000
|
||||
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.
|
||||
PLATFORM_REFERENCE_BUDGET = 250_000
|
||||
PLATFORM_REFERENCE_BUDGET = 600
|
||||
|
||||
|
||||
def _file_runs(body: list[str], budget: int) -> list[list[str]]:
|
||||
"""A system's file list in runs of at most ``budget`` characters.
|
||||
# Every part page a platform list has had, with the system it held:
|
||||
# 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,
|
||||
and a table row is one line.
|
||||
"""
|
||||
rows = next((i + 1 for i, line in enumerate(body) if line.startswith("|--")), None)
|
||||
first = rows if rows is not None else next(
|
||||
(i for i, line in enumerate(body) if line.startswith("- ")), len(body))
|
||||
head, items = body[:first], []
|
||||
for line in body[first:]:
|
||||
if line.startswith(("- ", "| ")) or not items:
|
||||
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]
|
||||
|
||||
def _published_platform_parts() -> dict[str, str]:
|
||||
if not PLATFORM_PARTS_FILE.is_file():
|
||||
return {}
|
||||
return json.loads(PLATFORM_PARTS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def _blocks_in_runs(blocks: list[list[str]], budget: int) -> list[list[str]]:
|
||||
"""Blocks (table rows, folded lists) in runs of at most ``budget``
|
||||
characters, a block never cut."""
|
||||
def _characters(lines: list[str]) -> int:
|
||||
return sum(len(line) + 1 for line in lines)
|
||||
|
||||
|
||||
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]] = [[]]
|
||||
size = 0
|
||||
for block in blocks:
|
||||
weight = sum(len(line) + 1 for line in block)
|
||||
weight = weigh(block)
|
||||
if runs[-1] and size + weight > budget:
|
||||
runs.append([])
|
||||
size = 0
|
||||
@@ -3426,16 +3422,15 @@ def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
|
||||
return found
|
||||
|
||||
|
||||
PLATFORM_FILE_HEADER = [
|
||||
"| File | Path | Status | Hash | Details |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
# The characters of a hash a row shows, as many as the served copy's sha1.
|
||||
SHORT_HASH = 8
|
||||
# The hash a row shows first, when the list does not say which it checks.
|
||||
_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
|
||||
# An untested entry whose reason is a hash the bytes do not match.
|
||||
_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
|
||||
# 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:
|
||||
@@ -3450,12 +3445,13 @@ def _status_chip(f: dict) -> str:
|
||||
tone = "warning"
|
||||
else:
|
||||
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:
|
||||
"""A declared hash cut to SHORT_HASH characters on a button that copies it whole.
|
||||
A list of accepted values (Recalbox) shows its first and how many more."""
|
||||
"""A declared hash cut to SHORT_HASH characters on a button that copies
|
||||
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
|
||||
|
||||
values = parse_hash_list(value)
|
||||
@@ -3463,43 +3459,68 @@ def _copy_hash(algorithm: str, value: str | list) -> str:
|
||||
return ""
|
||||
first = html.escape(values[0])
|
||||
more = f" +{len(values) - 1}" if len(values) > 1 else ""
|
||||
return (f'<button type="button" class="rb-copy" data-copy="{first}" '
|
||||
f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}">'
|
||||
f'{first[:SHORT_HASH]}</button>{more}')
|
||||
return (f'<button class="rb-copy" data-copy="{first}" '
|
||||
f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}{more}">'
|
||||
f'{first[:SHORT_HASH]}{more}</button>')
|
||||
|
||||
|
||||
def _cell(text: str) -> str:
|
||||
"""Text for one cell of a Markdown table: a pipe would end the cell."""
|
||||
return text.replace("|", "\\|")
|
||||
# Characters Markdown or MDX would read inside the raw HTML of a row: an
|
||||
# MDX page parses the text of its HTML as Markdown, a .md page does not.
|
||||
_INERT = str.maketrans({"*": "*", "_": "_", "`": "`", "[": "[", "]": "]"})
|
||||
_INLINE_MD = re.compile(
|
||||
r"\[(?P<label>(?:`[^`]*`|[^\]`])+)\]\((?P<url>[^)\s]+)\)"
|
||||
r"|`(?P<code>[^`]+)`"
|
||||
r"|\*\*(?P<bold>[^*]+)\*\*"
|
||||
)
|
||||
|
||||
|
||||
_MARKDOWN_PUNCTUATION = re.compile(r"([\\`*_\[\]~])")
|
||||
_CODE_LINK = re.compile(r"\[`([^`]+)`\]\(")
|
||||
_SHORT_SHA1 = re.compile(r"\(sha1 `([0-9a-f]+)`\)")
|
||||
def _text_html(text: str) -> str:
|
||||
"""Plain text as HTML that neither Markdown nor MDX rewrites."""
|
||||
return html.escape(html.unescape(text), quote=False).translate(_INERT)
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
"""Text written as itself in Markdown: no emphasis, link or tag."""
|
||||
return _MARKDOWN_PUNCTUATION.sub(r"\\\1", html.escape(text, quote=False))
|
||||
def _inline_html(markdown: str) -> str:
|
||||
"""The inline Markdown of a row's evidence (links, code, bold) as HTML.
|
||||
|
||||
|
||||
def _uncoded(text: str) -> str:
|
||||
"""A linked path as plain link text, a short sha1 as plain text.
|
||||
|
||||
Each code element is one more element of the page. With one in every
|
||||
name, path and reference, Batocera's page measured 10,479 elements, over
|
||||
validate_site's budget of 10,000. The link and the value say the same
|
||||
without it.
|
||||
Code spans become plain text and a linked path loses its code element:
|
||||
one per reference and per reader was the bulk of the elements of a
|
||||
platform page, and the words say the same without them.
|
||||
"""
|
||||
text = _CODE_LINK.sub(lambda m: f"[{_plain(m[1])}](", text)
|
||||
return _SHORT_SHA1.sub(r"(sha1 \1)", text)
|
||||
out: list[str] = []
|
||||
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 = "",
|
||||
launched: list[str] | None = None) -> str:
|
||||
"""One file of a platform's list as a table row: its name, where it goes,
|
||||
its status and a short hash at a glance. The details hold every hash the
|
||||
platform declares, the size, the copy that serves it and its readers.
|
||||
"""One file of a platform's list as one row, written as HTML.
|
||||
|
||||
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"]
|
||||
hashes = {
|
||||
@@ -3525,7 +3546,7 @@ def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
||||
elif size:
|
||||
facts.append(f"Size: {_fmt_size(size)}")
|
||||
if f.get("status") == "untested" and f.get("reason"):
|
||||
facts.append(f"Not confirmed: {f['reason']}")
|
||||
facts.append(_text_html(f"Not confirmed: {f['reason']}"))
|
||||
# Not every entry is firmware: the platform may list a game's data or a
|
||||
# file no core it runs opens.
|
||||
kind = {"game_data": "Game data, not firmware",
|
||||
@@ -3534,29 +3555,53 @@ def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
||||
facts.append(kind)
|
||||
served = _served_line(f)
|
||||
if served:
|
||||
facts.append(_uncoded(served))
|
||||
facts.append(_inline_html(served))
|
||||
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,
|
||||
# GC/EUR and GC/JAP. A destination that ends with the name shows its
|
||||
# folder only, the name being in the cell before it. Both are plain
|
||||
# text set in the code font by the table's style: a code element in
|
||||
# each cell was two more elements per row.
|
||||
destination = str(cfg_entry.get("destination") or "")
|
||||
if destination == fname or destination.endswith("/" + fname):
|
||||
destination = destination[:len(destination) - len(fname)]
|
||||
summary = f'<summary aria-label="Details of {html.escape(fname)}">Details</summary>'
|
||||
details = (f'<details class="rb-row-details">{summary}{"<br>".join(facts)}</details>'
|
||||
if facts else "")
|
||||
cells = [
|
||||
_plain(fname),
|
||||
_plain(destination),
|
||||
_status_chip(f),
|
||||
_copy_hash(shown, hashes[shown]) if shown else "",
|
||||
details,
|
||||
]
|
||||
return "| " + " | ".join(_cell(cell) for cell in cells) + " |"
|
||||
path = _row_path(fname, cfg_entry)
|
||||
summary = (_text_html(fname) + (f"<code>{_text_html(path)}</code>" if path else "")
|
||||
+ _status_chip(f))
|
||||
copy = _copy_hash(shown, hashes[shown]) if shown else ""
|
||||
return (f'<details class="rb-file"><summary>{summary}</summary>'
|
||||
f'{"<br>".join(facts)}</details>{copy}')
|
||||
|
||||
|
||||
def _platform_file_list(rows: list[str], has_path: bool, anchor: str) -> list[str]:
|
||||
"""A system's rows as one HTML block, with a filter box when long.
|
||||
|
||||
The list drops its path column when no row has one, so a blank cell
|
||||
never reads as missing data.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if len(rows) >= FILTER_ROWS:
|
||||
field = f"rb-files-filter-{anchor}"
|
||||
lines += [
|
||||
'<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(
|
||||
@@ -3586,17 +3631,18 @@ def _render_platform_systems(
|
||||
|
||||
sys_emus = _system_emulators(sys_id, emulator_files, by_system_declared.get(sys_id))
|
||||
anchor = sys_id.replace(" ", "-")
|
||||
body = [f"{ok_count}/{total} files verified"]
|
||||
head = [f"{ok_count}/{total} files verified"]
|
||||
if sys_emus:
|
||||
emu_links = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus))
|
||||
body[-1] += "\\"
|
||||
body.append(f"Emulators: {emu_links}")
|
||||
body.append("")
|
||||
head[-1] += "\\"
|
||||
head.append(f"Emulators: {emu_links}")
|
||||
head.append("")
|
||||
|
||||
# One row per file, the evidence in its details. The sort is stable,
|
||||
# so same-named entries keep the order verify reported them in,
|
||||
# which is the declaration order.
|
||||
body.extend(PLATFORM_FILE_HEADER)
|
||||
rows: list[str] = []
|
||||
has_path = False
|
||||
seen_names: dict[str, int] = {}
|
||||
for f in sorted(files, key=lambda x: x["name"]):
|
||||
fname = f["name"]
|
||||
@@ -3604,23 +3650,23 @@ def _render_platform_systems(
|
||||
nth = seen_names.get(fname, 0)
|
||||
seen_names[fname] = nth + 1
|
||||
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 ""),
|
||||
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
|
||||
# block indented by four, so both engines split the same systems.
|
||||
size = sum(len(line) + 1 for line in body)
|
||||
# Measured in the share of a page the list renders to.
|
||||
size = _page_share(body)
|
||||
if use_collapsible:
|
||||
status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue")
|
||||
title = f"{_count(total, 'file')} ({status_tag})"
|
||||
size += len(f'??? note "{title}"') + 2 + 4 * sum(1 for line in body if line)
|
||||
block = markup.details("note", title, body)
|
||||
block = markup.details("note", f"{_count(total, 'file')} ({status_tag})", body)
|
||||
while block and not block[-1]:
|
||||
block.pop()
|
||||
else:
|
||||
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})
|
||||
|
||||
# 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"]
|
||||
|
||||
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"]
|
||||
# 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
|
||||
@@ -3649,7 +3695,9 @@ def _render_platform_systems(
|
||||
lines.append("")
|
||||
if moved is not None and platform and sys_id in own_page:
|
||||
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 "")
|
||||
for n in range(1, len(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:
|
||||
write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
|
||||
published_parts = _published_platform_parts()
|
||||
for name, cov in coverages.items():
|
||||
# The emulators this platform runs, not every one of the system.
|
||||
runs = resolve_platform_cores(cov["config"], profiles)
|
||||
@@ -11322,6 +11371,9 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
if stem in coverages:
|
||||
raise RuntimeError(f"a system page takes the platform {stem}'s address")
|
||||
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
|
||||
# bios" found five setups' lists before the console's 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)
|
||||
|
||||
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)
|
||||
|
||||
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)),
|
||||
indent=1) + "\n")
|
||||
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")
|
||||
write_if_changed(str(generated / "lastmod.json"),
|
||||
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_-]+")
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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:
|
||||
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()
|
||||
if old not in present and new in present}
|
||||
out = {address(old): address(new) for old, new in {**MOVED_PAGES, **(moved or {})}.items()
|
||||
if old not in present and new.partition("#")[0] in present}
|
||||
pages = {rel.casefold() for rel in present}
|
||||
claims: dict[str, set[str]] = {}
|
||||
for key, profile in profiles.items():
|
||||
|
||||
@@ -340,9 +340,9 @@ class APlatformPageSaysWhereEachFileGoes(unittest.TestCase):
|
||||
lines = generate_site._render_platform_systems(
|
||||
{"nintendo-gamecube": files}, {("nintendo-gamecube", "IPL.bin"): declared}, {})
|
||||
for region in regions:
|
||||
# The folder: the name is in the cell before it.
|
||||
self.assertIn(f"| IPL.bin | dolphin-emu/Sys/GC/{region}/ | "
|
||||
'<span class="rb-badge rb-badge-success">OK</span> |',
|
||||
# The folder: the name is before it in the row.
|
||||
self.assertIn(f"<summary>IPL.bin<code>dolphin-emu/Sys/GC/{region}/</code>"
|
||||
'<span class="rb-badge rb-badge-success">OK</span></summary>',
|
||||
"\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
|
||||
spelled its full MD5, the copy serving it and each source reference of
|
||||
each reader, with "(emulator code, libretro port)" after every one. A row
|
||||
now shows the name, the folder, the status and a short hash; its details
|
||||
hold every hash, the served copy and the readers, the role of each
|
||||
reference named by one word that a legend defines once.
|
||||
now shows the name, the folder, the status and a short hash. Opened, it
|
||||
holds every hash, the served copy and the readers across the list, the
|
||||
role of each reference named by one word that a legend defines once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
@@ -93,24 +94,34 @@ class ARowPerFile(unittest.TestCase):
|
||||
row = self._row({"name": "bios_a.bin", "status": "ok", "size": 2048},
|
||||
{"name": "bios_a.bin", "md5": md5, "sha1": sha1, "crc32": "0badf00d",
|
||||
"destination": "bios/bios_a.bin"})
|
||||
cells = re.split(r"(?<!\\)\|", row)[1:-1]
|
||||
self.assertEqual(len(cells), len(generate_site.PLATFORM_FILE_HEADER[0].split("|")) - 2)
|
||||
name, folder, status, short, details = (cell.strip() for cell in cells)
|
||||
self.assertEqual(name, r"bios\_a.bin")
|
||||
# The folder: the name is in the cell before it.
|
||||
self.assertEqual(folder, "bios/")
|
||||
self.assertEqual(status, '<span class="rb-badge rb-badge-success">OK</span>')
|
||||
# The hash the platform checks, short, on a button that copies it whole.
|
||||
self.assertIn(f'data-copy="{md5}"', short)
|
||||
self.assertIn(f">{md5[:8]}</button>", short)
|
||||
# The accessible name holds the visible text (WCAG 2.5.3).
|
||||
self.assertIn(f'aria-label="Copy MD5 {md5[:8]}"', short)
|
||||
# The summary is the row: the name, its folder, the status.
|
||||
self.assertIn('<details class="rb-file"><summary>bios_a.bin<code>bios/</code>'
|
||||
'<span class="rb-badge rb-badge-success">OK</span></summary>', row)
|
||||
# The hash the platform checks, short, on one button beside the
|
||||
# summary (a summary is a control: no button inside it).
|
||||
details, button = row.split("</details>")
|
||||
self.assertNotIn("<button", details)
|
||||
self.assertEqual(button, f'<button class="rb-copy" data-copy="{md5}" '
|
||||
f'aria-label="Copy MD5 {md5[:8]}">{md5[:8]}</button>')
|
||||
# Every hash whole in the details, with the size.
|
||||
for value in (md5, sha1, "0badf00d"):
|
||||
self.assertIn(value, details)
|
||||
self.assertIn("2.0 KB", details)
|
||||
self.assertTrue(details.startswith('<details class="rb-row-details">'
|
||||
'<summary aria-label="Details of bios_a.bin">Details</summary>'))
|
||||
self.assertIn("(2.0 KB)", details)
|
||||
|
||||
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):
|
||||
cases = {
|
||||
@@ -129,18 +140,20 @@ class ARowPerFile(unittest.TestCase):
|
||||
values = ["1" * 32, "2" * 32, "3" * 32]
|
||||
row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)})
|
||||
self.assertIn(f'data-copy="{values[0]}"', row)
|
||||
self.assertIn("</button> +2", row)
|
||||
self.assertIn(f">{values[0][:8]} +2</button>", row)
|
||||
for value in values:
|
||||
self.assertIn(f'<code class="rb-hash">{value}</code>', row)
|
||||
|
||||
def test_a_destination_under_another_name_stays_whole(self):
|
||||
row = self._row({"name": "a.bin", "status": "ok"}, {"destination": "x/b.bin"})
|
||||
self.assertIn("| a.bin | x/b.bin |", row)
|
||||
def test_markdown_never_reads_the_text(self):
|
||||
row = self._row({"name": "*a_b*[1].bin", "status": "ok"}, {})
|
||||
self.assertIn("*a_b*[1].bin", row)
|
||||
|
||||
def test_a_pipe_does_not_end_the_cell(self):
|
||||
row = self._row({"name": "a|b", "status": "ok"}, {})
|
||||
self.assertIn(r"a\|b", row)
|
||||
self.assertEqual(len(re.split(r"(?<!\\)\|", row)), 7)
|
||||
def test_a_long_list_gets_a_filter(self):
|
||||
rows = ["<details></details>"] * generate_site.FILTER_ROWS
|
||||
block = generate_site._platform_file_list(rows, False, "arcade")
|
||||
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):
|
||||
@@ -162,7 +175,7 @@ class ReferencesNameTheirRoleOnce(unittest.TestCase):
|
||||
|
||||
|
||||
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")
|
||||
@@ -222,7 +235,7 @@ class NothingIsLost(unittest.TestCase):
|
||||
# 19,127: RetroDECK's arcade list).
|
||||
PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192}
|
||||
MAIN_PAGE_CEILING = 2459
|
||||
PART_PAGE_CEILING = 2306
|
||||
PART_PAGE_CEILING = 2219
|
||||
|
||||
_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))
|
||||
for page in sorted(_SITE.glob("*/index.html")):
|
||||
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)
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -158,16 +158,13 @@ class EachEntryNamesItsFile(unittest.TestCase):
|
||||
# Existence mode reports no path: nothing is claimed, held or not.
|
||||
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
|
||||
|
||||
def test_a_long_system_list_is_cut_between_files(self):
|
||||
body = ["2/2 files verified", ""]
|
||||
for n in range(4):
|
||||
body += [f"- `f{n}.bin` - OK", " " + "x" * 100]
|
||||
runs = generate_site._file_runs(body, 250)
|
||||
self.assertEqual(len(runs), 2)
|
||||
for run in runs:
|
||||
self.assertEqual(run[:2], body[:2])
|
||||
self.assertTrue(run[2].startswith("- "))
|
||||
self.assertEqual(sum(len(run) - 2 for run in runs), len(body) - 2)
|
||||
def test_a_long_system_list_is_cut_between_rows_by_elements(self):
|
||||
rows = [[f'<details class="rb-file"><summary>f{n}.bin</summary>x<br>y</details>']
|
||||
for n in range(4)]
|
||||
# Three elements a row: two rows fit in a budget of seven.
|
||||
runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight)
|
||||
self.assertEqual([len(run) for run in runs], [2, 2])
|
||||
self.assertEqual(sum(runs, []), sum(rows, []))
|
||||
|
||||
|
||||
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):
|
||||
# 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] = {}
|
||||
generate_site._render_platform_systems(
|
||||
{"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}
|
||||
moved: dict[str, str] = {}
|
||||
budget = generate_site.PLATFORM_SECTION_BUDGET
|
||||
generate_site.PLATFORM_SECTION_BUDGET = 500
|
||||
generate_site.PLATFORM_SECTION_BUDGET = 5
|
||||
try:
|
||||
lines = generate_site._render_platform_systems(
|
||||
{"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);
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -982,6 +1014,7 @@ breakPaths();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
copyButtons();
|
||||
filterFiles();
|
||||
osTabs();
|
||||
showCurrentPage();
|
||||
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[open] > summary { margin-bottom: 0.6rem; }
|
||||
|
||||
/* A platform file row: a short hash on a button that copies it whole
|
||||
(site.ts), and the row's evidence in a details cell. */
|
||||
/* A platform's file list (generate_site._platform_file_list), on the
|
||||
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 {
|
||||
display: inline-flex;
|
||||
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;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--rb-surface);
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-white);
|
||||
font: 0.8125rem/1.4 var(--sl-font-mono);
|
||||
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);
|
||||
}
|
||||
.sl-markdown-content button.rb-copy:hover { border-color: var(--rb-link); }
|
||||
/* The file name and its destination, written as plain text to spare an
|
||||
element per cell. */
|
||||
.sl-markdown-content table:has(:is(button.rb-copy, details.rb-row-details)) td:is(:nth-child(1), :nth-child(2)) {
|
||||
font-family: var(--sl-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sl-markdown-content details.rb-row-details {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.sl-markdown-content details.rb-row-details > summary {
|
||||
min-height: 24px;
|
||||
color: var(--rb-link);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sl-markdown-content details.rb-row-details[open] {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Open beside four narrow columns, the evidence keeps a readable width:
|
||||
the other cells give up their side padding for it. */
|
||||
@media (min-width: 50rem) {
|
||||
.sl-markdown-content table:has(details.rb-row-details) :is(th, td) { padding-inline: 0.5rem; }
|
||||
/* A long name or path wraps before the table outgrows the column. */
|
||||
.sl-markdown-content table:has(details.rb-row-details) td:first-child { min-width: 9rem; }
|
||||
.sl-markdown-content table:has(details.rb-row-details) td:nth-child(2) { min-width: 4rem; }
|
||||
.sl-markdown-content details.rb-row-details[open] { min-width: 15rem; }
|
||||
@media (max-width: 50rem) {
|
||||
/* On a phone the folder goes under the name, the status stays on the
|
||||
first line. A row with no folder prints nothing for it. */
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary,
|
||||
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary {
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
||||
row-gap: 0.1rem;
|
||||
}
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary::before { grid-row: 1; }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 2; grid-row: 2; }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: 3; grid-row: 1; }
|
||||
}
|
||||
|
||||
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
|
||||
|
||||
Reference in new issue
Block a user