mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: build the site with Astro Starlight
This commit is contained in:
commit
1c898ba389
86 files changed
+9118
-3291
No files matched your search
@@ -29,7 +29,6 @@ on:
|
||||
- "scripts/largefiles.py"
|
||||
- "scripts/launchers.py"
|
||||
- "scripts/manifestselect.py"
|
||||
- "scripts/mkdocs_hooks.py"
|
||||
- "scripts/nativemode.py"
|
||||
- "scripts/packextras.py"
|
||||
- "scripts/packpaths.py"
|
||||
@@ -45,9 +44,11 @@ on:
|
||||
- "scripts/sect233r1.py"
|
||||
- "scripts/sitediagrams.py"
|
||||
- "scripts/siteguides.py"
|
||||
- "scripts/sitemarkup.py"
|
||||
- "scripts/siteexports.py"
|
||||
- "scripts/siterender.py"
|
||||
- "scripts/sitesources.py"
|
||||
- "scripts/sitestarlight.py"
|
||||
- "scripts/sitewrite.py"
|
||||
- "scripts/slot.py"
|
||||
- "scripts/slots.py"
|
||||
@@ -67,7 +68,9 @@ on:
|
||||
- "database.json"
|
||||
- "release.json"
|
||||
- "derivable.json"
|
||||
- "mkdocs.yml"
|
||||
# The Starlight project: config, components, styles, lockfile. Its
|
||||
# generated folders are gitignored and never trigger anything.
|
||||
- "website/**"
|
||||
- ".github/workflows/deploy-site.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -93,11 +96,18 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# mkdocs-material >= 9.7.5 caps mkdocs < 2, and the upper bound keeps a major
|
||||
# theme release from changing the site without a deliberate bump here.
|
||||
# mkdocs is pinned below 2 on its own too: MkDocs 2 drops plugins, and the
|
||||
# theme, in maintenance mode since 9.7.0, will not follow it.
|
||||
- run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "mkdocs>=1.6,<2" "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
|
||||
- run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0"
|
||||
|
||||
# Node builds the site only: the scripts stay Python. The engine and
|
||||
# its plugins are pinned exactly in website/package.json and resolved
|
||||
# from the committed lockfile.
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- run: npm ci --prefix website
|
||||
|
||||
- name: Validate data contracts
|
||||
run: python scripts/validate_schemas.py
|
||||
@@ -120,8 +130,9 @@ jobs:
|
||||
run: |
|
||||
python scripts/generate_site.py
|
||||
python scripts/generate_readme.py --db database.json --platforms-dir platforms
|
||||
mkdocs build --strict
|
||||
python scripts/validate_site.py
|
||||
# starlight-links-validator fails the build on a dead link or anchor.
|
||||
npm run build --prefix website
|
||||
python scripts/validate_site.py --site-dir website/dist
|
||||
|
||||
# write_if_changed() compares content with its timestamp line stripped,
|
||||
# so a run that only moves the clock leaves the file untouched and this
|
||||
@@ -135,7 +146,7 @@ jobs:
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
with:
|
||||
path: site/
|
||||
path: website/dist/
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
File renamed without changes.
File renamed without changes.
@@ -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);
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,298 +0,0 @@
|
||||
/* Progressive enhancement for generated RetroBIOS reference tables. */
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// A heading's words without its permalink sign.
|
||||
const headingText = (heading) => {
|
||||
const copy = heading.cloneNode(true);
|
||||
copy.querySelectorAll(".headerlink").forEach((link) => link.remove());
|
||||
return copy.textContent.trim();
|
||||
};
|
||||
|
||||
const textFromHeading = (element) => {
|
||||
let cursor = element;
|
||||
while (cursor) {
|
||||
cursor = cursor.previousElementSibling;
|
||||
if (!cursor) break;
|
||||
if (/^H[1-6]$/.test(cursor.tagName)) {
|
||||
return headingText(cursor);
|
||||
}
|
||||
}
|
||||
const title = document.querySelector("h1");
|
||||
return title ? headingText(title) : "Data table";
|
||||
};
|
||||
|
||||
// A scroller is a stop in the tab order only while it overflows: a table
|
||||
// in a closed block or a hidden tab gains its overflow later.
|
||||
const markScrollable = (container, heading) => {
|
||||
if (!container) return;
|
||||
container.dataset.rbHeading = heading;
|
||||
if (container.scrollWidth > container.clientWidth + 1) {
|
||||
container.tabIndex = 0;
|
||||
container.setAttribute("role", "region");
|
||||
container.setAttribute("aria-label", `${heading}, scrollable table`);
|
||||
} else if (container.getAttribute("role") === "region") {
|
||||
container.removeAttribute("tabindex");
|
||||
container.removeAttribute("role");
|
||||
container.removeAttribute("aria-label");
|
||||
}
|
||||
};
|
||||
|
||||
const recheckScrollable = () => {
|
||||
document.querySelectorAll(".md-typeset__scrollwrap[data-rb-heading]").forEach((container) => {
|
||||
markScrollable(container, container.dataset.rbHeading);
|
||||
});
|
||||
};
|
||||
|
||||
// A table whose rows end in an action (a download button) is laid out one
|
||||
// block per row on a phone, each cell under its column name, so the action
|
||||
// never sits behind a horizontal scroll.
|
||||
const stackActionTable = (table) => {
|
||||
if (!table.querySelector("tbody .md-button")) return;
|
||||
const labels = Array.from(table.querySelectorAll("thead th")).map((cell) =>
|
||||
cell.textContent.trim()
|
||||
);
|
||||
if (!labels.length) return;
|
||||
table.dataset.rbStack = "true";
|
||||
table.querySelectorAll("tbody tr").forEach((row) => {
|
||||
Array.from(row.children).forEach((cell, column) => {
|
||||
if (labels[column] && !cell.hasAttribute("data-label")) {
|
||||
cell.setAttribute("data-label", labels[column]);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const enhanceTables = () => {
|
||||
document.querySelectorAll(".md-typeset table").forEach((table, index) => {
|
||||
if (table.dataset.rbEnhanced === "true") return;
|
||||
table.dataset.rbEnhanced = "true";
|
||||
|
||||
table.querySelectorAll("thead th").forEach((cell) => {
|
||||
if (!cell.hasAttribute("scope")) cell.setAttribute("scope", "col");
|
||||
});
|
||||
table.querySelectorAll("tbody tr").forEach((row) => {
|
||||
const first = row.querySelector(":scope > th");
|
||||
if (first && !first.hasAttribute("scope")) first.setAttribute("scope", "row");
|
||||
});
|
||||
|
||||
// The scrolling element is Material's scrollwrap, one level above
|
||||
// the table's own wrapper.
|
||||
const container = table.closest(".md-typeset__scrollwrap") || table.parentElement;
|
||||
const heading = textFromHeading(container);
|
||||
if (!table.querySelector("caption")) {
|
||||
const caption = document.createElement("caption");
|
||||
caption.className = "rb-visually-hidden";
|
||||
caption.textContent = heading;
|
||||
table.prepend(caption);
|
||||
}
|
||||
stackActionTable(table);
|
||||
markScrollable(container, heading);
|
||||
|
||||
const rows = Array.from(table.querySelectorAll("tbody tr"));
|
||||
if (rows.length < 20 || !container || container.dataset.rbFilter === "true") {
|
||||
return;
|
||||
}
|
||||
container.dataset.rbFilter = "true";
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "rb-table-filter";
|
||||
const id = `rb-table-filter-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = id;
|
||||
label.textContent = `Filter ${heading}`;
|
||||
const input = document.createElement("input");
|
||||
input.id = id;
|
||||
input.type = "search";
|
||||
input.autocomplete = "off";
|
||||
input.placeholder = "Name, platform, system, hash...";
|
||||
const count = document.createElement("span");
|
||||
count.className = "rb-table-filter-count";
|
||||
count.setAttribute("aria-live", "polite");
|
||||
count.textContent = `${rows.length} rows`;
|
||||
controls.append(label, input, count);
|
||||
container.before(controls);
|
||||
|
||||
const searchable = rows.map((row) => ({
|
||||
row,
|
||||
text: row.textContent.normalize("NFKD").toLocaleLowerCase(),
|
||||
}));
|
||||
input.addEventListener("input", () => {
|
||||
const query = input.value.trim().normalize("NFKD").toLocaleLowerCase();
|
||||
let visible = 0;
|
||||
searchable.forEach((entry) => {
|
||||
const matches = !query || entry.text.includes(query);
|
||||
entry.row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
});
|
||||
count.textContent = `${visible} of ${rows.length} rows`;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const nameMaterialWidgets = () => {
|
||||
document.querySelectorAll('[role="dialog"]').forEach((dialog) => {
|
||||
if (dialog.hasAttribute("aria-label") || dialog.hasAttribute("aria-labelledby")) return;
|
||||
const heading = dialog.querySelector("h1, h2, h3");
|
||||
dialog.setAttribute("aria-label", heading?.textContent.trim() || "Site search");
|
||||
});
|
||||
// The page-load bar is drawn for the eye only: the new page announces
|
||||
// itself when it arrives.
|
||||
document.querySelectorAll(".md-progress").forEach((progress) => {
|
||||
progress.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
// Material wraps each copy button in a nav element: one unnamed
|
||||
// navigation landmark per code block. The button stays, the landmark goes.
|
||||
document.querySelectorAll("nav.md-code__nav").forEach((nav) => {
|
||||
nav.setAttribute("role", "none");
|
||||
});
|
||||
};
|
||||
|
||||
// files/?hash= reads one shard of the lookup, named by the first two hex
|
||||
// characters of the value: 20 KB where the whole index was 675 KB.
|
||||
const HASH_LENGTHS = { 8: "CRC32", 32: "MD5", 40: "SHA1", 64: "SHA256" };
|
||||
const LAYER_LABELS = {
|
||||
collection: "In the collection",
|
||||
"data-directory": "In a data directory the packs ship",
|
||||
platform: "Declared by a platform",
|
||||
emulator: "Read by an emulator",
|
||||
};
|
||||
const siteRoot = () => {
|
||||
const script = document.querySelector('script[src$="javascripts/site.js"]');
|
||||
return script ? new URL("../", script.src) : new URL("../", window.location.href);
|
||||
};
|
||||
const lookupShard = (value) =>
|
||||
fetch(new URL(`api/v1/lookup/${value.slice(0, 2)}.json`, siteRoot()))
|
||||
.then((response) => (response.ok ? response.json() : { items: [] }))
|
||||
.then((shard) => shard.items || []);
|
||||
const normalizeHash = (raw) => raw.trim().toLowerCase().replace(/^0x/, "").replace(/\s+/g, "");
|
||||
|
||||
const renderMatches = (status, value, items) => {
|
||||
status.textContent = "";
|
||||
const kind = HASH_LENGTHS[value.length];
|
||||
const heading = document.createElement("p");
|
||||
heading.textContent = `${items.length} ${items.length === 1 ? "match" : "matches"} for ${kind} ${value}:`;
|
||||
status.append(heading);
|
||||
Object.entries(LAYER_LABELS).forEach(([layer, label]) => {
|
||||
const found = items.filter((item) => item.layer === layer);
|
||||
if (!found.length) return;
|
||||
const title = document.createElement("p");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = label;
|
||||
title.append(strong);
|
||||
const list = document.createElement("ul");
|
||||
found.forEach((item) => {
|
||||
const row = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = new URL(item.url, siteRoot()).href;
|
||||
const code = document.createElement("code");
|
||||
code.textContent = item.name || item.value;
|
||||
link.append(code);
|
||||
row.append(link);
|
||||
if (item.owner) row.append(` (${item.owner})`);
|
||||
list.append(row);
|
||||
});
|
||||
status.append(title, list);
|
||||
});
|
||||
};
|
||||
|
||||
// files/?sha1=<sha1> is a file's content address and opens its card
|
||||
// wherever it is now; files/?hash= also takes an MD5, CRC32 or SHA256 and
|
||||
// lists every page that names the value.
|
||||
const resolveFileAddress = () => {
|
||||
const status = document.getElementById("rb-file-lookup-status");
|
||||
if (!status) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const permalink = params.has("sha1");
|
||||
const value = normalizeHash(params.get("sha1") || params.get("hash") || "");
|
||||
if (!value) return;
|
||||
const input = document.getElementById("rb-hash");
|
||||
if (input) input.value = value;
|
||||
const prefix = permalink && /^[0-9a-f]{12}$/.test(value);
|
||||
if (!prefix && !(/^[0-9a-f]+$/.test(value) && HASH_LENGTHS[value.length])) {
|
||||
status.textContent =
|
||||
"A hash is 8 (CRC32), 32 (MD5), 40 (SHA1) or 64 (SHA256) hexadecimal characters.";
|
||||
return;
|
||||
}
|
||||
status.textContent = "Looking it up...";
|
||||
lookupShard(value)
|
||||
.then((items) => {
|
||||
const matches = items.filter((item) =>
|
||||
prefix
|
||||
? item.algorithm === "sha1" && item.value.startsWith(value)
|
||||
: item.value === value
|
||||
);
|
||||
const cards = matches.filter((item) => item.layer === "collection" || item.layer === "data-directory");
|
||||
const targets = new Set(cards.map((item) => item.url));
|
||||
if (permalink && targets.size === 1) {
|
||||
window.location.replace(new URL(cards[0].url, siteRoot()));
|
||||
} else if (matches.length) {
|
||||
renderMatches(status, prefix ? matches[0].value : value, prefix ? cards : matches);
|
||||
} else {
|
||||
status.textContent = "No file the site knows has this hash.";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
status.textContent = "The file index could not be read.";
|
||||
});
|
||||
};
|
||||
|
||||
// A file card moved to another page keeps its address: an id of the form
|
||||
// sha1-<12 hex> that this page does not hold is looked up by content, and
|
||||
// an emulator card listed in the table of a split page opens its page.
|
||||
const followMovedCard = () => {
|
||||
const id = decodeURIComponent(window.location.hash.slice(1));
|
||||
if (!id) return;
|
||||
const target = document.getElementById(id);
|
||||
const row = target?.closest(".rb-card-index tr");
|
||||
const link = row?.querySelector("a[href]");
|
||||
if (link) {
|
||||
window.location.replace(link.href);
|
||||
return;
|
||||
}
|
||||
const match = /^sha1-([0-9a-f]{12})$/.exec(id);
|
||||
if (!match || target) return;
|
||||
window.location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
|
||||
};
|
||||
|
||||
// The site search no longer holds hashes: Enter on a hash opens the lookup.
|
||||
const hashSearch = () => {
|
||||
const query = document.querySelector('input[data-md-component="search-query"]');
|
||||
if (!query || query.dataset.rbHash === "true") return;
|
||||
query.dataset.rbHash = "true";
|
||||
query.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
const value = normalizeHash(query.value);
|
||||
if (/^[0-9a-f]+$/.test(value) && HASH_LENGTHS[value.length]) {
|
||||
event.preventDefault();
|
||||
window.location.assign(new URL(`files/?hash=${value}`, siteRoot()));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const enhance = () => {
|
||||
enhanceTables();
|
||||
nameMaterialWidgets();
|
||||
resolveFileAddress();
|
||||
followMovedCard();
|
||||
hashSearch();
|
||||
};
|
||||
|
||||
let resizeTimer = 0;
|
||||
window.addEventListener("resize", () => {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(recheckScrollable, 150);
|
||||
});
|
||||
document.addEventListener("toggle", recheckScrollable, true);
|
||||
document.addEventListener("change", (event) => {
|
||||
if (event.target.matches?.(".tabbed-set > input")) recheckScrollable();
|
||||
});
|
||||
|
||||
if (window.document$?.subscribe) {
|
||||
window.document$.subscribe(enhance);
|
||||
} else if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", enhance, { once: true });
|
||||
} else {
|
||||
enhance();
|
||||
}
|
||||
})();
|
||||
-879
@@ -1,879 +0,0 @@
|
||||
site_name: RetroBIOS
|
||||
site_description: Source-verified BIOS and firmware packs for RetroArch, Batocera,
|
||||
Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX,
|
||||
KNULLI, AmberELEC, EmuELEC and MiSTer FPGA.
|
||||
site_url: https://abdess.github.io/retrobios/
|
||||
repo_url: https://github.com/Abdess/retrobios
|
||||
repo_name: Abdess/retrobios
|
||||
# Almost every page is generated from platforms/, emulators/ and database.json,
|
||||
# so a per-page edit link would point at a file that does not exist.
|
||||
edit_uri: ''
|
||||
# lastmod in the sitemap from each page's own date, or none at all.
|
||||
hooks:
|
||||
- scripts/mkdocs_hooks.py
|
||||
# Working notes kept under docs/ during development are not part of the site.
|
||||
exclude_docs: |
|
||||
plans/
|
||||
specs/
|
||||
copyright: MIT for the tooling. BIOS and firmware files are third-party system
|
||||
software, preserved for personal backup, archival and interoperability.
|
||||
theme:
|
||||
name: material
|
||||
# Open Graph tags live in overrides/main.html: the social plugin that
|
||||
# would otherwise emit them needs Pillow and CairoSVG only to render a
|
||||
# preview image the pages do not need.
|
||||
custom_dir: docs_assets/overrides
|
||||
palette:
|
||||
- media: (prefers-color-scheme)
|
||||
toggle:
|
||||
icon: material/brightness-auto
|
||||
name: Switch to light mode
|
||||
- media: '(prefers-color-scheme: light)'
|
||||
scheme: default
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- media: '(prefers-color-scheme: dark)'
|
||||
scheme: slate
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to auto
|
||||
font: false
|
||||
logo: assets/brand/logo.svg
|
||||
favicon: assets/brand/favicon-32.png
|
||||
icon:
|
||||
logo: material/chip
|
||||
features:
|
||||
- navigation.instant
|
||||
- navigation.instant.prefetch
|
||||
- navigation.instant.progress
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.top
|
||||
- navigation.tracking
|
||||
- navigation.indexes
|
||||
# Breadcrumbs above the title: on a deep page they say which tab it is in.
|
||||
- navigation.path
|
||||
# 400+ pages: pruning keeps the navigation out of every page's HTML.
|
||||
- navigation.prune
|
||||
- navigation.footer
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- search.share
|
||||
- content.code.copy
|
||||
- content.tabs.link
|
||||
- toc.follow
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
extra_javascript:
|
||||
- javascripts/site.js
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/Abdess/retrobios
|
||||
name: RetroBIOS on GitHub
|
||||
markdown_extensions:
|
||||
- abbr
|
||||
- admonition
|
||||
- attr_list
|
||||
- def_list
|
||||
- footnotes
|
||||
- meta
|
||||
- md_in_html
|
||||
- tables
|
||||
- toc:
|
||||
permalink: true
|
||||
# Level 3 is the wiki's subsections. On pages of file cards, also level
|
||||
# 3, the hook keeps the table of contents to level 2.
|
||||
toc_depth: 3
|
||||
- pymdownx.details
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.keys
|
||||
# No mermaid fence: Material drew it in the browser with a script from a
|
||||
# CDN. Diagrams are SVG files written by scripts/sitediagrams.py.
|
||||
- pymdownx.superfences
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
plugins:
|
||||
- search
|
||||
# Link rot fails the build: deploy-site.yml runs `mkdocs build --strict`.
|
||||
# omitted_files stays at its default (info) so a stale local docs/ does not
|
||||
# break a local build. CI regenerates docs/ from scratch anyway.
|
||||
validation:
|
||||
absolute_links: warn
|
||||
unrecognized_links: warn
|
||||
anchors: warn
|
||||
nav:
|
||||
- Start here:
|
||||
- index.md
|
||||
- What BIOS files are: wiki/what-are-bios-files.md
|
||||
- Get your files: which-pack.md
|
||||
- First install, step by step: wiki/first-install.md
|
||||
- When a game does not start: wiki/troubleshooting.md
|
||||
- Common questions: wiki/faq.md
|
||||
- Glossary: wiki/glossary.md
|
||||
- Your setup:
|
||||
- platforms/index.md
|
||||
- Computers and Steam Deck:
|
||||
- BizHawk: platforms/bizhawk.md
|
||||
- EmuDeck: platforms/emudeck.md
|
||||
- RetroArch: platforms/retroarch.md
|
||||
- RetroBat: platforms/retrobat.md
|
||||
- RetroDECK: platforms/retrodeck.md
|
||||
- Game systems for PCs and small boards:
|
||||
- Batocera: platforms/batocera.md
|
||||
- Lakka: platforms/lakka.md
|
||||
- Recalbox: platforms/recalbox.md
|
||||
- RetroPie: platforms/retropie.md
|
||||
- Handhelds and TV boxes:
|
||||
- AmberELEC: platforms/amberelec.md
|
||||
- EmuELEC: platforms/emuelec.md
|
||||
- KNULLI: platforms/knulli.md
|
||||
- MinUI: platforms/minui.md
|
||||
- MyMinUI: platforms/myminui.md
|
||||
- NextUI: platforms/nextui.md
|
||||
- ROCKNIX: platforms/rocknix.md
|
||||
- MiSTer FPGA:
|
||||
- MiSTer FPGA: platforms/misterfpga.md
|
||||
- Self-hosted game library:
|
||||
- RomM: platforms/romm.md
|
||||
- Explore:
|
||||
- explore.md
|
||||
- Systems:
|
||||
- systems/index.md
|
||||
- Consoles and computers:
|
||||
- 3DO Company: systems/3do-company.md
|
||||
- Acorn: systems/acorn.md
|
||||
- Amstrad: systems/amstrad.md
|
||||
- APF: systems/apf.md
|
||||
- Apple: systems/apple.md
|
||||
- Aquaplus: systems/aquaplus.md
|
||||
- Arcade: systems/arcade.md
|
||||
- Atari: systems/atari.md
|
||||
- Bally: systems/bally.md
|
||||
- Bandai: systems/bandai.md
|
||||
- BBK: systems/bbk.md
|
||||
- Bit Corporation: systems/bit-corporation.md
|
||||
- Camputers: systems/camputers.md
|
||||
- Casio: systems/casio.md
|
||||
- Coleco: systems/coleco.md
|
||||
- Commodore: systems/commodore.md
|
||||
- DOS: systems/dos.md
|
||||
- Dragon: systems/dragon.md
|
||||
- EACA: systems/eaca.md
|
||||
- Elektronika: systems/elektronika.md
|
||||
- Enterprise: systems/enterprise.md
|
||||
- Entex: systems/entex.md
|
||||
- Epoch: systems/epoch.md
|
||||
- Fairchild: systems/fairchild.md
|
||||
- Fujitsu: systems/fujitsu.md
|
||||
- Funtech: systems/funtech.md
|
||||
- Galaksija: systems/galaksija.md
|
||||
- GamePark: systems/gamepark.md
|
||||
- GCE: systems/gce.md
|
||||
- Gemei: systems/gemei.md
|
||||
- Grundy: systems/grundy.md
|
||||
- Hartung: systems/hartung.md
|
||||
- IBM: systems/ibm.md
|
||||
- Infocom: systems/infocom.md
|
||||
- Interact: systems/interact.md
|
||||
- Java: systems/java.md
|
||||
- Lexaloffle: systems/lexaloffle.md
|
||||
- Magnavox: systems/magnavox.md
|
||||
- Mattel: systems/mattel.md
|
||||
- Microsoft: systems/microsoft.md
|
||||
- NEC: systems/nec.md
|
||||
- Nintendo: systems/nintendo.md
|
||||
- Nokia: systems/nokia.md
|
||||
- Oric: systems/oric.md
|
||||
- Other: systems/other.md
|
||||
- Palm: systems/palm.md
|
||||
- PC: systems/pc.md
|
||||
- Philips: systems/philips.md
|
||||
- Pioneer: systems/pioneer.md
|
||||
- QEMU: systems/qemu.md
|
||||
- RCA: systems/rca.md
|
||||
- Real3D: systems/real3d.md
|
||||
- Roland: systems/roland.md
|
||||
- SDL: systems/sdl.md
|
||||
- Sega: systems/sega.md
|
||||
- Sharp: systems/sharp.md
|
||||
- Sinclair: systems/sinclair.md
|
||||
- SNK: systems/snk.md
|
||||
- Sony: systems/sony.md
|
||||
- Sord: systems/sord.md
|
||||
- Synertek: systems/synertek.md
|
||||
- Tandy: systems/tandy.md
|
||||
- Tangerine: systems/tangerine.md
|
||||
- Texas Instruments: systems/texas-instruments.md
|
||||
- Thomson: systems/thomson.md
|
||||
- Tiger: systems/tiger.md
|
||||
- TimeTop: systems/timetop.md
|
||||
- Tomy: systems/tomy.md
|
||||
- VideoBrain: systems/videobrain.md
|
||||
- Videoton: systems/videoton.md
|
||||
- Vircon: systems/vircon.md
|
||||
- VTech: systems/vtech.md
|
||||
- Wenquxing: systems/wenquxing.md
|
||||
- ZC: systems/zc.md
|
||||
- Game engines and PC games:
|
||||
- 3D Realms: systems/3d-realms.md
|
||||
- Apogee: systems/apogee.md
|
||||
- Blizzard: systems/blizzard.md
|
||||
- Bungie: systems/bungie.md
|
||||
- Dinothawr: systems/dinothawr.md
|
||||
- Electronic Arts: systems/electronic-arts.md
|
||||
- Epic MegaGames: systems/epic-megagames.md
|
||||
- Game Engines: systems/game-engines.md
|
||||
- Id Software: systems/id-software.md
|
||||
- Interplay: systems/interplay.md
|
||||
- Monolith: systems/monolith.md
|
||||
- RPG Maker: systems/rpg-maker.md
|
||||
- ScummVM: systems/scummvm.md
|
||||
- sdlpal: systems/sdlpal.md
|
||||
- Softdisk: systems/softdisk.md
|
||||
- Toys for Bob: systems/toys-for-bob.md
|
||||
- Valve: systems/valve.md
|
||||
- Emulators:
|
||||
- emulators/index.md
|
||||
- 0 to 9:
|
||||
- 2 Ship 2 Harkinian: emulators/2ship.md
|
||||
- 2600.emu: emulators/2600-emu.md
|
||||
- 3dSen: emulators/3dsen.md
|
||||
- A to C:
|
||||
- A:
|
||||
- a5200: emulators/a5200.md
|
||||
- A7800: emulators/a7800.md
|
||||
- Abuse: emulators/abuse.md
|
||||
- ACE-DL: emulators/ace-dl.md
|
||||
- advanced_drastic: emulators/advanced_drastic.md
|
||||
- AdvanceMAME: emulators/advancemame.md
|
||||
- AdvanceMAME 0.94: emulators/advmame-0.94.md
|
||||
- AdvanceMAME 1.4: emulators/advmame-1.4.md
|
||||
- Adventure Game Studio: emulators/ags.md
|
||||
- AetherSX2: emulators/aethersx2.md
|
||||
- Aleph One: emulators/alephone.md
|
||||
- Altirra: emulators/altirra.md
|
||||
- amiarcadia: emulators/amiarcadia.md
|
||||
- Amiberry: emulators/amiberry.md
|
||||
- AppleWin: emulators/applewin.md
|
||||
- aPS3e: emulators/aps3e.md
|
||||
- ArcadeDuck: emulators/arcadeduck.md
|
||||
- ArcadeFlashWeb: emulators/arcadeflashweb.md
|
||||
- Ardens: emulators/ardens.md
|
||||
- Arduous: emulators/arduous.md
|
||||
- ares: emulators/ares.md
|
||||
- ARMSX1: emulators/armsx1.md
|
||||
- ARMSX2: emulators/armsx2.md
|
||||
- Atari800: emulators/atari800.md
|
||||
- aX360e: emulators/ax360e.md
|
||||
- Azahar: emulators/azahar.md
|
||||
- Azahar (SDL2): emulators/azahar-sdl.md
|
||||
- AzaharPlus: emulators/azaharplus.md
|
||||
- B:
|
||||
- b2: emulators/b2.md
|
||||
- Basilisk II: emulators/basiliskii.md
|
||||
- BBKEmu: emulators/bbkemu.md
|
||||
- beebem: emulators/beebem.md
|
||||
- Beetle GBA (Mednafen): emulators/beetle_gba.md
|
||||
- Beetle Lynx (Mednafen Lynx): emulators/beetle_lynx.md
|
||||
- Beetle NGP (Mednafen Neo Geo Pocket): emulators/beetle_ngp.md
|
||||
- Beetle PC-FX (Mednafen): emulators/beetle_pcfx.md
|
||||
- Beetle PCE (Mednafen PCE): emulators/beetle_pce.md
|
||||
- Beetle PSX (Mednafen PSX): emulators/beetle_psx.md
|
||||
- Beetle VB (Mednafen Virtual Boy): emulators/beetle_vb.md
|
||||
- Beetle WonderSwan (Mednafen WonderSwan): emulators/beetle_wswan.md
|
||||
- beetle_saturn: emulators/beetle_saturn.md
|
||||
- BennuGD: emulators/bennugd.md
|
||||
- BennuGD monolithic (bgdi): emulators/bennugd-monolithic.md
|
||||
- Bermuda Syndrome: emulators/bsyndrome.md
|
||||
- Bermuda Syndrome (bs): emulators/bermuda.md
|
||||
- BigPEmu: emulators/bigpemu.md
|
||||
- BizHawk: emulators/bizhawk.md
|
||||
- bk-emulator: emulators/bk.md
|
||||
- BlastEm: emulators/blastem.md
|
||||
- blueMSX: emulators/bluemsx.md
|
||||
- bnes: emulators/bnes.md
|
||||
- boom3: emulators/boom3.md
|
||||
- Boytacean: emulators/boytacean.md
|
||||
- bsnes: emulators/bsnes.md
|
||||
- bsnes 2014: emulators/bsnes2014.md
|
||||
- bsnes C++98: emulators/bsnes_cplusplus98.md
|
||||
- bsnes-hd beta: emulators/bsnes_hd_beta.md
|
||||
- bsnes-jg: emulators/bsnes-jg.md
|
||||
- bsnes-mercury: emulators/bsnes_mercury.md
|
||||
- BStone: emulators/bstone.md
|
||||
- C:
|
||||
- C-Dogs SDL: emulators/cdogs.md
|
||||
- C64.emu: emulators/c64-emu.md
|
||||
- Cannonball: emulators/cannonball.md
|
||||
- CaPriCe Forever: emulators/capriceforever.md
|
||||
- Caprice32: emulators/cap32.md
|
||||
- CapriceRPI: emulators/capricerpi.md
|
||||
- CatacombGL: emulators/catacombgl.md
|
||||
- ccleste: emulators/ccleste.md
|
||||
- CDi 2015: emulators/cdi2015.md
|
||||
- Cemu: emulators/cemu.md
|
||||
- Chihiro-DS: emulators/chihiro-ds.md
|
||||
- ChimeraSNES: emulators/chimerasnes.md
|
||||
- Chocolate Doom: emulators/chocolate-doom.md
|
||||
- Citra: emulators/citra.md
|
||||
- Citra 2018: emulators/citra2018.md
|
||||
- Citra Canary: emulators/citra_canary.md
|
||||
- Citra MMJ: emulators/citra-mmj.md
|
||||
- Citron: emulators/citron.md
|
||||
- Clock Signal (CLK): emulators/clk.md
|
||||
- ClownMDEmu: emulators/clownmdemu.md
|
||||
- ColEm: emulators/colem.md
|
||||
- Commander Genius: emulators/cgenius.md
|
||||
- CoolbarEmu: emulators/coolbaremu.md
|
||||
- CoolCV: emulators/coolcv.md
|
||||
- corsixth: emulators/corsixth.md
|
||||
- CPCemu: emulators/cpcemu.md
|
||||
- CrocoDS: emulators/crocods.md
|
||||
- Cruzes: emulators/cruzes.md
|
||||
- CSpect: emulators/cspect.md
|
||||
- Cxbx-Reloaded: emulators/cxbx-reloaded.md
|
||||
- Cxbx-Reloaded (31ff15b): emulators/cxbx-reloaded-2023.md
|
||||
- D to F:
|
||||
- D:
|
||||
- D.Smile: emulators/dsmile.md
|
||||
- DaedalusX64: emulators/daedalusx64.md
|
||||
- Daphne: emulators/daphne.md
|
||||
- DarkPlaces: emulators/darkplaces.md
|
||||
- Demul: emulators/demul.md
|
||||
- DeSmuME: emulators/desmume.md
|
||||
- DeSmuME 2015: emulators/desmume2015.md
|
||||
- devilutionx: emulators/devilutionx.md
|
||||
- DGen/SDL: emulators/dgen.md
|
||||
- dhewm3: emulators/dhewm3.md
|
||||
- DICE: emulators/dice.md
|
||||
- DingooEmu: emulators/dingooemu.md
|
||||
- Dinothawr: emulators/dinothawr.md
|
||||
- DirectXBox: emulators/directxbox.md
|
||||
- DirkSimple: emulators/dirksimple.md
|
||||
- Dolphin: emulators/dolphin.md
|
||||
- Dolphin Launcher: emulators/dolphin_launcher.md
|
||||
- Dolphin MMJR: emulators/dolphin-mmjr.md
|
||||
- Dolphin MMJR2: emulators/dolphin-mmjr2.md
|
||||
- DOSBox: emulators/dosbox.md
|
||||
- DOSBox (SDL2 fork): emulators/dosbox-sdl2.md
|
||||
- DOSBox Pure: emulators/dosbox_pure.md
|
||||
- DOSBox Pure Unleashed: emulators/dosbox-pure-unleashed.md
|
||||
- DOSBox Staging: emulators/dosbox-staging.md
|
||||
- DOSBox-core: emulators/dosbox_core.md
|
||||
- DOSBox-SVN: emulators/dosbox_svn.md
|
||||
- DOSBox-SVN CE: emulators/dosbox_svn_ce.md
|
||||
- DOSBox-X: emulators/dosbox-x.md
|
||||
- DoubleCherryGB: emulators/doublecherrygb.md
|
||||
- doukutsu-rs: emulators/doukutsu_rs.md
|
||||
- DraStic: emulators/drastic.md
|
||||
- DREAMM: emulators/dreamm.md
|
||||
- DroidArcadia: emulators/droidarcadia.md
|
||||
- dsda-doom: emulators/dsda-doom.md
|
||||
- DSperate: emulators/dsperate.md
|
||||
- DuckStation: emulators/duckstation.md
|
||||
- DuckStation (legacy): emulators/duckstation-legacy.md
|
||||
- Dusklight: emulators/dusklight.md
|
||||
- dxx-rebirth: emulators/dxx-rebirth.md
|
||||
- E:
|
||||
- EasyRPG Player: emulators/easyrpg.md
|
||||
- ECWolf: emulators/ecwolf.md
|
||||
- Eden: emulators/eden.md
|
||||
- EDuke32: emulators/eduke32.md
|
||||
- EightyOne: emulators/81.md
|
||||
- EightyOne (Recalbox): emulators/eightyone.md
|
||||
- eka2l1: emulators/eka2l1.md
|
||||
- EmuCoreV: emulators/emucorev.md
|
||||
- EmuCoreX: emulators/emucorex.md
|
||||
- EmuCV: emulators/emucv.md
|
||||
- EmuSCV: emulators/emuscv.md
|
||||
- emux (CHIP-8): emulators/emux_chip8.md
|
||||
- emux (Game Boy): emulators/emux_gb.md
|
||||
- emux (NES): emulators/emux_nes.md
|
||||
- emux (SMS): emulators/emux_sms.md
|
||||
- ep128emu-core: emulators/ep128emu.md
|
||||
- ep128emu_core: emulators/ep128emu_core.md
|
||||
- ePSXe: emulators/epsxe.md
|
||||
- ET Legacy: emulators/etlegacy.md
|
||||
- Eternal Computer: emulators/eternal.md
|
||||
- Exel-Hence: emulators/exel-hence.md
|
||||
- F:
|
||||
- FAKE-08: emulators/fake08.md
|
||||
- fallout1-ce: emulators/fallout1-ce.md
|
||||
- fallout2-ce: emulators/fallout2-ce.md
|
||||
- FB Alpha 2012: emulators/fbalpha2012.md
|
||||
- FB Alpha 2012 CPS-1: emulators/fbalpha2012_cps1.md
|
||||
- FB Alpha 2012 CPS-2: emulators/fbalpha2012_cps2.md
|
||||
- FB Alpha 2012 CPS-3: emulators/fbalpha2012_cps3.md
|
||||
- FB Alpha 2012 Neo Geo: emulators/fbalpha2012_neogeo.md
|
||||
- FB Alpha 2019: emulators/fbalpha2019.md
|
||||
- FBZX: emulators/fbzx.md
|
||||
- FCEUmm: emulators/fceumm.md
|
||||
- FCEUmm Mod: emulators/fceumm_mod.md
|
||||
- fheroes2: emulators/fheroes2.md
|
||||
- FinalBurn Neo: emulators/fbneo.md
|
||||
- FinalBurn Neo (CPS-1/CPS-2): emulators/fbneo_cps12.md
|
||||
- FinalBurn Neo (Neo Geo): emulators/fbneo_neogeo.md
|
||||
- fixGB: emulators/fixgb.md
|
||||
- fixNES: emulators/fixnes.md
|
||||
- Flycast: emulators/flycast.md
|
||||
- Flycast 2021: emulators/flycast2021.md
|
||||
- Flycast 2022 Low-End: emulators/flycast2021le.md
|
||||
- Flycast Dojo: emulators/flycast-dojo.md
|
||||
- fMSX: emulators/fmsx.md
|
||||
- FPse: emulators/fpse.md
|
||||
- FPseNG: emulators/fpse-ng.md
|
||||
- FreeChaF: emulators/freechaf.md
|
||||
- FreeIntv: emulators/freeintv.md
|
||||
- FreeJ2ME: emulators/freej2me.md
|
||||
- FreeJ2ME-Plus: emulators/freej2me_plus.md
|
||||
- Frodo: emulators/frodo.md
|
||||
- Frotz: emulators/frotz.md
|
||||
- FS-UAE: emulators/fsuae.md
|
||||
- FS-UAE 3.1: emulators/fs-uae.md
|
||||
- Fuse: emulators/fuse.md
|
||||
- future-pinball: emulators/future-pinball.md
|
||||
- future-pinball-fploader: emulators/future-pinball-fploader.md
|
||||
- G to L:
|
||||
- G:
|
||||
- galaksija: emulators/galaksija.md
|
||||
- GAM4980: emulators/gam4980.md
|
||||
- Gambatte: emulators/gambatte.md
|
||||
- Game & Watch: emulators/gw.md
|
||||
- GameTank: emulators/gametank.md
|
||||
- Gargoyle: emulators/gargoyle.md
|
||||
- GBA.emu: emulators/gba-emu.md
|
||||
- GBC.emu: emulators/gbc-emu.md
|
||||
- Gearboy: emulators/gearboy.md
|
||||
- Gearcoleco: emulators/gearcoleco.md
|
||||
- Geargrafx: emulators/geargrafx.md
|
||||
- Gearlynx: emulators/gearlynx.md
|
||||
- Gearsystem: emulators/gearsystem.md
|
||||
- Geartowns: emulators/geartowns.md
|
||||
- GemRB: emulators/gemrb.md
|
||||
- Genesis Plus GX: emulators/genesis_plus_gx.md
|
||||
- Genesis Plus GX Expanded: emulators/genesis_plus_gx_expanded.md
|
||||
- Genesis Plus GX Wide: emulators/genesis_plus_gx_wide.md
|
||||
- Geolith: emulators/geolith.md
|
||||
- Ghostship: emulators/ghostship.md
|
||||
- GnGeo-Pi: emulators/gngeopi.md
|
||||
- Gopher2600: emulators/gopher2600.md
|
||||
- gopher64: emulators/gopher64.md
|
||||
- gpSP: emulators/gpsp.md
|
||||
- GroovyMAME: emulators/groovymame.md
|
||||
- GSplus: emulators/gsplus.md
|
||||
- GSplus (legacy): emulators/gsplus-legacy.md
|
||||
- GZDoom: emulators/gzdoom.md
|
||||
- H:
|
||||
- hakuX: emulators/hakux.md
|
||||
- Handy: emulators/handy.md
|
||||
- Hatari: emulators/hatari.md
|
||||
- Hatari 2014: emulators/hatari2014.md
|
||||
- hatariB: emulators/hatarib.md
|
||||
- HBMAME (Homebrew MAME): emulators/hbmame.md
|
||||
- higan (SFC Accuracy): emulators/higan_sfc.md
|
||||
- hode: emulators/hode.md
|
||||
- Holani: emulators/holani.md
|
||||
- Hurrican: emulators/hurrican.md
|
||||
- Hydra Castle Labyrinth: emulators/hcl.md
|
||||
- Hypseus Singe: emulators/hypseus-singe.md
|
||||
- I:
|
||||
- idTech4A++: emulators/idtech4a++.md
|
||||
- Ikemen GO: emulators/ikemen.md
|
||||
- iNES: emulators/ines.md
|
||||
- Infinity: emulators/infinity.md
|
||||
- Ion Fury: emulators/fury.md
|
||||
- ioquake3: emulators/ioquake3.md
|
||||
- ioRTCW: emulators/iortcw.md
|
||||
- IrataJaguar: emulators/iratajaguar.md
|
||||
- IroGB: emulators/irogb.md
|
||||
- Ishiiruka: emulators/ishiiruka.md
|
||||
- izapple2: emulators/izapple2.md
|
||||
- J:
|
||||
- J2ME Loader: emulators/j2me-loader.md
|
||||
- JAXE: emulators/jaxe.md
|
||||
- Jazz² Resurrection: emulators/jazz2-native.md
|
||||
- jgenesis: emulators/jgenesis.md
|
||||
- JL-Mod: emulators/jl-mod.md
|
||||
- JollyCV: emulators/jollycv.md
|
||||
- Julius: emulators/julius.md
|
||||
- Jynx: emulators/jynx.md
|
||||
- jzIntv: emulators/jzintv.md
|
||||
- K:
|
||||
- Kega Fusion: emulators/kega-fusion.md
|
||||
- KEGS: emulators/kegs.md
|
||||
- KEmulator nnmod: emulators/kemulator.md
|
||||
- Kenji-NX: emulators/kenji-nx.md
|
||||
- Kronos: emulators/kronos.md
|
||||
- L:
|
||||
- Lexaloffle: emulators/lexaloffle.md
|
||||
- lightspark: emulators/lightspark.md
|
||||
- Lime3DS: emulators/lime3ds.md
|
||||
- LinApple: emulators/linapple.md
|
||||
- Lindbergh Loader: emulators/lindbergh-loader.md
|
||||
- Linkboy: emulators/linkboy.md
|
||||
- Linux Loader: emulators/linuxloader.md
|
||||
- LowRes NX: emulators/lowresnx.md
|
||||
- LRPS2: emulators/lrps2.md
|
||||
- Lynx.emu: emulators/lynx-emu.md
|
||||
- LZDoom: emulators/lzdoom.md
|
||||
- M to O:
|
||||
- M:
|
||||
- M2000: emulators/m2000.md
|
||||
- M64Plus FZ: emulators/m64plus-fz.md
|
||||
- MagicEngine: emulators/magicengine.md
|
||||
- MAME: emulators/mame.md
|
||||
- MAME 0.258: emulators/mame0258.md
|
||||
- MAME 0.278: emulators/mame0278.md
|
||||
- MAME 2000: emulators/mame2000.md
|
||||
- MAME 2003: emulators/mame2003.md
|
||||
- MAME 2003 Midway: emulators/mame2003_midway.md
|
||||
- MAME 2003-Plus: emulators/mame2003_plus.md
|
||||
- MAME 2009: emulators/mame2009.md
|
||||
- MAME 2010: emulators/mame2010.md
|
||||
- MAME 2014: emulators/mame2014.md
|
||||
- MAME 2015: emulators/mame2015.md
|
||||
- MAME 2016: emulators/mame2016.md
|
||||
- MAME Arcade: emulators/mamearcade.md
|
||||
- MAME MESS: emulators/mamemess.md
|
||||
- MAME4ALL-Pi: emulators/mame4all.md
|
||||
- MAME4droid (0.139u1): emulators/mame4droid.md
|
||||
- MAME4droid 2024: emulators/mame4droid-2024.md
|
||||
- Mandarine: emulators/mandarine.md
|
||||
- Mariani: emulators/mariani.md
|
||||
- MasterGear: emulators/mastergear.md
|
||||
- MD.emu: emulators/md-emu.md
|
||||
- Mednafen: emulators/mednafen.md
|
||||
- Mednafen STV: emulators/mednafen_stv.md
|
||||
- mednafen_gba: emulators/mednafen_gba.md
|
||||
- mednafen_pce: emulators/mednafen_pce.md
|
||||
- mednafen_pce_fast: emulators/mednafen_pce_fast.md
|
||||
- mednafen_snes: emulators/mednafen_snes.md
|
||||
- mednafen_supafaust: emulators/mednafen_supafaust.md
|
||||
- mednafen_supergrafx: emulators/mednafen_supergrafx.md
|
||||
- melonDS: emulators/melonds.md
|
||||
- melonDS DS: emulators/melonds_ds.md
|
||||
- melonDS Nightly: emulators/melonds-nightly.md
|
||||
- Mesen: emulators/mesen.md
|
||||
- mesen-s: emulators/mesen-s.md
|
||||
- MesenCE: emulators/mesence.md
|
||||
- MESS 2015: emulators/mess2015.md
|
||||
- Meteor GBA: emulators/meteor.md
|
||||
- MFME: emulators/mfme.md
|
||||
- mGBA: emulators/mgba.md
|
||||
- mGBA Splitscreen: emulators/mgba_splitscreen.md
|
||||
- Mini vMac: emulators/minivmac.md
|
||||
- Mini vMac 37: emulators/minivmac37.md
|
||||
- mkxp-z: emulators/mkxp_z.md
|
||||
- Model 2 Emulator: emulators/model2.md
|
||||
- MojoZork: emulators/mojozork.md
|
||||
- MSX.emu: emulators/msx-emu.md
|
||||
- Mu: emulators/mu.md
|
||||
- Mupen64Plus: emulators/mupen64plus.md
|
||||
- Mupen64Plus (libretro, 2019): emulators/mupen64plus-libretro.md
|
||||
- Mupen64Plus AE: emulators/mupen64plus-ae.md
|
||||
- Mupen64Plus-Next: emulators/mupen64plus_next.md
|
||||
- My Boy!: emulators/my-boy.md
|
||||
- My OldBoy!: emulators/my-oldboy.md
|
||||
- N:
|
||||
- NanoBoyAdvance: emulators/nanoboyadvance.md
|
||||
- Native32Emu: emulators/native32emu.md
|
||||
- nekop2: emulators/nekop2.md
|
||||
- NEO.emu: emulators/neo-emu.md
|
||||
- NeoCD: emulators/neocd.md
|
||||
- nes: emulators/nes.md
|
||||
- NES.emu: emulators/nes-emu.md
|
||||
- Nesoid: emulators/nesoid.md
|
||||
- Nestopia UE: emulators/nestopia.md
|
||||
- NetherSX2: emulators/nethersx2.md
|
||||
- NetherSX2-Turnip: emulators/nethersx2-turnip.md
|
||||
- NetherSX2-Turnip Classic: emulators/nethersx2-turnip-classic.md
|
||||
- NGP.emu: emulators/ngp-emu.md
|
||||
- NicaiEmu: emulators/nicaiemu.md
|
||||
- no$gba: emulators/nosgba.md
|
||||
- NooDS: emulators/noods.md
|
||||
- NP2kai: emulators/np2kai.md
|
||||
- np2pi: emulators/np2pi.md
|
||||
- nSide (SFC Balanced): emulators/higan_sfc_balanced.md
|
||||
- Nuance: emulators/nuance.md
|
||||
- Numero: emulators/numero.md
|
||||
- NXEngine: emulators/nxengine.md
|
||||
- O:
|
||||
- O2EM: emulators/o2em.md
|
||||
- Oberon: emulators/oberon.md
|
||||
- Omicron: emulators/omicron.md
|
||||
- ONScripter: emulators/onscripter.md
|
||||
- ONScripter Yuri: emulators/onsyuri.md
|
||||
- openbor: emulators/openbor.md
|
||||
- OpenGOAL: emulators/opengoal.md
|
||||
- OpenJazz: emulators/openjazz.md
|
||||
- openjk: emulators/openjk.md
|
||||
- OpenJKDF2: emulators/openjkdf2.md
|
||||
- OpenLara: emulators/openlara.md
|
||||
- OpenMoHAA: emulators/openmohaa.md
|
||||
- openMSX: emulators/openmsx.md
|
||||
- OpenTTD: emulators/openttd.md
|
||||
- OpenTyrian: emulators/opentyrian.md
|
||||
- Opera (4DO): emulators/opera.md
|
||||
- Oricutron: emulators/oricutron.md
|
||||
- Osmose (RetroPie): emulators/osmose.md
|
||||
- P to R:
|
||||
- P:
|
||||
- P/EMU/SDL: emulators/piemu.md
|
||||
- Panda3DS: emulators/panda3ds.md
|
||||
- Parallel Launcher: emulators/parallel-launcher.md
|
||||
- ParaLLEl N64: emulators/parallel_n64.md
|
||||
- pce-emu: emulators/pce-emu.md
|
||||
- PCEE2: emulators/pcee2.md
|
||||
- PCem: emulators/pcem.md
|
||||
- PCSX-ReARMed: emulators/pcsx_rearmed.md
|
||||
- PCSX-ReARMed (standalone): emulators/pcsx_rearmed_standalone.md
|
||||
- PCSX1: emulators/pcsx1.md
|
||||
- PCSX2: emulators/pcsx2.md
|
||||
- PCSX2 Legacy: emulators/pcsx2-legacy.md
|
||||
- PCSX2x6: emulators/pcsx2x6.md
|
||||
- PD777: emulators/pd777.md
|
||||
- Perfect Dark (PC port): emulators/pdark.md
|
||||
- Phoenix: emulators/phoenix.md
|
||||
- Picocomputer 6502: emulators/rp6502.md
|
||||
- PicoDrive: emulators/picodrive.md
|
||||
- piece-emu: emulators/piece.md
|
||||
- piFBA (fba2x): emulators/fba2x.md
|
||||
- PiSNES: emulators/pisnes.md
|
||||
- Pizza Boy GBA: emulators/pizza-boy-gba.md
|
||||
- Pizza Boy GBC: emulators/pizza-boy-gbc.md
|
||||
- Pizza Boy SC: emulators/pizza-boy-sc.md
|
||||
- Plastic: emulators/plastic.md
|
||||
- play: emulators/play.md
|
||||
- PlaydiaEmu: emulators/playdiaemu.md
|
||||
- PokeMini: emulators/pokemini.md
|
||||
- pokketstation: emulators/pokketstation.md
|
||||
- POM2: emulators/pom2.md
|
||||
- Potator: emulators/potator.md
|
||||
- PPSSPP: emulators/ppsspp.md
|
||||
- PPSSPP 1.5.4: emulators/ppsspp-1.5.4.md
|
||||
- PrBoom: emulators/prboom.md
|
||||
- PrBoom+: emulators/prboom-plus.md
|
||||
- PrimeHack: emulators/primehack.md
|
||||
- project64: emulators/project64.md
|
||||
- ProSystem: emulators/prosystem.md
|
||||
- pSxMAME: emulators/psxmame.md
|
||||
- PUAE (P-UAE): emulators/puae.md
|
||||
- PUAE 2021: emulators/puae2021.md
|
||||
- puNES: emulators/punes.md
|
||||
- px68k: emulators/px68k.md
|
||||
- px68k (SDL): emulators/px68k-sdl.md
|
||||
- Q:
|
||||
- QEMU: emulators/qemu.md
|
||||
- Quake 3 (Raspberry Pi): emulators/quake3.md
|
||||
- QUASI88: emulators/quasi88.md
|
||||
- QUASI88kai: emulators/quasi88kai.md
|
||||
- QuickNES: emulators/quicknes.md
|
||||
- R:
|
||||
- RACE (Neo Geo Pocket): emulators/race.md
|
||||
- radio: emulators/radio.md
|
||||
- RAINE: emulators/raine.md
|
||||
- Raze: emulators/raze.md
|
||||
- RB5000: emulators/rb5000.md
|
||||
- Real3DOPlayer: emulators/real3doplayer.md
|
||||
- RealRTCW: emulators/realrtcw.md
|
||||
- Redream: emulators/redream.md
|
||||
- Reicast: emulators/reicast.md
|
||||
- REminiscence: emulators/reminiscence.md
|
||||
- Retro8: emulators/retro8.md
|
||||
- RetroDream: emulators/retrodream.md
|
||||
- RigelEngine: emulators/rigelengine.md
|
||||
- Rosalie's Mupen GUI: emulators/rosalies-mupen-gui.md
|
||||
- RPCS3: emulators/rpcs3.md
|
||||
- rpix86: emulators/rpix86.md
|
||||
- ruffle: emulators/ruffle.md
|
||||
- Rust-DOS: emulators/rust_dos.md
|
||||
- Rustation: emulators/rustation.md
|
||||
- RustyNES: emulators/rustynes.md
|
||||
- RVVM: emulators/rvvm.md
|
||||
- Ryujinx: emulators/ryujinx.md
|
||||
- S to U:
|
||||
- S:
|
||||
- SAME CDi: emulators/same_cdi.md
|
||||
- SameBoy: emulators/sameboy.md
|
||||
- sameduck: emulators/sameduck.md
|
||||
- Saturn.emu: emulators/saturn-emu.md
|
||||
- ScummVM: emulators/scummvm.md
|
||||
- ScummVM (SDL1): emulators/scummvm-sdl1.md
|
||||
- SDL2TRS: emulators/sdl2trs.md
|
||||
- SDLPAL: emulators/sdlpal.md
|
||||
- sdlpop: emulators/sdlpop.md
|
||||
- shadps4: emulators/shadps4.md
|
||||
- SheepShaver: emulators/sheepshaver.md
|
||||
- Ship of Harkinian: emulators/soh.md
|
||||
- SimCoupe: emulators/simcp.md
|
||||
- SimCoupe (standalone): emulators/samcoupe.md
|
||||
- simple64: emulators/simple64.md
|
||||
- Singe 2: emulators/singe2.md
|
||||
- sixtyforce: emulators/sixtyforce.md
|
||||
- skyemu: emulators/skyemu.md
|
||||
- Skyline: emulators/skyline.md
|
||||
- SMS Plus GX: emulators/smsplus.md
|
||||
- snes9x: emulators/snes9x.md
|
||||
- Snes9x 2002: emulators/snes9x2002.md
|
||||
- Snes9x 2005: emulators/snes9x2005.md
|
||||
- Snes9x 2005 Plus: emulators/snes9x2005_plus.md
|
||||
- Snes9x 2010: emulators/snes9x2010.md
|
||||
- Snes9x EX+: emulators/snes9x-explus.md
|
||||
- solarus: emulators/solarus.md
|
||||
- Solarus 2.0.0: emulators/solarus-2.0.0.md
|
||||
- Sonic 1 & 2 (2013) Decompilation: emulators/sonic2013.md
|
||||
- Sonic 3 A.I.R.: emulators/sonic3-air.md
|
||||
- Sonic CD Decompilation: emulators/soniccd.md
|
||||
- Sonic Mania Decompilation: emulators/sonic-mania.md
|
||||
- Sonic Robo Blast 2: emulators/srb2.md
|
||||
- Speccy: emulators/speccy.md
|
||||
- SplitWolf: emulators/splitwolf.md
|
||||
- SPMP8000Emu: emulators/spmp8000emu.md
|
||||
- SquirrelJME: emulators/squirreljme.md
|
||||
- SSF: emulators/ssf.md
|
||||
- Starboard: emulators/starboard.md
|
||||
- Starship: emulators/starship.md
|
||||
- Stella: emulators/stella.md
|
||||
- Stella 2014: emulators/stella2014.md
|
||||
- Stella 2023: emulators/stella2023.md
|
||||
- Sudachi: emulators/sudachi.md
|
||||
- Sugarbox2: emulators/sugarbox2.md
|
||||
- Super Mario War: emulators/supermariowar.md
|
||||
- SUPER ZSNES: emulators/superzsnes.md
|
||||
- SUPER3: emulators/super3.md
|
||||
- Supermodel: emulators/supermodel.md
|
||||
- Supermodel (Sinden fork): emulators/supermodel3.md
|
||||
- Supermodel Dojo: emulators/supermodel-dojo.md
|
||||
- Supermodel Legacy: emulators/supermodel-legacy.md
|
||||
- SuperSnes9x: emulators/supersnes9x.md
|
||||
- SuperTux: emulators/supertux.md
|
||||
- SuperTuxKart: emulators/supertuxkart.md
|
||||
- Suyu: emulators/suyu.md
|
||||
- Swan.emu: emulators/swan-emu.md
|
||||
- swanstation: emulators/swanstation.md
|
||||
- SWF Player: emulators/swf-player.md
|
||||
- Syobon Action: emulators/syobonaction.md
|
||||
- T:
|
||||
- TamaLIBretro: emulators/tamalibretro.md
|
||||
- Taradino: emulators/taradino.md
|
||||
- TeknoParrot: emulators/teknoparrot.md
|
||||
- TempGBA: emulators/tempgba.md
|
||||
- TGB Dual: emulators/tgbdual.md
|
||||
- The Force Engine: emulators/theforceengine.md
|
||||
- Theodore: emulators/theodore.md
|
||||
- TheXTech: emulators/thextech.md
|
||||
- ti99sim: emulators/ti99sim.md
|
||||
- ti99sim-sdl1: emulators/ti99sim-sdl1.md
|
||||
- Tia: emulators/tia.md
|
||||
- TIC-80: emulators/tic80.md
|
||||
- touchHLE: emulators/touchhle.md
|
||||
- TR1X: emulators/tr1x.md
|
||||
- TR2X: emulators/tr2x.md
|
||||
- trident: emulators/trident.md
|
||||
- Triforce: emulators/triforce.md
|
||||
- tsugaru: emulators/tsugaru.md
|
||||
- TyrQuake: emulators/tyrquake.md
|
||||
- U:
|
||||
- UAE4ALL (libretro, Chips-fr): emulators/uae4all-rpi.md
|
||||
- UAE4All2: emulators/uae4all.md
|
||||
- uae4arm: emulators/uae4arm.md
|
||||
- UME 2015: emulators/ume2015.md
|
||||
- uqm: emulators/uqm.md
|
||||
- UZDoom: emulators/uzdoom.md
|
||||
- uzem: emulators/uzem.md
|
||||
- V to Z:
|
||||
- V:
|
||||
- VaporSpec: emulators/vaporspec.md
|
||||
- VBA-M: emulators/vbam.md
|
||||
- VBA-Next: emulators/vba_next.md
|
||||
- vecx: emulators/vecx.md
|
||||
- veesem: emulators/veesem.md
|
||||
- VeMUlator: emulators/vemulator.md
|
||||
- VICE: emulators/vice.md
|
||||
- VICE x128: emulators/vice_x128.md
|
||||
- VICE x64: emulators/vice_x64.md
|
||||
- VICE x64dtv: emulators/vice_x64dtv.md
|
||||
- VICE x64sc: emulators/vice_x64sc.md
|
||||
- VICE xcbm2: emulators/vice_xcbm2.md
|
||||
- VICE xcbm5x0: emulators/vice_xcbm5x0.md
|
||||
- VICE xpet: emulators/vice_xpet.md
|
||||
- VICE xplus4: emulators/vice_xplus4.md
|
||||
- VICE xscpu64: emulators/vice_xscpu64.md
|
||||
- VICE xvic: emulators/vice_xvic.md
|
||||
- Vircon32: emulators/vircon32.md
|
||||
- Virtual Jaguar: emulators/virtualjaguar.md
|
||||
- Virtual Virtual Boy: emulators/virtual-virtual-boy.md
|
||||
- VirtualXT: emulators/virtualxt.md
|
||||
- visual-pinball: emulators/visual-pinball.md
|
||||
- Vita3K: emulators/vita3k.md
|
||||
- vitaQuakeII: emulators/vitaquake2.md
|
||||
- vitaQuakeIII: emulators/vitaquake3.md
|
||||
- vitavoyager: emulators/vitavoyager.md
|
||||
- vkQuake: emulators/vkquake.md
|
||||
- vkQuake2: emulators/vkquake2.md
|
||||
- vkQuake3: emulators/vkquake3.md
|
||||
- VVVVVV: emulators/vvvvvv.md
|
||||
- W:
|
||||
- WinUAE: emulators/winuae.md
|
||||
- Wolf4SDL: emulators/wolf4sdl.md
|
||||
- WQXEmu: emulators/wqxemu.md
|
||||
- X:
|
||||
- X Millennium: emulators/x1.md
|
||||
- X1 BOX: emulators/x1-box.md
|
||||
- x16emu: emulators/x16emu.md
|
||||
- x64sdl: emulators/x64sdl.md
|
||||
- Xash3D FWGS: emulators/xash3d_fwgs.md
|
||||
- xemu: emulators/xemu.md
|
||||
- xemu Chihiro: emulators/xemu-chihiro.md
|
||||
- XenDroid: emulators/xendroid.md
|
||||
- Xenia Canary: emulators/xenia.md
|
||||
- Xenia Edge: emulators/xenia-edge.md
|
||||
- XeniOS: emulators/xenios.md
|
||||
- xm6pro68k: emulators/xm6pro68k.md
|
||||
- xm6typeg: emulators/xm6typeg.md
|
||||
- XM7: emulators/xm7.md
|
||||
- XRick: emulators/xrick.md
|
||||
- XRoar: emulators/xroar.md
|
||||
- Y:
|
||||
- yabasanshiro: emulators/yabasanshiro.md
|
||||
- yabause: emulators/yabause.md
|
||||
- Yamagi Quake II: emulators/yquake2.md
|
||||
- yaps2: emulators/yaps2.md
|
||||
- ymir: emulators/ymir.md
|
||||
- Yuzu: emulators/yuzu.md
|
||||
- Z:
|
||||
- Zelda Classic 2.55 Prototype: emulators/zc255.md
|
||||
- Zelda Classic v2.10: emulators/zc210.md
|
||||
- ZEsarUX: emulators/zesarux.md
|
||||
- ZiNc: emulators/zinc.md
|
||||
- ZQuest Classic: emulators/zquestclassic.md
|
||||
- All installer options: wiki/installer.md
|
||||
- Is it legal?: wiki/legal.md
|
||||
- Reference:
|
||||
- wiki/index.md
|
||||
- How each platform checks files: wiki/verification-modes.md
|
||||
- What the packs do not hold: gaps.md
|
||||
- Cores by platform: cross-reference.md
|
||||
- Dump provenance: provenance.md
|
||||
- Updated platform files: exports.md
|
||||
- Data and API: data.md
|
||||
- Find a file: files/index.md
|
||||
- Data model: wiki/data-model.md
|
||||
- Architecture: wiki/architecture.md
|
||||
- Build your own packs: wiki/advanced-usage.md
|
||||
- Command-line tools: wiki/tools.md
|
||||
- Technical questions: wiki/technical-faq.md
|
||||
- Project terms: wiki/reference-terms.md
|
||||
- Projects using this data: wiki/community-tools.md
|
||||
- Contribute:
|
||||
- contributing.md
|
||||
- Profiling an emulator: wiki/profiling.md
|
||||
- Adding a platform: wiki/adding-a-platform.md
|
||||
- Adding a scraper: wiki/adding-a-scraper.md
|
||||
- Testing: wiki/testing-guide.md
|
||||
- Release process: wiki/release-process.md
|
||||
+2
-11
@@ -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"
|
||||
@@ -219,7 +219,7 @@ def hold_artifact_lock(directory: str) -> Iterator[None]:
|
||||
|
||||
|
||||
# What a pipeline run writes at fixed paths, whatever its --output-dir.
|
||||
REPOSITORY_OUTPUTS = ("database.json", "install/", "README.md", "CONTRIBUTING.md", "docs/")
|
||||
REPOSITORY_OUTPUTS = ("database.json", "install/", "README.md", "CONTRIBUTING.md", "website/")
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
||||
+16
-11
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate slim README.md from database.json and platform configs.
|
||||
|
||||
Detailed documentation lives on the MkDocs site (abdess.github.io/retrobios/).
|
||||
Detailed documentation lives on the site (abdess.github.io/retrobios/).
|
||||
This script produces a concise landing page with download links and coverage.
|
||||
|
||||
Usage:
|
||||
@@ -230,14 +230,16 @@ def release_totals(platform_name: str, record: dict) -> tuple[int | None, int |
|
||||
|
||||
|
||||
def download_links(
|
||||
platform_name: str, record: dict, repo: str, label: str, attrs: str = ""
|
||||
platform_name: str, record: dict, repo: str, label: str, attrs: str = "",
|
||||
button: Callable[[str, str], str] | None = None,
|
||||
) -> str:
|
||||
"""Markdown links to the files of a platform's pack in the recorded release.
|
||||
|
||||
A pack published whole gets one link labelled *label*. A pack in parts
|
||||
gets one link per part, since every part is needed. *repo* is the
|
||||
repository URL, absolute or relative, and *attrs* a MkDocs attribute list
|
||||
put after each link. Without a recorded release the link is the latest
|
||||
repository URL, absolute or relative, and *attrs* an attribute list
|
||||
put after each link, or *button* the function that draws each link as a
|
||||
button on the site. Without a recorded release the link is the latest
|
||||
release page. A release recorded without this platform's pack says so and
|
||||
names the installer, which reads the file lists on main: a button to the
|
||||
release served six platforms a page with no pack for them.
|
||||
@@ -245,18 +247,22 @@ def download_links(
|
||||
pack = release_record.pack_for(platform_name, record)
|
||||
tag = record.get("tag")
|
||||
assets = pack.get("assets") if pack else None
|
||||
def link(text: str, url: str) -> str:
|
||||
return button(text, url) if button else f"[{text}]({url}){attrs}"
|
||||
|
||||
if not tag:
|
||||
return f"[{label}]({repo}/releases/latest){attrs}"
|
||||
return link(label, f"{repo}/releases/latest")
|
||||
if not assets:
|
||||
return f"Not in {tag}: [use the installer]({SITE_URL}wiki/installer/)"
|
||||
urls = [f"{repo}/releases/download/{tag}/{asset}" for asset in assets]
|
||||
if len(urls) == 1:
|
||||
return f"[{label}]({urls[0]}){attrs}"
|
||||
return link(label, urls[0])
|
||||
styled = bool(attrs or button)
|
||||
parts = [
|
||||
f"[{'P' if attrs else 'p'}art {number} of {len(urls)}]({url}){attrs}"
|
||||
link(f"{'P' if styled else 'p'}art {number} of {len(urls)}", url)
|
||||
for number, url in enumerate(urls, 1)
|
||||
]
|
||||
if attrs:
|
||||
if styled:
|
||||
return " ".join(parts)
|
||||
return f"{label}: " + ", ".join(parts)
|
||||
|
||||
@@ -831,7 +837,7 @@ def contributing_text(
|
||||
|
||||
Two copies drifted apart: four steps to add a platform here, five on the
|
||||
site. *link* renders a site page (absolute URL for the README, relative
|
||||
.md for MkDocs); *site_words* names the site from where the text is read.
|
||||
.md for the site); *site_words* names the site from where the text is read.
|
||||
"""
|
||||
return f"""{title}
|
||||
|
||||
@@ -894,8 +900,7 @@ validation above is its check. For code, profiles or platform files, these are
|
||||
the checks CI runs on a pull request (Python 3.12 or newer):
|
||||
|
||||
```bash
|
||||
pip install pyyaml "jsonschema[format-nongpl]" markdown \\
|
||||
"mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
|
||||
pip install pyyaml "jsonschema[format-nongpl]" markdown
|
||||
python scripts/validate_pr.py --changed # the BIOS files the branch adds
|
||||
python scripts/validate_schemas.py --source-only # profiles and platform files
|
||||
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
|
||||
|
||||
+435
-526
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ from xml.sax.saxutils import escape
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import sitemarkup as markup # noqa: E402
|
||||
|
||||
PLACEHOLDER = re.compile(r"(?m)^([ \t]*)<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->[ \t]*$")
|
||||
PLACEHOLDER_ANYWHERE = re.compile(r"<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->")
|
||||
|
||||
@@ -605,12 +607,12 @@ def figure_html(image_id: str, occurrence: int = 1, inline: bool = False) -> str
|
||||
anchor = f"rb-fig-{image_id}{suffix}"
|
||||
if inline:
|
||||
html = (
|
||||
f'<span class="rb-figure rb-figure-inline" id="{anchor}" data-search-exclude>'
|
||||
f'<span class="rb-figure rb-figure-inline" id="{anchor}" {markup.search_exclude()}>'
|
||||
f'{drawings}<span class="rb-figure-caption">{caption}</span></span>'
|
||||
)
|
||||
return html.translate(_INLINE_ESCAPES)
|
||||
return (
|
||||
f'<figure class="rb-figure" id="{anchor}" data-search-exclude>\n'
|
||||
f'<figure class="rb-figure" id="{anchor}" {markup.search_exclude()}>\n'
|
||||
f"{drawings}\n"
|
||||
f"<figcaption>{caption}</figcaption>\n"
|
||||
"</figure>"
|
||||
|
||||
+25
-34
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import sitemarkup as markup
|
||||
|
||||
EXPORTS_DIR = "exports"
|
||||
REPO_BLOB = "https://github.com/Abdess/retrobios/blob/main"
|
||||
REPO_TREE = "https://github.com/Abdess/retrobios/tree/main"
|
||||
@@ -73,12 +75,15 @@ def requirement_lines(report: dict) -> list[str]:
|
||||
]
|
||||
if not items:
|
||||
return []
|
||||
return [
|
||||
f'??? note "Required flags aligned with the code ({len(items)})"',
|
||||
"",
|
||||
*[f" - {item}" for item in items],
|
||||
"",
|
||||
]
|
||||
return _lookup_only(markup.details(
|
||||
"note", f"Required flags aligned with the code ({len(items)})",
|
||||
[f"- {item}" for item in items]))
|
||||
|
||||
|
||||
def _lookup_only(block: list[str]) -> list[str]:
|
||||
"""A list of entries changed, out of the site search: 129 "md5" in them
|
||||
put this page above the one that explains MD5 checks."""
|
||||
return ["", markup.block_open("section", markup.search_exclude()), "", *block, "", "</section>", ""]
|
||||
|
||||
|
||||
def _plural(count: int, noun: str, plural: str = "") -> str:
|
||||
@@ -141,12 +146,8 @@ def candidate_lines(candidates: list[dict]) -> list[str]:
|
||||
title = (reason[:1].upper() + reason[1:]).replace('"', "'")
|
||||
items = [f"`{c['system']}` `{c['destination']}`" for c in entries]
|
||||
if len(entries) > LONG_LIST:
|
||||
lines.extend([
|
||||
f'??? note "{title} ({len(entries)})"',
|
||||
"",
|
||||
*[f" - {item}" for item in items],
|
||||
"",
|
||||
])
|
||||
lines.extend(_lookup_only(markup.details(
|
||||
"note", f"{title} ({len(entries)})", [f"- {item}" for item in items])))
|
||||
else:
|
||||
lines.extend([f"{title}:", "", *[f"- {item}" for item in items], ""])
|
||||
return lines
|
||||
@@ -263,35 +264,29 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str:
|
||||
])
|
||||
lines.extend(candidate_lines(report.get("removal_candidates") or []))
|
||||
if report.get("invalid"):
|
||||
lines.extend([
|
||||
'!!! warning "The updated file does not pass the platform\'s own checks"',
|
||||
"",
|
||||
*[f" - {issue}" for issue in report["invalid"]],
|
||||
"",
|
||||
])
|
||||
lines.extend(markup.aside(
|
||||
"warning", "The updated file does not pass the platform's own checks",
|
||||
[f"- {issue}" for issue in report["invalid"]]))
|
||||
|
||||
originals = report.get("originals") or {}
|
||||
files = report["files"]
|
||||
raw = [f"{REPO_RAW}/{EXPORTS_DIR}/{platform}/{name}" for name in files]
|
||||
if len(files) == 1:
|
||||
lines.extend([
|
||||
f"[Download the updated file]({raw[0]}){{ .md-button .md-button--primary }} "
|
||||
f"[See it on GitHub]({REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}){{ .md-button }}",
|
||||
markup.button("Download the updated file", raw[0], primary=True) + " "
|
||||
+ markup.button("See it on GitHub", f"{REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}"),
|
||||
"",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
f"[Browse the {len(files)} updated files]"
|
||||
f"({REPO_TREE}/{EXPORTS_DIR}/{platform}){{ .md-button .md-button--primary }}",
|
||||
markup.button(f"Browse the {len(files)} updated files",
|
||||
f"{REPO_TREE}/{EXPORTS_DIR}/{platform}", primary=True),
|
||||
"",
|
||||
])
|
||||
if len(originals) > 1:
|
||||
lines.extend([
|
||||
f'??? note "The {len(originals)} original files it is based on"',
|
||||
"",
|
||||
*[f" - [`{name}`]({url})" for name, url in sorted(originals.items())],
|
||||
"",
|
||||
])
|
||||
lines.extend(markup.details(
|
||||
"note", f"The {len(originals)} original files it is based on",
|
||||
[f"- [`{name}`]({url})" for name, url in sorted(originals.items())]))
|
||||
elif originals:
|
||||
(name, url), = originals.items()
|
||||
lines.extend([f"Based on [`{name}`]({url}).", ""])
|
||||
@@ -301,10 +296,6 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str:
|
||||
items = change_items(report, key)
|
||||
if not items:
|
||||
continue
|
||||
lines.extend([
|
||||
f'??? note "{title} ({len(items)})"',
|
||||
"",
|
||||
*[f" - {item}" for item in items],
|
||||
"",
|
||||
])
|
||||
lines.extend(_lookup_only(markup.details(
|
||||
"note", f"{title} ({len(items)})", [f"- {item}" for item in items])))
|
||||
return "\n".join(lines)
|
||||
+9
-14
@@ -15,6 +15,7 @@ import textwrap
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
|
||||
import sitemarkup as markup
|
||||
from safeparse import require_yaml
|
||||
|
||||
PLATFORMS_DIR = Path(__file__).resolve().parent.parent / "platforms"
|
||||
@@ -263,15 +264,12 @@ def terminal_tabs(
|
||||
"""Content tabs, one per place the command runs, each with how to open
|
||||
the terminal there. Tab labels are shared across the site, so the reader's
|
||||
choice carries from page to page."""
|
||||
lines: list[str] = []
|
||||
items = []
|
||||
for key in tabs:
|
||||
label, how, shell = TERMINALS[key]
|
||||
lines += [f'{indent}=== "{label}"', ""]
|
||||
if steps:
|
||||
lines += [f"{indent} {how}", ""]
|
||||
lines += _fence(shell, install_command(shell, args.get(key, "")), indent + " ")
|
||||
lines.append("")
|
||||
return lines
|
||||
body = [how, ""] if steps else []
|
||||
items.append((label, body + _fence(shell, install_command(shell, args.get(key, "")), "")))
|
||||
return markup.tabs(items, indent)
|
||||
|
||||
|
||||
def platform_args(name: str, guide: Guide) -> dict[str, str]:
|
||||
@@ -290,11 +288,8 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]:
|
||||
|
||||
def trust_box(prefix: str, indent: str = "") -> list[str]:
|
||||
"""Is it safe, under every command on a guide page: five plain facts."""
|
||||
return [
|
||||
f'{indent}??? note "Is it safe?"',
|
||||
return markup.details("note", "Is it safe?", [
|
||||
*(f"- {item}" for item in TRUST_ITEMS),
|
||||
"",
|
||||
*(f"{indent} - {item}" for item in TRUST_ITEMS),
|
||||
"",
|
||||
f"{indent} [Read the script first]({prefix}wiki/faq.md#is-piping-the-installer-into-a-shell-safe)",
|
||||
"",
|
||||
]
|
||||
f"[Read the script first]({prefix}wiki/faq.md#is-piping-the-installer-into-a-shell-safe)",
|
||||
], indent)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""The page constructs the generator writes, in the syntax of the site engine.
|
||||
|
||||
Every block that is not plain Markdown (a callout, a collapsible block, OS
|
||||
tabs, a button, a card grid) is written through one of these functions, so
|
||||
the generator holds no engine syntax of its own. Bodies are passed without
|
||||
indentation: the function indents them as the engine reads them.
|
||||
|
||||
The site is built with Astro Starlight. The MkDocs Material syntax the site
|
||||
was first written in stays as one thing only: the yardstick of the size
|
||||
budgets that split long pages (system pages, platform sections). Those
|
||||
budgets were set on that Markdown, and measuring it keeps every page cut
|
||||
at the same file, so no card changes address.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
ENGINES = ("mkdocs", "starlight")
|
||||
_engine = "starlight"
|
||||
|
||||
# Material's callout kinds, by the role Starlight gives them.
|
||||
ASIDE_ROLES = {
|
||||
"note": "note", "info": "note", "abstract": "note", "example": "note",
|
||||
"question": "note", "quote": "note", "tip": "tip", "success": "tip",
|
||||
"warning": "caution", "failure": "danger", "danger": "danger", "bug": "danger",
|
||||
}
|
||||
|
||||
# The attribute that keeps an element out of the site search.
|
||||
SEARCH_EXCLUDE = {"mkdocs": "data-search-exclude", "starlight": "data-pagefind-ignore"}
|
||||
|
||||
|
||||
def set_engine(name: str) -> None:
|
||||
global _engine
|
||||
if name not in ENGINES:
|
||||
raise ValueError(f"unknown site engine {name!r}, expected one of {ENGINES}")
|
||||
_engine = name
|
||||
|
||||
|
||||
def engine() -> str:
|
||||
return _engine
|
||||
|
||||
|
||||
@contextmanager
|
||||
def using(name: str) -> Iterator[None]:
|
||||
"""Write in another syntax for a while: a page split by size is
|
||||
measured in the MkDocs Markdown its budgets were set on."""
|
||||
previous = _engine
|
||||
set_engine(name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
set_engine(previous)
|
||||
|
||||
|
||||
def starlight() -> bool:
|
||||
return _engine == "starlight"
|
||||
|
||||
|
||||
def search_exclude() -> str:
|
||||
return SEARCH_EXCLUDE[_engine]
|
||||
|
||||
|
||||
def inline_html(text: str) -> str:
|
||||
"""Inline Markdown (code, bold, links) for text placed inside raw HTML,
|
||||
where Markdown is not read."""
|
||||
parts = re.split(r"(`[^`]+`)", text)
|
||||
out = []
|
||||
for part in parts:
|
||||
if part.startswith("`") and part.endswith("`") and len(part) > 1:
|
||||
out.append(f"<code>{html.escape(part[1:-1], quote=False)}</code>")
|
||||
continue
|
||||
part = html.escape(part, quote=False)
|
||||
part = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", part)
|
||||
part = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", r'<a href="\2">\1</a>', part)
|
||||
out.append(part)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _indented(lines: list[str], pad: str) -> list[str]:
|
||||
return [f"{pad}{line}" if line.strip() else "" for line in lines]
|
||||
|
||||
|
||||
def _trim(lines: list[str]) -> list[str]:
|
||||
start, end = 0, len(lines)
|
||||
while start < end and not lines[start].strip():
|
||||
start += 1
|
||||
while end > start and not lines[end - 1].strip():
|
||||
end -= 1
|
||||
return list(lines[start:end])
|
||||
|
||||
|
||||
def aside(kind: str, title: str, body: list[str], indent: str = "") -> list[str]:
|
||||
"""A callout that is always open."""
|
||||
body = _trim(body)
|
||||
if _engine == "mkdocs":
|
||||
return [f'{indent}!!! {kind} "{title}"', "", *_indented(body, indent + " "), ""]
|
||||
label = title.replace("[", "\\[").replace("]", "\\]")
|
||||
fence = "::::" if any(line.lstrip().startswith(":::") for line in body) else ":::"
|
||||
return [f"{indent}{fence}{ASIDE_ROLES.get(kind, 'note')}[{label}]",
|
||||
*_indented(body, indent), f"{indent}{fence}", ""]
|
||||
|
||||
|
||||
def details(kind: str, title: str, body: list[str], indent: str = "", opened: bool = False) -> list[str]:
|
||||
"""A collapsible block, closed unless *opened*."""
|
||||
body = _trim(body)
|
||||
if _engine == "mkdocs":
|
||||
marker = "???+" if opened else "???"
|
||||
return [f'{indent}{marker} {kind} "{title}"', "", *_indented(body, indent + " "), ""]
|
||||
role = ASIDE_ROLES.get(kind, "note")
|
||||
state = " open" if opened else ""
|
||||
return [
|
||||
f'{indent}<details class="rb-details rb-details-{role}"{state}>',
|
||||
f"{indent}<summary>{inline_html(title)}</summary>",
|
||||
"",
|
||||
*_indented(body, indent),
|
||||
"",
|
||||
f"{indent}</details>",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def tabs(items: list[tuple[str, list[str]]], indent: str = "", sync: str = "os") -> list[str]:
|
||||
"""Content tabs. Tabs that share a label share the reader's choice
|
||||
across pages."""
|
||||
lines: list[str] = []
|
||||
if _engine == "mkdocs":
|
||||
for label, body in items:
|
||||
lines += [f'{indent}=== "{label}"', "", *_indented(_trim(body), indent + " "), ""]
|
||||
return lines
|
||||
lines.append(f'{indent}<Tabs syncKey="{sync}">')
|
||||
for label, body in items:
|
||||
lines += [f'{indent}<TabItem label="{html.escape(label)}">', "",
|
||||
*_indented(_trim(body), indent), "", f"{indent}</TabItem>"]
|
||||
return [*lines, f"{indent}</Tabs>", ""]
|
||||
|
||||
|
||||
def button(text: str, target: str, primary: bool = False) -> str:
|
||||
"""A link drawn as a button."""
|
||||
if _engine == "mkdocs":
|
||||
classes = ".md-button .md-button--primary" if primary else ".md-button"
|
||||
return f"[{text}]({target}){{ {classes} }}"
|
||||
classes = "rb-button rb-button-primary" if primary else "rb-button"
|
||||
return f'<a class="{classes}" href="{html.escape(target)}">{inline_html(text)}</a>'
|
||||
|
||||
|
||||
def image(alt: str, src: str, **attributes: str) -> str:
|
||||
"""An image with HTML attributes (width, align)."""
|
||||
if _engine == "mkdocs":
|
||||
attrs = " ".join(f"{key}={value}" for key, value in attributes.items())
|
||||
return f"{{ {attrs} }}"
|
||||
attrs = "".join(f' {key}="{html.escape(str(value))}"' for key, value in attributes.items())
|
||||
return f'<img src="{html.escape(src)}" alt="{html.escape(alt)}"{attrs}>'
|
||||
|
||||
|
||||
def hash_value(value: object) -> str:
|
||||
"""A hash printed whole, in the hash style. One element under Starlight:
|
||||
a span around a code span was 1,400 extra elements on a page of cards."""
|
||||
text = str(value).replace("`", "'")
|
||||
if _engine == "mkdocs":
|
||||
return f'<span class="rb-hash">`{text}`</span>'
|
||||
return f'<code class="rb-hash">{html.escape(text, quote=False)}</code>'
|
||||
|
||||
|
||||
def block_open(tag: str, attributes: str = "") -> str:
|
||||
"""The opening tag of an HTML block whose content is Markdown."""
|
||||
attributes = f" {attributes.strip()}" if attributes.strip() else ""
|
||||
if _engine == "mkdocs":
|
||||
return f"<{tag}{attributes} markdown>"
|
||||
return f"<{tag}{attributes}>"
|
||||
|
||||
|
||||
def card_grid(cards: list[tuple[str, list[str]]]) -> list[str]:
|
||||
"""Cards in a grid: a bold title line and a body each."""
|
||||
lines = [block_open("div", 'class="grid cards"' if _engine == "mkdocs" else 'class="rb-cards"'), ""]
|
||||
for title, body in cards:
|
||||
if _engine == "mkdocs":
|
||||
lines += [f"- **{title}**", "", *_indented(_trim(body), " "), ""]
|
||||
else:
|
||||
lines += ['<div class="rb-card">', "", f"**{title}**", "", *_trim(body), "", "</div>", ""]
|
||||
return [*lines, "</div>", ""]
|
||||
|
||||
|
||||
def steps(items: list[str]) -> list[str]:
|
||||
"""An ordered list drawn as numbered steps."""
|
||||
return [block_open("div", 'class="rb-steps"'), "",
|
||||
*(f"{number}. {item}" for number, item in enumerate(items, 1)), "", "</div>", ""]
|
||||
|
||||
|
||||
def heading_attributes(anchor: str = "", classes: str = "", search: bool = True) -> str:
|
||||
"""The attribute block of a heading: its id, classes, and whether the
|
||||
search reads it."""
|
||||
parts = [f"#{anchor}" if anchor else "", *(f".{c}" for c in classes.split())]
|
||||
if not search:
|
||||
parts.append(search_exclude())
|
||||
parts = [part for part in parts if part]
|
||||
return f" {{ {' '.join(parts)} }}" if parts else ""
|
||||
|
||||
|
||||
def site_href(path: str) -> str:
|
||||
"""A link to a site address written in raw HTML (``files/?sha1=...``),
|
||||
which no Markdown resolution reaches: rooted at the site under Starlight,
|
||||
relative to a page two levels deep under MkDocs."""
|
||||
if _engine == "mkdocs":
|
||||
return f"../../{path}"
|
||||
return f"/retrobios/{path}"
|
||||
@@ -7,6 +7,8 @@ import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import sitemarkup as markup
|
||||
|
||||
|
||||
WIKI_SRC_DIR = "wiki" # manually maintained wiki sources
|
||||
SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png"
|
||||
@@ -89,7 +91,7 @@ def system_icon_markdown(manufacturer: str, console_name: str) -> str:
|
||||
name = _icon_name(manufacturer, console_name)
|
||||
if not _icon_available.get(name):
|
||||
return ""
|
||||
return f"}){{ width=24 }} "
|
||||
return markup.image(console_name, _icon_url(name), width="24") + " "
|
||||
|
||||
# Global index: maps system_id -> (manufacturer_slug, console_name) for cross-linking
|
||||
_system_page_map: dict[str, tuple[str, str]] = {}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
"""The site as an Astro Starlight project: pages, sidebar and data files.
|
||||
|
||||
generate_site.py writes each page body, and this module gives it the form
|
||||
Starlight reads. A page's links are written as the docs tree holds them
|
||||
(``../systems/sony.md``, MkDocs' convention, the one a Markdown editor
|
||||
follows), and the site serves them as addresses under /retrobios/, so
|
||||
each target is resolved against the page's own place here. Every heading
|
||||
keeps the id the MkDocs site gave it, so the anchors cited since stay
|
||||
valid.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from collections.abc import Callable, Iterator
|
||||
from html import unescape
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
BASE = "/retrobios/"
|
||||
SITE_URL = "https://abdess.github.io/retrobios/"
|
||||
|
||||
# Components a page body may use, and where each comes from.
|
||||
COMPONENTS = {
|
||||
"Tabs": "@astrojs/starlight/components",
|
||||
"TabItem": "@astrojs/starlight/components",
|
||||
"FilePicker": "components/FilePicker.astro",
|
||||
}
|
||||
|
||||
# Pages of file cards: level 3 headings by the hundred, so the table of
|
||||
# contents stops at level 2.
|
||||
CARD_PAGES = ("systems/", "emulators/", "platforms/")
|
||||
|
||||
FENCE = re.compile(r"(^[ \t]*(`{3,}|~{3,}).*?^[ \t]*\2[ \t]*$)", re.M | re.S)
|
||||
CODE_SPAN = re.compile(r"(`+)(?!`)(.+?)(?<!`)\1(?!`)", re.S)
|
||||
MD_LINK = re.compile(r"(?P<bang>!?)\[(?P<text>(?:[^\[\]\n]|\[[^\]\n]*\])*)\]\((?P<url>[^)\s]+)(?P<title> \"[^\"]*\")?\)")
|
||||
HTML_URL = re.compile(r"(?P<attr>\b(?:href|src))=\"(?P<url>[^\"]*)\"")
|
||||
ATTR_BLOCK = re.compile(r"[ \t]*\{(?P<attrs>[^{}]*)\}[ \t]*$")
|
||||
ID_ATTR = re.compile(r"\bid=\"(?P<id>[^\"]+)\"")
|
||||
IDCOUNT = re.compile(r"^(.*)_([0-9]+)$")
|
||||
|
||||
|
||||
# Addresses
|
||||
|
||||
|
||||
def page_url(rel: str) -> str:
|
||||
"""The address of a page, by its path under the docs tree."""
|
||||
stem = rel.rsplit(".", 1)[0] if rel.endswith((".md", ".mdx")) else rel
|
||||
if stem == "index":
|
||||
return BASE
|
||||
if stem.endswith("/index"):
|
||||
stem = stem[: -len("/index")]
|
||||
return f"{BASE}{stem}/"
|
||||
|
||||
|
||||
def resolve(rel_page: str, target: str) -> str:
|
||||
"""A link target written relative to the page's file, as a site address."""
|
||||
if not target or re.match(r"^(?:[a-z][a-z0-9+.-]*:|#|/)", target):
|
||||
return target
|
||||
path, hash_mark, fragment = target.partition("#")
|
||||
path, question, query = path.partition("?")
|
||||
parts: list[str] = []
|
||||
for piece in (PurePosixPath(rel_page).parent / path).as_posix().split("/"):
|
||||
if piece in ("", "."):
|
||||
continue
|
||||
if piece == "..":
|
||||
if parts:
|
||||
parts.pop()
|
||||
else:
|
||||
parts.append(piece)
|
||||
joined = "/".join(parts)
|
||||
if joined.endswith((".md", ".mdx")):
|
||||
url = page_url(joined)
|
||||
elif path.endswith("/") or not path:
|
||||
url = f"{BASE}{joined}/" if joined else BASE
|
||||
else:
|
||||
url = f"{BASE}{joined}"
|
||||
if question:
|
||||
url += f"?{query}"
|
||||
return url + (f"#{fragment}" if hash_mark else "")
|
||||
|
||||
|
||||
# Text outside code
|
||||
|
||||
|
||||
def outside_fences(text: str, fn: Callable[[str], str]) -> str:
|
||||
parts = FENCE.split(text)
|
||||
# FENCE has two groups: text, fence, marker, text, ...
|
||||
out = []
|
||||
for index in range(0, len(parts), 3):
|
||||
out.append(fn(parts[index]))
|
||||
if index + 1 < len(parts):
|
||||
out.append(parts[index + 1])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def outside_code(text: str, fn: Callable[[str], str]) -> str:
|
||||
def chunk(part: str) -> str:
|
||||
out, position = [], 0
|
||||
for match in CODE_SPAN.finditer(part):
|
||||
out.append(fn(part[position:match.start()]))
|
||||
out.append(match.group(0))
|
||||
position = match.end()
|
||||
out.append(fn(part[position:]))
|
||||
return "".join(out)
|
||||
return outside_fences(text, chunk)
|
||||
|
||||
|
||||
def rewrite_links(text: str, rel: str) -> str:
|
||||
def markdown(match: re.Match) -> str:
|
||||
return (f"{match['bang']}[{match['text']}]({resolve(rel, match['url'])}"
|
||||
f"{match['title'] or ''})")
|
||||
|
||||
def attribute(match: re.Match) -> str:
|
||||
return f'{match["attr"]}="{resolve(rel, match["url"])}"'
|
||||
|
||||
text = outside_fences(text, lambda chunk: MD_LINK.sub(markdown, chunk))
|
||||
return outside_fences(text, lambda chunk: HTML_URL.sub(attribute, chunk))
|
||||
|
||||
|
||||
# Heading ids, as Python-Markdown's toc extension gives them
|
||||
|
||||
|
||||
def toc_slug(text: str) -> str:
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)(\{[^}]*\})?", "", text)
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = unescape(text.replace("`", "").replace("**", "").replace("\\", ""))
|
||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
|
||||
text = re.sub(r"[^\w\s-]", "", text).strip().lower()
|
||||
return re.sub(r"[-\s]+", "-", text)
|
||||
|
||||
|
||||
def _unique(ident: str, used: set[str]) -> str:
|
||||
while ident in used or not ident:
|
||||
match = IDCOUNT.match(ident)
|
||||
ident = f"{match[1]}_{int(match[2]) + 1}" if match else f"{ident}_1"
|
||||
used.add(ident)
|
||||
return ident
|
||||
|
||||
|
||||
def pin_heading_ids(text: str, title_id: str = "") -> str:
|
||||
"""Every heading with the id the MkDocs build gave it. *title_id* is
|
||||
the page title's, taken first."""
|
||||
used: set[str] = {title_id} if title_id else set()
|
||||
explicit = set()
|
||||
for match in re.finditer(r"\{[^{}\n]*#([^\s}]+)[^{}\n]*\}[ \t]*$", text, re.M):
|
||||
explicit.add(match[1])
|
||||
for match in ID_ATTR.finditer(text):
|
||||
explicit.add(match["id"])
|
||||
used |= explicit
|
||||
|
||||
def markdown(match: re.Match) -> str:
|
||||
heading = match["mdtext"]
|
||||
attrs = ATTR_BLOCK.search(heading)
|
||||
body = heading[: attrs.start()] if attrs else heading
|
||||
tokens = attrs["attrs"].split() if attrs else []
|
||||
if any(token.startswith("#") for token in tokens):
|
||||
return match.group(0)
|
||||
ident = _unique(toc_slug(body), used)
|
||||
return f"{match['hashes']} {body.rstrip()} {{ {' '.join(['#' + ident, *tokens])} }}"
|
||||
|
||||
def raw(match: re.Match) -> str:
|
||||
if ID_ATTR.search(match["attrs"]):
|
||||
return match.group(0)
|
||||
level, text = match["level"], match["htmltext"]
|
||||
ident = _unique(toc_slug(text), used)
|
||||
return f'<h{level} id="{ident}"{match["attrs"]}>{text}</h{level}>'
|
||||
|
||||
def pick(match: re.Match) -> str:
|
||||
return markdown(match) if match["hashes"] else raw(match)
|
||||
|
||||
# In document order: one pass over either kind of heading.
|
||||
return outside_fences(text, lambda chunk: _ANY_HEADING.sub(pick, chunk))
|
||||
|
||||
|
||||
_ANY_HEADING = re.compile(
|
||||
r"^(?P<hashes>#{2,6})[ \t]+(?P<mdtext>.+?)[ \t]*$"
|
||||
r"|<h(?P<level>[2-6])(?P<attrs>(?:\s[^>]*)?)>(?P<htmltext>.*?)</h(?P=level)>",
|
||||
re.M | re.S,
|
||||
)
|
||||
|
||||
|
||||
# Glossary tooltips
|
||||
|
||||
|
||||
def glossary_terms(path: Path) -> dict[str, str]:
|
||||
"""Each term the glossary defines, with its definition as plain text."""
|
||||
terms: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return terms
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for match in re.finditer(r"^(?P<term>\*\*.+)\n: (?P<body>.+(?:\n .+)*)", text, re.M):
|
||||
definition = re.sub(r"\s+", " ", match["body"]).strip()
|
||||
definition = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", definition).replace("`", "")
|
||||
for term in re.findall(r"\*\*([^*]+)\*\*", match["term"]):
|
||||
terms.setdefault(term, definition)
|
||||
return terms
|
||||
|
||||
|
||||
_SKIP_TAGS = re.compile(r"<(a|code|pre|abbr|h[1-6]|script|style|summary|svg|figure)\b.*?</\1>", re.S | re.I)
|
||||
|
||||
|
||||
def add_tooltips(text: str, terms: dict[str, str]) -> str:
|
||||
"""The first use of each glossary term on a page, with its definition
|
||||
a hover or a tap away. Headings, links, code and HTML are left as they are."""
|
||||
if not terms:
|
||||
return text
|
||||
pending = dict(terms)
|
||||
pattern = re.compile(
|
||||
r"(?<![\w/.-])(" + "|".join(re.escape(t) for t in sorted(terms, key=len, reverse=True))
|
||||
+ r")(?![\w/-])"
|
||||
)
|
||||
|
||||
def prose(chunk: str) -> str:
|
||||
if not pending:
|
||||
return chunk
|
||||
out = []
|
||||
for line in chunk.split("\n"):
|
||||
stripped = line.lstrip()
|
||||
if (not pending or stripped.startswith(("#", "|", "<", ":::", "[", "!"))
|
||||
or not stripped):
|
||||
out.append(line)
|
||||
continue
|
||||
out.append(_tooltip_line(line, pattern, pending))
|
||||
return "\n".join(out)
|
||||
|
||||
return outside_code(text, prose)
|
||||
|
||||
|
||||
def _tooltip_line(line: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
|
||||
# Inline HTML, links and emphasis markers stay whole.
|
||||
protected = re.compile(r"<[^>]+>|\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|\{[^}]*\}")
|
||||
out, position = [], 0
|
||||
for match in protected.finditer(line):
|
||||
out.append(_tooltip_text(line[position:match.start()], pattern, pending))
|
||||
out.append(match.group(0))
|
||||
position = match.end()
|
||||
out.append(_tooltip_text(line[position:], pattern, pending))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _tooltip_text(text: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
|
||||
def swap(match: re.Match) -> str:
|
||||
term = match.group(1)
|
||||
if term not in pending:
|
||||
return term
|
||||
definition = pending.pop(term)
|
||||
return f'<abbr class="rb-term" title="{html.escape(definition)}">{term}</abbr>'
|
||||
return pattern.sub(swap, text)
|
||||
|
||||
|
||||
# MDX
|
||||
|
||||
|
||||
_VOID = re.compile(r"<(br|img|input|hr|source|meta|link|wbr)\b([^>]*?)(?<!/)>")
|
||||
_HEADING_ATTRS = re.compile(r"(?m)(?<=\S)[ \t]+\{[ \t]*#[^{}\n]*\}[ \t]*$")
|
||||
_COMMENT = re.compile(r"<!--.*?-->", re.S)
|
||||
|
||||
|
||||
def mdx_safe(text: str) -> str:
|
||||
"""What MDX would read as code in prose: braces, a stray ``<``,
|
||||
comments, unclosed void tags and ``<https://...>`` links."""
|
||||
def fix(chunk: str) -> str:
|
||||
chunk = _COMMENT.sub("", chunk)
|
||||
chunk = re.sub(r"<(https?://[^>\s]+)>", r"[\1](\1)", chunk)
|
||||
held: list[str] = []
|
||||
|
||||
def hold(match: re.Match) -> str:
|
||||
held.append(match.group(0))
|
||||
return f"\x00{len(held) - 1}\x00"
|
||||
chunk = _HEADING_ATTRS.sub(hold, chunk)
|
||||
chunk = chunk.replace("{", "{").replace("}", "}")
|
||||
chunk = re.sub(r"\x00(\d+)\x00", lambda m: held[int(m[1])], chunk)
|
||||
chunk = re.sub(r"<(?![A-Za-z/])", "<", chunk)
|
||||
return _VOID.sub(lambda m: f"<{m[1]}{m[2].rstrip()} />", chunk)
|
||||
return outside_code(text, fix)
|
||||
|
||||
|
||||
def used_components(body: str) -> list[str]:
|
||||
return [name for name in COMPONENTS if re.search(rf"<{name}[\s/>]", body)]
|
||||
|
||||
|
||||
# Pages
|
||||
|
||||
|
||||
def _front_matter(data: dict) -> str:
|
||||
import yaml
|
||||
|
||||
return "---\n" + yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=1000) + "---\n\n"
|
||||
|
||||
|
||||
def og_section(rel: str, level: str) -> str:
|
||||
"""The sharing card a page takes: its level, or what its address says."""
|
||||
url = page_url(rel)[len(BASE):]
|
||||
if not url:
|
||||
return "home"
|
||||
if level in ("start", "setup", "explore", "reference", "contribute"):
|
||||
return level
|
||||
if url.startswith(("which-pack", "wiki/first-install", "wiki/faq", "wiki/getting-started")):
|
||||
return "start"
|
||||
if url.startswith("platforms"):
|
||||
return "setup"
|
||||
if url.startswith(("systems", "emulators", "files")):
|
||||
return "explore"
|
||||
if url.startswith(("contributing", "wiki/adding", "wiki/profiling", "wiki/testing", "wiki/release")):
|
||||
return "contribute"
|
||||
return "reference"
|
||||
|
||||
|
||||
def page_head(rel: str, level: str, title: str, browser_title: str, structured: dict) -> list[dict]:
|
||||
"""What a page adds to its head: its own window title, the sharing
|
||||
card of its section, and its structured data."""
|
||||
section = og_section(rel, level)
|
||||
image = f"{SITE_URL}assets/og/{section}.png"
|
||||
head: list[dict] = []
|
||||
if browser_title != title:
|
||||
head.append({"tag": "title", "content": f"{browser_title} - RetroBIOS"})
|
||||
head += [
|
||||
{"tag": "meta", "attrs": {"property": "og:image", "content": image}},
|
||||
{"tag": "meta", "attrs": {"property": "og:image:width", "content": "1200"}},
|
||||
{"tag": "meta", "attrs": {"property": "og:image:height", "content": "630"}},
|
||||
{"tag": "meta", "attrs": {"property": "og:image:alt", "content": f"RetroBIOS: {browser_title}"}},
|
||||
{"tag": "meta", "attrs": {"name": "twitter:image", "content": image}},
|
||||
{"tag": "script", "attrs": {"type": "application/ld+json"},
|
||||
"content": json.dumps(structured, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")},
|
||||
]
|
||||
return head
|
||||
|
||||
|
||||
def split_title(markdown: str) -> tuple[str, str, str]:
|
||||
"""The page's H1 as plain text, the id MkDocs gave it, and the body
|
||||
without it."""
|
||||
match = re.search(r"^# (.+?)[ \t]*$\n?", markdown, re.M)
|
||||
if not match:
|
||||
return "", "", markdown
|
||||
explicit = re.search(r"\{[^}]*#([^\s}]+)[^}]*\}\s*$", match[1])
|
||||
title = re.sub(r"\s*\{[^}]*\}\s*$", "", match[1])
|
||||
ident = explicit[1] if explicit else toc_slug(title)
|
||||
title = re.sub(r"<[^>]+>", "", title)
|
||||
title = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", title)
|
||||
title = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", title).replace("`", "")
|
||||
return unescape(title).strip(), ident, markdown[: match.start()] + markdown[match.end():]
|
||||
|
||||
|
||||
_CARD_NAME = re.compile(r'<h3[^>]*class="rb-file-title"(?![^>]*data-pagefind-ignore)[^>]*><code>([^<]+)</code>')
|
||||
|
||||
|
||||
def file_names_meta(body: str) -> str:
|
||||
"""The names of the files a system page holds cards for, as a search
|
||||
field of the page. A page of 250 cards is long, and the search weighs a
|
||||
long page down: the Sony page, where scph5501.bin is downloaded, came
|
||||
after every short page citing the name. The field is weighted in the
|
||||
site's ranking (astro.config.mjs, metaWeights.files)."""
|
||||
names = sorted({unescape(name) for name in _CARD_NAME.findall(body)})
|
||||
if not names:
|
||||
return ""
|
||||
return f'\n<span data-pagefind-meta="files" hidden>{html.escape(" ".join(names))}</span>\n'
|
||||
|
||||
|
||||
def finalize_page(
|
||||
rel: str,
|
||||
markdown: str,
|
||||
*,
|
||||
level: str,
|
||||
weight: float | None,
|
||||
browser_title: str,
|
||||
description: str,
|
||||
structured: dict,
|
||||
terms: dict[str, str],
|
||||
search: bool = True,
|
||||
) -> tuple[str, str]:
|
||||
"""The Starlight file for a page body: its path under the content
|
||||
collection and its text."""
|
||||
title, title_id, body = split_title(markdown)
|
||||
body = body.lstrip("\n")
|
||||
body = rewrite_links(body, rel)
|
||||
body = pin_heading_ids(body, title_id)
|
||||
if title_id:
|
||||
# The title is the theme's, with an id of its own: the one MkDocs
|
||||
# gave it stays an anchor at the top of the text.
|
||||
body = f'<div id="{html.escape(title_id)}" class="rb-anchor"></div>\n\n' + body
|
||||
if rel not in ("wiki/glossary.md", "wiki/reference-terms.md"):
|
||||
body = add_tooltips(body, terms)
|
||||
# A badge is a status (required, missing, MD5), not a word a reader
|
||||
# searches for: 141 "missing" badges put an emulator page above the
|
||||
# troubleshooting page for "missing bios". The archive a file sits in
|
||||
# is a name, and stays: it is how "neogeo.zip" finds the cores reading it.
|
||||
body = re.sub(r'<span class="(rb-badge(?![^"]*rb-badge-archive)[^"]*)"',
|
||||
r'<span data-pagefind-ignore class="\1"', body)
|
||||
if rel.startswith("systems/"):
|
||||
body += file_names_meta(body)
|
||||
components = used_components(body)
|
||||
stem = rel.rsplit(".", 1)[0]
|
||||
if components:
|
||||
body = mdx_safe(body)
|
||||
imports: dict[str, list[str]] = {}
|
||||
for name in components:
|
||||
imports.setdefault(COMPONENTS[name], []).append(name)
|
||||
# Our own components are imported from src/, two levels above the
|
||||
# content collection plus the page's own folders.
|
||||
up = "../" * (rel.count("/") + 2)
|
||||
body = "".join(f"import {{ {', '.join(names)} }} from '{source}';\n"
|
||||
if source.startswith("@") else f"import {names[0]} from '{up}{source}';\n"
|
||||
for source, names in imports.items()) + "\n" + body
|
||||
front: dict = {"title": title or browser_title, "description": description}
|
||||
# Starlight's slugger drops dots and plus signs: the address stays MkDocs'.
|
||||
if re.search(r"[^a-z0-9_/-]", stem):
|
||||
front["slug"] = stem
|
||||
if level:
|
||||
front["level"] = level
|
||||
if weight:
|
||||
front["searchWeight"] = weight
|
||||
if rel.startswith(CARD_PAGES):
|
||||
sections = len(re.findall(r"^## ", body, re.M))
|
||||
# A page of cards under one or two headings has nothing to list, and
|
||||
# the table of contents watches every block of the page to follow
|
||||
# the reader: 2 x 430 ms of a phone's main thread on 400 cards.
|
||||
front["tableOfContents"] = (
|
||||
{"minHeadingLevel": 2, "maxHeadingLevel": 2} if sections > 2 else False)
|
||||
if not search:
|
||||
front["pagefind"] = False
|
||||
front["head"] = page_head(rel, level, front["title"], browser_title, structured)
|
||||
if structured.get("dateModified"):
|
||||
front["lastmod"] = str(structured["dateModified"])
|
||||
return stem + (".mdx" if components else ".md"), _front_matter(front) + body
|
||||
|
||||
|
||||
# Navigation
|
||||
|
||||
|
||||
def _sidebar_link(rel: str) -> str:
|
||||
return page_url(rel)[len(BASE) - 1:]
|
||||
|
||||
|
||||
def sidebar(nav: list, present: set[str]) -> dict:
|
||||
"""The navigation as Starlight topics, one per tab.
|
||||
|
||||
A page the navigation does not list (the parts of a split page) joins
|
||||
the topic that holds most pages of its directory.
|
||||
"""
|
||||
owner: dict[str, str] = {}
|
||||
|
||||
def item(entry: object, topic: str) -> dict | None:
|
||||
if isinstance(entry, str):
|
||||
owner[entry] = topic
|
||||
return {"label": "Overview", "link": _sidebar_link(entry)} if entry in present else None
|
||||
(label, value), = entry.items()
|
||||
if isinstance(value, str):
|
||||
if value.startswith("http"):
|
||||
return {"label": label, "link": value}
|
||||
owner[value] = topic
|
||||
return {"label": label, "link": _sidebar_link(value)} if value in present else None
|
||||
children = [child for child in (item(v, topic) for v in value) if child]
|
||||
return {"label": label, "items": children, "collapsed": True} if children else None
|
||||
|
||||
topics = []
|
||||
for tab in nav:
|
||||
(label, value), = tab.items()
|
||||
topic = re.sub(r"[^a-z]+", "-", label.lower()).strip("-")
|
||||
children = [child for child in (item(v, topic) for v in value) if child]
|
||||
if children:
|
||||
topics.append({"id": topic, "label": label, "link": next(_links(children))["link"],
|
||||
"items": children})
|
||||
|
||||
votes: dict[str, dict[str, int]] = {}
|
||||
for rel, topic in owner.items():
|
||||
tally = votes.setdefault(rel.rpartition("/")[0], {})
|
||||
tally[topic] = tally.get(topic, 0) + 1
|
||||
extra: dict[str, list[str]] = {}
|
||||
# The page a stale link lands on belongs to no tab.
|
||||
exclude: list[str] = ["/404"]
|
||||
for rel in sorted(present - owner.keys()):
|
||||
tally = votes.get(rel.rpartition("/")[0])
|
||||
path = "/" + (_sidebar_link(rel).strip("/") or "index")
|
||||
if tally:
|
||||
extra.setdefault(max(tally, key=tally.get), []).append(path)
|
||||
else:
|
||||
exclude.append(path)
|
||||
return {"topics": topics, "options": {"topics": extra, "exclude": exclude}}
|
||||
|
||||
|
||||
def _links(items: list[dict]) -> Iterator[dict]:
|
||||
for entry in items:
|
||||
if "link" in entry:
|
||||
yield entry
|
||||
else:
|
||||
yield from _links(entry["items"])
|
||||
|
||||
|
||||
# The picker's data
|
||||
|
||||
|
||||
def picker_data(install: Path, out: Path) -> int:
|
||||
"""Each manifest, compacted to what the file picker reads: per file its
|
||||
destination, size and systems, the systems install.py --system accepts.
|
||||
"""
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
index = []
|
||||
for manifest in sorted(install.glob("*.json")):
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or "files" not in data or "platform" not in data:
|
||||
continue
|
||||
systems = sorted({s for f in data["files"] for s in f.get("systems") or []})
|
||||
position = {s: k for k, s in enumerate(systems)}
|
||||
names = data.get("system_names") or {}
|
||||
payload = {
|
||||
"platform": data["platform"],
|
||||
"name": data.get("display_name", data["platform"]),
|
||||
"folder": (data.get("detect") or [{}])[0].get("bios_path", ""),
|
||||
"generated": data.get("generated", ""),
|
||||
"systems": [{"id": s, "native": names.get(s, [])} for s in systems],
|
||||
"other": any(not f.get("systems") for f in data["files"]),
|
||||
"files": [[f["dest"], f.get("size", 0), [position[s] for s in f.get("systems") or []]]
|
||||
for f in sorted(data["files"], key=lambda f: f["dest"])],
|
||||
}
|
||||
(out / f"{data['platform']}.json").write_text(
|
||||
json.dumps(payload, separators=(",", ":")), encoding="utf-8")
|
||||
index.append({"platform": data["platform"], "name": payload["name"],
|
||||
"files": len(payload["files"])})
|
||||
index.sort(key=lambda entry: entry["name"].casefold())
|
||||
(out / "index.json").write_text(json.dumps(index, separators=(",", ":")), encoding="utf-8")
|
||||
return len(index)
|
||||
|
||||
|
||||
def quote_url(url: str) -> str:
|
||||
return urllib.parse.quote(url, safe="/:#?=&%")
|
||||
@@ -439,11 +439,11 @@ def main() -> int:
|
||||
if release_path.exists():
|
||||
errors.extend(_validate_json_files([release_path], "release.schema.json"))
|
||||
|
||||
stats_path = ROOT / "docs" / "stats.json"
|
||||
stats_path = ROOT / "website" / "public" / "stats.json"
|
||||
if stats_path.exists():
|
||||
errors.extend(_validate_json_files([stats_path], "stats.schema.json"))
|
||||
|
||||
api_dir = ROOT / "docs" / "api" / "v1"
|
||||
api_dir = ROOT / "website" / "public" / "api" / "v1"
|
||||
api_database = api_dir / "database.json"
|
||||
if api_database.exists():
|
||||
errors.extend(
|
||||
|
||||
+30
-41
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the built RetroBIOS site as a navigable, accessible data product.
|
||||
|
||||
This complements ``mkdocs build --strict`` with checks on the rendered HTML:
|
||||
metadata, heading structure, image alternatives, duplicate IDs, structured
|
||||
data, local resources, links and fragments.
|
||||
This complements Starlight's own link check (starlight-links-validator,
|
||||
which fails the build) with checks on the rendered HTML: metadata,
|
||||
heading structure, image alternatives, duplicate IDs, structured data, local
|
||||
resources, links and fragments, and the budgets of what a visitor downloads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,10 +50,12 @@ class Page:
|
||||
# The largest page was 10.5 MB (systems/game-engines/) and took 21 s of
|
||||
# main-thread work on a throttled phone. Split pages now top out at 0.9 MB.
|
||||
MAX_PAGE_BYTES = 1_000_000
|
||||
# The search index is downloaded by every visitor who opens the search:
|
||||
# 21.0 MB raw and 4.88 MB gzip with hashes in it, 7.3 MB and 1.44 MB without.
|
||||
MAX_SEARCH_INDEX_BYTES = 8_500_000
|
||||
MAX_SEARCH_INDEX_GZIP_BYTES = 1_700_000
|
||||
# Pagefind loads its index in pieces: the metadata, then the chunks the words
|
||||
# typed fall in (stored compressed). 59 KB for the largest chunk and 7.5 KB of
|
||||
# metadata on 2026-10-22, 5.2 MB for the whole index on disk.
|
||||
MAX_PAGEFIND_CHUNK_BYTES = 150_000
|
||||
MAX_PAGEFIND_META_BYTES = 50_000
|
||||
MAX_PAGEFIND_TOTAL_BYTES = 12_000_000
|
||||
|
||||
|
||||
# Text inside these is shown as written, so markup there is not a leftover.
|
||||
@@ -167,37 +170,24 @@ def _heading_jumps(levels: list[int]) -> list[str]:
|
||||
if after > before + 1]
|
||||
|
||||
|
||||
def _check_search_index(site: Path) -> list[str]:
|
||||
"""The index every visitor who opens the search downloads, against its budget."""
|
||||
import gzip
|
||||
|
||||
path = site / "search" / "search_index.json"
|
||||
if not path.is_file():
|
||||
return []
|
||||
raw = path.read_bytes()
|
||||
packed = len(gzip.compress(raw, 6))
|
||||
def _check_pagefind(site: Path) -> list[str]:
|
||||
"""Pagefind's index pieces, each against its budget."""
|
||||
root = site / "pagefind"
|
||||
files = [path for path in root.rglob("*") if path.is_file()]
|
||||
issues = []
|
||||
if len(raw) > MAX_SEARCH_INDEX_BYTES:
|
||||
issues.append(f"search/search_index.json: {len(raw):,} bytes, "
|
||||
f"over the {MAX_SEARCH_INDEX_BYTES:,} budget")
|
||||
if packed > MAX_SEARCH_INDEX_GZIP_BYTES:
|
||||
issues.append(f"search/search_index.json: {packed:,} bytes gzip, "
|
||||
f"over the {MAX_SEARCH_INDEX_GZIP_BYTES:,} budget")
|
||||
for path in files:
|
||||
size = path.stat().st_size
|
||||
rel = path.relative_to(site).as_posix()
|
||||
if path.suffix == ".pf_index" and size > MAX_PAGEFIND_CHUNK_BYTES:
|
||||
issues.append(f"{rel}: {size:,} bytes, over the {MAX_PAGEFIND_CHUNK_BYTES:,} budget of a chunk")
|
||||
if path.suffix == ".pf_meta" and size > MAX_PAGEFIND_META_BYTES:
|
||||
issues.append(f"{rel}: {size:,} bytes, over the {MAX_PAGEFIND_META_BYTES:,} budget")
|
||||
total = sum(path.stat().st_size for path in files)
|
||||
if total > MAX_PAGEFIND_TOTAL_BYTES:
|
||||
issues.append(f"pagefind/: {total:,} bytes, over the {MAX_PAGEFIND_TOTAL_BYTES:,} budget")
|
||||
return issues
|
||||
|
||||
|
||||
def _base_path(config_path: Path) -> str:
|
||||
"""Read site_url without constructing executable YAML extension tags."""
|
||||
config = config_path.read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
r"(?m)^site_url:\s*['\"]?([^\s#'\"]+)",
|
||||
config,
|
||||
)
|
||||
site_url = match.group(1) if match else ""
|
||||
path = urlsplit(site_url).path or "/"
|
||||
return "/" + path.strip("/") + "/" if path.strip("/") else "/"
|
||||
|
||||
|
||||
def _local_target(
|
||||
href: str, page_path: Path, site: Path, base_path: str
|
||||
) -> tuple[list[Path], str] | None:
|
||||
@@ -343,12 +333,11 @@ def _check_links(pages: dict, site: Path, base_path: str) -> list[str]:
|
||||
return issues
|
||||
|
||||
|
||||
def validate_site(site: Path, config_path: Path) -> list[str]:
|
||||
def validate_site(site: Path, base_path: str = "/retrobios/") -> list[str]:
|
||||
site = site.resolve()
|
||||
if not site.is_dir():
|
||||
return [f"site directory does not exist: {site}"]
|
||||
|
||||
base_path = _base_path(config_path)
|
||||
html_paths = sorted(site.rglob("*.html"))
|
||||
if not html_paths:
|
||||
return [f"no HTML pages found in {site}"]
|
||||
@@ -367,7 +356,7 @@ def validate_site(site: Path, config_path: Path) -> list[str]:
|
||||
issues.append(f"duplicate {label} {value!r}: {rendered}")
|
||||
|
||||
issues.extend(_check_links(pages, site, base_path))
|
||||
issues.extend(_check_search_index(site))
|
||||
issues.extend(_check_pagefind(site))
|
||||
|
||||
return issues
|
||||
|
||||
@@ -379,12 +368,12 @@ def main() -> None:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--site-dir", default="site",
|
||||
"--site-dir", default="website/dist",
|
||||
help="the built site (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default="mkdocs.yml",
|
||||
help="the MkDocs configuration (default: %(default)s)",
|
||||
"--base", default="/retrobios/",
|
||||
help="the path the site is served under (default: %(default)s)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-errors", type=int, default=100,
|
||||
@@ -393,7 +382,7 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
issues = validate_site(Path(args.site_dir), Path(args.config))
|
||||
issues = validate_site(Path(args.site_dir), args.base)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
print(f"Site validation failed: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -56,6 +56,9 @@ def _tracked() -> list[Path]:
|
||||
|
||||
|
||||
def _semicolons(text: str) -> list[str]:
|
||||
# An HTML character reference (& {) 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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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']} |")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""The file picker on Get your files lists what the installer installs.
|
||||
|
||||
The picker reads a compacted copy of each install/<platform>.json and keeps
|
||||
a file when one of its systems is chosen, or when it has none and "other"
|
||||
is chosen, then prints `--system` with the choices. install.py --system
|
||||
narrows with install.narrow(). The two must agree file for file, or the
|
||||
list the page shows is not what the command it prints installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
install = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(install)
|
||||
|
||||
PICKER = REPO_ROOT / "website" / "src" / "components" / "FilePicker.astro"
|
||||
|
||||
|
||||
def picked(data: dict, chosen: set[str]) -> list[str]:
|
||||
"""The picker's rule, as FilePicker.astro applies it."""
|
||||
wanted = {index for index, system in enumerate(data["systems"]) if system["id"] in chosen}
|
||||
other = install.OTHER_SYSTEMS in chosen
|
||||
return [dest for dest, _size, systems in data["files"]
|
||||
if (any(s in wanted for s in systems) if systems else other)]
|
||||
|
||||
|
||||
class PickerMatchesInstaller(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import sitestarlight # noqa: PLC0415
|
||||
|
||||
self.folder = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None)
|
||||
self.out = Path(self.folder.name)
|
||||
self.count = sitestarlight.picker_data(REPO_ROOT / "install", self.out)
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_every_manifest_has_its_list(self):
|
||||
index = json.loads((self.out / "index.json").read_text(encoding="utf-8"))
|
||||
manifests = [p for p in (REPO_ROOT / "install").glob("*.json")
|
||||
if "files" in json.loads(p.read_text(encoding="utf-8"))]
|
||||
self.assertEqual(len(index), len(manifests))
|
||||
self.assertEqual(self.count, len(manifests))
|
||||
|
||||
def test_random_selections_equal_install_narrow(self):
|
||||
rng = random.Random(20261022)
|
||||
checked = 0
|
||||
for entry in json.loads((self.out / "index.json").read_text(encoding="utf-8")):
|
||||
platform = entry["platform"]
|
||||
manifest = json.loads((REPO_ROOT / "install" / f"{platform}.json").read_text(encoding="utf-8"))
|
||||
data = json.loads((self.out / f"{platform}.json").read_text(encoding="utf-8"))
|
||||
choices = [s["id"] for s in data["systems"]] + ([install.OTHER_SYSTEMS] if data["other"] else [])
|
||||
for _round in range(10):
|
||||
chosen = set(rng.sample(choices, rng.randint(1, min(len(choices), 6))))
|
||||
files, _omitted = install.narrow(manifest["files"], [], sorted(chosen), [], [])
|
||||
with self.subTest(platform=platform, systems=sorted(chosen)):
|
||||
self.assertEqual(sorted(picked(data, chosen)), sorted(f["dest"] for f in files))
|
||||
checked += 1
|
||||
self.assertGreater(checked, 0)
|
||||
|
||||
def test_choices_are_what_the_installer_offers(self):
|
||||
for entry in json.loads((self.out / "index.json").read_text(encoding="utf-8")):
|
||||
manifest = json.loads((REPO_ROOT / "install" / f"{entry['platform']}.json").read_text(encoding="utf-8"))
|
||||
data = json.loads((self.out / f"{entry['platform']}.json").read_text(encoding="utf-8"))
|
||||
offered = install.available_choices(manifest["files"], [])["systems"]
|
||||
ours = [s["id"] for s in data["systems"]] + ([install.OTHER_SYSTEMS] if data["other"] else [])
|
||||
with self.subTest(platform=entry["platform"]):
|
||||
self.assertEqual(ours, offered)
|
||||
|
||||
def test_component_applies_the_same_rule(self):
|
||||
"""The rule above is the component's: a change to one fails here."""
|
||||
source = PICKER.read_text(encoding="utf-8")
|
||||
self.assertIn("systems.length ? systems.some((s) => wanted.has(s)) : other", source)
|
||||
self.assertIn("picked.includes('other')", source)
|
||||
self.assertRegex(source, re.escape("--system ${picked.join(',')}"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+24
-18
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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: <system_dir>/x", lines)
|
||||
self.assertIn(" - extensions_tried: .bin, .zip", lines)
|
||||
self.assertIn(" - hle_available: no", lines)
|
||||
self.assertIn(" - root: <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
@@ -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__":
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
# Written by scripts/generate_site.py
|
||||
src/content/docs/
|
||||
src/generated/
|
||||
public/
|
||||
@@ -0,0 +1,101 @@
|
||||
// The RetroBIOS site. Pages, navigation and data files are written by
|
||||
// scripts/generate_site.py; this folder holds the engine's configuration,
|
||||
// the components and the styles.
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { satteri } from '@astrojs/markdown-satteri';
|
||||
import starlightSidebarTopics from 'starlight-sidebar-topics';
|
||||
import starlightLinksValidator from 'starlight-links-validator';
|
||||
import sidebar from './src/generated/sidebar.json' with { type: 'json' };
|
||||
import lastmod from './src/generated/lastmod.json' with { type: 'json' };
|
||||
|
||||
const SITE = 'https://abdess.github.io';
|
||||
const BASE = '/retrobios/';
|
||||
|
||||
// The sitemap keeps the address MkDocs gave it: sitemap.xml, beside the index.
|
||||
const sitemapAlias = {
|
||||
name: 'retrobios-sitemap-alias',
|
||||
hooks: {
|
||||
'astro:build:done': async ({ dir }) => {
|
||||
const root = fileURLToPath(dir);
|
||||
await copyFile(`${root}sitemap-index.xml`, `${root}sitemap.xml`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE,
|
||||
base: BASE,
|
||||
trailingSlash: 'always',
|
||||
markdown: {
|
||||
processor: satteri({
|
||||
features: {
|
||||
headingAttributes: true,
|
||||
definitionList: true,
|
||||
// Straight quotes: curly ones would change a copied command.
|
||||
smartPunctuation: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'RetroBIOS',
|
||||
titleDelimiter: '-',
|
||||
description:
|
||||
'Source-verified BIOS and firmware packs for RetroArch, Batocera, Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX, KNULLI, AmberELEC, EmuELEC and MiSTer FPGA.',
|
||||
// The brand mark is white on the indigo of the old header: indigo on a light page.
|
||||
logo: { light: './src/assets/logo-light.svg', dark: './src/assets/logo-dark.svg', replacesTitle: false },
|
||||
favicon: '/assets/brand/favicon-32.png',
|
||||
head: [
|
||||
{ tag: 'link', attrs: { rel: 'icon', type: 'image/svg+xml', href: `${BASE}assets/brand/favicon.svg` } },
|
||||
{ tag: 'link', attrs: { rel: 'apple-touch-icon', sizes: '180x180', href: `${BASE}assets/brand/apple-touch-icon.png` } },
|
||||
],
|
||||
social: [{ icon: 'github', label: 'RetroBIOS on GitHub', href: 'https://github.com/Abdess/retrobios' }],
|
||||
customCss: ['./src/styles/retrobios.css'],
|
||||
components: {
|
||||
PageTitle: './src/components/PageTitle.astro',
|
||||
MarkdownContent: './src/components/MarkdownContent.astro',
|
||||
Footer: './src/components/Footer.astro',
|
||||
},
|
||||
routeMiddleware: ['./src/routeData.ts'],
|
||||
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
||||
// Commands wrap instead of scrolling, so the end of a one-liner stays visible.
|
||||
expressiveCode: { defaultProps: { wrap: true } },
|
||||
lastUpdated: false,
|
||||
pagination: true,
|
||||
// Measured on the queries of tools/search_gate.py, at the centre of a
|
||||
// range where all of them answer. How often a word appears counts more
|
||||
// than in Starlight's defaults (0.1, 0.1, 2, 9), a page's title weighs
|
||||
// ten times its text, so "dolphin" finds Dolphin, and the file names of
|
||||
// a system page (its `files` field, written by sitestarlight.py) count
|
||||
// half, so "scph5501" finds the Sony page without a file name winning
|
||||
// every other query.
|
||||
pagefind: {
|
||||
ranking: {
|
||||
pageLength: 0.1,
|
||||
termFrequency: 0.5,
|
||||
termSaturation: 1.4,
|
||||
termSimilarity: 9,
|
||||
metaWeights: { title: 10, files: 0.5 },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
starlightSidebarTopics(sidebar.topics, sidebar.options),
|
||||
starlightLinksValidator(),
|
||||
],
|
||||
}),
|
||||
// A page that states its own date (an emulator page: the day its
|
||||
// profile was last read against the source) gives it to the sitemap.
|
||||
// The others get no lastmod rather than the build date.
|
||||
sitemap({
|
||||
serialize(item) {
|
||||
const date = lastmod[new URL(item.url).pathname];
|
||||
return date ? { ...item, lastmod: date } : item;
|
||||
},
|
||||
}),
|
||||
sitemapAlias,
|
||||
],
|
||||
});
|
||||
Generated
+5403
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "retrobios-site",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-satteri": "0.4.5",
|
||||
"@astrojs/sitemap": "3.7.4",
|
||||
"@astrojs/starlight": "0.42.6",
|
||||
"astro": "7.4.2",
|
||||
"starlight-links-validator": "0.27.0",
|
||||
"starlight-sidebar-topics": "0.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RetroBIOS"><mask id="glyph"><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff"/><rect x="11.6" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="24.8" y="11.6" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="18.2" y="18.2" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="18.2" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="11.6" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="31.4" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="24.8" y="31.4" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="31.4" width="5" height="5" rx="0.6" fill="#000"/></mask><rect x="11.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="11.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="18.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="18.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="25.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="25.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="32.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="32.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff" mask="url(#glyph)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RetroBIOS"><mask id="glyph"><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff"/><rect x="11.6" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="24.8" y="11.6" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="18.2" y="18.2" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="18.2" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="11.6" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="31.4" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="24.8" y="31.4" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="31.4" width="5" height="5" rx="0.6" fill="#000"/></mask><rect x="11.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="11.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="18.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="18.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="25.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="25.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="32.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="32.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="8" y="8" width="32" height="32" rx="4" fill="#3b3f7a" mask="url(#glyph)"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,186 @@
|
||||
---
|
||||
// "Which files do I need": pick a setup and systems, get the file list and
|
||||
// the install command. Client side only: reads public/data/picker/*.json,
|
||||
// which convert.py derives from install/<platform>.json. The selection
|
||||
// follows install.py narrow(): a file is kept when one of its systems is
|
||||
// chosen, a file with no system when "other" is chosen.
|
||||
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
---
|
||||
|
||||
<div class="rb-picker" data-base={base}>
|
||||
<fieldset>
|
||||
<label for="rb-pick-platform">Your setup</label>
|
||||
<select id="rb-pick-platform" name="platform">
|
||||
<option value="">Choose a setup</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<div class="rb-pick-systems" hidden>
|
||||
<p class="rb-pick-systems-head">
|
||||
<strong>Systems</strong>
|
||||
<button type="button" data-all>All</button>
|
||||
<button type="button" data-none>None</button>
|
||||
</p>
|
||||
<input type="search" class="rb-pick-filter" placeholder="Filter systems" aria-label="Filter systems" />
|
||||
<div class="rb-pick-list" role="group" aria-label="Systems"></div>
|
||||
</div>
|
||||
<div class="rb-pick-result" role="status" aria-live="polite"></div>
|
||||
<div class="rb-pick-output" hidden>
|
||||
<p><strong>Install command</strong> (Linux, macOS, Steam Deck, handhelds)</p>
|
||||
<pre class="rb-pick-cmd"><code></code></pre>
|
||||
<button type="button" data-copy>Copy the command</button>
|
||||
<details>
|
||||
<summary>Files and where they go</summary>
|
||||
<table>
|
||||
<thead><tr><th scope="col">Destination</th><th scope="col">Size</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
type Data = {
|
||||
platform: string;
|
||||
name: string;
|
||||
folder: string;
|
||||
generated: string;
|
||||
systems: { id: string; native: string[] }[];
|
||||
other: boolean;
|
||||
files: [string, number, number[]][];
|
||||
};
|
||||
|
||||
const INSTALL = 'curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s --';
|
||||
const MAX_ROWS = 500;
|
||||
|
||||
const size = (bytes: number) =>
|
||||
bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB`
|
||||
: bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB`
|
||||
: bytes >= 1e3 ? `${(bytes / 1e3).toFixed(1)} KB`
|
||||
: `${bytes} B`;
|
||||
|
||||
for (const root of document.querySelectorAll<HTMLElement>('.rb-picker')) {
|
||||
const base = root.dataset.base ?? '/';
|
||||
const select = root.querySelector('select')!;
|
||||
const systemsBox = root.querySelector<HTMLElement>('.rb-pick-systems')!;
|
||||
const list = root.querySelector<HTMLElement>('.rb-pick-list')!;
|
||||
const filter = root.querySelector<HTMLInputElement>('.rb-pick-filter')!;
|
||||
const result = root.querySelector<HTMLElement>('.rb-pick-result')!;
|
||||
const output = root.querySelector<HTMLElement>('.rb-pick-output')!;
|
||||
const cmd = root.querySelector<HTMLElement>('.rb-pick-cmd code')!;
|
||||
const tbody = root.querySelector('tbody')!;
|
||||
let data: Data | null = null;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const index: { platform: string; name: string }[] = await fetch(`${base}data/picker/index.json`).then((r) => r.json());
|
||||
for (const entry of index) select.add(new Option(entry.name, entry.platform));
|
||||
|
||||
const chosen = () => [...list.querySelectorAll<HTMLInputElement>('input:checked')].map((box) => box.value);
|
||||
|
||||
const render = () => {
|
||||
if (!data) return;
|
||||
const picked = chosen();
|
||||
const wanted = new Set(picked.map((id) => data!.systems.findIndex((s) => s.id === id)));
|
||||
const other = picked.includes('other');
|
||||
const files = data.files.filter(([, , systems]) =>
|
||||
systems.length ? systems.some((s) => wanted.has(s)) : other);
|
||||
const total = files.reduce((sum, [, bytes]) => sum + bytes, 0);
|
||||
const all = picked.length === data.systems.length + (data.other ? 1 : 0);
|
||||
result.textContent = picked.length
|
||||
? `${files.length} files, ${size(total)}, for ${picked.length} of ${data.systems.length + (data.other ? 1 : 0)} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
|
||||
: 'Choose at least one system.';
|
||||
output.hidden = !picked.length;
|
||||
cmd.textContent = `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`;
|
||||
tbody.replaceChildren(...files.slice(0, MAX_ROWS).map(([dest, bytes]) => {
|
||||
const row = document.createElement('tr');
|
||||
const where = document.createElement('td');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = dest;
|
||||
where.append(code);
|
||||
const weight = document.createElement('td');
|
||||
weight.textContent = size(bytes);
|
||||
row.append(where, weight);
|
||||
return row;
|
||||
}));
|
||||
if (files.length > MAX_ROWS) {
|
||||
const row = document.createElement('tr');
|
||||
const cell = document.createElement('td');
|
||||
cell.colSpan = 2;
|
||||
cell.textContent = `and ${files.length - MAX_ROWS} more.`;
|
||||
row.append(cell);
|
||||
tbody.append(row);
|
||||
}
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('platform', data.platform);
|
||||
if (all) url.searchParams.delete('system');
|
||||
else url.searchParams.set('system', picked.join(','));
|
||||
history.replaceState(null, '', url);
|
||||
};
|
||||
|
||||
const load = async (platform: string, preset: string[] | null) => {
|
||||
list.replaceChildren();
|
||||
output.hidden = true;
|
||||
systemsBox.hidden = !platform;
|
||||
if (!platform) { result.textContent = ''; return; }
|
||||
result.textContent = 'Loading the file list...';
|
||||
data = await fetch(`${base}data/picker/${platform}.json`).then((r) => r.json());
|
||||
const choices = data!.systems.map((s) => ({ id: s.id, label: s.native.length ? `${s.id} (${s.native.join(', ')})` : s.id }));
|
||||
if (data!.other) choices.push({ id: 'other', label: 'other (files no system owns: engines, computers)' });
|
||||
for (const choice of choices) {
|
||||
const label = document.createElement('label');
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.value = choice.id;
|
||||
box.checked = preset ? preset.includes(choice.id) : true;
|
||||
label.append(box, ` ${choice.label}`);
|
||||
list.append(label);
|
||||
}
|
||||
render();
|
||||
};
|
||||
|
||||
select.addEventListener('change', () => load(select.value, null));
|
||||
list.addEventListener('change', render);
|
||||
filter.addEventListener('input', () => {
|
||||
const query = filter.value.trim().toLowerCase();
|
||||
for (const label of list.querySelectorAll<HTMLElement>('label')) {
|
||||
label.hidden = !!query && !label.textContent!.toLowerCase().includes(query);
|
||||
}
|
||||
});
|
||||
root.querySelector('[data-all]')!.addEventListener('click', () => {
|
||||
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = true));
|
||||
render();
|
||||
});
|
||||
root.querySelector('[data-none]')!.addEventListener('click', () => {
|
||||
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = false));
|
||||
render();
|
||||
});
|
||||
root.querySelector('[data-copy]')!.addEventListener('click', () => navigator.clipboard?.writeText(cmd.textContent ?? ''));
|
||||
|
||||
const initial = params.get('platform');
|
||||
if (initial && index.some((entry) => entry.platform === initial)) {
|
||||
select.value = initial;
|
||||
await load(initial, params.get('system')?.split(',') ?? null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Global: the checkboxes are created by the script, outside Astro's scoped styles. -->
|
||||
<style is:global>
|
||||
.rb-picker .rb-pick-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 0.25rem 1rem;
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.rb-picker .rb-pick-list label { display: flex; gap: 0.5rem; align-items: center; min-height: 32px; margin: 0; font-size: 0.9rem; }
|
||||
.sl-markdown-content .rb-picker .rb-pick-list input { min-height: auto; width: 1.1rem; height: 1.1rem; margin: 0; flex: none; }
|
||||
.rb-picker .rb-pick-systems-head { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.rb-picker .rb-pick-systems-head > * { margin: 0; }
|
||||
.rb-picker .rb-pick-filter { width: 100%; margin: 0.5rem 0; }
|
||||
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.rb-picker .rb-pick-result { margin-top: 1rem; font-weight: 600; }
|
||||
.rb-picker fieldset { border: 0; padding: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
// Starlight's footer (edit link, previous and next) and the licence line.
|
||||
import Default from '@astrojs/starlight/components/Footer.astro';
|
||||
---
|
||||
|
||||
<Default />
|
||||
<p class="rb-copyright">
|
||||
MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup,
|
||||
archival and interoperability.
|
||||
</p>
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
// Search weight: Pagefind multiplies the score of words inside a weighted
|
||||
// element, so a Start here page outranks the reference page that names it,
|
||||
// and an emulator outranks its launcher or its frozen copy.
|
||||
import Default from '@astrojs/starlight/components/MarkdownContent.astro';
|
||||
|
||||
const { searchWeight } = Astro.locals.starlightRoute.entry.data;
|
||||
---
|
||||
|
||||
{
|
||||
searchWeight ? (
|
||||
<div data-pagefind-weight={String(searchWeight)}>
|
||||
<Default><slot /></Default>
|
||||
</div>
|
||||
) : (
|
||||
<Default><slot /></Default>
|
||||
)
|
||||
}
|
||||
|
||||
<script>
|
||||
import '../scripts/site.ts';
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
// Breadcrumbs, the level label and the title. The trail says which tab a
|
||||
// deep page belongs to; the label says who the page is written for.
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
|
||||
const LABELS = {
|
||||
start: 'Start here',
|
||||
setup: 'Setup guide',
|
||||
explore: 'Details',
|
||||
reference: 'Reference',
|
||||
contribute: 'For contributors',
|
||||
} as const;
|
||||
|
||||
const { title, level } = Astro.locals.starlightRoute.entry.data;
|
||||
const crumbs = Astro.locals.rbBreadcrumbs ?? [];
|
||||
---
|
||||
|
||||
{
|
||||
crumbs.length > 0 && (
|
||||
<nav class="rb-breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
{crumbs.map((crumb) => (
|
||||
<li>{crumb.href ? <a href={crumb.href}>{crumb.label}</a> : crumb.label}</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
{level && <span class={`rb-level rb-level-${level}`}>{LABELS[level]}</span>}
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--sl-text-h1);
|
||||
line-height: var(--sl-line-height-headings);
|
||||
font-weight: 700;
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { z } from 'astro/zod';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
// The generator's own front matter, typed: a page with a misspelt level fails the build.
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
loader: docsLoader(),
|
||||
schema: docsSchema({
|
||||
extend: z.object({
|
||||
// Who the page is written for, from someone who knows nothing
|
||||
// (start) to someone adding to the project (contribute).
|
||||
level: z.enum(['start', 'setup', 'explore', 'reference', 'contribute']).optional(),
|
||||
// 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(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
rbBreadcrumbs?: import('./routeData').Crumb[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// What each page knows of its place in the site, computed once per page:
|
||||
// - breadcrumbs: the tab and the groups above the page;
|
||||
// - previous and next: the reading order runs on from one tab into the next,
|
||||
// so the last Start here page leads to Your setup;
|
||||
// - a pruned sidebar: a group that does not hold the page becomes one link
|
||||
// to its first page, so an emulator page does not carry the 600 links of
|
||||
// every other emulator (the equivalent of Material's navigation.prune).
|
||||
import { defineRouteMiddleware, type StarlightRouteData } from '@astrojs/starlight/route-data';
|
||||
import sidebar from './generated/sidebar.json' with { type: 'json' };
|
||||
|
||||
type Entry = StarlightRouteData['sidebar'][number];
|
||||
type Link = Extract<Entry, { type: 'link' }>;
|
||||
type Item = { label: string; link?: string; items?: Item[] };
|
||||
export type Crumb = { label: string; href?: string };
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
|
||||
const withBase = (link: string) => (link.startsWith(BASE) ? link : BASE + link.replace(/^\//, ''));
|
||||
|
||||
const flatten = (items: Item[]): { label: string; link: string }[] =>
|
||||
items.flatMap((item) => (item.items ? flatten(item.items) : item.link && !item.link.startsWith('http') ? [{ label: item.label, link: item.link }] : []));
|
||||
|
||||
// Every listed page in reading order, tab after tab.
|
||||
const READING_ORDER = sidebar.topics.flatMap((topic) =>
|
||||
flatten(topic.items as Item[]).map((entry) => ({ ...entry, label: entry.label === 'Overview' ? topic.label : entry.label })),
|
||||
);
|
||||
|
||||
const holdsCurrent = (entry: Entry): boolean =>
|
||||
entry.type === 'link' ? entry.isCurrent : entry.entries.some(holdsCurrent);
|
||||
|
||||
const firstLink = (entry: Entry): string | undefined =>
|
||||
entry.type === 'link' ? entry.href : entry.entries.map(firstLink).find(Boolean);
|
||||
|
||||
const prune = (entries: Entry[]): Entry[] =>
|
||||
entries.map((entry) => {
|
||||
if (entry.type === 'link') return entry;
|
||||
if (holdsCurrent(entry)) return { ...entry, collapsed: false, entries: prune(entry.entries) };
|
||||
const href = firstLink(entry);
|
||||
return href ? { type: 'link', label: entry.label, href, isCurrent: false, badge: entry.badge, attrs: {} } : entry;
|
||||
});
|
||||
|
||||
// The groups above the current page, outermost first.
|
||||
const trail = (entries: Entry[], path: string): Crumb[] | undefined => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'link') {
|
||||
if (entry.isCurrent) return [];
|
||||
continue;
|
||||
}
|
||||
const below = trail(entry.entries, path);
|
||||
if (below) return [{ label: entry.label, href: firstLink(entry) }, ...below];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// A page the navigation does not list (part of a split page) sits under the
|
||||
// group holding the pages of its folder.
|
||||
const folderTrail = (entries: Entry[], folder: string): Crumb[] => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'group') continue;
|
||||
const links = entry.entries.map(firstLink).filter(Boolean) as string[];
|
||||
if (links.some((href) => href.startsWith(folder))) return [{ label: entry.label, href: links[0] }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const asLink = (entry: { label: string; link: string }): Link => ({
|
||||
type: 'link',
|
||||
label: entry.label,
|
||||
href: withBase(entry.link),
|
||||
isCurrent: false,
|
||||
badge: undefined,
|
||||
attrs: {},
|
||||
});
|
||||
|
||||
export const onRequest = defineRouteMiddleware(async (context, next) => {
|
||||
await next();
|
||||
const route = context.locals.starlightRoute;
|
||||
const path = context.url.pathname;
|
||||
|
||||
const topic = context.locals.starlightSidebarTopics?.topics.find((t) => t.isCurrent);
|
||||
const crumbs: Crumb[] = [];
|
||||
if (topic && withBase(topic.link) !== path) {
|
||||
crumbs.push({ label: topic.label, href: withBase(topic.link) });
|
||||
const segments = path.slice(BASE.length).split('/').filter(Boolean);
|
||||
const groups = trail(route.sidebar, path) ?? (segments.length > 1 ? folderTrail(route.sidebar, `${BASE}${segments[0]}/`) : []);
|
||||
crumbs.push(...groups.filter((crumb) => crumb.href !== path));
|
||||
}
|
||||
context.locals.rbBreadcrumbs = crumbs;
|
||||
|
||||
const position = READING_ORDER.findIndex((entry) => withBase(entry.link) === path);
|
||||
if (position !== -1) {
|
||||
if (!route.pagination.prev && position > 0) route.pagination.prev = asLink(READING_ORDER[position - 1]);
|
||||
if (!route.pagination.next && position < READING_ORDER.length - 1) route.pagination.next = asLink(READING_ORDER[position + 1]);
|
||||
}
|
||||
|
||||
route.sidebar = prune(route.sidebar);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
// Progressive enhancement of the generated pages: table captions, filters
|
||||
// and phone layout, the hash lookup on files/, moved cards, a hash typed in
|
||||
// the search, glossary terms on a tap, and the 404 page's hints.
|
||||
|
||||
const HASH_LENGTHS: Record<number, string> = { 8: 'CRC32', 32: 'MD5', 40: 'SHA1', 64: 'SHA256' };
|
||||
const LAYER_LABELS: Record<string, string> = {
|
||||
collection: 'In the collection',
|
||||
'data-directory': 'In a data directory the packs ship',
|
||||
platform: 'Declared by a platform',
|
||||
emulator: 'Read by an emulator',
|
||||
};
|
||||
type Item = { algorithm: string; layer: string; name?: string; value: string; url: string; owner?: string };
|
||||
|
||||
const siteRoot = () => new URL(import.meta.env.BASE_URL.replace(/\/?$/, '/'), location.origin);
|
||||
const normalizeHash = (raw: string) => raw.trim().toLowerCase().replace(/^0x/, '').replace(/\s+/g, '');
|
||||
const isHash = (value: string) => /^[0-9a-f]+$/.test(value) && value.length in HASH_LENGTHS;
|
||||
|
||||
// Tables
|
||||
|
||||
const headingText = (element: Element): string => {
|
||||
let cursor: Element | null = element;
|
||||
while (cursor) {
|
||||
cursor = cursor.previousElementSibling;
|
||||
if (!cursor) break;
|
||||
const heading = /^H[1-6]$/.test(cursor.tagName) ? cursor : cursor.querySelector('h1, h2, h3, h4, h5, h6');
|
||||
if (heading && cursor.classList.contains('sl-heading-wrapper')) return heading.textContent!.trim();
|
||||
if (/^H[1-6]$/.test(cursor.tagName)) return cursor.textContent!.trim();
|
||||
}
|
||||
return document.querySelector('h1')?.textContent?.trim() || 'Data table';
|
||||
};
|
||||
|
||||
// A table that overflows is a named stop in the tab order, so a keyboard
|
||||
// can scroll it (WCAG 2.1.1). Checked again when a closed block opens. All
|
||||
// widths are read first and the attributes written after, so a page of
|
||||
// twenty tables is laid out once, not twenty times.
|
||||
const markScrollable = (tables: HTMLTableElement[]) => {
|
||||
const overflowing = tables.map((table) => table.scrollWidth > table.clientWidth + 1);
|
||||
tables.forEach((table, index) => setScrollable(table, overflowing[index]));
|
||||
};
|
||||
|
||||
const setScrollable = (table: HTMLTableElement, overflows: boolean) => {
|
||||
const heading = table.dataset.rbHeading ?? 'Data table';
|
||||
if (overflows) {
|
||||
table.tabIndex = 0;
|
||||
table.setAttribute('role', 'region');
|
||||
table.setAttribute('aria-label', `${heading}, scrollable table`);
|
||||
} else if (table.getAttribute('role') === 'region') {
|
||||
table.removeAttribute('tabindex');
|
||||
table.removeAttribute('role');
|
||||
table.removeAttribute('aria-label');
|
||||
}
|
||||
};
|
||||
|
||||
// A table whose rows end in an action (a button, a pack to download) is laid
|
||||
// out one block per row on a phone, each cell under its column name, so the
|
||||
// action never sits behind a horizontal scroll.
|
||||
const stackActionTable = (table: HTMLTableElement) => {
|
||||
if (!table.querySelector('tbody :is(.rb-button, a[href*="/releases/download/"], a[href$="/releases/latest"])')) return;
|
||||
const labels = [...table.querySelectorAll('thead th')].map((cell) => cell.textContent!.trim());
|
||||
if (!labels.length) return;
|
||||
table.dataset.rbStack = 'true';
|
||||
for (const row of table.querySelectorAll('tbody tr')) {
|
||||
[...row.children].forEach((cell, column) => {
|
||||
if (labels[column] && !cell.hasAttribute('data-label')) cell.setAttribute('data-label', labels[column]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filterTable = (table: HTMLTableElement, index: number, heading: string) => {
|
||||
const rows = [...table.querySelectorAll<HTMLTableRowElement>('tbody tr')];
|
||||
if (rows.length < 20) return;
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'rb-table-filter';
|
||||
const id = `rb-table-filter-${index}`;
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = id;
|
||||
label.textContent = `Filter ${heading}`;
|
||||
const input = document.createElement('input');
|
||||
input.id = id;
|
||||
input.type = 'search';
|
||||
input.autocomplete = 'off';
|
||||
input.placeholder = 'Name, platform, system, hash...';
|
||||
const count = document.createElement('span');
|
||||
count.className = 'rb-table-filter-count';
|
||||
count.setAttribute('aria-live', 'polite');
|
||||
count.textContent = `${rows.length} rows`;
|
||||
controls.append(label, input, count);
|
||||
table.before(controls);
|
||||
// Each row's text is read on the first keystroke, not on every page load.
|
||||
let searchable: { row: HTMLTableRowElement; text: string }[] | null = null;
|
||||
input.addEventListener('input', () => {
|
||||
searchable ??= rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() }));
|
||||
const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
|
||||
let visible = 0;
|
||||
for (const entry of searchable) {
|
||||
entry.row.hidden = !!query && !entry.text.includes(query);
|
||||
if (!entry.row.hidden) visible += 1;
|
||||
}
|
||||
count.textContent = `${visible} of ${rows.length} rows`;
|
||||
});
|
||||
};
|
||||
|
||||
const enhanceTables = () => {
|
||||
const tables = [...document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table')];
|
||||
const enhance = (table: HTMLTableElement) => {
|
||||
for (const cell of table.querySelectorAll('thead th')) if (!cell.hasAttribute('scope')) cell.setAttribute('scope', 'col');
|
||||
const heading = headingText(table);
|
||||
table.dataset.rbHeading = heading;
|
||||
if (!table.querySelector('caption') && !table.closest('.rb-meta-card')) {
|
||||
const caption = document.createElement('caption');
|
||||
caption.className = 'rb-visually-hidden';
|
||||
caption.textContent = heading;
|
||||
table.prepend(caption);
|
||||
}
|
||||
stackActionTable(table);
|
||||
filterTable(table, tables.indexOf(table), heading);
|
||||
};
|
||||
// A table is set up and measured when it nears the screen. Doing all of
|
||||
// them at load relaid out every row of a 3,800-row page after its first
|
||||
// paint. One that appears later (a block opened, a tab chosen) is handled
|
||||
// then.
|
||||
const near = new Set<HTMLTableElement>();
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const shown = entries.filter((entry) => entry.isIntersecting).map((entry) => entry.target as HTMLTableElement);
|
||||
for (const table of shown) {
|
||||
if (!near.has(table)) enhance(table);
|
||||
near.add(table);
|
||||
}
|
||||
if (shown.length) requestAnimationFrame(() => markScrollable(shown));
|
||||
},
|
||||
{ rootMargin: '400px 0px' },
|
||||
);
|
||||
// Every table is measured once the page is drawn and idle, before anything
|
||||
// is written, so the read finds the layout done instead of forcing it: a
|
||||
// keyboard reaches a scrolling table anywhere on the page, not only near
|
||||
// the screen.
|
||||
const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 1));
|
||||
const start = () =>
|
||||
idle(() => {
|
||||
// A table in a closed block is not laid out, and measuring it would
|
||||
// lay it out: it is measured when its block opens.
|
||||
markScrollable(tables.filter((table) => table.checkVisibility?.() ?? true));
|
||||
tables.forEach((table) => observer.observe(table));
|
||||
});
|
||||
if (document.readyState === 'complete') start();
|
||||
else addEventListener('load', start, { once: true });
|
||||
let timer = 0;
|
||||
addEventListener('resize', () => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(() => markScrollable([...near]), 150);
|
||||
});
|
||||
};
|
||||
|
||||
// files/?hash= reads one shard of the lookup, named by the first two hex
|
||||
// characters of the value.
|
||||
|
||||
const lookupShard = (value: string): Promise<Item[]> =>
|
||||
fetch(new URL(`api/v1/lookup/${value.slice(0, 2)}.json`, siteRoot()))
|
||||
.then((response) => (response.ok ? response.json() : { items: [] }))
|
||||
.then((shard) => shard.items || []);
|
||||
|
||||
const renderMatches = (status: HTMLElement, value: string, items: Item[]) => {
|
||||
status.textContent = '';
|
||||
const heading = document.createElement('p');
|
||||
heading.textContent = `${items.length} ${items.length === 1 ? 'match' : 'matches'} for ${HASH_LENGTHS[value.length]} ${value}:`;
|
||||
status.append(heading);
|
||||
for (const [layer, label] of Object.entries(LAYER_LABELS)) {
|
||||
const found = items.filter((item) => item.layer === layer);
|
||||
if (!found.length) continue;
|
||||
const title = document.createElement('p');
|
||||
const strong = document.createElement('strong');
|
||||
strong.textContent = label;
|
||||
title.append(strong);
|
||||
const list = document.createElement('ul');
|
||||
for (const item of found) {
|
||||
const row = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
link.href = new URL(item.url, siteRoot()).href;
|
||||
const code = document.createElement('code');
|
||||
code.textContent = item.name || item.value;
|
||||
link.append(code);
|
||||
row.append(link);
|
||||
if (item.owner) row.append(` (${item.owner})`);
|
||||
list.append(row);
|
||||
}
|
||||
status.append(title, list);
|
||||
}
|
||||
};
|
||||
|
||||
// files/?sha1=<sha1> is a file's content address and opens its card
|
||||
// wherever it is now; files/?hash= also takes an MD5, CRC32 or SHA256 and
|
||||
// lists every page that names the value.
|
||||
const resolveFileAddress = () => {
|
||||
const status = document.getElementById('rb-file-lookup-status');
|
||||
if (!status) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const permalink = params.has('sha1');
|
||||
const value = normalizeHash(params.get('sha1') || params.get('hash') || '');
|
||||
if (!value) return;
|
||||
const input = document.getElementById('rb-hash') as HTMLInputElement | null;
|
||||
if (input) input.value = value;
|
||||
const prefix = permalink && /^[0-9a-f]{12}$/.test(value);
|
||||
if (!prefix && !isHash(value)) {
|
||||
status.textContent = 'A hash is 8 (CRC32), 32 (MD5), 40 (SHA1) or 64 (SHA256) hexadecimal characters.';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Looking it up...';
|
||||
lookupShard(value)
|
||||
.then((items) => {
|
||||
const matches = items.filter((item) =>
|
||||
prefix ? item.algorithm === 'sha1' && item.value.startsWith(value) : item.value === value);
|
||||
const cards = matches.filter((item) => item.layer === 'collection' || item.layer === 'data-directory');
|
||||
if (permalink && new Set(cards.map((item) => item.url)).size === 1) {
|
||||
location.replace(new URL(cards[0].url, siteRoot()));
|
||||
} else if (matches.length) {
|
||||
renderMatches(status, prefix ? matches[0].value : value, prefix ? cards : matches);
|
||||
} else {
|
||||
status.textContent = 'No file the site knows has this hash.';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
status.textContent = 'The file index could not be read.';
|
||||
});
|
||||
};
|
||||
|
||||
// A file card moved to another page keeps its address: an id of the form
|
||||
// sha1-<12 hex> that this page does not hold is looked up by content, and
|
||||
// an emulator card listed in the table of a split page opens its page.
|
||||
const followMovedCard = () => {
|
||||
const id = decodeURIComponent(location.hash.slice(1));
|
||||
if (!id) return;
|
||||
const target = document.getElementById(id);
|
||||
const link = target?.closest('.rb-card-index tr')?.querySelector<HTMLAnchorElement>('a[href]');
|
||||
if (link) {
|
||||
location.replace(link.href);
|
||||
return;
|
||||
}
|
||||
const match = /^sha1-([0-9a-f]{12})$/.exec(id);
|
||||
if (match && !target) location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot()));
|
||||
};
|
||||
|
||||
// The site search holds no hashes: Enter on one opens the lookup.
|
||||
const hashSearch = () => {
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.matches?.('site-search dialog input')) return;
|
||||
const value = normalizeHash(input.value);
|
||||
if (!isHash(value)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
location.assign(new URL(`files/?hash=${value}`, siteRoot()));
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
// A glossary term shows its definition on hover; a tap or Enter shows it
|
||||
// on a phone or to a keyboard, and Escape or a tap elsewhere closes it.
|
||||
const glossaryTerms = () => {
|
||||
const terms = document.querySelectorAll<HTMLElement>('abbr.rb-term');
|
||||
if (!terms.length) return;
|
||||
const close = () => document.querySelectorAll('abbr.rb-term[data-open]').forEach((term) => term.removeAttribute('data-open'));
|
||||
for (const term of terms) {
|
||||
term.tabIndex = 0;
|
||||
term.setAttribute('role', 'button');
|
||||
term.setAttribute('aria-label', `${term.textContent}: ${term.title}`);
|
||||
term.dataset.definition = term.title;
|
||||
const toggle = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
const open = term.hasAttribute('data-open');
|
||||
close();
|
||||
if (!open) term.setAttribute('data-open', '');
|
||||
};
|
||||
term.addEventListener('click', toggle);
|
||||
term.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggle(event);
|
||||
} else if (event.key === 'Escape') close();
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', close);
|
||||
};
|
||||
|
||||
// The 404 page: a search button, and the list a moved page belongs to.
|
||||
const notFound = () => {
|
||||
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
|
||||
document.querySelector<HTMLButtonElement>('site-search button[data-open-modal]')?.click();
|
||||
});
|
||||
const hint = document.getElementById('rb-notfound-hint');
|
||||
const match = location.pathname.match(/\/(emulators|systems|platforms)\/([^/]+)\/?$/);
|
||||
if (!hint || !match) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = new URL(`${match[1]}/`, siteRoot()).href;
|
||||
link.textContent = `the list of ${match[1]}`;
|
||||
hint.append(`No page is named ${decodeURIComponent(match[2])} here. It may be listed under another name in `, link, '.');
|
||||
hint.hidden = false;
|
||||
};
|
||||
|
||||
resolveFileAddress();
|
||||
followMovedCard();
|
||||
enhanceTables();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
notFound();
|
||||
@@ -0,0 +1,567 @@
|
||||
/* RetroBIOS on top of Starlight.
|
||||
|
||||
Starlight's dark theme is :root and its light theme
|
||||
:root[data-theme='light']. Colour pairs are the ones measured for WCAG AA
|
||||
on the previous site, in both themes.
|
||||
|
||||
Classes the generator and the wiki pages use:
|
||||
.rb-lead first paragraph under a page title
|
||||
.rb-level-* level label (start, setup, explore, reference, contribute)
|
||||
.rb-cards, .rb-card card grid
|
||||
.rb-steps ordered list drawn as numbered steps
|
||||
.rb-button a link drawn as a button (.rb-button-primary: filled)
|
||||
.rb-terminal wraps a code block that shows terminal output
|
||||
.rb-cmd wraps a command that must wrap instead of scrolling
|
||||
code.rb-path a folder path shown as a chip
|
||||
.rb-figure a generated diagram (scripts/sitediagrams.py)
|
||||
.rb-bar inline coverage bar, width from --v
|
||||
.rb-details-* collapsible block, in the callout colour of its role
|
||||
abbr.rb-term a glossary term, its definition on hover or tap
|
||||
*/
|
||||
|
||||
:root {
|
||||
--sl-font: system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
--sl-font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
--sl-color-accent-low: #1f2147;
|
||||
--sl-color-accent: #8c9cf0;
|
||||
--sl-color-accent-high: #c9d2ff;
|
||||
|
||||
--rb-link: #aebcff;
|
||||
--rb-accent: #ff9a8f;
|
||||
--rb-surface: #252838;
|
||||
--rb-border: #3a3d52;
|
||||
--rb-border-strong: #5a5e7a;
|
||||
--rb-text-secondary: #a6adc8;
|
||||
--rb-ok-fg: #a5d6a7;
|
||||
--rb-ok-bg: #1b3a1f;
|
||||
--rb-warn-fg: #ffcc80;
|
||||
--rb-warn-bg: #7a2e00;
|
||||
--rb-err-fg: #ef9a9a;
|
||||
--rb-err-bg: #7f1414;
|
||||
--rb-info-fg: #90caf9;
|
||||
--rb-info-bg: #0d2a55;
|
||||
--rb-muted-fg: #cfd8dc;
|
||||
--rb-muted-bg: #37474f;
|
||||
--rb-level-start: #a5d6a7;
|
||||
--rb-level-setup: #90caf9;
|
||||
--rb-level-explore: #ce93d8;
|
||||
--rb-level-reference: #cfd8dc;
|
||||
--rb-level-contribute: #ffcc80;
|
||||
--rb-terminal-bg: #12131c;
|
||||
--rb-terminal-fg: #e6e6f0;
|
||||
--rb-terminal-label: #9aa0c3;
|
||||
--rb-diagram-fill: #252838;
|
||||
--rb-diagram-stroke: #7c82a3;
|
||||
--rb-diagram-accent: #aebcff;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--sl-color-accent-low: #e3e5f6;
|
||||
--sl-color-accent: #3b3f7a;
|
||||
--sl-color-accent-high: #2b2e5c;
|
||||
|
||||
--rb-link: #273c8f;
|
||||
--rb-accent: #b42318;
|
||||
--rb-surface: #f5f6fa;
|
||||
--rb-border: #e0e3eb;
|
||||
--rb-border-strong: #b8bdcc;
|
||||
--rb-text-secondary: #546e7a;
|
||||
--rb-ok-fg: #1b5e20;
|
||||
--rb-ok-bg: #e8f5e9;
|
||||
--rb-warn-fg: #a33f00;
|
||||
--rb-warn-bg: #fff3e0;
|
||||
--rb-err-fg: #b71c1c;
|
||||
--rb-err-bg: #ffebee;
|
||||
--rb-info-fg: #0d47a1;
|
||||
--rb-info-bg: #e3f2fd;
|
||||
--rb-muted-fg: #455a64;
|
||||
--rb-muted-bg: #eceff1;
|
||||
--rb-level-start: #1b5e20;
|
||||
--rb-level-setup: #0d47a1;
|
||||
--rb-level-explore: #6a1b9a;
|
||||
--rb-level-reference: #455a64;
|
||||
--rb-level-contribute: #a33f00;
|
||||
--rb-terminal-bg: #1b1d2e;
|
||||
--rb-diagram-fill: #f5f6fa;
|
||||
--rb-diagram-stroke: #8c92ac;
|
||||
--rb-diagram-accent: #3b3f7a;
|
||||
}
|
||||
|
||||
/* Links and focus */
|
||||
.sl-markdown-content a:not(.rb-button):not(.rb-badge):not(.rb-permalink) {
|
||||
color: var(--rb-link);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
.sl-markdown-content a:not(.rb-button):hover { text-decoration-thickness: 0.14em; }
|
||||
.sl-markdown-content a code { color: var(--rb-link); }
|
||||
:where(a, button, input, select, textarea, summary, abbr, [tabindex]):focus-visible {
|
||||
outline: 3px solid var(--rb-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Type scale */
|
||||
.sl-markdown-content h2 { font-weight: 600; letter-spacing: -0.005em; }
|
||||
.sl-markdown-content h3 { font-weight: 600; }
|
||||
.sl-markdown-content .rb-lead { font-size: 1.05em; line-height: 1.55; max-width: 44em; }
|
||||
|
||||
/* Breadcrumbs above the title */
|
||||
.rb-breadcrumbs ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0;
|
||||
margin: 0 0 0.4rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: var(--sl-text-sm);
|
||||
color: var(--rb-text-secondary);
|
||||
}
|
||||
.rb-breadcrumbs li + li::before { content: '/'; padding: 0 0.45rem; color: var(--rb-border-strong); }
|
||||
.rb-breadcrumbs a {
|
||||
display: inline-block;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
color: var(--rb-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rb-breadcrumbs a:hover { color: var(--rb-link); text-decoration: underline; }
|
||||
|
||||
/* Level label above a title */
|
||||
.rb-level {
|
||||
display: inline-block;
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.6;
|
||||
color: var(--rb-level-reference);
|
||||
}
|
||||
.rb-level-start { color: var(--rb-level-start); }
|
||||
.rb-level-setup { color: var(--rb-level-setup); }
|
||||
.rb-level-explore { color: var(--rb-level-explore); }
|
||||
.rb-level-reference { color: var(--rb-level-reference); }
|
||||
.rb-level-contribute { color: var(--rb-level-contribute); }
|
||||
|
||||
/* Buttons */
|
||||
.sl-markdown-content .rb-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
margin: 0.25rem 0.4rem 0.25rem 0;
|
||||
border: 2px solid var(--rb-link);
|
||||
border-radius: 4px;
|
||||
color: var(--rb-link);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sl-markdown-content .rb-button-primary { background: var(--rb-link); color: var(--sl-color-black); }
|
||||
.sl-markdown-content .rb-button:is(:hover, :focus) {
|
||||
background: var(--rb-accent);
|
||||
border-color: var(--rb-accent);
|
||||
color: var(--sl-color-black);
|
||||
}
|
||||
.sl-markdown-content table .rb-button { font-size: 0.8rem; padding: 0.4rem 0.8rem; }
|
||||
|
||||
/* Badges */
|
||||
.rb-badge {
|
||||
display: inline-block;
|
||||
padding: 0 0.4em;
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-badge-success { background: var(--rb-ok-bg); color: var(--rb-ok-fg); }
|
||||
.rb-badge-warning { background: var(--rb-warn-bg); color: var(--rb-warn-fg); }
|
||||
.rb-badge-danger { background: var(--rb-err-bg); color: var(--rb-err-fg); }
|
||||
.rb-badge-info { background: var(--rb-info-bg); color: var(--rb-info-fg); }
|
||||
.rb-badge-muted { background: var(--rb-muted-bg); color: var(--rb-muted-fg); }
|
||||
.sl-markdown-content .rb-badge a,
|
||||
.sl-markdown-content a.rb-badge { color: inherit; }
|
||||
/* A badge link sits in a dense list: it keeps a 24 px target (WCAG 2.5.8). */
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) li:has(.rb-badge a) { min-height: 24px; line-height: 24px; }
|
||||
.sl-markdown-content .rb-badge a { display: inline-block; min-width: 24px; line-height: 24px; text-align: center; }
|
||||
|
||||
/* Headline figures */
|
||||
.rb-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.6rem 1rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: var(--rb-surface);
|
||||
}
|
||||
.sl-markdown-content .rb-stats > *,
|
||||
.sl-markdown-content .rb-stat > * { margin: 0; }
|
||||
.rb-stat-value { display: block; font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.rb-stat-label { display: block; font-size: 0.75rem; color: var(--rb-text-secondary); }
|
||||
|
||||
/* Logos float right beside the opening paragraph */
|
||||
.sl-markdown-content img[align='right'] {
|
||||
float: right;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
margin: 0 0 0.6rem 1rem;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
.sl-markdown-content img[src*='assets/logos/'] { padding: 2px; border-radius: 2px; background: #fff; }
|
||||
/* A system icon beside a console heading holds its box before it loads:
|
||||
it pushed every card below it down once it arrived. */
|
||||
.sl-markdown-content :is(h2, h3) img {
|
||||
display: inline;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
margin: 0 0.4rem 0 0;
|
||||
}
|
||||
|
||||
/* Card grids */
|
||||
.rb-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
}
|
||||
.sl-markdown-content .rb-cards > .rb-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-bg);
|
||||
}
|
||||
.rb-card:is(:hover, :focus-within) { border-color: var(--rb-border-strong); }
|
||||
.sl-markdown-content .rb-card > * { margin: 0; }
|
||||
.sl-markdown-content .rb-card .rb-button { margin-top: auto; align-self: flex-start; }
|
||||
.sl-markdown-content .rb-card-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Numbered steps */
|
||||
.sl-markdown-content .rb-steps > ol {
|
||||
counter-reset: rb-step;
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
.sl-markdown-content .rb-steps > ol > li {
|
||||
counter-increment: rb-step;
|
||||
position: relative;
|
||||
margin: 0 0 1.2rem;
|
||||
padding-left: 2.6rem;
|
||||
min-height: 1.8rem;
|
||||
}
|
||||
.sl-markdown-content .rb-steps > ol > li::before {
|
||||
content: counter(rb-step);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border-radius: 4px;
|
||||
background: var(--rb-link);
|
||||
color: var(--sl-color-black);
|
||||
font-weight: 700;
|
||||
line-height: 1.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Path chip */
|
||||
.sl-markdown-content code.rb-path {
|
||||
padding: 0.15rem 0.45rem;
|
||||
border: 1px solid var(--rb-border);
|
||||
border-radius: 2px;
|
||||
font-size: 0.8em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Inline coverage bar beside a number */
|
||||
.rb-bar {
|
||||
display: inline-block;
|
||||
width: 5rem;
|
||||
height: 0.4rem;
|
||||
margin-right: 0.4rem;
|
||||
border-radius: 2px;
|
||||
background: var(--rb-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rb-bar > span { display: block; width: var(--v, 0%); height: 100%; border-radius: 2px; background: var(--rb-ok-fg); }
|
||||
|
||||
/* Tables */
|
||||
.sl-markdown-content table { font-variant-numeric: tabular-nums; }
|
||||
.sl-markdown-content table th { font-weight: 600; }
|
||||
.sl-markdown-content table[role='region']:focus-visible { outline: 3px solid var(--rb-accent); outline-offset: 2px; }
|
||||
|
||||
/* Key and value cards: the empty header row is not drawn */
|
||||
.rb-meta-card thead,
|
||||
.sl-markdown-content table thead:not(:has(th:not(:empty))) { display: none; }
|
||||
.rb-meta-card td { overflow-wrap: anywhere; }
|
||||
.rb-meta-card td:first-child { font-weight: 600; color: var(--rb-text-secondary); white-space: nowrap; width: 140px; }
|
||||
|
||||
.rb-timestamp {
|
||||
font-size: 0.8rem;
|
||||
color: var(--rb-text-secondary);
|
||||
margin-top: 2rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rb-border);
|
||||
}
|
||||
|
||||
/* File cards on system and emulator pages */
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) {
|
||||
border-top: 1px solid var(--rb-border);
|
||||
padding: 0.75rem 0 0.25rem;
|
||||
}
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) > :first-child { margin-top: 0; }
|
||||
.sl-markdown-content .rb-file-title {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
.sl-markdown-content .rb-file-title code { font-weight: 700; }
|
||||
.sl-markdown-content .rb-search-skip > ul { margin-top: 0.1rem; }
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul {
|
||||
margin: 0.3rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) li { margin: 0.1rem 0; }
|
||||
.rb-hash {
|
||||
font-family: var(--sl-font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--rb-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.rb-hash code { background: none; padding: 0; overflow-wrap: anywhere; }
|
||||
.rb-permalink {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--rb-text-secondary);
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
}
|
||||
:is(.rb-sys-file, .rb-file-entry):hover .rb-permalink,
|
||||
.rb-permalink:focus { opacity: 1; }
|
||||
:is(.rb-sys-file, .rb-file-entry):has(> .rb-file-title:target) { background: var(--rb-surface); }
|
||||
|
||||
/* Collapsible blocks keep the callout colour of their role */
|
||||
.sl-markdown-content details.rb-details {
|
||||
border: 1px solid var(--sl-color-blue);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-blue-low);
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
.sl-markdown-content details.rb-details-tip { border-color: var(--sl-color-purple); background: var(--sl-color-purple-low); }
|
||||
.sl-markdown-content details.rb-details-caution { border-color: var(--sl-color-orange); background: var(--sl-color-orange-low); }
|
||||
.sl-markdown-content details.rb-details-danger { border-color: var(--sl-color-red); background: var(--sl-color-red-low); }
|
||||
.sl-markdown-content details.rb-details > summary {
|
||||
min-height: 24px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
|
||||
|
||||
/* Terminal output: what the screen shows, not something to type */
|
||||
.sl-markdown-content .rb-terminal .expressive-code pre { background: var(--rb-terminal-bg); color: var(--rb-terminal-fg); }
|
||||
|
||||
/* Diagrams written by scripts/sitediagrams.py follow the theme */
|
||||
.sl-markdown-content .rb-figure { margin: 1.2rem 0 1.6rem; }
|
||||
.sl-markdown-content .rb-figure svg { display: block; width: 100%; height: auto; max-width: 52rem; color: var(--sl-color-text); }
|
||||
.sl-markdown-content .rb-figure :is(figcaption, .rb-figure-caption) {
|
||||
display: block;
|
||||
max-width: 44em;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--rb-text-secondary);
|
||||
}
|
||||
.rb-diagram text { font-family: var(--sl-font); fill: currentColor; }
|
||||
.rb-diagram .rb-d-box { fill: var(--rb-diagram-fill); stroke: var(--rb-diagram-stroke); }
|
||||
.rb-diagram .rb-d-accent { stroke: var(--rb-diagram-accent); }
|
||||
.rb-diagram .rb-d-line { stroke: var(--rb-diagram-stroke); }
|
||||
.rb-diagram .rb-d-head { fill: var(--rb-diagram-stroke); }
|
||||
.rb-diagram .rb-d-num { fill: var(--rb-diagram-accent); }
|
||||
.rb-diagram .rb-d-numtext { fill: var(--sl-color-bg); }
|
||||
.rb-diagram .rb-d-muted { fill: var(--rb-text-secondary); }
|
||||
.rb-diagram-narrow { display: none !important; }
|
||||
@media (max-width: 37.5em) {
|
||||
.rb-diagram-wide { display: none !important; }
|
||||
.rb-diagram-narrow { display: block !important; }
|
||||
}
|
||||
|
||||
/* Glossary terms */
|
||||
abbr.rb-term {
|
||||
position: relative;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 0.2em;
|
||||
cursor: help;
|
||||
}
|
||||
abbr.rb-term[data-open]::after {
|
||||
content: attr(data-definition);
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: calc(100% + 0.3rem);
|
||||
left: 0;
|
||||
width: max-content;
|
||||
max-width: min(20rem, 80vw);
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Forms: lookup, picker, table filters */
|
||||
.rb-file-lookup,
|
||||
.rb-picker fieldset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.sl-markdown-content :is(.rb-file-lookup, .rb-picker) :is(input, select, button) {
|
||||
min-height: 44px;
|
||||
padding: 0 0.6rem;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-text);
|
||||
font: inherit;
|
||||
}
|
||||
.sl-markdown-content .rb-file-lookup input { flex: 1 1 20rem; font-family: var(--sl-font-mono); }
|
||||
.sl-markdown-content :is(.rb-file-lookup, .rb-picker) button {
|
||||
background: var(--rb-link);
|
||||
color: var(--sl-color-black);
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
}
|
||||
.rb-visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
.rb-table-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 28rem) auto;
|
||||
gap: 0.35rem 0.8rem;
|
||||
align-items: end;
|
||||
margin: 1rem 0 0.45rem;
|
||||
}
|
||||
.sl-markdown-content .rb-table-filter > * { margin: 0; }
|
||||
.rb-table-filter label { grid-column: 1 / -1; color: var(--rb-text-secondary); font-size: 0.78rem; font-weight: 700; }
|
||||
.rb-table-filter input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--rb-text-secondary);
|
||||
border-radius: 2px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-text);
|
||||
padding: 0.55rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
.rb-table-filter-count { align-self: center; color: var(--rb-text-secondary); font-size: 0.8rem; white-space: nowrap; }
|
||||
|
||||
/* 404 */
|
||||
.sl-markdown-content .rb-notfound-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0.55em 1.2em;
|
||||
border: 2px solid var(--rb-link);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--rb-link);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Licence line under every page */
|
||||
.rb-copyright { margin: 1.5rem 0 0; font-size: var(--sl-text-xs); color: var(--sl-color-gray-3); }
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
/* Tables with an action column become one block per row, so the button
|
||||
is never behind a horizontal scroll (site.ts marks them). */
|
||||
.sl-markdown-content table[data-rb-stack] { display: block; width: 100%; overflow: visible; }
|
||||
.sl-markdown-content table[data-rb-stack] thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
.sl-markdown-content table[data-rb-stack] :is(tbody, tr) { display: block; }
|
||||
.sl-markdown-content table[data-rb-stack] tr { padding: 0.5rem 0; border-top: 1px solid var(--rb-border); }
|
||||
.sl-markdown-content table[data-rb-stack] td {
|
||||
display: block;
|
||||
position: relative;
|
||||
min-height: 1.6rem;
|
||||
padding: 0.25rem 0.6rem 0.25rem 8.4rem;
|
||||
border: 0;
|
||||
text-align: left !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sl-markdown-content table[data-rb-stack] td::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0.6rem;
|
||||
width: 7.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--rb-text-secondary);
|
||||
}
|
||||
.sl-markdown-content table[data-rb-stack] td:empty { display: none; }
|
||||
|
||||
/* On a phone every command wraps: the end of a one-liner is never off-screen. */
|
||||
.sl-markdown-content .expressive-code pre > code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul,
|
||||
.rb-hash { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.rb-table-filter { grid-template-columns: 1fr; }
|
||||
.rb-table-filter label { grid-column: auto; }
|
||||
.rb-stats { grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); }
|
||||
.rb-stat-value { font-size: 1.1rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// axe-core (WCAG 2.2 A/AA rules) in light and dark, one line per page and scheme.
|
||||
// node axe.mjs <base url> <page>...
|
||||
import { chromium } from 'playwright-core';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const axeSource = await readFile(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||||
const [base, ...pages] = process.argv.slice(2);
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: scheme });
|
||||
const page = await context.newPage();
|
||||
for (const path of pages) {
|
||||
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
|
||||
await page.addScriptTag({ content: axeSource });
|
||||
const result = await page.evaluate(() =>
|
||||
window.axe.run(document, { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }));
|
||||
const violations = result.violations.map((v) => `${v.id}(${v.nodes.length})`);
|
||||
console.log(JSON.stringify({ page: path || '/', scheme, violations }));
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,89 @@
|
||||
// End-to-end check of the two interactive pages, in a real browser.
|
||||
// node check_tools.mjs <base url>
|
||||
import { chromium } from 'playwright-core';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const base = process.argv[2] ?? 'http://127.0.0.1:4321/retrobios/';
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
// Hash lookup: an MD5 lists every layer, a SHA1 permalink opens the card.
|
||||
await page.goto(`${base}files/?hash=490f666e1afb15b7362b406ed1cea246`, { waitUntil: 'networkidle' });
|
||||
const lookup = await page.textContent('#rb-file-lookup-status');
|
||||
assert.match(lookup, /match/);
|
||||
console.log('lookup md5:', lookup.slice(0, 160).replace(/\s+/g, ' '));
|
||||
await page.goto(`${base}files/?sha1=0555c6fae8906f3f09baf5988f00e55f88e9f30b`, { waitUntil: 'networkidle' });
|
||||
assert.ok(page.url().includes('systems/sony/#sha1-0555c6fae890'), page.url());
|
||||
console.log('permalink sha1 ->', page.url());
|
||||
|
||||
// Picker on Get your files: Batocera, PlayStation only.
|
||||
const transfers = [];
|
||||
page.on('response', async (response) => {
|
||||
if (response.url().includes('/data/picker/')) transfers.push([response.url(), (await response.body()).length]);
|
||||
});
|
||||
await page.goto(`${base}which-pack/?platform=batocera&system=sony-playstation`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.rb-pick-output:not([hidden])');
|
||||
const result = await page.textContent('.rb-pick-result');
|
||||
const command = await page.textContent('.rb-pick-cmd code');
|
||||
const rows = await page.locator('.rb-pick-output tbody tr').count();
|
||||
console.log('picker:', result);
|
||||
console.log('command:', command);
|
||||
console.log('rows:', rows, 'fetched:', transfers.map(([url, bytes]) => `${url.split('/').pop()} ${bytes} B`).join(', '));
|
||||
assert.match(command, /--platform batocera --system sony-playstation$/);
|
||||
assert.ok(rows > 0);
|
||||
|
||||
// Unchecking everything empties the result; "All" restores the full command.
|
||||
await page.click('[data-none]');
|
||||
assert.match(await page.textContent('.rb-pick-result'), /Choose at least one system/);
|
||||
await page.click('[data-all]');
|
||||
assert.match(await page.textContent('.rb-pick-cmd code'), /--platform batocera$/);
|
||||
|
||||
// A full hash typed in the search opens the lookup instead of text results.
|
||||
await page.goto(base, { waitUntil: 'networkidle' });
|
||||
await page.click('site-search button[data-open-modal]');
|
||||
const search = page.locator('site-search dialog input').first();
|
||||
await search.waitFor();
|
||||
await search.fill('490f666e1afb15b7362b406ed1cea246');
|
||||
await Promise.all([page.waitForURL(/files\/\?hash=/), search.press('Enter')]);
|
||||
console.log('hash in search ->', page.url());
|
||||
|
||||
// Breadcrumbs name the tab and the group of a deep page.
|
||||
await page.goto(`${base}emulators/duckstation/`, { waitUntil: 'networkidle' });
|
||||
const crumbs = await page.locator('.rb-breadcrumbs li').allTextContents();
|
||||
console.log('breadcrumbs:', crumbs.join(' / '));
|
||||
assert.deepEqual(crumbs.slice(0, 2), ['Explore', 'Emulators']);
|
||||
|
||||
// Previous and next run on from one tab into the next.
|
||||
await page.goto(`${base}wiki/glossary/`, { waitUntil: 'networkidle' });
|
||||
const next = await page.locator('.pagination-links a[rel="next"]').getAttribute('href');
|
||||
console.log('after the last Start here page:', next);
|
||||
assert.ok(next && next.endsWith('/platforms/'), next);
|
||||
|
||||
// A glossary term opens its definition on a tap.
|
||||
await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' });
|
||||
const term = page.locator('abbr.rb-term').first();
|
||||
await term.click();
|
||||
assert.equal(await term.getAttribute('data-open'), '');
|
||||
console.log('glossary term:', await term.textContent(), '=', await term.getAttribute('data-definition'));
|
||||
|
||||
// On a phone, a table whose rows end in a download is one block per row.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' });
|
||||
// Tables are set up as they near the screen.
|
||||
await page.locator('table:has(a[href*="/releases/download/"])').first().scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
const stacked = await page.locator('table[data-rb-stack]').count();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
|
||||
console.log('stacked tables:', stacked, 'page wider than the phone:', overflow);
|
||||
assert.ok(stacked > 0);
|
||||
assert.equal(overflow, false);
|
||||
|
||||
// A stale address offers the list its page may be in.
|
||||
await page.goto(`${base}emulators/no-such-core/`, { waitUntil: 'networkidle' });
|
||||
assert.match(await page.textContent('#rb-notfound-hint'), /list of emulators/);
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('ok');
|
||||
await browser.close();
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lighthouse mobile (default form factor) on a list of pages, one summary line each.
|
||||
|
||||
python lighthouse.py [--performance-only] <out dir> <name>=<base url> ... -- <page> ...
|
||||
|
||||
With all four categories, Lighthouse runs its accessibility checks inside the
|
||||
page during the same load, and those tasks count toward Total Blocking Time
|
||||
on a page of 15,000 elements. --performance-only measures the page alone.
|
||||
|
||||
Needs `npx lighthouse` and Chrome at /usr/bin/google-chrome.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CATEGORIES = ("performance", "accessibility", "best-practices", "seo")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
categories = CATEGORIES
|
||||
if args[0] == "--performance-only":
|
||||
args.pop(0)
|
||||
categories = ("performance",)
|
||||
out = Path(args.pop(0))
|
||||
split = args.index("--")
|
||||
engines = dict(item.split("=", 1) for item in args[:split])
|
||||
pages = args[split + 1:]
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
env = {**os.environ, "CHROME_PATH": "/usr/bin/google-chrome"}
|
||||
for name, base in engines.items():
|
||||
for page in pages:
|
||||
slug = page.strip("/").replace("/", "_") or "home"
|
||||
report = out / f"{name}-{slug}.json"
|
||||
# Under WSL chrome-launcher names its profile folder with a Windows path,
|
||||
# created relative to the working directory: run it inside the output folder.
|
||||
subprocess.run(
|
||||
["npx", "--prefix", str(Path(__file__).resolve().parent.parent), "lighthouse", base + page,
|
||||
"--quiet", "--output=json", f"--output-path={report.resolve()}",
|
||||
"--chrome-flags=--headless=new --no-sandbox",
|
||||
f"--only-categories={','.join(categories)}",
|
||||
# Extra flags for one experiment, space separated.
|
||||
*os.environ.get("LIGHTHOUSE_FLAGS", "").split()],
|
||||
env=env, cwd=out, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
data = json.loads(report.read_text())
|
||||
cats, audits = data["categories"], data["audits"]
|
||||
failed = [k for k, a in audits.items()
|
||||
if a.get("score") is not None and a["score"] < 1 and a.get("scoreDisplayMode") == "binary"]
|
||||
print(json.dumps({
|
||||
"engine": name, "page": page or "/",
|
||||
**{c: round(cats[c]["score"] * 100) for c in categories},
|
||||
"lcp": audits["largest-contentful-paint"]["displayValue"],
|
||||
"tbt": audits["total-blocking-time"]["displayValue"],
|
||||
"cls": audits["cumulative-layout-shift"]["displayValue"],
|
||||
"bytes": audits["total-byte-weight"]["displayValue"],
|
||||
# Lighthouse 13 moved the DOM size audit into an insight.
|
||||
"dom": (audits.get("dom-size") or audits.get("dom-size-insight") or {}).get("displayValue", ""),
|
||||
"failed": failed,
|
||||
}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,106 @@
|
||||
// Page weight and search measurements in a real browser.
|
||||
// node measure.mjs weight <base url> <page>...
|
||||
// node measure.mjs search <base url> starlight|mkdocs <query>...
|
||||
// Transfer sizes are bytes on the wire (gzip from serve.mjs), by type.
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const [mode, base, ...rest] = process.argv.slice(2);
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
|
||||
|
||||
const track = (page) => {
|
||||
const rows = [];
|
||||
page.on('response', async (response) => {
|
||||
const request = response.request();
|
||||
const headers = await response.allHeaders();
|
||||
let bytes = Number(headers['content-length'] ?? 0);
|
||||
if (!bytes) {
|
||||
try { bytes = (await response.body()).length; } catch { bytes = 0; }
|
||||
}
|
||||
rows.push({ url: response.url(), type: request.resourceType(), bytes });
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
const sum = (rows, test) => rows.filter(test).reduce((total, row) => total + row.bytes, 0);
|
||||
|
||||
if (mode === 'weight') {
|
||||
for (const path of rest) {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const page = await context.newPage();
|
||||
const rows = track(page);
|
||||
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(500);
|
||||
const dom = await page.evaluate(() => document.getElementsByTagName('*').length);
|
||||
const kb = (n) => (n / 1024).toFixed(1);
|
||||
console.log(JSON.stringify({
|
||||
page: path,
|
||||
html_kb: kb(sum(rows, (r) => r.type === 'document')),
|
||||
js_kb: kb(sum(rows, (r) => r.type === 'script')),
|
||||
css_kb: kb(sum(rows, (r) => r.type === 'stylesheet')),
|
||||
other_kb: kb(sum(rows, (r) => !['document', 'script', 'stylesheet'].includes(r.type))),
|
||||
total_kb: kb(sum(rows, () => true)),
|
||||
requests: rows.length,
|
||||
dom_nodes: dom,
|
||||
}));
|
||||
await context.close();
|
||||
}
|
||||
} else if (mode === 'search') {
|
||||
const [engine, ...queries] = rest;
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const rows = track(page);
|
||||
await page.goto(base, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(500);
|
||||
const before = rows.length;
|
||||
const first = [];
|
||||
for (const [n, query] of queries.entries()) {
|
||||
const mark = rows.length;
|
||||
let results;
|
||||
if (engine === 'starlight-ui') {
|
||||
// The reader's path: open the dialog, type, wait for the result list.
|
||||
if (n === 0) await page.click('button[data-open-modal]');
|
||||
const input = page.locator('dialog input[type="text"], dialog input[type="search"]').first();
|
||||
await input.fill(query);
|
||||
await page.waitForSelector('.pagefind-ui__result', { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
results = await page.evaluate(() => {
|
||||
const items = [...document.querySelectorAll('.pagefind-ui__result')];
|
||||
return {
|
||||
count: document.querySelector('.pagefind-ui__message')?.textContent ?? '',
|
||||
top: items.slice(0, 5).map((r) => `${r.querySelector('.pagefind-ui__result-link')?.textContent?.trim()} | ${r.querySelector('.pagefind-ui__result-link')?.getAttribute('href')}`),
|
||||
};
|
||||
});
|
||||
} else if (engine === 'starlight') {
|
||||
results = await page.evaluate(async ({ query, root, ranking }) => {
|
||||
if (!window.__pf) {
|
||||
window.__pf = await import(`${root}pagefind/pagefind.js`);
|
||||
if (ranking) await window.__pf.options({ ranking });
|
||||
}
|
||||
const search = await window.__pf.search(query);
|
||||
const top = await Promise.all(search.results.slice(0, 5).map((r) => r.data()));
|
||||
return { count: search.results.length, top: top.map((d) => `${d.meta.title} | ${d.url}`) };
|
||||
}, { query, root: new URL('/retrobios/', base).href, ranking: process.env.RB_RANKING ? JSON.parse(process.env.RB_RANKING) : null });
|
||||
} else {
|
||||
// Material: open the search through its share link (?q=) and read the result list.
|
||||
await page.goto(`${base}?q=${encodeURIComponent(query)}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForFunction(
|
||||
(q) => document.querySelector('input[data-md-component="search-query"]')?.value === q
|
||||
&& /matching|No matching/.test(document.querySelector('.md-search-result__meta')?.textContent ?? ''),
|
||||
query, { timeout: 30000 },
|
||||
);
|
||||
await page.waitForTimeout(800);
|
||||
results = await page.evaluate(() => {
|
||||
const items = [...document.querySelectorAll('.md-search-result__item > a')];
|
||||
const meta = document.querySelector('.md-search-result__meta')?.textContent ?? '';
|
||||
return { count: meta, top: items.slice(0, 5).map((a) => `${a.querySelector('h1,h2')?.textContent?.trim()} | ${a.getAttribute('href')}`) };
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
const transfer = sum(rows.slice(mark), () => true);
|
||||
if (n === 0) first.push(...rows.slice(before));
|
||||
console.log(JSON.stringify({ query, transfer_kb: (transfer / 1024).toFixed(1), ...results }));
|
||||
}
|
||||
console.log(JSON.stringify({ first_search_kb: (sum(first, () => true) / 1024).toFixed(1), first_search_requests: first.length }));
|
||||
await context.close();
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The search gate: queries a visitor types, and the pages that answer them.
|
||||
|
||||
Runs measure.mjs against a served build, through the site's own search box,
|
||||
and checks each query's expected pages against the rank it must reach: the
|
||||
first result, or the first three.
|
||||
|
||||
python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS = Path(__file__).resolve().parent
|
||||
|
||||
# Query: (pages that answer it, as path prefixes under the site, and the rank one
|
||||
# of them must reach). An emulator name must find the emulator itself first,
|
||||
# not its launcher or a frozen copy.
|
||||
QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
|
||||
"batocera folder": (("platforms/batocera/",), 1),
|
||||
"missing bios": (("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"), 1),
|
||||
"neogeo.zip": (("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"), 1),
|
||||
"retroarch system folder": (("platforms/retroarch/",), 1),
|
||||
"installer": (("wiki/installer/", "wiki/first-install/"), 1),
|
||||
# The file's card on its system page, where it is downloaded.
|
||||
"scph5501": (("systems/sony/",), 3),
|
||||
"md5": (("wiki/verification-modes/",), 1),
|
||||
"where do bios files go": (("which-pack/", "wiki/first-install/"), 1),
|
||||
"dolphin": (("emulators/dolphin/",), 1),
|
||||
"duckstation": (("emulators/duckstation/",), 1),
|
||||
"playstation bios": (("systems/sony/",), 3),
|
||||
"steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1),
|
||||
"knulli": (("platforms/knulli/",), 1),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321/retrobios/"
|
||||
root = "/" + base.split("/", 3)[3]
|
||||
run = subprocess.run(
|
||||
["node", str(TOOLS / "measure.mjs"), "search", base, "starlight-ui", *QUERIES],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
answered = 0
|
||||
for line in run.stdout.splitlines():
|
||||
result = json.loads(line)
|
||||
if "query" not in result:
|
||||
print(f"first search: {result['first_search_kb']} KB in {result['first_search_requests']} requests")
|
||||
continue
|
||||
pages, rank = QUERIES[result["query"]]
|
||||
top = [entry.split(" | ") for entry in result["top"][:3]]
|
||||
hit = any(href[len(root):].startswith(pages) for _title, href in top[:rank])
|
||||
answered += hit
|
||||
titles = "; ".join(title for title, _href in top)
|
||||
print(f"{'ok ' if hit else 'MISS'} {result['query']!r} (top {rank}): {titles}")
|
||||
print(f"{answered}/{len(QUERIES)} queries answered")
|
||||
return 0 if answered == len(QUERIES) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
// Raw Pagefind results with their scores, to see why a page ranks where it does.
|
||||
// node search_scores.mjs <base url> <query> [ranking json]
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const [base, query, ranking] = process.argv.slice(2);
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(base, { waitUntil: 'networkidle' });
|
||||
const rows = await page.evaluate(async ({ root, query, ranking, top }) => {
|
||||
const pagefind = await import(`${root}pagefind/pagefind.js`);
|
||||
if (ranking) await pagefind.options({ ranking: JSON.parse(ranking) });
|
||||
const search = await pagefind.search(query);
|
||||
const shown = search.results.slice(0, top);
|
||||
const data = await Promise.all(shown.map((result) => result.data()));
|
||||
return shown.map((result, index) => ({
|
||||
score: result.score.toFixed(3),
|
||||
url: data[index].url,
|
||||
words: data[index].word_count,
|
||||
hits: result.words.length,
|
||||
}));
|
||||
}, { root: base, query, ranking, top: Number(process.env.TOP || 8) });
|
||||
for (const row of rows) console.log(row.score, row.words, row.hits, row.url);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,51 @@
|
||||
// Static server shaped like GitHub Pages for measurements: gzip, no brotli,
|
||||
// max-age=600, the site under /retrobios/, real 404 page.
|
||||
// node serve.mjs <site dir> <port>
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
const [root = 'dist', port = '4321'] = process.argv.slice(2);
|
||||
const BASE = '/retrobios/';
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
|
||||
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
||||
'.xml': 'application/xml', '.txt': 'text/plain', '.wasm': 'application/wasm',
|
||||
};
|
||||
const COMPRESS = new Set(['.html', '.css', '.js', '.json', '.svg', '.xml', '.txt']);
|
||||
|
||||
const resolveFile = async (path) => {
|
||||
let file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, ''));
|
||||
try {
|
||||
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
|
||||
await stat(file);
|
||||
return file;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
if (!url.pathname.startsWith(BASE)) {
|
||||
res.writeHead(302, { location: BASE }).end();
|
||||
return;
|
||||
}
|
||||
const rel = decodeURIComponent(url.pathname.slice(BASE.length));
|
||||
let file = await resolveFile(rel);
|
||||
let status = 200;
|
||||
if (!file) {
|
||||
status = 404;
|
||||
file = await resolveFile('404.html');
|
||||
}
|
||||
const ext = extname(file);
|
||||
let body = await readFile(file);
|
||||
const headers = { 'content-type': TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'max-age=600' };
|
||||
if (COMPRESS.has(ext) && /\bgzip\b/.test(req.headers['accept-encoding'] ?? '')) {
|
||||
body = gzipSync(body);
|
||||
headers['content-encoding'] = 'gzip';
|
||||
}
|
||||
headers['content-length'] = body.length;
|
||||
res.writeHead(status, headers).end(body);
|
||||
}).listen(Number(port), '127.0.0.1');
|
||||
@@ -0,0 +1,13 @@
|
||||
// Screenshot of one element of a page, to look at a component closely.
|
||||
// node shot_element.mjs <url> <css selector> <out.png> [width] [light|dark]
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const [url, selector, out, width = '390', scheme = 'light'] = process.argv.slice(2);
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 }, colorScheme: scheme });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const element = page.locator(selector).first();
|
||||
await element.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(300);
|
||||
await element.screenshot({ path: out });
|
||||
await browser.close();
|
||||
@@ -0,0 +1,25 @@
|
||||
// Screenshots at 390 and 1440 px, light and dark.
|
||||
// node shots.mjs <base url> <out dir> <page>...
|
||||
import { chromium } from 'playwright-core';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
const [base, out, ...pages] = process.argv.slice(2);
|
||||
await mkdir(out, { recursive: true });
|
||||
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
|
||||
for (const scheme of ['light', 'dark']) {
|
||||
for (const width of [390, 1440]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: scheme,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
for (const path of pages) {
|
||||
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
|
||||
const name = path.replace(/^\/|\/$/g, '').replace(/[/?=&]+/g, '_') || 'home';
|
||||
await page.screenshot({ path: `${out}/${name}-${width}-${scheme}.png`, fullPage: false });
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -143,7 +143,8 @@ Running `verify.py --emulator <core> --verbose` shows the emulator-level ground
|
||||
|
||||
It reaches only as far as the profiles do. Files no profile states a value for are platform-only, and nothing can be said about their content. The footer of the platform report gives that ratio as `Ground truth: N/M files have emulator validation`.
|
||||
|
||||
!!! tip "Next"
|
||||
:::tip[Next]
|
||||
- [Reference index](index.md), every reference page in one list
|
||||
- For contributors: [How to contribute](../contributing.md)
|
||||
:::
|
||||
|
||||
- [Reference index](index.md), every reference page in one list
|
||||
- For contributors: [How to contribute](../contributing.md)
|
||||
+20
-4
@@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
|
||||
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
@@ -287,7 +287,23 @@ The test suite is one layer of verification. The full quality gate is:
|
||||
3. No unexpected CRITICAL entries in the verify output
|
||||
4. Pack file counts match verification file counts (consistency check)
|
||||
5. Pack integrity passes (every declared file extractable with correct hash)
|
||||
6. `mkdocs build --strict` and `python scripts/validate_site.py` pass
|
||||
6. `npm run build --prefix website` and `python scripts/validate_site.py` pass
|
||||
|
||||
A change to the site engine, its version, its search ranking or the page
|
||||
weights is measured on a served build before it lands, with the scripts in
|
||||
`website/tools/` (they need `npm install --no-save playwright-core axe-core
|
||||
lighthouse` and Chrome):
|
||||
|
||||
- `search_gate.py`: the queries a visitor types and the page that must answer
|
||||
each, read from the site's own search box
|
||||
- `lighthouse.py`: Lighthouse mobile on a list of pages, the heaviest ones
|
||||
included
|
||||
- `axe.mjs`: WCAG 2.2 AA in light and dark
|
||||
- `check_tools.mjs`: the hash lookup, the file picker, a hash typed in the
|
||||
search, breadcrumbs, previous and next across tabs, glossary terms, tables
|
||||
on a phone and the 404 page, in a real browser
|
||||
- `serve.mjs`: the build served under /retrobios/ with gzip, as GitHub Pages
|
||||
serves it
|
||||
|
||||
A change that passes the tests but breaks the pipeline is not merged. A new
|
||||
CRITICAL entry in the verify output after a change is explained before the
|
||||
@@ -380,7 +396,7 @@ nav_title: Save files # only when the menu names it otherwise than its headin
|
||||
- `level` picks the tab and the label shown at the top of the page.
|
||||
- `order` sorts the tab. Pages are spaced by ten, so a new page fits between
|
||||
two without renumbering. The generated pages (systems, emulators, the gaps
|
||||
and data pages) hold fixed places among them, set in `generate_mkdocs_nav`.
|
||||
and data pages) hold fixed places among them, set in `generate_nav`.
|
||||
- `nav_title` is the menu label. Without it the menu shows the page's `# `
|
||||
heading. It is not `title`, which both MkDocs and Astro Starlight read as
|
||||
the page's own title.
|
||||
@@ -419,7 +435,7 @@ The `validate.yml` workflow runs `python -m unittest discover -s tests -t . -v`
|
||||
roads into main: every pull request, and every direct push, that touches
|
||||
`bios/`, `platforms/`, `emulators/`, `schemas/`, `scripts/`, `tests/`,
|
||||
`install.py` or an input of the site build such as `wiki/`. The same job then
|
||||
builds the site with `mkdocs build --strict`, so a page that would fail the
|
||||
builds the site with `npm run build --prefix website`, so a page that would fail the
|
||||
deploy fails the pull request. Work reaches main by push as often as by pull request, so a suite
|
||||
wired to pull requests alone would guard the road nobody takes. The test job
|
||||
(`run-tests`) runs in parallel with schema validation, and on a pull request
|
||||
|
||||
+3
-3
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user