feat: build the site with Astro Starlight

generate_site.py writes the Starlight project in website/ and nothing
else: mkdocs.yml, the MkDocs hooks, theme overrides and stylesheet go,
and the wiki is written in Starlight's syntax. Both workflows set up
Node with the official actions/setup-node pinned by commit, run npm ci
from the committed lockfile, build with the strict link validator and
check the result with validate_site.py.

The search weighs a page by what it answers: Start here pages first,
a system page's cards and file names before a guide citing them, deep
guides, launchers, aliases and frozen copies after. Status badges,
hash blocks and the export change lists leave the index. The ranking
sits at the centre of a range where the 13 queries of search_gate.py
all answer, in the search box itself.

839 pages, every URL and 45,611 ids match the MkDocs build. The tests
read the Starlight content and syntax.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 18:47:22 +02:00
1 parent abe240073c
commit 123d698e43
64 files changed
+742 -3118

No files matched your search

+19 -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"
@@ -69,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:
@@ -95,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
@@ -122,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
@@ -137,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();
}
})();
-878
View File
@@ -1,878 +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
- 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
+4 -5
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:
@@ -237,7 +237,7 @@ def download_links(
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
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
@@ -837,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}
@@ -900,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
+116 -272
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
"""Generate MkDocs site pages from database.json, platform configs, and emulator profiles.
"""Generate the site from database.json, platform configs, and emulator profiles.
The pages, navigation and data files are written into the Astro Starlight
project in website/, which builds the site.
Reads the same data sources as verify.py and generate_pack.py to produce
a complete documentation site. Zero manual content.
@@ -91,7 +94,6 @@ from siteguides import (
)
import release_record
DOCS_DIR = "docs"
# The Starlight project, and where its pages go inside it.
STARLIGHT_DIR = "website"
STARLIGHT_CONTENT = "src/content/docs"
@@ -453,15 +455,10 @@ def generate_home(
"",
"## Three steps",
"",
*(markup.steps([
*markup.steps([
"**Pick your device.** Its page shows your BIOS folder.",
"**Run one command**, or download a ZIP file.",
"**Start a game.**",
]) if markup.starlight() else [
"1. **Pick your device.** Its page shows your BIOS folder.",
"2. **Run one command**, or download a ZIP file.",
"3. **Start a game.**",
"",
]),
"## Pick your device",
"",
@@ -1840,7 +1837,34 @@ LEVELS = {
"reference": "Reference",
"contribute": "For contributors",
}
SEARCH_BOOST = {"start": 5, "setup": 3}
# How much the site search weighs the words of a page (Pagefind's
# data-pagefind-weight, 1 by default). Each value was measured on the
# queries of website/tools/search_gate.py.
# - Start here pages answer a beginner's words first.
# - A system page holds the file cards themselves: a file name finds its
# card before a guide that cites the name as an example.
# - A setup guide is found by its name (the page title weighs it).
# - A guide behind the first two levels (reference, options, contributing)
# names everything a reader types, and ranks after the page that answers.
# - A launcher, an alias or a frozen copy answers a name after the emulator.
SEARCH_WEIGHT = {"start": 10, "setup": 1.5, "reference": 0.5, "contribute": 0.5}
SYSTEM_PAGE_WEIGHT = 5
DEEP_GUIDE_WEIGHT = 0.5
SEARCH_DEMOTED: set[str] = set()
DEMOTED_WEIGHT = 0.5
def search_weight(relative: Path, level: str) -> float | None:
"""The search weight of a page, None for the default."""
if relative.as_posix() in SEARCH_DEMOTED:
return DEMOTED_WEIGHT
if relative.parts[0] == "systems":
return SYSTEM_PAGE_WEIGHT
if relative.parts[0] == "wiki" and level not in ("start", "setup"):
return DEEP_GUIDE_WEIGHT
return SEARCH_WEIGHT.get(level)
_REFERENCE_PAGES = {
"gaps.md", "cross-reference.md", "provenance.md", "exports.md", "data.md",
"files/index.md", "wiki/data-model.md",
@@ -1894,6 +1918,10 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
continue
original = path.read_text(encoding="utf-8")
authored = _front_matter(original)
if "head" in authored:
# Written in its final form by an earlier pass and rewritten by
# nothing since: a second pass would find no title to read.
continue
markdown = prepare_page(original, relative)
for image_id in unresolved_images(markdown):
print(f" WARNING: {relative}: no diagram named {image_id!r}", file=sys.stderr)
@@ -1931,42 +1959,22 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
},
**PAGE_DATA.get(relative.as_posix(), {}),
}
if markup.starlight():
target, text = sitestarlight.finalize_page(
relative.as_posix(), markdown, level=level,
boost=SEARCH_BOOST.get(level), browser_title=browser_title,
description=description, structured=structured, terms=terms,
search=authored.get("search") != "exclude",
)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
# changed extension, and the other one would claim its address.
stem = relative.with_suffix("").as_posix()
for suffix in (".md", ".mdx"):
if f"{stem}{suffix}" != target:
(docs / f"{stem}{suffix}").unlink(missing_ok=True)
if structured.get("dateModified"):
lastmod[sitestarlight.page_url(target)] = str(structured["dateModified"])
continue
structured_json = json.dumps(
structured, ensure_ascii=False, separators=(",", ":")
).replace("</", "<\\/")
front_matter = (
"---\n"
"generated_by: retrobios-site\n"
f"title: {json.dumps(browser_title, ensure_ascii=False)}\n"
f"description: {json.dumps(description, ensure_ascii=False)}\n"
+ (f"level: {level}\n" if level else "")
+ (f"search:\n boost: {SEARCH_BOOST[level]}\n" if level in SEARCH_BOOST else "")
+ (f"date_modified: {json.dumps(str(structured['dateModified']))}\n"
if structured.get("dateModified") else "")
+ "---\n\n"
'<script type="application/ld+json">\n'
f"{structured_json}\n"
"</script>\n\n"
+ (f"{level_label(level)}\n\n" if level else "")
target, text = sitestarlight.finalize_page(
relative.as_posix(), markdown, level=level,
weight=search_weight(relative, level),
browser_title=browser_title,
description=description, structured=structured, terms=terms,
search=authored.get("search") != "exclude",
)
write_decorated(path, front_matter + markdown)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
# changed extension, and the other one would claim its address.
stem = relative.with_suffix("").as_posix()
for suffix in (".md", ".mdx"):
if f"{stem}{suffix}" != target:
(docs / f"{stem}{suffix}").unlink(missing_ok=True)
if structured.get("dateModified"):
lastmod[sitestarlight.page_url(target)] = str(structured["dateModified"])
return lastmod
@@ -3317,8 +3325,16 @@ def _system_file_card(
# looking up scph5501.bin found every platform but RetroBat, whose
# pack installs it for six cores.
lines.append(f"- Also installed by: {', '.join(installed)}")
# The title stays searchable: the file name is what a reader types.
lines[4:] = search_excluded(lines[4:])
# The title stays searchable: the file name is what a reader types. On
# the site the platforms line does too: it names the file as each setup
# names it, and with the name only in its title the card ranked below
# every guide citing scph5501 as an example. The size yardstick keeps
# its one block.
at = next((i for i, line in enumerate(lines) if line.startswith("- Platforms:")), None)
if markup.starlight() and searchable and at is not None:
lines[4:] = [*search_excluded(lines[4:at]), lines[at], *search_excluded(lines[at + 1:])]
else:
lines[4:] = search_excluded(lines[4:])
lines.extend(["", "</div>", ""])
return lines
@@ -3405,14 +3421,14 @@ def _search_skipped_apart(lines: list[str]) -> list[str]:
return out
def _card_title(text: str, anchor: str, searchable: bool = True) -> str:
def _card_title(text: str, anchor: str, searchable: bool = True, weight: int = 10) -> str:
"""A file card's heading, so the site search lands on the card.
The search splits a page at its headings: a card titled in bold sent a
pasted md5 to the top of a page of a hundred cards. Level 3, under
the console or the Files heading: a level 4 after a level 2 skipped
one for screen readers on 551 pages. The table of contents of a page
of cards stops above it (mkdocs_hooks.py), and a card without an
of cards stops above it (sitestarlight.py), and a card without an
anchor keeps the id the toc gives it.
"""
if markup.starlight():
@@ -3420,11 +3436,12 @@ def _card_title(text: str, anchor: str, searchable: bool = True) -> str:
# would add the engine's anchor link and icon to every card.
title = re.sub(r"`([^`]+)`", lambda m: f"<code>{html.escape(m[1], quote=False)}</code>", text)
ident = f' id="{anchor}"' if anchor else ""
# The card is the file's own page in all but name: its title weighs
# as a page title does, so a file name typed in the search finds
# the card before a guide that cites it as an example.
weight = ' data-pagefind-weight="7"' if searchable else f" {markup.search_exclude()}"
return f'<h3{ident} class="rb-file-title"{weight}>{title}</h3>'
# A system page's card is the file's own page in all but name: its
# title weighs the most a word can, so a file name typed in the
# search finds the card before a guide that cites it as an example.
# An emulator's card says who reads the file, and weighs half.
search = f' data-pagefind-weight="{weight}"' if searchable else f" {markup.search_exclude()}"
return f'<h3{ident} class="rb-file-title"{search}>{title}</h3>'
attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title"
if not searchable:
attributes += " data-search-exclude"
@@ -3574,8 +3591,8 @@ def system_pages(
def card_weight(card: list[str]) -> int:
return sum(len(line) + 1 for line in card)
# Sizes are those of the MkDocs Markdown the budget was tuned on, so
# every engine splits the same pages at the same cards.
# Sizes are those of the MkDocs Markdown the budget was set on, so the
# pages split where they always have and no card changes address.
with markup.using("mkdocs"):
measured = all_sections()
measured_text = whole_page(measured)
@@ -3667,7 +3684,7 @@ def _part_range(part: list[Card]) -> str:
"""The first and last file names of a part, as the cards show them."""
names = []
for _variant, card, _sha1 in part:
# A Markdown heading under MkDocs, an HTML one under Starlight.
# An HTML heading on the site, a Markdown one in the size yardstick.
match = re.search(r"`([^`]*)`|<code>(.*?)</code>", card[2])
names.append(match[1] if match[1] is not None else html.unescape(match[2]))
if len(names) == 1:
@@ -4025,7 +4042,7 @@ def _render_emulator_file(
lines.append(markup.block_open("div", 'class="rb-file-entry"'))
lines.append("")
marker = f'<span id="{alias}"></span>' if alias else ""
lines.append(_card_title(f"{marker}{_code(fname)} {badge_str}", anchor))
lines.append(_card_title(f"{marker}{_code(fname)} {badge_str}", anchor, weight=5))
lines.append("")
if desc:
lines.extend([_escape_tags(str(desc)), ""])
@@ -4845,7 +4862,7 @@ def emulator_pages(
# The member tables are a file list with hashes: out of the search,
# dolphin, primehack and ishiiruka each put 210 KB of it there.
lines.extend([
"## Data directories" + (" { data-search-exclude }" if not markup.starlight() else ""),
"## Data directories",
"",
])
trees_start = len(lines)
@@ -4895,10 +4912,8 @@ def emulator_pages(
"info", f"{_count(len(members), 'file')}, as the packs ship them",
table, indent=" "))
lines.append("")
if markup.starlight():
# The member tables are the lookup's, as the heading's attribute
# keeps them under MkDocs.
lines[trees_start:] = search_excluded(lines[trees_start:])
# The member tables are the lookup's.
lines[trees_start:] = search_excluded(lines[trees_start:])
lines.extend(_next_block([
"[Get your files](../which-pack.md), for the setup you play on",
@@ -6125,23 +6140,7 @@ def _system_emulators(
if names & data.get("names", set())]
def _mkdocs_config(docs: Path, text: str) -> tuple[Path, str]:
"""Where the site's config goes, and its text for that place.
The tracked mkdocs.yml belongs to docs/. A preview into another folder
rewrote it anyway, with the navigation of work in progress, and mkdocs
kept reading docs/. Another folder gets its own config beside it, its
paths taken from there.
"""
if os.path.normpath(str(docs)) == DOCS_DIR:
return Path("mkdocs.yml"), text
where = docs.parent
for relative in ("scripts/mkdocs_hooks.py", "docs_assets/overrides"):
text = text.replace(relative, os.path.relpath(relative, where).replace(os.sep, "/"))
return where / f"{docs.name}.mkdocs.yml", f"docs_dir: {docs.name}\n" + text
# mkdocs.yml nav generator
# Navigation
_OS_NAMES = {"windows": "Windows", "darwin": "macOS", "linux": "Linux"}
@@ -6332,13 +6331,10 @@ def generate_which_pack() -> str:
"",
"Run the command and press **c** when it asks.",
"",
*([
"Or choose your setup and consoles here. You get the list of files "
"and a command that installs only those.",
"",
"<FilePicker />",
"",
] if markup.starlight() else []),
"Or pick them here, and get the files and the command.",
"",
"<FilePicker />",
"",
*_next_block([
"[First install, step by step](wiki/first-install.md)",
"[When a game does not start](wiki/troubleshooting.md)",
@@ -6486,7 +6482,7 @@ def _nav_target(entry: object) -> str:
return entry if isinstance(entry, str) else str(next(iter(entry.values())))
def generate_mkdocs_nav(
def generate_nav(
coverages: dict,
manufacturers: dict,
profiles: dict,
@@ -6543,7 +6539,7 @@ def generate_mkdocs_nav(
def main() -> None:
parser = argparse.ArgumentParser(
allow_abbrev=False,
description="Generate MkDocs site from project data"
description="Generate the documentation site from project data"
)
parser.add_argument(
"--db", default="database.json",
@@ -6557,24 +6553,18 @@ def main() -> None:
"--emulators-dir", default="emulators",
help="emulator profile directory (default: %(default)s)",
)
parser.add_argument(
"--engine", choices=markup.ENGINES, default="mkdocs",
help="site engine the pages are written for (default: %(default)s)",
)
parser.add_argument(
"--site-dir", default=STARLIGHT_DIR,
help="the Starlight project, with --engine starlight (default: %(default)s)",
help="the Astro Starlight project the site is written into (default: %(default)s)",
)
parser.add_argument(
"--docs-dir", default=None,
help="directory the pages are written to (default: docs, or the "
"Starlight project's content collection)",
help="directory the pages are written to (default: the project's "
"content collection, src/content/docs)",
)
args = parser.parse_args()
markup.set_engine(args.engine)
if args.docs_dir is None:
args.docs_dir = (str(Path(args.site_dir) / STARLIGHT_CONTENT)
if markup.starlight() else DOCS_DIR)
args.docs_dir = str(Path(args.site_dir) / STARLIGHT_CONTENT)
try:
# Two runs over one docs/ delete each other's pages and scratch
# files in the sweep, so the second is refused instead.
@@ -6596,38 +6586,25 @@ def _generate(args: argparse.Namespace) -> None:
db = load_database(args.db)
docs = Path(args.docs_dir)
# Files the site serves as they are (data, downloads, images): beside the
# pages under MkDocs, in the project's public folder under Starlight.
public = Path(args.site_dir) / "public" if markup.starlight() else docs
# Files the site serves as they are (data, downloads, images), in the
# project's public folder.
public = Path(args.site_dir) / "public"
for d in GENERATED_DIRS:
(public / d if d in PUBLIC_DIRS else docs / d).mkdir(parents=True, exist_ok=True)
if not markup.starlight():
css_src = Path("docs_assets") / "extra.css"
css_dest = docs / "stylesheets" / "extra.css"
if css_src.exists():
css_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(css_src, css_dest)
js_src = Path("docs_assets") / "site.js"
js_dest = docs / "javascripts" / "site.js"
if js_src.exists():
js_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(js_src, js_dest)
# No robots.txt: a crawler reads it only at the host root (RFC 9309), and
# this project site lives under /retrobios/. Every page's head points at
# the sitemap instead (overrides/main.html).
# the sitemap instead (Starlight's head links it).
(public / "robots.txt").unlink(missing_ok=True)
images_dest = public / "assets" / "images"
images_dest.mkdir(parents=True, exist_ok=True)
# Logos, system icons and sharing cards are served from the site itself
# (docs_assets/*/sources.json records where each came from).
folders = [("logos", "logos"), ("system-icons", "system-icons"), ("og", "og")]
if markup.starlight():
# The theme's own folder under MkDocs: the mark, favicons, touch icon.
folders.append(("overrides/assets/brand", "brand"))
# brand: the mark, favicons and touch icon (scripts/make_site_icons.py).
folders = [("logos", "logos"), ("system-icons", "system-icons"), ("og", "og"),
("brand", "brand")]
for folder, dest in folders:
source = Path("docs_assets") / folder
if source.is_dir():
@@ -6799,6 +6776,9 @@ def _generate(args: argparse.Namespace) -> None:
data_dirs=page_data_directories(name, profiles),
)
PAGE_DATA[f"emulators/{name}.md"] = emulator_structured_data(profile)
if (profile.get("type") in ("launcher", "alias")
or profile.get("core_classification") in ("launcher", "alias", "frozen_snapshot")):
SEARCH_DEMOTED.add(f"emulators/{name}.md")
files = profile.get("files") or []
for stem, entries in emulator_parts(name, files):
PAGE_DATA[f"emulators/{stem}.md"] = emulator_structured_data(
@@ -6846,12 +6826,7 @@ def _generate(args: argparse.Namespace) -> None:
wiki_src = Path(WIKI_SRC_DIR)
if wiki_src.is_dir():
for src_file in wiki_src.glob("*.md"):
if markup.starlight():
# Written in Material syntax until the wiki is written for Starlight.
write_if_changed(str(wiki_dest / src_file.name),
sitestarlight.wiki_page(src_file.read_text(encoding="utf-8")))
else:
shutil.copy2(src_file, wiki_dest / src_file.name)
shutil.copy2(src_file, wiki_dest / src_file.name)
_record(wiki_dest / src_file.name)
# data-model.md is generated (contains live DB stats)
write_if_changed(
@@ -6870,163 +6845,32 @@ def _generate(args: argparse.Namespace) -> None:
stale = _sweep_generated(docs, [d for d in GENERATED_DIRS if d not in PUBLIC_DIRS])
stale += _sweep_generated(public, list(PUBLIC_DIRS))
if markup.starlight():
write_if_changed(str(docs / "404.md"), generate_not_found())
write_if_changed(str(docs / "404.md"), generate_not_found())
print("Adding page metadata and structured data...")
lastmod = decorate_markdown_pages(docs)
nav = generate_mkdocs_nav(coverages, manufacturers, profiles, registry,
nav = generate_nav(coverages, manufacturers, profiles, registry,
Path(WIKI_SRC_DIR))
if markup.starlight():
print("Writing the Starlight navigation and the picker's data...")
site = Path(args.site_dir)
present = {
path.relative_to(docs).with_suffix(".md").as_posix()
for path in docs.rglob("*.md*") if path.suffix in (".md", ".mdx")
} - {"404.md"}
generated = site / "src" / "generated"
generated.mkdir(parents=True, exist_ok=True)
write_if_changed(str(generated / "sidebar.json"),
json.dumps(sitestarlight.sidebar(nav, present), indent=1) + "\n")
write_if_changed(str(generated / "lastmod.json"),
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
sitestarlight.picker_data(_REPO_ROOT / "install", public / "data" / "picker")
total_pages = len(present)
print(f"\nGenerated {total_pages} pages in {args.docs_dir}/")
if stale:
print(f"Removed {stale} page(s) nothing produced this run")
return
# Update mkdocs.yml nav section only (avoid yaml.dump round-trip mangling quotes)
print("Updating mkdocs.yml nav...")
nav_yaml = yaml.dump(
{"nav": nav}, default_flow_style=False, sort_keys=False, allow_unicode=True
)
# Rewrite mkdocs.yml entirely (static config + generated nav)
mkdocs_static = """\
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
"""
config_path, config_text = _mkdocs_config(Path(docs), mkdocs_static + nav_yaml)
write_if_changed(str(config_path), config_text)
# Counted on disk: the split pages made a sum of page kinds wrong.
total_pages = sum(
1 for path in docs.rglob("*.md")
if not any(part in LOCAL_NOTE_DIRS for part in path.relative_to(docs).parts[:-1])
)
print("Writing the navigation and the picker's data...")
site = Path(args.site_dir)
present = {
path.relative_to(docs).with_suffix(".md").as_posix()
for path in docs.rglob("*.md*") if path.suffix in (".md", ".mdx")
} - {"404.md"}
generated = site / "src" / "generated"
generated.mkdir(parents=True, exist_ok=True)
write_if_changed(str(generated / "sidebar.json"),
json.dumps(sitestarlight.sidebar(nav, present), indent=1) + "\n")
write_if_changed(str(generated / "lastmod.json"),
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
sitestarlight.picker_data(_REPO_ROOT / "install", public / "data" / "picker")
total_pages = len(present)
print(f"\nGenerated {total_pages} pages in {args.docs_dir}/")
if stale:
print(f"Removed {stale} page(s) nothing produced this run")
from sitediagrams import prepare_page, unresolved_images # noqa: E402,F401
from sitewrite import ( # noqa: E402,F401
_produced,
+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
+13 -6
View File
@@ -75,8 +75,15 @@ def requirement_lines(report: dict) -> list[str]:
]
if not items:
return []
return markup.details("note", f"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:
@@ -139,8 +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(markup.details("note", f"{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
@@ -289,6 +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(markup.details("note", f"{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)
+10 -7
View File
@@ -5,18 +5,22 @@ 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 engine is Astro Starlight. MkDocs Material is kept while the two builds
are compared page by page, selected with set_engine("mkdocs").
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 = "mkdocs"
_engine = "starlight"
# Material's callout kinds, by the role Starlight gives them.
ASIDE_ROLES = {
@@ -41,10 +45,9 @@ def engine() -> str:
@contextmanager
def using(name: str):
"""Write in another engine's syntax for a while. A page split by size
is measured in the MkDocs Markdown its budgets were tuned on, so both
engines cut the same pages at the same files."""
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:
+25 -78
View File
@@ -16,6 +16,7 @@ import json
import re
import unicodedata
import urllib.parse
from collections.abc import Callable, Iterator
from html import unescape
from pathlib import Path, PurePosixPath
@@ -85,7 +86,7 @@ def resolve(rel_page: str, target: str) -> str:
# Text outside code
def outside_fences(text: str, fn) -> str:
def outside_fences(text: str, fn: Callable[[str], str]) -> str:
parts = FENCE.split(text)
# FENCE has two groups: text, fence, marker, text, ...
out = []
@@ -96,7 +97,7 @@ def outside_fences(text: str, fn) -> str:
return "".join(out)
def outside_code(text: str, fn) -> str:
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):
@@ -345,12 +346,27 @@ def split_title(markdown: str) -> tuple[str, str, str]:
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,
boost: int | None,
weight: float | None,
browser_title: str,
description: str,
structured: dict,
@@ -375,6 +391,8 @@ def finalize_page(
# 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:
@@ -394,8 +412,8 @@ def finalize_page(
front["slug"] = stem
if level:
front["level"] = level
if boost:
front["boost"] = boost
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
@@ -411,77 +429,6 @@ def finalize_page(
return stem + (".mdx" if components else ".md"), _front_matter(front) + body
# Material syntax in the hand-written wiki pages, read until they are
# written for Starlight.
_WIKI_ADMONITION = re.compile(r'^(?P<indent> *)(?P<kind>!!!|\?\?\?\+?) *(?P<type>[a-z]+)(?: +"(?P<title>[^"]*)")? *$')
_WIKI_TAB = re.compile(r'^(?P<indent> *)=== +"(?P<label>[^"]+)" *$')
_WIKI_BUTTON = re.compile(r"\[(?P<text>[^\]]+)\]\((?P<url>[^)\s]+)\)\{ *(?P<attrs>[^}]*)\}")
def _indented_block(lines: list[str], start: int, indent: int) -> tuple[list[str], int]:
body: list[str] = []
index = start
pad = " " * (indent + 4)
while index < len(lines) and (not lines[index].strip() or lines[index].startswith(pad)):
body.append(lines[index][indent + 4:] if lines[index].startswith(pad) else "")
index += 1
while body and not body[-1].strip():
body.pop()
index -= 1
return body, index
def wiki_page(text: str) -> str:
"""A wiki page in Material syntax, its blocks written for Starlight."""
import sitemarkup as markup
def blocks(lines: list[str]) -> list[str]:
out: list[str] = []
index = 0
while index < len(lines):
line = lines[index]
admonition = _WIKI_ADMONITION.match(line)
tab = _WIKI_TAB.match(line)
if admonition:
indent = len(admonition["indent"])
body, index = _indented_block(lines, index + 1, indent)
title = admonition["title"] if admonition["title"] is not None else admonition["type"].capitalize()
pad = " " * indent
if admonition["kind"] == "!!!":
out += markup.aside(admonition["type"], title, blocks(body), pad)
else:
out += markup.details(admonition["type"], title, blocks(body), pad,
opened=admonition["kind"].endswith("+"))
continue
if tab:
indent = len(tab["indent"])
items = []
while index < len(lines):
tab = _WIKI_TAB.match(lines[index])
if not tab or len(tab["indent"]) != indent:
break
body, index = _indented_block(lines, index + 1, indent)
items.append((tab["label"], blocks(body)))
while index < len(lines) and not lines[index].strip() and index + 1 < len(lines) \
and _WIKI_TAB.match(lines[index + 1]):
index += 1
out += markup.tabs(items, " " * indent)
continue
out.append(line)
index += 1
return out
def button(match: re.Match) -> str:
classes = match["attrs"].split()
if ".md-button" not in classes:
return match.group(0)
return markup.button(match["text"], match["url"], primary=".md-button--primary" in classes)
text = "\n".join(blocks(text.split("\n")))
return outside_fences(text, lambda chunk: _WIKI_BUTTON.sub(button, chunk))
# Navigation
@@ -497,7 +444,7 @@ def sidebar(nav: list, present: set[str]) -> dict:
"""
owner: dict[str, str] = {}
def item(entry, topic: str) -> dict | None:
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
@@ -536,7 +483,7 @@ def sidebar(nav: list, present: set[str]) -> dict:
return {"topics": topics, "options": {"topics": extra, "exclude": exclude}}
def _links(items: list[dict]):
def _links(items: list[dict]) -> Iterator[dict]:
for entry in items:
if "link" in entry:
yield entry
+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(
+6 -48
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Validate the built RetroBIOS site as a navigable, accessible data product.
This complements the engine's own link check (``mkdocs build --strict`` or
Starlight's link validator) with checks on the rendered HTML: metadata,
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.
"""
@@ -50,10 +50,6 @@ 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.
@@ -192,39 +188,6 @@ def _check_pagefind(site: Path) -> list[str]:
return issues
def _check_search_index(site: Path) -> list[str]:
"""The index every visitor who opens the search downloads, against its budget."""
import gzip
if (site / "pagefind").is_dir():
return _check_pagefind(site)
path = site / "search" / "search_index.json"
if not path.is_file():
return []
raw = path.read_bytes()
packed = len(gzip.compress(raw, 6))
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")
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:
@@ -370,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 | None = None, base: str = "/retrobios/") -> 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) if config_path and config_path.is_file() else base
html_paths = sorted(site.rglob("*.html"))
if not html_paths:
return [f"no HTML pages found in {site}"]
@@ -394,7 +356,7 @@ def validate_site(site: Path, config_path: Path | None = None, base: str = "/ret
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
@@ -406,13 +368,9 @@ 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=None,
help="an MkDocs configuration to read the base path from",
)
parser.add_argument(
"--base", default="/retrobios/",
help="the path the site is served under (default: %(default)s)",
@@ -424,7 +382,7 @@ def main() -> None:
args = parser.parse_args()
try:
issues = validate_site(Path(args.site_dir), Path(args.config) if args.config else None, args.base)
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):
+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]
+14 -5
View File
@@ -66,12 +66,21 @@ export default defineConfig({
expressiveCode: { defaultProps: { wrap: true } },
lastUpdated: false,
pagination: true,
// Measured on the ten queries of tools/search_gate.py, inside a range
// where all ten answer: Starlight's defaults (0.1, 0.1, 2, 9) let a guide
// that cites scph5501 once outrank the file's own card. A long page
// is penalised a little more, and how often a word appears counts.
// 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.3, termFrequency: 0.5, termSaturation: 1.4, termSimilarity: 9 },
ranking: {
pageLength: 0.1,
termFrequency: 0.5,
termSaturation: 1.4,
termSimilarity: 9,
metaWeights: { title: 10, files: 0.5 },
},
},
plugins: [
starlightSidebarTopics(sidebar.topics, sidebar.options),
+6 -5
View File
@@ -1,14 +1,15 @@
---
// Search ranking: Pagefind multiplies the score of words inside a weighted
// element, so a Start here page outranks the reference page that names it.
// 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 { boost } = Astro.locals.starlightRoute.entry.data;
const { searchWeight } = Astro.locals.starlightRoute.entry.data;
---
{
boost ? (
<div data-pagefind-weight={String(Math.min(10, boost * 2))}>
searchWeight ? (
<div data-pagefind-weight={String(searchWeight)}>
<Default><slot /></Default>
</div>
) : (
+2 -2
View File
@@ -12,8 +12,8 @@ export const collections = {
// Who the page is written for, from someone who knows nothing
// (start) to someone adding to the project (contribute).
level: z.enum(['start', 'setup', 'explore', 'reference', 'contribute']).optional(),
// Search ranking of the first two levels.
boost: z.number().min(1).max(10).optional(),
// 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(),
}),
+2 -1
View File
@@ -367,7 +367,8 @@
/* Collapsible blocks keep the callout colour of their role */
.sl-markdown-content details.rb-details {
border-inline-start: 4px solid var(--sl-color-blue);
border: 1px solid var(--sl-color-blue);
border-radius: 4px;
background: var(--sl-color-blue-low);
padding: 0.6rem 1rem;
}
+29 -24
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""The search gate: ten queries a visitor types, and the pages that answer them.
"""The search gate: queries a visitor types, and the pages that answer them.
Runs measure.mjs against a served build and counts a query as answered when
one of its expected pages is in the first three results of the site's own
search box.
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/
"""
@@ -17,21 +17,24 @@ from pathlib import Path
TOOLS = Path(__file__).resolve().parent
# Query, then the pages that answer it (path prefixes under the site).
QUERIES: dict[str, tuple[str, ...]] = {
"playstation bios": ("systems/sony/",),
# The file itself: its card on the system page, or a core that reads it.
"scph5501": ("systems/sony/", "emulators/beetle_psx/", "emulators/duckstation/",
"emulators/duckstation-legacy/", "emulators/pcsx1/", "emulators/swanstation/",
"emulators/ares/", "emulators/pcsx_rearmed/", "files/"),
"batocera folder": ("platforms/batocera/",),
"neogeo.zip": ("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"),
"duckstation": ("emulators/duckstation/", "emulators/duckstation-legacy/"),
"steam deck": ("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"),
"missing bios": ("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"),
"md5": ("wiki/verification-modes/",),
"retroarch system folder": ("platforms/retroarch/",),
"knulli": ("platforms/knulli/",),
# 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),
}
@@ -48,12 +51,14 @@ def main() -> int:
if "query" not in result:
print(f"first search: {result['first_search_kb']} KB in {result['first_search_requests']} requests")
continue
top = [entry.split(" | ")[1][len(root):] for entry in result["top"][:3]]
hit = any(page.startswith(QUERIES[result["query"]]) for page in top)
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
print(f"{'ok ' if hit else 'MISS'} {result['query']!r}: {', '.join(top)}")
print(f"{answered}/{len(QUERIES)} queries answered in the top 3")
return 0 if answered >= 9 else 1
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__":
+5 -5
View File
@@ -6,18 +6,18 @@ 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 }) => {
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 top = search.results.slice(0, 8);
const data = await Promise.all(top.map((result) => result.data()));
return top.map((result, index) => ({
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 });
}, { 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();
+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)
+19 -3
View File
@@ -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)