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] 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.+?)\s*\{\s*#(?P[^\s}]+)[^}]*\}\s*$", re.M) + cls.headings: dict[str, dict[str, str]] = {} + for page in (DOCS / "systems").glob("*.md*"): + text = page.read_text(encoding="utf-8") + cls.headings[page.name.rsplit(".", 1)[0]] = { + m["id"]: m["text"] for m in heading.finditer(text)} + cls.headings[page.name.rsplit(".", 1)[0]].update( + dict.fromkeys(sitestarlight.heading_ids(text) - cls.headings[page.name.rsplit(".", 1)[0]].keys(), "")) + + def test_every_anchor_exists_and_names_the_system(self): + import re + import urllib.parse + + linked = 0 + for sys_id in self.ids: + link = self.siterender._system_link(sys_id) + match = re.fullmatch(r"\[[^\]]*\]\(systems/(?P[^)#]+)\.md(?:#(?P[^)]+))?\)", link) + if not match: + self.assertEqual(link, sys_id) + continue + linked += 1 + page = urllib.parse.unquote(match["page"]) + with self.subTest(system=sys_id, link=link): + self.assertIn(page, self.headings) + if match["anchor"]: + self.assertIn(match["anchor"], self.headings[page]) + _slug, console = self.siterender.system_section(sys_id) + self.assertEqual(self.headings[page][match["anchor"]], console) + self.assertGreater(linked, 200) + + def test_a_system_without_a_section_of_its_name_is_not_sent_elsewhere(self): + for sys_id in ("emerson-arcadia-2001", "sega-naomi"): + with self.subTest(system=sys_id): + link = self.siterender._system_link(sys_id) + self.assertNotIn("#saturn", link) + self.assertNotIn("#dreamcast", link) + + if __name__ == "__main__": unittest.main()