mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-25 13:19:03 -05:00
feat: stack tables and add section tabs
# Conflicts: # wiki/testing-guide.md
This commit is contained in:
commit
e5871a6f21
11 files changed
+858
-81
No files matched your search
@@ -0,0 +1,229 @@
|
||||
"""Round 2, package 2: the site's components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there; the stacking rule runs the plugin under node
|
||||
and skips without node or the site's node_modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
CONFIG = WEBSITE / "astro.config.mjs"
|
||||
PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(page: str) -> str:
|
||||
path = DIST / page / "index.html"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"no built site at {path}")
|
||||
return _read(path)
|
||||
|
||||
|
||||
def _tables(html: str) -> list[str]:
|
||||
return re.findall(r"<table\b.*?</table>", html, re.S)
|
||||
|
||||
|
||||
def _headers(table: str) -> list[str]:
|
||||
head = re.search(r"<thead>(.*?)</thead>", table, re.S)
|
||||
cells = re.findall(r"<th\b[^>]*>(.*?)</th>", head.group(1), re.S) if head else []
|
||||
return [re.sub(r"<[^>]+>", "", cell).strip() for cell in cells]
|
||||
|
||||
|
||||
class TablesStackWhenBuilt(unittest.TestCase):
|
||||
"""The setup tables of Get your files were 591, 503 and 511 px wide in a
|
||||
358 px phone column: site.ts stacked them only once they neared the
|
||||
screen, so the ZIP column first painted off-screen."""
|
||||
|
||||
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.assertNotIn("stackActionTable", _read(SITE_TS))
|
||||
|
||||
def test_setup_tables_carry_the_layout_in_the_html(self):
|
||||
html = _built("which-pack")
|
||||
setups = [t for t in _tables(html) if _headers(t) == ["Setup", "Runs on", "BIOS folder", "ZIP"]]
|
||||
self.assertGreaterEqual(len(setups), 3)
|
||||
for table in setups:
|
||||
with self.subTest(table=table[:80]):
|
||||
self.assertIn('data-rb-stack="true"', table.split(">", 1)[0])
|
||||
cells = re.findall(r"<td\b[^>]*>", table)
|
||||
self.assertTrue(cells)
|
||||
for cell in cells:
|
||||
self.assertRegex(cell, r'data-label="(Setup|Runs on|BIOS folder|ZIP)"')
|
||||
|
||||
def test_two_column_tables_are_label_and_value(self):
|
||||
html = _built("platforms/retroarch")
|
||||
pairs = [t for t in _tables(html) if len(_headers(t)) == 2]
|
||||
self.assertTrue(pairs)
|
||||
for table in pairs:
|
||||
self.assertIn('data-rb-stack="kv"', table.split(">", 1)[0])
|
||||
|
||||
def test_the_rule_reads_the_table(self):
|
||||
node = shutil.which("node")
|
||||
if not node or not (WEBSITE / "node_modules").is_dir():
|
||||
self.skipTest("node or the site's node_modules is missing")
|
||||
cases = {
|
||||
# A setup added after the release: no ZIP link, the header still names one.
|
||||
"zip-header": {"labels": ["Setup", "Runs on", "BIOS folder", "ZIP"], "rows": 5, "action": False},
|
||||
"action": {"labels": ["A", "B", "C"], "rows": 40, "action": True},
|
||||
"pair": {"labels": ["Where", "BIOS folder"], "rows": 3, "action": False},
|
||||
"short-start": {"labels": ["System", "Runs", "BIOS folder"], "rows": 8, "action": False, "level": "start"},
|
||||
"long-setup": {"labels": ["System", "Files", "Status", "Emulators"], "rows": 97, "action": False, "level": "setup"},
|
||||
"reference": {"labels": ["A", "B", "C"], "rows": 5, "action": False, "level": "reference"},
|
||||
}
|
||||
script = (
|
||||
f"import {{ stackKind }} from {json.dumps(PLUGIN.as_uri())};"
|
||||
f"const cases = {json.dumps(cases)};"
|
||||
"console.log(JSON.stringify(Object.fromEntries(Object.entries(cases).map(([k, v]) => [k, stackKind(v)]))));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
)
|
||||
self.assertEqual(json.loads(result.stdout), {
|
||||
"zip-header": "true", "action": "true", "pair": "kv",
|
||||
"short-start": "true", "long-setup": None, "reference": None,
|
||||
})
|
||||
|
||||
|
||||
class ScrollingTablesHaveTheirOwnName(unittest.TestCase):
|
||||
"""Every scrolling table was the region "Data table, scrollable table"
|
||||
(axe landmark-unique on which-pack, retroarch, gaps)."""
|
||||
|
||||
def test_no_bare_fallback_label(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("'Data table'", source)
|
||||
self.assertIn("`${tableName(table)}, scrollable table`", source)
|
||||
self.assertIn("`${heading}, table ${count}`", source)
|
||||
|
||||
|
||||
class CommandBlocksHaveNoWindowDots(unittest.TestCase):
|
||||
def test_the_dots_are_off_in_the_theme(self):
|
||||
self.assertIn("terminalTitlebarDotsOpacity: '0'", _read(CONFIG))
|
||||
css = DIST / "_astro"
|
||||
if not css.is_dir():
|
||||
self.skipTest("no built site")
|
||||
values = {m for f in css.glob("ec.*.css") for m in re.findall(r"--ec-frm-trmTtbDotsOpa:([^;]+)", _read(f))}
|
||||
self.assertEqual(values, {"0"})
|
||||
|
||||
|
||||
class RedirectsComeFromTheGenerator(unittest.TestCase):
|
||||
def test_config_reads_the_file_and_builds_without_it(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("./src/generated/redirects.json", config)
|
||||
self.assertIn("if (error.code === 'ENOENT') return {};", config)
|
||||
self.assertIn("redirects: readRedirects(),", config)
|
||||
# Astro does not put the base before the new address.
|
||||
self.assertIn("BASE + to.replace(/^\\//, '')", config)
|
||||
|
||||
|
||||
class SearchAnswersAHashAndShowsNoFileDump(unittest.TestCase):
|
||||
"""Searching the scph5501 MD5 opened on Data model, results carried a
|
||||
second "#_top" title, and a system page showed its 120 file names."""
|
||||
|
||||
def test_runtime(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("Look up this hash", source)
|
||||
self.assertIn("files/?hash=${value}", source)
|
||||
self.assertIn("endsWith('#_top')", source)
|
||||
self.assertIn("tag.dataset.pagefindUiMeta === 'files'", source)
|
||||
# Pagefind's display rules beat the hidden attribute without this.
|
||||
self.assertIn("#starlight__search [hidden] { display: none !important; }", _read(COMPONENTS / "Header.astro"))
|
||||
|
||||
|
||||
class SectionTabsInTheHeader(unittest.TestCase):
|
||||
def test_registered_and_built(self):
|
||||
self.assertIn("Header: './src/components/Header.astro'", _read(CONFIG))
|
||||
html = _built("which-pack")
|
||||
nav = re.search(r'<nav class="rb-tabs[^"]*"[^>]*>(.*?)</nav>', html, re.S)
|
||||
self.assertIsNotNone(nav)
|
||||
labels = [re.sub(r"<[^>]+>", "", a).strip() for a in re.findall(r"<a\b[^>]*>.*?</a>", nav.group(1), re.S)]
|
||||
self.assertEqual(labels, ["Start here", "Your setup", "Explore", "Reference", "Contribute"])
|
||||
self.assertEqual(nav.group(1).count('aria-current="true"'), 1)
|
||||
self.assertRegex(nav.group(1), r'aria-current="true"[^>]*>\s*Start here')
|
||||
|
||||
|
||||
def _title_block(html: str) -> str:
|
||||
"""What PageTitle draws: from the main column to the h1."""
|
||||
end = html.index('id="_top"')
|
||||
return html[html.rfind("<main", 0, end):end]
|
||||
|
||||
|
||||
class LevelBadgeOnlyWhereItAddsSomething(unittest.TestCase):
|
||||
def test_badge_follows_the_crumb(self):
|
||||
# A tab root has no crumb and keeps its badge.
|
||||
self.assertIn('class="rb-level rb-level-setup', _title_block(_built("platforms")))
|
||||
# Under the "Start here" or "Your setup" crumb the badge repeated it.
|
||||
for page in ("which-pack", "platforms/batocera"):
|
||||
with self.subTest(page=page):
|
||||
block = _title_block(_built(page))
|
||||
self.assertIn('aria-label="Breadcrumb"', block)
|
||||
self.assertNotIn('class="rb-level ', block)
|
||||
|
||||
|
||||
class TitleLogo(unittest.TestCase):
|
||||
def test_front_matter_field(self):
|
||||
self.assertIn("logo: z.string().optional()", _read(WEBSITE / "src" / "content.config.ts"))
|
||||
title = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn('<img src={logoSrc} alt="" height="40" />', title)
|
||||
|
||||
|
||||
class FooterSaysWhoAndWhen(unittest.TestCase):
|
||||
def test_release_and_maintainer(self):
|
||||
tag = json.loads(_read(REPO_ROOT / "release.json"))["tag"]
|
||||
html = _built("which-pack")
|
||||
footer = html[html.index('class="rb-footer'):]
|
||||
self.assertIn("https://github.com/Abdess/retrobios/issues/new", footer)
|
||||
self.assertIn(f"/releases/tag/{tag}", footer)
|
||||
match = re.fullmatch(r"v(\d{4})\.(\d{2})\.(\d{2})", tag)
|
||||
if match:
|
||||
self.assertIn(f'<time datetime="{match[1]}-{match[2]}-{match[3]}"', footer)
|
||||
self.assertRegex(re.sub(r"<[^>]+>", "", footer), r"Abdess on GitHub\. Report a problem\.")
|
||||
|
||||
|
||||
class FindAFileGivesAVerdict(unittest.TestCase):
|
||||
def test_lookup_renderer(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("', held in the collection at '", source)
|
||||
self.assertIn("['Setup', 'Name as declared', 'Destination', 'Hash matched', 'Required']", source)
|
||||
self.assertIn("api/v1/platforms/${item.owner}.json", source)
|
||||
self.assertIn("api/v1/systems/${slug}.json", source)
|
||||
style = _read(COMPONENTS / "MarkdownContent.astro")
|
||||
self.assertIn("max-width: 40rem;", style)
|
||||
self.assertIn("width: 100%;", style)
|
||||
|
||||
|
||||
class Picker(unittest.TestCase):
|
||||
def test_choices(self):
|
||||
source = _read(COMPONENTS / "FilePicker.astro")
|
||||
self.assertIn("box.checked = preset ? preset.includes(choice.id) : false;", source)
|
||||
self.assertIn(".sort((a, b) => a.title.localeCompare(b.title, 'en'))", source)
|
||||
self.assertIn('placeholder="Type a console"', source)
|
||||
self.assertIn("filter.focus()", source)
|
||||
self.assertIn("n.toLocaleString('en')", source)
|
||||
self.assertNotRegex(source, r"`\$\{files\.length\} files")
|
||||
self.assertIn("<figcaption>Terminal</figcaption>", source)
|
||||
self.assertIn("flex-direction: column;", source)
|
||||
# The size gap is the release against the current list, from release.json.
|
||||
self.assertIn("import release from '../../../release.json';", source)
|
||||
self.assertIn("It was built from the list of that day.", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,7 @@
|
||||
// The RetroBIOS site. Pages, navigation and data files are written by
|
||||
// scripts/generate_site.py; this folder holds the engine's configuration,
|
||||
// the components and the styles.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'astro/config';
|
||||
@@ -11,10 +12,27 @@ import starlightSidebarTopics from 'starlight-sidebar-topics';
|
||||
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';
|
||||
|
||||
const SITE = 'https://abdess.github.io';
|
||||
const BASE = '/retrobios/';
|
||||
|
||||
// Old addresses that lead on to a page: {"/from/": "/to/"}, site paths
|
||||
// written by the generator. A checkout that has not run it builds with none.
|
||||
// Astro puts the base before the old address but not before the new one.
|
||||
const readRedirects = () => {
|
||||
let redirects;
|
||||
try {
|
||||
redirects = JSON.parse(readFileSync(new URL('./src/generated/redirects.json', import.meta.url), 'utf-8'));
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return {};
|
||||
throw error;
|
||||
}
|
||||
const withBase = (to) => (/^https?:/.test(to) || to.startsWith(BASE) ? to : BASE + to.replace(/^\//, ''));
|
||||
const withoutBase = (from) => (from.startsWith(BASE) ? `/${from.slice(BASE.length)}` : from);
|
||||
return Object.fromEntries(Object.entries(redirects).map(([from, to]) => [withoutBase(from), withBase(to)]));
|
||||
};
|
||||
|
||||
// The sitemap keeps the address MkDocs gave it: sitemap.xml, beside the index.
|
||||
const sitemapAlias = {
|
||||
name: 'retrobios-sitemap-alias',
|
||||
@@ -30,8 +48,10 @@ export default defineConfig({
|
||||
site: SITE,
|
||||
base: BASE,
|
||||
trailingSlash: 'always',
|
||||
redirects: readRedirects(),
|
||||
markdown: {
|
||||
processor: satteri({
|
||||
hastPlugins: [stackTables()],
|
||||
features: {
|
||||
headingAttributes: true,
|
||||
definitionList: true,
|
||||
@@ -59,11 +79,21 @@ export default defineConfig({
|
||||
PageTitle: './src/components/PageTitle.astro',
|
||||
MarkdownContent: './src/components/MarkdownContent.astro',
|
||||
Footer: './src/components/Footer.astro',
|
||||
Header: './src/components/Header.astro',
|
||||
},
|
||||
routeMiddleware: ['./src/routeData.ts'],
|
||||
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
||||
// Commands wrap instead of scrolling, so the end of a one-liner stays visible.
|
||||
expressiveCode: { defaultProps: { wrap: true } },
|
||||
// Commands wrap instead of scrolling, so the end of a one-liner stays
|
||||
// visible. A command block is titled (Terminal, PowerShell) and drawn
|
||||
// without the three window dots. Starlight writes the dots into each
|
||||
// theme after the global style overrides, so they are set here.
|
||||
expressiveCode: {
|
||||
defaultProps: { wrap: true },
|
||||
customizeTheme(theme) {
|
||||
theme.styleOverrides.frames = { ...theme.styleOverrides.frames, terminalTitlebarDotsOpacity: '0' };
|
||||
return theme;
|
||||
},
|
||||
},
|
||||
lastUpdated: false,
|
||||
pagination: true,
|
||||
// Measured on the queries of tools/search_gate.py, at the centre of a
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
// which convert.py derives from install/<platform>.json. The selection
|
||||
// follows install.py narrow(): a file is kept when one of its systems is
|
||||
// chosen, a file with no system when "other" is chosen.
|
||||
// The list follows the current data; release.json says what the published
|
||||
// ZIP of each setup holds, so the page can say when the two differ.
|
||||
import release from '../../../release.json';
|
||||
|
||||
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const packs = Object.fromEntries(
|
||||
Object.values(release.packs).flatMap((pack) =>
|
||||
pack.platforms.map((platform) => [platform, { files: pack.files, size: pack.extracted_size }]),
|
||||
),
|
||||
);
|
||||
---
|
||||
|
||||
<div class="rb-picker" data-base={base}>
|
||||
<div class="rb-picker" data-base={base} data-release={JSON.stringify({ tag: release.tag, packs })}>
|
||||
<fieldset>
|
||||
<label for="rb-pick-platform">Your setup</label>
|
||||
<select id="rb-pick-platform" name="platform">
|
||||
@@ -17,16 +26,20 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
<div class="rb-pick-systems" hidden>
|
||||
<p class="rb-pick-systems-head">
|
||||
<strong>Systems</strong>
|
||||
<button type="button" data-all>All</button>
|
||||
<button type="button" data-none>None</button>
|
||||
<button type="button" class="rb-text-button" data-all>All</button>
|
||||
<button type="button" class="rb-text-button" data-none>None</button>
|
||||
</p>
|
||||
<input type="search" class="rb-pick-filter" placeholder="Filter systems" aria-label="Filter systems" />
|
||||
<input type="search" class="rb-pick-filter" placeholder="Type a console" aria-label="Type a console" />
|
||||
<div class="rb-pick-list" role="group" aria-label="Systems"></div>
|
||||
</div>
|
||||
<div class="rb-pick-result" role="status" aria-live="polite"></div>
|
||||
<p class="rb-pick-release" hidden></p>
|
||||
<div class="rb-pick-output" hidden>
|
||||
<p><strong>Install command</strong> (Linux, macOS, Steam Deck, handhelds)</p>
|
||||
<pre class="rb-pick-cmd"><code></code></pre>
|
||||
<p><strong>Install command</strong> for Linux, macOS, Steam Deck and handhelds</p>
|
||||
<figure class="rb-pick-frame">
|
||||
<figcaption>Terminal</figcaption>
|
||||
<pre class="rb-pick-cmd"><code></code></pre>
|
||||
</figure>
|
||||
<button type="button" data-copy>Copy the command</button>
|
||||
<details>
|
||||
<summary>Files and where they go</summary>
|
||||
@@ -49,6 +62,8 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
files: [string, number, number[]][];
|
||||
};
|
||||
|
||||
type Release = { tag: string; packs: Record<string, { files: number; size: number }> };
|
||||
|
||||
const INSTALL = 'curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s --';
|
||||
const MAX_ROWS = 500;
|
||||
|
||||
@@ -68,6 +83,9 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const output = root.querySelector<HTMLElement>('.rb-pick-output')!;
|
||||
const cmd = root.querySelector<HTMLElement>('.rb-pick-cmd code')!;
|
||||
const tbody = root.querySelector('tbody')!;
|
||||
const releaseLine = root.querySelector<HTMLElement>('.rb-pick-release')!;
|
||||
const release: Release = JSON.parse(root.dataset.release ?? '{"tag":"","packs":{}}');
|
||||
const count = (n: number) => n.toLocaleString('en');
|
||||
let data: Data | null = null;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -86,7 +104,7 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const total = files.reduce((sum, [, bytes]) => sum + bytes, 0);
|
||||
const all = picked.length === data.systems.length + (data.other ? 1 : 0);
|
||||
result.textContent = picked.length
|
||||
? `${files.length} files, ${size(total)}, for ${picked.length} of ${data.systems.length + (data.other ? 1 : 0)} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
|
||||
? `${count(files.length)} files, ${size(total)}, for ${count(picked.length)} of ${count(data.systems.length + (data.other ? 1 : 0))} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
|
||||
: 'Choose at least one system.';
|
||||
output.hidden = !picked.length;
|
||||
cmd.textContent = `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`;
|
||||
@@ -105,7 +123,7 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 2;
|
||||
cell.textContent = `and ${files.length - MAX_ROWS} more.`;
|
||||
cell.textContent = `and ${count(files.length - MAX_ROWS)} more.`;
|
||||
row.append(cell);
|
||||
tbody.append(row);
|
||||
}
|
||||
@@ -119,32 +137,45 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const load = async (platform: string, preset: string[] | null) => {
|
||||
list.replaceChildren();
|
||||
output.hidden = true;
|
||||
releaseLine.hidden = true;
|
||||
systemsBox.hidden = !platform;
|
||||
if (!platform) { result.textContent = ''; return; }
|
||||
result.textContent = 'Loading the file list...';
|
||||
data = await fetch(`${base}data/picker/${platform}.json`).then((r) => r.json());
|
||||
const choices = data!.systems.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title || s.id,
|
||||
detail: [s.id, ...s.native].filter((n) => n !== s.title).join(', '),
|
||||
}));
|
||||
// Sorted by the name a reader looks for, not by id.
|
||||
const choices = data!.systems
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title || s.id,
|
||||
detail: [s.id, ...s.native].filter((n) => n !== s.title).join(', '),
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'en'));
|
||||
if (data!.other) choices.push({ id: 'other', title: 'Other files', detail: 'engines and computers no system owns' });
|
||||
for (const choice of choices) {
|
||||
const label = document.createElement('label');
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.value = choice.id;
|
||||
box.checked = preset ? preset.includes(choice.id) : true;
|
||||
box.checked = preset ? preset.includes(choice.id) : false;
|
||||
const detail = document.createElement('small');
|
||||
detail.textContent = choice.detail;
|
||||
label.append(box, ` ${choice.title} `, detail);
|
||||
list.append(label);
|
||||
}
|
||||
const pack = release.packs[data!.platform];
|
||||
const listed = data!.files.reduce((sum, [, bytes]) => sum + bytes, 0);
|
||||
if (pack && (pack.files !== data!.files.length || pack.size !== listed)) {
|
||||
releaseLine.textContent = `The ZIP of release ${release.tag} holds ${count(pack.files)} files, ${size(pack.size)}. It was built from the list of that day. The command installs the current list.`;
|
||||
releaseLine.hidden = false;
|
||||
}
|
||||
applyFilter();
|
||||
render();
|
||||
};
|
||||
|
||||
select.addEventListener('change', () => load(select.value, null));
|
||||
select.addEventListener('change', async () => {
|
||||
await load(select.value, null);
|
||||
if (select.value) filter.focus();
|
||||
});
|
||||
list.addEventListener('change', render);
|
||||
const applyFilter = () => {
|
||||
const query = filter.value.trim().toLowerCase();
|
||||
@@ -192,5 +223,42 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
.rb-picker .rb-pick-filter { width: 100%; margin: 0.5rem 0; }
|
||||
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.rb-picker .rb-pick-result { margin-top: 1rem; font-weight: 600; }
|
||||
.rb-picker fieldset { border: 0; padding: 0; }
|
||||
.rb-picker fieldset {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.rb-picker fieldset label { font-weight: 600; }
|
||||
.rb-picker .rb-pick-release { margin-top: 0.5rem; color: var(--sl-color-gray-2); }
|
||||
/* All and None act on the list: plain text, not two primary buttons. */
|
||||
.sl-markdown-content .rb-picker .rb-pick-systems-head .rb-text-button {
|
||||
min-height: 32px;
|
||||
padding: 0 0.25rem;
|
||||
background: none;
|
||||
color: var(--rb-link);
|
||||
font-weight: 400;
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* The command in the same titled frame as the page's other commands. */
|
||||
.rb-picker .rb-pick-frame {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rb-picker .rb-pick-frame figcaption {
|
||||
padding: 0.35rem 1rem;
|
||||
border-bottom: 1px solid var(--sl-color-gray-5);
|
||||
background: var(--sl-color-gray-6);
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: var(--sl-text-xs);
|
||||
}
|
||||
.rb-picker .rb-pick-frame pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,49 @@
|
||||
---
|
||||
// Starlight's footer (edit link, previous and next) and the licence line.
|
||||
// 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.
|
||||
import Default from '@astrojs/starlight/components/Footer.astro';
|
||||
import release from '../../../release.json';
|
||||
|
||||
const REPO = 'https://github.com/Abdess/retrobios';
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
// A release tag is its date, vYYYY.MM.DD.
|
||||
const releaseDate = (tag: string): string | undefined => {
|
||||
const match = /^v(\d{4})\.(\d{2})\.(\d{2})$/.exec(tag);
|
||||
return match ? `${Number(match[3])} ${MONTHS[Number(match[2]) - 1]} ${match[1]}` : undefined;
|
||||
};
|
||||
|
||||
const tag: string = release.tag;
|
||||
const date = releaseDate(tag);
|
||||
---
|
||||
|
||||
<Default />
|
||||
<p class="rb-copyright">
|
||||
MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup,
|
||||
archival and interoperability.
|
||||
</p>
|
||||
<div class="rb-footer">
|
||||
<p>
|
||||
Maintained by <a href="https://github.com/Abdess">Abdess on GitHub</a>.{' '}
|
||||
<a href={`${REPO}/issues/new`}>Report a problem</a>.
|
||||
</p>
|
||||
<p>
|
||||
Packs from release <a href={`${REPO}/releases/tag/${tag}`}>{tag}</a>{date && <>, <time datetime={tag.slice(1).replaceAll('.', '-')}>{date}</time></>}.
|
||||
</p>
|
||||
<p class="rb-copyright">
|
||||
MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup,
|
||||
archival and interoperability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rb-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--sl-color-hairline-light);
|
||||
font-size: var(--sl-text-xs);
|
||||
color: var(--sl-color-gray-3);
|
||||
}
|
||||
.rb-footer p { margin: 0.25rem 0 0; }
|
||||
.rb-footer a { color: inherit; }
|
||||
.rb-footer a:hover { color: var(--sl-color-white); }
|
||||
.rb-footer .rb-copyright { margin-top: 0.75rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
// Starlight's header bar, and under it the five sections of the site as
|
||||
// tabs: the funnel (Start here to Contribute) is the site's structure, so it
|
||||
// sits where every docs site puts its sections. On a phone the tabs stay the
|
||||
// first row of the menu, where the sidebar lists them.
|
||||
import Default from '@astrojs/starlight/components/Header.astro';
|
||||
|
||||
const topics = Astro.locals.starlightSidebarTopics?.topics ?? [];
|
||||
---
|
||||
|
||||
<div class="rb-header-bar"><Default /></div>
|
||||
{
|
||||
topics.length > 0 && (
|
||||
<nav class="rb-tabs print:hidden" aria-label="Sections">
|
||||
<ul>
|
||||
{topics.map((topic) => (
|
||||
<li>
|
||||
<a href={topic.link} aria-current={topic.isCurrent ? 'true' : undefined}>
|
||||
{topic.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Global: the bar height and the sidebar's topic list belong to other components. -->
|
||||
<style is:global>
|
||||
.rb-header-bar { height: 100%; }
|
||||
.rb-tabs { display: none; }
|
||||
|
||||
/* "Look up this hash", the first result of a hash typed in the search (site.ts). */
|
||||
.rb-search-hash {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.5rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rb-search-hash a { font-weight: 600; }
|
||||
/* Pagefind's own display rules would show what site.ts hides. */
|
||||
#starlight__search [hidden] { display: none !important; }
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
:root {
|
||||
--rb-nav-bar: 4rem;
|
||||
--rb-tab-row: 2.75rem;
|
||||
--sl-nav-height: calc(var(--rb-nav-bar) + var(--rb-tab-row));
|
||||
}
|
||||
.rb-header-bar { height: calc(100% - var(--rb-tab-row)); }
|
||||
.site-title { height: calc(var(--rb-nav-bar) - 2 * var(--sl-nav-pad-y)); }
|
||||
.rb-tabs {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
height: var(--rb-tab-row);
|
||||
padding-inline: var(--sl-nav-pad-x);
|
||||
}
|
||||
.rb-tabs ul {
|
||||
display: flex;
|
||||
gap: 1.75rem;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.rb-tabs li { display: flex; }
|
||||
.rb-tabs a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: var(--sl-text-sm);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-tabs a:hover { color: var(--sl-color-white); }
|
||||
.rb-tabs a[aria-current] {
|
||||
border-bottom-color: currentColor;
|
||||
color: var(--sl-color-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* The header holds the sections: the sidebar lists the pages of one. */
|
||||
.sidebar-content ul.starlight-sidebar-topics { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -20,3 +20,14 @@ const { searchWeight } = Astro.locals.starlightRoute.entry.data;
|
||||
<script>
|
||||
import '../scripts/site.ts';
|
||||
</script>
|
||||
|
||||
<!-- Global: the lookup form on files/ is written by the generator. Its input
|
||||
keeps to the column on a phone (size="66" made it 707 px wide). -->
|
||||
<style is:global>
|
||||
.sl-markdown-content .rb-file-lookup input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 40rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
// Breadcrumbs, the level label and the title. The trail says which tab a
|
||||
// deep page belongs to; the label says who the page is written for.
|
||||
// deep page belongs to; the label says who the page is written for, and is
|
||||
// left out where the first crumb already names that tab. A page with a
|
||||
// `logo` (a path under the site root) shows it beside its title.
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
|
||||
const LABELS = {
|
||||
@@ -11,8 +13,20 @@ const LABELS = {
|
||||
contribute: 'For contributors',
|
||||
} as const;
|
||||
|
||||
const { title, level } = Astro.locals.starlightRoute.entry.data;
|
||||
// The tab each level is read in, as the header names it.
|
||||
const TABS = {
|
||||
start: 'Start here',
|
||||
setup: 'Your setup',
|
||||
explore: 'Explore',
|
||||
reference: 'Reference',
|
||||
contribute: 'Contribute',
|
||||
} as const;
|
||||
|
||||
const { title, level, logo } = Astro.locals.starlightRoute.entry.data;
|
||||
const crumbs = Astro.locals.rbBreadcrumbs ?? [];
|
||||
const badge = level && crumbs[0]?.label !== TABS[level] && crumbs[0]?.label !== LABELS[level];
|
||||
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const logoSrc = logo && (/^(https?:)?\//.test(logo) ? logo : base + logo);
|
||||
---
|
||||
|
||||
{
|
||||
@@ -26,8 +40,17 @@ const crumbs = Astro.locals.rbBreadcrumbs ?? [];
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
{level && <span class={`rb-level rb-level-${level}`}>{LABELS[level]}</span>}
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
{badge && <span class={`rb-level rb-level-${level}`}>{LABELS[level]}</span>}
|
||||
{
|
||||
logoSrc ? (
|
||||
<div class="rb-title-logo">
|
||||
<img src={logoSrc} alt="" height="40" />
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
</div>
|
||||
) : (
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
@@ -37,4 +60,18 @@ const crumbs = Astro.locals.rbBreadcrumbs ?? [];
|
||||
font-weight: 700;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
.rb-title-logo {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rb-title-logo h1 { margin-top: 0; }
|
||||
.rb-title-logo img {
|
||||
flex: none;
|
||||
width: auto;
|
||||
height: 40px;
|
||||
max-width: 8rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,9 @@ export const collections = {
|
||||
searchWeight: z.number().min(0.1).max(10).optional(),
|
||||
// The date the page's content carries, for the sitemap.
|
||||
lastmod: z.string().optional(),
|
||||
// A mark drawn beside the title (a platform's logo), a path under
|
||||
// the site root such as assets/logos/batocera.svg.
|
||||
logo: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Tables a phone reads one row at a time, marked when the page is built so
|
||||
// the stacked layout is there on first paint (the CSS is retrobios.css,
|
||||
// table[data-rb-stack]). The rule reads the table, never the page that
|
||||
// wrote it:
|
||||
// - a row that ends in an action (a release download, a button, the link to
|
||||
// a setup's install command) or a header that names a ZIP column: one
|
||||
// block per row, each cell under its column name;
|
||||
// - two columns: a list of label and value, data-rb-stack="kv";
|
||||
// - on a level 0 or 1 page (`level: start` or `setup`), a table short enough
|
||||
// to read as records (under 20 rows, the size from which site.ts adds a
|
||||
// filter): one block per row. A longer one is a list scanned by column and
|
||||
// keeps its scroll.
|
||||
// Satteri runs hast plugins (the rehype stage of its pipeline): this one is
|
||||
// registered in astro.config.mjs through satteri({ hastPlugins }).
|
||||
|
||||
const ACTION_HREF = /\/releases\/(download\/|latest\/?$)|#install$/;
|
||||
const ACTION_CLASS = /\bclass=["'][^"']*\brb-button\b/;
|
||||
const DOWNLOAD_HEADER = /^(zip|download)$/i;
|
||||
const READER_LEVELS = new Set(['start', 'setup']);
|
||||
export const RECORD_ROWS = 20;
|
||||
|
||||
const elements = (node, tag, found = []) => {
|
||||
for (const child of node.children ?? []) {
|
||||
if (child.type !== 'element') continue;
|
||||
if (child.tagName === tag) found.push(child);
|
||||
// A table inside a cell is its own table.
|
||||
if (child.tagName !== 'table') elements(child, tag, found);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const holdsAction = (node) =>
|
||||
(node.children ?? []).some(
|
||||
(child) =>
|
||||
(child.type === 'element' &&
|
||||
((child.tagName === 'a' && ACTION_HREF.test(String(child.properties?.href ?? ''))) ||
|
||||
String(child.properties?.className ?? '').split(/[\s,]+/).includes('rb-button') ||
|
||||
holdsAction(child))) ||
|
||||
(child.type === 'raw' && ACTION_CLASS.test(child.value ?? '')),
|
||||
);
|
||||
|
||||
export const stackKind = ({ labels, rows, action, level }) => {
|
||||
if (labels.length === 2) return 'kv';
|
||||
if (action || DOWNLOAD_HEADER.test(labels.at(-1) ?? '')) return 'true';
|
||||
if (READER_LEVELS.has(level) && labels.length > 2 && rows < RECORD_ROWS) return 'true';
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function stackTables() {
|
||||
return {
|
||||
name: 'rb-stack-tables',
|
||||
element: {
|
||||
filter: ['table'],
|
||||
visit(table, ctx) {
|
||||
const head = elements(table, 'thead')[0];
|
||||
const body = elements(table, 'tbody')[0];
|
||||
if (!head || !body) return;
|
||||
const labels = elements(head, 'th').map((cell) => ctx.textContent(cell).trim());
|
||||
const rows = elements(body, 'tr');
|
||||
const level = ctx.data.astro?.frontmatter?.level;
|
||||
const kind = stackKind({ labels, rows: rows.length, action: holdsAction(body), level });
|
||||
if (!kind) return;
|
||||
ctx.setProperty(table, 'dataRbStack', kind);
|
||||
if (kind === 'kv') return;
|
||||
for (const row of rows) {
|
||||
elements(row, 'td').forEach((cell, column) => {
|
||||
if (labels[column] && cell.properties?.dataLabel === undefined) ctx.setProperty(cell, 'dataLabel', labels[column]);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
+252
-53
@@ -1,6 +1,8 @@
|
||||
// Progressive enhancement of the generated pages: table captions, filters
|
||||
// and phone layout, the hash lookup on files/, moved cards, a hash typed in
|
||||
// the search, glossary terms on a tap, and the 404 page's hints.
|
||||
// and keyboard scrolling, the hash lookup on files/, moved cards, a hash
|
||||
// typed in the search, glossary terms on a tap, 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.
|
||||
|
||||
const HASH_LENGTHS: Record<number, string> = { 8: 'CRC32', 32: 'MD5', 40: 'SHA1', 64: 'SHA256' };
|
||||
const LAYER_LABELS: Record<string, string> = {
|
||||
@@ -17,16 +19,39 @@ const isHash = (value: string) => /^[0-9a-f]+$/.test(value) && value.length in H
|
||||
|
||||
// Tables
|
||||
|
||||
// The heading a table sits under: the nearest one before it, looking up
|
||||
// through the blocks that hold it, else the page title.
|
||||
const headingText = (element: Element): string => {
|
||||
let cursor: Element | null = element;
|
||||
while (cursor) {
|
||||
cursor = cursor.previousElementSibling;
|
||||
if (!cursor) break;
|
||||
const heading = /^H[1-6]$/.test(cursor.tagName) ? cursor : cursor.querySelector('h1, h2, h3, h4, h5, h6');
|
||||
if (heading && cursor.classList.contains('sl-heading-wrapper')) return heading.textContent!.trim();
|
||||
if (/^H[1-6]$/.test(cursor.tagName)) return cursor.textContent!.trim();
|
||||
for (let block: Element | null = element; block && !block.matches('.sl-markdown-content'); block = block.parentElement) {
|
||||
for (let cursor = block.previousElementSibling; cursor; cursor = cursor.previousElementSibling) {
|
||||
const heading = /^H[1-6]$/.test(cursor.tagName)
|
||||
? cursor
|
||||
: cursor.classList.contains('sl-heading-wrapper')
|
||||
? cursor.querySelector('h1, h2, h3, h4, h5, h6')
|
||||
: null;
|
||||
if (heading?.textContent?.trim()) return heading.textContent.trim();
|
||||
}
|
||||
}
|
||||
return document.querySelector('h1')?.textContent?.trim() || 'Data table';
|
||||
return document.querySelector('h1')?.textContent?.trim() || document.title;
|
||||
};
|
||||
|
||||
// Each table's name, once per page: two tables under one heading are told
|
||||
// apart by their order, so no two regions share a label (axe landmark-unique).
|
||||
const tableNames = new WeakMap<HTMLTableElement, string>();
|
||||
const tableName = (table: HTMLTableElement): string => {
|
||||
let name = tableNames.get(table);
|
||||
if (name) return name;
|
||||
const tables = [...document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table')];
|
||||
const seen = new Map<string, number>();
|
||||
for (const each of tables) {
|
||||
const heading = headingText(each);
|
||||
const count = (seen.get(heading) ?? 0) + 1;
|
||||
seen.set(heading, count);
|
||||
tableNames.set(each, count === 1 ? heading : `${heading}, table ${count}`);
|
||||
}
|
||||
name = tableNames.get(table) ?? headingText(table);
|
||||
tableNames.set(table, name);
|
||||
return name;
|
||||
};
|
||||
|
||||
// A table that overflows is a named stop in the tab order, so a keyboard
|
||||
@@ -39,11 +64,10 @@ const markScrollable = (tables: HTMLTableElement[]) => {
|
||||
};
|
||||
|
||||
const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
|
||||
const heading = table.dataset.rbHeading ?? 'Data table';
|
||||
if (overflows) {
|
||||
table.tabIndex = 0;
|
||||
table.setAttribute('role', 'region');
|
||||
table.setAttribute('aria-label', `${heading}, scrollable table`);
|
||||
table.setAttribute('aria-label', `${tableName(table)}, scrollable table`);
|
||||
} else if (table.getAttribute('role') === 'region') {
|
||||
table.removeAttribute('tabindex');
|
||||
table.removeAttribute('role');
|
||||
@@ -51,21 +75,6 @@ const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
// A table whose rows end in an action (a button, a pack to download) is laid
|
||||
// out one block per row on a phone, each cell under its column name, so the
|
||||
// action never sits behind a horizontal scroll.
|
||||
const stackActionTable = (table: HTMLTableElement) => {
|
||||
if (!table.querySelector('tbody :is(.rb-button, a[href*="/releases/download/"], a[href$="/releases/latest"])')) return;
|
||||
const labels = [...table.querySelectorAll('thead th')].map((cell) => cell.textContent!.trim());
|
||||
if (!labels.length) return;
|
||||
table.dataset.rbStack = 'true';
|
||||
for (const row of table.querySelectorAll('tbody tr')) {
|
||||
[...row.children].forEach((cell, column) => {
|
||||
if (labels[column] && !cell.hasAttribute('data-label')) cell.setAttribute('data-label', labels[column]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filterTable = (table: HTMLTableElement, index: number, heading: string) => {
|
||||
const rows = [...table.querySelectorAll<HTMLTableRowElement>('tbody tr')];
|
||||
if (rows.length < 20) return;
|
||||
@@ -104,15 +113,13 @@ const enhanceTables = () => {
|
||||
const tables = [...document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table')];
|
||||
const enhance = (table: HTMLTableElement) => {
|
||||
for (const cell of table.querySelectorAll('thead th')) if (!cell.hasAttribute('scope')) cell.setAttribute('scope', 'col');
|
||||
const heading = headingText(table);
|
||||
table.dataset.rbHeading = heading;
|
||||
const heading = tableName(table);
|
||||
if (!table.querySelector('caption') && !table.closest('.rb-meta-card')) {
|
||||
const caption = document.createElement('caption');
|
||||
caption.className = 'rb-visually-hidden';
|
||||
caption.textContent = heading;
|
||||
table.prepend(caption);
|
||||
}
|
||||
stackActionTable(table);
|
||||
filterTable(table, tables.indexOf(table), heading);
|
||||
};
|
||||
// A table is set up and measured when it nears the screen. Doing all of
|
||||
@@ -160,32 +167,164 @@ const lookupShard = (value: string): Promise<Item[]> =>
|
||||
.then((response) => (response.ok ? response.json() : { items: [] }))
|
||||
.then((shard) => shard.items || []);
|
||||
|
||||
const renderMatches = (status: HTMLElement, value: string, items: Item[]) => {
|
||||
status.textContent = '';
|
||||
const heading = document.createElement('p');
|
||||
heading.textContent = `${items.length} ${items.length === 1 ? 'match' : 'matches'} for ${HASH_LENGTHS[value.length]} ${value}:`;
|
||||
status.append(heading);
|
||||
for (const [layer, label] of Object.entries(LAYER_LABELS)) {
|
||||
const found = items.filter((item) => item.layer === layer);
|
||||
if (!found.length) continue;
|
||||
const title = document.createElement('p');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = label;
|
||||
title.append(strong);
|
||||
type Held = { name: string; path: string; sha1: string; size: number };
|
||||
type Declared = { name?: string; destination?: string; required?: boolean; [hash: string]: unknown };
|
||||
|
||||
const json = (path: string) =>
|
||||
fetch(new URL(path, siteRoot())).then((response) => (response.ok ? response.json() : Promise.reject(response.status)));
|
||||
|
||||
const formatSize = (bytes: number) =>
|
||||
bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB`
|
||||
: bytes >= 1024 ** 2 ? `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
||||
: bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB`
|
||||
: `${bytes} B`;
|
||||
|
||||
const code = (text: string) => {
|
||||
const element = document.createElement('code');
|
||||
element.textContent = text;
|
||||
return element;
|
||||
};
|
||||
|
||||
const link = (url: string, ...content: (Node | string)[]) => {
|
||||
const element = document.createElement('a');
|
||||
element.href = new URL(url, siteRoot()).href;
|
||||
element.append(...content);
|
||||
return element;
|
||||
};
|
||||
|
||||
const paragraph = (...content: (Node | string)[]) => {
|
||||
const element = document.createElement('p');
|
||||
element.append(...content);
|
||||
return element;
|
||||
};
|
||||
|
||||
const strong = (text: string) => {
|
||||
const element = document.createElement('strong');
|
||||
element.textContent = text;
|
||||
return element;
|
||||
};
|
||||
|
||||
// The card a collection match points at, read from its manufacturer's file
|
||||
// of api/v1/systems/: the path in the collection and the size.
|
||||
const heldFile = async (item: Item & { sha1?: string }): Promise<Held | undefined> => {
|
||||
const slug = /^systems\/([^/]+)\//.exec(item.url)?.[1];
|
||||
if (!slug || !item.sha1) return undefined;
|
||||
const data = await json(`api/v1/systems/${slug}.json`);
|
||||
for (const files of Object.values<Held[]>(data.items?.[0]?.consoles ?? {})) {
|
||||
const found = files.find((file) => file.sha1 === item.sha1);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 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, value: string): Promise<Declared | undefined> => {
|
||||
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(value);
|
||||
return files.find((entry) => holds(entry) && entry.name === item.name) ?? files.find(holds);
|
||||
};
|
||||
|
||||
const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) => {
|
||||
const table = document.createElement('table');
|
||||
const head = table.createTHead().insertRow();
|
||||
for (const label of ['Setup', 'Name as declared', 'Destination', 'Hash matched', 'Required']) {
|
||||
const cell = document.createElement('th');
|
||||
cell.scope = 'col';
|
||||
cell.textContent = label;
|
||||
head.append(cell);
|
||||
}
|
||||
const body = table.createTBody();
|
||||
for (const { item, entry, setup } of rows) {
|
||||
const row = body.insertRow();
|
||||
const name = entry?.name ?? item.name ?? '';
|
||||
// EmuDeck lists a hash with no file name: any file that has it passes.
|
||||
const named = name && !name.includes(':');
|
||||
const destination = entry?.destination;
|
||||
const cells: (Node | string)[] = [
|
||||
link(item.url, setup),
|
||||
named ? code(name) : 'Any name',
|
||||
destination ? code(destination) : '-',
|
||||
item.algorithm.toUpperCase(),
|
||||
entry?.required === undefined ? '-' : entry.required ? 'Yes' : 'No',
|
||||
];
|
||||
cells.forEach((content, column) => {
|
||||
const cell = row.insertCell();
|
||||
cell.dataset.label = head.cells[column].textContent!;
|
||||
cell.append(content);
|
||||
});
|
||||
}
|
||||
table.dataset.rbStack = 'true';
|
||||
return table;
|
||||
};
|
||||
|
||||
// The answer first: which file this is and where the collection holds it,
|
||||
// then the setups that list it (where it goes, whether it is required),
|
||||
// then the emulators that read it.
|
||||
const renderMatches = async (status: HTMLElement, result: HTMLElement, value: string, items: Item[]) => {
|
||||
const byLayer = (layer: string) => items.filter((item) => item.layer === layer);
|
||||
const held = byLayer('collection');
|
||||
const platforms = byLayer('platform');
|
||||
const [files, entries, names] = await Promise.all([
|
||||
Promise.all(held.map((item) => heldFile(item).catch(() => undefined))),
|
||||
Promise.all(platforms.map((item) => declaredEntry(item, value).catch(() => undefined))),
|
||||
json('data/picker/index.json').catch(() => []) as Promise<{ platform: string; name: string }[]>,
|
||||
]);
|
||||
const setupName = new Map(names.map((entry) => [entry.platform, entry.name]));
|
||||
status.textContent = `${items.length.toLocaleString('en')} ${items.length === 1 ? 'match' : 'matches'} for ${HASH_LENGTHS[value.length]} ${value}.`;
|
||||
const blocks: Node[] = [];
|
||||
held.forEach((item, index) => {
|
||||
const file = files[index];
|
||||
const name = file?.name ?? item.name ?? value;
|
||||
blocks.push(
|
||||
file
|
||||
? paragraph('This is ', code(name), ', held in the collection at ', code(file.path), '.')
|
||||
: paragraph('This is ', code(name), ', held in the collection.'),
|
||||
paragraph(
|
||||
...(file ? ['SHA1 ', code(file.sha1), `, ${formatSize(file.size)}. `] : []),
|
||||
link(item.url, 'Open its card'),
|
||||
'.',
|
||||
),
|
||||
);
|
||||
});
|
||||
if (!held.length) blocks.push(paragraph('No file in the collection has this hash.'));
|
||||
const shipped = byLayer('data-directory');
|
||||
if (shipped.length) {
|
||||
const list = document.createElement('ul');
|
||||
for (const item of found) {
|
||||
for (const item of shipped) {
|
||||
const row = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
link.href = new URL(item.url, siteRoot()).href;
|
||||
const code = document.createElement('code');
|
||||
code.textContent = item.name || item.value;
|
||||
link.append(code);
|
||||
row.append(link);
|
||||
if (item.owner) row.append(` (${item.owner})`);
|
||||
row.append(link(item.url, code(item.name || item.value)));
|
||||
list.append(row);
|
||||
}
|
||||
status.append(title, list);
|
||||
blocks.push(paragraph(strong(LAYER_LABELS['data-directory'])), list);
|
||||
}
|
||||
if (platforms.length) {
|
||||
const owners = new Set(platforms.map((item) => item.owner));
|
||||
blocks.push(
|
||||
paragraph(strong(`Setups that list it (${owners.size})`)),
|
||||
setupTable(platforms.map((item, index) => ({
|
||||
item,
|
||||
entry: entries[index],
|
||||
setup: setupName.get(item.owner ?? '') ?? item.owner ?? '',
|
||||
}))),
|
||||
);
|
||||
}
|
||||
const readers = byLayer('emulator');
|
||||
if (readers.length) {
|
||||
const list = document.createElement('ul');
|
||||
for (const item of readers) {
|
||||
const row = document.createElement('li');
|
||||
row.append(link(item.url, item.owner ?? item.name ?? ''));
|
||||
if (item.name && item.name !== held[0]?.name) row.append(' as ', code(item.name));
|
||||
list.append(row);
|
||||
}
|
||||
blocks.push(paragraph(strong(`${LAYER_LABELS.emulator} (${readers.length})`)), list);
|
||||
}
|
||||
result.replaceChildren(...blocks);
|
||||
};
|
||||
|
||||
// files/?sha1=<sha1> is a file's content address and opens its card
|
||||
@@ -194,6 +333,9 @@ const renderMatches = (status: HTMLElement, value: string, items: Item[]) => {
|
||||
const resolveFileAddress = () => {
|
||||
const status = document.getElementById('rb-file-lookup-status');
|
||||
if (!status) return;
|
||||
const result = document.createElement('div');
|
||||
result.id = 'rb-file-lookup-result';
|
||||
status.after(result);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const permalink = params.has('sha1');
|
||||
const value = normalizeHash(params.get('sha1') || params.get('hash') || '');
|
||||
@@ -214,7 +356,7 @@ const resolveFileAddress = () => {
|
||||
if (permalink && new Set(cards.map((item) => item.url)).size === 1) {
|
||||
location.replace(new URL(cards[0].url, siteRoot()));
|
||||
} else if (matches.length) {
|
||||
renderMatches(status, prefix ? matches[0].value : value, prefix ? cards : matches);
|
||||
return renderMatches(status, result, prefix ? matches[0].value : value, prefix ? cards : matches);
|
||||
} else {
|
||||
status.textContent = 'No file the site knows has this hash.';
|
||||
}
|
||||
@@ -240,8 +382,65 @@ const followMovedCard = () => {
|
||||
if (match && !target) location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
|
||||
};
|
||||
|
||||
// The site search holds no hashes: Enter on one opens the lookup.
|
||||
// The site search holds no hashes. A hash typed in it gets "Look up this
|
||||
// hash" as its first result, and Enter opens the lookup.
|
||||
const hashLookupRow = (drawer: Element, value: string) => {
|
||||
let row = drawer.querySelector<HTMLElement>(':scope > .rb-search-hash');
|
||||
if (!isHash(value)) {
|
||||
row?.remove();
|
||||
return;
|
||||
}
|
||||
if (!row) {
|
||||
row = document.createElement('p');
|
||||
row.className = 'rb-search-hash';
|
||||
drawer.prepend(row);
|
||||
}
|
||||
const target = new URL(`files/?hash=${value}`, siteRoot()).href;
|
||||
if (row.querySelector('a')?.href === target) return;
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = target;
|
||||
anchor.append('Look up this hash');
|
||||
row.replaceChildren(anchor, ` ${HASH_LENGTHS[value.length]} `, code(value));
|
||||
};
|
||||
|
||||
// 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` field of a system page weighs its file names in the ranking
|
||||
// and is 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 tidyResults = (root: Element) => {
|
||||
for (const title of root.querySelectorAll<HTMLElement>('.pagefind-ui__result-nested > .pagefind-ui__result-title')) {
|
||||
title.hidden = !!title.querySelector('a')?.getAttribute('href')?.endsWith('#_top');
|
||||
}
|
||||
for (const tags of root.querySelectorAll<HTMLElement>('.pagefind-ui__result-tags')) {
|
||||
let shown = 0;
|
||||
for (const tag of tags.querySelectorAll<HTMLElement>('.pagefind-ui__result-tag')) {
|
||||
tag.hidden = tag.dataset.pagefindUiMeta === 'files';
|
||||
if (!tag.hidden) shown += 1;
|
||||
}
|
||||
tags.hidden = shown === 0;
|
||||
}
|
||||
};
|
||||
|
||||
const hashSearch = () => {
|
||||
const root = document.getElementById('starlight__search');
|
||||
if (root) {
|
||||
const refresh = () => {
|
||||
tidyResults(root);
|
||||
const input = root.querySelector<HTMLInputElement>('input');
|
||||
const drawer = root.querySelector('.pagefind-ui__drawer');
|
||||
if (input && drawer) hashLookupRow(drawer, normalizeHash(input.value));
|
||||
};
|
||||
new MutationObserver(refresh).observe(root, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['href', 'data-pagefind-ui-meta'],
|
||||
});
|
||||
root.addEventListener('input', refresh);
|
||||
}
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
|
||||
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
|
||||
Reference in new issue
Block a user