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