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
147fn build_protocol_inner(
148 files: &HashMap<String, String>,
149 entry: &str,
150) -> Result<String, BuildError> {
151 let protocol = parse_to_protocol(files, entry)?;
152 serde_json::to_string(&protocol).map_err(|e| BuildError::Protocol(e.to_string()))
153}
154
155fn create_handler(plugin: Option<Plugin>) -> Result<WebUIHandler, BuildError> {
157 match plugin {
158 Some(Plugin::Fast) => Ok(WebUIHandler::with_plugin(|| {
159 Box::new(FastHydrationPlugin::new())
160 })),
161 Some(Plugin::WebUI) => Ok(WebUIHandler::with_plugin(|| {
162 Box::new(WebUIHydrationPlugin::new())
163 })),
164 None => Ok(WebUIHandler::new()),
165 }
166}
167
168fn render_inner(
169 protocol_json: &str,
170 state_json: &str,
171 entry: &str,
172 request_path: &str,
173 plugin: Option<Plugin>,
174) -> Result<String, BuildError> {
175 let protocol: WebUIProtocol =
176 serde_json::from_str(protocol_json).map_err(|e| BuildError::Protocol(e.to_string()))?;
177 let state: Value =
178 serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?;
179
180 let mut writer = StringWriter::with_capacity(1024);
181 let handler = create_handler(plugin)?;
182 handler
183 .render(
184 &protocol,
185 &state,
186 &RenderOptions::new(entry, request_path),
187 &mut writer,
188 )
189 .map_err(|e| BuildError::Render(e.to_string()))?;
190
191 Ok(writer.content)
192}
193
194pub(crate) fn build_and_render_inner(
196 files: &HashMap<String, String>,
197 state_json: &str,
198 entry: &str,
199 request_path: &str,
200) -> Result<String, BuildError> {
201 let protocol = parse_to_protocol(files, entry)?;
202
203 let state: Value =
204 serde_json::from_str(state_json).map_err(|e| BuildError::State(e.to_string()))?;
205
206 let mut writer = StringWriter::with_capacity(1024);
207 let handler = create_handler(None)?;
208 handler
209 .render(
210 &protocol,
211 &state,
212 &RenderOptions::new(entry, request_path),
213 &mut writer,
214 )
215 .map_err(|e| BuildError::Render(e.to_string()))?;
216
217 Ok(writer.content)
218}
219
220fn parse_to_protocol(
222 files: &HashMap<String, String>,
223 entry: &str,
224) -> Result<WebUIProtocol, BuildError> {
225 let entry_html = files
226 .get(entry)
227 .ok_or_else(|| BuildError::MissingEntry(entry.to_string()))?;
228
229 let mut parser = HtmlParser::new();
230 parser.set_css_strategy(CssStrategy::Style);
231
232 for (filename, content) in files {
234 if filename != entry && filename.ends_with(".html") {
235 let tag_name = filename.trim_end_matches(".html");
236 if tag_name.contains('-') {
237 let css_key = format!("{tag_name}.css");
238 let css = files.get(&css_key).map(|s| s.as_str());
239 parser
240 .component_registry_mut()
241 .register_component(tag_name, content, css)
242 .map_err(|e| BuildError::Parse(e.to_string()))?;
243 }
244 }
245 }
246
247 parser
248 .parse(entry, entry_html)
249 .map_err(|e| BuildError::Parse(e.to_string()))?;
250
251 Ok(WebUIProtocol::new(parser.into_fragment_records()))
252}
253
254#[derive(Debug, PartialEq)]
256pub(crate) enum BuildError {
257 MissingEntry(String),
258 Parse(String),
259 Protocol(String),
260 State(String),
261 Render(String),
262}
263
264impl std::fmt::Display for BuildError {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 match self {
267 BuildError::MissingEntry(name) => write!(f, "Entry file '{name}' not found"),
268 BuildError::Parse(msg) => write!(f, "Parse error: {msg}"),
269 BuildError::Protocol(msg) => write!(f, "Protocol JSON error: {msg}"),
270 BuildError::State(msg) => write!(f, "State JSON error: {msg}"),
271 BuildError::Render(msg) => write!(f, "Render error: {msg}"),
272 }
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn test_simple_render() {
282 let mut files = HashMap::new();
283 files.insert(
284 "index.html".to_string(),
285 "<h1>Hello, {{name}}!</h1>".to_string(),
286 );
287
288 let result = build_and_render_inner(&files, r#"{"name": "WebUI"}"#, "index.html", "/");
289 assert!(result.is_ok(), "Render failed: {:?}", result);
290 assert_eq!(result.as_deref(), Ok("<h1>Hello, WebUI!</h1>"));
291 }
292
293 #[test]
294 fn test_missing_entry_file() {
295 let files = HashMap::new();
296 let result = build_and_render_inner(&files, "{}", "index.html", "/");
297 assert!(result.is_err());
298 let err = result.unwrap_err().to_string();
299 assert!(err.contains("not found"), "Unexpected error: {}", err);
300 }
301
302 #[test]
303 fn test_with_component() {
304 let mut files = HashMap::new();
305 files.insert(
306 "index.html".to_string(),
307 "<my-card>World</my-card>".to_string(),
308 );
309 files.insert(
310 "my-card.html".to_string(),
311 "<div class=\"card\"><slot></slot></div>".to_string(),
312 );
313
314 let result = build_and_render_inner(&files, "{}", "index.html", "/");
315 assert!(result.is_ok(), "Render failed: {:?}", result);
316 let html = result.as_deref().unwrap_or("");
317 assert!(html.contains("card"), "Expected card class in: {}", html);
318 }
319
320 #[test]
321 fn test_with_for_loop() {
322 let mut files = HashMap::new();
323 files.insert(
324 "index.html".to_string(),
325 "<for each=\"item in items\">{{item.name}}, </for>".to_string(),
326 );
327
328 let state = r#"{"items": [{"name": "A"}, {"name": "B"}]}"#;
329 let result = build_and_render_inner(&files, state, "index.html", "/");
330 assert!(result.is_ok(), "Render failed: {:?}", result);
331 let html = result.as_deref().unwrap_or("");
332 assert!(html.contains("A"), "Expected 'A' in: {}", html);
333 assert!(html.contains("B"), "Expected 'B' in: {}", html);
334 }
335
336 #[test]
337 fn test_with_if_condition() {
338 let mut files = HashMap::new();
339 files.insert(
340 "index.html".to_string(),
341 "<if condition=\"show\">Visible</if>".to_string(),
342 );
343
344 let result_true = build_and_render_inner(&files, r#"{"show": true}"#, "index.html", "/");
345 assert_eq!(result_true.as_deref(), Ok("Visible"));
346
347 let result_false = build_and_render_inner(&files, r#"{"show": false}"#, "index.html", "/");
348 assert_eq!(result_false.as_deref(), Ok(""));
349 }
350
351 #[test]
352 fn test_invalid_state_json() {
353 let mut files = HashMap::new();
354 files.insert("index.html".to_string(), "<p>Hi</p>".to_string());
355
356 let result = build_and_render_inner(&files, "not json", "index.html", "/");
357 assert!(result.is_err());
358 let err = result.unwrap_err().to_string();
359 assert!(
360 err.contains("State JSON error"),
361 "Unexpected error: {}",
362 err
363 );
364 }
365
366 #[test]
367 fn test_component_with_css() {
368 let mut files = HashMap::new();
369 files.insert(
370 "index.html".to_string(),
371 "<my-card>Content</my-card>".to_string(),
372 );
373 files.insert(
374 "my-card.html".to_string(),
375 "<p><slot></slot></p>".to_string(),
376 );
377 files.insert("my-card.css".to_string(), "p { color: red; }".to_string());
378
379 let result = build_and_render_inner(&files, "{}", "index.html", "/");
380 assert!(result.is_ok(), "Render failed: {:?}", result);
381 let html = result.as_deref().unwrap_or("");
382 assert!(
384 html.contains("<style>p { color: red; }</style>"),
385 "Expected inline <style> tag in: {}",
386 html
387 );
388 assert!(
389 !html.contains("<link"),
390 "Should not have external <link> tag in: {}",
391 html
392 );
393 }
394
395 #[test]
396 fn test_raw_signal() {
397 let mut files = HashMap::new();
398 files.insert(
399 "index.html".to_string(),
400 "<div>{{{raw_html}}}</div>".to_string(),
401 );
402
403 let result =
404 build_and_render_inner(&files, r#"{"raw_html": "<b>bold</b>"}"#, "index.html", "/");
405 assert!(result.is_ok(), "Render failed: {:?}", result);
406 let html = result.as_deref().unwrap_or("");
407 assert!(
408 html.contains("<b>bold</b>"),
409 "Expected raw HTML in: {}",
410 html
411 );
412 }
413
414 #[test]
415 fn test_static_html_passthrough() {
416 let mut files = HashMap::new();
417 files.insert(
418 "index.html".to_string(),
419 "<h1>Static</h1><p>Content</p>".to_string(),
420 );
421
422 let result = build_and_render_inner(&files, "{}", "index.html", "/");
423 assert_eq!(result.as_deref(), Ok("<h1>Static</h1><p>Content</p>"));
424 }
425}