diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py index 5763f19b4..616c0da95 100644 --- a/scripts/sitestarlight.py +++ b/scripts/sitestarlight.py @@ -417,10 +417,6 @@ def page_aliases(rel: str) -> list[str]: return list(_page_aliases.get(rel, [])) -# Search fields a page's own front matter fills, never typed here. -SEARCH_FIELDS = ("consoles", "terms") - - def field_words(value: object) -> list[str]: """The words of a front matter search field: a list, or one string with its words or phrases split at commas.""" @@ -431,6 +427,14 @@ def field_words(value: object) -> list[str]: return [] +def console_words(names: list[str]) -> list[str]: + """What a visitor types for a console: its name with "BIOS". A page that + answers "No file needed" says BIOS only where the search does not read, + so "nes bios" found NES.emu and never the NES page. As a task word it + weighs like one: in the consoles field, NES.emu's title still won.""" + return [f"{name} BIOS" for name in names] + + def _neighbour(rel: str, value: object) -> object: """A `prev:` or `next:` the page states, its link resolved like any other link of the page.""" @@ -503,8 +507,9 @@ def finalize_page( r' bool: return not rel.startswith(CARD_PAGES) or rel.endswith("/index.md") +_DIGIT_RANGE = re.compile(r"[0-9] to [0-9]") + + +def by_initial(label: str, children: list[dict]) -> bool: + """A group that files its pages by the first character of their names + (A, B, "0 to 9"): a way through a long list, not a place, so it is no + crumb.""" + if not children or any("items" in child for child in children): + return False + firsts = {str(child["label"])[:1].upper() for child in children} + if len(label) == 1: + return firsts == {label} + return bool(_DIGIT_RANGE.fullmatch(label)) and not any("A" <= first <= "Z" for first in firsts) + + def sidebar(nav: list, present: set[str], anchors: dict[str, set[str]] | None = None) -> dict: """The navigation as Starlight topics, one per tab, with what the route middleware (website/src/routeData.ts) reads beside it. A page the navigation does not list (the parts of a split page) joins - the topic that holds most pages of its directory. + the topic that holds most pages of its directory. In a directory the + navigation lists nothing of (get/), it joins the topic of the pages of + the same name, and a page with neither joins the first topic: no page + is left out of every tab. + + A group that files names by their first letter is marked `crumb: false`: + the breadcrumbs skip it. `groups` gives each group, keyed by the labels from its tab down, the page that introduces it: its own overview page, or the heading named @@ -649,7 +675,8 @@ def sidebar(nav: list, present: set[str], anchors: dict[str, set[str]] | None = children = [child for child in (item(v, topic, here, own or parent) for v in value) if child] if not children: return None - groups["/".join(here)] = {"href": href, "prune": topic in PRUNED_TABS and href is not None} + groups["/".join(here)] = {"href": href, "prune": topic in PRUNED_TABS and href is not None, + "crumb": not by_initial(label, children)} return {"label": label, "items": children, "collapsed": True} topics = [] @@ -670,15 +697,35 @@ def sidebar(nav: list, present: set[str], anchors: dict[str, set[str]] | None = # is the way back in. Excluded from every tab, the theme named the tabs # by their position, 0 to 4. extra: dict[str, list[str]] = {topics[0]["id"]: ["/404"]} if topics else {} - exclude: list[str] = [] - for rel in sorted(present - owner.keys()): - tally = votes.get(rel.rpartition("/")[0]) - path = "/" + (_sidebar_link(rel).strip("/") or "index") + # A folder the navigation lists nothing of (get/, one download page per + # setup) has no vote, and excluded from every tab its pages had the + # tabs numbered 0 to 4 too. Its pages take the tab of the pages of the + # same name (get/batocera.md, platforms/batocera.md), and its overview + # the tab its pages took. + by_name: dict[str, dict[str, int]] = {} + for rel, topic in owner.items(): + tally = by_name.setdefault(PurePosixPath(rel).name, {}) + tally[topic] = tally.get(topic, 0) + 1 + unlisted = sorted(present - owner.keys()) + adopted: dict[str, dict[str, int]] = {} + for rel in unlisted: + folder, name = rel.rpartition("/")[0], PurePosixPath(rel).name + if folder in votes or name == "index.md": + continue + tally = adopted.setdefault(folder, {}) + for topic, count in by_name.get(name, {}).items(): + tally[topic] = tally.get(topic, 0) + count + for rel in unlisted: + folder = rel.rpartition("/")[0] + tally = votes.get(folder) or adopted.get(folder) if tally: - extra.setdefault(max(tally, key=tally.get), []).append(path) + topic = max(tally, key=tally.get) + elif topics: + topic = topics[0]["id"] else: - exclude.append(path) - return {"topics": topics, "options": {"topics": extra, "exclude": exclude}, + continue + extra.setdefault(topic, []).append("/" + (_sidebar_link(rel).strip("/") or "index")) + return {"topics": topics, "options": {"topics": extra, "exclude": []}, "groups": groups, "reading": _reading(nav, present)} diff --git a/tests/test_site_r2_nav.py b/tests/test_site_r2_nav.py index c3f549ac2..6e3f134a7 100644 --- a/tests/test_site_r2_nav.py +++ b/tests/test_site_r2_nav.py @@ -117,10 +117,12 @@ class GroupsAreGroups(unittest.TestCase): def test_explore_groups_collapse_to_their_overview(self): explore = {key: value for key, value in self.groups.items() if key.startswith("Explore/")} - self.assertEqual(explore["Explore/Systems"], {"href": "/retrobios/systems/", "prune": True}) + self.assertEqual(explore["Explore/Systems"], + {"href": "/retrobios/systems/", "prune": True, "crumb": True}) self.assertEqual(explore["Explore/Systems/Consoles and computers"]["href"], "/retrobios/systems/#consoles-and-computers") - self.assertEqual(explore["Explore/Emulators/D"], {"href": "/retrobios/emulators/#d", "prune": True}) + self.assertEqual(explore["Explore/Emulators/D"], + {"href": "/retrobios/emulators/#d", "prune": True, "crumb": False}) 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 diff --git a/tests/test_site_r4_search.py b/tests/test_site_r4_search.py index 188b2aa4e..8d244baaf 100644 --- a/tests/test_site_r4_search.py +++ b/tests/test_site_r4_search.py @@ -120,7 +120,8 @@ class FieldsFromFrontMatter(unittest.TestCase): "terms": "bios not found, bios missing", })["text"] self.assertIn('data-pagefind-meta="consoles:Neo Geo Neo Geo Pocket Color"', text) - self.assertIn('data-pagefind-meta="terms:bios not found bios missing"', text) + self.assertIn('data-pagefind-meta="terms:bios not found bios missing Neo Geo BIOS ' + 'Neo Geo Pocket Color BIOS"', text) # A field value is an attribute: none of it reaches an excerpt. self.assertNotRegex(text, r">[^<]*bios missing[^<]*<") diff --git a/website/src/routeData.ts b/website/src/routeData.ts index 104d20270..31c41b190 100644 --- a/website/src/routeData.ts +++ b/website/src/routeData.ts @@ -1,7 +1,8 @@ // What each page knows of its place in the site, computed once per page // from what scripts/sitestarlight.py writes beside the sidebar: // - breadcrumbs: the tab and the groups above the page, each linked to the -// page or the section that introduces it, never to a sibling page; +// page or the section that introduces it, never to a sibling page. A +// group of names by their first letter (A, B) is no crumb; // - previous and next: the reading order of `reading`. Start here runs on // into Your setup, every other tab is read on its own, and a system, // emulator or platform page has none; @@ -19,7 +20,7 @@ import sidebar from './generated/sidebar.json' with { type: 'json' }; type Entry = StarlightRouteData['sidebar'][number]; type Link = Extract; -type Group = { href: string | null; prune: boolean }; +type Group = { href: string | null; prune: boolean; crumb?: boolean }; export type Crumb = { label: string; href?: string }; const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/'); @@ -55,7 +56,11 @@ const trail = (entries: Entry[], labels: string[]): Crumb[] | undefined => { const here = [...labels, entry.label]; const below = trail(entry.entries, here); if (below) { - const href = groupOf(here)?.href; + const group = groupOf(here); + // A group of names by their first letter is a way through the + // list, not a place: the crumb stops at the list. + if (group?.crumb === false) return below; + const href = group?.href; return [{ label: entry.label, href: href ? withBase(href) : undefined }, ...below]; } } diff --git a/website/tools/search_gate.py b/website/tools/search_gate.py index d5ede06f0..8c7bdc3ac 100644 --- a/website/tools/search_gate.py +++ b/website/tools/search_gate.py @@ -63,6 +63,13 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = { "psx bios": (("systems/sony",), 3), "dc bios missing": (("systems/sega", "emulators/flycast/"), 3), "gba bios": (("systems/nintendo",), 3), + # A console whose page answers "No file needed" says BIOS only where the + # search does not read: its `terms` field carries " BIOS". + "snes bios": (("systems/nintendo-snes/",), 1), + "nes bios": (("systems/nintendo-nes/",), 1), + "gamecube bios": (("systems/nintendo-gamecube/",), 1), + "game boy bios": (("systems/nintendo-game-boy/",), 1), + "3ds bios": (("systems/nintendo-3ds/",), 1), # The words an emulator prints, and the page that answers them. "bios not found": (("wiki/troubleshooting/",), 3), "please insert playstation cd-rom": (("wiki/troubleshooting/",), 3), @@ -74,12 +81,20 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = { # Checking a dump is what the Find a file page does. "check my dump": (("files/",), 1), "md5 check": (("files/",), 1), + "is my bios correct": (("files/",), 3), + # A folder is checked by the setup's command, which troubleshooting runs. + "check my bios folder": (("wiki/troubleshooting/",), 3), # A tool author's words: the data reference or the installer's. "manifest": (("data/", "wiki/installer/"), 3), "events": (("data/", "wiki/installer/"), 3), "exit code": (("data/", "wiki/installer/"), 3), "rate limit": (("data/", "wiki/installer/"), 3), - "ios": (("wiki/faq/",), 3), + "schema": (("data/",), 3), + "json schema": (("data/",), 3), + "license": (("data/", "wiki/legal/"), 3), + "check bios folder script": (("data/",), 3), + # The iPhone and iPad answer: copy the files to the app, no terminal. + "ios": (("which-pack/", "wiki/faq/"), 3), # A device name: the guide of the handhelds that run it. "r36s": (("platforms/", "which-pack/"), 3), "steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1),