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 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(BuildError::Protocol)
176}
177
178fn 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(BuildError::Protocol)?;
200 let state: Value = serde_json::from_str(state_json).map_err(BuildError::State)?;
201
202 let mut writer = StringWriter::with_capacity(1024);
203 let handler = create_handler(plugin)?;
204 handler.render(
205 &protocol,
206 &state,
207 &RenderOptions::new(entry, request_path),
208 &mut writer,
209 )?;
210
211 Ok(writer.content)
212}
213
214pub(crate) fn build_and_render_inner(
216 files: &HashMap<String, String>,
217 state_json: &str,
218 entry: &str,
219 request_path: &str,
220) -> Result<String, BuildError> {
221 let protocol = parse_to_protocol(files, entry)?;
222
223 let state: Value = serde_json::from_str(state_json).map_err(BuildError::State)?;
224
225 let mut writer = StringWriter::with_capacity(1024);
226 let handler = create_handler(None)?;
227 handler.render(
228 &protocol,
229 &state,
230 &RenderOptions::new(entry, request_path),
231 &mut writer,
232 )?;
233
234 Ok(writer.content)
235}
236
237fn parse_to_protocol(
239 files: &HashMap<String, String>,
240 entry: &str,
241) -> Result<WebUIProtocol, BuildError> {
242 let entry_html = files
243 .get(entry)
244 .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?;
245
246 let mut parser = HtmlParser::new();
247 parser.set_css_strategy(CssStrategy::Style);
248
249 for (filename, content) in files {
251 if filename != entry && filename.ends_with(".html") {
252 let tag_name = filename.trim_end_matches(".html");
253 if tag_name.contains('-') {
254 let css_key = format!("{tag_name}.css");
255 let css = files.get(&css_key).map(|s| s.as_str());
256 parser
257 .component_registry_mut()
258 .register_component(tag_name, content, css)?;
259 }
260 }
261 }
262
263 parser.parse(entry, entry_html)?;
264
265 Ok(WebUIProtocol::new(parser.into_fragment_records()))
266}
267
268#[derive(Debug, thiserror::Error)]
270pub(crate) enum BuildError {
271 #[error("Entry file '{0}' not found")]
272 MissingEntry(String),
273
274 #[error("{0}")]
275 Parse(#[from] webui_parser::ParserError),
276
277 #[error("Protocol JSON error: {0}")]
278 Protocol(serde_json::Error),
279
280 #[error("State JSON error: {0}")]
281 State(serde_json::Error),
282
283 #[error("{0}")]
284 Render(#[from] webui_handler::HandlerError),
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_simple_render() {
293 let mut files = HashMap::new();
294 files.insert(
295 "index.html".to_string(),
296 "<h1>Hello, {{name}}!</h1>".to_string(),
297 );
298
299 let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
300 assert_eq!(result.unwrap(), "<h1>Hello, WebUI!</h1>");
301 }
302
303 #[test]
304 fn test_missing_entry_file() {
305 let files = HashMap::new();
306 let result = build_and_render_inner(&files, "{}", "index.html", "/");
307 assert!(result.is_err());
308 let err = result.unwrap_err().to_string();
309 assert!(err.contains("not found"), "Unexpected error: {}", err);
310 }
311
312 #[test]
313 fn test_with_component() {
314 let mut files = HashMap::new();
315 files.insert(
316 "index.html".to_string(),
317 "<my-card>World</my-card>".to_string(),
318 );
319 files.insert(
320 "my-card.html".to_string(),
321 "<div class=\"card\"><slot></slot></div>".to_string(),
322 );
323
324 let result = build_and_render_inner(&files, "{}", "index.html", "/");
325 assert!(result.is_ok(), "Render failed: {:?}", result);
326 let html = result.as_deref().unwrap_or("");
327 assert!(html.contains("card"), "Expected card class in: {}", html);
328 }
329
330 #[test]
331 fn test_with_for_loop() {
332 let mut files = HashMap::new();
333 files.insert(
334 "index.html".to_string(),
335 "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
336 );
337
338 let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
339 let result = build_and_render_inner(&files, state, "index.html", "/");
340 assert!(result.is_ok(), "Render failed: {:?}", result);
341 let html = result.as_deref().unwrap_or("");
342 assert!(html.contains("A"), "Expected 'A' in: {}", html);
343 assert!(html.contains("B"), "Expected 'B' in: {}", html);
344 }
345
346 #[test]
347 fn test_with_if_condition() {
348 let mut files = HashMap::new();
349 files.insert(
350 "index.html".to_string(),
351 "<if condition=\"show\">Visible</if>".to_string(),
352 );
353
354 let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html", "/");
355 assert_eq!(result_true.unwrap(), "Visible");
356
357 let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html", "/");
358 assert_eq!(result_false.unwrap(), "");
359 }
360
361 #[test]
362 fn test_invalid_state_json() {
363 let mut files = HashMap::new();
364 files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
365
366 let result = build_and_render_inner(&files, "not json", "index.html", "/");
367 assert!(result.is_err());
368 let err = result.unwrap_err().to_string();
369 assert!(
370 err.contains("State JSON error"),
371 "Unexpected error: {}",
372 err
373 );
374 }
375
376 #[test]
377 fn test_component_with_css() {
378 let mut files = HashMap::new();
379 files.insert(
380 "index.html".to_string(),
381 "<my-card>Content</my-card>".to_string(),
382 );
383 files.insert(
384 "my-card.html".to_string(),
385 "<p><slot></slot></p>".to_string(),
386 );
387 files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
388
389 let result = build_and_render_inner(&files, "{}", "index.html", "/");
390 assert!(result.is_ok(), "Render failed: {:?}", result);
391 let html = result.as_deref().unwrap_or("");
392 assert!(
394 html.contains("<style>p { color: red; }</style>"),
395 "Expected inline <style> tag in: {}",
396 html
397 );
398 assert!(
399 !html.contains("<link"),
400 "Should not have external <link> tag in: {}",
401 html
402 );
403 }
404
405 #[test]
406 fn test_raw_signal() {
407 let mut files = HashMap::new();
408 files.insert(
409 "index.html".to_string(),
410 "<div>{{{raw_html}}}</div>".to_string(),
411 );
412
413 let result =
414 build_and_render_inner(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
415 assert!(result.is_ok(), "Render failed: {:?}", result);
416 let html = result.as_deref().unwrap_or("");
417 assert!(
418 html.contains("<b>bold</b>"),
419 "Expected raw HTML in: {}",
420 html
421 );
422 }
423
424 #[test]
425 fn test_static_html_passthrough() {
426 let mut files = HashMap::new();
427 files.insert(
428 "index.html".to_string(),
429 "<h1>Static</h1><p>Content</p>".to_string(),
430 );
431
432 let result = build_and_render_inner(&files, "{}", "index.html", "/");
433 assert_eq!(result.unwrap(), "<h1>Static</h1><p>Content</p>");
434 }
435}