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