1use serde_json::Value;
15use std::collections::HashMap;
16use wasm_bindgen::prelude::*;
17use webui_handler::plugin::fast_v2::FastV2HydrationPlugin;
18use webui_handler::plugin::fast_v3::FastV3HydrationPlugin;
19use webui_handler::plugin::webui::WebUIHydrationPlugin;
20use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
21use webui_parser::{CssStrategy, HtmlParser, Plugin};
22use webui_protocol::WebUIProtocol;
23
24struct StringWriter {
26 content: String,
27}
28
29impl StringWriter {
30 fn with_capacity(cap: usize) -> Self {
31 Self {
32 content: String::with_capacity(cap),
33 }
34 }
35}
36
37impl ResponseWriter for StringWriter {
38 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
39 self.content.push_str(content);
40 Ok(())
41 }
42
43 fn end(&mut self) -> webui_handler::Result<()> {
44 Ok(())
45 }
46}
47
48#[wasm_bindgen]
60pub fn render(
61 protocol_json: &str,
62 state_json: &str,
63 entry: &str,
64 request_path: &str,
65 plugin: Option<String>,
66) -> Result<String, JsValue> {
67 let plugin = plugin
68 .map(|s| s.parse::<Plugin>())
69 .transpose()
70 .map_err(|e| JsValue::from_str(&e))?;
71 render_inner(protocol_json, state_json, entry, request_path, plugin)
72 .map_err(|e| JsValue::from_str(&e.to_string()))
73}
74
75#[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#[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#[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 mut index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
139
140 let mut result = webui_handler::route_handler::render_partial(
141 &protocol,
142 entry_id,
143 request_path,
144 inventory_hex,
145 &mut index,
146 )
147 .map_err(|e| JsValue::from_str(&format!("render_partial failed: {e}")))?;
148 if let Some(obj) = result.as_object_mut() {
149 obj.insert("state".into(), state);
150 }
151
152 serde_json::to_string(&result)
153 .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
154}
155
156#[wasm_bindgen]
161pub fn protocol_tokens(protocol_json: &str) -> Result<JsValue, JsValue> {
162 let protocol: WebUIProtocol = serde_json::from_str(protocol_json)
163 .map_err(|e| JsValue::from_str(&format!("Protocol JSON error: {e}")))?;
164
165 serde_wasm_bindgen::to_value(&protocol.tokens)
166 .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
167}
168
169#[wasm_bindgen]
170pub fn render_component_templates(
171 protocol_json: &str,
172 component_tags_json: &str,
173 inventory_hex: &str,
174) -> Result<String, JsValue> {
175 let protocol: WebUIProtocol = serde_json::from_str(protocol_json)
176 .map_err(|e| JsValue::from_str(&format!("Protocol JSON error: {e}")))?;
177
178 let tags: Vec<String> = serde_json::from_str(component_tags_json)
179 .map_err(|e| JsValue::from_str(&format!("invalid tags JSON: {e}")))?;
180 let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect();
181
182 let index = webui_handler::route_handler::ProtocolIndex::new(&protocol);
184
185 let result = webui_handler::route_handler::render_component_templates(
186 &protocol,
187 &tag_refs,
188 inventory_hex,
189 &index,
190 )
191 .map_err(|e| JsValue::from_str(&format!("render_component_templates failed: {e}")))?;
192
193 serde_json::to_string(&result)
194 .map_err(|e| JsValue::from_str(&format!("JSON serialize error: {e}")))
195}
196
197fn build_protocol_inner(
198 files: &HashMap<String, String>,
199 entry: &str,
200) -> Result<String, BuildError> {
201 let protocol = parse_to_protocol(files, entry)?;
202 serde_json::to_string(&protocol).map_err(BuildError::Protocol)
203}
204
205fn create_handler(plugin: Option<Plugin>) -> Result<WebUIHandler, BuildError> {
207 match plugin {
208 Some(Plugin::Fast | Plugin::FastV2) => Ok(WebUIHandler::with_plugin(|| {
209 Box::new(FastV2HydrationPlugin::new())
210 })),
211 Some(Plugin::FastV3) => Ok(WebUIHandler::with_plugin(|| {
212 Box::new(FastV3HydrationPlugin::new())
213 })),
214 Some(Plugin::WebUI) => Ok(WebUIHandler::with_plugin(|| {
215 Box::new(WebUIHydrationPlugin::new())
216 })),
217 None => Ok(WebUIHandler::new()),
218 }
219}
220
221fn render_inner(
222 protocol_json: &str,
223 state_json: &str,
224 entry: &str,
225 request_path: &str,
226 plugin: Option<Plugin>,
227) -> Result<String, BuildError> {
228 let protocol: WebUIProtocol =
229 serde_json::from_str(protocol_json).map_err(BuildError::Protocol)?;
230 let state: Value = serde_json::from_str(state_json).map_err(BuildError::State)?;
231
232 let mut writer = StringWriter::with_capacity(1024);
233 let handler = create_handler(plugin)?;
234 handler.render(
235 &protocol,
236 &state,
237 &RenderOptions::new(entry, request_path),
238 &mut writer,
239 )?;
240
241 Ok(writer.content)
242}
243
244pub(crate) fn build_and_render_inner(
246 files: &HashMap<String, String>,
247 state_json: &str,
248 entry: &str,
249 request_path: &str,
250) -> Result<String, BuildError> {
251 let protocol = parse_to_protocol(files, entry)?;
252
253 let state: Value = serde_json::from_str(state_json).map_err(BuildError::State)?;
254
255 let mut writer = StringWriter::with_capacity(1024);
256 let handler = create_handler(None)?;
257 handler.render(
258 &protocol,
259 &state,
260 &RenderOptions::new(entry, request_path),
261 &mut writer,
262 )?;
263
264 Ok(writer.content)
265}
266
267fn parse_to_protocol(
269 files: &HashMap<String, String>,
270 entry: &str,
271) -> Result<WebUIProtocol, BuildError> {
272 let entry_html = files
273 .get(entry)
274 .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?;
275
276 let mut parser = HtmlParser::new();
277 parser.set_css_strategy(CssStrategy::Style);
278
279 for (filename, content) in files {
281 if filename != entry && filename.ends_with(".html") {
282 let tag_name = filename.trim_end_matches(".html");
283 if tag_name.contains('-') {
284 let css_key = format!("{tag_name}.css");
285 let css = files.get(&css_key).map(|s| s.as_str());
286 parser
287 .component_registry_mut()
288 .register_component(tag_name, content, css)?;
289 }
290 }
291 }
292
293 parser.parse(entry, entry_html)?;
294
295 Ok(WebUIProtocol::new(parser.into_fragment_records()))
296}
297
298#[derive(Debug, thiserror::Error)]
300pub(crate) enum BuildError {
301 #[error("Entry file '{0}' not found")]
302 MissingEntry(String),
303
304 #[error("{0}")]
305 Parse(#[from] webui_parser::ParserError),
306
307 #[error("Protocol JSON error: {0}")]
308 Protocol(serde_json::Error),
309
310 #[error("State JSON error: {0}")]
311 State(serde_json::Error),
312
313 #[error("{0}")]
314 Render(#[from] webui_handler::HandlerError),
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 #[test]
322 fn test_simple_render() {
323 let mut files = HashMap::new();
324 files.insert(
325 "index.html".to_string(),
326 "<h1>Hello, {{name}}!</h1>".to_string(),
327 );
328
329 let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
330 assert_eq!(result.unwrap(), "<h1>Hello, WebUI!</h1>");
331 }
332
333 #[test]
334 fn test_missing_entry_file() {
335 let files = HashMap::new();
336 let result = build_and_render_inner(&files, "{}", "index.html", "/");
337 assert!(result.is_err());
338 let err = result.unwrap_err().to_string();
339 assert!(err.contains("not found"), "Unexpected error: {}", err);
340 }
341
342 #[test]
343 fn test_with_component() {
344 let mut files = HashMap::new();
345 files.insert(
346 "index.html".to_string(),
347 "<my-card>World</my-card>".to_string(),
348 );
349 files.insert(
350 "my-card.html".to_string(),
351 "<div class=\"card\"><slot></slot></div>".to_string(),
352 );
353
354 let result = build_and_render_inner(&files, "{}", "index.html", "/");
355 assert!(result.is_ok(), "Render failed: {:?}", result);
356 let html = result.as_deref().unwrap_or("");
357 assert!(html.contains("card"), "Expected card class in: {}", html);
358 }
359
360 #[test]
361 fn test_with_for_loop() {
362 let mut files = HashMap::new();
363 files.insert(
364 "index.html".to_string(),
365 "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
366 );
367
368 let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
369 let result = build_and_render_inner(&files, state, "index.html", "/");
370 assert!(result.is_ok(), "Render failed: {:?}", result);
371 let html = result.as_deref().unwrap_or("");
372 assert!(html.contains("A"), "Expected 'A' in: {}", html);
373 assert!(html.contains("B"), "Expected 'B' in: {}", html);
374 }
375
376 #[test]
377 fn test_with_if_condition() {
378 let mut files = HashMap::new();
379 files.insert(
380 "index.html".to_string(),
381 "<if condition=\"show\">Visible</if>".to_string(),
382 );
383
384 let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html", "/");
385 assert_eq!(result_true.unwrap(), "Visible");
386
387 let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html", "/");
388 assert_eq!(result_false.unwrap(), "");
389 }
390
391 #[test]
392 fn test_invalid_state_json() {
393 let mut files = HashMap::new();
394 files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
395
396 let result = build_and_render_inner(&files, "not json", "index.html", "/");
397 assert!(result.is_err());
398 let err = result.unwrap_err().to_string();
399 assert!(
400 err.contains("State JSON error"),
401 "Unexpected error: {}",
402 err
403 );
404 }
405
406 #[test]
407 fn test_component_with_css() {
408 let mut files = HashMap::new();
409 files.insert(
410 "index.html".to_string(),
411 "<my-card>Content</my-card>".to_string(),
412 );
413 files.insert(
414 "my-card.html".to_string(),
415 "<p><slot></slot></p>".to_string(),
416 );
417 files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
418
419 let result = build_and_render_inner(&files, "{}", "index.html", "/");
420 assert!(result.is_ok(), "Render failed: {:?}", result);
421 let html = result.as_deref().unwrap_or("");
422 assert!(
424 html.contains("<style>p { color: red; }</style>"),
425 "Expected inline <style> tag in: {}",
426 html
427 );
428 assert!(
429 !html.contains("<link"),
430 "Should not have external <link> tag in: {}",
431 html
432 );
433 }
434
435 #[test]
436 fn test_raw_signal() {
437 let mut files = HashMap::new();
438 files.insert(
439 "index.html".to_string(),
440 "<div>{{{raw_html}}}</div>".to_string(),
441 );
442
443 let result =
444 build_and_render_inner(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
445 assert!(result.is_ok(), "Render failed: {:?}", result);
446 let html = result.as_deref().unwrap_or("");
447 assert!(
448 html.contains("<b>bold</b>"),
449 "Expected raw HTML in: {}",
450 html
451 );
452 }
453
454 #[test]
455 fn test_static_html_passthrough() {
456 let mut files = HashMap::new();
457 files.insert(
458 "index.html".to_string(),
459 "<h1>Static</h1><p>Content</p>".to_string(),
460 );
461
462 let result = build_and_render_inner(&files, "{}", "index.html", "/");
463 assert_eq!(result.unwrap(), "<h1>Static</h1><p>Content</p>");
464 }
465
466 #[test]
467 fn test_protocol_tokens_empty() {
468 let protocol = WebUIProtocol::new(HashMap::new());
469 let json = serde_json::to_string(&protocol).unwrap();
470 let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
471 assert!(decoded.tokens.is_empty());
472 }
473
474 #[test]
475 fn test_protocol_tokens_roundtrip() {
476 let tokens = vec![
477 "colorBrandBackground".to_string(),
478 "fontSizeBase300".to_string(),
479 ];
480 let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
481 let json = serde_json::to_string(&protocol).unwrap();
482 let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
483 assert_eq!(decoded.tokens, tokens);
484 }
485
486 #[test]
487 fn test_protocol_tokens_preserves_order() {
488 let tokens = vec!["zeta".to_string(), "alpha".to_string(), "zeta".to_string()];
489 let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
490 let json = serde_json::to_string(&protocol).unwrap();
491 let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
492 assert_eq!(decoded.tokens, tokens);
493 }
494}