mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
A manufacturer page over 500 KB of Markdown becomes a summary: its largest consoles move to pages of their own, cut into parts when one console is still too large, and every console keeps its heading there. MAME's 1,564 cards move to four pages, its page keeping a row per entry with the card's old anchor. RetroDECK's arcade list gets its own page. site.js follows a moved card by content or by its row. The largest page drops from 10.5 MB to 0.88 MB. Emulator groups of the navigation open by initial (65 KB to 41 KB before the content). File cards are level 3 under a level 2, and the hook keeps the table of contents of card pages to level 2: platform pages no longer list every system id. Key/value cards name their columns. The sweep runs before the decoration, which kept every page no generator wrote any more.
299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
/* Progressive enhancement for generated RetroBIOS reference tables. */
|
|
(() => {
|
|
"use strict";
|
|
|
|
// A heading's words without its permalink sign.
|
|
const headingText = (heading) => {
|
|
const copy = heading.cloneNode(true);
|
|
copy.querySelectorAll(".headerlink").forEach((link) => link.remove());
|
|
return copy.textContent.trim();
|
|
};
|
|
|
|
const textFromHeading = (element) => {
|
|
let cursor = element;
|
|
while (cursor) {
|
|
cursor = cursor.previousElementSibling;
|
|
if (!cursor) break;
|
|
if (/^H[1-6]$/.test(cursor.tagName)) {
|
|
return headingText(cursor);
|
|
}
|
|
}
|
|
const title = document.querySelector("h1");
|
|
return title ? headingText(title) : "Data table";
|
|
};
|
|
|
|
// A scroller is a stop in the tab order only while it overflows: a table
|
|
// in a closed block or a hidden tab gains its overflow later.
|
|
const markScrollable = (container, heading) => {
|
|
if (!container) return;
|
|
container.dataset.rbHeading = heading;
|
|
if (container.scrollWidth > container.clientWidth + 1) {
|
|
container.tabIndex = 0;
|
|
container.setAttribute("role", "region");
|
|
container.setAttribute("aria-label", `${heading}, scrollable table`);
|
|
} else if (container.getAttribute("role") === "region") {
|
|
container.removeAttribute("tabindex");
|
|
container.removeAttribute("role");
|
|
container.removeAttribute("aria-label");
|
|
}
|
|
};
|
|
|
|
const recheckScrollable = () => {
|
|
document.querySelectorAll(".md-typeset__scrollwrap[data-rb-heading]").forEach((container) => {
|
|
markScrollable(container, container.dataset.rbHeading);
|
|
});
|
|
};
|
|
|
|
// A table whose rows end in an action (a download button) 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) => {
|
|
if (!table.querySelector("tbody .md-button")) return;
|
|
const labels = Array.from(table.querySelectorAll("thead th")).map((cell) =>
|
|
cell.textContent.trim()
|
|
);
|
|
if (!labels.length) return;
|
|
table.dataset.rbStack = "true";
|
|
table.querySelectorAll("tbody tr").forEach((row) => {
|
|
Array.from(row.children).forEach((cell, column) => {
|
|
if (labels[column] && !cell.hasAttribute("data-label")) {
|
|
cell.setAttribute("data-label", labels[column]);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const enhanceTables = () => {
|
|
document.querySelectorAll(".md-typeset table").forEach((table, index) => {
|
|
if (table.dataset.rbEnhanced === "true") return;
|
|
table.dataset.rbEnhanced = "true";
|
|
|
|
table.querySelectorAll("thead th").forEach((cell) => {
|
|
if (!cell.hasAttribute("scope")) cell.setAttribute("scope", "col");
|
|
});
|
|
table.querySelectorAll("tbody tr").forEach((row) => {
|
|
const first = row.querySelector(":scope > th");
|
|
if (first && !first.hasAttribute("scope")) first.setAttribute("scope", "row");
|
|
});
|
|
|
|
// The scrolling element is Material's scrollwrap, one level above
|
|
// the table's own wrapper.
|
|
const container = table.closest(".md-typeset__scrollwrap") || table.parentElement;
|
|
const heading = textFromHeading(container);
|
|
if (!table.querySelector("caption")) {
|
|
const caption = document.createElement("caption");
|
|
caption.className = "rb-visually-hidden";
|
|
caption.textContent = heading;
|
|
table.prepend(caption);
|
|
}
|
|
stackActionTable(table);
|
|
markScrollable(container, heading);
|
|
|
|
const rows = Array.from(table.querySelectorAll("tbody tr"));
|
|
if (rows.length < 20 || !container || container.dataset.rbFilter === "true") {
|
|
return;
|
|
}
|
|
container.dataset.rbFilter = "true";
|
|
|
|
const controls = document.createElement("div");
|
|
controls.className = "rb-table-filter";
|
|
const id = `rb-table-filter-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const label = document.createElement("label");
|
|
label.htmlFor = id;
|
|
label.textContent = `Filter ${heading}`;
|
|
const input = document.createElement("input");
|
|
input.id = id;
|
|
input.type = "search";
|
|
input.autocomplete = "off";
|
|
input.placeholder = "Name, platform, system, hash...";
|
|
const count = document.createElement("span");
|
|
count.className = "rb-table-filter-count";
|
|
count.setAttribute("aria-live", "polite");
|
|
count.textContent = `${rows.length} rows`;
|
|
controls.append(label, input, count);
|
|
container.before(controls);
|
|
|
|
const searchable = rows.map((row) => ({
|
|
row,
|
|
text: row.textContent.normalize("NFKD").toLocaleLowerCase(),
|
|
}));
|
|
input.addEventListener("input", () => {
|
|
const query = input.value.trim().normalize("NFKD").toLocaleLowerCase();
|
|
let visible = 0;
|
|
searchable.forEach((entry) => {
|
|
const matches = !query || entry.text.includes(query);
|
|
entry.row.hidden = !matches;
|
|
if (matches) visible += 1;
|
|
});
|
|
count.textContent = `${visible} of ${rows.length} rows`;
|
|
});
|
|
});
|
|
};
|
|
|
|
const nameMaterialWidgets = () => {
|
|
document.querySelectorAll('[role="dialog"]').forEach((dialog) => {
|
|
if (dialog.hasAttribute("aria-label") || dialog.hasAttribute("aria-labelledby")) return;
|
|
const heading = dialog.querySelector("h1, h2, h3");
|
|
dialog.setAttribute("aria-label", heading?.textContent.trim() || "Site search");
|
|
});
|
|
// The page-load bar is drawn for the eye only: the new page announces
|
|
// itself when it arrives.
|
|
document.querySelectorAll(".md-progress").forEach((progress) => {
|
|
progress.setAttribute("aria-hidden", "true");
|
|
});
|
|
// Material wraps each copy button in a nav element: one unnamed
|
|
// navigation landmark per code block. The button stays, the landmark goes.
|
|
document.querySelectorAll("nav.md-code__nav").forEach((nav) => {
|
|
nav.setAttribute("role", "none");
|
|
});
|
|
};
|
|
|
|
// files/?hash= reads one shard of the lookup, named by the first two hex
|
|
// characters of the value: 20 KB where the whole index was 675 KB.
|
|
const HASH_LENGTHS = { 8: "CRC32", 32: "MD5", 40: "SHA1", 64: "SHA256" };
|
|
const LAYER_LABELS = {
|
|
collection: "In the collection",
|
|
"data-directory": "In a data directory the packs ship",
|
|
platform: "Declared by a platform",
|
|
emulator: "Read by an emulator",
|
|
};
|
|
const siteRoot = () => {
|
|
const script = document.querySelector('script[src$="javascripts/site.js"]');
|
|
return script ? new URL("../", script.src) : new URL("../", window.location.href);
|
|
};
|
|
const lookupShard = (value) =>
|
|
fetch(new URL(`api/v1/lookup/${value.slice(0, 2)}.json`, siteRoot()))
|
|
.then((response) => (response.ok ? response.json() : { items: [] }))
|
|
.then((shard) => shard.items || []);
|
|
const normalizeHash = (raw) => raw.trim().toLowerCase().replace(/^0x/, "").replace(/\s+/g, "");
|
|
|
|
const renderMatches = (status, value, items) => {
|
|
status.textContent = "";
|
|
const kind = HASH_LENGTHS[value.length];
|
|
const heading = document.createElement("p");
|
|
heading.textContent = `${items.length} ${items.length === 1 ? "match" : "matches"} for ${kind} ${value}:`;
|
|
status.append(heading);
|
|
Object.entries(LAYER_LABELS).forEach(([layer, label]) => {
|
|
const found = items.filter((item) => item.layer === layer);
|
|
if (!found.length) return;
|
|
const title = document.createElement("p");
|
|
const strong = document.createElement("strong");
|
|
strong.textContent = label;
|
|
title.append(strong);
|
|
const list = document.createElement("ul");
|
|
found.forEach((item) => {
|
|
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})`);
|
|
list.append(row);
|
|
});
|
|
status.append(title, list);
|
|
});
|
|
};
|
|
|
|
// files/?sha1=<sha1> is a file's content address and opens its card
|
|
// wherever it is now; files/?hash= also takes an MD5, CRC32 or SHA256 and
|
|
// lists every page that names the value.
|
|
const resolveFileAddress = () => {
|
|
const status = document.getElementById("rb-file-lookup-status");
|
|
if (!status) return;
|
|
const params = new URLSearchParams(window.location.search);
|
|
const permalink = params.has("sha1");
|
|
const value = normalizeHash(params.get("sha1") || params.get("hash") || "");
|
|
if (!value) return;
|
|
const input = document.getElementById("rb-hash");
|
|
if (input) input.value = value;
|
|
const prefix = permalink && /^[0-9a-f]{12}$/.test(value);
|
|
if (!prefix && !(/^[0-9a-f]+$/.test(value) && HASH_LENGTHS[value.length])) {
|
|
status.textContent =
|
|
"A hash is 8 (CRC32), 32 (MD5), 40 (SHA1) or 64 (SHA256) hexadecimal characters.";
|
|
return;
|
|
}
|
|
status.textContent = "Looking it up...";
|
|
lookupShard(value)
|
|
.then((items) => {
|
|
const matches = items.filter((item) =>
|
|
prefix
|
|
? item.algorithm === "sha1" && item.value.startsWith(value)
|
|
: item.value === value
|
|
);
|
|
const cards = matches.filter((item) => item.layer === "collection" || item.layer === "data-directory");
|
|
const targets = new Set(cards.map((item) => item.url));
|
|
if (permalink && targets.size === 1) {
|
|
window.location.replace(new URL(cards[0].url, siteRoot()));
|
|
} else if (matches.length) {
|
|
renderMatches(status, prefix ? matches[0].value : value, prefix ? cards : matches);
|
|
} else {
|
|
status.textContent = "No file the site knows has this hash.";
|
|
}
|
|
})
|
|
.catch(() => {
|
|
status.textContent = "The file index could not be read.";
|
|
});
|
|
};
|
|
|
|
// A file card moved to another page keeps its address: an id of the form
|
|
// sha1-<12 hex> that this page does not hold is looked up by content, and
|
|
// an emulator card listed in the table of a split page opens its page.
|
|
const followMovedCard = () => {
|
|
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
if (!id) return;
|
|
const target = document.getElementById(id);
|
|
const row = target?.closest(".rb-card-index tr");
|
|
const link = row?.querySelector("a[href]");
|
|
if (link) {
|
|
window.location.replace(link.href);
|
|
return;
|
|
}
|
|
const match = /^sha1-([0-9a-f]{12})$/.exec(id);
|
|
if (!match || target) return;
|
|
window.location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
|
|
};
|
|
|
|
// The site search no longer holds hashes: Enter on a hash opens the lookup.
|
|
const hashSearch = () => {
|
|
const query = document.querySelector('input[data-md-component="search-query"]');
|
|
if (!query || query.dataset.rbHash === "true") return;
|
|
query.dataset.rbHash = "true";
|
|
query.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter") return;
|
|
const value = normalizeHash(query.value);
|
|
if (/^[0-9a-f]+$/.test(value) && HASH_LENGTHS[value.length]) {
|
|
event.preventDefault();
|
|
window.location.assign(new URL(`files/?hash=${value}`, siteRoot()));
|
|
}
|
|
});
|
|
};
|
|
|
|
const enhance = () => {
|
|
enhanceTables();
|
|
nameMaterialWidgets();
|
|
resolveFileAddress();
|
|
followMovedCard();
|
|
hashSearch();
|
|
};
|
|
|
|
let resizeTimer = 0;
|
|
window.addEventListener("resize", () => {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(recheckScrollable, 150);
|
|
});
|
|
document.addEventListener("toggle", recheckScrollable, true);
|
|
document.addEventListener("change", (event) => {
|
|
if (event.target.matches?.(".tabbed-set > input")) recheckScrollable();
|
|
});
|
|
|
|
if (window.document$?.subscribe) {
|
|
window.document$.subscribe(enhance);
|
|
} else if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", enhance, { once: true });
|
|
} else {
|
|
enhance();
|
|
}
|
|
})();
|