diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index f73a52a08..9a84a5c55 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -45,6 +45,7 @@ on:
- "scripts/safeparse.py"
- "scripts/sect233r1.py"
- "scripts/sitediagrams.py"
+ - "scripts/siteentries.py"
- "scripts/siteguides.py"
- "scripts/sitemarkup.py"
- "scripts/sitemeasurements.py"
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index dd04f5697..4f4323fd5 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -83,6 +83,7 @@ from generate_readme import (
)
from provenance_report import build_report, distinct_missing
import sitemarkup as markup
+import siteentries
import sitestarlight
from siteexports import generate_exports_page, load_export_reports
from siteguides import (
@@ -5101,12 +5102,14 @@ def generate_provenance_page(
# the public for 79 of them.
derivable = derivable or {}
derived_entries: dict[str, dict] = {}
- for data in report.values():
+ derived_by_source: dict[str, int] = {}
+ for source, data in report.items():
kept = []
for entry in data["missing"]:
sha1 = (entry.get("sha1") or "").lower()
if sha1 in derivable:
derived_entries.setdefault(sha1, entry)
+ derived_by_source[source] = derived_by_source.get(source, 0) + 1
else:
kept.append(entry)
data["missing"] = kept
@@ -5134,7 +5137,19 @@ def generate_provenance_page(
for dat in entry.get("dats") or [entry.get("dat", "")]:
by_dat.setdefault(dat, []).append(entry)
lines.extend([f"### {label}", ""])
- blocks: list[list[str]] = []
+ if moved is not None:
+ where = _list_pages(
+ f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}",
+ f"Dumps {label} catalogues and the collection lacks. Part of "
+ f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).",
+ [], [_wanted_list(source, label, data, by_dat, derived_by_source.get(source, 0))],
+ moved, parent=("Dump provenance", "/provenance/"),
+ )
+ lines.extend([
+ f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: "
+ f"listed on {where}.", "",
+ ])
+ continue
for dat in sorted(by_dat):
entries = by_dat[dat]
table = ["| Name | Description | SHA1 |", "|------|-------------|------|"]
@@ -5150,20 +5165,7 @@ def generate_provenance_page(
f"`{hit['archive']}` as `{hit['member']}`."
)
table.append(f"| `{name}` | {desc} | `{sha1}` |")
- blocks.append(markup.details("note", f"{dat} ({len(entries)})", table))
- if moved is None:
- lines.extend(line for block in blocks for line in block)
- continue
- where = _list_pages(
- f"provenance-{_slugify_anchor(source)}", f"Wanted from {label}",
- f"Dumps {label} catalogues and the collection lacks, by DAT. Part of "
- f"[Dump provenance](provenance.md#{_slugify_anchor(label)}).",
- [], blocks, moved, parent=("Dump provenance", "/provenance/"),
- )
- lines.extend([
- f"{_count(len(data['missing']), 'dump')} in {_count(len(by_dat), 'DAT')}: "
- f"listed on {where}.", "",
- ])
+ lines.extend(markup.details("note", f"{dat} ({len(entries)})", table))
if derived_entries:
lines.extend(
@@ -5189,6 +5191,69 @@ def generate_provenance_page(
return "\n".join(lines) + "\n"
+def _wanted_list(source: str, label: str, data: dict, by_dat: dict[str, list],
+ derived: int) -> list[str]:
+ """A catalog's wanted page: the counts, then every missing dump in one
+ table folded under a filter box.
+
+ The page was one closed block per DAT, 46 for TOSEC: a name was found
+ by opening each in turn. The box filters the whole list by name, DAT,
+ description or SHA1, and says how many rows it keeps.
+ """
+ missing = data["missing"]
+ held = data["matched"]
+ reached = held + len(missing) + derived
+ dats = _count(len(data["covered_dats"]), "DAT")
+ lines = [
+ f"Held: **{held:,}** of {reached:,}, in the {dats} the collection reaches. "
+ f"Wanted: **{len(missing):,}**, from {_count(len(by_dat), 'DAT')}.",
+ "",
+ ]
+ if derived:
+ verb = "is" if derived == 1 else "are"
+ lines.extend([
+ f"{derived:,} more {verb} [derivable]"
+ "(provenance.md#derivable-dumps-the-collection-already-produces).", ""])
+ if data.get("out_of_scope"):
+ lines.extend([f"{data['out_of_scope']:,} more sit in DATs the collection does not reach.", ""])
+ counts = ["| DAT | Wanted |", "|-----|--------|",
+ *(f"| {dat.replace('|', '-')} | {len(entries):,} |"
+ for dat, entries in sorted(by_dat.items()))]
+ lines.extend(markup.details("note", f"Wanted by DAT ({len(by_dat):,})", counts))
+ rows = []
+ for entry in sorted(missing, key=lambda e: (e["name"], e.get("sha1") or "")):
+ desc = markup.inline_html(entry.get("description") or "")
+ for hit in entry.get("romsets", []):
+ desc += markup.inline_html(
+ f" Ships in {_ROMSET_SOURCES.get(hit['source'], hit['source'])} "
+ f"`{hit['archive']}` as `{hit['member']}`.")
+ in_dats = ", ".join(html.escape(d) for d in (entry.get("dats") or [entry.get("dat", "")]))
+ sha1 = html.escape(entry.get("sha1") or "-")
+ # The search reads the name alone. The DAT names are searched once, in
+ # the count table above, and the site search holds no hashes. With the
+ # descriptions indexed too, the TOSEC list matched "2" 556 times
+ # (years, versions) and put the PlayStation page ahead of the
+ # PlayStation 2 one for "playstation 2 bios". The name and the hash
+ # sit in their cells as text: a catalog name is words, and in code
+ # each word wore its own box on a narrow column.
+ skip = markup.search_exclude()
+ rows.append("
", "", "",
+ ])
+ return lines
+
+
_RELEASE_ASSET_NAMES: dict[str, str] | None = None
@@ -7462,12 +7527,15 @@ def _render_emulator_file(
owner: str = "",
alias: str = "",
data_href: str = "#data-folder",
+ badges_apart: bool = False,
) -> list[str]:
"""Render one row of an emulator profile's file table.
`anchor` is the card's id on the page, so one file can be cited by URL,
and `alias` a second id it answers to. `data_href` links the data
folder an app reads the entry under, from the page the card lands on.
+ `badges_apart`: the title holds the name alone, for a table whose
+ Status column shows the badges (siteentries.py).
Split out of generate_emulator_page, where it was a 228-line loop body
carrying most of that function's branching: thirty-odd optional fields,
@@ -7516,7 +7584,8 @@ def _render_emulator_file(
system = system_name((profile.get("systems") or [""])[0])
label = (f"a {system} BIOS (example: {_code(fname)})" if _named_example(str(fname))
else f"a {system} BIOS, any file name")
- lines.append(_card_title(f"{marker}{label} {badge_str}", anchor, weight=5))
+ title = f"{marker}{label}" if badges_apart else f"{marker}{label} {badge_str}"
+ lines.append(_card_title(title, anchor, weight=5))
lines.append("")
if desc:
lines.extend([data_root_words(_escape_tags(str(desc)), data_href), ""])
@@ -8522,6 +8591,10 @@ def _data_tree_page(ref: str, entry: dict, pages: list[tuple[str, list[str]]],
# anchor. A count, not a size: system cards link to these cards and are
# written before them. At 400 a part of MAME's cards was 805 KB of HTML.
EMULATOR_CARDS_PER_PAGE = 220
+# The most cards a part of a split page holds. Each card is a table row
+# since siteentries.py, about five elements and 60 bytes more: at 220 a
+# part, MAME's first was 612 KB and 9,777 elements.
+EMULATOR_CARDS_PER_PART = 180
# The headings an emulator's cards go under, in page order: what it needs
@@ -8556,12 +8629,59 @@ def emulator_parts(name: str, files: list) -> list[tuple[str, list[int]]]:
if len(files) <= EMULATOR_CARDS_PER_PAGE:
return []
order = _display_order(files)
- count = -(-len(files) // EMULATOR_CARDS_PER_PAGE)
+ count = -(-len(files) // EMULATOR_CARDS_PER_PART)
size = -(-len(files) // count)
return [(f"{name}-files-{index + 1}", order[start:start + size])
for index, start in enumerate(range(0, len(files), size))]
+# From this many entries an unsplit emulator page lists them as one table,
+# each row opening to its card (siteentries.py), in place of a summary
+# table above the cards: GroovyMAME's 171 cards read 14,400 words open.
+# A split emulator's card pages always do.
+EMULATOR_TABLE_MIN = 150
+ENTRY_HEADS = ["File", "Contents", "Status", "Hash"]
+# The end of the badge _file_badges writes for an entry of each section.
+_SECTION_BADGE = {"required": ">required", "optional": ">optional",
+ "with_app": ">comes with the app"}
+
+
+def _entry_rows(files: list[dict], cards: list[list[str]], profile: dict,
+ available: Callable[[dict], bool]) -> list[siteentries.Row]:
+ """Each entry as a table row: its card's title and body, the number of
+ members an archive holds, its badges and its hash."""
+ many = len(profile.get("systems") or []) > 1
+ rows = []
+ for f, card in zip(files, cards):
+ title, body = siteentries.card_title_and_body(card)
+ for_system = system_name(str(f["system"])) if f.get("system") and many else ""
+ contents = f.get("contents") or []
+ badges = _file_badges(f, available(f), for_system)
+ # The first badge says whether the entry is needed: under Required,
+ # Optional or Comes with the app it repeats the section heading.
+ # One element and two words fewer on each of MAME's 1,563 rows.
+ if badges and badges[0].endswith(_SECTION_BADGE.get(_card_section(f), "\0")):
+ badges = badges[1:]
+ rows.append(siteentries.Row(title, body, [
+ f"{len(contents):,}" if contents else "",
+ " ".join(badges),
+ _hash_short(f),
+ ]))
+ return rows
+
+
+def _entries_table(files: list[dict], group: list[int], rows: list[siteentries.Row]) -> list[str]:
+ """The rows of ``group``, one section's entries, as one table."""
+ key = _card_section(files[group[0]])
+ heading = dict(CARD_SECTIONS)[key]
+ return siteentries.entry_table([rows[i] for i in group], ENTRY_HEADS,
+ f"rb-entry-filter-{key.replace('_', '-')}", heading,
+ # The badges hide themselves from the search
+ # but for the archive's name ("in pgm.zip"),
+ # how a ZIP name finds the cores reading it.
+ searched=frozenset({"Status"}))
+
+
def _sectioned(files: list, indexes: list[int], render: Callable[[list[int]], list[str]],
fold: bool = False) -> list[str]:
"""Each section's heading, then ``render`` of its entries among ``indexes``.
@@ -8999,12 +9119,18 @@ def emulator_pages(
aliases = emulator_file_aliases(files, anchors)
parts = emulator_parts(name, files)
data_href = f"{name}.md#data-folder" if parts else "#data-folder"
+ table_form = markup.starlight() and (bool(parts) or len(files) >= EMULATOR_TABLE_MIN)
cards = [
_render_emulator_file(f, profile, consumers, files, _file_available, anchor, name,
- alias, data_href)
+ alias, data_href, badges_apart=table_form)
for f, anchor, alias in zip(files, anchors, aliases)
]
- if not parts:
+ rows = (_entry_rows(files, cards, profile, _file_available) if table_form else [])
+ if not parts and table_form:
+ lines.extend(held)
+ lines.extend(_sectioned(files, _display_order(files),
+ lambda group: _entries_table(files, group, rows)))
+ elif not parts:
order = _display_order(files)
indexed = len(files) >= EMULATOR_SUMMARY_MIN
if indexed:
@@ -9026,7 +9152,8 @@ def emulator_pages(
for number, (stem, entries) in enumerate(parts, 1):
part_pages[stem] = _emulator_part_page(
name, emu_name, number, parts, [cards[i] for i in entries],
- [files[i] for i in entries])
+ [files[i] for i in entries],
+ [rows[i] for i in entries] if table_form else None)
lines.extend(sitemeasurements.tested_section(name, _MEASUREMENT_ITEMS.get(name, [])))
@@ -9156,9 +9283,16 @@ def _part_front_matter(parent: tuple[str, str], siblings: list[tuple[str, str]],
def _emulator_part_page(
name: str, emu_name: str, number: int, parts: list[tuple[str, range]],
- cards: list[list[str]], files: list[dict],
+ cards: list[list[str]], files: list[dict], rows: list[siteentries.Row] | None = None,
) -> str:
- """One page of a split emulator's cards."""
+ """One page of a split emulator's cards: with ``rows``, one table row
+ each, the card under its name."""
+ if rows is not None:
+ def render(group: list[int]) -> list[str]:
+ return _entries_table(files, group, rows)
+ else:
+ def render(group: list[int]) -> list[str]:
+ return [line for i in group for line in cards[i]]
first = str(files[0].get("path") or files[0].get("name", ""))
last = str(files[-1].get("path") or files[-1].get("name", ""))
others = ", ".join(
@@ -9173,8 +9307,7 @@ def _emulator_part_page(
f"This part holds {_count(len(files), 'entry', 'entries')}, "
f"{_code(first)} to {_code(last)}. The other parts: {others}.",
"",
- *_sectioned(files, list(range(len(files))),
- lambda group: [line for i in group for line in cards[i]]),
+ *_sectioned(files, list(range(len(files))), render),
]
lines.extend(_next_block([f"[{emu_name}]({name}.md), the profile and every entry"]))
lines.extend(["", _date_line()])
diff --git a/scripts/siteentries.py b/scripts/siteentries.py
new file mode 100644
index 000000000..7fc5dc31c
--- /dev/null
+++ b/scripts/siteentries.py
@@ -0,0 +1,112 @@
+"""Long lists as one filterable table, each row opening to its details.
+
+MAME's card pages read 13,000 to 16,000 words, one card after another,
+before a reader found the archive they came for. Here each entry is one
+row: its name, the facts that tell rows apart, and the full card in a
+closed block under the name. The table is written as HTML so the block can
+hold the card's Markdown. It carries what rehype-stack-tables.mjs gives a
+Markdown table: `data-rb-stack` (one block per row on a phone) and the
+filter box above, which site.ts wires. Without JavaScript the rows show and
+every block opens. A closed block stays in the page, so the browser's find
+and the site search still reach every name it holds.
+"""
+
+from __future__ import annotations
+
+import html
+from dataclasses import dataclass, field
+
+import sitemarkup as markup
+
+# rehype-stack-tables.mjs RECORD_ROWS: from this many rows a table has a
+# filter box, and its stacked labels are written by site.ts, not the build.
+RECORD_ROWS = 20
+# filterPlaceholder in rehype-stack-tables.mjs: the column names offered.
+PLACEHOLDER_COLUMNS = 4
+
+
+@dataclass
+class Row:
+ """One entry: ``title`` is the inline HTML of its name (a heading, so
+ the site search lands on it), ``body`` the Markdown lines shown when the
+ row opens, ``cells`` the HTML of the other columns."""
+
+ title: str
+ body: list[str]
+ cells: list[str] = field(default_factory=list)
+
+
+def filter_placeholder(labels: list[str]) -> str:
+ """"File, contents, status..." as rehype-stack-tables.mjs words it."""
+ words = [label for label in labels if label][:PLACEHOLDER_COLUMNS]
+ if not words:
+ return "Type to filter..."
+ lowered = [word if index == 0 or not (word[:1].isupper() and word[1:2].islower())
+ else word[0].lower() + word[1:] for index, word in enumerate(words)]
+ return f"{', '.join(lowered)}..."
+
+
+def filter_box(ident: str, heading: str, labels: list[str], rows: int, noun: str = "rows") -> str:
+ """The filter box rehype-stack-tables.mjs writes above a long table."""
+ return (
+ f'
'
+ f''
+ f'{rows:,} {noun}
'
+ )
+
+
+def card_title_and_body(card: list[str]) -> tuple[str, list[str]]:
+ """A card's heading line and the lines under it, its wrapping block
+ (the first line and the last closing tag) taken off."""
+ title_at = next(index for index, line in enumerate(card) if line.startswith("
")
+ body = card[title_at + 1:closing]
+ while body and not body[0].strip():
+ body.pop(0)
+ while body and not body[-1].strip():
+ body.pop()
+ return card[title_at], body
+
+
+def entry_table(rows: list[Row], heads: list[str], ident: str, heading: str,
+ stacked: list[str] | None = None,
+ searched: frozenset[str] = frozenset()) -> list[str]:
+ """``rows`` under ``heads`` (the first names the column of titles).
+
+ ``stacked`` gives a column's short label on a phone, as a header's
+ `title` does in a Markdown table. A column every row leaves empty
+ (``""``) is not written. The site search skips every column but the
+ titles and those ``searched`` names: a count or a short hash is no word
+ anyone types.
+ """
+ keep = [0] + [column for column in range(1, len(heads))
+ if any(row.cells[column - 1] for row in rows)]
+ labels = [heads[c] for c in keep]
+ short = [(stacked or heads)[c] for c in keep]
+ skip = markup.search_exclude()
+ # The block holds the box and the table: an open card spans its width.
+ lines = ["", '
')
+ head_cells = ""
+ for label, name in zip(labels, short):
+ title = f' title="{html.escape(name)}"' if name != label else ""
+ head_cells += f'
{html.escape(label)}
'
+ lines.extend(["", f"
{head_cells}
", "", ""])
+ for row in rows:
+ lines.append("
")
+ if row.body:
+ lines.extend(["
", '', f"{row.title}",
+ "", *row.body, "", "", "
"])
+ else:
+ lines.append(f"
{row.title}
")
+ for column in keep[1:]:
+ label = (f' data-label="{html.escape(short[keep.index(column)])}"'
+ if len(rows) < RECORD_ROWS else "")
+ ignored = "" if heads[column] in searched else f" {skip}"
+ lines.append(f"
{row.cells[column - 1] or '-'}
")
+ lines.append("
")
+ lines.extend(["", "
", "
", ""])
+ return lines
diff --git a/tests/test_site_entry_tables.py b/tests/test_site_entry_tables.py
new file mode 100644
index 000000000..cd72b7c38
--- /dev/null
+++ b/tests/test_site_entry_tables.py
@@ -0,0 +1,311 @@
+"""Long lists read as a table first, details on demand (density round).
+
+MAME's card pages read 13,000 to 16,700 words, card after card, and
+GroovyMAME 14,400 before its last entry. The TOSEC wanted list was 46
+closed blocks, one per DAT, opened one by one to find a name. Each entry is
+now a table row that opens to its card, and each wanted list one filtered
+table under its counts. Nothing the pages said is lost: the tests below read
+every name and hash back.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import unittest
+from html.parser import HTMLParser
+from pathlib import Path
+
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+import generate_site as gs # noqa: E402
+import siteentries # noqa: E402
+import validate_site # noqa: E402
+
+EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
+DIST = REPO_ROOT / "website" / "dist"
+
+
+def _archive(i: int, members: int = 3, **fields: object) -> dict:
+ return {
+ "name": f"set{i:04d}.zip", "required": True, "category": "bios_zip",
+ "contents": [{"name": f"m{i}_{k}.rom", "size": 1024, "crc32": f"{i:04x}{k:04x}",
+ "sha1": f"{i:04x}{k:04x}" * 5} for k in range(members)],
+ **fields,
+ }
+
+
+class TheEntryTable(unittest.TestCase):
+ def _rows(self, count: int) -> list[siteentries.Row]:
+ return [siteentries.Row(f'
n{i}
',
+ [f"- Size: {i} bytes"], [str(i), "", f"MD5 {i}"])
+ for i in range(count)]
+
+ def test_a_long_table_has_its_filter_box_and_stacks(self):
+ lines = siteentries.entry_table(self._rows(25), gs.ENTRY_HEADS, "rb-entry-filter-x", "Required")
+ text = "\n".join(lines)
+ self.assertRegex(text, r'
25 rows
\n
', text)
+ # The Status column is empty on every row: not written.
+ self.assertIn('
File
Contents
'
+ '
Hash
', text)
+ self.assertNotIn("Status", text)
+ self.assertEqual(text.count(''), 25)
+ # Labels are written by site.ts on a table this long, as on a Markdown one.
+ self.assertNotIn("data-label", text)
+
+ def test_a_card_opens_under_its_name_as_markdown(self):
+ lines = siteentries.entry_table(self._rows(2), gs.ENTRY_HEADS, "x", "Required")
+ at = lines.index('
n0
')
+ # A blank line after the summary: the body is read as Markdown.
+ self.assertEqual(lines[at + 1:at + 4], ["", "- Size: 0 bytes", ""])
+ self.assertNotIn("rb-table-filter", "\n".join(lines))
+ self.assertIn('