From a9181f9d040a736fae67c27e216e6fc351fdeb05 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 07:02:40 +0200
Subject: [PATCH 1/9] feat: platform logos and emulator letter sections
The decoration pass now forwards the logo front matter, pointed at the
local copy. The emulators index lists every profile under the initial
the sidebar draws, so each letter link lands on its section.
---
scripts/generate_site.py | 63 ++++++++++++++++++--------------
scripts/sitestarlight.py | 3 ++
tests/test_site_r2_emulators.py | 4 +-
tests/test_site_r2_nav.py | 24 +++++++++++-
website/src/routeData.ts | 5 +--
website/src/styles/retrobios.css | 12 ------
6 files changed, 65 insertions(+), 46 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 22daac86..12d229fe 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -2199,6 +2199,7 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
browser_title=browser_title,
description=description, structured=structured, terms=terms,
search=authored.get("search") != "exclude",
+ logo=str(authored.get("logo") or ""),
)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
@@ -2921,6 +2922,7 @@ def generate_platform_page(
# The mark is drawn beside the title from the front matter: floated
# beside the first line at 48 px it read as a broken image.
logo_url = registry.get(name, {}).get("logo", "")
+ logo_url = local_images().get(logo_url, logo_url)
homepage = config.get("homepage", "")
version = config.get("version", "")
@@ -4543,6 +4545,16 @@ def _region_words(value: object, most: int = 0) -> str:
_CATEGORY_WORDS = {"bios": "BIOS", "game_data": "game data", "bios_zip": "BIOS ZIP"}
+INITIAL_DIGITS = "0 to 9"
+
+
+def emulator_initial(display: str) -> str:
+ """The letter an emulator is filed under, in the navigation and on the
+ index alike. A name that does not open on a letter goes under "0 to 9"."""
+ initial = display[:1].upper()
+ return initial if "A" <= initial <= "Z" else INITIAL_DIGITS
+
+
def generate_emulators_index(profiles: dict) -> str:
unique = {
k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test")
@@ -4562,13 +4574,13 @@ def generate_emulators_index(profiles: dict) -> str:
"",
"Each page is one emulator: the files it reads, whether it starts games "
"without them, and the line of its source code that says so. The "
- "classification below says how a core relates to the emulator it comes "
- "from, which tells how far its own files can differ.",
+ "kind says how a core relates to the emulator it comes from, which "
+ "tells how far its own files can differ.",
"",
f"**{len(unique):,}** emulator profiles, **{total_files:,}** files total, **{len(aliases):,}** aliases.",
"",
- "| Classification | Count | Description |",
- "|---------------|-------|-------------|",
+ "| Kind | Count | Description |",
+ "|------|-------|-------------|",
]
cls_desc = {
@@ -4601,28 +4613,25 @@ def generate_emulators_index(profiles: dict) -> str:
continue
label = CLS_LABELS.get(cls, cls)
desc = cls_desc.get(cls, "")
- lines.append(f"| [{label}](#{cls}) | {len(entries)} | {desc} |")
+ lines.append(f"| {label} | {len(entries)} | {desc} |")
lines.append("")
- for cls in cls_order:
- entries = by_class.get(cls, [])
- if not entries:
- continue
- label = CLS_LABELS.get(cls, cls)
- desc = cls_desc.get(cls, "")
- lines.extend(
- [
- f"## {label} {{ #{cls} }}",
- "",
- f"{desc}. {_count(len(entries), 'profile')}.",
- "",
- "| Emulator | Systems | Files |",
- "|--------|---------|-------|",
- ]
- )
-
+ # One section per initial, the groups the navigation draws: its letter
+ # links land on the section of the same name.
+ by_initial: dict[str, list[tuple[str, dict]]] = {}
+ for name, p in unique.items():
+ by_initial.setdefault(emulator_initial(str(p.get("emulator", name))), []).append((name, p))
+ for initial in sorted(by_initial, key=lambda g: (g != INITIAL_DIGITS, g)):
+ lines.extend([
+ f"## {initial}",
+ "",
+ "| Emulator | Kind | Systems | Files |",
+ "|--------|------|---------|-------|",
+ ])
+ entries = sorted(by_initial[initial], key=lambda e: str(e[1].get("emulator", e[0])).casefold())
for name, p in entries:
emu_name = p.get("emulator", name)
+ cls = p.get("core_classification", "other")
systems = list(dict.fromkeys(
_named_system_link(s, "../") for s in p.get("systems") or []))
files = p.get("files", [])
@@ -4631,7 +4640,7 @@ def generate_emulators_index(profiles: dict) -> str:
sys_str += f" and {len(systems) - 3} more"
file_count = len(files)
file_str = f"{file_count:,}" if file_count else "-"
- lines.append(f"| [{emu_name}]({name}.md) | {sys_str} | {file_str} |")
+ lines.append(f"| [{emu_name}]({name}.md) | {CLS_LABELS.get(cls, cls)} | {sys_str} | {file_str} |")
lines.append("")
if aliases:
@@ -7348,11 +7357,9 @@ def _emulator_nav(profiles: dict) -> list:
groups: dict[str, list[tuple[str, str]]] = {}
for key, profile in nav_emulators(profiles).items():
display = str(profile.get("emulator", key))
- initial = display[:1].upper()
- label = initial if "A" <= initial <= "Z" else "0 to 9"
- groups.setdefault(label, []).append((display, f"emulators/{key}.md"))
+ groups.setdefault(emulator_initial(display), []).append((display, f"emulators/{key}.md"))
nav: list = ["emulators/index.md"]
- for label in sorted(groups, key=lambda g: (g != "0 to 9", g)):
+ for label in sorted(groups, key=lambda g: (g != INITIAL_DIGITS, g)):
items = sorted(groups[label], key=lambda e: e[0].casefold())
nav.append({label: [{display: path} for display, path in items]})
return nav
@@ -7895,7 +7902,7 @@ def _generate(args: argparse.Namespace) -> None:
print(f"Removed {stale} page(s) nothing produced this run")
-from sitediagrams import prepare_page, unresolved_images # noqa: E402,F401
+from sitediagrams import local_images, prepare_page, unresolved_images # noqa: E402,F401
from sitewrite import ( # noqa: E402,F401
_produced,
_record,
diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py
index 94bba427..0b10e2f0 100644
--- a/scripts/sitestarlight.py
+++ b/scripts/sitestarlight.py
@@ -406,6 +406,7 @@ def finalize_page(
structured: dict,
terms: dict[str, str],
search: bool = True,
+ logo: str = "",
) -> tuple[str, str]:
"""The Starlight file for a page body: its path under the content
collection and its text."""
@@ -446,6 +447,8 @@ def finalize_page(
front["slug"] = stem
if level:
front["level"] = level
+ if logo:
+ front["logo"] = logo
if weight:
front["searchWeight"] = weight
if rel == NOT_FOUND:
diff --git a/tests/test_site_r2_emulators.py b/tests/test_site_r2_emulators.py
index 71526367..48957536 100644
--- a/tests/test_site_r2_emulators.py
+++ b/tests/test_site_r2_emulators.py
@@ -172,10 +172,10 @@ class TheIndexNamesEmulatorsAndSystems(unittest.TestCase):
"duckstation": {"emulator": "DuckStation", "core_classification": "official_port",
"systems": ["sony-playstation"], "files": _files(3)},
})
- self.assertIn("| Emulator | Systems | Files |", page)
+ self.assertIn("| Emulator | Kind | Systems | Files |", page)
+ self.assertIn("\n## D\n", page)
self.assertNotIn("| Engine |", page)
self.assertNotIn(" -- ", page)
- self.assertIn("1 profile.", page)
row = next(line for line in page.splitlines() if line.startswith("| [DuckStation]"))
self.assertIn(f"[{siterender.system_name('sony-playstation')}]", row)
self.assertIn("(../systems/sony.md#playstation)", row)
diff --git a/tests/test_site_r2_nav.py b/tests/test_site_r2_nav.py
index 0b43406b..07a89c55 100644
--- a/tests/test_site_r2_nav.py
+++ b/tests/test_site_r2_nav.py
@@ -65,9 +65,20 @@ def _present(nav: list) -> set[str]:
# follow the labels in platforms/_guides.yml instead of a copy of them.
from siteguides import DEVICE_GROUPS # noqa: E402
+def _index_anchors(markdown: str) -> set[str]:
+ """The heading ids a generated page carries once written."""
+ _title, title_id, body = sitestarlight.split_title(markdown)
+ return sitestarlight.heading_ids(sitestarlight.pin_heading_ids(body, title_id))
+
+
ANCHORS = {
"platforms/index.md": {sitestarlight.toc_slug(label) for label, _ in DEVICE_GROUPS.values()},
"systems/index.md": {"consoles-and-computers"},
+ "emulators/index.md": _index_anchors(generate_site.generate_emulators_index({
+ "duckstation": {"emulator": "DuckStation", "systems": ["sony-playstation"]},
+ "dolphin": {"emulator": "Dolphin", "systems": ["nintendo-gamecube"]},
+ "mame": {"emulator": "MAME", "systems": ["arcade"]},
+ })),
}
@@ -109,7 +120,7 @@ class GroupsAreGroups(unittest.TestCase):
self.assertEqual(explore["Explore/Systems"], {"href": "/retrobios/systems/", "prune": True})
self.assertEqual(explore["Explore/Systems/Consoles and computers"]["href"],
"/retrobios/systems/#consoles-and-computers")
- self.assertEqual(explore["Explore/Emulators/D"], {"href": "/retrobios/emulators/", "prune": True})
+ self.assertEqual(explore["Explore/Emulators/D"], {"href": "/retrobios/emulators/#d", "prune": True})
def test_no_group_points_at_one_of_its_own_pages(self):
"""The overview a group opens on is its own index page or a section of
@@ -138,6 +149,17 @@ class EmulatorsOneLevelDeep(unittest.TestCase):
self.assertEqual(nav, ["emulators/index.md", {"D": [
{"Dolphin": "emulators/dolphin.md"}, {"DuckStation": "emulators/duckstation.md"}]}])
+ def test_every_initial_has_its_section_on_the_index(self):
+ """A letter in the sidebar lands on the section of that letter."""
+ from common import load_emulator_profiles
+
+ profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
+ anchors = _index_anchors(generate_site.generate_emulators_index(profiles))
+ for group in generate_site._emulator_nav(profiles)[1:]:
+ (label, _pages), = group.items()
+ with self.subTest(initial=label):
+ self.assertIn(sitestarlight.toc_slug(label), anchors)
+
def test_every_initial_holds_pages(self):
from common import load_emulator_profiles
diff --git a/website/src/routeData.ts b/website/src/routeData.ts
index 0c4cd1f1..de7626fb 100644
--- a/website/src/routeData.ts
+++ b/website/src/routeData.ts
@@ -92,9 +92,8 @@ export const onRequest = defineRouteMiddleware(async (context, next) => {
trail(route.sidebar, [topic.label]) ??
(segments.length > 1 ? folderTrail(route.sidebar, [topic.label], `${BASE}${segments[0]}/`) : []);
for (const crumb of groups) {
- // A group introduced by the page itself is the page; one with no
- // section of its own (the initial D of the emulators) adds nothing
- // to the crumb before it.
+ // A group introduced by the page itself is the page, and one with
+ // no section of its own adds nothing to the crumb before it.
if (crumb.href === path) continue;
if (crumb.href && crumb.href === crumbs.at(-1)?.href) continue;
crumbs.push(crumb);
diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css
index f937e088..727a5133 100644
--- a/website/src/styles/retrobios.css
+++ b/website/src/styles/retrobios.css
@@ -217,18 +217,6 @@
.rb-stat-value { display: block; font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.rb-stat-label { display: block; font-size: 0.75rem; color: var(--rb-text-secondary); }
-/* Logos float right beside the opening paragraph */
-.sl-markdown-content img[align='right'] {
- float: right;
- width: 56px;
- height: 56px;
- object-fit: contain;
- margin: 0 0 0.6rem 1rem;
- padding: 4px;
- border: 1px solid var(--rb-border);
- border-radius: 4px;
- background: #fff;
-}
.sl-markdown-content img[src*='assets/logos/'] { padding: 2px; border-radius: 2px; background: #fff; }
/* A system icon beside a console heading holds its box before it loads:
it pushed every card below it down once it arrived. */
From bb13e7b5f97e0e995240a8e86340e398a7591b1f Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 07:08:09 +0200
Subject: [PATCH 2/9] fix: link a system to the section of its name
A system link followed the folder its files sit in: emerson-arcadia-2001
reached the Saturn and sega-naomi the Dreamcast. It now goes to the
section system_name() reads, or stays a plain name. Console headings
carry an explicit id, so Sega's Mega-CD no longer shares mega-cd.
---
scripts/generate_site.py | 25 +++++-----
scripts/siterender.py | 66 +++++++++++++++++++++++--
tests/test_site_r2_systems.py | 4 +-
tests/test_site_system_links.py | 85 +++++++++++++++++++++++++++++----
4 files changed, 153 insertions(+), 27 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 12d229fe..c2b625bc 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -2355,13 +2355,13 @@ def _system_heading_link(sys_id: str, prefix: str = "") -> str:
writes. A system with no section of its own is named, never sent to the
page a storage folder chose.
"""
- from siterender import _own_section
+ from siterender import system_section
name = _escape_tags(system_name(sys_id))
- section = _own_section(sys_id)
+ section = system_section(sys_id)
if section is not None:
- mfr, console = section
- name = f"[{name}]({console_href(_slug(mfr), console, prefix)})"
+ slug, console = section
+ name = f"[{name}]({console_href(slug, console, prefix)})"
return f"{name} {_escape_tags(sys_id)}"
@@ -3436,8 +3436,7 @@ def generate_systems_index(manufacturers: dict) -> str:
]
for name, mfr, console, count in rows:
cell = name.replace("|", "-")
- anchor = _slugify_anchor(console)
- href = f"{_slug(mfr)}.md" + (f"#{anchor}" if anchor else "")
+ href = console_href(_slug(mfr), console).removeprefix("systems/")
lines.append(f"| [{cell}]({href}) | {count:,} | [{mfr}]({_slug(mfr)}.md) |")
lines.append("")
@@ -4291,11 +4290,12 @@ def _console_heading(manufacturer: str, console: str) -> str:
"""A console's heading: its icon hidden from screen readers, then the
name with no space before it. The space read as "Section titled
“ PlayStation”" on five of seven Sony headings."""
+ anchor = f" {{ #{console_anchor(manufacturer, console)} }}"
icon = _icon_name(manufacturer, console)
if not _icon_available.get(icon):
- return f"## {console}"
+ return f"## {console}{anchor}"
image = markup.image("", _icon_url(icon), width="24", **{"aria-hidden": "true"})
- return f"## {image}{console}"
+ return f"## {image}{console}{anchor}"
_NEXT_FROM_SYSTEMS = [
@@ -4500,13 +4500,13 @@ def _named_system_link(sys_id: str, prefix: str = "") -> str:
The emulator pages printed the id ("sony-playstation") where every
other page says "Sony PlayStation". The id stays in the address only.
"""
- from siterender import _page_by_norm # noqa: PLC0415
+ from siterender import system_section # noqa: PLC0415
name = system_name(sys_id).replace("[", r"\[").replace("]", r"\]")
- page = _system_page_map.get(sys_id) or _page_by_norm(sys_id)
- if not page:
+ section = system_section(sys_id)
+ if not section:
return name
- slug, console = page
+ slug, console = section
return f"[{name}]({console_href(slug, console, prefix)})"
@@ -7934,6 +7934,7 @@ from siterender import ( # noqa: E402,F401
system_name,
card_page,
console_href,
+ console_anchor,
record_console_pages,
record_emulator_cards,
emulator_card_page,
diff --git a/scripts/siterender.py b/scripts/siterender.py
index 25c5dc48..bd9498cb 100644
--- a/scripts/siterender.py
+++ b/scripts/siterender.py
@@ -170,13 +170,31 @@ def split_system_stems() -> set[str]:
return {stem for stems in _console_pages.values() for stem in stems}
+# (manufacturer slug, console) -> the id of the console's heading. Two
+# folders spelled alike (Sega's "Mega CD" and "Mega-CD") share a slug, and
+# the second heading took "mega-cd_1" while every link said "mega-cd".
+_console_anchors: dict[tuple[str, str], str] = {}
+
+
+def _manufacturer_slug(manufacturer: str) -> str:
+ return manufacturer.lower().replace(" ", "-")
+
+
+def console_anchor(manufacturer: str, console: str) -> str:
+ """The id of a console's heading on its manufacturer's page."""
+ from sitestarlight import toc_slug
+
+ key = (_manufacturer_slug(manufacturer), console)
+ return _console_anchors.get(key) or toc_slug(console)
+
+
def console_href(slug: str, console: str, prefix: str = "") -> str:
"""Where a console's files are: its own page, or its section of the
manufacturer's page (also where a console cut into parts lists them)."""
stems = _console_pages.get((slug, console))
if stems and len(stems) == 1:
return f"{prefix}systems/{urllib.parse.quote(stems[0])}.md"
- return f"{prefix}systems/{urllib.parse.quote(slug)}.md#{_slugify_anchor(console)}"
+ return f"{prefix}systems/{urllib.parse.quote(slug)}.md#{console_anchor(slug, console)}"
def card_page(path: str, sha1: str) -> str:
@@ -204,11 +222,33 @@ def emulator_card_page(emulator: str, anchor: str) -> str:
return _emulator_card_pages.get((emulator, anchor), emulator)
+def system_section(sys_id: str) -> tuple[str, str] | None:
+ """The console section a system id is named after, as (manufacturer
+ slug, console): its own section, else the one section whose heading
+ reads system_name(). The folder a system's files sit in is no answer:
+ Emerson's Arcadia 2001 led to the Saturn and Sega's NAOMI to the
+ Dreamcast."""
+ own = _own_section(sys_id)
+ if own is not None:
+ return _manufacturer_slug(own[0]), own[1]
+ try:
+ name = system_name(sys_id)
+ except KeyError:
+ return None
+ places = _sections_by_name.get(name.casefold(), set())
+ if len(places) != 1:
+ return None
+ mfr, console = next(iter(places))
+ if mfr in _PROGRAM_TOPS:
+ return None
+ return _manufacturer_slug(mfr), console
+
+
def _system_link(sys_id: str, prefix: str = "") -> str:
- """Generate a markdown link to a system page with anchor."""
- page = _system_page_map.get(sys_id) or _page_by_norm(sys_id)
- if page:
- slug, console = page
+ """A system id linked to the section named after it, or the bare id."""
+ section = system_section(sys_id)
+ if section:
+ slug, console = section
return f"[{sys_id}]({console_href(slug, console, prefix)})"
return sys_id
@@ -322,6 +362,8 @@ _PROGRAM_TOPS = frozenset({"Other", "Arcade"})
# Normalized id -> the console sections spelled that way.
_console_sections: dict[str, set[tuple[str, str]]] = {}
+# A console section by the name its heading reads (Bandai WonderSwan).
+_sections_by_name: dict[str, set[tuple[str, str]]] = {}
# Emulator and engine names. A heading spelled like one (Game Engines >
# Solarus, ZC > ZC210) files that program's copies and names no system.
_program_names: set[str] = set()
@@ -355,7 +397,21 @@ def set_console_sections(
{(mfr, console) for mfr, consoles in sections.items() for console in consoles}
if isinstance(sections, dict) else set(sections)
)
+ from sitestarlight import _unique, toc_slug
+
_console_sections.clear()
+ _sections_by_name.clear()
+ _console_anchors.clear()
+ by_maker: dict[str, list[str]] = {}
+ for mfr, console in pairs:
+ by_maker.setdefault(mfr, []).append(console)
+ for name in {_heading_name(mfr, console).casefold(), console.casefold()}:
+ _sections_by_name.setdefault(name, set()).add((mfr, console))
+ # In the order the page lists them, after the page title's own id.
+ for mfr, consoles in by_maker.items():
+ used = {toc_slug(mfr)}
+ for console in sorted(consoles):
+ _console_anchors[(_manufacturer_slug(mfr), console)] = _unique(toc_slug(console), used)
for mfr, console in pairs:
spelled = console.lower().replace(" ", "-")
slug = mfr.lower().replace(" ", "-")
diff --git a/tests/test_site_r2_systems.py b/tests/test_site_r2_systems.py
index 7f2e2aa6..8df64e55 100644
--- a/tests/test_site_r2_systems.py
+++ b/tests/test_site_r2_systems.py
@@ -219,13 +219,13 @@ class ConsoleHeadingsStartWithTheName(unittest.TestCase):
icon = siterender._icon_name("Sony", "PlayStation")
with mock.patch.dict(siterender._icon_available, {icon: True}):
heading = gs._console_heading("Sony", "PlayStation")
- self.assertRegex(heading, r'^## ]*aria-hidden="true"[^>]*>PlayStation$')
+ self.assertRegex(heading, r'^##
]*aria-hidden="true"[^>]*>PlayStation \{ #playstation \}$')
self.assertIn('alt=""', heading)
def test_no_icon_no_image(self):
icon = siterender._icon_name("Nobody", "Nothing")
with mock.patch.dict(siterender._icon_available, {icon: False}):
- self.assertEqual(gs._console_heading("Nobody", "Nothing"), "## Nothing")
+ self.assertEqual(gs._console_heading("Nobody", "Nothing"), "## Nothing { #nothing }")
class TheSystemsIndexListsConsoles(unittest.TestCase):
diff --git a/tests/test_site_system_links.py b/tests/test_site_system_links.py
index 2cc38c43..33843c44 100644
--- a/tests/test_site_system_links.py
+++ b/tests/test_site_system_links.py
@@ -1,9 +1,12 @@
-"""A platform system links to the page of the folder its files live in.
+"""A system is placed by the folder its files live in, and a link to it
+goes to the section named after it.
The site mapped each system by the bare name of its first file, the last
console written winning: MiSTer's boot.rom sits under a dozen consoles, so
wonderswan and tiger-game-com linked to VideoBrain, ngpc and tandy-coco to
-TI-99. Reads the repository: skips when the database is absent.
+TI-99. A link then followed that folder, not the name it printed:
+emerson-arcadia-2001 led to the Saturn and sega-naomi to the Dreamcast.
+Reads the repository: skips when the database is absent.
"""
from __future__ import annotations
@@ -76,14 +79,14 @@ class OtherSpellingsOfOneSystem(unittest.TestCase):
self.addCleanup(siterender.set_system_page_map, saved)
siterender.set_system_page_map(mapping)
- def test_a_profile_spelling_links_to_the_platform_page(self):
+ def test_a_profile_spelling_finds_the_platform_page(self):
import siterender
self._install({"sega-mega-drive": ("sega", "Mega Drive")})
- self.assertIn("systems/sega.md#mega-drive", siterender._system_link("sega-megadrive"))
+ self.assertEqual(siterender._page_by_norm("sega-megadrive"), ("sega", "Mega Drive"))
self._install({"sega-mega-drive": ("sega", "Mega Drive"),
"megadrive": ("other", "Elsewhere")})
- self.assertEqual(siterender._system_link("sega-megadrive"), "sega-megadrive")
+ self.assertIsNone(siterender._page_by_norm("sega-megadrive"))
def test_a_table_of_the_same_size_replaces_the_other_spellings(self):
"""The index of other spellings was kept while the table kept its
@@ -91,10 +94,9 @@ class OtherSpellingsOfOneSystem(unittest.TestCase):
import siterender
self._install({"sega-mega-drive": ("sega", "Mega Drive")})
- self.assertIn("systems/sega.md#", siterender._system_link("sega-megadrive"))
+ self.assertEqual(siterender._page_by_norm("sega-megadrive"), ("sega", "Mega Drive"))
self._install({"sega-mega-drive": ("other", "Elsewhere")})
- self.assertEqual(siterender._system_link("sega-megadrive"),
- "[sega-megadrive](systems/other.md#elsewhere)")
+ self.assertEqual(siterender._page_by_norm("sega-megadrive"), ("other", "Elsewhere"))
def test_a_system_only_profiles_name_is_placed_by_their_files(self):
manufacturers = {"Commodore": {"PET": [{"name": "basic4.bin"}]}}
@@ -261,5 +263,72 @@ class SystemEmulatorsWhenNoIdMatches(unittest.TestCase):
)
+DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
+
+
+@unittest.skipUnless((REPO_ROOT / "database.json").is_file()
+ and (DOCS / "systems" / "sony.md").is_file(), "site not generated")
+class EverySystemLinkLandsOnItsSection(unittest.TestCase):
+ """Every system id of every platform and profile, linked as the pages
+ link it, against the generated pages: the anchor exists on its target
+ page, and the heading there is the one system_name() reads."""
+
+ @classmethod
+ def setUpClass(cls):
+ import re
+
+ import siterender
+ import sitestarlight
+ from common import (
+ list_registered_platforms, load_database, load_emulator_profiles, load_platform_config,
+ )
+
+ db = load_database(str(REPO_ROOT / "database.json"))
+ profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"), skip_aliases=False)
+ siterender.set_console_sections(gs._group_by_manufacturer(db), siterender.program_names(profiles))
+ platforms = REPO_ROOT / "platforms"
+ ids = {str(sid) for profile in profiles.values() for sid in profile.get("systems") or []}
+ for name in list_registered_platforms(str(platforms), include_archived=True):
+ ids |= {str(sid) for sid in load_platform_config(name, str(platforms)).get("systems") or {}}
+ cls.ids = sorted(ids)
+ cls.siterender = siterender
+ heading = re.compile(r"^##\s+(?:
]*>)?(?P
]*>
(.*?)', section)[1]
self.assertNotIn(first, {"LibPS.exe", "PSX_DTLH2000.bin"})
self.assertRegex(first, r"^scph\d+")
- self.assertIn("Start with", section.split('class="rb-sys-file"', 1)[0])
def test_headings_have_no_leading_space(self):
self.assertNotRegex(self.text, r']*>\s')
From 4c31935b7c1d6a9d6e39e04491a7c3ea11e1ddd3 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 07:26:30 +0200
Subject: [PATCH 6/9] fix: quiet heading ids and fit the upstream table
The system id a platform writes beside a heading is now small, muted
and set as code. The upstream table on the cross-reference page shares
the column width from 50rem: its rule never matched, a paragraph sits
between heading and table, and at 1440 px it ran 98 px past the edge.
---
website/src/styles/retrobios.css | 34 +++++++++++++++++++++++++++++---
1 file changed, 31 insertions(+), 3 deletions(-)
diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css
index 727a5133..dae9a3d4 100644
--- a/website/src/styles/retrobios.css
+++ b/website/src/styles/retrobios.css
@@ -345,12 +345,40 @@
.rb-meta-card td a { word-break: normal; }
.rb-meta-card td:first-child { font-weight: 600; color: var(--rb-text-secondary); width: 11rem; }
-/* Cross-reference by upstream: the address column wraps instead of pushing
- the cores and platforms off screen. */
-.sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) + section td:has(a[href^='http']) {
+/* Cross-reference by upstream, the last table of its page: the address
+ column wraps instead of pushing the cores and platforms off screen. */
+.sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section td:has(a[href^='http']) {
max-width: 22rem;
overflow-wrap: anywhere;
}
+/* Beside the column, the five columns share its width: at 1440 px the
+ Platforms column sat 100 px past the edge. */
+@media (min-width: 50rem) {
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section table {
+ display: table;
+ table-layout: fixed;
+ width: 100%;
+ }
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section :is(th, td) {
+ overflow-wrap: anywhere;
+ }
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section th:nth-child(1) { width: 26%; }
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section th:nth-child(2) { width: 26%; }
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section th:nth-child(3) { width: 17%; }
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section th:nth-child(4) { width: 18%; }
+ .sl-markdown-content .sl-heading-wrapper:has(> #by-upstream-emulator) ~ section th:nth-child(5) { width: 13%; }
+}
+
+/* The id a platform writes for a system, beside the name in its heading:
+ smaller and quieter than the name, set as code. */
+.sl-markdown-content :is(h2, h3) small {
+ margin-left: 0.4em;
+ font-family: var(--sl-font-mono);
+ font-size: 0.65em;
+ font-weight: 400;
+ color: var(--rb-text-secondary);
+ overflow-wrap: anywhere;
+}
.rb-timestamp {
font-size: 0.8rem;
From e83371ef19b596839c7dea2a105ea0dbd094d3e8 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 07:33:31 +0200
Subject: [PATCH 7/9] fix: list large data directories on own pages
Dolphin's Sys tree put 2,164 rows and 16,900 elements on the Dolphin,
PrimeHack and Ishiiruka pages. A tree past 400 files is now listed on
pages of its own, 1,000 files each, and the file lookup sends each
member's sha1 there.
---
scripts/generate_site.py | 102 +++++++++++++++++++++++++++++++++------
1 file changed, 86 insertions(+), 16 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index dc99b94d..21c331b3 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -3273,12 +3273,15 @@ def file_location_items(
for ref, files in sorted((members or {}).items()):
if ref not in owners:
continue
- for _member, info in sorted(files.items()):
+ # A large tree is listed on pages of its own (data_tree_pages).
+ page_of = {member: stem for stem, names in data_tree_pages(ref, files)
+ for member in names}
+ for member, info in sorted(files.items()):
sha1 = str(info.get("sha1", ""))
if sha1 and sha1 not in known:
known.add(sha1)
- items.append({"sha1": sha1,
- "url": f"emulators/{owners[ref]}/#sha1-{sha1[:12]}"})
+ page = page_of.get(member, owners[ref])
+ items.append({"sha1": sha1, "url": f"emulators/{page}/#sha1-{sha1[:12]}"})
return sorted(items, key=lambda item: item["sha1"])
@@ -5518,6 +5521,65 @@ def _said_as_list(items: list[str]) -> str:
# Data directory members and revisions, read once per build.
_DATA_MEMBERS: dict[str, dict[str, dict]] = {}
_DATA_REVISIONS: dict[str, dict] = {}
+# The emulator pages that list each data directory, by ref.
+_DATA_READERS: dict[str, list[tuple[str, str]]] = {}
+
+# A data directory listing more files than this goes to pages of its own,
+# this many files each: Dolphin's Sys tree put 2,164 rows and 16,900
+# elements on the Dolphin page, read on a phone.
+DATA_TREE_ROWS = 400
+DATA_TREE_ROWS_PER_PAGE = 1_000
+
+
+def data_tree_pages(ref: str, members: dict[str, dict]) -> list[tuple[str, list[str]]]:
+ """The pages under emulators/ listing a large data directory, with the
+ members each holds in name order, or none when the tree is listed on
+ the pages of the emulators reading it."""
+ if len(members) <= DATA_TREE_ROWS:
+ return []
+ names = sorted(members)
+ stem = f"data-{_slugify_anchor(ref)}"
+ return [(stem + (f"-{n}" if n > 1 else ""), names[start:start + DATA_TREE_ROWS_PER_PAGE])
+ for n, start in enumerate(range(0, len(names), DATA_TREE_ROWS_PER_PAGE), 1)]
+
+
+def _data_member_table(members: dict[str, dict], names: list[str], anchors: set[str]) -> list[str]:
+ """A data directory's files with their size and sha1, each sha1 an
+ anchor the first time the page lists it (``anchors``, the page's ids)."""
+ table = ["| File | Size | SHA1 |", "|------|-----:|------|"]
+ for member in names:
+ info = members[member]
+ sha1 = str(info.get("sha1", ""))
+ # One id per page: two members can hold the same bytes (every empty
+ # file), and Dolphin's trees share Sys.
+ anchor = f"sha1-{sha1[:12]}"
+ span = "" if anchor in anchors else f''
+ anchors.add(anchor)
+ table.append(f"| {span}{_code(member)} | {_fmt_size(int(info.get('size', 0)))} | {_code(sha1)} |")
+ return table
+
+
+def _data_tree_page(ref: str, entry: dict, pages: list[tuple[str, list[str]]],
+ index: int) -> str:
+ """One page of a large data directory's file list."""
+ stem, names = pages[index]
+ members = _DATA_MEMBERS.get(ref) or {}
+ part = f" (part {index + 1} of {len(pages)})" if len(pages) > 1 else ""
+ readers = _said_as_list([f"[{_escape_tags(display)}]({key}.md#data-directories)"
+ for key, display in _DATA_READERS.get(ref, [])])
+ lines = [
+ "---", "level: reference", "---", "",
+ f"# The {ref} data directory{part}", "",
+ f"{entry.get('description') or 'A data directory'}: "
+ f"{_count(len(members), 'file')}, as the packs ship them."
+ + (f" Read by {readers}." if readers else ""), "",
+ ]
+ if len(pages) > 1:
+ lines.extend([", ".join(f"[part {i}]({other}.md)" if other != stem else f"part {i}"
+ for i, (other, _names) in enumerate(pages, 1)), ""])
+ lines.extend(search_excluded(_data_member_table(members, names, set())))
+ lines.extend(["", _date_line()])
+ return "\n".join(lines) + "\n"
# An emulator page lists at most this many file cards: MAME's 1,564 made a
@@ -5896,19 +5958,16 @@ def emulator_pages(
if dd.get("source_ref"):
lines.append(f" - Source: {_source_ref_markdown(profile, dd['source_ref'])}")
members = _DATA_MEMBERS.get(ref) or {}
- if members:
- table = ["| File | Size | SHA1 |", "|------|-----:|------|"]
- for member, info in sorted(members.items()):
- sha1 = str(info.get("sha1", ""))
- # One id per page: two members can hold the same bytes
- # (every empty file), and Dolphin's trees share Sys.
- anchor = f"sha1-{sha1[:12]}"
- span = "" if anchor in member_anchors else f''
- member_anchors.add(anchor)
- table.append(
- f"| {span}{_code(member)} "
- f"| {_fmt_size(int(info.get('size', 0)))} | {_code(sha1)} |"
- )
+ tree_pages = data_tree_pages(ref, members)
+ if tree_pages:
+ where = (f"[a page of their own]({tree_pages[0][0]}.md)" if len(tree_pages) == 1
+ else "pages of their own: " + ", ".join(
+ f"[part {i}]({stem}.md)" for i, (stem, _names) in enumerate(tree_pages, 1)))
+ lines.append(f" - {_count(len(members), 'file')}, listed on {where}")
+ for index, (stem, _names) in enumerate(tree_pages):
+ part_pages[stem] = _data_tree_page(ref, entry, tree_pages, index)
+ elif members:
+ table = _data_member_table(members, sorted(members), member_anchors)
lines.append("")
lines.extend(markup.details(
"info", f"{_count(len(members), 'file')}, as the packs ship them",
@@ -7846,6 +7905,17 @@ def _generate(args: argparse.Namespace) -> None:
print("Generating emulator pages...")
_DATA_MEMBERS.update(data_directory_members())
_DATA_REVISIONS.update(data_directory_revisions())
+ for key in sorted(profiles):
+ if profiles[key].get("type") in ("alias", "test"):
+ continue
+ display = str(profiles[key].get("emulator", key))
+ for tree in page_data_directories(key, profiles):
+ readers = _DATA_READERS.setdefault(str(tree.get("ref", "")), [])
+ if (key, display) not in readers:
+ readers.append((key, display))
+ for ref, members in _DATA_MEMBERS.items():
+ for stem, _names in data_tree_pages(ref, members):
+ PART_PAGES.add(f"emulators/{stem}.md")
write_if_changed(
str(docs / "emulators" / "index.md"), generate_emulators_index(profiles)
)
From a3597cc8afea3bc8ff9ee881d3382d3d1c4289ba Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 07:33:37 +0200
Subject: [PATCH 8/9] chore: gate the second site round
Reads website/dist: page weight and element budgets, sidebar labels,
tips, crumbs, stacked phone tables on reader pages, dates in words, and
the integration items of the round (logo, emulator letters, system
links, the getting-started redirect, Served by in existence mode).
---
tests/test_site_r2_gate.py | 273 +++++++++++++++++++++++++++++++++++++
wiki/testing-guide.md | 2 +-
2 files changed, 274 insertions(+), 1 deletion(-)
create mode 100644 tests/test_site_r2_gate.py
diff --git a/tests/test_site_r2_gate.py b/tests/test_site_r2_gate.py
new file mode 100644
index 00000000..3a08cfa8
--- /dev/null
+++ b/tests/test_site_r2_gate.py
@@ -0,0 +1,273 @@
+"""Gate of the second site round, read from the built site (website/dist).
+
+Each package of the round was checked on its own. These checks hold the
+merged site to what the round set out to fix: no sidebar label a number, no
+"Next" tip, a sidebar label that is the page's own title, a card's link icon
+kept out of the heading a search indexes, crumbs that lead to a tab or a
+section, phone tables stacked where the reading level asks for it, and every
+page under 15,000 elements and 600 KB of HTML. The integration items of the
+round are held too: the platform logo beside the title, a section for every
+letter of the emulators index, system links to the section of the system's
+own name, dates in words and the moved getting-started page.
+
+Skips when the site is not built.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+import unittest
+from html import unescape
+from pathlib import Path
+
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+
+DIST = REPO_ROOT / "website" / "dist"
+DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
+SIDEBAR = REPO_ROOT / "website" / "src" / "generated" / "sidebar.json"
+BASE = "/retrobios/"
+
+PAGE_BYTES = 600_000
+PAGE_ELEMENTS = 15_000
+PHONE_COLUMN = 358
+READER_LEVELS = {"start", "setup"}
+# The size from which a table is a list scanned by column and keeps its
+# scroll (website/src/plugins/rehype-stack-tables.mjs, RECORD_ROWS).
+RECORD_ROWS = 20
+
+_SCRIPT = re.compile(r"<(script|style)\b[^>]*>.*?\1>", re.S | re.I)
+_ELEMENT = re.compile(r"<[a-zA-Z][a-zA-Z0-9-]*[\s/>]")
+_TAGS = re.compile(r"<[^>]+>")
+_TABLE = re.compile(r"
[^>]*)>(?P.*?)
", re.S)
+_CURRENT = re.compile(r']*aria-current="page"[^>]*>(?P', re.S)
+_H1 = re.compile(r"]*>(?P
", re.S)
+_HEADING = re.compile(r"