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::with_options(CssStrategy::Style);
277
278 for (filename, content) in files {
280 if filename != entry && filename.ends_with(".html") {
281 let tag_name = filename.trim_end_matches(".html");
282 if tag_name.contains('-') {
283 let css_key = format!("{tag_name}.css");
284 let css = files.get(&css_key).map(|s| s.as_str());
285 parser
286 .component_registry_mut()
287 .register_component(tag_name, content, css)?;
288 }
289 }
290 }
291
292 parser.parse(entry, entry_html)?;
293
294 Ok(WebUIProtocol::new(parser.into_fragment_records()))
295}
296
297#[derive(Debug, thiserror::Error)]
299pub(crate) enum BuildError {
300 #[error("Entry file '{0}' not found")]
301 MissingEntry(String),
302
303 #[error("{0}")]
304 Parse(#[from] webui_parser::ParserError),
305
306 #[error("Protocol JSON error: {0}")]
307 Protocol(serde_json::Error),
308
309 #[error("State JSON error: {0}")]
310 State(serde_json::Error),
311
312 #[error("{0}")]
313 Render(#[from] webui_handler::HandlerError),
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn test_simple_render() {
322 let mut files = HashMap::new();
323 files.insert(
324 "index.html".to_string(),
325 "<h1>Hello, {{name}}!</h1>".to_string(),
326 );
327
328 let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
329 assert_eq!(result.unwrap(), "<h1>Hello, WebUI!</h1>");
330 }
331
332 #[test]
333 fn test_missing_entry_file() {
334 let files = HashMap::new();
335 let result = build_and_render_inner(&files, "{}", "index.html", "/");
336 assert!(result.is_err());
337 let err = result.unwrap_err().to_string();
338 assert!(err.contains("not found"), "Unexpected error: {}", err);
339 }
340
341 #[test]
342 fn test_with_component() {
343 let mut files = HashMap::new();
344 files.insert(
345 "index.html".to_string(),
346 "<my-card>World</my-card>".to_string(),
347 );
348 files.insert(
349 "my-card.html".to_string(),
350 "<div class=\"card\"><slot></slot></div>".to_string(),
351 );
352
353 let result = build_and_render_inner(&files, "{}", "index.html", "/");
354 assert!(result.is_ok(), "Render failed: {:?}", result);
355 let html = result.as_deref().unwrap_or("");
356 assert!(html.contains("card"), "Expected card class in: {}", html);
357 }
358
359 #[test]
360 fn test_with_for_loop() {
361 let mut files = HashMap::new();
362 files.insert(
363 "index.html".to_string(),
364 "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
365 );
366
367 let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
368 let result = build_and_render_inner(&files, state, "index.html", "/");
369 assert!(result.is_ok(), "Render failed: {:?}", result);
370 let html = result.as_deref().unwrap_or("");
371 assert!(html.contains("A"), "Expected 'A' in: {}", html);
372 assert!(html.contains("B"), "Expected 'B' in: {}", html);
373 }
374
375 #[test]
376 fn test_with_if_condition() {
377 let mut files = HashMap::new();
378 files.insert(
379 "index.html".to_string(),
380 "<if condition=\"show\">Visible</if>".to_string(),
381 );
382
383 let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html", "/");
384 assert_eq!(result_true.unwrap(), "Visible");
385
386 let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html", "/");
387 assert_eq!(result_false.unwrap(), "");
388 }
389
390 #[test]
391 fn test_invalid_state_json() {
392 let mut files = HashMap::new();
393 files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
394
395 let result = build_and_render_inner(&files, "not json", "index.html", "/");
396 assert!(result.is_err());
397 let err = result.unwrap_err().to_string();
398 assert!(
399 err.contains("State JSON error"),
400 "Unexpected error: {}",
401 err
402 );
403 }
404
405 #[test]
406 fn test_component_with_css() {
407 let mut files = HashMap::new();
408 files.insert(
409 "index.html".to_string(),
410 "<my-card>Content</my-card>".to_string(),
411 );
412 files.insert(
413 "my-card.html".to_string(),
414 "<p><slot></slot></p>".to_string(),
415 );
416 files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
417
418 let result = build_and_render_inner(&files, "{}", "index.html", "/");
419 assert!(result.is_ok(), "Render failed: {:?}", result);
420 let html = result.as_deref().unwrap_or("");
421 assert!(
423 html.contains("<style>p { color: red; }</style>"),
424 "Expected inline <style> tag in: {}",
425 html
426 );
427 assert!(
428 !html.contains("<link"),
429 "Should not have external <link> tag in: {}",
430 html
431 );
432 }
433
434 #[test]
435 fn test_raw_signal() {
436 let mut files = HashMap::new();
437 files.insert(
438 "index.html".to_string(),
439 "<div>{{{raw_html}}}</div>".to_string(),
440 );
441
442 let result =
443 build_and_render_inner(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
444 assert!(result.is_ok(), "Render failed: {:?}", result);
445 let html = result.as_deref().unwrap_or("");
446 assert!(
447 html.contains("<b>bold</b>"),
448 "Expected raw HTML in: {}",
449 html
450 );
451 }
452
453 #[test]
454 fn test_static_html_passthrough() {
455 let mut files = HashMap::new();
456 files.insert(
457 "index.html".to_string(),
458 "<h1>Static</h1><p>Content</p>".to_string(),
459 );
460
461 let result = build_and_render_inner(&files, "{}", "index.html", "/");
462 assert_eq!(result.unwrap(), "<h1>Static</h1><p>Content</p>");
463 }
464
465 #[test]
466 fn test_protocol_tokens_empty() {
467 let protocol = WebUIProtocol::new(HashMap::new());
468 let json = serde_json::to_string(&protocol).unwrap();
469 let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
470 assert!(decoded.tokens.is_empty());
471 }
472
473 #[test]
474 fn test_protocol_tokens_roundtrip() {
475 let tokens = vec![
476 "colorBrandBackground".to_string(),
477 "fontSizeBase300".to_string(),
478 ];
479 let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
480 let json = serde_json::to_string(&protocol).unwrap();
481 let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
482 assert_eq!(decoded.tokens, tokens);
483 }
484
485 #[test]
486 fn test_protocol_tokens_preserves_order() {
487 let tokens = vec!["zeta".to_string(), "alpha".to_string(), "zeta".to_string()];
488 let protocol = WebUIProtocol::with_tokens(HashMap::new(), tokens.clone());
489 let json = serde_json::to_string(&protocol).unwrap();
490 let decoded: WebUIProtocol = serde_json::from_str(&json).unwrap();
491 assert_eq!(decoded.tokens, tokens);
492 }
493}