mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-28 14:47:21 -05:00
feat: write the site for Starlight behind a flag
generate_site.py --engine starlight writes the pages, navigation and data files of an Astro Starlight project in website/. Links resolve to site addresses, every heading keeps its MkDocs id, and pages split by size are measured in the MkDocs Markdown so both builds cut the same files. The 839 pages and their 45,613 ids match the MkDocs build.
This commit is contained in:
1 parent
d2974660eb
commit
342d0f7271
26 files changed
+7817
-60
No files matched your search
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RetroBIOS"><mask id="glyph"><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff"/><rect x="11.6" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="24.8" y="11.6" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="18.2" y="18.2" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="18.2" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="11.6" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="31.4" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="24.8" y="31.4" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="31.4" width="5" height="5" rx="0.6" fill="#000"/></mask><rect x="11.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="11.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="18.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="18.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="25.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="25.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="32.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="32.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff" mask="url(#glyph)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RetroBIOS"><mask id="glyph"><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff"/><rect x="11.6" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="24.8" y="11.6" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="18.2" y="18.2" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="18.2" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="11.6" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="31.4" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="24.8" y="31.4" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="31.4" width="5" height="5" rx="0.6" fill="#000"/></mask><rect x="11.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="11.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="18.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="18.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="25.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="25.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="32.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="32.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="8" y="8" width="32" height="32" rx="4" fill="#3b3f7a" mask="url(#glyph)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,186 @@
|
||||
---
|
||||
// "Which files do I need": pick a setup and systems, get the file list and
|
||||
// the install command. Client side only: reads public/data/picker/*.json,
|
||||
// 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.
|
||||
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
---
|
||||
|
||||
<div class="rb-picker" data-base={base}>
|
||||
<fieldset>
|
||||
<label for="rb-pick-platform">Your setup</label>
|
||||
<select id="rb-pick-platform" name="platform">
|
||||
<option value="">Choose a setup</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<div class="rb-pick-systems" hidden>
|
||||
<p class="rb-pick-systems-head">
|
||||
<strong>Systems</strong>
|
||||
<button type="button" data-all>All</button>
|
||||
<button type="button" data-none>None</button>
|
||||
</p>
|
||||
<input type="search" class="rb-pick-filter" placeholder="Filter systems" aria-label="Filter systems" />
|
||||
<div class="rb-pick-list" role="group" aria-label="Systems"></div>
|
||||
</div>
|
||||
<div class="rb-pick-result" role="status" aria-live="polite"></div>
|
||||
<div class="rb-pick-output" hidden>
|
||||
<p><strong>Install command</strong> (Linux, macOS, Steam Deck, handhelds)</p>
|
||||
<pre class="rb-pick-cmd"><code></code></pre>
|
||||
<button type="button" data-copy>Copy the command</button>
|
||||
<details>
|
||||
<summary>Files and where they go</summary>
|
||||
<table>
|
||||
<thead><tr><th scope="col">Destination</th><th scope="col">Size</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
type Data = {
|
||||
platform: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
generated: string;
|
||||
systems: { id: string; native: string[] }[];
|
||||
other: boolean;
|
||||
files: [string, number, number[]][];
|
||||
};
|
||||
|
||||
const INSTALL = 'curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s --';
|
||||
const MAX_ROWS = 500;
|
||||
|
||||
const size = (bytes: number) =>
|
||||
bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB`
|
||||
: bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB`
|
||||
: bytes >= 1e3 ? `${(bytes / 1e3).toFixed(1)} KB`
|
||||
: `${bytes} B`;
|
||||
|
||||
for (const root of document.querySelectorAll<HTMLElement>('.rb-picker')) {
|
||||
const base = root.dataset.base ?? '/';
|
||||
const select = root.querySelector('select')!;
|
||||
const systemsBox = root.querySelector<HTMLElement>('.rb-pick-systems')!;
|
||||
const list = root.querySelector<HTMLElement>('.rb-pick-list')!;
|
||||
const filter = root.querySelector<HTMLInputElement>('.rb-pick-filter')!;
|
||||
const result = root.querySelector<HTMLElement>('.rb-pick-result')!;
|
||||
const output = root.querySelector<HTMLElement>('.rb-pick-output')!;
|
||||
const cmd = root.querySelector<HTMLElement>('.rb-pick-cmd code')!;
|
||||
const tbody = root.querySelector('tbody')!;
|
||||
let data: Data | null = null;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const index: { platform: string; name: string }[] = await fetch(`${base}data/picker/index.json`).then((r) => r.json());
|
||||
for (const entry of index) select.add(new Option(entry.name, entry.platform));
|
||||
|
||||
const chosen = () => [...list.querySelectorAll<HTMLInputElement>('input:checked')].map((box) => box.value);
|
||||
|
||||
const render = () => {
|
||||
if (!data) return;
|
||||
const picked = chosen();
|
||||
const wanted = new Set(picked.map((id) => data!.systems.findIndex((s) => s.id === id)));
|
||||
const other = picked.includes('other');
|
||||
const files = data.files.filter(([, , systems]) =>
|
||||
systems.length ? systems.some((s) => wanted.has(s)) : other);
|
||||
const total = files.reduce((sum, [, bytes]) => sum + bytes, 0);
|
||||
const all = picked.length === data.systems.length + (data.other ? 1 : 0);
|
||||
result.textContent = picked.length
|
||||
? `${files.length} files, ${size(total)}, for ${picked.length} of ${data.systems.length + (data.other ? 1 : 0)} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
|
||||
: 'Choose at least one system.';
|
||||
output.hidden = !picked.length;
|
||||
cmd.textContent = `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`;
|
||||
tbody.replaceChildren(...files.slice(0, MAX_ROWS).map(([dest, bytes]) => {
|
||||
const row = document.createElement('tr');
|
||||
const where = document.createElement('td');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = dest;
|
||||
where.append(code);
|
||||
const weight = document.createElement('td');
|
||||
weight.textContent = size(bytes);
|
||||
row.append(where, weight);
|
||||
return row;
|
||||
}));
|
||||
if (files.length > MAX_ROWS) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 2;
|
||||
cell.textContent = `and ${files.length - MAX_ROWS} more.`;
|
||||
row.append(cell);
|
||||
tbody.append(row);
|
||||
}
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('platform', data.platform);
|
||||
if (all) url.searchParams.delete('system');
|
||||
else url.searchParams.set('system', picked.join(','));
|
||||
history.replaceState(null, '', url);
|
||||
};
|
||||
|
||||
const load = async (platform: string, preset: string[] | null) => {
|
||||
list.replaceChildren();
|
||||
output.hidden = true;
|
||||
systemsBox.hidden = !platform;
|
||||
if (!platform) { result.textContent = ''; return; }
|
||||
result.textContent = 'Loading the file list...';
|
||||
data = await fetch(`${base}data/picker/${platform}.json`).then((r) => r.json());
|
||||
const choices = data!.systems.map((s) => ({ id: s.id, label: s.native.length ? `${s.id} (${s.native.join(', ')})` : s.id }));
|
||||
if (data!.other) choices.push({ id: 'other', label: 'other (files no system owns: engines, computers)' });
|
||||
for (const choice of choices) {
|
||||
const label = document.createElement('label');
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.value = choice.id;
|
||||
box.checked = preset ? preset.includes(choice.id) : true;
|
||||
label.append(box, ` ${choice.label}`);
|
||||
list.append(label);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
select.addEventListener('change', () => load(select.value, null));
|
||||
list.addEventListener('change', render);
|
||||
filter.addEventListener('input', () => {
|
||||
const query = filter.value.trim().toLowerCase();
|
||||
for (const label of list.querySelectorAll<HTMLElement>('label')) {
|
||||
label.hidden = !!query && !label.textContent!.toLowerCase().includes(query);
|
||||
}
|
||||
});
|
||||
root.querySelector('[data-all]')!.addEventListener('click', () => {
|
||||
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = true));
|
||||
render();
|
||||
});
|
||||
root.querySelector('[data-none]')!.addEventListener('click', () => {
|
||||
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = false));
|
||||
render();
|
||||
});
|
||||
root.querySelector('[data-copy]')!.addEventListener('click', () => navigator.clipboard?.writeText(cmd.textContent ?? ''));
|
||||
|
||||
const initial = params.get('platform');
|
||||
if (initial && index.some((entry) => entry.platform === initial)) {
|
||||
select.value = initial;
|
||||
await load(initial, params.get('system')?.split(',') ?? null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Global: the checkboxes are created by the script, outside Astro's scoped styles. -->
|
||||
<style is:global>
|
||||
.rb-picker .rb-pick-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 0.25rem 1rem;
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rb-picker .rb-pick-list label { display: flex; gap: 0.5rem; align-items: center; min-height: 32px; margin: 0; font-size: 0.9rem; }
|
||||
.sl-markdown-content .rb-picker .rb-pick-list input { min-height: auto; width: 1.1rem; height: 1.1rem; margin: 0; flex: none; }
|
||||
.rb-picker .rb-pick-systems-head { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.rb-picker .rb-pick-systems-head > * { margin: 0; }
|
||||
.rb-picker .rb-pick-filter { width: 100%; margin: 0.5rem 0; }
|
||||
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.rb-picker .rb-pick-result { margin-top: 1rem; font-weight: 600; }
|
||||
.rb-picker fieldset { border: 0; padding: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
// Starlight's footer (edit link, previous and next) and the licence line.
|
||||
import Default from '@astrojs/starlight/components/Footer.astro';
|
||||
---
|
||||
|
||||
<Default />
|
||||
<p class="rb-copyright">
|
||||
MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup,
|
||||
archival and interoperability.
|
||||
</p>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
// Search ranking: Pagefind multiplies the score of words inside a weighted
|
||||
// element, so a Start here page outranks the reference page that names it.
|
||||
import Default from '@astrojs/starlight/components/MarkdownContent.astro';
|
||||
|
||||
const { boost } = Astro.locals.starlightRoute.entry.data;
|
||||
---
|
||||
|
||||
{
|
||||
boost ? (
|
||||
<div data-pagefind-weight={String(Math.min(10, boost * 2))}>
|
||||
<Default><slot /></Default>
|
||||
</div>
|
||||
) : (
|
||||
<Default><slot /></Default>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
import '../scripts/site.ts';
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
// Breadcrumbs, the level label and the title. The trail says which tab a
|
||||
// deep page belongs to; the label says who the page is written for.
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
|
||||
const LABELS = {
|
||||
start: 'Start here',
|
||||
setup: 'Setup guide',
|
||||
explore: 'Details',
|
||||
reference: 'Reference',
|
||||
contribute: 'For contributors',
|
||||
} as const;
|
||||
|
||||
const { title, level } = Astro.locals.starlightRoute.entry.data;
|
||||
const crumbs = Astro.locals.rbBreadcrumbs ?? [];
|
||||
---
|
||||
|
||||
{
|
||||
crumbs.length > 0 && (
|
||||
<nav class="rb-breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
{crumbs.map((crumb) => (
|
||||
<li>{crumb.href ? <a href={crumb.href}>{crumb.label}</a> : crumb.label}</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
{level && <span class={`rb-level rb-level-${level}`}>{LABELS[level]}</span>}
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--sl-text-h1);
|
||||
line-height: var(--sl-line-height-headings);
|
||||
font-weight: 700;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { z } from 'astro/zod';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
// The generator's own front matter, typed: a page with a misspelt level fails the build.
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
loader: docsLoader(),
|
||||
schema: docsSchema({
|
||||
extend: z.object({
|
||||
// Who the page is written for, from someone who knows nothing
|
||||
// (start) to someone adding to the project (contribute).
|
||||
level: z.enum(['start', 'setup', 'explore', 'reference', 'contribute']).optional(),
|
||||
// Search ranking of the first two levels.
|
||||
boost: z.number().min(1).max(10).optional(),
|
||||
// The date the page's content carries, for the sitemap.
|
||||
lastmod: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
rbBreadcrumbs?: import('./routeData').Crumb[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// What each page knows of its place in the site, computed once per page:
|
||||
// - breadcrumbs: the tab and the groups above the page;
|
||||
// - previous and next: the reading order runs on from one tab into the next,
|
||||
// so the last Start here page leads to Your setup;
|
||||
// - a pruned sidebar: a group that does not hold the page becomes one link
|
||||
// to its first page, so an emulator page does not carry the 600 links of
|
||||
// every other emulator (the equivalent of Material's navigation.prune).
|
||||
import { defineRouteMiddleware, type StarlightRouteData } from '@astrojs/starlight/route-data';
|
||||
import sidebar from './generated/sidebar.json' with { type: 'json' };
|
||||
|
||||
type Entry = StarlightRouteData['sidebar'][number];
|
||||
type Link = Extract<Entry, { type: 'link' }>;
|
||||
type Item = { label: string; link?: string; items?: Item[] };
|
||||
export type Crumb = { label: string; href?: string };
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const withBase = (link: string) => (link.startsWith(BASE) ? link : BASE + link.replace(/^\//, ''));
|
||||
|
||||
const flatten = (items: Item[]): { label: string; link: string }[] =>
|
||||
items.flatMap((item) => (item.items ? flatten(item.items) : item.link && !item.link.startsWith('http') ? [{ label: item.label, link: item.link }] : []));
|
||||
|
||||
// Every listed page in reading order, tab after tab.
|
||||
const READING_ORDER = sidebar.topics.flatMap((topic) =>
|
||||
flatten(topic.items as Item[]).map((entry) => ({ ...entry, label: entry.label === 'Overview' ? topic.label : entry.label })),
|
||||
);
|
||||
|
||||
const holdsCurrent = (entry: Entry): boolean =>
|
||||
entry.type === 'link' ? entry.isCurrent : entry.entries.some(holdsCurrent);
|
||||
|
||||
const firstLink = (entry: Entry): string | undefined =>
|
||||
entry.type === 'link' ? entry.href : entry.entries.map(firstLink).find(Boolean);
|
||||
|
||||
const prune = (entries: Entry[]): Entry[] =>
|
||||
entries.map((entry) => {
|
||||
if (entry.type === 'link') return entry;
|
||||
if (holdsCurrent(entry)) return { ...entry, collapsed: false, entries: prune(entry.entries) };
|
||||
const href = firstLink(entry);
|
||||
return href ? { type: 'link', label: entry.label, href, isCurrent: false, badge: entry.badge, attrs: {} } : entry;
|
||||
});
|
||||
|
||||
// The groups above the current page, outermost first.
|
||||
const trail = (entries: Entry[], path: string): Crumb[] | undefined => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'link') {
|
||||
if (entry.isCurrent) return [];
|
||||
continue;
|
||||
}
|
||||
const below = trail(entry.entries, path);
|
||||
if (below) return [{ label: entry.label, href: firstLink(entry) }, ...below];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// A page the navigation does not list (part of a split page) sits under the
|
||||
// group holding the pages of its folder.
|
||||
const folderTrail = (entries: Entry[], folder: string): Crumb[] => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'group') continue;
|
||||
const links = entry.entries.map(firstLink).filter(Boolean) as string[];
|
||||
if (links.some((href) => href.startsWith(folder))) return [{ label: entry.label, href: links[0] }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const asLink = (entry: { label: string; link: string }): Link => ({
|
||||
type: 'link',
|
||||
label: entry.label,
|
||||
href: withBase(entry.link),
|
||||
isCurrent: false,
|
||||
badge: undefined,
|
||||
attrs: {},
|
||||
});
|
||||
|
||||
export const onRequest = defineRouteMiddleware(async (context, next) => {
|
||||
await next();
|
||||
const route = context.locals.starlightRoute;
|
||||
const path = context.url.pathname;
|
||||
|
||||
const topic = context.locals.starlightSidebarTopics?.topics.find((t) => t.isCurrent);
|
||||
const crumbs: Crumb[] = [];
|
||||
if (topic && withBase(topic.link) !== path) {
|
||||
crumbs.push({ label: topic.label, href: withBase(topic.link) });
|
||||
const segments = path.slice(BASE.length).split('/').filter(Boolean);
|
||||
const groups = trail(route.sidebar, path) ?? (segments.length > 1 ? folderTrail(route.sidebar, `${BASE}${segments[0]}/`) : []);
|
||||
crumbs.push(...groups.filter((crumb) => crumb.href !== path));
|
||||
}
|
||||
context.locals.rbBreadcrumbs = crumbs;
|
||||
|
||||
const position = READING_ORDER.findIndex((entry) => withBase(entry.link) === path);
|
||||
if (position !== -1) {
|
||||
if (!route.pagination.prev && position > 0) route.pagination.prev = asLink(READING_ORDER[position - 1]);
|
||||
if (!route.pagination.next && position < READING_ORDER.length - 1) route.pagination.next = asLink(READING_ORDER[position + 1]);
|
||||
}
|
||||
|
||||
route.sidebar = prune(route.sidebar);
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
// Progressive enhancement of the generated pages: table captions, filters
|
||||
// and phone layout, the hash lookup on files/, moved cards, a hash typed in
|
||||
// the search, glossary terms on a tap, and the 404 page's hints.
|
||||
|
||||
const HASH_LENGTHS: Record<number, string> = { 8: 'CRC32', 32: 'MD5', 40: 'SHA1', 64: 'SHA256' };
|
||||
const LAYER_LABELS: Record<string, string> = {
|
||||
collection: 'In the collection',
|
||||
'data-directory': 'In a data directory the packs ship',
|
||||
platform: 'Declared by a platform',
|
||||
emulator: 'Read by an emulator',
|
||||
};
|
||||
type Item = { algorithm: string; layer: string; name?: string; value: string; url: string; owner?: string };
|
||||
|
||||
const siteRoot = () => new URL(import.meta.env.BASE_URL.replace(/\/?$/, '/'), location.origin);
|
||||
const normalizeHash = (raw: string) => raw.trim().toLowerCase().replace(/^0x/, '').replace(/\s+/g, '');
|
||||
const isHash = (value: string) => /^[0-9a-f]+$/.test(value) && value.length in HASH_LENGTHS;
|
||||
|
||||
// Tables
|
||||
|
||||
const headingText = (element: Element): string => {
|
||||
let cursor: Element | null = element;
|
||||
while (cursor) {
|
||||
cursor = cursor.previousElementSibling;
|
||||
if (!cursor) break;
|
||||
const heading = /^H[1-6]$/.test(cursor.tagName) ? cursor : cursor.querySelector('h1, h2, h3, h4, h5, h6');
|
||||
if (heading && cursor.classList.contains('sl-heading-wrapper')) return heading.textContent!.trim();
|
||||
if (/^H[1-6]$/.test(cursor.tagName)) return cursor.textContent!.trim();
|
||||
}
|
||||
return document.querySelector('h1')?.textContent?.trim() || 'Data table';
|
||||
};
|
||||
|
||||
// A table that overflows is a named stop in the tab order, so a keyboard
|
||||
// can scroll it (WCAG 2.1.1). Checked again when a closed block opens.
|
||||
const markScrollable = (table: HTMLTableElement) => {
|
||||
const heading = table.dataset.rbHeading ?? 'Data table';
|
||||
if (table.scrollWidth > table.clientWidth + 1) {
|
||||
table.tabIndex = 0;
|
||||
table.setAttribute('role', 'region');
|
||||
table.setAttribute('aria-label', `${heading}, scrollable table`);
|
||||
} else if (table.getAttribute('role') === 'region') {
|
||||
table.removeAttribute('tabindex');
|
||||
table.removeAttribute('role');
|
||||
table.removeAttribute('aria-label');
|
||||
}
|
||||
};
|
||||
|
||||
// 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: HTMLTableElement) => {
|
||||
if (!table.querySelector('tbody .rb-button')) return;
|
||||
const labels = [...table.querySelectorAll('thead th')].map((cell) => cell.textContent!.trim());
|
||||
if (!labels.length) return;
|
||||
table.dataset.rbStack = 'true';
|
||||
for (const row of table.querySelectorAll('tbody tr')) {
|
||||
[...row.children].forEach((cell, column) => {
|
||||
if (labels[column] && !cell.hasAttribute('data-label')) cell.setAttribute('data-label', labels[column]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filterTable = (table: HTMLTableElement, index: number, heading: string) => {
|
||||
const rows = [...table.querySelectorAll<HTMLTableRowElement>('tbody tr')];
|
||||
if (rows.length < 20) return;
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'rb-table-filter';
|
||||
const id = `rb-table-filter-${index}`;
|
||||
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);
|
||||
table.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;
|
||||
for (const entry of searchable) {
|
||||
entry.row.hidden = !!query && !entry.text.includes(query);
|
||||
if (!entry.row.hidden) visible += 1;
|
||||
}
|
||||
count.textContent = `${visible} of ${rows.length} rows`;
|
||||
});
|
||||
};
|
||||
|
||||
const enhanceTables = () => {
|
||||
document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table').forEach((table, index) => {
|
||||
for (const cell of table.querySelectorAll('thead th')) if (!cell.hasAttribute('scope')) cell.setAttribute('scope', 'col');
|
||||
const heading = headingText(table);
|
||||
table.dataset.rbHeading = heading;
|
||||
if (!table.querySelector('caption') && !table.closest('.rb-meta-card')) {
|
||||
const caption = document.createElement('caption');
|
||||
caption.className = 'rb-visually-hidden';
|
||||
caption.textContent = heading;
|
||||
table.prepend(caption);
|
||||
}
|
||||
stackActionTable(table);
|
||||
markScrollable(table);
|
||||
filterTable(table, index, heading);
|
||||
});
|
||||
const recheck = () => document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table').forEach(markScrollable);
|
||||
let timer = 0;
|
||||
addEventListener('resize', () => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(recheck, 150);
|
||||
});
|
||||
document.addEventListener('toggle', recheck, true);
|
||||
document.addEventListener('click', (event) => {
|
||||
if ((event.target as Element).closest?.('[role="tab"]')) setTimeout(recheck, 0);
|
||||
});
|
||||
};
|
||||
|
||||
// files/?hash= reads one shard of the lookup, named by the first two hex
|
||||
// characters of the value.
|
||||
|
||||
const lookupShard = (value: string): Promise<Item[]> =>
|
||||
fetch(new URL(`api/v1/lookup/${value.slice(0, 2)}.json`, siteRoot()))
|
||||
.then((response) => (response.ok ? response.json() : { items: [] }))
|
||||
.then((shard) => shard.items || []);
|
||||
|
||||
const renderMatches = (status: HTMLElement, value: string, items: Item[]) => {
|
||||
status.textContent = '';
|
||||
const heading = document.createElement('p');
|
||||
heading.textContent = `${items.length} ${items.length === 1 ? 'match' : 'matches'} for ${HASH_LENGTHS[value.length]} ${value}:`;
|
||||
status.append(heading);
|
||||
for (const [layer, label] of Object.entries(LAYER_LABELS)) {
|
||||
const found = items.filter((item) => item.layer === layer);
|
||||
if (!found.length) continue;
|
||||
const title = document.createElement('p');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = label;
|
||||
title.append(strong);
|
||||
const list = document.createElement('ul');
|
||||
for (const item of found) {
|
||||
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(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') as HTMLInputElement | null;
|
||||
if (input) input.value = value;
|
||||
const prefix = permalink && /^[0-9a-f]{12}$/.test(value);
|
||||
if (!prefix && !isHash(value)) {
|
||||
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');
|
||||
if (permalink && new Set(cards.map((item) => item.url)).size === 1) {
|
||||
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(location.hash.slice(1));
|
||||
if (!id) return;
|
||||
const target = document.getElementById(id);
|
||||
const link = target?.closest('.rb-card-index tr')?.querySelector<HTMLAnchorElement>('a[href]');
|
||||
if (link) {
|
||||
location.replace(link.href);
|
||||
return;
|
||||
}
|
||||
const match = /^sha1-([0-9a-f]{12})$/.exec(id);
|
||||
if (match && !target) location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
|
||||
};
|
||||
|
||||
// The site search holds no hashes: Enter on one opens the lookup.
|
||||
const hashSearch = () => {
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.matches?.('site-search dialog input')) return;
|
||||
const value = normalizeHash(input.value);
|
||||
if (!isHash(value)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
location.assign(new URL(`files/?hash=${value}`, siteRoot()));
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
// A glossary term shows its definition on hover; a tap or Enter shows it
|
||||
// on a phone or to a keyboard, and Escape or a tap elsewhere closes it.
|
||||
const glossaryTerms = () => {
|
||||
const terms = document.querySelectorAll<HTMLElement>('abbr.rb-term');
|
||||
if (!terms.length) return;
|
||||
const close = () => document.querySelectorAll('abbr.rb-term[data-open]').forEach((term) => term.removeAttribute('data-open'));
|
||||
for (const term of terms) {
|
||||
term.tabIndex = 0;
|
||||
term.setAttribute('role', 'button');
|
||||
term.setAttribute('aria-label', `${term.textContent}: ${term.title}`);
|
||||
term.dataset.definition = term.title;
|
||||
const toggle = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
const open = term.hasAttribute('data-open');
|
||||
close();
|
||||
if (!open) term.setAttribute('data-open', '');
|
||||
};
|
||||
term.addEventListener('click', toggle);
|
||||
term.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggle(event);
|
||||
} else if (event.key === 'Escape') close();
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', close);
|
||||
};
|
||||
|
||||
// The 404 page: a search button, and the list a moved page belongs to.
|
||||
const notFound = () => {
|
||||
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
|
||||
document.querySelector<HTMLButtonElement>('site-search button[data-open-modal]')?.click();
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
resolveFileAddress();
|
||||
followMovedCard();
|
||||
enhanceTables();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
notFound();
|
||||
@@ -0,0 +1,557 @@
|
||||
/* RetroBIOS on top of Starlight.
|
||||
|
||||
Starlight's dark theme is :root and its light theme
|
||||
:root[data-theme='light']. Colour pairs are the ones measured for WCAG AA
|
||||
on the previous site, in both themes.
|
||||
|
||||
Classes the generator and the wiki pages use:
|
||||
.rb-lead first paragraph under a page title
|
||||
.rb-level-* level label (start, setup, explore, reference, contribute)
|
||||
.rb-cards, .rb-card card grid
|
||||
.rb-steps ordered list drawn as numbered steps
|
||||
.rb-button a link drawn as a button (.rb-button-primary: filled)
|
||||
.rb-terminal wraps a code block that shows terminal output
|
||||
.rb-cmd wraps a command that must wrap instead of scrolling
|
||||
code.rb-path a folder path shown as a chip
|
||||
.rb-figure a generated diagram (scripts/sitediagrams.py)
|
||||
.rb-bar inline coverage bar, width from --v
|
||||
.rb-details-* collapsible block, in the callout colour of its role
|
||||
abbr.rb-term a glossary term, its definition on hover or tap
|
||||
*/
|
||||
|
||||
:root {
|
||||
--sl-font: system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
--sl-font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
--sl-color-accent-low: #1f2147;
|
||||
--sl-color-accent: #8c9cf0;
|
||||
--sl-color-accent-high: #c9d2ff;
|
||||
|
||||
--rb-link: #aebcff;
|
||||
--rb-accent: #ff9a8f;
|
||||
--rb-surface: #252838;
|
||||
--rb-border: #3a3d52;
|
||||
--rb-border-strong: #5a5e7a;
|
||||
--rb-text-secondary: #a6adc8;
|
||||
--rb-ok-fg: #a5d6a7;
|
||||
--rb-ok-bg: #1b3a1f;
|
||||
--rb-warn-fg: #ffcc80;
|
||||
--rb-warn-bg: #7a2e00;
|
||||
--rb-err-fg: #ef9a9a;
|
||||
--rb-err-bg: #7f1414;
|
||||
--rb-info-fg: #90caf9;
|
||||
--rb-info-bg: #0d2a55;
|
||||
--rb-muted-fg: #cfd8dc;
|
||||
--rb-muted-bg: #37474f;
|
||||
--rb-level-start: #a5d6a7;
|
||||
--rb-level-setup: #90caf9;
|
||||
--rb-level-explore: #ce93d8;
|
||||
--rb-level-reference: #cfd8dc;
|
||||
--rb-level-contribute: #ffcc80;
|
||||
--rb-terminal-bg: #12131c;
|
||||
--rb-terminal-fg: #e6e6f0;
|
||||
--rb-terminal-label: #9aa0c3;
|
||||
--rb-diagram-fill: #252838;
|
||||
--rb-diagram-stroke: #7c82a3;
|
||||
--rb-diagram-accent: #aebcff;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--sl-color-accent-low: #e3e5f6;
|
||||
--sl-color-accent: #3b3f7a;
|
||||
--sl-color-accent-high: #2b2e5c;
|
||||
|
||||
--rb-link: #273c8f;
|
||||
--rb-accent: #b42318;
|
||||
--rb-surface: #f5f6fa;
|
||||
--rb-border: #e0e3eb;
|
||||
--rb-border-strong: #b8bdcc;
|
||||
--rb-text-secondary: #546e7a;
|
||||
--rb-ok-fg: #1b5e20;
|
||||
--rb-ok-bg: #e8f5e9;
|
||||
--rb-warn-fg: #a33f00;
|
||||
--rb-warn-bg: #fff3e0;
|
||||
--rb-err-fg: #b71c1c;
|
||||
--rb-err-bg: #ffebee;
|
||||
--rb-info-fg: #0d47a1;
|
||||
--rb-info-bg: #e3f2fd;
|
||||
--rb-muted-fg: #455a64;
|
||||
--rb-muted-bg: #eceff1;
|
||||
--rb-level-start: #1b5e20;
|
||||
--rb-level-setup: #0d47a1;
|
||||
--rb-level-explore: #6a1b9a;
|
||||
--rb-level-reference: #455a64;
|
||||
--rb-level-contribute: #a33f00;
|
||||
--rb-terminal-bg: #1b1d2e;
|
||||
--rb-diagram-fill: #f5f6fa;
|
||||
--rb-diagram-stroke: #8c92ac;
|
||||
--rb-diagram-accent: #3b3f7a;
|
||||
}
|
||||
|
||||
/* Links and focus */
|
||||
.sl-markdown-content a:not(.rb-button):not(.rb-badge):not(.rb-permalink) {
|
||||
color: var(--rb-link);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
.sl-markdown-content a:not(.rb-button):hover { text-decoration-thickness: 0.14em; }
|
||||
.sl-markdown-content a code { color: var(--rb-link); }
|
||||
:where(a, button, input, select, textarea, summary, abbr, [tabindex]):focus-visible {
|
||||
outline: 3px solid var(--rb-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Type scale */
|
||||
.sl-markdown-content h2 { font-weight: 600; letter-spacing: -0.005em; }
|
||||
.sl-markdown-content h3 { font-weight: 600; }
|
||||
.sl-markdown-content .rb-lead { font-size: 1.05em; line-height: 1.55; max-width: 44em; }
|
||||
|
||||
/* Breadcrumbs above the title */
|
||||
.rb-breadcrumbs ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0;
|
||||
margin: 0 0 0.4rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--sl-text-sm);
|
||||
color: var(--rb-text-secondary);
|
||||
}
|
||||
.rb-breadcrumbs li + li::before { content: '/'; padding: 0 0.45rem; color: var(--rb-border-strong); }
|
||||
.rb-breadcrumbs a {
|
||||
display: inline-block;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
color: var(--rb-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rb-breadcrumbs a:hover { color: var(--rb-link); text-decoration: underline; }
|
||||
|
||||
/* Level label above a title */
|
||||
.rb-level {
|
||||
display: inline-block;
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.6;
|
||||
color: var(--rb-level-reference);
|
||||
}
|
||||
.rb-level-start { color: var(--rb-level-start); }
|
||||
.rb-level-setup { color: var(--rb-level-setup); }
|
||||
.rb-level-explore { color: var(--rb-level-explore); }
|
||||
.rb-level-reference { color: var(--rb-level-reference); }
|
||||
.rb-level-contribute { color: var(--rb-level-contribute); }
|
||||
|
||||
/* Buttons */
|
||||
.sl-markdown-content .rb-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
margin: 0.25rem 0.4rem 0.25rem 0;
|
||||
border: 2px solid var(--rb-link);
|
||||
border-radius: 4px;
|
||||
color: var(--rb-link);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sl-markdown-content .rb-button-primary { background: var(--rb-link); color: var(--sl-color-black); }
|
||||
.sl-markdown-content .rb-button:is(:hover, :focus) {
|
||||
background: var(--rb-accent);
|
||||
border-color: var(--rb-accent);
|
||||
color: var(--sl-color-black);
|
||||
}
|
||||
.sl-markdown-content table .rb-button { font-size: 0.8rem; padding: 0.4rem 0.8rem; }
|
||||
|
||||
/* Badges */
|
||||
.rb-badge {
|
||||
display: inline-block;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-badge-success { background: var(--rb-ok-bg); color: var(--rb-ok-fg); }
|
||||
.rb-badge-warning { background: var(--rb-warn-bg); color: var(--rb-warn-fg); }
|
||||
.rb-badge-danger { background: var(--rb-err-bg); color: var(--rb-err-fg); }
|
||||
.rb-badge-info { background: var(--rb-info-bg); color: var(--rb-info-fg); }
|
||||
.rb-badge-muted { background: var(--rb-muted-bg); color: var(--rb-muted-fg); }
|
||||
.sl-markdown-content .rb-badge a,
|
||||
.sl-markdown-content a.rb-badge { color: inherit; }
|
||||
/* A badge link sits in a dense list: it keeps a 24 px target (WCAG 2.5.8). */
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) li:has(.rb-badge a) { min-height: 24px; line-height: 24px; }
|
||||
.sl-markdown-content .rb-badge a { display: inline-block; min-width: 24px; line-height: 24px; text-align: center; }
|
||||
|
||||
/* Headline figures */
|
||||
.rb-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.6rem 1rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: var(--rb-surface);
|
||||
}
|
||||
.sl-markdown-content .rb-stats > *,
|
||||
.sl-markdown-content .rb-stat > * { margin: 0; }
|
||||
.rb-stat-value { display: block; font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.rb-stat-label { display: block; font-size: 0.75rem; color: var(--rb-text-secondary); }
|
||||
|
||||
/* Logos float right beside the opening paragraph */
|
||||
.sl-markdown-content img[align='right'] {
|
||||
float: right;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
margin: 0 0 0.6rem 1rem;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.sl-markdown-content img[src*='assets/logos/'] { padding: 2px; border-radius: 2px; background: #fff; }
|
||||
.sl-markdown-content :is(h2, h3) img { display: inline; vertical-align: middle; margin: 0 0.4rem 0 0; }
|
||||
|
||||
/* Card grids */
|
||||
.rb-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.sl-markdown-content .rb-cards > .rb-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-bg);
|
||||
}
|
||||
.rb-card:is(:hover, :focus-within) { border-color: var(--rb-border-strong); }
|
||||
.sl-markdown-content .rb-card > * { margin: 0; }
|
||||
.sl-markdown-content .rb-card .rb-button { margin-top: auto; align-self: flex-start; }
|
||||
.sl-markdown-content .rb-card-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Numbered steps */
|
||||
.sl-markdown-content .rb-steps > ol {
|
||||
counter-reset: rb-step;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.sl-markdown-content .rb-steps > ol > li {
|
||||
counter-increment: rb-step;
|
||||
position: relative;
|
||||
margin: 0 0 1.2rem;
|
||||
padding-left: 2.6rem;
|
||||
min-height: 1.8rem;
|
||||
}
|
||||
.sl-markdown-content .rb-steps > ol > li::before {
|
||||
content: counter(rb-step);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border-radius: 4px;
|
||||
background: var(--rb-link);
|
||||
color: var(--sl-color-black);
|
||||
font-weight: 700;
|
||||
line-height: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Path chip */
|
||||
.sl-markdown-content code.rb-path {
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Inline coverage bar beside a number */
|
||||
.rb-bar {
|
||||
display: inline-block;
|
||||
width: 5rem;
|
||||
height: 0.4rem;
|
||||
margin-right: 0.4rem;
|
||||
border-radius: 2px;
|
||||
background: var(--rb-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rb-bar > span { display: block; width: var(--v, 0%); height: 100%; border-radius: 2px; background: var(--rb-ok-fg); }
|
||||
|
||||
/* Tables */
|
||||
.sl-markdown-content table { font-variant-numeric: tabular-nums; }
|
||||
.sl-markdown-content table th { font-weight: 600; }
|
||||
.sl-markdown-content table[role='region']:focus-visible { outline: 3px solid var(--rb-accent); outline-offset: 2px; }
|
||||
|
||||
/* Key and value cards: the empty header row is not drawn */
|
||||
.rb-meta-card thead,
|
||||
.sl-markdown-content table thead:not(:has(th:not(:empty))) { display: none; }
|
||||
.rb-meta-card td { overflow-wrap: anywhere; }
|
||||
.rb-meta-card td:first-child { font-weight: 600; color: var(--rb-text-secondary); white-space: nowrap; width: 140px; }
|
||||
|
||||
.rb-timestamp {
|
||||
font-size: 0.8rem;
|
||||
color: var(--rb-text-secondary);
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rb-border);
|
||||
}
|
||||
|
||||
/* File cards on system and emulator pages */
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) {
|
||||
border-top: 1px solid var(--rb-border);
|
||||
padding: 0.75rem 0 0.25rem;
|
||||
}
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) > :first-child { margin-top: 0; }
|
||||
.sl-markdown-content .rb-file-title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
.sl-markdown-content .rb-file-title code { font-weight: 700; }
|
||||
.sl-markdown-content .rb-search-skip > ul { margin-top: 0.1rem; }
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul {
|
||||
margin: 0.3rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) li { margin: 0.1rem 0; }
|
||||
.rb-hash {
|
||||
font-family: var(--sl-font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--rb-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rb-hash code { background: none; padding: 0; overflow-wrap: anywhere; }
|
||||
.rb-permalink {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--rb-text-secondary);
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
}
|
||||
:is(.rb-sys-file, .rb-file-entry):hover .rb-permalink,
|
||||
.rb-permalink:focus { opacity: 1; }
|
||||
:is(.rb-sys-file, .rb-file-entry):has(> .rb-file-title:target) { background: var(--rb-surface); }
|
||||
|
||||
/* Collapsible blocks keep the callout colour of their role */
|
||||
.sl-markdown-content details.rb-details {
|
||||
border-inline-start: 4px solid var(--sl-color-blue);
|
||||
background: var(--sl-color-blue-low);
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.sl-markdown-content details.rb-details-tip { border-color: var(--sl-color-purple); background: var(--sl-color-purple-low); }
|
||||
.sl-markdown-content details.rb-details-caution { border-color: var(--sl-color-orange); background: var(--sl-color-orange-low); }
|
||||
.sl-markdown-content details.rb-details-danger { border-color: var(--sl-color-red); background: var(--sl-color-red-low); }
|
||||
.sl-markdown-content details.rb-details > summary {
|
||||
min-height: 24px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
|
||||
|
||||
/* Terminal output: what the screen shows, not something to type */
|
||||
.sl-markdown-content .rb-terminal .expressive-code pre { background: var(--rb-terminal-bg); color: var(--rb-terminal-fg); }
|
||||
|
||||
/* Diagrams written by scripts/sitediagrams.py follow the theme */
|
||||
.sl-markdown-content .rb-figure { margin: 1.2rem 0 1.6rem; }
|
||||
.sl-markdown-content .rb-figure svg { display: block; width: 100%; height: auto; max-width: 52rem; color: var(--sl-color-text); }
|
||||
.sl-markdown-content .rb-figure :is(figcaption, .rb-figure-caption) {
|
||||
display: block;
|
||||
max-width: 44em;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--rb-text-secondary);
|
||||
}
|
||||
.rb-diagram text { font-family: var(--sl-font); fill: currentColor; }
|
||||
.rb-diagram .rb-d-box { fill: var(--rb-diagram-fill); stroke: var(--rb-diagram-stroke); }
|
||||
.rb-diagram .rb-d-accent { stroke: var(--rb-diagram-accent); }
|
||||
.rb-diagram .rb-d-line { stroke: var(--rb-diagram-stroke); }
|
||||
.rb-diagram .rb-d-head { fill: var(--rb-diagram-stroke); }
|
||||
.rb-diagram .rb-d-num { fill: var(--rb-diagram-accent); }
|
||||
.rb-diagram .rb-d-numtext { fill: var(--sl-color-bg); }
|
||||
.rb-diagram .rb-d-muted { fill: var(--rb-text-secondary); }
|
||||
.rb-diagram-narrow { display: none !important; }
|
||||
@media (max-width: 37.5em) {
|
||||
.rb-diagram-wide { display: none !important; }
|
||||
.rb-diagram-narrow { display: block !important; }
|
||||
}
|
||||
|
||||
/* Glossary terms */
|
||||
abbr.rb-term {
|
||||
position: relative;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 0.2em;
|
||||
cursor: help;
|
||||
}
|
||||
abbr.rb-term[data-open]::after {
|
||||
content: attr(data-definition);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: calc(100% + 0.3rem);
|
||||
left: 0;
|
||||
width: max-content;
|
||||
max-width: min(20rem, 80vw);
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Forms: lookup, picker, table filters */
|
||||
.rb-file-lookup,
|
||||
.rb-picker fieldset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.sl-markdown-content :is(.rb-file-lookup, .rb-picker) :is(input, select, button) {
|
||||
min-height: 44px;
|
||||
padding: 0 0.6rem;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-text);
|
||||
font: inherit;
|
||||
}
|
||||
.sl-markdown-content .rb-file-lookup input { flex: 1 1 20rem; font-family: var(--sl-font-mono); }
|
||||
.sl-markdown-content :is(.rb-file-lookup, .rb-picker) button {
|
||||
background: var(--rb-link);
|
||||
color: var(--sl-color-black);
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
}
|
||||
.rb-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
.rb-table-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 28rem) auto;
|
||||
gap: 0.35rem 0.8rem;
|
||||
align-items: end;
|
||||
margin: 1rem 0 0.45rem;
|
||||
}
|
||||
.sl-markdown-content .rb-table-filter > * { margin: 0; }
|
||||
.rb-table-filter label { grid-column: 1 / -1; color: var(--rb-text-secondary); font-size: 0.78rem; font-weight: 700; }
|
||||
.rb-table-filter input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--rb-text-secondary);
|
||||
border-radius: 2px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-text);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
.rb-table-filter-count { align-self: center; color: var(--rb-text-secondary); font-size: 0.8rem; white-space: nowrap; }
|
||||
|
||||
/* 404 */
|
||||
.sl-markdown-content .rb-notfound-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.55em 1.2em;
|
||||
border: 2px solid var(--rb-link);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--rb-link);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Licence line under every page */
|
||||
.rb-copyright { margin: 1.5rem 0 0; font-size: var(--sl-text-xs); color: var(--sl-color-gray-3); }
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
/* Tables with an action column become one block per row, so the button
|
||||
is never behind a horizontal scroll (site.ts marks them). */
|
||||
.sl-markdown-content table[data-rb-stack] { display: block; width: 100%; overflow: visible; }
|
||||
.sl-markdown-content table[data-rb-stack] thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
.sl-markdown-content table[data-rb-stack] :is(tbody, tr) { display: block; }
|
||||
.sl-markdown-content table[data-rb-stack] tr { padding: 0.5rem 0; border-top: 1px solid var(--rb-border); }
|
||||
.sl-markdown-content table[data-rb-stack] td {
|
||||
display: block;
|
||||
position: relative;
|
||||
min-height: 1.6rem;
|
||||
padding: 0.25rem 0.6rem 0.25rem 8.4rem;
|
||||
border: 0;
|
||||
text-align: left !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sl-markdown-content table[data-rb-stack] td::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0.6rem;
|
||||
width: 7.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--rb-text-secondary);
|
||||
}
|
||||
.sl-markdown-content table[data-rb-stack] td:empty { display: none; }
|
||||
|
||||
/* On a phone every command wraps: the end of a one-liner is never off-screen. */
|
||||
.sl-markdown-content .expressive-code pre > code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul,
|
||||
.rb-hash { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.rb-table-filter { grid-template-columns: 1fr; }
|
||||
.rb-table-filter label { grid-column: auto; }
|
||||
.rb-stats { grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); }
|
||||
.rb-stat-value { font-size: 1.1rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user