#!/usr/bin/env python3 """Build the image manifest and commit it. Scans ./img recursively, writes ./images.json (the file the single-page app fetches at runtime), then stages and commits the result with git. Usage: ./generate-manifest.py # generate + commit ./generate-manifest.py --no-commit # generate only ./generate-manifest.py -m "Add logos" # custom commit message Run this after adding, renaming, or deleting images. """ from __future__ import annotations import argparse import json import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent IMG_DIR = ROOT / "img" OUT_FILE = ROOT / "images.json" IMAGE_EXTS = { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp", ".ico", ".tif", ".tiff", } def natural_key(text: str) -> list: """Sort key so 'img2' comes before 'img10'.""" return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", text)] def find_images() -> list[dict]: if not IMG_DIR.is_dir(): print(f"✗ Image folder not found: {IMG_DIR}", file=sys.stderr) print(" Create it and add some images, then re-run.", file=sys.stderr) sys.exit(1) images = [] for path in IMG_DIR.rglob("*"): if not path.is_file(): continue # Skip dotfiles and anything inside a dot-directory. if any(part.startswith(".") for part in path.relative_to(IMG_DIR).parts): continue if path.suffix.lower() not in IMAGE_EXTS: continue stat = path.stat() images.append({ "name": path.name, "path": path.relative_to(ROOT).as_posix(), # POSIX-style for URLs "size": stat.st_size, "mtime": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) .isoformat() .replace("+00:00", "Z"), }) images.sort(key=lambda img: natural_key(img["path"])) return images def write_manifest(images: list[dict]) -> None: manifest = { "generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "count": len(images), "images": images, } OUT_FILE.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") plural = "" if len(images) == 1 else "s" print(f"✓ Wrote {len(images)} image{plural} to {OUT_FILE.name}") def git(*args: str, check: bool = True) -> subprocess.CompletedProcess: return subprocess.run(["git", *args], cwd=ROOT, check=check, text=True, capture_output=True) def commit(message: str) -> None: """Stage everything and commit, if this is a git repo with changes.""" if git("rev-parse", "--is-inside-work-tree", check=False).returncode != 0: print("• Not a git repository — skipping commit.") return git("add", "-A") # Nothing staged? Then there's nothing to commit. if git("diff", "--cached", "--quiet", check=False).returncode == 0: print("• No changes to commit.") return result = git("commit", "-m", message, check=False) if result.returncode != 0: print(f"✗ git commit failed:\n{result.stderr.strip()}", file=sys.stderr) sys.exit(1) print(f"✓ Committed: {message}") def main() -> None: parser = argparse.ArgumentParser(description="Build images.json and commit it.") parser.add_argument("--no-commit", action="store_true", help="generate the manifest without committing") parser.add_argument("-m", "--message", default=None, help="commit message (default: auto-generated)") args = parser.parse_args() images = find_images() write_manifest(images) if args.no_commit: return message = args.message or f"Update image manifest ({len(images)} images)" commit(message) if __name__ == "__main__": main()