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

+2
View File
@@ -45,9 +45,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"
+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="/:#?=&%")
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.astro/
# Written by scripts/generate_site.py
src/content/docs/
src/generated/
public/
+85
View File
@@ -0,0 +1,85 @@
// 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,
plugins: [
starlightSidebarTopics(sidebar.topics, sidebar.options),
starlightLinksValidator(),
],
}),
// A page that states its own date (an emulator page: the day its
// profile was last read against the source) gives it to the sitemap.
// The others get no lastmod rather than the build date.
sitemap({
serialize(item) {
const date = lastmod[new URL(item.url).pathname];
return date ? { ...item, lastmod: date } : item;
},
}),
sitemapAlias,
],
});
+5403
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "retrobios-site",
"private": true,
"type": "module",
"scripts": {
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/markdown-satteri": "0.4.5",
"@astrojs/sitemap": "3.7.4",
"@astrojs/starlight": "0.42.6",
"astro": "7.4.2",
"starlight-links-validator": "0.27.0",
"starlight-sidebar-topics": "0.9.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RetroBIOS"><mask id="glyph"><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff"/><rect x="11.6" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="24.8" y="11.6" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="18.2" y="18.2" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="18.2" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="11.6" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="31.4" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="24.8" y="31.4" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="31.4" width="5" height="5" rx="0.6" fill="#000"/></mask><rect x="11.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="11.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="18.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="18.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="25.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="25.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="32.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="32.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#fff"/><rect x="2.5" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="40" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#fff"/><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff" mask="url(#glyph)"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="RetroBIOS"><mask id="glyph"><rect x="8" y="8" width="32" height="32" rx="4" fill="#fff"/><rect x="11.6" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="11.6" width="5" height="5" rx="0.6" fill="#000"/><rect x="24.8" y="11.6" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="18.2" y="18.2" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="18.2" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="11.6" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="24.8" width="5" height="5" rx="0.6" fill="#000"/><rect x="18.2" y="31.4" width="5" height="5" rx="0.6" fill="#5a5a5a"/><rect x="24.8" y="31.4" width="5" height="5" rx="0.6" fill="#000"/><rect x="31.4" y="31.4" width="5" height="5" rx="0.6" fill="#000"/></mask><rect x="11.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="11.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="11.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="18.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="18.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="18.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="25.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="25.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="25.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="32.8" y="2.5" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="32.8" y="40" width="3.4" height="5.5" rx="0.8" fill="#3b3f7a"/><rect x="2.5" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="40" y="32.8" width="5.5" height="3.4" rx="0.8" fill="#3b3f7a"/><rect x="8" y="8" width="32" height="32" rx="4" fill="#3b3f7a" mask="url(#glyph)"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+186
View File
@@ -0,0 +1,186 @@
---
// "Which files do I need": pick a setup and systems, get the file list and
// the install command. Client side only: reads public/data/picker/*.json,
// which convert.py derives from install/<platform>.json. The selection
// follows install.py narrow(): a file is kept when one of its systems is
// chosen, a file with no system when "other" is chosen.
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
---
<div class="rb-picker" data-base={base}>
<fieldset>
<label for="rb-pick-platform">Your setup</label>
<select id="rb-pick-platform" name="platform">
<option value="">Choose a setup</option>
</select>
</fieldset>
<div class="rb-pick-systems" hidden>
<p class="rb-pick-systems-head">
<strong>Systems</strong>
<button type="button" data-all>All</button>
<button type="button" data-none>None</button>
</p>
<input type="search" class="rb-pick-filter" placeholder="Filter systems" aria-label="Filter systems" />
<div class="rb-pick-list" role="group" aria-label="Systems"></div>
</div>
<div class="rb-pick-result" role="status" aria-live="polite"></div>
<div class="rb-pick-output" hidden>
<p><strong>Install command</strong> (Linux, macOS, Steam Deck, handhelds)</p>
<pre class="rb-pick-cmd"><code></code></pre>
<button type="button" data-copy>Copy the command</button>
<details>
<summary>Files and where they go</summary>
<table>
<thead><tr><th scope="col">Destination</th><th scope="col">Size</th></tr></thead>
<tbody></tbody>
</table>
</details>
</div>
</div>
<script>
type Data = {
platform: string;
name: string;
folder: string;
generated: string;
systems: { id: string; native: string[] }[];
other: boolean;
files: [string, number, number[]][];
};
const INSTALL = 'curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s --';
const MAX_ROWS = 500;
const size = (bytes: number) =>
bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB`
: bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB`
: bytes >= 1e3 ? `${(bytes / 1e3).toFixed(1)} KB`
: `${bytes} B`;
for (const root of document.querySelectorAll<HTMLElement>('.rb-picker')) {
const base = root.dataset.base ?? '/';
const select = root.querySelector('select')!;
const systemsBox = root.querySelector<HTMLElement>('.rb-pick-systems')!;
const list = root.querySelector<HTMLElement>('.rb-pick-list')!;
const filter = root.querySelector<HTMLInputElement>('.rb-pick-filter')!;
const result = root.querySelector<HTMLElement>('.rb-pick-result')!;
const output = root.querySelector<HTMLElement>('.rb-pick-output')!;
const cmd = root.querySelector<HTMLElement>('.rb-pick-cmd code')!;
const tbody = root.querySelector('tbody')!;
let data: Data | null = null;
const params = new URLSearchParams(location.search);
const index: { platform: string; name: string }[] = await fetch(`${base}data/picker/index.json`).then((r) => r.json());
for (const entry of index) select.add(new Option(entry.name, entry.platform));
const chosen = () => [...list.querySelectorAll<HTMLInputElement>('input:checked')].map((box) => box.value);
const render = () => {
if (!data) return;
const picked = chosen();
const wanted = new Set(picked.map((id) => data!.systems.findIndex((s) => s.id === id)));
const other = picked.includes('other');
const files = data.files.filter(([, , systems]) =>
systems.length ? systems.some((s) => wanted.has(s)) : other);
const total = files.reduce((sum, [, bytes]) => sum + bytes, 0);
const all = picked.length === data.systems.length + (data.other ? 1 : 0);
result.textContent = picked.length
? `${files.length} files, ${size(total)}, for ${picked.length} of ${data.systems.length + (data.other ? 1 : 0)} choices. Folder: ${data.folder || 'the setup BIOS folder'}.`
: 'Choose at least one system.';
output.hidden = !picked.length;
cmd.textContent = `${INSTALL} --platform ${data.platform}${all ? '' : ` --system ${picked.join(',')}`}`;
tbody.replaceChildren(...files.slice(0, MAX_ROWS).map(([dest, bytes]) => {
const row = document.createElement('tr');
const where = document.createElement('td');
const code = document.createElement('code');
code.textContent = dest;
where.append(code);
const weight = document.createElement('td');
weight.textContent = size(bytes);
row.append(where, weight);
return row;
}));
if (files.length > MAX_ROWS) {
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = 2;
cell.textContent = `and ${files.length - MAX_ROWS} more.`;
row.append(cell);
tbody.append(row);
}
const url = new URL(location.href);
url.searchParams.set('platform', data.platform);
if (all) url.searchParams.delete('system');
else url.searchParams.set('system', picked.join(','));
history.replaceState(null, '', url);
};
const load = async (platform: string, preset: string[] | null) => {
list.replaceChildren();
output.hidden = true;
systemsBox.hidden = !platform;
if (!platform) { result.textContent = ''; return; }
result.textContent = 'Loading the file list...';
data = await fetch(`${base}data/picker/${platform}.json`).then((r) => r.json());
const choices = data!.systems.map((s) => ({ id: s.id, label: s.native.length ? `${s.id} (${s.native.join(', ')})` : s.id }));
if (data!.other) choices.push({ id: 'other', label: 'other (files no system owns: engines, computers)' });
for (const choice of choices) {
const label = document.createElement('label');
const box = document.createElement('input');
box.type = 'checkbox';
box.value = choice.id;
box.checked = preset ? preset.includes(choice.id) : true;
label.append(box, ` ${choice.label}`);
list.append(label);
}
render();
};
select.addEventListener('change', () => load(select.value, null));
list.addEventListener('change', render);
filter.addEventListener('input', () => {
const query = filter.value.trim().toLowerCase();
for (const label of list.querySelectorAll<HTMLElement>('label')) {
label.hidden = !!query && !label.textContent!.toLowerCase().includes(query);
}
});
root.querySelector('[data-all]')!.addEventListener('click', () => {
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = true));
render();
});
root.querySelector('[data-none]')!.addEventListener('click', () => {
list.querySelectorAll<HTMLInputElement>('label:not([hidden]) input').forEach((box) => (box.checked = false));
render();
});
root.querySelector('[data-copy]')!.addEventListener('click', () => navigator.clipboard?.writeText(cmd.textContent ?? ''));
const initial = params.get('platform');
if (initial && index.some((entry) => entry.platform === initial)) {
select.value = initial;
await load(initial, params.get('system')?.split(',') ?? null);
}
}
</script>
<!-- Global: the checkboxes are created by the script, outside Astro's scoped styles. -->
<style is:global>
.rb-picker .rb-pick-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.25rem 1rem;
max-height: 22rem;
overflow: auto;
padding: 0.5rem;
border: 1px solid var(--rb-border);
border-radius: 4px;
}
.rb-picker .rb-pick-list label { display: flex; gap: 0.5rem; align-items: center; min-height: 32px; margin: 0; font-size: 0.9rem; }
.sl-markdown-content .rb-picker .rb-pick-list input { min-height: auto; width: 1.1rem; height: 1.1rem; margin: 0; flex: none; }
.rb-picker .rb-pick-systems-head { display: flex; gap: 0.5rem; align-items: center; }
.rb-picker .rb-pick-systems-head > * { margin: 0; }
.rb-picker .rb-pick-filter { width: 100%; margin: 0.5rem 0; }
.rb-picker .rb-pick-cmd code { white-space: pre-wrap; overflow-wrap: anywhere; }
.rb-picker .rb-pick-result { margin-top: 1rem; font-weight: 600; }
.rb-picker fieldset { border: 0; padding: 0; }
</style>
+10
View File
@@ -0,0 +1,10 @@
---
// Starlight's footer (edit link, previous and next) and the licence line.
import Default from '@astrojs/starlight/components/Footer.astro';
---
<Default />
<p class="rb-copyright">
MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup,
archival and interoperability.
</p>
@@ -0,0 +1,21 @@
---
// Search ranking: Pagefind multiplies the score of words inside a weighted
// element, so a Start here page outranks the reference page that names it.
import Default from '@astrojs/starlight/components/MarkdownContent.astro';
const { boost } = Astro.locals.starlightRoute.entry.data;
---
{
boost ? (
<div data-pagefind-weight={String(Math.min(10, boost * 2))}>
<Default><slot /></Default>
</div>
) : (
<Default><slot /></Default>
)
}
<script>
import '../scripts/site.ts';
</script>
+40
View File
@@ -0,0 +1,40 @@
---
// Breadcrumbs, the level label and the title. The trail says which tab a
// deep page belongs to; the label says who the page is written for.
const PAGE_TITLE_ID = '_top';
const LABELS = {
start: 'Start here',
setup: 'Setup guide',
explore: 'Details',
reference: 'Reference',
contribute: 'For contributors',
} as const;
const { title, level } = Astro.locals.starlightRoute.entry.data;
const crumbs = Astro.locals.rbBreadcrumbs ?? [];
---
{
crumbs.length > 0 && (
<nav class="rb-breadcrumbs" aria-label="Breadcrumb">
<ol>
{crumbs.map((crumb) => (
<li>{crumb.href ? <a href={crumb.href}>{crumb.label}</a> : crumb.label}</li>
))}
</ol>
</nav>
)
}
{level && <span class={`rb-level rb-level-${level}`}>{LABELS[level]}</span>}
<h1 id={PAGE_TITLE_ID}>{title}</h1>
<style>
h1 {
margin-top: 0.25rem;
font-size: var(--sl-text-h1);
line-height: var(--sl-line-height-headings);
font-weight: 700;
color: var(--sl-color-white);
}
</style>
+22
View File
@@ -0,0 +1,22 @@
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
// The generator's own front matter, typed: a page with a misspelt level fails the build.
export const collections = {
docs: defineCollection({
loader: docsLoader(),
schema: docsSchema({
extend: z.object({
// Who the page is written for, from someone who knows nothing
// (start) to someone adding to the project (contribute).
level: z.enum(['start', 'setup', 'explore', 'reference', 'contribute']).optional(),
// Search ranking of the first two levels.
boost: z.number().min(1).max(10).optional(),
// The date the page's content carries, for the sitemap.
lastmod: z.string().optional(),
}),
}),
}),
};
+7
View File
@@ -0,0 +1,7 @@
/// <reference path="../.astro/types.d.ts" />
declare namespace App {
interface Locals {
rbBreadcrumbs?: import('./routeData').Crumb[];
}
}
+96
View File
@@ -0,0 +1,96 @@
// What each page knows of its place in the site, computed once per page:
// - breadcrumbs: the tab and the groups above the page;
// - previous and next: the reading order runs on from one tab into the next,
// so the last Start here page leads to Your setup;
// - a pruned sidebar: a group that does not hold the page becomes one link
// to its first page, so an emulator page does not carry the 600 links of
// every other emulator (the equivalent of Material's navigation.prune).
import { defineRouteMiddleware, type StarlightRouteData } from '@astrojs/starlight/route-data';
import sidebar from './generated/sidebar.json' with { type: 'json' };
type Entry = StarlightRouteData['sidebar'][number];
type Link = Extract<Entry, { type: 'link' }>;
type Item = { label: string; link?: string; items?: Item[] };
export type Crumb = { label: string; href?: string };
const BASE = import.meta.env.BASE_URL.replace(/\/?$/, '/');
const withBase = (link: string) => (link.startsWith(BASE) ? link : BASE + link.replace(/^\//, ''));
const flatten = (items: Item[]): { label: string; link: string }[] =>
items.flatMap((item) => (item.items ? flatten(item.items) : item.link && !item.link.startsWith('http') ? [{ label: item.label, link: item.link }] : []));
// Every listed page in reading order, tab after tab.
const READING_ORDER = sidebar.topics.flatMap((topic) =>
flatten(topic.items as Item[]).map((entry) => ({ ...entry, label: entry.label === 'Overview' ? topic.label : entry.label })),
);
const holdsCurrent = (entry: Entry): boolean =>
entry.type === 'link' ? entry.isCurrent : entry.entries.some(holdsCurrent);
const firstLink = (entry: Entry): string | undefined =>
entry.type === 'link' ? entry.href : entry.entries.map(firstLink).find(Boolean);
const prune = (entries: Entry[]): Entry[] =>
entries.map((entry) => {
if (entry.type === 'link') return entry;
if (holdsCurrent(entry)) return { ...entry, collapsed: false, entries: prune(entry.entries) };
const href = firstLink(entry);
return href ? { type: 'link', label: entry.label, href, isCurrent: false, badge: entry.badge, attrs: {} } : entry;
});
// The groups above the current page, outermost first.
const trail = (entries: Entry[], path: string): Crumb[] | undefined => {
for (const entry of entries) {
if (entry.type === 'link') {
if (entry.isCurrent) return [];
continue;
}
const below = trail(entry.entries, path);
if (below) return [{ label: entry.label, href: firstLink(entry) }, ...below];
}
return undefined;
};
// A page the navigation does not list (part of a split page) sits under the
// group holding the pages of its folder.
const folderTrail = (entries: Entry[], folder: string): Crumb[] => {
for (const entry of entries) {
if (entry.type !== 'group') continue;
const links = entry.entries.map(firstLink).filter(Boolean) as string[];
if (links.some((href) => href.startsWith(folder))) return [{ label: entry.label, href: links[0] }];
}
return [];
};
const asLink = (entry: { label: string; link: string }): Link => ({
type: 'link',
label: entry.label,
href: withBase(entry.link),
isCurrent: false,
badge: undefined,
attrs: {},
});
export const onRequest = defineRouteMiddleware(async (context, next) => {
await next();
const route = context.locals.starlightRoute;
const path = context.url.pathname;
const topic = context.locals.starlightSidebarTopics?.topics.find((t) => t.isCurrent);
const crumbs: Crumb[] = [];
if (topic && withBase(topic.link) !== path) {
crumbs.push({ label: topic.label, href: withBase(topic.link) });
const segments = path.slice(BASE.length).split('/').filter(Boolean);
const groups = trail(route.sidebar, path) ?? (segments.length > 1 ? folderTrail(route.sidebar, `${BASE}${segments[0]}/`) : []);
crumbs.push(...groups.filter((crumb) => crumb.href !== path));
}
context.locals.rbBreadcrumbs = crumbs;
const position = READING_ORDER.findIndex((entry) => withBase(entry.link) === path);
if (position !== -1) {
if (!route.pagination.prev && position > 0) route.pagination.prev = asLink(READING_ORDER[position - 1]);
if (!route.pagination.next && position < READING_ORDER.length - 1) route.pagination.next = asLink(READING_ORDER[position + 1]);
}
route.sidebar = prune(route.sidebar);
});
+275
View File
@@ -0,0 +1,275 @@
// 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.
const markScrollable = (table: HTMLTableElement) => {
const heading = table.dataset.rbHeading ?? 'Data table';
if (table.scrollWidth > table.clientWidth + 1) {
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 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: HTMLTableElement) => {
if (!table.querySelector('tbody .rb-button')) 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);
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;
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 = () => {
document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table').forEach((table, index) => {
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);
markScrollable(table);
filterTable(table, index, heading);
});
const recheck = () => document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table').forEach(markScrollable);
let timer = 0;
addEventListener('resize', () => {
clearTimeout(timer);
timer = window.setTimeout(recheck, 150);
});
document.addEventListener('toggle', recheck, true);
document.addEventListener('click', (event) => {
if ((event.target as Element).closest?.('[role="tab"]')) setTimeout(recheck, 0);
});
};
// 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();
+557
View File
@@ -0,0 +1,557 @@
/* 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; }
.sl-markdown-content :is(h2, h3) img { display: inline; 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-inline-start: 4px solid var(--sl-color-blue);
background: var(--sl-color-blue-low);
padding: 0.6rem 1rem;
}
.sl-markdown-content details.rb-details-tip { border-color: var(--sl-color-purple); background: var(--sl-color-purple-low); }
.sl-markdown-content details.rb-details-caution { border-color: var(--sl-color-orange); background: var(--sl-color-orange-low); }
.sl-markdown-content details.rb-details-danger { border-color: var(--sl-color-red); background: var(--sl-color-red-low); }
.sl-markdown-content details.rb-details > summary {
min-height: 24px;
font-weight: 600;
cursor: pointer;
}
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
/* Terminal output: what the screen shows, not something to type */
.sl-markdown-content .rb-terminal .expressive-code pre { background: var(--rb-terminal-bg); color: var(--rb-terminal-fg); }
/* Diagrams written by scripts/sitediagrams.py follow the theme */
.sl-markdown-content .rb-figure { margin: 1.2rem 0 1.6rem; }
.sl-markdown-content .rb-figure svg { display: block; width: 100%; height: auto; max-width: 52rem; color: var(--sl-color-text); }
.sl-markdown-content .rb-figure :is(figcaption, .rb-figure-caption) {
display: block;
max-width: 44em;
margin-top: 0.4rem;
font-size: 0.8rem;
color: var(--rb-text-secondary);
}
.rb-diagram text { font-family: var(--sl-font); fill: currentColor; }
.rb-diagram .rb-d-box { fill: var(--rb-diagram-fill); stroke: var(--rb-diagram-stroke); }
.rb-diagram .rb-d-accent { stroke: var(--rb-diagram-accent); }
.rb-diagram .rb-d-line { stroke: var(--rb-diagram-stroke); }
.rb-diagram .rb-d-head { fill: var(--rb-diagram-stroke); }
.rb-diagram .rb-d-num { fill: var(--rb-diagram-accent); }
.rb-diagram .rb-d-numtext { fill: var(--sl-color-bg); }
.rb-diagram .rb-d-muted { fill: var(--rb-text-secondary); }
.rb-diagram-narrow { display: none !important; }
@media (max-width: 37.5em) {
.rb-diagram-wide { display: none !important; }
.rb-diagram-narrow { display: block !important; }
}
/* Glossary terms */
abbr.rb-term {
position: relative;
text-decoration: underline dotted;
text-underline-offset: 0.2em;
cursor: help;
}
abbr.rb-term[data-open]::after {
content: attr(data-definition);
position: absolute;
z-index: 10;
top: calc(100% + 0.3rem);
left: 0;
width: max-content;
max-width: min(20rem, 80vw);
padding: 0.4rem 0.6rem;
border: 1px solid var(--rb-border-strong);
border-radius: 4px;
background: var(--sl-color-bg);
color: var(--sl-color-text);
font-size: 0.85rem;
font-weight: 400;
line-height: 1.4;
white-space: normal;
}
/* Forms: lookup, picker, table filters */
.rb-file-lookup,
.rb-picker fieldset {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.sl-markdown-content :is(.rb-file-lookup, .rb-picker) :is(input, select, button) {
min-height: 44px;
padding: 0 0.6rem;
border: 1px solid var(--rb-border-strong);
border-radius: 4px;
background: var(--sl-color-bg);
color: var(--sl-color-text);
font: inherit;
}
.sl-markdown-content .rb-file-lookup input { flex: 1 1 20rem; font-family: var(--sl-font-mono); }
.sl-markdown-content :is(.rb-file-lookup, .rb-picker) button {
background: var(--rb-link);
color: var(--sl-color-black);
font-weight: 600;
border: 0;
}
.rb-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.rb-table-filter {
display: grid;
grid-template-columns: minmax(14rem, 28rem) auto;
gap: 0.35rem 0.8rem;
align-items: end;
margin: 1rem 0 0.45rem;
}
.sl-markdown-content .rb-table-filter > * { margin: 0; }
.rb-table-filter label { grid-column: 1 / -1; color: var(--rb-text-secondary); font-size: 0.78rem; font-weight: 700; }
.rb-table-filter input {
width: 100%;
min-height: 44px;
border: 1px solid var(--rb-text-secondary);
border-radius: 2px;
background: var(--sl-color-bg);
color: var(--sl-color-text);
padding: 0.55rem 0.75rem;
font: inherit;
}
.rb-table-filter-count { align-self: center; color: var(--rb-text-secondary); font-size: 0.8rem; white-space: nowrap; }
/* 404 */
.sl-markdown-content .rb-notfound-search {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.55em 1.2em;
border: 2px solid var(--rb-link);
border-radius: 4px;
background: none;
color: var(--rb-link);
font: inherit;
font-weight: 700;
cursor: pointer;
}
/* Licence line under every page */
.rb-copyright { margin: 1.5rem 0 0; font-size: var(--sl-text-xs); color: var(--sl-color-gray-3); }
@media (max-width: 50rem) {
/* Tables with an action column become one block per row, so the button
is never behind a horizontal scroll (site.ts marks them). */
.sl-markdown-content table[data-rb-stack] { display: block; width: 100%; overflow: visible; }
.sl-markdown-content table[data-rb-stack] thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.sl-markdown-content table[data-rb-stack] :is(tbody, tr) { display: block; }
.sl-markdown-content table[data-rb-stack] tr { padding: 0.5rem 0; border-top: 1px solid var(--rb-border); }
.sl-markdown-content table[data-rb-stack] td {
display: block;
position: relative;
min-height: 1.6rem;
padding: 0.25rem 0.6rem 0.25rem 8.4rem;
border: 0;
text-align: left !important;
overflow-wrap: anywhere;
}
.sl-markdown-content table[data-rb-stack] td::before {
content: attr(data-label);
position: absolute;
top: 0.25rem;
left: 0.6rem;
width: 7.4rem;
font-weight: 600;
color: var(--rb-text-secondary);
}
.sl-markdown-content table[data-rb-stack] td:empty { display: none; }
/* On a phone every command wraps: the end of a one-liner is never off-screen. */
.sl-markdown-content .expressive-code pre > code { white-space: pre-wrap; overflow-wrap: anywhere; }
.sl-markdown-content :is(.rb-sys-file, .rb-file-entry) ul,
.rb-hash { font-size: 0.75rem; }
}
@media (max-width: 600px) {
.rb-table-filter { grid-template-columns: 1fr; }
.rb-table-filter label { grid-column: auto; }
.rb-stats { grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); }
.rb-stat-value { font-size: 1.1rem; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+24
View File
@@ -0,0 +1,24 @@
// axe-core (WCAG 2.2 A/AA rules) in light and dark, one line per page and scheme.
// node axe.mjs <base url> <page>...
import { chromium } from 'playwright-core';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const axeSource = await readFile(require.resolve('axe-core/axe.min.js'), 'utf8');
const [base, ...pages] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
for (const scheme of ['light', 'dark']) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: scheme });
const page = await context.newPage();
for (const path of pages) {
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
await page.addScriptTag({ content: axeSource });
const result = await page.evaluate(() =>
window.axe.run(document, { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'] }));
const violations = result.violations.map((v) => `${v.id}(${v.nodes.length})`);
console.log(JSON.stringify({ page: path || '/', scheme, violations }));
}
await context.close();
}
await browser.close();
+44
View File
@@ -0,0 +1,44 @@
// 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$/);
assert.deepEqual(errors, []);
console.log('ok');
await browser.close();
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Lighthouse mobile (default form factor) on a list of pages, one summary line each.
python lighthouse.py <out dir> <name>=<base url> ... -- <page> ...
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:]
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)}"],
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"],
"dom": audits["dom-size"]["displayValue"],
"failed": failed,
}))
return 0
if __name__ == "__main__":
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
// Page weight and search measurements in a real browser.
// node measure.mjs weight <base url> <page>...
// node measure.mjs search <base url> starlight|mkdocs <query>...
// Transfer sizes are bytes on the wire (gzip from serve.mjs), by type.
import { chromium } from 'playwright-core';
const [mode, base, ...rest] = process.argv.slice(2);
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
const track = (page) => {
const rows = [];
page.on('response', async (response) => {
const request = response.request();
const headers = await response.allHeaders();
let bytes = Number(headers['content-length'] ?? 0);
if (!bytes) {
try { bytes = (await response.body()).length; } catch { bytes = 0; }
}
rows.push({ url: response.url(), type: request.resourceType(), bytes });
});
return rows;
};
const sum = (rows, test) => rows.filter(test).reduce((total, row) => total + row.bytes, 0);
if (mode === 'weight') {
for (const path of rest) {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
const rows = track(page);
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const dom = await page.evaluate(() => document.getElementsByTagName('*').length);
const kb = (n) => (n / 1024).toFixed(1);
console.log(JSON.stringify({
page: path,
html_kb: kb(sum(rows, (r) => r.type === 'document')),
js_kb: kb(sum(rows, (r) => r.type === 'script')),
css_kb: kb(sum(rows, (r) => r.type === 'stylesheet')),
other_kb: kb(sum(rows, (r) => !['document', 'script', 'stylesheet'].includes(r.type))),
total_kb: kb(sum(rows, () => true)),
requests: rows.length,
dom_nodes: dom,
}));
await context.close();
}
} else if (mode === 'search') {
const [engine, ...queries] = rest;
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
const rows = track(page);
await page.goto(base, { waitUntil: 'networkidle' });
await page.waitForTimeout(500);
const before = rows.length;
const first = [];
for (const [n, query] of queries.entries()) {
const mark = rows.length;
let results;
if (engine === 'starlight-ui') {
// The reader's path: open the dialog, type, wait for the result list.
if (n === 0) await page.click('button[data-open-modal]');
const input = page.locator('dialog input[type="text"], dialog input[type="search"]').first();
await input.fill(query);
await page.waitForSelector('.pagefind-ui__result', { timeout: 15000 });
await page.waitForTimeout(1200);
results = await page.evaluate(() => {
const items = [...document.querySelectorAll('.pagefind-ui__result')];
return {
count: document.querySelector('.pagefind-ui__message')?.textContent ?? '',
top: items.slice(0, 5).map((r) => `${r.querySelector('.pagefind-ui__result-link')?.textContent?.trim()} | ${r.querySelector('.pagefind-ui__result-link')?.getAttribute('href')}`),
};
});
} else if (engine === 'starlight') {
results = await page.evaluate(async ({ query, root, ranking }) => {
if (!window.__pf) {
window.__pf = await import(`${root}pagefind/pagefind.js`);
if (ranking) await window.__pf.options({ ranking });
}
const search = await window.__pf.search(query);
const top = await Promise.all(search.results.slice(0, 5).map((r) => r.data()));
return { count: search.results.length, top: top.map((d) => `${d.meta.title} | ${d.url}`) };
}, { query, root: new URL('/retrobios/', base).href, ranking: process.env.RB_RANKING ? JSON.parse(process.env.RB_RANKING) : null });
} else {
// Material: open the search through its share link (?q=) and read the result list.
await page.goto(`${base}?q=${encodeURIComponent(query)}`, { waitUntil: 'networkidle' });
await page.waitForFunction(
(q) => document.querySelector('input[data-md-component="search-query"]')?.value === q
&& /matching|No matching/.test(document.querySelector('.md-search-result__meta')?.textContent ?? ''),
query, { timeout: 30000 },
);
await page.waitForTimeout(800);
results = await page.evaluate(() => {
const items = [...document.querySelectorAll('.md-search-result__item > a')];
const meta = document.querySelector('.md-search-result__meta')?.textContent ?? '';
return { count: meta, top: items.slice(0, 5).map((a) => `${a.querySelector('h1,h2')?.textContent?.trim()} | ${a.getAttribute('href')}`) };
});
}
await page.waitForTimeout(300);
const transfer = sum(rows.slice(mark), () => true);
if (n === 0) first.push(...rows.slice(before));
console.log(JSON.stringify({ query, transfer_kb: (transfer / 1024).toFixed(1), ...results }));
}
console.log(JSON.stringify({ first_search_kb: (sum(first, () => true) / 1024).toFixed(1), first_search_requests: first.length }));
await context.close();
}
await browser.close();
+51
View File
@@ -0,0 +1,51 @@
// Static server shaped like GitHub Pages for measurements: gzip, no brotli,
// max-age=600, the site under /retrobios/, real 404 page.
// node serve.mjs <site dir> <port>
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { gzipSync } from 'node:zlib';
const [root = 'dist', port = '4321'] = process.argv.slice(2);
const BASE = '/retrobios/';
const TYPES = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
'.xml': 'application/xml', '.txt': 'text/plain', '.wasm': 'application/wasm',
};
const COMPRESS = new Set(['.html', '.css', '.js', '.json', '.svg', '.xml', '.txt']);
const resolveFile = async (path) => {
let file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, ''));
try {
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
await stat(file);
return file;
} catch {
return null;
}
};
createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
if (!url.pathname.startsWith(BASE)) {
res.writeHead(302, { location: BASE }).end();
return;
}
const rel = decodeURIComponent(url.pathname.slice(BASE.length));
let file = await resolveFile(rel);
let status = 200;
if (!file) {
status = 404;
file = await resolveFile('404.html');
}
const ext = extname(file);
let body = await readFile(file);
const headers = { 'content-type': TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'max-age=600' };
if (COMPRESS.has(ext) && /\bgzip\b/.test(req.headers['accept-encoding'] ?? '')) {
body = gzipSync(body);
headers['content-encoding'] = 'gzip';
}
headers['content-length'] = body.length;
res.writeHead(status, headers).end(body);
}).listen(Number(port), '127.0.0.1');
+25
View File
@@ -0,0 +1,25 @@
// Screenshots at 390 and 1440 px, light and dark.
// node shots.mjs <base url> <out dir> <page>...
import { chromium } from 'playwright-core';
import { mkdir } from 'node:fs/promises';
const [base, out, ...pages] = process.argv.slice(2);
await mkdir(out, { recursive: true });
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
for (const scheme of ['light', 'dark']) {
for (const width of [390, 1440]) {
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
deviceScaleFactor: 1,
colorScheme: scheme,
});
const page = await context.newPage();
for (const path of pages) {
await page.goto(new URL(path, base).href, { waitUntil: 'networkidle' });
const name = path.replace(/^\/|\/$/g, '').replace(/[/?=&]+/g, '_') || 'home';
await page.screenshot({ path: `${out}/${name}-${width}-${scheme}.png`, fullPage: false });
}
await context.close();
}
}
await browser.close();