diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index bd41ec6c..eb18bdd7 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -3295,7 +3295,7 @@ def _system_file_card(
for label, value in (("SHA1", sha1_full), ("MD5", md5_full),
("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))):
if value and value != "unknown":
- lines.append(f'- {label}: {_code(value)}')
+ lines.append(f'- {label}: {markup.hash_value(value)}')
if emus:
lines.append(f"- Emulators: {', '.join(_emulator_links(sha1_full, consumers))}")
provenance = f.get("provenance", {})
@@ -3420,8 +3420,11 @@ def _card_title(text: str, anchor: str, searchable: bool = True) -> str:
# would add the engine's anchor link and icon to every card.
title = re.sub(r"`([^`]+)`", lambda m: f"{html.escape(m[1], quote=False)}", text)
ident = f' id="{anchor}"' if anchor else ""
- ignore = "" if searchable else f" {markup.search_exclude()}"
- return f'
{title}
'
+ # The card is the file's own page in all but name: its title weighs
+ # as a page title does, so a file name typed in the search finds
+ # the card before a guide that cites it as an example.
+ weight = ' data-pagefind-weight="7"' if searchable else f" {markup.search_exclude()}"
+ return f'{title}
'
attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title"
if not searchable:
attributes += " data-search-exclude"
@@ -3873,7 +3876,7 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]:
)
if archive:
badges.append(
- f'in {archive}'
+ f'in {archive}'
)
if ftype and ftype != "bios":
badges.append(
@@ -4460,8 +4463,14 @@ def _render_structured_blocks(profile: dict) -> list[str]:
body: list[str] = []
_render_yaml_value(body, val, indent=0,
field_text=_structured_field_text(profile))
- lines.extend(markup.details(
- "abstract", label, [_escape_tags(line) for line in body], opened=True))
+ block = markup.details(
+ "abstract", label, [_escape_tags(line) for line in body], opened=True)
+ # A block listing hashes is the lookup's, as a card's hash lines are:
+ # izapple2's ROM table put "md5" 59 times in the search, above the
+ # page that explains MD5 checks.
+ if any(re.search(r"\b[0-9a-f]{32,64}\b", line) for line in body):
+ block = search_excluded(block)
+ lines.extend(block)
return lines
@@ -4476,7 +4485,7 @@ def _hash_spans(value: str | list) -> str:
from hashing import parse_hash_list # noqa: PLC0415
return ", ".join(
- f'{_code(v)}' for v in parse_hash_list(value)
+ markup.hash_value(v) for v in parse_hash_list(value)
)
diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py
index 14362806..f404a2fd 100644
--- a/scripts/sitemarkup.py
+++ b/scripts/sitemarkup.py
@@ -153,6 +153,15 @@ def image(alt: str, src: str, **attributes: str) -> str:
return f'
'
+def hash_value(value: object) -> str:
+ """A hash printed whole, in the hash style. One element under Starlight:
+ a span around a code span was 1,400 extra elements on a page of cards."""
+ text = str(value).replace("`", "'")
+ if _engine == "mkdocs":
+ return f'`{text}`'
+ return f'{html.escape(text, quote=False)}'
+
+
def block_open(tag: str, attributes: str = "") -> str:
"""The opening tag of an HTML block whose content is Markdown."""
attributes = f" {attributes.strip()}" if attributes.strip() else ""
diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py
index 324ae18c..b146ad0f 100644
--- a/scripts/sitestarlight.py
+++ b/scripts/sitestarlight.py
@@ -369,6 +369,12 @@ def finalize_page(
body = f'\n\n' + body
if rel not in ("wiki/glossary.md", "wiki/reference-terms.md"):
body = add_tooltips(body, terms)
+ # A badge is a status (required, missing, MD5), not a word a reader
+ # searches for: 141 "missing" badges put an emulator page above the
+ # troubleshooting page for "missing bios". The archive a file sits in
+ # is a name, and stays: it is how "neogeo.zip" finds the cores reading it.
+ body = re.sub(r' {
};
// A table that overflows is a named stop in the tab order, so a keyboard
-// can scroll it (WCAG 2.1.1). Checked again when a closed block opens.
-const markScrollable = (table: HTMLTableElement) => {
+// can scroll it (WCAG 2.1.1). Checked again when a closed block opens. All
+// widths are read first and the attributes written after, so a page of
+// twenty tables is laid out once, not twenty times.
+const markScrollable = (tables: HTMLTableElement[]) => {
+ const overflowing = tables.map((table) => table.scrollWidth > table.clientWidth + 1);
+ tables.forEach((table, index) => setScrollable(table, overflowing[index]));
+};
+
+const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
const heading = table.dataset.rbHeading ?? 'Data table';
- if (table.scrollWidth > table.clientWidth + 1) {
+ if (overflows) {
table.tabIndex = 0;
table.setAttribute('role', 'region');
table.setAttribute('aria-label', `${heading}, scrollable table`);
@@ -79,8 +86,10 @@ const filterTable = (table: HTMLTableElement, index: number, heading: string) =>
count.textContent = `${rows.length} rows`;
controls.append(label, input, count);
table.before(controls);
- const searchable = rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() }));
+ // Each row's text is read on the first keystroke, not on every page load.
+ let searchable: { row: HTMLTableRowElement; text: string }[] | null = null;
input.addEventListener('input', () => {
+ searchable ??= rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() }));
const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
let visible = 0;
for (const entry of searchable) {
@@ -92,7 +101,8 @@ const filterTable = (table: HTMLTableElement, index: number, heading: string) =>
};
const enhanceTables = () => {
- document.querySelectorAll('.sl-markdown-content table').forEach((table, index) => {
+ const tables = [...document.querySelectorAll('.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;
@@ -103,18 +113,29 @@ const enhanceTables = () => {
table.prepend(caption);
}
stackActionTable(table);
- markScrollable(table);
- filterTable(table, index, heading);
- });
- const recheck = () => document.querySelectorAll('.sl-markdown-content table').forEach(markScrollable);
+ filterTable(table, tables.indexOf(table), heading);
+ };
+ // A table is set up and measured when it nears the screen. Doing all of
+ // them at load relaid out every row of a 3,800-row page after its first
+ // paint. One that appears later (a block opened, a tab chosen) is handled
+ // then.
+ const near = new Set();
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const shown = entries.filter((entry) => entry.isIntersecting).map((entry) => entry.target as HTMLTableElement);
+ for (const table of shown) {
+ if (!near.has(table)) enhance(table);
+ near.add(table);
+ }
+ if (shown.length) requestAnimationFrame(() => markScrollable(shown));
+ },
+ { rootMargin: '400px 0px' },
+ );
+ tables.forEach((table) => observer.observe(table));
let timer = 0;
addEventListener('resize', () => {
clearTimeout(timer);
- timer = window.setTimeout(recheck, 150);
- });
- document.addEventListener('toggle', recheck, true);
- document.addEventListener('click', (event) => {
- if ((event.target as Element).closest?.('[role="tab"]')) setTimeout(recheck, 0);
+ timer = window.setTimeout(() => markScrollable([...near]), 150);
});
};
diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css
index e8385889..662d6298 100644
--- a/website/src/styles/retrobios.css
+++ b/website/src/styles/retrobios.css
@@ -216,7 +216,16 @@
background: #fff;
}
.sl-markdown-content img[src*='assets/logos/'] { padding: 2px; border-radius: 2px; background: #fff; }
-.sl-markdown-content :is(h2, h3) img { display: inline; vertical-align: middle; margin: 0 0.4rem 0 0; }
+/* A system icon beside a console heading holds its box before it loads:
+ it pushed every card below it down once it arrived. */
+.sl-markdown-content :is(h2, h3) img {
+ display: inline;
+ width: 24px;
+ height: 24px;
+ object-fit: contain;
+ vertical-align: middle;
+ margin: 0 0.4rem 0 0;
+}
/* Card grids */
.rb-cards {
diff --git a/website/tools/lighthouse.py b/website/tools/lighthouse.py
index 224f9abd..3c8bbfce 100644
--- a/website/tools/lighthouse.py
+++ b/website/tools/lighthouse.py
@@ -1,7 +1,11 @@
#!/usr/bin/env python3
"""Lighthouse mobile (default form factor) on a list of pages, one summary line each.
- python lighthouse.py = ... -- ...
+ python lighthouse.py [--performance-only] = ... -- ...
+
+With all four categories, Lighthouse runs its accessibility checks inside the
+page during the same load, and those tasks count toward Total Blocking Time
+on a page of 15,000 elements. --performance-only measures the page alone.
Needs `npx lighthouse` and Chrome at /usr/bin/google-chrome.
"""
@@ -18,6 +22,10 @@ CATEGORIES = ("performance", "accessibility", "best-practices", "seo")
def main() -> int:
args = sys.argv[1:]
+ categories = CATEGORIES
+ if args[0] == "--performance-only":
+ args.pop(0)
+ categories = ("performance",)
out = Path(args.pop(0))
split = args.index("--")
engines = dict(item.split("=", 1) for item in args[:split])
@@ -34,7 +42,7 @@ def main() -> int:
["npx", "--prefix", str(Path(__file__).resolve().parent.parent), "lighthouse", base + page,
"--quiet", "--output=json", f"--output-path={report.resolve()}",
"--chrome-flags=--headless=new --no-sandbox",
- f"--only-categories={','.join(CATEGORIES)}"],
+ f"--only-categories={','.join(categories)}"],
env=env, cwd=out, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
data = json.loads(report.read_text())
cats, audits = data["categories"], data["audits"]
@@ -42,12 +50,13 @@ def main() -> int:
if a.get("score") is not None and a["score"] < 1 and a.get("scoreDisplayMode") == "binary"]
print(json.dumps({
"engine": name, "page": page or "/",
- **{c: round(cats[c]["score"] * 100) for c in CATEGORIES},
+ **{c: round(cats[c]["score"] * 100) for c in categories},
"lcp": audits["largest-contentful-paint"]["displayValue"],
"tbt": audits["total-blocking-time"]["displayValue"],
"cls": audits["cumulative-layout-shift"]["displayValue"],
"bytes": audits["total-byte-weight"]["displayValue"],
- "dom": audits["dom-size"]["displayValue"],
+ # Lighthouse 13 moved the DOM size audit into an insight.
+ "dom": (audits.get("dom-size") or audits.get("dom-size-insight") or {}).get("displayValue", ""),
"failed": failed,
}))
return 0
diff --git a/website/tools/search_gate.py b/website/tools/search_gate.py
new file mode 100644
index 00000000..bc068d61
--- /dev/null
+++ b/website/tools/search_gate.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""The search gate: ten queries a visitor types, and the pages that answer them.
+
+Runs measure.mjs against a served build and counts a query as answered when
+one of its expected pages is in the first three results of the site's own
+search box.
+
+ python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+TOOLS = Path(__file__).resolve().parent
+
+# Query, then the pages that answer it (path prefixes under the site).
+QUERIES: dict[str, tuple[str, ...]] = {
+ "playstation bios": ("systems/sony/",),
+ # The file itself: its card on the system page, or a core that reads it.
+ "scph5501": ("systems/sony/", "emulators/beetle_psx/", "emulators/duckstation/",
+ "emulators/duckstation-legacy/", "emulators/pcsx1/", "emulators/swanstation/",
+ "emulators/ares/", "emulators/pcsx_rearmed/", "files/"),
+ "batocera folder": ("platforms/batocera/",),
+ "neogeo.zip": ("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"),
+ "duckstation": ("emulators/duckstation/", "emulators/duckstation-legacy/"),
+ "steam deck": ("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"),
+ "missing bios": ("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"),
+ "md5": ("wiki/verification-modes/",),
+ "retroarch system folder": ("platforms/retroarch/",),
+ "knulli": ("platforms/knulli/",),
+}
+
+
+def main() -> int:
+ base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321/retrobios/"
+ root = "/" + base.split("/", 3)[3]
+ run = subprocess.run(
+ ["node", str(TOOLS / "measure.mjs"), "search", base, "starlight-ui", *QUERIES],
+ capture_output=True, text=True, check=True,
+ )
+ answered = 0
+ for line in run.stdout.splitlines():
+ result = json.loads(line)
+ if "query" not in result:
+ print(f"first search: {result['first_search_kb']} KB in {result['first_search_requests']} requests")
+ continue
+ top = [entry.split(" | ")[1][len(root):] for entry in result["top"][:3]]
+ hit = any(page.startswith(QUERIES[result["query"]]) for page in top)
+ answered += hit
+ print(f"{'ok ' if hit else 'MISS'} {result['query']!r}: {', '.join(top)}")
+ print(f"{answered}/{len(QUERIES)} queries answered in the top 3")
+ return 0 if answered >= 9 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/website/tools/search_scores.mjs b/website/tools/search_scores.mjs
new file mode 100644
index 00000000..8e3f9a69
--- /dev/null
+++ b/website/tools/search_scores.mjs
@@ -0,0 +1,23 @@
+// Raw Pagefind results with their scores, to see why a page ranks where it does.
+// node search_scores.mjs [ranking json]
+import { chromium } from 'playwright-core';
+
+const [base, query, ranking] = process.argv.slice(2);
+const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
+const page = await browser.newPage();
+await page.goto(base, { waitUntil: 'networkidle' });
+const rows = await page.evaluate(async ({ root, query, ranking }) => {
+ const pagefind = await import(`${root}pagefind/pagefind.js`);
+ if (ranking) await pagefind.options({ ranking: JSON.parse(ranking) });
+ const search = await pagefind.search(query);
+ const top = search.results.slice(0, 8);
+ const data = await Promise.all(top.map((result) => result.data()));
+ return top.map((result, index) => ({
+ score: result.score.toFixed(3),
+ url: data[index].url,
+ words: data[index].word_count,
+ hits: result.words.length,
+ }));
+}, { root: base, query, ranking });
+for (const row of rows) console.log(row.score, row.words, row.hits, row.url);
+await browser.close();