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(|e| BuildError::Protocol(e.to_string()))
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(|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
217pub(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
243fn 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 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#[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 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}