From d2974660ebd5f7765389853c4372df3c8ac52419 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:21:52 +0200 Subject: [PATCH 1/6] refactor: emit site blocks through sitemarkup Callouts, collapsible blocks, tabs, buttons, card grids and search exclusion go through one module that writes the engine's syntax. The MkDocs output is unchanged apart from whitespace-only lines. --- scripts/generate_readme.py | 18 +- scripts/generate_site.py | 512 ++++++++++++++++--------------------- scripts/sitediagrams.py | 6 +- scripts/siteexports.py | 52 ++-- scripts/siteguides.py | 23 +- scripts/sitemarkup.py | 183 +++++++++++++ 6 files changed, 444 insertions(+), 350 deletions(-) create mode 100644 scripts/sitemarkup.py diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index 3723ed55..f8651907 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -230,14 +230,16 @@ def release_totals(platform_name: str, record: dict) -> tuple[int | None, int | def download_links( - platform_name: str, record: dict, repo: str, label: str, attrs: str = "" + platform_name: str, record: dict, repo: str, label: str, attrs: str = "", + button: Callable[[str, str], str] | None = None, ) -> str: """Markdown links to the files of a platform's pack in the recorded release. A pack published whole gets one link labelled *label*. A pack in parts gets one link per part, since every part is needed. *repo* is the repository URL, absolute or relative, and *attrs* a MkDocs attribute list - put after each link. Without a recorded release the link is the latest + put after each link, or *button* the function that draws each link as a + button on the site. Without a recorded release the link is the latest release page. A release recorded without this platform's pack says so and names the installer, which reads the file lists on main: a button to the release served six platforms a page with no pack for them. @@ -245,18 +247,22 @@ def download_links( pack = release_record.pack_for(platform_name, record) tag = record.get("tag") assets = pack.get("assets") if pack else None + def link(text: str, url: str) -> str: + return button(text, url) if button else f"[{text}]({url}){attrs}" + if not tag: - return f"[{label}]({repo}/releases/latest){attrs}" + return link(label, f"{repo}/releases/latest") if not assets: return f"Not in {tag}: [use the installer]({SITE_URL}wiki/installer/)" urls = [f"{repo}/releases/download/{tag}/{asset}" for asset in assets] if len(urls) == 1: - return f"[{label}]({urls[0]}){attrs}" + return link(label, urls[0]) + styled = bool(attrs or button) parts = [ - f"[{'P' if attrs else 'p'}art {number} of {len(urls)}]({url}){attrs}" + link(f"{'P' if styled else 'p'}art {number} of {len(urls)}", url) for number, url in enumerate(urls, 1) ] - if attrs: + if styled: return " ".join(parts) return f"{label}: " + ", ".join(parts) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 45b29724..f8e6da82 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -73,6 +73,7 @@ from generate_readme import ( release_totals, ) from provenance_report import build_report, distinct_missing +import sitemarkup as markup from siteexports import generate_exports_page, load_export_reports from siteguides import ( ALSO_RUNS, @@ -384,7 +385,7 @@ def _timestamp() -> str: def _stat(value: str, label: str) -> list[str]: return [ - '
', + markup.block_open("div", 'class="rb-stat"'), f'{value}', f'{label}', "
", @@ -402,7 +403,7 @@ def _group_members(coverages: dict, registry: dict, group: str) -> list[str]: def _next_block(links: list[str]) -> list[str]: """Where to go from here, at the end of a page.""" - return ['!!! tip "Next"', "", *(f" - {link}" for link in links), ""] + return markup.aside("tip", "Next", [f"- {link}" for link in links]) def generate_home( @@ -431,8 +432,8 @@ def generate_home( "the **BIOS**. Your emulator needs a copy of it. We put the right copy " "in the right folder.", "", - "[Get your files](which-pack.md){ .md-button .md-button--primary } " - "[What is a BIOS?](wiki/what-are-bios-files.md){ .md-button }", + markup.button("Get your files", "which-pack.md", primary=True) + " " + + markup.button("What is a BIOS?", "wiki/what-are-bios-files.md"), "", f"Free. No account. Works with {works_with}.", "", @@ -446,30 +447,28 @@ def generate_home( "", "## Three steps", "", - "1. **Pick your device.** Its page shows your BIOS folder.", - "2. **Run one command**, or download a ZIP file.", - "3. **Start a game.**", - "", + *(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", "", - '
', - "", ] - for group, (label, _description) in DEVICE_GROUPS.items(): - members = _group_members(coverages, registry, group) - if not members: - continue - lines += [ - f"- **{label}**", - "", - " " + ", ".join( - f"[{coverages[name]['platform']}](platforms/{name}.md)" for name in members - ), - "", - ] + lines += markup.card_grid([ + (label, [", ".join( + f"[{coverages[name]['platform']}](platforms/{name}.md)" + for name in _group_members(coverages, registry, group) + )]) + for group, (label, _description) in DEVICE_GROUPS.items() + if _group_members(coverages, registry, group) + ]) lines += [ - "
", - "", "## Is it safe?", "", "- It writes only in your BIOS folder.", @@ -501,43 +500,23 @@ def generate_explore_index(db: dict, coverages: dict, profiles: dict) -> str: "One console, one emulator, or one installer option: each has its page, " "with the files it needs and the code that reads them.", "", - '
', - "", - "- **Systems**", - "", - " What each console and computer needs, file by file.", - "", - " [Browse the systems](systems/index.md)", - "", - "- **Emulators**", - "", - " What each emulator reads, with the source code that reads it.", - "", - " [Browse the emulators](emulators/index.md)", - "", - "- **Installer options**", - "", - " Fewer files, one region, another folder, a check without download.", - "", - " [All installer options](wiki/installer.md)", - "", - "- **How files are checked**", - "", - " Each setup checks its files its own way: by name, by MD5 or by SHA1.", - "", - " Reference: [How each platform checks files](wiki/verification-modes.md)", - "", - "- **Data and API**", - "", - " Every file and hash as JSON, CSV and SQLite.", - "", - " Reference: [Data and API](data.md)", - "", - "
", - "", + *markup.card_grid([ + ("Systems", ["What each console and computer needs, file by file.", "", + "[Browse the systems](systems/index.md)"]), + ("Emulators", ["What each emulator reads, with the source code that reads it.", "", + "[Browse the emulators](emulators/index.md)"]), + ("Installer options", [ + "Fewer files, one region, another folder, a check without download.", "", + "[All installer options](wiki/installer.md)"]), + ("How files are checked", [ + "Each setup checks its files its own way: by name, by MD5 or by SHA1.", "", + "Reference: [How each platform checks files](wiki/verification-modes.md)"]), + ("Data and API", ["Every file and hash as JSON, CSV and SQLite.", "", + "Reference: [Data and API](data.md)"]), + ]), "## The collection", "", - '
', + markup.block_open("div", 'class="rb-stats"'), "", *_stat(f"{total_files:,}", COLLECTION_FILES_LABEL), *_stat(str(len(coverages)), "Setups supported"), @@ -582,7 +561,7 @@ def _pack_buttons(platform_name: str, label: str = "Pack") -> str: release_record.load_record(), REPO_URL, label, - "{ .md-button .md-button--primary }", + button=lambda text, url: markup.button(text, url, primary=True), ) @@ -1295,9 +1274,11 @@ def generate_data_page(exports: list[dict]) -> str: "RetroBIOS publishes the same metadata used by its verifier, pack builder " "and website as versioned static files. No account or API key is required.", "", - "[API catalog](api/v1/catalog.json){ .md-button .md-button--primary } " - "[SQLite snapshot](downloads/retrobios.sqlite){ .md-button } " - "[Collection DAT](downloads/retrobios.dat){ .md-button }", + " ".join([ + markup.button("API catalog", "api/v1/catalog.json", primary=True), + markup.button("SQLite snapshot", "downloads/retrobios.sqlite"), + markup.button("Collection DAT", "downloads/retrobios.dat"), + ]), "", "The DAT is Logiqx XML, the format clrmamepro and RomVault read: one game " "per `bios///` folder of the collection, named " @@ -1497,7 +1478,7 @@ def _page_description(markdown: str, relative: Path, title: str) -> str: stripped = paragraph.strip() if ( not stripped - or stripped.startswith(("#", "|", "```", "???", "<", "- ", "* ")) + or stripped.startswith(("#", "|", "```", "???", ":::", "<", "- ", "* ")) ): continue plain = _plain_markdown(stripped) @@ -1977,16 +1958,11 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st members = _group_members(coverages, registry, group) if not members: continue - lines += [f"## {label}", "", description, "", '
', ""] - for name in members: - guide = GUIDES[name] - lines += [ - f"- **[{coverages[name]['platform']}]({name}.md)**", - "", - f" {guide.summary}", - "", - ] - lines += ["
", ""] + lines += [f"## {label}", "", description, ""] + lines += markup.card_grid([ + (f"[{coverages[name]['platform']}]({name}.md)", [GUIDES[name].summary]) + for name in members + ]) lines += [ "## Reference: every platform at a glance { #reference }", @@ -2088,24 +2064,14 @@ def _render_platform_systems( # emulator cards link here, and the site search splits a page at its # headings. Folded into blocks under one heading, every hash pasted # in the search led to the systems overview of a 100-block page. - block_start = len(lines) + 2 - if use_collapsible: - status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue") - lines.append(f"### {sys_link} {{ #{anchor} }}") - lines.append("") - lines.append(f'??? note "{_count(total, "file")} ({status_tag})"') - lines.append("") - pad = " " - else: - lines.append(f"### {sys_link} {{ #{anchor} }}") - lines.append("") - pad = "" - - lines.append(f"{pad}{ok_count}/{total} files verified") + lines.append(f"### {sys_link} {{ #{anchor} }}") + lines.append("") + block_start = len(lines) + body = [f"{ok_count}/{total} files verified"] if sys_emus: emu_links = ", ".join(_emulator_link(e, "../") for e in sorted(sys_emus)) - lines.append(f"{pad}Emulators: {emu_links}") - lines.append("") + body.append(f"Emulators: {emu_links}") + body.append("") # File listing. The sort is stable, so same-named entries keep the # order verify reported them in, which is the declaration order. @@ -2146,7 +2112,7 @@ def _render_platform_systems( # GameCube IPL.bin differ only by GC/USA, GC/EUR and GC/JAP. destination = str(cfg_entry.get("destination") or "") where = f" at `{destination}`" if destination and destination != fname else "" - lines.append(f"{pad}- `{fname}`{where} - {', '.join(details)}") + body.append(f"- `{fname}`{where} - {', '.join(details)}") # Every hash the platform declares, whole: the crc32 is what # System.dat and clrmamepro print, and pasting it found nothing # for a pinned file the collection holds under other bytes. @@ -2167,23 +2133,32 @@ def _render_platform_systems( hash_parts.append(f"CRC32: `{crc32}`") if sha256: hash_parts.append(f"SHA256: `{sha256}`") - lines.append(f"{pad} {' | '.join(hash_parts)}") + body.append(f" {' | '.join(hash_parts)}") if f.get("category_evidence"): - lines.append(f"{pad} {_category_evidence_md(f['category_evidence'])}") + body.append(f" {_category_evidence_md(f['category_evidence'])}") - block = lines[block_start:] - if moved is not None and platform and sum(len(line) + 1 for line in block) > PLATFORM_SECTION_BUDGET: + # Measured as the Markdown the split was tuned on, a collapsible + # block indented by four, so both engines split the same systems. + size = sum(len(line) + 1 for line in body) + if use_collapsible: + status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue") + title = f"{_count(total, 'file')} ({status_tag})" + size += len(f'??? note "{title}"') + 2 + 4 * sum(1 for line in body if line) + block = markup.details("note", title, body) + while block and not block[-1]: + block.pop() + else: + block = body + if moved is not None and platform and size > PLATFORM_SECTION_BUDGET: key, display = platform stem = f"{key}-{anchor}" - body = block[2:] if use_collapsible else block moved[stem] = "\n".join([ f"# {display}: {sys_id}", "", f"Part of the [{display} page]({key}.md#{anchor}), whose other systems " f"are listed there. Each file below is as {display} declares it, with " "its status in the pack.", "", "## Files", "", - *search_excluded( - [line[len(pad):] if line.startswith(pad) else line for line in body]), + *search_excluded(body), "", f"*Generated on {_timestamp()}*", ]) + "\n" lines[block_start:] = [ @@ -2193,7 +2168,7 @@ def _render_platform_systems( continue # The section's heading stays searchable, its file list with every # hash is the lookup's: retrodeck's arcade list was 495 KB of index. - lines[block_start:] = search_excluded(lines[block_start:]) + lines[block_start:] = search_excluded(block) return lines @@ -2340,14 +2315,12 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: for line in (note, "")] if guide.more_folders: detected = _detected_systems(registry, name) if guide.more_folders_detected else [] - lines += [ - f'??? note "{guide.more_folders_title}"', + lines += markup.details("note", guide.more_folders_title, [ + *_folder_rows(guide.more_folders), + *(f"| {system} | `{folder}` |" for system, folder in detected), "", - *_folder_rows(guide.more_folders, " "), - *(f" | {system} | `{folder}` |" for system, folder in detected), - "", - *([f" {guide.more_folders_note}", ""] if guide.more_folders_note else []), - ] + *([guide.more_folders_note, ""] if guide.more_folders_note else []), + ]) lines += [ *(["", ""] if guide.on_card else []), "## Install", @@ -2454,14 +2427,9 @@ def generate_platform_page( ] if registry.get(name, {}).get("status") == "archived": - lines.extend( - [ - '!!! warning "Archived platform"', - "", - f" {display}'s list is kept as last read. Its pack is still built.", - "", - ] - ) + lines.extend(markup.aside("warning", "Archived platform", [ + f"{display}'s list is kept as last read. Its pack is still built.", + ])) summary = GUIDES[name].summary if name in GUIDES else "" if summary: @@ -2485,45 +2453,23 @@ def generate_platform_page( if no_list: # MinUI and its forks hand each core a folder and name no file: a # 0/0 coverage band read as "this device is not supported". - lines.extend( - [ - '!!! info "No BIOS list of its own"', - "", - f" {display} names no BIOS file itself: it gives each emulator core " - "its own folder and the core looks for its files there. The pack " - "places every file the cores it runs need into those folders. The " - "core files are listed further down this page.", - "", - ] - ) + lines.extend(markup.aside("info", "No BIOS list of its own", [ + f"{display} names no BIOS file itself: it gives each emulator core " + "its own folder and the core looks for its files there. The pack " + "places every file the cores it runs need into those folders. The " + "core files are listed further down this page.", + ])) band = [] if no_list else [ - '
', - "", - '
', - f'{cov["present"]}/{cov["total"]}', - f'Coverage ({pct})', - "
", - "", - '
', - f'{cov["verified"]}', - 'Verified', - "
", - "", - '
', - f'{cov["missing"]}', - 'Missing', - "
", - "", - '
', - f'' - f'{mode}', - 'Checked by', - "
", + markup.block_open("div", 'class="rb-stats"'), "", + *_stat(f"{cov['present']}/{cov['total']}", f"Coverage ({pct})"), + *_stat(str(cov["verified"]), "Verified"), + *_stat(str(cov["missing"]), "Missing"), + *_stat(f'{mode}', "Checked by"), "
", "", ] - lines.extend([*band, '
', "", + lines.extend([*band, markup.block_open("div", 'class="rb-meta-card"'), "", "| Detail | Value |", "|---|---|"]) if hash_type: lines.append(f"| Hash type | {hash_type} |") @@ -3150,9 +3096,7 @@ def generate_provenance_page( lines.extend([f"### {label}", ""]) for dat in sorted(by_dat): entries = by_dat[dat] - lines.extend([f'??? note "{dat} ({len(entries)})"', ""]) - lines.append(" | Name | Description | SHA1 |") - lines.append(" |------|-------------|------|") + table = ["| Name | Description | SHA1 |", "|------|-------------|------|"] for entry in sorted(entries, key=lambda e: e["name"]): sha1 = entry.get("sha1") or "-" # A pipe in catalog text would split the markdown table row @@ -3164,8 +3108,8 @@ def generate_provenance_page( f" Ships in {_ROMSET_SOURCES.get(hit['source'], hit['source'])} " f"`{hit['archive']}` as `{hit['member']}`." ) - lines.append(f" | `{name}` | {desc} | `{sha1}` |") - lines.append("") + table.append(f"| `{name}` | {desc} | `{sha1}` |") + lines.extend(markup.details("note", f"{dat} ({len(entries)})", table)) if derived_entries: lines.extend( @@ -3302,11 +3246,11 @@ def _system_file_card( anchor = "" else: anchored.add(anchor) - lines = ['
', ""] + lines = [markup.block_open("div", 'class="rb-sys-file"'), ""] # The heading's own link is this page's, the second link is the file's # own address, resolved by content wherever its card moves. permalink = ( - f' permalink' ) if anchor else "" lines.append(_card_title( @@ -3373,8 +3317,8 @@ def tables_out_of_search(markdown: str, threshold: int = SEARCH_TABLE_BYTES) -> end += 1 table = lines[index:end] if sum(len(line) for line in table) > threshold: - out += ["", f"{indent}
", "", *table, - "", f"{indent}
", ""] + out += ["", indent + markup.block_open("section", markup.search_exclude()), "", + *table, "", f"{indent}", ""] else: out += table index = end @@ -3393,7 +3337,8 @@ def search_excluded(lines: list[str]) -> list[str]: the first div closed inside an excluded div (a code block's) ended the exclusion and the rest of the block was indexed. """ - return ["", "
", "", *lines, "", "
", ""] + return ["", markup.block_open("section", markup.search_exclude()), "", + *lines, "", "", ""] # Card lines the site search leaves out. They list where a file goes and @@ -3418,7 +3363,8 @@ def _search_skipped_apart(lines: list[str]) -> list[str]: for line in lines: skip = line.startswith(SEARCH_SKIPPED) or (skipping and line.startswith(" ")) if skip and not skipping: - out.extend(["", '
', ""]) + out.extend(["", markup.block_open( + "div", f'class="rb-search-skip" {markup.search_exclude()}'), ""]) elif skipping and not skip: out.extend(["", "
", ""]) skipping = skip @@ -3438,6 +3384,13 @@ def _card_title(text: str, anchor: str, searchable: bool = True) -> str: of cards stops above it (mkdocs_hooks.py), and a card without an anchor keeps the id the toc gives it. """ + if markup.starlight(): + # Raw HTML: the card links to itself already, and a Markdown heading + # would add the engine's anchor link and icon to every card. + title = re.sub(r"`([^`]+)`", lambda m: f"{html.escape(m[1], quote=False)}", text) + ident = f' id="{anchor}"' if anchor else "" + ignore = "" if searchable else f" {markup.search_exclude()}" + return f'{title}' attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title" if not searchable: attributes += " data-search-exclude" @@ -3509,11 +3462,8 @@ def _cards_markdown(cards: list[Card]) -> list[str]: # A variant gets the same card as a primary: the revision a # platform pins is the one a reader comes to find, and showed as # a name and a truncated MD5, nothing said who pins it. - lines.append(f'??? note "Variants ({len(variants)})"') - lines.append("") - for card in variants: - lines.extend(f" {line}" if line else "" for line in card) - lines.append("") + lines.extend(markup.details( + "note", f"Variants ({len(variants)})", [line for card in variants for line in card])) return lines @@ -4019,7 +3969,7 @@ def _render_emulator_file( badges = _file_badges(f, in_repo) badge_str = " ".join(badges) - lines.append('
') + lines.append(markup.block_open("div", 'class="rb-file-entry"')) lines.append("") marker = f'' if alias else "" lines.append(_card_title(f"{marker}{_code(fname)} {badge_str}", anchor)) @@ -4307,14 +4257,12 @@ def _install_block(name: str, profile: dict) -> list[str]: """ if profile.get("type") in ("alias", "launcher", "test"): return [] - return search_excluded([ - '??? tip "Install this emulator\'s files"', - "", - " The installer finds your platform and its BIOS folder, then downloads " + return search_excluded(markup.details("tip", "Install this emulator's files", [ + "The installer finds your platform and its BIOS folder, then downloads " "only the files this emulator reads there and checks each one.", "", - *install_commands(f"--emulator {name}", " "), - ]) + *install_commands(f"--emulator {name}"), + ])) def _platform_install_block(name: str, display: str, on_card: bool = False) -> list[str]: @@ -4325,37 +4273,32 @@ def _platform_install_block(name: str, display: str, on_card: bool = False) -> l computer it is mounted on, whose root is named. """ if on_card: - return [ - '???+ tip "Install with one command"', - "", - f" With the {display} SD card mounted, the installer downloads the files " + return markup.details("tip", "Install with one command", [ + f"With the {display} SD card mounted, the installer downloads the files " "of the pack that are missing or differ from it into `Bios//` on " "the card, checks each one, and resumes where it stopped. `--dest` " "names the card's root.", "", - " Linux, macOS:", + "Linux, macOS:", "", - " ```sh", - f" {install_command('sh', f'--platform {name} --dest /path/to/card')}", - " ```", + "```sh", + install_command("sh", f"--platform {name} --dest /path/to/card"), + "```", "", - " Windows (PowerShell, E: being the card):", + "Windows (PowerShell, E: being the card):", "", - " ```powershell", - " " + install_command("powershell", f"--platform {name} --dest E:\\"), - " ```", - "", - ] - return [ - '???+ tip "Install with one command"', - "", - f" The installer downloads the files of the {display} pack that are " + "```powershell", + install_command("powershell", f"--platform {name} --dest E:\\"), + "```", + ], opened=True) + return markup.details("tip", "Install with one command", [ + f"The installer downloads the files of the {display} pack that are " "missing or differ from it, checks each one, and resumes where it " "stopped. Add `--system` with the ids of the systems overview below " "to install fewer systems.", "", - *install_commands(f"--platform {name}", " "), - ] + *install_commands(f"--platform {name}"), + ], opened=True) def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]: @@ -4363,19 +4306,17 @@ def _console_install_block(console: str, declared: list[tuple[str, str]]) -> lis if not declared: return [] lines = [ - f'??? tip "Install the {console} files"', - "", - " Each platform names the system its own way. On Windows, run the " + "Each platform names the system its own way. On Windows, run the " "same options through `&([scriptblock]::Create((irm " f"{INSTALL_BASE}/install.ps1)))`.", "", - " ```sh", + "```sh", ] lines += [ - f" {install_command('sh', f'--platform {platform} --system {shlex.quote(system)}')}" + install_command("sh", f"--platform {platform} --system {shlex.quote(system)}") for platform, system in declared ] - return search_excluded(lines + [" ```", ""]) + return search_excluded(markup.details("tip", f"Install the {console} files", [*lines, "```"])) def _render_platform_details(profile: dict) -> list[str]: @@ -4383,16 +4324,16 @@ def _render_platform_details(profile: dict) -> list[str]: lines: list[str] = [] platform_details = profile.get("platform_details") if platform_details and isinstance(platform_details, dict): - lines.extend(['???+ info "Platform details"', ""]) + body: list[str] = [] for pk, pv in platform_details.items(): if isinstance(pv, dict): - lines.extend([f" - **{pk}:**", ""]) + body.extend([f"- **{pk}:**", ""]) for sk, sv in pv.items(): - lines.append(f" - {sk}: {_detail_value(profile, sk, sv)}") - lines.append("") + body.append(f" - {sk}: {_detail_value(profile, sk, sv)}") + body.append("") else: - lines.append(f" - **{pk}:** {_detail_value(profile, pk, pv)}") - lines.append("") + body.append(f"- **{pk}:** {_detail_value(profile, pk, pv)}") + lines.extend(markup.details("info", "Platform details", body, opened=True)) return lines @@ -4466,13 +4407,11 @@ def _render_structured_blocks(profile: dict) -> list[str]: val = profile.get(field) if val is None: continue - lines.append(f'???+ abstract "{label}"') - lines.append("") - start = len(lines) - _render_yaml_value(lines, val, indent=4, + body: list[str] = [] + _render_yaml_value(body, val, indent=0, field_text=_structured_field_text(profile)) - lines[start:] = [_escape_tags(line) for line in lines[start:]] - lines.append("") + lines.extend(markup.details( + "abstract", label, [_escape_tags(line) for line in body], opened=True)) return lines @@ -4728,7 +4667,7 @@ def emulator_pages( f"# {emu_name}", "", *([intro, ""] if intro else []), - '
', + markup.block_open("div", 'class="rb-meta-card"'), "", "| Detail | Value |", "|---|---|", @@ -4812,19 +4751,14 @@ def emulator_pages( lines.extend(_render_structured_blocks(profile)) if notes: - indented = _admonition_body(link_prose_citations(profile, notes)) - lines.extend(['??? note "Technical notes"', f" {indented}", ""]) + body = _admonition_body(link_prose_citations(profile, notes)).replace("\n ", "\n") + lines.extend(markup.details("note", "Technical notes", body.split("\n"))) if not files: lines.append("No BIOS or firmware files required.") if exclusion: - lines.extend( - [ - "", - '!!! info "Why no files"', - f" {_escape_tags(str(exclusion))}", - ] - ) + lines.append("") + lines.extend(markup.aside("info", "Why no files", [_escape_tags(str(exclusion))])[:-1]) else: _file_available = _availability_check(db, data_names) @@ -4851,7 +4785,11 @@ def emulator_pages( if data_dirs: # 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 }", ""]) + lines.extend([ + "## Data directories" + (" { data-search-exclude }" if not markup.starlight() else ""), + "", + ]) + trees_start = len(lines) member_anchors: set[str] = set() # Each tree with what it holds, where it comes from, which build reads # it and the code that does: these trees put thousands of files in @@ -4881,13 +4819,7 @@ def emulator_pages( lines.append(f" - Source: {_source_ref_markdown(profile, dd['source_ref'])}") members = _DATA_MEMBERS.get(ref) or {} if members: - lines.extend([ - "", - f' ??? info "{_count(len(members), "file")}, as the packs ship them"', - "", - " | File | Size | SHA1 |", - " |------|-----:|------|", - ]) + table = ["| File | Size | SHA1 |", "|------|-----:|------|"] for member, info in sorted(members.items()): sha1 = str(info.get("sha1", "")) # One id per page: two members can hold the same bytes @@ -4895,12 +4827,19 @@ def emulator_pages( anchor = f"sha1-{sha1[:12]}" span = "" if anchor in member_anchors else f'' member_anchors.add(anchor) - lines.append( - f" | {span}{_code(member)} " + table.append( + f"| {span}{_code(member)} " f"| {_fmt_size(int(info.get('size', 0)))} | {_code(sha1)} |" ) lines.append("") + lines.extend(markup.details( + "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:]) lines.extend(_next_block([ "[Get your files](../which-pack.md), for the setup you play on", @@ -4927,7 +4866,7 @@ def _card_index( + ", ".join(f"[{number}]({stem}.md)" for number, (stem, _e) in enumerate(parts, 1)) + ".", "", - '
', + markup.block_open("section", f'class="rb-card-index" {markup.search_exclude()}'), "", "| File | Required | Category |", "|------|----------|----------|", @@ -5302,9 +5241,11 @@ def generate_gap_analysis( "\"Files to source\", [adding it](contributing.md) fills the gap for " "every platform at once.", "", - "[Download gaps CSV](downloads/gaps.csv){ .md-button } " - "[Open gaps API](api/v1/gaps.json){ .md-button } " - "[All data exports](data.md){ .md-button }", + " ".join([ + markup.button("Download gaps CSV", "downloads/gaps.csv"), + markup.button("Open gaps API", "api/v1/gaps.json"), + markup.button("All data exports", "data.md"), + ]), "", _platform_lists_sentence( total_files_verify, total_verified, total_untested, total_missing_verify, @@ -5733,13 +5674,7 @@ def _render_xref_by_platform( display = cov["platform"] config = cov["config"] - lines.append(f'??? abstract "{display}"') - lines.append("") - lines.append( - f" [Open {display} platform profile](platforms/{pname}.md)" - "{ .md-button }" - ) - lines.append("") + body = [markup.button(f"Open {display} platform profile", f"platforms/{pname}.md"), ""] # The cores the builder and verify resolve: a hand copy here dropped # the libretro set a list naming retroarch pulls in, and the system @@ -5750,11 +5685,11 @@ def _render_xref_by_platform( if key in unique } - lines.append(f" **{len(matched)} cores**{_core_scope_note(config, unique)}") - lines.append("") + body.append(f"**{len(matched)} cores**{_core_scope_note(config, unique)}") + body.append("") - lines.append(" | Core | Classification | Systems | Files | Upstream |") - lines.append(" |------|---------------|---------|-------|----------|") + body.append("| Core | Classification | Systems | Files | Upstream |") + body.append("|------|---------------|---------|-------|----------|") for emu_name in sorted(matched.keys()): p = matched[emu_name] @@ -5798,12 +5733,12 @@ def _render_xref_by_platform( elif source: upstream_display = source - lines.append( - f" | [{emu_display}](emulators/{emu_name}.md) | {cls} | " + body.append( + f"| [{emu_display}](emulators/{emu_name}.md) | {cls} | " f"{sys_str} | {file_str} | {upstream_display} |" ) - lines.append("") + lines.extend(markup.details("abstract", display, body)) return lines @@ -5892,9 +5827,11 @@ def generate_cross_reference( "The files a platform's emulators load and its own list does not name are " "counted on [What the packs do not hold](gaps.md).", "", - "[Download cross-reference CSV](downloads/cross-reference.csv){ .md-button } " - "[Open emulator API](api/v1/emulators.json){ .md-button } " - "[All data exports](data.md){ .md-button }", + " ".join([ + markup.button("Download cross-reference CSV", "downloads/cross-reference.csv"), + markup.button("Open emulator API", "api/v1/emulators.json"), + markup.button("All data exports", "data.md"), + ]), "", ] @@ -5915,13 +5852,11 @@ def generate_contributing() -> str: # The end of a reference page: back to the index, or on to contributing. -REFERENCE_NEXT = "\n" + "\n".join([ - '!!! tip "Next"', - "", - " - [Reference index](wiki/index.md), every reference page in one list", - " - For contributors: [How to contribute](contributing.md)", - "", -]) +def reference_next() -> str: + return "\n" + "\n".join(markup.aside("tip", "Next", [ + "- [Reference index](wiki/index.md), every reference page in one list", + "- For contributors: [How to contribute](contributing.md)", + ])) # Wiki pages @@ -6238,34 +6173,26 @@ def generate_which_pack() -> str: "", *terminal_tabs(("windows", "macos", "linux", "steamdeck", "android"), dict.fromkeys(TERMINALS, "--check"), steps=False), - '??? note "Fill an SD card from a computer"', - "", - " Name your setup and the BIOS folder on the card. For RetroArch on a " - "Nintendo Switch:", - "", - " ```sh", - " " + install_command( - "sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system"), - " ```", - "", + *markup.details("note", "Fill an SD card from a computer", [ + "Name your setup and the BIOS folder on the card. For RetroArch on a " + "Nintendo Switch:", + "", + "```sh", + install_command( + "sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system"), + "```", + ]), "Details: [All installer options](wiki/installer.md).", "", "## Pick your setup", "", - '
', - "", ] - for group, (label, _description) in DEVICE_GROUPS.items(): - members = _group_keys(registry, group, display) - if not members: - continue - lines += [ - f"- **{label}**", - "", - f" [{', '.join(display[k] for k in members)}](#{GROUP_ANCHORS[group]})", - "", - ] - lines += ["
", ""] + lines += markup.card_grid([ + (label, [f"[{', '.join(display[k] for k in members)}](#{GROUP_ANCHORS[group]})"]) + for group, (label, _description) in DEVICE_GROUPS.items() + for members in [_group_keys(registry, group, display)] + if members + ]) for group, (label, description) in DEVICE_GROUPS.items(): members = _group_keys(registry, group, display) if not members: @@ -6294,18 +6221,15 @@ def generate_which_pack() -> str: ) lines.append("") if group == "handheld": - lines += [ - '??? note "Other handhelds that run RetroArch"', - "", - " They use the RetroArch files, in the folder below. The command " + lines += markup.details("note", "Other handhelds that run RetroArch", [ + "They use the RetroArch files, in the folder below. The command " "finds it.", "", - " | System | Runs | BIOS folder |", - " |--------|------|-------------|", - *(f" | {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |" + "| System | Runs | BIOS folder |", + "|--------|------|-------------|", + *(f"| {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |" for name, folder in _detected_systems(registry, "retroarch")), - "", - ] + ]) lines += [ "## Download a ZIP { #manual-download }", "", @@ -6699,7 +6623,7 @@ def _generate(args: argparse.Namespace) -> None: exports = generate_data_exports( docs, db, coverages, profiles, stats, gap_report ) - write_if_changed(str(docs / "data.md"), generate_data_page(exports) + REFERENCE_NEXT) + write_if_changed(str(docs / "data.md"), generate_data_page(exports) + reference_next()) PAGE_DATA["data.md"] = dataset_structured_data(exports, db) export_reports = load_export_reports() @@ -6774,7 +6698,7 @@ def _generate(args: argparse.Namespace) -> None: print("Generating cross-reference page...") write_if_changed( str(docs / "cross-reference.md"), - tables_out_of_search(generate_cross_reference(coverages, profiles)) + REFERENCE_NEXT, + tables_out_of_search(generate_cross_reference(coverages, profiles)) + reference_next(), ) print("Generating gap analysis page...") @@ -6782,7 +6706,7 @@ def _generate(args: argparse.Namespace) -> None: str(docs / "gaps.md"), tables_out_of_search(generate_gap_analysis( profiles, coverages, db, suppl_names, registry, gap_report - )) + REFERENCE_NEXT, + )) + reference_next(), ) print("Generating updated platform files page...") @@ -6791,7 +6715,7 @@ def _generate(args: argparse.Namespace) -> None: generate_exports_page( export_reports, {name: cov["platform"] for name, cov in coverages.items()}, - ) + REFERENCE_NEXT, + ) + reference_next(), ) print("Generating dump provenance page...") @@ -6800,7 +6724,7 @@ def _generate(args: argparse.Namespace) -> None: str(docs / "provenance.md"), tables_out_of_search( generate_provenance_page(db, provenance_report, load_derivable()) - ) + REFERENCE_NEXT, + ) + reference_next(), ) # Wiki pages: copy manually maintained sources + generate dynamic ones diff --git a/scripts/sitediagrams.py b/scripts/sitediagrams.py index 99f28365..4d5d5f15 100644 --- a/scripts/sitediagrams.py +++ b/scripts/sitediagrams.py @@ -36,6 +36,8 @@ from xml.sax.saxutils import escape ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) +import sitemarkup as markup # noqa: E402 + PLACEHOLDER = re.compile(r"(?m)^([ \t]*)[ \t]*$") PLACEHOLDER_ANYWHERE = re.compile(r"") @@ -605,12 +607,12 @@ def figure_html(image_id: str, occurrence: int = 1, inline: bool = False) -> str anchor = f"rb-fig-{image_id}{suffix}" if inline: html = ( - f'' + f'' f'{drawings}{caption}' ) return html.translate(_INLINE_ESCAPES) return ( - f'
\n' + f'
\n' f"{drawings}\n" f"
{caption}
\n" "
" diff --git a/scripts/siteexports.py b/scripts/siteexports.py index bf85862b..5b02b6e9 100644 --- a/scripts/siteexports.py +++ b/scripts/siteexports.py @@ -11,6 +11,8 @@ from __future__ import annotations import json from pathlib import Path +import sitemarkup as markup + EXPORTS_DIR = "exports" REPO_BLOB = "https://github.com/Abdess/retrobios/blob/main" REPO_TREE = "https://github.com/Abdess/retrobios/tree/main" @@ -73,12 +75,8 @@ def requirement_lines(report: dict) -> list[str]: ] if not items: return [] - return [ - f'??? note "Required flags aligned with the code ({len(items)})"', - "", - *[f" - {item}" for item in items], - "", - ] + return markup.details("note", f"Required flags aligned with the code ({len(items)})", + [f"- {item}" for item in items]) def _plural(count: int, noun: str, plural: str = "") -> str: @@ -141,12 +139,8 @@ def candidate_lines(candidates: list[dict]) -> list[str]: title = (reason[:1].upper() + reason[1:]).replace('"', "'") items = [f"`{c['system']}` `{c['destination']}`" for c in entries] if len(entries) > LONG_LIST: - lines.extend([ - f'??? note "{title} ({len(entries)})"', - "", - *[f" - {item}" for item in items], - "", - ]) + lines.extend(markup.details("note", f"{title} ({len(entries)})", + [f"- {item}" for item in items])) else: lines.extend([f"{title}:", "", *[f"- {item}" for item in items], ""]) return lines @@ -263,35 +257,29 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str: ]) lines.extend(candidate_lines(report.get("removal_candidates") or [])) if report.get("invalid"): - lines.extend([ - '!!! warning "The updated file does not pass the platform\'s own checks"', - "", - *[f" - {issue}" for issue in report["invalid"]], - "", - ]) + lines.extend(markup.aside( + "warning", "The updated file does not pass the platform's own checks", + [f"- {issue}" for issue in report["invalid"]])) originals = report.get("originals") or {} files = report["files"] raw = [f"{REPO_RAW}/{EXPORTS_DIR}/{platform}/{name}" for name in files] if len(files) == 1: lines.extend([ - f"[Download the updated file]({raw[0]}){{ .md-button .md-button--primary }} " - f"[See it on GitHub]({REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}){{ .md-button }}", + markup.button("Download the updated file", raw[0], primary=True) + " " + + markup.button("See it on GitHub", f"{REPO_BLOB}/{EXPORTS_DIR}/{platform}/{files[0]}"), "", ]) else: lines.extend([ - f"[Browse the {len(files)} updated files]" - f"({REPO_TREE}/{EXPORTS_DIR}/{platform}){{ .md-button .md-button--primary }}", + markup.button(f"Browse the {len(files)} updated files", + f"{REPO_TREE}/{EXPORTS_DIR}/{platform}", primary=True), "", ]) if len(originals) > 1: - lines.extend([ - f'??? note "The {len(originals)} original files it is based on"', - "", - *[f" - [`{name}`]({url})" for name, url in sorted(originals.items())], - "", - ]) + lines.extend(markup.details( + "note", f"The {len(originals)} original files it is based on", + [f"- [`{name}`]({url})" for name, url in sorted(originals.items())])) elif originals: (name, url), = originals.items() lines.extend([f"Based on [`{name}`]({url}).", ""]) @@ -301,10 +289,6 @@ def generate_exports_page(reports: list[dict], names: dict[str, str]) -> str: items = change_items(report, key) if not items: continue - lines.extend([ - f'??? note "{title} ({len(items)})"', - "", - *[f" - {item}" for item in items], - "", - ]) + lines.extend(markup.details("note", f"{title} ({len(items)})", + [f"- {item}" for item in items])) return "\n".join(lines) diff --git a/scripts/siteguides.py b/scripts/siteguides.py index 990d435c..f0f4fe5b 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -15,6 +15,7 @@ import textwrap from dataclasses import dataclass, fields from pathlib import Path +import sitemarkup as markup from safeparse import require_yaml PLATFORMS_DIR = Path(__file__).resolve().parent.parent / "platforms" @@ -263,15 +264,12 @@ def terminal_tabs( """Content tabs, one per place the command runs, each with how to open the terminal there. Tab labels are shared across the site, so the reader's choice carries from page to page.""" - lines: list[str] = [] + items = [] for key in tabs: label, how, shell = TERMINALS[key] - lines += [f'{indent}=== "{label}"', ""] - if steps: - lines += [f"{indent} {how}", ""] - lines += _fence(shell, install_command(shell, args.get(key, "")), indent + " ") - lines.append("") - return lines + body = [how, ""] if steps else [] + items.append((label, body + _fence(shell, install_command(shell, args.get(key, "")), ""))) + return markup.tabs(items, indent) def platform_args(name: str, guide: Guide) -> dict[str, str]: @@ -290,11 +288,8 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]: def trust_box(prefix: str, indent: str = "") -> list[str]: """Is it safe, under every command on a guide page: five plain facts.""" - return [ - f'{indent}??? note "Is it safe?"', + return markup.details("note", "Is it safe?", [ + *(f"- {item}" for item in TRUST_ITEMS), "", - *(f"{indent} - {item}" for item in TRUST_ITEMS), - "", - f"{indent} [Read the script first]({prefix}wiki/faq.md#is-piping-the-installer-into-a-shell-safe)", - "", - ] + f"[Read the script first]({prefix}wiki/faq.md#is-piping-the-installer-into-a-shell-safe)", + ], indent) diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py new file mode 100644 index 00000000..2116b556 --- /dev/null +++ b/scripts/sitemarkup.py @@ -0,0 +1,183 @@ +"""The page constructs the generator writes, in the syntax of the site engine. + +Every block that is not plain Markdown (a callout, a collapsible block, OS +tabs, a button, a card grid) is written through one of these functions, so +the generator holds no engine syntax of its own. Bodies are passed without +indentation: the function indents them as the engine reads them. + +The engine is Astro Starlight. MkDocs Material is kept while the two builds +are compared page by page, selected with set_engine("mkdocs"). +""" + +from __future__ import annotations + +import html +import re + +ENGINES = ("mkdocs", "starlight") +_engine = "mkdocs" + +# Material's callout kinds, by the role Starlight gives them. +ASIDE_ROLES = { + "note": "note", "info": "note", "abstract": "note", "example": "note", + "question": "note", "quote": "note", "tip": "tip", "success": "tip", + "warning": "caution", "failure": "danger", "danger": "danger", "bug": "danger", +} + +# The attribute that keeps an element out of the site search. +SEARCH_EXCLUDE = {"mkdocs": "data-search-exclude", "starlight": "data-pagefind-ignore"} + + +def set_engine(name: str) -> None: + global _engine + if name not in ENGINES: + raise ValueError(f"unknown site engine {name!r}, expected one of {ENGINES}") + _engine = name + + +def engine() -> str: + return _engine + + +def starlight() -> bool: + return _engine == "starlight" + + +def search_exclude() -> str: + return SEARCH_EXCLUDE[_engine] + + +def inline_html(text: str) -> str: + """Inline Markdown (code, bold, links) for text placed inside raw HTML, + where Markdown is not read.""" + parts = re.split(r"(`[^`]+`)", text) + out = [] + for part in parts: + if part.startswith("`") and part.endswith("`") and len(part) > 1: + out.append(f"{html.escape(part[1:-1], quote=False)}") + continue + part = html.escape(part, quote=False) + part = re.sub(r"\*\*([^*]+)\*\*", r"\1", part) + part = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", r'\1', part) + out.append(part) + return "".join(out) + + +def _indented(lines: list[str], pad: str) -> list[str]: + return [f"{pad}{line}" if line.strip() else "" for line in lines] + + +def _trim(lines: list[str]) -> list[str]: + start, end = 0, len(lines) + while start < end and not lines[start].strip(): + start += 1 + while end > start and not lines[end - 1].strip(): + end -= 1 + return list(lines[start:end]) + + +def aside(kind: str, title: str, body: list[str], indent: str = "") -> list[str]: + """A callout that is always open.""" + body = _trim(body) + if _engine == "mkdocs": + return [f'{indent}!!! {kind} "{title}"', "", *_indented(body, indent + " "), ""] + label = title.replace("[", "\\[").replace("]", "\\]") + fence = "::::" if any(line.lstrip().startswith(":::") for line in body) else ":::" + return [f"{indent}{fence}{ASIDE_ROLES.get(kind, 'note')}[{label}]", + *_indented(body, indent), f"{indent}{fence}", ""] + + +def details(kind: str, title: str, body: list[str], indent: str = "", opened: bool = False) -> list[str]: + """A collapsible block, closed unless *opened*.""" + body = _trim(body) + if _engine == "mkdocs": + marker = "???+" if opened else "???" + return [f'{indent}{marker} {kind} "{title}"', "", *_indented(body, indent + " "), ""] + role = ASIDE_ROLES.get(kind, "note") + state = " open" if opened else "" + return [ + f'{indent}
', + f"{indent}{inline_html(title)}", + "", + *_indented(body, indent), + "", + f"{indent}
", + "", + ] + + +def tabs(items: list[tuple[str, list[str]]], indent: str = "", sync: str = "os") -> list[str]: + """Content tabs. Tabs that share a label share the reader's choice + across pages.""" + lines: list[str] = [] + if _engine == "mkdocs": + for label, body in items: + lines += [f'{indent}=== "{label}"', "", *_indented(_trim(body), indent + " "), ""] + return lines + lines.append(f'{indent}') + for label, body in items: + lines += [f'{indent}', "", + *_indented(_trim(body), indent), "", f"{indent}"] + return [*lines, f"{indent}", ""] + + +def button(text: str, target: str, primary: bool = False) -> str: + """A link drawn as a button.""" + if _engine == "mkdocs": + classes = ".md-button .md-button--primary" if primary else ".md-button" + return f"[{text}]({target}){{ {classes} }}" + classes = "rb-button rb-button-primary" if primary else "rb-button" + return f'{inline_html(text)}' + + +def image(alt: str, src: str, **attributes: str) -> str: + """An image with HTML attributes (width, align).""" + if _engine == "mkdocs": + attrs = " ".join(f"{key}={value}" for key, value in attributes.items()) + return f"![{alt}]({src}){{ {attrs} }}" + attrs = "".join(f' {key}="{html.escape(str(value))}"' for key, value in attributes.items()) + return f'{html.escape(alt)}' + + +def block_open(tag: str, attributes: str = "") -> str: + """The opening tag of an HTML block whose content is Markdown.""" + attributes = f" {attributes.strip()}" if attributes.strip() else "" + if _engine == "mkdocs": + return f"<{tag}{attributes} markdown>" + return f"<{tag}{attributes}>" + + +def card_grid(cards: list[tuple[str, list[str]]]) -> list[str]: + """Cards in a grid: a bold title line and a body each.""" + lines = [block_open("div", 'class="grid cards"' if _engine == "mkdocs" else 'class="rb-cards"'), ""] + for title, body in cards: + if _engine == "mkdocs": + lines += [f"- **{title}**", "", *_indented(_trim(body), " "), ""] + else: + lines += ['
', "", f"**{title}**", "", *_trim(body), "", "
", ""] + return [*lines, "
", ""] + + +def steps(items: list[str]) -> list[str]: + """An ordered list drawn as numbered steps.""" + return [block_open("div", 'class="rb-steps"'), "", + *(f"{number}. {item}" for number, item in enumerate(items, 1)), "", "
", ""] + + +def heading_attributes(anchor: str = "", classes: str = "", search: bool = True) -> str: + """The attribute block of a heading: its id, classes, and whether the + search reads it.""" + parts = [f"#{anchor}" if anchor else "", *(f".{c}" for c in classes.split())] + if not search: + parts.append(search_exclude()) + parts = [part for part in parts if part] + return f" {{ {' '.join(parts)} }}" if parts else "" + + +def site_href(path: str) -> str: + """A link to a site address written in raw HTML (``files/?sha1=...``), + which no Markdown resolution reaches: rooted at the site under Starlight, + relative to a page two levels deep under MkDocs.""" + if _engine == "mkdocs": + return f"../../{path}" + return f"/retrobios/{path}" From 342d0f727135692afbfe3ceb73f8c0b7988d5387 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:41:39 +0200 Subject: [PATCH 2/6] 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. --- .github/workflows/deploy-site.yml | 2 + scripts/generate_site.py | 250 +- scripts/sitemarkup.py | 14 + scripts/siterender.py | 4 +- scripts/sitestarlight.py | 572 ++ website/.gitignore | 7 + website/astro.config.mjs | 85 + website/package-lock.json | 5403 ++++++++++++++++++ website/package.json | 17 + website/src/assets/logo-dark.svg | 1 + website/src/assets/logo-light.svg | 1 + website/src/components/FilePicker.astro | 186 + website/src/components/Footer.astro | 10 + website/src/components/MarkdownContent.astro | 21 + website/src/components/PageTitle.astro | 40 + website/src/content.config.ts | 22 + website/src/env.d.ts | 7 + website/src/routeData.ts | 96 + website/src/scripts/site.ts | 275 + website/src/styles/retrobios.css | 557 ++ website/tools/axe.mjs | 24 + website/tools/check_tools.mjs | 44 + website/tools/lighthouse.py | 57 + website/tools/measure.mjs | 106 + website/tools/serve.mjs | 51 + website/tools/shots.mjs | 25 + 26 files changed, 7817 insertions(+), 60 deletions(-) create mode 100644 scripts/sitestarlight.py create mode 100644 website/.gitignore create mode 100644 website/astro.config.mjs create mode 100644 website/package-lock.json create mode 100644 website/package.json create mode 100644 website/src/assets/logo-dark.svg create mode 100644 website/src/assets/logo-light.svg create mode 100644 website/src/components/FilePicker.astro create mode 100644 website/src/components/Footer.astro create mode 100644 website/src/components/MarkdownContent.astro create mode 100644 website/src/components/PageTitle.astro create mode 100644 website/src/content.config.ts create mode 100644 website/src/env.d.ts create mode 100644 website/src/routeData.ts create mode 100644 website/src/scripts/site.ts create mode 100644 website/src/styles/retrobios.css create mode 100644 website/tools/axe.mjs create mode 100644 website/tools/check_tools.mjs create mode 100644 website/tools/lighthouse.py create mode 100644 website/tools/measure.mjs create mode 100644 website/tools/serve.mjs create mode 100644 website/tools/shots.mjs diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 22c6dd54..27ca1287 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -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" diff --git a/scripts/generate_site.py b/scripts/generate_site.py index f8e6da82..bd41ec6c 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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(" 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'
Generated on {_timestamp()}.
') - 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'
Generated on {_timestamp()}.
') + 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"`([^`]*)`|(.*?)", 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", + "", + '

This address has no page. It may have moved since the link was written.

', + "", + '

', + "", + "- [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)", + "", + '', + "", + ]) + + 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.", + "", + "", + "", + ] 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 ) diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py index 2116b556..14362806 100644 --- a/scripts/sitemarkup.py +++ b/scripts/sitemarkup.py @@ -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" diff --git a/scripts/siterender.py b/scripts/siterender.py index 0683ad43..16d48ec9 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -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]] = {} diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py new file mode 100644 index 00000000..324ae18c --- /dev/null +++ b/scripts/sitestarlight.py @@ -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"(`+)(?!`)(.+?)(?!?)\[(?P(?:[^\[\]\n]|\[[^\]\n]*\])*)\]\((?P[^)\s]+)(?P \"[^\"]*\")?\)") +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("{", "{").replace("}", "}") + chunk = re.sub(r"\x00(\d+)\x00", lambda m: held[int(m[1])], chunk) + chunk = re.sub(r"<(?![A-Za-z/])", "<", chunk) + return _VOID.sub(lambda m: f"<{m[1]}{m[2].rstrip()} />", chunk) + return outside_code(text, fix) + + +def used_components(body: str) -> list[str]: + return [name for name in COMPONENTS if re.search(rf"<{name}[\s/>]", body)] + + +# Pages + + +def _front_matter(data: dict) -> str: + import yaml + + return "---\n" + yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=1000) + "---\n\n" + + +def og_section(rel: str, level: str) -> str: + """The sharing card a page takes: its level, or what its address says.""" + url = page_url(rel)[len(BASE):] + if not url: + return "home" + if level in ("start", "setup", "explore", "reference", "contribute"): + return level + if url.startswith(("which-pack", "wiki/first-install", "wiki/faq", "wiki/getting-started")): + return "start" + if url.startswith("platforms"): + return "setup" + if url.startswith(("systems", "emulators", "files")): + return "explore" + if url.startswith(("contributing", "wiki/adding", "wiki/profiling", "wiki/testing", "wiki/release")): + return "contribute" + return "reference" + + +def page_head(rel: str, level: str, title: str, browser_title: str, structured: dict) -> list[dict]: + """What a page adds to its head: its own window title, the sharing + card of its section, and its structured data.""" + section = og_section(rel, level) + image = f"{SITE_URL}assets/og/{section}.png" + head: list[dict] = [] + if browser_title != title: + head.append({"tag": "title", "content": f"{browser_title} - RetroBIOS"}) + head += [ + {"tag": "meta", "attrs": {"property": "og:image", "content": image}}, + {"tag": "meta", "attrs": {"property": "og:image:width", "content": "1200"}}, + {"tag": "meta", "attrs": {"property": "og:image:height", "content": "630"}}, + {"tag": "meta", "attrs": {"property": "og:image:alt", "content": f"RetroBIOS: {browser_title}"}}, + {"tag": "meta", "attrs": {"name": "twitter:image", "content": image}}, + {"tag": "script", "attrs": {"type": "application/ld+json"}, + "content": json.dumps(structured, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")}, + ] + return head + + +def split_title(markdown: str) -> tuple[str, str, str]: + """The page's H1 as plain text, the id MkDocs gave it, and the body + without it.""" + match = re.search(r"^# (.+?)[ \t]*$\n?", markdown, re.M) + if not match: + return "", "", markdown + explicit = re.search(r"\{[^}]*#([^\s}]+)[^}]*\}\s*$", match[1]) + title = re.sub(r"\s*\{[^}]*\}\s*$", "", match[1]) + ident = explicit[1] if explicit else toc_slug(title) + title = re.sub(r"<[^>]+>", "", title) + title = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", title) + title = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", title).replace("`", "") + return unescape(title).strip(), ident, markdown[: match.start()] + markdown[match.end():] + + +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="/:#?=&%") diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 00000000..4220bff9 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.astro/ +# Written by scripts/generate_site.py +src/content/docs/ +src/generated/ +public/ diff --git a/website/astro.config.mjs b/website/astro.config.mjs new file mode 100644 index 00000000..3101060d --- /dev/null +++ b/website/astro.config.mjs @@ -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, + ], +}); diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000..06829b5d --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,5403 @@ +{ + "name": "retrobios-site", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "retrobios-site", + "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" + } + }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.5.2.tgz", + "integrity": "sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-android-arm64": "0.5.2", + "@astrojs/compiler-binding-darwin-arm64": "0.5.2", + "@astrojs/compiler-binding-darwin-x64": "0.5.2", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.5.2", + "@astrojs/compiler-binding-linux-arm64-musl": "0.5.2", + "@astrojs/compiler-binding-linux-x64-gnu": "0.5.2", + "@astrojs/compiler-binding-linux-x64-musl": "0.5.2", + "@astrojs/compiler-binding-wasm32-wasi": "0.5.2", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.5.2", + "@astrojs/compiler-binding-win32-x64-msvc": "0.5.2" + } + }, + "node_modules/@astrojs/compiler-binding-android-arm64": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-android-arm64/-/compiler-binding-android-arm64-0.5.2.tgz", + "integrity": "sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.5.2.tgz", + "integrity": "sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.5.2.tgz", + "integrity": "sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.5.2.tgz", + "integrity": "sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.5.2.tgz", + "integrity": "sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.5.2.tgz", + "integrity": "sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.5.2.tgz", + "integrity": "sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.5.2.tgz", + "integrity": "sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/wasm-runtime": "^1.2.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.5.2.tgz", + "integrity": "sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.5.2.tgz", + "integrity": "sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.5.2.tgz", + "integrity": "sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.5.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.13.0.tgz", + "integrity": "sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "picomatch": "^4.0.7", + "retext-smartypants": "^6.2.0", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "unified": "^11.0.5", + "yaml": "^2.9.1" + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.4.5.tgz", + "integrity": "sha512-0IeGdoFbv0f+442KJhYS44Wg8F3MuHmRsxIEYuFyqkjRubc6dPBFZ7azor5t4AeXLQoT5A5h9HyViKpd70Dl9w==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/prism": "4.1.1", + "github-slugger": "^2.0.0", + "satteri": "^0.10.5" + } + }, + "node_modules/@astrojs/mdx": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-8.1.1.tgz", + "integrity": "sha512-jEJUJsO2KqmKfBXaKeDHDCCj+Kerj1Fj+XndHznF9wxahvs+1onYCBeKmieM28N2+uA6mW57DOQQw5YaDLdBnA==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/markdown-satteri": "0.4.5", + "es-module-lexer": "^3.0.3" + }, + "engines": { + "node": ">=22.23.4" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0", + "@astrojs/markdown-satteri": "^0.4.0", + "astro": "^7.2.10" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/prism": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.1.1.tgz", + "integrity": "sha512-sXlAXJX6t4QfuLg+CuCDzpg7NoxWcyb4paFprgtFppLeisCBmh935h+tJ5CWXfsXEoW2IDy/ihbRMtiu28Chjw==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.23.4" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz", + "integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.42.6", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.42.6.tgz", + "integrity": "sha512-Cfs7GdgzgNRCauDxQVsFUVL1thrGHK/rQdbv/SURv/xhGBXx+j5ypOHW9ENPraQxbIzUC8KAnJV7L9mvEVRuyQ==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-satteri": "^0.4.0", + "@astrojs/mdx": "^8.0.0", + "@astrojs/sitemap": "^3.7.3", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.5", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.44.0", + "bcp-47": "^2.1.0", + "hast-util-format": "^1.1.0", + "hast-util-select": "^6.0.4", + "hast-util-to-html": "^9.0.5", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", + "klona": "^2.0.6", + "magic-string": "^1.2.3", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.5.2", + "remark-directive": "^4.0.0", + "satteri": "^0.10.3", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0", + "astro": "^7.2.10" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.4.1.tgz", + "integrity": "sha512-SMkvMsGOpM0FMAoT5pzhgI3LZC0JSPWf7Evwop+fZ3N6UkusPM7lI+sOJjMNI5OJrVoiei1/AGo1cY7sikTc5A==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.9.0" + }, + "engines": { + "node": ">=22.23.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", + "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.3.tgz", + "integrity": "sha512-pffktF0TJoxC08dgNcutl/0LcSnTrjF6vXR39qVpwZ0EyxpwO9zjI7dbni6sO7w3yHpRBO0voLFvhzB+EPJRsA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^7.0.2", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.3.tgz", + "integrity": "sha512-ysW4VXk9vXEliA99Ur6ApUepVtx74sPolp6o/j45BRjZQoL5O6kXKMhSP4koQLih8HbC5B3ImIvO+HKKhkv/oA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.3" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.3.tgz", + "integrity": "sha512-jAibAZPG7lDmi61EFJADZjoSZzjHiwZZdbbgR/mTlWXM4ON/Pyo/9tB2vrqQNKylsci6jqtCi6idrwqQXF53xA==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.3", + "shiki": "^4.0.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.3.tgz", + "integrity": "sha512-4Ae2xRSDV5kdWPsi40xWbOtoihbzM0zLVjQKkDAnD5KzFXl+6/kd+VhuWOlTRVaJ5cqGk++vCbAaf0MR1XbZlw==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.3" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.5.2.tgz", + "integrity": "sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==", + "license": "MIT" + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz", + "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz", + "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz", + "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz", + "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz", + "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz", + "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.5.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz", + "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.1.0.tgz", + "integrity": "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/am-i-vibing": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz", + "integrity": "sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/astro": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.4.2.tgz", + "integrity": "sha512-FAH4cdDlFaVnQGjazLTEIC5vJqGlo5XANc1wIbzQHIqAjJTll/RWTUs+SPNW1JdezG4YGxMZllaE5A41T2kzhA==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-rs": "^0.5.1", + "@astrojs/internal-helpers": "0.13.0", + "@astrojs/markdown-satteri": "0.4.5", + "@astrojs/telemetry": "3.4.1", + "@capsizecss/unpack": "^4.0.1", + "@clack/prompts": "^1.8.1", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.5.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^6.0.2", + "diff": "^9.0.0", + "dset": "^3.1.4", + "es-module-lexer": "^3.0.3", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.6", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.3.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.4.3", + "magicast": "^0.5.5", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^3.0.0", + "p-limit": "^7.3.3", + "p-queue": "^9.3.5", + "package-manager-detector": "^1.9.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.7", + "rolldown": "^1.2.13", + "shiki": "^4.5.0", + "smol-toml": "^1.9.1", + "svgo": "^4.1.0", + "tinyclip": "^1.0.3", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "tinyps": "^0.5.0", + "ultrahtml": "^1.7.0", + "unifont": "^1.0.3", + "unstorage": "^1.17.5", + "verkit": "^0.5.0", + "vite": "^8.3.4", + "vitefu": "^1.1.3", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.6.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.23.4", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.3.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/astro-expressive-code": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.3.tgz", + "integrity": "sha512-LUpQmmvzr90JXP5QIAopgdjlOKLuT3x0wlPAUpc4OZtPtFS8sbflPcGPpL/lG3pQuUCQV2CXUFgYNBYNu9tphA==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.44.3", + "url-extras": "^0.1.0" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.2.tgz", + "integrity": "sha512-wsv86UuNFuPnVieKLyECTT+CP1WSAHeoIg5P3xu2kA9Z+7fVPoKt7dxzSeEhtJel0AI+NW3fIW3KtTKQKUCngg==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-6.0.3.tgz", + "integrity": "sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-module-lexer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.3.tgz", + "integrity": "sha512-7RGslhJTNt01pdn5I3QmVh5C8ZIiSm0A+ORQ9j9r39oOgJVKjn/7dXl9rMT/SqmFLE6pJkTYI7UjW0BxJnW7OQ==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.3", + "@expressive-code/plugin-frames": "^0.44.3", + "@expressive-code/plugin-shiki": "^0.44.3", + "@expressive-code/plugin-text-markers": "^0.44.3" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fnv1a-64": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/fnv1a-64/-/fnv1a-64-0.1.2.tgz", + "integrity": "sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==", + "license": "MIT" + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "node_modules/has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "26.5.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.5.tgz", + "integrity": "sha512-txdaNOlnZCdiTMSlEzl5hUJU8TC9R4J/RCR1G7XrDPbWT2t3mR142QPz84ZuZeXDt3yq+9zmsdPiakxYtmp3fw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-absolute-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-5.0.0.tgz", + "integrity": "sha512-sdJyNpBnQHuVnBunfzjAecOhZr2+A30ywfFvu3EnxtKLUWfwGgyWUmqHbGZiU6vTfHpCPm5GvLe4BAvlU9n8VQ==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.1.tgz", + "integrity": "sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-character": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.3.tgz", + "integrity": "sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.5.tgz", + "integrity": "sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.2.tgz", + "integrity": "sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz", + "integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype-expressive-code": { + "version": "0.44.3", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.3.tgz", + "integrity": "sha512-vaVuQPobFXvmtcHXQ5uqXZlzuuPnLyIuZcX/03sX1U/Qfj9fZrPYI9w66OSr/PUx6alzRYmreEwgYBbsVx8HsA==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.44.3" + } + }, + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz", + "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.5.0", + "@shikijs/engine-javascript": "4.5.0", + "@shikijs/engine-oniguruma": "4.5.0", + "@shikijs/langs": "4.5.0", + "@shikijs/themes": "4.5.0", + "@shikijs/types": "4.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/starlight-links-validator": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/starlight-links-validator/-/starlight-links-validator-0.27.0.tgz", + "integrity": "sha512-UvPl8L9hfWR+yWbLOEtljLmpJCGcM6AVseVy5IO9dmqdOjW4R+Y8FaYyVA5axCC8RvxM5xU7M1WTerpwU8rYbw==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-satteri": "^0.4.0", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "is-absolute-url": "^5.0.0", + "mdast-util-mdx-jsx": "^3.2.0", + "mdast-util-to-hast": "^13.2.1", + "picomatch": "^4.0.3", + "satteri": "^0.10.3", + "terminal-link": "^5.0.0", + "unist-util-visit": "^5.1.0", + "yaml": "^2.8.3" + }, + "peerDependencies": { + "@astrojs/starlight": ">=0.42.0", + "astro": ">=7.2.10" + } + }, + "node_modules/starlight-sidebar-topics": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/starlight-sidebar-topics/-/starlight-sidebar-topics-0.9.0.tgz", + "integrity": "sha512-xFwMAAtTVyqsO5DZ8Ldk0vN+2QoPdtjtDi8fbrzvQATIJvIvfaBLL9JzfXAjNHI9vMkj35VMWMd8tTICeO6RIA==", + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@astrojs/starlight": ">=0.42.0", + "astro": "^7.2.10" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-hyperlinks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.6.0.tgz", + "integrity": "sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==", + "license": "MIT", + "dependencies": { + "has-flag": "^5.0.1", + "supports-color": "^10.2.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/svgo": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^6.0.0", + "css-tree": "^3.0.1", + "css-what": "^7.0.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "1.6.1" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/terminal-link": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-5.0.0.tgz", + "integrity": "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^4.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-1.0.3.tgz", + "integrity": "sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyps": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/tinyps/-/tinyps-0.5.1.tgz", + "integrity": "sha512-B6Ykif5HflC3AdDkS4Zst/2muYVfeXoVMy+KorqH80s/1x4vunIORpNySH681X7fjj+dFM9MDKD6/Hsccjb87Q==", + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-1.0.3.tgz", + "integrity": "sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "fnv1a-64": "^0.1.2", + "std-env": "^4.2.0", + "undici": "^8.0.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.6.tgz", + "integrity": "sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.11", + "lru-cache": "^11.5.3", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.4" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/url-extras": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", + "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 00000000..83f39b96 --- /dev/null +++ b/website/package.json @@ -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" + } +} diff --git a/website/src/assets/logo-dark.svg b/website/src/assets/logo-dark.svg new file mode 100644 index 00000000..31174ea3 --- /dev/null +++ b/website/src/assets/logo-dark.svg @@ -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> diff --git a/website/src/assets/logo-light.svg b/website/src/assets/logo-light.svg new file mode 100644 index 00000000..88b79ee3 --- /dev/null +++ b/website/src/assets/logo-light.svg @@ -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> diff --git a/website/src/components/FilePicker.astro b/website/src/components/FilePicker.astro new file mode 100644 index 00000000..793e8e5e --- /dev/null +++ b/website/src/components/FilePicker.astro @@ -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> diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro new file mode 100644 index 00000000..4ad1c9f5 --- /dev/null +++ b/website/src/components/Footer.astro @@ -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> diff --git a/website/src/components/MarkdownContent.astro b/website/src/components/MarkdownContent.astro new file mode 100644 index 00000000..44cb19e3 --- /dev/null +++ b/website/src/components/MarkdownContent.astro @@ -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> diff --git a/website/src/components/PageTitle.astro b/website/src/components/PageTitle.astro new file mode 100644 index 00000000..526a780f --- /dev/null +++ b/website/src/components/PageTitle.astro @@ -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> diff --git a/website/src/content.config.ts b/website/src/content.config.ts new file mode 100644 index 00000000..15a91e1b --- /dev/null +++ b/website/src/content.config.ts @@ -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(), + }), + }), + }), +}; diff --git a/website/src/env.d.ts b/website/src/env.d.ts new file mode 100644 index 00000000..15399ae0 --- /dev/null +++ b/website/src/env.d.ts @@ -0,0 +1,7 @@ +/// <reference path="../.astro/types.d.ts" /> + +declare namespace App { + interface Locals { + rbBreadcrumbs?: import('./routeData').Crumb[]; + } +} diff --git a/website/src/routeData.ts b/website/src/routeData.ts new file mode 100644 index 00000000..b9f63a27 --- /dev/null +++ b/website/src/routeData.ts @@ -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); +}); diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts new file mode 100644 index 00000000..9d9bfe77 --- /dev/null +++ b/website/src/scripts/site.ts @@ -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(); diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css new file mode 100644 index 00000000..e8385889 --- /dev/null +++ b/website/src/styles/retrobios.css @@ -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; + } +} diff --git a/website/tools/axe.mjs b/website/tools/axe.mjs new file mode 100644 index 00000000..2b01fdec --- /dev/null +++ b/website/tools/axe.mjs @@ -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(); diff --git a/website/tools/check_tools.mjs b/website/tools/check_tools.mjs new file mode 100644 index 00000000..91c2e7cd --- /dev/null +++ b/website/tools/check_tools.mjs @@ -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(); diff --git a/website/tools/lighthouse.py b/website/tools/lighthouse.py new file mode 100644 index 00000000..224f9abd --- /dev/null +++ b/website/tools/lighthouse.py @@ -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()) diff --git a/website/tools/measure.mjs b/website/tools/measure.mjs new file mode 100644 index 00000000..2a854573 --- /dev/null +++ b/website/tools/measure.mjs @@ -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(); diff --git a/website/tools/serve.mjs b/website/tools/serve.mjs new file mode 100644 index 00000000..1b12d881 --- /dev/null +++ b/website/tools/serve.mjs @@ -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'); diff --git a/website/tools/shots.mjs b/website/tools/shots.mjs new file mode 100644 index 00000000..61edc77a --- /dev/null +++ b/website/tools/shots.mjs @@ -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(); From 294942afd08c3bef1e898060f177cf35f727feae Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:46:36 +0200 Subject: [PATCH 3/6] feat: port the site ports and picker checks Tables with a download link stack on phones, the browser check covers the lookup, a hash typed in the search, breadcrumbs, previous and next across tabs, glossary terms and the 404 hint. validate_site budgets the Pagefind index pieces and takes the base path without mkdocs.yml. A test holds the picker to install.narrow() on 180 selections. --- scripts/validate_site.py | 47 +++++++++++++++--- tests/test_site_picker.py | 91 ++++++++++++++++++++++++++++++++++ website/src/scripts/site.ts | 8 +-- website/tools/check_tools.mjs | 42 ++++++++++++++++ website/tools/shot_element.mjs | 13 +++++ wiki/testing-guide.md | 2 +- 6 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 tests/test_site_picker.py create mode 100644 website/tools/shot_element.mjs diff --git a/scripts/validate_site.py b/scripts/validate_site.py index b39bf9ee..fe21d2d7 100644 --- a/scripts/validate_site.py +++ b/scripts/validate_site.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Validate the built RetroBIOS site as a navigable, accessible data product. -This complements ``mkdocs build --strict`` with checks on the rendered HTML: -metadata, heading structure, image alternatives, duplicate IDs, structured -data, local resources, links and fragments. +This complements the engine's own link check (``mkdocs build --strict`` or +Starlight's link validator) with checks on the rendered HTML: metadata, +heading structure, image alternatives, duplicate IDs, structured data, local +resources, links and fragments, and the budgets of what a visitor downloads. """ from __future__ import annotations @@ -53,6 +54,12 @@ MAX_PAGE_BYTES = 1_000_000 # 21.0 MB raw and 4.88 MB gzip with hashes in it, 7.3 MB and 1.44 MB without. MAX_SEARCH_INDEX_BYTES = 8_500_000 MAX_SEARCH_INDEX_GZIP_BYTES = 1_700_000 +# Pagefind loads its index in pieces: the metadata, then the chunks the words +# typed fall in (stored compressed). 59 KB for the largest chunk and 7.5 KB of +# metadata on 2026-10-22, 5.2 MB for the whole index on disk. +MAX_PAGEFIND_CHUNK_BYTES = 150_000 +MAX_PAGEFIND_META_BYTES = 50_000 +MAX_PAGEFIND_TOTAL_BYTES = 12_000_000 # Text inside these is shown as written, so markup there is not a leftover. @@ -167,10 +174,30 @@ def _heading_jumps(levels: list[int]) -> list[str]: if after > before + 1] +def _check_pagefind(site: Path) -> list[str]: + """Pagefind's index pieces, each against its budget.""" + root = site / "pagefind" + files = [path for path in root.rglob("*") if path.is_file()] + issues = [] + for path in files: + size = path.stat().st_size + rel = path.relative_to(site).as_posix() + if path.suffix == ".pf_index" and size > MAX_PAGEFIND_CHUNK_BYTES: + issues.append(f"{rel}: {size:,} bytes, over the {MAX_PAGEFIND_CHUNK_BYTES:,} budget of a chunk") + if path.suffix == ".pf_meta" and size > MAX_PAGEFIND_META_BYTES: + issues.append(f"{rel}: {size:,} bytes, over the {MAX_PAGEFIND_META_BYTES:,} budget") + total = sum(path.stat().st_size for path in files) + if total > MAX_PAGEFIND_TOTAL_BYTES: + issues.append(f"pagefind/: {total:,} bytes, over the {MAX_PAGEFIND_TOTAL_BYTES:,} budget") + return issues + + def _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 [] @@ -343,12 +370,12 @@ def _check_links(pages: dict, site: Path, base_path: str) -> list[str]: return issues -def validate_site(site: Path, config_path: Path) -> list[str]: +def validate_site(site: Path, config_path: Path | None = None, base: 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) + 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}"] @@ -383,8 +410,12 @@ def main() -> None: help="the built site (default: %(default)s)", ) parser.add_argument( - "--config", default="mkdocs.yml", - help="the MkDocs configuration (default: %(default)s)", + "--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)", ) parser.add_argument( "--max-errors", type=int, default=100, @@ -393,7 +424,7 @@ def main() -> None: args = parser.parse_args() try: - issues = validate_site(Path(args.site_dir), Path(args.config)) + issues = validate_site(Path(args.site_dir), Path(args.config) if args.config else None, args.base) except (OSError, RuntimeError, ValueError) as exc: print(f"Site validation failed: {exc}", file=sys.stderr) raise SystemExit(1) from exc diff --git a/tests/test_site_picker.py b/tests/test_site_picker.py new file mode 100644 index 00000000..a4f2fc0e --- /dev/null +++ b/tests/test_site_picker.py @@ -0,0 +1,91 @@ +"""The file picker on Get your files lists what the installer installs. + +The picker reads a compacted copy of each install/<platform>.json and keeps +a file when one of its systems is chosen, or when it has none and "other" +is chosen, then prints `--system` with the choices. install.py --system +narrows with install.narrow(). The two must agree file for file, or the +list the page shows is not what the command it prints installs. +""" + +from __future__ import annotations + +import importlib.util +import json +import random +import re +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py") +install = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(install) + +PICKER = REPO_ROOT / "website" / "src" / "components" / "FilePicker.astro" + + +def picked(data: dict, chosen: set[str]) -> list[str]: + """The picker's rule, as FilePicker.astro applies it.""" + wanted = {index for index, system in enumerate(data["systems"]) if system["id"] in chosen} + other = install.OTHER_SYSTEMS in chosen + return [dest for dest, _size, systems in data["files"] + if (any(s in wanted for s in systems) if systems else other)] + + +class PickerMatchesInstaller(unittest.TestCase): + def setUp(self): + import sitestarlight # noqa: PLC0415 + + self.folder = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None) + self.out = Path(self.folder.name) + self.count = sitestarlight.picker_data(REPO_ROOT / "install", self.out) + + def tearDown(self): + self.folder.cleanup() + + def test_every_manifest_has_its_list(self): + index = json.loads((self.out / "index.json").read_text(encoding="utf-8")) + manifests = [p for p in (REPO_ROOT / "install").glob("*.json") + if "files" in json.loads(p.read_text(encoding="utf-8"))] + self.assertEqual(len(index), len(manifests)) + self.assertEqual(self.count, len(manifests)) + + def test_random_selections_equal_install_narrow(self): + rng = random.Random(20261022) + checked = 0 + for entry in json.loads((self.out / "index.json").read_text(encoding="utf-8")): + platform = entry["platform"] + manifest = json.loads((REPO_ROOT / "install" / f"{platform}.json").read_text(encoding="utf-8")) + data = json.loads((self.out / f"{platform}.json").read_text(encoding="utf-8")) + choices = [s["id"] for s in data["systems"]] + ([install.OTHER_SYSTEMS] if data["other"] else []) + for _round in range(10): + chosen = set(rng.sample(choices, rng.randint(1, min(len(choices), 6)))) + files, _omitted = install.narrow(manifest["files"], [], sorted(chosen), [], []) + with self.subTest(platform=platform, systems=sorted(chosen)): + self.assertEqual(sorted(picked(data, chosen)), sorted(f["dest"] for f in files)) + checked += 1 + self.assertGreater(checked, 0) + + def test_choices_are_what_the_installer_offers(self): + for entry in json.loads((self.out / "index.json").read_text(encoding="utf-8")): + manifest = json.loads((REPO_ROOT / "install" / f"{entry['platform']}.json").read_text(encoding="utf-8")) + data = json.loads((self.out / f"{entry['platform']}.json").read_text(encoding="utf-8")) + offered = install.available_choices(manifest["files"], [])["systems"] + ours = [s["id"] for s in data["systems"]] + ([install.OTHER_SYSTEMS] if data["other"] else []) + with self.subTest(platform=entry["platform"]): + self.assertEqual(ours, offered) + + def test_component_applies_the_same_rule(self): + """The rule above is the component's: a change to one fails here.""" + source = PICKER.read_text(encoding="utf-8") + self.assertIn("systems.length ? systems.some((s) => wanted.has(s)) : other", source) + self.assertIn("picked.includes('other')", source) + self.assertRegex(source, re.escape("--system ${picked.join(',')}")) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts index 9d9bfe77..46a5bcb9 100644 --- a/website/src/scripts/site.ts +++ b/website/src/scripts/site.ts @@ -44,11 +44,11 @@ const markScrollable = (table: HTMLTableElement) => { } }; -// 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. +// A table whose rows end in an action (a button, a pack to download) is laid +// out one block per row on a phone, each cell under its column name, so the +// action never sits behind a horizontal scroll. const stackActionTable = (table: HTMLTableElement) => { - if (!table.querySelector('tbody .rb-button')) return; + if (!table.querySelector('tbody :is(.rb-button, a[href*="/releases/download/"], a[href$="/releases/latest"])')) return; const labels = [...table.querySelectorAll('thead th')].map((cell) => cell.textContent!.trim()); if (!labels.length) return; table.dataset.rbStack = 'true'; diff --git a/website/tools/check_tools.mjs b/website/tools/check_tools.mjs index 91c2e7cd..2e843d8b 100644 --- a/website/tools/check_tools.mjs +++ b/website/tools/check_tools.mjs @@ -39,6 +39,48 @@ await page.click('[data-none]'); assert.match(await page.textContent('.rb-pick-result'), /Choose at least one system/); await page.click('[data-all]'); assert.match(await page.textContent('.rb-pick-cmd code'), /--platform batocera$/); + +// A full hash typed in the search opens the lookup instead of text results. +await page.goto(base, { waitUntil: 'networkidle' }); +await page.click('site-search button[data-open-modal]'); +const search = page.locator('site-search dialog input').first(); +await search.waitFor(); +await search.fill('490f666e1afb15b7362b406ed1cea246'); +await Promise.all([page.waitForURL(/files\/\?hash=/), search.press('Enter')]); +console.log('hash in search ->', page.url()); + +// Breadcrumbs name the tab and the group of a deep page. +await page.goto(`${base}emulators/duckstation/`, { waitUntil: 'networkidle' }); +const crumbs = await page.locator('.rb-breadcrumbs li').allTextContents(); +console.log('breadcrumbs:', crumbs.join(' / ')); +assert.deepEqual(crumbs.slice(0, 2), ['Explore', 'Emulators']); + +// Previous and next run on from one tab into the next. +await page.goto(`${base}wiki/glossary/`, { waitUntil: 'networkidle' }); +const next = await page.locator('.pagination-links a[rel="next"]').getAttribute('href'); +console.log('after the last Start here page:', next); +assert.ok(next && next.endsWith('/platforms/'), next); + +// A glossary term opens its definition on a tap. +await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' }); +const term = page.locator('abbr.rb-term').first(); +await term.click(); +assert.equal(await term.getAttribute('data-open'), ''); +console.log('glossary term:', await term.textContent(), '=', await term.getAttribute('data-definition')); + +// On a phone, a table whose rows end in a download is one block per row. +await page.setViewportSize({ width: 390, height: 844 }); +await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' }); +const stacked = await page.locator('table[data-rb-stack]').count(); +const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); +console.log('stacked tables:', stacked, 'page wider than the phone:', overflow); +assert.ok(stacked > 0); +assert.equal(overflow, false); + +// A stale address offers the list its page may be in. +await page.goto(`${base}emulators/no-such-core/`, { waitUntil: 'networkidle' }); +assert.match(await page.textContent('#rb-notfound-hint'), /list of emulators/); + assert.deepEqual(errors, []); console.log('ok'); await browser.close(); diff --git a/website/tools/shot_element.mjs b/website/tools/shot_element.mjs new file mode 100644 index 00000000..db35460e --- /dev/null +++ b/website/tools/shot_element.mjs @@ -0,0 +1,13 @@ +// Screenshot of one element of a page, to look at a component closely. +// node shot_element.mjs <url> <css selector> <out.png> [width] [light|dark] +import { chromium } from 'playwright-core'; + +const [url, selector, out, width = '390', scheme = 'light'] = process.argv.slice(2); +const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' }); +const page = await browser.newPage({ viewport: { width: Number(width), height: 900 }, colorScheme: scheme }); +await page.goto(url, { waitUntil: 'networkidle' }); +const element = page.locator(selector).first(); +await element.scrollIntoViewIfNeeded(); +await page.waitForTimeout(300); +await element.screenshot({ path: out }); +await browser.close(); diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 28022bda..5ad7e172 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards. | Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | | Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | -| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_copy`, `test_site_content_data`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_copy`, `test_site_content_data`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture From 5f3534bdf2e864c5e89b0433a158639b321a14a3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:49:27 +0200 Subject: [PATCH 4/6] feat: tune search weights and heavy page loads Status badges and hash-listing blocks leave the search index, card titles weigh as page titles, and Pagefind ranking is set inside a range where the ten gate queries all answer (search_gate.py: 10/10). Tables are set up and measured when they near the screen, which relaid out a 3,800-row page after its first paint, and console icons hold their box. --- scripts/generate_site.py | 23 ++++++++---- scripts/sitemarkup.py | 9 +++++ scripts/sitestarlight.py | 6 ++++ website/astro.config.mjs | 7 ++++ website/src/scripts/site.ts | 49 ++++++++++++++++++-------- website/src/styles/retrobios.css | 11 +++++- website/tools/lighthouse.py | 17 ++++++--- website/tools/search_gate.py | 60 ++++++++++++++++++++++++++++++++ website/tools/search_scores.mjs | 23 ++++++++++++ 9 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 website/tools/search_gate.py create mode 100644 website/tools/search_scores.mjs diff --git a/scripts/generate_site.py b/scripts/generate_site.py index bd41ec6c..eb18bdd7 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3295,7 +3295,7 @@ def _system_file_card( for label, value in (("SHA1", sha1_full), ("MD5", md5_full), ("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))): if value and value != "unknown": - lines.append(f'- {label}: <span class="rb-hash">{_code(value)}</span>') + lines.append(f'- {label}: {markup.hash_value(value)}') if emus: lines.append(f"- Emulators: {', '.join(_emulator_links(sha1_full, consumers))}") provenance = f.get("provenance", {}) @@ -3420,8 +3420,11 @@ 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 "" - ignore = "" if searchable else f" {markup.search_exclude()}" - return f'<h3{ident} class="rb-file-title"{ignore}>{title}</h3>' + # 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>' attributes = f"#{anchor} .rb-file-title" if anchor else ".rb-file-title" if not searchable: attributes += " data-search-exclude" @@ -3873,7 +3876,7 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]: ) if archive: badges.append( - f'<span class="rb-badge rb-badge-muted">in {archive}</span>' + f'<span class="rb-badge rb-badge-muted rb-badge-archive">in {archive}</span>' ) if ftype and ftype != "bios": badges.append( @@ -4460,8 +4463,14 @@ def _render_structured_blocks(profile: dict) -> list[str]: body: list[str] = [] _render_yaml_value(body, val, indent=0, field_text=_structured_field_text(profile)) - lines.extend(markup.details( - "abstract", label, [_escape_tags(line) for line in body], opened=True)) + block = markup.details( + "abstract", label, [_escape_tags(line) for line in body], opened=True) + # A block listing hashes is the lookup's, as a card's hash lines are: + # izapple2's ROM table put "md5" 59 times in the search, above the + # page that explains MD5 checks. + if any(re.search(r"\b[0-9a-f]{32,64}\b", line) for line in body): + block = search_excluded(block) + lines.extend(block) return lines @@ -4476,7 +4485,7 @@ def _hash_spans(value: str | list) -> str: from hashing import parse_hash_list # noqa: PLC0415 return ", ".join( - f'<span class="rb-hash">{_code(v)}</span>' for v in parse_hash_list(value) + markup.hash_value(v) for v in parse_hash_list(value) ) diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py index 14362806..f404a2fd 100644 --- a/scripts/sitemarkup.py +++ b/scripts/sitemarkup.py @@ -153,6 +153,15 @@ def image(alt: str, src: str, **attributes: str) -> str: return f'<img src="{html.escape(src)}" alt="{html.escape(alt)}"{attrs}>' +def hash_value(value: object) -> str: + """A hash printed whole, in the hash style. One element under Starlight: + a span around a code span was 1,400 extra elements on a page of cards.""" + text = str(value).replace("`", "'") + if _engine == "mkdocs": + return f'<span class="rb-hash">`{text}`</span>' + return f'<code class="rb-hash">{html.escape(text, quote=False)}</code>' + + def block_open(tag: str, attributes: str = "") -> str: """The opening tag of an HTML block whose content is Markdown.""" attributes = f" {attributes.strip()}" if attributes.strip() else "" diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py index 324ae18c..b146ad0f 100644 --- a/scripts/sitestarlight.py +++ b/scripts/sitestarlight.py @@ -369,6 +369,12 @@ def finalize_page( body = f'<div id="{html.escape(title_id)}" class="rb-anchor"></div>\n\n' + body if rel not in ("wiki/glossary.md", "wiki/reference-terms.md"): body = add_tooltips(body, terms) + # A badge is a status (required, missing, MD5), not a word a reader + # searches for: 141 "missing" badges put an emulator page above the + # troubleshooting page for "missing bios". The archive a file sits in + # is a name, and stays: it is how "neogeo.zip" finds the cores reading it. + body = re.sub(r'<span class="(rb-badge(?![^"]*rb-badge-archive)[^"]*)"', + r'<span data-pagefind-ignore class="\1"', body) components = used_components(body) stem = rel.rsplit(".", 1)[0] if components: diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 3101060d..9c2a04ed 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -66,6 +66,13 @@ export default defineConfig({ expressiveCode: { defaultProps: { wrap: true } }, lastUpdated: false, pagination: true, + // Measured on the ten queries of tools/search_gate.py, inside a range + // where all ten answer: Starlight's defaults (0.1, 0.1, 2, 9) let a guide + // that cites scph5501 once outrank the file's own card. A long page + // is penalised a little more, and how often a word appears counts. + pagefind: { + ranking: { pageLength: 0.3, termFrequency: 0.5, termSaturation: 1.4, termSimilarity: 9 }, + }, plugins: [ starlightSidebarTopics(sidebar.topics, sidebar.options), starlightLinksValidator(), diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts index 46a5bcb9..d7e9fd97 100644 --- a/website/src/scripts/site.ts +++ b/website/src/scripts/site.ts @@ -30,10 +30,17 @@ const headingText = (element: Element): string => { }; // 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) => { +// can scroll it (WCAG 2.1.1). Checked again when a closed block opens. All +// widths are read first and the attributes written after, so a page of +// twenty tables is laid out once, not twenty times. +const markScrollable = (tables: HTMLTableElement[]) => { + const overflowing = tables.map((table) => table.scrollWidth > table.clientWidth + 1); + tables.forEach((table, index) => setScrollable(table, overflowing[index])); +}; + +const setScrollable = (table: HTMLTableElement, overflows: boolean) => { const heading = table.dataset.rbHeading ?? 'Data table'; - if (table.scrollWidth > table.clientWidth + 1) { + if (overflows) { table.tabIndex = 0; table.setAttribute('role', 'region'); table.setAttribute('aria-label', `${heading}, scrollable table`); @@ -79,8 +86,10 @@ const filterTable = (table: HTMLTableElement, index: number, heading: string) => 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() })); + // Each row's text is read on the first keystroke, not on every page load. + let searchable: { row: HTMLTableRowElement; text: string }[] | null = null; input.addEventListener('input', () => { + searchable ??= rows.map((row) => ({ row, text: row.textContent!.normalize('NFKD').toLocaleLowerCase() })); const query = input.value.trim().normalize('NFKD').toLocaleLowerCase(); let visible = 0; for (const entry of searchable) { @@ -92,7 +101,8 @@ const filterTable = (table: HTMLTableElement, index: number, heading: string) => }; const enhanceTables = () => { - document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table').forEach((table, index) => { + const tables = [...document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table')]; + const enhance = (table: HTMLTableElement) => { for (const cell of table.querySelectorAll('thead th')) if (!cell.hasAttribute('scope')) cell.setAttribute('scope', 'col'); const heading = headingText(table); table.dataset.rbHeading = heading; @@ -103,18 +113,29 @@ const enhanceTables = () => { table.prepend(caption); } stackActionTable(table); - markScrollable(table); - filterTable(table, index, heading); - }); - const recheck = () => document.querySelectorAll<HTMLTableElement>('.sl-markdown-content table').forEach(markScrollable); + filterTable(table, tables.indexOf(table), heading); + }; + // A table is set up and measured when it nears the screen. Doing all of + // them at load relaid out every row of a 3,800-row page after its first + // paint. One that appears later (a block opened, a tab chosen) is handled + // then. + const near = new Set<HTMLTableElement>(); + const observer = new IntersectionObserver( + (entries) => { + const shown = entries.filter((entry) => entry.isIntersecting).map((entry) => entry.target as HTMLTableElement); + for (const table of shown) { + if (!near.has(table)) enhance(table); + near.add(table); + } + if (shown.length) requestAnimationFrame(() => markScrollable(shown)); + }, + { rootMargin: '400px 0px' }, + ); + tables.forEach((table) => observer.observe(table)); 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); + timer = window.setTimeout(() => markScrollable([...near]), 150); }); }; diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css index e8385889..662d6298 100644 --- a/website/src/styles/retrobios.css +++ b/website/src/styles/retrobios.css @@ -216,7 +216,16 @@ 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; } +/* A system icon beside a console heading holds its box before it loads: + it pushed every card below it down once it arrived. */ +.sl-markdown-content :is(h2, h3) img { + display: inline; + width: 24px; + height: 24px; + object-fit: contain; + vertical-align: middle; + margin: 0 0.4rem 0 0; +} /* Card grids */ .rb-cards { diff --git a/website/tools/lighthouse.py b/website/tools/lighthouse.py index 224f9abd..3c8bbfce 100644 --- a/website/tools/lighthouse.py +++ b/website/tools/lighthouse.py @@ -1,7 +1,11 @@ #!/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> ... + python lighthouse.py [--performance-only] <out dir> <name>=<base url> ... -- <page> ... + +With all four categories, Lighthouse runs its accessibility checks inside the +page during the same load, and those tasks count toward Total Blocking Time +on a page of 15,000 elements. --performance-only measures the page alone. Needs `npx lighthouse` and Chrome at /usr/bin/google-chrome. """ @@ -18,6 +22,10 @@ CATEGORIES = ("performance", "accessibility", "best-practices", "seo") def main() -> int: args = sys.argv[1:] + categories = CATEGORIES + if args[0] == "--performance-only": + args.pop(0) + categories = ("performance",) out = Path(args.pop(0)) split = args.index("--") engines = dict(item.split("=", 1) for item in args[:split]) @@ -34,7 +42,7 @@ def main() -> int: ["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)}"], + 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"] @@ -42,12 +50,13 @@ def main() -> int: 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}, + **{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"], + # Lighthouse 13 moved the DOM size audit into an insight. + "dom": (audits.get("dom-size") or audits.get("dom-size-insight") or {}).get("displayValue", ""), "failed": failed, })) return 0 diff --git a/website/tools/search_gate.py b/website/tools/search_gate.py new file mode 100644 index 00000000..bc068d61 --- /dev/null +++ b/website/tools/search_gate.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""The search gate: ten queries a visitor types, and the pages that answer them. + +Runs measure.mjs against a served build and counts a query as answered when +one of its expected pages is in the first three results of the site's own +search box. + + python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/ +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +TOOLS = Path(__file__).resolve().parent + +# Query, then the pages that answer it (path prefixes under the site). +QUERIES: dict[str, tuple[str, ...]] = { + "playstation bios": ("systems/sony/",), + # The file itself: its card on the system page, or a core that reads it. + "scph5501": ("systems/sony/", "emulators/beetle_psx/", "emulators/duckstation/", + "emulators/duckstation-legacy/", "emulators/pcsx1/", "emulators/swanstation/", + "emulators/ares/", "emulators/pcsx_rearmed/", "files/"), + "batocera folder": ("platforms/batocera/",), + "neogeo.zip": ("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"), + "duckstation": ("emulators/duckstation/", "emulators/duckstation-legacy/"), + "steam deck": ("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), + "missing bios": ("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"), + "md5": ("wiki/verification-modes/",), + "retroarch system folder": ("platforms/retroarch/",), + "knulli": ("platforms/knulli/",), +} + + +def main() -> int: + base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:4321/retrobios/" + root = "/" + base.split("/", 3)[3] + run = subprocess.run( + ["node", str(TOOLS / "measure.mjs"), "search", base, "starlight-ui", *QUERIES], + capture_output=True, text=True, check=True, + ) + answered = 0 + for line in run.stdout.splitlines(): + result = json.loads(line) + if "query" not in result: + print(f"first search: {result['first_search_kb']} KB in {result['first_search_requests']} requests") + continue + top = [entry.split(" | ")[1][len(root):] for entry in result["top"][:3]] + hit = any(page.startswith(QUERIES[result["query"]]) for page in top) + answered += hit + print(f"{'ok ' if hit else 'MISS'} {result['query']!r}: {', '.join(top)}") + print(f"{answered}/{len(QUERIES)} queries answered in the top 3") + return 0 if answered >= 9 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/website/tools/search_scores.mjs b/website/tools/search_scores.mjs new file mode 100644 index 00000000..8e3f9a69 --- /dev/null +++ b/website/tools/search_scores.mjs @@ -0,0 +1,23 @@ +// Raw Pagefind results with their scores, to see why a page ranks where it does. +// node search_scores.mjs <base url> <query> [ranking json] +import { chromium } from 'playwright-core'; + +const [base, query, ranking] = process.argv.slice(2); +const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' }); +const page = await browser.newPage(); +await page.goto(base, { waitUntil: 'networkidle' }); +const rows = await page.evaluate(async ({ root, query, ranking }) => { + const pagefind = await import(`${root}pagefind/pagefind.js`); + if (ranking) await pagefind.options({ ranking: JSON.parse(ranking) }); + const search = await pagefind.search(query); + const top = search.results.slice(0, 8); + const data = await Promise.all(top.map((result) => result.data())); + return top.map((result, index) => ({ + score: result.score.toFixed(3), + url: data[index].url, + words: data[index].word_count, + hits: result.words.length, + })); +}, { root: base, query, ranking }); +for (const row of rows) console.log(row.score, row.words, row.hits, row.url); +await browser.close(); From abe240073cc225fd87b4a24a039a97052a3b06ee Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:21:14 +0200 Subject: [PATCH 5/6] feat: pass the Starlight gate on dense pages Pages of cards under one or two headings drop the table of contents, whose script watched every block of a 400-card page: 2 x 430 ms of main thread on a throttled phone. Tables are measured once the page is idle, skipping those in closed blocks, so a scrolling table is a tab stop anywhere on the page. The 12 heaviest pages score 97 to 100 in Lighthouse mobile, axe reports nothing on 16 pages in both themes, and the search answers the ten gate queries. --- scripts/sitestarlight.py | 9 +++++++-- website/src/scripts/site.ts | 15 ++++++++++++++- website/tools/check_tools.mjs | 3 +++ website/tools/lighthouse.py | 4 +++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py index b146ad0f..60783b53 100644 --- a/scripts/sitestarlight.py +++ b/scripts/sitestarlight.py @@ -1,6 +1,6 @@ """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 +generate_site.py writes each page body, and this module gives it the form Starlight reads. A page's links are written as the docs tree holds them (``../systems/sony.md``, MkDocs' convention, the one a Markdown editor follows), and the site serves them as addresses under /retrobios/, so @@ -397,7 +397,12 @@ def finalize_page( if boost: front["boost"] = boost if rel.startswith(CARD_PAGES): - front["tableOfContents"] = {"minHeadingLevel": 2, "maxHeadingLevel": 2} + sections = len(re.findall(r"^## ", body, re.M)) + # A page of cards under one or two headings has nothing to list, and + # the table of contents watches every block of the page to follow + # the reader: 2 x 430 ms of a phone's main thread on 400 cards. + front["tableOfContents"] = ( + {"minHeadingLevel": 2, "maxHeadingLevel": 2} if sections > 2 else False) if not search: front["pagefind"] = False front["head"] = page_head(rel, level, front["title"], browser_title, structured) diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts index d7e9fd97..68a426dc 100644 --- a/website/src/scripts/site.ts +++ b/website/src/scripts/site.ts @@ -131,7 +131,20 @@ const enhanceTables = () => { }, { rootMargin: '400px 0px' }, ); - tables.forEach((table) => observer.observe(table)); + // Every table is measured once the page is drawn and idle, before anything + // is written, so the read finds the layout done instead of forcing it: a + // keyboard reaches a scrolling table anywhere on the page, not only near + // the screen. + const idle = window.requestIdleCallback ?? ((callback: () => void) => setTimeout(callback, 1)); + const start = () => + idle(() => { + // A table in a closed block is not laid out, and measuring it would + // lay it out: it is measured when its block opens. + markScrollable(tables.filter((table) => table.checkVisibility?.() ?? true)); + tables.forEach((table) => observer.observe(table)); + }); + if (document.readyState === 'complete') start(); + else addEventListener('load', start, { once: true }); let timer = 0; addEventListener('resize', () => { clearTimeout(timer); diff --git a/website/tools/check_tools.mjs b/website/tools/check_tools.mjs index 2e843d8b..facb8d79 100644 --- a/website/tools/check_tools.mjs +++ b/website/tools/check_tools.mjs @@ -71,6 +71,9 @@ console.log('glossary term:', await term.textContent(), '=', await term.getAttri // On a phone, a table whose rows end in a download is one block per row. await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${base}which-pack/`, { waitUntil: 'networkidle' }); +// Tables are set up as they near the screen. +await page.locator('table:has(a[href*="/releases/download/"])').first().scrollIntoViewIfNeeded(); +await page.waitForTimeout(300); const stacked = await page.locator('table[data-rb-stack]').count(); const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); console.log('stacked tables:', stacked, 'page wider than the phone:', overflow); diff --git a/website/tools/lighthouse.py b/website/tools/lighthouse.py index 3c8bbfce..e40ea638 100644 --- a/website/tools/lighthouse.py +++ b/website/tools/lighthouse.py @@ -42,7 +42,9 @@ def main() -> int: ["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)}"], + f"--only-categories={','.join(categories)}", + # Extra flags for one experiment, space separated. + *os.environ.get("LIGHTHOUSE_FLAGS", "").split()], env=env, cwd=out, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) data = json.loads(report.read_text()) cats, audits = data["categories"], data["audits"] From 123d698e43fd1982ef86a8b01284bcfcc0ea8fb4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:47:22 +0200 Subject: [PATCH 6/6] 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. --- .github/workflows/deploy-site.yml | 29 +- .github/workflows/validate.yml | 24 +- .gitignore | 3 +- CONTRIBUTING.md | 3 +- .../assets => }/brand/apple-touch-icon.png | Bin .../assets => }/brand/favicon-32.png | Bin .../{overrides/assets => }/brand/favicon.svg | 0 .../{overrides/assets => }/brand/logo.svg | 0 docs_assets/extra.css | 977 ------------------ docs_assets/overrides/404.html | 37 - docs_assets/overrides/main.html | 58 -- docs_assets/site.js | 298 ------ mkdocs.yml | 878 ---------------- pyproject.toml | 13 +- scripts/artifacts.py | 2 +- scripts/generate_readme.py | 9 +- scripts/generate_site.py | 388 +++---- scripts/make_site_icons.py | 6 +- scripts/mkdocs_hooks.py | 37 - scripts/pipeline.py | 14 +- scripts/siteexports.py | 19 +- scripts/sitemarkup.py | 17 +- scripts/sitestarlight.py | 103 +- scripts/validate_schemas.py | 4 +- scripts/validate_site.py | 54 +- tests/sitepages.py | 46 + tests/test_audit_regressions.py | 54 +- tests/test_contributing_text.py | 19 +- tests/test_export_report.py | 20 +- tests/test_prose_style.py | 22 +- tests/test_schema_semantic_gate.py | 4 +- tests/test_site_content_data.py | 2 +- tests/test_site_copy.py | 95 +- tests/test_site_cores.py | 3 +- tests/test_site_emulator_page.py | 13 +- tests/test_site_exports.py | 31 +- tests/test_site_funnel.py | 70 +- tests/test_site_images.py | 10 +- tests/test_site_render.py | 42 +- tests/test_site_system_links.py | 2 +- tests/test_site_validation.py | 66 +- tests/test_site_weight.py | 45 +- tests/test_workflow_paths.py | 35 +- website/astro.config.mjs | 19 +- website/src/components/MarkdownContent.astro | 11 +- website/src/content.config.ts | 4 +- website/src/styles/retrobios.css | 3 +- website/tools/search_gate.py | 53 +- website/tools/search_scores.mjs | 10 +- wiki/adding-a-platform.md | 2 +- wiki/architecture.md | 8 +- wiki/community-tools.md | 5 +- wiki/faq.md | 7 +- wiki/first-install.md | 61 +- wiki/getting-started.md | 2 +- wiki/glossary.md | 9 +- wiki/legal.md | 7 +- wiki/reference-terms.md | 7 +- wiki/release-process.md | 49 +- wiki/technical-faq.md | 7 +- wiki/testing-guide.md | 22 +- wiki/tools.md | 6 +- wiki/troubleshooting.md | 7 +- wiki/what-are-bios-files.md | 9 +- 64 files changed, 742 insertions(+), 3118 deletions(-) rename docs_assets/{overrides/assets => }/brand/apple-touch-icon.png (100%) rename docs_assets/{overrides/assets => }/brand/favicon-32.png (100%) rename docs_assets/{overrides/assets => }/brand/favicon.svg (100%) rename docs_assets/{overrides/assets => }/brand/logo.svg (100%) delete mode 100644 docs_assets/extra.css delete mode 100644 docs_assets/overrides/404.html delete mode 100644 docs_assets/overrides/main.html delete mode 100644 docs_assets/site.js delete mode 100644 mkdocs.yml delete mode 100644 scripts/mkdocs_hooks.py create mode 100644 tests/sitepages.py diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 27ca1287..5d4b370d 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -29,7 +29,6 @@ on: - "scripts/largefiles.py" - "scripts/launchers.py" - "scripts/manifestselect.py" - - "scripts/mkdocs_hooks.py" - "scripts/nativemode.py" - "scripts/packextras.py" - "scripts/packpaths.py" @@ -69,7 +68,9 @@ on: - "database.json" - "release.json" - "derivable.json" - - "mkdocs.yml" + # The Starlight project: config, components, styles, lockfile. Its + # generated folders are gitignored and never trigger anything. + - "website/**" - ".github/workflows/deploy-site.yml" workflow_dispatch: @@ -95,11 +96,18 @@ jobs: with: python-version: "3.12" - # mkdocs-material >= 9.7.5 caps mkdocs < 2, and the upper bound keeps a major - # theme release from changing the site without a deliberate bump here. - # mkdocs is pinned below 2 on its own too: MkDocs 2 drops plugins, and the - # theme, in maintenance mode since 9.7.0, will not follow it. - - run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "mkdocs>=1.6,<2" "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14" + - run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" + + # Node builds the site only: the scripts stay Python. The engine and + # its plugins are pinned exactly in website/package.json and resolved + # from the committed lockfile. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24" + cache: npm + cache-dependency-path: website/package-lock.json + + - run: npm ci --prefix website - name: Validate data contracts run: python scripts/validate_schemas.py @@ -122,8 +130,9 @@ jobs: run: | python scripts/generate_site.py python scripts/generate_readme.py --db database.json --platforms-dir platforms - mkdocs build --strict - python scripts/validate_site.py + # starlight-links-validator fails the build on a dead link or anchor. + npm run build --prefix website + python scripts/validate_site.py --site-dir website/dist # write_if_changed() compares content with its timestamp line stripped, # so a run that only moves the clock leaves the file untouched and this @@ -137,7 +146,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: - path: site/ + path: website/dist/ deploy: needs: build diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 915696b7..7f0ccb19 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -29,7 +29,7 @@ on: - "database.json" - "release.json" - "derivable.json" - - "mkdocs.yml" + - "website/**" # Spelled out twice because the workflow parser reads no YAML anchor. A test # holds the two lists equal. push: @@ -53,7 +53,7 @@ on: - "database.json" - "release.json" - "derivable.json" - - "mkdocs.yml" + - "website/**" permissions: contents: read @@ -213,10 +213,18 @@ jobs: run: python3.9 -m pip install pyyaml "jsonschema[format-nongpl]==4.25.1" # markdown: the site validation test renders a wiki line to prove the - # heading guard's premise, and skipped on every run without it. The docs - # group builds the site as deploy-site.yml does, with the same bounds. + # heading guard's premise, and skipped on every run without it. - name: Install dependencies - run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "mkdocs>=1.6,<2" "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14" + run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" + + # The site is built as deploy-site.yml builds it, from the lockfile. + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "24" + cache: npm + cache-dependency-path: website/package-lock.json + + - run: npm ci --prefix website # The exporter fidelity cases compare what we hand back against the # file each platform publishes, so they need those files. They skip @@ -234,7 +242,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # docs/ is gitignored, and the tests that read the rendered pages fail + # The pages are gitignored, and the tests that read them fail # in CI without it. The suite runs whatever this step returned, so a # generator that stops still leaves every other test reporting. - name: Generate the site @@ -256,8 +264,8 @@ jobs: - name: Build the site if: ${{ !cancelled() }} run: | - mkdocs build --strict - python scripts/validate_site.py + npm run build --prefix website + python scripts/validate_site.py --site-dir website/dist label-pr: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e8f9b3e0..d9051a43 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,8 @@ site/ *.log node_modules/ -# Generated site pages (built by generate_site.py, deployed in CI) +# Pages of the MkDocs build the site had before Astro Starlight: a clone may +# still hold them. The Starlight project ignores its own generated folders. docs/ # Large files stored as GitHub Release assets (> 50MB) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06870fb9..6a70d00d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,8 +59,7 @@ validation above is its check. For code, profiles or platform files, these are the checks CI runs on a pull request (Python 3.12 or newer): ```bash -pip install pyyaml "jsonschema[format-nongpl]" markdown \ - "mkdocs-material>=9.7.5,<10" "pymdown-extensions>=10.14" +pip install pyyaml "jsonschema[format-nongpl]" markdown python scripts/validate_pr.py --changed # the BIOS files the branch adds python scripts/validate_schemas.py --source-only # profiles and platform files python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits diff --git a/docs_assets/overrides/assets/brand/apple-touch-icon.png b/docs_assets/brand/apple-touch-icon.png similarity index 100% rename from docs_assets/overrides/assets/brand/apple-touch-icon.png rename to docs_assets/brand/apple-touch-icon.png diff --git a/docs_assets/overrides/assets/brand/favicon-32.png b/docs_assets/brand/favicon-32.png similarity index 100% rename from docs_assets/overrides/assets/brand/favicon-32.png rename to docs_assets/brand/favicon-32.png diff --git a/docs_assets/overrides/assets/brand/favicon.svg b/docs_assets/brand/favicon.svg similarity index 100% rename from docs_assets/overrides/assets/brand/favicon.svg rename to docs_assets/brand/favicon.svg diff --git a/docs_assets/overrides/assets/brand/logo.svg b/docs_assets/brand/logo.svg similarity index 100% rename from docs_assets/overrides/assets/brand/logo.svg rename to docs_assets/brand/logo.svg diff --git a/docs_assets/extra.css b/docs_assets/extra.css deleted file mode 100644 index fed41bb5..00000000 --- a/docs_assets/extra.css +++ /dev/null @@ -1,977 +0,0 @@ -/* RetroBIOS theme on top of mkdocs-material. - - Classes the generator and the wiki pages can use: - .rb-lead first paragraph under a page title - .rb-level-* level label (start, setup, explore, reference, contribute) - .rb-page-header logo or image beside the title block of an entity page - .rb-cards, .rb-card card grid without Material's list markup - .grid.cards Material card grid, restyled here - .rb-card-logo 40 px logo tile inside a card - .rb-steps ordered list drawn as numbered steps - .rb-terminal wraps a code block that shows terminal output - .rb-cmd wraps a command that must wrap instead of scrolling - code.rb-path a folder path shown as a chip - .rb-figure a generated diagram (scripts/sitediagrams.py) - .rb-bar inline coverage bar, width from --v -*/ - -:root { - --rb-primary: #3b3f7a; - --rb-primary-dark: #2b2e5c; - --rb-accent: #b42318; - --rb-link: #273c8f; - --rb-fg: rgba(0, 0, 0, 0.87); - --rb-surface: #f5f6fa; - --rb-border: #e0e3eb; - --rb-border-strong: #b8bdcc; - --rb-text-secondary: #546e7a; - --rb-ok-fg: #1b5e20; - --rb-ok-bg: #e8f5e9; - --rb-warn-fg: #a33f00; - --rb-warn-bg: #fff3e0; - --rb-err-fg: #b71c1c; - --rb-err-bg: #ffebee; - --rb-info-fg: #0d47a1; - --rb-info-bg: #e3f2fd; - --rb-muted-fg: #455a64; - --rb-muted-bg: #eceff1; - --rb-level-start: #1b5e20; - --rb-level-setup: #0d47a1; - --rb-level-explore: #6a1b9a; - --rb-level-reference: #455a64; - --rb-level-contribute: #a33f00; - --rb-terminal-bg: #1b1d2e; - --rb-terminal-fg: #e6e6f0; - --rb-terminal-label: #9aa0c3; - --rb-diagram-fill: #f5f6fa; - --rb-diagram-stroke: #8c92ac; - --rb-diagram-accent: #3b3f7a; - --md-code-font: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; -} - -[data-md-color-scheme="slate"] { - --rb-accent: #ff9a8f; - --rb-link: #aebcff; - --rb-fg: #e3e4ee; - --rb-surface: #252838; - --rb-border: #3a3d52; - --rb-border-strong: #5a5e7a; - --rb-text-secondary: #a6adc8; - --rb-ok-fg: #a5d6a7; - --rb-ok-bg: #1b3a1f; - --rb-warn-fg: #ffcc80; - --rb-warn-bg: #7a2e00; - --rb-err-fg: #ef9a9a; - --rb-err-bg: #7f1414; - --rb-info-fg: #90caf9; - --rb-info-bg: #0d2a55; - --rb-muted-fg: #cfd8dc; - --rb-muted-bg: #37474f; - --rb-level-start: #a5d6a7; - --rb-level-setup: #90caf9; - --rb-level-explore: #ce93d8; - --rb-level-reference: #cfd8dc; - --rb-level-contribute: #ffcc80; - --rb-terminal-bg: #12131c; - --rb-diagram-fill: #252838; - --rb-diagram-stroke: #7c82a3; - --rb-diagram-accent: #aebcff; -} - -/* Colour roles. The header keeps the brand indigo in both schemes: the - lighter indigo it used in dark mode left the tab labels at 3.5:1. */ -[data-md-color-scheme="default"] { - --md-primary-fg-color: var(--rb-primary); - --md-primary-fg-color--light: #4a4e8a; - --md-primary-fg-color--dark: var(--rb-primary-dark); - --md-accent-fg-color: var(--rb-accent); - --md-typeset-a-color: var(--rb-link); - /* Comments and punctuation sat at 4.47:1 on the code background. */ - --md-code-hl-comment-color: #5c5c5c; - --md-code-hl-generic-color: #5c5c5c; - --md-code-hl-variable-color: #5c5c5c; - --md-code-hl-operator-color: #5c5c5c; - --md-code-hl-punctuation-color: #5c5c5c; -} - -[data-md-color-scheme="slate"] { - --md-primary-fg-color: var(--rb-primary-dark); - --md-primary-fg-color--light: var(--rb-primary); - --md-primary-fg-color--dark: #1f2147; - --md-accent-fg-color: var(--rb-accent); - --md-accent-bg-color: #1e2129; - --md-typeset-a-color: var(--rb-link); - --md-code-hl-constant-color: #b39ddb; -} - -/* Type scale: the page title is the strongest heading on the page. */ -.md-typeset h1 { - color: var(--md-default-fg-color); - font-size: 2em; - font-weight: 700; - letter-spacing: -0.01em; - line-height: 1.25; - margin: 0 0 0.9em; -} - -.md-typeset h2 { - font-size: 1.45em; - font-weight: 600; - letter-spacing: -0.005em; - margin-top: 2em; -} - -.md-typeset h3 { - font-size: 1.15em; - font-weight: 600; -} - -.md-typeset h4 { - font-weight: 600; -} - -.md-typeset .rb-lead { - font-size: 1.05em; - line-height: 1.55; - max-width: 44em; -} - -/* Level label under a page title */ -.rb-level { - display: inline-block; - margin: 0 0 0.8rem; - padding: 0.05rem 0.45rem; - border: 1px solid currentColor; - border-radius: 2px; - font-size: 0.68rem; - font-weight: 600; - letter-spacing: 0.01em; - line-height: 1.6; - color: var(--rb-level-reference); -} - -.rb-level-start { color: var(--rb-level-start); } -.rb-level-setup { color: var(--rb-level-setup); } -.rb-level-explore { color: var(--rb-level-explore); } -.rb-level-reference { color: var(--rb-level-reference); } -.rb-level-contribute { color: var(--rb-level-contribute); } - -/* Header block of platform, system and emulator pages */ -.rb-page-header { - display: grid; - grid-template-columns: 56px 1fr; - gap: 0.4rem 1rem; - align-items: center; - margin: 0 0 1.2rem; -} - -.rb-page-header > :first-child img, -.rb-page-header > img { - width: 56px; - height: 56px; - object-fit: contain; -} - -.md-typeset .rb-page-header h1 { - margin: 0; -} - -.rb-page-header p { - margin: 0.2rem 0 0; -} - -/* A page that names its logo next to the title floats it right; the logo - keeps its own box instead of shifting the counters below. */ -.md-typeset img[align="right"] { - width: 56px; - height: 56px; - object-fit: contain; - margin: 0 0 0.6rem 1rem; - padding: 4px; - border: 1px solid var(--rb-border); - border-radius: 4px; - background: #fff; -} - -/* Logos are drawn for a light background: they keep one in dark mode. */ -.md-typeset img[src*="assets/logos/"] { - padding: 2px; - border-radius: 2px; - background: #fff; -} - -/* Headline figures, read as one line of numbers: four fit on one row of - the text column. */ -.rb-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); - gap: 0.6rem 1rem; - margin: 1rem 0 1.5rem; - padding: 0.8rem 1rem; - border: 1px solid var(--rb-border); - border-radius: 4px; - background: var(--rb-surface); -} - -.md-typeset .rb-stat p { - margin: 0; -} - -.rb-stat .rb-stat-value { - font-size: 1.25rem; - font-weight: 700; - display: block; - font-variant-numeric: tabular-nums; -} - -.rb-stat .rb-stat-label { - font-size: 0.75rem; - color: var(--rb-text-secondary); - display: block; -} - -.rb-badge { - display: inline-block; - padding: 0 0.4em; - border-radius: 2px; - font-size: 0.75rem; - font-weight: 600; - line-height: 1.6; - white-space: nowrap; -} - -.rb-badge-success { background: var(--rb-ok-bg); color: var(--rb-ok-fg); } -.rb-badge-warning { background: var(--rb-warn-bg); color: var(--rb-warn-fg); } -.rb-badge-danger { background: var(--rb-err-bg); color: var(--rb-err-fg); } -.rb-badge-info { background: var(--rb-info-bg); color: var(--rb-info-fg); } -.rb-badge-muted { background: var(--rb-muted-bg); color: var(--rb-muted-fg); } - -/* A link inside a badge keeps the badge's tested colour pair. */ -.md-typeset .rb-badge a:not(.md-button), -.md-typeset a.rb-badge { - color: inherit; -} - -/* Inline code inside a link reads as a link in both schemes. */ -.md-typeset a code { - color: var(--rb-link); -} - -/* Tables */ -.md-typeset table:not([class]) { - font-variant-numeric: tabular-nums; -} - -.md-typeset table:not([class]) th { - position: sticky; - top: 0; - z-index: 1; - font-weight: 600; -} - -.md-typeset table:not([class]) th, -.md-typeset table:not([class]) td { - padding: 0.55em 0.9em; -} - -.md-typeset__scrollwrap { - margin: 1rem 0; -} - -/* A table lines up with the text around it. Material indents the table - by 0.8rem and offsets that with a negative margin on the wrapper, which - the margin above removes, and the table's own margin doubled the space - above it. */ -.md-typeset .md-typeset__table { - padding: 0; -} - -.md-typeset .md-typeset__table > table { - margin: 0; -} - -.rb-timestamp { - font-size: 0.8rem; - color: var(--rb-text-secondary); - margin-top: 2rem; - padding-top: 1rem; - border-top: 1px solid var(--rb-border); -} - -/* One block per file on emulator and system pages, separated by a rule */ -.rb-file-entry, -.rb-sys-file { - border-top: 1px solid var(--rb-border); - padding: 0.75rem 0 0.25rem; - margin: 0; -} - -.rb-file-entry > p:first-child, -.rb-sys-file > p:first-child { - margin-top: 0; -} - -.rb-file-header code { - font-size: 0.95rem; - font-weight: 700; -} - -/* A card's title is a heading so the search lands on it, set as body text */ -.md-typeset .rb-file-title { - margin: 0 0 0.3rem; - font-size: inherit; - font-weight: 400; - letter-spacing: normal; - scroll-margin-top: 4rem; -} - -.md-typeset .rb-file-title code { - font-weight: 700; -} - -/* Lines the search skips continue the card's list */ -.rb-search-skip > ul { - margin-top: 0.1rem; -} - -.rb-file-entry ul, -.rb-sys-file ul { - margin: 0.3rem 0 0; - padding-left: 1.2rem; - font-size: 0.85rem; -} - -.rb-file-entry li, -.rb-sys-file li { - margin: 0.1rem 0; -} - -/* Hashes are printed whole so the search finds them, and wrap on phones */ -.rb-hash { - font-family: var(--md-code-font); - font-size: 0.78rem; - color: var(--rb-text-secondary); - overflow-wrap: anywhere; -} - -/* Key and value cards: the header row is empty, so it is not drawn and - not announced. */ -.rb-meta-card thead, -.md-typeset table thead:not(:has(th:not(:empty))) { - display: none; -} - -.rb-meta-card td { - overflow-wrap: anywhere; -} - -.rb-meta-card td:first-child { - font-weight: 600; - color: var(--rb-text-secondary); - white-space: nowrap; - width: 140px; -} - -.md-typeset table .md-button { - font-size: 0.75rem; - min-height: 44px; - padding: 0.55em 0.8em; -} - -/* Buttons. The dark scheme draws them in the link colour: the header indigo - would sit at 3:1 on the page. */ -[data-md-color-scheme="slate"] .md-typeset .md-button { - color: var(--rb-link); - border-color: var(--rb-link); -} - -[data-md-color-scheme="slate"] .md-typeset .md-button--primary { - background-color: var(--rb-link); - color: #1e2129; -} - -[data-md-color-scheme="slate"] .md-typeset .md-button:is(:hover, :focus) { - background-color: var(--rb-accent); - border-color: var(--rb-accent); - color: #1e2129; -} - -.md-typeset a:not(.md-button):not(.headerlink):not(.rb-badge) { - color: var(--rb-link); - text-decoration: underline; - text-decoration-thickness: 0.08em; - text-underline-offset: 0.14em; -} - -.md-typeset a:not(.md-button):not(.headerlink):hover { - text-decoration-thickness: 0.14em; -} - -:where(a, button, input, select, textarea, label, [tabindex]):focus-visible { - outline: 3px solid var(--rb-accent); - outline-offset: 3px; -} - -.md-typeset .md-button, -.md-typeset button, -.md-typeset input, -.md-typeset select { - min-height: 44px; -} - -/* Buttons that wrap onto a second line keep a gap between the rows. */ -.md-typeset .md-button { - margin-block: 0.25rem; -} - -/* The copy button stays inside a one-line code block: at 44 px it hung - below the block and covered the end of the line. 24 px is the minimum - target size of WCAG 2.2. */ -.md-typeset .md-code__button { - min-height: 0; - width: 24px; - height: 24px; -} - -.md-typeset pre > code { - padding-right: 2.6rem; -} - -.rb-visually-hidden { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} - -.rb-table-filter { - display: grid; - grid-template-columns: minmax(14rem, 28rem) auto; - gap: 0.35rem 0.8rem; - align-items: end; - margin: 1rem 0 0.45rem; -} - -.rb-table-filter label { - grid-column: 1 / -1; - color: var(--rb-text-secondary); - font-size: 0.78rem; - font-weight: 700; -} - -.rb-table-filter input { - width: 100%; - border: 1px solid var(--rb-text-secondary); - border-radius: 2px; - background: var(--md-default-bg-color); - color: var(--md-default-fg-color); - padding: 0.55rem 0.75rem; - font: inherit; -} - -.rb-table-filter-count { - align-self: center; - color: var(--rb-text-secondary); - font-size: 0.8rem; - white-space: nowrap; -} - -.md-typeset__scrollwrap[role="region"]:focus-visible { - outline: 3px solid var(--rb-accent); - outline-offset: 2px; -} - -/* Admonitions: one meaning per colour. - blue note, info, abstract, example, question, quote: background - teal tip: a shortcut - green success: what it looks like when it worked - orange warning: a risk of losing data or using the wrong file - red danger, failure, bug: something is broken or refused */ -.md-typeset :is(.admonition, details):is(.info, .abstract, .example, .question, .quote, .note) { - border-color: #448aff; -} - -.md-typeset :is(.info, .abstract, .example, .question, .quote, .note) > :is(.admonition-title, summary) { - background-color: rgba(68, 138, 255, 0.1); -} - -.md-typeset :is(.info, .abstract, .example, .question, .quote, .note) > :is(.admonition-title, summary)::before { - background-color: #448aff; - -webkit-mask-image: var(--md-admonition-icon--info); - mask-image: var(--md-admonition-icon--info); -} - -.md-typeset :is(.admonition, details).tip { - border-color: #00897b; -} - -.md-typeset .tip > :is(.admonition-title, summary) { - background-color: rgba(0, 137, 123, 0.1); -} - -.md-typeset .tip > :is(.admonition-title, summary)::before { - background-color: #00897b; - -webkit-mask-image: var(--rb-icon-shortcut); - mask-image: var(--rb-icon-shortcut); -} - -.md-typeset :is(.admonition, details):is(.danger, .failure, .bug, .error) { - border-color: #d32f2f; -} - -.md-typeset :is(.danger, .failure, .bug, .error) > :is(.admonition-title, summary) { - background-color: rgba(211, 47, 47, 0.1); -} - -.md-typeset :is(.danger, .failure, .bug, .error) > :is(.admonition-title, summary)::before { - background-color: #d32f2f; - -webkit-mask-image: var(--md-admonition-icon--danger); - mask-image: var(--md-admonition-icon--danger); -} - -:root { - --rb-icon-shortcut: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7M9 21a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-1H9z"/></svg>'); -} - -/* Code: shell commands wrap so the whole command shows on a phone, and the - copy button keeps its own column instead of covering the end. */ -.md-typeset :is(.language-sh, .language-bash, .language-shell, .language-console, - .language-powershell, .language-ps1, .language-pwsh, .language-cmd, .language-bat) pre > code, -.md-typeset .rb-cmd pre > code { - white-space: pre-wrap; - overflow-wrap: anywhere; - padding-right: 2.6rem; -} - -/* Terminal output: what the screen shows, not something to type */ -.md-typeset .rb-terminal pre, -.md-typeset .rb-terminal pre > code { - background: var(--rb-terminal-bg); - color: var(--rb-terminal-fg); -} - -.md-typeset .rb-terminal pre { - border: 1px solid var(--rb-border); - border-radius: 4px; -} - -.md-typeset .rb-terminal pre::before { - content: "Terminal output"; - display: block; - padding: 0.5rem 1.2em 0; - font: 600 0.65rem var(--md-text-font-family); - color: var(--rb-terminal-label); -} - -.md-typeset .rb-terminal .md-code__button { - color: var(--rb-terminal-label); -} - -/* Numbered steps */ -.md-typeset ol.rb-steps, -.md-typeset .rb-steps > ol { - counter-reset: rb-step; - list-style: none; - margin-left: 0; - padding-left: 0; -} - -.md-typeset ol.rb-steps > li, -.md-typeset .rb-steps > ol > li { - counter-increment: rb-step; - position: relative; - margin: 0 0 1.2rem; - padding-left: 2.6rem; - min-height: 1.8rem; -} - -.md-typeset ol.rb-steps > li::before, -.md-typeset .rb-steps > ol > li::before { - content: counter(rb-step); - position: absolute; - top: 0; - left: 0; - width: 1.8rem; - height: 1.8rem; - border-radius: 4px; - background: var(--md-primary-fg-color); - color: #fff; - font-weight: 700; - line-height: 1.8rem; - text-align: center; -} - -[data-md-color-scheme="slate"] .md-typeset ol.rb-steps > li::before, -[data-md-color-scheme="slate"] .md-typeset .rb-steps > ol > li::before { - background: var(--rb-link); - color: #1e2129; -} - -/* Card grids. Material's grid cards keep their markup; the hover shadow - gives way to a stronger border. */ -.md-typeset .grid.cards { - --md-shadow-z2: 0 0 #0000; -} - -.md-typeset .grid.cards > :is(ul, ol) > li, -.md-typeset .grid > .card { - border: 1px solid var(--rb-border); - border-radius: 4px; -} - -.md-typeset .grid.cards > :is(ul, ol) > li:is(:hover, :focus-within), -.md-typeset .grid > .card:is(:hover, :focus-within) { - border-color: var(--rb-border-strong); -} - -.rb-cards { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); - gap: 0.8rem; - margin: 1rem 0 1.5rem; -} - -.rb-card { - display: flex; - flex-direction: column; - gap: 0.35rem; - padding: 0.8rem; - border: 1px solid var(--rb-border); - border-radius: 4px; - background: var(--md-default-bg-color); -} - -.rb-card:is(:hover, :focus-within) { - border-color: var(--rb-border-strong); -} - -.md-typeset .rb-card p { - margin: 0; -} - -.md-typeset .rb-card .md-button { - margin-top: auto; - align-self: flex-start; -} - -.md-typeset .rb-card-logo, -.md-typeset .grid.cards img.rb-card-logo { - width: 40px; - height: 40px; - object-fit: contain; - padding: 4px; - border: 1px solid var(--rb-border); - border-radius: 4px; - background: #fff; - vertical-align: middle; -} - -/* Path chip */ -.md-typeset code.rb-path { - padding: 0.15rem 0.45rem; - border: 1px solid var(--rb-border); - border-radius: 2px; - font-size: 0.8em; - overflow-wrap: anywhere; -} - -/* Content tabs (operating systems): the selected tab reads clearly. */ -.md-typeset .tabbed-labels > label { - font-size: 0.75rem; - font-weight: 600; - color: var(--rb-text-secondary); -} - -.md-typeset .tabbed-set > input:checked + label, -.md-typeset .tabbed-labels > label:hover { - color: var(--rb-link); -} - -.md-typeset .tabbed-labels::before { - background: var(--rb-link); -} - -/* Inline coverage bar beside a number */ -.rb-bar { - display: inline-block; - width: 5rem; - height: 0.4rem; - margin-right: 0.4rem; - border-radius: 2px; - background: var(--rb-border); - vertical-align: middle; -} - -.rb-bar > span { - display: block; - width: var(--v, 0%); - height: 100%; - border-radius: 2px; - background: var(--rb-ok-fg); -} - -/* Diagrams written by scripts/sitediagrams.py. Text takes the page font and - colour, boxes take the theme surface, so the drawing follows the palette - toggle. Each diagram carries a wide and a narrow layout. */ -.md-typeset .rb-figure { - margin: 1.2rem 0 1.6rem; -} - -.md-typeset .rb-figure svg { - display: block; - width: 100%; - height: auto; - max-width: 52rem; - color: var(--md-default-fg-color); -} - -.md-typeset .rb-figure figcaption { - max-width: 44em; - margin: 0.4rem 0 0; - font-size: 0.75rem; - font-style: normal; - color: var(--rb-text-secondary); - text-align: left; -} - -.rb-diagram text { - font-family: var(--md-text-font-family); - fill: currentColor; -} - -.rb-diagram .rb-d-box { - fill: var(--rb-diagram-fill); - stroke: var(--rb-diagram-stroke); -} - -.rb-diagram .rb-d-accent { - stroke: var(--rb-diagram-accent); -} - -.rb-diagram .rb-d-line { - stroke: var(--rb-diagram-stroke); -} - -.rb-diagram .rb-d-head { - fill: var(--rb-diagram-stroke); -} - -.rb-diagram .rb-d-num { - fill: var(--rb-diagram-accent); -} - -.rb-diagram .rb-d-numtext { - fill: var(--md-default-bg-color); -} - -.rb-diagram .rb-d-muted { - fill: var(--rb-text-secondary); -} - -.rb-diagram-narrow { - display: none !important; -} - -@media (max-width: 37.5em) { - .rb-diagram-wide { - display: none !important; - } - - .rb-diagram-narrow { - display: block !important; - } -} - -/* The sidebar title stays legible over the list scrolling under it. With - tabs the title shown is the active section's label, held at the top of - the sidebar: a rule under it ends the list cleanly instead of a half - line of text touching the label. */ -@media (min-width: 76.25em) { - .md-nav--primary > .md-nav__title, - .md-nav--secondary > .md-nav__title, - .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - background: var(--md-default-bg-color); - border-bottom: 1px solid var(--rb-border); - } - - .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - padding-bottom: 0.4rem; - } -} - -/* Material's compact footer defaults sit just below WCAG AA on the dark bar. */ -.md-footer-meta .md-copyright { - color: #b5b5b5; -} - -.md-footer-meta.md-typeset .md-copyright a { - color: #b8c6ff !important; -} - -/* 404 page */ -.rb-notfound-search { - display: inline-flex; - align-items: center; - min-height: 44px; - padding: 0.55em 1.2em; - border: 1px solid currentColor; - border-radius: 2px; - color: var(--rb-link); - font-weight: 700; - cursor: pointer; -} - -@media (max-width: 44.984375em) { - /* Tables with an action column become one block per row, so the button - is never behind a horizontal scroll (site.js marks them). */ - .md-typeset__table:has(table[data-rb-stack]) { - display: block; - } - - .md-typeset table[data-rb-stack] { - display: block; - width: 100%; - } - - .md-typeset table[data-rb-stack] thead { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - } - - .md-typeset table[data-rb-stack] tbody, - .md-typeset table[data-rb-stack] tr { - display: block; - } - - .md-typeset table[data-rb-stack] tr { - padding: 0.5rem 0; - border-top: 1px solid var(--rb-border); - } - - .md-typeset table[data-rb-stack] td { - display: block; - position: relative; - min-height: 1.6rem; - padding: 0.25rem 0.6rem 0.25rem 8.4rem; - border: 0; - text-align: left !important; - overflow-wrap: anywhere; - } - - .md-typeset table[data-rb-stack] td::before { - content: attr(data-label); - position: absolute; - top: 0.25rem; - left: 0.6rem; - width: 7.4rem; - font-weight: 600; - color: var(--rb-text-secondary); - } - - .md-typeset table[data-rb-stack] td .md-button { - margin: 0 0.3rem 0.3rem 0; - } - - .md-typeset table[data-rb-stack] td:empty { - display: none; - } - - /* Other wide tables scroll with their first column held in place. */ - .md-typeset table:not([class]):not([data-rb-stack]) :is(td, th):first-child { - position: sticky; - left: 0; - min-width: min(6rem, 30vw); - max-width: 40vw; - background: var(--md-default-bg-color); - overflow-wrap: break-word; - } - - /* A long link or path breaks anywhere, so it never squeezes the first - column down to one letter per line. */ - .md-typeset table:not([class]):not([data-rb-stack]) td:not(:first-child) :is(a, code) { - overflow-wrap: anywhere; - } - - .md-typeset table:not([class]):not([data-rb-stack]) th:first-child { - z-index: 2; - } - - /* On a phone every command wraps: the end of a one-liner (the "| sh" - that makes it work) is never off-screen. Terminal output keeps its - columns and scrolls. */ - .md-typeset pre > code { - white-space: pre-wrap; - overflow-wrap: anywhere; - } - - .md-typeset :is(.rb-terminal, .language-text) pre > code { - white-space: pre; - overflow-wrap: normal; - } - - /* Text on reference pages stays at 12 px or more on a phone. */ - .rb-file-entry ul, - .rb-sys-file ul, - .rb-hash { - font-size: 0.75rem; - } - - .rb-page-header { - grid-template-columns: 1fr; - } -} - -@media (max-width: 600px) { - .rb-table-filter { - grid-template-columns: 1fr; - } - - .rb-table-filter label { - grid-column: auto; - } - - .rb-stats { - grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr)); - } - - .rb-stat .rb-stat-value { - font-size: 1.1rem; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - } -} - -/* A file block can be cited by URL: its link shows on hover, and the block a - link lands on is marked. */ -.rb-permalink { - margin-left: 0.4rem; - color: var(--md-default-fg-color--light, var(--rb-border)); - text-decoration: none; - opacity: 0; -} -.rb-file-entry:hover .rb-permalink, -.rb-sys-file:hover .rb-permalink, -.rb-permalink:focus { - opacity: 1; -} -.rb-file-entry:has(> .rb-file-title:target), -.rb-sys-file:has(> .rb-file-title:target) { - background: var(--rb-surface); -} diff --git a/docs_assets/overrides/404.html b/docs_assets/overrides/404.html deleted file mode 100644 index 59ad2c3d..00000000 --- a/docs_assets/overrides/404.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "main.html" %} - -{# - The page a stale link lands on. Pages have moved before (file cards - refiled between manufacturers), so it offers the search, the two pages a - visitor most often wants, and the list a moved emulator, system or - platform page belongs to. -#} -{% block content %} - <h1>Page not found</h1> - <p class="rb-lead">This address has no page. It may have moved since the link was written.</p> - <p><label class="rb-notfound-search" for="__search" tabindex="0">Search the site</label></p> - <ul> - <li><a href="{{ config.site_url }}">Start here</a>: what BIOS files are and how to install them</li> - <li><a href="{{ config.site_url }}which-pack/">Get your files</a>: the pack or the command for your device</li> - <li>Browse <a href="{{ config.site_url }}platforms/">platforms</a>, <a href="{{ config.site_url }}systems/">systems</a> or <a href="{{ config.site_url }}emulators/">emulators</a></li> - </ul> - <p id="rb-notfound-hint" hidden></p> - <script> - (() => { - const match = window.location.pathname.match(/\/(emulators|systems|platforms)\/([^/]+)\/?$/); - const hint = document.getElementById("rb-notfound-hint"); - if (!match || !hint) return; - const link = document.createElement("a"); - link.href = "{{ config.site_url }}" + match[1] + "/"; - link.textContent = "the list of " + match[1]; - hint.append("No page is named " + decodeURIComponent(match[2]) + " here. It may be listed under another name in ", link, "."); - hint.hidden = false; - })(); - document.querySelector(".rb-notfound-search")?.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - event.currentTarget.click(); - } - }); - </script> -{% endblock %} diff --git a/docs_assets/overrides/main.html b/docs_assets/overrides/main.html deleted file mode 100644 index 426b5447..00000000 --- a/docs_assets/overrides/main.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} - -{# - Open Graph and Twitter card tags. - - mkdocs-material only emits these through its social plugin, which pulls in - Pillow and CairoSVG to render preview images. The pages here already carry a - per-page title and description, so the tags can be filled from those without - taking on two image dependencies. The links get shared on Discord, Reddit and - forums, where a bare URL with no preview is the difference between a card and - a grey rectangle. - - One sharing card per section (docs_assets/og, made by - scripts/make_og_cards.py). The section is the page's level when the - generator gives one, otherwise it follows from the URL. -#} -{% block extrahead %} - {% set page_title = page.meta.title if page and page.meta and page.meta.title else (page.title if page and page.title else config.site_name) %} - {% set page_description = page.meta.description if page and page.meta and page.meta.description else config.site_description %} - {% set page_url = page.canonical_url if page and page.canonical_url else config.site_url %} - {% set url = page.url if page and page.url else '' %} - {% set level = page.meta.level if page and page.meta and page.meta.level else '' %} - {% if not url %}{% set section = 'home' %} - {% elif level in ['start', 'setup', 'explore', 'reference', 'contribute'] %}{% set section = level %} - {% elif url.startswith('which-pack') or url.startswith('wiki/first-install') or url.startswith('wiki/faq') or url.startswith('wiki/getting-started') %}{% set section = 'start' %} - {% elif url.startswith('platforms') %}{% set section = 'setup' %} - {% elif url.startswith('systems') or url.startswith('emulators') or url.startswith('files') %}{% set section = 'explore' %} - {% elif url.startswith('contributing') or url.startswith('wiki/adding') or url.startswith('wiki/profiling') or url.startswith('wiki/testing') or url.startswith('wiki/release') %}{% set section = 'contribute' %} - {% else %}{% set section = 'reference' %}{% endif %} - {% set preview_image = config.site_url ~ 'assets/og/' ~ section ~ '.png' if config.site_url else '' %} - - {# The theme's favicon is the 32 px PNG. Browsers that read an SVG icon - take this one, listed after it, and a home-screen shortcut takes the - opaque 180 px icon (scripts/make_site_icons.py makes all three). #} - <link rel="icon" type="image/svg+xml" href="{{ 'assets/brand/favicon.svg' | url }}"> - <link rel="apple-touch-icon" sizes="180x180" href="{{ 'assets/brand/apple-touch-icon.png' | url }}"> - - <meta property="og:type" content="{{ 'website' if not url else 'article' }}"> - <meta property="og:site_name" content="{{ config.site_name }}"> - <meta property="og:title" content="{{ page_title }}"> - <meta property="og:description" content="{{ page_description }}"> - {% if page_url %}<meta property="og:url" content="{{ page_url }}">{% endif %} - {% if preview_image %} - <meta property="og:image" content="{{ preview_image }}"> - <meta property="og:image:width" content="1200"> - <meta property="og:image:height" content="630"> - <meta property="og:image:alt" content="{{ config.site_name }}: {{ page_title }}"> - {% endif %} - - {# The sitemap, named from every page: a project site under /retrobios/ - cannot serve the host-root robots.txt that would announce it. #} - {% if config.site_url %}<link rel="sitemap" type="application/xml" title="Sitemap" href="{{ config.site_url }}sitemap.xml">{% endif %} - - <meta name="twitter:card" content="summary_large_image"> - <meta name="twitter:title" content="{{ page_title }}"> - <meta name="twitter:description" content="{{ page_description }}"> - {% if preview_image %}<meta name="twitter:image" content="{{ preview_image }}">{% endif %} -{% endblock %} diff --git a/docs_assets/site.js b/docs_assets/site.js deleted file mode 100644 index 33548400..00000000 --- a/docs_assets/site.js +++ /dev/null @@ -1,298 +0,0 @@ -/* Progressive enhancement for generated RetroBIOS reference tables. */ -(() => { - "use strict"; - - // A heading's words without its permalink sign. - const headingText = (heading) => { - const copy = heading.cloneNode(true); - copy.querySelectorAll(".headerlink").forEach((link) => link.remove()); - return copy.textContent.trim(); - }; - - const textFromHeading = (element) => { - let cursor = element; - while (cursor) { - cursor = cursor.previousElementSibling; - if (!cursor) break; - if (/^H[1-6]$/.test(cursor.tagName)) { - return headingText(cursor); - } - } - const title = document.querySelector("h1"); - return title ? headingText(title) : "Data table"; - }; - - // A scroller is a stop in the tab order only while it overflows: a table - // in a closed block or a hidden tab gains its overflow later. - const markScrollable = (container, heading) => { - if (!container) return; - container.dataset.rbHeading = heading; - if (container.scrollWidth > container.clientWidth + 1) { - container.tabIndex = 0; - container.setAttribute("role", "region"); - container.setAttribute("aria-label", `${heading}, scrollable table`); - } else if (container.getAttribute("role") === "region") { - container.removeAttribute("tabindex"); - container.removeAttribute("role"); - container.removeAttribute("aria-label"); - } - }; - - const recheckScrollable = () => { - document.querySelectorAll(".md-typeset__scrollwrap[data-rb-heading]").forEach((container) => { - markScrollable(container, container.dataset.rbHeading); - }); - }; - - // A table whose rows end in an action (a download button) is laid out one - // block per row on a phone, each cell under its column name, so the action - // never sits behind a horizontal scroll. - const stackActionTable = (table) => { - if (!table.querySelector("tbody .md-button")) return; - const labels = Array.from(table.querySelectorAll("thead th")).map((cell) => - cell.textContent.trim() - ); - if (!labels.length) return; - table.dataset.rbStack = "true"; - table.querySelectorAll("tbody tr").forEach((row) => { - Array.from(row.children).forEach((cell, column) => { - if (labels[column] && !cell.hasAttribute("data-label")) { - cell.setAttribute("data-label", labels[column]); - } - }); - }); - }; - - const enhanceTables = () => { - document.querySelectorAll(".md-typeset table").forEach((table, index) => { - if (table.dataset.rbEnhanced === "true") return; - table.dataset.rbEnhanced = "true"; - - table.querySelectorAll("thead th").forEach((cell) => { - if (!cell.hasAttribute("scope")) cell.setAttribute("scope", "col"); - }); - table.querySelectorAll("tbody tr").forEach((row) => { - const first = row.querySelector(":scope > th"); - if (first && !first.hasAttribute("scope")) first.setAttribute("scope", "row"); - }); - - // The scrolling element is Material's scrollwrap, one level above - // the table's own wrapper. - const container = table.closest(".md-typeset__scrollwrap") || table.parentElement; - const heading = textFromHeading(container); - if (!table.querySelector("caption")) { - const caption = document.createElement("caption"); - caption.className = "rb-visually-hidden"; - caption.textContent = heading; - table.prepend(caption); - } - stackActionTable(table); - markScrollable(container, heading); - - const rows = Array.from(table.querySelectorAll("tbody tr")); - if (rows.length < 20 || !container || container.dataset.rbFilter === "true") { - return; - } - container.dataset.rbFilter = "true"; - - const controls = document.createElement("div"); - controls.className = "rb-table-filter"; - const id = `rb-table-filter-${index}-${Math.random().toString(36).slice(2, 8)}`; - const label = document.createElement("label"); - label.htmlFor = id; - label.textContent = `Filter ${heading}`; - const input = document.createElement("input"); - input.id = id; - input.type = "search"; - input.autocomplete = "off"; - input.placeholder = "Name, platform, system, hash..."; - const count = document.createElement("span"); - count.className = "rb-table-filter-count"; - count.setAttribute("aria-live", "polite"); - count.textContent = `${rows.length} rows`; - controls.append(label, input, count); - container.before(controls); - - const searchable = rows.map((row) => ({ - row, - text: row.textContent.normalize("NFKD").toLocaleLowerCase(), - })); - input.addEventListener("input", () => { - const query = input.value.trim().normalize("NFKD").toLocaleLowerCase(); - let visible = 0; - searchable.forEach((entry) => { - const matches = !query || entry.text.includes(query); - entry.row.hidden = !matches; - if (matches) visible += 1; - }); - count.textContent = `${visible} of ${rows.length} rows`; - }); - }); - }; - - const nameMaterialWidgets = () => { - document.querySelectorAll('[role="dialog"]').forEach((dialog) => { - if (dialog.hasAttribute("aria-label") || dialog.hasAttribute("aria-labelledby")) return; - const heading = dialog.querySelector("h1, h2, h3"); - dialog.setAttribute("aria-label", heading?.textContent.trim() || "Site search"); - }); - // The page-load bar is drawn for the eye only: the new page announces - // itself when it arrives. - document.querySelectorAll(".md-progress").forEach((progress) => { - progress.setAttribute("aria-hidden", "true"); - }); - // Material wraps each copy button in a nav element: one unnamed - // navigation landmark per code block. The button stays, the landmark goes. - document.querySelectorAll("nav.md-code__nav").forEach((nav) => { - nav.setAttribute("role", "none"); - }); - }; - - // files/?hash= reads one shard of the lookup, named by the first two hex - // characters of the value: 20 KB where the whole index was 675 KB. - const HASH_LENGTHS = { 8: "CRC32", 32: "MD5", 40: "SHA1", 64: "SHA256" }; - const LAYER_LABELS = { - collection: "In the collection", - "data-directory": "In a data directory the packs ship", - platform: "Declared by a platform", - emulator: "Read by an emulator", - }; - const siteRoot = () => { - const script = document.querySelector('script[src$="javascripts/site.js"]'); - return script ? new URL("../", script.src) : new URL("../", window.location.href); - }; - const lookupShard = (value) => - fetch(new URL(`api/v1/lookup/${value.slice(0, 2)}.json`, siteRoot())) - .then((response) => (response.ok ? response.json() : { items: [] })) - .then((shard) => shard.items || []); - const normalizeHash = (raw) => raw.trim().toLowerCase().replace(/^0x/, "").replace(/\s+/g, ""); - - const renderMatches = (status, value, items) => { - status.textContent = ""; - const kind = HASH_LENGTHS[value.length]; - const heading = document.createElement("p"); - heading.textContent = `${items.length} ${items.length === 1 ? "match" : "matches"} for ${kind} ${value}:`; - status.append(heading); - Object.entries(LAYER_LABELS).forEach(([layer, label]) => { - const found = items.filter((item) => item.layer === layer); - if (!found.length) return; - const title = document.createElement("p"); - const strong = document.createElement("strong"); - strong.textContent = label; - title.append(strong); - const list = document.createElement("ul"); - found.forEach((item) => { - const row = document.createElement("li"); - const link = document.createElement("a"); - link.href = new URL(item.url, siteRoot()).href; - const code = document.createElement("code"); - code.textContent = item.name || item.value; - link.append(code); - row.append(link); - if (item.owner) row.append(` (${item.owner})`); - list.append(row); - }); - status.append(title, list); - }); - }; - - // files/?sha1=<sha1> is a file's content address and opens its card - // wherever it is now; files/?hash= also takes an MD5, CRC32 or SHA256 and - // lists every page that names the value. - const resolveFileAddress = () => { - const status = document.getElementById("rb-file-lookup-status"); - if (!status) return; - const params = new URLSearchParams(window.location.search); - const permalink = params.has("sha1"); - const value = normalizeHash(params.get("sha1") || params.get("hash") || ""); - if (!value) return; - const input = document.getElementById("rb-hash"); - if (input) input.value = value; - const prefix = permalink && /^[0-9a-f]{12}$/.test(value); - if (!prefix && !(/^[0-9a-f]+$/.test(value) && HASH_LENGTHS[value.length])) { - status.textContent = - "A hash is 8 (CRC32), 32 (MD5), 40 (SHA1) or 64 (SHA256) hexadecimal characters."; - return; - } - status.textContent = "Looking it up..."; - lookupShard(value) - .then((items) => { - const matches = items.filter((item) => - prefix - ? item.algorithm === "sha1" && item.value.startsWith(value) - : item.value === value - ); - const cards = matches.filter((item) => item.layer === "collection" || item.layer === "data-directory"); - const targets = new Set(cards.map((item) => item.url)); - if (permalink && targets.size === 1) { - window.location.replace(new URL(cards[0].url, siteRoot())); - } else if (matches.length) { - renderMatches(status, prefix ? matches[0].value : value, prefix ? cards : matches); - } else { - status.textContent = "No file the site knows has this hash."; - } - }) - .catch(() => { - status.textContent = "The file index could not be read."; - }); - }; - - // A file card moved to another page keeps its address: an id of the form - // sha1-<12 hex> that this page does not hold is looked up by content, and - // an emulator card listed in the table of a split page opens its page. - const followMovedCard = () => { - const id = decodeURIComponent(window.location.hash.slice(1)); - if (!id) return; - const target = document.getElementById(id); - const row = target?.closest(".rb-card-index tr"); - const link = row?.querySelector("a[href]"); - if (link) { - window.location.replace(link.href); - return; - } - const match = /^sha1-([0-9a-f]{12})$/.exec(id); - if (!match || target) return; - window.location.replace(new URL(`files/?sha1=${match[1]}`, siteRoot())); - }; - - // The site search no longer holds hashes: Enter on a hash opens the lookup. - const hashSearch = () => { - const query = document.querySelector('input[data-md-component="search-query"]'); - if (!query || query.dataset.rbHash === "true") return; - query.dataset.rbHash = "true"; - query.addEventListener("keydown", (event) => { - if (event.key !== "Enter") return; - const value = normalizeHash(query.value); - if (/^[0-9a-f]+$/.test(value) && HASH_LENGTHS[value.length]) { - event.preventDefault(); - window.location.assign(new URL(`files/?hash=${value}`, siteRoot())); - } - }); - }; - - const enhance = () => { - enhanceTables(); - nameMaterialWidgets(); - resolveFileAddress(); - followMovedCard(); - hashSearch(); - }; - - let resizeTimer = 0; - window.addEventListener("resize", () => { - window.clearTimeout(resizeTimer); - resizeTimer = window.setTimeout(recheckScrollable, 150); - }); - document.addEventListener("toggle", recheckScrollable, true); - document.addEventListener("change", (event) => { - if (event.target.matches?.(".tabbed-set > input")) recheckScrollable(); - }); - - if (window.document$?.subscribe) { - window.document$.subscribe(enhance); - } else if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", enhance, { once: true }); - } else { - enhance(); - } -})(); diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 0170446e..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,878 +0,0 @@ -site_name: RetroBIOS -site_description: Source-verified BIOS and firmware packs for RetroArch, Batocera, - Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX, - KNULLI, AmberELEC, EmuELEC and MiSTer FPGA. -site_url: https://abdess.github.io/retrobios/ -repo_url: https://github.com/Abdess/retrobios -repo_name: Abdess/retrobios -# Almost every page is generated from platforms/, emulators/ and database.json, -# so a per-page edit link would point at a file that does not exist. -edit_uri: '' -# lastmod in the sitemap from each page's own date, or none at all. -hooks: - - scripts/mkdocs_hooks.py -# Working notes kept under docs/ during development are not part of the site. -exclude_docs: | - plans/ - specs/ -copyright: MIT for the tooling. BIOS and firmware files are third-party system - software, preserved for personal backup, archival and interoperability. -theme: - name: material - # Open Graph tags live in overrides/main.html: the social plugin that - # would otherwise emit them needs Pillow and CairoSVG only to render a - # preview image the pages do not need. - custom_dir: docs_assets/overrides - palette: - - media: (prefers-color-scheme) - toggle: - icon: material/brightness-auto - name: Switch to light mode - - media: '(prefers-color-scheme: light)' - scheme: default - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - media: '(prefers-color-scheme: dark)' - scheme: slate - toggle: - icon: material/brightness-4 - name: Switch to auto - font: false - logo: assets/brand/logo.svg - favicon: assets/brand/favicon-32.png - icon: - logo: material/chip - features: - - navigation.instant - - navigation.instant.prefetch - - navigation.instant.progress - - navigation.tabs - - navigation.tabs.sticky - - navigation.sections - - navigation.top - - navigation.tracking - - navigation.indexes - # Breadcrumbs above the title: on a deep page they say which tab it is in. - - navigation.path - # 400+ pages: pruning keeps the navigation out of every page's HTML. - - navigation.prune - - navigation.footer - - search.suggest - - search.highlight - - search.share - - content.code.copy - - content.tabs.link - - toc.follow -extra_css: -- stylesheets/extra.css -extra_javascript: -- javascripts/site.js -extra: - social: - - icon: fontawesome/brands/github - link: https://github.com/Abdess/retrobios - name: RetroBIOS on GitHub -markdown_extensions: -- abbr -- admonition -- attr_list -- def_list -- footnotes -- meta -- md_in_html -- tables -- toc: - permalink: true - # Level 3 is the wiki's subsections. On pages of file cards, also level - # 3, the hook keeps the table of contents to level 2. - toc_depth: 3 -- pymdownx.details -- pymdownx.highlight: - anchor_linenums: true -- pymdownx.inlinehilite -- pymdownx.keys -# No mermaid fence: Material drew it in the browser with a script from a -# CDN. Diagrams are SVG files written by scripts/sitediagrams.py. -- pymdownx.superfences -- pymdownx.tabbed: - alternate_style: true -plugins: -- search -# Link rot fails the build: deploy-site.yml runs `mkdocs build --strict`. -# omitted_files stays at its default (info) so a stale local docs/ does not -# break a local build. CI regenerates docs/ from scratch anyway. -validation: - absolute_links: warn - unrecognized_links: warn - anchors: warn -nav: -- Start here: - - index.md - - What BIOS files are: wiki/what-are-bios-files.md - - Get your files: which-pack.md - - First install, step by step: wiki/first-install.md - - When a game does not start: wiki/troubleshooting.md - - Common questions: wiki/faq.md - - Glossary: wiki/glossary.md -- Your setup: - - platforms/index.md - - Computers and Steam Deck: - - BizHawk: platforms/bizhawk.md - - EmuDeck: platforms/emudeck.md - - RetroArch: platforms/retroarch.md - - RetroBat: platforms/retrobat.md - - RetroDECK: platforms/retrodeck.md - - Game systems for PCs and small boards: - - Batocera: platforms/batocera.md - - Lakka: platforms/lakka.md - - Recalbox: platforms/recalbox.md - - RetroPie: platforms/retropie.md - - Handhelds and TV boxes: - - AmberELEC: platforms/amberelec.md - - EmuELEC: platforms/emuelec.md - - KNULLI: platforms/knulli.md - - MinUI: platforms/minui.md - - MyMinUI: platforms/myminui.md - - NextUI: platforms/nextui.md - - ROCKNIX: platforms/rocknix.md - - MiSTer FPGA: - - MiSTer FPGA: platforms/misterfpga.md - - Self-hosted game library: - - RomM: platforms/romm.md -- Explore: - - explore.md - - Systems: - - systems/index.md - - Consoles and computers: - - 3DO Company: systems/3do-company.md - - Acorn: systems/acorn.md - - Amstrad: systems/amstrad.md - - APF: systems/apf.md - - Apple: systems/apple.md - - Aquaplus: systems/aquaplus.md - - Arcade: systems/arcade.md - - Atari: systems/atari.md - - Bally: systems/bally.md - - Bandai: systems/bandai.md - - BBK: systems/bbk.md - - Bit Corporation: systems/bit-corporation.md - - Camputers: systems/camputers.md - - Casio: systems/casio.md - - Coleco: systems/coleco.md - - Commodore: systems/commodore.md - - DOS: systems/dos.md - - Dragon: systems/dragon.md - - EACA: systems/eaca.md - - Elektronika: systems/elektronika.md - - Enterprise: systems/enterprise.md - - Entex: systems/entex.md - - Epoch: systems/epoch.md - - Fairchild: systems/fairchild.md - - Fujitsu: systems/fujitsu.md - - Funtech: systems/funtech.md - - Galaksija: systems/galaksija.md - - GamePark: systems/gamepark.md - - GCE: systems/gce.md - - Gemei: systems/gemei.md - - Grundy: systems/grundy.md - - Hartung: systems/hartung.md - - IBM: systems/ibm.md - - Infocom: systems/infocom.md - - Interact: systems/interact.md - - Java: systems/java.md - - Lexaloffle: systems/lexaloffle.md - - Magnavox: systems/magnavox.md - - Mattel: systems/mattel.md - - Microsoft: systems/microsoft.md - - NEC: systems/nec.md - - Nintendo: systems/nintendo.md - - Nokia: systems/nokia.md - - Oric: systems/oric.md - - Other: systems/other.md - - Palm: systems/palm.md - - PC: systems/pc.md - - Philips: systems/philips.md - - Pioneer: systems/pioneer.md - - QEMU: systems/qemu.md - - RCA: systems/rca.md - - Real3D: systems/real3d.md - - Roland: systems/roland.md - - SDL: systems/sdl.md - - Sega: systems/sega.md - - Sharp: systems/sharp.md - - Sinclair: systems/sinclair.md - - SNK: systems/snk.md - - Sony: systems/sony.md - - Sord: systems/sord.md - - Synertek: systems/synertek.md - - Tandy: systems/tandy.md - - Tangerine: systems/tangerine.md - - Texas Instruments: systems/texas-instruments.md - - Thomson: systems/thomson.md - - Tiger: systems/tiger.md - - TimeTop: systems/timetop.md - - Tomy: systems/tomy.md - - VideoBrain: systems/videobrain.md - - Videoton: systems/videoton.md - - Vircon: systems/vircon.md - - VTech: systems/vtech.md - - Wenquxing: systems/wenquxing.md - - ZC: systems/zc.md - - Game engines and PC games: - - 3D Realms: systems/3d-realms.md - - Apogee: systems/apogee.md - - Blizzard: systems/blizzard.md - - Bungie: systems/bungie.md - - Dinothawr: systems/dinothawr.md - - Electronic Arts: systems/electronic-arts.md - - Epic MegaGames: systems/epic-megagames.md - - Game Engines: systems/game-engines.md - - Id Software: systems/id-software.md - - Interplay: systems/interplay.md - - Monolith: systems/monolith.md - - RPG Maker: systems/rpg-maker.md - - ScummVM: systems/scummvm.md - - sdlpal: systems/sdlpal.md - - Softdisk: systems/softdisk.md - - Toys for Bob: systems/toys-for-bob.md - - Valve: systems/valve.md - - Emulators: - - emulators/index.md - - 0 to 9: - - 2 Ship 2 Harkinian: emulators/2ship.md - - 2600.emu: emulators/2600-emu.md - - 3dSen: emulators/3dsen.md - - A to C: - - A: - - a5200: emulators/a5200.md - - A7800: emulators/a7800.md - - Abuse: emulators/abuse.md - - ACE-DL: emulators/ace-dl.md - - advanced_drastic: emulators/advanced_drastic.md - - AdvanceMAME: emulators/advancemame.md - - AdvanceMAME 0.94: emulators/advmame-0.94.md - - AdvanceMAME 1.4: emulators/advmame-1.4.md - - Adventure Game Studio: emulators/ags.md - - AetherSX2: emulators/aethersx2.md - - Aleph One: emulators/alephone.md - - Altirra: emulators/altirra.md - - amiarcadia: emulators/amiarcadia.md - - Amiberry: emulators/amiberry.md - - AppleWin: emulators/applewin.md - - aPS3e: emulators/aps3e.md - - ArcadeDuck: emulators/arcadeduck.md - - ArcadeFlashWeb: emulators/arcadeflashweb.md - - Ardens: emulators/ardens.md - - Arduous: emulators/arduous.md - - ares: emulators/ares.md - - ARMSX1: emulators/armsx1.md - - ARMSX2: emulators/armsx2.md - - Atari800: emulators/atari800.md - - aX360e: emulators/ax360e.md - - Azahar: emulators/azahar.md - - AzaharPlus: emulators/azaharplus.md - - B: - - b2: emulators/b2.md - - Basilisk II: emulators/basiliskii.md - - BBKEmu: emulators/bbkemu.md - - beebem: emulators/beebem.md - - Beetle GBA (Mednafen): emulators/beetle_gba.md - - Beetle Lynx (Mednafen Lynx): emulators/beetle_lynx.md - - Beetle NGP (Mednafen Neo Geo Pocket): emulators/beetle_ngp.md - - Beetle PC-FX (Mednafen): emulators/beetle_pcfx.md - - Beetle PCE (Mednafen PCE): emulators/beetle_pce.md - - Beetle PSX (Mednafen PSX): emulators/beetle_psx.md - - Beetle VB (Mednafen Virtual Boy): emulators/beetle_vb.md - - Beetle WonderSwan (Mednafen WonderSwan): emulators/beetle_wswan.md - - beetle_saturn: emulators/beetle_saturn.md - - BennuGD: emulators/bennugd.md - - BennuGD monolithic (bgdi): emulators/bennugd-monolithic.md - - Bermuda Syndrome: emulators/bsyndrome.md - - Bermuda Syndrome (bs): emulators/bermuda.md - - BigPEmu: emulators/bigpemu.md - - BizHawk: emulators/bizhawk.md - - bk-emulator: emulators/bk.md - - BlastEm: emulators/blastem.md - - blueMSX: emulators/bluemsx.md - - bnes: emulators/bnes.md - - boom3: emulators/boom3.md - - Boytacean: emulators/boytacean.md - - bsnes: emulators/bsnes.md - - bsnes 2014: emulators/bsnes2014.md - - bsnes C++98: emulators/bsnes_cplusplus98.md - - bsnes-hd beta: emulators/bsnes_hd_beta.md - - bsnes-jg: emulators/bsnes-jg.md - - bsnes-mercury: emulators/bsnes_mercury.md - - BStone: emulators/bstone.md - - C: - - C-Dogs SDL: emulators/cdogs.md - - C64.emu: emulators/c64-emu.md - - Cannonball: emulators/cannonball.md - - CaPriCe Forever: emulators/capriceforever.md - - Caprice32: emulators/cap32.md - - CapriceRPI: emulators/capricerpi.md - - CatacombGL: emulators/catacombgl.md - - ccleste: emulators/ccleste.md - - CDi 2015: emulators/cdi2015.md - - Cemu: emulators/cemu.md - - Chihiro-DS: emulators/chihiro-ds.md - - ChimeraSNES: emulators/chimerasnes.md - - Chocolate Doom: emulators/chocolate-doom.md - - Citra: emulators/citra.md - - Citra 2018: emulators/citra2018.md - - Citra Canary: emulators/citra_canary.md - - Citra MMJ: emulators/citra-mmj.md - - Citron: emulators/citron.md - - Clock Signal (CLK): emulators/clk.md - - ClownMDEmu: emulators/clownmdemu.md - - ColEm: emulators/colem.md - - Commander Genius: emulators/cgenius.md - - CoolbarEmu: emulators/coolbaremu.md - - CoolCV: emulators/coolcv.md - - corsixth: emulators/corsixth.md - - CPCemu: emulators/cpcemu.md - - CrocoDS: emulators/crocods.md - - Cruzes: emulators/cruzes.md - - CSpect: emulators/cspect.md - - Cxbx-Reloaded: emulators/cxbx-reloaded.md - - Cxbx-Reloaded (31ff15b): emulators/cxbx-reloaded-2023.md - - D to F: - - D: - - D.Smile: emulators/dsmile.md - - DaedalusX64: emulators/daedalusx64.md - - Daphne: emulators/daphne.md - - DarkPlaces: emulators/darkplaces.md - - Demul: emulators/demul.md - - DeSmuME: emulators/desmume.md - - DeSmuME 2015: emulators/desmume2015.md - - devilutionx: emulators/devilutionx.md - - DGen/SDL: emulators/dgen.md - - dhewm3: emulators/dhewm3.md - - DICE: emulators/dice.md - - DingooEmu: emulators/dingooemu.md - - Dinothawr: emulators/dinothawr.md - - DirectXBox: emulators/directxbox.md - - DirkSimple: emulators/dirksimple.md - - Dolphin: emulators/dolphin.md - - Dolphin Launcher: emulators/dolphin_launcher.md - - Dolphin MMJR: emulators/dolphin-mmjr.md - - Dolphin MMJR2: emulators/dolphin-mmjr2.md - - DOSBox: emulators/dosbox.md - - DOSBox (SDL2 fork): emulators/dosbox-sdl2.md - - DOSBox Pure: emulators/dosbox_pure.md - - DOSBox Pure Unleashed: emulators/dosbox-pure-unleashed.md - - DOSBox Staging: emulators/dosbox-staging.md - - DOSBox-core: emulators/dosbox_core.md - - DOSBox-SVN: emulators/dosbox_svn.md - - DOSBox-SVN CE: emulators/dosbox_svn_ce.md - - DOSBox-X: emulators/dosbox-x.md - - DoubleCherryGB: emulators/doublecherrygb.md - - doukutsu-rs: emulators/doukutsu_rs.md - - DraStic: emulators/drastic.md - - DREAMM: emulators/dreamm.md - - DroidArcadia: emulators/droidarcadia.md - - dsda-doom: emulators/dsda-doom.md - - DSperate: emulators/dsperate.md - - DuckStation: emulators/duckstation.md - - DuckStation (legacy): emulators/duckstation-legacy.md - - Dusklight: emulators/dusklight.md - - dxx-rebirth: emulators/dxx-rebirth.md - - E: - - EasyRPG Player: emulators/easyrpg.md - - ECWolf: emulators/ecwolf.md - - Eden: emulators/eden.md - - EDuke32: emulators/eduke32.md - - EightyOne: emulators/81.md - - EightyOne (Recalbox): emulators/eightyone.md - - eka2l1: emulators/eka2l1.md - - EmuCoreV: emulators/emucorev.md - - EmuCoreX: emulators/emucorex.md - - EmuCV: emulators/emucv.md - - EmuSCV: emulators/emuscv.md - - emux (CHIP-8): emulators/emux_chip8.md - - emux (Game Boy): emulators/emux_gb.md - - emux (NES): emulators/emux_nes.md - - emux (SMS): emulators/emux_sms.md - - ep128emu-core: emulators/ep128emu.md - - ep128emu_core: emulators/ep128emu_core.md - - ePSXe: emulators/epsxe.md - - ET Legacy: emulators/etlegacy.md - - Eternal Computer: emulators/eternal.md - - Exel-Hence: emulators/exel-hence.md - - F: - - FAKE-08: emulators/fake08.md - - fallout1-ce: emulators/fallout1-ce.md - - fallout2-ce: emulators/fallout2-ce.md - - FB Alpha 2012: emulators/fbalpha2012.md - - FB Alpha 2012 CPS-1: emulators/fbalpha2012_cps1.md - - FB Alpha 2012 CPS-2: emulators/fbalpha2012_cps2.md - - FB Alpha 2012 CPS-3: emulators/fbalpha2012_cps3.md - - FB Alpha 2012 Neo Geo: emulators/fbalpha2012_neogeo.md - - FB Alpha 2019: emulators/fbalpha2019.md - - FBZX: emulators/fbzx.md - - FCEUmm: emulators/fceumm.md - - FCEUmm Mod: emulators/fceumm_mod.md - - fheroes2: emulators/fheroes2.md - - FinalBurn Neo: emulators/fbneo.md - - FinalBurn Neo (CPS-1/CPS-2): emulators/fbneo_cps12.md - - FinalBurn Neo (Neo Geo): emulators/fbneo_neogeo.md - - fixGB: emulators/fixgb.md - - fixNES: emulators/fixnes.md - - Flycast: emulators/flycast.md - - Flycast 2021: emulators/flycast2021.md - - Flycast 2022 Low-End: emulators/flycast2021le.md - - Flycast Dojo: emulators/flycast-dojo.md - - fMSX: emulators/fmsx.md - - FPse: emulators/fpse.md - - FPseNG: emulators/fpse-ng.md - - FreeChaF: emulators/freechaf.md - - FreeIntv: emulators/freeintv.md - - FreeJ2ME: emulators/freej2me.md - - FreeJ2ME-Plus: emulators/freej2me_plus.md - - Frodo: emulators/frodo.md - - Frotz: emulators/frotz.md - - FS-UAE: emulators/fsuae.md - - FS-UAE 3.1: emulators/fs-uae.md - - Fuse: emulators/fuse.md - - future-pinball: emulators/future-pinball.md - - future-pinball-fploader: emulators/future-pinball-fploader.md - - G to L: - - G: - - galaksija: emulators/galaksija.md - - GAM4980: emulators/gam4980.md - - Gambatte: emulators/gambatte.md - - Game & Watch: emulators/gw.md - - GameTank: emulators/gametank.md - - Gargoyle: emulators/gargoyle.md - - GBA.emu: emulators/gba-emu.md - - GBC.emu: emulators/gbc-emu.md - - Gearboy: emulators/gearboy.md - - Gearcoleco: emulators/gearcoleco.md - - Geargrafx: emulators/geargrafx.md - - Gearlynx: emulators/gearlynx.md - - Gearsystem: emulators/gearsystem.md - - Geartowns: emulators/geartowns.md - - GemRB: emulators/gemrb.md - - Genesis Plus GX: emulators/genesis_plus_gx.md - - Genesis Plus GX Expanded: emulators/genesis_plus_gx_expanded.md - - Genesis Plus GX Wide: emulators/genesis_plus_gx_wide.md - - Geolith: emulators/geolith.md - - Ghostship: emulators/ghostship.md - - GnGeo-Pi: emulators/gngeopi.md - - Gopher2600: emulators/gopher2600.md - - gopher64: emulators/gopher64.md - - gpSP: emulators/gpsp.md - - GroovyMAME: emulators/groovymame.md - - GSplus: emulators/gsplus.md - - GSplus (legacy): emulators/gsplus-legacy.md - - GZDoom: emulators/gzdoom.md - - H: - - hakuX: emulators/hakux.md - - Handy: emulators/handy.md - - Hatari: emulators/hatari.md - - Hatari 2014: emulators/hatari2014.md - - hatariB: emulators/hatarib.md - - HBMAME (Homebrew MAME): emulators/hbmame.md - - higan (SFC Accuracy): emulators/higan_sfc.md - - hode: emulators/hode.md - - Holani: emulators/holani.md - - Hurrican: emulators/hurrican.md - - Hydra Castle Labyrinth: emulators/hcl.md - - Hypseus Singe: emulators/hypseus-singe.md - - I: - - idTech4A++: emulators/idtech4a++.md - - Ikemen GO: emulators/ikemen.md - - iNES: emulators/ines.md - - Infinity: emulators/infinity.md - - Ion Fury: emulators/fury.md - - ioquake3: emulators/ioquake3.md - - ioRTCW: emulators/iortcw.md - - IrataJaguar: emulators/iratajaguar.md - - IroGB: emulators/irogb.md - - Ishiiruka: emulators/ishiiruka.md - - izapple2: emulators/izapple2.md - - J: - - J2ME Loader: emulators/j2me-loader.md - - JAXE: emulators/jaxe.md - - Jazz² Resurrection: emulators/jazz2-native.md - - jgenesis: emulators/jgenesis.md - - JL-Mod: emulators/jl-mod.md - - JollyCV: emulators/jollycv.md - - Julius: emulators/julius.md - - Jynx: emulators/jynx.md - - jzIntv: emulators/jzintv.md - - K: - - Kega Fusion: emulators/kega-fusion.md - - KEGS: emulators/kegs.md - - KEmulator nnmod: emulators/kemulator.md - - Kenji-NX: emulators/kenji-nx.md - - Kronos: emulators/kronos.md - - L: - - Lexaloffle: emulators/lexaloffle.md - - lightspark: emulators/lightspark.md - - Lime3DS: emulators/lime3ds.md - - LinApple: emulators/linapple.md - - Lindbergh Loader: emulators/lindbergh-loader.md - - Linkboy: emulators/linkboy.md - - Linux Loader: emulators/linuxloader.md - - LowRes NX: emulators/lowresnx.md - - LRPS2: emulators/lrps2.md - - Lynx.emu: emulators/lynx-emu.md - - LZDoom: emulators/lzdoom.md - - M to O: - - M: - - M2000: emulators/m2000.md - - M64Plus FZ: emulators/m64plus-fz.md - - MagicEngine: emulators/magicengine.md - - MAME: emulators/mame.md - - MAME 0.258: emulators/mame0258.md - - MAME 0.278: emulators/mame0278.md - - MAME 2000: emulators/mame2000.md - - MAME 2003: emulators/mame2003.md - - MAME 2003 Midway: emulators/mame2003_midway.md - - MAME 2003-Plus: emulators/mame2003_plus.md - - MAME 2009: emulators/mame2009.md - - MAME 2010: emulators/mame2010.md - - MAME 2014: emulators/mame2014.md - - MAME 2015: emulators/mame2015.md - - MAME 2016: emulators/mame2016.md - - MAME Arcade: emulators/mamearcade.md - - MAME MESS: emulators/mamemess.md - - MAME4ALL-Pi: emulators/mame4all.md - - MAME4droid (0.139u1): emulators/mame4droid.md - - MAME4droid 2024: emulators/mame4droid-2024.md - - Mandarine: emulators/mandarine.md - - Mariani: emulators/mariani.md - - MasterGear: emulators/mastergear.md - - MD.emu: emulators/md-emu.md - - Mednafen: emulators/mednafen.md - - Mednafen STV: emulators/mednafen_stv.md - - mednafen_gba: emulators/mednafen_gba.md - - mednafen_pce: emulators/mednafen_pce.md - - mednafen_pce_fast: emulators/mednafen_pce_fast.md - - mednafen_snes: emulators/mednafen_snes.md - - mednafen_supafaust: emulators/mednafen_supafaust.md - - mednafen_supergrafx: emulators/mednafen_supergrafx.md - - melonDS: emulators/melonds.md - - melonDS DS: emulators/melonds_ds.md - - melonDS Nightly: emulators/melonds-nightly.md - - Mesen: emulators/mesen.md - - mesen-s: emulators/mesen-s.md - - MesenCE: emulators/mesence.md - - MESS 2015: emulators/mess2015.md - - Meteor GBA: emulators/meteor.md - - MFME: emulators/mfme.md - - mGBA: emulators/mgba.md - - mGBA Splitscreen: emulators/mgba_splitscreen.md - - Mini vMac: emulators/minivmac.md - - Mini vMac 37: emulators/minivmac37.md - - mkxp-z: emulators/mkxp_z.md - - Model 2 Emulator: emulators/model2.md - - MojoZork: emulators/mojozork.md - - MSX.emu: emulators/msx-emu.md - - Mu: emulators/mu.md - - Mupen64Plus: emulators/mupen64plus.md - - Mupen64Plus (libretro, 2019): emulators/mupen64plus-libretro.md - - Mupen64Plus AE: emulators/mupen64plus-ae.md - - Mupen64Plus-Next: emulators/mupen64plus_next.md - - My Boy!: emulators/my-boy.md - - My OldBoy!: emulators/my-oldboy.md - - N: - - NanoBoyAdvance: emulators/nanoboyadvance.md - - Native32Emu: emulators/native32emu.md - - nekop2: emulators/nekop2.md - - NEO.emu: emulators/neo-emu.md - - NeoCD: emulators/neocd.md - - nes: emulators/nes.md - - NES.emu: emulators/nes-emu.md - - Nesoid: emulators/nesoid.md - - Nestopia UE: emulators/nestopia.md - - NetherSX2: emulators/nethersx2.md - - NetherSX2-Turnip: emulators/nethersx2-turnip.md - - NetherSX2-Turnip Classic: emulators/nethersx2-turnip-classic.md - - NGP.emu: emulators/ngp-emu.md - - NicaiEmu: emulators/nicaiemu.md - - no$gba: emulators/nosgba.md - - NooDS: emulators/noods.md - - NP2kai: emulators/np2kai.md - - np2pi: emulators/np2pi.md - - nSide (SFC Balanced): emulators/higan_sfc_balanced.md - - Nuance: emulators/nuance.md - - Numero: emulators/numero.md - - NXEngine: emulators/nxengine.md - - O: - - O2EM: emulators/o2em.md - - Oberon: emulators/oberon.md - - Omicron: emulators/omicron.md - - ONScripter: emulators/onscripter.md - - ONScripter Yuri: emulators/onsyuri.md - - openbor: emulators/openbor.md - - OpenGOAL: emulators/opengoal.md - - OpenJazz: emulators/openjazz.md - - openjk: emulators/openjk.md - - OpenJKDF2: emulators/openjkdf2.md - - OpenLara: emulators/openlara.md - - OpenMoHAA: emulators/openmohaa.md - - openMSX: emulators/openmsx.md - - OpenTTD: emulators/openttd.md - - OpenTyrian: emulators/opentyrian.md - - Opera (4DO): emulators/opera.md - - Oricutron: emulators/oricutron.md - - Osmose (RetroPie): emulators/osmose.md - - P to R: - - P: - - P/EMU/SDL: emulators/piemu.md - - Panda3DS: emulators/panda3ds.md - - Parallel Launcher: emulators/parallel-launcher.md - - ParaLLEl N64: emulators/parallel_n64.md - - pce-emu: emulators/pce-emu.md - - PCEE2: emulators/pcee2.md - - PCem: emulators/pcem.md - - PCSX-ReARMed: emulators/pcsx_rearmed.md - - PCSX-ReARMed (standalone): emulators/pcsx_rearmed_standalone.md - - PCSX1: emulators/pcsx1.md - - PCSX2: emulators/pcsx2.md - - PCSX2 Legacy: emulators/pcsx2-legacy.md - - PCSX2x6: emulators/pcsx2x6.md - - PD777: emulators/pd777.md - - Perfect Dark (PC port): emulators/pdark.md - - Phoenix: emulators/phoenix.md - - Picocomputer 6502: emulators/rp6502.md - - PicoDrive: emulators/picodrive.md - - piece-emu: emulators/piece.md - - piFBA (fba2x): emulators/fba2x.md - - PiSNES: emulators/pisnes.md - - Pizza Boy GBA: emulators/pizza-boy-gba.md - - Pizza Boy GBC: emulators/pizza-boy-gbc.md - - Pizza Boy SC: emulators/pizza-boy-sc.md - - Plastic: emulators/plastic.md - - play: emulators/play.md - - PlaydiaEmu: emulators/playdiaemu.md - - PokeMini: emulators/pokemini.md - - pokketstation: emulators/pokketstation.md - - POM2: emulators/pom2.md - - Potator: emulators/potator.md - - PPSSPP: emulators/ppsspp.md - - PPSSPP 1.5.4: emulators/ppsspp-1.5.4.md - - PrBoom: emulators/prboom.md - - PrBoom+: emulators/prboom-plus.md - - PrimeHack: emulators/primehack.md - - project64: emulators/project64.md - - ProSystem: emulators/prosystem.md - - pSxMAME: emulators/psxmame.md - - PUAE (P-UAE): emulators/puae.md - - PUAE 2021: emulators/puae2021.md - - puNES: emulators/punes.md - - px68k: emulators/px68k.md - - px68k (SDL): emulators/px68k-sdl.md - - Q: - - QEMU: emulators/qemu.md - - Quake 3 (Raspberry Pi): emulators/quake3.md - - QUASI88: emulators/quasi88.md - - QUASI88kai: emulators/quasi88kai.md - - QuickNES: emulators/quicknes.md - - R: - - RACE (Neo Geo Pocket): emulators/race.md - - radio: emulators/radio.md - - RAINE: emulators/raine.md - - Raze: emulators/raze.md - - RB5000: emulators/rb5000.md - - Real3DOPlayer: emulators/real3doplayer.md - - RealRTCW: emulators/realrtcw.md - - Redream: emulators/redream.md - - Reicast: emulators/reicast.md - - REminiscence: emulators/reminiscence.md - - Retro8: emulators/retro8.md - - RetroDream: emulators/retrodream.md - - RigelEngine: emulators/rigelengine.md - - Rosalie's Mupen GUI: emulators/rosalies-mupen-gui.md - - RPCS3: emulators/rpcs3.md - - rpix86: emulators/rpix86.md - - ruffle: emulators/ruffle.md - - Rust-DOS: emulators/rust_dos.md - - Rustation: emulators/rustation.md - - RustyNES: emulators/rustynes.md - - RVVM: emulators/rvvm.md - - Ryujinx: emulators/ryujinx.md - - S to U: - - S: - - SAME CDi: emulators/same_cdi.md - - SameBoy: emulators/sameboy.md - - sameduck: emulators/sameduck.md - - Saturn.emu: emulators/saturn-emu.md - - ScummVM: emulators/scummvm.md - - ScummVM (SDL1): emulators/scummvm-sdl1.md - - SDL2TRS: emulators/sdl2trs.md - - SDLPAL: emulators/sdlpal.md - - sdlpop: emulators/sdlpop.md - - shadps4: emulators/shadps4.md - - SheepShaver: emulators/sheepshaver.md - - Ship of Harkinian: emulators/soh.md - - SimCoupe: emulators/simcp.md - - SimCoupe (standalone): emulators/samcoupe.md - - simple64: emulators/simple64.md - - Singe 2: emulators/singe2.md - - sixtyforce: emulators/sixtyforce.md - - skyemu: emulators/skyemu.md - - Skyline: emulators/skyline.md - - SMS Plus GX: emulators/smsplus.md - - snes9x: emulators/snes9x.md - - Snes9x 2002: emulators/snes9x2002.md - - Snes9x 2005: emulators/snes9x2005.md - - Snes9x 2005 Plus: emulators/snes9x2005_plus.md - - Snes9x 2010: emulators/snes9x2010.md - - Snes9x EX+: emulators/snes9x-explus.md - - solarus: emulators/solarus.md - - Solarus 2.0.0: emulators/solarus-2.0.0.md - - Sonic 1 & 2 (2013) Decompilation: emulators/sonic2013.md - - Sonic 3 A.I.R.: emulators/sonic3-air.md - - Sonic CD Decompilation: emulators/soniccd.md - - Sonic Mania Decompilation: emulators/sonic-mania.md - - Sonic Robo Blast 2: emulators/srb2.md - - Speccy: emulators/speccy.md - - SplitWolf: emulators/splitwolf.md - - SPMP8000Emu: emulators/spmp8000emu.md - - SquirrelJME: emulators/squirreljme.md - - SSF: emulators/ssf.md - - Starboard: emulators/starboard.md - - Starship: emulators/starship.md - - Stella: emulators/stella.md - - Stella 2014: emulators/stella2014.md - - Stella 2023: emulators/stella2023.md - - Sudachi: emulators/sudachi.md - - Sugarbox2: emulators/sugarbox2.md - - Super Mario War: emulators/supermariowar.md - - SUPER ZSNES: emulators/superzsnes.md - - SUPER3: emulators/super3.md - - Supermodel: emulators/supermodel.md - - Supermodel (Sinden fork): emulators/supermodel3.md - - Supermodel Dojo: emulators/supermodel-dojo.md - - Supermodel Legacy: emulators/supermodel-legacy.md - - SuperSnes9x: emulators/supersnes9x.md - - SuperTux: emulators/supertux.md - - SuperTuxKart: emulators/supertuxkart.md - - Suyu: emulators/suyu.md - - Swan.emu: emulators/swan-emu.md - - swanstation: emulators/swanstation.md - - SWF Player: emulators/swf-player.md - - Syobon Action: emulators/syobonaction.md - - T: - - TamaLIBretro: emulators/tamalibretro.md - - Taradino: emulators/taradino.md - - TeknoParrot: emulators/teknoparrot.md - - TempGBA: emulators/tempgba.md - - TGB Dual: emulators/tgbdual.md - - The Force Engine: emulators/theforceengine.md - - Theodore: emulators/theodore.md - - TheXTech: emulators/thextech.md - - ti99sim: emulators/ti99sim.md - - ti99sim-sdl1: emulators/ti99sim-sdl1.md - - Tia: emulators/tia.md - - TIC-80: emulators/tic80.md - - touchHLE: emulators/touchhle.md - - TR1X: emulators/tr1x.md - - TR2X: emulators/tr2x.md - - trident: emulators/trident.md - - Triforce: emulators/triforce.md - - tsugaru: emulators/tsugaru.md - - TyrQuake: emulators/tyrquake.md - - U: - - UAE4ALL (libretro, Chips-fr): emulators/uae4all-rpi.md - - UAE4All2: emulators/uae4all.md - - uae4arm: emulators/uae4arm.md - - UME 2015: emulators/ume2015.md - - uqm: emulators/uqm.md - - UZDoom: emulators/uzdoom.md - - uzem: emulators/uzem.md - - V to Z: - - V: - - VaporSpec: emulators/vaporspec.md - - VBA-M: emulators/vbam.md - - VBA-Next: emulators/vba_next.md - - vecx: emulators/vecx.md - - veesem: emulators/veesem.md - - VeMUlator: emulators/vemulator.md - - VICE: emulators/vice.md - - VICE x128: emulators/vice_x128.md - - VICE x64: emulators/vice_x64.md - - VICE x64dtv: emulators/vice_x64dtv.md - - VICE x64sc: emulators/vice_x64sc.md - - VICE xcbm2: emulators/vice_xcbm2.md - - VICE xcbm5x0: emulators/vice_xcbm5x0.md - - VICE xpet: emulators/vice_xpet.md - - VICE xplus4: emulators/vice_xplus4.md - - VICE xscpu64: emulators/vice_xscpu64.md - - VICE xvic: emulators/vice_xvic.md - - Vircon32: emulators/vircon32.md - - Virtual Jaguar: emulators/virtualjaguar.md - - Virtual Virtual Boy: emulators/virtual-virtual-boy.md - - VirtualXT: emulators/virtualxt.md - - visual-pinball: emulators/visual-pinball.md - - Vita3K: emulators/vita3k.md - - vitaQuakeII: emulators/vitaquake2.md - - vitaQuakeIII: emulators/vitaquake3.md - - vitavoyager: emulators/vitavoyager.md - - vkQuake: emulators/vkquake.md - - vkQuake2: emulators/vkquake2.md - - vkQuake3: emulators/vkquake3.md - - VVVVVV: emulators/vvvvvv.md - - W: - - WinUAE: emulators/winuae.md - - Wolf4SDL: emulators/wolf4sdl.md - - WQXEmu: emulators/wqxemu.md - - X: - - X Millennium: emulators/x1.md - - X1 BOX: emulators/x1-box.md - - x16emu: emulators/x16emu.md - - x64sdl: emulators/x64sdl.md - - Xash3D FWGS: emulators/xash3d_fwgs.md - - xemu: emulators/xemu.md - - xemu Chihiro: emulators/xemu-chihiro.md - - XenDroid: emulators/xendroid.md - - Xenia Canary: emulators/xenia.md - - Xenia Edge: emulators/xenia-edge.md - - XeniOS: emulators/xenios.md - - xm6pro68k: emulators/xm6pro68k.md - - xm6typeg: emulators/xm6typeg.md - - XM7: emulators/xm7.md - - XRick: emulators/xrick.md - - XRoar: emulators/xroar.md - - Y: - - yabasanshiro: emulators/yabasanshiro.md - - yabause: emulators/yabause.md - - Yamagi Quake II: emulators/yquake2.md - - yaps2: emulators/yaps2.md - - ymir: emulators/ymir.md - - Yuzu: emulators/yuzu.md - - Z: - - Zelda Classic 2.55 Prototype: emulators/zc255.md - - Zelda Classic v2.10: emulators/zc210.md - - ZEsarUX: emulators/zesarux.md - - ZiNc: emulators/zinc.md - - ZQuest Classic: emulators/zquestclassic.md - - All installer options: wiki/installer.md - - Is it legal?: wiki/legal.md -- Reference: - - wiki/index.md - - How each platform checks files: wiki/verification-modes.md - - What the packs do not hold: gaps.md - - Cores by platform: cross-reference.md - - Dump provenance: provenance.md - - Updated platform files: exports.md - - Data and API: data.md - - Find a file: files/index.md - - Data model: wiki/data-model.md - - Architecture: wiki/architecture.md - - Build your own packs: wiki/advanced-usage.md - - Command-line tools: wiki/tools.md - - Technical questions: wiki/technical-faq.md - - Project terms: wiki/reference-terms.md - - Projects using this data: wiki/community-tools.md -- Contribute: - - contributing.md - - Profiling an emulator: wiki/profiling.md - - Adding a platform: wiki/adding-a-platform.md - - Adding a scraper: wiki/adding-a-scraper.md - - Testing: wiki/testing-guide.md - - Release process: wiki/release-process.md diff --git a/pyproject.toml b/pyproject.toml index 2036926e..3935e6e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,17 +18,8 @@ dev = [ "jsonschema[format-nongpl]>=4.23", "markdown>=3.7", ] -# The site build the repo generates and documents. Both were hard -# requirements of deploy-site.yml and of wiki/release-process.md while being -# declared nowhere, so a contributor following either had to read the -# workflow to learn what to install. -docs = [ - # MkDocs 2 drops plugins, and the theme, in maintenance since 9.7.0, - # will not follow it. - "mkdocs>=1.6,<2", - "mkdocs-material>=9.7.5,<10", - "pymdown-extensions>=10.14", -] +# The site is built by Astro Starlight from website/package.json and its +# lockfile (npm ci --prefix website): no Python package builds it. [project.urls] Repository = "https://github.com/Abdess/retrobios" diff --git a/scripts/artifacts.py b/scripts/artifacts.py index 49627b19..873d8580 100644 --- a/scripts/artifacts.py +++ b/scripts/artifacts.py @@ -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 diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index f8651907..4870f441 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -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 diff --git a/scripts/generate_site.py b/scripts/generate_site.py index eb18bdd7..8d87140a 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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, diff --git a/scripts/make_site_icons.py b/scripts/make_site_icons.py index a4f1cdca..fbf94609 100644 --- a/scripts/make_site_icons.py +++ b/scripts/make_site_icons.py @@ -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" diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py deleted file mode 100644 index 11cef35d..00000000 --- a/scripts/mkdocs_hooks.py +++ /dev/null @@ -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 diff --git a/scripts/pipeline.py b/scripts/pipeline.py index 536b0306..f1daf953 100644 --- a/scripts/pipeline.py +++ b/scripts/pipeline.py @@ -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 diff --git a/scripts/siteexports.py b/scripts/siteexports.py index 5b02b6e9..2e1f691a 100644 --- a/scripts/siteexports.py +++ b/scripts/siteexports.py @@ -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) diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py index f404a2fd..7218e880 100644 --- a/scripts/sitemarkup.py +++ b/scripts/sitemarkup.py @@ -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: diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py index 60783b53..55654522 100644 --- a/scripts/sitestarlight.py +++ b/scripts/sitestarlight.py @@ -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 diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py index d7964f81..fe9162fb 100644 --- a/scripts/validate_schemas.py +++ b/scripts/validate_schemas.py @@ -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( diff --git a/scripts/validate_site.py b/scripts/validate_site.py index fe21d2d7..6f76ca6f 100644 --- a/scripts/validate_site.py +++ b/scripts/validate_site.py @@ -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 diff --git a/tests/sitepages.py b/tests/sitepages.py new file mode 100644 index 00000000..ee8897a5 --- /dev/null +++ b/tests/sitepages.py @@ -0,0 +1,46 @@ +"""The generated site pages, for the tests that read them. + +generate_site.py writes each page into the Starlight project's content +collection, as .md or as .mdx when it uses a component. A test names a +page by its docs path (``which-pack.md``) and gets whichever file holds it. +""" + +from __future__ import annotations + +import os +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs" + + +def require() -> Path: + """The content folder, or a skip when the site was not generated (a + failure in CI, which generates it before the suite).""" + if not (CONTENT / "index.md").is_file(): + if os.environ.get("CI"): + raise AssertionError("CI generates the site before the suite") + raise unittest.SkipTest("site not generated (python scripts/generate_site.py)") + return CONTENT + + +def page(name: str) -> Path: + """The file of a page named by its docs path.""" + stem = name[:-3] if name.endswith(".md") else name + for suffix in (".md", ".mdx"): + candidate = CONTENT / f"{stem}{suffix}" + if candidate.is_file(): + return candidate + return CONTENT / f"{stem}.md" + + +def pages() -> list[Path]: + """Every page file, sorted.""" + root = require() + return sorted(p for p in root.rglob("*") if p.suffix in (".md", ".mdx")) + + +def name(path: Path) -> str: + """A page file's docs path, the name tests use for it.""" + return path.relative_to(CONTENT).with_suffix(".md").as_posix() diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 78388d20..99ae6883 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -258,7 +258,7 @@ class WorkflowRegressions(unittest.TestCase): and acted on afterwards rather than swallowed. A best-effort side action such as adding a label is not a check and keeps its `|| true`. """ - checks = ("python scripts/", "unittest", "mkdocs build") + checks = ("python scripts/", "unittest", "npm run build") for name in ("validate.yml", "deploy-site.yml"): workflow = self._workflow(name) for job_name, job in workflow["jobs"].items(): @@ -4311,7 +4311,7 @@ class AFileCardSaysAllItKnows(unittest.TestCase): )) # The card is a heading with this id, linked by the heading's own # permalink. - self.assertIn("{ #file-neogeo-zip .rb-file-title }", text) + self.assertIn('<h3 id="file-neogeo-zip" class="rb-file-title"', text) self.assertIn("Standalone path: `Firmware/neogeo.zip`", text) self.assertIn("`m11.bin`", text) self.assertIn("CRC32 `0000000b`", text) @@ -4954,7 +4954,7 @@ class CollectionCardsCarryFullHashes(unittest.TestCase): "crc32": "504086c1", "sha256": "b" * 64} text = "\n".join(_system_file_card(entry, FileConsumers({}, {}))) for value in (entry["sha1"], entry["md5"], entry["crc32"], entry["sha256"]): - self.assertIn(f"`{value}`", text) + self.assertIn(f">{value}</code>", text) @@ -4975,17 +4975,11 @@ class NullReadsAsNone(unittest.TestCase): self.assertIn("none: the source is the project itself", page) def test_the_rendered_site_has_no_python_none(self): - docs = ROOT / "docs" - if not docs.is_dir(): - if os.environ.get("CI"): - self.fail("CI generates docs/ before the suite") - self.skipTest("docs/ not generated") - pattern = re.compile(r"\| None \||: None$|: None \|", re.M) - from scripts.generate_site import LOCAL_NOTE_DIRS + from tests import sitepages - found = [p.relative_to(docs).as_posix() for p in sorted(docs.rglob("*.md")) - if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS) - and pattern.search(p.read_text(encoding="utf-8"))] + pattern = re.compile(r"\| None \||: None$|: None \|", re.M) + found = [sitepages.name(p) for p in sitepages.pages() + if pattern.search(p.read_text(encoding="utf-8"))] self.assertEqual(found, []) @@ -5029,7 +5023,7 @@ class VersionAnchorsFollowContent(unittest.TestCase): "raze", {"emulator": "Raze", "type": "standalone", "files": files}, {"files": {}, "indexes": {}}, ) - return re.findall(r"\{ #(file-[^ ]+) \.rb-file-title \}", page) + return re.findall(r'<h3 id="(file-[^"]+)" class="rb-file-title"', page) def test_inserting_a_version_keeps_the_others(self): a = {"name": "DUKE3D.GRP", "path": "DUKE3D.GRP", "crc32": "bbc9ce44"} @@ -5051,18 +5045,26 @@ class SitemapDatesComeFromThePage(unittest.TestCase): """All 664 sitemap URLs carried the build date: eduke32, re-read on 2026-10-04, was announced as changed on the day of every build.""" - def test_the_hook_takes_the_page_date_or_none(self): - from types import SimpleNamespace + def test_the_page_date_or_none_reaches_the_sitemap(self): + import tempfile - from scripts.mkdocs_hooks import on_page_markdown + from scripts import generate_site - dated = SimpleNamespace(meta={"date_modified": "2026-10-04"}, update_date="today") - undated = SimpleNamespace(meta={}, update_date="today") - on_page_markdown("", dated, None, None) - on_page_markdown("", undated, None, None) - self.assertEqual((dated.update_date, undated.update_date), ("2026-10-04", "")) - config = (ROOT / "mkdocs.yml").read_text(encoding="utf-8") - self.assertIn("hooks:\n - scripts/mkdocs_hooks.py", config) + saved = dict(generate_site.PAGE_DATA) + self.addCleanup(lambda: (generate_site.PAGE_DATA.clear(), + generate_site.PAGE_DATA.update(saved))) + with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as tmp: + docs = Path(tmp) + (docs / "emulators").mkdir() + (docs / "emulators" / "dated.md").write_text("# Dated\n\nA page with its own date.\n") + (docs / "emulators" / "undated.md").write_text("# Undated\n\nA page with no date.\n") + generate_site.PAGE_DATA["emulators/dated.md"] = {"dateModified": "2026-10-04"} + lastmod = generate_site.decorate_markdown_pages(docs) + self.assertEqual(lastmod, {"/retrobios/emulators/dated/": "2026-10-04"}) + # The sitemap reads that map, and leaves every other page undated. + config = (ROOT / "website" / "astro.config.mjs").read_text(encoding="utf-8") + self.assertIn("lastmod.json", config) + self.assertIn("serialize(item)", config) class SiteCodeSpansHoldTheirValue(unittest.TestCase): @@ -6082,8 +6084,8 @@ class ThePipelineRunsTheSchemaCheckCIRuns(unittest.TestCase): duplicate ids passed it and failed the deploy.""" workflow = (ROOT / ".github" / "workflows" / "deploy-site.yml").read_text(encoding="utf-8") source = (ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8") - self.assertIn("mkdocs build --strict", workflow) - self.assertIn('["mkdocs", "build", "--strict"]', source) + self.assertIn("npm run build --prefix website", workflow) + self.assertIn('["npm", "run", "build", "--prefix", "website"]', source) self.assertIn("scripts/validate_site.py", workflow) self.assertIn('"scripts/validate_site.py"', source) diff --git a/tests/test_contributing_text.py b/tests/test_contributing_text.py index f23fe0aa..ccfd04e2 100644 --- a/tests/test_contributing_text.py +++ b/tests/test_contributing_text.py @@ -22,7 +22,7 @@ import generate_site # noqa: E402 def _normalized(text: str) -> str: # The site ends each page with its next step in the reading order. That # is navigation, not contributing text, and a repository file has none. - text = text.split('\n\n!!! tip "Next"\n', 1)[0].rstrip("\n") + text = text.split("\n\n:::tip[Next]\n", 1)[0].rstrip("\n") text = re.sub(r"\]\([^)]*\)", "](link)", text) return "\n".join(text.splitlines()[1:]).replace("on this site", "on the documentation site") @@ -73,17 +73,14 @@ class BeforeOpeningAPullRequest(unittest.TestCase): workflow = (REPO_ROOT / ".github" / "workflows" / "validate.yml").read_text(encoding="utf-8") self.assertIn("validate_schemas.py --source-only", workflow) - def test_it_installs_the_packages_the_site_render_needs(self): - """Without mkdocs-material and pymdown-extensions the pipeline's - render step (8b) is skipped for every contributor.""" - deploy = (REPO_ROOT / ".github" / "workflows" / "deploy-site.yml").read_text( - encoding="utf-8" - ) - specs = re.findall(r'"((?:mkdocs-material|pymdown-extensions)[^"]*)"', deploy) - self.assertEqual(len(specs), 2) + def test_it_installs_what_the_checks_need_and_no_site_package(self): + """The site is built by Node from website/package-lock.json: a pip + line naming the old theme installed packages nothing reads.""" text = generate_readme.generate_contributing() - for spec in specs: - self.assertIn(f'"{spec}"', text) + section = text.split("## Before opening a PR", 1)[1].split("\n## ", 1)[0] + self.assertIn('pip install pyyaml "jsonschema[format-nongpl]" markdown', section) + self.assertNotIn("mkdocs", section) + self.assertTrue((REPO_ROOT / "website" / "package-lock.json").is_file()) class OneFileThroughTheBrowser(unittest.TestCase): diff --git a/tests/test_export_report.py b/tests/test_export_report.py index 7008c4c8..26c25fc4 100644 --- a/tests/test_export_report.py +++ b/tests/test_export_report.py @@ -85,22 +85,22 @@ class CorrectedFilesPage(unittest.TestCase): def test_single_file_is_downloadable_raw(self): self.assertIn( - "(https://raw.githubusercontent.com/Abdess/retrobios/main/exports/recalbox/es_bios.xml)", + 'href="https://raw.githubusercontent.com/Abdess/retrobios/main/exports/recalbox/es_bios.xml"', self.page, ) def test_several_files_link_to_their_directory(self): self.assertIn("tree/main/exports/retropie", self.page) - self.assertIn('??? note "The 2 original files it is based on"', self.page) + self.assertIn("<summary>The 2 original files it is based on</summary>", self.page) def test_every_change_is_listed(self): - self.assertIn('??? note "Hashes updated to the emulator source (12)"', self.page) + self.assertIn("<summary>Hashes updated to the emulator source (12)</summary>", self.page) for i in range(12): - self.assertIn(f" - `sys/file{i}.bin md5`", self.page) + self.assertIn(f"\n- `sys/file{i}.bin md5`", self.page) def test_a_failing_file_is_said_to_fail(self): - self.assertIn(" - bash -n failed on a.sh", self.page) - self.assertIn(" - a size the format has no field for", self.page) + self.assertIn("\n- bash -n failed on a.sh", self.page) + self.assertIn("\n- a size the format has no field for", self.page) def test_nothing_published_says_how_to_build(self): self.assertIn("export_native.py --all --fetch", generate_exports_page([], {})) @@ -412,16 +412,16 @@ class ThePageShowsEachValueBeforeAndAfter(unittest.TestCase): def test_a_corrected_hash_shows_both_values(self): self.assertIn( - f" - `psx/scph5501.bin` md5 `5626{'0' * 28}` becomes `c523{'1' * 28}`", + f"\n- `psx/scph5501.bin` md5 `5626{'0' * 28}` becomes `c523{'1' * 28}`", self.page, ) def test_a_requirement_names_the_entry_and_its_value(self): - self.assertIn('??? note "Required flags aligned with the code (1)"', self.page) - self.assertIn(" - `psx/scph5501.bin` becomes optional", self.page) + self.assertIn("<summary>Required flags aligned with the code (1)</summary>", self.page) + self.assertIn("\n- `psx/scph5501.bin` becomes optional", self.page) def test_a_filled_hash_shows_its_value(self): - self.assertIn(f" - `psx/scph5501.bin` sha1 `{'f' * 40}` added", self.page) + self.assertIn(f"\n- `psx/scph5501.bin` sha1 `{'f' * 40}` added", self.page) def test_no_arrow_glyph(self): for glyph in (chr(0x2192), "->", "=>"): diff --git a/tests/test_prose_style.py b/tests/test_prose_style.py index a1312290..df24a41f 100644 --- a/tests/test_prose_style.py +++ b/tests/test_prose_style.py @@ -56,6 +56,9 @@ def _tracked() -> list[Path]: def _semicolons(text: str) -> list[str]: + # An HTML character reference (& {) ends in a semicolon that + # joins no clauses. + text = re.sub(r"&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);", "&", text) return [ line.strip() for line in BACKTICKS.sub("``", text).splitlines() @@ -356,18 +359,17 @@ class GeneratedSiteProse(unittest.TestCase): sys.path.insert(0, str(ROOT / "scripts")) from generate_site import LOCAL_NOTE_DIRS # noqa: PLC0415 - docs = ROOT / "docs" - pages = [ - p for p in docs.rglob("*.md") - if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS) - ] if docs.is_dir() else [] - if not pages: - if os.environ.get("CI"): - self.fail("CI generates docs/ before the suite") - self.skipTest("docs/ not generated (python scripts/generate_site.py)") + from tests import sitepages # noqa: PLC0415 + + pages = [p for p in sitepages.pages() + if not set(p.relative_to(sitepages.CONTENT).parts[:-1]) & set(LOCAL_NOTE_DIRS)] offenders = [] for page in pages: - text = re.sub(r"<script.*?</script>", "", page.read_text(encoding="utf-8"), flags=re.S) + text = page.read_text(encoding="utf-8") + # The front matter carries the page's structured data, not prose. + if text.startswith("---\n"): + text = text[text.index("\n---\n", 4) + 5:] + text = re.sub(r"<script.*?</script>", "", text, flags=re.S) text = FENCED.sub("", text) for number, line in enumerate(text.splitlines(), 1): if GLYPHS.search(line) or _semicolons(line) or ONE_PLURAL.search(line): diff --git a/tests/test_schema_semantic_gate.py b/tests/test_schema_semantic_gate.py index a8022cc6..6c06b3d0 100644 --- a/tests/test_schema_semantic_gate.py +++ b/tests/test_schema_semantic_gate.py @@ -42,7 +42,7 @@ class SemanticManifestValidation(unittest.TestCase): (install / "synthetic.json").write_text( json.dumps({"files": None}), encoding="utf-8" ) - api = root / "docs" / "api" / "v1" + api = root / "website" / "public" / "api" / "v1" api.mkdir(parents=True) (api / "catalog.json").write_text( json.dumps({"count": 1, "items": None}), encoding="utf-8" @@ -58,7 +58,7 @@ class SemanticManifestValidation(unittest.TestCase): text = output.getvalue().replace("\\", "/") self.assertIn("database.json", text) self.assertIn("install/synthetic.json", text) - self.assertIn("docs/api/v1/catalog.json", text) + self.assertIn("website/public/api/v1/catalog.json", text) self.assertIn("files", text) self.assertIn("items", text) self.assertNotIn("Traceback", text) diff --git a/tests/test_site_content_data.py b/tests/test_site_content_data.py index fc77a2d3..295ea1ba 100644 --- a/tests/test_site_content_data.py +++ b/tests/test_site_content_data.py @@ -148,7 +148,7 @@ def _wiki_copy(tmp: str) -> Path: class NavFromFrontMatter(unittest.TestCase): def _nav(self, wiki: Path) -> dict: - nav = generate_site.generate_mkdocs_nav({}, {}, {}, {}, wiki) + nav = generate_site.generate_nav({}, {}, {}, {}, wiki) return {next(iter(tab)): next(iter(tab.values())) for tab in nav} def test_the_repository_nav_matches_the_pages(self): diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py index d44a673a..eece3067 100644 --- a/tests/test_site_copy.py +++ b/tests/test_site_copy.py @@ -21,7 +21,10 @@ from dataclasses import dataclass, field from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent -DOCS = REPO_ROOT / "docs" +sys.path.insert(0, str(REPO_ROOT)) +from tests import sitepages # noqa: E402 + +DOCS = sitepages.CONTENT # Level 0: the Start here pages, with their own ceiling on words. START_PAGES = { @@ -142,15 +145,19 @@ def guide_part(markdown: str) -> str: def parse(markdown: str) -> Page: - """Blocks of prose a reader sees, code and HTML left out.""" + """Blocks of prose a reader sees, code and HTML left out. + + A collapsed block (<details>) counts, but not before the first action: + the reader opens it on purpose. Of a group of tabs the first is read and + the others only stand in for it. + """ text = guide_part(markdown) text = re.sub(r"<script.*?</script>", "", text, flags=re.S) text = re.sub(r"<!--.*?-->", "", text, flags=re.S) page = Page() seen_words = 0 - collapsed_indent: int | None = None - tab_indent: int | None = None # the tabs of a group a reader picks one of - hidden_indent: int | None = None + details = 0 # depth of collapsed blocks + tab = 0 # the tab of a group being read, 0 outside a group fence: str | None = None current: list[str] = [] kind = "paragraph" @@ -162,8 +169,7 @@ def parse(markdown: str) -> Page: body = strip_inline(" ".join(current)) if WORD.search(body): term = page.blocks[-1].text.lower() if kind == "definition" and page.blocks else "" - block = Block(kind, body, collapsed_indent is not None, term, - hidden_indent is not None) + block = Block(kind, body, details > 0, term, tab > 1) page.blocks.append(block) if (page.before_action is None and not block.collapsed and not block.hidden and kind not in ("table", "heading")): @@ -185,45 +191,52 @@ def parse(markdown: str) -> Page: if stripped.startswith(fence): fence = None continue - if stripped and tab_indent is not None and indent <= tab_indent and not stripped.startswith("=== "): - flush() - tab_indent = hidden_indent = None if stripped.startswith(("```", "~~~")): fence = stripped[:3] - if hidden_indent is None: + if tab < 2: act() continue - if collapsed_indent is not None and stripped and indent <= collapsed_indent: - flush() - collapsed_indent = None if not stripped: flush() continue - if re.match(r"(\?\?\?\+?|!!!) ", stripped): - flush() - if stripped.startswith("??? "): - collapsed_indent = indent + if stripped.startswith("import ") and stripped.endswith(";"): continue - if stripped.startswith("=== "): - flush() - if tab_indent == indent: - hidden_indent = indent - else: - tab_indent, hidden_indent = indent, None - continue - if stripped.startswith("<") or stripped.startswith("</"): + if stripped.startswith(":::"): flush() continue - if ".md-button" in stripped: + if stripped.startswith("<details"): + flush() + details += 1 + continue + if stripped.startswith("</details>"): + flush() + details = max(0, details - 1) + continue + if stripped.startswith("<Tabs"): + flush() + tab = 0 + continue + if stripped.startswith("<TabItem"): + flush() + tab += 1 + continue + if stripped.startswith("</Tabs>"): + flush() + tab = 0 + continue + if "rb-button" in stripped or stripped.startswith("<FilePicker"): act() continue + if re.match(r"</?(?!abbr\b|a\b|code\b|strong\b|em\b)[A-Za-z]", stripped): + flush() + continue if stripped.startswith("#"): flush() if run: page.list_runs.append(run) run = 0 kind = "heading" - current = [stripped.lstrip("#").strip()] + current = [re.sub(r"\s*\{[^}]*\}\s*$", "", stripped.lstrip("#").strip())] flush() kind = "paragraph" continue @@ -311,25 +324,23 @@ def held_pages(docs: Path) -> list[tuple[str, str, int]]: """(relative path, level, ceiling) for every page the budgets hold.""" pages = [(name, "start", ceiling) for name, ceiling in START_PAGES.items()] pages += [ - (str(path.relative_to(docs)), "setup", SETUP_CEILING) - for path in sorted((docs / "platforms").glob("*.md")) - if path.name != "index.md" + (sitepages.name(path), "setup", SETUP_CEILING) + for path in sorted((docs / "platforms").glob("*.md*")) + if path.stem != "index" and "\n## Where the files go" in path.read_text(encoding="utf-8") ] pages.append(("platforms/index.md", "setup", SETUP_INDEX_CEILING)) - return [(name, level, ceiling) for name, level, ceiling in pages if (docs / name).is_file()] + return [(name, level, ceiling) for name, level, ceiling in pages + if sitepages.page(name).is_file()] def _docs() -> Path: - if not (DOCS / "index.md").is_file(): - if os.environ.get("CI"): - raise AssertionError("CI generates docs/ before the suite") - raise unittest.SkipTest("docs/ not generated (python scripts/generate_site.py)") - return DOCS + return sitepages.require() def _load(name: str) -> Page: - return parse((_docs() / name).read_text(encoding="utf-8")) + _docs() + return parse(sitepages.page(name).read_text(encoding="utf-8")) class CopyBudgets(unittest.TestCase): @@ -400,8 +411,10 @@ class TheMeasureItself(unittest.TestCase): SAMPLE = ( "---\nlevel: start\n---\n\n# Title\n\nOne two three. Four five.\n\n" "- Item **one**.\n- Item [two](x.md).\n\n```sh\nnot counted at all\n```\n\n" - "??? note \"Hidden\"\n\n Collapsed words here.\n\n" - "=== \"A\"\n\n First tab.\n\n=== \"B\"\n\n Second tab words.\n\n" + '<details class="rb-details rb-details-note">\n<summary>Hidden</summary>\n\n' + "Collapsed words here.\n\n</details>\n\n" + '<Tabs syncKey="os">\n<TabItem label="A">\n\nFirst tab.\n\n</TabItem>\n' + '<TabItem label="B">\n\nSecond tab words.\n\n</TabItem>\n</Tabs>\n\n' "| A | B |\n|---|---|\n| cell | cell |\n\n## Reference: deep\n\nNot read.\n" ) @@ -429,7 +442,7 @@ def report(docs: Path) -> str: rows = ["| Page | Words | Before action | Max block | Median / max sentence |", "|---|---:|---:|---:|---|"] for name, _level, _ceiling in held_pages(docs): - m = measure(parse((docs / name).read_text(encoding="utf-8"))) + m = measure(parse(sitepages.page(name).read_text(encoding="utf-8"))) before = "none" if m["before_action"] is None else m["before_action"] rows.append(f"| {name} | {m['words']} | {before} | {m['max_block']} | " f"{m['median_sentence']:g} / {m['max_sentence']} |") diff --git a/tests/test_site_cores.py b/tests/test_site_cores.py index b60fb636..0133e397 100644 --- a/tests/test_site_cores.py +++ b/tests/test_site_cores.py @@ -36,7 +36,8 @@ class SiteCoreCounts(unittest.TestCase): page = generate_site.generate_cross_reference(coverages, profiles) shown = { m.group(1): int(m.group(2)) - for m in re.finditer(r'\?\?\? abstract "([^"]+)"\n\n.*\n\n {4}\*\*(\d+) cores', page) + for m in re.finditer( + r'<summary>([^<]+)</summary>\n\n.*\n\n\*\*(\d+) cores', page) } for name, cov in coverages.items(): with self.subTest(platform=name): diff --git a/tests/test_site_emulator_page.py b/tests/test_site_emulator_page.py index 9c5df978..0342897b 100644 --- a/tests/test_site_emulator_page.py +++ b/tests/test_site_emulator_page.py @@ -86,7 +86,7 @@ class ACardKeepsItsAddress(unittest.TestCase): files = [{"name": "DUKE3D.GRP", "md5": "aaaa1111bbbb"}] page = _page("raze", {"type": "standalone", "files": files}) self.assertIn('<span id="file-duke3d-grp"></span>', page) - self.assertIn("{ #file-duke3d-grp-aaaa1111 .rb-file-title }", page) + self.assertIn('<h3 id="file-duke3d-grp-aaaa1111" class="rb-file-title"', page) # A card already holding the plain id gives it to no one else. files = [{"name": "x.bin"}, {"name": "x.bin", "sha1": "ab" * 20}] anchors = gs.emulator_file_anchors(files) @@ -121,8 +121,8 @@ class WhatASearchSnippetSays(unittest.TestCase): "only validate_schemas.py and this") def test_structured_url_is_encoded_like_the_canonical(self): - """mkdocs encodes idtech4a++ as idtech4a%2B%2B in the canonical link - and the sitemap, and the JSON-LD named the unencoded URL.""" + """The canonical link and the sitemap encode idtech4a++ as + idtech4a%2B%2B, and the JSON-LD named the unencoded URL.""" import json import tempfile @@ -132,8 +132,11 @@ class WhatASearchSnippetSays(unittest.TestCase): page.write_text("# idTech4A++\n\nSome text long enough to be a description.\n") gs.decorate_markdown_pages(Path(tmp)) text = page.read_text() - data = json.loads(text.split('<script type="application/ld+json">')[1] - .split("</script>")[0].replace("<\\/", "</")) + import yaml + + front = yaml.safe_load(text.split("---\n")[1]) + script = next(entry for entry in front["head"] if entry["tag"] == "script") + data = json.loads(script["content"].replace("<\\/", "</")) self.assertEqual(data["url"], "https://abdess.github.io/retrobios/emulators/idtech4a%2B%2B/") diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index be861d5c..2357731f 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -236,7 +236,7 @@ class SiteReferenceContracts(unittest.TestCase): decorate_markdown_pages(self.docs) decorate_markdown_pages(self.docs) content = page.read_text() - self.assertEqual(content.count('type="application/ld+json"'), 1) + self.assertEqual(content.count("type: application/ld+json"), 1) self.assertIn("Current RetroBIOS verification gaps", content) self.assertIn('"@type":"TechArticle"', content) @@ -357,11 +357,11 @@ class AVariantShowsWhoPinsIt(unittest.TestCase): platforms={"c" * 40: {"batocera", "retrobat"}}, emulators={"a" * 40: {"mame"}}) page = generate_site.generate_system_page( "Entex", {"Adventure Vision": [primary, variant]}, consumers) - block = page[page.index('??? note "Variants (1)"'):] - self.assertIn(" - Collection path: `bios/Entex/Adventure Vision/.variants/advision.zip.1c086064`", block) + block = page[page.index("<summary>Variants (1)</summary>"):] + self.assertIn("- Collection path: `bios/Entex/Adventure Vision/.variants/advision.zip.1c086064`", block) self.assertIn("[batocera](../platforms/batocera.md)", block) self.assertIn("[retrobat](../platforms/retrobat.md)", block) - self.assertIn('`' + "c" * 40 + '`', block) + self.assertIn('<code class="rb-hash">' + "c" * 40 + "</code>", block) self.assertIn("Catalogued", block) @@ -402,10 +402,10 @@ class EmulatorPagesPrintWholeHashes(unittest.TestCase): from generate_site import _hash_spans sha1 = "0c3de403b524107809fa9308b730d60e8e41ba93" - self.assertIn(f"`{sha1}`", _hash_spans(sha1)) + self.assertIn(f">{sha1}</code>", _hash_spans(sha1)) both = _hash_spans(f"{'a' * 64}, {'b' * 64}") - self.assertIn("`" + "a" * 64 + "`", both) - self.assertIn("`" + "b" * 64 + "`", both) + self.assertIn(">" + "a" * 64 + "</code>", both) + self.assertIn(">" + "b" * 64 + "</code>", both) self.assertNotIn("...", both) @@ -724,15 +724,16 @@ class AFileHasAnAddressByContent(unittest.TestCase): [{"sha1": "ab" * 20, "url": "systems/roland/#sha1-" + "ab" * 6}], ) self.assertIn('id="rb-file-lookup-status"', generate_site.generate_files_page()) - script = (Path(__file__).resolve().parents[1] / "docs_assets" / "site.js").read_text() + script = (Path(__file__).resolve().parents[1] / "website" / "src" / "scripts" + / "site.ts").read_text() self.assertIn("api/v1/lookup/", script) - self.assertIn('params.has("sha1")', script) + self.assertIn("params.has('sha1')", script) from cross_reference import FileConsumers # noqa: PLC0415 card = "\n".join(generate_site._system_file_card( {"name": "x.rom", "sha1": "ab" * 20, "size": 1}, FileConsumers({"files": {}}), )) - self.assertIn("../../files/?sha1=" + "ab" * 20, card) + self.assertIn("/retrobios/files/?sha1=" + "ab" * 20, card) class ACardNamesThePacksThatInstallIt(unittest.TestCase): @@ -825,7 +826,7 @@ class EveryAcceptedHashIsItsOwnWord(unittest.TestCase): md5s = ["96cf" + "0" * 28, "d5af" + "0" * 28] text = _hash_spans(",".join(md5s)) for md5 in md5s: - self.assertIn(f"`{md5}`</span>", text) + self.assertIn(f'<code class="rb-hash">{md5}</code>', text) class AMismatchNamesTheDumpTheCollectionHolds(unittest.TestCase): @@ -924,12 +925,14 @@ class NestedProfileBlocksRenderAsLists(unittest.TestCase): lines = generate_site._render_platform_details( {"platform_details": {"megacd": {"root": "x"}, "note": "y"}} ) - lines[0] = lines[0].replace("???+", "!!!") try: import markdown except ImportError: self.skipTest("markdown is not installed") - html = markdown.markdown("\n".join(lines), extensions=["admonition"]) + # The block's body, read as the engine reads Markdown inside HTML. + body = lines[lines.index("<summary>Platform details</summary>") + 1: + lines.index("</details>")] + html = markdown.markdown("\n".join(body)) self.assertIn("<li>root: x</li>", html) self.assertNotIn("**", html) @@ -959,7 +962,7 @@ class TheOneLinerIsWhereUsersLand(unittest.TestCase): "verified": 0, "missing": 0, "details": [], "config": {"systems": {}}} page = generate_site.generate_platform_page("batocera", cov) self.assertIn("install.sh | sh -s -- --platform batocera", page) - self.assertLess(page.index("--platform batocera"), page.index("md-button")) + self.assertLess(page.index("--platform batocera"), page.index("rb-button")) # Batocera runs the command on the device itself. A platform detected # on Windows gets the PowerShell form. cov = dict(cov, platform="RetroArch", mode="existence") diff --git a/tests/test_site_funnel.py b/tests/test_site_funnel.py index a6a2e92e..bf2234a1 100644 --- a/tests/test_site_funnel.py +++ b/tests/test_site_funnel.py @@ -19,12 +19,14 @@ REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "scripts")) import generate_site # noqa: E402 +from tests import sitepages # noqa: E402 from common import list_registered_platforms, load_platform_config, load_platform_registry # noqa: E402 from siteguides import DEVICE_GROUPS, GROUP_ANCHORS, GUIDES # noqa: E402 -DOCS = REPO_ROOT / "docs" +DOCS = sitepages.CONTENT ORDER = list(generate_site.LEVELS) -LINK = re.compile(r"\[[^\]]*\]\(([^)\s#]+\.md)(?:#[^)]*)?\)") +# Pages link to site addresses once written for the site. +LINK = re.compile(r"\[[^\]]*\]\((/retrobios/[^)\s#?]*)(?:[#?][^)]*)?\)") FENCED = re.compile(r"^\s*```.*?^\s*```", re.S | re.M) @@ -38,13 +40,7 @@ def _which_pack() -> str: def _docs_pages() -> list[Path]: - if not DOCS.is_dir() or not (DOCS / "index.md").is_file(): - if os.environ.get("CI"): - raise AssertionError("CI generates docs/ before the suite") - raise unittest.SkipTest("docs/ not generated (python scripts/generate_site.py)") - skipped = set(generate_site.LOCAL_NOTE_DIRS) - return [p for p in sorted(DOCS.rglob("*.md")) - if not set(p.relative_to(DOCS).parts[:-1]) & skipped] + return [p for p in sitepages.pages() if p.name not in ("404.md",)] def _level(page: Path) -> str: @@ -55,14 +51,14 @@ class FiveTabs(unittest.TestCase): """Twelve tabs put the walkthrough at position eleven, under "Wiki".""" def test_the_tabs_in_reading_order(self): - nav = generate_site.generate_mkdocs_nav({}, {}, {}, {}) + nav = generate_site.generate_nav({}, {}, {}, {}) self.assertEqual( [next(iter(tab)) for tab in nav], ["Start here", "Your setup", "Explore", "Reference", "Contribute"], ) def test_each_tab_opens_on_an_index_or_a_first_page(self): - nav = generate_site.generate_mkdocs_nav({}, {}, {}, {}) + nav = generate_site.generate_nav({}, {}, {}, {}) tabs = {next(iter(tab)): next(iter(tab.values())) for tab in nav} self.assertEqual(tabs["Start here"][0], "index.md") self.assertEqual(tabs["Your setup"][0], "platforms/index.md") @@ -70,8 +66,10 @@ class FiveTabs(unittest.TestCase): self.assertEqual(tabs["Contribute"][0], "contributing.md") def test_the_breadcrumbs_are_on(self): - source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8") - self.assertIn(" - navigation.path\n", source) + title = (REPO_ROOT / "website" / "src" / "components" / "PageTitle.astro").read_text(encoding="utf-8") + route = (REPO_ROOT / "website" / "src" / "routeData.ts").read_text(encoding="utf-8") + self.assertIn('aria-label="Breadcrumb"', title) + self.assertIn("rbBreadcrumbs", route) class EveryPlatformOnThePicker(unittest.TestCase): @@ -100,8 +98,17 @@ class EveryPlatformOnThePicker(unittest.TestCase): of the nav is reachable only by a link and MkDocs says so at info level. getting-started.md is kept out on purpose: it only links the pages that took its content, for the old links that point to it.""" - # mkdocs.yml is what generate_site writes, and CI checks it is current. - nav = (Path(__file__).resolve().parents[1] / "mkdocs.yml").read_text(encoding="utf-8") + def targets(entries) -> list[str]: + found = [] + for entry in entries: + if isinstance(entry, str): + found.append(entry) + else: + for value in entry.values(): + found += targets(value) if isinstance(value, list) else [value] + return found + + nav = " ".join(targets(generate_site.generate_nav({}, {}, {}, {}))) kept_out = {"getting-started.md"} wiki = Path(__file__).resolve().parents[1] / "wiki" missing = sorted( @@ -116,7 +123,7 @@ class EveryPlatformOnThePicker(unittest.TestCase): key: {"platform": load_platform_config(key, str(REPO_ROOT / "platforms"))["platform"]} for key in _registered() } - nav = generate_site.generate_mkdocs_nav(coverages, {}, {}, registry) + nav = generate_site.generate_nav(coverages, {}, {}, registry) setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab) groups = {next(iter(item)): next(iter(item.values())) for item in setup if isinstance(item, dict)} @@ -166,7 +173,12 @@ class EveryPageHasALevel(unittest.TestCase): level = _level(page) with self.subTest(page=str(page.relative_to(DOCS))): self.assertIn(level, generate_site.LEVELS) - self.assertIn(generate_site.level_label(level), text) + self.assertIn(f"\nlevel: {level}\n", text) + # The label above each title comes from the page's level. + component = (REPO_ROOT / "website" / "src" / "components" / "PageTitle.astro").read_text() + for level, label in generate_site.LEVELS.items(): + with self.subTest(level=level): + self.assertIn(f"{level}: '{label}'", component) def test_the_decoration_keeps_an_authored_level(self): with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: @@ -177,9 +189,8 @@ class EveryPageHasALevel(unittest.TestCase): generate_site.decorate_markdown_pages(Path(tmp)) text = page.read_text() self.assertEqual(text.count("level: start"), 1) - self.assertEqual(text.count(generate_site.level_label("start")), 1) - self.assertIn(" boost: 5\n", text) - self.assertIn("# FAQ\n", text) + self.assertIn("searchWeight: 10\n", text) + self.assertIn("title: FAQ\n", text) class DeepLinksCarryALabel(unittest.TestCase): @@ -189,8 +200,11 @@ class DeepLinksCarryALabel(unittest.TestCase): its line, or in the heading of its section ("## Reference: ...").""" def test_start_and_setup_pages(self): + import sitestarlight # noqa: PLC0415 + pages = _docs_pages() levels = {page: _level(page) for page in pages} + by_url = {sitestarlight.page_url(sitepages.name(page)): page for page in pages} offenders = [] for page, level in levels.items(): if level not in ("start", "setup"): @@ -201,7 +215,7 @@ class DeepLinksCarryALabel(unittest.TestCase): if re.match(r"#{1,3} ", line): heading = line.lstrip("#").strip() for match in LINK.finditer(line): - target = (page.parent / match.group(1)).resolve() + target = by_url.get(match.group(1)) if target not in levels: continue deeper = levels[target] @@ -218,18 +232,20 @@ class GuidePagesEndOnANextStep(unittest.TestCase): for page in ("index.md", "which-pack.md", "platforms/batocera.md", "wiki/first-install.md", "wiki/troubleshooting.md", "wiki/what-are-bios-files.md", "wiki/faq.md"): - path = DOCS / page if page.startswith(("index", "which", "platforms")) else REPO_ROOT / page - if not path.is_file(): - _docs_pages() + if page.startswith(("index", "which", "platforms")): + sitepages.require() + path = sitepages.page(page) + else: + path = REPO_ROOT / page with self.subTest(page=page): text = path.read_text(encoding="utf-8") - self.assertTrue('!!! tip "Next"' in text or "## What comes next" in text) + self.assertTrue(":::tip[Next]" in text or "## What comes next" in text) def test_the_platform_page_opens_on_its_guide(self): """The folder came after four counters and a metadata table, and was the pack-relative `bios/` where Batocera reads /userdata/bios/.""" - text = (DOCS / "platforms" / "batocera.md").read_text(encoding="utf-8") \ - if (DOCS / "platforms" / "batocera.md").is_file() else _docs_pages() + sitepages.require() + text = sitepages.page("platforms/batocera.md").read_text(encoding="utf-8") order = [text.index(h) for h in ( "## Where the files go", "## Install", "## Download", "## How to check", "## Reference: every file by system", diff --git a/tests/test_site_images.py b/tests/test_site_images.py index 9abf635f..2084bc56 100644 --- a/tests/test_site_images.py +++ b/tests/test_site_images.py @@ -200,9 +200,15 @@ class SharingCards(unittest.TestCase): ET.fromstring(sitediagrams.og_card_svg(section, b"png")) def test_the_template_names_every_section(self): - template = (ROOT / "docs_assets" / "overrides" / "main.html").read_text(encoding="utf-8") - named = set(re.findall(r"section = '([a-z]+)'", template)) + import sitestarlight # noqa: PLC0415 + + named = {sitestarlight.og_section("index.md", "")} | { + sitestarlight.og_section("wiki/page.md", level) + for level in ("start", "setup", "explore", "reference", "contribute")} self.assertEqual(named, set(sitediagrams.OG_SECTIONS)) + for section in named: + with self.subTest(section=section): + self.assertTrue((ROOT / "docs_assets" / "og" / f"{section}.png").is_file()) class ImagesStillNeeded(unittest.TestCase): diff --git a/tests/test_site_render.py b/tests/test_site_render.py index a5b4d6f0..83c4e69b 100644 --- a/tests/test_site_render.py +++ b/tests/test_site_render.py @@ -126,16 +126,18 @@ class StructuredBlocks(unittest.TestCase): page = self.render(self.profile("firmware_titles", [ {"id": "0005001b-10042400", "name": "Shared fonts", "content": ["CafeCn.ttf", "CafeKr.ttf"]}])) - self.assertIn(" - id: 0005001b-10042400, name: Shared fonts\n", page) - self.assertIn(" - **content:**", page) - self.assertIn(" - CafeKr.ttf", page) + self.assertIn("\n- id: 0005001b-10042400, name: Shared fonts\n", page) + self.assertIn("\n - **content:**", page) + self.assertIn("\n - CafeKr.ttf", page) def test_a_folded_note_stays_inside_its_block(self): page = self.render(self.profile("analysis", {"tmss": { "note": "The core points\nsystem/tmss_path at the system directory."}})) self.assertIn("- **note:** The core points system/tmss_path at the", page) - body = page.split("\n")[1:] - self.assertEqual([line for line in body if line and not line.startswith(" ")], []) + # Nothing of the note is left after the block closes. + lines = page.split("\n") + end = lines.index("</details>") + self.assertEqual([line for line in lines[end + 1:] if line.strip()], []) def test_a_long_note_is_not_cut(self): note = "word " * 80 + "end." @@ -315,7 +317,7 @@ class TheSearchLandsOnTheCard(unittest.TestCase): self.assertEqual(len(headings), 11) self.assertTrue(headings[0].endswith("{ #system-00 }")) self.assertNotIn('<a id="system-00"></a>', lines) - self.assertIn('??? note "1 file (OK)"', lines) + self.assertIn("<summary>1 file (OK)</summary>", lines) def test_an_emulator_card_is_a_heading_with_its_anchor(self): import generate_site @@ -323,8 +325,8 @@ class TheSearchLandsOnTheCard(unittest.TestCase): entry = {"name": "scph5501.bin", "required": True} lines = generate_site._render_emulator_file( entry, {"files": [entry]}, None, [entry], lambda f: True, "file-scph5501-bin") - self.assertTrue(lines[2].startswith("### `scph5501.bin` ")) - self.assertTrue(lines[2].endswith("{ #file-scph5501-bin .rb-file-title }")) + self.assertTrue(lines[2].startswith('<h3 id="file-scph5501-bin" class="rb-file-title"')) + self.assertIn("<code>scph5501.bin</code>", lines[2]) def test_a_system_card_is_a_heading_with_its_anchor(self): import generate_site @@ -332,8 +334,8 @@ class TheSearchLandsOnTheCard(unittest.TestCase): entry = {"name": "a.bin", "sha1": "a" * 40, "md5": "b" * 32, "size": 3} lines = generate_site._system_file_card(entry, FileConsumers({"files": {}})) - self.assertTrue(lines[2].startswith("### `a.bin` ")) - self.assertTrue(lines[2].endswith("{ #sha1-aaaaaaaaaaaa .rb-file-title }")) + self.assertTrue(lines[2].startswith('<h3 id="sha1-aaaaaaaaaaaa" class="rb-file-title"')) + self.assertIn("<code>a.bin</code>", lines[2]) class TheSearchSkipsLinkLists(unittest.TestCase): @@ -353,11 +355,14 @@ class TheSearchSkipsLinkLists(unittest.TestCase): consumers.emulators = {sha1: {"duckstation"}} entry = {"name": "a.bin", "sha1": sha1, "md5": "b" * 32, "size": 3} text = "\n".join(generate_site._system_file_card(entry, consumers)) - searched, skipped = text.split("<section data-search-exclude markdown>") - self.assertIn("`a.bin`", searched) + searched, _sep, skipped = text.partition("<section data-pagefind-ignore>") + self.assertIn("<code>a.bin</code>", searched) self.assertNotIn("- MD5:", searched) - for line in ("- MD5:", "- Emulators:", "- Platforms:", "- Also installed by:"): + for line in ("- MD5:", "- Emulators:", "- Also installed by:"): self.assertIn(line, skipped) + # The platforms line names the file as each setup does: the site + # search reads it, so a file name finds its card. + self.assertIn("- Platforms:", searched + skipped.split("</section>", 1)[1]) def test_a_game_data_card_skips_its_name_too(self): import generate_site @@ -366,7 +371,8 @@ class TheSearchSkipsLinkLists(unittest.TestCase): entry = {"name": "0.ogg", "sha1": "a" * 40, "size": 3} lines = generate_site._system_file_card( entry, FileConsumers({"files": {}}), searchable=False) - self.assertTrue(lines[2].endswith("data-search-exclude }")) + self.assertIn("data-pagefind-ignore", lines[2]) + self.assertNotIn("data-pagefind-weight", lines[2]) def test_runs_alternate_in_card_order(self): import generate_site @@ -377,10 +383,10 @@ class TheSearchSkipsLinkLists(unittest.TestCase): out = [line for line in generate_site._search_skipped_apart(lines) if line] self.assertEqual(out, [ "- Path: `p`", - '<div class="rb-search-skip" data-search-exclude markdown>', + '<div class="rb-search-skip" data-pagefind-ignore>', "- SHA1: x", "- Source: `a.c:1`", "- Platforms: p", "</div>", "- Destination: `d`", - '<div class="rb-search-skip" data-search-exclude markdown>', + '<div class="rb-search-skip" data-pagefind-ignore>', "- Contents (1 members):", " - `m`", "</div>", "- Note", ]) @@ -391,8 +397,8 @@ class TheSearchSkipsLinkLists(unittest.TestCase): short = "| a |\n|---|\n| 1 |" long = " | a |\n |---|\n" + "\n".join(f" | {'x' * 50} |" for _ in range(80)) text = generate_site.tables_out_of_search(f"# T\n\n{short}\n\n{long}\n") - self.assertEqual(text.count("data-search-exclude"), 1) - self.assertIn(" <section data-search-exclude markdown>\n\n | a |", text) + self.assertEqual(text.count("data-pagefind-ignore"), 1) + self.assertIn(" <section data-pagefind-ignore>\n\n | a |", text) self.assertIn(short, text) diff --git a/tests/test_site_system_links.py b/tests/test_site_system_links.py index 9e1edf78..2cc38c43 100644 --- a/tests/test_site_system_links.py +++ b/tests/test_site_system_links.py @@ -143,7 +143,7 @@ class AnIdNamesOneCardPerPage(unittest.TestCase): "path": "bios/Nintendo/DS/a.bin"} page = gs.generate_system_page( "Nintendo", {"DS": [entry], "DSi": [entry]}, FileConsumers({"files": {}})) - self.assertEqual(page.count("#sha1-aaaaaaaaaaaa .rb-file-title"), 1) + self.assertEqual(page.count('id="sha1-aaaaaaaaaaaa" class="rb-file-title"'), 1) self.assertEqual(page.count("rb-sys-file"), 2) diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py index 4c897af3..b553ac43 100644 --- a/tests/test_site_validation.py +++ b/tests/test_site_validation.py @@ -101,9 +101,9 @@ class PlatformDetails(unittest.TestCase): "root": "<system_dir>/x", }}} ) - self.assertIn(" - extensions_tried: .bin, .zip", lines) - self.assertIn(" - hle_available: no", lines) - self.assertIn(" - root: <system_dir>/x", lines) + self.assertIn(" - extensions_tried: .bin, .zip", lines) + self.assertIn(" - hle_available: no", lines) + self.assertIn(" - root: <system_dir>/x", lines) class RenderedSiteValidation(unittest.TestCase): @@ -112,10 +112,8 @@ class RenderedSiteValidation(unittest.TestCase): self.root = Path(self.temp.name) self.site = self.root / "site" self.site.mkdir() - self.config = self.root / "mkdocs.yml" - self.config.write_text( - "site_url: https://example.test/retrobios/\n", encoding="utf-8" - ) + # The path the site is served under. + self.config = "/retrobios/" def tearDown(self) -> None: self.temp.cleanup() @@ -174,8 +172,7 @@ class TheSiteStaysWithinItsBudgets(unittest.TestCase): self.temp = tempfile.TemporaryDirectory(dir=TMP_ROOT) self.site = Path(self.temp.name) / "site" self.site.mkdir() - self.config = Path(self.temp.name) / "mkdocs.yml" - self.config.write_text("site_url: https://example.test/retrobios/\n", encoding="utf-8") + self.config = "/retrobios/" def tearDown(self) -> None: self.temp.cleanup() @@ -214,17 +211,16 @@ class TheSiteStaysWithinItsBudgets(unittest.TestCase): self.assertTrue(any("drawn by a script at runtime" in issue for issue in issues)) def test_a_search_index_over_its_budget(self): - import random - import validate_site as module - (self.site / "search").mkdir() - rng = random.Random(1) - noise = "".join(rng.choice("0123456789abcdef") for _ in range(module.MAX_SEARCH_INDEX_BYTES + 1)) - (self.site / "search" / "search_index.json").write_text(noise, encoding="utf-8") + index = self.site / "pagefind" / "index" + index.mkdir(parents=True) + (index / "en_big.pf_index").write_bytes(b"x" * (module.MAX_PAGEFIND_CHUNK_BYTES + 1)) + (self.site / "pagefind" / "pagefind.en_x.pf_meta").write_bytes( + b"x" * (module.MAX_PAGEFIND_META_BYTES + 1)) issues = self._issues("") - self.assertTrue(any("search_index.json" in issue and "gzip" not in issue for issue in issues)) - self.assertTrue(any("search_index.json" in issue and "gzip" in issue for issue in issues)) + self.assertTrue(any("en_big.pf_index" in issue for issue in issues), issues) + self.assertTrue(any(".pf_meta" in issue for issue in issues), issues) class UnrenderedMarkdown(unittest.TestCase): @@ -327,9 +323,12 @@ class TheSitemapIsAnnouncedWhereCrawlersLook(unittest.TestCase): never found that way.""" def test_the_head_names_it_and_no_robots_is_written(self): + # Starlight links the sitemap from every page's head when the site + # has an address, and the config keeps sitemap.xml at its old path. root = Path(__file__).resolve().parents[1] - override = (root / "docs_assets" / "overrides" / "main.html").read_text() - self.assertIn('rel="sitemap"', override) + config = (root / "website" / "astro.config.mjs").read_text() + self.assertIn("site: SITE", config) + self.assertIn("sitemap.xml", config) source = (root / "scripts" / "generate_site.py").read_text() self.assertNotIn('"robots.txt"),\n "User-agent', source) self.assertNotIn("Sitemap: https://", source) @@ -351,11 +350,10 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase): only its sha1 and md5, so the crc a clrmamepro user pastes found nothing.""" def test_the_rendered_page(self): - page = ROOT / "docs" / "platforms" / "retroarch.md" - if not page.is_file(): - if os.environ.get("CI"): - self.fail("CI generates docs/ before the suite") - self.skipTest("site not generated") + from tests import sitepages # noqa: PLC0415 + + sitepages.require() + page = sitepages.page("platforms/retroarch.md") self.assertIn("CRC32: `91e93107`", page.read_text(encoding="utf-8")) @@ -378,7 +376,7 @@ class PlainHouseStyle(unittest.TestCase): } def test_the_stylesheet_has_none_of_the_patterns(self): - css = (ROOT / "docs_assets" / "extra.css").read_text(encoding="utf-8") + css = (ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8") for label, pattern in self.PATTERNS.items(): with self.subTest(pattern=label): self.assertIsNone(pattern.search(css), label) @@ -392,21 +390,3 @@ class PlainHouseStyle(unittest.TestCase): first = page.read_text(encoding="utf-8").splitlines()[0] with self.subTest(page=page.name): self.assertFalse(first.endswith(" - RetroBIOS"), first) - - -class APreviewLeavesTheTrackedConfig(unittest.TestCase): - """--docs-dir moved the pages but still rewrote the tracked mkdocs.yml.""" - - def test_another_folder_gets_its_own_config(self): - from generate_site import _mkdocs_config - - path, text = _mkdocs_config(Path("docs"), "hooks:\n - scripts/mkdocs_hooks.py\n") - self.assertEqual(path, Path("mkdocs.yml")) - path, text = _mkdocs_config(Path("tmp/preview"), "hooks:\n - scripts/mkdocs_hooks.py\n") - self.assertEqual(path, Path("tmp/preview.mkdocs.yml")) - self.assertTrue(text.startswith("docs_dir: preview\n")) - self.assertIn("../scripts/mkdocs_hooks.py", text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_site_weight.py b/tests/test_site_weight.py index a524d7ed..9cc55b7a 100644 --- a/tests/test_site_weight.py +++ b/tests/test_site_weight.py @@ -18,8 +18,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT / "scripts")) import generate_site # noqa: E402 -import mkdocs_hooks # noqa: E402 +import sitestarlight # noqa: E402 import siterender # noqa: E402 +import sitemarkup as markup # noqa: E402 from cross_reference import FileConsumers # noqa: E402 @@ -54,7 +55,7 @@ class ASystemPageOverItsBudgetIsSplit(unittest.TestCase): self.assertIn("## Big", summary) self.assertIn("## Small", summary) # The small section keeps its cards in place. - self.assertIn("{ #sha1-" + f"{900:012x}", summary) + self.assertIn('id="sha1-' + f"{900:012x}", summary) parts = [stem for stem in pages if stem.startswith("maker-big")] self.assertGreater(len(parts), 1) self.assertEqual(parts[0], "maker-big") @@ -73,7 +74,9 @@ class ASystemPageOverItsBudgetIsSplit(unittest.TestCase): def test_a_console_on_one_page_is_linked_directly(self): self.consoles["Big"] = self.consoles["Big"][:12] - whole = self._pages(10**9)["maker"][0] + # The budget is measured in the Markdown it was set on. + with markup.using("mkdocs"): + whole = self._pages(10**9)["maker"][0] pages = self._pages(len(whole) - 1) self.assertIn("maker-big", pages) self.assertNotIn("maker-big-2", pages) @@ -99,8 +102,8 @@ class AnEmulatorWithTooManyCardsIsSplit(unittest.TestCase): for anchor in anchors: self.assertIn(f'<span id="{anchor}"></span>', main) self.assertIn('class="rb-card-index"', main) - self.assertNotIn(".rb-file-title", main) - self.assertIn(f"{{ #{anchors[0]} .rb-file-title }}", pages["big-files-1"]) + self.assertNotIn("rb-file-title", main) + self.assertIn(f'id="{anchors[0]}" class="rb-file-title"', pages["big-files-1"]) def test_cards_are_linked_on_the_page_that_holds_them(self): files = [{"name": f"set{i:04d}.zip"} for i in range(generate_site.EMULATOR_CARDS_PER_PAGE + 1)] @@ -147,21 +150,27 @@ class TheNavigationOpensOneInitial(unittest.TestCase): class TheTableOfContentsOfACardPageStopsAtLevelTwo(unittest.TestCase): @staticmethod - def _item(level: int, children: list | None = None) -> SimpleNamespace: - return SimpleNamespace(level=level, children=children or []) + @staticmethod + def _contents(rel: str, sections: int): + import yaml # noqa: PLC0415 + + body = "# T\n\n" + "".join(f"## S{i}\n\n### card\n\n" for i in range(sections)) + _target, text = sitestarlight.finalize_page( + rel, body, level="", weight=None, browser_title="T", description="d", + structured={}, terms={}) + return yaml.safe_load(text.split("---\n")[1]).get("tableOfContents", "default") def test_cards_leave_the_toc_and_wiki_pages_keep_level_three(self): - def page(uri: str) -> SimpleNamespace: - h2 = self._item(2, [self._item(3)]) - return SimpleNamespace(file=SimpleNamespace(src_uri=uri), - toc=SimpleNamespace(items=[self._item(1, [h2])])) - - for uri, kept in (("systems/sony.md", 0), ("platforms/retroarch.md", 0), - ("emulators/mame.md", 0), ("wiki/faq.md", 1)): - current = page(uri) - mkdocs_hooks.on_page_content("", current, None, None) - with self.subTest(uri=uri): - self.assertEqual(len(current.toc.items[0].children[0].children), kept) + level_two = {"minHeadingLevel": 2, "maxHeadingLevel": 2} + for rel, sections, expected in ( + ("systems/sony.md", 3, level_two), ("platforms/retroarch.md", 5, level_two), + ("emulators/mame.md", 3, level_two), ("wiki/faq.md", 3, "default"), + # One or two headings over hundreds of cards: no table at all, + # whose script watched every block of the page. + ("emulators/mame-files-2.md", 1, False), ("systems/arcade-arcade.md", 2, False), + ): + with self.subTest(rel=rel): + self.assertEqual(self._contents(rel, sections), expected) if __name__ == "__main__": diff --git a/tests/test_workflow_paths.py b/tests/test_workflow_paths.py index 88636e12..f9929b4b 100644 --- a/tests/test_workflow_paths.py +++ b/tests/test_workflow_paths.py @@ -79,15 +79,8 @@ class DeploySiteTriggers(unittest.TestCase): } ) self.assertIn("generate_site.py", entry_points) - # mkdocs build runs the hooks mkdocs.yml names: a script of the build. - # The file carries Python tags safe_load refuses, but the list is plain. - mkdocs = (REPO_ROOT / "mkdocs.yml").read_text(encoding="utf-8") - hooks = re.search(r"^hooks:\n((?: - .+\n)+)", mkdocs, re.M) - entry_points += [ - line.strip()[2:].removeprefix("scripts/") - for line in (hooks.group(1).splitlines() if hooks else []) - if line.strip()[2:].startswith("scripts/") - ] + # The Starlight project is an input of its own build. + self.assertIn("website/**", patterns) uncovered = sorted( path for path in closure(entry_points) @@ -109,9 +102,9 @@ class DeploySiteTriggers(unittest.TestCase): class ThePullRequestBuildsTheSite(unittest.TestCase): - """mkdocs was installed by deploy-site.yml alone, which runs after the - merge, and validate.yml did not watch wiki/: a dead link or a line read as - a second heading passed every check and failed the deploy on main.""" + """The site build ran in deploy-site.yml alone, after the merge, and + validate.yml did not watch wiki/: a dead link or a line read as a second + heading passed every check and failed the deploy on main.""" @staticmethod def _workflow(name: str) -> dict: @@ -124,17 +117,23 @@ class ThePullRequestBuildsTheSite(unittest.TestCase): runs = "\n".join(str(step.get("run", "")) for step in steps) for command in ( "python scripts/generate_site.py", - "mkdocs build --strict", - "python scripts/validate_site.py", + "npm ci --prefix website", + "npm run build --prefix website", + "python scripts/validate_site.py --site-dir website/dist", ): with self.subTest(command=command): self.assertIn(command, runs) - for package in ("mkdocs-material", "pymdown-extensions"): - with self.subTest(package=package): - self.assertIn(package, runs) + # The same Node, from the official action pinned by commit, in both. + uses = {str(step.get("uses", "")) for step in steps} + deploy = {str(step.get("uses", "")) + for step in self._workflow("deploy-site.yml")["jobs"]["build"]["steps"]} + node = {use for use in uses if use.startswith("actions/setup-node@")} + self.assertEqual(len(node), 1) + self.assertTrue(node <= deploy) + self.assertRegex(next(iter(node)), r"@[0-9a-f]{40}$") def test_the_pages_exist_before_the_suite_reads_them(self): - """docs/ is gitignored. Built after the suite, the tests that read + """The pages are gitignored. Built after the suite, the tests that read the rendered pages found none and skipped on every CI run.""" steps = self._workflow("validate.yml")["jobs"]["run-tests"]["steps"] runs = [str(step.get("run", "")) for step in steps] diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 9c2a04ed..03b72b92 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -66,12 +66,21 @@ export default defineConfig({ expressiveCode: { defaultProps: { wrap: true } }, lastUpdated: false, pagination: true, - // Measured on the ten queries of tools/search_gate.py, inside a range - // where all ten answer: Starlight's defaults (0.1, 0.1, 2, 9) let a guide - // that cites scph5501 once outrank the file's own card. A long page - // is penalised a little more, and how often a word appears counts. + // Measured on the queries of tools/search_gate.py, at the centre of a + // range where all of them answer. How often a word appears counts more + // than in Starlight's defaults (0.1, 0.1, 2, 9), a page's title weighs + // ten times its text, so "dolphin" finds Dolphin, and the file names of + // a system page (its `files` field, written by sitestarlight.py) count + // half, so "scph5501" finds the Sony page without a file name winning + // every other query. pagefind: { - ranking: { pageLength: 0.3, termFrequency: 0.5, termSaturation: 1.4, termSimilarity: 9 }, + ranking: { + pageLength: 0.1, + termFrequency: 0.5, + termSaturation: 1.4, + termSimilarity: 9, + metaWeights: { title: 10, files: 0.5 }, + }, }, plugins: [ starlightSidebarTopics(sidebar.topics, sidebar.options), diff --git a/website/src/components/MarkdownContent.astro b/website/src/components/MarkdownContent.astro index 44cb19e3..b0e31a6d 100644 --- a/website/src/components/MarkdownContent.astro +++ b/website/src/components/MarkdownContent.astro @@ -1,14 +1,15 @@ --- -// Search ranking: Pagefind multiplies the score of words inside a weighted -// element, so a Start here page outranks the reference page that names it. +// Search weight: Pagefind multiplies the score of words inside a weighted +// element, so a Start here page outranks the reference page that names it, +// and an emulator outranks its launcher or its frozen copy. import Default from '@astrojs/starlight/components/MarkdownContent.astro'; -const { boost } = Astro.locals.starlightRoute.entry.data; +const { searchWeight } = Astro.locals.starlightRoute.entry.data; --- { - boost ? ( - <div data-pagefind-weight={String(Math.min(10, boost * 2))}> + searchWeight ? ( + <div data-pagefind-weight={String(searchWeight)}> <Default><slot /></Default> </div> ) : ( diff --git a/website/src/content.config.ts b/website/src/content.config.ts index 15a91e1b..b926e385 100644 --- a/website/src/content.config.ts +++ b/website/src/content.config.ts @@ -12,8 +12,8 @@ export const collections = { // 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(), + // How much the site search weighs the page's words (1 by default). + searchWeight: z.number().min(0.1).max(10).optional(), // The date the page's content carries, for the sitemap. lastmod: z.string().optional(), }), diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css index 662d6298..2c00d08c 100644 --- a/website/src/styles/retrobios.css +++ b/website/src/styles/retrobios.css @@ -367,7 +367,8 @@ /* Collapsible blocks keep the callout colour of their role */ .sl-markdown-content details.rb-details { - border-inline-start: 4px solid var(--sl-color-blue); + border: 1px solid var(--sl-color-blue); + border-radius: 4px; background: var(--sl-color-blue-low); padding: 0.6rem 1rem; } diff --git a/website/tools/search_gate.py b/website/tools/search_gate.py index bc068d61..1a3a8a6a 100644 --- a/website/tools/search_gate.py +++ b/website/tools/search_gate.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""The search gate: ten queries a visitor types, and the pages that answer them. +"""The search gate: queries a visitor types, and the pages that answer them. -Runs measure.mjs against a served build and counts a query as answered when -one of its expected pages is in the first three results of the site's own -search box. +Runs measure.mjs against a served build, through the site's own search box, +and checks each query's expected pages against the rank it must reach: the +first result, or the first three. python website/tools/search_gate.py http://127.0.0.1:4321/retrobios/ """ @@ -17,21 +17,24 @@ from pathlib import Path TOOLS = Path(__file__).resolve().parent -# Query, then the pages that answer it (path prefixes under the site). -QUERIES: dict[str, tuple[str, ...]] = { - "playstation bios": ("systems/sony/",), - # The file itself: its card on the system page, or a core that reads it. - "scph5501": ("systems/sony/", "emulators/beetle_psx/", "emulators/duckstation/", - "emulators/duckstation-legacy/", "emulators/pcsx1/", "emulators/swanstation/", - "emulators/ares/", "emulators/pcsx_rearmed/", "files/"), - "batocera folder": ("platforms/batocera/",), - "neogeo.zip": ("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"), - "duckstation": ("emulators/duckstation/", "emulators/duckstation-legacy/"), - "steam deck": ("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), - "missing bios": ("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"), - "md5": ("wiki/verification-modes/",), - "retroarch system folder": ("platforms/retroarch/",), - "knulli": ("platforms/knulli/",), +# Query: (pages that answer it, as path prefixes under the site, and the rank one +# of them must reach). An emulator name must find the emulator itself first, +# not its launcher or a frozen copy. +QUERIES: dict[str, tuple[tuple[str, ...], int]] = { + "batocera folder": (("platforms/batocera/",), 1), + "missing bios": (("wiki/troubleshooting/", "wiki/what-are-bios-files/", "wiki/faq/"), 1), + "neogeo.zip": (("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"), 1), + "retroarch system folder": (("platforms/retroarch/",), 1), + "installer": (("wiki/installer/", "wiki/first-install/"), 1), + # The file's card on its system page, where it is downloaded. + "scph5501": (("systems/sony/",), 3), + "md5": (("wiki/verification-modes/",), 1), + "where do bios files go": (("which-pack/", "wiki/first-install/"), 1), + "dolphin": (("emulators/dolphin/",), 1), + "duckstation": (("emulators/duckstation/",), 1), + "playstation bios": (("systems/sony/",), 3), + "steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1), + "knulli": (("platforms/knulli/",), 1), } @@ -48,12 +51,14 @@ def main() -> int: if "query" not in result: print(f"first search: {result['first_search_kb']} KB in {result['first_search_requests']} requests") continue - top = [entry.split(" | ")[1][len(root):] for entry in result["top"][:3]] - hit = any(page.startswith(QUERIES[result["query"]]) for page in top) + pages, rank = QUERIES[result["query"]] + top = [entry.split(" | ") for entry in result["top"][:3]] + hit = any(href[len(root):].startswith(pages) for _title, href in top[:rank]) answered += hit - print(f"{'ok ' if hit else 'MISS'} {result['query']!r}: {', '.join(top)}") - print(f"{answered}/{len(QUERIES)} queries answered in the top 3") - return 0 if answered >= 9 else 1 + titles = "; ".join(title for title, _href in top) + print(f"{'ok ' if hit else 'MISS'} {result['query']!r} (top {rank}): {titles}") + print(f"{answered}/{len(QUERIES)} queries answered") + return 0 if answered == len(QUERIES) else 1 if __name__ == "__main__": diff --git a/website/tools/search_scores.mjs b/website/tools/search_scores.mjs index 8e3f9a69..4673be14 100644 --- a/website/tools/search_scores.mjs +++ b/website/tools/search_scores.mjs @@ -6,18 +6,18 @@ const [base, query, ranking] = process.argv.slice(2); const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' }); const page = await browser.newPage(); await page.goto(base, { waitUntil: 'networkidle' }); -const rows = await page.evaluate(async ({ root, query, ranking }) => { +const rows = await page.evaluate(async ({ root, query, ranking, top }) => { const pagefind = await import(`${root}pagefind/pagefind.js`); if (ranking) await pagefind.options({ ranking: JSON.parse(ranking) }); const search = await pagefind.search(query); - const top = search.results.slice(0, 8); - const data = await Promise.all(top.map((result) => result.data())); - return top.map((result, index) => ({ + const shown = search.results.slice(0, top); + const data = await Promise.all(shown.map((result) => result.data())); + return shown.map((result, index) => ({ score: result.score.toFixed(3), url: data[index].url, words: data[index].word_count, hits: result.words.length, })); -}, { root: base, query, ranking }); +}, { root: base, query, ranking, top: Number(process.env.TOP || 8) }); for (const row of rows) console.log(row.score, row.words, row.hits, row.url); await browser.close(); diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index d564b04c..384a04f5 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -652,7 +652,7 @@ This executes in sequence: 5. Consistency check: verify and the pack agree on counts and on the paths each archive holds 6. Pack integrity - extract ZIPs and verify hashes per platform mode 7. `generate_readme.py` - regenerate README and CONTRIBUTING -8. `generate_site.py` - regenerate documentation site and `mkdocs.yml` +8. `generate_site.py` - regenerate the documentation site and its navigation A new platform is archived-by-default nowhere: `status: active` puts it in `list_platforms.py`, the CI matrix, and the scrapers. Set diff --git a/wiki/architecture.md b/wiki/architecture.md index 102ad182..43afbff3 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -39,7 +39,8 @@ data/ cached data directories (not BIOS, fetched at build) schemas/ JSON schemas for platform and emulator YAML (checked in CI) tests/ test suite with synthetic fixtures wiki/ hand-written documentation sources -docs/ generated MkDocs site (gitignored, rebuilt in CI) +website/ Astro Starlight project; pages, data and navigation + are generated into it (gitignored, rebuilt in CI) _mame_clones.json MAME parent/clone set mappings database.json file index built from bios/ (SHA1 primary key) dist/ generated packs (gitignored) @@ -55,7 +56,8 @@ heading above the match. Every file card is therefore a level 3 heading with the card's anchor, under its console or a Files heading, and every platform system a heading of its own: a file name typed in the search opens that card, not the top of the page. On pages of cards (systems, emulators, platforms) the -table of contents stops at level 2 (`scripts/mkdocs_hooks.py`). +table of contents stops at level 2, and a page of cards under one or two +headings has none (`scripts/sitestarlight.py`). The browser downloads and indexes the whole search index before it shows a first result, so the index holds names and prose only. Hashes, lists of other @@ -450,7 +452,7 @@ pattern and how to add a test. | Workflow | File | Trigger | Role | |----------|------|---------|------| -| Deploy Site | `deploy-site.yml` | push to main on any input of the site build, and manual | validate contracts, generate site, build with MkDocs, validate rendered HTML, deploy to Pages | +| Deploy Site | `deploy-site.yml` | push to main on any input of the site build, and manual | validate contracts, generate site, build with Astro Starlight, validate rendered HTML, deploy to Pages | | Validation | `validate.yml` | pull request and push to main on the collection, profiles, schemas, scripts, tests, the installer and every input of the site build | schema check and full test suite on both, BIOS hash validation and auto-label on pull requests | Each workflow's `paths:` list is the exact set of files that starts it. diff --git a/wiki/community-tools.md b/wiki/community-tools.md index baa3bdb3..7718f7d7 100644 --- a/wiki/community-tools.md +++ b/wiki/community-tools.md @@ -21,6 +21,7 @@ See the project's [README](https://github.com/monster-penguin/BIOS-Preservation- --- -!!! note "Add your project" +:::note[Add your project] +Open an issue with a link to your repository. +::: - Open an issue with a link to your repository. diff --git a/wiki/faq.md b/wiki/faq.md index 78b03233..092a1cc6 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -104,7 +104,8 @@ and name the file. A file is removed when the owner of its rights asks. Files disappear when stores close, like the 3DS and Wii U shops in 2023. We keep them while they can still be found. -!!! tip "Next" +:::tip[Next] +- [Get your files](../which-pack.md) +- [Words used on this site](glossary.md) +::: - - [Get your files](../which-pack.md) - - [Words used on this site](glossary.md) diff --git a/wiki/first-install.md b/wiki/first-install.md index 7746aa0a..fb31ea97 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -15,39 +15,47 @@ From an empty BIOS folder to games that start. A **terminal** is a window where you type commands. Paste the line for your system, then press Enter. -=== "Windows" +<Tabs syncKey="os"> +<TabItem label="Windows"> - Open the Start menu. Type **PowerShell** and open it. +Open the Start menu. Type **PowerShell** and open it. - ```powershell - irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex - ``` +```powershell +irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex +``` -=== "Mac" +</TabItem> +<TabItem label="Mac"> - Open **Terminal**. It is in Applications, then Utilities. +Open **Terminal**. It is in Applications, then Utilities. - ```sh - curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh - ``` +```sh +curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh +``` -=== "Linux and Steam Deck" +</TabItem> +<TabItem label="Linux and Steam Deck"> - On a Steam Deck, switch to Desktop Mode and open **Konsole**. +On a Steam Deck, switch to Desktop Mode and open **Konsole**. - ```sh - curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh - ``` +```sh +curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh +``` -=== "Android" +</TabItem> +<TabItem label="Android"> - Install **Termux**, a terminal app. Run these three lines in it. +Install **Termux**, a terminal app. Run these three lines in it. + +```sh +pkg install python +termux-setup-storage +curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh +``` + +</TabItem> +</Tabs> - ```sh - pkg install python - termux-setup-storage - curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh - ``` On Batocera, Recalbox and other game systems, follow [your setup's page](../which-pack.md). @@ -145,8 +153,9 @@ drive. See [When a game does not start](troubleshooting.md). -!!! tip "Next" +:::tip[Next] +- [Your setup's page](../which-pack.md): its folder and how it checks files +- [When a game does not start](troubleshooting.md) +- Details: [Reading the installer's output, line by line](installer.md#reading-a-run) +::: - - [Your setup's page](../which-pack.md): its folder and how it checks files - - [When a game does not start](troubleshooting.md) - - Details: [Reading the installer's output, line by line](installer.md#reading-a-run) diff --git a/wiki/getting-started.md b/wiki/getting-started.md index 2d2cc6a5..ec50eee6 100644 --- a/wiki/getting-started.md +++ b/wiki/getting-started.md @@ -7,7 +7,7 @@ nav: false Three pages take you from nothing to games that start. -[Get your files](../which-pack.md){ .md-button .md-button--primary } +<a class="rb-button rb-button-primary" href="../which-pack.md">Get your files</a> ## What is a BIOS? diff --git a/wiki/glossary.md b/wiki/glossary.md index b7b11749..e19275e4 100644 --- a/wiki/glossary.md +++ b/wiki/glossary.md @@ -43,8 +43,9 @@ nav_title: Glossary **Game data** : Files from your own copy of a PC game, like Doom. Not a BIOS. -!!! tip "Next" +:::tip[Next] +- [What is a BIOS?](what-are-bios-files.md) +- [Get your files](../which-pack.md) +- Reference: [Terms of the reference pages](reference-terms.md) +::: - - [What is a BIOS?](what-are-bios-files.md) - - [Get your files](../which-pack.md) - - Reference: [Terms of the reference pages](reference-terms.md) diff --git a/wiki/legal.md b/wiki/legal.md index a0e282bf..f2524f56 100644 --- a/wiki/legal.md +++ b/wiki/legal.md @@ -41,7 +41,8 @@ This reflects the project's good-faith understanding, not legal advice. A rights holder, or anyone acting for one, can [open an issue](https://github.com/Abdess/retrobios/issues) identifying the file by path or hash. A file whose removal is requested by its rights holder is removed from the repository and from the next release. No formal notice is required and none will be demanded. State the file and the basis of the claim. The same channel is open to anyone else with a concern about a specific file. -!!! tip "Next" +:::tip[Next] +- [FAQ](faq.md) +- [What is a BIOS?](what-are-bios-files.md) +::: - - [FAQ](faq.md) - - [What is a BIOS?](what-are-bios-files.md) diff --git a/wiki/reference-terms.md b/wiki/reference-terms.md index ddcdde12..9ed2edfe 100644 --- a/wiki/reference-terms.md +++ b/wiki/reference-terms.md @@ -149,7 +149,8 @@ everyday words (BIOS, emulator, core, hash) are in the [glossary](glossary.md). takes a file from (the collection, a data folder, a release asset) is its source, not its provenance. -!!! tip "Next" +:::tip[Next] +- [How each platform checks files](verification-modes.md) +- [Glossary](glossary.md) +::: - - [How each platform checks files](verification-modes.md) - - [Glossary](glossary.md) diff --git a/wiki/release-process.md b/wiki/release-process.md index e6353421..7d366126 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -18,8 +18,8 @@ Budget target: ~175 minutes/month on the GitHub free tier. | Workflow | File | Trigger | |----------|------|---------| -| Deploy Site | `deploy-site.yml` | Push to main (platforms, emulators, provenance, wiki, scripts, database.json, mkdocs.yml), manual | -| Validation | `validate.yml` | PR and push to main touching `bios/**`, `platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`, `install.py`, `install.sh`, `install.ps1`, or any other input of the site build (`wiki/**`, `docs_assets/**`, `provenance/**`, `exports/**`, `install/**`, `database.json`, `release.json`, `derivable.json`, `mkdocs.yml`) | +| Deploy Site | `deploy-site.yml` | Push to main (platforms, emulators, provenance, wiki, scripts, database.json, website), manual | +| Validation | `validate.yml` | PR and push to main touching `bios/**`, `platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`, `install.py`, `install.sh`, `install.ps1`, or any other input of the site build (`wiki/**`, `docs_assets/**`, `provenance/**`, `exports/**`, `install/**`, `database.json`, `release.json`, `derivable.json`, `website/**`) | Upstream BIOS lists are not scraped on a schedule. A maintainer runs the scrapers by hand (see [adding a scraper](adding-a-scraper.md)), reviews the @@ -32,7 +32,8 @@ hosted runner should rebuild and re-upload. **Trigger.** Push to `main` when any of these paths change: `platforms/`, `emulators/`, `provenance/`, `wiki/`, `schemas/`, `tests/`, `docs_assets/`, -`install/`, `install.py`, `database.json`, `release.json`, `mkdocs.yml`, the +`install/`, `install.py`, `database.json`, `release.json`, `website/` (the +Starlight project: configuration, components, styles, lockfile), the workflow itself, and every script the build runs or imports. Also manual dispatch. @@ -45,19 +46,21 @@ is missing. ### Steps 1. Checkout, Python 3.12 -2. Install `pyyaml`, `jsonschema[format-nongpl]==4.26.0`, - `mkdocs>=1.6,<2`, `mkdocs-material>=9.7.5,<10`, - `pymdown-extensions>=10.14` +2. Install `pyyaml` and `jsonschema[format-nongpl]==4.26.0`, then Node 24 with + the official `actions/setup-node` (npm cache keyed on the lockfile) and + `npm ci --prefix website` 3. Run `validate_schemas.py`: the data contracts, checked before anything is generated (see below) 4. Restore large files from the `large-files` release, refresh data directories -5. Run `generate_site.py` (converts YAML data into MkDocs pages and rewrites - `mkdocs.yml`) +5. Run `generate_site.py`: it writes the pages, the navigation and the data + files into the Astro Starlight project in `website/` 6. Run `generate_readme.py` (rebuilds README.md and CONTRIBUTING.md) -7. `mkdocs build --strict` to produce the static site -8. Run `validate_site.py` on the rendered HTML (metadata, headings, image - alternatives, duplicate ids, local links and fragments, page and search - index budgets) +7. `npm run build --prefix website` to produce the static site in + `website/dist/`. The build runs `starlight-links-validator` with its + strict defaults, so a dead link or anchor fails it +8. Run `validate_site.py --site-dir website/dist` on the rendered HTML + (metadata, headings, image alternatives, duplicate ids, every local link + and fragment again, page and search index budgets) 9. Require the committed README and CONTRIBUTING to match what the generator just produced. `write_if_changed()` compares content with the timestamp line stripped, so a run that only moves the clock leaves the files untouched and @@ -77,14 +80,18 @@ The site is deployed via the `github-pages` environment using the official cancelled (`cancel-in-progress: false`): cancelling one mid-flight leaves the deployment stuck and the next runs time out waiting on it. -`--strict` turns MkDocs warnings into failures, so a broken internal link or a -dangling anchor fails the build instead of shipping. The `validation:` block in -`mkdocs.yml` is what promotes unrecognized links and missing anchors to -warnings in the first place. +Two link checks run, and both fail the build: Starlight's validator inside +`astro build`, and `validate_site.py` over the rendered files. They read +different things (the content collection, the final HTML with its navigation +and breadcrumbs), and each has caught what the other passed. -The theme version is pinned on both sides: `>=9.7.5` because that is the -release which caps `mkdocs < 2` (MkDocs 2.0 ships without a license), `<10` -so a major theme release cannot change the site without a deliberate bump. +Astro, Starlight and their plugins are pinned to exact versions in +`website/package.json`, resolved from the committed `website/package-lock.json` +by `npm ci`. Starlight is below 1.0 and Astro shipped two majors in 2026, so an +upgrade is its own change, built and measured with the scripts in +`website/tools/` (search queries, Lighthouse, axe, screenshots) before it +merges. Node runs only inside the site build: `install.py` and the build +scripts stay Python. ## validate.yml - Validation @@ -119,7 +126,7 @@ declares while schemas and tests pass it. This job confronts it with its upstream. An edited profile is not held to it. **run-tests.** Runs `python -m unittest discover -s tests -t . -v`, then builds the -site as Deploy Site does (`generate_site.py`, `mkdocs build --strict`, +site as Deploy Site does (`generate_site.py`, `npm run build --prefix website`, `validate_site.py`) whatever the suite returned. Must pass before a merge, and again on the commit a direct push puts at the head of main. @@ -257,7 +264,7 @@ python scripts/check_release.py --tag "v${DATE}" --sums dist/SHA256SUMS.txt \ # and pack_size describe the packs just built. git status names anything # else the run rewrote. git status --short -git add release.json README.md mkdocs.yml install/*.json install/targets/*.json +git add release.json README.md install/*.json install/targets/*.json git commit -m "chore: record release v${DATE}" && git push # 6. Keep only the new release plus large-files: an older pack carries hashes diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index 45d027e7..9b9adaf6 100644 --- a/wiki/technical-faq.md +++ b/wiki/technical-faq.md @@ -143,7 +143,8 @@ Running `verify.py --emulator <core> --verbose` shows the emulator-level ground It reaches only as far as the profiles do. Files no profile states a value for are platform-only, and nothing can be said about their content. The footer of the platform report gives that ratio as `Ground truth: N/M files have emulator validation`. -!!! tip "Next" +:::tip[Next] +- [Reference index](index.md), every reference page in one list +- For contributors: [How to contribute](../contributing.md) +::: - - [Reference index](index.md), every reference page in one list - - For contributors: [How to contribute](../contributing.md) diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index f0e45fa5..537f7238 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -287,7 +287,23 @@ The test suite is one layer of verification. The full quality gate is: 3. No unexpected CRITICAL entries in the verify output 4. Pack file counts match verification file counts (consistency check) 5. Pack integrity passes (every declared file extractable with correct hash) -6. `mkdocs build --strict` and `python scripts/validate_site.py` pass +6. `npm run build --prefix website` and `python scripts/validate_site.py` pass + +A change to the site engine, its version, its search ranking or the page +weights is measured on a served build before it lands, with the scripts in +`website/tools/` (they need `npm install --no-save playwright-core axe-core +lighthouse` and Chrome): + +- `search_gate.py`: the queries a visitor types and the page that must answer + each, read from the site's own search box +- `lighthouse.py`: Lighthouse mobile on a list of pages, the heaviest ones + included +- `axe.mjs`: WCAG 2.2 AA in light and dark +- `check_tools.mjs`: the hash lookup, the file picker, a hash typed in the + search, breadcrumbs, previous and next across tabs, glossary terms, tables + on a phone and the 404 page, in a real browser +- `serve.mjs`: the build served under /retrobios/ with gzip, as GitHub Pages + serves it A change that passes the tests but breaks the pipeline is not merged. A new CRITICAL entry in the verify output after a change is explained before the @@ -380,7 +396,7 @@ nav_title: Save files # only when the menu names it otherwise than its headin - `level` picks the tab and the label shown at the top of the page. - `order` sorts the tab. Pages are spaced by ten, so a new page fits between two without renumbering. The generated pages (systems, emulators, the gaps - and data pages) hold fixed places among them, set in `generate_mkdocs_nav`. + and data pages) hold fixed places among them, set in `generate_nav`. - `nav_title` is the menu label. Without it the menu shows the page's `# ` heading. It is not `title`, which both MkDocs and Astro Starlight read as the page's own title. @@ -419,7 +435,7 @@ The `validate.yml` workflow runs `python -m unittest discover -s tests -t . -v` roads into main: every pull request, and every direct push, that touches `bios/`, `platforms/`, `emulators/`, `schemas/`, `scripts/`, `tests/`, `install.py` or an input of the site build such as `wiki/`. The same job then -builds the site with `mkdocs build --strict`, so a page that would fail the +builds the site with `npm run build --prefix website`, so a page that would fail the deploy fails the pull request. Work reaches main by push as often as by pull request, so a suite wired to pull requests alone would guard the road nobody takes. The test job (`run-tests`) runs in parallel with schema validation, and on a pull request diff --git a/wiki/tools.md b/wiki/tools.md index d85d1a1c..c84eabb8 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -51,7 +51,7 @@ Pipeline steps: | 6 | Pack integrity (extract + hash) | `--skip-packs` | | 7 | Generate README | `--skip-docs` | | 8 | Generate site | `--skip-docs` | -| 8b | Render the site with `mkdocs build --strict`, then `validate_site.py`, as the deploy does | `--skip-docs`, or no `mkdocs` installed | +| 8b | Render the site with `npm run build --prefix website`, then `validate_site.py`, as the deploy does | `--skip-docs`, or Node not set up (`npm ci --prefix website`) | Pipeline flags: @@ -692,7 +692,7 @@ python scripts/refresh_stale.py --only platforms,targets --jobs 6 | `provenance_report.py` | Dump-catalog coverage and acquisition targets (see above) | | `find_pinned_revision.py` | Walk the upstream commits that touched a file and return the revision whose bytes match a platform's pinned md5 (`--write` saves them) | | `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database | -| `generate_site.py` | Generate all MkDocs site pages (this documentation) | +| `generate_site.py` | Generate the site (this documentation) into the Starlight project in `website/` | | `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets | | `romset_recipes.py` | Identify which emulator version an arcade archive matches, and rebuild a pinned archive from ROMs already held | | `scraper/romset_dat_importer.py` | Fetch and import per-set recipes from MAME `-listxml` or FBNeo DATs into `recipes/`. `--fetch` alone takes the newest MAME release, and FBNeo DATs are refreshed by git blob sha. `--fetch <revision>` reads FBNeo at that tag, branch or commit | @@ -732,7 +732,7 @@ Further options, by script: | `split_pack.py` | `--max-size 1900M`, the ceiling per part (K, M, G, default 2G) | | `torrentzip.py` | `--check ZIP` reports conformance, `--rebuild ZIP -o OUT` normalizes an archive | | `validate_pr.py` | `--changed` takes the BIOS files the branch changes, `--markdown`, `--json` | -| `validate_site.py` | `--site-dir` (rendered site), `--config` (mkdocs.yml), `--max-errors N` | +| `validate_site.py` | `--site-dir` (rendered site, default `website/dist`), `--base` (path the site is served under), `--max-errors N` | | `scraper/coreinfo_scraper.py` | `--compare-db database.json` lists the `.info` firmware the database lacks | | `scraper/mame_hash_scraper.py` | `--release mameNNNN --profile FILE --source-commit SHA --core NAME --built-by PLATFORM` writes the profile of a MAME build a platform compiles. `--devices --listxml FILE` documents in `mame.yml` every device set with dumped ROMs that the release's `-listxml` names (`isdevice="yes"`), with `--dry-run` to preview | | `scraper/romset_dat_importer.py` | `--cache-dir` (downloaded listxml and DATs) | diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index fb1827e8..966f2df4 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -78,7 +78,8 @@ Other messages: Details: [When something goes wrong](installer.md#when-something <span id="hash-mismatch-untested"></span><span id="pack-is-missing-files"></span><span id="verifypy-reports-errors"></span> -!!! tip "Next" +:::tip[Next] +- [Your setup's page](../which-pack.md): its folder and how it checks files +- [FAQ](faq.md) +::: - - [Your setup's page](../which-pack.md): its folder and how it checks files - - [FAQ](faq.md) diff --git a/wiki/what-are-bios-files.md b/wiki/what-are-bios-files.md index 149fdf37..87427f84 100644 --- a/wiki/what-are-bios-files.md +++ b/wiki/what-are-bios-files.md @@ -9,7 +9,7 @@ nav_title: What BIOS files are A console has a small program inside it. It starts first, then starts the game. This program is the **BIOS**, also called **firmware**. -[Get your files](../which-pack.md){ .md-button .md-button--primary } +<a class="rb-button rb-button-primary" href="../which-pack.md">Get your files</a> An **emulator** is an app that acts like a console. For many consoles, it needs a copy of the BIOS. That copy is a file: a **BIOS file**. @@ -59,7 +59,8 @@ then glitch or do not start. The real file works best, so we include it. Laws differ by country. [Read the project's position](faq.md#is-this-legal). -!!! tip "Next" +:::tip[Next] +- [Get your files](../which-pack.md) +- [Words used on this site](glossary.md) +::: - - [Get your files](../which-pack.md) - - [Words used on this site](glossary.md)