"""Build the web Studio: the Studio GUI with the isokit engine compiled to WASM. python tools/build_web.py [--out dist/web] [--skip-cargo] Needs rustup with the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown) and the wasm-bindgen CLI at the version in Cargo.lock. rustup's cargo is used even when another Rust install comes first on PATH. wasm-opt (binaryen) is used when it is on PATH. The output folder is static files: serve it from any web server (it needs http(s), not file://). """ import argparse import hashlib import json import os import re import shutil import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SKIP_DIRS = {"dist", "target", "node_modules"} TARGET = "wasm32-unknown-unknown" # rustup's bin folder first: a standalone Rust install earlier on PATH (one # without the wasm target, e.g. "Rust stable GNU") must not win CARGO_BIN = Path(os.environ.get("CARGO_HOME") or Path.home() / ".cargo") / "bin" ENV = dict(os.environ) if CARGO_BIN.is_dir(): ENV["PATH"] = str(CARGO_BIN) + os.pathsep + ENV.get("PATH", "") def tool(name): return shutil.which(name, path=ENV["PATH"]) def run(cmd, **kw): print("+", " ".join(str(c) for c in cmd), flush=True) subprocess.run([str(c) for c in cmd], check=True, cwd=ROOT, env=ENV, **kw) def check_toolchain(): """rustup with the wasm target, and wasm-bindgen at the locked version.""" rustup = tool("rustup") if not rustup: sys.exit("rustup not found (https://rustup.rs): the WASM build needs rustup's toolchain") installed = subprocess.run([rustup, "target", "list", "--installed"], capture_output=True, text=True, cwd=ROOT, env=ENV).stdout.split() if TARGET not in installed: sys.exit(f"the {TARGET} target is not installed: rustup target add {TARGET}") cargo = tool("cargo") if not cargo or Path(cargo).parent.resolve() != CARGO_BIN.resolve(): sys.exit(f"cargo resolves to {cargo}, not rustup's in {CARGO_BIN}") want = lock_version("wasm-bindgen") exe = tool("wasm-bindgen") if not exe: sys.exit(f"wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {want}") have = subprocess.run([exe, "--version"], capture_output=True, text=True, env=ENV).stdout.split()[-1] if have != want: sys.exit(f"wasm-bindgen CLI is {have}, Cargo.lock has {want}: cargo install wasm-bindgen-cli --version {want}") def lock_version(name): lock = (ROOT / "Cargo.lock").read_text(encoding="utf-8") m = re.search(r'name = "%s"\nversion = "([^"]+)"' % re.escape(name), lock) return m.group(1) if m else None def demo_files(): """The demo project as the web Studio stores projects: path -> text.""" base = ROOT / "examples" / "demo" out = {} for p in sorted(base.rglob("*")): rel = p.relative_to(base).as_posix() parts = rel.split("/") if p.is_dir() or any(s.startswith(".") or s in SKIP_DIRS for s in parts[:-1]): continue if p.suffix in (".rhai", ".toml", ".json") or p.name == "README.md": out[rel] = p.read_text(encoding="utf-8") assert "isokit.toml" in out, "examples/demo has no isokit.toml" return out def replace_once(s, old, new): n = s.count(old) assert n == 1, f"studio/index.html: expected {old!r} once, found {n}" return s.replace(old, new) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--out", default=str(ROOT / "dist" / "web")) ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)") a = ap.parse_args() out = Path(a.out).resolve() web = out / "assets" / "web" wasm = ROOT / "target" / "wasm32-unknown-unknown" / "web" / "isokit_wasm.wasm" if not a.skip_cargo: check_toolchain() run([tool("cargo"), "build", "-p", "isokit-wasm", "--target", TARGET, "--profile", "web"]) elif not tool("wasm-bindgen"): sys.exit("wasm-bindgen CLI not found") if out.exists(): shutil.rmtree(out) web.mkdir(parents=True) if not wasm.exists(): sys.exit(f"{wasm} missing: run without --skip-cargo") run([tool("wasm-bindgen"), "--target", "web", "--no-typescript", "--remove-name-section", "--remove-producers-section", "--out-dir", web.as_posix(), wasm.as_posix()]) bg = web / "isokit_wasm_bg.wasm" if tool("wasm-opt"): run([tool("wasm-opt"), "-Oz", "--all-features", bg.as_posix(), "-o", bg.as_posix()]) else: print(" (wasm-opt not found: the .wasm stays unoptimised by binaryen, about 15% larger)") for src, dst in [("viewer/isokit-view.js", "viewer"), ("viewer/isokit-view.css", "viewer"), ("studio/studio.js", "studio"), ("studio/studio.css", "studio"), ("web/web.js", "web"), ("web/web.css", "web")]: (out / "assets" / dst).mkdir(parents=True, exist_ok=True) shutil.copy2(ROOT / src, out / "assets" / dst / Path(src).name) (web / "demo.json").write_text(json.dumps(demo_files(), ensure_ascii=False), encoding="utf-8") h = hashlib.sha256() for p in sorted((out / "assets").rglob("*")): if p.is_file(): h.update(p.read_bytes()) v = "v=" + h.hexdigest()[:10] html = (ROOT / "studio" / "index.html").read_text(encoding="utf-8") html = replace_once(html, 'isokit Studio', 'isokit Studio\n') html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"') html = replace_once(html, '', '') html = replace_once(html, '', f'\n') html = replace_once(html, 'href="/view"', 'href="#"') html = replace_once(html, '', f'') html = replace_once(html, '', f'\n' '') assert "/assets/" not in html, "an absolute /assets/ URL is left in index.html" (out / "index.html").write_text(html, encoding="utf-8") total = 0 for p in sorted(out.rglob("*")): if p.is_file(): total += p.stat().st_size print(f" {p.relative_to(out).as_posix():40} {p.stat().st_size / 1024:8.1f} kB") print(f"{out} ({total / 1024 / 1024:.1f} MB, {v})") if __name__ == "__main__": main()