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