chore: merge round 5 component fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-24 00:57:06 +02:00
commit 518d4670db
7 files changed
+384 -21

No files matched your search

+3 -2
View File
@@ -225,8 +225,9 @@ class Picker(unittest.TestCase):
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)
# The size gap is the release against the current list, from the
# release record reduced to the registry's release_pack setups.
self.assertIn("import release from '../generated/release-advertised.json';", source)
self.assertIn("It was built from the list of that day.", source)
+227
View File
@@ -0,0 +1,227 @@
"""Round 5, package 2: components and runtime.
Source checks always run. Checks on the built site read website/dist and
skip when no build is there. The rules written in a build plugin run under
node and skip without it.
"""
from __future__ import annotations
import html
import json
import re
import shutil
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from common import list_release_pack_platforms # noqa: E402
WEBSITE = REPO_ROOT / "website"
DIST = WEBSITE / "dist"
COMPONENTS = WEBSITE / "src" / "components"
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
PLUGINS = WEBSITE / "src" / "plugins"
SHORT_CODE = PLUGINS / "rehype-short-code.mjs"
STACK_TABLES = PLUGINS / "rehype-stack-tables.mjs"
ENTITY = re.compile(r"&(?:#\d+|#x[0-9a-f]+|[a-z]+);", re.I)
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 _node(script: str) -> str:
node = shutil.which("node")
if not node:
raise unittest.SkipTest("node is missing")
result = subprocess.run(
[node, "--input-type=module", "-e", script],
capture_output=True, text=True, check=True, cwd=WEBSITE,
env={"NODE_DISABLE_COMPILE_CACHE": "1", "PATH": str(Path(node).parent)},
)
return result.stdout
def _text(page: str) -> str:
"""A built page's visible words: tags dropped, entities read."""
return html.unescape(re.sub(r"<[^>]+>", "", page))
def _picker_packs() -> set[str]:
"""The setups the built picker offers a release ZIP line for."""
page = _built("which-pack")
match = re.search(r'data-release="([^"]*)"', page)
if not match:
raise AssertionError("which-pack has no picker release data")
return set(json.loads(html.unescape(match[1]))["packs"])
class PickerFollowsTheRegistry(unittest.TestCase):
"""2.1: the picker said "The ZIP of release v2026.10.10 holds 5,804
files" for ROCKNIX while its setup page and get/ page said "No ZIP for
ROCKNIX" (N2, J5). The picker read the raw release.json."""
def test_source(self):
source = _read(COMPONENTS / "FilePicker.astro")
self.assertIn("import release from '../generated/release-advertised.json';", source)
self.assertNotIn("release.json'", source)
def test_picker_offers_only_registry_packs(self):
offered = _picker_packs()
self.assertTrue(offered)
released = set(list_release_pack_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
self.assertLessEqual(offered, released)
def test_picker_setup_and_get_pages_agree(self):
offered = _picker_packs()
setups = json.loads(_read(DIST / "data" / "picker" / "index.json"))
self.assertTrue(setups)
for entry in setups:
name = entry["platform"]
with self.subTest(platform=name):
get_page = _text(_built(f"get/{name}"))
setup_page = _text(_built(f"platforms/{name}"))
no_zip = f"No ZIP for {entry['name']}."
self.assertEqual(name in offered, no_zip not in get_page)
self.assertEqual(name in offered, no_zip not in setup_page)
class PathsBreakAtSeparators(unittest.TestCase):
"""2.2: `~/.var/app/org.libretro.RetroArch/config/r` then
`etroarch/system/` on platforms/retroarch/ at 390 px (V8)."""
def test_the_rule(self):
uri = json.dumps(SHORT_CODE.as_uri())
out = _node(
f"import {{ markRawCode, withBreaks, breaksAtSeparators }} from {uri};"
"console.log(JSON.stringify(["
"markRawCode('<td><code>~/.config/retroarch/system/aaaaaaaaaaa/</code> <code>bios/a.bin</code></td>'),"
"markRawCode('<td><code class=\"rb-path\">C:\\\\RetroArch-Win64\\\\system\\\\aaaaaaaaaaaaa</code></td>'),"
"withBreaks('a/b/'), breaksAtSeparators('x'.repeat(31), false),"
"breaksAtSeparators('a/' + 'x'.repeat(30), true), breaksAtSeparators('a/b', false)]));"
)
self.assertEqual(json.loads(out), [
'<td><code>~/<wbr>.config/<wbr>retroarch/<wbr>system/<wbr>aaaaaaaaaaa/</code>'
' <code class="rb-short">bios/a.bin</code></td>',
'<td><code class="rb-path">C:\\<wbr>RetroArch-Win64\\<wbr>system\\<wbr>aaaaaaaaaaaaa</code></td>',
"a/<wbr>b/", False, False, False,
])
def test_built_page(self):
page = _built("platforms/retroarch")
self.assertIn("~/<wbr>.var/<wbr>app/<wbr>org.libretro.RetroArch/<wbr>config/<wbr>retroarch/<wbr>system/", page)
def test_the_page_budget(self):
"""Each `wbr` is an element: bluemsx held 3,097 of them on 9,369
elements against a budget of 10,000. The build spends BREAK_BUDGET
per page, the runtime writes the others."""
uri = json.dumps(SHORT_CODE.as_uri())
out = _node(
f"import {{ markRawCode, BREAK_BUDGET }} from {uri};"
"const room = { left: 3 };"
"const path = '<code>aaaaaaaaaa/bbbbbbbbbb/cccccccccc/d</code>';"
"console.log(JSON.stringify([BREAK_BUDGET, markRawCode(path, room), markRawCode(path, room), room.left]));"
)
budget, first, second, left = json.loads(out)
self.assertEqual(first, "<code>aaaaaaaaaa/<wbr>bbbbbbbbbb/<wbr>cccccccccc/<wbr>d</code>")
self.assertEqual(second, "<code>aaaaaaaaaa/bbbbbbbbbb/cccccccccc/d</code>")
self.assertEqual(left, 0)
if not DIST.is_dir():
self.skipTest("no built site")
# Link text the generator breaks itself (a forge address) is not code.
code = re.compile(r"<code(?:\s[^>]*)?>(?:[^<]|<wbr>)*</code>")
over = [str(path.relative_to(DIST)) for path in DIST.rglob("index.html")
if sum(found.count("<wbr>") for found in code.findall(_read(path))) > budget]
self.assertEqual(over, [])
def test_the_runtime_writes_the_rest(self):
source = _read(SITE_TS)
self.assertIn("const LONG_CODE = 30;", source)
self.assertIn("const SEPARATORS = /([/\\\\])(?!$)/g;", source)
self.assertIn("if (unbrokenPath(code)) observer.observe(code);", source)
self.assertIn("\nbreakPaths();\n", source)
plugin = _read(SHORT_CODE)
self.assertIn("export const SHORT_CODE = 30;", plugin)
self.assertIn("const SEPARATORS = /([/\\\\])(?!$)/g;", plugin)
def test_every_long_path_breaks_at_run_time(self):
"""Inline code over 30 characters holding a separator: the build
broke it, or the runtime rule picks it (all text, no `wbr`)."""
if not DIST.is_dir():
self.skipTest("no built site")
unbroken = []
for path in sorted(DIST.rglob("index.html")):
page = _read(path)
if 'http-equiv="refresh"' in page or "sl-markdown-content" not in page:
continue
body = re.sub(r"<pre\b.*?</pre>", "", page[page.index("sl-markdown-content"):], flags=re.S)
for text in re.findall(r"<code(?:\s[^>]*)?>((?:[^<]|<(?!/code>))*)</code>", body):
plain = ENTITY.sub("_", re.sub(r"<wbr>", "", text))
if len(plain) > 30 and re.search(r"[/\\]", plain[:-1]) \
and "<wbr>" not in text and "<" in text:
unbroken.append(f"{path.relative_to(DIST)}: {text}")
self.assertEqual(unbroken, [])
def test_crumb_labels_in_a_span(self):
source = _read(COMPONENTS / "PageTitle.astro")
self.assertIn("<a href={crumb.href}><span>{crumb.label}</span></a> : <span>{crumb.label}</span>", source)
crumbs = _built("platforms/retroarch")
crumbs = crumbs[crumbs.index('class="rb-breadcrumbs'):]
crumbs = crumbs[:crumbs.index("</ol>")]
items = re.findall(r"<li\b[^>]*>(.*?)</li>", crumbs)
self.assertTrue(items)
for item in items:
self.assertRegex(item, r"^(?:<a\b[^>]*>)?<span\b[^>]*>[^<]+</span>")
class ShortStackedLabels(unittest.TestCase):
"""2.3: "Name as declared" and "On its BIOS list (destinations)" wrap
to two lines in the stacked label column (V9)."""
def test_the_rule(self):
uri = json.dumps(STACK_TABLES.as_uri())
out = _node(
f"import {{ stackedLabel }} from {uri};"
"console.log(JSON.stringify([stackedLabel('Declared as', 'Name as declared'),"
"stackedLabel(undefined, 'Setup'), stackedLabel(' ', 'Setup')]));"
)
self.assertEqual(json.loads(out), ["Declared as", "Setup", "Setup"])
def test_plugin_and_runtime_read_the_title(self):
plugin = _read(STACK_TABLES)
self.assertIn("stackedLabel(cell.properties?.title, labels[column])", plugin)
self.assertIn("ctx.setProperty(cell, 'dataLabel', stacked[column])", plugin)
runtime = _read(SITE_TS)
self.assertIn("cell.getAttribute('title')?.trim() || (cell.textContent?.trim() ?? '')", runtime)
self.assertIn("[...table.querySelectorAll('thead th')].map(stackedLabel)", runtime)
self.assertIn("'Name as declared': 'Declared as'", runtime)
self.assertIn("cell.dataset.label = stackedLabel(head.cells[column]);", runtime)
class LookupShowsTheDeclaredHash(unittest.TestCase):
"""2.4: with the CRC32 pasted, Batocera's row read "Hash matched: CRC32",
though Batocera declares an MD5 (J9, N10)."""
def test_source(self):
source = _read(SITE_TS)
self.assertIn("declared?: string;", source)
self.assertIn("const declaredHash = (item: Item) => (item.declared || item.algorithm).toUpperCase();", source)
table = source[source.index("const setupTable"):source.index("const distinct")]
self.assertIn("declaredHash(item),", table)
self.assertNotIn("item.algorithm", table)
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -4,19 +4,23 @@
// 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';
// The list follows the current data. release-advertised.json says what the
// published ZIP of each setup holds, so the page can say when the two
// differ. generate_site.py writes it from release_record.load_advertised():
// the release record reduced to the setups the registry marks release_pack,
// the rule the setup pages and get/ pages follow. A setup the release still
// holds a pack for but the registry no longer marks gets no ZIP line here.
import release from '../generated/release-advertised.json';
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
const packs = Object.fromEntries(
Object.values(release.packs).flatMap((pack) =>
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} data-release={JSON.stringify({ tag: release.tag, packs })}>
<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">
+5 -1
View File
@@ -8,6 +8,10 @@
// gets its initials in the same square, as its tile does (sitemarkup.py
// platform_tiles).
//
// Each crumb's label sits in a span: the item keeps its "/" (retrobios.css,
// `li { white-space: nowrap }`) and only the label wraps, so a long label
// never leaves the separator alone at the start of a line.
//
// 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.
@@ -53,7 +57,7 @@ const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platf
<nav class="rb-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>
<ol>
{crumbs.map((crumb) => (
<li>{crumb.href ? <a href={crumb.href}>{crumb.label}</a> : crumb.label}</li>
<li>{crumb.href ? <a href={crumb.href}><span>{crumb.label}</span></a> : <span>{crumb.label}</span>}</li>
))}
</ol>
</nav>
+67 -7
View File
@@ -4,7 +4,14 @@
// - Inline code of SHORT_CODE characters or fewer gets `rb-short` and never
// breaks: `--restore` split into `--` and `restore` on the home page at
// 1440 px, and `--list-` / `systems` in the installer's options table.
// Longer inline code (a path, a full command) still wraps.
// Longer inline code (a path, a full command) still wraps, and gets a
// `<wbr>` after each `/` and `\` so a path breaks at a separator:
// `~/.var/app/org.libretro.RetroArch/config/r` then `etroarch/system/` on a
// 390 px phone. The break adds no character to a copy. Each `wbr` is an
// element of the page, and a page of file cards holds thousands of paths
// (bluemsx: 3,097 breaks on 9,369 elements, against a budget of 10,000).
// So the build writes the first BREAK_BUDGET of a page, those of its first
// screens, and site.ts writes the others as their block nears the screen.
// - A table whose first column is "Option" gets `rb-options`, so its flags
// keep their column.
//
@@ -40,24 +47,71 @@ const firstHeader = (ctx, table) => {
// A code element is inline unless a `pre` holds it.
export const isShort = (text, inPre) => !inPre && text.length > 0 && text.length <= SHORT_CODE;
export const BREAK_BUDGET = 200;
const SEPARATOR = /[/\\]/;
const SEPARATORS = /([/\\])(?!$)/g;
// Inline code long enough to wrap, with a separator to wrap at.
export const breaksAtSeparators = (text, inPre) => !inPre && text.length > SHORT_CODE && SEPARATOR.test(text);
const escapeText = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// The text as HTML with a break after each separator but the last character.
export const withBreaks = (html) => html.replace(SEPARATORS, '$1<wbr>');
// How many breaks withBreaks() writes in the text.
export const breakCount = (text) => text.match(SEPARATORS)?.length ?? 0;
// Takes *count* breaks from the page's budget, when it still holds them.
const spend = (room, count) => {
if (count > room.left) return false;
room.left -= count;
return true;
};
// The cards and tables the generator writes as HTML reach this step as raw
// text: their code elements are marked by rewriting the tag. A block that
// holds a `pre` is left as written.
const RAW_CODE = /<code(\s[^>]*)?>([^<]*)<\/code>/g;
const ENTITY = /&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi;
export const markRawCode = (html) => {
export const markRawCode = (html, room = { left: Infinity }) => {
if (html.includes('<pre')) return html;
return html.replace(RAW_CODE, (whole, attributes = '', text) => {
if (/\bclass=/.test(attributes) || !isShort(text.replace(ENTITY, '_'), false)) return whole;
const plain = text.replace(ENTITY, '_');
if (breaksAtSeparators(plain, false)) {
return spend(room, breakCount(text)) ? `<code${attributes}>${withBreaks(text)}</code>` : whole;
}
if (/\bclass=/.test(attributes) || !isShort(plain, false)) return whole;
return `<code class="rb-short"${attributes}>${text}</code>`;
});
};
// A code element whose children are all text gets them back split, a `wbr`
// element after each separator. One already split is left alone.
const breakAtSeparators = (ctx, code, room) => {
const children = code.children ?? [];
if (!children.length || children.some((child) => child.type !== 'text')) return;
if (!spend(room, breakCount(children.map((child) => child.value).join('')))) return;
// split() with a capturing group puts each separator at an odd index.
const parts = children.map((child) => child.value).join('').split(SEPARATORS).flatMap((piece, index) => [
...(piece ? [{ type: 'text', value: piece }] : []),
...(index % 2 ? [{ type: 'element', tagName: 'wbr', properties: {}, children: [] }] : []),
]);
ctx.replaceNode(code, { type: 'element', tagName: 'code', properties: { ...(code.properties ?? {}) }, children: parts });
};
// The page's budget of breaks, kept with the page being built.
const breakRoom = (ctx) => {
ctx.data.rbBreaks ??= { left: BREAK_BUDGET };
return ctx.data.rbBreaks;
};
export default function shortCode() {
return {
name: 'rb-short-code',
raw(node, ctx) {
const room = breakRoom(ctx);
// Inline HTML in Markdown (a hash in a table cell) arrives in three
// nodes: the opening tag, the text, the closing tag.
const open = /^<code(\s[^>]*)?>$/.exec(node.value ?? '');
@@ -65,13 +119,17 @@ export default function shortCode() {
const siblings = ctx.parent(node)?.children ?? [];
const index = ctx.indexOf(node) ?? -1;
const [text, close] = [siblings[index + 1], siblings[index + 2]];
if (text?.type === 'text' && close?.type === 'raw' && close.value === '</code>'
&& !/\bclass=/.test(open[1] ?? '') && isShort(text.value, false)) {
if (text?.type !== 'text' || close?.type !== 'raw' || close.value !== '</code>') return;
if (breaksAtSeparators(text.value, false)) {
if (spend(room, breakCount(text.value))) {
ctx.replaceNode(text, { type: 'raw', value: withBreaks(escapeText(text.value)) });
}
} else if (!/\bclass=/.test(open[1] ?? '') && isShort(text.value, false)) {
ctx.replaceNode(node, { type: 'raw', value: `<code class="rb-short"${open[1] ?? ''}>` });
}
return;
}
const value = markRawCode(node.value ?? '');
const value = markRawCode(node.value ?? '', room);
if (value !== node.value) ctx.replaceNode(node, { type: 'raw', value });
},
element: [
@@ -80,7 +138,9 @@ export default function shortCode() {
visit(code, ctx) {
const parent = ctx.parent(code);
const inPre = parent?.type === 'element' && parent.tagName === 'pre';
if (isShort(ctx.textContent(code), inPre)) addClass(ctx, code, 'rb-short');
const text = ctx.textContent(code);
if (isShort(text, inPre)) addClass(ctx, code, 'rb-short');
else if (breaksAtSeparators(text, inPre)) breakAtSeparators(ctx, code, breakRoom(ctx));
},
},
{
+11 -2
View File
@@ -16,6 +16,10 @@
// so the row is wider than a phone at any length (NetherSX2's File,
// Required, Hash summary measured 604 px in a 358 px column).
//
// A stacked cell is labelled with its column name, or with the header's
// `title` when the table's writer gives one: "Name as declared" wrapped to two
// lines in the label column, its title "Declared as" does not.
//
// A table of RECORD_ROWS rows or more gets its filter box here, above it.
// Written by site.ts once the page had painted, the box pushed the table
// down (CLS 0.066 on gaps-files-to-source). site.ts only wires it.
@@ -101,6 +105,9 @@ const headingBefore = (ctx, table, title) => {
return title || 'this table';
};
// The label a stacked cell shows: the header's title, else its text.
export const stackedLabel = (title, text) => String(title ?? '').trim() || text;
const filterBox = ({ id, heading, labels, rows }) => ({
type: 'element',
tagName: 'div',
@@ -136,7 +143,9 @@ export default function stackTables() {
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 headers = elements(head, 'th');
const labels = headers.map((cell) => ctx.textContent(cell).trim());
const stacked = headers.map((cell, column) => stackedLabel(cell.properties?.title, labels[column]));
const rows = elements(body, 'tr');
const frontmatter = ctx.data.astro?.frontmatter ?? {};
if (rows.length >= RECORD_ROWS) {
@@ -153,7 +162,7 @@ export default function stackTables() {
if (kind === 'kv' || rows.length >= RECORD_ROWS) 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]);
if (stacked[column] && cell.properties?.dataLabel === undefined) ctx.setProperty(cell, 'dataLabel', stacked[column]);
});
}
},
+62 -4
View File
@@ -8,8 +8,11 @@
const HASH_LENGTHS: Record<number, string> = { 8: 'CRC32', 32: 'MD5', 40: 'SHA1', 64: 'SHA256' };
// One line of api/v1/lookup/. `system_name` is read when a record carries it.
// `algorithm` is the hash the value was found under, `declared` the hash
// type the setup or profile wrote for that entry.
type Item = {
algorithm: string;
declared?: string;
layer: string;
name?: string;
value: string;
@@ -111,10 +114,12 @@ const filterTable = (table: HTMLTableElement) => {
// A stacked table of many rows leaves its column names out of the HTML
// (rehype-stack-tables.mjs): each cell gets its own here, as the table nears
// the screen. The name sits in the left room every stacked cell keeps, so
// writing it moves nothing.
// writing it moves nothing. A header's `title` is its short stacked name.
const stackedLabel = (cell: Element) => cell.getAttribute('title')?.trim() || (cell.textContent?.trim() ?? '');
const labelCells = (table: HTMLTableElement) => {
if (table.dataset.rbStack !== 'true') return;
const labels = [...table.querySelectorAll('thead th')].map((cell) => cell.textContent?.trim() ?? '');
const labels = [...table.querySelectorAll('thead th')].map(stackedLabel);
for (const row of table.tBodies[0]?.rows ?? []) {
[...row.cells].forEach((cell, column) => {
if (labels[column] && !cell.dataset.label) cell.dataset.label = labels[column];
@@ -173,6 +178,49 @@ const enhanceTables = () => {
});
};
// Paths
// A long path breaks at its separators, never inside a word. The build
// writes the `wbr` of a page's first BREAK_BUDGET separators
// (rehype-short-code.mjs), since each is an element of the page. The others
// are written here, as their code nears the screen. Same rule: inline code
// over 30 characters, a break after each `/` and `\` but the last character.
const LONG_CODE = 30;
const SEPARATORS = /([/\\])(?!$)/g;
const SEPARATOR = /[/\\](?!$)/;
const unbrokenPath = (code: Element) =>
!code.closest('pre') &&
!code.querySelector('wbr') &&
[...code.childNodes].every((child) => child.nodeType === Node.TEXT_NODE) &&
(code.textContent ?? '').length > LONG_CODE &&
SEPARATOR.test(code.textContent ?? '');
const breakPath = (code: Element) => {
if (!unbrokenPath(code)) return;
const parts = (code.textContent ?? '').split(SEPARATORS).flatMap((piece, index) => [
...(piece ? [document.createTextNode(piece)] : []),
...(index % 2 ? [document.createElement('wbr')] : []),
]);
code.replaceChildren(...parts);
};
const breakPaths = () => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
breakPath(entry.target);
observer.unobserve(entry.target);
}
},
{ rootMargin: '400px 0px' },
);
const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 1));
idle(() => {
for (const code of document.querySelectorAll('.sl-markdown-content code')) {
if (unbrokenPath(code)) observer.observe(code);
}
});
};
// Hashing a file on this device: SHA1 and SHA256 by Web Crypto, MD5 and
// CRC32 here, since Web Crypto has neither. The bytes never leave the page.
@@ -381,6 +429,14 @@ const agreed = (values: (string | undefined)[]): string | undefined => {
// "system:md5", and any file that has it passes.
const anyName = (name: string) => !name || name.includes(':');
// The column name a stacked row shows where the full one wraps on a phone,
// as rehype-stack-tables.mjs reads a header's `title`.
const STACKED_LABELS: Record<string, string> = { 'Name as declared': 'Declared as' };
// "Hash matched" is the hash the setup's list writes, whatever was pasted:
// Batocera declares an MD5, so its row says MD5 when a CRC32 found it.
const declaredHash = (item: Item) => (item.declared || item.algorithm).toUpperCase();
const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) => {
const table = document.createElement('table');
const head = table.createTHead().insertRow();
@@ -388,6 +444,7 @@ const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) =>
const cell = document.createElement('th');
cell.scope = 'col';
cell.textContent = label;
if (STACKED_LABELS[label]) cell.title = STACKED_LABELS[label];
head.append(cell);
}
const body = table.createTBody();
@@ -399,12 +456,12 @@ const setupTable = (rows: { item: Item; entry?: Declared; setup: string }[]) =>
link(item.url, setup),
anyName(name) ? 'Any name' : code(name),
destination ? code(destination) : '-',
item.algorithm.toUpperCase(),
declaredHash(item),
entry?.required === undefined ? '-' : entry.required ? 'Yes' : 'No',
];
cells.forEach((content, column) => {
const cell = row.insertCell();
cell.dataset.label = head.cells[column].textContent!;
cell.dataset.label = stackedLabel(head.cells[column]);
cell.append(content);
});
}
@@ -892,6 +949,7 @@ const notFound = () => {
resolveFileAddress();
followMovedCard();
enhanceTables();
breakPaths();
hashSearch();
glossaryTerms();
osTabs();