mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-26 21:57:24 -05:00
feat: hash verdict, built filters, unbroken flags
Tables of four columns stack on a phone, and a long table's filter box is written at build time, which ends the layout shift on gaps-files-to-source. Find a file names its counts, its emulators and accepts md5=, crc32= and sha256=, hashes a chosen file on the device and points an unknown hash to contributing. A hash typed in the search folds the pages that only mention it. Words holding a hyphen in command blocks no longer break across lines.
This commit is contained in:
1 parent
e19c3f86c4
commit
b239be4984
7 files changed
+663
-100
No files matched your search
@@ -0,0 +1,96 @@
|
||||
// Command blocks wrap (astro.config.mjs, wrap: true) so the end of a
|
||||
// one-liner stays visible. A browser may also break a line after a hyphen,
|
||||
// which split `--platform` into `--` and `platform`, and `nintendo-64dd`
|
||||
// into `nintendo-` and `64dd`: a reader who types the command reads two
|
||||
// things. Each word that holds a hyphen is put in a span that does not
|
||||
// break inside. Only spans are added: the text, the copy button's
|
||||
// data-code and a selection copied by hand are unchanged.
|
||||
//
|
||||
// A word longer than LONGEST is left breakable: an address that long would
|
||||
// push the block wider than a phone.
|
||||
|
||||
export const LONGEST = 48;
|
||||
export const UNBROKEN = new RegExp(`(?<!\\S)(?=\\S*-)\\S{1,${LONGEST}}(?!\\S)`, 'g');
|
||||
const CLASS = 'rb-nb';
|
||||
|
||||
const textOf = (node) => (node.type === 'text' ? node.value : (node.children ?? []).map(textOf).join(''));
|
||||
|
||||
// A token span holds one text node: it can be cut in two, both halves
|
||||
// keeping its colours.
|
||||
const cuttable = (node) =>
|
||||
node.type === 'text' || (node.type === 'element' && node.children?.length === 1 && node.children[0].type === 'text');
|
||||
|
||||
const slice = (node, from, to) =>
|
||||
node.type === 'text'
|
||||
? { type: 'text', value: node.value.slice(from, to) }
|
||||
: { ...node, properties: { ...node.properties }, children: [{ type: 'text', value: node.children[0].value.slice(from, to) }] };
|
||||
|
||||
// The children of *container*, with every hyphenated word wrapped in one
|
||||
// unbreakable span. A word that crosses a node this cannot cut is left as is.
|
||||
export const unbreakWords = (container) => {
|
||||
const children = container.children ?? [];
|
||||
const text = children.map(textOf).join('');
|
||||
const ranges = [...text.matchAll(UNBROKEN)].map((match) => [match.index, match.index + match[0].length]);
|
||||
if (!ranges.length) return children;
|
||||
const cuts = [...new Set(ranges.flat())].sort((a, b) => a - b);
|
||||
const pieces = [];
|
||||
let position = 0;
|
||||
for (const child of children) {
|
||||
const end = position + textOf(child).length;
|
||||
const inner = cuts.filter((cut) => cut > position && cut < end);
|
||||
if (inner.length && cuttable(child)) {
|
||||
let start = position;
|
||||
for (const cut of [...inner, end]) {
|
||||
pieces.push({ node: slice(child, start - position, cut - position), from: start, to: cut });
|
||||
start = cut;
|
||||
}
|
||||
} else {
|
||||
pieces.push({ node: child, from: position, to: end });
|
||||
}
|
||||
position = end;
|
||||
}
|
||||
const ends = new Map(ranges);
|
||||
const out = [];
|
||||
for (let index = 0; index < pieces.length; ) {
|
||||
const end = ends.get(pieces[index].from);
|
||||
if (end !== undefined && pieces[index].to > pieces[index].from) {
|
||||
let last = index;
|
||||
while (last < pieces.length && pieces[last].to < end) last += 1;
|
||||
if (last < pieces.length && pieces[last].to === end) {
|
||||
out.push({
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: [CLASS] },
|
||||
children: pieces.slice(index, last + 1).map((piece) => piece.node),
|
||||
});
|
||||
index = last + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(pieces[index].node);
|
||||
index += 1;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// The element of a rendered line that holds its tokens (div.code), else
|
||||
// the line itself.
|
||||
const tokensOf = (line) => {
|
||||
for (const child of line.children ?? []) {
|
||||
if (child.type === 'element' && String(child.properties?.className ?? '').split(/[\s,]+/).includes('code')) return child;
|
||||
}
|
||||
return line;
|
||||
};
|
||||
|
||||
export default function unbrokenTokens() {
|
||||
return {
|
||||
name: 'rb-unbroken-tokens',
|
||||
baseStyles: `.${CLASS} { white-space: nowrap; }`,
|
||||
hooks: {
|
||||
postprocessRenderedLine({ renderData }) {
|
||||
const container = tokensOf(renderData.lineAst);
|
||||
container.children = unbreakWords(container);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
// 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:
|
||||
// Tables, settled when the page is built so the layout is right on first
|
||||
// paint (the CSS is retrobios.css, table[data-rb-stack] and .rb-table-filter).
|
||||
// The rule reads the table, never the page that wrote it.
|
||||
//
|
||||
// Which tables a phone reads one row at a time:
|
||||
// - two columns: a list of label and value, data-rb-stack="kv";
|
||||
// - four columns or more: too wide for a phone column, one block per row,
|
||||
// each cell under its column name;
|
||||
// - 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.
|
||||
// block per row, so the action is never behind a horizontal scroll;
|
||||
// - on a level 0 or 1 page (`level: start` or `setup`), a three-column table
|
||||
// short enough to read as records (under RECORD_ROWS rows): one block per
|
||||
// row. A longer one is a list scanned by column and keeps its scroll.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Satteri runs hast plugins (the rehype stage of its pipeline): this one is
|
||||
// registered in astro.config.mjs through satteri({ hastPlugins }).
|
||||
|
||||
@@ -17,7 +24,11 @@ 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']);
|
||||
const HEADING = /^h[1-6]$/;
|
||||
export const RECORD_ROWS = 20;
|
||||
export const WIDE_COLUMNS = 4;
|
||||
// The column names a filter's placeholder offers, at most.
|
||||
const PLACEHOLDER_COLUMNS = 4;
|
||||
|
||||
const elements = (node, tag, found = []) => {
|
||||
for (const child of node.children ?? []) {
|
||||
@@ -29,23 +40,87 @@ const elements = (node, tag, found = []) => {
|
||||
return found;
|
||||
};
|
||||
|
||||
const classes = (node) => String(node.properties?.className ?? '').split(/[\s,]+/);
|
||||
|
||||
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') ||
|
||||
classes(child).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 (labels.length >= WIDE_COLUMNS) return 'true';
|
||||
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;
|
||||
};
|
||||
|
||||
// "File, region, required, hash...": what the rows can be filtered by, in
|
||||
// the table's own words. A word that is not capitalised prose (MD5, ZIP)
|
||||
// keeps its case.
|
||||
export const filterPlaceholder = (labels) => {
|
||||
const words = labels.filter(Boolean).slice(0, PLACEHOLDER_COLUMNS);
|
||||
if (!words.length) return 'Type to filter...';
|
||||
const lowered = words.map((word, index) =>
|
||||
index > 0 && /^[A-Z][a-z]/.test(word) ? word[0].toLowerCase() + word.slice(1) : word,
|
||||
);
|
||||
return `${lowered.join(', ')}...`;
|
||||
};
|
||||
|
||||
// The heading a table sits under: the nearest one before it, looking up
|
||||
// through the blocks that hold it, else the page title. site.ts names the
|
||||
// table's scroll region the same way.
|
||||
const headingBefore = (ctx, table, title) => {
|
||||
for (let block = table; block; block = ctx.parent(block)) {
|
||||
const parent = ctx.parent(block);
|
||||
if (!parent) break;
|
||||
const siblings = parent.children ?? [];
|
||||
for (let index = (ctx.indexOf(block) ?? 0) - 1; index >= 0; index -= 1) {
|
||||
const node = siblings[index];
|
||||
if (node.type !== 'element') continue;
|
||||
const heading = HEADING.test(node.tagName)
|
||||
? node
|
||||
: classes(node).includes('sl-heading-wrapper')
|
||||
? (node.children ?? []).find((child) => child.type === 'element' && HEADING.test(child.tagName))
|
||||
: undefined;
|
||||
const text = heading && ctx.textContent(heading).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return title || 'this table';
|
||||
};
|
||||
|
||||
const filterBox = ({ id, heading, labels, rows }) => ({
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: ['rb-table-filter'] },
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'label',
|
||||
properties: { htmlFor: id },
|
||||
children: [{ type: 'text', value: `Filter ${heading}` }],
|
||||
},
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'input',
|
||||
properties: { id, type: 'search', autoComplete: 'off', placeholder: filterPlaceholder(labels) },
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: ['rb-table-filter-count'], ariaLive: 'polite' },
|
||||
children: [{ type: 'text', value: `${rows.toLocaleString('en')} rows` }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default function stackTables() {
|
||||
return {
|
||||
name: 'rb-stack-tables',
|
||||
@@ -57,11 +132,19 @@ export default function stackTables() {
|
||||
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 });
|
||||
const frontmatter = ctx.data.astro?.frontmatter ?? {};
|
||||
if (rows.length >= RECORD_ROWS) {
|
||||
ctx.data.rbFilters = (ctx.data.rbFilters ?? 0) + 1;
|
||||
const heading = headingBefore(ctx, table, frontmatter.title);
|
||||
ctx.insertBefore(table, filterBox({ id: `rb-table-filter-${ctx.data.rbFilters}`, heading, labels, rows: rows.length }));
|
||||
}
|
||||
const kind = stackKind({ labels, rows: rows.length, action: holdsAction(body), level: frontmatter.level });
|
||||
if (!kind) return;
|
||||
ctx.setProperty(table, 'dataRbStack', kind);
|
||||
if (kind === 'kv') return;
|
||||
// A long table's labels are written by site.ts when it nears the
|
||||
// screen: on mame they were 122 KB of the page. The label is drawn
|
||||
// in the room the cell already keeps for it, so nothing moves.
|
||||
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]);
|
||||
|
||||
Reference in new issue
Block a user