isokit: web Studio (engine as WASM, projects in the browser or a live folder)
- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind the same JSON API as `isokit studio`, plus the MCP tools - isokit-project: Store trait (FsStore folder, MemStore memory) under a generic Workspace; Project::from_files; shared Studio API module (api.rs) used by serve.rs and the WASM build; web_time for clocks in the browser - studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio keys while a dialog is open - web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and deflated), folder import by drop or picker, folders opened live via File System Access with outside edits followed, code editor, page build and preview, two tabs in step, isokit.mcp() and WebMCP registration - tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
This commit is contained in:
1 parent
564aeb75f6
commit
5eb60d2a0c
20 files changed
+1950
-178
No files matched your search
@@ -0,0 +1,125 @@
|
||||
"""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 the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown)
|
||||
and the wasm-bindgen CLI at the version in Cargo.lock. 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"}
|
||||
|
||||
|
||||
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, **kw)
|
||||
|
||||
|
||||
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:
|
||||
want = lock_version("wasm-bindgen")
|
||||
try:
|
||||
have = subprocess.run(["wasm-bindgen", "--version"], capture_output=True, text=True, check=True).stdout.split()[-1]
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
sys.exit(f"wasm-bindgen CLI not found: cargo install wasm-bindgen-cli --version {want}")
|
||||
if have != want:
|
||||
sys.exit(f"wasm-bindgen CLI is {have}, Cargo.lock has {want}: cargo install wasm-bindgen-cli --version {want}")
|
||||
run(["cargo", "build", "-p", "isokit-wasm", "--target", "wasm32-unknown-unknown", "--profile", "web"])
|
||||
|
||||
if out.exists():
|
||||
shutil.rmtree(out)
|
||||
web.mkdir(parents=True)
|
||||
if not wasm.exists():
|
||||
sys.exit(f"{wasm} missing: run without --skip-cargo")
|
||||
run(["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 shutil.which("wasm-opt"):
|
||||
run(["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, '<title>isokit Studio</title>',
|
||||
'<title>isokit Studio</title>\n<meta name="description" content="isokit Studio: edit isometric site plans in the browser.">')
|
||||
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})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user