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