1mod component_assets;
24mod error;
25pub mod server;
26pub mod streaming;
27
28pub use component_assets::{render_component_assets, ComponentAssetFile};
29pub use error::WebUIError;
30
31pub use webui_handler::route_handler::{
33 encode_inventory, get_needed_components, get_needed_components_for_request, parse_inventory,
34 ProtocolIndex,
35};
36pub use webui_handler::route_matcher::CompiledRouteCache;
37pub use webui_handler::Result as HandlerResult;
38pub use webui_handler::{
39 plugin::HandlerPlugin, HandlerError, RenderOptions, ResponseWriter, WebUIHandler,
40};
41pub use webui_parser::plugin::ComponentTemplateArtifact;
42pub use webui_parser::CssStrategy;
43pub use webui_parser::DomStrategy;
44pub use webui_parser::LegalComments;
45pub use webui_parser::ParserError;
46pub use webui_parser::ParserOptions;
47pub use webui_parser::Plugin;
48pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
49pub use webui_parser::{
50 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
51};
52pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
53pub use webui_parser::{Diagnostic, Severity};
54pub use webui_protocol::WebUIProtocol;
55pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
56
57use std::collections::HashSet;
58use std::ffi::{OsStr, OsString};
59use std::fs;
60use std::path::Path;
61use std::time::Instant;
62use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
63use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
64use webui_parser::plugin::webui::WebUIParserPlugin;
65use webui_parser::plugin::ParserPluginArtifacts;
66use webui_parser::HtmlParser;
67
68#[derive(Debug, Clone)]
70pub struct BuildOptions {
71 pub app_dir: std::path::PathBuf,
73 pub entry: String,
75 pub css: CssStrategy,
77 pub dom: DomStrategy,
79 pub plugin: Option<Plugin>,
81 pub components: Vec<String>,
83 pub component_asset_roots: Vec<String>,
89 pub css_file_name_template: String,
93 pub css_public_base: Option<String>,
96 pub legal_comments: LegalComments,
98 pub theme: Option<TokenFile>,
106}
107
108impl Default for BuildOptions {
109 fn default() -> Self {
110 Self {
111 app_dir: std::path::PathBuf::from("."),
112 entry: "index.html".to_string(),
113 css: CssStrategy::Link,
114 dom: DomStrategy::Shadow,
115 plugin: None,
116 components: Vec::new(),
117 component_asset_roots: Vec::new(),
118 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
119 css_public_base: None,
120 legal_comments: LegalComments::default(),
121 theme: None,
122 }
123 }
124}
125
126#[derive(Debug, Clone)]
128pub struct BuildStats {
129 pub duration: std::time::Duration,
131 pub fragment_count: usize,
133 pub component_count: usize,
135 pub css_file_count: usize,
137 pub protocol_size_bytes: usize,
139 pub token_count: usize,
141}
142
143#[derive(Debug)]
145pub struct BuildResult {
146 pub protocol: WebUIProtocol,
148 pub protocol_bytes: Vec<u8>,
150 pub css_files: Vec<(String, String)>,
152 pub component_asset_files: Vec<ComponentAssetFile>,
156 pub component_templates: Vec<ComponentTemplateArtifact>,
160 pub warnings: Vec<Diagnostic>,
167 pub stats: BuildStats,
169}
170
171#[must_use = "BuildResult contains the compiled protocol and statistics"]
181pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
182 let started = Instant::now();
183
184 let raw = build_protocol_inner(&options)?;
185
186 let protocol_bytes = raw.protocol.to_protobuf()?;
187
188 let stats = BuildStats {
189 duration: started.elapsed(),
190 fragment_count: raw.fragment_count,
191 component_count: raw.component_count,
192 css_file_count: raw.css_files.len(),
193 protocol_size_bytes: protocol_bytes.len(),
194 token_count: raw.token_count,
195 };
196
197 Ok(BuildResult {
198 protocol: raw.protocol,
199 protocol_bytes,
200 css_files: raw.css_files,
201 component_asset_files: raw.component_asset_files,
202 component_templates: raw.component_templates,
203 warnings: raw.warnings,
204 stats,
205 })
206}
207
208pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
218 let result = build(options)?;
219 validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
220
221 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
222 context: format!("Failed to create {}", out_dir.display()),
223 source,
224 })?;
225
226 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
227 WebUIError::Io {
228 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
229 source,
230 }
231 })?;
232
233 for (name, content) in &result.css_files {
234 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
235 context: format!("Failed to write {name} to {}", out_dir.display()),
236 source,
237 })?;
238 }
239 for file in &result.component_asset_files {
240 fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
241 context: format!(
242 "Failed to write component asset {} to {}",
243 file.name,
244 out_dir.display()
245 ),
246 source,
247 })?;
248 }
249
250 Ok(result.stats)
251}
252
253pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
255 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
256 context: format!("Failed to read {}", protocol_path.display()),
257 source,
258 })?;
259 inspect_bytes(&bytes)
260}
261
262pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
264 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
265 protocol
266 .to_json_pretty()
267 .map_err(|e| WebUIError::Serialization(e.to_string()))
268}
269
270struct RawBuildOutput {
272 protocol: WebUIProtocol,
273 css_files: Vec<(String, String)>,
274 component_asset_files: Vec<ComponentAssetFile>,
275 component_templates: Vec<ComponentTemplateArtifact>,
276 warnings: Vec<Diagnostic>,
277 fragment_count: usize,
278 component_count: usize,
279 token_count: usize,
280}
281
282fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
284 let parser_options = ParserOptions::try_new(
285 options.css,
286 options.dom,
287 &options.css_file_name_template,
288 options.css_public_base.as_deref(),
289 options.legal_comments,
290 )
291 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
292 let css_link_options = parser_options.css_link_options.clone();
293
294 let mut parser = match options.plugin {
295 Some(Plugin::Fast | Plugin::FastV2) => {
296 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
297 }
298 Some(Plugin::FastV3) => {
299 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
300 }
301 Some(Plugin::WebUI) => {
302 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
303 }
304 None => HtmlParser::with_options(parser_options),
305 };
306
307 parser
309 .component_registry_mut()
310 .register_from_paths(&[&options.app_dir])
311 .map_err(|e| {
312 WebUIError::ComponentRegistration(format!(
313 "Failed to register components from {}: {e}",
314 options.app_dir.display()
315 ))
316 })?;
317
318 for source in &options.components {
320 let result = webui_discovery::discover_source(source, &options.app_dir).map_err(|e| {
321 WebUIError::ComponentDiscovery(format!(
322 "Failed to discover components from {source}: {e}"
323 ))
324 })?;
325 for comp in &result.components {
326 parser
327 .component_registry_mut()
328 .register_component(
329 &comp.tag_name,
330 &comp.html_content,
331 comp.css_content.as_deref(),
332 comp.has_script,
333 )
334 .map_err(|e| {
335 WebUIError::ComponentRegistration(format!(
336 "Failed to register component '{}' from {}: {e}",
337 comp.tag_name, comp.source
338 ))
339 })?;
340 }
341 }
342
343 let component_count = parser.component_registry().len();
344
345 let entry_path = options.app_dir.join(&options.entry);
347 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
348 context: format!("Failed to read {}", entry_path.display()),
349 source,
350 })?;
351 parser
352 .parse(&options.entry, &html_content)
353 .map_err(|source| WebUIError::Parse {
354 context: format!("Failed to parse {}", options.entry),
355 source,
356 })?;
357
358 let synthetic_asset_fragments =
359 parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
360
361 let css_snapshot: Vec<(String, String)> = parser
362 .component_registry()
363 .get_all()
364 .filter(|component| parser.has_fragment(&component.tag_name))
365 .filter_map(|component| {
366 component
367 .css_content
368 .as_ref()
369 .map(|css| (component.tag_name.clone(), css.clone()))
370 })
371 .collect();
372
373 let token_analysis = parser.token_analysis();
375 let mut warnings: Vec<Diagnostic> = Vec::new();
376 if let Some(theme) = options.theme.as_ref() {
377 token_analysis
378 .validate_theme_tokens(theme)
379 .map_err(|source| WebUIError::Parse {
380 context: "Failed to validate theme tokens".to_string(),
381 source,
382 })?;
383 warnings = token_analysis.theme_token_warnings(theme);
388 }
389 let token_count = token_analysis.protocol_tokens.len();
390
391 let component_templates =
392 match parser
393 .take_plugin_artifacts()
394 .map_err(|source| WebUIError::Parse {
395 context: format!(
396 "Failed to compile component templates for {}",
397 options.entry
398 ),
399 source,
400 })? {
401 ParserPluginArtifacts::None => Vec::new(),
402 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
403 };
404 let mut fragment_records = parser.into_fragment_records();
406 for fragment_id in synthetic_asset_fragments {
407 fragment_records.remove(&fragment_id);
408 }
409 let fragment_count: usize = fragment_records.values().map(|v| v.fragments.len()).sum();
410
411 let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
412
413 protocol.set_css_strategy(match options.css {
415 CssStrategy::Link => webui_protocol::CssStrategy::Link,
416 CssStrategy::Style => webui_protocol::CssStrategy::Style,
417 CssStrategy::Module => webui_protocol::CssStrategy::Module,
418 });
419 protocol.set_dom_strategy(match options.dom {
420 DomStrategy::Shadow => webui_protocol::DomStrategy::Shadow,
421 DomStrategy::Light => webui_protocol::DomStrategy::Light,
422 });
423
424 let is_module = options.css == CssStrategy::Module;
427 let is_link = options.css == CssStrategy::Link;
428 let mut css_files: Vec<(String, String)> = Vec::new();
429 let mut emitted_names: HashSet<String> = HashSet::new();
430 for (tag, css) in css_snapshot {
431 if !protocol.fragments.contains_key(&tag) {
432 continue;
433 }
434 if is_module {
435 protocol.components.entry(tag).or_default().css = css.trim().to_string();
436 } else if is_link {
437 let resolved = css_link_options.resolve(&tag, &css);
438 if !emitted_names.insert(resolved.filename.clone()) {
439 return Err(WebUIError::InvalidBuildOptions(format!(
440 "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
441 resolved.filename
442 )));
443 }
444 protocol.components.entry(tag).or_default().css_href = resolved.href;
445 css_files.push((resolved.filename, css));
446 }
447 }
450
451 for artifact in &component_templates {
453 let component = protocol
454 .components
455 .entry(artifact.tag_name.clone())
456 .or_default();
457 component.template = artifact.template.clone();
458 component.template_json = artifact.template_json.clone();
459 component.template_functions = artifact.template_functions.clone();
460 }
461
462 let component_asset_files = component_assets::render_component_assets(
463 &protocol,
464 &options.component_asset_roots,
465 &options.css_file_name_template,
466 )?;
467 validate_generated_file_names(&css_files, &component_asset_files)?;
468
469 Ok(RawBuildOutput {
470 protocol,
471 css_files,
472 component_asset_files,
473 component_templates,
474 warnings,
475 fragment_count,
476 component_count,
477 token_count,
478 })
479}
480
481fn parse_component_asset_roots(
482 parser: &mut HtmlParser,
483 roots: &[String],
484) -> Result<Vec<String>, WebUIError> {
485 let mut synthetic_fragments = Vec::with_capacity(roots.len());
486 for (index, root) in roots.iter().enumerate() {
487 let root = root.trim();
488 if parser.has_fragment(root) {
489 continue;
490 }
491 let mut synthetic = String::with_capacity(root.len() * 2 + 5);
494 synthetic.push('<');
495 synthetic.push_str(root);
496 synthetic.push_str("></");
497 synthetic.push_str(root);
498 synthetic.push('>');
499
500 let fragment_id = format!("__webui_asset_root_{index}");
501 parser
502 .parse(&fragment_id, &synthetic)
503 .map_err(|source| WebUIError::Parse {
504 context: format!("Failed to parse component asset root <{root}>"),
505 source,
506 })?;
507 synthetic_fragments.push(fragment_id);
508 }
509 Ok(synthetic_fragments)
510}
511
512fn validate_generated_file_names(
513 css_files: &[(String, String)],
514 component_asset_files: &[ComponentAssetFile],
515) -> Result<(), WebUIError> {
516 let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
517 for (name, _) in css_files {
518 names.insert(name.as_str());
519 }
520 for file in component_asset_files {
521 if !names.insert(file.name.as_str()) {
522 return Err(WebUIError::InvalidBuildOptions(format!(
523 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
524 file.name
525 )));
526 }
527 }
528 Ok(())
529}
530
531fn validate_output_file_names(
532 protocol_name: &OsStr,
533 result: &BuildResult,
534) -> Result<(), WebUIError> {
535 let mut names =
536 HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
537 names.insert(protocol_name.to_os_string());
538 for (name, _) in &result.css_files {
539 let file_name = OsString::from(name);
540 if !names.insert(file_name.clone()) {
541 return Err(output_file_collision_error(&file_name));
542 }
543 }
544 for file in &result.component_asset_files {
545 let file_name = OsString::from(&file.name);
546 if !names.insert(file_name.clone()) {
547 return Err(output_file_collision_error(&file_name));
548 }
549 }
550 Ok(())
551}
552
553fn output_file_collision_error(name: &OsStr) -> WebUIError {
554 WebUIError::InvalidBuildOptions(format!(
555 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
556 name.to_string_lossy()
557 ))
558}
559
560#[cfg(test)]
561#[allow(clippy::disallowed_methods)]
562mod tests {
563 use super::*;
564 use std::collections::HashMap;
565 use std::path::PathBuf;
566 use tempfile::TempDir;
567 use webui_protocol::web_ui_fragment::Fragment;
568
569 struct StringWriter {
570 buf: String,
571 }
572
573 impl ResponseWriter for StringWriter {
574 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
575 self.buf.push_str(content);
576 Ok(())
577 }
578
579 fn end(&mut self) -> webui_handler::Result<()> {
580 Ok(())
581 }
582 }
583
584 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
585 let dir = TempDir::new().unwrap();
586 for (name, content) in files {
587 let path = dir.path().join(name);
588 if let Some(parent) = path.parent() {
589 fs::create_dir_all(parent).unwrap();
590 }
591 fs::write(&path, content).unwrap();
592 }
593 dir
594 }
595
596 fn default_options(app_dir: &Path) -> BuildOptions {
597 BuildOptions {
598 app_dir: app_dir.to_path_buf(),
599 ..BuildOptions::default()
600 }
601 }
602
603 #[test]
604 fn test_build_simple_html() {
605 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
606 let result = build(default_options(app.path())).unwrap();
607
608 assert!(result.protocol.fragments.contains_key("index.html"));
609 assert!(result.stats.fragment_count > 0);
610 assert!(result.stats.protocol_size_bytes > 0);
611 assert!(!result.stats.duration.is_zero());
612 }
613
614 #[test]
615 fn test_build_marks_stateful_html_only_component_as_template_host() {
616 let app = create_app_dir(&[
617 ("index.html", "<demo-card></demo-card>"),
618 ("demo-card.html", "<p>{{name}}</p>"),
619 ]);
620 let mut options = default_options(app.path());
621 options.plugin = Some(Plugin::WebUI);
622 let result = build(options).unwrap();
623
624 assert!(result
625 .component_templates
626 .iter()
627 .any(|template| template.tag_name == "demo-card"
628 && template.template_json.contains(r#""th":1"#)));
629 assert!(result.protocol.components.contains_key("demo-card"));
630 }
631
632 #[test]
633 fn test_build_treats_sibling_component_script_as_scripted() {
634 let app = create_app_dir(&[
635 ("index.html", "<demo-card></demo-card>"),
636 (
637 "demo-card.html",
638 r#"<button @click="{onClick()}">{{name}}</button>"#,
639 ),
640 (
641 "demo-card.ts",
642 "import { WebUIElement } from '@microsoft/webui-framework';\n\
643 class DemoCard extends WebUIElement { onClick() {} }\n\
644 DemoCard.define('demo-card');",
645 ),
646 ]);
647 let mut options = default_options(app.path());
648 options.plugin = Some(Plugin::WebUI);
649 let result = build(options).unwrap();
650
651 let template = result
652 .component_templates
653 .iter()
654 .find(|template| template.tag_name == "demo-card")
655 .unwrap();
656 assert!(!template.template_json.contains(r#""th":1"#));
657 assert!(template.template_json.contains(r#""eg":[["click""#));
658 }
659
660 #[test]
661 fn test_build_rejects_scriptless_event_component() {
662 let app = create_app_dir(&[
663 ("index.html", "<demo-card></demo-card>"),
664 (
665 "demo-card.html",
666 r#"<button @click="{onClick()}">Click</button>"#,
667 ),
668 ]);
669 let mut options = default_options(app.path());
670 options.plugin = Some(Plugin::WebUI);
671 let err = build(options).expect_err("scriptless events should fail");
672
673 let message = err.chain_message();
674 assert!(
675 message.contains("scriptless-event-handler"),
676 "msg: {message}"
677 );
678 }
679
680 #[test]
681 fn test_build_returns_actionable_error_for_invalid_w_ref() {
682 let app = create_app_dir(&[
683 ("index.html", "<my-card>Hello</my-card>"),
684 ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
685 ]);
686 let mut options = default_options(app.path());
687 options.plugin = Some(Plugin::WebUI);
688
689 let Err(err) = build(options) else {
692 panic!("non-braced w-ref must fail the build");
693 };
694 let msg = err.chain_message();
695 assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
696 assert!(
697 msg.contains("component <my-card> · element <input>"),
698 "msg: {msg}"
699 );
700 assert!(msg.contains("help:"), "msg: {msg}");
701 }
702
703 #[test]
704 fn test_build_returns_actionable_error_for_invalid_event_handler() {
705 let app = create_app_dir(&[
706 ("index.html", "<my-btn>x</my-btn>"),
707 (
708 "my-btn.html",
709 r#"<div><button @click="e.preventDefault()">x</button></div>"#,
710 ),
711 ]);
712 let mut options = default_options(app.path());
713 options.plugin = Some(Plugin::WebUI);
714
715 let Err(err) = build(options) else {
716 panic!("invalid @event handler must fail the build");
717 };
718 let msg = err.chain_message();
719 assert!(msg.contains("invalid @click handler"), "msg: {msg}");
720 assert!(
721 msg.contains("component <my-btn> · element <button>"),
722 "msg: {msg}"
723 );
724 }
725
726 #[test]
727 fn test_error_chain_message_has_no_duplicate_source() {
728 let err = WebUIError::Parse {
729 context: "Failed to parse index.html".to_string(),
730 source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
731 };
732 assert_eq!(err.to_string(), "Failed to parse index.html");
735 assert_eq!(
737 err.chain_message(),
738 "Failed to parse index.html: Directive error: Invalid for each: x"
739 );
740 }
741
742 #[test]
743 fn test_build_with_directives() {
744 let html = r#"<h1>Hello</h1>
745<for each="item in items">
746 <p>{{item.name}}</p>
747</for>
748<if condition="show">
749 <p>Visible</p>
750</if>"#;
751 let app = create_app_dir(&[("index.html", html)]);
752 let result = build(default_options(app.path())).unwrap();
753
754 let index = &result.protocol.fragments["index.html"].fragments;
755 assert!(index
756 .iter()
757 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
758 assert!(index
759 .iter()
760 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
761 }
762
763 #[test]
764 fn test_build_with_component_css() {
765 let app = create_app_dir(&[
766 ("index.html", "<my-card>Hello</my-card>"),
767 ("my-card.html", "<div><slot></slot></div>"),
768 ("my-card.css", ".card { color: red; }"),
769 ]);
770 let result = build(default_options(app.path())).unwrap();
771
772 assert_eq!(result.css_files.len(), 1);
773 assert_eq!(result.css_files[0].0, "my-card.css");
774 assert!(result.css_files[0].1.contains("color: red"));
775 assert_eq!(result.stats.css_file_count, 1);
776 }
777
778 #[test]
779 fn test_build_strips_non_legal_css_comments_from_output_file() {
780 let app = create_app_dir(&[
781 ("index.html", "<my-card>Hello</my-card>"),
782 ("my-card.html", "<div><slot></slot></div>"),
783 (
784 "my-card.css",
785 "/* remove var(--ignored) */ .card { color: var(--textColor); }",
786 ),
787 ]);
788 let result = build(default_options(app.path())).unwrap();
789
790 assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
791 assert_eq!(result.protocol.tokens, vec!["textColor"]);
792 }
793
794 #[test]
795 fn test_build_preserves_legal_css_comments_by_default() {
796 let app = create_app_dir(&[
797 ("index.html", "<my-card>Hello</my-card>"),
798 ("my-card.html", "<div><slot></slot></div>"),
799 (
800 "my-card.css",
801 "/*! @license MIT */ .card { color: red; } /* remove */",
802 ),
803 ]);
804 let result = build(default_options(app.path())).unwrap();
805
806 assert_eq!(
807 result.css_files[0].1,
808 "/*! @license MIT */ .card { color: red; } "
809 );
810 }
811
812 #[test]
813 fn test_build_legal_comments_none_strips_legal_css_comments() {
814 let app = create_app_dir(&[
815 ("index.html", "<my-card>Hello</my-card>"),
816 ("my-card.html", "<div><slot></slot></div>"),
817 ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
818 ]);
819 let mut options = default_options(app.path());
820 options.legal_comments = LegalComments::None;
821 let result = build(options).unwrap();
822
823 assert_eq!(result.css_files[0].1, " .card { color: red; }");
824 }
825
826 #[test]
827 fn test_build_with_css_hashed_filename_template() {
828 let app = create_app_dir(&[
829 ("index.html", "<my-card>Hello</my-card>"),
830 ("my-card.html", "<div><slot></slot></div>"),
831 ("my-card.css", ".card { color: red; }"),
832 ]);
833 let mut options = default_options(app.path());
834 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
835 let result = build(options).unwrap();
836
837 assert_eq!(result.css_files.len(), 1);
838 let generated = &result.css_files[0].0;
839 assert!(generated.starts_with("my-card-"));
840 assert!(generated.ends_with(".css"));
841 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
842 assert_eq!(
843 result
844 .protocol
845 .components
846 .get("my-card")
847 .map(|c| c.css_href.as_str()),
848 Some(generated.as_str())
849 );
850 }
851
852 #[test]
853 fn test_css_public_base_prefixes_css_href_only() {
854 let app = create_app_dir(&[
855 ("index.html", "<my-card>Hello</my-card>"),
856 ("my-card.html", "<div><slot></slot></div>"),
857 ("my-card.css", ".card { color: red; }"),
858 ]);
859 let mut options = default_options(app.path());
860 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
861 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
862 let result = build(options).unwrap();
863
864 let filename = &result.css_files[0].0;
865 assert!(filename.starts_with("my-card-"));
866 let expected_href = format!("https://cdn.example.com/assets/{filename}");
867 assert_eq!(
868 result
869 .protocol
870 .components
871 .get("my-card")
872 .map(|c| c.css_href.as_str()),
873 Some(expected_href.as_str())
874 );
875 }
876
877 #[test]
878 fn test_css_public_base_emits_parser_and_handler_links() {
879 let app = create_app_dir(&[
880 (
881 "index.html",
882 "<html><head></head><body><my-card>Hello</my-card></body></html>",
883 ),
884 ("my-card.html", "<div><slot></slot></div>"),
885 ("my-card.css", ".card { color: red; }"),
886 ]);
887 let mut options = default_options(app.path());
888 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
889 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
890 let result = build(options).unwrap();
891
892 let filename = &result.css_files[0].0;
893 let expected_href = format!("https://cdn.example.com/assets/{filename}");
894 let component_html = result.protocol.fragments["my-card"]
895 .fragments
896 .iter()
897 .filter_map(|fragment| match fragment.fragment.as_ref() {
898 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
899 _ => None,
900 })
901 .collect::<String>();
902 assert!(
903 component_html.contains(&format!(
904 r#"<link rel="stylesheet" href="{expected_href}">"#
905 )),
906 "parser-generated component template should use CDN href: {component_html}"
907 );
908
909 let handler = WebUIHandler::new();
910 let mut writer = StringWriter { buf: String::new() };
911 handler
912 .handle(
913 &result.protocol,
914 &serde_json::json!({}),
915 &RenderOptions::new("index.html", "/"),
916 &mut writer,
917 )
918 .unwrap();
919 assert!(
920 writer.buf.contains(&format!(
921 r#"<link rel="preload" href="{expected_href}" as="style" data-webui-ssr-preload="style">"#
922 )),
923 "handler head preload should use CDN href: {}",
924 writer.buf
925 );
926 }
927
928 #[test]
929 fn test_css_public_base_emits_webui_plugin_template_links() {
930 let app = create_app_dir(&[
931 ("index.html", "<my-card>Hello</my-card>"),
932 ("my-card.html", "<div><slot></slot></div>"),
933 ("my-card.css", ".card { color: red; }"),
934 ]);
935 let mut options = default_options(app.path());
936 options.plugin = Some(Plugin::WebUI);
937 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
938 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
939 let result = build(options).unwrap();
940
941 let filename = &result.css_files[0].0;
942 let expected_href = format!("https://cdn.example.com/assets/{filename}");
943 let template = &result.protocol.components["my-card"].template_json;
944 assert!(
945 template.contains(&format!(r#"href=\"{expected_href}\""#)),
946 "plugin component template should use CDN href: {template}"
947 );
948 }
949
950 #[test]
951 fn test_css_public_base_emits_fast_plugin_template_links() {
952 let app = create_app_dir(&[
953 ("index.html", "<my-card>Hello</my-card>"),
954 ("my-card.html", "<div><slot></slot></div>"),
955 ("my-card.css", ".card { color: red; }"),
956 ]);
957 let mut options = default_options(app.path());
958 options.plugin = Some(Plugin::FastV3);
959 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
960 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
961 let result = build(options).unwrap();
962
963 let filename = &result.css_files[0].0;
964 let expected_href = format!("https://cdn.example.com/assets/{filename}");
965 let template = &result.protocol.components["my-card"].template;
966 assert!(
967 template.contains(&format!(r#"href="{expected_href}""#)),
968 "FAST component template should use CDN href: {template}"
969 );
970 }
971
972 #[test]
973 fn test_invalid_css_template_is_rejected() {
974 let app = create_app_dir(&[
975 ("index.html", "<my-card>Hello</my-card>"),
976 ("my-card.html", "<div><slot></slot></div>"),
977 ("my-card.css", ".card { color: red; }"),
978 ]);
979 let mut options = default_options(app.path());
980 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
981 let result = build(options);
982
983 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
984 }
985
986 #[test]
987 fn test_css_filename_collision_is_rejected() {
988 let app = create_app_dir(&[
989 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
990 ("card-a.html", "<div><slot></slot></div>"),
991 ("card-a.css", ".x { color: red; }"),
992 ("card-b.html", "<div><slot></slot></div>"),
993 ("card-b.css", ".x { color: red; }"),
994 ]);
995 let mut options = default_options(app.path());
996 options.css_file_name_template = "[hash].[ext]".to_string();
997 let result = build(options);
998
999 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1000 }
1001
1002 #[test]
1003 fn test_build_returns_static_component_asset_files() {
1004 let app = create_app_dir(&[
1005 ("index.html", "<app-shell></app-shell>"),
1006 ("app-shell.html", "<div></div>"),
1007 (
1008 "lazy-panel.html",
1009 r#"<if condition="ready"><p>{{title}}</p></if>"#,
1010 ),
1011 ]);
1012 let mut options = default_options(app.path());
1013 options.plugin = Some(Plugin::WebUI);
1014 options.component_asset_roots = vec!["lazy-panel".to_string()];
1015
1016 let result = build(options).unwrap();
1017
1018 assert_eq!(result.component_asset_files.len(), 1);
1019 assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
1020 assert!(result.component_asset_files[0]
1021 .content
1022 .contains(r#""type":"webui-component-asset""#));
1023 assert!(result.component_asset_files[0]
1024 .content
1025 .contains(r#""templateFunctions":{"lazy-panel":"#));
1026 assert!(result.protocol.fragments.contains_key("lazy-panel"));
1027 assert!(
1028 !result
1029 .protocol
1030 .fragments
1031 .keys()
1032 .any(|key| key.starts_with("__webui_asset_root_")),
1033 "synthetic asset root fragments must not be serialized"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_component_asset_filename_collision_with_css_is_rejected() {
1039 let app = create_app_dir(&[
1040 ("index.html", "<app-shell></app-shell>"),
1041 ("app-shell.html", "<div></div>"),
1042 ("lazy-panel.html", "<p>Lazy</p>"),
1043 ("lazy-panel.css", ".panel { color: red; }"),
1044 ]);
1045 let mut options = default_options(app.path());
1046 options.plugin = Some(Plugin::WebUI);
1047 options.component_asset_roots = vec!["lazy-panel".to_string()];
1048 options.css_file_name_template = "[name]".to_string();
1049
1050 let result = build(options);
1051
1052 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1053 }
1054
1055 #[test]
1056 fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
1057 let app = create_app_dir(&[
1058 ("index.html", "<app-shell></app-shell>"),
1059 ("app-shell.html", "<div></div>"),
1060 ("lazy-panel.html", "<p>Lazy</p>"),
1061 ]);
1062 let out = TempDir::new().unwrap();
1063 let mut options = default_options(app.path());
1064 options.plugin = Some(Plugin::WebUI);
1065 options.component_asset_roots = vec!["lazy-panel".to_string()];
1066 options.css_file_name_template = "protocol.bin".to_string();
1067
1068 let result = build_to_disk(options, out.path());
1069
1070 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
1071 assert!(!out.path().join("protocol.bin").exists());
1072 }
1073
1074 #[test]
1075 fn test_css_href_set_for_light_dom_link_strategy() {
1076 let app = create_app_dir(&[
1077 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
1078 ("has-css.html", "<p><slot></slot></p>"),
1079 ("has-css.css", ".yes { color: green; }"),
1080 ("no-css.html", "<p><slot></slot></p>"),
1081 ]);
1082 let mut options = default_options(app.path());
1083 options.dom = DomStrategy::Light;
1084 let result = build(options).unwrap();
1085
1086 let href = result
1087 .protocol
1088 .components
1089 .get("has-css")
1090 .map(|c| c.css_href.as_str())
1091 .unwrap_or("");
1092 assert_eq!(
1093 href, "has-css.css",
1094 "Light×Link component with CSS should have css_href"
1095 );
1096
1097 let no_href = result
1098 .protocol
1099 .components
1100 .get("no-css")
1101 .map(|c| c.css_href.as_str())
1102 .unwrap_or("");
1103 assert!(
1104 no_href.is_empty(),
1105 "Component without CSS should have empty css_href"
1106 );
1107 }
1108
1109 #[test]
1110 fn test_shadow_dom_link_strategy_sets_css_href() {
1111 let app = create_app_dir(&[
1115 ("index.html", "<my-card>A</my-card>"),
1116 ("my-card.html", "<p><slot></slot></p>"),
1117 ("my-card.css", ".card { color: red; }"),
1118 ]);
1119 let result = build(default_options(app.path())).unwrap();
1120
1121 let comp = result.protocol.components.get("my-card").unwrap();
1122 assert_eq!(
1123 comp.css_href, "my-card.css",
1124 "Shadow×Link should set css_href"
1125 );
1126
1127 assert_eq!(
1129 result.protocol.css_strategy(),
1130 webui_protocol::CssStrategy::Link,
1131 );
1132 assert_eq!(
1133 result.protocol.dom_strategy(),
1134 webui_protocol::DomStrategy::Shadow,
1135 );
1136
1137 assert_eq!(
1139 result.css_files.len(),
1140 1,
1141 "CSS file should still be produced"
1142 );
1143 }
1144
1145 #[test]
1146 fn test_css_href_empty_for_style_strategy() {
1147 let app = create_app_dir(&[
1148 ("index.html", "<my-card>A</my-card>"),
1149 ("my-card.html", "<p><slot></slot></p>"),
1150 ("my-card.css", ".card { color: red; }"),
1151 ]);
1152 let mut options = default_options(app.path());
1153 options.css = CssStrategy::Style;
1154 let result = build(options).unwrap();
1155
1156 let href = result
1157 .protocol
1158 .components
1159 .get("my-card")
1160 .map(|c| c.css_href.as_str())
1161 .unwrap_or("");
1162 assert!(href.is_empty(), "Style-strategy should not set css_href");
1163 }
1164
1165 #[test]
1166 fn test_css_href_empty_for_module_strategy() {
1167 let app = create_app_dir(&[
1168 ("index.html", "<my-card>A</my-card>"),
1169 ("my-card.html", "<p><slot></slot></p>"),
1170 ("my-card.css", ".card { color: red; }"),
1171 ]);
1172 let mut options = default_options(app.path());
1173 options.css = CssStrategy::Module;
1174 let result = build(options).unwrap();
1175
1176 let href = result
1177 .protocol
1178 .components
1179 .get("my-card")
1180 .map(|c| c.css_href.as_str())
1181 .unwrap_or("");
1182 assert!(href.is_empty(), "Module-strategy should not set css_href");
1183 assert!(
1184 !result.protocol.components["my-card"].css.is_empty(),
1185 "Module-strategy should still populate css content"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_build_to_disk_writes_files() {
1191 let app = create_app_dir(&[
1192 ("index.html", "<my-card>Hello</my-card>"),
1193 ("my-card.html", "<div><slot></slot></div>"),
1194 ("my-card.css", ".card { color: red; }"),
1195 ]);
1196 let out = TempDir::new().unwrap();
1197
1198 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1199
1200 assert!(out.path().join("protocol.bin").exists());
1201 assert!(out.path().join("my-card.css").exists());
1202 assert_eq!(stats.css_file_count, 1);
1203 assert!(stats.fragment_count > 0);
1204 }
1205
1206 #[test]
1207 fn test_build_to_disk_creates_nested_output_dir() {
1208 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1209 let out = TempDir::new().unwrap();
1210 let nested = out.path().join("nested").join("output");
1211
1212 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
1213 assert!(nested.join("protocol.bin").exists());
1214 assert!(stats.fragment_count > 0);
1215 }
1216
1217 #[test]
1218 fn test_build_missing_entry() {
1219 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
1220 let result = build(default_options(app.path()));
1221
1222 assert!(result.is_err());
1223 let err = result.unwrap_err();
1224 assert!(matches!(err, WebUIError::Io { .. }));
1225 }
1226
1227 #[test]
1228 fn test_build_stats_populated() {
1229 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1230 let result = build(default_options(app.path())).unwrap();
1231
1232 assert!(result.stats.fragment_count > 0);
1233 assert!(result.stats.protocol_size_bytes > 0);
1234 assert_eq!(result.stats.css_file_count, 0);
1235 }
1236
1237 #[test]
1238 fn test_build_inline_css() {
1239 let app = create_app_dir(&[
1240 ("index.html", "<my-card>Hello</my-card>"),
1241 ("my-card.html", "<div><slot></slot></div>"),
1242 ("my-card.css", ".card { color: red; }"),
1243 ]);
1244 let mut options = default_options(app.path());
1245 options.css = CssStrategy::Style;
1246
1247 let result = build(options).unwrap();
1248 assert!(result.css_files.is_empty());
1250 assert_eq!(result.stats.css_file_count, 0);
1251 assert!(result.stats.fragment_count > 0);
1252 }
1253
1254 #[test]
1255 fn test_build_module_css() {
1256 let app = create_app_dir(&[
1257 ("index.html", "<my-card>Hello</my-card>"),
1258 ("my-card.html", "<div><slot></slot></div>"),
1259 ("my-card.css", ".card { color: red; }"),
1260 ]);
1261 let mut options = default_options(app.path());
1262 options.css = CssStrategy::Module;
1263
1264 let result = build(options).unwrap();
1265 assert!(result.css_files.is_empty());
1267 assert_eq!(result.stats.css_file_count, 0);
1268 assert!(result.stats.fragment_count > 0);
1269 }
1270
1271 #[test]
1272 fn test_build_custom_entry() {
1273 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
1274 let mut options = default_options(app.path());
1275 options.entry = "page.html".to_string();
1276
1277 let result = build(options).unwrap();
1278 assert!(result.protocol.fragments.contains_key("page.html"));
1279 assert!(!result.protocol.fragments.contains_key("index.html"));
1280 }
1281
1282 #[test]
1283 fn test_build_protocol_roundtrip() {
1284 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1285 let result = build(default_options(app.path())).unwrap();
1286
1287 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1288 assert!(restored.fragments.contains_key("index.html"));
1289 }
1290
1291 #[test]
1292 fn test_inspect_bytes_valid() {
1293 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1294 let result = build(default_options(app.path())).unwrap();
1295
1296 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1297 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1298 assert!(parsed.get("fragments").is_some());
1299 }
1300
1301 #[test]
1302 fn test_inspect_bytes_invalid() {
1303 let result = inspect_bytes(b"not a protobuf");
1304 assert!(result.is_err());
1305 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
1306 }
1307
1308 #[test]
1309 fn test_inspect_file() {
1310 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1311 let out = TempDir::new().unwrap();
1312 build_to_disk(default_options(app.path()), out.path()).unwrap();
1313
1314 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
1315 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1316 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
1317 }
1318
1319 #[test]
1320 fn test_inspect_missing_file() {
1321 let result = inspect(Path::new("/nonexistent/protocol.bin"));
1322 assert!(result.is_err());
1323 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
1324 }
1325
1326 #[test]
1327 fn test_build_with_components_local_path() {
1328 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
1329 let ext_dir = TempDir::new().unwrap();
1330 fs::write(
1331 ext_dir.path().join("ext-card.html"),
1332 "<div class=\"card\"><slot></slot></div>",
1333 )
1334 .unwrap();
1335 fs::write(
1336 ext_dir.path().join("ext-card.css"),
1337 ".card { border: 1px solid #ccc; }",
1338 )
1339 .unwrap();
1340
1341 let mut options = default_options(app.path());
1342 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1343
1344 let result = build(options).unwrap();
1345 assert!(result.protocol.fragments.contains_key("index.html"));
1346 assert_eq!(result.css_files.len(), 1);
1347 assert!(result.css_files[0].1.contains("border"));
1348 }
1349
1350 #[test]
1351 fn test_build_with_scriptless_component_path_emits_static_host() {
1352 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1353 let ext_dir = TempDir::new().unwrap();
1354 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1355
1356 let mut options = default_options(app.path());
1357 options.plugin = Some(Plugin::WebUI);
1358 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1359
1360 let result = build(options).unwrap();
1361 let template = &result.protocol.components["ext-card"].template_json;
1362 assert!(template.contains(r#""th":1"#), "template: {template}");
1363 }
1364
1365 #[test]
1366 fn test_build_with_scripted_component_path_skips_static_host() {
1367 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
1368 let ext_dir = TempDir::new().unwrap();
1369 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
1370 fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
1371
1372 let mut options = default_options(app.path());
1373 options.plugin = Some(Plugin::WebUI);
1374 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
1375
1376 let result = build(options).unwrap();
1377 let template = &result.protocol.components["ext-card"].template_json;
1378 assert!(!template.contains(r#""th":1"#), "template: {template}");
1379 }
1380
1381 #[test]
1382 fn test_build_hello_world_example() {
1383 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1384 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
1385
1386 let result = build(BuildOptions {
1387 app_dir,
1388 entry: "index.html".to_string(),
1389 ..BuildOptions::default()
1390 })
1391 .unwrap();
1392
1393 let index = &result.protocol.fragments["index.html"].fragments;
1394 assert!(index.iter().any(
1395 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
1396 ));
1397 assert!(index
1398 .iter()
1399 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1400 }
1401
1402 #[test]
1405 fn test_css_filename_sanitizes_path_separators() {
1406 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1409 let result = build(default_options(app.path())).unwrap();
1410
1411 for (filename, _) in &result.css_files {
1413 assert!(
1414 !filename.contains('/') && !filename.contains('\\'),
1415 "CSS filename contains path separator: {filename}"
1416 );
1417 }
1418 }
1419
1420 #[test]
1421 fn test_build_to_disk_css_stays_in_output_dir() {
1422 let app = create_app_dir(&[
1423 ("index.html", "<my-card>Hello</my-card>"),
1424 ("my-card.html", "<div><slot></slot></div>"),
1425 ("my-card.css", ".card { color: red; }"),
1426 ]);
1427 let out = TempDir::new().unwrap();
1428
1429 build_to_disk(default_options(app.path()), out.path()).unwrap();
1430
1431 let css_path = out.path().join("my-card.css");
1433 assert!(css_path.exists());
1434 let canonical = css_path.canonicalize().unwrap();
1435 let out_canonical = out.path().canonicalize().unwrap();
1436 assert!(
1437 canonical.starts_with(&out_canonical),
1438 "CSS file escaped output directory"
1439 );
1440 }
1441
1442 #[test]
1445 fn test_build_empty_html() {
1446 let app = create_app_dir(&[("index.html", "")]);
1447 let result = build(default_options(app.path())).unwrap();
1448
1449 assert!(result.protocol.fragments.contains_key("index.html"));
1450 assert_eq!(result.stats.fragment_count, 0);
1451 assert!(result.stats.protocol_size_bytes > 0);
1452 }
1453
1454 #[test]
1455 fn test_build_large_fragment_count() {
1456 let mut html = String::with_capacity(2000);
1458 for i in 0..50 {
1459 html.push_str(&format!("<p>Item {i}</p>\n"));
1460 }
1461 let app = create_app_dir(&[("index.html", &html)]);
1462 let result = build(default_options(app.path())).unwrap();
1463
1464 assert!(result.stats.fragment_count > 0);
1465 assert_eq!(result.stats.css_file_count, 0);
1466 }
1467
1468 #[test]
1469 fn test_build_with_fast_plugin() {
1470 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1471 let mut options = default_options(app.path());
1472 options.plugin = Some(Plugin::FastV3);
1473
1474 let result = build(options).unwrap();
1475 assert!(result.protocol.fragments.contains_key("index.html"));
1476 }
1477
1478 #[test]
1479 fn test_build_to_disk_inline_mode_no_css_files() {
1480 let app = create_app_dir(&[
1481 ("index.html", "<my-card>Hello</my-card>"),
1482 ("my-card.html", "<div><slot></slot></div>"),
1483 ("my-card.css", ".card { color: red; }"),
1484 ]);
1485 let out = TempDir::new().unwrap();
1486 let mut options = default_options(app.path());
1487 options.css = CssStrategy::Style;
1488
1489 let stats = build_to_disk(options, out.path()).unwrap();
1490
1491 assert!(out.path().join("protocol.bin").exists());
1492 assert!(!out.path().join("my-card.css").exists());
1493 assert_eq!(stats.css_file_count, 0);
1494 }
1495
1496 #[test]
1497 fn test_build_stats_duration_is_nonzero() {
1498 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
1499 let result = build(default_options(app.path())).unwrap();
1500
1501 assert!(!result.stats.duration.is_zero());
1502 }
1503
1504 #[test]
1505 fn test_build_multiple_components_css() {
1506 let app = create_app_dir(&[
1507 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
1508 ("card-a.html", "<div><slot></slot></div>"),
1509 ("card-a.css", ".a { color: red; }"),
1510 ("card-b.html", "<span><slot></slot></span>"),
1511 ("card-b.css", ".b { color: blue; }"),
1512 ]);
1513 let result = build(default_options(app.path())).unwrap();
1514
1515 assert_eq!(result.css_files.len(), 2);
1516 assert_eq!(result.stats.css_file_count, 2);
1517 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
1518 assert!(filenames.contains(&"card-a.css"));
1519 assert!(filenames.contains(&"card-b.css"));
1520 }
1521
1522 #[test]
1523 fn test_build_unused_component_css_not_emitted() {
1524 let app = create_app_dir(&[
1526 ("index.html", "<card-a>A</card-a>"),
1527 ("card-a.html", "<div><slot></slot></div>"),
1528 ("card-a.css", ".a { color: red; }"),
1529 ("card-b.html", "<span><slot></slot></span>"),
1530 ("card-b.css", ".b { color: blue; }"),
1531 ]);
1532 let result = build(default_options(app.path())).unwrap();
1533
1534 assert_eq!(result.css_files.len(), 1);
1535 assert_eq!(result.css_files[0].0, "card-a.css");
1536 }
1537
1538 #[test]
1539 fn test_inspect_roundtrip_preserves_content() {
1540 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
1541 let result = build(default_options(app.path())).unwrap();
1542
1543 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
1544 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1545
1546 let fragments = &parsed["fragments"]["index.html"]["fragments"];
1547 assert!(fragments.is_array());
1548 assert!(!fragments.as_array().unwrap().is_empty());
1549 }
1550
1551 #[test]
1552 fn test_build_nonexistent_app_dir() {
1553 let options = BuildOptions {
1554 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
1555 ..BuildOptions::default()
1556 };
1557 let result = build(options);
1558 assert!(result.is_err());
1559 }
1560
1561 #[test]
1562 fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
1563 let app = create_app_dir(&[
1564 ("index.html", "<my-card></my-card>"),
1565 ("my-card.html", "<div>Card</div>"),
1566 (
1567 "my-card.css",
1568 ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
1569 ),
1570 ]);
1571 let mut options = default_options(app.path());
1572 options.theme = Some(TokenFile {
1573 themes: HashMap::from([(
1574 "light".to_string(),
1575 HashMap::from([
1576 ("token-a".to_string(), "red".to_string()),
1577 ("token-b".to_string(), "blue".to_string()),
1578 ("token-c".to_string(), "green".to_string()),
1579 ]),
1580 )]),
1581 });
1582
1583 let result = build(options).expect("all fallback tokens are defined");
1584 assert!(result.protocol.tokens.contains(&"token-a".to_string()));
1585 assert!(result.protocol.tokens.contains(&"token-b".to_string()));
1586 assert!(result.protocol.tokens.contains(&"token-c".to_string()));
1587 }
1588
1589 #[test]
1590 fn test_build_allows_literal_fallback_token_absent_from_theme() {
1591 let app = create_app_dir(&[
1592 ("index.html", "<my-card></my-card>"),
1593 ("my-card.html", "<div>Card</div>"),
1594 ("my-card.css", ":host { color: var(--brand, #000); }"),
1595 ]);
1596 let mut options = default_options(app.path());
1597 options.theme = Some(TokenFile {
1598 themes: HashMap::from([("light".to_string(), HashMap::new())]),
1599 });
1600
1601 let result = build(options).expect("literal fallback should not fail the build");
1605 assert!(result.protocol.tokens.contains(&"brand".to_string()));
1606 assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
1609 assert!(result.warnings[0].body().contains("--brand"));
1610 }
1611
1612 #[test]
1613 fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
1614 let app = create_app_dir(&[
1619 (
1620 "index.html",
1621 "<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
1622 ),
1623 ("my-card.html", "<div>Card</div>"),
1624 ("my-card.css", ":host { padding: var(--foo-bar); }"),
1625 ]);
1626 let mut options = default_options(app.path());
1627 options.theme = Some(TokenFile {
1628 themes: HashMap::from([("light".to_string(), HashMap::new())]),
1629 });
1630
1631 let result = build(options)
1632 .expect("ancestor inline-style definition (after a comment) should satisfy the child");
1633 assert!(
1634 !result.protocol.tokens.contains(&"foo-bar".to_string()),
1635 "ancestor-defined token should not be hoisted: {:?}",
1636 result.protocol.tokens
1637 );
1638 }
1639
1640 #[test]
1641 fn test_build_no_warning_when_literal_fallback_token_is_themed() {
1642 let app = create_app_dir(&[
1643 ("index.html", "<my-card></my-card>"),
1644 ("my-card.html", "<div>Card</div>"),
1645 ("my-card.css", ":host { color: var(--brand, #000); }"),
1646 ]);
1647 let mut options = default_options(app.path());
1648 options.theme = Some(TokenFile {
1649 themes: HashMap::from([(
1650 "light".to_string(),
1651 HashMap::from([("brand".to_string(), "#123456".to_string())]),
1652 )]),
1653 });
1654
1655 let result = build(options).expect("themed literal-fallback token builds");
1656 assert!(
1657 result.warnings.is_empty(),
1658 "a themed token must not warn: {:?}",
1659 result.warnings
1660 );
1661 }
1662
1663 #[test]
1664 fn test_build_without_theme_produces_no_warnings() {
1665 let app = create_app_dir(&[
1666 ("index.html", "<my-card></my-card>"),
1667 ("my-card.html", "<div>Card</div>"),
1668 ("my-card.css", ":host { color: var(--brand, #000); }"),
1669 ]);
1670 let result = build(default_options(app.path())).unwrap();
1671 assert!(result.warnings.is_empty());
1672 }
1673
1674 #[test]
1675 fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
1676 let app = create_app_dir(&[
1677 ("index.html", "<component-a></component-a>"),
1678 ("component-a.html", "<component-b></component-b>"),
1679 ("component-a.css", ":host { --brand-color: red; }"),
1680 ("component-b.html", "<div>Child</div>"),
1681 ("component-b.css", ":host { color: var(--brand-color); }"),
1682 ]);
1683 let mut options = default_options(app.path());
1684 options.theme = Some(TokenFile {
1685 themes: HashMap::from([("light".to_string(), HashMap::new())]),
1686 });
1687
1688 let result =
1689 build(options).expect("ancestor component custom property should satisfy child");
1690 assert!(
1691 !result.protocol.tokens.contains(&"brand-color".to_string()),
1692 "ancestor-defined token should not be exposed: {:?}",
1693 result.protocol.tokens
1694 );
1695 }
1696
1697 #[test]
1698 fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
1699 let app = create_app_dir(&[
1700 (
1701 "index.html",
1702 "<component-a></component-a><component-b></component-b>",
1703 ),
1704 ("component-a.html", "<div>A</div>"),
1705 ("component-a.css", ":host { --brand-color: red; }"),
1706 ("component-b.html", "<div>B</div>"),
1707 ("component-b.css", ":host { color: var(--brand-color); }"),
1708 ]);
1709 let mut options = default_options(app.path());
1710 options.theme = Some(TokenFile {
1711 themes: HashMap::from([("light".to_string(), HashMap::new())]),
1712 });
1713
1714 let Err(err) = build(options) else {
1715 panic!("sibling custom property must not satisfy component-b");
1716 };
1717 let message = err.chain_message();
1718 assert!(message.contains("missing theme token"), "msg: {message}");
1719 assert!(message.contains("missing-theme-token"), "msg: {message}");
1720 assert!(message.contains("--brand-color"), "msg: {message}");
1721 }
1722
1723 #[test]
1724 fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
1725 let app = create_app_dir(&[
1726 ("index.html", "<my-card></my-card>"),
1727 ("my-card.html", "<div>Card</div>"),
1728 (
1729 "my-card.css",
1730 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
1731 ),
1732 ]);
1733 let mut options = default_options(app.path());
1734 options.theme = Some(TokenFile {
1735 themes: HashMap::from([(
1736 "light".to_string(),
1737 HashMap::from([("token-b".to_string(), "green".to_string())]),
1738 )]),
1739 });
1740
1741 let Err(err) = build(options) else {
1742 panic!("missing --token-c in the theme must fail the build");
1743 };
1744 let message = err.chain_message();
1745 assert!(message.contains("missing-theme-token"), "msg: {message}");
1746 assert!(message.contains("--token-c"), "msg: {message}");
1747 assert!(!message.contains("add --token-a"), "msg: {message}");
1750 }
1751
1752 #[test]
1753 fn test_build_to_disk_returns_accurate_stats() {
1754 let app = create_app_dir(&[
1755 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
1756 ("my-card.html", "<div><slot></slot></div>"),
1757 ("my-card.css", ".card { color: red; }"),
1758 ]);
1759 let out = TempDir::new().unwrap();
1760
1761 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
1762
1763 assert!(stats.fragment_count > 0);
1764 assert_eq!(stats.css_file_count, 1);
1765 assert!(stats.component_count > 0);
1766 assert!(stats.protocol_size_bytes > 0);
1767 assert!(!stats.duration.is_zero());
1768 }
1769}