feat: guide a stale link and the first download

Readers reached the 404 page with a pasted command (/i.ps1%20%7C%20iex),
a file name, a GitHub path or a section below a page: the page now says
which it is and leads on. Beside "Start with", the first file's
download sits next to its name.
This commit is contained in:
Abdessamad Derraz committed 2026-11-09 06:04:42 +01:00
1 parent a7a27028ab
commit 29aeccba3c
3 files changed
+148 -12

No files matched your search

+1 -1
View File
@@ -11584,7 +11584,7 @@ def generate_not_found() -> str:
"- Browse [platforms](platforms/index.md), [systems](systems/index.md) "
"or [emulators](emulators/index.md)",
"",
'<p id="rb-notfound-hint" hidden></p>',
'<div id="rb-notfound-hint" hidden></div>',
"",
])
+132 -11
View File
@@ -1282,19 +1282,139 @@ const filterFiles = () => {
}
};
// The 404 page: a search button, and the list a moved page belongs to.
// "Start with" names the file to get first and links its card, where the
// download is. A console's first file is the most downloaded of the site
// (the PS2's: 436 in three days): its download sits beside its name too,
// the card's own link reused, never a second address.
const startDownloads = () => {
for (const strong of document.querySelectorAll<HTMLElement>('.sl-markdown-content strong')) {
if (strong.textContent?.trim() !== 'Start with') continue;
const block = strong.closest('p')?.nextElementSibling?.matches('ul') && !strong.closest('p')?.querySelector('a[href^="#sha1-"]')
? strong.closest('p')!.nextElementSibling!
: strong.closest('p');
for (const named of block?.querySelectorAll<HTMLAnchorElement>('a[href^="#sha1-"]') ?? []) {
const card = document.getElementById(named.hash.slice(1))?.closest('.rb-sys-file');
const source = card?.querySelector<HTMLAnchorElement>('a.rb-download');
if (!source || named.nextElementSibling?.classList.contains('rb-start-download')) continue;
const download = document.createElement('a');
download.className = 'rb-start-download';
download.href = source.href;
download.textContent = 'Download';
download.setAttribute('aria-label', source.getAttribute('aria-label') ?? 'Download');
named.after(' ', download);
}
}
};
// The 404 page: a search button, and what the address it was asked for
// looks like. The addresses readers reached (counted, wiki/privacy.md): a
// command pasted as a link (/i.ps1%20%7C%20iex), a file name under files/,
// a GitHub path (/blob/main/bios), a section below a page
// (/platforms/emudeck/install). The address is only ever set as text.
const INSTALL_COMMANDS: [string, string][] = [
['Windows (PowerShell)', 'irm https://abdess.github.io/retrobios/i.ps1 | iex'],
['Linux, macOS, Steam Deck, handhelds', 'curl -fsSL https://abdess.github.io/retrobios/i | sh'],
];
const openSearch = (query = '') => {
document.querySelector<HTMLButtonElement>('site-search button[data-open-modal]')?.click();
if (!query) return;
// The search box exists once the search has loaded: fill it then.
const fill = (tries: number) => {
const input = document.querySelector<HTMLInputElement>('.pagefind-ui__search-input');
if (input) {
input.value = query;
input.dispatchEvent(new Event('input', { bubbles: true }));
} else if (tries > 0) {
setTimeout(() => fill(tries - 1), 100);
}
};
fill(30);
};
const hintLine = (...parts: (string | Node)[]) => {
const line = document.createElement('p');
line.append(...parts);
return line;
};
const notFound = () => {
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
document.querySelector<HTMLButtonElement>('site-search button[data-open-modal]')?.click();
});
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => openSearch());
const hint = document.getElementById('rb-notfound-hint');
const match = location.pathname.match(/\/(emulators|systems|platforms)\/([^/]+)\/?$/);
if (!hint || !match) return;
const link = document.createElement('a');
link.href = new URL(`${match[1]}/`, siteRoot()).href;
link.textContent = `the list of ${match[1]}`;
hint.append(`No page is named ${decodeURIComponent(match[2])} here. It may be listed under another name in `, link, '.');
hint.hidden = false;
if (!hint) return;
const base = siteRoot().pathname;
let rest = location.pathname.startsWith(base) ? location.pathname.slice(base.length) : location.pathname;
try {
rest = decodeURIComponent(rest);
} catch {
// A malformed escape stays as typed.
}
const show = (...lines: HTMLElement[]) => {
hint.replaceChildren(...lines);
hint.hidden = false;
};
// A command that a chat or a forum turned into a link.
if (/^i(\.ps1)?[\s|)(;'"&-]/.test(rest)) {
const list = document.createElement('ul');
for (const [where, command] of INSTALL_COMMANDS) {
const item = document.createElement('li');
const code = document.createElement('code');
code.textContent = command;
item.append(`${where}: `, code);
list.append(item);
}
show(hintLine('This address is an install command, not a page. Paste the command into a terminal, not into the browser:'), list);
return;
}
// A GitHub path: the files are browsed on the repository.
const github = rest.match(/^(blob|tree|raw)\/(.+)$/);
if (github) {
const link = document.createElement('a');
link.href = `https://github.com/Abdess/retrobios/${github[1] === 'raw' ? 'blob' : github[1]}/${github[2]}`;
link.textContent = 'the same path on GitHub';
show(hintLine('This is a path of the GitHub repository. Open ', link, '.'));
return;
}
// A file name: the search finds the pages that list it.
const file = rest.match(/(?:^|\/)([^/]+\.[a-z0-9]{1,5})\/?$/i);
const word = file ? file[1] : rest.replace(/\/$/, '').split('/').pop() ?? '';
const lines: HTMLElement[] = [];
const known = rest.match(/^(emulators|systems|platforms)\/([^/]+)\/?$/);
if (known) {
const link = document.createElement('a');
link.href = new URL(`${known[1]}/`, siteRoot()).href;
link.textContent = `the list of ${known[1]}`;
lines.push(hintLine(`No page is named ${known[2]} here. It may be listed under another name in `, link, '.'));
}
if (word && word.length <= 60) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'rb-notfound-search';
button.textContent = `Search for ${word}`;
button.addEventListener('click', () => openSearch(word));
lines.push(hintLine(button));
}
if (lines.length) show(...lines);
// A section below a page: the nearest page above it that exists.
const parts = rest.replace(/\/$/, '').split('/');
const parents = parts.slice(0, -1).map((_, index) => parts.slice(0, parts.length - 1 - index).join('/'));
(async () => {
for (const parent of parents) {
const url = new URL(`${parent}/`, siteRoot());
try {
if ((await fetch(url, { method: 'HEAD' })).ok) {
const link = document.createElement('a');
link.href = url.href;
link.textContent = url.pathname;
show(...lines, hintLine('The page above it: ', link, '.'));
return;
}
} catch {
return;
}
}
})();
};
resolveFileAddress();
@@ -1310,4 +1430,5 @@ filterFiles();
osTabs();
installPickers();
showCurrentPage();
startDownloads();
notFound();
+15
View File
@@ -504,6 +504,21 @@ body:has(.rb-sys-file, .rb-file-entry) { --sl-content-width: var(--rb-grid-width
vertical-align: middle;
}
.rb-download:hover { background: var(--sl-color-accent-low); }
/* The download beside a "Start with" file (site.ts startDownloads): the
card's button, at the size of the line it sits in. */
.sl-markdown-content a.rb-start-download {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 0.6rem;
border: 1px solid var(--rb-link);
border-radius: 4px;
font-size: 0.8em;
font-weight: 600;
color: var(--rb-link);
text-decoration: none;
}
.sl-markdown-content a.rb-start-download:hover { background: var(--sl-color-accent-low); }
/* The facts of a file card: label column, value column, one column on a phone. */
.sl-markdown-content dl.rb-facts {