mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
chore: merge round 5 component fixes
This commit is contained in:
commit
518d4670db
7 files changed
+384
-21
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user