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