feat: build the site with Astro Starlight

This commit is contained in:
Abdessamad Derraz committed 2026-10-22 19:16:36 +02:00
commit 1c898ba389
86 files changed
+9118 -3291

No files matched your search

+21 -10
View File
@@ -29,7 +29,6 @@ on:
- "scripts/largefiles.py"
- "scripts/launchers.py"
- "scripts/manifestselect.py"
- "scripts/mkdocs_hooks.py"
- "scripts/nativemode.py"
- "scripts/packextras.py"
- "scripts/packpaths.py"
@@ -45,9 +44,11 @@ on:
- "scripts/sect233r1.py"
- "scripts/sitediagrams.py"
- "scripts/siteguides.py"
- "scripts/sitemarkup.py"
- "scripts/siteexports.py"
- "scripts/siterender.py"
- "scripts/sitesources.py"
- "scripts/sitestarlight.py"
- "scripts/sitewrite.py"
- "scripts/slot.py"
- "scripts/slots.py"
@@ -67,7 +68,9 @@ on:
- "database.json"
- "release.json"
- "derivable.json"
- "mkdocs.yml"
# The Starlight project: config, components, styles, lockfile. Its
# generated folders are gitignored and never trigger anything.
- "website/**"
- ".github/workflows/deploy-site.yml"
workflow_dispatch:
@@ -93,11 +96,18 @@ jobs:
with:
python-version: "3.12"
# mkdocs-material >= 9.7.5 caps mkdocs < 2, and the upper bound keeps a major
# theme release from changing the site without a deliberate bump here.
# mkdocs is pinned below 2 on its own too: MkDocs 2 drops plugins, and the
# theme, in maintenance mode since 9.7.0, will not follow it.
- run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "mkdocs>=1.6,<2" "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
- run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0"
# Node builds the site only: the scripts stay Python. The engine and
# its plugins are pinned exactly in website/package.json and resolved
# from the committed lockfile.
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: npm
cache-dependency-path: website/package-lock.json
- run: npm ci --prefix website
- name: Validate data contracts
run: python scripts/validate_schemas.py
@@ -120,8 +130,9 @@ jobs:
run: |
python scripts/generate_site.py
python scripts/generate_readme.py --db database.json --platforms-dir platforms
mkdocs build --strict
python scripts/validate_site.py
# starlight-links-validator fails the build on a dead link or anchor.
npm run build --prefix website
python scripts/validate_site.py --site-dir website/dist
# write_if_changed() compares content with its timestamp line stripped,
# so a run that only moves the clock leaves the file untouched and this
@@ -135,7 +146,7 @@ jobs:
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: site/
path: website/dist/
deploy:
needs: build
+16 -8
View File
@@ -29,7 +29,7 @@ on:
- "database.json"
- "release.json"
- "derivable.json"
- "mkdocs.yml"
- "website/**"
# Spelled out twice because the workflow parser reads no YAML anchor. A test
# holds the two lists equal.
push:
@@ -53,7 +53,7 @@ on:
- "database.json"
- "release.json"
- "derivable.json"
- "mkdocs.yml"
- "website/**"
permissions:
contents: read
@@ -213,10 +213,18 @@ jobs:
run: python3.9 -m pip install pyyaml "jsonschema[format-nongpl]==4.25.1"
# markdown: the site validation test renders a wiki line to prove the
# heading guard's premise, and skipped on every run without it. The docs
# group builds the site as deploy-site.yml does, with the same bounds.
# heading guard's premise, and skipped on every run without it.
- name: Install dependencies
run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "mkdocs>=1.6,<2" "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2"
# The site is built as deploy-site.yml builds it, from the lockfile.
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: npm
cache-dependency-path: website/package-lock.json
- run: npm ci --prefix website
# The exporter fidelity cases compare what we hand back against the
# file each platform publishes, so they need those files. They skip
@@ -234,7 +242,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# docs/ is gitignored, and the tests that read the rendered pages fail
# The pages are gitignored, and the tests that read them fail
# in CI without it. The suite runs whatever this step returned, so a
# generator that stops still leaves every other test reporting.
- name: Generate the site
@@ -256,8 +264,8 @@ jobs:
- name: Build the site
if: ${{ !cancelled() }}
run: |
mkdocs build --strict
python scripts/validate_site.py
npm run build --prefix website
python scripts/validate_site.py --site-dir website/dist
label-pr:
runs-on: ubuntu-latest
+2 -1
View File
@@ -13,7 +13,8 @@ site/
*.log
node_modules/
# Generated site pages (built by generate_site.py, deployed in CI)
# Pages of the MkDocs build the site had before Astro Starlight: a clone may
# still hold them. The Starlight project ignores its own generated folders.
docs/
# Large files stored as GitHub Release assets (> 50MB)
+1 -2
View File
@@ -59,8 +59,7 @@ validation above is its check. For code, profiles or platform files, these are
the checks CI runs on a pull request (Python 3.12 or newer):
```bash
pip install pyyaml "jsonschema[format-nongpl]" markdown \
"mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
pip install pyyaml "jsonschema[format-nongpl]" markdown
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
File renamed without changes.
File renamed without changes.
-977
View File
@@ -1,977 +0,0 @@
/* RetroBIOS theme on top of mkdocs-material.
Classes the generator and the wiki pages can use:
.rb-lead first paragraph under a page title
.rb-level-* level label (start, setup, explore, reference, contribute)
.rb-page-header logo or image beside the title block of an entity page
.rb-cards, .rb-card card grid without Material's list markup
.grid.cards Material card grid, restyled here
.rb-card-logo 40 px logo tile inside a card
.rb-steps ordered list drawn as numbered steps
.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
*/
:root {
--rb-primary: #3b3f7a;
--rb-primary-dark: #2b2e5c;
--rb-accent: #b42318;
--rb-link: #273c8f;
--rb-fg: rgba(0, 0, 0, 0.87);
--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-terminal-fg: #e6e6f0;
--rb-terminal-label: #9aa0c3;
--rb-diagram-fill: #f5f6fa;
--rb-diagram-stroke: #8c92ac;
--rb-diagram-accent: #3b3f7a;
--md-code-font: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
[data-md-color-scheme="slate"] {
--rb-accent: #ff9a8f;
--rb-link: #aebcff;
--rb-fg: #e3e4ee;
--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-diagram-fill: #252838;
--rb-diagram-stroke: #7c82a3;
--rb-diagram-accent: #aebcff;
}
/* Colour roles. The header keeps the brand indigo in both schemes: the
lighter indigo it used in dark mode left the tab labels at 3.5:1. */
[data-md-color-scheme="default"] {
--md-primary-fg-color: var(--rb-primary);
--md-primary-fg-color--light: #4a4e8a;
--md-primary-fg-color--dark: var(--rb-primary-dark);
--md-accent-fg-color: var(--rb-accent);
--md-typeset-a-color: var(--rb-link);
/* Comments and punctuation sat at 4.47:1 on the code background. */
--md-code-hl-comment-color: #5c5c5c;
--md-code-hl-generic-color: #5c5c5c;
--md-code-hl-variable-color: #5c5c5c;
--md-code-hl-operator-color: #5c5c5c;
--md-code-hl-punctuation-color: #5c5c5c;
}
[data-md-color-scheme="slate"] {
--md-primary-fg-color: var(--rb-primary-dark);
--md-primary-fg-color--light: var(--rb-primary);
--md-primary-fg-color--dark: #1f2147;
--md-accent-fg-color: var(--rb-accent);
--md-accent-bg-color: #1e2129;
--md-typeset-a-color: var(--rb-link);
--md-code-hl-constant-color: #b39ddb;
}
/* Type scale: the page title is the strongest heading on the page. */
.md-typeset h1 {
color: var(--md-default-fg-color);
font-size: 2em;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.25;
margin: 0 0 0.9em;
}
.md-typeset h2 {
font-size: 1.45em;
font-weight: 600;
letter-spacing: -0.005em;
margin-top: 2em;
}
.md-typeset h3 {
font-size: 1.15em;
font-weight: 600;
}
.md-typeset h4 {
font-weight: 600;
}
.md-typeset .rb-lead {
font-size: 1.05em;
line-height: 1.55;
max-width: 44em;
}
/* Level label under a page title */
.rb-level {
display: inline-block;
margin: 0 0 0.8rem;
padding: 0.05rem 0.45rem;
border: 1px solid currentColor;
border-radius: 2px;
font-size: 0.68rem;
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); }
/* Header block of platform, system and emulator pages */
.rb-page-header {
display: grid;
grid-template-columns: 56px 1fr;
gap: 0.4rem 1rem;
align-items: center;
margin: 0 0 1.2rem;
}
.rb-page-header > :first-child img,
.rb-page-header > img {
width: 56px;
height: 56px;
object-fit: contain;
}
.md-typeset .rb-page-header h1 {
margin: 0;
}
.rb-page-header p {
margin: 0.2rem 0 0;
}
/* A page that names its logo next to the title floats it right; the logo
keeps its own box instead of shifting the counters below. */
.md-typeset img[align="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;
}
/* Logos are drawn for a light background: they keep one in dark mode. */
.md-typeset img[src*="assets/logos/"] {
padding: 2px;
border-radius: 2px;
background: #fff;
}
/* Headline figures, read as one line of numbers: four fit on one row of
the text column. */
.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);
}
.md-typeset .rb-stat p {
margin: 0;
}
.rb-stat .rb-stat-value {
font-size: 1.25rem;
font-weight: 700;
display: block;
font-variant-numeric: tabular-nums;
}
.rb-stat .rb-stat-label {
font-size: 0.75rem;
color: var(--rb-text-secondary);
display: block;
}
.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); }
/* A link inside a badge keeps the badge's tested colour pair. */
.md-typeset .rb-badge a:not(.md-button),
.md-typeset a.rb-badge {
color: inherit;
}
/* Inline code inside a link reads as a link in both schemes. */
.md-typeset a code {
color: var(--rb-link);
}
/* Tables */
.md-typeset table:not([class]) {
font-variant-numeric: tabular-nums;
}
.md-typeset table:not([class]) th {
position: sticky;
top: 0;
z-index: 1;
font-weight: 600;
}
.md-typeset table:not([class]) th,
.md-typeset table:not([class]) td {
padding: 0.55em 0.9em;
}
.md-typeset__scrollwrap {
margin: 1rem 0;
}
/* A table lines up with the text around it. Material indents the table
by 0.8rem and offsets that with a negative margin on the wrapper, which
the margin above removes, and the table's own margin doubled the space
above it. */
.md-typeset .md-typeset__table {
padding: 0;
}
.md-typeset .md-typeset__table > table {
margin: 0;
}
.rb-timestamp {
font-size: 0.8rem;
color: var(--rb-text-secondary);
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--rb-border);
}
/* One block per file on emulator and system pages, separated by a rule */
.rb-file-entry,
.rb-sys-file {
border-top: 1px solid var(--rb-border);
padding: 0.75rem 0 0.25rem;
margin: 0;
}
.rb-file-entry > p:first-child,
.rb-sys-file > p:first-child {
margin-top: 0;
}
.rb-file-header code {
font-size: 0.95rem;
font-weight: 700;
}
/* A card's title is a heading so the search lands on it, set as body text */
.md-typeset .rb-file-title {
margin: 0 0 0.3rem;
font-size: inherit;
font-weight: 400;
letter-spacing: normal;
scroll-margin-top: 4rem;
}
.md-typeset .rb-file-title code {
font-weight: 700;
}
/* Lines the search skips continue the card's list */
.rb-search-skip > ul {
margin-top: 0.1rem;
}
.rb-file-entry ul,
.rb-sys-file ul {
margin: 0.3rem 0 0;
padding-left: 1.2rem;
font-size: 0.85rem;
}
.rb-file-entry li,
.rb-sys-file li {
margin: 0.1rem 0;
}
/* Hashes are printed whole so the search finds them, and wrap on phones */
.rb-hash {
font-family: var(--md-code-font);
font-size: 0.78rem;
color: var(--rb-text-secondary);
overflow-wrap: anywhere;
}
/* Key and value cards: the header row is empty, so it is not drawn and
not announced. */
.rb-meta-card thead,
.md-typeset 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;
}
.md-typeset table .md-button {
font-size: 0.75rem;
min-height: 44px;
padding: 0.55em 0.8em;
}
/* Buttons. The dark scheme draws them in the link colour: the header indigo
would sit at 3:1 on the page. */
[data-md-color-scheme="slate"] .md-typeset .md-button {
color: var(--rb-link);
border-color: var(--rb-link);
}
[data-md-color-scheme="slate"] .md-typeset .md-button--primary {
background-color: var(--rb-link);
color: #1e2129;
}
[data-md-color-scheme="slate"] .md-typeset .md-button:is(:hover, :focus) {
background-color: var(--rb-accent);
border-color: var(--rb-accent);
color: #1e2129;
}
.md-typeset a:not(.md-button):not(.headerlink):not(.rb-badge) {
color: var(--rb-link);
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.14em;
}
.md-typeset a:not(.md-button):not(.headerlink):hover {
text-decoration-thickness: 0.14em;
}
:where(a, button, input, select, textarea, label, [tabindex]):focus-visible {
outline: 3px solid var(--rb-accent);
outline-offset: 3px;
}
.md-typeset .md-button,
.md-typeset button,
.md-typeset input,
.md-typeset select {
min-height: 44px;
}
/* Buttons that wrap onto a second line keep a gap between the rows. */
.md-typeset .md-button {
margin-block: 0.25rem;
}
/* The copy button stays inside a one-line code block: at 44 px it hung
below the block and covered the end of the line. 24 px is the minimum
target size of WCAG 2.2. */
.md-typeset .md-code__button {
min-height: 0;
width: 24px;
height: 24px;
}
.md-typeset pre > code {
padding-right: 2.6rem;
}
.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;
}
.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%;
border: 1px solid var(--rb-text-secondary);
border-radius: 2px;
background: var(--md-default-bg-color);
color: var(--md-default-fg-color);
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;
}
.md-typeset__scrollwrap[role="region"]:focus-visible {
outline: 3px solid var(--rb-accent);
outline-offset: 2px;
}
/* Admonitions: one meaning per colour.
blue note, info, abstract, example, question, quote: background
teal tip: a shortcut
green success: what it looks like when it worked
orange warning: a risk of losing data or using the wrong file
red danger, failure, bug: something is broken or refused */
.md-typeset :is(.admonition, details):is(.info, .abstract, .example, .question, .quote, .note) {
border-color: #448aff;
}
.md-typeset :is(.info, .abstract, .example, .question, .quote, .note) > :is(.admonition-title, summary) {
background-color: rgba(68, 138, 255, 0.1);
}
.md-typeset :is(.info, .abstract, .example, .question, .quote, .note) > :is(.admonition-title, summary)::before {
background-color: #448aff;
-webkit-mask-image: var(--md-admonition-icon--info);
mask-image: var(--md-admonition-icon--info);
}
.md-typeset :is(.admonition, details).tip {
border-color: #00897b;
}
.md-typeset .tip > :is(.admonition-title, summary) {
background-color: rgba(0, 137, 123, 0.1);
}
.md-typeset .tip > :is(.admonition-title, summary)::before {
background-color: #00897b;
-webkit-mask-image: var(--rb-icon-shortcut);
mask-image: var(--rb-icon-shortcut);
}
.md-typeset :is(.admonition, details):is(.danger, .failure, .bug, .error) {
border-color: #d32f2f;
}
.md-typeset :is(.danger, .failure, .bug, .error) > :is(.admonition-title, summary) {
background-color: rgba(211, 47, 47, 0.1);
}
.md-typeset :is(.danger, .failure, .bug, .error) > :is(.admonition-title, summary)::before {
background-color: #d32f2f;
-webkit-mask-image: var(--md-admonition-icon--danger);
mask-image: var(--md-admonition-icon--danger);
}
:root {
--rb-icon-shortcut: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7M9 21a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-1H9z"/></svg>');
}
/* Code: shell commands wrap so the whole command shows on a phone, and the
copy button keeps its own column instead of covering the end. */
.md-typeset :is(.language-sh, .language-bash, .language-shell, .language-console,
.language-powershell, .language-ps1, .language-pwsh, .language-cmd, .language-bat) pre > code,
.md-typeset .rb-cmd pre > code {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding-right: 2.6rem;
}
/* Terminal output: what the screen shows, not something to type */
.md-typeset .rb-terminal pre,
.md-typeset .rb-terminal pre > code {
background: var(--rb-terminal-bg);
color: var(--rb-terminal-fg);
}
.md-typeset .rb-terminal pre {
border: 1px solid var(--rb-border);
border-radius: 4px;
}
.md-typeset .rb-terminal pre::before {
content: "Terminal output";
display: block;
padding: 0.5rem 1.2em 0;
font: 600 0.65rem var(--md-text-font-family);
color: var(--rb-terminal-label);
}
.md-typeset .rb-terminal .md-code__button {
color: var(--rb-terminal-label);
}
/* Numbered steps */
.md-typeset ol.rb-steps,
.md-typeset .rb-steps > ol {
counter-reset: rb-step;
list-style: none;
margin-left: 0;
padding-left: 0;
}
.md-typeset ol.rb-steps > li,
.md-typeset .rb-steps > ol > li {
counter-increment: rb-step;
position: relative;
margin: 0 0 1.2rem;
padding-left: 2.6rem;
min-height: 1.8rem;
}
.md-typeset ol.rb-steps > li::before,
.md-typeset .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(--md-primary-fg-color);
color: #fff;
font-weight: 700;
line-height: 1.8rem;
text-align: center;
}
[data-md-color-scheme="slate"] .md-typeset ol.rb-steps > li::before,
[data-md-color-scheme="slate"] .md-typeset .rb-steps > ol > li::before {
background: var(--rb-link);
color: #1e2129;
}
/* Card grids. Material's grid cards keep their markup; the hover shadow
gives way to a stronger border. */
.md-typeset .grid.cards {
--md-shadow-z2: 0 0 #0000;
}
.md-typeset .grid.cards > :is(ul, ol) > li,
.md-typeset .grid > .card {
border: 1px solid var(--rb-border);
border-radius: 4px;
}
.md-typeset .grid.cards > :is(ul, ol) > li:is(:hover, :focus-within),
.md-typeset .grid > .card:is(:hover, :focus-within) {
border-color: var(--rb-border-strong);
}
.rb-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.8rem;
margin: 1rem 0 1.5rem;
}
.rb-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.8rem;
border: 1px solid var(--rb-border);
border-radius: 4px;
background: var(--md-default-bg-color);
}
.rb-card:is(:hover, :focus-within) {
border-color: var(--rb-border-strong);
}
.md-typeset .rb-card p {
margin: 0;
}
.md-typeset .rb-card .md-button {
margin-top: auto;
align-self: flex-start;
}
.md-typeset .rb-card-logo,
.md-typeset .grid.cards img.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;
}
/* Path chip */
.md-typeset code.rb-path {
padding: 0.15rem 0.45rem;
border: 1px solid var(--rb-border);
border-radius: 2px;
font-size: 0.8em;
overflow-wrap: anywhere;
}
/* Content tabs (operating systems): the selected tab reads clearly. */
.md-typeset .tabbed-labels > label {
font-size: 0.75rem;
font-weight: 600;
color: var(--rb-text-secondary);
}
.md-typeset .tabbed-set > input:checked + label,
.md-typeset .tabbed-labels > label:hover {
color: var(--rb-link);
}
.md-typeset .tabbed-labels::before {
background: var(--rb-link);
}
/* 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);
}
/* Diagrams written by scripts/sitediagrams.py. Text takes the page font and
colour, boxes take the theme surface, so the drawing follows the palette
toggle. Each diagram carries a wide and a narrow layout. */
.md-typeset .rb-figure {
margin: 1.2rem 0 1.6rem;
}
.md-typeset .rb-figure svg {
display: block;
width: 100%;
height: auto;
max-width: 52rem;
color: var(--md-default-fg-color);
}
.md-typeset .rb-figure figcaption {
max-width: 44em;
margin: 0.4rem 0 0;
font-size: 0.75rem;
font-style: normal;
color: var(--rb-text-secondary);
text-align: left;
}
.rb-diagram text {
font-family: var(--md-text-font-family);
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(--md-default-bg-color);
}
.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;
}
}
/* The sidebar title stays legible over the list scrolling under it. With
tabs the title shown is the active section's label, held at the top of
the sidebar: a rule under it ends the list cleanly instead of a half
line of text touching the label. */
@media (min-width: 76.25em) {
.md-nav--primary > .md-nav__title,
.md-nav--secondary > .md-nav__title,
.md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link {
background: var(--md-default-bg-color);
border-bottom: 1px solid var(--rb-border);
}
.md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link {
padding-bottom: 0.4rem;
}
}
/* Material's compact footer defaults sit just below WCAG AA on the dark bar. */
.md-footer-meta .md-copyright {
color: #b5b5b5;
}
.md-footer-meta.md-typeset .md-copyright a {
color: #b8c6ff !important;
}
/* 404 page */
.rb-notfound-search {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.55em 1.2em;
border: 1px solid currentColor;
border-radius: 2px;
color: var(--rb-link);
font-weight: 700;
cursor: pointer;
}
@media (max-width: 44.984375em) {
/* Tables with an action column become one block per row, so the button
is never behind a horizontal scroll (site.js marks them). */
.md-typeset__table:has(table[data-rb-stack]) {
display: block;
}
.md-typeset table[data-rb-stack] {
display: block;
width: 100%;
}
.md-typeset table[data-rb-stack] thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.md-typeset table[data-rb-stack] tbody,
.md-typeset table[data-rb-stack] tr {
display: block;
}
.md-typeset table[data-rb-stack] tr {
padding: 0.5rem 0;
border-top: 1px solid var(--rb-border);
}
.md-typeset 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;
}
.md-typeset 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);
}
.md-typeset table[data-rb-stack] td .md-button {
margin: 0 0.3rem 0.3rem 0;
}
.md-typeset table[data-rb-stack] td:empty {
display: none;
}
/* Other wide tables scroll with their first column held in place. */
.md-typeset table:not([class]):not([data-rb-stack]) :is(td, th):first-child {
position: sticky;
left: 0;
min-width: min(6rem, 30vw);
max-width: 40vw;
background: var(--md-default-bg-color);
overflow-wrap: break-word;
}
/* A long link or path breaks anywhere, so it never squeezes the first
column down to one letter per line. */
.md-typeset table:not([class]):not([data-rb-stack]) td:not(:first-child) :is(a, code) {
overflow-wrap: anywhere;
}
.md-typeset table:not([class]):not([data-rb-stack]) th:first-child {
z-index: 2;
}
/* On a phone every command wraps: the end of a one-liner (the "| sh"
that makes it work) is never off-screen. Terminal output keeps its
columns and scrolls. */
.md-typeset pre > code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.md-typeset :is(.rb-terminal, .language-text) pre > code {
white-space: pre;
overflow-wrap: normal;
}
/* Text on reference pages stays at 12 px or more on a phone. */
.rb-file-entry ul,
.rb-sys-file ul,
.rb-hash {
font-size: 0.75rem;
}
.rb-page-header {
grid-template-columns: 1fr;
}
}
@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 .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;
}
}
/* A file block can be cited by URL: its link shows on hover, and the block a
link lands on is marked. */
.rb-permalink {
margin-left: 0.4rem;
color: var(--md-default-fg-color--light, var(--rb-border));
text-decoration: none;
opacity: 0;
}
.rb-file-entry:hover .rb-permalink,
.rb-sys-file:hover .rb-permalink,
.rb-permalink:focus {
opacity: 1;
}
.rb-file-entry:has(> .rb-file-title:target),
.rb-sys-file:has(> .rb-file-title:target) {
background: var(--rb-surface);
}
-37
View File
@@ -1,37 +0,0 @@
{% extends "main.html" %}
{#
The page a stale link lands on. Pages have moved before (file cards
refiled between manufacturers), so it offers the search, the two pages a
visitor most often wants, and the list a moved emulator, system or
platform page belongs to.
#}
{% block content %}
<h1>Page not found</h1>
<p class="rb-lead">This address has no page. It may have moved since the link was written.</p>
<p><label class="rb-notfound-search" for="__search" tabindex="0">Search the site</label></p>
<ul>
<li><a href="{{ config.site_url }}">Start here</a>: what BIOS files are and how to install them</li>
<li><a href="{{ config.site_url }}which-pack/">Get your files</a>: the pack or the command for your device</li>
<li>Browse <a href="{{ config.site_url }}platforms/">platforms</a>, <a href="{{ config.site_url }}systems/">systems</a> or <a href="{{ config.site_url }}emulators/">emulators</a></li>
</ul>
<p id="rb-notfound-hint" hidden></p>
<script>
(() => {
const match = window.location.pathname.match(/\/(emulators|systems|platforms)\/([^/]+)\/?$/);
const hint = document.getElementById("rb-notfound-hint");
if (!match || !hint) return;
const link = document.createElement("a");
link.href = "{{ config.site_url }}" + match[1] + "/";
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;
})();
document.querySelector(".rb-notfound-search")?.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.currentTarget.click();
}
});
</script>
{% endblock %}
-58
View File
@@ -1,58 +0,0 @@
{% extends "base.html" %}
{#
Open Graph and Twitter card tags.
mkdocs-material only emits these through its social plugin, which pulls in
Pillow and CairoSVG to render preview images. The pages here already carry a
per-page title and description, so the tags can be filled from those without
taking on two image dependencies. The links get shared on Discord, Reddit and
forums, where a bare URL with no preview is the difference between a card and
a grey rectangle.
One sharing card per section (docs_assets/og, made by
scripts/make_og_cards.py). The section is the page's level when the
generator gives one, otherwise it follows from the URL.
#}
{% block extrahead %}
{% set page_title = page.meta.title if page and page.meta and page.meta.title else (page.title if page and page.title else config.site_name) %}
{% set page_description = page.meta.description if page and page.meta and page.meta.description else config.site_description %}
{% set page_url = page.canonical_url if page and page.canonical_url else config.site_url %}
{% set url = page.url if page and page.url else '' %}
{% set level = page.meta.level if page and page.meta and page.meta.level else '' %}
{% if not url %}{% set section = 'home' %}
{% elif level in ['start', 'setup', 'explore', 'reference', 'contribute'] %}{% set section = level %}
{% elif url.startswith('which-pack') or url.startswith('wiki/first-install') or url.startswith('wiki/faq') or url.startswith('wiki/getting-started') %}{% set section = 'start' %}
{% elif url.startswith('platforms') %}{% set section = 'setup' %}
{% elif url.startswith('systems') or url.startswith('emulators') or url.startswith('files') %}{% set section = 'explore' %}
{% elif url.startswith('contributing') or url.startswith('wiki/adding') or url.startswith('wiki/profiling') or url.startswith('wiki/testing') or url.startswith('wiki/release') %}{% set section = 'contribute' %}
{% else %}{% set section = 'reference' %}{% endif %}
{% set preview_image = config.site_url ~ 'assets/og/' ~ section ~ '.png' if config.site_url else '' %}
{# The theme's favicon is the 32 px PNG. Browsers that read an SVG icon
take this one, listed after it, and a home-screen shortcut takes the
opaque 180 px icon (scripts/make_site_icons.py makes all three). #}
<link rel="icon" type="image/svg+xml" href="{{ 'assets/brand/favicon.svg' | url }}">
<link rel="apple-touch-icon" sizes="180x180" href="{{ 'assets/brand/apple-touch-icon.png' | url }}">
<meta property="og:type" content="{{ 'website' if not url else 'article' }}">
<meta property="og:site_name" content="{{ config.site_name }}">
<meta property="og:title" content="{{ page_title }}">
<meta property="og:description" content="{{ page_description }}">
{% if page_url %}<meta property="og:url" content="{{ page_url }}">{% endif %}
{% if preview_image %}
<meta property="og:image" content="{{ preview_image }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ config.site_name }}: {{ page_title }}">
{% endif %}
{# The sitemap, named from every page: a project site under /retrobios/
cannot serve the host-root robots.txt that would announce it. #}
{% if config.site_url %}<link rel="sitemap" type="application/xml" title="Sitemap" href="{{ config.site_url }}sitemap.xml">{% endif %}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ page_title }}">
<meta name="twitter:description" content="{{ page_description }}">
{% if preview_image %}<meta name="twitter:image" content="{{ preview_image }}">{% endif %}
{% endblock %}
-298
View File
@@ -1,298 +0,0 @@
/* 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();
}
})();
-879
View File
@@ -1,879 +0,0 @@
site_name: RetroBIOS
site_description: Source-verified BIOS and firmware packs for RetroArch, Batocera,
Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX,
KNULLI, AmberELEC, EmuELEC and MiSTer FPGA.
site_url: https://abdess.github.io/retrobios/
repo_url: https://github.com/Abdess/retrobios
repo_name: Abdess/retrobios
# Almost every page is generated from platforms/, emulators/ and database.json,
# so a per-page edit link would point at a file that does not exist.
edit_uri: ''
# lastmod in the sitemap from each page's own date, or none at all.
hooks:
- scripts/mkdocs_hooks.py
# Working notes kept under docs/ during development are not part of the site.
exclude_docs: |
plans/
specs/
copyright: MIT for the tooling. BIOS and firmware files are third-party system
software, preserved for personal backup, archival and interoperability.
theme:
name: material
# Open Graph tags live in overrides/main.html: the social plugin that
# would otherwise emit them needs Pillow and CairoSVG only to render a
# preview image the pages do not need.
custom_dir: docs_assets/overrides
palette:
- media: (prefers-color-scheme)
toggle:
icon: material/brightness-auto
name: Switch to light mode
- media: '(prefers-color-scheme: light)'
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: '(prefers-color-scheme: dark)'
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to auto
font: false
logo: assets/brand/logo.svg
favicon: assets/brand/favicon-32.png
icon:
logo: material/chip
features:
- navigation.instant
- navigation.instant.prefetch
- navigation.instant.progress
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.top
- navigation.tracking
- navigation.indexes
# Breadcrumbs above the title: on a deep page they say which tab it is in.
- navigation.path
# 400+ pages: pruning keeps the navigation out of every page's HTML.
- navigation.prune
- navigation.footer
- search.suggest
- search.highlight
- search.share
- content.code.copy
- content.tabs.link
- toc.follow
extra_css:
- stylesheets/extra.css
extra_javascript:
- javascripts/site.js
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/Abdess/retrobios
name: RetroBIOS on GitHub
markdown_extensions:
- abbr
- admonition
- attr_list
- def_list
- footnotes
- meta
- md_in_html
- tables
- toc:
permalink: true
# Level 3 is the wiki's subsections. On pages of file cards, also level
# 3, the hook keeps the table of contents to level 2.
toc_depth: 3
- pymdownx.details
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.keys
# No mermaid fence: Material drew it in the browser with a script from a
# CDN. Diagrams are SVG files written by scripts/sitediagrams.py.
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
plugins:
- search
# Link rot fails the build: deploy-site.yml runs `mkdocs build --strict`.
# omitted_files stays at its default (info) so a stale local docs/ does not
# break a local build. CI regenerates docs/ from scratch anyway.
validation:
absolute_links: warn
unrecognized_links: warn
anchors: warn
nav:
- Start here:
- index.md
- What BIOS files are: wiki/what-are-bios-files.md
- Get your files: which-pack.md
- First install, step by step: wiki/first-install.md
- When a game does not start: wiki/troubleshooting.md
- Common questions: wiki/faq.md
- Glossary: wiki/glossary.md
- Your setup:
- platforms/index.md
- Computers and Steam Deck:
- BizHawk: platforms/bizhawk.md
- EmuDeck: platforms/emudeck.md
- RetroArch: platforms/retroarch.md
- RetroBat: platforms/retrobat.md
- RetroDECK: platforms/retrodeck.md
- Game systems for PCs and small boards:
- Batocera: platforms/batocera.md
- Lakka: platforms/lakka.md
- Recalbox: platforms/recalbox.md
- RetroPie: platforms/retropie.md
- Handhelds and TV boxes:
- AmberELEC: platforms/amberelec.md
- EmuELEC: platforms/emuelec.md
- KNULLI: platforms/knulli.md
- MinUI: platforms/minui.md
- MyMinUI: platforms/myminui.md
- NextUI: platforms/nextui.md
- ROCKNIX: platforms/rocknix.md
- MiSTer FPGA:
- MiSTer FPGA: platforms/misterfpga.md
- Self-hosted game library:
- RomM: platforms/romm.md
- Explore:
- explore.md
- Systems:
- systems/index.md
- Consoles and computers:
- 3DO Company: systems/3do-company.md
- Acorn: systems/acorn.md
- Amstrad: systems/amstrad.md
- APF: systems/apf.md
- Apple: systems/apple.md
- Aquaplus: systems/aquaplus.md
- Arcade: systems/arcade.md
- Atari: systems/atari.md
- Bally: systems/bally.md
- Bandai: systems/bandai.md
- BBK: systems/bbk.md
- Bit Corporation: systems/bit-corporation.md
- Camputers: systems/camputers.md
- Casio: systems/casio.md
- Coleco: systems/coleco.md
- Commodore: systems/commodore.md
- DOS: systems/dos.md
- Dragon: systems/dragon.md
- EACA: systems/eaca.md
- Elektronika: systems/elektronika.md
- Enterprise: systems/enterprise.md
- Entex: systems/entex.md
- Epoch: systems/epoch.md
- Fairchild: systems/fairchild.md
- Fujitsu: systems/fujitsu.md
- Funtech: systems/funtech.md
- Galaksija: systems/galaksija.md
- GamePark: systems/gamepark.md
- GCE: systems/gce.md
- Gemei: systems/gemei.md
- Grundy: systems/grundy.md
- Hartung: systems/hartung.md
- IBM: systems/ibm.md
- Infocom: systems/infocom.md
- Interact: systems/interact.md
- Java: systems/java.md
- Lexaloffle: systems/lexaloffle.md
- Magnavox: systems/magnavox.md
- Mattel: systems/mattel.md
- Microsoft: systems/microsoft.md
- NEC: systems/nec.md
- Nintendo: systems/nintendo.md
- Nokia: systems/nokia.md
- Oric: systems/oric.md
- Other: systems/other.md
- Palm: systems/palm.md
- PC: systems/pc.md
- Philips: systems/philips.md
- Pioneer: systems/pioneer.md
- QEMU: systems/qemu.md
- RCA: systems/rca.md
- Real3D: systems/real3d.md
- Roland: systems/roland.md
- SDL: systems/sdl.md
- Sega: systems/sega.md
- Sharp: systems/sharp.md
- Sinclair: systems/sinclair.md
- SNK: systems/snk.md
- Sony: systems/sony.md
- Sord: systems/sord.md
- Synertek: systems/synertek.md
- Tandy: systems/tandy.md
- Tangerine: systems/tangerine.md
- Texas Instruments: systems/texas-instruments.md
- Thomson: systems/thomson.md
- Tiger: systems/tiger.md
- TimeTop: systems/timetop.md
- Tomy: systems/tomy.md
- VideoBrain: systems/videobrain.md
- Videoton: systems/videoton.md
- Vircon: systems/vircon.md
- VTech: systems/vtech.md
- Wenquxing: systems/wenquxing.md
- ZC: systems/zc.md
- Game engines and PC games:
- 3D Realms: systems/3d-realms.md
- Apogee: systems/apogee.md
- Blizzard: systems/blizzard.md
- Bungie: systems/bungie.md
- Dinothawr: systems/dinothawr.md
- Electronic Arts: systems/electronic-arts.md
- Epic MegaGames: systems/epic-megagames.md
- Game Engines: systems/game-engines.md
- Id Software: systems/id-software.md
- Interplay: systems/interplay.md
- Monolith: systems/monolith.md
- RPG Maker: systems/rpg-maker.md
- ScummVM: systems/scummvm.md
- sdlpal: systems/sdlpal.md
- Softdisk: systems/softdisk.md
- Toys for Bob: systems/toys-for-bob.md
- Valve: systems/valve.md
- Emulators:
- emulators/index.md
- 0 to 9:
- 2 Ship 2 Harkinian: emulators/2ship.md
- 2600.emu: emulators/2600-emu.md
- 3dSen: emulators/3dsen.md
- A to C:
- A:
- a5200: emulators/a5200.md
- A7800: emulators/a7800.md
- Abuse: emulators/abuse.md
- ACE-DL: emulators/ace-dl.md
- advanced_drastic: emulators/advanced_drastic.md
- AdvanceMAME: emulators/advancemame.md
- AdvanceMAME 0.94: emulators/advmame-0.94.md
- AdvanceMAME 1.4: emulators/advmame-1.4.md
- Adventure Game Studio: emulators/ags.md
- AetherSX2: emulators/aethersx2.md
- Aleph One: emulators/alephone.md
- Altirra: emulators/altirra.md
- amiarcadia: emulators/amiarcadia.md
- Amiberry: emulators/amiberry.md
- AppleWin: emulators/applewin.md
- aPS3e: emulators/aps3e.md
- ArcadeDuck: emulators/arcadeduck.md
- ArcadeFlashWeb: emulators/arcadeflashweb.md
- Ardens: emulators/ardens.md
- Arduous: emulators/arduous.md
- ares: emulators/ares.md
- ARMSX1: emulators/armsx1.md
- ARMSX2: emulators/armsx2.md
- Atari800: emulators/atari800.md
- aX360e: emulators/ax360e.md
- Azahar: emulators/azahar.md
- Azahar (SDL2): emulators/azahar-sdl.md
- AzaharPlus: emulators/azaharplus.md
- B:
- b2: emulators/b2.md
- Basilisk II: emulators/basiliskii.md
- BBKEmu: emulators/bbkemu.md
- beebem: emulators/beebem.md
- Beetle GBA (Mednafen): emulators/beetle_gba.md
- Beetle Lynx (Mednafen Lynx): emulators/beetle_lynx.md
- Beetle NGP (Mednafen Neo Geo Pocket): emulators/beetle_ngp.md
- Beetle PC-FX (Mednafen): emulators/beetle_pcfx.md
- Beetle PCE (Mednafen PCE): emulators/beetle_pce.md
- Beetle PSX (Mednafen PSX): emulators/beetle_psx.md
- Beetle VB (Mednafen Virtual Boy): emulators/beetle_vb.md
- Beetle WonderSwan (Mednafen WonderSwan): emulators/beetle_wswan.md
- beetle_saturn: emulators/beetle_saturn.md
- BennuGD: emulators/bennugd.md
- BennuGD monolithic (bgdi): emulators/bennugd-monolithic.md
- Bermuda Syndrome: emulators/bsyndrome.md
- Bermuda Syndrome (bs): emulators/bermuda.md
- BigPEmu: emulators/bigpemu.md
- BizHawk: emulators/bizhawk.md
- bk-emulator: emulators/bk.md
- BlastEm: emulators/blastem.md
- blueMSX: emulators/bluemsx.md
- bnes: emulators/bnes.md
- boom3: emulators/boom3.md
- Boytacean: emulators/boytacean.md
- bsnes: emulators/bsnes.md
- bsnes 2014: emulators/bsnes2014.md
- bsnes C++98: emulators/bsnes_cplusplus98.md
- bsnes-hd beta: emulators/bsnes_hd_beta.md
- bsnes-jg: emulators/bsnes-jg.md
- bsnes-mercury: emulators/bsnes_mercury.md
- BStone: emulators/bstone.md
- C:
- C-Dogs SDL: emulators/cdogs.md
- C64.emu: emulators/c64-emu.md
- Cannonball: emulators/cannonball.md
- CaPriCe Forever: emulators/capriceforever.md
- Caprice32: emulators/cap32.md
- CapriceRPI: emulators/capricerpi.md
- CatacombGL: emulators/catacombgl.md
- ccleste: emulators/ccleste.md
- CDi 2015: emulators/cdi2015.md
- Cemu: emulators/cemu.md
- Chihiro-DS: emulators/chihiro-ds.md
- ChimeraSNES: emulators/chimerasnes.md
- Chocolate Doom: emulators/chocolate-doom.md
- Citra: emulators/citra.md
- Citra 2018: emulators/citra2018.md
- Citra Canary: emulators/citra_canary.md
- Citra MMJ: emulators/citra-mmj.md
- Citron: emulators/citron.md
- Clock Signal (CLK): emulators/clk.md
- ClownMDEmu: emulators/clownmdemu.md
- ColEm: emulators/colem.md
- Commander Genius: emulators/cgenius.md
- CoolbarEmu: emulators/coolbaremu.md
- CoolCV: emulators/coolcv.md
- corsixth: emulators/corsixth.md
- CPCemu: emulators/cpcemu.md
- CrocoDS: emulators/crocods.md
- Cruzes: emulators/cruzes.md
- CSpect: emulators/cspect.md
- Cxbx-Reloaded: emulators/cxbx-reloaded.md
- Cxbx-Reloaded (31ff15b): emulators/cxbx-reloaded-2023.md
- D to F:
- D:
- D.Smile: emulators/dsmile.md
- DaedalusX64: emulators/daedalusx64.md
- Daphne: emulators/daphne.md
- DarkPlaces: emulators/darkplaces.md
- Demul: emulators/demul.md
- DeSmuME: emulators/desmume.md
- DeSmuME 2015: emulators/desmume2015.md
- devilutionx: emulators/devilutionx.md
- DGen/SDL: emulators/dgen.md
- dhewm3: emulators/dhewm3.md
- DICE: emulators/dice.md
- DingooEmu: emulators/dingooemu.md
- Dinothawr: emulators/dinothawr.md
- DirectXBox: emulators/directxbox.md
- DirkSimple: emulators/dirksimple.md
- Dolphin: emulators/dolphin.md
- Dolphin Launcher: emulators/dolphin_launcher.md
- Dolphin MMJR: emulators/dolphin-mmjr.md
- Dolphin MMJR2: emulators/dolphin-mmjr2.md
- DOSBox: emulators/dosbox.md
- DOSBox (SDL2 fork): emulators/dosbox-sdl2.md
- DOSBox Pure: emulators/dosbox_pure.md
- DOSBox Pure Unleashed: emulators/dosbox-pure-unleashed.md
- DOSBox Staging: emulators/dosbox-staging.md
- DOSBox-core: emulators/dosbox_core.md
- DOSBox-SVN: emulators/dosbox_svn.md
- DOSBox-SVN CE: emulators/dosbox_svn_ce.md
- DOSBox-X: emulators/dosbox-x.md
- DoubleCherryGB: emulators/doublecherrygb.md
- doukutsu-rs: emulators/doukutsu_rs.md
- DraStic: emulators/drastic.md
- DREAMM: emulators/dreamm.md
- DroidArcadia: emulators/droidarcadia.md
- dsda-doom: emulators/dsda-doom.md
- DSperate: emulators/dsperate.md
- DuckStation: emulators/duckstation.md
- DuckStation (legacy): emulators/duckstation-legacy.md
- Dusklight: emulators/dusklight.md
- dxx-rebirth: emulators/dxx-rebirth.md
- E:
- EasyRPG Player: emulators/easyrpg.md
- ECWolf: emulators/ecwolf.md
- Eden: emulators/eden.md
- EDuke32: emulators/eduke32.md
- EightyOne: emulators/81.md
- EightyOne (Recalbox): emulators/eightyone.md
- eka2l1: emulators/eka2l1.md
- EmuCoreV: emulators/emucorev.md
- EmuCoreX: emulators/emucorex.md
- EmuCV: emulators/emucv.md
- EmuSCV: emulators/emuscv.md
- emux (CHIP-8): emulators/emux_chip8.md
- emux (Game Boy): emulators/emux_gb.md
- emux (NES): emulators/emux_nes.md
- emux (SMS): emulators/emux_sms.md
- ep128emu-core: emulators/ep128emu.md
- ep128emu_core: emulators/ep128emu_core.md
- ePSXe: emulators/epsxe.md
- ET Legacy: emulators/etlegacy.md
- Eternal Computer: emulators/eternal.md
- Exel-Hence: emulators/exel-hence.md
- F:
- FAKE-08: emulators/fake08.md
- fallout1-ce: emulators/fallout1-ce.md
- fallout2-ce: emulators/fallout2-ce.md
- FB Alpha 2012: emulators/fbalpha2012.md
- FB Alpha 2012 CPS-1: emulators/fbalpha2012_cps1.md
- FB Alpha 2012 CPS-2: emulators/fbalpha2012_cps2.md
- FB Alpha 2012 CPS-3: emulators/fbalpha2012_cps3.md
- FB Alpha 2012 Neo Geo: emulators/fbalpha2012_neogeo.md
- FB Alpha 2019: emulators/fbalpha2019.md
- FBZX: emulators/fbzx.md
- FCEUmm: emulators/fceumm.md
- FCEUmm Mod: emulators/fceumm_mod.md
- fheroes2: emulators/fheroes2.md
- FinalBurn Neo: emulators/fbneo.md
- FinalBurn Neo (CPS-1/CPS-2): emulators/fbneo_cps12.md
- FinalBurn Neo (Neo Geo): emulators/fbneo_neogeo.md
- fixGB: emulators/fixgb.md
- fixNES: emulators/fixnes.md
- Flycast: emulators/flycast.md
- Flycast 2021: emulators/flycast2021.md
- Flycast 2022 Low-End: emulators/flycast2021le.md
- Flycast Dojo: emulators/flycast-dojo.md
- fMSX: emulators/fmsx.md
- FPse: emulators/fpse.md
- FPseNG: emulators/fpse-ng.md
- FreeChaF: emulators/freechaf.md
- FreeIntv: emulators/freeintv.md
- FreeJ2ME: emulators/freej2me.md
- FreeJ2ME-Plus: emulators/freej2me_plus.md
- Frodo: emulators/frodo.md
- Frotz: emulators/frotz.md
- FS-UAE: emulators/fsuae.md
- FS-UAE 3.1: emulators/fs-uae.md
- Fuse: emulators/fuse.md
- future-pinball: emulators/future-pinball.md
- future-pinball-fploader: emulators/future-pinball-fploader.md
- G to L:
- G:
- galaksija: emulators/galaksija.md
- GAM4980: emulators/gam4980.md
- Gambatte: emulators/gambatte.md
- Game & Watch: emulators/gw.md
- GameTank: emulators/gametank.md
- Gargoyle: emulators/gargoyle.md
- GBA.emu: emulators/gba-emu.md
- GBC.emu: emulators/gbc-emu.md
- Gearboy: emulators/gearboy.md
- Gearcoleco: emulators/gearcoleco.md
- Geargrafx: emulators/geargrafx.md
- Gearlynx: emulators/gearlynx.md
- Gearsystem: emulators/gearsystem.md
- Geartowns: emulators/geartowns.md
- GemRB: emulators/gemrb.md
- Genesis Plus GX: emulators/genesis_plus_gx.md
- Genesis Plus GX Expanded: emulators/genesis_plus_gx_expanded.md
- Genesis Plus GX Wide: emulators/genesis_plus_gx_wide.md
- Geolith: emulators/geolith.md
- Ghostship: emulators/ghostship.md
- GnGeo-Pi: emulators/gngeopi.md
- Gopher2600: emulators/gopher2600.md
- gopher64: emulators/gopher64.md
- gpSP: emulators/gpsp.md
- GroovyMAME: emulators/groovymame.md
- GSplus: emulators/gsplus.md
- GSplus (legacy): emulators/gsplus-legacy.md
- GZDoom: emulators/gzdoom.md
- H:
- hakuX: emulators/hakux.md
- Handy: emulators/handy.md
- Hatari: emulators/hatari.md
- Hatari 2014: emulators/hatari2014.md
- hatariB: emulators/hatarib.md
- HBMAME (Homebrew MAME): emulators/hbmame.md
- higan (SFC Accuracy): emulators/higan_sfc.md
- hode: emulators/hode.md
- Holani: emulators/holani.md
- Hurrican: emulators/hurrican.md
- Hydra Castle Labyrinth: emulators/hcl.md
- Hypseus Singe: emulators/hypseus-singe.md
- I:
- idTech4A++: emulators/idtech4a++.md
- Ikemen GO: emulators/ikemen.md
- iNES: emulators/ines.md
- Infinity: emulators/infinity.md
- Ion Fury: emulators/fury.md
- ioquake3: emulators/ioquake3.md
- ioRTCW: emulators/iortcw.md
- IrataJaguar: emulators/iratajaguar.md
- IroGB: emulators/irogb.md
- Ishiiruka: emulators/ishiiruka.md
- izapple2: emulators/izapple2.md
- J:
- J2ME Loader: emulators/j2me-loader.md
- JAXE: emulators/jaxe.md
- Jazz² Resurrection: emulators/jazz2-native.md
- jgenesis: emulators/jgenesis.md
- JL-Mod: emulators/jl-mod.md
- JollyCV: emulators/jollycv.md
- Julius: emulators/julius.md
- Jynx: emulators/jynx.md
- jzIntv: emulators/jzintv.md
- K:
- Kega Fusion: emulators/kega-fusion.md
- KEGS: emulators/kegs.md
- KEmulator nnmod: emulators/kemulator.md
- Kenji-NX: emulators/kenji-nx.md
- Kronos: emulators/kronos.md
- L:
- Lexaloffle: emulators/lexaloffle.md
- lightspark: emulators/lightspark.md
- Lime3DS: emulators/lime3ds.md
- LinApple: emulators/linapple.md
- Lindbergh Loader: emulators/lindbergh-loader.md
- Linkboy: emulators/linkboy.md
- Linux Loader: emulators/linuxloader.md
- LowRes NX: emulators/lowresnx.md
- LRPS2: emulators/lrps2.md
- Lynx.emu: emulators/lynx-emu.md
- LZDoom: emulators/lzdoom.md
- M to O:
- M:
- M2000: emulators/m2000.md
- M64Plus FZ: emulators/m64plus-fz.md
- MagicEngine: emulators/magicengine.md
- MAME: emulators/mame.md
- MAME 0.258: emulators/mame0258.md
- MAME 0.278: emulators/mame0278.md
- MAME 2000: emulators/mame2000.md
- MAME 2003: emulators/mame2003.md
- MAME 2003 Midway: emulators/mame2003_midway.md
- MAME 2003-Plus: emulators/mame2003_plus.md
- MAME 2009: emulators/mame2009.md
- MAME 2010: emulators/mame2010.md
- MAME 2014: emulators/mame2014.md
- MAME 2015: emulators/mame2015.md
- MAME 2016: emulators/mame2016.md
- MAME Arcade: emulators/mamearcade.md
- MAME MESS: emulators/mamemess.md
- MAME4ALL-Pi: emulators/mame4all.md
- MAME4droid (0.139u1): emulators/mame4droid.md
- MAME4droid 2024: emulators/mame4droid-2024.md
- Mandarine: emulators/mandarine.md
- Mariani: emulators/mariani.md
- MasterGear: emulators/mastergear.md
- MD.emu: emulators/md-emu.md
- Mednafen: emulators/mednafen.md
- Mednafen STV: emulators/mednafen_stv.md
- mednafen_gba: emulators/mednafen_gba.md
- mednafen_pce: emulators/mednafen_pce.md
- mednafen_pce_fast: emulators/mednafen_pce_fast.md
- mednafen_snes: emulators/mednafen_snes.md
- mednafen_supafaust: emulators/mednafen_supafaust.md
- mednafen_supergrafx: emulators/mednafen_supergrafx.md
- melonDS: emulators/melonds.md
- melonDS DS: emulators/melonds_ds.md
- melonDS Nightly: emulators/melonds-nightly.md
- Mesen: emulators/mesen.md
- mesen-s: emulators/mesen-s.md
- MesenCE: emulators/mesence.md
- MESS 2015: emulators/mess2015.md
- Meteor GBA: emulators/meteor.md
- MFME: emulators/mfme.md
- mGBA: emulators/mgba.md
- mGBA Splitscreen: emulators/mgba_splitscreen.md
- Mini vMac: emulators/minivmac.md
- Mini vMac 37: emulators/minivmac37.md
- mkxp-z: emulators/mkxp_z.md
- Model 2 Emulator: emulators/model2.md
- MojoZork: emulators/mojozork.md
- MSX.emu: emulators/msx-emu.md
- Mu: emulators/mu.md
- Mupen64Plus: emulators/mupen64plus.md
- Mupen64Plus (libretro, 2019): emulators/mupen64plus-libretro.md
- Mupen64Plus AE: emulators/mupen64plus-ae.md
- Mupen64Plus-Next: emulators/mupen64plus_next.md
- My Boy!: emulators/my-boy.md
- My OldBoy!: emulators/my-oldboy.md
- N:
- NanoBoyAdvance: emulators/nanoboyadvance.md
- Native32Emu: emulators/native32emu.md
- nekop2: emulators/nekop2.md
- NEO.emu: emulators/neo-emu.md
- NeoCD: emulators/neocd.md
- nes: emulators/nes.md
- NES.emu: emulators/nes-emu.md
- Nesoid: emulators/nesoid.md
- Nestopia UE: emulators/nestopia.md
- NetherSX2: emulators/nethersx2.md
- NetherSX2-Turnip: emulators/nethersx2-turnip.md
- NetherSX2-Turnip Classic: emulators/nethersx2-turnip-classic.md
- NGP.emu: emulators/ngp-emu.md
- NicaiEmu: emulators/nicaiemu.md
- no$gba: emulators/nosgba.md
- NooDS: emulators/noods.md
- NP2kai: emulators/np2kai.md
- np2pi: emulators/np2pi.md
- nSide (SFC Balanced): emulators/higan_sfc_balanced.md
- Nuance: emulators/nuance.md
- Numero: emulators/numero.md
- NXEngine: emulators/nxengine.md
- O:
- O2EM: emulators/o2em.md
- Oberon: emulators/oberon.md
- Omicron: emulators/omicron.md
- ONScripter: emulators/onscripter.md
- ONScripter Yuri: emulators/onsyuri.md
- openbor: emulators/openbor.md
- OpenGOAL: emulators/opengoal.md
- OpenJazz: emulators/openjazz.md
- openjk: emulators/openjk.md
- OpenJKDF2: emulators/openjkdf2.md
- OpenLara: emulators/openlara.md
- OpenMoHAA: emulators/openmohaa.md
- openMSX: emulators/openmsx.md
- OpenTTD: emulators/openttd.md
- OpenTyrian: emulators/opentyrian.md
- Opera (4DO): emulators/opera.md
- Oricutron: emulators/oricutron.md
- Osmose (RetroPie): emulators/osmose.md
- P to R:
- P:
- P/EMU/SDL: emulators/piemu.md
- Panda3DS: emulators/panda3ds.md
- Parallel Launcher: emulators/parallel-launcher.md
- ParaLLEl N64: emulators/parallel_n64.md
- pce-emu: emulators/pce-emu.md
- PCEE2: emulators/pcee2.md
- PCem: emulators/pcem.md
- PCSX-ReARMed: emulators/pcsx_rearmed.md
- PCSX-ReARMed (standalone): emulators/pcsx_rearmed_standalone.md
- PCSX1: emulators/pcsx1.md
- PCSX2: emulators/pcsx2.md
- PCSX2 Legacy: emulators/pcsx2-legacy.md
- PCSX2x6: emulators/pcsx2x6.md
- PD777: emulators/pd777.md
- Perfect Dark (PC port): emulators/pdark.md
- Phoenix: emulators/phoenix.md
- Picocomputer 6502: emulators/rp6502.md
- PicoDrive: emulators/picodrive.md
- piece-emu: emulators/piece.md
- piFBA (fba2x): emulators/fba2x.md
- PiSNES: emulators/pisnes.md
- Pizza Boy GBA: emulators/pizza-boy-gba.md
- Pizza Boy GBC: emulators/pizza-boy-gbc.md
- Pizza Boy SC: emulators/pizza-boy-sc.md
- Plastic: emulators/plastic.md
- play: emulators/play.md
- PlaydiaEmu: emulators/playdiaemu.md
- PokeMini: emulators/pokemini.md
- pokketstation: emulators/pokketstation.md
- POM2: emulators/pom2.md
- Potator: emulators/potator.md
- PPSSPP: emulators/ppsspp.md
- PPSSPP 1.5.4: emulators/ppsspp-1.5.4.md
- PrBoom: emulators/prboom.md
- PrBoom+: emulators/prboom-plus.md
- PrimeHack: emulators/primehack.md
- project64: emulators/project64.md
- ProSystem: emulators/prosystem.md
- pSxMAME: emulators/psxmame.md
- PUAE (P-UAE): emulators/puae.md
- PUAE 2021: emulators/puae2021.md
- puNES: emulators/punes.md
- px68k: emulators/px68k.md
- px68k (SDL): emulators/px68k-sdl.md
- Q:
- QEMU: emulators/qemu.md
- Quake 3 (Raspberry Pi): emulators/quake3.md
- QUASI88: emulators/quasi88.md
- QUASI88kai: emulators/quasi88kai.md
- QuickNES: emulators/quicknes.md
- R:
- RACE (Neo Geo Pocket): emulators/race.md
- radio: emulators/radio.md
- RAINE: emulators/raine.md
- Raze: emulators/raze.md
- RB5000: emulators/rb5000.md
- Real3DOPlayer: emulators/real3doplayer.md
- RealRTCW: emulators/realrtcw.md
- Redream: emulators/redream.md
- Reicast: emulators/reicast.md
- REminiscence: emulators/reminiscence.md
- Retro8: emulators/retro8.md
- RetroDream: emulators/retrodream.md
- RigelEngine: emulators/rigelengine.md
- Rosalie's Mupen GUI: emulators/rosalies-mupen-gui.md
- RPCS3: emulators/rpcs3.md
- rpix86: emulators/rpix86.md
- ruffle: emulators/ruffle.md
- Rust-DOS: emulators/rust_dos.md
- Rustation: emulators/rustation.md
- RustyNES: emulators/rustynes.md
- RVVM: emulators/rvvm.md
- Ryujinx: emulators/ryujinx.md
- S to U:
- S:
- SAME CDi: emulators/same_cdi.md
- SameBoy: emulators/sameboy.md
- sameduck: emulators/sameduck.md
- Saturn.emu: emulators/saturn-emu.md
- ScummVM: emulators/scummvm.md
- ScummVM (SDL1): emulators/scummvm-sdl1.md
- SDL2TRS: emulators/sdl2trs.md
- SDLPAL: emulators/sdlpal.md
- sdlpop: emulators/sdlpop.md
- shadps4: emulators/shadps4.md
- SheepShaver: emulators/sheepshaver.md
- Ship of Harkinian: emulators/soh.md
- SimCoupe: emulators/simcp.md
- SimCoupe (standalone): emulators/samcoupe.md
- simple64: emulators/simple64.md
- Singe 2: emulators/singe2.md
- sixtyforce: emulators/sixtyforce.md
- skyemu: emulators/skyemu.md
- Skyline: emulators/skyline.md
- SMS Plus GX: emulators/smsplus.md
- snes9x: emulators/snes9x.md
- Snes9x 2002: emulators/snes9x2002.md
- Snes9x 2005: emulators/snes9x2005.md
- Snes9x 2005 Plus: emulators/snes9x2005_plus.md
- Snes9x 2010: emulators/snes9x2010.md
- Snes9x EX+: emulators/snes9x-explus.md
- solarus: emulators/solarus.md
- Solarus 2.0.0: emulators/solarus-2.0.0.md
- Sonic 1 & 2 (2013) Decompilation: emulators/sonic2013.md
- Sonic 3 A.I.R.: emulators/sonic3-air.md
- Sonic CD Decompilation: emulators/soniccd.md
- Sonic Mania Decompilation: emulators/sonic-mania.md
- Sonic Robo Blast 2: emulators/srb2.md
- Speccy: emulators/speccy.md
- SplitWolf: emulators/splitwolf.md
- SPMP8000Emu: emulators/spmp8000emu.md
- SquirrelJME: emulators/squirreljme.md
- SSF: emulators/ssf.md
- Starboard: emulators/starboard.md
- Starship: emulators/starship.md
- Stella: emulators/stella.md
- Stella 2014: emulators/stella2014.md
- Stella 2023: emulators/stella2023.md
- Sudachi: emulators/sudachi.md
- Sugarbox2: emulators/sugarbox2.md
- Super Mario War: emulators/supermariowar.md
- SUPER ZSNES: emulators/superzsnes.md
- SUPER3: emulators/super3.md
- Supermodel: emulators/supermodel.md
- Supermodel (Sinden fork): emulators/supermodel3.md
- Supermodel Dojo: emulators/supermodel-dojo.md
- Supermodel Legacy: emulators/supermodel-legacy.md
- SuperSnes9x: emulators/supersnes9x.md
- SuperTux: emulators/supertux.md
- SuperTuxKart: emulators/supertuxkart.md
- Suyu: emulators/suyu.md
- Swan.emu: emulators/swan-emu.md
- swanstation: emulators/swanstation.md
- SWF Player: emulators/swf-player.md
- Syobon Action: emulators/syobonaction.md
- T:
- TamaLIBretro: emulators/tamalibretro.md
- Taradino: emulators/taradino.md
- TeknoParrot: emulators/teknoparrot.md
- TempGBA: emulators/tempgba.md
- TGB Dual: emulators/tgbdual.md
- The Force Engine: emulators/theforceengine.md
- Theodore: emulators/theodore.md
- TheXTech: emulators/thextech.md
- ti99sim: emulators/ti99sim.md
- ti99sim-sdl1: emulators/ti99sim-sdl1.md
- Tia: emulators/tia.md
- TIC-80: emulators/tic80.md
- touchHLE: emulators/touchhle.md
- TR1X: emulators/tr1x.md
- TR2X: emulators/tr2x.md
- trident: emulators/trident.md
- Triforce: emulators/triforce.md
- tsugaru: emulators/tsugaru.md
- TyrQuake: emulators/tyrquake.md
- U:
- UAE4ALL (libretro, Chips-fr): emulators/uae4all-rpi.md
- UAE4All2: emulators/uae4all.md
- uae4arm: emulators/uae4arm.md
- UME 2015: emulators/ume2015.md
- uqm: emulators/uqm.md
- UZDoom: emulators/uzdoom.md
- uzem: emulators/uzem.md
- V to Z:
- V:
- VaporSpec: emulators/vaporspec.md
- VBA-M: emulators/vbam.md
- VBA-Next: emulators/vba_next.md
- vecx: emulators/vecx.md
- veesem: emulators/veesem.md
- VeMUlator: emulators/vemulator.md
- VICE: emulators/vice.md
- VICE x128: emulators/vice_x128.md
- VICE x64: emulators/vice_x64.md
- VICE x64dtv: emulators/vice_x64dtv.md
- VICE x64sc: emulators/vice_x64sc.md
- VICE xcbm2: emulators/vice_xcbm2.md
- VICE xcbm5x0: emulators/vice_xcbm5x0.md
- VICE xpet: emulators/vice_xpet.md
- VICE xplus4: emulators/vice_xplus4.md
- VICE xscpu64: emulators/vice_xscpu64.md
- VICE xvic: emulators/vice_xvic.md
- Vircon32: emulators/vircon32.md
- Virtual Jaguar: emulators/virtualjaguar.md
- Virtual Virtual Boy: emulators/virtual-virtual-boy.md
- VirtualXT: emulators/virtualxt.md
- visual-pinball: emulators/visual-pinball.md
- Vita3K: emulators/vita3k.md
- vitaQuakeII: emulators/vitaquake2.md
- vitaQuakeIII: emulators/vitaquake3.md
- vitavoyager: emulators/vitavoyager.md
- vkQuake: emulators/vkquake.md
- vkQuake2: emulators/vkquake2.md
- vkQuake3: emulators/vkquake3.md
- VVVVVV: emulators/vvvvvv.md
- W:
- WinUAE: emulators/winuae.md
- Wolf4SDL: emulators/wolf4sdl.md
- WQXEmu: emulators/wqxemu.md
- X:
- X Millennium: emulators/x1.md
- X1 BOX: emulators/x1-box.md
- x16emu: emulators/x16emu.md
- x64sdl: emulators/x64sdl.md
- Xash3D FWGS: emulators/xash3d_fwgs.md
- xemu: emulators/xemu.md
- xemu Chihiro: emulators/xemu-chihiro.md
- XenDroid: emulators/xendroid.md
- Xenia Canary: emulators/xenia.md
- Xenia Edge: emulators/xenia-edge.md
- XeniOS: emulators/xenios.md
- xm6pro68k: emulators/xm6pro68k.md
- xm6typeg: emulators/xm6typeg.md
- XM7: emulators/xm7.md
- XRick: emulators/xrick.md
- XRoar: emulators/xroar.md
- Y:
- yabasanshiro: emulators/yabasanshiro.md
- yabause: emulators/yabause.md
- Yamagi Quake II: emulators/yquake2.md
- yaps2: emulators/yaps2.md
- ymir: emulators/ymir.md
- Yuzu: emulators/yuzu.md
- Z:
- Zelda Classic 2.55 Prototype: emulators/zc255.md
- Zelda Classic v2.10: emulators/zc210.md
- ZEsarUX: emulators/zesarux.md
- ZiNc: emulators/zinc.md
- ZQuest Classic: emulators/zquestclassic.md
- All installer options: wiki/installer.md
- Is it legal?: wiki/legal.md
- Reference:
- wiki/index.md
- How each platform checks files: wiki/verification-modes.md
- What the packs do not hold: gaps.md
- Cores by platform: cross-reference.md
- Dump provenance: provenance.md
- Updated platform files: exports.md
- Data and API: data.md
- Find a file: files/index.md
- Data model: wiki/data-model.md
- Architecture: wiki/architecture.md
- Build your own packs: wiki/advanced-usage.md
- Command-line tools: wiki/tools.md
- Technical questions: wiki/technical-faq.md
- Project terms: wiki/reference-terms.md
- Projects using this data: wiki/community-tools.md
- Contribute:
- contributing.md
- Profiling an emulator: wiki/profiling.md
- Adding a platform: wiki/adding-a-platform.md
- Adding a scraper: wiki/adding-a-scraper.md
- Testing: wiki/testing-guide.md
- Release process: wiki/release-process.md
+2 -11
View File
@@ -18,17 +18,8 @@ dev = [
"jsonschema[format-nongpl]>=4.23",
"markdown>=3.7",
]
# The site build the repo generates and documents. Both were hard
# requirements of deploy-site.yml and of wiki/release-process.md while being
# declared nowhere, so a contributor following either had to read the
# workflow to learn what to install.
docs = [
# MkDocs 2 drops plugins, and the theme, in maintenance since 9.7.0,
# will not follow it.
"mkdocs>=1.6,<2",
"mkdocs-material>=9.7.5,<10",
"pymdown-extensions>=10.14",
]
# The site is built by Astro Starlight from website/package.json and its
# lockfile (npm ci --prefix website): no Python package builds it.
[project.urls]
Repository = "https://github.com/Abdess/retrobios"
+1 -1
View File
@@ -219,7 +219,7 @@ def hold_artifact_lock(directory: str) -> Iterator[None]:
# What a pipeline run writes at fixed paths, whatever its --output-dir.
REPOSITORY_OUTPUTS = ("database.json", "install/", "README.md", "CONTRIBUTING.md", "docs/")
REPOSITORY_OUTPUTS = ("database.json", "install/", "README.md", "CONTRIBUTING.md", "website/")
@contextlib.contextmanager
+16 -11
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Generate slim README.md from database.json and platform configs.
Detailed documentation lives on the MkDocs site (abdess.github.io/retrobios/).
Detailed documentation lives on the site (abdess.github.io/retrobios/).
This script produces a concise landing page with download links and coverage.
Usage:
@@ -230,14 +230,16 @@ def release_totals(platform_name: str, record: dict) -> tuple[int | None, int |
def download_links(
platform_name: str, record: dict, repo: str, label: str, attrs: str = ""
platform_name: str, record: dict, repo: str, label: str, attrs: str = "",
button: Callable[[str, str], str] | None = None,
) -> str:
"""Markdown links to the files of a platform's pack in the recorded release.
A pack published whole gets one link labelled *label*. A pack in parts
gets one link per part, since every part is needed. *repo* is the
repository URL, absolute or relative, and *attrs* a MkDocs attribute list
put after each link. Without a recorded release the link is the latest
repository URL, absolute or relative, and *attrs* an attribute list
put after each link, or *button* the function that draws each link as a
button on the site. Without a recorded release the link is the latest
release page. A release recorded without this platform's pack says so and
names the installer, which reads the file lists on main: a button to the
release served six platforms a page with no pack for them.
@@ -245,18 +247,22 @@ def download_links(
pack = release_record.pack_for(platform_name, record)
tag = record.get("tag")
assets = pack.get("assets") if pack else None
def link(text: str, url: str) -> str:
return button(text, url) if button else f"[{text}]({url}){attrs}"
if not tag:
return f"[{label}]({repo}/releases/latest){attrs}"
return link(label, f"{repo}/releases/latest")
if not assets:
return f"Not in {tag}: [use the installer]({SITE_URL}wiki/installer/)"
urls = [f"{repo}/releases/download/{tag}/{asset}" for asset in assets]
if len(urls) == 1:
return f"[{label}]({urls[0]}){attrs}"
return link(label, urls[0])
styled = bool(attrs or button)
parts = [
f"[{'P' if attrs else 'p'}art {number} of {len(urls)}]({url}){attrs}"
link(f"{'P' if styled else 'p'}art {number} of {len(urls)}", url)
for number, url in enumerate(urls, 1)
]
if attrs:
if styled:
return " ".join(parts)
return f"{label}: " + ", ".join(parts)
@@ -831,7 +837,7 @@ def contributing_text(
Two copies drifted apart: four steps to add a platform here, five on the
site. *link* renders a site page (absolute URL for the README, relative
.md for MkDocs); *site_words* names the site from where the text is read.
.md for the site); *site_words* names the site from where the text is read.
"""
return f"""{title}
@@ -894,8 +900,7 @@ validation above is its check. For code, profiles or platform files, these are
the checks CI runs on a pull request (Python 3.12 or newer):
```bash
pip install pyyaml "jsonschema[format-nongpl]" markdown \\
"mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
pip install pyyaml "jsonschema[format-nongpl]" markdown
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
+435 -526
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -11,8 +11,8 @@ four pins per side and the pixel glyph on a 4 by 4 grid. Three variants:
shapes rasterised by a headless Chromium through Playwright, for browsers
that take no SVG icon and for home-screen shortcuts.
The files land in ``docs_assets/overrides/assets/brand/``, which MkDocs copies
with the theme. The site build never runs this script.
The files land in ``docs_assets/brand/``, which generate_site.py copies into
the site. The site build never runs this script.
python scripts/make_site_icons.py
"""
@@ -22,7 +22,7 @@ from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "docs_assets" / "overrides" / "assets" / "brand"
OUT = ROOT / "docs_assets" / "brand"
HEADER = "#3b3f7a"
GLYPH = "#8be0f7"
-37
View File
@@ -1,37 +0,0 @@
"""MkDocs hooks for the generated site.
The sitemap gave all 664 pages the build date as lastmod, so the field told
a crawler nothing. A page whose content carries its own date (an emulator
page: the day its profile was last read against the source) states it in
its front matter. The rest get no lastmod rather than an inexact one.
"""
from __future__ import annotations
from typing import Any
# Pages made of file cards, level 3 headings by the hundred: their table of
# contents stops at level 2, the consoles of a system page, the Files of an
# emulator, the sections of a platform page (which listed every system id).
CARD_PAGES = ("systems/", "emulators/", "platforms/")
def on_page_markdown(markdown: str, page: Any, config: Any, files: Any) -> str: # noqa: ARG001
"""Take lastmod from the page's own date, or leave it out."""
page.update_date = str(page.meta.get("date_modified") or "")
return markdown
def _prune(items: list, depth: int) -> None:
for item in items:
if item.level >= depth:
item.children = []
else:
_prune(item.children, depth)
def on_page_content(html: str, page: Any, config: Any, files: Any) -> str: # noqa: ARG001
"""Keep the table of contents of a page of cards to level 2."""
if page.file.src_uri.startswith(CARD_PAGES):
_prune(page.toc.items, 2)
return html
+8 -6
View File
@@ -24,8 +24,8 @@ Steps, numbered as the run prints them:
5 consistency check verify == pack: counts, paths, every emulator pack
6 --verify-packs pack integrity, extraction and hashes
7 generate_readme.py README.md and CONTRIBUTING.md
8 generate_site.py MkDocs pages
8b mkdocs build --strict rendered as the site deploy renders it, then validate_site.py
8 generate_site.py site pages, into the Starlight project in website/
8b npm run build (website/) rendered as the site deploy renders it, then validate_site.py
--offline skips 2 to 2b2 and the network side of 2e and 4: release assets
come from .cache/large/ only.
@@ -1102,13 +1102,15 @@ def main() -> None:
if args.skip_docs:
skip_notice("render_site", "--skip-docs")
results["render_site"] = SKIPPED
elif shutil.which("mkdocs") is None:
skip_notice("render_site", "mkdocs not installed")
elif shutil.which("npm") is None or not Path("website/node_modules").is_dir():
skip_notice("render_site", "Node not set up: npm ci --prefix website")
results["render_site"] = SKIPPED
else:
ok, _ = run(["mkdocs", "build", "--strict"], STEP_LABELS["render_site"])
# Starlight's link check runs inside the build and fails it.
ok, _ = run(["npm", "run", "build", "--prefix", "website"], STEP_LABELS["render_site"])
if ok:
ok, _ = run([sys.executable, "scripts/validate_site.py"], "8b validate site")
ok, _ = run([sys.executable, "scripts/validate_site.py", "--site-dir", "website/dist"],
"8b validate site")
results["render_site"] = ok
all_ok = all_ok and ok
+4 -2
View File
@@ -36,6 +36,8 @@ from xml.sax.saxutils import escape
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import sitemarkup as markup # noqa: E402
PLACEHOLDER = re.compile(r"(?m)^([ \t]*)<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->[ \t]*$")
PLACEHOLDER_ANYWHERE = re.compile(r"<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->")
@@ -605,12 +607,12 @@ def figure_html(image_id: str, occurrence: int = 1, inline: bool = False) -> str
anchor = f"rb-fig-{image_id}{suffix}"
if inline:
html = (
f'<span class="rb-figure rb-figure-inline" id="{anchor}" data-search-exclude>'
f'<span class="rb-figure rb-figure-inline" id="{anchor}" {markup.search_exclude()}>'
f'{drawings}<span class="rb-figure-caption">{caption}</span></span>'
)
return html.translate(_INLINE_ESCAPES)
return (
f'<figure class="rb-figure" id="{anchor}" data-search-exclude>\n'
f'<figure class="rb-figure" id="{anchor}" {markup.search_exclude()}>\n'
f"{drawings}\n"
f"<figcaption>{caption}</figcaption>\n"
"</figure>"
+25 -34
View File
@@ -11,6 +11,8 @@ from __future__ import annotations
import json
from pathlib import Path
import sitemarkup as markup
EXPORTS_DIR = "exports"
REPO_BLOB = "https://github.com/Abdess/retrobios/blob/main"
REPO_TREE = "https://github.com/Abdess/retrobios/tree/main"
@@ -73,12 +75,15 @@ def requirement_lines(report: dict) -> list[str]:
]
if not items:
return []
return [
f'??? note "Required flags aligned with the code ({len(items)})"',
"",
*[f" - {item}" for item in items],
"",
]
return _lookup_only(markup.details(
"note", f"Required flags aligned with the code ({len(items)})",
[f"- {item}" for item in items]))
def _lookup_only(block: list[str]) -> list[str]:
"""A list of entries changed, out of the site search: 129 "md5" in them
put this page above the one that explains MD5 checks."""
return ["", markup.block_open("section", markup.search_exclude()), "", *block, "", "</section>", ""]
def _plural(count: int, noun: str, plural: str = "") -> str:
@@ -141,12 +146,8 @@ def candidate_lines(candidates: list[dict]) -> list[str]:
title = (reason[:1].upper() + reason[1:]).replace('"', "'")
items = [f"`{c['system']}` `{c['destination']}`" for c in entries]
if len(entries) > LONG_LIST:
lines.extend([
f'??? note "{title} ({len(entries)})"',
"",
*[f" - {item}" for item in items],
"",
])
lines.extend(_lookup_only(markup.details(
"note", f"{title} ({len(entries)})", [f"- {item}" for item in items])))
else:
lines.extend([f"{title}:", "", *[f"- {item}" for item in items], ""])
return lines
@@ -263,35 +264,29 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str:
])
lines.extend(candidate_lines(report.get("removal_candidates") or []))
if report.get("invalid"):
lines.extend([
'!!! warning "The updated file does not pass the platform\'s own checks"',
"",
*[f" - {issue}" for issue in report["invalid"]],
"",
])
lines.extend(markup.aside(
"warning", "The updated file does not pass the platform's own checks",
[f"- {issue}" for issue in report["invalid"]]))
originals = report.get("originals") or {}
files = report["files"]
raw = [f"{REPO_RAW}/{EXPORTS_DIR}/{platform}/{name}" for name in files]
if len(files) == 1:
lines.extend([
f"[Download the updated file]({raw[0]}){{ .md-button .md-button--primary }} "
f"[See it on GitHub]({REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}){{ .md-button }}",
markup.button("Download the updated file", raw[0], primary=True) + " "
+ markup.button("See it on GitHub", f"{REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}"),
"",
])
else:
lines.extend([
f"[Browse the {len(files)} updated files]"
f"({REPO_TREE}/{EXPORTS_DIR}/{platform}){{ .md-button .md-button--primary }}",
markup.button(f"Browse the {len(files)} updated files",
f"{REPO_TREE}/{EXPORTS_DIR}/{platform}", primary=True),
"",
])
if len(originals) > 1:
lines.extend([
f'??? note "The {len(originals)} original files it is based on"',
"",
*[f" - [`{name}`]({url})" for name, url in sorted(originals.items())],
"",
])
lines.extend(markup.details(
"note", f"The {len(originals)} original files it is based on",
[f"- [`{name}`]({url})" for name, url in sorted(originals.items())]))
elif originals:
(name, url), = originals.items()
lines.extend([f"Based on [`{name}`]({url}).", ""])
@@ -301,10 +296,6 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str:
items = change_items(report, key)
if not items:
continue
lines.extend([
f'??? note "{title} ({len(items)})"',
"",
*[f" - {item}" for item in items],
"",
])
lines.extend(_lookup_only(markup.details(
"note", f"{title} ({len(items)})", [f"- {item}" for item in items])))
return "\n".join(lines)
+9 -14
View File
@@ -15,6 +15,7 @@ import textwrap
from dataclasses import dataclass, fields
from pathlib import Path
import sitemarkup as markup
from safeparse import require_yaml
PLATFORMS_DIR = Path(__file__).resolve().parent.parent / "platforms"
@@ -263,15 +264,12 @@ def terminal_tabs(
"""Content tabs, one per place the command runs, each with how to open
the terminal there. Tab labels are shared across the site, so the reader's
choice carries from page to page."""
lines: list[str] = []
items = []
for key in tabs:
label, how, shell = TERMINALS[key]
lines += [f'{indent}=== "{label}"', ""]
if steps:
lines += [f"{indent} {how}", ""]
lines += _fence(shell, install_command(shell, args.get(key, "")), indent + " ")
lines.append("")
return lines
body = [how, ""] if steps else []
items.append((label, body + _fence(shell, install_command(shell, args.get(key, "")), "")))
return markup.tabs(items, indent)
def platform_args(name: str, guide: Guide) -> dict[str, str]:
@@ -290,11 +288,8 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]:
def trust_box(prefix: str, indent: str = "") -> list[str]:
"""Is it safe, under every command on a guide page: five plain facts."""
return [
f'{indent}??? note "Is it safe?"',
return markup.details("note", "Is it safe?", [
*(f"- {item}" for item in TRUST_ITEMS),
"",
*(f"{indent} - {item}" for item in TRUST_ITEMS),
"",
f"{indent} [Read the script first]({prefix}wiki/faq.md#is-piping-the-installer-into-a-shell-safe)",
"",
]
f"[Read the script first]({prefix}wiki/faq.md#is-piping-the-installer-into-a-shell-safe)",
], indent)
+209
View File
@@ -0,0 +1,209 @@
"""The page constructs the generator writes, in the syntax of the site engine.
Every block that is not plain Markdown (a callout, a collapsible block, OS
tabs, a button, a card grid) is written through one of these functions, so
the generator holds no engine syntax of its own. Bodies are passed without
indentation: the function indents them as the engine reads them.
The site is built with Astro Starlight. The MkDocs Material syntax the site
was first written in stays as one thing only: the yardstick of the size
budgets that split long pages (system pages, platform sections). Those
budgets were set on that Markdown, and measuring it keeps every page cut
at the same file, so no card changes address.
"""
from __future__ import annotations
import html
import re
from collections.abc import Iterator
from contextlib import contextmanager
ENGINES = ("mkdocs", "starlight")
_engine = "starlight"
# Material's callout kinds, by the role Starlight gives them.
ASIDE_ROLES = {
"note": "note", "info": "note", "abstract": "note", "example": "note",
"question": "note", "quote": "note", "tip": "tip", "success": "tip",
"warning": "caution", "failure": "danger", "danger": "danger", "bug": "danger",
}
# The attribute that keeps an element out of the site search.
SEARCH_EXCLUDE = {"mkdocs": "data-search-exclude", "starlight": "data-pagefind-ignore"}
def set_engine(name: str) -> None:
global _engine
if name not in ENGINES:
raise ValueError(f"unknown site engine {name!r}, expected one of {ENGINES}")
_engine = name
def engine() -> str:
return _engine
@contextmanager
def using(name: str) -> Iterator[None]:
"""Write in another syntax for a while: a page split by size is
measured in the MkDocs Markdown its budgets were set on."""
previous = _engine
set_engine(name)
try:
yield
finally:
set_engine(previous)
def starlight() -> bool:
return _engine == "starlight"
def search_exclude() -> str:
return SEARCH_EXCLUDE[_engine]
def inline_html(text: str) -> str:
"""Inline Markdown (code, bold, links) for text placed inside raw HTML,
where Markdown is not read."""
parts = re.split(r"(`[^`]+`)", text)
out = []
for part in parts:
if part.startswith("`") and part.endswith("`") and len(part) > 1:
out.append(f"<code>{html.escape(part[1:-1], quote=False)}</code>")
continue
part = html.escape(part, quote=False)
part = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", part)
part = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", r'<a href="\2">\1</a>', part)
out.append(part)
return "".join(out)
def _indented(lines: list[str], pad: str) -> list[str]:
return [f"{pad}{line}" if line.strip() else "" for line in lines]
def _trim(lines: list[str]) -> list[str]:
start, end = 0, len(lines)
while start < end and not lines[start].strip():
start += 1
while end > start and not lines[end - 1].strip():
end -= 1
return list(lines[start:end])
def aside(kind: str, title: str, body: list[str], indent: str = "") -> list[str]:
"""A callout that is always open."""
body = _trim(body)
if _engine == "mkdocs":
return [f'{indent}!!! {kind} "{title}"', "", *_indented(body, indent + " "), ""]
label = title.replace("[", "\\[").replace("]", "\\]")
fence = "::::" if any(line.lstrip().startswith(":::") for line in body) else ":::"
return [f"{indent}{fence}{ASIDE_ROLES.get(kind, 'note')}[{label}]",
*_indented(body, indent), f"{indent}{fence}", ""]
def details(kind: str, title: str, body: list[str], indent: str = "", opened: bool = False) -> list[str]:
"""A collapsible block, closed unless *opened*."""
body = _trim(body)
if _engine == "mkdocs":
marker = "???+" if opened else "???"
return [f'{indent}{marker} {kind} "{title}"', "", *_indented(body, indent + " "), ""]
role = ASIDE_ROLES.get(kind, "note")
state = " open" if opened else ""
return [
f'{indent}<details class="rb-details rb-details-{role}"{state}>',
f"{indent}<summary>{inline_html(title)}</summary>",
"",
*_indented(body, indent),
"",
f"{indent}</details>",
"",
]
def tabs(items: list[tuple[str, list[str]]], indent: str = "", sync: str = "os") -> list[str]:
"""Content tabs. Tabs that share a label share the reader's choice
across pages."""
lines: list[str] = []
if _engine == "mkdocs":
for label, body in items:
lines += [f'{indent}=== "{label}"', "", *_indented(_trim(body), indent + " "), ""]
return lines
lines.append(f'{indent}<Tabs syncKey="{sync}">')
for label, body in items:
lines += [f'{indent}<TabItem label="{html.escape(label)}">', "",
*_indented(_trim(body), indent), "", f"{indent}</TabItem>"]
return [*lines, f"{indent}</Tabs>", ""]
def button(text: str, target: str, primary: bool = False) -> str:
"""A link drawn as a button."""
if _engine == "mkdocs":
classes = ".md-button .md-button--primary" if primary else ".md-button"
return f"[{text}]({target}){{ {classes} }}"
classes = "rb-button rb-button-primary" if primary else "rb-button"
return f'<a class="{classes}" href="{html.escape(target)}">{inline_html(text)}</a>'
def image(alt: str, src: str, **attributes: str) -> str:
"""An image with HTML attributes (width, align)."""
if _engine == "mkdocs":
attrs = " ".join(f"{key}={value}" for key, value in attributes.items())
return f"![{alt}]({src}){{ {attrs} }}"
attrs = "".join(f' {key}="{html.escape(str(value))}"' for key, value in attributes.items())
return f'<img src="{html.escape(src)}" alt="{html.escape(alt)}"{attrs}>'
def hash_value(value: object) -> str:
"""A hash printed whole, in the hash style. One element under Starlight:
a span around a code span was 1,400 extra elements on a page of cards."""
text = str(value).replace("`", "'")
if _engine == "mkdocs":
return f'<span class="rb-hash">`{text}`</span>'
return f'<code class="rb-hash">{html.escape(text, quote=False)}</code>'
def block_open(tag: str, attributes: str = "") -> str:
"""The opening tag of an HTML block whose content is Markdown."""
attributes = f" {attributes.strip()}" if attributes.strip() else ""
if _engine == "mkdocs":
return f"<{tag}{attributes} markdown>"
return f"<{tag}{attributes}>"
def card_grid(cards: list[tuple[str, list[str]]]) -> list[str]:
"""Cards in a grid: a bold title line and a body each."""
lines = [block_open("div", 'class="grid cards"' if _engine == "mkdocs" else 'class="rb-cards"'), ""]
for title, body in cards:
if _engine == "mkdocs":
lines += [f"- **{title}**", "", *_indented(_trim(body), " "), ""]
else:
lines += ['<div class="rb-card">', "", f"**{title}**", "", *_trim(body), "", "</div>", ""]
return [*lines, "</div>", ""]
def steps(items: list[str]) -> list[str]:
"""An ordered list drawn as numbered steps."""
return [block_open("div", 'class="rb-steps"'), "",
*(f"{number}. {item}" for number, item in enumerate(items, 1)), "", "</div>", ""]
def heading_attributes(anchor: str = "", classes: str = "", search: bool = True) -> str:
"""The attribute block of a heading: its id, classes, and whether the
search reads it."""
parts = [f"#{anchor}" if anchor else "", *(f".{c}" for c in classes.split())]
if not search:
parts.append(search_exclude())
parts = [part for part in parts if part]
return f" {{ {' '.join(parts)} }}" if parts else ""
def site_href(path: str) -> str:
"""A link to a site address written in raw HTML (``files/?sha1=...``),
which no Markdown resolution reaches: rooted at the site under Starlight,
relative to a page two levels deep under MkDocs."""
if _engine == "mkdocs":
return f"../../{path}"
return f"/retrobios/{path}"
+3 -1
View File
@@ -7,6 +7,8 @@ import urllib.parse
from collections.abc import Callable
from pathlib import Path
import sitemarkup as markup
WIKI_SRC_DIR = "wiki" # manually maintained wiki sources
SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png"
@@ -89,7 +91,7 @@ def system_icon_markdown(manufacturer: str, console_name: str) -> str:
name = _icon_name(manufacturer, console_name)
if not _icon_available.get(name):
return ""
return f"![{console_name}]({_icon_url(name)}){{ width=24 }} "
return markup.image(console_name, _icon_url(name), width="24") + " "
# Global index: maps system_id -> (manufacturer_slug, console_name) for cross-linking
_system_page_map: dict[str, tuple[str, str]] = {}
+530
View File
@@ -0,0 +1,530 @@
"""The site as an Astro Starlight project: pages, sidebar and data files.
generate_site.py writes each page body, and this module gives it the form
Starlight reads. A page's links are written as the docs tree holds them
(``../systems/sony.md``, MkDocs' convention, the one a Markdown editor
follows), and the site serves them as addresses under /retrobios/, so
each target is resolved against the page's own place here. Every heading
keeps the id the MkDocs site gave it, so the anchors cited since stay
valid.
"""
from __future__ import annotations
import html
import json
import re
import unicodedata
import urllib.parse
from collections.abc import Callable, Iterator
from html import unescape
from pathlib import Path, PurePosixPath
BASE = "/retrobios/"
SITE_URL = "https://abdess.github.io/retrobios/"
# Components a page body may use, and where each comes from.
COMPONENTS = {
"Tabs": "@astrojs/starlight/components",
"TabItem": "@astrojs/starlight/components",
"FilePicker": "components/FilePicker.astro",
}
# Pages of file cards: level 3 headings by the hundred, so the table of
# contents stops at level 2.
CARD_PAGES = ("systems/", "emulators/", "platforms/")
FENCE = re.compile(r"(^[ \t]*(`{3,}|~{3,}).*?^[ \t]*\2[ \t]*$)", re.M | re.S)
CODE_SPAN = re.compile(r"(`+)(?!`)(.+?)(?<!`)\1(?!`)", re.S)
MD_LINK = re.compile(r"(?P<bang>!?)\[(?P<text>(?:[^\[\]\n]|\[[^\]\n]*\])*)\]\((?P<url>[^)\s]+)(?P<title> \"[^\"]*\")?\)")
HTML_URL = re.compile(r"(?P<attr>\b(?:href|src))=\"(?P<url>[^\"]*)\"")
ATTR_BLOCK = re.compile(r"[ \t]*\{(?P<attrs>[^{}]*)\}[ \t]*$")
ID_ATTR = re.compile(r"\bid=\"(?P<id>[^\"]+)\"")
IDCOUNT = re.compile(r"^(.*)_([0-9]+)$")
# Addresses
def page_url(rel: str) -> str:
"""The address of a page, by its path under the docs tree."""
stem = rel.rsplit(".", 1)[0] if rel.endswith((".md", ".mdx")) else rel
if stem == "index":
return BASE
if stem.endswith("/index"):
stem = stem[: -len("/index")]
return f"{BASE}{stem}/"
def resolve(rel_page: str, target: str) -> str:
"""A link target written relative to the page's file, as a site address."""
if not target or re.match(r"^(?:[a-z][a-z0-9+.-]*:|#|/)", target):
return target
path, hash_mark, fragment = target.partition("#")
path, question, query = path.partition("?")
parts: list[str] = []
for piece in (PurePosixPath(rel_page).parent / path).as_posix().split("/"):
if piece in ("", "."):
continue
if piece == "..":
if parts:
parts.pop()
else:
parts.append(piece)
joined = "/".join(parts)
if joined.endswith((".md", ".mdx")):
url = page_url(joined)
elif path.endswith("/") or not path:
url = f"{BASE}{joined}/" if joined else BASE
else:
url = f"{BASE}{joined}"
if question:
url += f"?{query}"
return url + (f"#{fragment}" if hash_mark else "")
# Text outside code
def outside_fences(text: str, fn: Callable[[str], str]) -> str:
parts = FENCE.split(text)
# FENCE has two groups: text, fence, marker, text, ...
out = []
for index in range(0, len(parts), 3):
out.append(fn(parts[index]))
if index + 1 < len(parts):
out.append(parts[index + 1])
return "".join(out)
def outside_code(text: str, fn: Callable[[str], str]) -> str:
def chunk(part: str) -> str:
out, position = [], 0
for match in CODE_SPAN.finditer(part):
out.append(fn(part[position:match.start()]))
out.append(match.group(0))
position = match.end()
out.append(fn(part[position:]))
return "".join(out)
return outside_fences(text, chunk)
def rewrite_links(text: str, rel: str) -> str:
def markdown(match: re.Match) -> str:
return (f"{match['bang']}[{match['text']}]({resolve(rel, match['url'])}"
f"{match['title'] or ''})")
def attribute(match: re.Match) -> str:
return f'{match["attr"]}="{resolve(rel, match["url"])}"'
text = outside_fences(text, lambda chunk: MD_LINK.sub(markdown, chunk))
return outside_fences(text, lambda chunk: HTML_URL.sub(attribute, chunk))
# Heading ids, as Python-Markdown's toc extension gives them
def toc_slug(text: str) -> str:
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)(\{[^}]*\})?", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = unescape(text.replace("`", "").replace("**", "").replace("\\", ""))
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
text = re.sub(r"[^\w\s-]", "", text).strip().lower()
return re.sub(r"[-\s]+", "-", text)
def _unique(ident: str, used: set[str]) -> str:
while ident in used or not ident:
match = IDCOUNT.match(ident)
ident = f"{match[1]}_{int(match[2]) + 1}" if match else f"{ident}_1"
used.add(ident)
return ident
def pin_heading_ids(text: str, title_id: str = "") -> str:
"""Every heading with the id the MkDocs build gave it. *title_id* is
the page title's, taken first."""
used: set[str] = {title_id} if title_id else set()
explicit = set()
for match in re.finditer(r"\{[^{}\n]*#([^\s}]+)[^{}\n]*\}[ \t]*$", text, re.M):
explicit.add(match[1])
for match in ID_ATTR.finditer(text):
explicit.add(match["id"])
used |= explicit
def markdown(match: re.Match) -> str:
heading = match["mdtext"]
attrs = ATTR_BLOCK.search(heading)
body = heading[: attrs.start()] if attrs else heading
tokens = attrs["attrs"].split() if attrs else []
if any(token.startswith("#") for token in tokens):
return match.group(0)
ident = _unique(toc_slug(body), used)
return f"{match['hashes']} {body.rstrip()} {{ {' '.join(['#' + ident, *tokens])} }}"
def raw(match: re.Match) -> str:
if ID_ATTR.search(match["attrs"]):
return match.group(0)
level, text = match["level"], match["htmltext"]
ident = _unique(toc_slug(text), used)
return f'<h{level} id="{ident}"{match["attrs"]}>{text}</h{level}>'
def pick(match: re.Match) -> str:
return markdown(match) if match["hashes"] else raw(match)
# In document order: one pass over either kind of heading.
return outside_fences(text, lambda chunk: _ANY_HEADING.sub(pick, chunk))
_ANY_HEADING = re.compile(
r"^(?P<hashes>#{2,6})[ \t]+(?P<mdtext>.+?)[ \t]*$"
r"|<h(?P<level>[2-6])(?P<attrs>(?:\s[^>]*)?)>(?P<htmltext>.*?)</h(?P=level)>",
re.M | re.S,
)
# Glossary tooltips
def glossary_terms(path: Path) -> dict[str, str]:
"""Each term the glossary defines, with its definition as plain text."""
terms: dict[str, str] = {}
if not path.is_file():
return terms
text = path.read_text(encoding="utf-8")
for match in re.finditer(r"^(?P<term>\*\*.+)\n: (?P<body>.+(?:\n .+)*)", text, re.M):
definition = re.sub(r"\s+", " ", match["body"]).strip()
definition = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", definition).replace("`", "")
for term in re.findall(r"\*\*([^*]+)\*\*", match["term"]):
terms.setdefault(term, definition)
return terms
_SKIP_TAGS = re.compile(r"<(a|code|pre|abbr|h[1-6]|script|style|summary|svg|figure)\b.*?</\1>", re.S | re.I)
def add_tooltips(text: str, terms: dict[str, str]) -> str:
"""The first use of each glossary term on a page, with its definition
a hover or a tap away. Headings, links, code and HTML are left as they are."""
if not terms:
return text
pending = dict(terms)
pattern = re.compile(
r"(?<![\w/.-])(" + "|".join(re.escape(t) for t in sorted(terms, key=len, reverse=True))
+ r")(?![\w/-])"
)
def prose(chunk: str) -> str:
if not pending:
return chunk
out = []
for line in chunk.split("\n"):
stripped = line.lstrip()
if (not pending or stripped.startswith(("#", "|", "<", ":::", "[", "!"))
or not stripped):
out.append(line)
continue
out.append(_tooltip_line(line, pattern, pending))
return "\n".join(out)
return outside_code(text, prose)
def _tooltip_line(line: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
# Inline HTML, links and emphasis markers stay whole.
protected = re.compile(r"<[^>]+>|\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|\{[^}]*\}")
out, position = [], 0
for match in protected.finditer(line):
out.append(_tooltip_text(line[position:match.start()], pattern, pending))
out.append(match.group(0))
position = match.end()
out.append(_tooltip_text(line[position:], pattern, pending))
return "".join(out)
def _tooltip_text(text: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
def swap(match: re.Match) -> str:
term = match.group(1)
if term not in pending:
return term
definition = pending.pop(term)
return f'<abbr class="rb-term" title="{html.escape(definition)}">{term}</abbr>'
return pattern.sub(swap, text)
# MDX
_VOID = re.compile(r"<(br|img|input|hr|source|meta|link|wbr)\b([^>]*?)(?<!/)>")
_HEADING_ATTRS = re.compile(r"(?m)(?<=\S)[ \t]+\{[ \t]*#[^{}\n]*\}[ \t]*$")
_COMMENT = re.compile(r"<!--.*?-->", re.S)
def mdx_safe(text: str) -> str:
"""What MDX would read as code in prose: braces, a stray ``<``,
comments, unclosed void tags and ``<https://...>`` links."""
def fix(chunk: str) -> str:
chunk = _COMMENT.sub("", chunk)
chunk = re.sub(r"<(https?://[^>\s]+)>", r"[\1](\1)", chunk)
held: list[str] = []
def hold(match: re.Match) -> str:
held.append(match.group(0))
return f"\x00{len(held) - 1}\x00"
chunk = _HEADING_ATTRS.sub(hold, chunk)
chunk = chunk.replace("{", "&#123;").replace("}", "&#125;")
chunk = re.sub(r"\x00(\d+)\x00", lambda m: held[int(m[1])], chunk)
chunk = re.sub(r"<(?![A-Za-z/])", "&lt;", chunk)
return _VOID.sub(lambda m: f"<{m[1]}{m[2].rstrip()} />", chunk)
return outside_code(text, fix)
def used_components(body: str) -> list[str]:
return [name for name in COMPONENTS if re.search(rf"<{name}[\s/>]", body)]
# Pages
def _front_matter(data: dict) -> str:
import yaml
return "---\n" + yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=1000) + "---\n\n"
def og_section(rel: str, level: str) -> str:
"""The sharing card a page takes: its level, or what its address says."""
url = page_url(rel)[len(BASE):]
if not url:
return "home"
if level in ("start", "setup", "explore", "reference", "contribute"):
return level
if url.startswith(("which-pack", "wiki/first-install", "wiki/faq", "wiki/getting-started")):
return "start"
if url.startswith("platforms"):
return "setup"
if url.startswith(("systems", "emulators", "files")):
return "explore"
if url.startswith(("contributing", "wiki/adding", "wiki/profiling", "wiki/testing", "wiki/release")):
return "contribute"
return "reference"
def page_head(rel: str, level: str, title: str, browser_title: str, structured: dict) -> list[dict]:
"""What a page adds to its head: its own window title, the sharing
card of its section, and its structured data."""
section = og_section(rel, level)
image = f"{SITE_URL}assets/og/{section}.png"
head: list[dict] = []
if browser_title != title:
head.append({"tag": "title", "content": f"{browser_title} - RetroBIOS"})
head += [
{"tag": "meta", "attrs": {"property": "og:image", "content": image}},
{"tag": "meta", "attrs": {"property": "og:image:width", "content": "1200"}},
{"tag": "meta", "attrs": {"property": "og:image:height", "content": "630"}},
{"tag": "meta", "attrs": {"property": "og:image:alt", "content": f"RetroBIOS: {browser_title}"}},
{"tag": "meta", "attrs": {"name": "twitter:image", "content": image}},
{"tag": "script", "attrs": {"type": "application/ld+json"},
"content": json.dumps(structured, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")},
]
return head
def split_title(markdown: str) -> tuple[str, str, str]:
"""The page's H1 as plain text, the id MkDocs gave it, and the body
without it."""
match = re.search(r"^# (.+?)[ \t]*$\n?", markdown, re.M)
if not match:
return "", "", markdown
explicit = re.search(r"\{[^}]*#([^\s}]+)[^}]*\}\s*$", match[1])
title = re.sub(r"\s*\{[^}]*\}\s*$", "", match[1])
ident = explicit[1] if explicit else toc_slug(title)
title = re.sub(r"<[^>]+>", "", title)
title = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", title)
title = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", title).replace("`", "")
return unescape(title).strip(), ident, markdown[: match.start()] + markdown[match.end():]
_CARD_NAME = re.compile(r'<h3[^>]*class="rb-file-title"(?![^>]*data-pagefind-ignore)[^>]*><code>([^<]+)</code>')
def file_names_meta(body: str) -> str:
"""The names of the files a system page holds cards for, as a search
field of the page. A page of 250 cards is long, and the search weighs a
long page down: the Sony page, where scph5501.bin is downloaded, came
after every short page citing the name. The field is weighted in the
site's ranking (astro.config.mjs, metaWeights.files)."""
names = sorted({unescape(name) for name in _CARD_NAME.findall(body)})
if not names:
return ""
return f'\n<span data-pagefind-meta="files" hidden>{html.escape(" ".join(names))}</span>\n'
def finalize_page(
rel: str,
markdown: str,
*,
level: str,
weight: float | None,
browser_title: str,
description: str,
structured: dict,
terms: dict[str, str],
search: bool = True,
) -> tuple[str, str]:
"""The Starlight file for a page body: its path under the content
collection and its text."""
title, title_id, body = split_title(markdown)
body = body.lstrip("\n")
body = rewrite_links(body, rel)
body = pin_heading_ids(body, title_id)
if title_id:
# The title is the theme's, with an id of its own: the one MkDocs
# gave it stays an anchor at the top of the text.
body = f'<div id="{html.escape(title_id)}" class="rb-anchor"></div>\n\n' + body
if rel not in ("wiki/glossary.md", "wiki/reference-terms.md"):
body = add_tooltips(body, terms)
# A badge is a status (required, missing, MD5), not a word a reader
# searches for: 141 "missing" badges put an emulator page above the
# troubleshooting page for "missing bios". The archive a file sits in
# is a name, and stays: it is how "neogeo.zip" finds the cores reading it.
body = re.sub(r'<span class="(rb-badge(?![^"]*rb-badge-archive)[^"]*)"',
r'<span data-pagefind-ignore class="\1"', body)
if rel.startswith("systems/"):
body += file_names_meta(body)
components = used_components(body)
stem = rel.rsplit(".", 1)[0]
if components:
body = mdx_safe(body)
imports: dict[str, list[str]] = {}
for name in components:
imports.setdefault(COMPONENTS[name], []).append(name)
# Our own components are imported from src/, two levels above the
# content collection plus the page's own folders.
up = "../" * (rel.count("/") + 2)
body = "".join(f"import {{ {', '.join(names)} }} from '{source}';\n"
if source.startswith("@") else f"import {names[0]} from '{up}{source}';\n"
for source, names in imports.items()) + "\n" + body
front: dict = {"title": title or browser_title, "description": description}
# Starlight's slugger drops dots and plus signs: the address stays MkDocs'.
if re.search(r"[^a-z0-9_/-]", stem):
front["slug"] = stem
if level:
front["level"] = level
if weight:
front["searchWeight"] = weight
if rel.startswith(CARD_PAGES):
sections = len(re.findall(r"^## ", body, re.M))
# A page of cards under one or two headings has nothing to list, and
# the table of contents watches every block of the page to follow
# the reader: 2 x 430 ms of a phone's main thread on 400 cards.
front["tableOfContents"] = (
{"minHeadingLevel": 2, "maxHeadingLevel": 2} if sections > 2 else False)
if not search:
front["pagefind"] = False
front["head"] = page_head(rel, level, front["title"], browser_title, structured)
if structured.get("dateModified"):
front["lastmod"] = str(structured["dateModified"])
return stem + (".mdx" if components else ".md"), _front_matter(front) + body
# Navigation
def _sidebar_link(rel: str) -> str:
return page_url(rel)[len(BASE) - 1:]
def sidebar(nav: list, present: set[str]) -> dict:
"""The navigation as Starlight topics, one per tab.
A page the navigation does not list (the parts of a split page) joins
the topic that holds most pages of its directory.
"""
owner: dict[str, str] = {}
def item(entry: object, topic: str) -> dict | None:
if isinstance(entry, str):
owner[entry] = topic
return {"label": "Overview", "link": _sidebar_link(entry)} if entry in present else None
(label, value), = entry.items()
if isinstance(value, str):
if value.startswith("http"):
return {"label": label, "link": value}
owner[value] = topic
return {"label": label, "link": _sidebar_link(value)} if value in present else None
children = [child for child in (item(v, topic) for v in value) if child]
return {"label": label, "items": children, "collapsed": True} if children else None
topics = []
for tab in nav:
(label, value), = tab.items()
topic = re.sub(r"[^a-z]+", "-", label.lower()).strip("-")
children = [child for child in (item(v, topic) for v in value) if child]
if children:
topics.append({"id": topic, "label": label, "link": next(_links(children))["link"],
"items": children})
votes: dict[str, dict[str, int]] = {}
for rel, topic in owner.items():
tally = votes.setdefault(rel.rpartition("/")[0], {})
tally[topic] = tally.get(topic, 0) + 1
extra: dict[str, list[str]] = {}
# The page a stale link lands on belongs to no tab.
exclude: list[str] = ["/404"]
for rel in sorted(present - owner.keys()):
tally = votes.get(rel.rpartition("/")[0])
path = "/" + (_sidebar_link(rel).strip("/") or "index")
if tally:
extra.setdefault(max(tally, key=tally.get), []).append(path)
else:
exclude.append(path)
return {"topics": topics, "options": {"topics": extra, "exclude": exclude}}
def _links(items: list[dict]) -> Iterator[dict]:
for entry in items:
if "link" in entry:
yield entry
else:
yield from _links(entry["items"])
# The picker's data
def picker_data(install: Path, out: Path) -> int:
"""Each manifest, compacted to what the file picker reads: per file its
destination, size and systems, the systems install.py --system accepts.
"""
out.mkdir(parents=True, exist_ok=True)
index = []
for manifest in sorted(install.glob("*.json")):
data = json.loads(manifest.read_text(encoding="utf-8"))
if not isinstance(data, dict) or "files" not in data or "platform" not in data:
continue
systems = sorted({s for f in data["files"] for s in f.get("systems") or []})
position = {s: k for k, s in enumerate(systems)}
names = data.get("system_names") or {}
payload = {
"platform": data["platform"],
"name": data.get("display_name", data["platform"]),
"folder": (data.get("detect") or [{}])[0].get("bios_path", ""),
"generated": data.get("generated", ""),
"systems": [{"id": s, "native": names.get(s, [])} for s in systems],
"other": any(not f.get("systems") for f in data["files"]),
"files": [[f["dest"], f.get("size", 0), [position[s] for s in f.get("systems") or []]]
for f in sorted(data["files"], key=lambda f: f["dest"])],
}
(out / f"{data['platform']}.json").write_text(
json.dumps(payload, separators=(",", ":")), encoding="utf-8")
index.append({"platform": data["platform"], "name": payload["name"],
"files": len(payload["files"])})
index.sort(key=lambda entry: entry["name"].casefold())
(out / "index.json").write_text(json.dumps(index, separators=(",", ":")), encoding="utf-8")
return len(index)
def quote_url(url: str) -> str:
return urllib.parse.quote(url, safe="/:#?=&%")
+2 -2
View File
@@ -439,11 +439,11 @@ def main() -> int:
if release_path.exists():
errors.extend(_validate_json_files([release_path], "release.schema.json"))
stats_path = ROOT / "docs" / "stats.json"
stats_path = ROOT / "website" / "public" / "stats.json"
if stats_path.exists():
errors.extend(_validate_json_files([stats_path], "stats.schema.json"))
api_dir = ROOT / "docs" / "api" / "v1"
api_dir = ROOT / "website" / "public" / "api" / "v1"
api_database = api_dir / "database.json"
if api_database.exists():
errors.extend(
+30 -41
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Validate the built RetroBIOS site as a navigable, accessible data product.
This complements ``mkdocs build --strict`` with checks on the rendered HTML:
metadata, heading structure, image alternatives, duplicate IDs, structured
data, local resources, links and fragments.
This complements Starlight's own link check (starlight-links-validator,
which fails the build) with checks on the rendered HTML: metadata,
heading structure, image alternatives, duplicate IDs, structured data, local
resources, links and fragments, and the budgets of what a visitor downloads.
"""
from __future__ import annotations
@@ -49,10 +50,12 @@ class Page:
# The largest page was 10.5 MB (systems/game-engines/) and took 21 s of
# main-thread work on a throttled phone. Split pages now top out at 0.9 MB.
MAX_PAGE_BYTES = 1_000_000
# The search index is downloaded by every visitor who opens the search:
# 21.0 MB raw and 4.88 MB gzip with hashes in it, 7.3 MB and 1.44 MB without.
MAX_SEARCH_INDEX_BYTES = 8_500_000
MAX_SEARCH_INDEX_GZIP_BYTES = 1_700_000
# Pagefind loads its index in pieces: the metadata, then the chunks the words
# typed fall in (stored compressed). 59 KB for the largest chunk and 7.5 KB of
# metadata on 2026-10-22, 5.2 MB for the whole index on disk.
MAX_PAGEFIND_CHUNK_BYTES = 150_000
MAX_PAGEFIND_META_BYTES = 50_000
MAX_PAGEFIND_TOTAL_BYTES = 12_000_000
# Text inside these is shown as written, so markup there is not a leftover.
@@ -167,37 +170,24 @@ def _heading_jumps(levels: list[int]) -> list[str]:
if after > before + 1]
def _check_search_index(site: Path) -> list[str]:
"""The index every visitor who opens the search downloads, against its budget."""
import gzip
path = site / "search" / "search_index.json"
if not path.is_file():
return []
raw = path.read_bytes()
packed = len(gzip.compress(raw, 6))
def _check_pagefind(site: Path) -> list[str]:
"""Pagefind's index pieces, each against its budget."""
root = site / "pagefind"
files = [path for path in root.rglob("*") if path.is_file()]
issues = []
if len(raw) > MAX_SEARCH_INDEX_BYTES:
issues.append(f"search/search_index.json: {len(raw):,} bytes, "
f"over the {MAX_SEARCH_INDEX_BYTES:,} budget")
if packed > MAX_SEARCH_INDEX_GZIP_BYTES:
issues.append(f"search/search_index.json: {packed:,} bytes gzip, "
f"over the {MAX_SEARCH_INDEX_GZIP_BYTES:,} budget")
for path in files:
size = path.stat().st_size
rel = path.relative_to(site).as_posix()
if path.suffix == ".pf_index" and size > MAX_PAGEFIND_CHUNK_BYTES:
issues.append(f"{rel}: {size:,} bytes, over the {MAX_PAGEFIND_CHUNK_BYTES:,} budget of a chunk")
if path.suffix == ".pf_meta" and size > MAX_PAGEFIND_META_BYTES:
issues.append(f"{rel}: {size:,} bytes, over the {MAX_PAGEFIND_META_BYTES:,} budget")
total = sum(path.stat().st_size for path in files)
if total > MAX_PAGEFIND_TOTAL_BYTES:
issues.append(f"pagefind/: {total:,} bytes, over the {MAX_PAGEFIND_TOTAL_BYTES:,} budget")
return issues
def _base_path(config_path: Path) -> str:
"""Read site_url without constructing executable YAML extension tags."""
config = config_path.read_text(encoding="utf-8")
match = re.search(
r"(?m)^site_url:\s*['\"]?([^\s#'\"]+)",
config,
)
site_url = match.group(1) if match else ""
path = urlsplit(site_url).path or "/"
return "/" + path.strip("/") + "/" if path.strip("/") else "/"
def _local_target(
href: str, page_path: Path, site: Path, base_path: str
) -> tuple[list[Path], str] | None:
@@ -343,12 +333,11 @@ def _check_links(pages: dict, site: Path, base_path: str) -> list[str]:
return issues
def validate_site(site: Path, config_path: Path) -> list[str]:
def validate_site(site: Path, base_path: str = "/retrobios/") -> list[str]:
site = site.resolve()
if not site.is_dir():
return [f"site directory does not exist: {site}"]
base_path = _base_path(config_path)
html_paths = sorted(site.rglob("*.html"))
if not html_paths:
return [f"no HTML pages found in {site}"]
@@ -367,7 +356,7 @@ def validate_site(site: Path, config_path: Path) -> list[str]:
issues.append(f"duplicate {label} {value!r}: {rendered}")
issues.extend(_check_links(pages, site, base_path))
issues.extend(_check_search_index(site))
issues.extend(_check_pagefind(site))
return issues
@@ -379,12 +368,12 @@ def main() -> None:
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--site-dir", default="site",
"--site-dir", default="website/dist",
help="the built site (default: %(default)s)",
)
parser.add_argument(
"--config", default="mkdocs.yml",
help="the MkDocs configuration (default: %(default)s)",
"--base", default="/retrobios/",
help="the path the site is served under (default: %(default)s)",
)
parser.add_argument(
"--max-errors", type=int, default=100,
@@ -393,7 +382,7 @@ def main() -> None:
args = parser.parse_args()
try:
issues = validate_site(Path(args.site_dir), Path(args.config))
issues = validate_site(Path(args.site_dir), args.base)
except (OSError, RuntimeError, ValueError) as exc:
print(f"Site validation failed: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
+46
View File
@@ -0,0 +1,46 @@
"""The generated site pages, for the tests that read them.
generate_site.py writes each page into the Starlight project's content
collection, as .md or as .mdx when it uses a component. A test names a
page by its docs path (``which-pack.md``) and gets whichever file holds it.
"""
from __future__ import annotations
import os
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
def require() -> Path:
"""The content folder, or a skip when the site was not generated (a
failure in CI, which generates it before the suite)."""
if not (CONTENT / "index.md").is_file():
if os.environ.get("CI"):
raise AssertionError("CI generates the site before the suite")
raise unittest.SkipTest("site not generated (python scripts/generate_site.py)")
return CONTENT
def page(name: str) -> Path:
"""The file of a page named by its docs path."""
stem = name[:-3] if name.endswith(".md") else name
for suffix in (".md", ".mdx"):
candidate = CONTENT / f"{stem}{suffix}"
if candidate.is_file():
return candidate
return CONTENT / f"{stem}.md"
def pages() -> list[Path]:
"""Every page file, sorted."""
root = require()
return sorted(p for p in root.rglob("*") if p.suffix in (".md", ".mdx"))
def name(path: Path) -> str:
"""A page file's docs path, the name tests use for it."""
return path.relative_to(CONTENT).with_suffix(".md").as_posix()
+28 -26
View File
@@ -258,7 +258,7 @@ class WorkflowRegressions(unittest.TestCase):
and acted on afterwards rather than swallowed. A best-effort side
action such as adding a label is not a check and keeps its `|| true`.
"""
checks = ("python scripts/", "unittest", "mkdocs build")
checks = ("python scripts/", "unittest", "npm run build")
for name in ("validate.yml", "deploy-site.yml"):
workflow = self._workflow(name)
for job_name, job in workflow["jobs"].items():
@@ -4311,7 +4311,7 @@ class AFileCardSaysAllItKnows(unittest.TestCase):
))
# The card is a heading with this id, linked by the heading's own
# permalink.
self.assertIn("{ #file-neogeo-zip .rb-file-title }", text)
self.assertIn('<h3 id="file-neogeo-zip" class="rb-file-title"', text)
self.assertIn("Standalone path: `Firmware/neogeo.zip`", text)
self.assertIn("`m11.bin`", text)
self.assertIn("CRC32 `0000000b`", text)
@@ -4954,7 +4954,7 @@ class CollectionCardsCarryFullHashes(unittest.TestCase):
"crc32": "504086c1", "sha256": "b" * 64}
text = "\n".join(_system_file_card(entry, FileConsumers({}, {})))
for value in (entry["sha1"], entry["md5"], entry["crc32"], entry["sha256"]):
self.assertIn(f"`{value}`", text)
self.assertIn(f">{value}</code>", text)
@@ -4975,17 +4975,11 @@ class NullReadsAsNone(unittest.TestCase):
self.assertIn("none: the source is the project itself", page)
def test_the_rendered_site_has_no_python_none(self):
docs = ROOT / "docs"
if not docs.is_dir():
if os.environ.get("CI"):
self.fail("CI generates docs/ before the suite")
self.skipTest("docs/ not generated")
pattern = re.compile(r"\| None \||: None$|: None \|", re.M)
from scripts.generate_site import LOCAL_NOTE_DIRS
from tests import sitepages
found = [p.relative_to(docs).as_posix() for p in sorted(docs.rglob("*.md"))
if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS)
and pattern.search(p.read_text(encoding="utf-8"))]
pattern = re.compile(r"\| None \||: None$|: None \|", re.M)
found = [sitepages.name(p) for p in sitepages.pages()
if pattern.search(p.read_text(encoding="utf-8"))]
self.assertEqual(found, [])
@@ -5029,7 +5023,7 @@ class VersionAnchorsFollowContent(unittest.TestCase):
"raze", {"emulator": "Raze", "type": "standalone", "files": files},
{"files": {}, "indexes": {}},
)
return re.findall(r"\{ #(file-[^ ]+) \.rb-file-title \}", page)
return re.findall(r'<h3 id="(file-[^"]+)" class="rb-file-title"', page)
def test_inserting_a_version_keeps_the_others(self):
a = {"name": "DUKE3D.GRP", "path": "DUKE3D.GRP", "crc32": "bbc9ce44"}
@@ -5051,18 +5045,26 @@ class SitemapDatesComeFromThePage(unittest.TestCase):
"""All 664 sitemap URLs carried the build date: eduke32, re-read on
2026-10-04, was announced as changed on the day of every build."""
def test_the_hook_takes_the_page_date_or_none(self):
from types import SimpleNamespace
def test_the_page_date_or_none_reaches_the_sitemap(self):
import tempfile
from scripts.mkdocs_hooks import on_page_markdown
from scripts import generate_site
dated = SimpleNamespace(meta={"date_modified": "2026-10-04"}, update_date="today")
undated = SimpleNamespace(meta={}, update_date="today")
on_page_markdown("", dated, None, None)
on_page_markdown("", undated, None, None)
self.assertEqual((dated.update_date, undated.update_date), ("2026-10-04", ""))
config = (ROOT / "mkdocs.yml").read_text(encoding="utf-8")
self.assertIn("hooks:\n - scripts/mkdocs_hooks.py", config)
saved = dict(generate_site.PAGE_DATA)
self.addCleanup(lambda: (generate_site.PAGE_DATA.clear(),
generate_site.PAGE_DATA.update(saved)))
with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as tmp:
docs = Path(tmp)
(docs / "emulators").mkdir()
(docs / "emulators" / "dated.md").write_text("# Dated\n\nA page with its own date.\n")
(docs / "emulators" / "undated.md").write_text("# Undated\n\nA page with no date.\n")
generate_site.PAGE_DATA["emulators/dated.md"] = {"dateModified": "2026-10-04"}
lastmod = generate_site.decorate_markdown_pages(docs)
self.assertEqual(lastmod, {"/retrobios/emulators/dated/": "2026-10-04"})
# The sitemap reads that map, and leaves every other page undated.
config = (ROOT / "website" / "astro.config.mjs").read_text(encoding="utf-8")
self.assertIn("lastmod.json", config)
self.assertIn("serialize(item)", config)
class SiteCodeSpansHoldTheirValue(unittest.TestCase):
@@ -6082,8 +6084,8 @@ class ThePipelineRunsTheSchemaCheckCIRuns(unittest.TestCase):
duplicate ids passed it and failed the deploy."""
workflow = (ROOT / ".github" / "workflows" / "deploy-site.yml").read_text(encoding="utf-8")
source = (ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8")
self.assertIn("mkdocs build --strict", workflow)
self.assertIn('["mkdocs", "build", "--strict"]', source)
self.assertIn("npm run build --prefix website", workflow)
self.assertIn('["npm", "run", "build", "--prefix", "website"]', source)
self.assertIn("scripts/validate_site.py", workflow)
self.assertIn('"scripts/validate_site.py"', source)
+8 -11
View File
@@ -22,7 +22,7 @@ import generate_site # noqa: E402
def _normalized(text: str) -> str:
# The site ends each page with its next step in the reading order. That
# is navigation, not contributing text, and a repository file has none.
text = text.split('\n\n!!! tip "Next"\n', 1)[0].rstrip("\n")
text = text.split("\n\n:::tip[Next]\n", 1)[0].rstrip("\n")
text = re.sub(r"\]\([^)]*\)", "](link)", text)
return "\n".join(text.splitlines()[1:]).replace("on this site", "on the documentation site")
@@ -73,17 +73,14 @@ class BeforeOpeningAPullRequest(unittest.TestCase):
workflow = (REPO_ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8")
self.assertIn("validate_schemas.py --source-only", workflow)
def test_it_installs_the_packages_the_site_render_needs(self):
"""Without mkdocs-material and pymdown-extensions the pipeline's
render step (8b) is skipped for every contributor."""
deploy = (REPO_ROOT / ".github" / "workflows" / "deploy-site.yml").read_text(
encoding="utf-8"
)
specs = re.findall(r'"((?:mkdocs-material|pymdown-extensions)[^"]*)"', deploy)
self.assertEqual(len(specs), 2)
def test_it_installs_what_the_checks_need_and_no_site_package(self):
"""The site is built by Node from website/package-lock.json: a pip
line naming the old theme installed packages nothing reads."""
text = generate_readme.generate_contributing()
for spec in specs:
self.assertIn(f'"{spec}"', text)
section = text.split("## Before opening a PR", 1)[1].split("\n## ", 1)[0]
self.assertIn('pip install pyyaml "jsonschema[format-nongpl]" markdown', section)
self.assertNotIn("mkdocs", section)
self.assertTrue((REPO_ROOT / "website" / "package-lock.json").is_file())
class OneFileThroughTheBrowser(unittest.TestCase):
+10 -10
View File
@@ -85,22 +85,22 @@ class CorrectedFilesPage(unittest.TestCase):
def test_single_file_is_downloadable_raw(self):
self.assertIn(
"(https://raw.githubusercontent.com/Abdess/retrobios/main/exports/recalbox/es_bios.xml)",
'href="https://raw.githubusercontent.com/Abdess/retrobios/main/exports/recalbox/es_bios.xml"',
self.page,
)
def test_several_files_link_to_their_directory(self):
self.assertIn("tree/main/exports/retropie", self.page)
self.assertIn('??? note "The 2 original files it is based on"', self.page)
self.assertIn("<summary>The 2 original files it is based on</summary>", self.page)
def test_every_change_is_listed(self):
self.assertIn('??? note "Hashes updated to the emulator source (12)"', self.page)
self.assertIn("<summary>Hashes updated to the emulator source (12)</summary>", self.page)
for i in range(12):
self.assertIn(f" - `sys/file{i}.bin md5`", self.page)
self.assertIn(f"\n- `sys/file{i}.bin md5`", self.page)
def test_a_failing_file_is_said_to_fail(self):
self.assertIn(" - bash -n failed on a.sh", self.page)
self.assertIn(" - a size the format has no field for", self.page)
self.assertIn("\n- bash -n failed on a.sh", self.page)
self.assertIn("\n- a size the format has no field for", self.page)
def test_nothing_published_says_how_to_build(self):
self.assertIn("export_native.py --all --fetch", generate_exports_page([], {}))
@@ -412,16 +412,16 @@ class ThePageShowsEachValueBeforeAndAfter(unittest.TestCase):
def test_a_corrected_hash_shows_both_values(self):
self.assertIn(
f" - `psx/scph5501.bin` md5 `5626{'0' * 28}` becomes `c523{'1' * 28}`",
f"\n- `psx/scph5501.bin` md5 `5626{'0' * 28}` becomes `c523{'1' * 28}`",
self.page,
)
def test_a_requirement_names_the_entry_and_its_value(self):
self.assertIn('??? note "Required flags aligned with the code (1)"', self.page)
self.assertIn(" - `psx/scph5501.bin` becomes optional", self.page)
self.assertIn("<summary>Required flags aligned with the code (1)</summary>", self.page)
self.assertIn("\n- `psx/scph5501.bin` becomes optional", self.page)
def test_a_filled_hash_shows_its_value(self):
self.assertIn(f" - `psx/scph5501.bin` sha1 `{'f' * 40}` added", self.page)
self.assertIn(f"\n- `psx/scph5501.bin` sha1 `{'f' * 40}` added", self.page)
def test_no_arrow_glyph(self):
for glyph in (chr(0x2192), "->", "=>"):
+12 -10
View File
@@ -56,6 +56,9 @@ def _tracked() -> list[Path]:
def _semicolons(text: str) -> list[str]:
# An HTML character reference (&amp; &#123;) ends in a semicolon that
# joins no clauses.
text = re.sub(r"&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);", "&", text)
return [
line.strip()
for line in BACKTICKS.sub("``", text).splitlines()
@@ -356,18 +359,17 @@ class GeneratedSiteProse(unittest.TestCase):
sys.path.insert(0, str(ROOT / "scripts"))
from generate_site import LOCAL_NOTE_DIRS # noqa: PLC0415
docs = ROOT / "docs"
pages = [
p for p in docs.rglob("*.md")
if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS)
] if docs.is_dir() else []
if not pages:
if os.environ.get("CI"):
self.fail("CI generates docs/ before the suite")
self.skipTest("docs/ not generated (python scripts/generate_site.py)")
from tests import sitepages # noqa: PLC0415
pages = [p for p in sitepages.pages()
if not set(p.relative_to(sitepages.CONTENT).parts[:-1]) & set(LOCAL_NOTE_DIRS)]
offenders = []
for page in pages:
text = re.sub(r"<script.*?</script>", "", page.read_text(encoding="utf-8"), flags=re.S)
text = page.read_text(encoding="utf-8")
# The front matter carries the page's structured data, not prose.
if text.startswith("---\n"):
text = text[text.index("\n---\n", 4) + 5:]
text = re.sub(r"<script.*?</script>", "", text, flags=re.S)
text = FENCED.sub("", text)
for number, line in enumerate(text.splitlines(), 1):
if GLYPHS.search(line) or _semicolons(line) or ONE_PLURAL.search(line):
+2 -2
View File
@@ -42,7 +42,7 @@ class SemanticManifestValidation(unittest.TestCase):
(install / "synthetic.json").write_text(
json.dumps({"files": None}), encoding="utf-8"
)
api = root / "docs" / "api" / "v1"
api = root / "website" / "public" / "api" / "v1"
api.mkdir(parents=True)
(api / "catalog.json").write_text(
json.dumps({"count": 1, "items": None}), encoding="utf-8"
@@ -58,7 +58,7 @@ class SemanticManifestValidation(unittest.TestCase):
text = output.getvalue().replace("\\", "/")
self.assertIn("database.json", text)
self.assertIn("install/synthetic.json", text)
self.assertIn("docs/api/v1/catalog.json", text)
self.assertIn("website/public/api/v1/catalog.json", text)
self.assertIn("files", text)
self.assertIn("items", text)
self.assertNotIn("Traceback", text)
+1 -1
View File
@@ -148,7 +148,7 @@ def _wiki_copy(tmp: str) -> Path:
class NavFromFrontMatter(unittest.TestCase):
def _nav(self, wiki: Path) -> dict:
nav = generate_site.generate_mkdocs_nav({}, {}, {}, {}, wiki)
nav = generate_site.generate_nav({}, {}, {}, {}, wiki)
return {next(iter(tab)): next(iter(tab.values())) for tab in nav}
def test_the_repository_nav_matches_the_pages(self):
+54 -41
View File
@@ -21,7 +21,10 @@ from dataclasses import dataclass, field
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DOCS = REPO_ROOT / "docs"
sys.path.insert(0, str(REPO_ROOT))
from tests import sitepages # noqa: E402
DOCS = sitepages.CONTENT
# Level 0: the Start here pages, with their own ceiling on words.
START_PAGES = {
@@ -142,15 +145,19 @@ def guide_part(markdown: str) -> str:
def parse(markdown: str) -> Page:
"""Blocks of prose a reader sees, code and HTML left out."""
"""Blocks of prose a reader sees, code and HTML left out.
A collapsed block (<details>) counts, but not before the first action:
the reader opens it on purpose. Of a group of tabs the first is read and
the others only stand in for it.
"""
text = guide_part(markdown)
text = re.sub(r"<script.*?</script>", "", text, flags=re.S)
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
page = Page()
seen_words = 0
collapsed_indent: int | None = None
tab_indent: int | None = None # the tabs of a group a reader picks one of
hidden_indent: int | None = None
details = 0 # depth of collapsed blocks
tab = 0 # the tab of a group being read, 0 outside a group
fence: str | None = None
current: list[str] = []
kind = "paragraph"
@@ -162,8 +169,7 @@ def parse(markdown: str) -> Page:
body = strip_inline(" ".join(current))
if WORD.search(body):
term = page.blocks[-1].text.lower() if kind == "definition" and page.blocks else ""
block = Block(kind, body, collapsed_indent is not None, term,
hidden_indent is not None)
block = Block(kind, body, details > 0, term, tab > 1)
page.blocks.append(block)
if (page.before_action is None and not block.collapsed
and not block.hidden and kind not in ("table", "heading")):
@@ -185,45 +191,52 @@ def parse(markdown: str) -> Page:
if stripped.startswith(fence):
fence = None
continue
if stripped and tab_indent is not None and indent <= tab_indent and not stripped.startswith("=== "):
flush()
tab_indent = hidden_indent = None
if stripped.startswith(("```", "~~~")):
fence = stripped[:3]
if hidden_indent is None:
if tab < 2:
act()
continue
if collapsed_indent is not None and stripped and indent <= collapsed_indent:
flush()
collapsed_indent = None
if not stripped:
flush()
continue
if re.match(r"(\?\?\?\+?|!!!) ", stripped):
flush()
if stripped.startswith("??? "):
collapsed_indent = indent
if stripped.startswith("import ") and stripped.endswith(";"):
continue
if stripped.startswith("=== "):
flush()
if tab_indent == indent:
hidden_indent = indent
else:
tab_indent, hidden_indent = indent, None
continue
if stripped.startswith("<") or stripped.startswith("</"):
if stripped.startswith(":::"):
flush()
continue
if ".md-button" in stripped:
if stripped.startswith("<details"):
flush()
details += 1
continue
if stripped.startswith("</details>"):
flush()
details = max(0, details - 1)
continue
if stripped.startswith("<Tabs"):
flush()
tab = 0
continue
if stripped.startswith("<TabItem"):
flush()
tab += 1
continue
if stripped.startswith("</Tabs>"):
flush()
tab = 0
continue
if "rb-button" in stripped or stripped.startswith("<FilePicker"):
act()
continue
if re.match(r"</?(?!abbr\b|a\b|code\b|strong\b|em\b)[A-Za-z]", stripped):
flush()
continue
if stripped.startswith("#"):
flush()
if run:
page.list_runs.append(run)
run = 0
kind = "heading"
current = [stripped.lstrip("#").strip()]
current = [re.sub(r"\s*\{[^}]*\}\s*$", "", stripped.lstrip("#").strip())]
flush()
kind = "paragraph"
continue
@@ -311,25 +324,23 @@ def held_pages(docs: Path) -> list[tuple[str, str, int]]:
"""(relative path, level, ceiling) for every page the budgets hold."""
pages = [(name, "start", ceiling) for name, ceiling in START_PAGES.items()]
pages += [
(str(path.relative_to(docs)), "setup", SETUP_CEILING)
for path in sorted((docs / "platforms").glob("*.md"))
if path.name != "index.md"
(sitepages.name(path), "setup", SETUP_CEILING)
for path in sorted((docs / "platforms").glob("*.md*"))
if path.stem != "index"
and "\n## Where the files go" in path.read_text(encoding="utf-8")
]
pages.append(("platforms/index.md", "setup", SETUP_INDEX_CEILING))
return [(name, level, ceiling) for name, level, ceiling in pages if (docs / name).is_file()]
return [(name, level, ceiling) for name, level, ceiling in pages
if sitepages.page(name).is_file()]
def _docs() -> Path:
if not (DOCS / "index.md").is_file():
if os.environ.get("CI"):
raise AssertionError("CI generates docs/ before the suite")
raise unittest.SkipTest("docs/ not generated (python scripts/generate_site.py)")
return DOCS
return sitepages.require()
def _load(name: str) -> Page:
return parse((_docs() / name).read_text(encoding="utf-8"))
_docs()
return parse(sitepages.page(name).read_text(encoding="utf-8"))
class CopyBudgets(unittest.TestCase):
@@ -400,8 +411,10 @@ class TheMeasureItself(unittest.TestCase):
SAMPLE = (
"---\nlevel: start\n---\n\n# Title\n\nOne two three. Four five.\n\n"
"- Item **one**.\n- Item [two](x.md).\n\n```sh\nnot counted at all\n```\n\n"
"??? note \"Hidden\"\n\n Collapsed words here.\n\n"
"=== \"A\"\n\n First tab.\n\n=== \"B\"\n\n Second tab words.\n\n"
'<details class="rb-details rb-details-note">\n<summary>Hidden</summary>\n\n'
"Collapsed words here.\n\n</details>\n\n"
'<Tabs syncKey="os">\n<TabItem label="A">\n\nFirst tab.\n\n</TabItem>\n'
'<TabItem label="B">\n\nSecond tab words.\n\n</TabItem>\n</Tabs>\n\n'
"| A | B |\n|---|---|\n| cell | cell |\n\n## Reference: deep\n\nNot read.\n"
)
@@ -429,7 +442,7 @@ def report(docs: Path) -> str:
rows = ["| Page | Words | Before action | Max block | Median / max sentence |",
"|---|---:|---:|---:|---|"]
for name, _level, _ceiling in held_pages(docs):
m = measure(parse((docs / name).read_text(encoding="utf-8")))
m = measure(parse(sitepages.page(name).read_text(encoding="utf-8")))
before = "none" if m["before_action"] is None else m["before_action"]
rows.append(f"| {name} | {m['words']} | {before} | {m['max_block']} | "
f"{m['median_sentence']:g} / {m['max_sentence']} |")
+2 -1
View File
@@ -36,7 +36,8 @@ class SiteCoreCounts(unittest.TestCase):
page = generate_site.generate_cross_reference(coverages, profiles)
shown = {
m.group(1): int(m.group(2))
for m in re.finditer(r'\?\?\? abstract "([^"]+)"\n\n.*\n\n {4}\*\*(\d+) cores', page)
for m in re.finditer(
r'<summary>([^<]+)</summary>\n\n.*\n\n\*\*(\d+) cores', page)
}
for name, cov in coverages.items():
with self.subTest(platform=name):
+8 -5
View File
@@ -86,7 +86,7 @@ class ACardKeepsItsAddress(unittest.TestCase):
files = [{"name": "DUKE3D.GRP", "md5": "aaaa1111bbbb"}]
page = _page("raze", {"type": "standalone", "files": files})
self.assertIn('<span id="file-duke3d-grp"></span>', page)
self.assertIn("{ #file-duke3d-grp-aaaa1111 .rb-file-title }", page)
self.assertIn('<h3 id="file-duke3d-grp-aaaa1111" class="rb-file-title"', page)
# A card already holding the plain id gives it to no one else.
files = [{"name": "x.bin"}, {"name": "x.bin", "sha1": "ab" * 20}]
anchors = gs.emulator_file_anchors(files)
@@ -121,8 +121,8 @@ class WhatASearchSnippetSays(unittest.TestCase):
"only validate_schemas.py and this")
def test_structured_url_is_encoded_like_the_canonical(self):
"""mkdocs encodes idtech4a++ as idtech4a%2B%2B in the canonical link
and the sitemap, and the JSON-LD named the unencoded URL."""
"""The canonical link and the sitemap encode idtech4a++ as
idtech4a%2B%2B, and the JSON-LD named the unencoded URL."""
import json
import tempfile
@@ -132,8 +132,11 @@ class WhatASearchSnippetSays(unittest.TestCase):
page.write_text("# idTech4A++\n\nSome text long enough to be a description.\n")
gs.decorate_markdown_pages(Path(tmp))
text = page.read_text()
data = json.loads(text.split('<script type="application/ld+json">')[1]
.split("</script>")[0].replace("<\\/", "</"))
import yaml
front = yaml.safe_load(text.split("---\n")[1])
script = next(entry for entry in front["head"] if entry["tag"] == "script")
data = json.loads(script["content"].replace("<\\/", "</"))
self.assertEqual(data["url"],
"https://abdess.github.io/retrobios/emulators/idtech4a%2B%2B/")
+17 -14
View File
@@ -236,7 +236,7 @@ class SiteReferenceContracts(unittest.TestCase):
decorate_markdown_pages(self.docs)
decorate_markdown_pages(self.docs)
content = page.read_text()
self.assertEqual(content.count('type="application/ld+json"'), 1)
self.assertEqual(content.count("type: application/ld+json"), 1)
self.assertIn("Current RetroBIOS verification gaps", content)
self.assertIn('"@type":"TechArticle"', content)
@@ -357,11 +357,11 @@ class AVariantShowsWhoPinsIt(unittest.TestCase):
platforms={"c" * 40: {"batocera", "retrobat"}}, emulators={"a" * 40: {"mame"}})
page = generate_site.generate_system_page(
"Entex", {"Adventure Vision": [primary, variant]}, consumers)
block = page[page.index('??? note "Variants (1)"'):]
self.assertIn(" - Collection path: `bios/Entex/Adventure Vision/.variants/advision.zip.1c086064`", block)
block = page[page.index("<summary>Variants (1)</summary>"):]
self.assertIn("- Collection path: `bios/Entex/Adventure Vision/.variants/advision.zip.1c086064`", block)
self.assertIn("[batocera](../platforms/batocera.md)", block)
self.assertIn("[retrobat](../platforms/retrobat.md)", block)
self.assertIn('`' + "c" * 40 + '`', block)
self.assertIn('<code class="rb-hash">' + "c" * 40 + "</code>", block)
self.assertIn("Catalogued", block)
@@ -402,10 +402,10 @@ class EmulatorPagesPrintWholeHashes(unittest.TestCase):
from generate_site import _hash_spans
sha1 = "0c3de403b524107809fa9308b730d60e8e41ba93"
self.assertIn(f"`{sha1}`", _hash_spans(sha1))
self.assertIn(f">{sha1}</code>", _hash_spans(sha1))
both = _hash_spans(f"{'a' * 64}, {'b' * 64}")
self.assertIn("`" + "a" * 64 + "`", both)
self.assertIn("`" + "b" * 64 + "`", both)
self.assertIn(">" + "a" * 64 + "</code>", both)
self.assertIn(">" + "b" * 64 + "</code>", both)
self.assertNotIn("...", both)
@@ -724,15 +724,16 @@ class AFileHasAnAddressByContent(unittest.TestCase):
[{"sha1": "ab" * 20, "url": "systems/roland/#sha1-" + "ab" * 6}],
)
self.assertIn('id="rb-file-lookup-status"', generate_site.generate_files_page())
script = (Path(__file__).resolve().parents[1] / "docs_assets" / "site.js").read_text()
script = (Path(__file__).resolve().parents[1] / "website" / "src" / "scripts"
/ "site.ts").read_text()
self.assertIn("api/v1/lookup/", script)
self.assertIn('params.has("sha1")', script)
self.assertIn("params.has('sha1')", script)
from cross_reference import FileConsumers # noqa: PLC0415
card = "\n".join(generate_site._system_file_card(
{"name": "x.rom", "sha1": "ab" * 20, "size": 1}, FileConsumers({"files": {}}),
))
self.assertIn("../../files/?sha1=" + "ab" * 20, card)
self.assertIn("/retrobios/files/?sha1=" + "ab" * 20, card)
class ACardNamesThePacksThatInstallIt(unittest.TestCase):
@@ -825,7 +826,7 @@ class EveryAcceptedHashIsItsOwnWord(unittest.TestCase):
md5s = ["96cf" + "0" * 28, "d5af" + "0" * 28]
text = _hash_spans(",".join(md5s))
for md5 in md5s:
self.assertIn(f"`{md5}`</span>", text)
self.assertIn(f'<code class="rb-hash">{md5}</code>', text)
class AMismatchNamesTheDumpTheCollectionHolds(unittest.TestCase):
@@ -924,12 +925,14 @@ class NestedProfileBlocksRenderAsLists(unittest.TestCase):
lines = generate_site._render_platform_details(
{"platform_details": {"megacd": {"root": "x"}, "note": "y"}}
)
lines[0] = lines[0].replace("???+", "!!!")
try:
import markdown
except ImportError:
self.skipTest("markdown is not installed")
html = markdown.markdown("\n".join(lines), extensions=["admonition"])
# The block's body, read as the engine reads Markdown inside HTML.
body = lines[lines.index("<summary>Platform details</summary>") + 1:
lines.index("</details>")]
html = markdown.markdown("\n".join(body))
self.assertIn("<li>root: x</li>", html)
self.assertNotIn("**", html)
@@ -959,7 +962,7 @@ class TheOneLinerIsWhereUsersLand(unittest.TestCase):
"verified": 0, "missing": 0, "details": [], "config": {"systems": {}}}
page = generate_site.generate_platform_page("batocera", cov)
self.assertIn("install.sh | sh -s -- --platform batocera", page)
self.assertLess(page.index("--platform batocera"), page.index("md-button"))
self.assertLess(page.index("--platform batocera"), page.index("rb-button"))
# Batocera runs the command on the device itself. A platform detected
# on Windows gets the PowerShell form.
cov = dict(cov, platform="RetroArch", mode="existence")
+43 -27
View File
@@ -19,12 +19,14 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import generate_site # noqa: E402
from tests import sitepages # noqa: E402
from common import list_registered_platforms, load_platform_config, load_platform_registry # noqa: E402
from siteguides import DEVICE_GROUPS, GROUP_ANCHORS, GUIDES # noqa: E402
DOCS = REPO_ROOT / "docs"
DOCS = sitepages.CONTENT
ORDER = list(generate_site.LEVELS)
LINK = re.compile(r"\[[^\]]*\]\(([^)\s#]+\.md)(?:#[^)]*)?\)")
# Pages link to site addresses once written for the site.
LINK = re.compile(r"\[[^\]]*\]\((/retrobios/[^)\s#?]*)(?:[#?][^)]*)?\)")
FENCED = re.compile(r"^\s*```.*?^\s*```", re.S | re.M)
@@ -38,13 +40,7 @@ def _which_pack() -> str:
def _docs_pages() -> list[Path]:
if not DOCS.is_dir() or not (DOCS / "index.md").is_file():
if os.environ.get("CI"):
raise AssertionError("CI generates docs/ before the suite")
raise unittest.SkipTest("docs/ not generated (python scripts/generate_site.py)")
skipped = set(generate_site.LOCAL_NOTE_DIRS)
return [p for p in sorted(DOCS.rglob("*.md"))
if not set(p.relative_to(DOCS).parts[:-1]) & skipped]
return [p for p in sitepages.pages() if p.name not in ("404.md",)]
def _level(page: Path) -> str:
@@ -55,14 +51,14 @@ class FiveTabs(unittest.TestCase):
"""Twelve tabs put the walkthrough at position eleven, under "Wiki"."""
def test_the_tabs_in_reading_order(self):
nav = generate_site.generate_mkdocs_nav({}, {}, {}, {})
nav = generate_site.generate_nav({}, {}, {}, {})
self.assertEqual(
[next(iter(tab)) for tab in nav],
["Start here", "Your setup", "Explore", "Reference", "Contribute"],
)
def test_each_tab_opens_on_an_index_or_a_first_page(self):
nav = generate_site.generate_mkdocs_nav({}, {}, {}, {})
nav = generate_site.generate_nav({}, {}, {}, {})
tabs = {next(iter(tab)): next(iter(tab.values())) for tab in nav}
self.assertEqual(tabs["Start here"][0], "index.md")
self.assertEqual(tabs["Your setup"][0], "platforms/index.md")
@@ -70,8 +66,10 @@ class FiveTabs(unittest.TestCase):
self.assertEqual(tabs["Contribute"][0], "contributing.md")
def test_the_breadcrumbs_are_on(self):
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
self.assertIn(" - navigation.path\n", source)
title = (REPO_ROOT / "website" / "src" / "components" / "PageTitle.astro").read_text(encoding="utf-8")
route = (REPO_ROOT / "website" / "src" / "routeData.ts").read_text(encoding="utf-8")
self.assertIn('aria-label="Breadcrumb"', title)
self.assertIn("rbBreadcrumbs", route)
class EveryPlatformOnThePicker(unittest.TestCase):
@@ -100,8 +98,17 @@ class EveryPlatformOnThePicker(unittest.TestCase):
of the nav is reachable only by a link and MkDocs says so at info
level. getting-started.md is kept out on purpose: it only links the
pages that took its content, for the old links that point to it."""
# mkdocs.yml is what generate_site writes, and CI checks it is current.
nav = (Path(__file__).resolve().parents[1] / "mkdocs.yml").read_text(encoding="utf-8")
def targets(entries) -> list[str]:
found = []
for entry in entries:
if isinstance(entry, str):
found.append(entry)
else:
for value in entry.values():
found += targets(value) if isinstance(value, list) else [value]
return found
nav = " ".join(targets(generate_site.generate_nav({}, {}, {}, {})))
kept_out = {"getting-started.md"}
wiki = Path(__file__).resolve().parents[1] / "wiki"
missing = sorted(
@@ -116,7 +123,7 @@ class EveryPlatformOnThePicker(unittest.TestCase):
key: {"platform": load_platform_config(key, str(REPO_ROOT / "platforms"))["platform"]}
for key in _registered()
}
nav = generate_site.generate_mkdocs_nav(coverages, {}, {}, registry)
nav = generate_site.generate_nav(coverages, {}, {}, registry)
setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab)
groups = {next(iter(item)): next(iter(item.values()))
for item in setup if isinstance(item, dict)}
@@ -166,7 +173,12 @@ class EveryPageHasALevel(unittest.TestCase):
level = _level(page)
with self.subTest(page=str(page.relative_to(DOCS))):
self.assertIn(level, generate_site.LEVELS)
self.assertIn(generate_site.level_label(level), text)
self.assertIn(f"\nlevel: {level}\n", text)
# The label above each title comes from the page's level.
component = (REPO_ROOT / "website" / "src" / "components" / "PageTitle.astro").read_text()
for level, label in generate_site.LEVELS.items():
with self.subTest(level=level):
self.assertIn(f"{level}: '{label}'", component)
def test_the_decoration_keeps_an_authored_level(self):
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
@@ -177,9 +189,8 @@ class EveryPageHasALevel(unittest.TestCase):
generate_site.decorate_markdown_pages(Path(tmp))
text = page.read_text()
self.assertEqual(text.count("level: start"), 1)
self.assertEqual(text.count(generate_site.level_label("start")), 1)
self.assertIn(" boost: 5\n", text)
self.assertIn("# FAQ\n", text)
self.assertIn("searchWeight: 10\n", text)
self.assertIn("title: FAQ\n", text)
class DeepLinksCarryALabel(unittest.TestCase):
@@ -189,8 +200,11 @@ class DeepLinksCarryALabel(unittest.TestCase):
its line, or in the heading of its section ("## Reference: ...")."""
def test_start_and_setup_pages(self):
import sitestarlight # noqa: PLC0415
pages = _docs_pages()
levels = {page: _level(page) for page in pages}
by_url = {sitestarlight.page_url(sitepages.name(page)): page for page in pages}
offenders = []
for page, level in levels.items():
if level not in ("start", "setup"):
@@ -201,7 +215,7 @@ class DeepLinksCarryALabel(unittest.TestCase):
if re.match(r"#{1,3} ", line):
heading = line.lstrip("#").strip()
for match in LINK.finditer(line):
target = (page.parent / match.group(1)).resolve()
target = by_url.get(match.group(1))
if target not in levels:
continue
deeper = levels[target]
@@ -218,18 +232,20 @@ class GuidePagesEndOnANextStep(unittest.TestCase):
for page in ("index.md", "which-pack.md", "platforms/batocera.md",
"wiki/first-install.md", "wiki/troubleshooting.md",
"wiki/what-are-bios-files.md", "wiki/faq.md"):
path = DOCS / page if page.startswith(("index", "which", "platforms")) else REPO_ROOT / page
if not path.is_file():
_docs_pages()
if page.startswith(("index", "which", "platforms")):
sitepages.require()
path = sitepages.page(page)
else:
path = REPO_ROOT / page
with self.subTest(page=page):
text = path.read_text(encoding="utf-8")
self.assertTrue('!!! tip "Next"' in text or "## What comes next" in text)
self.assertTrue(":::tip[Next]" in text or "## What comes next" in text)
def test_the_platform_page_opens_on_its_guide(self):
"""The folder came after four counters and a metadata table, and was
the pack-relative `bios/` where Batocera reads /userdata/bios/."""
text = (DOCS / "platforms" / "batocera.md").read_text(encoding="utf-8") \
if (DOCS / "platforms" / "batocera.md").is_file() else _docs_pages()
sitepages.require()
text = sitepages.page("platforms/batocera.md").read_text(encoding="utf-8")
order = [text.index(h) for h in (
"## Where the files go", "## Install", "## Download", "## How to check",
"## Reference: every file by system",
+8 -2
View File
@@ -200,9 +200,15 @@ class SharingCards(unittest.TestCase):
ET.fromstring(sitediagrams.og_card_svg(section, b"png"))
def test_the_template_names_every_section(self):
template = (ROOT / "docs_assets" / "overrides" / "main.html").read_text(encoding="utf-8")
named = set(re.findall(r"section = '([a-z]+)'", template))
import sitestarlight # noqa: PLC0415
named = {sitestarlight.og_section("index.md", "")} | {
sitestarlight.og_section("wiki/page.md", level)
for level in ("start", "setup", "explore", "reference", "contribute")}
self.assertEqual(named, set(sitediagrams.OG_SECTIONS))
for section in named:
with self.subTest(section=section):
self.assertTrue((ROOT / "docs_assets" / "og" / f"{section}.png").is_file())
class ImagesStillNeeded(unittest.TestCase):
+91
View File
@@ -0,0 +1,91 @@
"""The file picker on Get your files lists what the installer installs.
The picker reads a compacted copy of each install/<platform>.json and keeps
a file when one of its systems is chosen, or when it has none and "other"
is chosen, then prints `--system` with the choices. install.py --system
narrows with install.narrow(). The two must agree file for file, or the
list the page shows is not what the command it prints installs.
"""
from __future__ import annotations
import importlib.util
import json
import random
import re
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
install = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(install)
PICKER = REPO_ROOT / "website" / "src" / "components" / "FilePicker.astro"
def picked(data: dict, chosen: set[str]) -> list[str]:
"""The picker's rule, as FilePicker.astro applies it."""
wanted = {index for index, system in enumerate(data["systems"]) if system["id"] in chosen}
other = install.OTHER_SYSTEMS in chosen
return [dest for dest, _size, systems in data["files"]
if (any(s in wanted for s in systems) if systems else other)]
class PickerMatchesInstaller(unittest.TestCase):
def setUp(self):
import sitestarlight # noqa: PLC0415
self.folder = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None)
self.out = Path(self.folder.name)
self.count = sitestarlight.picker_data(REPO_ROOT / "install", self.out)
def tearDown(self):
self.folder.cleanup()
def test_every_manifest_has_its_list(self):
index = json.loads((self.out / "index.json").read_text(encoding="utf-8"))
manifests = [p for p in (REPO_ROOT / "install").glob("*.json")
if "files" in json.loads(p.read_text(encoding="utf-8"))]
self.assertEqual(len(index), len(manifests))
self.assertEqual(self.count, len(manifests))
def test_random_selections_equal_install_narrow(self):
rng = random.Random(20261022)
checked = 0
for entry in json.loads((self.out / "index.json").read_text(encoding="utf-8")):
platform = entry["platform"]
manifest = json.loads((REPO_ROOT / "install" / f"{platform}.json").read_text(encoding="utf-8"))
data = json.loads((self.out / f"{platform}.json").read_text(encoding="utf-8"))
choices = [s["id"] for s in data["systems"]] + ([install.OTHER_SYSTEMS] if data["other"] else [])
for _round in range(10):
chosen = set(rng.sample(choices, rng.randint(1, min(len(choices), 6))))
files, _omitted = install.narrow(manifest["files"], [], sorted(chosen), [], [])
with self.subTest(platform=platform, systems=sorted(chosen)):
self.assertEqual(sorted(picked(data, chosen)), sorted(f["dest"] for f in files))
checked += 1
self.assertGreater(checked, 0)
def test_choices_are_what_the_installer_offers(self):
for entry in json.loads((self.out / "index.json").read_text(encoding="utf-8")):
manifest = json.loads((REPO_ROOT / "install" / f"{entry['platform']}.json").read_text(encoding="utf-8"))
data = json.loads((self.out / f"{entry['platform']}.json").read_text(encoding="utf-8"))
offered = install.available_choices(manifest["files"], [])["systems"]
ours = [s["id"] for s in data["systems"]] + ([install.OTHER_SYSTEMS] if data["other"] else [])
with self.subTest(platform=entry["platform"]):
self.assertEqual(ours, offered)
def test_component_applies_the_same_rule(self):
"""The rule above is the component's: a change to one fails here."""
source = PICKER.read_text(encoding="utf-8")
self.assertIn("systems.length ? systems.some((s) => wanted.has(s)) : other", source)
self.assertIn("picked.includes('other')", source)
self.assertRegex(source, re.escape("--system ${picked.join(',')}"))
if __name__ == "__main__":
unittest.main()
+24 -18
View File
@@ -126,16 +126,18 @@ class StructuredBlocks(unittest.TestCase):
page = self.render(self.profile("firmware_titles", [
{"id": "0005001b-10042400", "name": "Shared fonts",
"content": ["CafeCn.ttf", "CafeKr.ttf"]}]))
self.assertIn(" - id: 0005001b-10042400, name: Shared fonts\n", page)
self.assertIn(" - **content:**", page)
self.assertIn(" - CafeKr.ttf", page)
self.assertIn("\n- id: 0005001b-10042400, name: Shared fonts\n", page)
self.assertIn("\n - **content:**", page)
self.assertIn("\n - CafeKr.ttf", page)
def test_a_folded_note_stays_inside_its_block(self):
page = self.render(self.profile("analysis", {"tmss": {
"note": "The core points\nsystem/tmss_path at the system directory."}}))
self.assertIn("- **note:** The core points system/tmss_path at the", page)
body = page.split("\n")[1:]
self.assertEqual([line for line in body if line and not line.startswith(" ")], [])
# Nothing of the note is left after the block closes.
lines = page.split("\n")
end = lines.index("</details>")
self.assertEqual([line for line in lines[end + 1:] if line.strip()], [])
def test_a_long_note_is_not_cut(self):
note = "word " * 80 + "end."
@@ -315,7 +317,7 @@ class TheSearchLandsOnTheCard(unittest.TestCase):
self.assertEqual(len(headings), 11)
self.assertTrue(headings[0].endswith("{ #system-00 }"))
self.assertNotIn('<a id="system-00"></a>', lines)
self.assertIn('??? note "1 file (OK)"', lines)
self.assertIn("<summary>1 file (OK)</summary>", lines)
def test_an_emulator_card_is_a_heading_with_its_anchor(self):
import generate_site
@@ -323,8 +325,8 @@ class TheSearchLandsOnTheCard(unittest.TestCase):
entry = {"name": "scph5501.bin", "required": True}
lines = generate_site._render_emulator_file(
entry, {"files": [entry]}, None, [entry], lambda f: True, "file-scph5501-bin")
self.assertTrue(lines[2].startswith("### `scph5501.bin` "))
self.assertTrue(lines[2].endswith("{ #file-scph5501-bin .rb-file-title }"))
self.assertTrue(lines[2].startswith('<h3 id="file-scph5501-bin" class="rb-file-title"'))
self.assertIn("<code>scph5501.bin</code>", lines[2])
def test_a_system_card_is_a_heading_with_its_anchor(self):
import generate_site
@@ -332,8 +334,8 @@ class TheSearchLandsOnTheCard(unittest.TestCase):
entry = {"name": "a.bin", "sha1": "a" * 40, "md5": "b" * 32, "size": 3}
lines = generate_site._system_file_card(entry, FileConsumers({"files": {}}))
self.assertTrue(lines[2].startswith("### `a.bin` "))
self.assertTrue(lines[2].endswith("{ #sha1-aaaaaaaaaaaa .rb-file-title }"))
self.assertTrue(lines[2].startswith('<h3 id="sha1-aaaaaaaaaaaa" class="rb-file-title"'))
self.assertIn("<code>a.bin</code>", lines[2])
class TheSearchSkipsLinkLists(unittest.TestCase):
@@ -353,11 +355,14 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
consumers.emulators = {sha1: {"duckstation"}}
entry = {"name": "a.bin", "sha1": sha1, "md5": "b" * 32, "size": 3}
text = "\n".join(generate_site._system_file_card(entry, consumers))
searched, skipped = text.split("<section data-search-exclude markdown>")
self.assertIn("`a.bin`", searched)
searched, _sep, skipped = text.partition("<section data-pagefind-ignore>")
self.assertIn("<code>a.bin</code>", searched)
self.assertNotIn("- MD5:", searched)
for line in ("- MD5:", "- Emulators:", "- Platforms:", "- Also installed by:"):
for line in ("- MD5:", "- Emulators:", "- Also installed by:"):
self.assertIn(line, skipped)
# The platforms line names the file as each setup does: the site
# search reads it, so a file name finds its card.
self.assertIn("- Platforms:", searched + skipped.split("</section>", 1)[1])
def test_a_game_data_card_skips_its_name_too(self):
import generate_site
@@ -366,7 +371,8 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
entry = {"name": "0.ogg", "sha1": "a" * 40, "size": 3}
lines = generate_site._system_file_card(
entry, FileConsumers({"files": {}}), searchable=False)
self.assertTrue(lines[2].endswith("data-search-exclude }"))
self.assertIn("data-pagefind-ignore", lines[2])
self.assertNotIn("data-pagefind-weight", lines[2])
def test_runs_alternate_in_card_order(self):
import generate_site
@@ -377,10 +383,10 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
out = [line for line in generate_site._search_skipped_apart(lines) if line]
self.assertEqual(out, [
"- Path: `p`",
'<div class="rb-search-skip" data-search-exclude markdown>',
'<div class="rb-search-skip" data-pagefind-ignore>',
"- SHA1: x", "- Source: `a.c:1`", "- Platforms: p", "</div>",
"- Destination: `d`",
'<div class="rb-search-skip" data-search-exclude markdown>',
'<div class="rb-search-skip" data-pagefind-ignore>',
"- Contents (1 members):", " - `m`", "</div>",
"- Note",
])
@@ -391,8 +397,8 @@ class TheSearchSkipsLinkLists(unittest.TestCase):
short = "| a |\n|---|\n| 1 |"
long = " | a |\n |---|\n" + "\n".join(f" | {'x' * 50} |" for _ in range(80))
text = generate_site.tables_out_of_search(f"# T\n\n{short}\n\n{long}\n")
self.assertEqual(text.count("data-search-exclude"), 1)
self.assertIn(" <section data-search-exclude markdown>\n\n | a |", text)
self.assertEqual(text.count("data-pagefind-ignore"), 1)
self.assertIn(" <section data-pagefind-ignore>\n\n | a |", text)
self.assertIn(short, text)
+1 -1
View File
@@ -143,7 +143,7 @@ class AnIdNamesOneCardPerPage(unittest.TestCase):
"path": "bios/Nintendo/DS/a.bin"}
page = gs.generate_system_page(
"Nintendo", {"DS": [entry], "DSi": [entry]}, FileConsumers({"files": {}}))
self.assertEqual(page.count("#sha1-aaaaaaaaaaaa .rb-file-title"), 1)
self.assertEqual(page.count('id="sha1-aaaaaaaaaaaa" class="rb-file-title"'), 1)
self.assertEqual(page.count("rb-sys-file"), 2)
+23 -43
View File
@@ -101,9 +101,9 @@ class PlatformDetails(unittest.TestCase):
"root": "<system_dir>/x",
}}}
)
self.assertIn(" - extensions_tried: .bin, .zip", lines)
self.assertIn(" - hle_available: no", lines)
self.assertIn(" - root: &lt;system_dir>/x", lines)
self.assertIn(" - extensions_tried: .bin, .zip", lines)
self.assertIn(" - hle_available: no", lines)
self.assertIn(" - root: &lt;system_dir>/x", lines)
class RenderedSiteValidation(unittest.TestCase):
@@ -112,10 +112,8 @@ class RenderedSiteValidation(unittest.TestCase):
self.root = Path(self.temp.name)
self.site = self.root / "site"
self.site.mkdir()
self.config = self.root / "mkdocs.yml"
self.config.write_text(
"site_url: https://example.test/retrobios/\n", encoding="utf-8"
)
# The path the site is served under.
self.config = "/retrobios/"
def tearDown(self) -> None:
self.temp.cleanup()
@@ -174,8 +172,7 @@ class TheSiteStaysWithinItsBudgets(unittest.TestCase):
self.temp = tempfile.TemporaryDirectory(dir=TMP_ROOT)
self.site = Path(self.temp.name) / "site"
self.site.mkdir()
self.config = Path(self.temp.name) / "mkdocs.yml"
self.config.write_text("site_url: https://example.test/retrobios/\n", encoding="utf-8")
self.config = "/retrobios/"
def tearDown(self) -> None:
self.temp.cleanup()
@@ -214,17 +211,16 @@ class TheSiteStaysWithinItsBudgets(unittest.TestCase):
self.assertTrue(any("drawn by a script at runtime" in issue for issue in issues))
def test_a_search_index_over_its_budget(self):
import random
import validate_site as module
(self.site / "search").mkdir()
rng = random.Random(1)
noise = "".join(rng.choice("0123456789abcdef") for _ in range(module.MAX_SEARCH_INDEX_BYTES + 1))
(self.site / "search" / "search_index.json").write_text(noise, encoding="utf-8")
index = self.site / "pagefind" / "index"
index.mkdir(parents=True)
(index / "en_big.pf_index").write_bytes(b"x" * (module.MAX_PAGEFIND_CHUNK_BYTES + 1))
(self.site / "pagefind" / "pagefind.en_x.pf_meta").write_bytes(
b"x" * (module.MAX_PAGEFIND_META_BYTES + 1))
issues = self._issues("")
self.assertTrue(any("search_index.json" in issue and "gzip" not in issue for issue in issues))
self.assertTrue(any("search_index.json" in issue and "gzip" in issue for issue in issues))
self.assertTrue(any("en_big.pf_index" in issue for issue in issues), issues)
self.assertTrue(any(".pf_meta" in issue for issue in issues), issues)
class UnrenderedMarkdown(unittest.TestCase):
@@ -327,9 +323,12 @@ class TheSitemapIsAnnouncedWhereCrawlersLook(unittest.TestCase):
never found that way."""
def test_the_head_names_it_and_no_robots_is_written(self):
# Starlight links the sitemap from every page's head when the site
# has an address, and the config keeps sitemap.xml at its old path.
root = Path(__file__).resolve().parents[1]
override = (root / "docs_assets" / "overrides" / "main.html").read_text()
self.assertIn('rel="sitemap"', override)
config = (root / "website" / "astro.config.mjs").read_text()
self.assertIn("site: SITE", config)
self.assertIn("sitemap.xml", config)
source = (root / "scripts" / "generate_site.py").read_text()
self.assertNotIn('"robots.txt"),\n "User-agent', source)
self.assertNotIn("Sitemap: https://", source)
@@ -351,11 +350,10 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase):
only its sha1 and md5, so the crc a clrmamepro user pastes found nothing."""
def test_the_rendered_page(self):
page = ROOT / "docs" / "platforms" / "retroarch.md"
if not page.is_file():
if os.environ.get("CI"):
self.fail("CI generates docs/ before the suite")
self.skipTest("site not generated")
from tests import sitepages # noqa: PLC0415
sitepages.require()
page = sitepages.page("platforms/retroarch.md")
self.assertIn("CRC32: `91e93107`", page.read_text(encoding="utf-8"))
@@ -378,7 +376,7 @@ class PlainHouseStyle(unittest.TestCase):
}
def test_the_stylesheet_has_none_of_the_patterns(self):
css = (ROOT / "docs_assets" / "extra.css").read_text(encoding="utf-8")
css = (ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8")
for label, pattern in self.PATTERNS.items():
with self.subTest(pattern=label):
self.assertIsNone(pattern.search(css), label)
@@ -392,21 +390,3 @@ class PlainHouseStyle(unittest.TestCase):
first = page.read_text(encoding="utf-8").splitlines()[0]
with self.subTest(page=page.name):
self.assertFalse(first.endswith(" - RetroBIOS"), first)
class APreviewLeavesTheTrackedConfig(unittest.TestCase):
"""--docs-dir moved the pages but still rewrote the tracked mkdocs.yml."""
def test_another_folder_gets_its_own_config(self):
from generate_site import _mkdocs_config
path, text = _mkdocs_config(Path("docs"), "hooks:\n - scripts/mkdocs_hooks.py\n")
self.assertEqual(path, Path("mkdocs.yml"))
path, text = _mkdocs_config(Path("tmp/preview"), "hooks:\n - scripts/mkdocs_hooks.py\n")
self.assertEqual(path, Path("tmp/preview.mkdocs.yml"))
self.assertTrue(text.startswith("docs_dir: preview\n"))
self.assertIn("../scripts/mkdocs_hooks.py", text)
if __name__ == "__main__":
unittest.main()
+27 -18
View File
@@ -18,8 +18,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import generate_site # noqa: E402
import mkdocs_hooks # noqa: E402
import sitestarlight # noqa: E402
import siterender # noqa: E402
import sitemarkup as markup # noqa: E402
from cross_reference import FileConsumers # noqa: E402
@@ -54,7 +55,7 @@ class ASystemPageOverItsBudgetIsSplit(unittest.TestCase):
self.assertIn("## Big", summary)
self.assertIn("## Small", summary)
# The small section keeps its cards in place.
self.assertIn("{ #sha1-" + f"{900:012x}", summary)
self.assertIn('id="sha1-' + f"{900:012x}", summary)
parts = [stem for stem in pages if stem.startswith("maker-big")]
self.assertGreater(len(parts), 1)
self.assertEqual(parts[0], "maker-big")
@@ -73,7 +74,9 @@ class ASystemPageOverItsBudgetIsSplit(unittest.TestCase):
def test_a_console_on_one_page_is_linked_directly(self):
self.consoles["Big"] = self.consoles["Big"][:12]
whole = self._pages(10**9)["maker"][0]
# The budget is measured in the Markdown it was set on.
with markup.using("mkdocs"):
whole = self._pages(10**9)["maker"][0]
pages = self._pages(len(whole) - 1)
self.assertIn("maker-big", pages)
self.assertNotIn("maker-big-2", pages)
@@ -99,8 +102,8 @@ class AnEmulatorWithTooManyCardsIsSplit(unittest.TestCase):
for anchor in anchors:
self.assertIn(f'<span id="{anchor}"></span>', main)
self.assertIn('class="rb-card-index"', main)
self.assertNotIn(".rb-file-title", main)
self.assertIn(f"{{ #{anchors[0]} .rb-file-title }}", pages["big-files-1"])
self.assertNotIn("rb-file-title", main)
self.assertIn(f'id="{anchors[0]}" class="rb-file-title"', pages["big-files-1"])
def test_cards_are_linked_on_the_page_that_holds_them(self):
files = [{"name": f"set{i:04d}.zip"} for i in range(generate_site.EMULATOR_CARDS_PER_PAGE + 1)]
@@ -147,21 +150,27 @@ class TheNavigationOpensOneInitial(unittest.TestCase):
class TheTableOfContentsOfACardPageStopsAtLevelTwo(unittest.TestCase):
@staticmethod
def _item(level: int, children: list | None = None) -> SimpleNamespace:
return SimpleNamespace(level=level, children=children or [])
@staticmethod
def _contents(rel: str, sections: int):
import yaml # noqa: PLC0415
body = "# T\n\n" + "".join(f"## S{i}\n\n### card\n\n" for i in range(sections))
_target, text = sitestarlight.finalize_page(
rel, body, level="", weight=None, browser_title="T", description="d",
structured={}, terms={})
return yaml.safe_load(text.split("---\n")[1]).get("tableOfContents", "default")
def test_cards_leave_the_toc_and_wiki_pages_keep_level_three(self):
def page(uri: str) -> SimpleNamespace:
h2 = self._item(2, [self._item(3)])
return SimpleNamespace(file=SimpleNamespace(src_uri=uri),
toc=SimpleNamespace(items=[self._item(1, [h2])]))
for uri, kept in (("systems/sony.md", 0), ("platforms/retroarch.md", 0),
("emulators/mame.md", 0), ("wiki/faq.md", 1)):
current = page(uri)
mkdocs_hooks.on_page_content("", current, None, None)
with self.subTest(uri=uri):
self.assertEqual(len(current.toc.items[0].children[0].children), kept)
level_two = {"minHeadingLevel": 2, "maxHeadingLevel": 2}
for rel, sections, expected in (
("systems/sony.md", 3, level_two), ("platforms/retroarch.md", 5, level_two),
("emulators/mame.md", 3, level_two), ("wiki/faq.md", 3, "default"),
# One or two headings over hundreds of cards: no table at all,
# whose script watched every block of the page.
("emulators/mame-files-2.md", 1, False), ("systems/arcade-arcade.md", 2, False),
):
with self.subTest(rel=rel):
self.assertEqual(self._contents(rel, sections), expected)
if __name__ == "__main__":
+17 -18
View File
@@ -79,15 +79,8 @@ class DeploySiteTriggers(unittest.TestCase):
}
)
self.assertIn("generate_site.py", entry_points)
# mkdocs build runs the hooks mkdocs.yml names: a script of the build.
# The file carries Python tags safe_load refuses, but the list is plain.
mkdocs = (REPO_ROOT / "mkdocs.yml").read_text(encoding="utf-8")
hooks = re.search(r"^hooks:\n((?: - .+\n)+)", mkdocs, re.M)
entry_points += [
line.strip()[2:].removeprefix("scripts/")
for line in (hooks.group(1).splitlines() if hooks else [])
if line.strip()[2:].startswith("scripts/")
]
# The Starlight project is an input of its own build.
self.assertIn("website/**", patterns)
uncovered = sorted(
path
for path in closure(entry_points)
@@ -109,9 +102,9 @@ class DeploySiteTriggers(unittest.TestCase):
class ThePullRequestBuildsTheSite(unittest.TestCase):
"""mkdocs was installed by deploy-site.yml alone, which runs after the
merge, and validate.yml did not watch wiki/: a dead link or a line read as
a second heading passed every check and failed the deploy on main."""
"""The site build ran in deploy-site.yml alone, after the merge, and
validate.yml did not watch wiki/: a dead link or a line read as a second
heading passed every check and failed the deploy on main."""
@staticmethod
def _workflow(name: str) -> dict:
@@ -124,17 +117,23 @@ class ThePullRequestBuildsTheSite(unittest.TestCase):
runs = "\n".join(str(step.get("run", "")) for step in steps)
for command in (
"python scripts/generate_site.py",
"mkdocs build --strict",
"python scripts/validate_site.py",
"npm ci --prefix website",
"npm run build --prefix website",
"python scripts/validate_site.py --site-dir website/dist",
):
with self.subTest(command=command):
self.assertIn(command, runs)
for package in ("mkdocs-material", "pymdown-extensions"):
with self.subTest(package=package):
self.assertIn(package, runs)
# The same Node, from the official action pinned by commit, in both.
uses = {str(step.get("uses", "")) for step in steps}
deploy = {str(step.get("uses", ""))
for step in self._workflow("deploy-site.yml")["jobs"]["build"]["steps"]}
node = {use for use in uses if use.startswith("actions/setup-node@")}
self.assertEqual(len(node), 1)
self.assertTrue(node <= deploy)
self.assertRegex(next(iter(node)), r"@[0-9a-f]{40}$")
def test_the_pages_exist_before_the_suite_reads_them(self):
"""docs/ is gitignored. Built after the suite, the tests that read
"""The pages are gitignored. Built after the suite, the tests that read
the rendered pages found none and skipped on every CI run."""
steps = self._workflow("validate.yml")["jobs"]["run-tests"]["steps"]
runs = [str(step.get("run", "")) for step in steps]
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.astro/
# Written by scripts/generate_site.py
src/content/docs/
src/generated/
public/
+101
View File
@@ -0,0 +1,101 @@
// The RetroBIOS site. Pages, navigation and data files are written by
// scripts/generate_site.py; this folder holds the engine's configuration,
// the components and the styles.
import { copyFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import sitemap from '@astrojs/sitemap';
import { satteri } from '@astrojs/markdown-satteri';
import starlightSidebarTopics from 'starlight-sidebar-topics';
import starlightLinksValidator from 'starlight-links-validator';
import sidebar from './src/generated/sidebar.json' with { type: 'json' };
import lastmod from './src/generated/lastmod.json' with { type: 'json' };
const SITE = 'https://abdess.github.io';
const BASE = '/retrobios/';
// The sitemap keeps the address MkDocs gave it: sitemap.xml, beside the index.
const sitemapAlias = {
name: 'retrobios-sitemap-alias',
hooks: {
'astro:build:done': async ({ dir }) => {
const root = fileURLToPath(dir);
await copyFile(`${root}sitemap-index.xml`, `${root}sitemap.xml`);
},
},
};
export default defineConfig({
site: SITE,
base: BASE,
trailingSlash: 'always',
markdown: {
processor: satteri({
features: {
headingAttributes: true,
definitionList: true,
// Straight quotes: curly ones would change a copied command.
smartPunctuation: false,
},
}),
},
integrations: [
starlight({
title: 'RetroBIOS',
titleDelimiter: '-',
description:
'Source-verified BIOS and firmware packs for RetroArch, Batocera, Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX, KNULLI, AmberELEC, EmuELEC and MiSTer FPGA.',
// The brand mark is white on the indigo of the old header: indigo on a light page.
logo: { light: './src/assets/logo-light.svg', dark: './src/assets/logo-dark.svg', replacesTitle: false },
favicon: '/assets/brand/favicon-32.png',
head: [
{ tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: `${BASE}assets/brand/favicon.svg` } },
{ tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: `${BASE}assets/brand/apple-touch-icon.png` } },
],
social: [{ icon: 'github', label: 'RetroBIOS on GitHub', href: 'https://github.com/Abdess/retrobios' }],
customCss: ['./src/styles/retrobios.css'],
components: {
PageTitle: './src/components/PageTitle.astro',
MarkdownContent: './src/components/MarkdownContent.astro',
Footer: './src/components/Footer.astro',
},
routeMiddleware: ['./src/routeData.ts'],
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
// Commands wrap instead of scrolling, so the end of a one-liner stays visible.
expressiveCode: { defaultProps: { wrap: true } },
lastUpdated: false,
pagination: true,
// Measured on the queries of tools/search_gate.py, at the centre of a
// range where all of them answer. How often a word appears counts more
// than in Starlight's defaults (0.1, 0.1, 2, 9), a page's title weighs
// ten times its text, so "dolphin" finds Dolphin, and the file names of
// a system page (its `files` field, written by sitestarlight.py) count
// half, so "scph5501" finds the Sony page without a file name winning
// every other query.
pagefind: {
ranking: {
pageLength: 0.1,
termFrequency: 0.5,
termSaturation: 1.4,
termSimilarity: 9,
metaWeights: { title: 10, files: 0.5 },
},
},
plugins: [
starlightSidebarTopics(sidebar.topics, sidebar.options),
starlightLinksValidator(),
],
}),
// A page that states its own date (an emulator page: the day its
// profile was last read against the source) gives it to the sitemap.
// The others get no lastmod rather than the build date.
sitemap({
serialize(item) {
const date = lastmod[new URL(item.url).pathname];
return date ? { ...item, lastmod: date } : item;
},
}),
sitemapAlias,
],
});
+5403
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "retrobios-site",
"private": true,
"type": "module",
"scripts": {
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/markdown-satteri": "0.4.5",
"@astrojs/sitemap": "3.7.4",
"@astrojs/starlight": "0.42.6",
"astro": "7.4.2",
"starlight-links-validator": "0.27.0",
"starlight-sidebar-topics": "0.9.0"
}
}
+1
View File
@@ -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

+1
View File
@@ -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

+186
View File
@@ -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>
+10
View File
@@ -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,22 @@
---
// Search weight: Pagefind multiplies the score of words inside a weighted
// element, so a Start here page outranks the reference page that names it,
// and an emulator outranks its launcher or its frozen copy.
import Default from '@astrojs/starlight/components/MarkdownContent.astro';
const { searchWeight } = Astro.locals.starlightRoute.entry.data;
---
{
searchWeight ? (
<div data-pagefind-weight={String(searchWeight)}>
<Default><slot /></Default>
</div>
) : (
<Default><slot /></Default>
)
}
<script>
import '../scripts/site.ts';
</script>
+40
View File
@@ -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>
+22
View File
@@ -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(),
// How much the site search weighs the page's words (1 by default).
searchWeight: z.number().min(0.1).max(10).optional(),
// The date the page's content carries, for the sitemap.
lastmod: z.string().optional(),
}),
}),
}),
};
+7
View File
@@ -0,0 +1,7 @@
/// <reference path="../.astro/types.d.ts" />
declare namespace App {
interface Locals {
rbBreadcrumbs?: import('./routeData').Crumb[];
}
}
+96
View File
@@ -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);
});
+309
View File
@@ -0,0 +1,309 @@
// 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. All
// widths are read first and the attributes written after, so a page of
// twenty tables is laid out once, not twenty times.
const markScrollable = (tables: HTMLTableElement[]) => {
const overflowing = tables.map((table) => table.scrollWidth > table.clientWidth + 1);
tables.forEach((table, index) => setScrollable(table, overflowing[index]));
};
const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
const heading = table.dataset.rbHeading ?? 'Data table';
if (overflows) {
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 button, a pack to download) 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 :is(.rb-button, a[href*="/releases/download/"], a[href$="/releases/latest"])')) 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);
// Each row's text is read on the first keystroke, not on every page load.
let searchable: { row: HTMLTableRowElement; text: string }[] | null = null;
input.addEventListener('input', () => {
searchable ??= rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() }));
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 = () => {
const tables = [...document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table')];
const enhance = (table: HTMLTableElement) => {
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);
filterTable(table, tables.indexOf(table), heading);
};
// A table is set up and measured when it nears the screen. Doing all of
// them at load relaid out every row of a 3,800-row page after its first
// paint. One that appears later (a block opened, a tab chosen) is handled
// then.
const near = new Set<HTMLTableElement>();
const observer = new IntersectionObserver(
(entries) => {
const shown = entries.filter((entry) => entry.isIntersecting).map((entry) => entry.target as HTMLTableElement);
for (const table of shown) {
if (!near.has(table)) enhance(table);
near.add(table);
}
if (shown.length) requestAnimationFrame(() => markScrollable(shown));
},
{ rootMargin: '400px 0px' },
);
// Every table is measured once the page is drawn and idle, before anything
// is written, so the read finds the layout done instead of forcing it: a
// keyboard reaches a scrolling table anywhere on the page, not only near
// the screen.
const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 1));
const start = () =>
idle(() => {
// A table in a closed block is not laid out, and measuring it would
// lay it out: it is measured when its block opens.
markScrollable(tables.filter((table) => table.checkVisibility?.() ?? true));
tables.forEach((table) => observer.observe(table));
});
if (document.readyState === 'complete') start();
else addEventListener('load', start, { once: true });
let timer = 0;
addEventListener('resize', () => {
clearTimeout(timer);
timer = window.setTimeout(() => markScrollable([...near]), 150);
});
};
// 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();
+567
View File
@@ -0,0 +1,567 @@
/* 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; }
/* A system icon beside a console heading holds its box before it loads:
it pushed every card below it down once it arrived. */
.sl-markdown-content :is(h2, h3) img {
display: inline;
width: 24px;
height: 24px;
object-fit: contain;
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: 1px solid var(--sl-color-blue);
border-radius: 4px;
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;
}
}
+24
View File
@@ -0,0 +1,24 @@
// axe-core (WCAG 2.2 A/AA rules) in light and dark, one line per page and scheme.
// node axe.mjs <base url> <page>...
import { chromium } from 'playwright-core';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const axeSource = await readFile(require.resolve('axe-core/axe.min.js'), 'utf8');
const [base, ...pages] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
for (const scheme of ['light', 'dark']) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: scheme });
const page = await context.newPage();
for (const path of pages) {
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
await page.addScriptTag({ content: axeSource });
const result = await page.evaluate(() =>
window.axe.run(document, { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }));
const violations = result.violations.map((v) => `${v.id}(${v.nodes.length})`);
console.log(JSON.stringify({ page: path || '/', scheme, violations }));
}
await context.close();
}
await browser.close();
+89
View File
@@ -0,0 +1,89 @@
// End-to-end check of the two interactive pages, in a real browser.
// node check_tools.mjs <base url>
import { chromium } from 'playwright-core';
import assert from 'node:assert/strict';
const base = process.argv[2] ?? 'http://127.0.0.1:4321/retrobios/';
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
// Hash lookup: an MD5 lists every layer, a SHA1 permalink opens the card.
await page.goto(`${base}files/?hash=490f666e1afb15b7362b406ed1cea246`, { waitUntil: 'networkidle' });
const lookup = await page.textContent('#rb-file-lookup-status');
assert.match(lookup, /match/);
console.log('lookup md5:', lookup.slice(0, 160).replace(/\s+/g, ' '));
await page.goto(`${base}files/?sha1=0555c6fae8906f3f09baf5988f00e55f88e9f30b`, { waitUntil: 'networkidle' });
assert.ok(page.url().includes('systems/sony/#sha1-0555c6fae890'), page.url());
console.log('permalink sha1 ->', page.url());
// Picker on Get your files: Batocera, PlayStation only.
const transfers = [];
page.on('response', async (response) => {
if (response.url().includes('/data/picker/')) transfers.push([response.url(), (await response.body()).length]);
});
await page.goto(`${base}which-pack/?platform=batocera&system=sony-playstation`, { waitUntil: 'networkidle' });
await page.waitForSelector('.rb-pick-output:not([hidden])');
const result = await page.textContent('.rb-pick-result');
const command = await page.textContent('.rb-pick-cmd code');
const rows = await page.locator('.rb-pick-output tbody tr').count();
console.log('picker:', result);
console.log('command:', command);
console.log('rows:', rows, 'fetched:', transfers.map(([url, bytes]) => `${url.split('/').pop()} ${bytes} B`).join(', '));
assert.match(command, /--platform batocera --system sony-playstation$/);
assert.ok(rows > 0);
// Unchecking everything empties the result; "All" restores the full command.
await page.click('[data-none]');
assert.match(await page.textContent('.rb-pick-result'), /Choose at least one system/);
await page.click('[data-all]');
assert.match(await page.textContent('.rb-pick-cmd code'), /--platform batocera$/);
// A full hash typed in the search opens the lookup instead of text results.
await page.goto(base, { waitUntil: 'networkidle' });
await page.click('site-search button[data-open-modal]');
const search = page.locator('site-search dialog input').first();
await search.waitFor();
await search.fill('490f666e1afb15b7362b406ed1cea246');
await Promise.all([page.waitForURL(/files\/\?hash=/), search.press('Enter')]);
console.log('hash in search ->', page.url());
// Breadcrumbs name the tab and the group of a deep page.
await page.goto(`${base}emulators/duckstation/`, { waitUntil: 'networkidle' });
const crumbs = await page.locator('.rb-breadcrumbs li').allTextContents();
console.log('breadcrumbs:', crumbs.join(' / '));
assert.deepEqual(crumbs.slice(0, 2), ['Explore', 'Emulators']);
// Previous and next run on from one tab into the next.
await page.goto(`${base}wiki/glossary/`, { waitUntil: 'networkidle' });
const next = await page.locator('.pagination-links a[rel="next"]').getAttribute('href');
console.log('after the last Start here page:', next);
assert.ok(next && next.endsWith('/platforms/'), next);
// A glossary term opens its definition on a tap.
await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' });
const term = page.locator('abbr.rb-term').first();
await term.click();
assert.equal(await term.getAttribute('data-open'), '');
console.log('glossary term:', await term.textContent(), '=', await term.getAttribute('data-definition'));
// On a phone, a table whose rows end in a download is one block per row.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' });
// Tables are set up as they near the screen.
await page.locator('table:has(a[href*="/releases/download/"])').first().scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const stacked = await page.locator('table[data-rb-stack]').count();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
console.log('stacked tables:', stacked, 'page wider than the phone:', overflow);
assert.ok(stacked > 0);
assert.equal(overflow, false);
// A stale address offers the list its page may be in.
await page.goto(`${base}emulators/no-such-core/`, { waitUntil: 'networkidle' });
assert.match(await page.textContent('#rb-notfound-hint'), /list of emulators/);
assert.deepEqual(errors, []);
console.log('ok');
await browser.close();
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Lighthouse mobile (default form factor) on a list of pages, one summary line each.
python lighthouse.py [--performance-only] <out dir> <name>=<base url> ... -- <page> ...
With all four categories, Lighthouse runs its accessibility checks inside the
page during the same load, and those tasks count toward Total Blocking Time
on a page of 15,000 elements. --performance-only measures the page alone.
Needs `npx lighthouse` and Chrome at /usr/bin/google-chrome.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
CATEGORIES = ("performance", "accessibility", "best-practices", "seo")
def main() -> int:
args = sys.argv[1:]
categories = CATEGORIES
if args[0] == "--performance-only":
args.pop(0)
categories = ("performance",)
out = Path(args.pop(0))
split = args.index("--")
engines = dict(item.split("=", 1) for item in args[:split])
pages = args[split + 1:]
out.mkdir(parents=True, exist_ok=True)
env = {**os.environ, "CHROME_PATH": "/usr/bin/google-chrome"}
for name, base in engines.items():
for page in pages:
slug = page.strip("/").replace("/", "_") or "home"
report = out / f"{name}-{slug}.json"
# Under WSL chrome-launcher names its profile folder with a Windows path,
# created relative to the working directory: run it inside the output folder.
subprocess.run(
["npx", "--prefix", str(Path(__file__).resolve().parent.parent), "lighthouse", base + page,
"--quiet", "--output=json", f"--output-path={report.resolve()}",
"--chrome-flags=--headless=new --no-sandbox",
f"--only-categories={','.join(categories)}",
# Extra flags for one experiment, space separated.
*os.environ.get("LIGHTHOUSE_FLAGS", "").split()],
env=env, cwd=out, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
data = json.loads(report.read_text())
cats, audits = data["categories"], data["audits"]
failed = [k for k, a in audits.items()
if a.get("score") is not None and a["score"] < 1 and a.get("scoreDisplayMode") == "binary"]
print(json.dumps({
"engine": name, "page": page or "/",
**{c: round(cats[c]["score"] * 100) for c in categories},
"lcp": audits["largest-contentful-paint"]["displayValue"],
"tbt": audits["total-blocking-time"]["displayValue"],
"cls": audits["cumulative-layout-shift"]["displayValue"],
"bytes": audits["total-byte-weight"]["displayValue"],
# Lighthouse 13 moved the DOM size audit into an insight.
"dom": (audits.get("dom-size") or audits.get("dom-size-insight") or {}).get("displayValue", ""),
"failed": failed,
}))
return 0
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
// Page weight and search measurements in a real browser.
// node measure.mjs weight <base url> <page>...
// node measure.mjs search <base url> starlight|mkdocs <query>...
// Transfer sizes are bytes on the wire (gzip from serve.mjs), by type.
import { chromium } from 'playwright-core';
const [mode, base, ...rest] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const track = (page) => {
const rows = [];
page.on('response', async (response) => {
const request = response.request();
const headers = await response.allHeaders();
let bytes = Number(headers['content-length'] ?? 0);
if (!bytes) {
try { bytes = (await response.body()).length; } catch { bytes = 0; }
}
rows.push({ url: response.url(), type: request.resourceType(), bytes });
});
return rows;
};
const sum = (rows, test) => rows.filter(test).reduce((total, row) => total + row.bytes, 0);
if (mode === 'weight') {
for (const path of rest) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
const rows = track(page);
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const dom = await page.evaluate(() => document.getElementsByTagName('*').length);
const kb = (n) => (n / 1024).toFixed(1);
console.log(JSON.stringify({
page: path,
html_kb: kb(sum(rows, (r) => r.type === 'document')),
js_kb: kb(sum(rows, (r) => r.type === 'script')),
css_kb: kb(sum(rows, (r) => r.type === 'stylesheet')),
other_kb: kb(sum(rows, (r) => !['document', 'script', 'stylesheet'].includes(r.type))),
total_kb: kb(sum(rows, () => true)),
requests: rows.length,
dom_nodes: dom,
}));
await context.close();
}
} else if (mode === 'search') {
const [engine, ...queries] = rest;
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const rows = track(page);
await page.goto(base, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const before = rows.length;
const first = [];
for (const [n, query] of queries.entries()) {
const mark = rows.length;
let results;
if (engine === 'starlight-ui') {
// The reader's path: open the dialog, type, wait for the result list.
if (n === 0) await page.click('button[data-open-modal]');
const input = page.locator('dialog input[type="text"], dialog input[type="search"]').first();
await input.fill(query);
await page.waitForSelector('.pagefind-ui__result', { timeout: 15000 });
await page.waitForTimeout(1200);
results = await page.evaluate(() => {
const items = [...document.querySelectorAll('.pagefind-ui__result')];
return {
count: document.querySelector('.pagefind-ui__message')?.textContent ?? '',
top: items.slice(0, 5).map((r) => `${r.querySelector('.pagefind-ui__result-link')?.textContent?.trim()} | ${r.querySelector('.pagefind-ui__result-link')?.getAttribute('href')}`),
};
});
} else if (engine === 'starlight') {
results = await page.evaluate(async ({ query, root, ranking }) => {
if (!window.__pf) {
window.__pf = await import(`${root}pagefind/pagefind.js`);
if (ranking) await window.__pf.options({ ranking });
}
const search = await window.__pf.search(query);
const top = await Promise.all(search.results.slice(0, 5).map((r) => r.data()));
return { count: search.results.length, top: top.map((d) => `${d.meta.title} | ${d.url}`) };
}, { query, root: new URL('/retrobios/', base).href, ranking: process.env.RB_RANKING ? JSON.parse(process.env.RB_RANKING) : null });
} else {
// Material: open the search through its share link (?q=) and read the result list.
await page.goto(`${base}?q=${encodeURIComponent(query)}`, { waitUntil: 'networkidle' });
await page.waitForFunction(
(q) => document.querySelector('input[data-md-component="search-query"]')?.value === q
&& /matching|No matching/.test(document.querySelector('.md-search-result__meta')?.textContent ?? ''),
query, { timeout: 30000 },
);
await page.waitForTimeout(800);
results = await page.evaluate(() => {
const items = [...document.querySelectorAll('.md-search-result__item > a')];
const meta = document.querySelector('.md-search-result__meta')?.textContent ?? '';
return { count: meta, top: items.slice(0, 5).map((a) => `${a.querySelector('h1,h2')?.textContent?.trim()} | ${a.getAttribute('href')}`) };
});
}
await page.waitForTimeout(300);
const transfer = sum(rows.slice(mark), () => true);
if (n === 0) first.push(...rows.slice(before));
console.log(JSON.stringify({ query, transfer_kb: (transfer / 1024).toFixed(1), ...results }));
}
console.log(JSON.stringify({ first_search_kb: (sum(first, () => true) / 1024).toFixed(1), first_search_requests: first.length }));
await context.close();
}
await browser.close();
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""The search gate: queries a visitor types, and the pages that answer them.
Runs measure.mjs against a served build, through the site's own search box,
and checks each query's expected pages against the rank it must reach: the
first result, or the first three.
python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
TOOLS = Path(__file__).resolve().parent
# Query: (pages that answer it, as path prefixes under the site, and the rank one
# of them must reach). An emulator name must find the emulator itself first,
# not its launcher or a frozen copy.
QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
"batocera folder": (("platforms/batocera/",), 1),
"missing bios": (("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"), 1),
"neogeo.zip": (("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"), 1),
"retroarch system folder": (("platforms/retroarch/",), 1),
"installer": (("wiki/installer/", "wiki/first-install/"), 1),
# The file's card on its system page, where it is downloaded.
"scph5501": (("systems/sony/",), 3),
"md5": (("wiki/verification-modes/",), 1),
"where do bios files go": (("which-pack/", "wiki/first-install/"), 1),
"dolphin": (("emulators/dolphin/",), 1),
"duckstation": (("emulators/duckstation/",), 1),
"playstation bios": (("systems/sony/",), 3),
"steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1),
"knulli": (("platforms/knulli/",), 1),
}
def main() -> int:
base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321/retrobios/"
root = "/" + base.split("/", 3)[3]
run = subprocess.run(
["node", str(TOOLS / "measure.mjs"), "search", base, "starlight-ui", *QUERIES],
capture_output=True, text=True, check=True,
)
answered = 0
for line in run.stdout.splitlines():
result = json.loads(line)
if "query" not in result:
print(f"first search: {result['first_search_kb']} KB in {result['first_search_requests']} requests")
continue
pages, rank = QUERIES[result["query"]]
top = [entry.split(" | ") for entry in result["top"][:3]]
hit = any(href[len(root):].startswith(pages) for _title, href in top[:rank])
answered += hit
titles = "; ".join(title for title, _href in top)
print(f"{'ok ' if hit else 'MISS'} {result['query']!r} (top {rank}): {titles}")
print(f"{answered}/{len(QUERIES)} queries answered")
return 0 if answered == len(QUERIES) else 1
if __name__ == "__main__":
sys.exit(main())
+23
View File
@@ -0,0 +1,23 @@
// Raw Pagefind results with their scores, to see why a page ranks where it does.
// node search_scores.mjs <base url> <query> [ranking json]
import { chromium } from 'playwright-core';
const [base, query, ranking] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const page = await browser.newPage();
await page.goto(base, { waitUntil: 'networkidle' });
const rows = await page.evaluate(async ({ root, query, ranking, top }) => {
const pagefind = await import(`${root}pagefind/pagefind.js`);
if (ranking) await pagefind.options({ ranking: JSON.parse(ranking) });
const search = await pagefind.search(query);
const shown = search.results.slice(0, top);
const data = await Promise.all(shown.map((result) => result.data()));
return shown.map((result, index) => ({
score: result.score.toFixed(3),
url: data[index].url,
words: data[index].word_count,
hits: result.words.length,
}));
}, { root: base, query, ranking, top: Number(process.env.TOP || 8) });
for (const row of rows) console.log(row.score, row.words, row.hits, row.url);
await browser.close();
+51
View File
@@ -0,0 +1,51 @@
// Static server shaped like GitHub Pages for measurements: gzip, no brotli,
// max-age=600, the site under /retrobios/, real 404 page.
// node serve.mjs <site dir> <port>
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { gzipSync } from 'node:zlib';
const [root = 'dist', port = '4321'] = process.argv.slice(2);
const BASE = '/retrobios/';
const TYPES = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
'.xml': 'application/xml', '.txt': 'text/plain', '.wasm': 'application/wasm',
};
const COMPRESS = new Set(['.html', '.css', '.js', '.json', '.svg', '.xml', '.txt']);
const resolveFile = async (path) => {
let file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, ''));
try {
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
await stat(file);
return file;
} catch {
return null;
}
};
createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
if (!url.pathname.startsWith(BASE)) {
res.writeHead(302, { location: BASE }).end();
return;
}
const rel = decodeURIComponent(url.pathname.slice(BASE.length));
let file = await resolveFile(rel);
let status = 200;
if (!file) {
status = 404;
file = await resolveFile('404.html');
}
const ext = extname(file);
let body = await readFile(file);
const headers = { 'content-type': TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'max-age=600' };
if (COMPRESS.has(ext) && /\bgzip\b/.test(req.headers['accept-encoding'] ?? '')) {
body = gzipSync(body);
headers['content-encoding'] = 'gzip';
}
headers['content-length'] = body.length;
res.writeHead(status, headers).end(body);
}).listen(Number(port), '127.0.0.1');
+13
View File
@@ -0,0 +1,13 @@
// Screenshot of one element of a page, to look at a component closely.
// node shot_element.mjs <url> <css selector> <out.png> [width] [light|dark]
import { chromium } from 'playwright-core';
const [url, selector, out, width = '390', scheme = 'light'] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 }, colorScheme: scheme });
await page.goto(url, { waitUntil: 'networkidle' });
const element = page.locator(selector).first();
await element.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
await element.screenshot({ path: out });
await browser.close();
+25
View File
@@ -0,0 +1,25 @@
// Screenshots at 390 and 1440 px, light and dark.
// node shots.mjs <base url> <out dir> <page>...
import { chromium } from 'playwright-core';
import { mkdir } from 'node:fs/promises';
const [base, out, ...pages] = process.argv.slice(2);
await mkdir(out, { recursive: true });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
for (const scheme of ['light', 'dark']) {
for (const width of [390, 1440]) {
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
deviceScaleFactor: 1,
colorScheme: scheme,
});
const page = await context.newPage();
for (const path of pages) {
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
const name = path.replace(/^\/|\/$/g, '').replace(/[/?=&]+/g, '_') || 'home';
await page.screenshot({ path: `${out}/${name}-${width}-${scheme}.png`, fullPage: false });
}
await context.close();
}
}
await browser.close();
+1 -1
View File
@@ -652,7 +652,7 @@ This executes in sequence:
5. Consistency check: verify and the pack agree on counts and on the paths each archive holds
6. Pack integrity - extract ZIPs and verify hashes per platform mode
7. `generate_readme.py` - regenerate README and CONTRIBUTING
8. `generate_site.py` - regenerate documentation site and `mkdocs.yml`
8. `generate_site.py` - regenerate the documentation site and its navigation
A new platform is archived-by-default nowhere: `status: active` puts it in
`list_platforms.py`, the CI matrix, and the scrapers. Set
+5 -3
View File
@@ -39,7 +39,8 @@ data/ cached data directories (not BIOS, fetched at build)
schemas/ JSON schemas for platform and emulator YAML (checked in CI)
tests/ test suite with synthetic fixtures
wiki/ hand-written documentation sources
docs/ generated MkDocs site (gitignored, rebuilt in CI)
website/ Astro Starlight project; pages, data and navigation
are generated into it (gitignored, rebuilt in CI)
_mame_clones.json MAME parent/clone set mappings
database.json file index built from bios/ (SHA1 primary key)
dist/ generated packs (gitignored)
@@ -55,7 +56,8 @@ heading above the match. Every file card is therefore a level 3 heading with
the card's anchor, under its console or a Files heading, and every platform
system a heading of its own: a file name typed in the search opens that card,
not the top of the page. On pages of cards (systems, emulators, platforms) the
table of contents stops at level 2 (`scripts/mkdocs_hooks.py`).
table of contents stops at level 2, and a page of cards under one or two
headings has none (`scripts/sitestarlight.py`).
The browser downloads and indexes the whole search index before it shows a
first result, so the index holds names and prose only. Hashes, lists of other
@@ -450,7 +452,7 @@ pattern and how to add a test.
| Workflow | File | Trigger | Role |
|----------|------|---------|------|
| Deploy Site | `deploy-site.yml` | push to main on any input of the site build, and manual | validate contracts, generate site, build with MkDocs, validate rendered HTML, deploy to Pages |
| Deploy Site | `deploy-site.yml` | push to main on any input of the site build, and manual | validate contracts, generate site, build with Astro Starlight, validate rendered HTML, deploy to Pages |
| Validation | `validate.yml` | pull request and push to main on the collection, profiles, schemas, scripts, tests, the installer and every input of the site build | schema check and full test suite on both, BIOS hash validation and auto-label on pull requests |
Each workflow's `paths:` list is the exact set of files that starts it.
+3 -2
View File
@@ -21,6 +21,7 @@ See the project's [README](https://github.com/monster-penguin/BIOS-Preservation-
---
!!! note "Add your project"
:::note[Add your project]
Open an issue with a link to your repository.
:::
Open an issue with a link to your repository.
+4 -3
View File
@@ -104,7 +104,8 @@ and name the file. A file is removed when the owner of its rights asks.
Files disappear when stores close, like the 3DS and Wii U shops in 2023. We keep
them while they can still be found.
!!! tip "Next"
:::tip[Next]
- [Get your files](../which-pack.md)
- [Words used on this site](glossary.md)
:::
- [Get your files](../which-pack.md)
- [Words used on this site](glossary.md)
+35 -26
View File
@@ -15,39 +15,47 @@ From an empty BIOS folder to games that start.
A **terminal** is a window where you type commands. Paste the line for your
system, then press Enter.
=== "Windows"
<Tabs syncKey="os">
<TabItem label="Windows">
Open the Start menu. Type **PowerShell** and open it.
Open the Start menu. Type **PowerShell** and open it.
```powershell
irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex
```
```powershell
irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex
```
=== "Mac"
</TabItem>
<TabItem label="Mac">
Open **Terminal**. It is in Applications, then Utilities.
Open **Terminal**. It is in Applications, then Utilities.
```sh
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
```
```sh
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
```
=== "Linux and Steam Deck"
</TabItem>
<TabItem label="Linux and Steam Deck">
On a Steam Deck, switch to Desktop Mode and open **Konsole**.
On a Steam Deck, switch to Desktop Mode and open **Konsole**.
```sh
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
```
```sh
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
```
=== "Android"
</TabItem>
<TabItem label="Android">
Install **Termux**, a terminal app. Run these three lines in it.
Install **Termux**, a terminal app. Run these three lines in it.
```sh
pkg install python
termux-setup-storage
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
```
</TabItem>
</Tabs>
```sh
pkg install python
termux-setup-storage
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh
```
On Batocera, Recalbox and other game systems, follow
[your setup's page](../which-pack.md).
@@ -145,8 +153,9 @@ drive.
See [When a game does not start](troubleshooting.md).
!!! tip "Next"
:::tip[Next]
- [Your setup's page](../which-pack.md): its folder and how it checks files
- [When a game does not start](troubleshooting.md)
- Details: [Reading the installer's output, line by line](installer.md#reading-a-run)
:::
- [Your setup's page](../which-pack.md): its folder and how it checks files
- [When a game does not start](troubleshooting.md)
- Details: [Reading the installer's output, line by line](installer.md#reading-a-run)
+1 -1
View File
@@ -7,7 +7,7 @@ nav: false
Three pages take you from nothing to games that start.
[Get your files](../which-pack.md){ .md-button .md-button--primary }
<a class="rb-button rb-button-primary" href="../which-pack.md">Get your files</a>
## What is a BIOS?
+5 -4
View File
@@ -43,8 +43,9 @@ nav_title: Glossary
**Game data**
: Files from your own copy of a PC game, like Doom. Not a BIOS.
!!! tip "Next"
:::tip[Next]
- [What is a BIOS?](what-are-bios-files.md)
- [Get your files](../which-pack.md)
- Reference: [Terms of the reference pages](reference-terms.md)
:::
- [What is a BIOS?](what-are-bios-files.md)
- [Get your files](../which-pack.md)
- Reference: [Terms of the reference pages](reference-terms.md)
+4 -3
View File
@@ -41,7 +41,8 @@ This reflects the project's good-faith understanding, not legal advice.
A rights holder, or anyone acting for one, can [open an issue](https://github.com/Abdess/retrobios/issues) identifying the file by path or hash. A file whose removal is requested by its rights holder is removed from the repository and from the next release. No formal notice is required and none will be demanded. State the file and the basis of the claim. The same channel is open to anyone else with a concern about a specific file.
!!! tip "Next"
:::tip[Next]
- [FAQ](faq.md)
- [What is a BIOS?](what-are-bios-files.md)
:::
- [FAQ](faq.md)
- [What is a BIOS?](what-are-bios-files.md)
+4 -3
View File
@@ -149,7 +149,8 @@ everyday words (BIOS, emulator, core, hash) are in the [glossary](glossary.md).
takes a file from (the collection, a data folder, a release asset) is its
source, not its provenance.
!!! tip "Next"
:::tip[Next]
- [How each platform checks files](verification-modes.md)
- [Glossary](glossary.md)
:::
- [How each platform checks files](verification-modes.md)
- [Glossary](glossary.md)
+28 -21
View File
@@ -18,8 +18,8 @@ Budget target: ~175 minutes/month on the GitHub free tier.
| Workflow | File | Trigger |
|----------|------|---------|
| Deploy Site | `deploy-site.yml` | Push to main (platforms, emulators, provenance, wiki, scripts, database.json, mkdocs.yml), manual |
| Validation | `validate.yml` | PR and push to main touching `bios/**`, `platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`, `install.py`, `install.sh`, `install.ps1`, or any other input of the site build (`wiki/**`, `docs_assets/**`, `provenance/**`, `exports/**`, `install/**`, `database.json`, `release.json`, `derivable.json`, `mkdocs.yml`) |
| Deploy Site | `deploy-site.yml` | Push to main (platforms, emulators, provenance, wiki, scripts, database.json, website), manual |
| Validation | `validate.yml` | PR and push to main touching `bios/**`, `platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`, `install.py`, `install.sh`, `install.ps1`, or any other input of the site build (`wiki/**`, `docs_assets/**`, `provenance/**`, `exports/**`, `install/**`, `database.json`, `release.json`, `derivable.json`, `website/**`) |
Upstream BIOS lists are not scraped on a schedule. A maintainer runs the
scrapers by hand (see [adding a scraper](adding-a-scraper.md)), reviews the
@@ -32,7 +32,8 @@ hosted runner should rebuild and re-upload.
**Trigger.** Push to `main` when any of these paths change: `platforms/`,
`emulators/`, `provenance/`, `wiki/`, `schemas/`, `tests/`, `docs_assets/`,
`install/`, `install.py`, `database.json`, `release.json`, `mkdocs.yml`, the
`install/`, `install.py`, `database.json`, `release.json`, `website/` (the
Starlight project: configuration, components, styles, lockfile), the
workflow itself, and every script the build runs or imports. Also manual
dispatch.
@@ -45,19 +46,21 @@ is missing.
### Steps
1. Checkout, Python 3.12
2. Install `pyyaml`, `jsonschema[format-nongpl]==4.26.0`,
`mkdocs>=1.6,<2`, `mkdocs-material>=9.7.5,<10`,
`pymdown-extensions>=10.14`
2. Install `pyyaml` and `jsonschema[format-nongpl]==4.26.0`, then Node 24 with
the official `actions/setup-node` (npm cache keyed on the lockfile) and
`npm ci --prefix website`
3. Run `validate_schemas.py`: the data contracts, checked before anything is
generated (see below)
4. Restore large files from the `large-files` release, refresh data directories
5. Run `generate_site.py` (converts YAML data into MkDocs pages and rewrites
`mkdocs.yml`)
5. Run `generate_site.py`: it writes the pages, the navigation and the data
files into the Astro Starlight project in `website/`
6. Run `generate_readme.py` (rebuilds README.md and CONTRIBUTING.md)
7. `mkdocs build --strict` to produce the static site
8. Run `validate_site.py` on the rendered HTML (metadata, headings, image
alternatives, duplicate ids, local links and fragments, page and search
index budgets)
7. `npm run build --prefix website` to produce the static site in
`website/dist/`. The build runs `starlight-links-validator` with its
strict defaults, so a dead link or anchor fails it
8. Run `validate_site.py --site-dir website/dist` on the rendered HTML
(metadata, headings, image alternatives, duplicate ids, every local link
and fragment again, page and search index budgets)
9. Require the committed README and CONTRIBUTING to match what the generator
just produced. `write_if_changed()` compares content with the timestamp line
stripped, so a run that only moves the clock leaves the files untouched and
@@ -77,14 +80,18 @@ The site is deployed via the `github-pages` environment using the official
cancelled (`cancel-in-progress: false`): cancelling one mid-flight leaves the
deployment stuck and the next runs time out waiting on it.
`--strict` turns MkDocs warnings into failures, so a broken internal link or a
dangling anchor fails the build instead of shipping. The `validation:` block in
`mkdocs.yml` is what promotes unrecognized links and missing anchors to
warnings in the first place.
Two link checks run, and both fail the build: Starlight's validator inside
`astro build`, and `validate_site.py` over the rendered files. They read
different things (the content collection, the final HTML with its navigation
and breadcrumbs), and each has caught what the other passed.
The theme version is pinned on both sides: `>=9.7.5` because that is the
release which caps `mkdocs < 2` (MkDocs 2.0 ships without a license), `<10`
so a major theme release cannot change the site without a deliberate bump.
Astro, Starlight and their plugins are pinned to exact versions in
`website/package.json`, resolved from the committed `website/package-lock.json`
by `npm ci`. Starlight is below 1.0 and Astro shipped two majors in 2026, so an
upgrade is its own change, built and measured with the scripts in
`website/tools/` (search queries, Lighthouse, axe, screenshots) before it
merges. Node runs only inside the site build: `install.py` and the build
scripts stay Python.
## validate.yml - Validation
@@ -119,7 +126,7 @@ declares while schemas and tests pass it. This job confronts it with its
upstream. An edited profile is not held to it.
**run-tests.** Runs `python -m unittest discover -s tests -t . -v`, then builds the
site as Deploy Site does (`generate_site.py`, `mkdocs build --strict`,
site as Deploy Site does (`generate_site.py`, `npm run build --prefix website`,
`validate_site.py`) whatever the suite returned. Must pass before a merge, and
again on the commit a direct push puts at the head of main.
@@ -257,7 +264,7 @@ python scripts/check_release.py --tag "v${DATE}" --sums dist/SHA256SUMS.txt \
# and pack_size describe the packs just built. git status names anything
# else the run rewrote.
git status --short
git add release.json README.md mkdocs.yml install/*.json install/targets/*.json
git add release.json README.md install/*.json install/targets/*.json
git commit -m "chore: record release v${DATE}" && git push
# 6. Keep only the new release plus large-files: an older pack carries hashes
+4 -3
View File
@@ -143,7 +143,8 @@ Running `verify.py --emulator <core> --verbose` shows the emulator-level ground
It reaches only as far as the profiles do. Files no profile states a value for are platform-only, and nothing can be said about their content. The footer of the platform report gives that ratio as `Ground truth: N/M files have emulator validation`.
!!! tip "Next"
:::tip[Next]
- [Reference index](index.md), every reference page in one list
- For contributors: [How to contribute](../contributing.md)
:::
- [Reference index](index.md), every reference page in one list
- For contributors: [How to contribute](../contributing.md)
+20 -4
View File
@@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards.
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture
@@ -287,7 +287,23 @@ The test suite is one layer of verification. The full quality gate is:
3. No unexpected CRITICAL entries in the verify output
4. Pack file counts match verification file counts (consistency check)
5. Pack integrity passes (every declared file extractable with correct hash)
6. `mkdocs build --strict` and `python scripts/validate_site.py` pass
6. `npm run build --prefix website` and `python scripts/validate_site.py` pass
A change to the site engine, its version, its search ranking or the page
weights is measured on a served build before it lands, with the scripts in
`website/tools/` (they need `npm install --no-save playwright-core axe-core
lighthouse` and Chrome):
- `search_gate.py`: the queries a visitor types and the page that must answer
each, read from the site's own search box
- `lighthouse.py`: Lighthouse mobile on a list of pages, the heaviest ones
included
- `axe.mjs`: WCAG 2.2 AA in light and dark
- `check_tools.mjs`: the hash lookup, the file picker, a hash typed in the
search, breadcrumbs, previous and next across tabs, glossary terms, tables
on a phone and the 404 page, in a real browser
- `serve.mjs`: the build served under /retrobios/ with gzip, as GitHub Pages
serves it
A change that passes the tests but breaks the pipeline is not merged. A new
CRITICAL entry in the verify output after a change is explained before the
@@ -380,7 +396,7 @@ nav_title: Save files # only when the menu names it otherwise than its headin
- `level` picks the tab and the label shown at the top of the page.
- `order` sorts the tab. Pages are spaced by ten, so a new page fits between
two without renumbering. The generated pages (systems, emulators, the gaps
and data pages) hold fixed places among them, set in `generate_mkdocs_nav`.
and data pages) hold fixed places among them, set in `generate_nav`.
- `nav_title` is the menu label. Without it the menu shows the page's `# `
heading. It is not `title`, which both MkDocs and Astro Starlight read as
the page's own title.
@@ -419,7 +435,7 @@ The `validate.yml` workflow runs `python -m unittest discover -s tests -t . -v`
roads into main: every pull request, and every direct push, that touches
`bios/`, `platforms/`, `emulators/`, `schemas/`, `scripts/`, `tests/`,
`install.py` or an input of the site build such as `wiki/`. The same job then
builds the site with `mkdocs build --strict`, so a page that would fail the
builds the site with `npm run build --prefix website`, so a page that would fail the
deploy fails the pull request. Work reaches main by push as often as by pull request, so a suite
wired to pull requests alone would guard the road nobody takes. The test job
(`run-tests`) runs in parallel with schema validation, and on a pull request
+3 -3
View File
@@ -51,7 +51,7 @@ Pipeline steps:
| 6 | Pack integrity (extract + hash) | `--skip-packs` |
| 7 | Generate README | `--skip-docs` |
| 8 | Generate site | `--skip-docs` |
| 8b | Render the site with `mkdocs build --strict`, then `validate_site.py`, as the deploy does | `--skip-docs`, or no `mkdocs` installed |
| 8b | Render the site with `npm run build --prefix website`, then `validate_site.py`, as the deploy does | `--skip-docs`, or Node not set up (`npm ci --prefix website`) |
Pipeline flags:
@@ -692,7 +692,7 @@ python scripts/refresh_stale.py --only platforms,targets --jobs 6
| `provenance_report.py` | Dump-catalog coverage and acquisition targets (see above) |
| `find_pinned_revision.py` | Walk the upstream commits that touched a file and return the revision whose bytes match a platform's pinned md5 (`--write` saves them) |
| `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database |
| `generate_site.py` | Generate all MkDocs site pages (this documentation) |
| `generate_site.py` | Generate the site (this documentation) into the Starlight project in `website/` |
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
| `romset_recipes.py` | Identify which emulator version an arcade archive matches, and rebuild a pinned archive from ROMs already held |
| `scraper/romset_dat_importer.py` | Fetch and import per-set recipes from MAME `-listxml` or FBNeo DATs into `recipes/`. `--fetch` alone takes the newest MAME release, and FBNeo DATs are refreshed by git blob sha. `--fetch <revision>` reads FBNeo at that tag, branch or commit |
@@ -732,7 +732,7 @@ Further options, by script:
| `split_pack.py` | `--max-size 1900M`, the ceiling per part (K, M, G, default 2G) |
| `torrentzip.py` | `--check ZIP` reports conformance, `--rebuild ZIP -o OUT` normalizes an archive |
| `validate_pr.py` | `--changed` takes the BIOS files the branch changes, `--markdown`, `--json` |
| `validate_site.py` | `--site-dir` (rendered site), `--config` (mkdocs.yml), `--max-errors N` |
| `validate_site.py` | `--site-dir` (rendered site, default `website/dist`), `--base` (path the site is served under), `--max-errors N` |
| `scraper/coreinfo_scraper.py` | `--compare-db database.json` lists the `.info` firmware the database lacks |
| `scraper/mame_hash_scraper.py` | `--release mameNNNN --profile FILE --source-commit SHA --core NAME --built-by PLATFORM` writes the profile of a MAME build a platform compiles. `--devices --listxml FILE` documents in `mame.yml` every device set with dumped ROMs that the release's `-listxml` names (`isdevice="yes"`), with `--dry-run` to preview |
| `scraper/romset_dat_importer.py` | `--cache-dir` (downloaded listxml and DATs) |
+4 -3
View File
@@ -78,7 +78,8 @@ Other messages: Details: [When something goes wrong](installer.md#when-something
<span id="hash-mismatch-untested"></span><span id="pack-is-missing-files"></span><span id="verifypy-reports-errors"></span>
!!! tip "Next"
:::tip[Next]
- [Your setup's page](../which-pack.md): its folder and how it checks files
- [FAQ](faq.md)
:::
- [Your setup's page](../which-pack.md): its folder and how it checks files
- [FAQ](faq.md)
+5 -4
View File
@@ -9,7 +9,7 @@ nav_title: What BIOS files are
A console has a small program inside it. It starts first, then starts the game.
This program is the **BIOS**, also called **firmware**.
[Get your files](../which-pack.md){ .md-button .md-button--primary }
<a class="rb-button rb-button-primary" href="../which-pack.md">Get your files</a>
An **emulator** is an app that acts like a console. For many consoles, it needs
a copy of the BIOS. That copy is a file: a **BIOS file**.
@@ -59,7 +59,8 @@ then glitch or do not start. The real file works best, so we include it.
Laws differ by country. [Read the project's position](faq.md#is-this-legal).
!!! tip "Next"
:::tip[Next]
- [Get your files](../which-pack.md)
- [Words used on this site](glossary.md)
:::
- [Get your files](../which-pack.md)
- [Words used on this site](glossary.md)