#!/usr/bin/env python3 """Download pinned frontend vendor assets into web/app/vendor/ (F2e-06). Run after upgrading versions in VENDOR_MANIFEST, then commit vendor/ + HTML path updates. """ from __future__ import annotations import hashlib import json import tarfile import tempfile import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parent.parent VENDOR_DIR = ROOT / "web" / "app" / "vendor" VENDOR_MANIFEST: dict[str, dict[str, str]] = { "tailwindcss-browser.js": { "url": "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4.0.6/dist/index.global.js", "version": "@tailwindcss/browser@4.0.6", }, "alpinejs.min.js": { "url": "https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js", "version": "alpinejs@3.14.8", }, "xterm.css": { "url": "https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css", "version": "xterm@5.3.0", }, "xterm.js": { "url": "https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js", "version": "xterm@5.3.0", }, "xterm-addon-fit.js": { "url": "https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js", "version": "xterm-addon-fit@0.8.0", }, "xterm-addon-web-links.js": { "url": "https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js", "version": "xterm-addon-web-links@0.9.0", }, "qrious.min.js": { "url": "https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js", "version": "qrious@4.0.2", }, } MONACO_VERSION = "0.45.0" MONACO_VS_PREFIX = "package/min/vs/" MONACO_NPM_TGZ = ( f"https://registry.npmjs.org/monaco-editor/-/monaco-editor-{MONACO_VERSION}.tgz" ) MONACO_VS_PUBLIC = "/app/vendor/monaco/vs" CDN_REPLACEMENTS = { "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": "/app/vendor/tailwindcss-browser.js", "https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js": "/app/vendor/alpinejs.min.js", "https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css": "/app/vendor/xterm.css", "https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js": "/app/vendor/xterm.js", "https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js": "/app/vendor/xterm-addon-fit.js", "https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js": "/app/vendor/xterm-addon-web-links.js", "https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js": "/app/vendor/qrious.min.js", f"https://cdn.jsdelivr.net/npm/monaco-editor@{MONACO_VERSION}/min/vs": MONACO_VS_PUBLIC, f"https://cdn.jsdelivr.net/npm/monaco-editor@{MONACO_VERSION}/min/vs/loader.js": ( f"{MONACO_VS_PUBLIC}/loader.js" ), } def _sha384(path: Path) -> str: digest = hashlib.sha384(path.read_bytes()).digest() return "sha384-" + __import__("base64").b64encode(digest).decode() def download_monaco() -> dict: """Extract monaco-editor min/vs/ into web/app/vendor/monaco/vs/.""" vs_dest = VENDOR_DIR / "monaco" / "vs" vs_dest.mkdir(parents=True, exist_ok=True) print(f"Fetching monaco-editor@{MONACO_VERSION} npm tarball -> {vs_dest}") with tempfile.NamedTemporaryFile(suffix=".tgz", delete=False) as tmp: tmp_path = Path(tmp.name) try: with urllib.request.urlopen(MONACO_NPM_TGZ, timeout=120) as resp: tmp_path.write_bytes(resp.read()) file_count = 0 total_bytes = 0 with tarfile.open(tmp_path, "r:gz") as archive: for member in archive.getmembers(): if not member.isfile() or not member.name.startswith(MONACO_VS_PREFIX): continue rel = member.name[len(MONACO_VS_PREFIX) :] if not rel or rel.startswith("/") or ".." in Path(rel).parts: continue target = vs_dest / rel target.parent.mkdir(parents=True, exist_ok=True) extracted = archive.extractfile(member) if extracted is None: continue data = extracted.read() target.write_bytes(data) file_count += 1 total_bytes += len(data) loader = vs_dest / "loader.js" if not loader.is_file(): raise RuntimeError(f"monaco vs/loader.js missing after extract ({vs_dest})") return { "version": f"monaco-editor@{MONACO_VERSION}", "path": str(vs_dest.relative_to(ROOT)), "files": file_count, "bytes": total_bytes, "integrity": _sha384(loader), } finally: tmp_path.unlink(missing_ok=True) def download_vendor() -> dict: VENDOR_DIR.mkdir(parents=True, exist_ok=True) manifest: dict[str, dict] = {"files": {}, "replacements": CDN_REPLACEMENTS} for filename, meta in VENDOR_MANIFEST.items(): dest = VENDOR_DIR / filename print(f"Fetching {meta['version']} -> {dest.name}") with urllib.request.urlopen(meta["url"], timeout=60) as resp: data = resp.read() dest.write_bytes(data) manifest["files"][filename] = { "version": meta["version"], "url": meta["url"], "bytes": len(data), "integrity": _sha384(dest), } manifest["monaco"] = download_monaco() (VENDOR_DIR / "manifest.json").write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) return manifest def patch_app_js_files() -> int: app_dir = ROOT / "web" / "app" changed = 0 for js in sorted(app_dir.glob("*.js")): text = js.read_text(encoding="utf-8") original = text for old, new in CDN_REPLACEMENTS.items(): text = text.replace(old, new) if text != original: js.write_text(text, encoding="utf-8") changed += 1 print(f"Updated {js.name}") return changed def patch_html_files() -> int: app_dir = ROOT / "web" / "app" changed = 0 for html in sorted(app_dir.glob("*.html")): text = html.read_text(encoding="utf-8") original = text for old, new in CDN_REPLACEMENTS.items(): text = text.replace(old, new) if text != original: html.write_text(text, encoding="utf-8") changed += 1 print(f"Updated {html.name}") return changed def main() -> None: download_vendor() html_n = patch_html_files() js_n = patch_app_js_files() print(f"Done. {html_n} HTML file(s), {js_n} JS file(s) patched.") if __name__ == "__main__": main()