Deploy web Studio / deploy (push) Successful in 3m15s
The status bar ends with "isokit <engine version>"; the web build adds the commit (linked on Gitea), its date as a tooltip and a "+" when built from a tree with uncommitted changes (build_web.py writes a <meta name="isokit-build"> from GITHUB_SHA or git). /api/meta now carries "engine". studio/favicon.svg (isometric cube in the accent green) is served by `isokit studio` (also on /view) and copied into the web build. check_web.py and check_studio.py check both.
177 lines
7.9 KiB
Python
177 lines
7.9 KiB
Python
"""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 build_info():
|
|
"""'<commit> <commit date> [dirty]' for the version text in the Studio's
|
|
status bar; GITHUB_SHA (CI) wins over git. Empty when git is missing."""
|
|
def git(*args):
|
|
r = subprocess.run(["git", *args], capture_output=True, text=True, cwd=ROOT)
|
|
return r.stdout.strip() if r.returncode == 0 else ""
|
|
try:
|
|
sha = os.environ.get("GITHUB_SHA") or git("rev-parse", "HEAD")
|
|
if not sha:
|
|
return ""
|
|
parts = [sha, git("log", "-1", "--format=%cs", sha) or "-"]
|
|
if git("status", "--porcelain", "--untracked-files=no"):
|
|
parts.append("dirty")
|
|
return " ".join(parts)
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
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"), ("studio/favicon.svg", "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")
|
|
build = build_info()
|
|
html = replace_once(html, '<title>isokit Studio</title>',
|
|
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">'
|
|
+ (f'\n<meta name="isokit-build" content="{build}">' if build else ""))
|
|
html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"')
|
|
html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"')
|
|
html = replace_once(html, '<link rel="stylesheet" href="/assets/theme.css">', '<style id="iso-theme"></style>')
|
|
html = replace_once(html, '<link rel="stylesheet" href="/assets/studio/studio.css">',
|
|
f'<link rel="stylesheet" href="assets/studio/studio.css?{v}">\n<link rel="stylesheet" href="assets/web/web.css?{v}">')
|
|
html = replace_once(html, 'href="/view"', 'href="#"')
|
|
html = replace_once(html, '<script src="/assets/viewer/isokit-view.js"></script>', f'<script src="assets/viewer/isokit-view.js?{v}"></script>')
|
|
html = replace_once(html, '<script src="/assets/studio/studio.js"></script>',
|
|
f'<script type="module" src="assets/web/web.js?{v}"></script>\n'
|
|
'<noscript>The isokit Studio needs JavaScript and WebAssembly.</noscript>')
|
|
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}, build {build or 'unknown'})")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|