1mod error;
24pub mod server;
25pub mod streaming;
26
27pub use error::WebUIError;
28
29pub use webui_handler::route_handler::{
31 encode_inventory, get_needed_components, get_needed_components_for_request, parse_inventory,
32 ProtocolIndex,
33};
34pub use webui_handler::route_matcher::CompiledRouteCache;
35pub use webui_handler::Result as HandlerResult;
36pub use webui_handler::{
37 plugin::HandlerPlugin, HandlerError, RenderOptions, ResponseWriter, WebUIHandler,
38};
39pub use webui_parser::CssStrategy;
40pub use webui_parser::DomStrategy;
41pub use webui_parser::ParserOptions;
42pub use webui_parser::Plugin;
43pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
44pub use webui_protocol::WebUIProtocol;
45
46use std::collections::HashSet;
47use std::fs;
48use std::path::Path;
49use std::time::Instant;
50use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
51use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
52use webui_parser::plugin::webui::WebUIParserPlugin;
53use webui_parser::plugin::ParserPluginArtifacts;
54use webui_parser::HtmlParser;
55
56#[derive(Debug, Clone)]
58pub struct BuildOptions {
59 pub app_dir: std::path::PathBuf,
61 pub entry: String,
63 pub css: CssStrategy,
65 pub dom: DomStrategy,
67 pub plugin: Option<Plugin>,
69 pub components: Vec<String>,
71 pub css_file_name_template: String,
74 pub css_public_base: Option<String>,
77}
78
79impl Default for BuildOptions {
80 fn default() -> Self {
81 Self {
82 app_dir: std::path::PathBuf::from("."),
83 entry: "index.html".to_string(),
84 css: CssStrategy::Link,
85 dom: DomStrategy::Shadow,
86 plugin: None,
87 components: Vec::new(),
88 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
89 css_public_base: None,
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
96pub struct BuildStats {
97 pub duration: std::time::Duration,
99 pub fragment_count: usize,
101 pub component_count: usize,
103 pub css_file_count: usize,
105 pub protocol_size_bytes: usize,
107 pub token_count: usize,
109}
110
111#[derive(Debug)]
113pub struct BuildResult {
114 pub protocol: WebUIProtocol,
116 pub protocol_bytes: Vec<u8>,
118 pub css_files: Vec<(String, String)>,
120 pub component_templates: Vec<(String, String)>,
124 pub stats: BuildStats,
126}
127
128#[must_use = "BuildResult contains the compiled protocol and statistics"]
138pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
139 let started = Instant::now();
140
141 let raw = build_protocol_inner(&options)?;
142
143 let protocol_bytes = raw.protocol.to_protobuf()?;
144
145 let stats = BuildStats {
146 duration: started.elapsed(),
147 fragment_count: raw.fragment_count,
148 component_count: raw.component_count,
149 css_file_count: raw.css_files.len(),
150 protocol_size_bytes: protocol_bytes.len(),
151 token_count: raw.token_count,
152 };
153
154 Ok(BuildResult {
155 protocol: raw.protocol,
156 protocol_bytes,
157 css_files: raw.css_files,
158 component_templates: raw.component_templates,
159 stats,
160 })
161}
162
163pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
172 let result = build(options)?;
173
174 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
175 context: format!("Failed to create {}", out_dir.display()),
176 source,
177 })?;
178
179 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
180 WebUIError::Io {
181 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
182 source,
183 }
184 })?;
185
186 for (name, content) in &result.css_files {
187 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
188 context: format!("Failed to write {name} to {}", out_dir.display()),
189 source,
190 })?;
191 }
192
193 Ok(result.stats)
194}
195
196pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
198 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
199 context: format!("Failed to read {}", protocol_path.display()),
200 source,
201 })?;
202 inspect_bytes(&bytes)
203}
204
205pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
207 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
208 protocol
209 .to_json_pretty()
210 .map_err(|e| WebUIError::Serialization(e.to_string()))
211}
212
213struct RawBuildOutput {
215 protocol: WebUIProtocol,
216 css_files: Vec<(String, String)>,
217 component_templates: Vec<(String, String)>,
218 fragment_count: usize,
219 component_count: usize,
220 token_count: usize,
221}
222
223fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
225 let parser_options = ParserOptions::try_new(
226 options.css,
227 options.dom,
228 &options.css_file_name_template,
229 options.css_public_base.as_deref(),
230 )
231 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
232 let css_link_options = parser_options.css_link_options.clone();
233
234 let mut parser = match options.plugin {
235 Some(Plugin::Fast | Plugin::FastV2) => {
236 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
237 }
238 Some(Plugin::FastV3) => {
239 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
240 }
241 Some(Plugin::WebUI) => {
242 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
243 }
244 None => HtmlParser::with_options(parser_options),
245 };
246
247 parser
249 .component_registry_mut()
250 .register_from_paths(&[&options.app_dir])
251 .map_err(|e| {
252 WebUIError::ComponentRegistration(format!(
253 "Failed to register components from {}: {e}",
254 options.app_dir.display()
255 ))
256 })?;
257
258 for source in &options.components {
260 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
261 WebUIError::ComponentDiscovery(format!(
262 "Failed to discover components from {source}: {e}"
263 ))
264 })?;
265 for comp in &result.components {
266 parser
267 .component_registry_mut()
268 .register_component(
269 &comp.tag_name,
270 &comp.html_content,
271 comp.css_content.as_deref(),
272 )
273 .map_err(|e| {
274 WebUIError::ComponentRegistration(format!(
275 "Failed to register component '{}' from {}: {e}",
276 comp.tag_name, comp.source
277 ))
278 })?;
279 }
280 }
281
282 let component_count = parser.component_registry().len();
283
284 let entry_path = options.app_dir.join(&options.entry);
286 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
287 context: format!("Failed to read {}", entry_path.display()),
288 source,
289 })?;
290 parser
291 .parse(&options.entry, &html_content)
292 .map_err(|source| WebUIError::Parse {
293 context: format!("Failed to parse {}", options.entry),
294 source,
295 })?;
296
297 let css_snapshot: Vec<(String, String)> = parser
298 .component_registry()
299 .get_all()
300 .filter(|component| parser.has_fragment(&component.tag_name))
301 .filter_map(|component| {
302 component
303 .css_content
304 .as_ref()
305 .map(|css| (component.tag_name.clone(), css.clone()))
306 })
307 .collect();
308
309 let tokens = parser.take_tokens();
311 let token_count = tokens.len();
312
313 let component_templates = match parser.take_plugin_artifacts() {
314 ParserPluginArtifacts::None => Vec::new(),
315 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
316 };
317
318 let fragment_records = parser.into_fragment_records();
320 let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
321
322 let mut protocol = WebUIProtocol::with_tokens(fragment_records, tokens);
323
324 protocol.set_css_strategy(match options.css {
326 CssStrategy::Link => webui_protocol::CssStrategy::Link,
327 CssStrategy::Style => webui_protocol::CssStrategy::Style,
328 CssStrategy::Module => webui_protocol::CssStrategy::Module,
329 });
330 protocol.set_dom_strategy(match options.dom {
331 DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
332 DomStrategy::Light => webui_protocol::DomStrategy::Light,
333 });
334
335 let is_module = options.css == CssStrategy::Module;
338 let is_link = options.css == CssStrategy::Link;
339 let mut css_files: Vec<(String, String)> = Vec::new();
340 let mut emitted_names: HashSet<String> = HashSet::new();
341 for (tag, css) in css_snapshot {
342 if !protocol.fragments.contains_key(&tag) {
343 continue;
344 }
345 if is_module {
346 protocol.components.entry(tag).or_default().css = css.trim().to_string();
347 } else if is_link {
348 let resolved = css_link_options.resolve(&tag, &css);
349 if !emitted_names.insert(resolved.filename.clone()) {
350 return Err(WebUIError::InvalidBuildOptions(format!(
351 "CSS filename collision for Link strategy: '{}'. Adjust --css-file-name-template to include [name] or another unique component-specific segment.",
352 resolved.filename
353 )));
354 }
355 protocol.components.entry(tag).or_default().css_href = resolved.href;
356 css_files.push((resolved.filename, css));
357 }
358 }
361
362 for (tag, tmpl) in &component_templates {
364 protocol.components.entry(tag.clone()).or_default().template = tmpl.clone();
365 }
366
367 Ok(RawBuildOutput {
368 protocol,
369 css_files,
370 component_templates,
371 fragment_count,
372 component_count,
373 token_count,
374 })
375}
376
377#[cfg(test)]
378#[allow(clippy::disallowed_methods)]
379mod tests {
380 use super::*;
381 use std::path::PathBuf;
382 use tempfile::TempDir;
383 use webui_protocol::web_ui_fragment::Fragment;
384
385 struct StringWriter {
386 buf: String,
387 }
388
389 impl ResponseWriter for StringWriter {
390 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
391 self.buf.push_str(content);
392 Ok(())
393 }
394
395 fn end(&mut self) -> webui_handler::Result<()> {
396 Ok(())
397 }
398 }
399
400 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
401 let dir = TempDir::new().unwrap();
402 for (name, content) in files {
403 let path = dir.path().join(name);
404 if let Some(parent) = path.parent() {
405 fs::create_dir_all(parent).unwrap();
406 }
407 fs::write(&path, content).unwrap();
408 }
409 dir
410 }
411
412 fn default_options(app_dir: &Path) -> BuildOptions {
413 BuildOptions {
414 app_dir: app_dir.to_path_buf(),
415 ..BuildOptions::default()
416 }
417 }
418
419 #[test]
420 fn test_build_simple_html() {
421 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
422 let result = build(default_options(app.path())).unwrap();
423
424 assert!(result.protocol.fragments.contains_key("index.html"));
425 assert!(result.stats.fragment_count > 0);
426 assert!(result.stats.protocol_size_bytes > 0);
427 assert!(!result.stats.duration.is_zero());
428 }
429
430 #[test]
431 fn test_build_with_directives() {
432 let html = r#"<h1>Hello</h1>
433<for each="item in items">
434 <p>{{item.name}}</p>
435</for>
436<if condition="show">
437 <p>Visible</p>
438</if>"#;
439 let app = create_app_dir(&[("index.html", html)]);
440 let result = build(default_options(app.path())).unwrap();
441
442 let index = &result.protocol.fragments["index.html"].fragments;
443 assert!(index
444 .iter()
445 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
446 assert!(index
447 .iter()
448 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
449 }
450
451 #[test]
452 fn test_build_with_component_css() {
453 let app = create_app_dir(&[
454 ("index.html", "<my-card>Hello</my-card>"),
455 ("my-card.html", "<div><slot></slot></div>"),
456 ("my-card.css", ".card { color: red; }"),
457 ]);
458 let result = build(default_options(app.path())).unwrap();
459
460 assert_eq!(result.css_files.len(), 1);
461 assert_eq!(result.css_files[0].0, "my-card.css");
462 assert!(result.css_files[0].1.contains("color: red"));
463 assert_eq!(result.stats.css_file_count, 1);
464 }
465
466 #[test]
467 fn test_build_with_css_hashed_filename_template() {
468 let app = create_app_dir(&[
469 ("index.html", "<my-card>Hello</my-card>"),
470 ("my-card.html", "<div><slot></slot></div>"),
471 ("my-card.css", ".card { color: red; }"),
472 ]);
473 let mut options = default_options(app.path());
474 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
475 let result = build(options).unwrap();
476
477 assert_eq!(result.css_files.len(), 1);
478 let generated = &result.css_files[0].0;
479 assert!(generated.starts_with("my-card-"));
480 assert!(generated.ends_with(".css"));
481 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
482 assert_eq!(
483 result
484 .protocol
485 .components
486 .get("my-card")
487 .map(|c| c.css_href.as_str()),
488 Some(generated.as_str())
489 );
490 }
491
492 #[test]
493 fn test_css_public_base_prefixes_css_href_only() {
494 let app = create_app_dir(&[
495 ("index.html", "<my-card>Hello</my-card>"),
496 ("my-card.html", "<div><slot></slot></div>"),
497 ("my-card.css", ".card { color: red; }"),
498 ]);
499 let mut options = default_options(app.path());
500 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
501 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
502 let result = build(options).unwrap();
503
504 let filename = &result.css_files[0].0;
505 assert!(filename.starts_with("my-card-"));
506 let expected_href = format!("https://cdn.example.com/assets/{filename}");
507 assert_eq!(
508 result
509 .protocol
510 .components
511 .get("my-card")
512 .map(|c| c.css_href.as_str()),
513 Some(expected_href.as_str())
514 );
515 }
516
517 #[test]
518 fn test_css_public_base_emits_parser_and_handler_links() {
519 let app = create_app_dir(&[
520 (
521 "index.html",
522 "<html><head></head><body><my-card>Hello</my-card></body></html>",
523 ),
524 ("my-card.html", "<div><slot></slot></div>"),
525 ("my-card.css", ".card { color: red; }"),
526 ]);
527 let mut options = default_options(app.path());
528 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
529 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
530 let result = build(options).unwrap();
531
532 let filename = &result.css_files[0].0;
533 let expected_href = format!("https://cdn.example.com/assets/{filename}");
534 let component_html = result.protocol.fragments["my-card"]
535 .fragments
536 .iter()
537 .filter_map(|fragment| match fragment.fragment.as_ref() {
538 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
539 _ => None,
540 })
541 .collect::<String>();
542 assert!(
543 component_html.contains(&format!(
544 r#"<link rel="stylesheet" href="{expected_href}">"#
545 )),
546 "parser-generated component template should use CDN href: {component_html}"
547 );
548
549 let handler = WebUIHandler::new();
550 let mut writer = StringWriter { buf: String::new() };
551 handler
552 .handle(
553 &result.protocol,
554 &serde_json::json!({}),
555 &RenderOptions::new("index.html", "/"),
556 &mut writer,
557 )
558 .unwrap();
559 assert!(
560 writer.buf.contains(&format!(
561 r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
562 )),
563 "handler head preload should use CDN href: {}",
564 writer.buf
565 );
566 }
567
568 #[test]
569 fn test_css_public_base_emits_webui_plugin_template_links() {
570 let app = create_app_dir(&[
571 ("index.html", "<my-card>Hello</my-card>"),
572 ("my-card.html", "<div><slot></slot></div>"),
573 ("my-card.css", ".card { color: red; }"),
574 ]);
575 let mut options = default_options(app.path());
576 options.plugin = Some(Plugin::WebUI);
577 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
578 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
579 let result = build(options).unwrap();
580
581 let filename = &result.css_files[0].0;
582 let expected_href = format!("https://cdn.example.com/assets/{filename}");
583 let template = &result.protocol.components["my-card"].template;
584 assert!(
585 template.contains(&format!(r#"href=\"{expected_href}\""#)),
586 "plugin component template should use CDN href: {template}"
587 );
588 }
589
590 #[test]
591 fn test_css_public_base_emits_fast_plugin_template_links() {
592 let app = create_app_dir(&[
593 ("index.html", "<my-card>Hello</my-card>"),
594 ("my-card.html", "<div><slot></slot></div>"),
595 ("my-card.css", ".card { color: red; }"),
596 ]);
597 let mut options = default_options(app.path());
598 options.plugin = Some(Plugin::FastV3);
599 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
600 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
601 let result = build(options).unwrap();
602
603 let filename = &result.css_files[0].0;
604 let expected_href = format!("https://cdn.example.com/assets/{filename}");
605 let template = &result.protocol.components["my-card"].template;
606 assert!(
607 template.contains(&format!(r#"href="{expected_href}""#)),
608 "FAST component template should use CDN href: {template}"
609 );
610 }
611
612 #[test]
613 fn test_invalid_css_template_is_rejected() {
614 let app = create_app_dir(&[
615 ("index.html", "<my-card>Hello</my-card>"),
616 ("my-card.html", "<div><slot></slot></div>"),
617 ("my-card.css", ".card { color: red; }"),
618 ]);
619 let mut options = default_options(app.path());
620 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
621 let result = build(options);
622
623 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
624 }
625
626 #[test]
627 fn test_css_filename_collision_is_rejected() {
628 let app = create_app_dir(&[
629 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
630 ("card-a.html", "<div><slot></slot></div>"),
631 ("card-a.css", ".x { color: red; }"),
632 ("card-b.html", "<div><slot></slot></div>"),
633 ("card-b.css", ".x { color: red; }"),
634 ]);
635 let mut options = default_options(app.path());
636 options.css_file_name_template = "[hash].[ext]".to_string();
637 let result = build(options);
638
639 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
640 }
641
642 #[test]
643 fn test_css_href_set_for_light_dom_link_strategy() {
644 let app = create_app_dir(&[
645 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
646 ("has-css.html", "<p><slot></slot></p>"),
647 ("has-css.css", ".yes { color: green; }"),
648 ("no-css.html", "<p><slot></slot></p>"),
649 ]);
650 let mut options = default_options(app.path());
651 options.dom = DomStrategy::Light;
652 let result = build(options).unwrap();
653
654 let href = result
655 .protocol
656 .components
657 .get("has-css")
658 .map(|c| c.css_href.as_str())
659 .unwrap_or("");
660 assert_eq!(
661 href, "has-css.css",
662 "Light×Link component with CSS should have css_href"
663 );
664
665 let no_href = result
666 .protocol
667 .components
668 .get("no-css")
669 .map(|c| c.css_href.as_str())
670 .unwrap_or("");
671 assert!(
672 no_href.is_empty(),
673 "Component without CSS should have empty css_href"
674 );
675 }
676
677 #[test]
678 fn test_shadow_dom_link_strategy_sets_css_href() {
679 let app = create_app_dir(&[
683 ("index.html", "<my-card>A</my-card>"),
684 ("my-card.html", "<p><slot></slot></p>"),
685 ("my-card.css", ".card { color: red; }"),
686 ]);
687 let result = build(default_options(app.path())).unwrap();
688
689 let comp = result.protocol.components.get("my-card").unwrap();
690 assert_eq!(
691 comp.css_href, "my-card.css",
692 "Shadow×Link should set css_href"
693 );
694
695 assert_eq!(
697 result.protocol.css_strategy(),
698 webui_protocol::CssStrategy::Link,
699 );
700 assert_eq!(
701 result.protocol.dom_strategy(),
702 webui_protocol::DomStrategy::Shadow,
703 );
704
705 assert_eq!(
707 result.css_files.len(),
708 1,
709 "CSS file should still be produced"
710 );
711 }
712
713 #[test]
714 fn test_css_href_empty_for_style_strategy() {
715 let app = create_app_dir(&[
716 ("index.html", "<my-card>A</my-card>"),
717 ("my-card.html", "<p><slot></slot></p>"),
718 ("my-card.css", ".card { color: red; }"),
719 ]);
720 let mut options = default_options(app.path());
721 options.css = CssStrategy::Style;
722 let result = build(options).unwrap();
723
724 let href = result
725 .protocol
726 .components
727 .get("my-card")
728 .map(|c| c.css_href.as_str())
729 .unwrap_or("");
730 assert!(href.is_empty(), "Style-strategy should not set css_href");
731 }
732
733 #[test]
734 fn test_css_href_empty_for_module_strategy() {
735 let app = create_app_dir(&[
736 ("index.html", "<my-card>A</my-card>"),
737 ("my-card.html", "<p><slot></slot></p>"),
738 ("my-card.css", ".card { color: red; }"),
739 ]);
740 let mut options = default_options(app.path());
741 options.css = CssStrategy::Module;
742 let result = build(options).unwrap();
743
744 let href = result
745 .protocol
746 .components
747 .get("my-card")
748 .map(|c| c.css_href.as_str())
749 .unwrap_or("");
750 assert!(href.is_empty(), "Module-strategy should not set css_href");
751 assert!(
752 !result.protocol.components["my-card"].css.is_empty(),
753 "Module-strategy should still populate css content"
754 );
755 }
756
757 #[test]
758 fn test_build_to_disk_writes_files() {
759 let app = create_app_dir(&[
760 ("index.html", "<my-card>Hello</my-card>"),
761 ("my-card.html", "<div><slot></slot></div>"),
762 ("my-card.css", ".card { color: red; }"),
763 ]);
764 let out = TempDir::new().unwrap();
765
766 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
767
768 assert!(out.path().join("protocol.bin").exists());
769 assert!(out.path().join("my-card.css").exists());
770 assert_eq!(stats.css_file_count, 1);
771 assert!(stats.fragment_count > 0);
772 }
773
774 #[test]
775 fn test_build_to_disk_creates_nested_output_dir() {
776 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
777 let out = TempDir::new().unwrap();
778 let nested = out.path().join("nested").join("output");
779
780 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
781 assert!(nested.join("protocol.bin").exists());
782 assert!(stats.fragment_count > 0);
783 }
784
785 #[test]
786 fn test_build_missing_entry() {
787 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
788 let result = build(default_options(app.path()));
789
790 assert!(result.is_err());
791 let err = result.unwrap_err();
792 assert!(matches!(err, WebUIError::Io { .. }));
793 }
794
795 #[test]
796 fn test_build_stats_populated() {
797 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
798 let result = build(default_options(app.path())).unwrap();
799
800 assert!(result.stats.fragment_count > 0);
801 assert!(result.stats.protocol_size_bytes > 0);
802 assert_eq!(result.stats.css_file_count, 0);
803 }
804
805 #[test]
806 fn test_build_inline_css() {
807 let app = create_app_dir(&[
808 ("index.html", "<my-card>Hello</my-card>"),
809 ("my-card.html", "<div><slot></slot></div>"),
810 ("my-card.css", ".card { color: red; }"),
811 ]);
812 let mut options = default_options(app.path());
813 options.css = CssStrategy::Style;
814
815 let result = build(options).unwrap();
816 assert!(result.css_files.is_empty());
818 assert_eq!(result.stats.css_file_count, 0);
819 assert!(result.stats.fragment_count > 0);
820 }
821
822 #[test]
823 fn test_build_module_css() {
824 let app = create_app_dir(&[
825 ("index.html", "<my-card>Hello</my-card>"),
826 ("my-card.html", "<div><slot></slot></div>"),
827 ("my-card.css", ".card { color: red; }"),
828 ]);
829 let mut options = default_options(app.path());
830 options.css = CssStrategy::Module;
831
832 let result = build(options).unwrap();
833 assert!(result.css_files.is_empty());
835 assert_eq!(result.stats.css_file_count, 0);
836 assert!(result.stats.fragment_count > 0);
837 }
838
839 #[test]
840 fn test_build_custom_entry() {
841 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
842 let mut options = default_options(app.path());
843 options.entry = "page.html".to_string();
844
845 let result = build(options).unwrap();
846 assert!(result.protocol.fragments.contains_key("page.html"));
847 assert!(!result.protocol.fragments.contains_key("index.html"));
848 }
849
850 #[test]
851 fn test_build_protocol_roundtrip() {
852 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
853 let result = build(default_options(app.path())).unwrap();
854
855 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
856 assert!(restored.fragments.contains_key("index.html"));
857 }
858
859 #[test]
860 fn test_inspect_bytes_valid() {
861 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
862 let result = build(default_options(app.path())).unwrap();
863
864 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
865 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
866 assert!(parsed.get("fragments").is_some());
867 }
868
869 #[test]
870 fn test_inspect_bytes_invalid() {
871 let result = inspect_bytes(b"not a protobuf");
872 assert!(result.is_err());
873 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
874 }
875
876 #[test]
877 fn test_inspect_file() {
878 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
879 let out = TempDir::new().unwrap();
880 build_to_disk(default_options(app.path()), out.path()).unwrap();
881
882 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
883 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
884 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
885 }
886
887 #[test]
888 fn test_inspect_missing_file() {
889 let result = inspect(Path::new("/nonexistent/protocol.bin"));
890 assert!(result.is_err());
891 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
892 }
893
894 #[test]
895 fn test_build_with_components_local_path() {
896 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
897 let ext_dir = TempDir::new().unwrap();
898 fs::write(
899 ext_dir.path().join("ext-card.html"),
900 "<div class=\"card\"><slot></slot></div>",
901 )
902 .unwrap();
903 fs::write(
904 ext_dir.path().join("ext-card.css"),
905 ".card { border: 1px solid #ccc; }",
906 )
907 .unwrap();
908
909 let mut options = default_options(app.path());
910 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
911
912 let result = build(options).unwrap();
913 assert!(result.protocol.fragments.contains_key("index.html"));
914 assert_eq!(result.css_files.len(), 1);
915 assert!(result.css_files[0].1.contains("border"));
916 }
917
918 #[test]
919 fn test_build_hello_world_example() {
920 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
921 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
922
923 let result = build(BuildOptions {
924 app_dir,
925 entry: "index.html".to_string(),
926 ..BuildOptions::default()
927 })
928 .unwrap();
929
930 let index = &result.protocol.fragments["index.html"].fragments;
931 assert!(index.iter().any(
932 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
933 ));
934 assert!(index
935 .iter()
936 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
937 }
938
939 #[test]
942 fn test_css_filename_sanitizes_path_separators() {
943 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
946 let result = build(default_options(app.path())).unwrap();
947
948 for (filename, _) in &result.css_files {
950 assert!(
951 !filename.contains('/') && !filename.contains('\\'),
952 "CSS filename contains path separator: {filename}"
953 );
954 }
955 }
956
957 #[test]
958 fn test_build_to_disk_css_stays_in_output_dir() {
959 let app = create_app_dir(&[
960 ("index.html", "<my-card>Hello</my-card>"),
961 ("my-card.html", "<div><slot></slot></div>"),
962 ("my-card.css", ".card { color: red; }"),
963 ]);
964 let out = TempDir::new().unwrap();
965
966 build_to_disk(default_options(app.path()), out.path()).unwrap();
967
968 let css_path = out.path().join("my-card.css");
970 assert!(css_path.exists());
971 let canonical = css_path.canonicalize().unwrap();
972 let out_canonical = out.path().canonicalize().unwrap();
973 assert!(
974 canonical.starts_with(&out_canonical),
975 "CSS file escaped output directory"
976 );
977 }
978
979 #[test]
982 fn test_build_empty_html() {
983 let app = create_app_dir(&[("index.html", "")]);
984 let result = build(default_options(app.path())).unwrap();
985
986 assert!(result.protocol.fragments.contains_key("index.html"));
987 assert_eq!(result.stats.fragment_count, 0);
988 assert!(result.stats.protocol_size_bytes > 0);
989 }
990
991 #[test]
992 fn test_build_large_fragment_count() {
993 let mut html = String::with_capacity(2000);
995 for i in 0..50 {
996 html.push_str(&format!("<p>Item {i}</p>\n"));
997 }
998 let app = create_app_dir(&[("index.html", &html)]);
999 let result = build(default_options(app.path())).unwrap();
1000
1001 assert!(result.stats.fragment_count > 0);
1002 assert_eq!(result.stats.css_file_count, 0);
1003 }
1004
1005 #[test]
1006 fn test_build_with_fast_plugin() {
1007 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1008 let mut options = default_options(app.path());
1009 options.plugin = Some(Plugin::FastV3);
1010
1011 let result = build(options).unwrap();
1012 assert!(result.protocol.fragments.contains_key("index.html"));
1013 }
1014
1015 #[test]
1016 fn test_build_to_disk_inline_mode_no_css_files() {
1017 let app = create_app_dir(&[
1018 ("index.html", "<my-card>Hello</my-card>"),
1019 ("my-card.html", "<div><slot></slot></div>"),
1020 ("my-card.css", ".card { color: red; }"),
1021 ]);
1022 let out = TempDir::new().unwrap();
1023 let mut options = default_options(app.path());
1024 options.css = CssStrategy::Style;
1025
1026 let stats = build_to_disk(options, out.path()).unwrap();
1027
1028 assert!(out.path().join("protocol.bin").exists());
1029 assert!(!out.path().join("my-card.css").exists());
1030 assert_eq!(stats.css_file_count, 0);
1031 }
1032
1033 #[test]
1034 fn test_build_stats_duration_is_nonzero() {
1035 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
1036 let result = build(default_options(app.path())).unwrap();
1037
1038 assert!(!result.stats.duration.is_zero());
1039 }
1040
1041 #[test]
1042 fn test_build_multiple_components_css() {
1043 let app = create_app_dir(&[
1044 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1045 ("card-a.html", "<div><slot></slot></div>"),
1046 ("card-a.css", ".a { color: red; }"),
1047 ("card-b.html", "<span><slot></slot></span>"),
1048 ("card-b.css", ".b { color: blue; }"),
1049 ]);
1050 let result = build(default_options(app.path())).unwrap();
1051
1052 assert_eq!(result.css_files.len(), 2);
1053 assert_eq!(result.stats.css_file_count, 2);
1054 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
1055 assert!(filenames.contains(&"card-a.css"));
1056 assert!(filenames.contains(&"card-b.css"));
1057 }
1058
1059 #[test]
1060 fn test_build_unused_component_css_not_emitted() {
1061 let app = create_app_dir(&[
1063 ("index.html", "<card-a>A</card-a>"),
1064 ("card-a.html", "<div><slot></slot></div>"),
1065 ("card-a.css", ".a { color: red; }"),
1066 ("card-b.html", "<span><slot></slot></span>"),
1067 ("card-b.css", ".b { color: blue; }"),
1068 ]);
1069 let result = build(default_options(app.path())).unwrap();
1070
1071 assert_eq!(result.css_files.len(), 1);
1072 assert_eq!(result.css_files[0].0, "card-a.css");
1073 }
1074
1075 #[test]
1076 fn test_inspect_roundtrip_preserves_content() {
1077 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1078 let result = build(default_options(app.path())).unwrap();
1079
1080 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1081 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1082
1083 let fragments = &parsed["fragments"]["index.html"]["fragments"];
1084 assert!(fragments.is_array());
1085 assert!(!fragments.as_array().unwrap().is_empty());
1086 }
1087
1088 #[test]
1089 fn test_build_nonexistent_app_dir() {
1090 let options = BuildOptions {
1091 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
1092 ..BuildOptions::default()
1093 };
1094 let result = build(options);
1095 assert!(result.is_err());
1096 }
1097
1098 #[test]
1099 fn test_build_to_disk_returns_accurate_stats() {
1100 let app = create_app_dir(&[
1101 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
1102 ("my-card.html", "<div><slot></slot></div>"),
1103 ("my-card.css", ".card { color: red; }"),
1104 ]);
1105 let out = TempDir::new().unwrap();
1106
1107 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1108
1109 assert!(stats.fragment_count > 0);
1110 assert_eq!(stats.css_file_count, 1);
1111 assert!(stats.component_count > 0);
1112 assert!(stats.protocol_size_bytes > 0);
1113 assert!(!stats.duration.is_zero());
1114 }
1115}