diff --git a/.github/actions/setup-wasm/action.yml b/.github/actions/setup-wasm/action.yml new file mode 100644 index 00000000..1b27b3ad --- /dev/null +++ b/.github/actions/setup-wasm/action.yml @@ -0,0 +1,45 @@ +name: 'Setup and Build WASM' +description: 'Install WASM toolchain, build webui-wasm, and verify output' + +runs: + using: 'composite' + steps: + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.93 + targets: wasm32-unknown-unknown + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: Install wasm-pack and protoc + shell: bash + run: | + cargo install wasm-pack + sudo apt-get update -qq && sudo apt-get install -y -qq protobuf-compiler + + - name: Setup WASM C headers + shell: bash + run: | + # tree-sitter-language 0.1.7 ships minimal WASM C stdlib headers + # sufficient for compiling tree-sitter's C code to wasm32-unknown-unknown + cargo fetch + WASM_HEADERS=$(find $HOME/.cargo/registry/src -path "*/tree-sitter-language-*/wasm/include" -type d | head -1) + if [ -n "$WASM_HEADERS" ]; then + echo "WASI_INCLUDE=$WASM_HEADERS" >> $GITHUB_ENV + else + echo "::error::Could not find tree-sitter-language WASM headers after cargo fetch" + exit 1 + fi + + - name: Build WASM + shell: bash + run: cargo xtask build-wasm + + - name: Verify WASM output + shell: bash + run: | + test -f docs/public/wasm/webui_wasm_bg.wasm + test -f docs/public/wasm/webui_wasm.js + ! grep -q "from 'env'" docs/public/wasm/webui_wasm.js diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 334400a2..707d7508 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,6 +8,10 @@ on: branches: [ "main" ] paths: - 'docs/**' + - 'crates/webui-wasm/**' + - 'crates/webui-parser/**' + - 'crates/webui-handler/**' + - 'crates/webui-protocol/**' workflow_dispatch: jobs: @@ -15,32 +19,29 @@ jobs: runs-on: ubuntu-latest steps: - # Step 1: Checkout the repository - name: Checkout Repository uses: actions/checkout@v4 - # Step 2: Setup Node.js environment + - name: Build WASM playground + uses: ./.github/actions/setup-wasm + - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '22' - - # Step 3: Setup pnpm + - name: Setup pnpm uses: pnpm/action-setup@v2 with: version: latest run_install: false - # Step 4: Install dependencies - name: Install Dependencies run: cd docs && pnpm install - # Step 5: Build the Vitepress site - name: Build Site run: cd docs && pnpm build - # Step 6: Deploy to GitHub Pages - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v3 with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4d0b7487..235e0cf0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,6 +4,9 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + env: CARGO_TERM_COLOR: always @@ -54,3 +57,10 @@ jobs: - name: Build docs run: pnpm --filter @webui/docs build + + wasm: + name: WASM Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ./.github/actions/setup-wasm diff --git a/Cargo.lock b/Cargo.lock index 36e1658e..9ff941ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,12 +447,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.182" @@ -530,15 +524,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -845,6 +830,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1116,19 +1112,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" - -[[package]] -name = "tree-sitter-typescript" -version = "0.23.2" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" -dependencies = [ - "cc", - "tree-sitter-language", -] +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" [[package]] name = "unicode-ident" @@ -1305,21 +1291,15 @@ dependencies = [ name = "webui-parser" version = "0.1.0" dependencies = [ - "anyhow", - "lazy_static", "mockall", - "nom", "serde", "serde_json", - "streaming-iterator", "tempfile", "thiserror", - "tokio", - "tokio-test", "tree-sitter", "tree-sitter-css", "tree-sitter-html", - "tree-sitter-typescript", + "tree-sitter-language", "walkdir", "webui-expressions", "webui-protocol", @@ -1359,6 +1339,20 @@ dependencies = [ "webui-protocol", ] +[[package]] +name = "webui-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", + "webui-handler", + "webui-parser", + "webui-protocol", + "webui-test-utils", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1683,6 +1677,9 @@ dependencies = [ [[package]] name = "xtask" version = "0.1.0" +dependencies = [ + "console", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 0f1befba..0e9d2409 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/webui-parser", "crates/webui-protocol", "crates/webui-state", + "crates/webui-wasm", "xtask", ] resolver = "2" @@ -30,6 +31,8 @@ tokio = { version = "1.49.0", features = ["full"] } clap = { version = "4", features = ["derive"] } console = "0.15" prost = "0.13" +wasm-bindgen = "0.2" +serde-wasm-bindgen = "0.6" # Test dependencies criterion = "0.8.2" diff --git a/crates/webui-parser/Cargo.toml b/crates/webui-parser/Cargo.toml index 1da79d19..bb13f407 100644 --- a/crates/webui-parser/Cargo.toml +++ b/crates/webui-parser/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true [features] default = ["fs"] -fs = [] +fs = ["walkdir"] [dependencies] webui-expressions = { path = "../webui-expressions" } @@ -17,19 +17,13 @@ webui-protocol = { path = "../webui-protocol" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -anyhow = { workspace = true } -tokio = { workspace = true } tree-sitter = "0.26.5" +tree-sitter-language = "0.1.7" tree-sitter-html = "0.23.2" tree-sitter-css = "0.25.0" -tree-sitter-typescript = "0.23.2" -walkdir = "2.5.0" -lazy_static = "1.5.0" -nom = "8.0.0" -streaming-iterator = "0.1.9" +walkdir = { version = "2.5.0", optional = true } [dev-dependencies] mockall = { workspace = true } -tokio-test = { workspace = true } tempfile = { workspace = true } webui-test-utils = { path = "../webui-test-utils" } diff --git a/crates/webui-wasm/Cargo.toml b/crates/webui-wasm/Cargo.toml new file mode 100644 index 00000000..2cfcd3bf --- /dev/null +++ b/crates/webui-wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "webui-wasm" +description = "WebAssembly bindings for WebUI framework — powers the online playground" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +webui-parser = { path = "../webui-parser", default-features = false } +webui-handler = { path = "../webui-handler" } +webui-protocol = { path = "../webui-protocol" } +serde = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen = { workspace = true } +serde-wasm-bindgen = { workspace = true } + +[dev-dependencies] +webui-test-utils = { path = "../webui-test-utils" } diff --git a/crates/webui-wasm/src/lib.rs b/crates/webui-wasm/src/lib.rs new file mode 100644 index 00000000..6fb964a6 --- /dev/null +++ b/crates/webui-wasm/src/lib.rs @@ -0,0 +1,341 @@ +//! WebAssembly bindings for the WebUI framework. +//! +//! This crate exposes the WebUI rendering pipeline to JavaScript via `wasm-bindgen`, +//! powering the interactive playground in the documentation site. +//! +//! Two modes of operation: +//! - **`render`** — Takes a pre-built protocol (JSON) + state and renders HTML. +//! - **`build_and_render`** — Takes virtual files + state, parses and renders HTML +//! using the real `webui-parser` (same parser used by the CLI). + +use serde_json::Value; +use std::collections::HashMap; +use wasm_bindgen::prelude::*; +use webui_handler::{ResponseWriter, WebUIHandler}; +use webui_parser::{CssStrategy, HtmlParser}; +use webui_protocol::WebUIProtocol; + +/// A simple string buffer for collecting rendered output. +struct StringWriter { + content: String, +} + +impl StringWriter { + fn with_capacity(cap: usize) -> Self { + Self { + content: String::with_capacity(cap), + } + } +} + +impl ResponseWriter for StringWriter { + fn write(&mut self, content: &str) -> webui_handler::Result<()> { + self.content.push_str(content); + Ok(()) + } + + fn end(&mut self) -> webui_handler::Result<()> { + Ok(()) + } +} + +/// Render a pre-built WebUI protocol with state data. +/// +/// # Arguments +/// +/// * `protocol_json` — JSON string of the serialized `WebUIProtocol`. +/// * `state_json` — JSON string of the state data. +/// +/// # Returns +/// +/// The rendered HTML string, or throws a JS error on failure. +#[wasm_bindgen] +pub fn render(protocol_json: &str, state_json: &str) -> Result { + render_inner(protocol_json, state_json).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Build and render a WebUI application from virtual files. +/// +/// Uses a lightweight pure-Rust parser suitable for the playground. +/// Handles signals, for-loops, if-conditions, components, and dynamic attributes. +/// +/// # Arguments +/// +/// * `files` — A JS object mapping filenames to their string content. +/// Example: `{ "index.html": "

