feat: write the site for Starlight behind a flag

generate_site.py --engine starlight writes the pages, navigation and
data files of an Astro Starlight project in website/. Links resolve to
site addresses, every heading keeps its MkDocs id, and pages split by
size are measured in the MkDocs Markdown so both builds cut the same
files. The 839 pages and their 45,613 ids match the MkDocs build.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 15:41:39 +02:00
1 parent d2974660eb
commit 342d0f7271
26 files changed
+7817 -60

No files matched your search

+191 -59
View File
@@ -74,6 +74,7 @@ from generate_readme import (
)
from provenance_report import build_report, distinct_missing
import sitemarkup as markup
import sitestarlight
from siteexports import generate_exports_page, load_export_reports
from siteguides import (
ALSO_RUNS,
@@ -91,11 +92,16 @@ 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"
SITE_NAME = "RetroBIOS"
REPO_URL = "https://github.com/Abdess/retrobios"
RELEASE_URL = f"{REPO_URL}/releases/latest"
SITE_URL = "https://abdess.github.io/retrobios/"
GENERATED_DIRS = ["platforms", "systems", "emulators", "wiki", "api", "downloads"]
# Generated directories of files served as they are, not pages.
PUBLIC_DIRS = ("api", "downloads")
@@ -1873,8 +1879,15 @@ def _front_matter(markdown: str) -> dict:
return data if isinstance(data, dict) else {}
def decorate_markdown_pages(docs: Path) -> None:
"""Add per-page descriptions and machine-readable structured data."""
def decorate_markdown_pages(docs: Path) -> dict[str, str]:
"""Add per-page descriptions and machine-readable structured data.
Under Starlight each page is written in its final form (front matter,
site addresses, pinned heading ids) and the dates a page states are
returned by address, for the sitemap.
"""
lastmod: dict[str, str] = {}
terms = sitestarlight.glossary_terms(_REPO_ROOT / WIKI_SRC_DIR / "glossary.md")
for path in sorted(docs.rglob("*.md")):
relative = path.relative_to(docs)
if any(part in LOCAL_NOTE_DIRS for part in relative.parts[:-1]):
@@ -1918,6 +1931,23 @@ def decorate_markdown_pages(docs: Path) -> None:
},
**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("</", "<\\/")
@@ -1937,6 +1967,7 @@ def decorate_markdown_pages(docs: Path) -> None:
+ (f"{level_label(level)}\n\n" if level else "")
)
write_decorated(path, front_matter + markdown)
return lastmod
# Platform pages
@@ -2408,7 +2439,7 @@ def generate_platform_page(
logo_url = registry.get(name, {}).get("logo", "")
logo_md = (
f"![{display}]({logo_url}){{ width=48 align=right }}\n\n" if logo_url else ""
markup.image(display, logo_url, width="48", align="right") + "\n\n" if logo_url else ""
)
homepage = config.get("homepage", "")
@@ -3467,12 +3498,13 @@ def _cards_markdown(cards: list[Card]) -> list[str]:
return lines
def _parts(cards: list[Card], budget: int) -> list[list[Card]]:
"""Cards in runs of at most ``budget`` bytes of Markdown, in order."""
def _parts(cards: list[Card], budget: int, sizes: list[int] | None = None) -> list[list[Card]]:
"""Cards in runs of at most ``budget`` bytes of Markdown, in order.
``sizes`` gives each card's weight when it is not its own Markdown."""
parts: list[list[Card]] = [[]]
size = 0
for card in cards:
weight = sum(len(line) + 1 for line in card[1])
for index, card in enumerate(cards):
weight = sizes[index] if sizes else sum(len(line) + 1 for line in card[1])
if parts[-1] and size + weight > budget:
parts.append([])
size = 0
@@ -3507,11 +3539,11 @@ def system_pages(
"""
slug = _slug(manufacturer)
json_link = (f"[JSON](../api/v1/systems/{urllib.parse.quote(slug)}.json)")
whole_anchors: set[str] = set()
sections = {
console: _console_cards(manufacturer, consoles[console], consumers, whole_anchors)
for console in sorted(consoles)
}
def all_sections() -> dict[str, list[Card]]:
anchors: set[str] = set()
return {console: _console_cards(manufacturer, consoles[console], consumers, anchors)
for console in sorted(consoles)}
intro = (
f"Every BIOS file the collection holds for {manufacturer} machines, one "
"card per file. A card gives the file's fingerprints, the emulators that "
@@ -3520,29 +3552,40 @@ def system_pages(
"first, the others under \"Variants\". To install the files of one "
"console, open its \"Install\" box."
)
lines = [
f"# {manufacturer}", "", intro, "",
f"The files below by console, with every hash, are also published as {json_link}.",
"",
]
for console, cards in sections.items():
lines.append(f"## {system_icon_markdown(manufacturer, console)}{console}")
lines.append("")
lines.extend(_console_install_block(console, (installs or {}).get(console, [])))
lines.extend(_cards_markdown(cards))
lines.append("")
lines.extend(_next_block(_NEXT_FROM_SYSTEMS))
lines.append(f'<div class="rb-timestamp">Generated on {_timestamp()}.</div>')
whole = "\n".join(lines) + "\n"
if len(whole) <= budget:
return {slug: (whole, consoles)}
def whole_page(sections: dict[str, list[Card]]) -> str:
lines = [
f"# {manufacturer}", "", intro, "",
f"The files below by console, with every hash, are also published as {json_link}.",
"",
]
for console, cards in sections.items():
lines.append(f"## {system_icon_markdown(manufacturer, console)}{console}")
lines.append("")
lines.extend(_console_install_block(console, (installs or {}).get(console, [])))
lines.extend(_cards_markdown(cards))
lines.append("")
lines.extend(_next_block(_NEXT_FROM_SYSTEMS))
lines.append(f'<div class="rb-timestamp">Generated on {_timestamp()}.</div>')
return "\n".join(lines) + "\n"
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.
with markup.using("mkdocs"):
measured = all_sections()
measured_text = whole_page(measured)
if len(measured_text) <= budget:
text = whole_page(all_sections()) if markup.starlight() else measured_text
return {slug: (text, consoles)}
# The heaviest sections move to pages of their own until what stays on
# the manufacturer's page is half the budget: a section of one file
# keeps its place, and a reader does not open 91 pages to browse Other.
weights = {
console: sum(len(line) + 1 for _variant, card, _sha1 in cards for line in card)
for console, cards in sections.items()
console: sum(card_weight(card) for _variant, card, _sha1 in cards)
for console, cards in measured.items()
}
moved: set[str] = set()
for console in sorted(weights, key=lambda c: (-weights[c], c)):
@@ -3570,7 +3613,10 @@ def system_pages(
# Cards are rendered again for their own page: the anchors a page
# holds are its own.
cards = _console_cards(manufacturer, consoles[console], consumers, set())
parts = _parts(cards, budget)
with markup.using("mkdocs"):
sizes = [card_weight(card) for _variant, card, _sha1
in _console_cards(manufacturer, consoles[console], consumers, set())]
parts = _parts(cards, budget, sizes)
base = f"{slug}-{_slugify_anchor(console) or 'files'}"
stems = [base if index == 0 else f"{base}-{index + 1}" for index in range(len(parts))]
record_console_pages(slug, console, stems, [
@@ -3616,7 +3662,11 @@ def system_pages(
def _part_range(part: list[Card]) -> str:
"""The first and last file names of a part, as the cards show them."""
names = [re.search(r"`([^`]*)`", card[2]).group(1) for _variant, card, _sha1 in part]
names = []
for _variant, card, _sha1 in part:
# A Markdown heading under MkDocs, an HTML one under Starlight.
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:
return f"`{names[0]}`"
return f"{_count(len(names), 'file')}, `{names[0]}` to `{names[-1]}`"
@@ -5852,6 +5902,31 @@ def generate_contributing() -> str:
# The end of a reference page: back to the index, or on to contributing.
def generate_not_found() -> str:
"""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 page belongs to."""
return "\n".join([
"---",
"search: exclude",
"---",
"",
"# Page not found",
"",
'<p class="rb-lead">This address has no page. It may have moved since the link was written.</p>',
"",
'<p><button type="button" class="rb-notfound-search" data-rb-open-search>Search the site</button></p>',
"",
"- [Start here](index.md): what BIOS files are and how to install them",
"- [Get your files](which-pack.md): the pack or the command for your device",
"- Browse [platforms](platforms/index.md), [systems](systems/index.md) "
"or [emulators](emulators/index.md)",
"",
'<p id="rb-notfound-hint" hidden></p>',
"",
])
def reference_next() -> str:
return "\n" + "\n".join(markup.aside("tip", "Next", [
"- [Reference index](wiki/index.md), every reference page in one list",
@@ -6248,6 +6323,13 @@ 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 []),
*_next_block([
"[First install, step by step](wiki/first-install.md)",
"[When a game does not start](wiki/troubleshooting.md)",
@@ -6467,10 +6549,23 @@ def main() -> None:
help="emulator profile directory (default: %(default)s)",
)
parser.add_argument(
"--docs-dir", default=DOCS_DIR,
help="directory the site sources are written to (default: %(default)s)",
"--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)",
)
parser.add_argument(
"--docs-dir", default=None,
help="directory the pages are written to (default: docs, or the "
"Starlight project's content collection)",
)
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)
try:
# Two runs over one docs/ delete each other's pages and scratch
# files in the sweep, so the second is refused instead.
@@ -6492,34 +6587,42 @@ 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
for d in GENERATED_DIRS:
(docs / d).mkdir(parents=True, exist_ok=True)
(public / d if d in PUBLIC_DIRS else docs / d).mkdir(parents=True, exist_ok=True)
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)
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).
(docs / "robots.txt").unlink(missing_ok=True)
(public / "robots.txt").unlink(missing_ok=True)
images_dest = docs / "assets" / "images"
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).
for folder in ("logos", "system-icons", "og"):
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"))
for folder, dest in folders:
source = Path("docs_assets") / folder
if source.is_dir():
shutil.copytree(source, docs / "assets" / folder, dirs_exist_ok=True)
shutil.copytree(source, public / "assets" / dest, dirs_exist_ok=True)
assets_src = Path(".github") / "assets"
for name, dest_name in [("logo.png", "logo.png"), ("favicon.png", "favicon.png")]:
src = assets_src / name
@@ -6572,7 +6675,7 @@ def _generate(args: argparse.Namespace) -> None:
stats = compute_stats(db, coverages, profiles)
stats["composition"] = compute_composition(db)
write_if_changed(str(docs / "stats.json"), generate_stats(stats))
write_if_changed(str(public / "stats.json"), generate_stats(stats))
# Computed once: the gap analysis page and the published gaps export must
# report the same files.
@@ -6611,7 +6714,7 @@ def _generate(args: argparse.Namespace) -> None:
PART_PAGES.add(f"systems/{stem}.md")
write_if_changed(str(docs / "systems" / f"{stem}.md"), page)
write_single_item(
docs / "api" / "v1" / "systems" / f"{slug}.json", "systems",
public / "api" / "v1" / "systems" / f"{slug}.json", "systems",
system_export_item(slug, mfr, consoles),
str(db.get("generated_at") or _timestamp()),
)
@@ -6621,7 +6724,7 @@ def _generate(args: argparse.Namespace) -> None:
print("Generating static API and bulk data exports...")
exports = generate_data_exports(
docs, db, coverages, profiles, stats, gap_report
public, db, coverages, profiles, stats, gap_report
)
write_if_changed(str(docs / "data.md"), generate_data_page(exports) + reference_next())
PAGE_DATA["data.md"] = dataset_structured_data(exports, db)
@@ -6662,7 +6765,7 @@ def _generate(args: argparse.Namespace) -> None:
for name, profile in profiles.items()
if profile.get("type") not in ("alias", "test")
}
(docs / "api" / "v1" / "emulators").mkdir(parents=True, exist_ok=True)
(public / "api" / "v1" / "emulators").mkdir(parents=True, exist_ok=True)
for name, profile in public_profiles.items():
# The profile itself, one small document per core: reusing one
# meant downloading emulators.json (5.9 MB) and filtering it.
@@ -6670,7 +6773,7 @@ def _generate(args: argparse.Namespace) -> None:
# profile validates against the published schema, which an "id"
# beside its fields broke on all 530, and schema_version is there.
write_if_changed(
str(docs / "api" / "v1" / "emulators" / f"{name}.json"),
str(public / "api" / "v1" / "emulators" / f"{name}.json"),
json.dumps(
{
"schema_version": 1,
@@ -6734,7 +6837,12 @@ 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"):
shutil.copy2(src_file, wiki_dest / src_file.name)
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)
_record(wiki_dest / src_file.name)
# data-model.md is generated (contains live DB stats)
write_if_changed(
@@ -6750,15 +6858,39 @@ def _generate(args: argparse.Namespace) -> None:
# Swept before the decoration, which rewrites every page it finds: a
# page no generator produced any more (a console moved back onto its
# manufacturer's page) was decorated, recorded, and kept for ever.
stale = _sweep_generated(docs, GENERATED_DIRS)
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())
print("Adding page metadata and structured data...")
decorate_markdown_pages(docs)
lastmod = decorate_markdown_pages(docs)
nav = generate_mkdocs_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 = generate_mkdocs_nav(coverages, manufacturers, profiles, registry,
Path(WIKI_SRC_DIR))
nav_yaml = yaml.dump(
{"nav": nav}, default_flow_style=False, sort_keys=False, allow_unicode=True
)
+14
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import html
import re
from contextlib import contextmanager
ENGINES = ("mkdocs", "starlight")
_engine = "mkdocs"
@@ -39,6 +40,19 @@ def engine() -> str:
return _engine
@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."""
previous = _engine
set_engine(name)
try:
yield
finally:
set_engine(previous)
def starlight() -> bool:
return _engine == "starlight"
+3 -1
View File
@@ -7,6 +7,8 @@ import urllib.parse
from collections.abc import Callable
from pathlib import Path
import sitemarkup as markup
WIKI_SRC_DIR = "wiki" # manually maintained wiki sources
SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png"
@@ -89,7 +91,7 @@ def system_icon_markdown(manufacturer: str, console_name: str) -> str:
name = _icon_name(manufacturer, console_name)
if not _icon_available.get(name):
return ""
return f"![{console_name}]({_icon_url(name)}){{ width=24 }} "
return markup.image(console_name, _icon_url(name), width="24") + " "
# Global index: maps system_id -> (manufacturer_slug, console_name) for cross-linking
_system_page_map: dict[str, tuple[str, str]] = {}
+572
View File
@@ -0,0 +1,572 @@
"""The site as an Astro Starlight project: pages, sidebar and data files.
generate_site.py writes each page body; 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 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) -> 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) -> str:
def chunk(part: str) -> str:
out, position = [], 0
for match in CODE_SPAN.finditer(part):
out.append(fn(part[position:match.start()]))
out.append(match.group(0))
position = match.end()
out.append(fn(part[position:]))
return "".join(out)
return outside_fences(text, chunk)
def rewrite_links(text: str, rel: str) -> str:
def markdown(match: re.Match) -> str:
return (f"{match['bang']}[{match['text']}]({resolve(rel, match['url'])}"
f"{match['title'] or ''})")
def attribute(match: re.Match) -> str:
return f'{match["attr"]}="{resolve(rel, match["url"])}"'
text = outside_fences(text, lambda chunk: MD_LINK.sub(markdown, chunk))
return outside_fences(text, lambda chunk: HTML_URL.sub(attribute, chunk))
# Heading ids, as Python-Markdown's toc extension gives them
def toc_slug(text: str) -> str:
text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)(\{[^}]*\})?", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = unescape(text.replace("`", "").replace("**", "").replace("\\", ""))
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
text = re.sub(r"[^\w\s-]", "", text).strip().lower()
return re.sub(r"[-\s]+", "-", text)
def _unique(ident: str, used: set[str]) -> str:
while ident in used or not ident:
match = IDCOUNT.match(ident)
ident = f"{match[1]}_{int(match[2]) + 1}" if match else f"{ident}_1"
used.add(ident)
return ident
def pin_heading_ids(text: str, title_id: str = "") -> str:
"""Every heading with the id the MkDocs build gave it. *title_id* is
the page title's, taken first."""
used: set[str] = {title_id} if title_id else set()
explicit = set()
for match in re.finditer(r"\{[^{}\n]*#([^\s}]+)[^{}\n]*\}[ \t]*$", text, re.M):
explicit.add(match[1])
for match in ID_ATTR.finditer(text):
explicit.add(match["id"])
used |= explicit
def markdown(match: re.Match) -> str:
heading = match["mdtext"]
attrs = ATTR_BLOCK.search(heading)
body = heading[: attrs.start()] if attrs else heading
tokens = attrs["attrs"].split() if attrs else []
if any(token.startswith("#") for token in tokens):
return match.group(0)
ident = _unique(toc_slug(body), used)
return f"{match['hashes']} {body.rstrip()} {{ {' '.join(['#' + ident, *tokens])} }}"
def raw(match: re.Match) -> str:
if ID_ATTR.search(match["attrs"]):
return match.group(0)
level, text = match["level"], match["htmltext"]
ident = _unique(toc_slug(text), used)
return f'<h{level} id="{ident}"{match["attrs"]}>{text}</h{level}>'
def pick(match: re.Match) -> str:
return markdown(match) if match["hashes"] else raw(match)
# In document order: one pass over either kind of heading.
return outside_fences(text, lambda chunk: _ANY_HEADING.sub(pick, chunk))
_ANY_HEADING = re.compile(
r"^(?P<hashes>#{2,6})[ \t]+(?P<mdtext>.+?)[ \t]*$"
r"|<h(?P<level>[2-6])(?P<attrs>(?:\s[^>]*)?)>(?P<htmltext>.*?)</h(?P=level)>",
re.M | re.S,
)
# Glossary tooltips
def glossary_terms(path: Path) -> dict[str, str]:
"""Each term the glossary defines, with its definition as plain text."""
terms: dict[str, str] = {}
if not path.is_file():
return terms
text = path.read_text(encoding="utf-8")
for match in re.finditer(r"^(?P<term>\*\*.+)\n: (?P<body>.+(?:\n .+)*)", text, re.M):
definition = re.sub(r"\s+", " ", match["body"]).strip()
definition = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", definition).replace("`", "")
for term in re.findall(r"\*\*([^*]+)\*\*", match["term"]):
terms.setdefault(term, definition)
return terms
_SKIP_TAGS = re.compile(r"<(a|code|pre|abbr|h[1-6]|script|style|summary|svg|figure)\b.*?</\1>", re.S | re.I)
def add_tooltips(text: str, terms: dict[str, str]) -> str:
"""The first use of each glossary term on a page, with its definition
a hover or a tap away. Headings, links, code and HTML are left as they are."""
if not terms:
return text
pending = dict(terms)
pattern = re.compile(
r"(?<![\w/.-])(" + "|".join(re.escape(t) for t in sorted(terms, key=len, reverse=True))
+ r")(?![\w/-])"
)
def prose(chunk: str) -> str:
if not pending:
return chunk
out = []
for line in chunk.split("\n"):
stripped = line.lstrip()
if (not pending or stripped.startswith(("#", "|", "<", ":::", "[", "!"))
or not stripped):
out.append(line)
continue
out.append(_tooltip_line(line, pattern, pending))
return "\n".join(out)
return outside_code(text, prose)
def _tooltip_line(line: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
# Inline HTML, links and emphasis markers stay whole.
protected = re.compile(r"<[^>]+>|\[[^\]]*\]\([^)]*\)|\*\*[^*]+\*\*|\{[^}]*\}")
out, position = [], 0
for match in protected.finditer(line):
out.append(_tooltip_text(line[position:match.start()], pattern, pending))
out.append(match.group(0))
position = match.end()
out.append(_tooltip_text(line[position:], pattern, pending))
return "".join(out)
def _tooltip_text(text: str, pattern: re.Pattern, pending: dict[str, str]) -> str:
def swap(match: re.Match) -> str:
term = match.group(1)
if term not in pending:
return term
definition = pending.pop(term)
return f'<abbr class="rb-term" title="{html.escape(definition)}">{term}</abbr>'
return pattern.sub(swap, text)
# MDX
_VOID = re.compile(r"<(br|img|input|hr|source|meta|link|wbr)\b([^>]*?)(?<!/)>")
_HEADING_ATTRS = re.compile(r"(?m)(?<=\S)[ \t]+\{[ \t]*#[^{}\n]*\}[ \t]*$")
_COMMENT = re.compile(r"<!--.*?-->", re.S)
def mdx_safe(text: str) -> str:
"""What MDX would read as code in prose: braces, a stray ``<``,
comments, unclosed void tags and ``<https://...>`` links."""
def fix(chunk: str) -> str:
chunk = _COMMENT.sub("", chunk)
chunk = re.sub(r"<(https?://[^>\s]+)>", r"[\1](\1)", chunk)
held: list[str] = []
def hold(match: re.Match) -> str:
held.append(match.group(0))
return f"\x00{len(held) - 1}\x00"
chunk = _HEADING_ATTRS.sub(hold, chunk)
chunk = chunk.replace("{", "&#123;").replace("}", "&#125;")
chunk = re.sub(r"\x00(\d+)\x00", lambda m: held[int(m[1])], chunk)
chunk = re.sub(r"<(?![A-Za-z/])", "&lt;", chunk)
return _VOID.sub(lambda m: f"<{m[1]}{m[2].rstrip()} />", chunk)
return outside_code(text, fix)
def used_components(body: str) -> list[str]:
return [name for name in COMPONENTS if re.search(rf"<{name}[\s/>]", body)]
# Pages
def _front_matter(data: dict) -> str:
import yaml
return "---\n" + yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=1000) + "---\n\n"
def og_section(rel: str, level: str) -> str:
"""The sharing card a page takes: its level, or what its address says."""
url = page_url(rel)[len(BASE):]
if not url:
return "home"
if level in ("start", "setup", "explore", "reference", "contribute"):
return level
if url.startswith(("which-pack", "wiki/first-install", "wiki/faq", "wiki/getting-started")):
return "start"
if url.startswith("platforms"):
return "setup"
if url.startswith(("systems", "emulators", "files")):
return "explore"
if url.startswith(("contributing", "wiki/adding", "wiki/profiling", "wiki/testing", "wiki/release")):
return "contribute"
return "reference"
def page_head(rel: str, level: str, title: str, browser_title: str, structured: dict) -> list[dict]:
"""What a page adds to its head: its own window title, the sharing
card of its section, and its structured data."""
section = og_section(rel, level)
image = f"{SITE_URL}assets/og/{section}.png"
head: list[dict] = []
if browser_title != title:
head.append({"tag": "title", "content": f"{browser_title} - RetroBIOS"})
head += [
{"tag": "meta", "attrs": {"property": "og:image", "content": image}},
{"tag": "meta", "attrs": {"property": "og:image:width", "content": "1200"}},
{"tag": "meta", "attrs": {"property": "og:image:height", "content": "630"}},
{"tag": "meta", "attrs": {"property": "og:image:alt", "content": f"RetroBIOS: {browser_title}"}},
{"tag": "meta", "attrs": {"name": "twitter:image", "content": image}},
{"tag": "script", "attrs": {"type": "application/ld+json"},
"content": json.dumps(structured, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")},
]
return head
def split_title(markdown: str) -> tuple[str, str, str]:
"""The page's H1 as plain text, the id MkDocs gave it, and the body
without it."""
match = re.search(r"^# (.+?)[ \t]*$\n?", markdown, re.M)
if not match:
return "", "", markdown
explicit = re.search(r"\{[^}]*#([^\s}]+)[^}]*\}\s*$", match[1])
title = re.sub(r"\s*\{[^}]*\}\s*$", "", match[1])
ident = explicit[1] if explicit else toc_slug(title)
title = re.sub(r"<[^>]+>", "", title)
title = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", title)
title = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", title).replace("`", "")
return unescape(title).strip(), ident, markdown[: match.start()] + markdown[match.end():]
def finalize_page(
rel: str,
markdown: str,
*,
level: str,
boost: int | 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)
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 boost:
front["boost"] = boost
if rel.startswith(CARD_PAGES):
front["tableOfContents"] = {"minHeadingLevel": 2, "maxHeadingLevel": 2}
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
# 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
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, 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]):
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="/:#?=&%")