diff --git a/scripts/generate_site.py b/scripts/generate_site.py index c4fed5c3..11b24416 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -65,6 +65,49 @@ ICON_CACHE_PATH = Path(".cache") / "system_icons.json" _icon_available: dict[str, bool] = {} +def _by_mode(value, mode: str) -> str: + """Read a profile field that may be keyed by build mode.""" + if isinstance(value, dict): + return str(value.get(mode) or "") if mode else "" + return str(value or "") + + +def _repo_pins(profile: dict, field: str, label: str) -> list[tuple[str, str, str]]: + """Pair each declared repository URL with its own revision. + + A profile whose builds live in separate repositories keys both the URL + and the revision by build mode. Reading them apart would pin a libretro + fork to the standalone revision and produce a permalink into a tree that + never held the cited line. Each tuple is (url, pin, source_commit + fallback for that same mode). + """ + raw = profile.get(field, "") + pin_field = profile.get(f"{field}_commit") or "" + source_field = profile.get("source_commit") or "" + + if not isinstance(raw, dict): + if not raw: + return [] + return [ + ( + str(raw), + _by_mode(pin_field, label), + _by_mode(source_field, label), + ) + ] + + # The requested build mode first, so it wins when several repositories + # would otherwise be equally good candidates. + ordered = ([label] if label and label in raw else []) + [ + key for key in raw if key != label + ] + return [ + (str(raw[key]), _by_mode(pin_field, key), _by_mode(source_field, key)) + for key in ordered + if raw.get(key) + ] + + def _forge_sources(profile: dict, label: str = "") -> list[tuple[upstream.Repo, str]]: """Supported source repositories and immutable revisions for a profile.""" candidates: list[tuple[upstream.Repo, str]] = [] @@ -72,42 +115,7 @@ def _forge_sources(profile: dict, label: str = "") -> list[tuple[upstream.Repo, source_repos: set[tuple[str, str]] = set() for field in ("source", "upstream"): - raw = profile.get(field, "") - raw_pin = profile.get(f"{field}_commit") or "" - source_pin = profile.get("source_commit") or "" - - # A profile whose builds live in separate repositories keys both the - # URL and the revision by build mode. The two have to be read as - # pairs: pinning a libretro fork to the standalone revision would - # produce a permalink into the wrong tree. - pairs: list[tuple[str, str]] = [] - if isinstance(raw, dict): - ordered = ([label] if label and label in raw else []) + [ - key for key in raw if key != label - ] - for key in ordered: - value = raw.get(key) - if not value: - continue - pin = raw_pin.get(key, "") if isinstance(raw_pin, dict) else raw_pin - fallback = ( - source_pin.get(key, "") - if isinstance(source_pin, dict) - else source_pin - ) - pairs.append((str(value), str(pin or ""), str(fallback or ""))) - elif raw: - pin = raw_pin.get(label, "") if isinstance(raw_pin, dict) and label else ( - "" if isinstance(raw_pin, dict) else raw_pin - ) - fallback = ( - source_pin.get(label, "") - if isinstance(source_pin, dict) and label - else ("" if isinstance(source_pin, dict) else source_pin) - ) - pairs.append((str(raw), str(pin or ""), str(fallback or ""))) - - for value, pin, fallback in pairs: + for value, pin, fallback in _repo_pins(profile, field, label): repo = upstream.parse_repo(value) if repo is None: continue @@ -2109,6 +2117,271 @@ def generate_emulators_index(profiles: dict) -> str: return "\n".join(lines) + "\n" +def _file_badges(f: dict, in_repo: bool) -> list[str]: + """The status chips shown beside a file name. + + Fourteen independent optional fields, each contributing at most one + chip. Kept apart from the rest of the row so the field-by-field + rendering stays readable. + """ + required = f.get("required", False) + hle = f.get("hle_fallback", False) + mode = f.get("mode", "") + category = f.get("category", "") + region = f.get("region", "") + storage = f.get("storage", "") + bundled = f.get("bundled", False) + embedded = f.get("embedded", False) + has_builtin = f.get("has_builtin", False) + archive = f.get("archive", "") + ftype = f.get("type", "") + badges = [] + if required: + badges.append( + 'required' + ) + else: + badges.append( + 'optional' + ) + if not in_repo: + badges.append( + 'missing' + ) + elif in_repo: + badges.append( + 'in repo' + ) + if hle: + badges.append( + 'HLE fallback' + ) + if mode: + badges.append( + f'{mode}' + ) + if category and category != "bios": + badges.append( + f'{category}' + ) + if region: + region_str = ( + ", ".join(region) if isinstance(region, list) else str(region) + ) + badges.append( + f'{region_str}' + ) + if storage and storage != "embedded": + badges.append( + f'{storage}' + ) + if bundled: + badges.append( + 'bundled' + ) + if embedded: + badges.append( + 'embedded' + ) + if has_builtin: + badges.append( + 'built-in fallback' + ) + if archive: + badges.append( + f'in {archive}' + ) + if ftype and ftype != "bios": + badges.append( + f'{ftype}' + ) + + return badges + + +def _render_emulator_file( + f: dict, + profile: dict, + platform_files: dict | None, + files: list, + _file_available, +) -> list[str]: + """Render one row of an emulator profile's file table. + + Split out of generate_emulator_page, where it was a 228-line loop body + carrying most of that function's branching: thirty-odd optional fields, + each with its own badge, hash line or note. + """ + lines: list[str] = [] + fname = f.get("name", "") + required = f.get("required", False) + in_repo = _file_available(f) + source_ref = f.get("source_ref", "") + mode = f.get("mode", "") + hle = f.get("hle_fallback", False) + aliases = f.get("aliases", []) + category = f.get("category", "") + validation = f.get("validation", []) + size = f.get("size") + fnote = f.get("note", f.get("notes", "")) + storage = f.get("storage", "") + fmd5 = f.get("md5", "") + fsha1 = f.get("sha1", "") + fcrc32 = f.get("crc32", "") + fsha256 = f.get("sha256", "") + fadler32 = f.get("known_hash_adler32", "") + fmin = f.get("min_size") + fmax = f.get("max_size") + desc = f.get("description", "") + region = f.get("region", "") + archive = f.get("archive", "") + fpath = f.get("path", "") + fsystem = f.get("system", "") + priority = f.get("priority") + fast_boot = f.get("fast_boot") + bundled = f.get("bundled", False) + embedded = f.get("embedded", False) + has_builtin = f.get("has_builtin", False) + contents = f.get("contents", []) + config_key = f.get("config_key", "") + dest = f.get("dest", f.get("destination", "")) + ftype = f.get("type", "") + fpattern = f.get("pattern", "") + region_check = f.get("region_check") + size_note = f.get("size_note", "") + size_options = f.get("size_options", []) + size_range = f.get("size_range", "") + + badges = _file_badges(f, in_repo) + badge_str = " ".join(badges) + border_cls = ( + "rb-file-entry-required" if required else "rb-file-entry-optional" + ) + lines.append( + f'
' + ) + lines.append("") + lines.append(f"**`{fname}`** {badge_str}") + if desc: + lines.append(f"
{desc}") + lines.append("") + + details = [] + if fpath and fpath != fname: + details.append(f"Path: `{fpath}`") + if fsystem: + details.append(f"System: {_system_link(fsystem, '../')}") + if size: + if isinstance(size, list): + size_str = " / ".join(_fmt_size(s) for s in size) + else: + size_str = _fmt_size(size) + if fmin or fmax: + bounds = [] + if fmin: + bounds.append(f"min {_fmt_size(fmin)}") + if fmax: + bounds.append(f"max {_fmt_size(fmax)}") + size_str += f" ({', '.join(bounds)})" + details.append(f"Size: {size_str}") + elif fmin or fmax: + bounds = [] + if fmin: + bounds.append(f"min {_fmt_size(fmin)}") + if fmax: + bounds.append(f"max {_fmt_size(fmax)}") + details.append(f"Size: {', '.join(bounds)}") + if fsha1: + s = fsha1[:12] + details.append( + f'SHA1: ' + f"`{s}...`" + ) + if fmd5: + s = fmd5[:12] + details.append( + f'MD5: ' + f"`{s}...`" + ) + if fcrc32: + details.append(f"CRC32: `{fcrc32}`") + if fsha256: + s = fsha256[:12] + details.append( + f'SHA256: ' + f"`{s}...`" + ) + if fadler32: + details.append(f"Adler32: `{fadler32}`") + if aliases: + details.append(f"Aliases: {', '.join(f'`{a}`' for a in aliases)}") + if priority is not None: + details.append(f"Priority: {priority}") + if fast_boot is not None: + details.append(f"Fast boot: {'yes' if fast_boot else 'no'}") + if validation: + if isinstance(validation, list): + details.append(f"Validation: {', '.join(validation)}") + elif isinstance(validation, dict): + for scope, checks in validation.items(): + details.append(f"Validation ({scope}): {', '.join(checks)}") + if source_ref: + details.append( + f"Source: {_source_ref_markdown(profile, source_ref)}" + ) + if platform_files: + plats = sorted( + p for p, names in platform_files.items() if fname in names + ) + if plats: + plat_links = [_platform_link(p, p, "../") for p in plats] + details.append(f"Platforms: {', '.join(plat_links)}") + + if dest and dest != fname and dest != fpath: + details.append(f"Destination: `{dest}`") + if config_key: + details.append(f"Config key: `{config_key}`") + if fpattern: + details.append(f"Pattern: `{fpattern}`") + if region_check is not None: + details.append(f"Region check: {'yes' if region_check else 'no'}") + if size_note: + details.append(f"Size note: {size_note}") + if size_options: + details.append( + f"Size options: {', '.join(_fmt_size(s) for s in size_options)}" + ) + if size_range: + details.append(f"Size range: {size_range}") + + if details: + for d in details: + lines.append(f"- {d}") + if fnote: + lines.append(f"- {fnote}") + if contents: + lines.append(f"- Contents ({len(contents)} entries):") + for c in contents[:10]: + if isinstance(c, dict): + cname = c.get("name", "") + cdesc = c.get("description", "") + csize = c.get("size", "") + parts = [f"`{cname}`"] + if cdesc: + parts.append(cdesc) + if csize: + parts.append(_fmt_size(csize)) + lines.append(f" - {' -'.join(parts)}") + else: + lines.append(f" - {c}") + if len(contents) > 10: + lines.append(f" - ... and {len(contents) - 10} more") + lines.append("") + lines.append("
") + lines.append("") + return lines + + def generate_emulator_page( name: str, profile: dict, @@ -2369,233 +2642,11 @@ def generate_emulator_page( # File table for f in files: - fname = f.get("name", "") - required = f.get("required", False) - in_repo = _file_available(f) - source_ref = f.get("source_ref", "") - mode = f.get("mode", "") - hle = f.get("hle_fallback", False) - aliases = f.get("aliases", []) - category = f.get("category", "") - validation = f.get("validation", []) - size = f.get("size") - fnote = f.get("note", f.get("notes", "")) - storage = f.get("storage", "") - fmd5 = f.get("md5", "") - fsha1 = f.get("sha1", "") - fcrc32 = f.get("crc32", "") - fsha256 = f.get("sha256", "") - fadler32 = f.get("known_hash_adler32", "") - fmin = f.get("min_size") - fmax = f.get("max_size") - desc = f.get("description", "") - region = f.get("region", "") - archive = f.get("archive", "") - fpath = f.get("path", "") - fsystem = f.get("system", "") - priority = f.get("priority") - fast_boot = f.get("fast_boot") - bundled = f.get("bundled", False) - embedded = f.get("embedded", False) - has_builtin = f.get("has_builtin", False) - contents = f.get("contents", []) - config_key = f.get("config_key", "") - dest = f.get("dest", f.get("destination", "")) - ftype = f.get("type", "") - fpattern = f.get("pattern", "") - region_check = f.get("region_check") - size_note = f.get("size_note", "") - size_options = f.get("size_options", []) - size_range = f.get("size_range", "") - - # Status badges (HTML) - badges = [] - if required: - badges.append( - 'required' + lines.extend( + _render_emulator_file( + f, profile, platform_files, files, _file_available ) - else: - badges.append( - 'optional' - ) - if not in_repo: - badges.append( - 'missing' - ) - elif in_repo: - badges.append( - 'in repo' - ) - if hle: - badges.append( - 'HLE fallback' - ) - if mode: - badges.append( - f'{mode}' - ) - if category and category != "bios": - badges.append( - f'{category}' - ) - if region: - region_str = ( - ", ".join(region) if isinstance(region, list) else str(region) - ) - badges.append( - f'{region_str}' - ) - if storage and storage != "embedded": - badges.append( - f'{storage}' - ) - if bundled: - badges.append( - 'bundled' - ) - if embedded: - badges.append( - 'embedded' - ) - if has_builtin: - badges.append( - 'built-in fallback' - ) - if archive: - badges.append( - f'in {archive}' - ) - if ftype and ftype != "bios": - badges.append( - f'{ftype}' - ) - - badge_str = " ".join(badges) - border_cls = ( - "rb-file-entry-required" if required else "rb-file-entry-optional" ) - lines.append( - f'
' - ) - lines.append("") - lines.append(f"**`{fname}`** {badge_str}") - if desc: - lines.append(f"
{desc}") - lines.append("") - - details = [] - if fpath and fpath != fname: - details.append(f"Path: `{fpath}`") - if fsystem: - details.append(f"System: {_system_link(fsystem, '../')}") - if size: - if isinstance(size, list): - size_str = " / ".join(_fmt_size(s) for s in size) - else: - size_str = _fmt_size(size) - if fmin or fmax: - bounds = [] - if fmin: - bounds.append(f"min {_fmt_size(fmin)}") - if fmax: - bounds.append(f"max {_fmt_size(fmax)}") - size_str += f" ({', '.join(bounds)})" - details.append(f"Size: {size_str}") - elif fmin or fmax: - bounds = [] - if fmin: - bounds.append(f"min {_fmt_size(fmin)}") - if fmax: - bounds.append(f"max {_fmt_size(fmax)}") - details.append(f"Size: {', '.join(bounds)}") - if fsha1: - s = fsha1[:12] - details.append( - f'SHA1: ' - f"`{s}...`" - ) - if fmd5: - s = fmd5[:12] - details.append( - f'MD5: ' - f"`{s}...`" - ) - if fcrc32: - details.append(f"CRC32: `{fcrc32}`") - if fsha256: - s = fsha256[:12] - details.append( - f'SHA256: ' - f"`{s}...`" - ) - if fadler32: - details.append(f"Adler32: `{fadler32}`") - if aliases: - details.append(f"Aliases: {', '.join(f'`{a}`' for a in aliases)}") - if priority is not None: - details.append(f"Priority: {priority}") - if fast_boot is not None: - details.append(f"Fast boot: {'yes' if fast_boot else 'no'}") - if validation: - if isinstance(validation, list): - details.append(f"Validation: {', '.join(validation)}") - elif isinstance(validation, dict): - for scope, checks in validation.items(): - details.append(f"Validation ({scope}): {', '.join(checks)}") - if source_ref: - details.append( - f"Source: {_source_ref_markdown(profile, source_ref)}" - ) - if platform_files: - plats = sorted( - p for p, names in platform_files.items() if fname in names - ) - if plats: - plat_links = [_platform_link(p, p, "../") for p in plats] - details.append(f"Platforms: {', '.join(plat_links)}") - - if dest and dest != fname and dest != fpath: - details.append(f"Destination: `{dest}`") - if config_key: - details.append(f"Config key: `{config_key}`") - if fpattern: - details.append(f"Pattern: `{fpattern}`") - if region_check is not None: - details.append(f"Region check: {'yes' if region_check else 'no'}") - if size_note: - details.append(f"Size note: {size_note}") - if size_options: - details.append( - f"Size options: {', '.join(_fmt_size(s) for s in size_options)}" - ) - if size_range: - details.append(f"Size range: {size_range}") - - if details: - for d in details: - lines.append(f"- {d}") - if fnote: - lines.append(f"- {fnote}") - if contents: - lines.append(f"- Contents ({len(contents)} entries):") - for c in contents[:10]: - if isinstance(c, dict): - cname = c.get("name", "") - cdesc = c.get("description", "") - csize = c.get("size", "") - parts = [f"`{cname}`"] - if cdesc: - parts.append(cdesc) - if csize: - parts.append(_fmt_size(csize)) - lines.append(f" - {' -'.join(parts)}") - else: - lines.append(f" - {c}") - if len(contents) > 10: - lines.append(f" - ... and {len(contents) - 10} more") - lines.append("") - lines.append("
") - lines.append("") # Data directories if data_dirs: