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] 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. */