{{title}}

", "my-card.html": "

" }` +/// * `state_json` — A JSON string of the state data to render with. +/// * `entry` — The entry HTML filename (e.g. `"index.html"`). +/// +/// # Returns +/// +/// The rendered HTML string, or throws a JS error on failure. +#[wasm_bindgen] +pub fn build_and_render(files: JsValue, state_json: &str, entry: &str) -> Result { + let files_map: HashMap = + serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?; + + build_and_render_inner(&files_map, state_json, entry) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Build the protocol JSON from virtual files without rendering. +/// +/// Returns the serialized `WebUIProtocol` as a JSON string. +#[wasm_bindgen] +pub fn build_protocol(files: JsValue, entry: &str) -> Result { + let files_map: HashMap = + serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?; + + build_protocol_inner(&files_map, entry).map_err(|e| JsValue::from_str(&e.to_string())) +} + +fn build_protocol_inner( + files: &HashMap, + entry: &str, +) -> Result { + let protocol = parse_to_protocol(files, entry)?; + serde_json::to_string(&protocol).map_err(|e| BuildError::Protocol(e.to_string())) +} + +fn render_inner(protocol_json: &str, state_json: &str) -> Result { + let protocol: WebUIProtocol = + serde_json::from_str(protocol_json).map_err(|e| BuildError::Protocol(e.to_string()))?; + let state: Value = + serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?; + + let mut writer = StringWriter::with_capacity(1024); + let handler = WebUIHandler::new(); + handler + .render(&protocol, &state, &mut writer) + .map_err(|e| BuildError::Render(e.to_string()))?; + + Ok(writer.content) +} + +/// Core build-and-render implementation (testable without WASM). +pub(crate) fn build_and_render_inner( + files: &HashMap, + state_json: &str, + entry: &str, +) -> Result { + let protocol = parse_to_protocol(files, entry)?; + + let state: Value = + serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?; + + let mut writer = StringWriter::with_capacity(1024); + let handler = WebUIHandler::new(); + handler + .render(&protocol, &state, &mut writer) + .map_err(|e| BuildError::Render(e.to_string()))?; + + Ok(writer.content) +} + +/// Parse virtual files into a `WebUIProtocol` using the real `webui-parser`. +fn parse_to_protocol( + files: &HashMap, + entry: &str, +) -> Result { + let entry_html = files + .get(entry) + .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?; + + let mut parser = HtmlParser::new(); + parser.set_css_strategy(CssStrategy::Inline); + + // Register components from virtual files (no filesystem needed) + for (filename, content) in files { + if filename != entry && filename.ends_with(".html") { + let tag_name = filename.trim_end_matches(".html"); + if tag_name.contains('-') { + let css_key = format!("{tag_name}.css"); + let css = files.get(&css_key).map(|s| s.as_str()); + parser + .component_registry_mut() + .register_component(tag_name, content, css) + .map_err(|e| BuildError::Parse(e.to_string()))?; + } + } + } + + parser + .parse(entry, entry_html) + .map_err(|e| BuildError::Parse(e.to_string()))?; + + Ok(WebUIProtocol { + fragments: parser.into_fragment_records(), + }) +} + +/// Errors from the build-and-render pipeline. +#[derive(Debug, PartialEq)] +pub(crate) enum BuildError { + MissingEntry(String), + Parse(String), + Protocol(String), + State(String), + Render(String), +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BuildError::MissingEntry(name) => write!(f, "Entry file '{name}' not found"), + BuildError::Parse(msg) => write!(f, "Parse error: {msg}"), + BuildError::Protocol(msg) => write!(f, "Protocol JSON error: {msg}"), + BuildError::State(msg) => write!(f, "State JSON error: {msg}"), + BuildError::Render(msg) => write!(f, "Render error: {msg}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_render() { + let mut files = HashMap::new(); + files.insert( + "index.html".to_string(), + "

Hello, {{name}}!

".to_string(), + ); + + let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html"); + assert!(result.is_ok(), "Render failed: {:?}", result); + assert_eq!(result.as_deref(), Ok("

Hello, WebUI!

")); + } + + #[test] + fn test_missing_entry_file() { + let files = HashMap::new(); + let result = build_and_render_inner(&files, "{}", "index.html"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("not found"), "Unexpected error: {}", err); + } + + #[test] + fn test_with_component() { + let mut files = HashMap::new(); + files.insert( + "index.html".to_string(), + "World".to_string(), + ); + files.insert( + "my-card.html".to_string(), + "
".to_string(), + ); + + let result = build_and_render_inner(&files, "{}", "index.html"); + assert!(result.is_ok(), "Render failed: {:?}", result); + let html = result.as_deref().unwrap_or(""); + assert!(html.contains("card"), "Expected card class in: {}", html); + } + + #[test] + fn test_with_for_loop() { + let mut files = HashMap::new(); + files.insert( + "index.html".to_string(), + "{{item.name}}, ".to_string(), + ); + + let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#; + let result = build_and_render_inner(&files, state, "index.html"); + assert!(result.is_ok(), "Render failed: {:?}", result); + let html = result.as_deref().unwrap_or(""); + assert!(html.contains("A"), "Expected 'A' in: {}", html); + assert!(html.contains("B"), "Expected 'B' in: {}", html); + } + + #[test] + fn test_with_if_condition() { + let mut files = HashMap::new(); + files.insert( + "index.html".to_string(), + "Visible".to_string(), + ); + + let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html"); + assert_eq!(result_true.as_deref(), Ok("Visible")); + + let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html"); + assert_eq!(result_false.as_deref(), Ok("")); + } + + #[test] + fn test_invalid_state_json() { + let mut files = HashMap::new(); + files.insert("index.html".to_string(), "

Hi

".to_string()); + + let result = build_and_render_inner(&files, "not json", "index.html"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("State JSON error"), + "Unexpected error: {}", + err + ); + } + + #[test] + fn test_component_with_css() { + let mut files = HashMap::new(); + files.insert( + "index.html".to_string(), + "Content".to_string(), + ); + files.insert( + "my-card.html".to_string(), + "

".to_string(), + ); + files.insert("my-card.css".to_string(), "p { color: red; }".to_string()); + + let result = build_and_render_inner(&files, "{}", "index.html"); + assert!(result.is_ok(), "Render failed: {:?}", result); + let html = result.as_deref().unwrap_or(""); + // WASM uses CssStrategy::Inline, so CSS should be in "), + "Expected inline