diff --git a/scripts/generate_site.py b/scripts/generate_site.py index fd3db8ea..c3414d0a 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -2637,9 +2637,12 @@ def generate_files_page() -> str: return "\n".join([ "# Find a file", "", - "Paste a hash from clrmamepro, RomVault, an emulator log or a platform's " - "BIOS list. The page lists the file in the collection that has it, the " - "platforms that declare it and the emulators that check for it.", + "A hash is a fingerprint of a file's contents ([Glossary](../wiki/glossary.md)). " + "Tools such as clrmamepro and RomVault print one, and so do emulator logs " + "and the BIOS lists of setups.", + "", + "Paste it below. The page shows the file in the collection that has it, the " + "setups that list it and the emulators that check for it.", "", '
', ' ', @@ -2659,8 +2662,8 @@ def generate_files_page() -> str: "", "A file's card lives on its manufacturer's page, and a file refiled " "between manufacturers moves with it. `files/?sha1=` is the address " - "that does not move: it opens the card wherever it is now. Each card's " - "**permalink** is that address.", + "that does not move: it opens the card wherever it is now. The link icon " + "beside each card's name is that address.", "", "## The index behind it", "", @@ -2893,7 +2896,22 @@ def _group_by_manufacturer(db: dict) -> dict[str, dict[str, list]]: return manufacturers +def _console_display(manufacturer: str, console: str) -> str: + """A console section's name as the reader looks for it: the heading + system_name gives its id, or the folder alone under a program's top + (Other, Arcade), where "Other mame" would name nothing.""" + from siterender import _PROGRAM_TOPS, _heading_name # noqa: PLC0415 + + return console if manufacturer in _PROGRAM_TOPS else _heading_name(manufacturer, console) + + def generate_systems_index(manufacturers: dict) -> str: + """Every console in one table, found by its own name. + + The index listed 74 manufacturers and no console: someone looking for + PlayStation had to know it is filed under Sony, and Other hid 91 + sections. Manufacturer pages stay the containers. + """ total_mfr = len(manufacturers) total_consoles = sum(len(c) for c in manufacturers.values()) # A copy filed under two folders appears on both pages, counted once here. @@ -2905,38 +2923,43 @@ def generate_systems_index(manufacturers: dict) -> str: lines = [ "# Systems", "", - "Every BIOS file the collection holds, by manufacturer and console. Open " - "a console to see each file, the emulators that read it and the setups " - "that list it.", + "Every BIOS file the collection holds, by console. Open a console to " + "see each file, the emulators that read it and the setups that list it.", "", - f"{total_mfr} manufacturers, {total_consoles} consoles, " - f"{total_files:,} files in the repository.", + f"{_count(total_consoles, 'console')}, {_count(total_files, 'file')} in the " + f"repository, from {_count(total_mfr, 'manufacturer')}.", "", ] - machines = [m for m in sorted(manufacturers, key=str.casefold) if m not in GAME_DATA_TOPS] - games = [m for m in sorted(manufacturers, key=str.casefold) if m in GAME_DATA_TOPS] - for heading, names in (("Consoles and computers", machines), - ("Game engines and PC games", games)): - if not names: + for heading, games, first in (("Consoles and computers", False, "Console"), + ("Game engines and PC games", True, "Name")): + rows = sorted( + ((_console_display(mfr, console), mfr, console, len(files)) + for mfr, consoles in manufacturers.items() + if (mfr in GAME_DATA_TOPS) == games + for console, files in consoles.items()), + key=lambda row: (row[0].casefold(), row[1].casefold(), row[2]), + ) + if not rows: continue lines += [ f"## {heading}", "", - "| Manufacturer | Consoles | Files |", - "|-------------|----------|-------|", + f"| {first} | Manufacturer | Files |", + "|---------|--------------|------:|", ] - for mfr in names: - consoles = manufacturers[mfr] - file_count = sum(len(files) for files in consoles.values()) - lines.append(f"| [{mfr}]({_slug(mfr)}.md) | {len(consoles)} | {file_count} |") + for name, mfr, console, count in rows: + cell = name.replace("|", "-") + anchor = _slugify_anchor(console) + href = f"{_slug(mfr)}.md" + (f"#{anchor}" if anchor else "") + lines.append(f"| [{cell}]({href}) | [{mfr}]({_slug(mfr)}.md) | {count:,} |") lines.append("") - if games: - lines += [ - "Game engines and PC games hold the files a game engine reads, such " - "as the data of a game, rather than the BIOS of a machine.", - "", - ] + if games: + lines += [ + "Game engines and PC games hold the files a game engine reads, such " + "as the data of a game, rather than the BIOS of a machine.", + "", + ] return "\n".join(lines) + "\n" @@ -3215,21 +3238,46 @@ def _fetch_url(path: str) -> str: ) -def _platform_links(sha1: str, consumers: FileConsumers) -> list[str]: - """Each platform declaring the file: a link to its system section, and +_platform_names: dict[str, str] = {} + + +def _platform_display(name: str) -> str: + """A setup's name as its platform file writes it: Batocera, not batocera.""" + display = _platform_names.get(name) + if display is None: + try: + config = load_platform_config(name, str(_REPO_ROOT / "platforms")) + except FileNotFoundError: + config = {} + display = _platform_names[name] = str(config.get("platform") or name) + return display + + +# A setup, the link to its page, and where it puts the file. +Place = tuple[str, str, list[str]] + + +def _platform_places(sha1: str, consumers: FileConsumers) -> list[Place]: + """Each platform declaring the file, linked to its system section, with the destination it gives the file.""" systems_of = getattr(consumers, "platform_systems", {}).get(sha1, {}) destinations_of = getattr(consumers, "platform_destinations", {}).get(sha1, {}) - links: list[str] = [] - for platform in sorted(consumers.platforms.get(sha1, ())): + places: list[Place] = [] + for platform in consumers.platforms.get(sha1, ()): section = sorted(systems_of.get(platform, ())) fragment = f"#{section[0].replace(' ', '-')}" if section else "" - link = f"[{platform}](../platforms/{platform}.md{fragment})" - destinations = sorted(destinations_of.get(platform, ())) - if destinations: - link += " " + ", ".join(_code(d) for d in destinations) - links.append(link) - return links + places.append((_platform_display(platform), f"../platforms/{platform}.md{fragment}", + sorted(destinations_of.get(platform, ())))) + return sorted(places, key=lambda place: place[0].casefold()) + + +def _platform_links(sha1: str, consumers: FileConsumers) -> list[str]: + """Each platform declaring the file, as Markdown: its name, linked to + its system section, then the destination it gives the file.""" + return [ + f"[{name}]({href})" + (": " + ", ".join(_code(d) for d in dests) if dests else "") + for name, href, dests in _platform_places(sha1, consumers) + ] _card_anchors: dict[str, tuple[list, list[str]]] = {} @@ -3263,16 +3311,60 @@ def _emulator_links(sha1: str, consumers: FileConsumers) -> list[str]: return links -def _installed_links(sha1: str, consumers: FileConsumers, declared: set[str]) -> list[str]: +def _installed_places(sha1: str, consumers: FileConsumers, declared: set[str]) -> list[Place]: """Each pack installing the file that its platform's list does not name, with the place the pack gives it.""" - links = [] - for platform, dests in sorted(getattr(consumers, "installed", {}).get(sha1, {}).items()): - if platform in declared: - continue - where = ", ".join(_code(d) for d in sorted(dests) if d) - links.append(f"[{platform}](../platforms/{platform}.md)" + (f" {where}" if where else "")) - return links + places = [ + (_platform_display(platform), f"../platforms/{platform}.md", sorted(d for d in dests if d)) + for platform, dests in getattr(consumers, "installed", {}).get(sha1, {}).items() + if platform not in declared + ] + return sorted(places, key=lambda place: place[0].casefold()) + + +def _places_text(places: list[Place]) -> str: + """Setups as "Lakka: scph1000.bin", the name linked to its page and the + destination as text: a code chip per path made the line a row of + boxes.""" + def plain(text: str) -> str: + return html.escape(text, quote=False).replace("`", "`") + + return ", ".join( + f"[{name}]({href})" + (": " + " and ".join(plain(d) for d in dests) if dests else "") + for name, href, dests in places + ) + + +def _inline_card_html(text: str) -> str: + """A card value written in Markdown (code spans, links), as the HTML a + fact list holds. HTML already in the value is kept.""" + out: list[str] = [] + for part in re.split(r"(`[^`]*`)", text): + if len(part) > 1 and part.startswith("`") and part.endswith("`"): + out.append(f"{html.escape(part[1:-1], quote=False)}") + else: + out.append(re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", r'\1', part)) + return "".join(out) + + +# A link icon, the way a heading anchor is drawn: the word "permalink" was +# read as part of every card title, by the search and by screen readers. +# Drawn once per page and used by each card: inline, its 400 bytes were +# 100 KB on the Sony page. +PERMALINK_SYMBOL = ( + '' +) +_PERMALINK_ICON = ('') + + +def _icon_symbol() -> list[str]: + """The icon's drawing, for a page of cards. None in the yardstick.""" + return [PERMALINK_SYMBOL, ""] if markup.starlight() else [] def _system_file_card( @@ -3291,6 +3383,7 @@ def _system_file_card( # Who reads this file, not who reads a file of this name plats = sorted(consumers.platforms.get(sha1_full, ())) emus = sorted(consumers.emulators.get(sha1_full, ())) + installed = _installed_places(sha1_full, consumers, set(plats)) anchor = f"sha1-{sha1_full[:12]}" if sha1_full != "unknown" else "" # A copy filed under two consoles of one maker is listed twice on its @@ -3300,56 +3393,84 @@ def _system_file_card( anchor = "" else: anchored.add(anchor) + skip = markup.search_exclude() 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. + # The heading's own link is this page's, the icon is the file's own + # address, resolved by content wherever its card moves. + # The yardstick keeps the word, the size the page budget was set on. + icon = _PERMALINK_ICON if markup.starlight() else "permalink" permalink = ( f' permalink' + f'aria-label="Permanent link to {html.escape(name)}" ' + f'title="Permanent link, by content" {skip}>{icon}' ) if anchor else "" + # A file nothing reads said so nowhere: 53 of 248 Sony cards had no + # emulator, platform or pack line and looked unfinished. + unread = (f' No reader' + if not (emus or plats or installed) else "") lines.append(_card_title( - f'{_code(name)} {size}{permalink}', + f'{_code(name)} {size}{unread}{permalink}', anchor, searchable, )) lines.append("") - # Every hash in full: the site search indexes text, not a title - # attribute, so a pasted md5 or crc32 (what clrmamepro, RomVault or an - # emulator log prints) found nothing behind a 12-character prefix. - 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}: {markup.hash_value(value)}') - if emus: - lines.append(f"- Emulators: {', '.join(_emulator_links(sha1_full, consumers))}") - provenance = f.get("provenance", {}) - if provenance: - lines.extend(_provenance_lines(provenance, "../")) - if f.get("path"): - # The card named a file, its hashes and provenance, but not where to - # get it: a user missing scph5501.bin found the reference and no way - # to the bytes. - lines.append(f"- Collection path: {_code(f['path'])} ([download]({_fetch_url(f['path'])}))") + + # Each fact: its label, its values, whether the search reads it. What a + # setup does with the file comes first, the fingerprints last. + facts: list[tuple[str, list[str], bool]] = [] if plats: # Each link lands on the platform's system section, and says where - # that platform puts the file: from the card, retrodeck sent the - # reader to the top of a 2,000-file page. - lines.append(f"- Platforms: {', '.join(_platform_links(sha1_full, consumers))}") - installed = _installed_links(sha1_full, consumers, set(plats)) + # that platform puts the file. The site search reads this line: 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. + facts.append(("On the setup's list", + [_places_text(_platform_places(sha1_full, consumers))], True)) if installed: # The packs that carry the file for their cores: a RetroBat user # 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. 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:])] + facts.append(("Packed for its emulators by", [_places_text(installed)], False)) + if emus: + facts.append(("Emulators", [", ".join(_emulator_links(sha1_full, consumers))], False)) + if f.get("path"): + # The card named a file, its hashes and provenance, but not where to + # get it: a user missing scph5501.bin found the reference and no way + # to the bytes. + facts.append(("Collection path", + [f"{_code(f['path'])} ([download]({_fetch_url(f['path'])}))"], False)) + catalogued = [line.split(": ", 1)[1] + for line in _provenance_lines(f.get("provenance") or {}, "../")] + if catalogued: + facts.append(("Catalogued", catalogued, False)) + # Every hash in full: the site search indexes text, not a title + # attribute, so a pasted md5 or crc32 (what clrmamepro, RomVault or an + # emulator log prints) found nothing behind a 12-character prefix. + # files/?hash= finds them, and the index skips them. + for label, value in (("SHA1", sha1_full), ("MD5", md5_full), + ("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))): + if value and value != "unknown": + facts.append((label, [f'{html.escape(str(value))}'], + False)) + + if not markup.starlight(): + # The size yardstick pages are cut by: the list a card was when the + # budget was set, so a fact grid moves no card to another page. + lines.extend(search_excluded([f"- {label}: {', '.join(values)}" + for label, values, _searched in facts])) else: - lines[4:] = search_excluded(lines[4:]) + # A grid of facts, label then value: two bullet lists of code chips + # read as a dump. What the search skips is one list with one + # attribute: on each row it was 77 KB of the Sony page. + read = [fact for fact in facts if fact[2] and searchable] + skipped = [fact for fact in facts if fact not in read] + for group, attributes in ((read, ""), (skipped, f" {skip}")): + if group: + lines.append(f'
') + # One value cell per label: the grid pairs each dt with one dd. + lines.extend(f"
{label}
" + + "
".join(_inline_card_html(v) for v in values) + "
" + for label, values, _searched in group) + lines.append("
") lines.extend(["", "", ""]) return lines @@ -3502,24 +3623,144 @@ SYSTEM_PAGE_BUDGET = 500_000 Card = tuple[bool, list[str], str] +def _reading_entries(sha1: str, consumers: FileConsumers) -> list[dict]: + """The profile entries that read a file, one per emulator.""" + files_of = getattr(consumers, "emulator_files", {}) + entries = [] + for emulator, index in getattr(consumers, "emulator_entries", {}).get(sha1, {}).items(): + files = files_of.get(emulator) or [] + if 0 <= index < len(files) and isinstance(files[index], dict): + entries.append(files[index]) + return entries + + +_NO_PRIORITY = 1 << 30 + + +def _card_rank(f: dict, consumers: FileConsumers) -> tuple: + """Where a file's card sits in its console: the files an emulator + requires first, then by the emulators' search order (smallest first), + then by how many setups list the file, then by name. + + By byte order the PlayStation section opened on LibPS.exe and + PSX_DTLH2000.bin, capitals first, and scph5501.bin was 28th. + """ + sha1 = str(f.get("sha1", "")) + entries = _reading_entries(sha1, consumers) + priorities = [e["priority"] for e in entries if isinstance(e.get("priority"), int)] + return ( + not any(e.get("required", True) for e in entries), + min(priorities, default=_NO_PRIORITY), + -len(consumers.platforms.get(sha1, ())), + f["name"].casefold(), f["name"], f.get("path", ""), + ) + + +def _by_name(f: dict) -> tuple: + return f["name"].casefold(), f["name"], f.get("path", "") + + def _console_cards( manufacturer: str, files: list[dict], consumers: FileConsumers, anchored: set[str], + by_name: bool = False, ) -> list[Card]: - """Each card of one console, primaries by name then variants, with - whether it is a variant.""" + """Each card of one console, primaries then variants, with whether it + is a variant. ``by_name`` orders by name alone, for a console cut into + parts named by their first and last file.""" searchable = manufacturer not in GAME_DATA_TOPS + order = _by_name if by_name else (lambda f: _card_rank(f, consumers)) main_files = [f for f in files if "/.variants/" not in f["path"]] variant_files = [f for f in files if "/.variants/" in f["path"]] cards = [(False, _system_file_card(f, consumers, anchored, searchable), str(f.get("sha1", ""))) - for f in sorted(main_files, key=lambda x: x["name"])] + for f in sorted(main_files, key=order)] cards += [(True, _system_file_card(v, consumers, anchored, searchable), str(v.get("sha1", ""))) - for v in sorted(variant_files, key=lambda x: (x["name"], x["path"]))] + for v in sorted(variant_files, key=order)] return cards +# A "Start with" line names this many files at most: Game Engines' +# TheXTech requires 2,014. +START_WITH_NAMES = 6 + + +def _region_order() -> dict[str, int]: + from region import REGION_TREE, WORLD # noqa: PLC0415 + + ordered = [WORLD] + for parent, members in REGION_TREE.items(): + ordered += [parent, *sorted(members)] + return {region: index for index, region in enumerate(ordered)} + + +_REGION_WORDS = {"uk": "UK", "asia-ntsc": "Asia (NTSC)", "asia-pal": "Asia (PAL)"} + + +def _region_word(region: str) -> str: + return _REGION_WORDS.get(region) or region.replace("-", " ").title() + + +def _start_with(files: list[dict], consumers: FileConsumers, first_below: bool = True) -> list[str]: + """The line a console section opens on: the files an emulator requires, + by the region the emulator picks each for. + + Read from the profiles' required and region fields, never typed per + system. A section where no emulator requires a file gets no line. + """ + from region import _PARENT # noqa: PLC0415 + + order = _region_order() + by_region: dict[str, list[str]] = {} + plain: list[str] = [] + primaries = [f for f in files if "/.variants/" not in f["path"]] + for f in sorted(primaries, key=lambda f: _card_rank(f, consumers)): + required = [e for e in _reading_entries(str(f.get("sha1", "")), consumers) + if e.get("required", True)] + if not required: + continue + regions = {str(r) for e in required for r in e.get("region") or []} + for names in [by_region.setdefault(r, []) for r in regions] or [plain]: + if f["name"] not in names: + names.append(f["name"]) + # A territory its region answers the same way adds nothing: Brazil + # under Latin America. + by_region = {r: names for r, names in by_region.items() + if by_region.get(_PARENT.get(r, "")) != names} + # Regions that need the same files share a line. + regional: dict[tuple[str, ...], list[str]] = {} + for region in sorted(by_region, key=lambda r: (order.get(r, len(order)), r)): + names = by_region[region] + same = next((rs for rs, held in regional.items() if held == names), None) + if same is None: + regional[(region,)] = names + else: + regional[(*same, region)] = regional.pop(same) + if not regional and not plain: + return [] + + def named(names: list[str], lead: str) -> str: + if len(names) <= START_WITH_NAMES: + return f"{lead}: {', '.join(_code(n) for n in names)}" + where = ", first below" if first_below else "" + return f"{lead}: {_count(len(names), 'more file' if regional else 'file')}{where}" + + if not regional: + if len(plain) > START_WITH_NAMES: + where = " They come first below." if first_below else "" + return [f"**Start with** the {_count(len(plain), 'file')} an emulator requires.{where}", + ""] + return [named(plain, "**Start with**") + ".", ""] + lines = ["**Start with** the BIOS for the region of your games:", ""] + for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs)): + lead = ", ".join(_region_word(r) for r in regions) + lines.append(f"- **{lead}**: {', '.join(_code(n) for n in regional[regions])}") + if plain: + lines.append(named(plain, "- **Some emulators also require**")) + return [*lines, ""] + + def _cards_markdown(cards: list[Card]) -> list[str]: """Primaries, then the variants in a closed block.""" lines = [line for variant, card, _sha1 in cards if not variant for line in card] @@ -3548,6 +3789,17 @@ def _parts(cards: list[Card], budget: int, sizes: list[int] | None = None) -> li return parts +def _console_heading(manufacturer: str, console: str) -> str: + """A console's heading: its icon hidden from screen readers, then the + name with no space before it. The space read as "Section titled + “ PlayStation”" on five of seven Sony headings.""" + icon = _icon_name(manufacturer, console) + if not _icon_available.get(icon): + return f"## {console}" + image = markup.image("", _icon_url(icon), width="24", **{"aria-hidden": "true"}) + return f"## {image}{console}" + + _NEXT_FROM_SYSTEMS = [ "[Get your files](../which-pack.md), for the setup you play on", "[When a game does not start](../wiki/troubleshooting.md)", @@ -3587,15 +3839,22 @@ def system_pages( "first, the others under \"Variants\". To install the files of one " "console, open its \"Install\" box." ) + # The two setup lines of a card differ, and only a code comment said how. + key = ( + "On a card, **On the setup's list** names the setups whose own BIOS " + "list has the file. **Packed for its emulators by** names the setups " + "whose pack adds it because one of their emulators reads it." + ) def whole_page(sections: dict[str, list[Card]]) -> str: lines = [ - f"# {manufacturer}", "", intro, "", + f"# {manufacturer}", "", intro, "", key, "", f"The files below by console, with every hash, are also published as {json_link}.", - "", + "", *_icon_symbol(), ] for console, cards in sections.items(): - lines.append(f"## {system_icon_markdown(manufacturer, console)}{console}") + lines.append(_console_heading(manufacturer, console)) lines.append("") + lines.extend(_start_with(consoles[console], consumers)) lines.extend(_console_install_block(console, (installs or {}).get(console, []))) lines.extend(_cards_markdown(cards)) lines.append("") @@ -3630,16 +3889,17 @@ def system_pages( pages: dict[str, tuple[str, dict[str, list]]] = {} summary = [ - f"# {manufacturer}", "", intro, "", + f"# {manufacturer}", "", intro, "", key, "", f"The files below by console, with every hash, are also published as {json_link}. " f"The largest sections have a page of their own, linked from their heading.", - "", + "", *_icon_symbol(), ] summary_anchors: set[str] = set() for console in sorted(consoles): - summary.append(f"## {system_icon_markdown(manufacturer, console)}{console}") + summary.append(_console_heading(manufacturer, console)) summary.append("") if console not in moved: + summary.extend(_start_with(consoles[console], consumers)) summary.extend(_console_install_block(console, (installs or {}).get(console, []))) summary.extend(_cards_markdown( _console_cards(manufacturer, consoles[console], consumers, summary_anchors))) @@ -3647,11 +3907,18 @@ def system_pages( continue # Cards are rendered again for their own page: the anchors a page # holds are its own. - cards = _console_cards(manufacturer, consoles[console], consumers, set()) - 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) + def cut(by_name: bool) -> list[list[Card]]: + cards = _console_cards(manufacturer, consoles[console], consumers, set(), by_name) + with markup.using("mkdocs"): + sizes = [card_weight(card) for _variant, card, _sha1 in _console_cards( + manufacturer, consoles[console], consumers, set(), by_name)] + return _parts(cards, budget, sizes) + + # One page keeps the required files first. Parts are named by their + # first and last file, so a console cut in parts goes by name. + parts = cut(False) + if len(parts) > 1: + parts = cut(True) 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, [ @@ -3659,6 +3926,7 @@ def system_pages( ]) title = f"{manufacturer}: {console}" count = _count(len(consoles[console]), "file") + summary.extend(_start_with(consoles[console], consumers, first_below=False)) if len(stems) == 1: summary.append(f"{count}, on [their own page]({stems[0]}.md).") else: @@ -3673,12 +3941,15 @@ def system_pages( f"# {heading}", "", f"Part of [{manufacturer}]({slug}.md), in [Systems](index.md). " f"Every hash of these files is also published as {json_link}.", - "", + "", *_icon_symbol(), ] if len(stems) > 1: others = ", ".join( f"[part {i + 1}]({other}.md)" for i, other in enumerate(stems) if i != index) lines.extend([f"This part holds {_part_range(part)}. The others: {others}.", ""]) + if index == 0: + lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1)) + lines.extend([key, ""]) lines.extend(_console_install_block(console, (installs or {}).get(console, []))) lines.extend(["## Files", ""]) lines.extend(_cards_markdown(part)) diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 9fa932fc..f0e5f7c5 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -4963,7 +4963,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}', text) diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index 2357731f..78c3a179 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -358,10 +358,11 @@ class AVariantShowsWhoPinsIt(unittest.TestCase): page = generate_site.generate_system_page( "Entex", {"Adventure Vision": [primary, variant]}, consumers) block = page[page.index("Variants (1)"):] - 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("
Collection path
bios/Entex/Adventure Vision/" + ".variants/advision.zip.1c086064", block) + self.assertIn('Batocera', block) + self.assertIn('RetroBat', block) + self.assertIn('' + "c" * 40 + "", block) self.assertIn("Catalogued", block) @@ -461,7 +462,7 @@ class ASystemCardLandsOnThePlatformsSection(unittest.TestCase): ) self.assertEqual( _platform_links("s", consumers), - ["[retrodeck](../platforms/retrodeck.md#sony-playstation) `bios/scph5000.bin`"], + ["[RetroDECK](../platforms/retrodeck.md#sony-playstation): `bios/scph5000.bin`"], ) @@ -745,7 +746,7 @@ class ACardNamesThePacksThatInstallIt(unittest.TestCase): import json as _json from cross_reference import FileConsumers - from generate_site import _installed_links + from generate_site import _installed_places with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as tmp: Path(tmp, "retrobat.json").write_text(_json.dumps({ @@ -755,9 +756,9 @@ class ACardNamesThePacksThatInstallIt(unittest.TestCase): })) consumers = FileConsumers({"files": {}}) consumers.add_manifests(tmp) - links = _installed_links("a" * 40, consumers, set()) - self.assertEqual(links, ["[retrobat](../platforms/retrobat.md) `scph5501.bin`"]) - self.assertEqual(_installed_links("a" * 40, consumers, {"retrobat"}), []) + places = _installed_places("a" * 40, consumers, set()) + self.assertEqual(places, [("RetroBat", "../platforms/retrobat.md", ["scph5501.bin"])]) + self.assertEqual(_installed_places("a" * 40, consumers, {"retrobat"}), []) class ProseCitationsLinkToThePin(unittest.TestCase): diff --git a/tests/test_site_render.py b/tests/test_site_render.py index 83c4e69b..ad238838 100644 --- a/tests/test_site_render.py +++ b/tests/test_site_render.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import sys import tempfile import unittest @@ -354,15 +355,15 @@ class TheSearchSkipsLinkLists(unittest.TestCase): consumers.installed = {sha1: {"batocera": {"bios/a.bin"}}} 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, _sep, skipped = text.partition("
") - self.assertIn("a.bin", searched) - self.assertNotIn("- MD5:", searched) - for line in ("- MD5:", "- Emulators:", "- Also installed by:"): - self.assertIn(line, skipped) + lines = generate_site._system_file_card(entry, consumers) + self.assertIn("a.bin", lines[2]) + rows = {re.search(r"
(.*?)
", line)[1]: line for line in lines + if line.startswith("" in line} + for label in ("MD5", "Emulators", "Packed for its emulators by"): + self.assertTrue(rows[label].startswith("
"), label) # 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("
", 1)[1]) + self.assertTrue(rows["On the setup's list"].startswith("
")) def test_a_game_data_card_skips_its_name_too(self): import generate_site