#!/usr/bin/env python3 """Build frontend assets with content hashes for cache-immutable PWA deploy.""" from __future__ import annotations import hashlib import json import re import shutil import tomllib from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent.parent FRONTEND = ROOT / "frontend" PUBLIC = ROOT / "public" ASSETS_DIR = PUBLIC / "assets" HASH_LEN = 8 IMPORT_RE = re.compile(r'''from\s+["'](\./(?:lib/)?[^"']+\.js)["']''') STATIC_COPY = ("manifest.json",) STATIC_ICONS = ( "icon.png", "icon-72x72.png", "icon-192x192.png", "icon-512x512.png", ) def short_hash(content: bytes) -> str: return hashlib.sha256(content).hexdigest()[:HASH_LEN] def js_sources() -> list[tuple[str, str]]: sources: list[tuple[str, str]] = [("app.js", "app")] lib_dir = FRONTEND / "lib" if lib_dir.is_dir(): for path in sorted(lib_dir.glob("*.js")): sources.append((f"lib/{path.name}", path.stem)) return sources def read_version() -> str: pyproject = ROOT / "pyproject.toml" with pyproject.open("rb") as fh: data = tomllib.load(fh) return str(data["project"]["version"]) def rewrite_imports(source: str, stem_to_filename: dict[str, str]) -> str: def replace(match: re.Match[str]) -> str: import_path = match.group(1) stem = Path(import_path).stem hashed = stem_to_filename.get(stem) if not hashed: raise ValueError(f"Unknown import stem {stem!r} in {import_path!r}") quote = match.group(0).split("from", 1)[1].strip()[0] return f'from {quote}./{hashed}{quote}' return IMPORT_RE.sub(replace, source) def clean_assets_dir() -> None: ASSETS_DIR.mkdir(parents=True, exist_ok=True) for path in ASSETS_DIR.iterdir(): if path.name == ".gitkeep": continue if path.is_file(): path.unlink() def copy_static_files() -> list[str]: copied: list[str] = [] for name in STATIC_COPY: src = FRONTEND / name if not src.is_file(): raise FileNotFoundError(f"Missing frontend source: {src}") shutil.copy2(src, PUBLIC / name) copied.append(f"/{name}") for name in STATIC_ICONS: src = FRONTEND / name if src.is_file(): shutil.copy2(src, PUBLIC / name) copied.append(f"/{name}") return copied def build() -> dict[str, object]: if not FRONTEND.is_dir(): raise FileNotFoundError(f"Frontend source directory not found: {FRONTEND}") PUBLIC.mkdir(parents=True, exist_ok=True) clean_assets_dir() hashed_assets: dict[str, str] = {} stem_to_filename: dict[str, str] = {} file_hashes: list[str] = [] styles_src = FRONTEND / "styles.css" if not styles_src.is_file(): raise FileNotFoundError(f"Missing frontend source: {styles_src}") styles_bytes = styles_src.read_bytes() styles_hash = short_hash(styles_bytes) styles_filename = f"styles.{styles_hash}.css" (ASSETS_DIR / styles_filename).write_bytes(styles_bytes) hashed_assets["styles"] = f"/assets/{styles_filename}" file_hashes.append(styles_hash) for rel_path, stem in js_sources(): src = FRONTEND / rel_path if not src.is_file(): raise FileNotFoundError(f"Missing frontend source: {src}") content = src.read_text(encoding="utf-8") content_hash = short_hash(content.encode("utf-8")) filename = f"{stem}.{content_hash}.js" stem_to_filename[stem] = filename file_hashes.append(content_hash) for rel_path, stem in js_sources(): src = FRONTEND / rel_path content = rewrite_imports(src.read_text(encoding="utf-8"), stem_to_filename) filename = stem_to_filename[stem] (ASSETS_DIR / filename).write_text(content, encoding="utf-8") hashed_assets[stem] = f"/assets/{filename}" build_hash = short_hash("".join(file_hashes).encode("utf-8")) version = read_version() build_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") cache_version = f"tym-v{version}-{build_hash}" static_paths = copy_static_files() asset_paths = sorted(hashed_assets.values()) sw_assets = ["/", "/index.html", *asset_paths, *static_paths, "/version.json"] index_template = (FRONTEND / "index.template.html").read_text(encoding="utf-8") index_html = ( index_template.replace("{{STYLES_HREF}}", hashed_assets["styles"]) .replace("{{APP_SCRIPT_SRC}}", hashed_assets["app"]) .replace("{{BUILD_HASH}}", build_hash) ) (PUBLIC / "index.html").write_text(index_html, encoding="utf-8") sw_template = (FRONTEND / "sw.template.js").read_text(encoding="utf-8") sw_js = ( sw_template.replace("{{CACHE_VERSION}}", cache_version) .replace("{{ASSETS_JSON}}", json.dumps(sw_assets, indent=2)) ) (PUBLIC / "sw.js").write_text(sw_js, encoding="utf-8") version_json = { "version": version, "buildTime": build_time, "buildHash": build_hash, } (PUBLIC / "version.json").write_text( json.dumps(version_json, indent=2) + "\n", encoding="utf-8", ) return { "version": version, "buildHash": build_hash, "cacheVersion": cache_version, "assets": hashed_assets, "static": static_paths, } def main() -> None: result = build() print(f"Built frontend v{result['version']} ({result['buildHash']})") print(f"Cache: {result['cacheVersion']}") for stem, url in sorted(result["assets"].items()): print(f" {stem}: {url}") if __name__ == "__main__": main()