Skip to main content

webui_wasm/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! WebAssembly bindings for the WebUI framework.
5//!
6//! This crate exposes the WebUI rendering pipeline to JavaScript via `wasm-bindgen`,
7//! powering the interactive playground in the documentation site.
8//!
9//! Two modes of operation:
10//! - **`render`** — Takes a pre-built protocol (JSON) + state and renders HTML.
11//! - **`build_and_render`** — Takes virtual files + state, parses and renders HTML
12//!   using the real `webui-parser` (same parser used by the CLI).
13
14use serde_json::Value;
15use std::collections::HashMap;
16use wasm_bindgen::prelude::*;
17use webui_handler::plugin::FastHydrationPlugin;
18use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
19use webui_parser::{CssStrategy, HtmlParser};
20use webui_protocol::WebUIProtocol;
21
22/// A simple string buffer for collecting rendered output.
23struct StringWriter {
24    content: String,
25}
26
27impl StringWriter {
28    fn with_capacity(cap: usize) -> Self {
29        Self {
30            content: String::with_capacity(cap),
31        }
32    }
33}
34
35impl ResponseWriter for StringWriter {
36    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
37        self.content.push_str(content);
38        Ok(())
39    }
40
41    fn end(&mut self) -> webui_handler::Result<()> {
42        Ok(())
43    }
44}
45
46/// Render a pre-built WebUI protocol with state data.
47///
48/// # Arguments
49///
50/// * `protocol_json` — JSON string of the serialized `WebUIProtocol`.
51/// * `state_json` — JSON string of the state data.
52/// * `plugin` — Optional plugin identifier (e.g., `"fast"`).
53///
54/// # Returns
55///
56/// The rendered HTML string, or throws a JS error on failure.
57#[wasm_bindgen]
58pub fn render(
59    protocol_json: &str,
60    state_json: &str,
61    entry: &str,
62    request_path: &str,
63    plugin: Option<String>,
64) -> Result<String, JsValue> {
65    render_inner(
66        protocol_json,
67        state_json,
68        entry,
69        request_path,
70        plugin.as_deref(),
71    )
72    .map_err(|e| JsValue::from_str(&e.to_string()))
73}
74
75/// Build and render a WebUI application from virtual files.
76///
77/// Uses a lightweight pure-Rust parser suitable for the playground.
78/// Handles signals, for-loops, if-conditions, components, and dynamic attributes.
79///
80/// # Arguments
81///
82/// * `files` — A JS object mapping filenames to their string content.
83///   Example: `{ "index.html": "<h1>{{title}}</h1>", "my-card.html": "<p><slot></slot></p>" }`
84/// * `state_json` — A JSON string of the state data to render with.
85/// * `entry` — The entry HTML filename (e.g. `"index.html"`).
86///
87/// # Returns
88///
89/// The rendered HTML string, or throws a JS error on failure.
90#[wasm_bindgen]
91pub fn build_and_render(
92    files: JsValue,
93    state_json: &str,
94    entry: &str,
95    request_path: &str,
96) -> Result<String, JsValue> {
97    let files_map: HashMap<String, String> =
98        serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?;
99
100    build_and_render_inner(&files_map, state_json, entry, request_path)
101        .map_err(|e| JsValue::from_str(&e.to_string()))
102}
103
104/// Build the protocol JSON from virtual files without rendering.
105///
106/// Returns the serialized `WebUIProtocol` as a JSON string.
107#[wasm_bindgen]
108pub fn build_protocol(files: JsValue, entry: &str) -> Result<String, JsValue> {
109    let files_map: HashMap<String, String> =
110        serde_wasm_bindgen::from_value(files).map_err(|e| JsValue::from_str(&e.to_string()))?;
111
112    build_protocol_inner(&files_map, entry).map_err(|e| JsValue::from_str(&e.to_string()))
113}
114
115/// Produce a complete JSON partial response for client-side navigation.
116///
117/// Combines application state, route templates, inventory, request path, and
118/// matched route chain into a single JSON string:
119/// `{"state":{...},"templates":[...],"inventory":"...","path":"...","chain":[...]}`.
120///
121/// Host servers return this directly — no assembly required.
122#[wasm_bindgen]
123pub fn render_partial(
124    protocol_json: &str,
125    state_json: &str,
126    entry_id: &str,
127    request_path: &str,
128    inventory_hex: &str,
129) -> Result<String, JsValue> {
130    let protocol: WebUIProtocol = serde_json::from_str(protocol_json)
131        .map_err(|e| JsValue::from_str(&format!("Protocol JSON error: {e}")))?;
132
133    let state: serde_json::Value = serde_json::from_str(state_json)
134        .map_err(|e| JsValue::from_str(&format!("invalid state JSON: {e}")))?;
135
136    let result = webui_handler::route_handler::render_partial(
137        &protocol,
138        state,
139        entry_id,
140        request_path,
141        inventory_hex,
142    );
143
144    serde_json::to_string(&result)
145        .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
146}
147
148fn build_protocol_inner(
149    files: &HashMap<String, String>,
150    entry: &str,
151) -> Result<String, BuildError> {
152    let protocol = parse_to_protocol(files, entry)?;
153    serde_json::to_string(&protocol).map_err(|e| BuildError::Protocol(e.to_string()))
154}
155
156/// Create a handler with an optional plugin.
157fn create_handler(plugin: Option<&str>) -> Result<WebUIHandler, BuildError> {
158    match plugin {
159        Some("fast") => Ok(WebUIHandler::with_plugin(|| {
160            Box::new(FastHydrationPlugin::new())
161        })),
162        Some(unknown) => Err(BuildError::Render(format!("Unknown plugin: {unknown}"))),
163        None => Ok(WebUIHandler::new()),
164    }
165}
166
167fn render_inner(
168    protocol_json: &str,
169    state_json: &str,
170    entry: &str,
171    request_path: &str,
172    plugin: Option<&str>,
173) -> Result<String, BuildError> {
174    let protocol: WebUIProtocol =
175        serde_json::from_str(protocol_json).map_err(|e| BuildError::Protocol(e.to_string()))?;
176    let state: Value =
177        serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?;
178
179    let mut writer = StringWriter::with_capacity(1024);
180    let handler = create_handler(plugin)?;
181    handler
182        .render(
183            &protocol,
184            &state,
185            &RenderOptions::new(entry, request_path),
186            &mut writer,
187        )
188        .map_err(|e| BuildError::Render(e.to_string()))?;
189
190    Ok(writer.content)
191}
192
193/// Core build-and-render implementation (testable without WASM).
194pub(crate) fn build_and_render_inner(
195    files: &HashMap<String, String>,
196    state_json: &str,
197    entry: &str,
198    request_path: &str,
199) -> Result<String, BuildError> {
200    let protocol = parse_to_protocol(files, entry)?;
201
202    let state: Value =
203        serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?;
204
205    let mut writer = StringWriter::with_capacity(1024);
206    let handler = create_handler(None)?;
207    handler
208        .render(
209            &protocol,
210            &state,
211            &RenderOptions::new(entry, request_path),
212            &mut writer,
213        )
214        .map_err(|e| BuildError::Render(e.to_string()))?;
215
216    Ok(writer.content)
217}
218
219/// Parse virtual files into a `WebUIProtocol` using the real `webui-parser`.
220fn parse_to_protocol(
221    files: &HashMap<String, String>,
222    entry: &str,
223) -> Result<WebUIProtocol, BuildError> {
224    let entry_html = files
225        .get(entry)
226        .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?;
227
228    let mut parser = HtmlParser::new();
229    parser.set_css_strategy(CssStrategy::Style);
230
231    // Register components from virtual files (no filesystem needed)
232    for (filename, content) in files {
233        if filename != entry && filename.ends_with(".html") {
234            let tag_name = filename.trim_end_matches(".html");
235            if tag_name.contains('-') {
236                let css_key = format!("{tag_name}.css");
237                let css = files.get(&css_key).map(|s| s.as_str());
238                parser
239                    .component_registry_mut()
240                    .register_component(tag_name, content, css)
241                    .map_err(|e| BuildError::Parse(e.to_string()))?;
242            }
243        }
244    }
245
246    parser
247        .parse(entry, entry_html)
248        .map_err(|e| BuildError::Parse(e.to_string()))?;
249
250    Ok(WebUIProtocol::new(parser.into_fragment_records()))
251}
252
253/// Errors from the build-and-render pipeline.
254#[derive(Debug, PartialEq)]
255pub(crate) enum BuildError {
256    MissingEntry(String),
257    Parse(String),
258    Protocol(String),
259    State(String),
260    Render(String),
261}
262
263impl std::fmt::Display for BuildError {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        match self {
266            BuildError::MissingEntry(name) => write!(f, "Entry file '{name}' not found"),
267            BuildError::Parse(msg) => write!(f, "Parse error: {msg}"),
268            BuildError::Protocol(msg) => write!(f, "Protocol JSON error: {msg}"),
269            BuildError::State(msg) => write!(f, "State JSON error: {msg}"),
270            BuildError::Render(msg) => write!(f, "Render error: {msg}"),
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_simple_render() {
281        let mut files = HashMap::new();
282        files.insert(
283            "index.html".to_string(),
284            "<h1>Hello, {{name}}!</h1>".to_string(),
285        );
286
287        let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
288        assert!(result.is_ok(), "Render failed: {:?}", result);
289        assert_eq!(result.as_deref(), Ok("<h1>Hello, WebUI!</h1>"));
290    }
291
292    #[test]
293    fn test_missing_entry_file() {
294        let files = HashMap::new();
295        let result = build_and_render_inner(&files, "{}", "index.html", "/");
296        assert!(result.is_err());
297        let err = result.unwrap_err().to_string();
298        assert!(err.contains("not found"), "Unexpected error: {}", err);
299    }
300
301    #[test]
302    fn test_with_component() {
303        let mut files = HashMap::new();
304        files.insert(
305            "index.html".to_string(),
306            "<my-card>World</my-card>".to_string(),
307        );
308        files.insert(
309            "my-card.html".to_string(),
310            "<div class=\"card\"><slot></slot></div>".to_string(),
311        );
312
313        let result = build_and_render_inner(&files, "{}", "index.html", "/");
314        assert!(result.is_ok(), "Render failed: {:?}", result);
315        let html = result.as_deref().unwrap_or("");
316        assert!(html.contains("card"), "Expected card class in: {}", html);
317    }
318
319    #[test]
320    fn test_with_for_loop() {
321        let mut files = HashMap::new();
322        files.insert(
323            "index.html".to_string(),
324            "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
325        );
326
327        let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
328        let result = build_and_render_inner(&files, state, "index.html", "/");
329        assert!(result.is_ok(), "Render failed: {:?}", result);
330        let html = result.as_deref().unwrap_or("");
331        assert!(html.contains("A"), "Expected 'A' in: {}", html);
332        assert!(html.contains("B"), "Expected 'B' in: {}", html);
333    }
334
335    #[test]
336    fn test_with_if_condition() {
337        let mut files = HashMap::new();
338        files.insert(
339            "index.html".to_string(),
340            "<if condition=\"show\">Visible</if>".to_string(),
341        );
342
343        let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html", "/");
344        assert_eq!(result_true.as_deref(), Ok("Visible"));
345
346        let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html", "/");
347        assert_eq!(result_false.as_deref(), Ok(""));
348    }
349
350    #[test]
351    fn test_invalid_state_json() {
352        let mut files = HashMap::new();
353        files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
354
355        let result = build_and_render_inner(&files, "not json", "index.html", "/");
356        assert!(result.is_err());
357        let err = result.unwrap_err().to_string();
358        assert!(
359            err.contains("State JSON error"),
360            "Unexpected error: {}",
361            err
362        );
363    }
364
365    #[test]
366    fn test_component_with_css() {
367        let mut files = HashMap::new();
368        files.insert(
369            "index.html".to_string(),
370            "<my-card>Content</my-card>".to_string(),
371        );
372        files.insert(
373            "my-card.html".to_string(),
374            "<p><slot></slot></p>".to_string(),
375        );
376        files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
377
378        let result = build_and_render_inner(&files, "{}", "index.html", "/");
379        assert!(result.is_ok(), "Render failed: {:?}", result);
380        let html = result.as_deref().unwrap_or("");
381        // WASM uses CssStrategy::Style, so CSS should be in <style> tags, not <link>
382        assert!(
383            html.contains("<style>p { color: red; }</style>"),
384            "Expected inline <style> tag in: {}",
385            html
386        );
387        assert!(
388            !html.contains("<link"),
389            "Should not have external <link> tag in: {}",
390            html
391        );
392    }
393
394    #[test]
395    fn test_raw_signal() {
396        let mut files = HashMap::new();
397        files.insert(
398            "index.html".to_string(),
399            "<div>{{{raw_html}}}</div>".to_string(),
400        );
401
402        let result =
403            build_and_render_inner(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
404        assert!(result.is_ok(), "Render failed: {:?}", result);
405        let html = result.as_deref().unwrap_or("");
406        assert!(
407            html.contains("<b>bold</b>"),
408            "Expected raw HTML in: {}",
409            html
410        );
411    }
412
413    #[test]
414    fn test_static_html_passthrough() {
415        let mut files = HashMap::new();
416        files.insert(
417            "index.html".to_string(),
418            "<h1>Static</h1><p>Content</p>".to_string(),
419        );
420
421        let result = build_and_render_inner(&files, "{}", "index.html", "/");
422        assert_eq!(result.as_deref(), Ok("<h1>Static</h1><p>Content</p>"));
423    }
424}