1mod error;
27
28pub use error::WebUIError;
29
30pub use webui_handler::route_handler::{
32 encode_inventory, get_needed_components, get_needed_components_for_request,
33 get_route_templates, get_route_templates_for_request, parse_inventory,
34};
35pub use webui_handler::{plugin::HandlerPlugin, HandlerError, ResponseWriter, WebUIHandler};
36pub use webui_parser::CssStrategy;
37pub use webui_protocol::WebUIProtocol;
38
39use std::fs;
40use std::path::Path;
41use std::time::Instant;
42use webui_parser::plugin::FastParserPlugin;
43use webui_parser::HtmlParser;
44
45#[derive(Debug, Clone)]
47pub struct BuildOptions {
48 pub app_dir: std::path::PathBuf,
50 pub entry: String,
52 pub css: CssStrategy,
54 pub plugin: Option<String>,
56 pub components: Vec<String>,
58}
59
60#[derive(Debug, Clone)]
62pub struct BuildStats {
63 pub duration: std::time::Duration,
65 pub fragment_count: usize,
67 pub component_count: usize,
69 pub css_file_count: usize,
71 pub protocol_size_bytes: usize,
73 pub token_count: usize,
75}
76
77#[derive(Debug)]
79pub struct BuildResult {
80 pub protocol: WebUIProtocol,
82 pub protocol_bytes: Vec<u8>,
84 pub css_files: Vec<(String, String)>,
86 pub component_templates: Vec<(String, String)>,
90 pub stats: BuildStats,
92}
93
94#[must_use = "BuildResult contains the compiled protocol and statistics"]
104pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
105 let started = Instant::now();
106
107 let raw = build_protocol_inner(&options)?;
108
109 let protocol_bytes = raw
110 .protocol
111 .to_protobuf()
112 .map_err(|e| WebUIError::Serialization(e.to_string()))?;
113
114 let stats = BuildStats {
115 duration: started.elapsed(),
116 fragment_count: raw.fragment_count,
117 component_count: raw.component_count,
118 css_file_count: raw.css_files.len(),
119 protocol_size_bytes: protocol_bytes.len(),
120 token_count: raw.token_count,
121 };
122
123 Ok(BuildResult {
124 protocol: raw.protocol,
125 protocol_bytes,
126 css_files: raw.css_files,
127 component_templates: raw.component_templates,
128 stats,
129 })
130}
131
132pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
141 let result = build(options)?;
142
143 fs::create_dir_all(out_dir)
144 .map_err(|e| WebUIError::Io(format!("Failed to create {}: {e}", out_dir.display())))?;
145
146 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|e| {
147 WebUIError::Io(format!(
148 "Failed to write protocol.bin to {}: {e}",
149 out_dir.display()
150 ))
151 })?;
152
153 for (name, content) in &result.css_files {
154 fs::write(out_dir.join(name), content).map_err(|e| {
155 WebUIError::Io(format!(
156 "Failed to write {name} to {}: {e}",
157 out_dir.display()
158 ))
159 })?;
160 }
161
162 Ok(result.stats)
163}
164
165pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
167 let bytes = fs::read(protocol_path)
168 .map_err(|e| WebUIError::Io(format!("Failed to read {}: {e}", protocol_path.display())))?;
169 inspect_bytes(&bytes)
170}
171
172pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
174 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)
175 .map_err(|e| WebUIError::Protocol(e.to_string()))?;
176 protocol
177 .to_json_pretty()
178 .map_err(|e| WebUIError::Serialization(e.to_string()))
179}
180
181struct RawBuildOutput {
183 protocol: WebUIProtocol,
184 css_files: Vec<(String, String)>,
185 component_templates: Vec<(String, String)>,
186 fragment_count: usize,
187 component_count: usize,
188 token_count: usize,
189}
190
191fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
193 let mut parser = match options.plugin.as_deref() {
194 Some("fast") => {
195 let mut plugin = FastParserPlugin::new();
196 plugin.set_css_strategy(options.css);
197 HtmlParser::with_plugin(Box::new(plugin))
198 }
199 Some(unknown) => return Err(WebUIError::InvalidPlugin(unknown.to_string())),
200 None => HtmlParser::new(),
201 };
202 parser.set_css_strategy(options.css);
203
204 parser
206 .component_registry_mut()
207 .register_from_paths(&[&options.app_dir])
208 .map_err(|e| {
209 WebUIError::ComponentRegistration(format!(
210 "Failed to register components from {}: {e}",
211 options.app_dir.display()
212 ))
213 })?;
214
215 for source in &options.components {
217 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
218 WebUIError::ComponentDiscovery(format!(
219 "Failed to discover components from {source}: {e}"
220 ))
221 })?;
222 for comp in &result.components {
223 parser
224 .component_registry_mut()
225 .register_component(
226 &comp.tag_name,
227 &comp.html_content,
228 comp.css_content.as_deref(),
229 )
230 .map_err(|e| {
231 WebUIError::ComponentRegistration(format!(
232 "Failed to register component '{}' from {}: {e}",
233 comp.tag_name, comp.source
234 ))
235 })?;
236 }
237 }
238
239 let component_count = parser.component_registry_mut().len();
240
241 let entry_path = options.app_dir.join(&options.entry);
243 let html_content = fs::read_to_string(&entry_path)
244 .map_err(|e| WebUIError::Io(format!("Failed to read {}: {e}", entry_path.display())))?;
245 parser
246 .parse(&options.entry, &html_content)
247 .map_err(|e| WebUIError::Parse(format!("Failed to parse {}: {e}", options.entry)))?;
248
249 let css_snapshot: Vec<(String, String)> = parser
251 .component_registry_mut()
252 .get_all()
253 .filter_map(|c| {
254 c.css_content
255 .as_ref()
256 .map(|css| (c.tag_name.clone(), css.clone()))
257 })
258 .collect();
259
260 let tokens = parser.take_tokens();
262 let token_count = tokens.len();
263
264 let component_templates = parser
267 .take_plugin()
268 .and_then(|p| {
269 p.as_any()
270 .downcast_ref::<FastParserPlugin>()
271 .map(|fast| fast.take_component_templates())
272 })
273 .unwrap_or_default();
274
275 let fragment_records = parser.into_fragment_records();
277 let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
278
279 let mut protocol = WebUIProtocol::with_tokens(fragment_records, tokens);
280
281 if options.css == CssStrategy::Module {
287 for (tag, css) in &css_snapshot {
288 if protocol.fragments.contains_key(tag) {
289 protocol.components.entry(tag.clone()).or_default().css = css.trim().to_string();
290 }
291 }
292 }
293
294 let css_files: Vec<(String, String)> =
299 if matches!(options.css, CssStrategy::Style | CssStrategy::Module) {
300 Vec::new()
301 } else {
302 css_snapshot
303 .into_iter()
304 .filter(|(tag, _)| protocol.fragments.contains_key(tag))
305 .map(|(tag, css)| {
306 let safe_tag = tag.replace(['/', '\\'], "-");
307 (format!("{safe_tag}.css"), css)
308 })
309 .collect()
310 };
311
312 for (tag, tmpl) in &component_templates {
314 protocol.components.entry(tag.clone()).or_default().template = tmpl.clone();
315 }
316
317 Ok(RawBuildOutput {
318 protocol,
319 css_files,
320 component_templates,
321 fragment_count,
322 component_count,
323 token_count,
324 })
325}
326
327#[cfg(test)]
328#[allow(clippy::disallowed_methods)]
329mod tests {
330 use super::*;
331 use std::path::PathBuf;
332 use tempfile::TempDir;
333 use webui_protocol::web_ui_fragment::Fragment;
334
335 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
336 let dir = TempDir::new().unwrap();
337 for (name, content) in files {
338 let path = dir.path().join(name);
339 if let Some(parent) = path.parent() {
340 fs::create_dir_all(parent).unwrap();
341 }
342 fs::write(&path, content).unwrap();
343 }
344 dir
345 }
346
347 fn default_options(app_dir: &Path) -> BuildOptions {
348 BuildOptions {
349 app_dir: app_dir.to_path_buf(),
350 entry: "index.html".to_string(),
351 css: CssStrategy::Link,
352 plugin: None,
353 components: Vec::new(),
354 }
355 }
356
357 #[test]
358 fn test_build_simple_html() {
359 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
360 let result = build(default_options(app.path())).unwrap();
361
362 assert!(result.protocol.fragments.contains_key("index.html"));
363 assert!(result.stats.fragment_count > 0);
364 assert!(result.stats.protocol_size_bytes > 0);
365 assert!(!result.stats.duration.is_zero());
366 }
367
368 #[test]
369 fn test_build_with_directives() {
370 let html = r#"<h1>Hello</h1>
371<for each="item in items">
372 <p>{{item.name}}</p>
373</for>
374<if condition="show">
375 <p>Visible</p>
376</if>"#;
377 let app = create_app_dir(&[("index.html", html)]);
378 let result = build(default_options(app.path())).unwrap();
379
380 let index = &result.protocol.fragments["index.html"].fragments;
381 assert!(index
382 .iter()
383 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
384 assert!(index
385 .iter()
386 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
387 }
388
389 #[test]
390 fn test_build_with_component_css() {
391 let app = create_app_dir(&[
392 ("index.html", "<my-card>Hello</my-card>"),
393 ("my-card.html", "<div><slot></slot></div>"),
394 ("my-card.css", ".card { color: red; }"),
395 ]);
396 let result = build(default_options(app.path())).unwrap();
397
398 assert_eq!(result.css_files.len(), 1);
399 assert_eq!(result.css_files[0].0, "my-card.css");
400 assert!(result.css_files[0].1.contains("color: red"));
401 assert_eq!(result.stats.css_file_count, 1);
402 }
403
404 #[test]
405 fn test_build_to_disk_writes_files() {
406 let app = create_app_dir(&[
407 ("index.html", "<my-card>Hello</my-card>"),
408 ("my-card.html", "<div><slot></slot></div>"),
409 ("my-card.css", ".card { color: red; }"),
410 ]);
411 let out = TempDir::new().unwrap();
412
413 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
414
415 assert!(out.path().join("protocol.bin").exists());
416 assert!(out.path().join("my-card.css").exists());
417 assert_eq!(stats.css_file_count, 1);
418 assert!(stats.fragment_count > 0);
419 }
420
421 #[test]
422 fn test_build_to_disk_creates_nested_output_dir() {
423 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
424 let out = TempDir::new().unwrap();
425 let nested = out.path().join("nested").join("output");
426
427 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
428 assert!(nested.join("protocol.bin").exists());
429 assert!(stats.fragment_count > 0);
430 }
431
432 #[test]
433 fn test_build_missing_entry() {
434 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
435 let result = build(default_options(app.path()));
436
437 assert!(result.is_err());
438 let err = result.unwrap_err();
439 assert!(matches!(err, WebUIError::Io(_)));
440 }
441
442 #[test]
443 fn test_build_invalid_plugin() {
444 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
445 let mut options = default_options(app.path());
446 options.plugin = Some("nonexistent".to_string());
447
448 let result = build(options);
449 assert!(result.is_err());
450 assert!(matches!(result.unwrap_err(), WebUIError::InvalidPlugin(_)));
451 }
452
453 #[test]
454 fn test_build_stats_populated() {
455 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
456 let result = build(default_options(app.path())).unwrap();
457
458 assert!(result.stats.fragment_count > 0);
459 assert!(result.stats.protocol_size_bytes > 0);
460 assert_eq!(result.stats.css_file_count, 0);
461 }
462
463 #[test]
464 fn test_build_inline_css() {
465 let app = create_app_dir(&[
466 ("index.html", "<my-card>Hello</my-card>"),
467 ("my-card.html", "<div><slot></slot></div>"),
468 ("my-card.css", ".card { color: red; }"),
469 ]);
470 let mut options = default_options(app.path());
471 options.css = CssStrategy::Style;
472
473 let result = build(options).unwrap();
474 assert!(result.css_files.is_empty());
476 assert_eq!(result.stats.css_file_count, 0);
477 assert!(result.stats.fragment_count > 0);
478 }
479
480 #[test]
481 fn test_build_module_css() {
482 let app = create_app_dir(&[
483 ("index.html", "<my-card>Hello</my-card>"),
484 ("my-card.html", "<div><slot></slot></div>"),
485 ("my-card.css", ".card { color: red; }"),
486 ]);
487 let mut options = default_options(app.path());
488 options.css = CssStrategy::Module;
489
490 let result = build(options).unwrap();
491 assert!(result.css_files.is_empty());
493 assert_eq!(result.stats.css_file_count, 0);
494 assert!(result.stats.fragment_count > 0);
495 }
496
497 #[test]
498 fn test_build_custom_entry() {
499 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
500 let mut options = default_options(app.path());
501 options.entry = "page.html".to_string();
502
503 let result = build(options).unwrap();
504 assert!(result.protocol.fragments.contains_key("page.html"));
505 assert!(!result.protocol.fragments.contains_key("index.html"));
506 }
507
508 #[test]
509 fn test_build_protocol_roundtrip() {
510 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
511 let result = build(default_options(app.path())).unwrap();
512
513 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
514 assert!(restored.fragments.contains_key("index.html"));
515 }
516
517 #[test]
518 fn test_inspect_bytes_valid() {
519 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
520 let result = build(default_options(app.path())).unwrap();
521
522 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
523 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
524 assert!(parsed.get("fragments").is_some());
525 }
526
527 #[test]
528 fn test_inspect_bytes_invalid() {
529 let result = inspect_bytes(b"not a protobuf");
530 assert!(result.is_err());
531 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
532 }
533
534 #[test]
535 fn test_inspect_file() {
536 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
537 let out = TempDir::new().unwrap();
538 build_to_disk(default_options(app.path()), out.path()).unwrap();
539
540 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
541 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
542 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
543 }
544
545 #[test]
546 fn test_inspect_missing_file() {
547 let result = inspect(Path::new("/nonexistent/protocol.bin"));
548 assert!(result.is_err());
549 assert!(matches!(result.unwrap_err(), WebUIError::Io(_)));
550 }
551
552 #[test]
553 fn test_build_with_components_local_path() {
554 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
555 let ext_dir = TempDir::new().unwrap();
556 fs::write(
557 ext_dir.path().join("ext-card.html"),
558 "<div class=\"card\"><slot></slot></div>",
559 )
560 .unwrap();
561 fs::write(
562 ext_dir.path().join("ext-card.css"),
563 ".card { border: 1px solid #ccc; }",
564 )
565 .unwrap();
566
567 let mut options = default_options(app.path());
568 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
569
570 let result = build(options).unwrap();
571 assert!(result.protocol.fragments.contains_key("index.html"));
572 assert_eq!(result.css_files.len(), 1);
573 assert!(result.css_files[0].1.contains("border"));
574 }
575
576 #[test]
577 fn test_build_hello_world_example() {
578 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
579 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
580
581 let result = build(BuildOptions {
582 app_dir,
583 entry: "index.html".to_string(),
584 css: CssStrategy::Link,
585 plugin: None,
586 components: Vec::new(),
587 })
588 .unwrap();
589
590 let index = &result.protocol.fragments["index.html"].fragments;
591 assert!(index.iter().any(
592 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
593 ));
594 assert!(index
595 .iter()
596 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
597 }
598
599 #[test]
602 fn test_css_filename_sanitizes_path_separators() {
603 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
606 let result = build(default_options(app.path())).unwrap();
607
608 for (filename, _) in &result.css_files {
610 assert!(
611 !filename.contains('/') && !filename.contains('\\'),
612 "CSS filename contains path separator: {filename}"
613 );
614 }
615 }
616
617 #[test]
618 fn test_build_to_disk_css_stays_in_output_dir() {
619 let app = create_app_dir(&[
620 ("index.html", "<my-card>Hello</my-card>"),
621 ("my-card.html", "<div><slot></slot></div>"),
622 ("my-card.css", ".card { color: red; }"),
623 ]);
624 let out = TempDir::new().unwrap();
625
626 build_to_disk(default_options(app.path()), out.path()).unwrap();
627
628 let css_path = out.path().join("my-card.css");
630 assert!(css_path.exists());
631 let canonical = css_path.canonicalize().unwrap();
632 let out_canonical = out.path().canonicalize().unwrap();
633 assert!(
634 canonical.starts_with(&out_canonical),
635 "CSS file escaped output directory"
636 );
637 }
638
639 #[test]
642 fn test_build_empty_html() {
643 let app = create_app_dir(&[("index.html", "")]);
644 let result = build(default_options(app.path())).unwrap();
645
646 assert!(result.protocol.fragments.contains_key("index.html"));
647 assert_eq!(result.stats.fragment_count, 0);
648 assert!(result.stats.protocol_size_bytes > 0);
649 }
650
651 #[test]
652 fn test_build_large_fragment_count() {
653 let mut html = String::with_capacity(2000);
655 for i in 0..50 {
656 html.push_str(&format!("<p>Item {i}</p>\n"));
657 }
658 let app = create_app_dir(&[("index.html", &html)]);
659 let result = build(default_options(app.path())).unwrap();
660
661 assert!(result.stats.fragment_count > 0);
662 assert_eq!(result.stats.css_file_count, 0);
663 }
664
665 #[test]
666 fn test_build_with_fast_plugin() {
667 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
668 let mut options = default_options(app.path());
669 options.plugin = Some("fast".to_string());
670
671 let result = build(options).unwrap();
672 assert!(result.protocol.fragments.contains_key("index.html"));
673 }
674
675 #[test]
676 fn test_build_to_disk_inline_mode_no_css_files() {
677 let app = create_app_dir(&[
678 ("index.html", "<my-card>Hello</my-card>"),
679 ("my-card.html", "<div><slot></slot></div>"),
680 ("my-card.css", ".card { color: red; }"),
681 ]);
682 let out = TempDir::new().unwrap();
683 let mut options = default_options(app.path());
684 options.css = CssStrategy::Style;
685
686 let stats = build_to_disk(options, out.path()).unwrap();
687
688 assert!(out.path().join("protocol.bin").exists());
689 assert!(!out.path().join("my-card.css").exists());
690 assert_eq!(stats.css_file_count, 0);
691 }
692
693 #[test]
694 fn test_build_stats_duration_is_nonzero() {
695 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
696 let result = build(default_options(app.path())).unwrap();
697
698 assert!(!result.stats.duration.is_zero());
699 }
700
701 #[test]
702 fn test_build_multiple_components_css() {
703 let app = create_app_dir(&[
704 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
705 ("card-a.html", "<div><slot></slot></div>"),
706 ("card-a.css", ".a { color: red; }"),
707 ("card-b.html", "<span><slot></slot></span>"),
708 ("card-b.css", ".b { color: blue; }"),
709 ]);
710 let result = build(default_options(app.path())).unwrap();
711
712 assert_eq!(result.css_files.len(), 2);
713 assert_eq!(result.stats.css_file_count, 2);
714 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
715 assert!(filenames.contains(&"card-a.css"));
716 assert!(filenames.contains(&"card-b.css"));
717 }
718
719 #[test]
720 fn test_build_unused_component_css_not_emitted() {
721 let app = create_app_dir(&[
723 ("index.html", "<card-a>A</card-a>"),
724 ("card-a.html", "<div><slot></slot></div>"),
725 ("card-a.css", ".a { color: red; }"),
726 ("card-b.html", "<span><slot></slot></span>"),
727 ("card-b.css", ".b { color: blue; }"),
728 ]);
729 let result = build(default_options(app.path())).unwrap();
730
731 assert_eq!(result.css_files.len(), 1);
732 assert_eq!(result.css_files[0].0, "card-a.css");
733 }
734
735 #[test]
736 fn test_inspect_roundtrip_preserves_content() {
737 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
738 let result = build(default_options(app.path())).unwrap();
739
740 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
741 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
742
743 let fragments = &parsed["fragments"]["index.html"]["fragments"];
744 assert!(fragments.is_array());
745 assert!(!fragments.as_array().unwrap().is_empty());
746 }
747
748 #[test]
749 fn test_build_nonexistent_app_dir() {
750 let options = BuildOptions {
751 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
752 entry: "index.html".to_string(),
753 css: CssStrategy::Link,
754 plugin: None,
755 components: Vec::new(),
756 };
757 let result = build(options);
758 assert!(result.is_err());
759 }
760
761 #[test]
762 fn test_build_to_disk_returns_accurate_stats() {
763 let app = create_app_dir(&[
764 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
765 ("my-card.html", "<div><slot></slot></div>"),
766 ("my-card.css", ".card { color: red; }"),
767 ]);
768 let out = TempDir::new().unwrap();
769
770 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
771
772 assert!(stats.fragment_count > 0);
773 assert_eq!(stats.css_file_count, 1);
774 assert!(stats.component_count > 0);
775 assert!(stats.protocol_size_bytes > 0);
776 assert!(!stats.duration.is_zero());
777 }
778}