mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-10 03:21:27 -10:00
The site's one-liners pointed at develop, so any installer change went live to users the moment it merged, before a release. Point every installer URL at master: the site's RAW_URL and source links, the README, the scripts' own usage text, the nightly notes, and install.sh's RAW, which --auto-update re-fetches. Co-authored-by: Claude Opus 5.5 <[email protected]>
1821 lines
79 KiB
Python
1821 lines
79 KiB
Python
#!/usr/bin/env python3
|
|
"""Static-site generator for the Virtual Jaguar libretro GitHub Pages site.
|
|
|
|
Stdlib only -- no pip, no external JS/CSS/fonts/trackers.
|
|
|
|
Inputs (committed in this repo):
|
|
site/pages/*.html page body fragments (title/nav/description in
|
|
leading comments)
|
|
site/style.css the one stylesheet
|
|
site/assets/* images, copied verbatim
|
|
site/cheats/* community cheat packs (generated by
|
|
tools/cheat-import/build_packs.py), copied verbatim
|
|
site/thumbnails/* Jaguar CD artwork mirror, copied verbatim, plus one
|
|
drop-in zip per system (see write_thumbnail_zips)
|
|
docs/cd-boot-matrix.md parsed for the CD compatibility table
|
|
Makefile CORE_BASE_VERSION -> JSON-LD softwareVersion
|
|
|
|
Output:
|
|
_site/ ready to serve (used by .github/workflows/pages.yml)
|
|
_site/sitemap.xml one <url> per page, lastmod from git
|
|
_site/robots.txt advisory (see render_robots for the caveat)
|
|
|
|
Usage:
|
|
python3 scripts/build_site.py # build into _site/
|
|
python3 scripts/build_site.py --out DIR # build elsewhere
|
|
python3 scripts/build_site.py --build-date YYYY-MM-DD # pin the fallback
|
|
|
|
The CD-matrix parser is deliberately strict: if docs/cd-boot-matrix.md drifts
|
|
from the expected shape, the build FAILS with a loud message instead of
|
|
publishing garbage. Tolerated in-format noise: `<!-- build:<rev> -->` stamps
|
|
inside cells (extracted, reported, stripped) and unknown Stage strings (mapped
|
|
to the honest "not yet verified" bucket).
|
|
|
|
SEO surface (all generated here, never hand-written into a page fragment):
|
|
per-page <title>/description, rel=canonical, Open Graph + Twitter card,
|
|
JSON-LD (SoftwareApplication on the home page, BreadcrumbList on subpages),
|
|
sitemap.xml and robots.txt. No trackers, no analytics, no external assets --
|
|
that is a hard rule, see docs/site-maintenance.md.
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import html
|
|
import json
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import zipfile
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SITE_SRC = ROOT / "site"
|
|
MATRIX_MD = ROOT / "docs" / "cd-boot-matrix.md"
|
|
CART_MATRIX_MD = ROOT / "docs" / "cart-boot-matrix.md"
|
|
MAKEFILE = ROOT / "Makefile"
|
|
|
|
REPO_URL = "https://github.com/libretro/virtualjaguar-libretro"
|
|
# raw.githubusercontent for the install one-liner; derived from REPO_URL so a
|
|
# fork or rename cannot leave the curl command pointing at the wrong repo.
|
|
# master, not develop: users get the released installer, not unreleased edits.
|
|
RAW_URL = REPO_URL.replace("https://github.com/",
|
|
"https://raw.githubusercontent.com/") + "/master"
|
|
|
|
# The canonical base for every absolute URL the site emits; for a given page
|
|
# the <link rel=canonical>, og:url and the sitemap <loc> must come out
|
|
# byte-identical -- a mismatch between those three is the classic own-goal.
|
|
#
|
|
# Default: jaguar.provenance-emu.com, GitHub Pages on the Provenance-Emu fork
|
|
# with a verified custom domain. The libretro org's own Pages routing sends
|
|
# this repo's site to www.libretro.com/virtualjaguar-libretro/, which that
|
|
# host does not serve (404) and only a libretro org owner can fix; if that
|
|
# ever changes, migrate by flipping VJ_SITE_BASE. Keep the trailing slash.
|
|
import os as _os
|
|
SITE_BASE = _os.environ.get(
|
|
"VJ_SITE_BASE", "https://jaguar.provenance-emu.com/")
|
|
if not SITE_BASE.endswith("/"):
|
|
SITE_BASE += "/"
|
|
SITE_NAME = "Virtual Jaguar libretro"
|
|
|
|
# Official libretro documentation for this core: the canonical reference
|
|
# manual, maintained in libretro/docs as docs/library/virtual_jaguar.md.
|
|
# This site is the project showcase; that page is the manual.
|
|
LIBRETRO_DOCS = "https://docs.libretro.com/library/virtual_jaguar/"
|
|
LIBRETRO_DOCS_OPTIONS = LIBRETRO_DOCS + "#core-options"
|
|
|
|
# The Provenance family of emulator sites this showcase is published
|
|
# alongside (jaguar.provenance-emu.com is hosted by Provenance). Source of
|
|
# truth: provenance-emu.github.io/data/family.yml. Keep in sync by hand.
|
|
PROVENANCE_ORG_ID = "https://provenance-emu.com/#organization"
|
|
FAMILY = [
|
|
("Provenance", "https://provenance-emu.com/",
|
|
"Multi-system retro emulator for iOS, tvOS and macOS"),
|
|
("iCube", "https://icube-emu.com/",
|
|
"GameCube & Wii emulator for iOS and tvOS"),
|
|
("iFly", "https://ifly-emu.com/",
|
|
"Dreamcast emulator for iOS and tvOS"),
|
|
("Provenance Wiki", "https://wiki.provenance-emu.com/",
|
|
"Guides, ROM ripping, BIOS help"),
|
|
]
|
|
|
|
|
|
def family_links_html():
|
|
"""Footer links to the sibling Provenance sites, one per line, with the
|
|
tagline as the title attribute so the footer stays one short paragraph."""
|
|
return " ·\n".join(
|
|
' <a href="%s" title="%s">%s</a>' % (url, html.escape(tagline), name)
|
|
for name, url, tagline in FAMILY)
|
|
LIBRETRO_DOCS_CONTROLS = LIBRETRO_DOCS + "#controllers"
|
|
LIBRETRO_DOCS_SRC = ("https://github.com/libretro/docs/blob/master/"
|
|
"docs/library/virtual_jaguar.md")
|
|
# libretro's own community-maintained Jaguar compatibility list -- a different
|
|
# document from our generated CD boot matrix; both are linked, labelled.
|
|
LIBRETRO_DOCS_COMPAT = "https://docs.libretro.com/library/compatibility/jaguar/"
|
|
|
|
# Social share cards (og:image / twitter:image), per page, with a default.
|
|
# The PNGs are generated by scripts/make_share_cards.py and committed.
|
|
# Dimensions are read from each file (png_size) so og:image:width/height can
|
|
# never drift from reality.
|
|
SHARE_DEFAULT = (
|
|
"assets/share/home.png",
|
|
"Virtual Jaguar: eight Atari Jaguar games running in the emulator (Doom, "
|
|
"Raiden, Tempest 2000, Super Burnout, Iron Soldier, Wolfenstein 3D, Alien "
|
|
"vs Predator and Atari Karts) above the Virtual Jaguar name and app icon.")
|
|
SHARE_IMAGES = {
|
|
"app.html": (
|
|
"assets/share/app.png",
|
|
"The Virtual Jaguar desktop app: the cartridge library with box art, "
|
|
"Rayman under a CRT shader, the palette debugger and the light theme, "
|
|
"above the Virtual Jaguar name and app icon."),
|
|
}
|
|
|
|
# Favicon set, generated by scripts/make_share_cards.py from the app icon.
|
|
# favicon.ico is also copied to the output root, where browsers ask for it
|
|
# unprompted.
|
|
ICON_DIR = "assets/icons/"
|
|
ICON_PNG_SIZES = (192, 512)
|
|
|
|
# Page order defines nav order.
|
|
PAGES = ["index.html", "download.html", "app.html", "compatibility.html",
|
|
"enhancements.html", "performance.html", "why-this-core.html"]
|
|
# Legal pages: built, in the sitemap and checked like any other page, but kept
|
|
# out of the primary nav -- they are linked from the footer instead.
|
|
LEGAL_PAGES = ["privacy.html", "terms.html"]
|
|
ALL_PAGES = PAGES + LEGAL_PAGES
|
|
|
|
EXPECTED_HEADER = ["Title", "Mode", "Score", "Stage", "Watchdog", "PC evidence"]
|
|
CART_EXPECTED_HEADER = ["Title", "HLE", "HLE notes", "Real BIOS", "BIOS notes"]
|
|
BUILD_STAMP_RE = re.compile(r"<!--\s*build:([^\s>]+)\s*-->")
|
|
ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
def die(msg):
|
|
sys.stderr.write("\nFATAL: build_site.py: %s\n" % msg)
|
|
sys.stderr.write("Refusing to publish a site with bad or missing data.\n")
|
|
sys.exit(1)
|
|
|
|
|
|
# ------------------------------------------------------- repo-derived facts
|
|
|
|
def write_thumbnail_zips(src, dest):
|
|
"""Write <dest>/<System>.zip for each system folder in src.
|
|
|
|
The zip holds only <System>/Named_*/*.png, so it unpacks straight into
|
|
RetroArch's thumbnails directory (and is what libretro-thumbnails#141 is
|
|
offered). Entries are sorted with a fixed timestamp, so the same images
|
|
always give the same bytes.
|
|
"""
|
|
for system in sorted(p for p in src.iterdir() if p.is_dir()):
|
|
pngs = sorted(f for f in system.glob("Named_*/*.png") if f.is_file())
|
|
if not pngs:
|
|
continue
|
|
with zipfile.ZipFile(dest / (system.name + ".zip"), "w",
|
|
zipfile.ZIP_DEFLATED) as zf:
|
|
for f in pngs:
|
|
info = zipfile.ZipInfo(f.relative_to(src).as_posix(),
|
|
(1996, 1, 1, 0, 0, 0))
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
info.external_attr = 0o644 << 16
|
|
zf.writestr(info, f.read_bytes())
|
|
|
|
|
|
def read_core_version(makefile):
|
|
"""softwareVersion for the JSON-LD, straight from the Makefile.
|
|
|
|
Pulled from the build system rather than typed into the site so a release
|
|
bump can't leave stale structured data behind. Fails loudly, like the
|
|
matrix parser: publishing a wrong version is worse than not publishing.
|
|
"""
|
|
if not makefile.is_file():
|
|
die("%s not found -- cannot determine the core version" % makefile)
|
|
m = re.search(r"^CORE_BASE_VERSION\s*:=\s*v?([0-9][0-9A-Za-z.\-]*)\s*$",
|
|
makefile.read_text(encoding="utf-8"), re.M)
|
|
if not m:
|
|
die("no 'CORE_BASE_VERSION := vX.Y.Z' line in %s -- structured data "
|
|
"would publish a stale or missing version" % makefile)
|
|
return m.group(1)
|
|
|
|
|
|
def png_size(path):
|
|
"""(width, height) from a PNG IHDR, so og:image dimensions can't lie."""
|
|
if not path.is_file():
|
|
die("share image %s is missing" % path)
|
|
data = path.read_bytes()[:24]
|
|
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
|
|
die("%s is not a PNG -- refusing to guess og:image dimensions" % path)
|
|
return struct.unpack(">II", data[16:24])
|
|
|
|
|
|
def git_output(args):
|
|
"""Run git at ROOT; stripped stdout, or None on ANY failure.
|
|
|
|
Never fatal: a source tarball, a missing git binary and a detached CI
|
|
checkout must all still produce a site.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(["git", "-C", str(ROOT)] + args,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL, timeout=15)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if proc.returncode != 0:
|
|
return None
|
|
return proc.stdout.decode("utf-8", "replace").strip()
|
|
|
|
|
|
def repo_is_shallow():
|
|
return git_output(["rev-parse", "--is-shallow-repository"]) == "true"
|
|
|
|
|
|
def git_commit_date(path):
|
|
"""YYYY-MM-DD of the last commit touching `path`, or None."""
|
|
out = git_output(["log", "-1", "--format=%cs", "--", str(path)])
|
|
# `%cs` needs git >= 2.21; an older git echoes the literal format string,
|
|
# which the regex rejects -- so we degrade to the fallback date instead of
|
|
# emitting '%cs' as a lastmod.
|
|
if not out or not ISO_DATE_RE.match(out):
|
|
return None
|
|
return out
|
|
|
|
|
|
def last_modified(paths, fallback, shallow):
|
|
"""Newest git commit date across `paths`, else `fallback`.
|
|
|
|
A page's real lastmod depends on more than its own fragment: every page
|
|
depends on this generator's template, and the compatibility page is
|
|
generated from docs/cd-boot-matrix.md. Callers pass all of them.
|
|
|
|
On a SHALLOW clone (`actions/checkout` defaults to fetch-depth: 1) `git
|
|
log -1 -- <path>` reports the single checkout commit for *every* path, so
|
|
all pages would get the same wrong date while looking perfect locally.
|
|
We refuse that data outright; .github/workflows/pages.yml sets
|
|
fetch-depth: 0 so the real dates are available in CI.
|
|
"""
|
|
if shallow:
|
|
return fallback
|
|
best = None
|
|
for p in paths:
|
|
d = git_commit_date(p)
|
|
if d and (best is None or d > best):
|
|
best = d
|
|
return best or fallback
|
|
|
|
|
|
# ---------------------------------------------------------------- CD matrix
|
|
|
|
DASH_RE = re.compile("[—–]")
|
|
|
|
|
|
def normalize_dashes(s):
|
|
"""Em/en dash -> hyphen, for text arriving from the generated matrices.
|
|
|
|
The site's copy standard is zero em-dashes, and it has to hold for cells
|
|
the sweep tools wrote as much as for prose someone typed: cart_boot_matrix
|
|
emits notes like "black video (headless -- undetermined)", which reach the
|
|
reader through a title= tooltip. Normalizing here rather than editing
|
|
docs/cart-boot-matrix.md keeps that file exactly as its generator wrote it
|
|
-- the compatibility page's whole claim is that nothing on it was typed by
|
|
hand. This changes punctuation only; no cell's meaning moves.
|
|
"""
|
|
return DASH_RE.sub("-", s)
|
|
|
|
|
|
def split_md_row(line):
|
|
"""Split a markdown table row into cells (outer pipes optional)."""
|
|
line = line.strip()
|
|
if line.startswith("|"):
|
|
line = line[1:]
|
|
if line.endswith("|"):
|
|
line = line[:-1]
|
|
return [c.strip() for c in line.split("|")]
|
|
|
|
|
|
def parse_cd_matrix(path):
|
|
"""Parse the current Results table from docs/cd-boot-matrix.md.
|
|
|
|
Returns (rows, build_ids) where rows is a list of dicts with keys
|
|
title/mode/score/stage/watchdog/evidence, and build_ids is the sorted set
|
|
of build stamps found in the table.
|
|
|
|
Only the FIRST table after the '## Results' heading is the current data;
|
|
later sections of the doc contain historical tables that must be ignored.
|
|
"""
|
|
if not path.is_file():
|
|
die("%s not found -- the compatibility page has no data source" % path)
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
|
|
# Locate the '## Results' heading.
|
|
start = None
|
|
for i, ln in enumerate(lines):
|
|
if ln.strip() == "## Results":
|
|
start = i + 1
|
|
break
|
|
if start is None:
|
|
die("no '## Results' heading in %s -- format drifted?" % path)
|
|
|
|
# Find the first table after it, stopping at the next heading.
|
|
hdr_idx = None
|
|
for i in range(start, len(lines)):
|
|
s = lines[i].strip()
|
|
if s.startswith("#"):
|
|
die("hit heading %r before any table under '## Results' in %s"
|
|
% (s, path))
|
|
if s.startswith("|"):
|
|
hdr_idx = i
|
|
break
|
|
if hdr_idx is None:
|
|
die("no table found under '## Results' in %s" % path)
|
|
|
|
header = split_md_row(lines[hdr_idx])
|
|
if header != EXPECTED_HEADER:
|
|
die("Results table header changed in %s.\n expected: %s\n found: %s"
|
|
% (path, EXPECTED_HEADER, header))
|
|
|
|
sep = lines[hdr_idx + 1].strip() if hdr_idx + 1 < len(lines) else ""
|
|
if not re.fullmatch(r"\|?[\s:|-]+\|?", sep):
|
|
die("missing separator row after Results header in %s (got %r)"
|
|
% (path, sep))
|
|
|
|
rows = []
|
|
build_ids = set()
|
|
for i in range(hdr_idx + 2, len(lines)):
|
|
s = lines[i].strip()
|
|
if not s.startswith("|"):
|
|
break # end of the current table
|
|
cells = split_md_row(lines[i])
|
|
if len(cells) != len(EXPECTED_HEADER):
|
|
die("row %d of the Results table in %s has %d cells, expected %d:\n %r"
|
|
% (i + 1, path, len(cells), len(EXPECTED_HEADER), lines[i]))
|
|
for m in BUILD_STAMP_RE.finditer(lines[i]):
|
|
build_ids.add(m.group(1))
|
|
cells = [normalize_dashes(BUILD_STAMP_RE.sub("", c).strip())
|
|
for c in cells]
|
|
title, mode, score, stage, watchdog, evidence = cells
|
|
if mode not in ("hle", "bios"):
|
|
die("row %d in %s: unknown Mode %r (expected 'hle' or 'bios')"
|
|
% (i + 1, path, mode))
|
|
if not title:
|
|
die("row %d in %s: empty Title" % (i + 1, path))
|
|
rows.append({"title": title, "mode": mode, "score": score,
|
|
"stage": stage, "watchdog": watchdog,
|
|
"evidence": evidence})
|
|
if not rows:
|
|
die("Results table in %s has zero data rows" % path)
|
|
return rows, sorted(build_ids)
|
|
|
|
|
|
def classify_stage(stage):
|
|
"""Map a Stage cell to (css_class, human_label). Honest three-state."""
|
|
s = stage.strip()
|
|
if s == "GAME_CODE":
|
|
return ("good", "Reaches game code")
|
|
if s in ("LOAD_FAIL", "HARNESS_HANG") or s.startswith("? (pc_escape"):
|
|
return ("bad", "Known issue: " + s)
|
|
# BIOS_INTRO / BOOT_STUB / '? (...)' / anything new: not verified either way.
|
|
return ("warn", "Not yet verified: " + s)
|
|
|
|
|
|
def summarize_watchdog(watchdog):
|
|
"""Short label for the watchdog cell; full text goes into a title attr."""
|
|
w = watchdog.strip()
|
|
if w in ("", "(none)"):
|
|
return None
|
|
m = re.search(r"\b(cd_seek_wedge|video_stall|gpu_pc_escape|dsp_pc_escape|"
|
|
r"gpu_wedge|dsp_wedge)\b", w)
|
|
return m.group(1) if m else "watchdog note"
|
|
|
|
|
|
def render_cd_table(rows):
|
|
"""Group hle/bios rows per title into one table row each."""
|
|
order = []
|
|
by_title = {}
|
|
for r in rows:
|
|
if r["title"] not in by_title:
|
|
by_title[r["title"]] = {}
|
|
order.append(r["title"])
|
|
by_title[r["title"]][r["mode"]] = r
|
|
|
|
out = []
|
|
out.append('<div class="table-wrap"><table>')
|
|
out.append("<tr><th>Disc image</th><th>HLE mode</th><th>Real-BIOS mode</th>"
|
|
"<th>Notes</th></tr>")
|
|
n_good = {"hle": 0, "bios": 0}
|
|
for title in order:
|
|
cells = ['<td>%s</td>' % html.escape(title)]
|
|
notes = []
|
|
for mode in ("hle", "bios"):
|
|
r = by_title[title].get(mode)
|
|
if r is None:
|
|
cells.append('<td><span class="badge warn">not run</span></td>')
|
|
continue
|
|
cls, label = classify_stage(r["stage"])
|
|
if cls == "good":
|
|
n_good[mode] += 1
|
|
cells.append('<td><span class="badge %s">%s</span></td>'
|
|
% (cls, html.escape(label)))
|
|
wd = summarize_watchdog(r["watchdog"])
|
|
if wd:
|
|
notes.append('<span title="%s"><code>%s</code> (%s)</span>'
|
|
% (html.escape(r["watchdog"], quote=True),
|
|
html.escape(wd), mode))
|
|
# No em-dash anywhere the site renders: a plain hyphen is what a
|
|
# 1994 printout put in an empty cell, and it is the character the
|
|
# copy standard on every page uses.
|
|
cells.append("<td>%s</td>" % (" · ".join(notes) if notes
|
|
else "-"))
|
|
out.append("<tr>%s</tr>" % "".join(cells))
|
|
out.append("</table></div>")
|
|
return "\n".join(out), len(order), n_good
|
|
|
|
|
|
def parse_cart_matrix(path):
|
|
"""Parse the single table in docs/cart-boot-matrix.md.
|
|
|
|
Returns (rows, build_ids) where rows is a list of dicts with keys
|
|
title/hle_stage/hle_notes/bios_stage/bios_notes. Deliberately strict,
|
|
like parse_cd_matrix: if the generator's format drifts, fail the site
|
|
build rather than render half a table.
|
|
"""
|
|
if not path.is_file():
|
|
die("%s not found -- run test/tools/cart_boot_matrix.sh" % path)
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
|
|
hdr_idx = None
|
|
for i, ln in enumerate(lines):
|
|
if ln.strip().startswith("|"):
|
|
hdr_idx = i
|
|
break
|
|
if hdr_idx is None:
|
|
die("no table found in %s" % path)
|
|
header = split_md_row(lines[hdr_idx])
|
|
if header != CART_EXPECTED_HEADER:
|
|
die("cart matrix header changed in %s.\n expected: %s\n found: %s"
|
|
% (path, CART_EXPECTED_HEADER, header))
|
|
sep = lines[hdr_idx + 1].strip() if hdr_idx + 1 < len(lines) else ""
|
|
if not re.fullmatch(r"\|?[\s:|-]+\|?", sep):
|
|
die("missing separator row after cart matrix header in %s (got %r)"
|
|
% (path, sep))
|
|
|
|
rows = []
|
|
build_ids = set()
|
|
for i in range(hdr_idx + 2, len(lines)):
|
|
s = lines[i].strip()
|
|
if not s.startswith("|"):
|
|
break
|
|
for m in BUILD_STAMP_RE.finditer(lines[i]):
|
|
build_ids.add(m.group(1))
|
|
cells = split_md_row(BUILD_STAMP_RE.sub("", lines[i]).strip())
|
|
if len(cells) != len(CART_EXPECTED_HEADER):
|
|
die("row %d of %s has %d cells, expected %d:\n %r"
|
|
% (i + 1, path, len(cells), len(CART_EXPECTED_HEADER),
|
|
lines[i]))
|
|
title, hle_stage, hle_notes, bios_stage, bios_notes = \
|
|
[normalize_dashes(c.strip()) for c in cells]
|
|
if not title:
|
|
die("row %d in %s: empty Title" % (i + 1, path))
|
|
for st in (hle_stage, bios_stage):
|
|
# Infrastructure-invalid rows (build-guard refusal, missing PC
|
|
# evidence) are sweep failures, not title results -- rendering
|
|
# them at all would publish "Known issue" for titles that are
|
|
# fine. Fail the build so the sweep gets fixed and re-run.
|
|
if st.startswith("? (build_mismatch") or st.startswith("? (no_reg"):
|
|
die("row %d in %s has invalid-infrastructure stage %r -- "
|
|
"re-run cart_boot_matrix.sh against a matching wide-ABI "
|
|
"core (make TEST_EXPORTS=1)" % (i + 1, path, st))
|
|
rows.append({"title": title,
|
|
"hle_stage": hle_stage, "hle_notes": hle_notes,
|
|
"bios_stage": bios_stage, "bios_notes": bios_notes})
|
|
if not rows:
|
|
die("cart matrix in %s has zero data rows" % path)
|
|
return rows, sorted(build_ids)
|
|
|
|
|
|
def classify_cart_stage(stage, notes, mode="hle"):
|
|
"""Map a cart stage + notes to (css_class, human_label).
|
|
|
|
Same honest three-state as the CD table, plus 'plain' for an
|
|
authentic outcome that is neither a pass nor a bug (#976).
|
|
|
|
* 'GAME_CODE' with a black-video note downgrades to warn: the title
|
|
executes but headless video evidence is inconclusive (headless
|
|
read-path caveat).
|
|
* In the real-BIOS column, a run whose notes never mention the
|
|
handoff had no boot phase at all (cart_classify.sh: bios_ran=0, a
|
|
RAM-loaded executable), so the cell says the boot ROM was not used
|
|
rather than implying it accepted the file.
|
|
* 'BIOS_REJECT' is the real boot ROM's red "Jaguar" reject screen
|
|
(#852/#744): an observed, authentic outcome, never "not verified".
|
|
* '? (trap_loop)' / '? (bios_trap)' are crashes (#975)."""
|
|
s = stage.strip()
|
|
if s == "GAME_CODE":
|
|
no_boot_rom = mode == "bios" and "handoff" not in notes
|
|
if "black video" in notes:
|
|
return ("warn", "Boot ROM not used; runs, no picture in the test"
|
|
if no_boot_rom else
|
|
"Runs; no picture in the test")
|
|
return ("good", "Boot ROM not used; runs" if no_boot_rom
|
|
else "Boots and runs")
|
|
if s == "BIOS_REJECT":
|
|
return ("plain", "Rejected by the boot ROM")
|
|
if s.startswith("? (trap_loop"):
|
|
return ("bad", "Known issue: crashes at start")
|
|
if s.startswith("? (bios_trap"):
|
|
return ("bad", "Known issue: crashes after the boot ROM")
|
|
if (s == "LOAD_FAIL" or s.startswith("? (pc_escape") or
|
|
s.startswith("? (timeout") or
|
|
s.startswith("? (build_mismatch") or
|
|
s.startswith("? (no_reg")):
|
|
return ("bad", "Known issue: " + s)
|
|
return ("warn", "Not yet verified: " + s)
|
|
|
|
|
|
def render_cart_table(rows):
|
|
"""One table row per title; badge per boot mode; merged notes cell."""
|
|
out = []
|
|
out.append('<div class="table-wrap"><table>')
|
|
out.append("<tr><th>Cartridge</th><th>HLE boot</th><th>Real-BIOS boot</th>"
|
|
"<th>Notes</th></tr>")
|
|
n_good = {"hle": 0, "bios": 0, "hle_run": 0, "bios_run": 0}
|
|
for r in rows:
|
|
cells = ['<td>%s</td>' % html.escape(r["title"])]
|
|
notes = []
|
|
for mode, stage_k, notes_k in (("hle", "hle_stage", "hle_notes"),
|
|
("bios", "bios_stage", "bios_notes")):
|
|
cls, label = classify_cart_stage(r[stage_k], r[notes_k], mode)
|
|
if cls == "good":
|
|
n_good[mode] += 1
|
|
# "Reaches game code" regardless of the headless video verdict.
|
|
if r[stage_k].strip() == "GAME_CODE":
|
|
n_good[mode + "_run"] += 1
|
|
cells.append('<td><span class="badge %s" title="%s">%s</span></td>'
|
|
% (cls, html.escape(r[notes_k], quote=True),
|
|
html.escape(label)))
|
|
wd = summarize_watchdog(r[notes_k])
|
|
if wd:
|
|
notes.append('<span title="%s"><code>%s</code> (%s)</span>'
|
|
% (html.escape(r[notes_k], quote=True),
|
|
html.escape(wd), mode))
|
|
# No em-dash anywhere the site renders: a plain hyphen is what a
|
|
# 1994 printout put in an empty cell, and it is the character the
|
|
# copy standard on every page uses.
|
|
cells.append("<td>%s</td>" % (" · ".join(notes) if notes
|
|
else "-"))
|
|
out.append("<tr>%s</tr>" % "".join(cells))
|
|
out.append("</table></div>")
|
|
return "\n".join(out), len(rows), n_good
|
|
|
|
|
|
# ------------------------------------------------------------- downloads
|
|
|
|
# Release data is fetched by the Pages workflow (GitHub API: releases/latest,
|
|
# releases/tags/nightly and the last few tagged releases) and passed in with
|
|
# --releases. It is never committed: the asset list changes with every
|
|
# release and the file names of the app packages carry the version. Without
|
|
# the file the Download page still builds, with links to the release pages
|
|
# instead of to the files. scripts/testdata/releases.json is a trimmed
|
|
# fixture of the same shape for offline builds and tests.
|
|
#
|
|
# Every release is rendered to its own panel here, in Python, from one
|
|
# classification table, so the platform/arch grouping exists exactly once.
|
|
# assets/js/download.js only reveals the panel the reader picks (the page
|
|
# without it shows the latest release); it never rebuilds markup.
|
|
|
|
APP_ASSET_RE = re.compile(
|
|
r"^(?:VirtualJaguar-[^/]+\.(?:zip|exe|AppImage|flatpak)|virtualjaguar_[^/]+\.deb)$")
|
|
CORE_ASSET_RE = re.compile(
|
|
r"^virtualjaguar_libretro-(.+)\.(so|dll|dylib|bc|a)$")
|
|
DEBUG_ASSET_RE = re.compile(r"^virtualjaguar_libretro-(.+)-debug\.tar\.gz$")
|
|
TOOL_ASSET_RE = re.compile(
|
|
r"^(jagcd-tools|cue2cdi)-(linux|macos|windows)(?:-([\w.]+?))?(?:\.zip|\.exe)?$")
|
|
SUMS_NAME = "SHA256SUMS.txt"
|
|
INFO_NAME = "virtualjaguar_libretro.info"
|
|
|
|
# Platform id -> (label, icon id). Order is display order for the core;
|
|
# the app and the tools list follow the same order. The icons are generic
|
|
# glyphs (a laptop, a window, a prompt, a board ...) on purpose: no vendor
|
|
# logo is redistributable here. The name is always printed beside the icon,
|
|
# so the icon itself is decoration (aria-hidden).
|
|
PLATFORMS = [
|
|
("macos", "macOS", "laptop"),
|
|
("windows", "Windows", "window"),
|
|
("linux", "Linux", "terminal"),
|
|
("rpi", "Raspberry Pi", "board"),
|
|
("android", "Android", "phone"),
|
|
("ios", "iOS and tvOS", "tablet"),
|
|
("web", "Web (Emscripten)", "globe"),
|
|
("handheld", "Switch and Vita", "handheld"),
|
|
("other", "Other", "box"),
|
|
]
|
|
PLATFORM = dict((k, (label, icon)) for k, label, icon in PLATFORMS)
|
|
# Groups opened by default; the long tail stays one line each until asked.
|
|
PLATFORMS_OPEN = ("macos", "windows", "linux")
|
|
|
|
# How an app package reads on the page: (file-name regex, platform, arch
|
|
# label, package label). Order is display order.
|
|
APP_KINDS = [
|
|
(r"-macos-universal\.zip$", "macos", "universal", "App (zip)"),
|
|
(r"-windows-x64-setup\.exe$", "windows", "x64", "Installer"),
|
|
(r"-windows-x64\.zip$", "windows", "x64", "Portable zip"),
|
|
(r"-windows-arm64-setup\.exe$", "windows", "arm64", "Installer"),
|
|
(r"-windows-arm64\.zip$", "windows", "arm64", "Portable zip"),
|
|
(r"-linux-x86_64\.AppImage$", "linux", "x86_64", "AppImage"),
|
|
(r"_amd64\.deb$", "linux", "x86_64", ".deb"),
|
|
(r"-linux-x86_64\.flatpak$", "linux", "x86_64", "Flatpak"),
|
|
(r"-linux-aarch64\.AppImage$", "linux", "aarch64", "AppImage"),
|
|
(r"_arm64\.deb$", "linux", "aarch64", ".deb"),
|
|
]
|
|
|
|
TOOL_BLURB = {
|
|
"jagcd-tools": "session-aware chdman for Jaguar CD images",
|
|
"cue2cdi": "converts CUE/BIN to CDI",
|
|
}
|
|
|
|
|
|
def arch_icon(arch):
|
|
"""Icon id for an architecture or core-target string."""
|
|
a = arch.lower()
|
|
if a == "universal":
|
|
return "layers"
|
|
if a == "wasm":
|
|
return "cube"
|
|
if re.search(r"arm|aarch|rpi|cortex", a):
|
|
return "chip-arm"
|
|
return "chip"
|
|
|
|
|
|
def core_platform(target):
|
|
"""(platform id, short target) for the part of a core file name between
|
|
'virtualjaguar_libretro-' and the extension."""
|
|
if target.startswith("linux-rpi"):
|
|
return "rpi", target[len("linux-"):]
|
|
for prefix, plat in (("windows-", "windows"), ("macos-", "macos"),
|
|
("linux-", "linux"), ("android-", "android"),
|
|
("emscripten-", "web")):
|
|
if target.startswith(prefix):
|
|
return plat, target[len(prefix):]
|
|
if target.startswith("ios-"):
|
|
return "ios", "iOS " + target[4:]
|
|
if target.startswith("tvos-"):
|
|
return "ios", "tvOS " + target[5:]
|
|
if target in ("switch", "vita"):
|
|
return "handheld", target
|
|
return "other", target
|
|
|
|
|
|
def load_releases(path):
|
|
"""{"latest": <release>, "nightly": <release>, "older": [<release>...]}
|
|
in the GitHub API shape. Every key is optional; a present release must
|
|
look like one. "older" is newest first."""
|
|
try:
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as e:
|
|
die("--releases %s: %s" % (path, e))
|
|
if not isinstance(data, dict):
|
|
die("--releases %s: expected an object with latest/nightly" % path)
|
|
rels = [(k, data.get(k)) for k in ("latest", "nightly")]
|
|
rels += [("older[%d]" % i, r) for i, r in enumerate(data.get("older") or [])]
|
|
for key, rel in rels:
|
|
if rel is None:
|
|
continue
|
|
if not (isinstance(rel, dict) and rel.get("tag_name")
|
|
and isinstance(rel.get("assets"), list)):
|
|
die("--releases %s: %r is not a GitHub release object" % (path, key))
|
|
for a in rel["assets"]:
|
|
if not (a.get("name") and a.get("browser_download_url")):
|
|
die("--releases %s: %r has an asset without name/url"
|
|
% (path, key))
|
|
return data
|
|
|
|
|
|
GITHUB_API = "https://api.github.com/repos/" + REPO_URL[len("https://github.com/"):]
|
|
OLDER_RELEASES = 10 # how many releases before the latest to offer
|
|
|
|
|
|
def fetch_releases():
|
|
"""The same {"latest", "nightly", "older"} shape as --releases, straight
|
|
from the GitHub API. Unauthenticated is fine (three requests); GH_TOKEN
|
|
or GITHUB_TOKEN is sent when set, which CI should do. Any failure is
|
|
fatal: a publisher that cannot fetch must keep the previous deployment
|
|
rather than publish a Download page without the links."""
|
|
import urllib.request
|
|
import urllib.error
|
|
token = _os.environ.get("GH_TOKEN") or _os.environ.get("GITHUB_TOKEN")
|
|
|
|
def get(path):
|
|
req = urllib.request.Request(
|
|
"%s/%s" % (GITHUB_API, path),
|
|
headers={"Accept": "application/vnd.github+json",
|
|
"User-Agent": "virtualjaguar-build_site",
|
|
**({"Authorization": "Bearer " + token} if token else {})})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except (urllib.error.URLError, ValueError, OSError) as e:
|
|
die("--releases-from-github: GET %s failed: %s" % (path, e))
|
|
|
|
out = {"latest": get("releases/latest"),
|
|
"nightly": get("releases/tags/nightly")}
|
|
listing = get("releases?per_page=30")
|
|
for key, rel in out.items():
|
|
if not (rel.get("tag_name") and isinstance(rel.get("assets"), list)):
|
|
die("--releases-from-github: %s is not a release object" % key)
|
|
if not isinstance(listing, list):
|
|
die("--releases-from-github: releases listing is not a list")
|
|
# Tagged releases only: drafts, the rolling nightly and the rolling
|
|
# release-candidate prerelease are all marked draft/prerelease.
|
|
out["older"] = [
|
|
r for r in listing
|
|
if not r.get("draft") and not r.get("prerelease")
|
|
and r.get("tag_name") and r["tag_name"] != out["latest"]["tag_name"]
|
|
and isinstance(r.get("assets"), list)][:OLDER_RELEASES]
|
|
return out
|
|
|
|
|
|
def _size(n):
|
|
try:
|
|
n = int(n)
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
if n >= 1048576:
|
|
return "%.1f MB" % (n / 1048576.0)
|
|
return "%d KB" % max(1, round(n / 1024.0))
|
|
|
|
|
|
def _esc(s):
|
|
return html.escape(s, quote=True)
|
|
|
|
|
|
def _icon(icon_id):
|
|
return ('<svg class="ico" aria-hidden="true" focusable="false">'
|
|
'<use href="#i-%s"></use></svg>' % icon_id)
|
|
|
|
|
|
def _plat_cell(plat):
|
|
label, icon = PLATFORM[plat]
|
|
return '<span class="plat-name">%s%s</span>' % (_icon(icon), _esc(label))
|
|
|
|
|
|
def _arch_cell(arch):
|
|
return '<span class="arch">%s%s</span>' % (_icon(arch_icon(arch)),
|
|
_esc(arch))
|
|
|
|
|
|
def _dl(asset, text, cls=""):
|
|
"""A download link with the file name as its tooltip."""
|
|
return '<a%s href="%s" title="%s">%s</a>' % (
|
|
(' class="%s"' % cls) if cls else "",
|
|
_esc(asset["browser_download_url"]), _esc(asset["name"]), text)
|
|
|
|
|
|
def classify_assets(assets):
|
|
"""Sort a release's assets into the page's four lists. Every asset
|
|
lands in exactly one list; nothing is dropped.
|
|
app: [(platform, arch, label, asset)]
|
|
core: [(platform, target, ext, asset)]
|
|
debug: [(platform, target, asset)]
|
|
tools: [(tool, platform, arch, asset)]
|
|
other: [asset] (SHA256SUMS.txt and the .info file are returned by
|
|
name, not listed here)
|
|
"""
|
|
app, core, debug, tools, other = [], [], [], [], []
|
|
sums = info = None
|
|
seen = set()
|
|
for pat, plat, arch, label in APP_KINDS:
|
|
for a in assets:
|
|
if (APP_ASSET_RE.match(a["name"]) and a["name"] not in seen
|
|
and re.search(pat, a["name"])):
|
|
app.append((plat, arch, label, a))
|
|
seen.add(a["name"])
|
|
for a in assets:
|
|
n = a["name"]
|
|
if n in seen:
|
|
continue
|
|
m_dbg = DEBUG_ASSET_RE.match(n)
|
|
m_core = CORE_ASSET_RE.match(n)
|
|
m_tool = TOOL_ASSET_RE.match(n)
|
|
if n == SUMS_NAME:
|
|
sums = a
|
|
elif n == INFO_NAME:
|
|
info = a
|
|
elif m_dbg:
|
|
# Keep the full id (linux-x86_64): out of its platform group a
|
|
# bare "x86_64" would not say which build the symbols belong to.
|
|
debug.append((core_platform(m_dbg.group(1))[0], m_dbg.group(1), a))
|
|
elif m_core:
|
|
plat, tgt = core_platform(m_core.group(1))
|
|
core.append((plat, tgt, "." + m_core.group(2), a))
|
|
elif m_tool:
|
|
tools.append((m_tool.group(1), m_tool.group(2),
|
|
m_tool.group(3) or "", a))
|
|
elif APP_ASSET_RE.match(n):
|
|
app.append(("other", "", n, a)) # a package we have no row for
|
|
else:
|
|
other.append(a)
|
|
return {"app": app, "core": core, "debug": debug, "tools": tools,
|
|
"other": other, "sums": sums, "info": info}
|
|
|
|
|
|
def _plat_order(plat):
|
|
return [k for k, _l, _i in PLATFORMS].index(plat)
|
|
|
|
|
|
def _app_table(rows):
|
|
"""Platform / Arch / Download, one row per (platform, arch)."""
|
|
out = ['<div class="table-wrap"><table class="dl-table">',
|
|
"<thead><tr><th scope=\"col\">Platform</th>"
|
|
"<th scope=\"col\">Arch</th>"
|
|
"<th scope=\"col\">Download</th></tr></thead>"]
|
|
by_plat = []
|
|
for plat, arch, label, a in rows:
|
|
if not by_plat or by_plat[-1][0] != plat:
|
|
by_plat.append((plat, []))
|
|
grp = by_plat[-1][1]
|
|
if not grp or grp[-1][0] != arch:
|
|
grp.append((arch, []))
|
|
grp[-1][1].append((label, a))
|
|
for plat, archs in by_plat:
|
|
out.append("<tbody>")
|
|
for i, (arch, files) in enumerate(archs):
|
|
cells = []
|
|
if i == 0:
|
|
cells.append('<th scope="rowgroup" rowspan="%d">%s</th>'
|
|
% (len(archs), _plat_cell(plat)))
|
|
cells.append("<td>%s</td>" % (_arch_cell(arch) if arch else ""))
|
|
cells.append('<td class="files">%s</td>' % " ".join(
|
|
'<span class="file">%s<small>%s</small></span>'
|
|
% (_dl(a, _esc(label)), _esc(_size(a.get("size"))))
|
|
for label, a in files))
|
|
out.append("<tr>%s</tr>" % "".join(cells))
|
|
out.append("</tbody>")
|
|
out.append("</table></div>")
|
|
return "\n".join(out)
|
|
|
|
|
|
def _chip(asset, target, ext, arch=None):
|
|
return ('<li><a class="chip" href="%s">%s<span class="t">%s</span>'
|
|
'<small>%s · %s</small></a></li>'
|
|
% (_esc(asset["browser_download_url"]),
|
|
_icon(arch_icon(arch if arch is not None else target)),
|
|
_esc(target), _esc(ext), _esc(_size(asset.get("size")))))
|
|
|
|
|
|
def _core_groups(core, debug):
|
|
"""One <details> per platform: icon, name and count in the summary,
|
|
a grid of download chips inside."""
|
|
by = {}
|
|
for plat, tgt, ext, a in core:
|
|
by.setdefault(plat, []).append((tgt, ext, a))
|
|
out = []
|
|
for plat, _l, _i in PLATFORMS:
|
|
items = by.get(plat)
|
|
if not items:
|
|
continue
|
|
hint = ""
|
|
if plat == "rpi":
|
|
hint = ('<p class="dl-hint">Match RetroArch's word size, not '
|
|
'the kernel's: <code>rpi4</code> is 32-bit, '
|
|
'<code>rpi4_64</code> is 64-bit.</p>')
|
|
out.append(
|
|
'<details class="plat"%s><summary>%s<span class="count">%d '
|
|
'build%s</span></summary>%s<ul class="chips">%s</ul></details>'
|
|
% (" open" if plat in PLATFORMS_OPEN else "", _plat_cell(plat),
|
|
len(items), "" if len(items) == 1 else "s", hint,
|
|
"".join(_chip(a, tgt, ext) for tgt, ext, a in items)))
|
|
if debug:
|
|
out.append(
|
|
'<details class="plat"><summary><span class="plat-name">%s'
|
|
'Debug symbols</span><span class="count">%d bundle%s</span>'
|
|
'</summary><ul class="chips">%s</ul></details>'
|
|
% (_icon("box"), len(debug), "" if len(debug) == 1 else "s",
|
|
"".join(_chip(a, tgt, ".debug.tar.gz")
|
|
for _p, tgt, a in debug)))
|
|
return "\n".join(out)
|
|
|
|
|
|
def _tools_table(cls):
|
|
rows = []
|
|
for tool, plat, arch, a in sorted(
|
|
cls["tools"], key=lambda t: (t[0], _plat_order(t[1]
|
|
if t[1] in PLATFORM else "other"))):
|
|
rows.append(
|
|
'<tr><td><code>%s</code></td><td>%s%s</td><td>%s</td>'
|
|
'<td>%s</td></tr>'
|
|
% (_esc(tool), _plat_cell(plat) if plat in PLATFORM else _esc(plat),
|
|
(" " + _arch_cell(arch)) if arch else "",
|
|
_dl(a, _esc(a["name"])), _esc(_size(a.get("size")))))
|
|
if cls["info"]:
|
|
a = cls["info"]
|
|
rows.append('<tr><td>core info</td><td>any</td><td>%s</td><td>%s</td></tr>'
|
|
% (_dl(a, _esc(a["name"])), _esc(_size(a.get("size")))))
|
|
for a in cls["other"]:
|
|
rows.append('<tr><td>other</td><td></td><td>%s</td><td>%s</td></tr>'
|
|
% (_dl(a, _esc(a["name"])), _esc(_size(a.get("size")))))
|
|
if not rows:
|
|
return ""
|
|
return ('<div class="table-wrap"><table class="dl-table">'
|
|
'<thead><tr><th scope="col">Tool</th><th scope="col">Platform</th>'
|
|
'<th scope="col">File</th><th scope="col">Size</th></tr></thead>'
|
|
'<tbody>%s</tbody></table></div>' % "".join(rows))
|
|
|
|
|
|
def _panel_id(tag):
|
|
return "rel-" + re.sub(r"[^\w.-]", "_", tag)
|
|
|
|
|
|
def render_panel(rel, kind, hidden, app_in=(), app_only=False):
|
|
"""One release's panel. kind is 'latest', 'nightly' or 'older'.
|
|
app_in: [(tag, url, label)] of the releases that ship the desktop app,
|
|
for the pointer shown when this one does not. app_only: the app page's
|
|
copy, which lists the app packages and nothing else (callers only pass
|
|
releases that carry the app)."""
|
|
tag = rel["tag_name"]
|
|
cls = classify_assets(rel["assets"])
|
|
url = rel.get("html_url") or "%s/releases/tag/%s" % (REPO_URL, tag)
|
|
date = (rel.get("published_at") or "")[:10]
|
|
badge = {"latest": ' <span class="badge good">latest</span>',
|
|
"nightly": ' <span class="badge warn">nightly</span>',
|
|
"older": ""}[kind]
|
|
meta = ["<strong>%s</strong>%s" % (_esc("nightly build" if kind == "nightly"
|
|
else tag), badge)]
|
|
if date:
|
|
meta.append("<time datetime=\"%s\">%s</time>" % (_esc(date), _esc(date)))
|
|
meta.append('<a href="%s">%s</a>' % (
|
|
_esc(url), "release page" if kind == "nightly" else "release notes"))
|
|
if cls["sums"]:
|
|
s = cls["sums"]
|
|
meta.append(_dl(s, "SHA256SUMS.txt") + " <small>(%s)</small>"
|
|
% _esc(_size(s.get("size"))))
|
|
out = ['<div class="dl-panel" id="%s" data-rel="%s" data-kind="%s"%s>'
|
|
% (_esc(_panel_id(tag)), _esc(tag), kind, " hidden" if hidden else ""),
|
|
'<p class="dl-meta">%s</p>' % " · ".join(meta)]
|
|
if kind == "nightly":
|
|
out.append('<p class="dl-note">Rebuilt from <code>develop</code> on '
|
|
'every push that compiles; gated on compiling, not on the '
|
|
'test suite. File names never change, so these links '
|
|
'always fetch the newest build.</p>')
|
|
|
|
if app_only:
|
|
out.append(_app_table(cls["app"]))
|
|
out.append("</div>")
|
|
return "\n".join(out)
|
|
out.append('<h3 class="dl-h">Desktop app</h3>')
|
|
if cls["app"]:
|
|
out.append(_app_table(cls["app"]))
|
|
else:
|
|
# Point at the builds that do carry it (data-rel-link: download.js
|
|
# switches the panel; the href is the GitHub page without it).
|
|
pointers = " or ".join(
|
|
'<a href="%s" data-rel-link="%s">%s</a>' % (_esc(u), _esc(t), l)
|
|
for t, u, l in app_in if t != tag)
|
|
out.append('<p class="dl-empty">No desktop app in this release.%s</p>'
|
|
% ((" It is in the %s." % pointers) if pointers else ""))
|
|
n_core = len(cls["core"])
|
|
out.append('<h3 class="dl-h">RetroArch core <small>%d builds</small></h3>'
|
|
% n_core)
|
|
if n_core or cls["debug"]:
|
|
out.append(_core_groups(cls["core"], cls["debug"]))
|
|
else:
|
|
out.append('<p class="dl-empty">No core builds are attached to this '
|
|
'release.</p>')
|
|
tools = _tools_table(cls)
|
|
if tools:
|
|
out.append(
|
|
'<details class="plat tools" id="tools-%s"><summary>'
|
|
'<span class="plat-name">%sTools and info file</span>'
|
|
'<span class="count">%s</span></summary>'
|
|
'<p class="dl-hint"><code>jagcd-tools</code> is a session-aware '
|
|
'<code>chdman</code> for Jaguar CD images; <code>cue2cdi</code> '
|
|
'converts CUE/BIN to CDI.</p>%s</details>'
|
|
% (_esc(_panel_id(tag)[4:]), _icon("wrench"),
|
|
"%d files" % (len(cls["tools"]) + bool(cls["info"])
|
|
+ len(cls["other"])), tools))
|
|
out.append("</div>")
|
|
return "\n".join(out)
|
|
|
|
|
|
def _installer_widget(what=None):
|
|
"""The one-line installers as tabs (JS) or three stacked blocks (no JS).
|
|
download.js rewrites the commands for the selected release from the
|
|
data-cmd / data-base / data-what attributes. what: preselect --what
|
|
(the app page passes 'app')."""
|
|
sh = "curl -fsSL %s/tools/install.sh | sh" % RAW_URL
|
|
ps = "irm %s/tools/install.ps1 | iex" % RAW_URL
|
|
sh_text = sh + (" -s -- --what %s" % what if what else "")
|
|
ps_text = ("$env:VJ_WHAT = '%s'; " % what if what else "") + ps
|
|
what_attr = ' data-what="%s"' % _esc(what) if what else ""
|
|
return """<details class="plat inst" id="installer">
|
|
<summary><span class="plat-name">%(i_term)sInstall with one command</span><span class="count">core, app or both, checksum-verified</span></summary>
|
|
<div class="inst-tabs" role="tablist" aria-label="Operating system" hidden>
|
|
<button type="button" role="tab" id="inst-t-sh" aria-controls="inst-p-sh" aria-selected="true">macOS, Linux, Pi</button>
|
|
<button type="button" role="tab" id="inst-t-ps" aria-controls="inst-p-ps" aria-selected="false" tabindex="-1">Windows</button>
|
|
<button type="button" role="tab" id="inst-t-pm" aria-controls="inst-p-pm" aria-selected="false" tabindex="-1">Package managers</button>
|
|
</div>
|
|
<div class="inst-panel" id="inst-p-sh" role="tabpanel" aria-labelledby="inst-t-sh">
|
|
<p class="inst-label"><strong>macOS, Linux, Raspberry Pi</strong></p>
|
|
<pre><code data-cmd="sh" data-base="%(sh)s"%(what_attr)s>%(sh_text)s</code></pre>
|
|
</div>
|
|
<div class="inst-panel" id="inst-p-ps" role="tabpanel" aria-labelledby="inst-t-ps">
|
|
<p class="inst-label"><strong>Windows</strong> (PowerShell)</p>
|
|
<pre><code data-cmd="ps" data-base="%(ps)s"%(what_attr)s>%(ps_text)s</code></pre>
|
|
</div>
|
|
<div class="inst-panel" id="inst-p-pm" role="tabpanel" aria-labelledby="inst-t-pm">
|
|
<p class="inst-label"><strong>Package managers</strong></p>
|
|
<p>Homebrew, winget and Scoop manifests are rendered by CI as templates in
|
|
<a href="%(repo)s/blob/develop/app/packaging/manifests">app/packaging/manifests</a>;
|
|
none is submitted yet. The Linux app also comes as a Flatpak bundle (table below).</p>
|
|
</div>
|
|
<p class="footnote">%(what_note)s; it verifies each file against
|
|
<code>SHA256SUMS.txt</code> and updates what you already have when run again. Selecting a release above
|
|
adds its <code>--channel</code>. Other options and install locations are on the
|
|
<a href="./#get">home page</a>.</p>
|
|
</details>""" % {"sh": _esc(sh), "ps": _esc(ps), "repo": REPO_URL,
|
|
"sh_text": _esc(sh_text), "ps_text": _esc(ps_text),
|
|
"what_attr": what_attr,
|
|
"what_note": ("<code>--what %s</code> is already filled in"
|
|
% _esc(what)) if what else
|
|
"It asks what to install (<code>--what core|app|both</code>)",
|
|
"i_term": _icon("terminal")}
|
|
|
|
|
|
ICON_SPRITE = """<svg class="sprite" width="0" height="0" aria-hidden="true" focusable="false">
|
|
<symbol id="i-laptop" viewBox="0 0 16 16"><rect x="3" y="3.5" width="10" height="7"/><path d="M1.5 12.5h13"/></symbol>
|
|
<symbol id="i-window" viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10"/><path d="M2 6h12M4 4.5h.5M6 4.5h.5"/></symbol>
|
|
<symbol id="i-terminal" viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10"/><path d="M4.5 6.5l2 1.5-2 1.5M8 10.5h3"/></symbol>
|
|
<symbol id="i-board" viewBox="0 0 16 16"><rect x="1.5" y="3" width="13" height="10"/><rect x="4.5" y="5.5" width="4" height="5"/><path d="M10.5 6h2M10.5 8h2M10.5 10h2"/></symbol>
|
|
<symbol id="i-phone" viewBox="0 0 16 16"><rect x="4.5" y="1.5" width="7" height="13" rx="1"/><path d="M7 12.5h2"/></symbol>
|
|
<symbol id="i-tablet" viewBox="0 0 16 16"><rect x="2.5" y="2" width="11" height="12" rx="1"/><path d="M8 12h.01"/></symbol>
|
|
<symbol id="i-globe" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c-2.5 2.5-2.5 9.5 0 12M8 2c2.5 2.5 2.5 9.5 0 12"/></symbol>
|
|
<symbol id="i-handheld" viewBox="0 0 16 16"><rect x="1.5" y="4.5" width="13" height="7" rx="1.5"/><path d="M5 6.5v3M3.5 8h3M11 7.5h.01M12.5 9h.01"/></symbol>
|
|
<symbol id="i-box" viewBox="0 0 16 16"><path d="M2 5l6-3 6 3v6l-6 3-6-3zM2 5l6 3 6-3M8 8v6"/></symbol>
|
|
<symbol id="i-cube" viewBox="0 0 16 16"><path d="M2 5l6-3 6 3v6l-6 3-6-3zM2 5l6 3 6-3M8 8v6"/></symbol>
|
|
<symbol id="i-wrench" viewBox="0 0 16 16"><path d="M10.5 2.5a3.5 3.5 0 0 0-3.2 4.8L2.5 12.1l1.4 1.4 4.8-4.8a3.5 3.5 0 0 0 4.8-3.2l-2 2-1.8-.4-.4-1.8z"/></symbol>
|
|
<symbol id="i-chip" viewBox="0 0 16 16"><rect x="4" y="4" width="8" height="8"/><path d="M6 1.5V4M10 1.5V4M6 12v2.5M10 12v2.5M1.5 6H4M1.5 10H4M12 6h2.5M12 10h2.5"/></symbol>
|
|
<symbol id="i-chip-arm" viewBox="0 0 16 16"><rect x="4" y="4" width="8" height="8"/><circle cx="8" cy="8" r="1.4"/><path d="M6 1.5V4M10 1.5V4M6 12v2.5M10 12v2.5M1.5 6H4M1.5 10H4M12 6h2.5M12 10h2.5"/></symbol>
|
|
<symbol id="i-layers" viewBox="0 0 16 16"><rect x="2" y="2" width="8" height="8"/><rect x="6" y="6" width="8" height="8"/></symbol>
|
|
</svg>"""
|
|
|
|
|
|
def render_downloads(releases, app_only=False):
|
|
"""The body of the Download page below the lede: one window holding the
|
|
release picker, the installer widget, one panel per release and the
|
|
verification note.
|
|
|
|
app_only: the app page's "Get it" window -- the same picker, but only
|
|
over releases that ship the app, each panel just the app table, and the
|
|
installer preset to --what app."""
|
|
releases = releases or {}
|
|
latest = releases.get("latest")
|
|
nightly = releases.get("nightly")
|
|
older = releases.get("older") or []
|
|
if app_only:
|
|
return _render_app_downloads(latest, nightly, older)
|
|
if not latest:
|
|
return """<section class="win" id="app">
|
|
<h2>Releases</h2>
|
|
<div class="win-body">
|
|
<p>
|
|
This copy of the site was built without release data, so here are the
|
|
release pages: the <a href="%(repo)s/releases/latest">latest release</a>
|
|
(the desktop app packages and the RetroArch core for every platform)
|
|
and the <a href="%(repo)s/releases/tag/nightly">nightly build</a>.
|
|
</p>
|
|
</div>
|
|
</section>""" % {"repo": REPO_URL}
|
|
|
|
ltag = latest["tag_name"]
|
|
def rel_url(rel):
|
|
return rel.get("html_url") or "%s/releases/tag/%s" % (
|
|
REPO_URL, rel["tag_name"])
|
|
|
|
app_in = []
|
|
if _has_app(latest):
|
|
app_in.append((ltag, rel_url(latest), "latest release"))
|
|
if nightly and _has_app(nightly):
|
|
app_in.append((nightly["tag_name"], rel_url(nightly), "nightly build"))
|
|
panels = []
|
|
if nightly:
|
|
panels.append(render_panel(nightly, "nightly", True, app_in))
|
|
panels.append(render_panel(latest, "latest", False, app_in))
|
|
for r in older:
|
|
panels.append(render_panel(r, "older", True, app_in))
|
|
|
|
# The picker. Hidden until download.js runs: without it there is
|
|
# nothing to operate, so the no-JS note below takes its place.
|
|
older_opts = "".join(
|
|
'<option value="%s">%s%s</option>' % (
|
|
_esc(r["tag_name"]), _esc(r["tag_name"]),
|
|
_esc(" (%s)" % r["published_at"][:10]) if r.get("published_at")
|
|
else "") for r in older)
|
|
picker = ['<div class="dl-picker" hidden>',
|
|
'<span class="dl-label" id="dl-pick-label">Release</span>',
|
|
'<div class="dl-seg" role="group" aria-labelledby="dl-pick-label">']
|
|
if nightly:
|
|
picker.append('<button type="button" class="dl-btn" data-rel="nightly" '
|
|
'aria-pressed="false">Nightly</button>')
|
|
picker.append('<button type="button" class="dl-btn" data-rel="%s" '
|
|
'aria-pressed="true">Latest <code>%s</code></button>'
|
|
% (_esc(ltag), _esc(ltag)))
|
|
picker.append("</div>")
|
|
if older:
|
|
picker.append(
|
|
'<label class="dl-older"><span class="sr-only">Older releases'
|
|
'</span><select id="dl-older"><option value="">Older releases '
|
|
'(%d)</option>%s</select></label>' % (len(older), older_opts))
|
|
picker.append('<p class="sr-only" id="dl-status" role="status" '
|
|
'aria-live="polite"></p></div>')
|
|
|
|
nojs = ('<p class="dl-nojs">Showing %s, the latest release. '
|
|
'<a href="%s/releases/tag/nightly">Nightly build</a> · '
|
|
'<a href="%s/releases">All releases</a> on GitHub.</p>'
|
|
% (_esc(ltag), REPO_URL, REPO_URL))
|
|
|
|
return """%(sprite)s
|
|
<section class="win dl" id="app">
|
|
<h2>Downloads</h2>
|
|
<div class="win-body">
|
|
%(nojs)s
|
|
%(picker)s
|
|
%(installer)s
|
|
%(panels)s
|
|
<p class="footnote" id="verify">
|
|
Compare a download's SHA-256 with its line in <code>SHA256SUMS.txt</code>
|
|
(<code>shasum -a 256 <file></code> on macOS and Linux,
|
|
<code>Get-FileHash <file></code> in PowerShell). The macOS app is
|
|
Developer ID signed and notarized; the Windows installer is not yet
|
|
code-signed, so SmartScreen may warn; the core files are unsigned.
|
|
<a href="%(repo)s/blob/develop/app/packaging/README.md">Packaging details</a>.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
<script src="assets/js/download.js" defer></script>""" % {
|
|
"sprite": ICON_SPRITE, "nojs": nojs, "picker": "\n".join(picker),
|
|
"installer": _installer_widget(), "panels": "\n".join(panels),
|
|
"repo": REPO_URL}
|
|
|
|
|
|
def _has_app(rel):
|
|
return any(APP_ASSET_RE.match(a["name"]) for a in rel["assets"])
|
|
|
|
|
|
def _render_app_downloads(latest, nightly, older):
|
|
"""{{APP_DOWNLOADS}}: see render_downloads(app_only=True). The section
|
|
id is "get" (the app page's existing anchor); download.js finds it by
|
|
its .dl class."""
|
|
rels = [r for r in [latest] + list(older) if r and _has_app(r)]
|
|
if not rels:
|
|
return """<section class="win" id="get">
|
|
<h2>Get it</h2>
|
|
<div class="win-body">
|
|
<p>
|
|
The app packages are attached to each
|
|
<a href="%(repo)s/releases">GitHub release</a> next to the core builds;
|
|
the <a href="%(repo)s/releases/tag/nightly">nightly build</a> carries
|
|
them too.
|
|
</p>
|
|
</div>
|
|
</section>""" % {"repo": REPO_URL}
|
|
|
|
head = rels[0]
|
|
htag = head["tag_name"]
|
|
panels = []
|
|
if nightly and _has_app(nightly):
|
|
panels.append(render_panel(nightly, "nightly", True, app_only=True))
|
|
panels.append(render_panel(head, "latest", False, app_only=True))
|
|
for r in rels[1:]:
|
|
panels.append(render_panel(r, "older", True, app_only=True))
|
|
|
|
picker = ['<div class="dl-picker" hidden>',
|
|
'<span class="dl-label" id="dl-pick-label">Release</span>',
|
|
'<div class="dl-seg" role="group" aria-labelledby="dl-pick-label">']
|
|
if nightly and _has_app(nightly):
|
|
picker.append('<button type="button" class="dl-btn" data-rel="nightly" '
|
|
'aria-pressed="false">Nightly</button>')
|
|
picker.append('<button type="button" class="dl-btn" data-rel="%s" '
|
|
'aria-pressed="true">Latest <code>%s</code></button>'
|
|
% (_esc(htag), _esc(htag)))
|
|
picker.append("</div>")
|
|
if len(rels) > 1:
|
|
picker.append(
|
|
'<label class="dl-older"><span class="sr-only">Older releases'
|
|
'</span><select id="dl-older"><option value="">Older releases '
|
|
'(%d)</option>%s</select></label>' % (len(rels) - 1, "".join(
|
|
'<option value="%s">%s%s</option>' % (
|
|
_esc(r["tag_name"]), _esc(r["tag_name"]),
|
|
_esc(" (%s)" % r["published_at"][:10])
|
|
if r.get("published_at") else "") for r in rels[1:])))
|
|
picker.append('<p class="sr-only" id="dl-status" role="status" '
|
|
'aria-live="polite"></p></div>')
|
|
|
|
nojs = ('<p class="dl-nojs">Showing %s, the latest release. '
|
|
'<a href="%s/releases/tag/nightly">Nightly build</a> · '
|
|
'<a href="download.html">Download page</a> (the RetroArch core '
|
|
'and older releases).</p>' % (_esc(htag), REPO_URL))
|
|
|
|
return """%(sprite)s
|
|
<section class="win dl" id="get">
|
|
<h2>Get it</h2>
|
|
<div class="win-body">
|
|
<p>
|
|
The packages are attached to each
|
|
<a href="%(repo)s/releases">GitHub release</a> next to the core builds
|
|
and listed in its <code>SHA256SUMS.txt</code>. Pick a release; the
|
|
RetroArch core builds are on the <a href="download.html">Download</a>
|
|
page.
|
|
</p>
|
|
%(nojs)s
|
|
%(picker)s
|
|
%(installer)s
|
|
%(panels)s
|
|
<p class="footnote" id="verify">
|
|
Compare a download's SHA-256 with its line in <code>SHA256SUMS.txt</code>
|
|
(<code>shasum -a 256 <file></code> on macOS and Linux,
|
|
<code>Get-FileHash <file></code> in PowerShell). The macOS app is
|
|
Developer ID signed and notarized, so it opens without a Gatekeeper
|
|
prompt; the Windows installer is not yet code-signed, so SmartScreen may
|
|
warn. Builds you make yourself, and builds from forks, are unsigned.
|
|
<a href="%(repo)s/blob/develop/app/packaging/README.md">Packaging details</a>.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
<script src="assets/js/download.js" defer></script>""" % {
|
|
"sprite": ICON_SPRITE, "nojs": nojs, "picker": "\n".join(picker),
|
|
"installer": _installer_widget("app"), "panels": "\n".join(panels),
|
|
"repo": REPO_URL}
|
|
|
|
|
|
def latest_link_html(releases):
|
|
"""For the home page: 'v3.8.0' linked to its release, or the releases list."""
|
|
latest = (releases or {}).get("latest")
|
|
if latest:
|
|
url = latest.get("html_url") or "%s/releases/tag/%s" % (
|
|
REPO_URL, latest["tag_name"])
|
|
return 'latest: <a href="%s"><strong>%s</strong></a>' % (
|
|
html.escape(url), html.escape(latest["tag_name"]))
|
|
return '<a href="%s/releases">Releases</a>' % REPO_URL
|
|
|
|
|
|
# ------------------------------------------------------------ release log
|
|
#
|
|
# The home page's release log is generated from docs/RELEASE_NOTES_v*.md, so a
|
|
# new release appears on the site as soon as its notes are committed. Each
|
|
# row's summary is the notes file's own opening paragraph (the bold tagline
|
|
# under the "# Virtual Jaguar libretro vX.Y.Z" heading), trimmed to whole
|
|
# sentences. Nothing is typed into the page by hand.
|
|
|
|
NOTES_DIR = ROOT / "docs"
|
|
NOTES_RE = re.compile(r"^RELEASE_NOTES_v(\d+)\.(\d+)\.(\d+)\.md$")
|
|
RELEASE_LOG_MIN_MAJOR = 3 # v2.x predates the libretro rework; GitHub has it
|
|
RELEASE_LOG_SHOWN = 5 # newest rows visible; the rest sit in a <details>
|
|
RELEASE_LOG_SUMMARY_MAX = 240 # characters, cut at a sentence boundary
|
|
RELEASE_LOG_SUMMARY_MIN = 80 # a shorter opener takes the next sentence too
|
|
SENTENCE_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z])")
|
|
|
|
|
|
def release_summary(text):
|
|
"""Opening paragraph of a notes file as one plain-text summary.
|
|
|
|
Skips the H1, takes the first paragraph, drops markdown emphasis, code
|
|
ticks, links and "Closes ..." bookkeeping sentences, then keeps whole
|
|
sentences up to RELEASE_LOG_SUMMARY_MAX characters (at least
|
|
RELEASE_LOG_SUMMARY_MIN, so a one-line opener is not the whole row).
|
|
"""
|
|
lines = text.splitlines()
|
|
i = 0
|
|
while i < len(lines) and (not lines[i].strip() or lines[i].startswith("#")):
|
|
i += 1
|
|
para = []
|
|
while i < len(lines) and lines[i].strip() and not lines[i].startswith(
|
|
("#", "---", "- ", "* ")):
|
|
para.append(lines[i].strip())
|
|
i += 1
|
|
flat = " ".join(para)
|
|
flat = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", flat)
|
|
flat = re.sub(r"https?://\S+", "", flat)
|
|
flat = flat.replace("**", "").replace("`", "")
|
|
flat = re.sub(r"\s*\(#\d+[^)]*\)", "", flat)
|
|
flat = re.sub(r"\s+", " ", flat).strip()
|
|
sentences = [x for x in SENTENCE_RE.split(flat)
|
|
if x and not x.startswith("Closes")
|
|
and not re.search(r"\d+ commits", x)]
|
|
out = ""
|
|
for sent in sentences:
|
|
if (len(out) >= RELEASE_LOG_SUMMARY_MIN
|
|
and len(out) + 1 + len(sent) > RELEASE_LOG_SUMMARY_MAX):
|
|
break
|
|
out = (out + " " + sent).strip()
|
|
return normalize_dashes(out)
|
|
|
|
|
|
def load_release_notes(notes_dir=NOTES_DIR):
|
|
"""[(version tuple, 'vX.Y.Z', summary)] newest first, v3+ only."""
|
|
found = []
|
|
for path in notes_dir.glob("RELEASE_NOTES_v*.md"):
|
|
m = NOTES_RE.match(path.name)
|
|
if not m:
|
|
continue
|
|
ver = tuple(int(x) for x in m.groups())
|
|
if ver[0] < RELEASE_LOG_MIN_MAJOR:
|
|
continue
|
|
summary = release_summary(path.read_text(encoding="utf-8"))
|
|
if not summary:
|
|
die("%s: no opening paragraph to use as the release summary"
|
|
% path.name)
|
|
found.append((ver, "v%d.%d.%d" % ver, summary))
|
|
if not found:
|
|
die("no docs/RELEASE_NOTES_v*.md files found for the release log")
|
|
found.sort(reverse=True)
|
|
return found
|
|
|
|
|
|
def drop_unreleased(entries, releases):
|
|
"""With release data in hand, hide notes newer than the latest release.
|
|
|
|
Notes are committed before the tag exists (docs/release-process.md), and a
|
|
row whose release link 404s would announce a release that has not
|
|
shipped. Without release data (offline build) every notes file shows.
|
|
"""
|
|
tag = ((releases or {}).get("latest") or {}).get("tag_name", "")
|
|
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag)
|
|
if not m:
|
|
return entries
|
|
newest = tuple(int(x) for x in m.groups())
|
|
return [e for e in entries if e[0] <= newest]
|
|
|
|
|
|
def render_release_log(entries):
|
|
"""The home page's release table, newest first; older rows folded."""
|
|
def row(tag, summary):
|
|
return (
|
|
" <tr>\n"
|
|
" <td><strong>%(tag)s</strong></td>\n"
|
|
" <td>%(summary)s</td>\n"
|
|
" <td><a href=\"%(repo)s/releases/tag/%(tag)s\">release</a>"
|
|
" ·\n"
|
|
" <a href=\"%(repo)s/blob/develop/docs/"
|
|
"RELEASE_NOTES_%(tag)s.md\">notes</a></td>\n"
|
|
" </tr>\n" % {"tag": tag, "repo": REPO_URL,
|
|
"summary": html.escape(summary)})
|
|
|
|
def table(rows):
|
|
return (" <div class=\"table-wrap\"><table class=\"release-log\">\n"
|
|
" <tr><th>Version</th><th>What landed</th>"
|
|
"<th>Links</th></tr>\n" + "".join(rows) +
|
|
" </table></div>")
|
|
|
|
rows = [row(tag, summary) for _, tag, summary in entries]
|
|
out = table(rows[:RELEASE_LOG_SHOWN])
|
|
if rows[RELEASE_LOG_SHOWN:]:
|
|
out += (
|
|
"\n <details class=\"fold\">\n"
|
|
" <summary>Older releases (%s to %s)</summary>\n%s\n"
|
|
" </details>"
|
|
% (entries[RELEASE_LOG_SHOWN][1], entries[-1][1],
|
|
table(rows[RELEASE_LOG_SHOWN:])))
|
|
out += ("\n <p class=\"footnote\">Every release, with its binaries, is "
|
|
"on the <a href=\"%s/releases\">releases page</a>.</p>" % REPO_URL)
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- pages
|
|
|
|
META_RE = re.compile(r"<!--\s*(title|nav|description):\s*(.*?)\s*-->")
|
|
REQUIRED_META = ("title", "nav", "description")
|
|
|
|
|
|
def read_fragment(path):
|
|
text = path.read_text(encoding="utf-8")
|
|
meta = dict(META_RE.findall(text))
|
|
for key in REQUIRED_META:
|
|
if not meta.get(key):
|
|
die("%s is missing a non-empty '<!-- %s: ... -->' comment"
|
|
% (path, key))
|
|
# Search engines truncate around 160 characters; 200 leaves slack while
|
|
# still catching "someone pasted a paragraph in here".
|
|
if len(meta["description"]) > 200:
|
|
die("%s: description is %d characters (max 200) -- it would be "
|
|
"truncated in results:\n %r"
|
|
% (path, len(meta["description"]), meta["description"]))
|
|
body = META_RE.sub("", text).strip()
|
|
return meta, body
|
|
|
|
|
|
# ------------------------------------------------------------ SEO / metadata
|
|
|
|
def page_url(page_name):
|
|
"""Absolute, canonical URL for a page. Single source of truth for
|
|
rel=canonical, og:url and the sitemap <loc> -- they must not diverge."""
|
|
if page_name == "index.html":
|
|
return SITE_BASE
|
|
return SITE_BASE + page_name
|
|
|
|
|
|
def page_href(page_name):
|
|
"""Relative link to a page, matching page_url()'s choice of form.
|
|
|
|
The home page is reachable both as `.../` and `.../index.html`; the
|
|
canonical is the directory form, so every internal link must use `./` --
|
|
otherwise the site's own links tell crawlers the home page is the URL
|
|
rel=canonical says it isn't. check_site.py asserts no href is
|
|
"index.html".
|
|
"""
|
|
if page_name == "index.html":
|
|
return "./"
|
|
return page_name
|
|
|
|
|
|
def json_ld(obj):
|
|
"""Serialize structured data from a dict, so it is valid JSON by
|
|
construction. '<' is escaped: no payload can close the <script>."""
|
|
return json.dumps(obj, indent=2, ensure_ascii=False).replace("<", "\\u003c")
|
|
|
|
|
|
def software_application_ld(version, share_url, description):
|
|
"""Describes the core itself. Deliberately omits aggregateRating and
|
|
review: we have no such data, and inventing it is out of the question."""
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "SoftwareApplication",
|
|
"name": SITE_NAME,
|
|
"alternateName": "virtualjaguar_libretro",
|
|
"description": description,
|
|
"applicationCategory": "GameApplication",
|
|
"applicationSubCategory": "Emulator",
|
|
# Exactly the targets .github/workflows/release.yml builds.
|
|
"operatingSystem": ["Linux", "macOS", "Windows", "Android", "iOS",
|
|
"tvOS", "Web browser (WebAssembly)"],
|
|
"softwareVersion": version,
|
|
"license": "https://www.gnu.org/licenses/gpl-3.0.html",
|
|
"isAccessibleForFree": True,
|
|
"programmingLanguage": "C",
|
|
"url": SITE_BASE,
|
|
"image": share_url,
|
|
"codeRepository": REPO_URL,
|
|
"downloadUrl": REPO_URL + "/releases",
|
|
"softwareHelp": {
|
|
"@type": "CreativeWork",
|
|
"name": "Virtual Jaguar core documentation (docs.libretro.com)",
|
|
"url": LIBRETRO_DOCS,
|
|
},
|
|
# The showcase site is published by Provenance; the Organization node
|
|
# itself lives on provenance-emu.com and is referenced by @id.
|
|
"publisher": {
|
|
"@type": "Organization",
|
|
"@id": PROVENANCE_ORG_ID,
|
|
"name": "Provenance",
|
|
"url": "https://provenance-emu.com/",
|
|
},
|
|
}
|
|
|
|
|
|
def breadcrumb_ld(page_name, label):
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
"itemListElement": [
|
|
{"@type": "ListItem", "position": 1, "name": SITE_NAME,
|
|
"item": SITE_BASE},
|
|
{"@type": "ListItem", "position": 2, "name": label,
|
|
"item": page_url(page_name)},
|
|
],
|
|
}
|
|
|
|
|
|
def head_meta(page_name, meta, ctx):
|
|
"""The whole per-page <head> SEO block, generated -- never hand-written
|
|
into a fragment, so the tags can't drift apart page to page."""
|
|
url = page_url(page_name)
|
|
title = meta["title"]
|
|
desc = meta["description"]
|
|
share = ctx["share"][page_name]
|
|
esc = lambda s: html.escape(s, quote=True)
|
|
out = [
|
|
'<title>%s</title>' % html.escape(title),
|
|
'<meta name="description" content="%s">' % esc(desc),
|
|
'<link rel="canonical" href="%s">' % esc(url),
|
|
'<meta property="og:type" content="website">',
|
|
'<meta property="og:site_name" content="%s">' % esc(SITE_NAME),
|
|
'<meta property="og:locale" content="en_US">',
|
|
'<meta property="og:title" content="%s">' % esc(title),
|
|
'<meta property="og:description" content="%s">' % esc(desc),
|
|
'<meta property="og:url" content="%s">' % esc(url),
|
|
'<meta property="og:image" content="%s">' % esc(share["url"]),
|
|
'<meta property="og:image:type" content="image/png">',
|
|
'<meta property="og:image:width" content="%d">' % share["w"],
|
|
'<meta property="og:image:height" content="%d">' % share["h"],
|
|
'<meta property="og:image:alt" content="%s">' % esc(share["alt"]),
|
|
'<meta name="twitter:card" content="summary_large_image">',
|
|
'<meta name="twitter:title" content="%s">' % esc(title),
|
|
'<meta name="twitter:description" content="%s">' % esc(desc),
|
|
'<meta name="twitter:image" content="%s">' % esc(share["url"]),
|
|
'<meta name="twitter:image:alt" content="%s">' % esc(share["alt"]),
|
|
'<link rel="icon" href="favicon.ico" sizes="32x32">',
|
|
'<link rel="icon" href="%sfavicon.svg" type="image/svg+xml">'
|
|
% ICON_DIR,
|
|
] + [
|
|
'<link rel="icon" href="%sicon-%d.png" type="image/png" '
|
|
'sizes="%dx%d">' % (ICON_DIR, n, n, n) for n in ICON_PNG_SIZES
|
|
] + [
|
|
'<link rel="apple-touch-icon" href="%sapple-touch-icon.png">'
|
|
% ICON_DIR,
|
|
]
|
|
if page_name == "index.html":
|
|
data = software_application_ld(ctx["version"], share["url"], desc)
|
|
else:
|
|
data = breadcrumb_ld(page_name, meta["nav"])
|
|
out.append('<script type="application/ld+json">\n%s\n</script>'
|
|
% json_ld(data))
|
|
return "\n".join(out)
|
|
|
|
|
|
def layout(page_name, meta, body, nav_items, ctx):
|
|
nav = []
|
|
for fname, label in nav_items:
|
|
cls = ' class="active"' if fname == page_name else ""
|
|
nav.append('<a href="%s"%s>%s</a>'
|
|
% (page_href(fname), cls, html.escape(label)))
|
|
# NOTE: the site title is an <a>, not a heading, on purpose -- every page
|
|
# must have exactly one <h1> and it belongs to the page content.
|
|
#
|
|
# color-scheme is "dark light": dark is the primary theme (the console
|
|
# was a black box), light is a deliberate period-desktop variant, not a
|
|
# fallback. The order tells the UA which one to paint before CSS lands.
|
|
#
|
|
# The two <div class="hazard"> bars are the page's only pure decoration:
|
|
# a diagonal red stripe bookending the content, drawn in CSS. They are
|
|
# aria-hidden by being empty non-semantic divs.
|
|
return """<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="dark light">
|
|
%(headmeta)s
|
|
<link rel="stylesheet" href="style.css">
|
|
</head>
|
|
<body>
|
|
<header class="site-header"><div class="inner">
|
|
<a class="site-title" href="./">Virtual <span class="jag">Jaguar</span> libretro</a>
|
|
<nav class="site-nav" aria-label="Primary">%(nav)s</nav>
|
|
</div></header>
|
|
<div class="hazard"></div>
|
|
<main>
|
|
%(body)s
|
|
</main>
|
|
<div class="hazard"></div>
|
|
<footer class="site-footer"><div class="inner">
|
|
<p>
|
|
Virtual Jaguar libretro. GPLv3.
|
|
<a href="%(repo)s">Source on GitHub</a> ·
|
|
<a href="%(repo)s#readme">README</a> ·
|
|
<a href="%(repo)s/releases">releases</a> ·
|
|
<a href="%(repo)s/releases/tag/nightly">nightly build</a> ·
|
|
<a href="%(repo)s/discussions">Discussions</a> ·
|
|
<a href="%(repo)s/issues">Issues</a> ·
|
|
<a href="privacy.html">Privacy</a> ·
|
|
<a href="terms.html">Terms</a>.
|
|
</p>
|
|
<p>
|
|
Core options, controls and file extensions are documented in the
|
|
<a href="%(docs)s">official libretro documentation for this core</a> on
|
|
docs.libretro.com. That page is the reference manual. This site is the
|
|
project showcase.
|
|
</p>
|
|
<p>
|
|
Generated from committed repository data by
|
|
<a href="%(repo)s/blob/develop/scripts/build_site.py">scripts/build_site.py</a>.
|
|
Every claim links to its evidence.
|
|
</p>
|
|
<p class="family">
|
|
More from Provenance:
|
|
%(family)s
|
|
</p>
|
|
</div></footer>
|
|
</body>
|
|
</html>
|
|
""" % {"headmeta": head_meta(page_name, meta, ctx), "nav": "\n".join(nav),
|
|
"body": body, "repo": REPO_URL, "docs": LIBRETRO_DOCS,
|
|
"family": family_links_html()}
|
|
|
|
|
|
# ------------------------------------------------------ sitemap / robots
|
|
|
|
def render_sitemap(entries):
|
|
"""entries: [(absolute url, YYYY-MM-DD), ...]"""
|
|
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
|
|
for url, lastmod in entries:
|
|
out.append(" <url>")
|
|
out.append(" <loc>%s</loc>" % html.escape(url))
|
|
out.append(" <lastmod>%s</lastmod>" % lastmod)
|
|
out.append(" </url>")
|
|
out.append("</urlset>")
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def render_robots():
|
|
"""Honest about its own reach: robots.txt is only obeyed at a domain
|
|
root, and this file is published under a subdirectory of a domain this
|
|
repository does not own. It is emitted anyway because it costs nothing
|
|
and documents the sitemap location; discovery of the sitemap relies on
|
|
the absolute Sitemap: line below, not on this file being authoritative.
|
|
"""
|
|
return (
|
|
"# Virtual Jaguar libretro -- generated by scripts/build_site.py.\n"
|
|
"# robots.txt is only honoured at a domain root; this copy lives at\n"
|
|
"# " + SITE_BASE + "robots.txt, so treat it as documentation of the\n"
|
|
"# sitemap location rather than as this host's policy file.\n"
|
|
"User-agent: *\n"
|
|
"Allow: /\n"
|
|
"\n"
|
|
"Sitemap: " + SITE_BASE + "sitemap.xml\n")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--out", default=str(ROOT / "_site"),
|
|
help="output directory (default: _site/)")
|
|
ap.add_argument("--releases", default=None, metavar="JSON",
|
|
help="GitHub release data ({\"latest\": ..., \"nightly\": "
|
|
"..., \"older\": [...]}) for direct download links; without it the "
|
|
"Download page links the release pages instead")
|
|
ap.add_argument("--releases-from-github", action="store_true",
|
|
help="fetch that data from the GitHub API instead "
|
|
"(GH_TOKEN/GITHUB_TOKEN used when set); a failed "
|
|
"fetch fails the build")
|
|
ap.add_argument("--build-date", default=None, metavar="YYYY-MM-DD",
|
|
help="sitemap lastmod fallback when git history is "
|
|
"unavailable (default: today, UTC)")
|
|
args = ap.parse_args()
|
|
out = Path(args.out)
|
|
|
|
# Computed at run time, never baked into the source.
|
|
build_date = args.build_date or datetime.datetime.now(
|
|
datetime.timezone.utc).strftime("%Y-%m-%d")
|
|
if not ISO_DATE_RE.match(build_date):
|
|
die("--build-date must be YYYY-MM-DD, got %r" % build_date)
|
|
|
|
for p in ALL_PAGES:
|
|
if not (SITE_SRC / "pages" / p).is_file():
|
|
die("missing page fragment site/pages/%s" % p)
|
|
|
|
# Parse compatibility data first: a broken matrix must fail the build.
|
|
rows, build_ids = parse_cd_matrix(MATRIX_MD)
|
|
table_html, n_titles, n_good = render_cd_table(rows)
|
|
print("cd-boot-matrix: %d rows, %d disc images, build stamp(s): %s"
|
|
% (len(rows), n_titles, ", ".join(build_ids) or "(none)"))
|
|
|
|
cart_rows, cart_build_ids = parse_cart_matrix(CART_MATRIX_MD)
|
|
cart_table_html, cart_n_titles, cart_n_good = render_cart_table(cart_rows)
|
|
print("cart-boot-matrix: %d titles, build stamp(s): %s"
|
|
% (cart_n_titles, ", ".join(cart_build_ids) or "(none)"))
|
|
|
|
version = read_core_version(MAKEFILE)
|
|
if args.releases and args.releases_from_github:
|
|
die("--releases and --releases-from-github are exclusive")
|
|
releases = (load_releases(args.releases) if args.releases
|
|
else fetch_releases() if args.releases_from_github else None)
|
|
if releases:
|
|
print("releases: latest %s (%d assets), nightly %s, %d older"
|
|
% (releases.get("latest", {}).get("tag_name", "-"),
|
|
len(releases.get("latest", {}).get("assets", [])),
|
|
"present" if releases.get("nightly") else "absent",
|
|
len(releases.get("older") or [])))
|
|
else:
|
|
print("releases: none (no --releases/--releases-from-github); the "
|
|
"Download page links the release pages, not the files")
|
|
share = {}
|
|
for p in ALL_PAGES:
|
|
rel, alt = SHARE_IMAGES.get(p, SHARE_DEFAULT)
|
|
w, h = png_size(SITE_SRC / rel)
|
|
share[p] = {"url": SITE_BASE + rel, "alt": alt, "w": w, "h": h}
|
|
shallow = repo_is_shallow()
|
|
ctx = {"version": version, "share": share}
|
|
print("core version: %s | share images: %s | lastmod source: %s"
|
|
% (version, ", ".join(sorted(set(
|
|
"%s %dx%d" % (v["url"][len(SITE_BASE):], v["w"], v["h"])
|
|
for v in share.values()))),
|
|
"build date %s (shallow clone or no git)" % build_date
|
|
if shallow else "git log (fallback %s)" % build_date))
|
|
|
|
subs = {
|
|
"{{REPO}}": REPO_URL,
|
|
"{{RAW_URL}}": RAW_URL,
|
|
"{{CORE_VERSION}}": html.escape(version),
|
|
"{{DOCS_URL}}": LIBRETRO_DOCS,
|
|
"{{DOCS_OPTIONS_URL}}": LIBRETRO_DOCS_OPTIONS,
|
|
"{{DOCS_CONTROLS_URL}}": LIBRETRO_DOCS_CONTROLS,
|
|
"{{DOCS_COMPAT_URL}}": LIBRETRO_DOCS_COMPAT,
|
|
"{{DOCS_SRC_URL}}": LIBRETRO_DOCS_SRC,
|
|
"{{DOWNLOADS}}": render_downloads(releases),
|
|
"{{APP_DOWNLOADS}}": render_downloads(releases, app_only=True),
|
|
"{{LATEST_LINK}}": latest_link_html(releases),
|
|
"{{RELEASE_LOG}}": render_release_log(
|
|
drop_unreleased(load_release_notes(), releases)),
|
|
"{{COMPAT_TABLE}}": table_html,
|
|
"{{COMPAT_BUILD_IDS}}":
|
|
", ".join("<code>%s</code>" % html.escape(b) for b in build_ids)
|
|
or "no build stamp found",
|
|
"{{COMPAT_N_TITLES}}": str(n_titles),
|
|
"{{COMPAT_N_HLE_GOOD}}": str(n_good["hle"]),
|
|
"{{COMPAT_N_BIOS_GOOD}}": str(n_good["bios"]),
|
|
"{{CART_TABLE}}": cart_table_html,
|
|
"{{CART_BUILD_IDS}}":
|
|
", ".join("<code>%s</code>" % html.escape(b)
|
|
for b in cart_build_ids) or "no build stamp found",
|
|
"{{CART_N_TITLES}}": str(cart_n_titles),
|
|
"{{CART_N_HLE_GOOD}}": str(cart_n_good["hle"]),
|
|
"{{CART_N_BIOS_GOOD}}": str(cart_n_good["bios"]),
|
|
"{{CART_N_HLE_RUN}}":
|
|
"%d of %d" % (cart_n_good["hle_run"], cart_n_titles),
|
|
"{{CART_N_BIOS_RUN}}":
|
|
"%d of %d" % (cart_n_good["bios_run"], cart_n_titles),
|
|
}
|
|
|
|
# Fresh output dir. Only ever recursively delete a directory this
|
|
# script produced: a stray --out (a typo like `--out ..`, or a
|
|
# misconfigured workflow) must not wipe unrelated files. The marker
|
|
# is written on every successful build below.
|
|
marker = out / ".vj-generated-site"
|
|
if out.exists():
|
|
if not out.is_dir():
|
|
raise SystemExit("--out exists and is not a directory: %s" % out)
|
|
if any(out.iterdir()) and not marker.exists():
|
|
raise SystemExit(
|
|
"refusing to clear non-empty %s: no %s marker, so this "
|
|
"directory was not produced by this script. Remove it by "
|
|
"hand or build into a different --out." % (out, marker.name))
|
|
shutil.rmtree(out)
|
|
out.mkdir(parents=True)
|
|
marker.write_text("Generated by scripts/build_site.py. Safe to delete.\n")
|
|
|
|
# Static bits.
|
|
shutil.copy2(SITE_SRC / "style.css", out / "style.css")
|
|
shutil.copy2(SITE_SRC / ICON_DIR / "favicon.ico", out / "favicon.ico")
|
|
assets_src = SITE_SRC / "assets"
|
|
if assets_src.is_dir():
|
|
shutil.copytree(assets_src, out / "assets")
|
|
# Jaguar CD artwork mirror (libretro-thumbnails layout), served at
|
|
# /thumbnails/<System>/<Type>/<Name>.png; the desktop app falls back to it.
|
|
thumbs_src = SITE_SRC / "thumbnails"
|
|
if thumbs_src.is_dir():
|
|
shutil.copytree(thumbs_src, out / "thumbnails")
|
|
write_thumbnail_zips(thumbs_src, out / "thumbnails")
|
|
# Community cheat packs, generated by tools/cheat-import/build_packs.py
|
|
# and committed; served verbatim at /cheats/ (CI diffs the committed tree
|
|
# against a fresh regeneration, and check_site.py validates the copy).
|
|
cheats_src = SITE_SRC / "cheats"
|
|
if cheats_src.is_dir():
|
|
shutil.copytree(cheats_src, out / "cheats")
|
|
(out / ".nojekyll").write_text("")
|
|
|
|
nav_items = []
|
|
frags = {}
|
|
for p in ALL_PAGES:
|
|
meta, body = read_fragment(SITE_SRC / "pages" / p)
|
|
if p in PAGES:
|
|
nav_items.append((p, meta["nav"]))
|
|
frags[p] = (meta, body)
|
|
|
|
# Every page's rendered output depends on the generator's template too,
|
|
# and the compatibility page additionally on the parsed matrix doc.
|
|
common_deps = [Path(__file__).resolve(), SITE_SRC / "style.css"]
|
|
sitemap_entries = []
|
|
|
|
for p in ALL_PAGES:
|
|
meta, body = frags[p]
|
|
for k, v in subs.items():
|
|
body = body.replace(k, v)
|
|
leftover = re.search(r"\{\{[A-Z_]+\}\}", body)
|
|
if leftover:
|
|
die("unresolved placeholder %s in site/pages/%s"
|
|
% (leftover.group(0), p))
|
|
page = layout(p, meta, body, nav_items, ctx)
|
|
leftover = re.search(r"\{\{[A-Z_]+\}\}", page)
|
|
if leftover:
|
|
die("unresolved placeholder %s in the rendered %s (template?)"
|
|
% (leftover.group(0), p))
|
|
(out / p).write_text(page, encoding="utf-8")
|
|
|
|
deps = [SITE_SRC / "pages" / p] + common_deps
|
|
if p == "compatibility.html":
|
|
deps.append(MATRIX_MD)
|
|
if p == "index.html":
|
|
deps.extend(sorted(NOTES_DIR.glob("RELEASE_NOTES_v*.md")))
|
|
sitemap_entries.append(
|
|
(page_url(p), last_modified(deps, build_date, shallow)))
|
|
print("wrote %s" % (out / p))
|
|
|
|
(out / "sitemap.xml").write_text(render_sitemap(sitemap_entries),
|
|
encoding="utf-8")
|
|
(out / "robots.txt").write_text(render_robots(), encoding="utf-8")
|
|
print("wrote %s (%d urls) and %s"
|
|
% (out / "sitemap.xml", len(sitemap_entries), out / "robots.txt"))
|
|
|
|
print("site built: %s" % out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|