From 35660161890ffcc0c94dfbc614e303e4311bf453 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:06:03 +0200 Subject: [PATCH 1/4] fix: one lookup verdict, visitor's os tab The lookup reads every hash of a matched file, so its MD5, SHA1, CRC32 and SHA256 give one answer. The OS tabs open on the visitor's system unless a choice is saved, and the sidebar shows the current page. --- tests/test_site_r3_components.py | 14 +-- website/src/scripts/site.ts | 159 ++++++++++++++++++++++--------- 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/tests/test_site_r3_components.py b/tests/test_site_r3_components.py index 0aafcc5a..00b8caee 100644 --- a/tests/test_site_r3_components.py +++ b/tests/test_site_r3_components.py @@ -213,7 +213,7 @@ class HashLookupGivesOneVerdict(unittest.TestCase): source = _read(SITE_TS) self.assertNotIn("'match' : 'matches'", source) self.assertIn("count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}", source) - self.assertIn("count(setups.size, 'setup lists', 'setups list')", source) + self.assertIn("count(listers.size, 'setup lists', 'setups list')", source) self.assertIn("'file in the collection', 'files in the collection'", source) def test_emulators_by_name(self): @@ -291,14 +291,16 @@ class SearchFoldsPagesUnderAHash(unittest.TestCase): def test_search_fields_are_weighted_and_never_shown(self): config = _read(CONFIG) - self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 },", config) - self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);", _read(SITE_TS)) + self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5, consoles: 10, terms: 10 },", config) + self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);", _read(SITE_TS)) -class OsTabsKeepTheChoiceInView(unittest.TestCase): - def test_the_row_scrolls_not_the_page(self): +class OsTabsNeverScrollThePage(unittest.TestCase): + """The tabs wrap since round 4 (retrobios.css): the script picks the + visitor's tab and never scrolls the page.""" + + def test_no_page_scroll(self): source = _read(SITE_TS) - self.assertIn("list.scrollLeft", source) self.assertNotIn("scrollIntoView", source) self.assertIn("osTabs();", source) diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts index c4a49b50..624ca35e 100644 --- a/website/src/scripts/site.ts +++ b/website/src/scripts/site.ts @@ -1,7 +1,8 @@ // Progressive enhancement of the generated pages: table captions, filters // and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file // hashed on this device), moved cards, a hash typed in the search, glossary -// terms on a tap, the OS tabs kept in view, and the 404 page's hints. +// terms on a tap, the OS tab of the visitor's system, the current page in +// the sidebar, and the 404 page's hints. // Which tables stack on a phone is decided when the page is built // (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint. @@ -262,7 +263,11 @@ const lookupValues = async (values: string[]): Promise => { return shards.flat().filter((item) => wanted.has(item.value)); }; -type Held = { name: string; path: string; sha1: string; size: number }; +type Held = { name: string; path: string; sha1: string; size: number; md5?: string; crc32?: string; sha256?: string }; + +// Every hash of a collection file, lower case. +const heldHashes = (file: Held) => + [file.sha1, file.md5, file.crc32, file.sha256].filter((value): value is string => !!value).map((value) => value.toLowerCase()); type Declared = { name?: string; destination?: string; required?: boolean; [hash: string]: unknown }; type ProfileFile = { name?: string; region?: string | string[]; category?: string; [field: string]: unknown }; type Reader = { name: string; files: ProfileFile[] }; @@ -334,15 +339,21 @@ const heldFile = async (item: Item): Promise => { return undefined; }; +// Whether an entry of a list declares one of *values*, under any of its +// hash fields. Recalbox writes several MD5 in one field, separated by commas. +const HASH_FIELDS = ['sha1', 'md5', 'crc32', 'sha256']; +const declares = (entry: Record, values: Set) => + HASH_FIELDS.some((field) => + String(entry[field] ?? '').toLowerCase().split(',').some((hash) => values.has(hash.trim()))); + // A setup's own entry for the value, from its contract in api/v1/platforms/: -// where the file goes and whether the setup requires it. Recalbox writes -// several MD5 in one field, separated by commas. -const declaredEntry = async (item: Item): Promise => { +// where the file goes and whether the setup requires it. *values* are the +// hashes of the file the value names. +const declaredEntry = async (item: Item, values: Set): Promise => { const system = decodeURIComponent(new URL(item.url, siteRoot()).hash.slice(1)); const data = await json(`api/v1/platforms/${item.owner}.json`); const files: Declared[] = data.items?.[0]?.contract?.systems?.[system]?.files ?? []; - const holds = (entry: Declared) => - String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value); + const holds = (entry: Declared) => declares(entry, values); return files.find((entry) => holds(entry) && entry.name === item.name) ?? files.find(holds); }; @@ -355,9 +366,8 @@ const readerProfile = async (owner: string): Promise => { }; // The entry of a profile that reads the value: by name and hash, else by hash. -const readerEntry = (reader: Reader | undefined, item: Item): ProfileFile | undefined => { - const holds = (entry: ProfileFile) => - String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value); +const readerEntry = (reader: Reader | undefined, item: Item, values: Set): ProfileFile | undefined => { + const holds = (entry: ProfileFile) => declares(entry, values); return reader?.files.find((entry) => holds(entry) && entry.name === item.name) ?? reader?.files.find(holds); }; @@ -414,21 +424,38 @@ const distinct = (items: Item[], key: (item: Item) => string) => { // how many setups list it under which names. Then where the collection // holds it, the setups that list it (where it goes, whether it is // required), and the emulators that read it, by name. -const renderMatches = async (status: HTMLElement, result: HTMLElement, items: Item[]) => { +// +// A setup or a profile declares one hash of a file, the one its list +// writes. When the value matches a file of the collection, the lists are +// read under every hash of that file, so the MD5, the SHA1, the CRC32 and +// the SHA256 of one dump give one answer. Setups and emulators are counted +// per file, by its SHA1. +const renderMatches = async (status: HTMLElement, result: HTMLElement, found: Item[]) => { + const held = distinct(found.filter((item) => item.layer === 'collection'), (item) => item.sha1 ?? item.url); + const files = await Promise.all(held.map((item) => heldFile(item).catch(() => undefined))); + const looked = new Set(found.map((item) => item.value)); + const others = [...new Set(files.flatMap((file) => (file ? heldHashes(file) : [])))].filter((value) => !looked.has(value)); + const items = others.length ? [...found, ...(await lookupValues(others).catch(() => [] as Item[]))] : found; + // The file a declaration names: the SHA1 the record carries, else the + // collection file one of whose hashes it declares. + const fileOf = (item: Item) => + item.sha1 ?? files.find((file) => file && heldHashes(file).includes(item.value))?.sha1; + const valuesOf = (item: Item) => { + const file = files.find((each) => each && each.sha1 === fileOf(item)); + return new Set([item.value, ...(file ? heldHashes(file) : [])]); + }; const byLayer = (layer: string) => items.filter((item) => item.layer === layer); - const held = distinct(byLayer('collection'), (item) => item.sha1 ?? item.url); const platforms = distinct(byLayer('platform'), (item) => `${item.owner}|${item.name}|${item.url}`); const emulators = distinct(byLayer('emulator'), (item) => `${item.owner}|${item.name}|${item.url}`); const owners = [...new Set(emulators.map((item) => item.owner ?? ''))]; - const [files, entries, names, readers] = await Promise.all([ - Promise.all(held.map((item) => heldFile(item).catch(() => undefined))), - Promise.all(platforms.map((item) => declaredEntry(item).catch(() => undefined))), + const [entries, names, readers] = await Promise.all([ + Promise.all(platforms.map((item) => declaredEntry(item, valuesOf(item)).catch(() => undefined))), json('data/picker/index.json').catch(() => []) as Promise<{ platform: string; name: string }[]>, Promise.all(owners.map((owner) => readerProfile(owner))), ]); const setupName = new Map(names.map((entry) => [entry.platform, entry.name])); const reader = new Map(owners.map((owner, index) => [owner, readers[index]])); - const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item)); + const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item))); const setups = new Set(platforms.map((item) => item.owner)); const counted = [ @@ -452,8 +479,11 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, items: It const what: (Node | string)[] = system ? [', ', link(item.url, system), category ? ` ${category}` : ' file'] : category ? [`, a ${category} file`] : []; + // The setups that list this file, whatever hash their list writes. + const listing = platforms.filter((declared) => (fileOf(declared) ?? item.sha1) === item.sha1); + const listers = new Set(listing.map((declared) => declared.owner)); const others = new Map>(); - for (const declared of platforms) { + for (const declared of listing) { const other = declared.name ?? ''; if (anyName(other) || other === name) continue; if (!others.has(other)) others.set(other, new Set()); @@ -466,17 +496,17 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, items: It if (position) named.push(position === otherNames.length - 1 ? ' and ' : ', '); named.push(code(other)); }); - const listing: (Node | string)[] = setups.size + const verdict: (Node | string)[] = listers.size ? [ - ` ${count(setups.size, 'setup lists', 'setups list')} it`, + ` ${count(listers.size, 'setup lists', 'setups list')} it`, ...(renamed.size - ? [`, ${renamed.size.toLocaleString('en')} of them under the ${otherNames.length === 1 ? 'name' : 'names'} `, ...named] + ? [`, ${renamed.size.toLocaleString('en')} of them under ${otherNames.length === 1 ? 'another name' : 'other names'}: `, ...named] : []), '.', ] : []; blocks.push( - paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...listing), + paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...verdict), paragraph( ...(file ? ['Held in the collection at ', code(file.path), '. SHA1 ', code(file.sha1), `, ${formatSize(file.size)}. `] : []), link(item.url, 'Open its card'), @@ -697,11 +727,11 @@ const hashLookupRow = (drawer: HTMLElement, value: string) => { // Pagefind lists a page's title twice: once for the page and once for the // anchor of its h1 (#_top). The second title is hidden, its excerpt kept. -// The `files`, `aliases` and `devices` fields weigh in the ranking and are -// never shown: a result is not a list of 120 names. Pagefind reuses +// The `files`, `aliases`, `devices`, `consoles` and `terms` fields weigh in +// the ranking and are never shown: a result is not a list of 120 names. Pagefind reuses // its nodes from one query to the next, so both are worked out again on // every change. -const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']); +const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']); const tidyResults = (root: Element) => { for (const title of root.querySelectorAll('.pagefind-ui__result-nested > .pagefind-ui__result-title')) { @@ -751,29 +781,69 @@ const hashSearch = () => { ); }; -// The OS tabs scroll sideways on a phone instead of wrapping to a second -// row (retrobios.css): the chosen one is kept in view, on load and on a -// change, by scrolling the row and never the page. -const keepTabInView = (tab: Element) => { - const list = tab.closest('[role="tablist"]'); - if (!list) return; - const row = list.getBoundingClientRect(); - const box = tab.getBoundingClientRect(); - if (box.left < row.left) list.scrollLeft -= row.left - box.left; - else if (box.right > row.right) list.scrollLeft += box.right - row.right; +// The OS tabs open on the visitor's own system: the tab its label names, on +// every tab set of the page that shares the "os" sync key. A tab the +// visitor chose before (Starlight keeps it under the sync key and restores +// it as the page draws) wins over the guess, and the guess is not saved as +// a choice. No tab is for iOS or ChromeOS: those keep the first tab. +const OS_SYNC_KEY = 'os'; +const OS_TAB_RULES: [RegExp, string][] = [ + [/SteamOS|Valve Steam/i, 'Steam Deck'], + [/Android/i, 'Android (Termux)'], + [/iPhone|iPad|iPod|CrOS|Chrome OS/i, ''], + [/Mac/i, 'macOS'], + [/Win/i, 'Windows'], + [/Linux|X11/i, 'Linux'], +]; + +const osTabLabel = (userAgent: string, platform: string): string => + OS_TAB_RULES.find(([pattern]) => pattern.test(userAgent) || pattern.test(platform))?.[1] ?? ''; +// End of the OS tab rule. + +type SyncedTabs = HTMLElement & { tabs: HTMLElement[]; switchTab(tab: HTMLElement, index: number, sync?: boolean): void }; + +const savedTab = (key: string): string | null => { + try { + return localStorage.getItem(`starlight-synced-tabs__${key}`); + } catch { + // Storage refused (a private window, blocked site data): no saved tab. + return null; + } }; const osTabs = () => { - const selected = () => document.querySelectorAll('starlight-tabs [role="tab"][aria-selected="true"]').forEach(keepTabInView); - if (!document.querySelector('starlight-tabs')) return; - const later = () => requestAnimationFrame(selected); - for (const type of ['click', 'keyup']) { - document.addEventListener(type, (event) => { - if ((event.target as Element | null)?.closest?.('starlight-tabs [role="tab"]')) later(); - }); - } - if (document.readyState === 'complete') later(); - else addEventListener('load', later, { once: true }); + const sets = [...document.querySelectorAll(`starlight-tabs[data-sync-key="${OS_SYNC_KEY}"]`)]; + if (!sets.length || savedTab(OS_SYNC_KEY)) return; + const agent = navigator as Navigator & { userAgentData?: { platform?: string } }; + const label = osTabLabel(navigator.userAgent, agent.userAgentData?.platform ?? ''); + if (!label) return; + customElements.whenDefined('starlight-tabs').then(() => { + for (const set of sets) { + const index = set.tabs.findIndex((tab) => tab.textContent?.trim() === label); + if (index > 0 && set.tabs[index].getAttribute('aria-selected') !== 'true') set.switchTab(set.tabs[index], index, false); + } + }); +}; + +// The sidebar lists 91 manufacturers: the current page's link sat below the +// pane, out of sight. On load, and when the phone menu opens, the pane +// scrolls so the link sits a third of the way down. Only the pane scrolls, +// never the page. +const showCurrentPage = () => { + const pane = document.getElementById('starlight__sidebar'); + if (!pane) return; + const reveal = () => { + const current = pane.querySelector('[aria-current="page"]'); + if (!current) return; + const box = pane.getBoundingClientRect(); + const link = current.getBoundingClientRect(); + if (link.top >= box.top && link.bottom <= box.bottom) return; + pane.scrollTop += link.top - box.top - pane.clientHeight / 3; + }; + reveal(); + pane.addEventListener('toggle', (event) => { + if ((event as ToggleEvent).newState === 'open') requestAnimationFrame(reveal); + }); }; // A glossary term shows its definition on hover; a tap or Enter shows it @@ -825,4 +895,5 @@ enhanceTables(); hashSearch(); glossaryTerms(); osTabs(); +showCurrentPage(); notFound(); From e14f9359945762a36471127340429e61a132bc7b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:06:08 +0200 Subject: [PATCH 2/4] feat: short code marks, lazy search, index fields Inline code of 30 characters or fewer is marked rb-short at build time and the options table rb-options. The search UI loads on first use. Title, crumbs and footer leave the search index, and consoles and terms weigh as a title. --- tests/test_site_r2_components.py | 2 +- website/astro.config.mjs | 15 +- website/src/components/Footer.astro | 8 +- website/src/components/MarkdownContent.astro | 7 + website/src/components/PageTitle.astro | 10 +- website/src/components/Search.astro | 498 +++++++++++++++++++ website/src/plugins/rehype-short-code.mjs | 94 ++++ 7 files changed, 625 insertions(+), 9 deletions(-) create mode 100644 website/src/components/Search.astro create mode 100644 website/src/plugins/rehype-short-code.mjs diff --git a/tests/test_site_r2_components.py b/tests/test_site_r2_components.py index 0d037a22..07cbbe09 100644 --- a/tests/test_site_r2_components.py +++ b/tests/test_site_r2_components.py @@ -52,7 +52,7 @@ class TablesStackWhenBuilt(unittest.TestCase): def test_the_plugin_is_registered_and_the_runtime_rule_is_gone(self): config = _read(CONFIG) self.assertIn("import stackTables from './src/plugins/rehype-stack-tables.mjs'", config) - self.assertIn("hastPlugins: [stackTables()]", config) + self.assertIn("hastPlugins: [stackTables(), ", config) self.assertNotIn("stackActionTable", _read(SITE_TS)) def test_setup_tables_carry_the_layout_in_the_html(self): diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 71fa2b65..4fd5748d 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -13,6 +13,7 @@ import starlightLinksValidator from 'starlight-links-validator'; import sidebar from './src/generated/sidebar.json' with { type: 'json' }; import lastmod from './src/generated/lastmod.json' with { type: 'json' }; import stackTables from './src/plugins/rehype-stack-tables.mjs'; +import shortCode from './src/plugins/rehype-short-code.mjs'; import unbrokenTokens from './src/plugins/ec-unbroken-tokens.mjs'; const SITE = 'https://abdess.github.io'; @@ -52,7 +53,7 @@ export default defineConfig({ redirects: readRedirects(), markdown: { processor: satteri({ - hastPlugins: [stackTables()], + hastPlugins: [stackTables(), shortCode()], features: { headingAttributes: true, definitionList: true, @@ -81,6 +82,7 @@ export default defineConfig({ MarkdownContent: './src/components/MarkdownContent.astro', Footer: './src/components/Footer.astro', Header: './src/components/Header.astro', + Search: './src/components/Search.astro', }, routeMiddleware: ['./src/routeData.ts'], tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 }, @@ -109,15 +111,20 @@ export default defineConfig({ // every other query. Two fields name the page's subject in other words // and weigh half a title: `aliases`, the ids setups file a system under // (psx, dreamcast, gba), and `devices`, the handhelds and boxes a - // setup's own site names (R36S, Miyoo Mini). All three are attributes - // on an empty element, so no text of theirs reaches an excerpt. + // setup's own site names (R36S, Miyoo Mini). Two more weigh as much as + // a title, since each names what a reader types to reach the page: + // `consoles`, the consoles a manufacturer page holds (Neo Geo, + // WonderSwan Color), and `terms`, the words of a task or a message on + // screen (bios not found, mame version, check dump). All of them are + // attributes on an empty element, so no text of theirs reaches an + // excerpt. pagefind: { ranking: { pageLength: 0.1, termFrequency: 0.5, termSaturation: 1.4, termSimilarity: 9, - metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 }, + metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5, consoles: 10, terms: 10 }, }, }, plugins: [ diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro index e96db4d8..c70aae7e 100644 --- a/website/src/components/Footer.astro +++ b/website/src/components/Footer.astro @@ -2,7 +2,9 @@ // Starlight's footer (edit link, previous and next), who keeps the project // and where to report a problem, the release the packs come from, and the // licence line. The release is read from release.json, written when the -// packs were published: the site may be newer than the packs. +// packs were published: the site may be newer than the packs. None of it +// is in the search index: "MIT for the tooling" was the excerpt of short +// pages, and "bios", "release" and "problem" matched every page. import Default from '@astrojs/starlight/components/Footer.astro'; import release from '../../../release.json'; @@ -19,6 +21,7 @@ const tag: string = release.tag; const date = releaseDate(tag); --- + diff --git a/website/src/components/PageTitle.astro b/website/src/components/PageTitle.astro index 168949b3..b4795bb3 100644 --- a/website/src/components/PageTitle.astro +++ b/website/src/components/PageTitle.astro @@ -7,6 +7,10 @@ // a dark mark stays visible in the dark theme. A setup page with no logo file // gets its initials in the same square, as its tile does (sitemarkup.py // platform_tiles). +// +// The crumbs and the title stay out of the search index: every excerpt +// began with the title and the crumbs matched "bios" on every page. The +// title still names the result, as the page's title field. import setups from '../../public/data/picker/index.json'; const PAGE_TITLE_ID = '_top'; @@ -46,7 +50,7 @@ const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platf { crumbs.length > 0 && ( -