diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 0174f9035..540f64ed5 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -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'{word}'
+ return f'{html.escape(word)}'
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'{more}')
+ return (f'')
-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