feat: build the site with Astro Starlight

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

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

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

No files matched your search

+1 -1
View File
@@ -219,7 +219,7 @@ def hold_artifact_lock(directory: str) -> Iterator[None]:
# What a pipeline run writes at fixed paths, whatever its --output-dir.
REPOSITORY_OUTPUTS = ("database.json", "install/", "README.md", "CONTRIBUTING.md", "docs/")
REPOSITORY_OUTPUTS = ("database.json", "install/", "README.md", "CONTRIBUTING.md", "website/")
@contextlib.contextmanager
+4 -5
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Generate slim README.md from database.json and platform configs.
Detailed documentation lives on the MkDocs site (abdess.github.io/retrobios/).
Detailed documentation lives on the site (abdess.github.io/retrobios/).
This script produces a concise landing page with download links and coverage.
Usage:
@@ -237,7 +237,7 @@ def download_links(
A pack published whole gets one link labelled *label*. A pack in parts
gets one link per part, since every part is needed. *repo* is the
repository URL, absolute or relative, and *attrs* a MkDocs attribute list
repository URL, absolute or relative, and *attrs* an attribute list
put after each link, or *button* the function that draws each link as a
button on the site. Without a recorded release the link is the latest
release page. A release recorded without this platform's pack says so and
@@ -837,7 +837,7 @@ def contributing_text(
Two copies drifted apart: four steps to add a platform here, five on the
site. *link* renders a site page (absolute URL for the README, relative
.md for MkDocs); *site_words* names the site from where the text is read.
.md for the site); *site_words* names the site from where the text is read.
"""
return f"""{title}
@@ -900,8 +900,7 @@ validation above is its check. For code, profiles or platform files, these are
the checks CI runs on a pull request (Python 3.12 or newer):
```bash
pip install pyyaml "jsonschema[format-nongpl]" markdown \\
"mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14"
pip install pyyaml "jsonschema[format-nongpl]" markdown
python scripts/validate_pr.py --changed # the BIOS files the branch adds
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
+116 -272
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3
"""Generate MkDocs site pages from database.json, platform configs, and emulator profiles.
"""Generate the site from database.json, platform configs, and emulator profiles.
The pages, navigation and data files are written into the Astro Starlight
project in website/, which builds the site.
Reads the same data sources as verify.py and generate_pack.py to produce
a complete documentation site. Zero manual content.
@@ -91,7 +94,6 @@ from siteguides import (
)
import release_record
DOCS_DIR = "docs"
# The Starlight project, and where its pages go inside it.
STARLIGHT_DIR = "website"
STARLIGHT_CONTENT = "src/content/docs"
@@ -453,15 +455,10 @@ def generate_home(
"",
"## Three steps",
"",
*(markup.steps([
*markup.steps([
"**Pick your device.** Its page shows your BIOS folder.",
"**Run one command**, or download a ZIP file.",
"**Start a game.**",
]) if markup.starlight() else [
"1. **Pick your device.** Its page shows your BIOS folder.",
"2. **Run one command**, or download a ZIP file.",
"3. **Start a game.**",
"",
]),
"## Pick your device",
"",
@@ -1840,7 +1837,34 @@ LEVELS = {
"reference": "Reference",
"contribute": "For contributors",
}
SEARCH_BOOST = {"start": 5, "setup": 3}
# How much the site search weighs the words of a page (Pagefind's
# data-pagefind-weight, 1 by default). Each value was measured on the
# queries of website/tools/search_gate.py.
# - Start here pages answer a beginner's words first.
# - A system page holds the file cards themselves: a file name finds its
# card before a guide that cites the name as an example.
# - A setup guide is found by its name (the page title weighs it).
# - A guide behind the first two levels (reference, options, contributing)
# names everything a reader types, and ranks after the page that answers.
# - A launcher, an alias or a frozen copy answers a name after the emulator.
SEARCH_WEIGHT = {"start": 10, "setup": 1.5, "reference": 0.5, "contribute": 0.5}
SYSTEM_PAGE_WEIGHT = 5
DEEP_GUIDE_WEIGHT = 0.5
SEARCH_DEMOTED: set[str] = set()
DEMOTED_WEIGHT = 0.5
def search_weight(relative: Path, level: str) -> float | None:
"""The search weight of a page, None for the default."""
if relative.as_posix() in SEARCH_DEMOTED:
return DEMOTED_WEIGHT
if relative.parts[0] == "systems":
return SYSTEM_PAGE_WEIGHT
if relative.parts[0] == "wiki" and level not in ("start", "setup"):
return DEEP_GUIDE_WEIGHT
return SEARCH_WEIGHT.get(level)
_REFERENCE_PAGES = {
"gaps.md", "cross-reference.md", "provenance.md", "exports.md", "data.md",
"files/index.md", "wiki/data-model.md",
@@ -1894,6 +1918,10 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
continue
original = path.read_text(encoding="utf-8")
authored = _front_matter(original)
if "head" in authored:
# Written in its final form by an earlier pass and rewritten by
# nothing since: a second pass would find no title to read.
continue
markdown = prepare_page(original, relative)
for image_id in unresolved_images(markdown):
print(f" WARNING: {relative}: no diagram named {image_id!r}", file=sys.stderr)
@@ -1931,42 +1959,22 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
},
**PAGE_DATA.get(relative.as_posix(), {}),
}
if markup.starlight():
target, text = sitestarlight.finalize_page(
relative.as_posix(), markdown, level=level,
boost=SEARCH_BOOST.get(level), browser_title=browser_title,
description=description, structured=structured, terms=terms,
search=authored.get("search") != "exclude",
)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
# changed extension, and the other one would claim its address.
stem = relative.with_suffix("").as_posix()
for suffix in (".md", ".mdx"):
if f"{stem}{suffix}" != target:
(docs / f"{stem}{suffix}").unlink(missing_ok=True)
if structured.get("dateModified"):
lastmod[sitestarlight.page_url(target)] = str(structured["dateModified"])
continue
structured_json = json.dumps(
structured, ensure_ascii=False, separators=(",", ":")
).replace("</", "<\\/")
front_matter = (
"---\n"
"generated_by: retrobios-site\n"
f"title: {json.dumps(browser_title, ensure_ascii=False)}\n"
f"description: {json.dumps(description, ensure_ascii=False)}\n"
+ (f"level: {level}\n" if level else "")
+ (f"search:\n boost: {SEARCH_BOOST[level]}\n" if level in SEARCH_BOOST else "")
+ (f"date_modified: {json.dumps(str(structured['dateModified']))}\n"
if structured.get("dateModified") else "")
+ "---\n\n"
'<script type="application/ld+json">\n'
f"{structured_json}\n"
"</script>\n\n"
+ (f"{level_label(level)}\n\n" if level else "")
target, text = sitestarlight.finalize_page(
relative.as_posix(), markdown, level=level,
weight=search_weight(relative, level),
browser_title=browser_title,
description=description, structured=structured, terms=terms,
search=authored.get("search") != "exclude",
)
write_decorated(path, front_matter + markdown)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
# changed extension, and the other one would claim its address.
stem = relative.with_suffix("").as_posix()
for suffix in (".md", ".mdx"):
if f"{stem}{suffix}" != target:
(docs / f"{stem}{suffix}").unlink(missing_ok=True)
if structured.get("dateModified"):
lastmod[sitestarlight.page_url(target)] = str(structured["dateModified"])
return lastmod
@@ -3317,8 +3325,16 @@ def _system_file_card(
# looking up scph5501.bin found every platform but RetroBat, whose
# pack installs it for six cores.
lines.append(f"- Also installed by: {', '.join(installed)}")
# The title stays searchable: the file name is what a reader types.
lines[4:] = search_excluded(lines[4:])
# The title stays searchable: the file name is what a reader types. On
# the site the platforms line does too: it names the file as each setup
# names it, and with the name only in its title the card ranked below
# every guide citing scph5501 as an example. The size yardstick keeps
# its one block.
at = next((i for i, line in enumerate(lines) if line.startswith("- Platforms:")), None)
if markup.starlight() and searchable and at is not None:
lines[4:] = [*search_excluded(lines[4:at]), lines[at], *search_excluded(lines[at + 1:])]
else:
lines[4:] = search_excluded(lines[4:])
lines.extend(["", "</div>", ""])
return lines
@@ -3405,14 +3421,14 @@ def _search_skipped_apart(lines: list[str]) -> list[str]:
return out
def _card_title(text: str, anchor: str, searchable: bool = True) -> str:
def _card_title(text: str, anchor: str, searchable: bool = True, weight: int = 10) -> str:
"""A file card's heading, so the site search lands on the card.
The search splits a page at its headings: a card titled in bold sent a
pasted md5 to the top of a page of a hundred cards. Level 3, under
the console or the Files heading: a level 4 after a level 2 skipped
one for screen readers on 551 pages. The table of contents of a page
of cards stops above it (mkdocs_hooks.py), and a card without an
of cards stops above it (sitestarlight.py), and a card without an
anchor keeps the id the toc gives it.
"""
if markup.starlight():
@@ -3420,11 +3436,12 @@ def _card_title(text: str, anchor: str, searchable: bool = True) -> str:
# would add the engine's anchor link and icon to every card.
title = re.sub(r"`([^`]+)`", lambda m: f"<code>{html.escape(m[1], quote=False)}</code>", text)
ident = f' id="{anchor}"' if anchor else ""
# The card is the file's own page in all but name: its title weighs
# as a page title does, so a file name typed in the search finds
# the card before a guide that cites it as an example.
weight = ' data-pagefind-weight="7"' if searchable else f" {markup.search_exclude()}"
return f'<h3{ident} class="rb-file-title"{weight}>{title}</h3>'
# A system page's card is the file's own page in all but name: its
# title weighs the most a word can, so a file name typed in the
# search finds the card before a guide that cites it as an example.
# An emulator's card says who reads the file, and weighs half.
search = f' data-pagefind-weight="{weight}"' if searchable else f" {markup.search_exclude()}"
return f'<h3{ident} class="rb-file-title"{search}>{title}</h3>'
attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title"
if not searchable:
attributes += " data-search-exclude"
@@ -3574,8 +3591,8 @@ def system_pages(
def card_weight(card: list[str]) -> int:
return sum(len(line) + 1 for line in card)
# Sizes are those of the MkDocs Markdown the budget was tuned on, so
# every engine splits the same pages at the same cards.
# Sizes are those of the MkDocs Markdown the budget was set on, so the
# pages split where they always have and no card changes address.
with markup.using("mkdocs"):
measured = all_sections()
measured_text = whole_page(measured)
@@ -3667,7 +3684,7 @@ def _part_range(part: list[Card]) -> str:
"""The first and last file names of a part, as the cards show them."""
names = []
for _variant, card, _sha1 in part:
# A Markdown heading under MkDocs, an HTML one under Starlight.
# An HTML heading on the site, a Markdown one in the size yardstick.
match = re.search(r"`([^`]*)`|<code>(.*?)</code>", card[2])
names.append(match[1] if match[1] is not None else html.unescape(match[2]))
if len(names) == 1:
@@ -4025,7 +4042,7 @@ def _render_emulator_file(
lines.append(markup.block_open("div", 'class="rb-file-entry"'))
lines.append("")
marker = f'<span id="{alias}"></span>' if alias else ""
lines.append(_card_title(f"{marker}{_code(fname)} {badge_str}", anchor))
lines.append(_card_title(f"{marker}{_code(fname)} {badge_str}", anchor, weight=5))
lines.append("")
if desc:
lines.extend([_escape_tags(str(desc)), ""])
@@ -4845,7 +4862,7 @@ def emulator_pages(
# The member tables are a file list with hashes: out of the search,
# dolphin, primehack and ishiiruka each put 210 KB of it there.
lines.extend([
"## Data directories" + (" { data-search-exclude }" if not markup.starlight() else ""),
"## Data directories",
"",
])
trees_start = len(lines)
@@ -4895,10 +4912,8 @@ def emulator_pages(
"info", f"{_count(len(members), 'file')}, as the packs ship them",
table, indent=" "))
lines.append("")
if markup.starlight():
# The member tables are the lookup's, as the heading's attribute
# keeps them under MkDocs.
lines[trees_start:] = search_excluded(lines[trees_start:])
# The member tables are the lookup's.
lines[trees_start:] = search_excluded(lines[trees_start:])
lines.extend(_next_block([
"[Get your files](../which-pack.md), for the setup you play on",
@@ -6125,23 +6140,7 @@ def _system_emulators(
if names & data.get("names", set())]
def _mkdocs_config(docs: Path, text: str) -> tuple[Path, str]:
"""Where the site's config goes, and its text for that place.
The tracked mkdocs.yml belongs to docs/. A preview into another folder
rewrote it anyway, with the navigation of work in progress, and mkdocs
kept reading docs/. Another folder gets its own config beside it, its
paths taken from there.
"""
if os.path.normpath(str(docs)) == DOCS_DIR:
return Path("mkdocs.yml"), text
where = docs.parent
for relative in ("scripts/mkdocs_hooks.py", "docs_assets/overrides"):
text = text.replace(relative, os.path.relpath(relative, where).replace(os.sep, "/"))
return where / f"{docs.name}.mkdocs.yml", f"docs_dir: {docs.name}\n" + text
# mkdocs.yml nav generator
# Navigation
_OS_NAMES = {"windows": "Windows", "darwin": "macOS", "linux": "Linux"}
@@ -6332,13 +6331,10 @@ def generate_which_pack() -> str:
"",
"Run the command and press **c** when it asks.",
"",
*([
"Or choose your setup and consoles here. You get the list of files "
"and a command that installs only those.",
"",
"<FilePicker />",
"",
] if markup.starlight() else []),
"Or pick them here, and get the files and the command.",
"",
"<FilePicker />",
"",
*_next_block([
"[First install, step by step](wiki/first-install.md)",
"[When a game does not start](wiki/troubleshooting.md)",
@@ -6486,7 +6482,7 @@ def _nav_target(entry: object) -> str:
return entry if isinstance(entry, str) else str(next(iter(entry.values())))
def generate_mkdocs_nav(
def generate_nav(
coverages: dict,
manufacturers: dict,
profiles: dict,
@@ -6543,7 +6539,7 @@ def generate_mkdocs_nav(
def main() -> None:
parser = argparse.ArgumentParser(
allow_abbrev=False,
description="Generate MkDocs site from project data"
description="Generate the documentation site from project data"
)
parser.add_argument(
"--db", default="database.json",
@@ -6557,24 +6553,18 @@ def main() -> None:
"--emulators-dir", default="emulators",
help="emulator profile directory (default: %(default)s)",
)
parser.add_argument(
"--engine", choices=markup.ENGINES, default="mkdocs",
help="site engine the pages are written for (default: %(default)s)",
)
parser.add_argument(
"--site-dir", default=STARLIGHT_DIR,
help="the Starlight project, with --engine starlight (default: %(default)s)",
help="the Astro Starlight project the site is written into (default: %(default)s)",
)
parser.add_argument(
"--docs-dir", default=None,
help="directory the pages are written to (default: docs, or the "
"Starlight project's content collection)",
help="directory the pages are written to (default: the project's "
"content collection, src/content/docs)",
)
args = parser.parse_args()
markup.set_engine(args.engine)
if args.docs_dir is None:
args.docs_dir = (str(Path(args.site_dir) / STARLIGHT_CONTENT)
if markup.starlight() else DOCS_DIR)
args.docs_dir = str(Path(args.site_dir) / STARLIGHT_CONTENT)
try:
# Two runs over one docs/ delete each other's pages and scratch
# files in the sweep, so the second is refused instead.
@@ -6596,38 +6586,25 @@ def _generate(args: argparse.Namespace) -> None:
db = load_database(args.db)
docs = Path(args.docs_dir)
# Files the site serves as they are (data, downloads, images): beside the
# pages under MkDocs, in the project's public folder under Starlight.
public = Path(args.site_dir) / "public" if markup.starlight() else docs
# Files the site serves as they are (data, downloads, images), in the
# project's public folder.
public = Path(args.site_dir) / "public"
for d in GENERATED_DIRS:
(public / d if d in PUBLIC_DIRS else docs / d).mkdir(parents=True, exist_ok=True)
if not markup.starlight():
css_src = Path("docs_assets") / "extra.css"
css_dest = docs / "stylesheets" / "extra.css"
if css_src.exists():
css_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(css_src, css_dest)
js_src = Path("docs_assets") / "site.js"
js_dest = docs / "javascripts" / "site.js"
if js_src.exists():
js_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(js_src, js_dest)
# No robots.txt: a crawler reads it only at the host root (RFC 9309), and
# this project site lives under /retrobios/. Every page's head points at
# the sitemap instead (overrides/main.html).
# the sitemap instead (Starlight's head links it).
(public / "robots.txt").unlink(missing_ok=True)
images_dest = public / "assets" / "images"
images_dest.mkdir(parents=True, exist_ok=True)
# Logos, system icons and sharing cards are served from the site itself
# (docs_assets/*/sources.json records where each came from).
folders = [("logos", "logos"), ("system-icons", "system-icons"), ("og", "og")]
if markup.starlight():
# The theme's own folder under MkDocs: the mark, favicons, touch icon.
folders.append(("overrides/assets/brand", "brand"))
# brand: the mark, favicons and touch icon (scripts/make_site_icons.py).
folders = [("logos", "logos"), ("system-icons", "system-icons"), ("og", "og"),
("brand", "brand")]
for folder, dest in folders:
source = Path("docs_assets") / folder
if source.is_dir():
@@ -6799,6 +6776,9 @@ def _generate(args: argparse.Namespace) -> None:
data_dirs=page_data_directories(name, profiles),
)
PAGE_DATA[f"emulators/{name}.md"] = emulator_structured_data(profile)
if (profile.get("type") in ("launcher", "alias")
or profile.get("core_classification") in ("launcher", "alias", "frozen_snapshot")):
SEARCH_DEMOTED.add(f"emulators/{name}.md")
files = profile.get("files") or []
for stem, entries in emulator_parts(name, files):
PAGE_DATA[f"emulators/{stem}.md"] = emulator_structured_data(
@@ -6846,12 +6826,7 @@ def _generate(args: argparse.Namespace) -> None:
wiki_src = Path(WIKI_SRC_DIR)
if wiki_src.is_dir():
for src_file in wiki_src.glob("*.md"):
if markup.starlight():
# Written in Material syntax until the wiki is written for Starlight.
write_if_changed(str(wiki_dest / src_file.name),
sitestarlight.wiki_page(src_file.read_text(encoding="utf-8")))
else:
shutil.copy2(src_file, wiki_dest / src_file.name)
shutil.copy2(src_file, wiki_dest / src_file.name)
_record(wiki_dest / src_file.name)
# data-model.md is generated (contains live DB stats)
write_if_changed(
@@ -6870,163 +6845,32 @@ def _generate(args: argparse.Namespace) -> None:
stale = _sweep_generated(docs, [d for d in GENERATED_DIRS if d not in PUBLIC_DIRS])
stale += _sweep_generated(public, list(PUBLIC_DIRS))
if markup.starlight():
write_if_changed(str(docs / "404.md"), generate_not_found())
write_if_changed(str(docs / "404.md"), generate_not_found())
print("Adding page metadata and structured data...")
lastmod = decorate_markdown_pages(docs)
nav = generate_mkdocs_nav(coverages, manufacturers, profiles, registry,
nav = generate_nav(coverages, manufacturers, profiles, registry,
Path(WIKI_SRC_DIR))
if markup.starlight():
print("Writing the Starlight navigation and the picker's data...")
site = Path(args.site_dir)
present = {
path.relative_to(docs).with_suffix(".md").as_posix()
for path in docs.rglob("*.md*") if path.suffix in (".md", ".mdx")
} - {"404.md"}
generated = site / "src" / "generated"
generated.mkdir(parents=True, exist_ok=True)
write_if_changed(str(generated / "sidebar.json"),
json.dumps(sitestarlight.sidebar(nav, present), indent=1) + "\n")
write_if_changed(str(generated / "lastmod.json"),
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
sitestarlight.picker_data(_REPO_ROOT / "install", public / "data" / "picker")
total_pages = len(present)
print(f"\nGenerated {total_pages} pages in {args.docs_dir}/")
if stale:
print(f"Removed {stale} page(s) nothing produced this run")
return
# Update mkdocs.yml nav section only (avoid yaml.dump round-trip mangling quotes)
print("Updating mkdocs.yml nav...")
nav_yaml = yaml.dump(
{"nav": nav}, default_flow_style=False, sort_keys=False, allow_unicode=True
)
# Rewrite mkdocs.yml entirely (static config + generated nav)
mkdocs_static = """\
site_name: RetroBIOS
site_description: Source-verified BIOS and firmware packs for RetroArch, Batocera,
Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX,
KNULLI, AmberELEC, EmuELEC and MiSTer FPGA.
site_url: https://abdess.github.io/retrobios/
repo_url: https://github.com/Abdess/retrobios
repo_name: Abdess/retrobios
# Almost every page is generated from platforms/, emulators/ and database.json,
# so a per-page edit link would point at a file that does not exist.
edit_uri: ''
# lastmod in the sitemap from each page's own date, or none at all.
hooks:
- scripts/mkdocs_hooks.py
# Working notes kept under docs/ during development are not part of the site.
exclude_docs: |
plans/
specs/
copyright: MIT for the tooling. BIOS and firmware files are third-party system
software, preserved for personal backup, archival and interoperability.
theme:
name: material
# Open Graph tags live in overrides/main.html: the social plugin that
# would otherwise emit them needs Pillow and CairoSVG only to render a
# preview image the pages do not need.
custom_dir: docs_assets/overrides
palette:
- media: (prefers-color-scheme)
toggle:
icon: material/brightness-auto
name: Switch to light mode
- media: '(prefers-color-scheme: light)'
scheme: default
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: '(prefers-color-scheme: dark)'
scheme: slate
toggle:
icon: material/brightness-4
name: Switch to auto
font: false
logo: assets/brand/logo.svg
favicon: assets/brand/favicon-32.png
icon:
logo: material/chip
features:
- navigation.instant
- navigation.instant.prefetch
- navigation.instant.progress
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.top
- navigation.tracking
- navigation.indexes
# Breadcrumbs above the title: on a deep page they say which tab it is in.
- navigation.path
# 400+ pages: pruning keeps the navigation out of every page's HTML.
- navigation.prune
- navigation.footer
- search.suggest
- search.highlight
- search.share
- content.code.copy
- content.tabs.link
- toc.follow
extra_css:
- stylesheets/extra.css
extra_javascript:
- javascripts/site.js
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/Abdess/retrobios
name: RetroBIOS on GitHub
markdown_extensions:
- abbr
- admonition
- attr_list
- def_list
- footnotes
- meta
- md_in_html
- tables
- toc:
permalink: true
# Level 3 is the wiki's subsections. On pages of file cards, also level
# 3, the hook keeps the table of contents to level 2.
toc_depth: 3
- pymdownx.details
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.keys
# No mermaid fence: Material drew it in the browser with a script from a
# CDN. Diagrams are SVG files written by scripts/sitediagrams.py.
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
plugins:
- search
# Link rot fails the build: deploy-site.yml runs `mkdocs build --strict`.
# omitted_files stays at its default (info) so a stale local docs/ does not
# break a local build. CI regenerates docs/ from scratch anyway.
validation:
absolute_links: warn
unrecognized_links: warn
anchors: warn
"""
config_path, config_text = _mkdocs_config(Path(docs), mkdocs_static + nav_yaml)
write_if_changed(str(config_path), config_text)
# Counted on disk: the split pages made a sum of page kinds wrong.
total_pages = sum(
1 for path in docs.rglob("*.md")
if not any(part in LOCAL_NOTE_DIRS for part in path.relative_to(docs).parts[:-1])
)
print("Writing the navigation and the picker's data...")
site = Path(args.site_dir)
present = {
path.relative_to(docs).with_suffix(".md").as_posix()
for path in docs.rglob("*.md*") if path.suffix in (".md", ".mdx")
} - {"404.md"}
generated = site / "src" / "generated"
generated.mkdir(parents=True, exist_ok=True)
write_if_changed(str(generated / "sidebar.json"),
json.dumps(sitestarlight.sidebar(nav, present), indent=1) + "\n")
write_if_changed(str(generated / "lastmod.json"),
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
sitestarlight.picker_data(_REPO_ROOT / "install", public / "data" / "picker")
total_pages = len(present)
print(f"\nGenerated {total_pages} pages in {args.docs_dir}/")
if stale:
print(f"Removed {stale} page(s) nothing produced this run")
from sitediagrams import prepare_page, unresolved_images # noqa: E402,F401
from sitewrite import ( # noqa: E402,F401
_produced,
+3 -3
View File
@@ -11,8 +11,8 @@ four pins per side and the pixel glyph on a 4 by 4 grid. Three variants:
shapes rasterised by a headless Chromium through Playwright, for browsers
that take no SVG icon and for home-screen shortcuts.
The files land in ``docs_assets/overrides/assets/brand/``, which MkDocs copies
with the theme. The site build never runs this script.
The files land in ``docs_assets/brand/``, which generate_site.py copies into
the site. The site build never runs this script.
python scripts/make_site_icons.py
"""
@@ -22,7 +22,7 @@ from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "docs_assets" / "overrides" / "assets" / "brand"
OUT = ROOT / "docs_assets" / "brand"
HEADER = "#3b3f7a"
GLYPH = "#8be0f7"
-37
View File
@@ -1,37 +0,0 @@
"""MkDocs hooks for the generated site.
The sitemap gave all 664 pages the build date as lastmod, so the field told
a crawler nothing. A page whose content carries its own date (an emulator
page: the day its profile was last read against the source) states it in
its front matter. The rest get no lastmod rather than an inexact one.
"""
from __future__ import annotations
from typing import Any
# Pages made of file cards, level 3 headings by the hundred: their table of
# contents stops at level 2, the consoles of a system page, the Files of an
# emulator, the sections of a platform page (which listed every system id).
CARD_PAGES = ("systems/", "emulators/", "platforms/")
def on_page_markdown(markdown: str, page: Any, config: Any, files: Any) -> str: # noqa: ARG001
"""Take lastmod from the page's own date, or leave it out."""
page.update_date = str(page.meta.get("date_modified") or "")
return markdown
def _prune(items: list, depth: int) -> None:
for item in items:
if item.level >= depth:
item.children = []
else:
_prune(item.children, depth)
def on_page_content(html: str, page: Any, config: Any, files: Any) -> str: # noqa: ARG001
"""Keep the table of contents of a page of cards to level 2."""
if page.file.src_uri.startswith(CARD_PAGES):
_prune(page.toc.items, 2)
return html
+8 -6
View File
@@ -24,8 +24,8 @@ Steps, numbered as the run prints them:
5 consistency check verify == pack: counts, paths, every emulator pack
6 --verify-packs pack integrity, extraction and hashes
7 generate_readme.py README.md and CONTRIBUTING.md
8 generate_site.py MkDocs pages
8b mkdocs build --strict rendered as the site deploy renders it, then validate_site.py
8 generate_site.py site pages, into the Starlight project in website/
8b npm run build (website/) rendered as the site deploy renders it, then validate_site.py
--offline skips 2 to 2b2 and the network side of 2e and 4: release assets
come from .cache/large/ only.
@@ -1102,13 +1102,15 @@ def main() -> None:
if args.skip_docs:
skip_notice("render_site", "--skip-docs")
results["render_site"] = SKIPPED
elif shutil.which("mkdocs") is None:
skip_notice("render_site", "mkdocs not installed")
elif shutil.which("npm") is None or not Path("website/node_modules").is_dir():
skip_notice("render_site", "Node not set up: npm ci --prefix website")
results["render_site"] = SKIPPED
else:
ok, _ = run(["mkdocs", "build", "--strict"], STEP_LABELS["render_site"])
# Starlight's link check runs inside the build and fails it.
ok, _ = run(["npm", "run", "build", "--prefix", "website"], STEP_LABELS["render_site"])
if ok:
ok, _ = run([sys.executable, "scripts/validate_site.py"], "8b validate site")
ok, _ = run([sys.executable, "scripts/validate_site.py", "--site-dir", "website/dist"],
"8b validate site")
results["render_site"] = ok
all_ok = all_ok and ok
+13 -6
View File
@@ -75,8 +75,15 @@ def requirement_lines(report: dict) -> list[str]:
]
if not items:
return []
return markup.details("note", f"Required flags aligned with the code ({len(items)})",
[f"- {item}" for item in items])
return _lookup_only(markup.details(
"note", f"Required flags aligned with the code ({len(items)})",
[f"- {item}" for item in items]))
def _lookup_only(block: list[str]) -> list[str]:
"""A list of entries changed, out of the site search: 129 "md5" in them
put this page above the one that explains MD5 checks."""
return ["", markup.block_open("section", markup.search_exclude()), "", *block, "", "</section>", ""]
def _plural(count: int, noun: str, plural: str = "") -> str:
@@ -139,8 +146,8 @@ def candidate_lines(candidates: list[dict]) -> list[str]:
title = (reason[:1].upper() + reason[1:]).replace('"', "'")
items = [f"`{c['system']}` `{c['destination']}`" for c in entries]
if len(entries) > LONG_LIST:
lines.extend(markup.details("note", f"{title} ({len(entries)})",
[f"- {item}" for item in items]))
lines.extend(_lookup_only(markup.details(
"note", f"{title} ({len(entries)})", [f"- {item}" for item in items])))
else:
lines.extend([f"{title}:", "", *[f"- {item}" for item in items], ""])
return lines
@@ -289,6 +296,6 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str:
items = change_items(report, key)
if not items:
continue
lines.extend(markup.details("note", f"{title} ({len(items)})",
[f"- {item}" for item in items]))
lines.extend(_lookup_only(markup.details(
"note", f"{title} ({len(items)})", [f"- {item}" for item in items])))
return "\n".join(lines)
+10 -7
View File
@@ -5,18 +5,22 @@ tabs, a button, a card grid) is written through one of these functions, so
the generator holds no engine syntax of its own. Bodies are passed without
indentation: the function indents them as the engine reads them.
The engine is Astro Starlight. MkDocs Material is kept while the two builds
are compared page by page, selected with set_engine("mkdocs").
The site is built with Astro Starlight. The MkDocs Material syntax the site
was first written in stays as one thing only: the yardstick of the size
budgets that split long pages (system pages, platform sections). Those
budgets were set on that Markdown, and measuring it keeps every page cut
at the same file, so no card changes address.
"""
from __future__ import annotations
import html
import re
from collections.abc import Iterator
from contextlib import contextmanager
ENGINES = ("mkdocs", "starlight")
_engine = "mkdocs"
_engine = "starlight"
# Material's callout kinds, by the role Starlight gives them.
ASIDE_ROLES = {
@@ -41,10 +45,9 @@ def engine() -> str:
@contextmanager
def using(name: str):
"""Write in another engine's syntax for a while. A page split by size
is measured in the MkDocs Markdown its budgets were tuned on, so both
engines cut the same pages at the same files."""
def using(name: str) -> Iterator[None]:
"""Write in another syntax for a while: a page split by size is
measured in the MkDocs Markdown its budgets were set on."""
previous = _engine
set_engine(name)
try:
+25 -78
View File
@@ -16,6 +16,7 @@ import json
import re
import unicodedata
import urllib.parse
from collections.abc import Callable, Iterator
from html import unescape
from pathlib import Path, PurePosixPath
@@ -85,7 +86,7 @@ def resolve(rel_page: str, target: str) -> str:
# Text outside code
def outside_fences(text: str, fn) -> str:
def outside_fences(text: str, fn: Callable[[str], str]) -> str:
parts = FENCE.split(text)
# FENCE has two groups: text, fence, marker, text, ...
out = []
@@ -96,7 +97,7 @@ def outside_fences(text: str, fn) -> str:
return "".join(out)
def outside_code(text: str, fn) -> str:
def outside_code(text: str, fn: Callable[[str], str]) -> str:
def chunk(part: str) -> str:
out, position = [], 0
for match in CODE_SPAN.finditer(part):
@@ -345,12 +346,27 @@ def split_title(markdown: str) -> tuple[str, str, str]:
return unescape(title).strip(), ident, markdown[: match.start()] + markdown[match.end():]
_CARD_NAME = re.compile(r'<h3[^>]*class="rb-file-title"(?![^>]*data-pagefind-ignore)[^>]*><code>([^<]+)</code>')
def file_names_meta(body: str) -> str:
"""The names of the files a system page holds cards for, as a search
field of the page. A page of 250 cards is long, and the search weighs a
long page down: the Sony page, where scph5501.bin is downloaded, came
after every short page citing the name. The field is weighted in the
site's ranking (astro.config.mjs, metaWeights.files)."""
names = sorted({unescape(name) for name in _CARD_NAME.findall(body)})
if not names:
return ""
return f'\n<span data-pagefind-meta="files" hidden>{html.escape(" ".join(names))}</span>\n'
def finalize_page(
rel: str,
markdown: str,
*,
level: str,
boost: int | None,
weight: float | None,
browser_title: str,
description: str,
structured: dict,
@@ -375,6 +391,8 @@ def finalize_page(
# is a name, and stays: it is how "neogeo.zip" finds the cores reading it.
body = re.sub(r'<span class="(rb-badge(?![^"]*rb-badge-archive)[^"]*)"',
r'<span data-pagefind-ignore class="\1"', body)
if rel.startswith("systems/"):
body += file_names_meta(body)
components = used_components(body)
stem = rel.rsplit(".", 1)[0]
if components:
@@ -394,8 +412,8 @@ def finalize_page(
front["slug"] = stem
if level:
front["level"] = level
if boost:
front["boost"] = boost
if weight:
front["searchWeight"] = weight
if rel.startswith(CARD_PAGES):
sections = len(re.findall(r"^## ", body, re.M))
# A page of cards under one or two headings has nothing to list, and
@@ -411,77 +429,6 @@ def finalize_page(
return stem + (".mdx" if components else ".md"), _front_matter(front) + body
# Material syntax in the hand-written wiki pages, read until they are
# written for Starlight.
_WIKI_ADMONITION = re.compile(r'^(?P<indent> *)(?P<kind>!!!|\?\?\?\+?) *(?P<type>[a-z]+)(?: +"(?P<title>[^"]*)")? *$')
_WIKI_TAB = re.compile(r'^(?P<indent> *)=== +"(?P<label>[^"]+)" *$')
_WIKI_BUTTON = re.compile(r"\[(?P<text>[^\]]+)\]\((?P<url>[^)\s]+)\)\{ *(?P<attrs>[^}]*)\}")
def _indented_block(lines: list[str], start: int, indent: int) -> tuple[list[str], int]:
body: list[str] = []
index = start
pad = " " * (indent + 4)
while index < len(lines) and (not lines[index].strip() or lines[index].startswith(pad)):
body.append(lines[index][indent + 4:] if lines[index].startswith(pad) else "")
index += 1
while body and not body[-1].strip():
body.pop()
index -= 1
return body, index
def wiki_page(text: str) -> str:
"""A wiki page in Material syntax, its blocks written for Starlight."""
import sitemarkup as markup
def blocks(lines: list[str]) -> list[str]:
out: list[str] = []
index = 0
while index < len(lines):
line = lines[index]
admonition = _WIKI_ADMONITION.match(line)
tab = _WIKI_TAB.match(line)
if admonition:
indent = len(admonition["indent"])
body, index = _indented_block(lines, index + 1, indent)
title = admonition["title"] if admonition["title"] is not None else admonition["type"].capitalize()
pad = " " * indent
if admonition["kind"] == "!!!":
out += markup.aside(admonition["type"], title, blocks(body), pad)
else:
out += markup.details(admonition["type"], title, blocks(body), pad,
opened=admonition["kind"].endswith("+"))
continue
if tab:
indent = len(tab["indent"])
items = []
while index < len(lines):
tab = _WIKI_TAB.match(lines[index])
if not tab or len(tab["indent"]) != indent:
break
body, index = _indented_block(lines, index + 1, indent)
items.append((tab["label"], blocks(body)))
while index < len(lines) and not lines[index].strip() and index + 1 < len(lines) \
and _WIKI_TAB.match(lines[index + 1]):
index += 1
out += markup.tabs(items, " " * indent)
continue
out.append(line)
index += 1
return out
def button(match: re.Match) -> str:
classes = match["attrs"].split()
if ".md-button" not in classes:
return match.group(0)
return markup.button(match["text"], match["url"], primary=".md-button--primary" in classes)
text = "\n".join(blocks(text.split("\n")))
return outside_fences(text, lambda chunk: _WIKI_BUTTON.sub(button, chunk))
# Navigation
@@ -497,7 +444,7 @@ def sidebar(nav: list, present: set[str]) -> dict:
"""
owner: dict[str, str] = {}
def item(entry, topic: str) -> dict | None:
def item(entry: object, topic: str) -> dict | None:
if isinstance(entry, str):
owner[entry] = topic
return {"label": "Overview", "link": _sidebar_link(entry)} if entry in present else None
@@ -536,7 +483,7 @@ def sidebar(nav: list, present: set[str]) -> dict:
return {"topics": topics, "options": {"topics": extra, "exclude": exclude}}
def _links(items: list[dict]):
def _links(items: list[dict]) -> Iterator[dict]:
for entry in items:
if "link" in entry:
yield entry
+2 -2
View File
@@ -439,11 +439,11 @@ def main() -> int:
if release_path.exists():
errors.extend(_validate_json_files([release_path], "release.schema.json"))
stats_path = ROOT / "docs" / "stats.json"
stats_path = ROOT / "website" / "public" / "stats.json"
if stats_path.exists():
errors.extend(_validate_json_files([stats_path], "stats.schema.json"))
api_dir = ROOT / "docs" / "api" / "v1"
api_dir = ROOT / "website" / "public" / "api" / "v1"
api_database = api_dir / "database.json"
if api_database.exists():
errors.extend(
+6 -48
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Validate the built RetroBIOS site as a navigable, accessible data product.
This complements the engine's own link check (``mkdocs build --strict`` or
Starlight's link validator) with checks on the rendered HTML: metadata,
This complements Starlight's own link check (starlight-links-validator,
which fails the build) with checks on the rendered HTML: metadata,
heading structure, image alternatives, duplicate IDs, structured data, local
resources, links and fragments, and the budgets of what a visitor downloads.
"""
@@ -50,10 +50,6 @@ class Page:
# The largest page was 10.5 MB (systems/game-engines/) and took 21 s of
# main-thread work on a throttled phone. Split pages now top out at 0.9 MB.
MAX_PAGE_BYTES = 1_000_000
# The search index is downloaded by every visitor who opens the search:
# 21.0 MB raw and 4.88 MB gzip with hashes in it, 7.3 MB and 1.44 MB without.
MAX_SEARCH_INDEX_BYTES = 8_500_000
MAX_SEARCH_INDEX_GZIP_BYTES = 1_700_000
# Pagefind loads its index in pieces: the metadata, then the chunks the words
# typed fall in (stored compressed). 59 KB for the largest chunk and 7.5 KB of
# metadata on 2026-10-22, 5.2 MB for the whole index on disk.
@@ -192,39 +188,6 @@ def _check_pagefind(site: Path) -> list[str]:
return issues
def _check_search_index(site: Path) -> list[str]:
"""The index every visitor who opens the search downloads, against its budget."""
import gzip
if (site / "pagefind").is_dir():
return _check_pagefind(site)
path = site / "search" / "search_index.json"
if not path.is_file():
return []
raw = path.read_bytes()
packed = len(gzip.compress(raw, 6))
issues = []
if len(raw) > MAX_SEARCH_INDEX_BYTES:
issues.append(f"search/search_index.json: {len(raw):,} bytes, "
f"over the {MAX_SEARCH_INDEX_BYTES:,} budget")
if packed > MAX_SEARCH_INDEX_GZIP_BYTES:
issues.append(f"search/search_index.json: {packed:,} bytes gzip, "
f"over the {MAX_SEARCH_INDEX_GZIP_BYTES:,} budget")
return issues
def _base_path(config_path: Path) -> str:
"""Read site_url without constructing executable YAML extension tags."""
config = config_path.read_text(encoding="utf-8")
match = re.search(
r"(?m)^site_url:\s*['\"]?([^\s#'\"]+)",
config,
)
site_url = match.group(1) if match else ""
path = urlsplit(site_url).path or "/"
return "/" + path.strip("/") + "/" if path.strip("/") else "/"
def _local_target(
href: str, page_path: Path, site: Path, base_path: str
) -> tuple[list[Path], str] | None:
@@ -370,12 +333,11 @@ def _check_links(pages: dict, site: Path, base_path: str) -> list[str]:
return issues
def validate_site(site: Path, config_path: Path | None = None, base: str = "/retrobios/") -> list[str]:
def validate_site(site: Path, base_path: str = "/retrobios/") -> list[str]:
site = site.resolve()
if not site.is_dir():
return [f"site directory does not exist: {site}"]
base_path = _base_path(config_path) if config_path and config_path.is_file() else base
html_paths = sorted(site.rglob("*.html"))
if not html_paths:
return [f"no HTML pages found in {site}"]
@@ -394,7 +356,7 @@ def validate_site(site: Path, config_path: Path | None = None, base: str = "/ret
issues.append(f"duplicate {label} {value!r}: {rendered}")
issues.extend(_check_links(pages, site, base_path))
issues.extend(_check_search_index(site))
issues.extend(_check_pagefind(site))
return issues
@@ -406,13 +368,9 @@ def main() -> None:
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--site-dir", default="site",
"--site-dir", default="website/dist",
help="the built site (default: %(default)s)",
)
parser.add_argument(
"--config", default=None,
help="an MkDocs configuration to read the base path from",
)
parser.add_argument(
"--base", default="/retrobios/",
help="the path the site is served under (default: %(default)s)",
@@ -424,7 +382,7 @@ def main() -> None:
args = parser.parse_args()
try:
issues = validate_site(Path(args.site_dir), Path(args.config) if args.config else None, args.base)
issues = validate_site(Path(args.site_dir), args.base)
except (OSError, RuntimeError, ValueError) as exc:
print(f"Site validation failed: {exc}", file=sys.stderr)
raise SystemExit(1) from exc