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