diff --git a/scripts/generate_site.py b/scripts/generate_site.py index f0576227..4c3b8f7f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -11,6 +11,7 @@ Usage: from __future__ import annotations +from siterender import WIKI_SRC_DIR import argparse import csv import hashlib @@ -44,64 +45,10 @@ from common import ( ) from nativemode import reads_file_contents -# Every path this run produced. The generated directories used to be deleted -# before the build, which made every page new: write_if_changed had no earlier -# version to compare against, so the whole site was rewritten for the clock -# alone and a deploy republished 600 unchanged pages. They are swept instead, -# and a page is only removed once nothing produced it. -_produced: set[str] = set() -def _record(path) -> None: - _produced.add(os.path.realpath(str(path))) -def _undecorated(markdown: str) -> str: - """Return a page body without the front matter the decoration pass adds.""" - if markdown.startswith("---\n") and "generated_by: retrobios-site" in markdown[:300]: - end = markdown.find("\n---\n", 4) - if end != -1: - markdown = markdown[end + 5:].lstrip("\n") - script_end = markdown.find("\n\n") - if markdown.startswith('\n\n"):] - return markdown - - -def write_if_changed(path: str, content: str) -> bool: - """Write a page body only when it moved, and remember it either way. - - A page is written twice: the body here, then the same body wrapped in - front matter by the decoration pass. This comparison therefore reduces - the file on disk to its body. The decoration pass compares in full - - normalizing there too would make its own write look like a no-op and - every page would lose its front matter. - """ - _record(path) - return _write_artifact(path, content, normalize=_undecorated) - - -def write_decorated(path, content: str) -> bool: - """Write the wrapped page, comparing front matter and body together.""" - _record(path) - return _write_artifact(str(path), content) - - -def _sweep_generated(docs: Path, directories: list[str]) -> int: - """Delete files in the generated tree that this run did not produce.""" - removed = 0 - for name in directories: - root = docs / name - if not root.is_dir(): - continue - for path in sorted(root.rglob("*")): - if path.is_file() and os.path.realpath(str(path)) not in _produced: - path.unlink() - removed += 1 - for path in sorted(root.rglob("*"), reverse=True): - if path.is_dir() and not any(path.iterdir()): - path.rmdir() - return removed yaml = require_yaml() from generate_readme import compute_coverage, manifest_totals @@ -115,161 +62,8 @@ REPO_URL = "https://github.com/Abdess/retrobios" RELEASE_URL = f"{REPO_URL}/releases/latest" SITE_URL = "https://abdess.github.io/retrobios/" GENERATED_DIRS = ["platforms", "systems", "emulators", "wiki", "api", "downloads"] -WIKI_SRC_DIR = "wiki" # manually maintained wiki sources -SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png" -ICON_CACHE_PATH = Path(".cache") / "system_icons.json" - -# Icon names confirmed to exist upstream. A name absent from this map has not -# been checked yet; a name mapped to False has no icon and gets none rendered, -# because a heading with a broken image reads worse than a heading without one. -_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]] = [] - seen: set[tuple[str, str, str]] = set() - source_repos: set[tuple[str, str]] = set() - - for field in ("source", "upstream"): - for value, pin, fallback in _repo_pins(profile, field, label): - repo = upstream.parse_repo(value) - if repo is None: - continue - repo_key = (repo.host, repo.slug) - if field == "upstream" and not pin and repo_key in source_repos: - pin = fallback - if not pin: - continue - key = (repo.host, repo.slug, pin) - if key not in seen: - candidates.append((repo, pin)) - seen.add(key) - if field == "source": - source_repos.add(repo_key) - return candidates - - -def _source_permalink(repo: upstream.Repo, pin: str, path: str, - start: int | None, end: int | None) -> str: - """Forge-specific browser URL for one file at one immutable revision.""" - quoted_path = urllib.parse.quote(path, safe="/@:+-._~") - base = f"https://{repo.host}/{repo.owner}/{repo.name}" - if repo.family == "github": - url = f"{base}/blob/{pin}/{quoted_path}" - elif repo.family == "gitlab": - url = f"{base}/-/blob/{pin}/{quoted_path}" - else: - url = f"{base}/src/commit/{pin}/{quoted_path}" - if start is not None: - if repo.family == "gitlab": - url += f"#L{start}" - if end is not None and end != start: - url += f"-{end}" - else: - url += f"#L{start}" - if end is not None and end != start: - url += f"-L{end}" - return url - - -def _source_ref_markdown(profile: dict, value) -> str: - """Render source_ref values as pinned links when their forge is known. - - A profile can declare two repositories: the libretro port in ``source`` and - the original emulator in ``upstream``. Which of the two carries a given - path cannot be decided without reading their trees, and this generator runs - offline. Rather than guess, an unattributable path is rendered as plain - code: a citation with no link still names the file and the lines, while a - link to the wrong repository is a false citation. - """ - rendered_groups: list[str] = [] - path_re = re.compile(r"^[A-Za-z0-9_.@/+~-]+$") - for label, refs in source_ref_values(value): - candidates = _forge_sources(profile, label) - rendered: list[str] = [] - for part in split_source_ref(refs): - display = part.path - if part.start is not None: - display += f":{part.start}" - if part.end is not None and part.end != part.start: - display += f"-{part.end}" - - selected: tuple[upstream.Repo, str] | None = None - real_path = part.path - if path_re.fullmatch(part.path) and candidates: - for repo, pin in candidates: - prefix = f"{repo.name}/" - if part.path.startswith(prefix): - selected = (repo, pin) - real_path = part.path[len(prefix):] - break - if selected is None and len(candidates) == 1: - selected = candidates[0] - - if selected is None: - rendered.append(f"`{display}`") - else: - repo, pin = selected - url = _source_permalink( - repo, pin, real_path, part.start, part.end - ) - rendered.append(f"[`{display}`]({url})") - - text = ", ".join(rendered) if rendered else f"`{refs}`" - if label: - text = f"**{label}:** {text}" - rendered_groups.append(text) - return "; ".join(rendered_groups) - - -def _admonition_body(text: str) -> str: - """Indent prose without turning source tokens such as ``#if`` into H1s.""" - escaped = re.sub(r"(?m)^(\s*)#", r"\1\\#", text) - return escaped.replace("\n", "\n ") def _content_check_ceiling(profiles: dict) -> str: @@ -301,67 +95,8 @@ def _content_check_ceiling(profiles: dict) -> str: ) -def _icon_name(manufacturer: str, console_name: str) -> str: - return f"{manufacturer} - {console_name}".replace("/", " ") -def _icon_url(icon_name: str) -> str: - return f"{SYSTEM_ICON_BASE}/{urllib.parse.quote(icon_name)}.png" - - -def prime_system_icons(names: set[str]) -> None: - """Record which system icons upstream actually serves. - - Results persist in ``.cache`` so later runs skip the network. Names that - cannot be checked stay unavailable: the site never links an image it has - not seen answer. - """ - cached: dict[str, bool] = {} - if ICON_CACHE_PATH.exists(): - try: - with open(ICON_CACHE_PATH) as f: - cached = json.load(f) - except (json.JSONDecodeError, OSError): - cached = {} - - unknown = sorted(n for n in names if n not in cached) - if unknown: - def check(name: str) -> tuple[str, bool | None]: - """True when served, False when upstream says it is gone. - - None on a transient failure, so a flaky run never records an - icon as absent for every later build. - """ - req = urllib.request.Request(_icon_url(name), method="HEAD") - try: - with urllib.request.urlopen(req, timeout=15) as resp: - return name, resp.status == 200 - except urllib.error.HTTPError as exc: - return name, False if exc.code == 404 else None - except (urllib.error.URLError, OSError): - return name, None - - print(f"Checking {len(unknown)} system icons...") - with ThreadPoolExecutor(max_workers=8) as pool: - for name, ok in pool.map(check, unknown): - if ok is not None: - cached[name] = ok - ICON_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(ICON_CACHE_PATH, "w") as f: - json.dump(cached, f, indent=2, sort_keys=True) - - _icon_available.update(cached) - missing = sum(1 for n in names if not cached.get(n)) - if missing: - print(f" {missing} systems have no upstream icon") - - -def system_icon_markdown(manufacturer: str, console_name: str) -> str: - """Icon image for a system heading, empty when upstream serves none.""" - name = _icon_name(manufacturer, console_name) - if not _icon_available.get(name): - return "" - return f"![{console_name}]({_icon_url(name)}){{ width=24 }} " CLS_LABELS = { "official_port": "Official ports", @@ -376,9 +111,6 @@ CLS_LABELS = { "other": "Other", } -# Global index: maps system_id -> (manufacturer_slug, console_name) for cross-linking -_system_page_map: dict[str, tuple[str, str]] = {} - def _build_system_page_map_from_data( manufacturers: dict, @@ -452,101 +184,15 @@ def _build_system_page_map_from_data( break -def _slugify_anchor(text: str) -> str: - """Slugify text for MkDocs anchor compatibility.""" - import re - - slug = text.lower() - slug = re.sub(r"[^\w\s-]", "", slug) - slug = re.sub(r"[\s]+", "-", slug) - slug = slug.strip("-") - return slug -def _system_link(sys_id: str, prefix: str = "") -> str: - """Generate a markdown link to a system page with anchor.""" - if sys_id in _system_page_map: - slug, console = _system_page_map[sys_id] - anchor = _slugify_anchor(console) - return f"[{sys_id}]({prefix}systems/{slug}.md#{anchor})" - return sys_id -def _render_yaml_value(lines: list[str], val, indent: int = 4) -> None: - """Render any YAML value as indented markdown.""" - pad = " " * indent - if isinstance(val, dict): - for k, v in val.items(): - if isinstance(v, dict): - lines.append(f"{pad}**{k}:**") - lines.append("") - _render_yaml_value(lines, v, indent + 4) - elif isinstance(v, list): - lines.append(f"{pad}**{k}:**") - lines.append("") - for item in v: - if isinstance(item, dict): - parts = [ - f"{ik}: {iv}" - for ik, iv in item.items() - if not isinstance(iv, (dict, list)) - ] - lines.append(f"{pad}- {', '.join(parts)}") - else: - lines.append(f"{pad}- {item}") - lines.append("") - else: - # Truncate very long strings in tables - sv = str(v) - if len(sv) > 200: - sv = sv[:200] + "..." - lines.append(f"{pad}- **{k}:** {sv}") - elif isinstance(val, list): - for item in val: - if isinstance(item, dict): - parts = [ - f"{ik}: {iv}" - for ik, iv in item.items() - if not isinstance(iv, (dict, list)) - ] - lines.append(f"{pad}- {', '.join(parts)}") - else: - lines.append(f"{pad}- {item}") - elif isinstance(val, str) and "\n" in val: - for line in val.split("\n"): - lines.append(f"{pad}{line}") - else: - lines.append(f"{pad}{val}") - - -def _platform_link(name: str, display: str, prefix: str = "") -> str: - """Generate a markdown link to a platform page.""" - return f"[{display}]({prefix}platforms/{name}.md)" - - -def _emulator_link(name: str, prefix: str = "") -> str: - """Generate a markdown link to an emulator page.""" - return f"[{name}]({prefix}emulators/{name}.md)" - def _timestamp() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -def _fmt_size(size: int) -> str: - if size >= 1024 * 1024 * 1024: - return f"{size / (1024**3):.1f} GB" - if size >= 1024 * 1024: - return f"{size / (1024**2):.1f} MB" - if size >= 1024: - return f"{size / 1024:.1f} KB" - return f"{size} B" - - -def _pct(n: int, total: int) -> str: - if total == 0: - return "0%" - return f"{n / total * 100:.1f}%" # Home page @@ -4265,6 +3911,39 @@ validation: if stale: print(f"Removed {stale} page(s) nothing produced this run") +from sitewrite import ( # noqa: E402,F401 + _produced, + _record, + _undecorated, + write_if_changed, + write_decorated, + _sweep_generated, +) +from sitesources import ( # noqa: E402,F401 + _repo_pins, + _forge_sources, + _source_permalink, + _source_ref_markdown, +) +from siterender import ( # noqa: E402,F401 + _by_mode, + _render_yaml_value, + _slugify_anchor, + _system_page_map, + _system_link, + _platform_link, + _emulator_link, + _fmt_size, + _pct, + _admonition_body, + _icon_name, + _icon_url, + system_icon_markdown, + prime_system_icons, + _icon_available, + SYSTEM_ICON_BASE, + ICON_CACHE_PATH, +) if __name__ == "__main__": main() diff --git a/scripts/siterender.py b/scripts/siterender.py new file mode 100644 index 00000000..e1018fcb --- /dev/null +++ b/scripts/siterender.py @@ -0,0 +1,179 @@ +"""Small renderings shared by every page.""" + +from __future__ import annotations + +import re + +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor +import json +import urllib.request +WIKI_SRC_DIR = "wiki" # manually maintained wiki sources +SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png" + +SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png" +ICON_CACHE_PATH = Path(".cache") / "system_icons.json" + +# Icon names confirmed to exist upstream. A name absent from this map has not +# been checked yet; a name mapped to False has no icon and gets none rendered, +# because a heading with a broken image reads worse than a heading without one. +_icon_available: dict[str, bool] = {} + +def _admonition_body(text: str) -> str: + """Indent prose without turning source tokens such as ``#if`` into H1s.""" + escaped = re.sub(r"(?m)^(\s*)#", r"\1\\#", text) + return escaped.replace("\n", "\n ") + +def _icon_name(manufacturer: str, console_name: str) -> str: + return f"{manufacturer} - {console_name}".replace("/", " ") + +def _icon_url(icon_name: str) -> str: + return f"{SYSTEM_ICON_BASE}/{urllib.parse.quote(icon_name)}.png" + +def prime_system_icons(names: set[str]) -> None: + """Record which system icons upstream actually serves. + + Results persist in ``.cache`` so later runs skip the network. Names that + cannot be checked stay unavailable: the site never links an image it has + not seen answer. + """ + cached: dict[str, bool] = {} + if ICON_CACHE_PATH.exists(): + try: + with open(ICON_CACHE_PATH) as f: + cached = json.load(f) + except (json.JSONDecodeError, OSError): + cached = {} + + unknown = sorted(n for n in names if n not in cached) + if unknown: + def check(name: str) -> tuple[str, bool | None]: + """True when served, False when upstream says it is gone. + + None on a transient failure, so a flaky run never records an + icon as absent for every later build. + """ + req = urllib.request.Request(_icon_url(name), method="HEAD") + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return name, resp.status == 200 + except urllib.error.HTTPError as exc: + return name, False if exc.code == 404 else None + except (urllib.error.URLError, OSError): + return name, None + + print(f"Checking {len(unknown)} system icons...") + with ThreadPoolExecutor(max_workers=8) as pool: + for name, ok in pool.map(check, unknown): + if ok is not None: + cached[name] = ok + ICON_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(ICON_CACHE_PATH, "w") as f: + json.dump(cached, f, indent=2, sort_keys=True) + + _icon_available.update(cached) + missing = sum(1 for n in names if not cached.get(n)) + if missing: + print(f" {missing} systems have no upstream icon") + +def system_icon_markdown(manufacturer: str, console_name: str) -> str: + """Icon image for a system heading, empty when upstream serves none.""" + name = _icon_name(manufacturer, console_name) + if not _icon_available.get(name): + return "" + return f"![{console_name}]({_icon_url(name)}){{ width=24 }} " + +# Global index: maps system_id -> (manufacturer_slug, console_name) for cross-linking +_system_page_map: dict[str, tuple[str, str]] = {} + +def _slugify_anchor(text: str) -> str: + """Slugify text for MkDocs anchor compatibility.""" + import re + + slug = text.lower() + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"[\s]+", "-", slug) + slug = slug.strip("-") + return slug + +def _system_link(sys_id: str, prefix: str = "") -> str: + """Generate a markdown link to a system page with anchor.""" + if sys_id in _system_page_map: + slug, console = _system_page_map[sys_id] + anchor = _slugify_anchor(console) + return f"[{sys_id}]({prefix}systems/{slug}.md#{anchor})" + return sys_id + +def _render_yaml_value(lines: list[str], val, indent: int = 4) -> None: + """Render any YAML value as indented markdown.""" + pad = " " * indent + if isinstance(val, dict): + for k, v in val.items(): + if isinstance(v, dict): + lines.append(f"{pad}**{k}:**") + lines.append("") + _render_yaml_value(lines, v, indent + 4) + elif isinstance(v, list): + lines.append(f"{pad}**{k}:**") + lines.append("") + for item in v: + if isinstance(item, dict): + parts = [ + f"{ik}: {iv}" + for ik, iv in item.items() + if not isinstance(iv, (dict, list)) + ] + lines.append(f"{pad}- {', '.join(parts)}") + else: + lines.append(f"{pad}- {item}") + lines.append("") + else: + # Truncate very long strings in tables + sv = str(v) + if len(sv) > 200: + sv = sv[:200] + "..." + lines.append(f"{pad}- **{k}:** {sv}") + elif isinstance(val, list): + for item in val: + if isinstance(item, dict): + parts = [ + f"{ik}: {iv}" + for ik, iv in item.items() + if not isinstance(iv, (dict, list)) + ] + lines.append(f"{pad}- {', '.join(parts)}") + else: + lines.append(f"{pad}- {item}") + elif isinstance(val, str) and "\n" in val: + for line in val.split("\n"): + lines.append(f"{pad}{line}") + else: + lines.append(f"{pad}{val}") + +def _platform_link(name: str, display: str, prefix: str = "") -> str: + """Generate a markdown link to a platform page.""" + return f"[{display}]({prefix}platforms/{name}.md)" + +def _emulator_link(name: str, prefix: str = "") -> str: + """Generate a markdown link to an emulator page.""" + return f"[{name}]({prefix}emulators/{name}.md)" + +def _fmt_size(size: int) -> str: + if size >= 1024 * 1024 * 1024: + return f"{size / (1024**3):.1f} GB" + if size >= 1024 * 1024: + return f"{size / (1024**2):.1f} MB" + if size >= 1024: + return f"{size / 1024:.1f} KB" + return f"{size} B" + +def _pct(n: int, total: int) -> str: + if total == 0: + return "0%" + return f"{n / total * 100:.1f}%" + +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 "") diff --git a/scripts/sitesources.py b/scripts/sitesources.py new file mode 100644 index 00000000..e5863db5 --- /dev/null +++ b/scripts/sitesources.py @@ -0,0 +1,142 @@ +"""Permalinks into the sources a profile cites. + +A citation is only checkable if it points at the revision the profile +was written against, so a permalink is pinned, never guessed.""" + +from __future__ import annotations + +from siterender import _by_mode +import re +from profile_sync import source_ref_values +from profile_sync import split_source_ref +import upstream +import urllib.request +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]] = [] + seen: set[tuple[str, str, str]] = set() + source_repos: set[tuple[str, str]] = set() + + for field in ("source", "upstream"): + for value, pin, fallback in _repo_pins(profile, field, label): + repo = upstream.parse_repo(value) + if repo is None: + continue + repo_key = (repo.host, repo.slug) + if field == "upstream" and not pin and repo_key in source_repos: + pin = fallback + if not pin: + continue + key = (repo.host, repo.slug, pin) + if key not in seen: + candidates.append((repo, pin)) + seen.add(key) + if field == "source": + source_repos.add(repo_key) + return candidates + +def _source_permalink(repo: upstream.Repo, pin: str, path: str, + start: int | None, end: int | None) -> str: + """Forge-specific browser URL for one file at one immutable revision.""" + quoted_path = urllib.parse.quote(path, safe="/@:+-._~") + base = f"https://{repo.host}/{repo.owner}/{repo.name}" + if repo.family == "github": + url = f"{base}/blob/{pin}/{quoted_path}" + elif repo.family == "gitlab": + url = f"{base}/-/blob/{pin}/{quoted_path}" + else: + url = f"{base}/src/commit/{pin}/{quoted_path}" + if start is not None: + if repo.family == "gitlab": + url += f"#L{start}" + if end is not None and end != start: + url += f"-{end}" + else: + url += f"#L{start}" + if end is not None and end != start: + url += f"-L{end}" + return url + +def _source_ref_markdown(profile: dict, value) -> str: + """Render source_ref values as pinned links when their forge is known. + + A profile can declare two repositories: the libretro port in ``source`` and + the original emulator in ``upstream``. Which of the two carries a given + path cannot be decided without reading their trees, and this generator runs + offline. Rather than guess, an unattributable path is rendered as plain + code: a citation with no link still names the file and the lines, while a + link to the wrong repository is a false citation. + """ + rendered_groups: list[str] = [] + path_re = re.compile(r"^[A-Za-z0-9_.@/+~-]+$") + for label, refs in source_ref_values(value): + candidates = _forge_sources(profile, label) + rendered: list[str] = [] + for part in split_source_ref(refs): + display = part.path + if part.start is not None: + display += f":{part.start}" + if part.end is not None and part.end != part.start: + display += f"-{part.end}" + + selected: tuple[upstream.Repo, str] | None = None + real_path = part.path + if path_re.fullmatch(part.path) and candidates: + for repo, pin in candidates: + prefix = f"{repo.name}/" + if part.path.startswith(prefix): + selected = (repo, pin) + real_path = part.path[len(prefix):] + break + if selected is None and len(candidates) == 1: + selected = candidates[0] + + if selected is None: + rendered.append(f"`{display}`") + else: + repo, pin = selected + url = _source_permalink( + repo, pin, real_path, part.start, part.end + ) + rendered.append(f"[`{display}`]({url})") + + text = ", ".join(rendered) if rendered else f"`{refs}`" + if label: + text = f"**{label}:** {text}" + rendered_groups.append(text) + return "; ".join(rendered_groups) diff --git a/scripts/sitewrite.py b/scripts/sitewrite.py new file mode 100644 index 00000000..5302a1f9 --- /dev/null +++ b/scripts/sitewrite.py @@ -0,0 +1,65 @@ +"""Writing the site, and knowing what was written. + +The generated tree is swept rather than deleted: a page is removed +only once nothing produces it, so a page whose content did not move +keeps its bytes and a deploy stops republishing the whole site for +the clock alone.""" + +from __future__ import annotations + +from pathlib import Path +from artifacts import write_if_changed as _write_artifact +import os +# Every path this run produced. The generated directories used to be deleted +# before the build, which made every page new: write_if_changed had no earlier +# version to compare against, so the whole site was rewritten for the clock +# alone and a deploy republished 600 unchanged pages. They are swept instead, +# and a page is only removed once nothing produced it. +_produced: set[str] = set() + +def _record(path) -> None: + _produced.add(os.path.realpath(str(path))) + +def _undecorated(markdown: str) -> str: + """Return a page body without the front matter the decoration pass adds.""" + if markdown.startswith("---\n") and "generated_by: retrobios-site" in markdown[:300]: + end = markdown.find("\n---\n", 4) + if end != -1: + markdown = markdown[end + 5:].lstrip("\n") + script_end = markdown.find("\n\n") + if markdown.startswith('\n\n"):] + return markdown + +def write_if_changed(path: str, content: str) -> bool: + """Write a page body only when it moved, and remember it either way. + + A page is written twice: the body here, then the same body wrapped in + front matter by the decoration pass. This comparison therefore reduces + the file on disk to its body. The decoration pass compares in full - + normalizing there too would make its own write look like a no-op and + every page would lose its front matter. + """ + _record(path) + return _write_artifact(path, content, normalize=_undecorated) + +def write_decorated(path, content: str) -> bool: + """Write the wrapped page, comparing front matter and body together.""" + _record(path) + return _write_artifact(str(path), content) + +def _sweep_generated(docs: Path, directories: list[str]) -> int: + """Delete files in the generated tree that this run did not produce.""" + removed = 0 + for name in directories: + root = docs / name + if not root.is_dir(): + continue + for path in sorted(root.rglob("*")): + if path.is_file() and os.path.realpath(str(path)) not in _produced: + path.unlink() + removed += 1 + for path in sorted(root.rglob("*"), reverse=True): + if path.is_dir() and not any(path.iterdir()): + path.rmdir() + return removed