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
@@ -3,9 +3,8 @@
|
||||
//! live. Single-threaded: one request at a time against one Workspace.
|
||||
|
||||
use anyhow::Result;
|
||||
use isokit_project::ops::Op;
|
||||
use isokit_project::workspace::Workspace;
|
||||
use isokit_project::{assets, check, mcp, page};
|
||||
use isokit_project::{api, assets, check, mcp, page};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
@@ -100,18 +99,6 @@ fn origin_ok(req: &Request) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn state(ws: &Workspace) -> Value {
|
||||
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
|
||||
}
|
||||
|
||||
fn meta(ws: &Workspace) -> Value {
|
||||
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
|
||||
v["dir"] = json!(ws.dir.display().to_string());
|
||||
v["load_error"] = json!(ws.load_error);
|
||||
v["script_error"] = json!(ws.project.runtime.as_ref().err());
|
||||
v
|
||||
}
|
||||
|
||||
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
||||
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
|
||||
// Windows canonical paths carry a \?\ prefix that other tools do not accept
|
||||
@@ -191,21 +178,16 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
||||
}
|
||||
(Method::Get, "/api/meta") => {
|
||||
ws.refresh();
|
||||
send_json(req, meta(&ws))
|
||||
send_json(req, api::meta(&ws))
|
||||
}
|
||||
(Method::Get, "/api/doc") => {
|
||||
ws.refresh();
|
||||
send_json(req, state(&ws))
|
||||
send_json(req, api::state(&ws))
|
||||
}
|
||||
(Method::Get, "/api/render") => {
|
||||
ws.refresh();
|
||||
let q = query(&url);
|
||||
let p = &ws.project;
|
||||
let level = q
|
||||
.get("level")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| p.manifest.default_level());
|
||||
let level = q.get("level").and_then(|v| v.as_str());
|
||||
let rot = q
|
||||
.get("rot")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -216,85 +198,40 @@ pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
|
||||
let dev = q.get("dev").is_some();
|
||||
match p.render(&level, rot, cfg.as_ref(), dev) {
|
||||
Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
// script errors are an answer for the viewer, not a failed request
|
||||
send(
|
||||
req,
|
||||
200,
|
||||
"application/json; charset=utf-8",
|
||||
api::render(&ws, level, rot, cfg.as_ref(), dev).to_string(),
|
||||
)
|
||||
}
|
||||
(Method::Get, "/api/check") => {
|
||||
ws.refresh();
|
||||
send_json(req, json!({ "findings": check::check(&ws.project) }))
|
||||
}
|
||||
(Method::Post, "/api/op") => {
|
||||
let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) {
|
||||
let ops: Result<Vec<Op>, String> = list
|
||||
.iter()
|
||||
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
|
||||
.collect();
|
||||
ops.and_then(|l| ws.apply_all(l))
|
||||
.map(|o| o.last().and_then(|x| x.id.clone()))
|
||||
} else {
|
||||
serde_json::from_value::<Op>(bodyv.clone())
|
||||
.map_err(|e| format!("bad operation: {e}"))
|
||||
.and_then(|op| ws.apply(op))
|
||||
.map(|o| o.id)
|
||||
};
|
||||
match r {
|
||||
Ok(id) => {
|
||||
let mut s = state(&ws);
|
||||
s["ok"] = json!(true);
|
||||
s["id"] = json!(id);
|
||||
send_json(req, s)
|
||||
}
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
let r = api::op(&mut ws, &bodyv);
|
||||
send(req, 200, "application/json; charset=utf-8", r.to_string())
|
||||
}
|
||||
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
|
||||
let r = if path.ends_with("undo") {
|
||||
ws.undo()
|
||||
} else {
|
||||
ws.redo()
|
||||
};
|
||||
match r {
|
||||
Ok(_) => send_json(req, state(&ws)),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
"application/json",
|
||||
json!({ "error": e }).to_string(),
|
||||
),
|
||||
}
|
||||
let r = api::undo(&mut ws, path.ends_with("redo"));
|
||||
send(req, 200, "application/json; charset=utf-8", r.to_string())
|
||||
}
|
||||
(Method::Post, "/api/build") => {
|
||||
ws.refresh();
|
||||
let p = &ws.project;
|
||||
let out = p.dir.join(
|
||||
p.manifest
|
||||
.build
|
||||
.out
|
||||
.clone()
|
||||
.unwrap_or_else(|| "dist/index.html".into()),
|
||||
);
|
||||
let r = page::static_page(p)
|
||||
let out = ws
|
||||
.project
|
||||
.manifest
|
||||
.build
|
||||
.out
|
||||
.clone()
|
||||
.unwrap_or_else(|| "dist/index.html".into());
|
||||
let r = page::static_page(&ws.project)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|html| {
|
||||
if let Some(d) = out.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&out, html).map_err(|e| e.to_string())
|
||||
});
|
||||
.and_then(|html| ws.write_file(&out, &html));
|
||||
match r {
|
||||
Ok(_) => send_json(req, json!({ "path": out.display().to_string() })),
|
||||
Ok(path) => send_json(req, json!({ "path": path.display().to_string() })),
|
||||
Err(e) => send(
|
||||
req,
|
||||
200,
|
||||
|
||||
@@ -12,3 +12,4 @@ isokit-theme = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The Studio's JSON API, shared by `isokit studio` (HTTP, `/api/*`) and the
|
||||
//! web Studio (WASM): the same requests and answers on both.
|
||||
|
||||
use crate::ops::Op;
|
||||
use crate::store::Store;
|
||||
use crate::workspace::Workspace;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// The document and the undo state.
|
||||
pub fn state<S: Store>(ws: &Workspace<S>) -> Value {
|
||||
// the version goes out as a string: u64 does not fit a JS number
|
||||
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
|
||||
}
|
||||
|
||||
/// Project facts: title, levels, parameters, catalog, materials, errors.
|
||||
pub fn meta<S: Store>(ws: &Workspace<S>) -> Value {
|
||||
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
|
||||
v["dir"] = json!(ws.dir.display().to_string());
|
||||
v["load_error"] = json!(ws.load_error);
|
||||
v["script_error"] = json!(ws.project.runtime.as_ref().err());
|
||||
v
|
||||
}
|
||||
|
||||
/// One operation, or `{ "ops": [...] }` as one undo step. Answers the new
|
||||
/// state with `ok` and the id of the object made or changed, or `error`.
|
||||
pub fn op<S: Store>(ws: &mut Workspace<S>, body: &Value) -> Value {
|
||||
let r = if let Some(list) = body.get("ops").and_then(|v| v.as_array()) {
|
||||
let ops: Result<Vec<Op>, String> = list
|
||||
.iter()
|
||||
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
|
||||
.collect();
|
||||
ops.and_then(|l| ws.apply_all(l))
|
||||
.map(|o| o.last().and_then(|x| x.id.clone()))
|
||||
} else {
|
||||
serde_json::from_value::<Op>(body.clone())
|
||||
.map_err(|e| format!("bad operation: {e}"))
|
||||
.and_then(|op| ws.apply(op))
|
||||
.map(|o| o.id)
|
||||
};
|
||||
match r {
|
||||
Ok(id) => {
|
||||
let mut s = state(ws);
|
||||
s["ok"] = json!(true);
|
||||
s["id"] = json!(id);
|
||||
s
|
||||
}
|
||||
Err(e) => json!({ "error": e }),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo<S: Store>(ws: &mut Workspace<S>, redo: bool) -> Value {
|
||||
let r = if redo { ws.redo() } else { ws.undo() };
|
||||
match r {
|
||||
Ok(_) => state(ws),
|
||||
Err(e) => json!({ "error": e }),
|
||||
}
|
||||
}
|
||||
|
||||
/// One view for the viewer (`level` default: the project's default level).
|
||||
pub fn render<S: Store>(
|
||||
ws: &Workspace<S>,
|
||||
level: Option<&str>,
|
||||
rot: u8,
|
||||
cfg: Option<&Map<String, Value>>,
|
||||
dev: bool,
|
||||
) -> Value {
|
||||
let p = &ws.project;
|
||||
let level = level
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| p.manifest.default_level());
|
||||
match p.render(&level, rot, cfg, dev) {
|
||||
Ok(v) => serde_json::to_value(v).unwrap_or_default(),
|
||||
Err(e) => json!({ "error": e }),
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,17 @@
|
||||
//! [`Project`] loads and renders it; [`ops`] edits the document; [`page`]
|
||||
//! exports HTML/SVG; [`check`] lints it; [`workspace::Workspace`] keeps a
|
||||
//! project loaded across edits (undo, reload on file changes) for the Studio
|
||||
//! and the MCP server ([`mcp`]).
|
||||
//! and the MCP server ([`mcp`]). Where the files live is a [`store::Store`]:
|
||||
//! a folder on disk, or memory (the web Studio, WASM). [`api`] is the JSON
|
||||
//! API both Studio front ends speak.
|
||||
|
||||
pub mod api;
|
||||
pub mod assets;
|
||||
pub mod check;
|
||||
pub mod mcp;
|
||||
pub mod ops;
|
||||
pub mod page;
|
||||
pub mod store;
|
||||
pub mod workspace;
|
||||
|
||||
use isokit_core::{render, Info, RenderOpts};
|
||||
@@ -29,12 +33,16 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
use web_time::Instant;
|
||||
|
||||
pub use isokit_script;
|
||||
|
||||
pub const MANIFEST: &str = "isokit.toml";
|
||||
|
||||
/// Project files by forward-slash relative path ("isokit.toml",
|
||||
/// "lib/kiosk.rhai"): what a project is made of, wherever it is stored.
|
||||
pub type Files = BTreeMap<String, String>;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LevelDef {
|
||||
pub id: String,
|
||||
@@ -213,9 +221,18 @@ fn rd(path: &Path) -> Result<String, Error> {
|
||||
std::fs::read_to_string(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Every `*.rhai` below `dir` (skipping dist, target and dot folders),
|
||||
/// keyed by forward-slash relative path.
|
||||
fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
|
||||
/// Folders a project walk skips.
|
||||
pub fn skip_dir(name: &str) -> bool {
|
||||
name.starts_with('.') || name == "dist" || name == "target" || name == "node_modules"
|
||||
}
|
||||
|
||||
/// Files that make up a project (scripts, manifest, themes, documents).
|
||||
pub fn is_project_file(name: &str) -> bool {
|
||||
name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
|
||||
}
|
||||
|
||||
/// Every project file below `dir` (skipping dist, target and dot folders).
|
||||
pub fn read_dir_files(dir: &Path) -> Result<Files, Error> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut stack = vec![dir.to_path_buf()];
|
||||
while let Some(d) = stack.pop() {
|
||||
@@ -224,14 +241,10 @@ fn collect_sources(dir: &Path) -> Result<BTreeMap<String, String>, Error> {
|
||||
let p = ent.path();
|
||||
let name = ent.file_name().to_string_lossy().to_string();
|
||||
if p.is_dir() {
|
||||
if !name.starts_with('.')
|
||||
&& name != "dist"
|
||||
&& name != "target"
|
||||
&& name != "node_modules"
|
||||
{
|
||||
if !skip_dir(&name) {
|
||||
stack.push(p);
|
||||
}
|
||||
} else if name.ends_with(".rhai") {
|
||||
} else if is_project_file(&name) {
|
||||
let rel = p
|
||||
.strip_prefix(dir)
|
||||
.unwrap_or(&p)
|
||||
@@ -259,35 +272,49 @@ impl Project {
|
||||
/// document stays editable and [`Project::runtime`] holds the error.
|
||||
pub fn load(dir: impl AsRef<Path>) -> Result<Project, Error> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let mpath = dir.join(MANIFEST);
|
||||
let manifest: Manifest = if mpath.exists() {
|
||||
toml::from_str(&rd(&mpath)?).map_err(|e| Error::Manifest(e.to_string()))?
|
||||
} else {
|
||||
if !dir.join(MANIFEST).exists() {
|
||||
return Err(Error::Io(format!(
|
||||
"{} not found (create a project with `isokit new`)",
|
||||
mpath.display()
|
||||
dir.join(MANIFEST).display()
|
||||
)));
|
||||
}
|
||||
let files = read_dir_files(&dir)?;
|
||||
Project::from_files(dir, &files)
|
||||
}
|
||||
|
||||
/// Load a project from its files (no disk access). `dir` names it in
|
||||
/// messages and is where the CLI writes builds.
|
||||
pub fn from_files(dir: impl Into<PathBuf>, files: &Files) -> Result<Project, Error> {
|
||||
let dir = dir.into();
|
||||
let get = |name: &str| {
|
||||
files
|
||||
.get(name)
|
||||
.ok_or_else(|| Error::Io(format!("{name} not found")))
|
||||
};
|
||||
let manifest: Manifest =
|
||||
toml::from_str(get(MANIFEST)?).map_err(|e| Error::Manifest(e.to_string()))?;
|
||||
let mut layers = vec![];
|
||||
for t in &manifest.theme {
|
||||
layers.push((t.clone(), rd(&dir.join(t))?));
|
||||
layers.push((t.clone(), get(t)?.clone()));
|
||||
}
|
||||
let refs: Vec<(&str, &str)> = layers
|
||||
.iter()
|
||||
.map(|(a, b)| (a.as_str(), b.as_str()))
|
||||
.collect();
|
||||
let theme = isokit_theme::load(&refs).map_err(Error::Theme)?;
|
||||
let dpath = dir.join(manifest.doc_path());
|
||||
let doc: Doc = if dpath.exists() {
|
||||
serde_json::from_str(&rd(&dpath)?)
|
||||
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?
|
||||
} else {
|
||||
Doc {
|
||||
let doc: Doc = match files.get(&manifest.doc_path()) {
|
||||
Some(text) => serde_json::from_str(text)
|
||||
.map_err(|e| Error::Doc(format!("{}: {e}", manifest.doc_path())))?,
|
||||
None => Doc {
|
||||
version: 1,
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
};
|
||||
let sources = collect_sources(&dir)?;
|
||||
let sources: BTreeMap<String, String> = files
|
||||
.iter()
|
||||
.filter(|(k, _)| k.ends_with(".rhai"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let main = match &manifest.script {
|
||||
Some(s) => Some(s.clone()),
|
||||
None if sources.contains_key("scene.rhai") => Some("scene.rhai".into()),
|
||||
@@ -402,16 +429,12 @@ impl Project {
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the document (pretty JSON, atomically).
|
||||
pub fn save_doc(&self) -> Result<(), Error> {
|
||||
let path = self.dir.join(self.manifest.doc_path());
|
||||
/// The document as saved: pretty JSON with a final newline.
|
||||
pub fn doc_text(&self) -> Result<String, Error> {
|
||||
let mut s =
|
||||
serde_json::to_string_pretty(&self.doc).map_err(|e| Error::Doc(e.to_string()))?;
|
||||
s.push('\n');
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, s).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,15 +453,10 @@ pub fn version(dir: &Path) -> u64 {
|
||||
let p = ent.path();
|
||||
let name = ent.file_name().to_string_lossy().to_string();
|
||||
if p.is_dir() {
|
||||
if !name.starts_with('.')
|
||||
&& name != "dist"
|
||||
&& name != "target"
|
||||
&& name != "node_modules"
|
||||
{
|
||||
if !skip_dir(&name) {
|
||||
stack.push(p);
|
||||
}
|
||||
} else if name.ends_with(".rhai") || name.ends_with(".toml") || name.ends_with(".json")
|
||||
{
|
||||
} else if is_project_file(&name) {
|
||||
if let Ok(m) = ent.metadata() {
|
||||
files.push((p.to_string_lossy().to_string(), m.len(), m.modified().ok()));
|
||||
}
|
||||
@@ -450,6 +468,19 @@ pub fn version(dir: &Path) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The files of a new project with a starter scene.
|
||||
pub fn scaffold_files(title: &str) -> Files {
|
||||
let title = title.replace('\\', "").replace('"', "'");
|
||||
let mut f = Files::new();
|
||||
f.insert(
|
||||
MANIFEST.into(),
|
||||
assets::NEW_MANIFEST.replace("{title}", &title),
|
||||
);
|
||||
f.insert("scene.json".into(), assets::NEW_DOC.into());
|
||||
f.insert("theme.toml".into(), assets::NEW_THEME.into());
|
||||
f
|
||||
}
|
||||
|
||||
/// Write a new project folder with a starter scene.
|
||||
pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
|
||||
if dir.join(MANIFEST).exists() {
|
||||
@@ -462,9 +493,9 @@ pub fn scaffold(dir: &Path, title: &str) -> Result<(), Error> {
|
||||
let w = |name: &str, body: &str| {
|
||||
std::fs::write(dir.join(name), body).map_err(|e| Error::Io(format!("{name}: {e}")))
|
||||
};
|
||||
w(MANIFEST, &assets::NEW_MANIFEST.replace("{title}", title))?;
|
||||
w("scene.json", assets::NEW_DOC)?;
|
||||
w("theme.toml", assets::NEW_THEME)?;
|
||||
for (name, body) in scaffold_files(title) {
|
||||
w(&name, &body)?;
|
||||
}
|
||||
w("lib/README.md", assets::NEW_LIB_README)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
use crate::check::check;
|
||||
use crate::ops::Op;
|
||||
use crate::page;
|
||||
use crate::store::Store;
|
||||
use crate::workspace::Workspace;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -85,7 +86,7 @@ fn tool_error(msg: impl Into<String>) -> Value {
|
||||
json!({ "content": [{ "type": "text", "text": msg.into() }], "isError": true })
|
||||
}
|
||||
|
||||
fn boxes(ws: &Workspace) -> Map<String, Value> {
|
||||
fn boxes<S: Store>(ws: &Workspace<S>) -> Map<String, Value> {
|
||||
let p = &ws.project;
|
||||
match p.render(&p.manifest.default_level(), 0, None, false) {
|
||||
Ok(v) => v
|
||||
@@ -97,7 +98,7 @@ fn boxes(ws: &Workspace) -> Map<String, Value> {
|
||||
}
|
||||
}
|
||||
|
||||
fn object_json(ws: &Workspace, id: &str, bx: &Map<String, Value>) -> Option<Value> {
|
||||
fn object_json<S: Store>(ws: &Workspace<S>, id: &str, bx: &Map<String, Value>) -> Option<Value> {
|
||||
let o = ws.project.doc.get(id)?;
|
||||
let mut v = serde_json::to_value(o).ok()?;
|
||||
if let Some(b) = bx.get(id) {
|
||||
@@ -111,7 +112,7 @@ fn str_arg(a: &Map<String, Value>, k: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Run one tool.
|
||||
pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Value {
|
||||
pub fn call_tool<S: Store>(ws: &mut Workspace<S>, name: &str, a: &Map<String, Value>) -> Value {
|
||||
ws.refresh();
|
||||
match name {
|
||||
"project_info" => {
|
||||
@@ -287,18 +288,12 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Valu
|
||||
let out = str_arg(a, "out")
|
||||
.or_else(|| p.manifest.build.out.clone())
|
||||
.unwrap_or_else(|| "dist/index.html".into());
|
||||
let path = p.dir.join(&out);
|
||||
match page::static_page(p) {
|
||||
Err(e) => tool_error(e.to_string()),
|
||||
Ok(html) => {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
match std::fs::write(&path, html) {
|
||||
Ok(_) => text(json!({ "path": path.display().to_string() })),
|
||||
Err(e) => tool_error(format!("{}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
Ok(html) => match ws.write_file(&out, &html) {
|
||||
Ok(path) => text(json!({ "path": path.display().to_string() })),
|
||||
Err(e) => tool_error(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
"undo" => match ws.undo() {
|
||||
@@ -313,7 +308,7 @@ pub fn call_tool(ws: &mut Workspace, name: &str, a: &Map<String, Value>) -> Valu
|
||||
}
|
||||
}
|
||||
|
||||
fn render_warnings(ws: &Workspace) -> Vec<String> {
|
||||
fn render_warnings<S: Store>(ws: &Workspace<S>) -> Vec<String> {
|
||||
let p = &ws.project;
|
||||
p.render(&p.manifest.default_level(), 0, None, false)
|
||||
.map(|v| v.warnings)
|
||||
@@ -321,7 +316,7 @@ fn render_warnings(ws: &Workspace) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message.
|
||||
pub fn handle(ws: &mut Workspace, msg: &Value) -> Option<Value> {
|
||||
pub fn handle<S: Store>(ws: &mut Workspace<S>, msg: &Value) -> Option<Value> {
|
||||
let id = msg.get("id").cloned();
|
||||
let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
|
||||
@@ -69,8 +69,9 @@ pub fn svg(p: &Project, level: &str, rot: u8, light: bool) -> Result<String, Scr
|
||||
|
||||
/// Today's date (UTC) as YYYY-MM-DD, without a date crate.
|
||||
pub fn today() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
// web_time: std's clock traps in the browser (wasm32-unknown-unknown)
|
||||
let secs = web_time::SystemTime::now()
|
||||
.duration_since(web_time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Where a project's files live. [`FsStore`]: a folder on disk (the CLI,
|
||||
//! `isokit studio`, `isokit mcp`). [`MemStore`]: memory, filled and drained
|
||||
//! by a host (the web Studio keeps the files in the browser or in a folder
|
||||
//! the user opened, and persists what the workspace writes).
|
||||
|
||||
use crate::{is_project_file, read_dir_files, Error, Files, MANIFEST};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub trait Store {
|
||||
/// The project's name in messages (a folder path, or a label).
|
||||
fn dir(&self) -> PathBuf;
|
||||
/// All project files.
|
||||
fn read(&self) -> Result<Files, Error>;
|
||||
/// Changes whenever a file is edited, added or removed.
|
||||
fn version(&self) -> u64;
|
||||
/// Write one file (relative path).
|
||||
fn write(&mut self, path: &str, body: &str) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
/// A project folder on disk.
|
||||
pub struct FsStore {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
impl FsStore {
|
||||
pub fn new(dir: impl AsRef<Path>) -> FsStore {
|
||||
FsStore {
|
||||
dir: dir.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A relative path that stays inside the project (no `..`, no root).
|
||||
fn safe_rel(path: &str) -> Result<String, Error> {
|
||||
let p = path.replace('\\', "/");
|
||||
let p = p.trim_start_matches("./");
|
||||
if p.is_empty()
|
||||
|| p.starts_with('/')
|
||||
|| p.contains(':')
|
||||
|| p.split('/').any(|s| s == ".." || s.is_empty())
|
||||
{
|
||||
return Err(Error::Io(format!("{path}: not a path inside the project")));
|
||||
}
|
||||
Ok(p.to_string())
|
||||
}
|
||||
|
||||
impl Store for FsStore {
|
||||
fn dir(&self) -> PathBuf {
|
||||
self.dir.clone()
|
||||
}
|
||||
fn read(&self) -> Result<Files, Error> {
|
||||
if !self.dir.join(MANIFEST).exists() {
|
||||
return Err(Error::Io(format!(
|
||||
"{} not found (create a project with `isokit new`)",
|
||||
self.dir.join(MANIFEST).display()
|
||||
)));
|
||||
}
|
||||
read_dir_files(&self.dir)
|
||||
}
|
||||
fn version(&self) -> u64 {
|
||||
crate::version(&self.dir)
|
||||
}
|
||||
/// Atomic: a temporary file renamed over the old one.
|
||||
fn write(&mut self, path: &str, body: &str) -> Result<(), Error> {
|
||||
let path = self.dir.join(safe_rel(path)?);
|
||||
if let Some(d) = path.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| Error::Io(format!("{}: {e}", d.display())))?;
|
||||
}
|
||||
let mut tmp = path.clone().into_os_string();
|
||||
tmp.push(".tmp");
|
||||
let tmp = PathBuf::from(tmp);
|
||||
std::fs::write(&tmp, body).map_err(|e| Error::Io(format!("{}: {e}", tmp.display())))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Project files in memory. The host fills it ([`MemStore::set`] for edits
|
||||
/// made outside the workspace) and persists what the workspace wrote
|
||||
/// ([`MemStore::take_written`]).
|
||||
#[derive(Default)]
|
||||
pub struct MemStore {
|
||||
pub name: String,
|
||||
files: Files,
|
||||
version: u64,
|
||||
written: Vec<String>,
|
||||
}
|
||||
|
||||
impl MemStore {
|
||||
pub fn new(name: impl Into<String>, files: Files) -> MemStore {
|
||||
MemStore {
|
||||
name: name.into(),
|
||||
files,
|
||||
version: 1,
|
||||
written: vec![],
|
||||
}
|
||||
}
|
||||
pub fn files(&self) -> &Files {
|
||||
&self.files
|
||||
}
|
||||
/// An edit from outside (code editor, another tab, a watched folder):
|
||||
/// the workspace reloads on its next refresh. `None` removes the file.
|
||||
pub fn set(&mut self, path: &str, body: Option<String>) -> Result<(), Error> {
|
||||
let p = safe_rel(path)?;
|
||||
match body {
|
||||
Some(b) => {
|
||||
if self.files.get(&p) == Some(&b) {
|
||||
return Ok(());
|
||||
}
|
||||
self.files.insert(p, b);
|
||||
}
|
||||
None => {
|
||||
if self.files.remove(&p).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.version += 1;
|
||||
Ok(())
|
||||
}
|
||||
/// Paths the workspace wrote since the last call (deduplicated, in order).
|
||||
pub fn take_written(&mut self) -> Vec<String> {
|
||||
let mut out: Vec<String> = vec![];
|
||||
for p in self.written.drain(..) {
|
||||
if !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for MemStore {
|
||||
fn dir(&self) -> PathBuf {
|
||||
PathBuf::from(&self.name)
|
||||
}
|
||||
fn read(&self) -> Result<Files, Error> {
|
||||
if !self.files.contains_key(MANIFEST) {
|
||||
return Err(Error::Io(format!("{MANIFEST} not found")));
|
||||
}
|
||||
Ok(self.files.clone())
|
||||
}
|
||||
fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
/// Exports (dist/index.html) do not change the version: like on disk,
|
||||
/// only project files make the workspace reload.
|
||||
fn write(&mut self, path: &str, body: &str) -> Result<(), Error> {
|
||||
let p = safe_rel(path)?;
|
||||
if is_project_file(&p) {
|
||||
self.version += 1;
|
||||
}
|
||||
self.files.insert(p.clone(), body.to_string());
|
||||
self.written.push(p);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paths_stay_inside_the_project() {
|
||||
assert_eq!(safe_rel("lib/a.rhai").unwrap(), "lib/a.rhai");
|
||||
assert_eq!(safe_rel("./scene.json").unwrap(), "scene.json");
|
||||
assert_eq!(safe_rel("dist\\index.html").unwrap(), "dist/index.html");
|
||||
for bad in ["../x", "/etc/passwd", "C:/x", "a//b", "", "lib/../../x"] {
|
||||
assert!(safe_rel(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mem_store_tracks_versions_and_writes() {
|
||||
let mut m = MemStore::new("t", Files::new());
|
||||
assert!(m.read().is_err(), "no manifest yet");
|
||||
let v0 = m.version();
|
||||
m.set("isokit.toml", Some("title = \"t\"".into())).unwrap();
|
||||
assert!(m.version() > v0);
|
||||
let v1 = m.version();
|
||||
m.set("isokit.toml", Some("title = \"t\"".into())).unwrap();
|
||||
assert_eq!(m.version(), v1, "same content is not a change");
|
||||
m.write("scene.json", "{}").unwrap();
|
||||
m.write("scene.json", "{ }").unwrap();
|
||||
let v2 = m.version();
|
||||
assert!(v2 > v1);
|
||||
m.write("dist/index.html", "<html>").unwrap();
|
||||
assert_eq!(m.version(), v2, "an export is not a project change");
|
||||
assert_eq!(
|
||||
m.take_written(),
|
||||
vec!["scene.json".to_string(), "dist/index.html".to_string()]
|
||||
);
|
||||
assert!(m.take_written().is_empty());
|
||||
m.set("scene.json", None).unwrap();
|
||||
assert!(!m.files().contains_key("scene.json"));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
//! A project kept loaded across edits: reload on file changes, save after
|
||||
//! every operation, undo and redo. The Studio server and the MCP server
|
||||
//! each hold one.
|
||||
//! every operation, undo and redo. The Studio server, the MCP server and the
|
||||
//! web Studio each hold one; the [`Store`] says where the files live.
|
||||
|
||||
use crate::ops::{self, Op, Outcome};
|
||||
use crate::{version, Error, Project};
|
||||
use crate::store::{FsStore, Store};
|
||||
use crate::{Error, Project};
|
||||
use isokit_script::doc::Doc;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct Workspace {
|
||||
pub struct Workspace<S: Store = FsStore> {
|
||||
pub dir: PathBuf,
|
||||
pub store: S,
|
||||
pub project: Project,
|
||||
/// Fingerprint of the files the loaded project was read from.
|
||||
/// Version of the files the loaded project was read from.
|
||||
pub version: u64,
|
||||
/// Error of the last reload attempt (the previous project stays loaded).
|
||||
pub load_error: Option<String>,
|
||||
@@ -20,13 +22,21 @@ pub struct Workspace {
|
||||
|
||||
const UNDO_DEPTH: usize = 200;
|
||||
|
||||
impl Workspace {
|
||||
impl Workspace<FsStore> {
|
||||
/// A project folder on disk.
|
||||
pub fn open(dir: impl AsRef<Path>) -> Result<Workspace, Error> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
let project = Project::load(&dir)?;
|
||||
let version = version(&dir);
|
||||
Workspace::with_store(FsStore::new(dir))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Store> Workspace<S> {
|
||||
pub fn with_store(store: S) -> Result<Workspace<S>, Error> {
|
||||
let dir = store.dir();
|
||||
let project = Project::from_files(dir.clone(), &store.read()?)?;
|
||||
let version = store.version();
|
||||
Ok(Workspace {
|
||||
dir,
|
||||
store,
|
||||
project,
|
||||
version,
|
||||
load_error: None,
|
||||
@@ -35,15 +45,19 @@ impl Workspace {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reload when a project file changed on disk (another editor, an agent,
|
||||
/// a git pull). Returns true when something was reloaded.
|
||||
/// Reload when a project file changed in the store (another editor, an
|
||||
/// agent, a git pull). Returns true when something was reloaded.
|
||||
pub fn refresh(&mut self) -> bool {
|
||||
let v = version(&self.dir);
|
||||
let v = self.store.version();
|
||||
if v == self.version {
|
||||
return false;
|
||||
}
|
||||
self.version = v;
|
||||
match Project::load(&self.dir) {
|
||||
match self
|
||||
.store
|
||||
.read()
|
||||
.and_then(|f| Project::from_files(self.dir.clone(), &f))
|
||||
{
|
||||
Ok(p) => {
|
||||
if p.doc != self.project.doc {
|
||||
self.push_undo(self.project.doc.clone());
|
||||
@@ -65,11 +79,22 @@ impl Workspace {
|
||||
}
|
||||
|
||||
fn save(&mut self) -> Result<(), String> {
|
||||
self.project.save_doc().map_err(|e| e.to_string())?;
|
||||
self.version = version(&self.dir);
|
||||
let text = self.project.doc_text().map_err(|e| e.to_string())?;
|
||||
let path = self.project.manifest.doc_path();
|
||||
self.store.write(&path, &text).map_err(|e| e.to_string())?;
|
||||
self.version = self.store.version();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a file that is not the document (an exported page), relative
|
||||
/// to the project. Returns the path as the store names it.
|
||||
pub fn write_file(&mut self, path: &str, body: &str) -> Result<PathBuf, String> {
|
||||
self.refresh();
|
||||
self.store.write(path, body).map_err(|e| e.to_string())?;
|
||||
self.version = self.store.version();
|
||||
Ok(self.dir.join(path))
|
||||
}
|
||||
|
||||
/// Apply an operation to the document and save it.
|
||||
pub fn apply(&mut self, op: Op) -> Result<Outcome, String> {
|
||||
self.refresh();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "isokit-wasm"
|
||||
description = "isokit in the browser: the engine behind the web Studio (wasm-bindgen)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
isokit-project = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Rhai's hasher seeds from getrandom: use the browser's crypto
|
||||
rhai = { workspace = true, features = ["wasm-bindgen"] }
|
||||
@@ -0,0 +1,317 @@
|
||||
//! isokit in the browser. [`Studio`] holds one project in memory (a
|
||||
//! [`MemStore`]) and answers the same JSON as `isokit studio`'s `/api/*`
|
||||
//! routes, so the Studio GUI runs unchanged on top of it; the page persists
|
||||
//! the files it writes ([`Studio::take_written`]). Also exposes the MCP tools
|
||||
//! ([`Studio::mcp`]) for agents working inside the page.
|
||||
//!
|
||||
//! Strings in, JSON strings out: no serde-wasm glue, and the same calls work
|
||||
//! in native tests.
|
||||
|
||||
use isokit_project::store::MemStore;
|
||||
use isokit_project::workspace::Workspace;
|
||||
use isokit_project::{api, check, mcp, page, Files};
|
||||
use serde_json::{json, Map, Value};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console)]
|
||||
fn error(s: &str);
|
||||
}
|
||||
|
||||
/// Panics go to the browser console instead of "unreachable executed".
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
std::panic::set_hook(Box::new(|info| error(&format!("isokit: {info}"))));
|
||||
}
|
||||
|
||||
fn parse_files(files: &str) -> Result<Files, String> {
|
||||
let v: Map<String, Value> = serde_json::from_str(files).map_err(|e| format!("files: {e}"))?;
|
||||
Ok(v.into_iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct Studio {
|
||||
ws: Workspace<MemStore>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Studio {
|
||||
/// `files`: JSON object, relative path -> text. `name` labels the
|
||||
/// project in messages ("browser: Demo", a folder name).
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(name: &str, files: &str) -> Result<Studio, String> {
|
||||
let store = MemStore::new(name, parse_files(files)?);
|
||||
let ws = Workspace::with_store(store).map_err(|e| e.to_string())?;
|
||||
Ok(Studio { ws })
|
||||
}
|
||||
|
||||
/// GET /api/meta
|
||||
pub fn meta(&mut self) -> String {
|
||||
self.ws.refresh();
|
||||
api::meta(&self.ws).to_string()
|
||||
}
|
||||
|
||||
/// GET /api/doc
|
||||
pub fn state(&mut self) -> String {
|
||||
self.ws.refresh();
|
||||
api::state(&self.ws).to_string()
|
||||
}
|
||||
|
||||
/// GET /api/version
|
||||
pub fn version(&mut self) -> String {
|
||||
self.ws.refresh();
|
||||
self.ws.version.to_string()
|
||||
}
|
||||
|
||||
/// POST /api/op: one operation or `{"ops": [...]}`.
|
||||
pub fn op(&mut self, body: &str) -> String {
|
||||
match serde_json::from_str::<Value>(body) {
|
||||
Ok(v) => api::op(&mut self.ws, &v).to_string(),
|
||||
Err(e) => json!({ "error": format!("bad operation: {e}") }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/undo
|
||||
pub fn undo(&mut self) -> String {
|
||||
api::undo(&mut self.ws, false).to_string()
|
||||
}
|
||||
|
||||
/// POST /api/redo
|
||||
pub fn redo(&mut self) -> String {
|
||||
api::undo(&mut self.ws, true).to_string()
|
||||
}
|
||||
|
||||
/// GET /api/render. `cfg`: JSON object of parameter values, or "".
|
||||
pub fn render(&mut self, level: &str, rot: u8, cfg: &str, dev: bool) -> String {
|
||||
self.ws.refresh();
|
||||
let cfg = serde_json::from_str::<Map<String, Value>>(cfg).ok();
|
||||
let level = Some(level).filter(|l| !l.is_empty());
|
||||
api::render(&self.ws, level, rot % 4, cfg.as_ref(), dev).to_string()
|
||||
}
|
||||
|
||||
/// GET /api/check
|
||||
pub fn check(&mut self) -> String {
|
||||
self.ws.refresh();
|
||||
json!({ "findings": check::check(&self.ws.project) }).to_string()
|
||||
}
|
||||
|
||||
/// The theme's CSS (built-in theme plus the project's layers).
|
||||
pub fn theme_css(&mut self) -> String {
|
||||
self.ws.refresh();
|
||||
self.ws.project.theme.css()
|
||||
}
|
||||
|
||||
/// The self-contained HTML page: `{"html"}` or `{"error"}`.
|
||||
pub fn page(&mut self) -> String {
|
||||
self.ws.refresh();
|
||||
match page::static_page(&self.ws.project) {
|
||||
Ok(html) => json!({ "html": html }).to_string(),
|
||||
Err(e) => json!({ "error": e.to_string() }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One view as a standalone SVG: `{"svg"}` or `{"error"}`.
|
||||
pub fn svg(&mut self, level: &str, rot: u8, light: bool) -> String {
|
||||
self.ws.refresh();
|
||||
let level = if level.is_empty() {
|
||||
self.ws.project.manifest.default_level()
|
||||
} else {
|
||||
level.to_string()
|
||||
};
|
||||
match page::svg(&self.ws.project, &level, rot % 4, light) {
|
||||
Ok(svg) => json!({ "svg": svg }).to_string(),
|
||||
Err(e) => json!({ "error": e.to_string() }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One MCP JSON-RPC message (or a batch); "" for notifications.
|
||||
pub fn mcp(&mut self, msg: &str) -> String {
|
||||
let v = match serde_json::from_str::<Value>(msg) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } })
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
let out = match &v {
|
||||
Value::Array(list) => {
|
||||
let r: Vec<Value> = list
|
||||
.iter()
|
||||
.filter_map(|m| mcp::handle(&mut self.ws, m))
|
||||
.collect();
|
||||
(!r.is_empty()).then_some(Value::Array(r))
|
||||
}
|
||||
m => mcp::handle(&mut self.ws, m),
|
||||
};
|
||||
out.map(|v| v.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// An edit made outside the Studio (code editor, watched folder); null
|
||||
/// removes the file. The project reloads on the next call, and a
|
||||
/// document change can be undone. `{"ok"}` or `{"error"}`.
|
||||
pub fn set_file(&mut self, path: &str, body: Option<String>) -> String {
|
||||
match self.ws.store.set(path, body) {
|
||||
Ok(()) => json!({ "ok": true }).to_string(),
|
||||
Err(e) => json!({ "error": e.to_string() }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<String> {
|
||||
self.ws.store.files().get(path).cloned()
|
||||
}
|
||||
|
||||
/// All files as a JSON object (path -> text).
|
||||
pub fn files(&self) -> String {
|
||||
serde_json::to_string(self.ws.store.files()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Files the Studio wrote since the last call (path -> text), for the
|
||||
/// page to persist.
|
||||
pub fn take_written(&mut self) -> String {
|
||||
let paths = self.ws.store.take_written();
|
||||
let files = self.ws.store.files();
|
||||
let out: Map<String, Value> = paths
|
||||
.into_iter()
|
||||
.filter_map(|p| files.get(&p).map(|t| (p, Value::String(t.clone()))))
|
||||
.collect();
|
||||
Value::Object(out).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The files of a new project with a starter scene (JSON object).
|
||||
#[wasm_bindgen]
|
||||
pub fn new_project_files(title: &str) -> String {
|
||||
serde_json::to_string(&isokit_project::scaffold_files(title)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The engine version.
|
||||
#[wasm_bindgen]
|
||||
pub fn engine_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
fn demo_files() -> String {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo");
|
||||
serde_json::to_string(&isokit_project::read_dir_files(&dir).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn j(s: &str) -> Value {
|
||||
serde_json::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_runs_through_the_web_api() {
|
||||
let mut s = Studio::new("browser: demo", &demo_files()).unwrap();
|
||||
let m = j(&s.meta());
|
||||
assert_eq!(m["title"], "isokit demo works");
|
||||
assert!(m["catalog"].as_array().unwrap().len() > 20);
|
||||
assert!(m["script_error"].is_null());
|
||||
let n = j(&s.state())["doc"]["objects"].as_array().unwrap().len();
|
||||
let v = j(&s.render("site", 1, "", true));
|
||||
assert!(
|
||||
v["html"].as_str().unwrap().contains("data-obj=\"kiosk\""),
|
||||
"project prefab drawn"
|
||||
);
|
||||
assert!(v["warnings"].as_array().unwrap().is_empty());
|
||||
assert!(j(&s.render("", 2, "{\"labels\": false}", false))["error"].is_null());
|
||||
|
||||
// an edit is answered with the new state and handed out for saving
|
||||
let r = j(&s.op(r#"{"op": "add", "type": "tree", "x": 3, "y": 4}"#));
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(r["doc"]["objects"].as_array().unwrap().len(), n + 1);
|
||||
let w = j(&s.take_written());
|
||||
let saved: Value = serde_json::from_str(w["scene.json"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(saved["objects"].as_array().unwrap().len(), n + 1);
|
||||
assert_eq!(j(&s.take_written()), json!({}));
|
||||
assert!(j(&s.op(r#"{"op": "add", "type": "ufo"}"#))["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("available"));
|
||||
|
||||
// undo, and an outside edit that reloads and can be undone
|
||||
assert_eq!(j(&s.undo())["doc"]["objects"].as_array().unwrap().len(), n);
|
||||
let v0 = s.version();
|
||||
let mut doc = j(&s.file("scene.json").unwrap());
|
||||
doc["objects"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|o| o["type"] != "lamp");
|
||||
assert_eq!(
|
||||
j(&s.set_file("scene.json", Some(doc.to_string())))["ok"],
|
||||
true
|
||||
);
|
||||
assert_ne!(s.version(), v0);
|
||||
let st = j(&s.state());
|
||||
assert!(st["doc"]["objects"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|o| o["type"] != "lamp"));
|
||||
assert_eq!(st["can_undo"], true);
|
||||
|
||||
// a script error keeps the document editable and is reported
|
||||
let good = s.file("scene.rhai").unwrap();
|
||||
s.set_file("scene.rhai", Some("let x = ;".into()));
|
||||
assert!(!j(&s.meta())["script_error"].is_null());
|
||||
assert!(j(&s.render("site", 0, "", false))["error"].is_object());
|
||||
s.set_file("scene.rhai", Some(good));
|
||||
assert!(j(&s.meta())["script_error"].is_null());
|
||||
|
||||
// exports and the theme
|
||||
assert!(j(&s.page())["html"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("IsoKit.mount"));
|
||||
assert!(j(&s.svg("", 0, true))["svg"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("<svg"));
|
||||
assert!(s.theme_css().contains(".iso-brick"));
|
||||
assert!(j(&s.check())["findings"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tools_work_in_memory() {
|
||||
let mut s = Studio::new("browser: demo", &demo_files()).unwrap();
|
||||
let r = j(&s.mcp(r#"{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}"#));
|
||||
assert_eq!(r["result"]["tools"].as_array().unwrap().len(), 15);
|
||||
let r = j(&s.mcp(
|
||||
r#"{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "add_object", "arguments": {"type": "lamp", "x": 1, "y": 1}}}"#,
|
||||
));
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
assert!(j(&s.take_written()).get("scene.json").is_some());
|
||||
let r = j(&s.mcp(
|
||||
r#"{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "build_html", "arguments": {}}}"#,
|
||||
));
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
assert!(j(&s.take_written())["dist/index.html"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("<html"));
|
||||
assert_eq!(
|
||||
s.mcp(r#"{"jsonrpc": "2.0", "method": "notifications/initialized"}"#),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_projects_and_bad_input() {
|
||||
let files = new_project_files("My \"site\"");
|
||||
let mut s = Studio::new("browser: new", &files).unwrap();
|
||||
assert_eq!(j(&s.meta())["title"], "My 'site'");
|
||||
assert!(j(&s.render("site", 1, "", false))["error"].is_null());
|
||||
assert!(Studio::new("x", "{}").is_err(), "no manifest");
|
||||
assert!(Studio::new("x", "not json").is_err());
|
||||
assert!(j(&s.set_file("../evil.rhai", Some(String::new())))["error"].is_string());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user