1mod chunking;
24mod component_assets;
25mod error;
26mod module_preload;
27mod projection;
28pub mod server;
29pub mod streaming;
30mod style_bundle;
31
32pub use component_assets::{render_component_assets, ComponentAssetFile, ComponentAssetGraph};
33pub use error::WebUIError;
34
35pub use webui_handler::route_handler::{encode_inventory, get_needed_components, parse_inventory};
37pub use webui_handler::Result as HandlerResult;
38pub use webui_handler::{
39 plugin::HandlerPlugin, BoundaryDescriptor, BoundaryInstanceId, BoundaryKey, BoundaryMode,
40 FlushWriter, HandlerError, Protocol, RenderOptions, ResponseWriter, SessionOptions,
41 SpanInstanceId, StreamStatus, StreamStep, StreamingResponse, StreamingSession, StreamingState,
42 WebUIHandler,
43};
44pub use webui_parser::plugin::{ComponentTemplateArtifact, StateSurface};
45pub use webui_parser::CssStrategy;
46pub use webui_parser::DomStrategy;
47pub use webui_parser::LegalComments;
48pub use webui_parser::ParserError;
49pub use webui_parser::ParserOptions;
50pub use webui_parser::Plugin;
51pub use webui_parser::DEFAULT_CSS_FILE_NAME_TEMPLATE;
52pub use webui_parser::{
53 AssetFileNameTemplate, AssetFileNameTemplateError, DEFAULT_ASSET_FILE_NAME_TEMPLATE,
54};
55pub use webui_parser::{CssFallbackChain, CssTokenAnalysis};
56pub use webui_parser::{Diagnostic, Severity};
57pub use webui_protocol::WebUIProtocol;
58pub use webui_tokens::{load_token_file, resolve_theme_path, TokenFile};
59
60use std::collections::HashSet;
61use std::ffi::{OsStr, OsString};
62use std::fs;
63use std::path::Path;
64use std::time::Instant;
65use webui_discovery::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
66use webui_parser::plugin::fast_v2::FastV2ParserPlugin;
67use webui_parser::plugin::fast_v3::FastV3ParserPlugin;
68use webui_parser::plugin::webui::WebUIParserPlugin;
69use webui_parser::plugin::ParserPluginArtifacts;
70use webui_parser::HtmlParser;
71
72#[derive(Debug, Clone)]
74pub struct PreparedProjectionManifests {
75 snapshot: std::sync::Arc<projection::ProjectionSnapshot>,
76}
77
78#[derive(Debug)]
79enum ProjectionBarrierState {
80 Waiting,
81 Ready(PreparedProjectionManifests),
82 Failed(String),
83}
84
85#[doc(hidden)]
87#[derive(Debug, Clone)]
88pub struct PendingProjectionManifests {
89 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
90}
91
92#[doc(hidden)]
94pub struct ProjectionManifestCompleter {
95 state: std::sync::Arc<(std::sync::Mutex<ProjectionBarrierState>, std::sync::Condvar)>,
96 completed: bool,
97}
98
99#[derive(Debug, Clone)]
101pub enum ProjectionManifestSource {
102 Path(std::path::PathBuf),
104 Inline {
109 manifest_path: std::path::PathBuf,
111 json: String,
113 },
114 Prepared(PreparedProjectionManifests),
117 #[doc(hidden)]
119 Pending(PendingProjectionManifests),
120}
121
122impl From<std::path::PathBuf> for ProjectionManifestSource {
123 fn from(path: std::path::PathBuf) -> Self {
124 Self::Path(path)
125 }
126}
127
128pub fn prepare_projection_manifests(
133 sources: &[ProjectionManifestSource],
134) -> Result<PreparedProjectionManifests, WebUIError> {
135 let snapshot = projection::load_and_merge(sources)?
136 .unwrap_or_else(|| std::sync::Arc::new(projection::ProjectionSnapshot::default()));
137 Ok(PreparedProjectionManifests { snapshot })
138}
139
140#[doc(hidden)]
147#[must_use]
148pub fn resolve_generated_module_preloads(
149 projection: &PreparedProjectionManifests,
150 existing_hrefs: &[String],
151 entry_outputs: &[&Path],
152 output_urls: &std::collections::BTreeMap<std::path::PathBuf, String>,
153) -> (Vec<String>, Vec<Diagnostic>) {
154 let mut resolved = module_preload::resolve_exact(
155 &projection.snapshot.entry_closures,
156 existing_hrefs,
157 entry_outputs,
158 output_urls,
159 );
160 (
161 std::mem::take(&mut resolved.hrefs),
162 std::mem::take(&mut resolved.warnings),
163 )
164}
165
166#[doc(hidden)]
168#[must_use]
169pub fn generated_module_preload_outputs(
170 projection: &PreparedProjectionManifests,
171 entry_outputs: &[&Path],
172) -> Vec<std::path::PathBuf> {
173 module_preload::exact_output_identities(&projection.snapshot.entry_closures, entry_outputs)
174}
175
176#[doc(hidden)]
178#[must_use]
179pub fn projection_manifest_barrier() -> (ProjectionManifestSource, ProjectionManifestCompleter) {
180 let state = std::sync::Arc::new((
181 std::sync::Mutex::new(ProjectionBarrierState::Waiting),
182 std::sync::Condvar::new(),
183 ));
184 (
185 ProjectionManifestSource::Pending(PendingProjectionManifests {
186 state: std::sync::Arc::clone(&state),
187 }),
188 ProjectionManifestCompleter {
189 state,
190 completed: false,
191 },
192 )
193}
194
195impl PendingProjectionManifests {
196 fn wait(&self) -> Result<PreparedProjectionManifests, WebUIError> {
197 let (lock, ready) = self.state.as_ref();
198 let mut state = lock.lock().map_err(|_| {
199 WebUIError::InvalidBuildOptions("projection synchronization lock poisoned".to_string())
200 })?;
201 loop {
202 match &*state {
203 ProjectionBarrierState::Waiting => {
204 state = ready.wait(state).map_err(|_| {
205 WebUIError::InvalidBuildOptions(
206 "projection synchronization lock poisoned".to_string(),
207 )
208 })?;
209 }
210 ProjectionBarrierState::Ready(projection) => {
211 return Ok(projection.clone());
212 }
213 ProjectionBarrierState::Failed(message) => {
214 return Err(WebUIError::InvalidBuildOptions(message.clone()));
215 }
216 }
217 }
218 }
219}
220
221impl ProjectionManifestCompleter {
222 pub fn complete(mut self, result: std::result::Result<PreparedProjectionManifests, String>) {
224 let (lock, ready) = self.state.as_ref();
225 if let Ok(mut state) = lock.lock() {
226 *state = match result {
227 Ok(projection) => ProjectionBarrierState::Ready(projection),
228 Err(message) => ProjectionBarrierState::Failed(message),
229 };
230 self.completed = true;
231 ready.notify_all();
232 }
233 }
234}
235
236impl Drop for ProjectionManifestCompleter {
237 fn drop(&mut self) {
238 if self.completed {
239 return;
240 }
241 let (lock, ready) = self.state.as_ref();
242 if let Ok(mut state) = lock.lock() {
243 *state = ProjectionBarrierState::Failed(
244 "projection producer terminated before completing metadata".to_string(),
245 );
246 ready.notify_all();
247 }
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct BuildOptions {
254 pub app_dir: std::path::PathBuf,
256 pub entry: String,
258 pub css: CssStrategy,
260 pub dom: DomStrategy,
262 pub css_bundle: bool,
269 pub plugin: Option<Plugin>,
271 pub components: Vec<String>,
273 pub component_asset_roots: Vec<String>,
279 pub metafile: bool,
281 pub css_file_name_template: String,
285 pub css_public_base: Option<String>,
288 pub legal_comments: LegalComments,
290 pub theme: Option<TokenFile>,
298 pub projection_manifests: Vec<ProjectionManifestSource>,
319}
320
321impl Default for BuildOptions {
322 fn default() -> Self {
323 Self {
324 app_dir: std::path::PathBuf::from("."),
325 entry: "index.html".to_string(),
326 css: CssStrategy::Link,
327 dom: DomStrategy::Shadow,
328 css_bundle: false,
329 plugin: None,
330 components: Vec::new(),
331 component_asset_roots: Vec::new(),
332 metafile: false,
333 css_file_name_template: DEFAULT_CSS_FILE_NAME_TEMPLATE.to_string(),
334 css_public_base: None,
335 legal_comments: LegalComments::default(),
336 theme: None,
337 projection_manifests: Vec::new(),
338 }
339 }
340}
341
342#[derive(Debug, Clone)]
344pub struct BuildStats {
345 pub duration: std::time::Duration,
347 pub fragment_count: usize,
349 pub component_count: usize,
351 pub css_file_count: usize,
353 pub protocol_size_bytes: usize,
355 pub token_count: usize,
357}
358
359#[derive(Debug)]
361pub struct BuildResult {
362 pub protocol: WebUIProtocol,
364 pub protocol_bytes: Vec<u8>,
366 pub css_files: Vec<(String, String)>,
368 pub component_asset_files: Vec<ComponentAssetFile>,
372 pub metafile: Option<String>,
374 pub component_templates: Vec<ComponentTemplateArtifact>,
378 pub warnings: Vec<Diagnostic>,
385 pub stats: BuildStats,
387}
388
389#[cold]
394#[inline(never)]
395fn streaming_without_projection_warning(entry: &str, boundary_count: usize) -> Diagnostic {
396 let plural = if boundary_count == 1 { "" } else { "s" };
397 Diagnostic::warning(format!(
398 "{entry} declares {boundary_count} streaming boundary checkpoint{plural} \
399 but this build has no state-projection manifest"
400 ))
401 .code(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
402 .help(
403 "every checkpoint will serialize the whole state object instead of its own \
404 components' keys; pass the client build's `webui-projection.json` through \
405 `BuildOptions::projection_manifests` to send boundary-local state only",
406 )
407}
408
409#[must_use = "BuildResult contains the compiled protocol and statistics"]
419pub fn build(options: BuildOptions) -> Result<BuildResult, WebUIError> {
420 let started = Instant::now();
421
422 let raw = build_protocol_inner(&options)?;
423
424 let protocol_bytes = raw.protocol.to_protobuf()?;
425
426 let stats = BuildStats {
427 duration: started.elapsed(),
428 fragment_count: raw.fragment_count,
429 component_count: raw.component_count,
430 css_file_count: raw.css_files.len(),
431 protocol_size_bytes: protocol_bytes.len(),
432 token_count: raw.token_count,
433 };
434
435 Ok(BuildResult {
436 protocol: raw.protocol,
437 protocol_bytes,
438 css_files: raw.css_files,
439 component_asset_files: raw.component_asset_files,
440 metafile: raw.metafile,
441 component_templates: raw.component_templates,
442 warnings: raw.warnings,
443 stats,
444 })
445}
446
447pub fn build_to_disk(options: BuildOptions, out_dir: &Path) -> Result<BuildStats, WebUIError> {
457 let result = build(options)?;
458 validate_output_file_names(OsStr::new("protocol.bin"), &result)?;
459
460 fs::create_dir_all(out_dir).map_err(|source| WebUIError::Io {
461 context: format!("Failed to create {}", out_dir.display()),
462 source,
463 })?;
464
465 fs::write(out_dir.join("protocol.bin"), &result.protocol_bytes).map_err(|source| {
466 WebUIError::Io {
467 context: format!("Failed to write protocol.bin to {}", out_dir.display()),
468 source,
469 }
470 })?;
471
472 for (name, content) in &result.css_files {
473 fs::write(out_dir.join(name), content).map_err(|source| WebUIError::Io {
474 context: format!("Failed to write {name} to {}", out_dir.display()),
475 source,
476 })?;
477 }
478 for file in &result.component_asset_files {
479 fs::write(out_dir.join(&file.name), &file.content).map_err(|source| WebUIError::Io {
480 context: format!(
481 "Failed to write component asset {} to {}",
482 file.name,
483 out_dir.display()
484 ),
485 source,
486 })?;
487 }
488
489 Ok(result.stats)
490}
491
492pub fn inspect(protocol_path: &Path) -> Result<String, WebUIError> {
494 let bytes = fs::read(protocol_path).map_err(|source| WebUIError::Io {
495 context: format!("Failed to read {}", protocol_path.display()),
496 source,
497 })?;
498 inspect_bytes(&bytes)
499}
500
501pub fn inspect_bytes(protocol_bytes: &[u8]) -> Result<String, WebUIError> {
503 let protocol = WebUIProtocol::from_protobuf(protocol_bytes)?;
504 protocol
505 .to_json_pretty()
506 .map_err(|e| WebUIError::Serialization(e.to_string()))
507}
508
509struct RawBuildOutput {
511 protocol: WebUIProtocol,
512 css_files: Vec<(String, String)>,
513 component_asset_files: Vec<ComponentAssetFile>,
514 metafile: Option<String>,
515 component_templates: Vec<ComponentTemplateArtifact>,
516 warnings: Vec<Diagnostic>,
517 fragment_count: usize,
518 component_count: usize,
519 token_count: usize,
520}
521
522fn build_protocol_inner(options: &BuildOptions) -> Result<RawBuildOutput, WebUIError> {
524 let projection_enabled = !options.projection_manifests.is_empty();
525 if projection_enabled && options.plugin != Some(Plugin::WebUI) {
526 return Err(projection::incompatible_plugin_error());
527 }
528 if options.css_bundle && options.css == CssStrategy::Module {
529 return Err(css_bundle_module_error());
530 }
531 let parser_options = ParserOptions::try_new(
532 options.css,
533 options.dom,
534 &options.css_file_name_template,
535 options.css_public_base.as_deref(),
536 options.legal_comments,
537 )
538 .map_err(|e| WebUIError::InvalidBuildOptions(e.to_string()))?;
539 let css_link_options = parser_options.css_link_options.clone();
540
541 let mut parser = match options.plugin {
542 Some(Plugin::Fast | Plugin::FastV2) => {
543 HtmlParser::with_plugin_options(Box::new(FastV2ParserPlugin::new()), parser_options)
544 }
545 Some(Plugin::FastV3) => {
546 HtmlParser::with_plugin_options(Box::new(FastV3ParserPlugin::new()), parser_options)
547 }
548 Some(Plugin::WebUI) => {
549 HtmlParser::with_plugin_options(Box::new(WebUIParserPlugin::new()), parser_options)
550 }
551 None => HtmlParser::with_options(parser_options),
552 };
553 let discovery_plugin: Box<dyn DiscoveryPlugin> = match options.plugin {
554 Some(Plugin::Fast | Plugin::FastV2 | Plugin::FastV3) => {
555 Box::new(FastDiscoveryPlugin::new())
556 }
557 Some(Plugin::WebUI) | None => Box::new(WebUIDiscoveryPlugin::new()),
558 };
559
560 let app_components = discovery_plugin
561 .discover_local(&options.app_dir)
562 .map_err(|error| {
563 WebUIError::ComponentDiscovery(format!(
564 "Failed to discover components from {}: {error}",
565 options.app_dir.display()
566 ))
567 })?;
568 for component in &app_components {
569 parser
570 .component_registry_mut()
571 .register_component(webui_parser::ComponentRegistration {
572 tag_name: &component.tag_name,
573 html_content: &component.html_content,
574 css_content: component.css_content.as_deref(),
575 is_client_owned: component.is_client_owned,
576 })
577 .map_err(|source| WebUIError::ComponentRegistration {
578 context: format!(
579 "Failed to register component '{}' from {}",
580 component.tag_name, component.source
581 ),
582 source,
583 })?;
584 }
585
586 for source in &options.components {
588 let result = webui_discovery::discover_source_with_plugin(
589 source,
590 &options.app_dir,
591 discovery_plugin.as_ref(),
592 )
593 .map_err(|e| {
594 WebUIError::ComponentDiscovery(format!(
595 "Failed to discover components from {source}: {e}"
596 ))
597 })?;
598 for comp in &result.components {
599 parser
602 .component_registry_mut()
603 .register_component(webui_parser::ComponentRegistration {
604 tag_name: &comp.tag_name,
605 html_content: &comp.html_content,
606 css_content: comp.css_content.as_deref(),
607 is_client_owned: comp.is_client_owned,
608 })
609 .map_err(|source| WebUIError::ComponentRegistration {
610 context: format!(
611 "Failed to register component '{}' from {}",
612 comp.tag_name, comp.source
613 ),
614 source,
615 })?;
616 }
617 }
618
619 let component_count = parser.component_registry().len();
620
621 let entry_path = options.app_dir.join(&options.entry);
623 let html_content = fs::read_to_string(&entry_path).map_err(|source| WebUIError::Io {
624 context: format!("Failed to read {}", entry_path.display()),
625 source,
626 })?;
627 parser
628 .parse(&options.entry, &html_content)
629 .map_err(|source| WebUIError::Parse {
630 context: format!("Failed to parse {}", options.entry),
631 source,
632 })?;
633
634 let module_entry_srcs = parser.module_entry_srcs().to_vec();
637
638 let synthetic_asset_fragments =
639 parse_component_asset_roots(&mut parser, &options.component_asset_roots)?;
640
641 let component_render_css = parser.component_registry().render_policy_css(
642 parser
643 .component_registry()
644 .get_all()
645 .filter(|component| parser.has_fragment(&component.tag_name))
646 .map(|component| component.tag_name.as_str()),
647 );
648 let css_snapshot: Vec<(String, String)> = parser
649 .component_registry()
650 .get_all()
651 .filter(|component| parser.has_fragment(&component.tag_name))
652 .filter_map(|component| {
653 component
654 .css_content
655 .as_ref()
656 .map(|css| (component.tag_name.clone(), css.clone()))
657 })
658 .collect();
659 let component_shadow_dom_usage: Vec<(String, bool)> = parser
660 .component_shadow_dom_usage()
661 .map(|(tag_name, uses_shadow_dom)| (tag_name.to_string(), uses_shadow_dom))
662 .collect();
663 let component_work_policies: Vec<(String, u8)> = parser
664 .component_work_policies()
665 .map(|(tag_name, policy)| (tag_name.to_string(), policy))
666 .collect();
667
668 let token_analysis = parser.token_analysis();
670 let mut warnings: Vec<Diagnostic> = Vec::new();
671 if let Some(theme) = options.theme.as_ref() {
672 token_analysis
673 .validate_theme_tokens(theme)
674 .map_err(|source| WebUIError::Parse {
675 context: "Failed to validate theme tokens".to_string(),
676 source,
677 })?;
678 warnings = token_analysis.theme_token_warnings(theme);
683 }
684 let token_count = token_analysis.protocol_tokens.len();
685
686 let component_templates =
687 match parser
688 .take_plugin_artifacts()
689 .map_err(|source| WebUIError::Parse {
690 context: format!(
691 "Failed to compile component templates for {}",
692 options.entry
693 ),
694 source,
695 })? {
696 ParserPluginArtifacts::None => Vec::new(),
697 ParserPluginArtifacts::ComponentTemplates(templates) => templates,
698 };
699 let mut fragment_records = parser.into_fragment_records();
701 for fragment_id in synthetic_asset_fragments {
702 fragment_records.remove(&fragment_id);
703 }
704 let boundary_count = fragment_records
705 .values()
706 .flat_map(|list| list.fragments.iter())
707 .filter(|fragment| {
708 matches!(
709 fragment.fragment.as_ref(),
710 Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
711 if boundary.phase() == webui_protocol::BoundaryPhase::Start
712 )
713 })
714 .count();
715 let merged_manifest = projection::load_and_merge(&options.projection_manifests)?;
719 let mut protocol = WebUIProtocol::with_tokens(fragment_records, token_analysis.protocol_tokens);
720 protocol.initial_state_strategy = if merged_manifest.is_some() {
721 webui_protocol::InitialStateStrategy::Components as i32
722 } else {
723 webui_protocol::InitialStateStrategy::Full as i32
724 };
725
726 if boundary_count > 0 && merged_manifest.is_none() {
731 warnings.push(streaming_without_projection_warning(
732 &options.entry,
733 boundary_count,
734 ));
735 }
736
737 if let Some(merged) = &merged_manifest {
742 let mut preloads = module_preload::resolve(&merged.entry_closures, &module_entry_srcs);
743 protocol.module_preloads = std::mem::take(&mut preloads.hrefs);
744 warnings.append(&mut preloads.warnings);
745 }
746
747 protocol.set_css_strategy(match options.css {
749 CssStrategy::Link => webui_protocol::CssStrategy::Link,
750 CssStrategy::Style => webui_protocol::CssStrategy::Style,
751 CssStrategy::Module => webui_protocol::CssStrategy::Module,
752 });
753 for (tag_name, uses_shadow_dom) in component_shadow_dom_usage {
754 if !protocol.fragments.contains_key(&tag_name) {
755 continue;
756 }
757 protocol
758 .components
759 .entry(tag_name)
760 .or_default()
761 .uses_shadow_dom = uses_shadow_dom;
762 }
763 for (tag_name, work_policy) in component_work_policies {
764 if protocol.fragments.contains_key(&tag_name) {
765 protocol.components.entry(tag_name).or_default().work_policy = i32::from(work_policy);
766 }
767 }
768 protocol.component_render_css = component_render_css;
769
770 let stores_css = options.css != CssStrategy::Link;
773 let mut css_files: Vec<(String, String)> = Vec::new();
774 let mut emitted_names: HashSet<String> = HashSet::new();
775 let mut bundle_inputs: Vec<(String, String)> = Vec::new();
776 for (tag, css) in css_snapshot {
777 if !protocol.fragments.contains_key(&tag) {
778 continue;
779 }
780 if options.css_bundle {
781 bundle_inputs.push((tag.clone(), css.clone()));
782 }
783 if stores_css {
784 protocol.components.entry(tag).or_default().css = css;
785 } else {
786 let resolved = css_link_options.resolve(&tag, &css);
787 if !emitted_names.insert(resolved.filename.clone()) {
788 return Err(WebUIError::InvalidBuildOptions(format!(
789 "CSS filename collision for Link strategy: '{}'. Adjust the asset filename template to include [name] or another unique component-specific segment.",
790 resolved.filename
791 )));
792 }
793 protocol.components.entry(tag).or_default().css_href = resolved.href;
794 css_files.push((resolved.filename, css));
795 }
796 }
797
798 protocol.populate_style_closures(&[options.entry.as_str()]);
801
802 if options.css_bundle {
803 let by_tag = bundle_inputs
806 .iter()
807 .map(|(tag, css)| (tag.as_str(), css.as_str()))
808 .collect();
809 let chunk_files = style_bundle::bundle_style_chunks(
810 &mut protocol,
811 options.entry.as_str(),
812 &by_tag,
813 &css_link_options,
814 )?;
815 if !stores_css {
816 for (filename, css) in chunk_files {
820 if emitted_names.insert(filename.clone()) {
821 css_files.push((filename, css));
822 continue;
823 }
824 let identical = css_files
827 .iter()
828 .any(|(name, existing)| *name == filename && *existing == css);
829 if !identical {
830 return Err(WebUIError::InvalidBuildOptions(format!(
831 "CSS filename collision between bundled chunk '{filename}' and a component stylesheet of the same name. Adjust the asset filename template to include [hash] or another unique segment."
832 )));
833 }
834 }
835 }
836 }
837
838 for artifact in &component_templates {
842 let component = protocol
843 .components
844 .entry(artifact.tag_name.clone())
845 .or_default();
846 component.template = artifact.template.clone();
847 component.template_json = artifact.template_json.clone();
848 component.template_functions = artifact.template_functions.clone();
849 let manifest_entry = if artifact.is_scripted {
854 merged_manifest
855 .as_ref()
856 .and_then(|merged| merged.components.get(&artifact.tag_name))
857 } else {
858 None
859 };
860 let (hydration_mode, hydration_keys) = match manifest_entry {
861 Some(entry) => encode_state_surface(&StateSurface::Keys(entry.hydration_keys.clone())),
862 None => encode_state_surface(&artifact.hydration),
863 };
864 component.hydration_mode = hydration_mode;
865 component.hydration_keys = hydration_keys;
866 let (navigation_mode, navigation_keys) = match manifest_entry {
867 Some(entry) => {
868 let union =
869 projection::union_keys(&entry.navigation_keys, &artifact.template_roots);
870 encode_state_surface(&StateSurface::Keys(union))
871 }
872 None => encode_state_surface(&artifact.navigation),
873 };
874 component.navigation_mode = Some(navigation_mode);
875 component.navigation_keys = navigation_keys;
876 }
877
878 let mut component_asset_graph = component_assets::render_component_assets(
879 &protocol,
880 &options.entry,
881 &options.component_asset_roots,
882 &options.css_file_name_template,
883 options.metafile,
884 )?;
885 component_asset_graph.retain_entry_protocol(&mut protocol)?;
886 if let Some(merged) = &merged_manifest {
890 let entry_scripted_tags: Vec<&str> = component_templates
891 .iter()
892 .filter(|artifact| {
893 artifact.is_scripted && protocol.fragments.contains_key(&artifact.tag_name)
894 })
895 .map(|artifact| artifact.tag_name.as_str())
896 .collect();
897 projection::validate_coverage(&merged.components, &entry_scripted_tags)?;
898 }
899 let fragment_count = protocol
900 .fragments
901 .values()
902 .map(|fragments| fragments.fragments.len())
903 .sum();
904 let component_asset_files = component_asset_graph.files;
905 validate_generated_file_names(&css_files, &component_asset_files)?;
906
907 Ok(RawBuildOutput {
908 protocol,
909 css_files,
910 component_asset_files,
911 metafile: component_asset_graph.metafile,
912 component_templates,
913 warnings,
914 fragment_count,
915 component_count,
916 token_count,
917 })
918}
919
920fn encode_state_surface(surface: &StateSurface) -> (i32, Vec<String>) {
921 match surface {
922 StateSurface::None => (webui_protocol::StateProjectionMode::None as i32, Vec::new()),
923 StateSurface::Keys(keys) => (
924 webui_protocol::StateProjectionMode::Keys as i32,
925 keys.clone(),
926 ),
927 StateSurface::All => (webui_protocol::StateProjectionMode::All as i32, Vec::new()),
928 }
929}
930
931fn parse_component_asset_roots(
932 parser: &mut HtmlParser,
933 roots: &[String],
934) -> Result<Vec<String>, WebUIError> {
935 let mut synthetic_fragments = Vec::with_capacity(roots.len());
936 for (index, root) in roots.iter().enumerate() {
937 let root = root.trim();
938 if parser.has_fragment(root) {
939 continue;
940 }
941 let mut synthetic = String::with_capacity(root.len() * 2 + 5);
944 synthetic.push('<');
945 synthetic.push_str(root);
946 synthetic.push_str("></");
947 synthetic.push_str(root);
948 synthetic.push('>');
949
950 let fragment_id = format!("__webui_asset_root_{index}");
951 parser
952 .parse(&fragment_id, &synthetic)
953 .map_err(|source| WebUIError::Parse {
954 context: format!("Failed to parse component asset root <{root}>"),
955 source,
956 })?;
957 synthetic_fragments.push(fragment_id);
958 }
959 Ok(synthetic_fragments)
960}
961
962fn validate_generated_file_names(
963 css_files: &[(String, String)],
964 component_asset_files: &[ComponentAssetFile],
965) -> Result<(), WebUIError> {
966 let mut names = HashSet::with_capacity(css_files.len() + component_asset_files.len());
967 for (name, _) in css_files {
968 names.insert(name.as_str());
969 }
970 for file in component_asset_files {
971 if !names.insert(file.name.as_str()) {
972 return Err(WebUIError::InvalidBuildOptions(format!(
973 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
974 file.name
975 )));
976 }
977 }
978 Ok(())
979}
980
981fn validate_output_file_names(
982 protocol_name: &OsStr,
983 result: &BuildResult,
984) -> Result<(), WebUIError> {
985 let mut names =
986 HashSet::with_capacity(1 + result.css_files.len() + result.component_asset_files.len());
987 names.insert(protocol_name.to_os_string());
988 for (name, _) in &result.css_files {
989 let file_name = OsString::from(name);
990 if !names.insert(file_name.clone()) {
991 return Err(output_file_collision_error(&file_name));
992 }
993 }
994 for file in &result.component_asset_files {
995 let file_name = OsString::from(&file.name);
996 if !names.insert(file_name.clone()) {
997 return Err(output_file_collision_error(&file_name));
998 }
999 }
1000 Ok(())
1001}
1002
1003fn output_file_collision_error(name: &OsStr) -> WebUIError {
1004 WebUIError::InvalidBuildOptions(format!(
1005 "output filename collision for '{}'. Adjust the asset filename template to include [ext] or another unique asset-type segment.",
1006 name.to_string_lossy()
1007 ))
1008}
1009
1010#[cold]
1011#[inline(never)]
1012fn css_bundle_module_error() -> WebUIError {
1013 WebUIError::InvalidBuildOptions(
1014 "CSS bundling is not supported with the Module strategy. Module builds already inline every stylesheet as a data URI, so there are no requests to merge, and their import-map specifiers are resolved per component before chunks exist. Use --css link (or --css style) with --css-bundle, or drop --css-bundle.".to_string(),
1015 )
1016}
1017
1018#[cfg(test)]
1019#[allow(clippy::disallowed_methods)]
1020mod tests {
1021 use super::*;
1022 use std::collections::HashMap;
1023 use std::path::PathBuf;
1024 use tempfile::TempDir;
1025 use webui_handler::plugin::webui::WebUIHydrationPlugin;
1026 use webui_protocol::web_ui_fragment::Fragment;
1027
1028 struct StringWriter {
1029 buf: String,
1030 }
1031
1032 impl ResponseWriter for StringWriter {
1033 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
1034 self.buf.push_str(content);
1035 Ok(())
1036 }
1037
1038 fn end(&mut self) -> webui_handler::Result<()> {
1039 Ok(())
1040 }
1041 }
1042
1043 fn create_app_dir(files: &[(&str, &str)]) -> TempDir {
1044 let dir = TempDir::new().unwrap();
1045 for (name, content) in files {
1046 let path = dir.path().join(name);
1047 if let Some(parent) = path.parent() {
1048 fs::create_dir_all(parent).unwrap();
1049 }
1050 fs::write(&path, content).unwrap();
1051 }
1052 dir
1053 }
1054
1055 fn default_options(app_dir: &Path) -> BuildOptions {
1056 BuildOptions {
1057 app_dir: app_dir.to_path_buf(),
1058 ..BuildOptions::default()
1059 }
1060 }
1061
1062 fn light_options(app_dir: &Path) -> BuildOptions {
1063 BuildOptions {
1064 dom: DomStrategy::Light,
1065 ..default_options(app_dir)
1066 }
1067 }
1068
1069 mod fast;
1070
1071 #[test]
1072 fn test_build_simple_html() {
1073 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1074 let result = build(default_options(app.path())).unwrap();
1075
1076 assert!(result.protocol.fragments.contains_key("index.html"));
1077 assert!(result.stats.fragment_count > 0);
1078 assert!(result.stats.protocol_size_bytes > 0);
1079 assert!(!result.stats.duration.is_zero());
1080 assert_eq!(
1081 result.protocol.initial_state_strategy,
1082 webui_protocol::InitialStateStrategy::Full as i32
1083 );
1084 }
1085
1086 #[test]
1087 fn light_build_uses_light_unless_component_authors_shadow() {
1088 let app = create_app_dir(&[
1089 (
1090 "index.html",
1091 "<my-card></my-card><shadow-card></shadow-card>",
1092 ),
1093 ("my-card.html", "<p>content</p>"),
1094 (
1095 "shadow-card.html",
1096 r#"<template shadowrootmode="open"><p>shadow</p></template>"#,
1097 ),
1098 ]);
1099
1100 let result = build(BuildOptions {
1101 app_dir: app.path().to_path_buf(),
1102 dom: DomStrategy::Light,
1103 ..BuildOptions::default()
1104 })
1105 .unwrap();
1106 assert!(!result.protocol.component_uses_shadow_dom("my-card"));
1107 assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
1108 let light_html: String = result.protocol.fragments["my-card"]
1109 .fragments
1110 .iter()
1111 .filter_map(|fragment| match fragment.fragment.as_ref() {
1112 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1113 _ => None,
1114 })
1115 .collect();
1116 assert!(!light_html.contains("shadowrootmode"));
1117
1118 let shadow_html: String = result.protocol.fragments["shadow-card"]
1119 .fragments
1120 .iter()
1121 .filter_map(|fragment| match fragment.fragment.as_ref() {
1122 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1123 _ => None,
1124 })
1125 .collect();
1126 assert!(shadow_html.contains(r#"<template shadowrootmode="open">"#));
1127 }
1128
1129 #[test]
1130 fn build_defaults_unwrapped_components_to_shadow() {
1131 let app = create_app_dir(&[
1132 ("index.html", "<my-card></my-card>"),
1133 ("my-card.html", "<p>content</p>"),
1134 ]);
1135
1136 let result = build(default_options(app.path())).unwrap();
1137 assert!(result.protocol.component_uses_shadow_dom("my-card"));
1138 let html: String = result.protocol.fragments["my-card"]
1139 .fragments
1140 .iter()
1141 .filter_map(|fragment| match fragment.fragment.as_ref() {
1142 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
1143 _ => None,
1144 })
1145 .collect();
1146 assert!(html.contains(r#"<template shadowrootmode="open">"#));
1147 }
1148
1149 #[test]
1150 fn build_rejects_slot_in_unwrapped_light_component() {
1151 let app = create_app_dir(&[
1152 ("index.html", "<my-card></my-card>"),
1153 ("my-card.html", "<div><slot></slot></div>"),
1154 ]);
1155
1156 let error = build(BuildOptions {
1157 app_dir: app.path().to_path_buf(),
1158 dom: DomStrategy::Light,
1159 ..BuildOptions::default()
1160 })
1161 .expect_err("unwrapped Light DOM slot must fail the build");
1162
1163 assert!(
1164 error.chain_message().contains("[light-dom-slot]"),
1165 "unexpected build error: {}",
1166 error.chain_message()
1167 );
1168 }
1169
1170 #[test]
1171 fn pending_projection_unblocks_after_template_compilation() {
1172 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
1173 let (source, completer) = projection_manifest_barrier();
1174 let options = BuildOptions {
1175 app_dir: app.path().to_path_buf(),
1176 plugin: Some(Plugin::WebUI),
1177 projection_manifests: vec![source],
1178 ..BuildOptions::default()
1179 };
1180 let handle = std::thread::spawn(move || build(options));
1181 let prepared = prepare_projection_manifests(&[]).unwrap();
1182 completer.complete(Ok(prepared));
1183 let result = handle.join().unwrap().unwrap();
1184 assert_eq!(
1185 result.protocol.initial_state_strategy,
1186 webui_protocol::InitialStateStrategy::Components as i32
1187 );
1188 }
1189
1190 #[test]
1193 fn streaming_entry_without_projection_manifest_warns() {
1194 let app = create_app_dir(&[(
1195 "index.html",
1196 concat!(
1197 "<html><body>",
1198 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1199 r#"<boundary name="b"><p>{{two}}</p></boundary>"#,
1200 "</body></html>",
1201 ),
1202 )]);
1203 let mut options = default_options(app.path());
1204 options.plugin = Some(Plugin::WebUI);
1205
1206 let result = build(options).unwrap();
1207
1208 assert_eq!(
1209 result.protocol.initial_state_strategy,
1210 webui_protocol::InitialStateStrategy::Full as i32
1211 );
1212 let boundary_names = |protocol: &WebUIProtocol| {
1213 protocol.fragments["index.html"]
1214 .fragments
1215 .iter()
1216 .filter_map(|fragment| match fragment.fragment.as_ref() {
1217 Some(webui_protocol::web_ui_fragment::Fragment::Boundary(boundary))
1218 if boundary.phase() == webui_protocol::BoundaryPhase::Start =>
1219 {
1220 Some(boundary.name.clone())
1221 }
1222 _ => None,
1223 })
1224 .collect::<Vec<_>>()
1225 };
1226 assert_eq!(boundary_names(&result.protocol), ["a", "b"]);
1227 let decoded = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
1228 assert_eq!(boundary_names(&decoded), ["a", "b"]);
1229 let warning = result
1230 .warnings
1231 .iter()
1232 .find(|diag| {
1233 diag.error_code() == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)
1234 })
1235 .expect("a streaming build without projection must warn");
1236 assert_eq!(warning.severity(), webui_parser::Severity::Warning);
1237 assert!(
1238 warning.to_string().contains("2 streaming boundary"),
1239 "warning should report the boundary count: {warning}"
1240 );
1241 assert!(
1242 warning
1243 .help_text()
1244 .is_some_and(|help| help.contains("projection_manifests")),
1245 "warning should point at the fix: {warning}"
1246 );
1247 }
1248
1249 #[test]
1250 fn streaming_entry_with_projection_manifest_does_not_warn() {
1251 let app = create_app_dir(&[(
1252 "index.html",
1253 concat!(
1254 "<html><body>",
1255 r#"<boundary name="a"><p>{{one}}</p></boundary>"#,
1256 "</body></html>",
1257 ),
1258 )]);
1259 let manifest_json =
1260 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1261 let manifest = projection::test_support::write_manifest(
1262 app.path(),
1263 "webui-projection.json",
1264 &manifest_json,
1265 );
1266 let mut options = default_options(app.path());
1267 options.plugin = Some(Plugin::WebUI);
1268 options.projection_manifests = vec![manifest.into()];
1269
1270 let result = build(options).unwrap();
1271
1272 assert_eq!(
1273 result.protocol.initial_state_strategy,
1274 webui_protocol::InitialStateStrategy::Components as i32
1275 );
1276 assert!(
1277 !result
1278 .warnings
1279 .iter()
1280 .any(|diag| diag.error_code()
1281 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1282 "a projected streaming build must not warn"
1283 );
1284 }
1285
1286 #[test]
1289 fn non_streaming_entry_without_projection_manifest_does_not_warn() {
1290 let app = create_app_dir(&[("index.html", "<html><body><p>{{one}}</p></body></html>")]);
1291 let mut options = default_options(app.path());
1292 options.plugin = Some(Plugin::WebUI);
1293
1294 let result = build(options).unwrap();
1295
1296 assert!(
1297 !result
1298 .warnings
1299 .iter()
1300 .any(|diag| diag.error_code()
1301 == Some(webui_parser::codes::STREAMING_WITHOUT_PROJECTION)),
1302 "a non-streaming build must not warn about projection"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_build_keeps_scriptless_component_dormant_until_client_use() {
1308 let app = create_app_dir(&[
1309 (
1310 "index.html",
1311 "<html><body><demo-card></demo-card></body></html>",
1312 ),
1313 ("demo-card.html", "<p>{{name}} {{serverTitle}}</p>"),
1314 ]);
1315 let manifest_json =
1320 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
1321 let manifest = projection::test_support::write_manifest(
1322 app.path(),
1323 "webui-projection.json",
1324 &manifest_json,
1325 );
1326 let mut options = default_options(app.path());
1327 options.plugin = Some(Plugin::WebUI);
1328 options.projection_manifests = vec![manifest.into()];
1329 let result = build(options).unwrap();
1330 assert_eq!(
1331 result.protocol.initial_state_strategy,
1332 webui_protocol::InitialStateStrategy::Components as i32
1333 );
1334
1335 assert!(result
1336 .component_templates
1337 .iter()
1338 .any(|template| template.tag_name == "demo-card"));
1339 let component = result
1340 .protocol
1341 .components
1342 .get("demo-card")
1343 .expect("scriptless component metadata should be retained");
1344 assert!(component.hydration_keys.is_empty());
1345 assert_eq!(component.navigation_keys, ["name", "serverTitle"]);
1346 assert!(component.template_json.contains(r#""th":1"#));
1347
1348 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1349 let state = serde_json::json!({ "name": "Server rendered" });
1350 let protocol = Protocol::new(result.protocol.clone());
1351 let mut writer = StringWriter { buf: String::new() };
1352 handler
1353 .render(
1354 &protocol,
1355 &state,
1356 &RenderOptions::new("index.html", "/"),
1357 &mut writer,
1358 )
1359 .unwrap();
1360 assert!(writer.buf.contains("Server rendered"));
1361 assert!(writer.buf.contains(r#""state":{}"#));
1362 assert!(writer.buf.contains(r#""templates":{"demo-card":"#));
1363 }
1364
1365 #[test]
1366 fn test_build_treats_sibling_component_script_as_scripted() {
1367 let app = create_app_dir(&[
1368 ("index.html", "<demo-card></demo-card>"),
1369 (
1370 "demo-card.html",
1371 r#"<button @click="{onClick()}">{{name}}</button>"#,
1372 ),
1373 (
1374 "demo-card.ts",
1375 "import { WebUIElement } from '@microsoft/webui-framework';\n\
1376 class DemoCard extends WebUIElement { onClick() {} }\n\
1377 DemoCard.define('demo-card');",
1378 ),
1379 ]);
1380 let mut options = default_options(app.path());
1381 options.plugin = Some(Plugin::WebUI);
1382 let result = build(options).unwrap();
1383
1384 let template = result
1385 .component_templates
1386 .iter()
1387 .find(|template| template.tag_name == "demo-card")
1388 .unwrap();
1389 assert!(template.template_json.contains(r#""eg":[["click""#));
1390 }
1391
1392 #[test]
1393 fn test_build_populates_component_hydration_keys_from_script() {
1394 let app = create_app_dir(&[
1395 ("index.html", "<demo-card></demo-card>"),
1396 ("demo-card.html", "<p>{{name}}</p>"),
1397 (
1398 "demo-card.ts",
1399 "import { WebUIElement, attr, observable } from '@microsoft/webui-framework';\n\
1400 class DemoCard extends WebUIElement {\n\
1401 @observable name = '';\n\
1402 @attr({ attribute: 'cta-href' }) ctaHref = '/x';\n\
1403 @observable count = 0;\n\
1404 }\n\
1405 DemoCard.define('demo-card');",
1406 ),
1407 ]);
1408 let manifest_json = projection::test_support::build_valid_manifest_json(
1412 app.path(),
1413 &[("demo-card.ts", "source")],
1414 &[("demo-card.js", "output")],
1415 &[(
1416 "demo-card",
1417 "demo-card.ts",
1418 &["demo-card.js"],
1419 &["count", "ctaHref", "name"],
1420 &["count", "ctaHref", "name"],
1421 )],
1422 );
1423 let manifest = projection::test_support::write_manifest(
1424 app.path(),
1425 "webui-projection.json",
1426 &manifest_json,
1427 );
1428 let mut options = default_options(app.path());
1429 options.plugin = Some(Plugin::WebUI);
1430 options.projection_manifests = vec![manifest.into()];
1431 let result = build(options).unwrap();
1432
1433 assert_eq!(
1434 result.protocol.initial_state_strategy,
1435 webui_protocol::InitialStateStrategy::Components as i32
1436 );
1437 let component = result.protocol.components.get("demo-card").unwrap();
1438 assert_eq!(
1439 component.hydration_mode,
1440 webui_protocol::StateProjectionMode::Keys as i32
1441 );
1442 assert_eq!(component.hydration_keys, vec!["count", "ctaHref", "name"]);
1443 assert_eq!(
1444 component.navigation_mode,
1445 Some(webui_protocol::StateProjectionMode::Keys as i32)
1446 );
1447 assert_eq!(component.navigation_keys, vec!["count", "ctaHref", "name"]);
1448 }
1449
1450 #[test]
1451 fn test_build_scripted_component_without_manifest_falls_back_to_full_state() {
1452 let app = create_app_dir(&[
1453 (
1454 "index.html",
1455 "<html><body><demo-card></demo-card></body></html>",
1456 ),
1457 ("demo-card.html", "<p>{{name}}</p>"),
1458 (
1459 "demo-card.ts",
1460 "import { WebUIElement, observable } from '@microsoft/webui-framework';\n\
1461 class DemoCard extends WebUIElement {\n\
1462 @observable name = '';\n\
1463 }\n\
1464 DemoCard.define('demo-card');",
1465 ),
1466 ]);
1467 let mut options = default_options(app.path());
1468 options.plugin = Some(Plugin::WebUI);
1469 let result = build(options).unwrap();
1472
1473 assert_eq!(
1474 result.protocol.initial_state_strategy,
1475 webui_protocol::InitialStateStrategy::Full as i32
1476 );
1477 let component = result.protocol.components.get("demo-card").unwrap();
1478 assert_eq!(
1479 component.hydration_mode,
1480 webui_protocol::StateProjectionMode::All as i32
1481 );
1482 assert!(component.hydration_keys.is_empty());
1483 assert_eq!(
1484 component.navigation_mode,
1485 Some(webui_protocol::StateProjectionMode::All as i32)
1486 );
1487
1488 let handler = WebUIHandler::with_plugin(|| Box::new(WebUIHydrationPlugin::new()));
1489 let state = serde_json::json!({ "name": "Visible" });
1490 let protocol = Protocol::new(result.protocol.clone());
1491 let mut writer = StringWriter { buf: String::new() };
1492 handler
1493 .render(
1494 &protocol,
1495 &state,
1496 &RenderOptions::new("index.html", "/"),
1497 &mut writer,
1498 )
1499 .unwrap();
1500 assert!(writer.buf.contains("Visible"));
1501 assert!(writer.buf.contains(r#""state":{"name":"Visible"}"#));
1504 }
1505
1506 #[test]
1507 fn test_build_rejects_scriptless_event_component() {
1508 let app = create_app_dir(&[
1509 ("index.html", "<demo-card></demo-card>"),
1510 (
1511 "demo-card.html",
1512 r#"<button @click="{onClick()}">Click</button>"#,
1513 ),
1514 ]);
1515 let mut options = default_options(app.path());
1516 options.plugin = Some(Plugin::WebUI);
1517 let err = build(options).expect_err("scriptless events should fail");
1518
1519 let message = err.chain_message();
1520 assert!(
1521 message.contains("scriptless-event-handler"),
1522 "msg: {message}"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_build_returns_actionable_error_for_invalid_w_ref() {
1528 let app = create_app_dir(&[
1529 ("index.html", "<my-card>Hello</my-card>"),
1530 ("my-card.html", r#"<div><input w-ref="myInput" /></div>"#),
1531 ]);
1532 let mut options = default_options(app.path());
1533 options.plugin = Some(Plugin::WebUI);
1534
1535 let Err(err) = build(options) else {
1538 panic!("non-braced w-ref must fail the build");
1539 };
1540 let msg = err.chain_message();
1541 assert!(msg.contains("invalid w-ref binding"), "msg: {msg}");
1542 assert!(
1543 msg.contains("component <my-card> · element <input>"),
1544 "msg: {msg}"
1545 );
1546 assert!(msg.contains("help:"), "msg: {msg}");
1547 }
1548
1549 #[test]
1550 fn test_build_returns_actionable_error_for_invalid_event_handler() {
1551 let app = create_app_dir(&[
1552 ("index.html", "<my-btn>x</my-btn>"),
1553 (
1554 "my-btn.html",
1555 r#"<div><button @click="e.preventDefault()">x</button></div>"#,
1556 ),
1557 ]);
1558 let mut options = default_options(app.path());
1559 options.plugin = Some(Plugin::WebUI);
1560
1561 let Err(err) = build(options) else {
1562 panic!("invalid @event handler must fail the build");
1563 };
1564 let msg = err.chain_message();
1565 assert!(msg.contains("invalid @click handler"), "msg: {msg}");
1566 assert!(
1567 msg.contains("component <my-btn> · element <button>"),
1568 "msg: {msg}"
1569 );
1570 }
1571
1572 #[test]
1573 fn test_error_chain_message_has_no_duplicate_source() {
1574 let err = WebUIError::Parse {
1575 context: "Failed to parse index.html".to_string(),
1576 source: webui_parser::ParserError::Directive("Invalid for each: x".to_string()),
1577 };
1578 assert_eq!(err.to_string(), "Failed to parse index.html");
1581 assert_eq!(
1583 err.chain_message(),
1584 "Failed to parse index.html: Directive error: Invalid for each: x"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_build_with_directives() {
1590 let html = r#"<h1>Hello</h1>
1591<for each="item in items">
1592 <p>{{item.name}}</p>
1593</for>
1594<if condition="show">
1595 <p>Visible</p>
1596</if>"#;
1597 let app = create_app_dir(&[("index.html", html)]);
1598 let result = build(default_options(app.path())).unwrap();
1599
1600 let index = &result.protocol.fragments["index.html"].fragments;
1601 assert!(index
1602 .iter()
1603 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(_)))));
1604 assert!(index
1605 .iter()
1606 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
1607 }
1608
1609 #[test]
1610 fn named_for_builds_recursive_component_assets_and_styles() {
1611 for (css, expected_tokens) in [
1612 (":root { --accent: red; }", &[][..]),
1613 ("tree-view { --accent: red; }", &["accent"][..]),
1614 ] {
1615 let app = create_app_dir(&[
1616 ("index.html", "<tree-view></tree-view>"),
1617 (
1618 "tree-view.html",
1619 r#"<for id="tree-item" each="child in items"><span>{{child.name}}</span><token-leaf></token-leaf><for id="tree-item" each="child in child.children" /></for>"#,
1620 ),
1621 ("tree-view.css", css),
1622 ("token-leaf.html", "<b>leaf</b>"),
1623 ("token-leaf.css", "b { color: var(--accent); }"),
1624 ]);
1625 let result = build(BuildOptions {
1626 plugin: Some(Plugin::WebUI),
1627 css_bundle: true,
1628 component_asset_roots: vec!["tree-view".to_string()],
1629 ..light_options(app.path())
1630 })
1631 .unwrap();
1632 assert_eq!(result.protocol.tokens, expected_tokens, "{css}");
1633 assert!(!result.component_asset_files.is_empty());
1634 assert_eq!(
1635 result.protocol.style_closures["tree-view"].component_tags,
1636 ["tree-view", "token-leaf"]
1637 );
1638 let meta: serde_json::Value =
1639 serde_json::from_str(&result.protocol.components["tree-view"].template_json)
1640 .unwrap();
1641 let blocks = meta["b"].as_array().unwrap();
1642 assert_eq!(
1643 blocks.len(),
1644 1,
1645 "recursive depth does not expand compiled metadata"
1646 );
1647 assert_eq!(meta["r"][0][2], blocks[0]["r"][0][2]);
1648 assert!(result.protocol_bytes.len() < 16_384);
1649 }
1650 }
1651
1652 #[test]
1653 fn test_build_with_component_css() {
1654 let app = create_app_dir(&[
1655 ("index.html", "<my-card>Hello</my-card>"),
1656 (
1657 "my-card.html",
1658 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
1659 ),
1660 ("my-card.css", ".card { color: red; }"),
1661 ("my-card.ts", "export {};"),
1662 ]);
1663 let result = build(default_options(app.path())).unwrap();
1664
1665 assert_eq!(result.css_files.len(), 1);
1666 assert_eq!(result.css_files[0].0, "my-card.css");
1667 assert!(result.css_files[0].1.contains("color: red"));
1668 assert_eq!(result.stats.css_file_count, 1);
1669 }
1670
1671 #[test]
1672 fn light_css_bytes_align_across_link_style_and_module() {
1673 let authored = " \n.ready{color:red}\n ";
1674 let expected = authored;
1675
1676 for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
1677 let app = create_app_dir(&[
1678 ("index.html", "<my-card></my-card>"),
1679 ("my-card.html", "<p>content</p>"),
1680 ("my-card.css", authored),
1681 ]);
1682 let mut options = light_options(app.path());
1683 options.css = strategy;
1684 if strategy == CssStrategy::Link {
1685 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
1686 }
1687 let result = build(options).unwrap();
1688
1689 let actual = match strategy {
1690 CssStrategy::Link => {
1691 let expected_name = webui_parser::CssLinkOptions::try_new(
1692 "[name]-[hash].[ext]".to_string(),
1693 None,
1694 )
1695 .unwrap()
1696 .resolve("my-card", expected)
1697 .filename;
1698 assert_eq!(result.css_files[0].0, expected_name);
1699 result.css_files[0].1.clone()
1700 }
1701 CssStrategy::Module => result.protocol.components["my-card"].css.clone(),
1702 CssStrategy::Style => {
1703 assert_eq!(result.protocol.components["my-card"].css, expected);
1704 result.protocol.components["my-card"].css.clone()
1705 }
1706 };
1707 assert_eq!(actual, expected, "mismatch for {strategy:?}");
1708 if strategy != CssStrategy::Link {
1709 assert_eq!(
1710 result.protocol.component_style_resource("my-card"),
1711 Some(expected)
1712 );
1713 }
1714 assert_eq!(
1715 result
1716 .protocol
1717 .style_closure("index.html")
1718 .expect("entry closure"),
1719 ["my-card"],
1720 "Light CSS must be installed by the Document closure"
1721 );
1722
1723 let protocol = Protocol::new(result.protocol.clone());
1724 let mut writer = StringWriter { buf: String::new() };
1725 WebUIHandler::new()
1726 .render(
1727 &protocol,
1728 &serde_json::json!({}),
1729 &RenderOptions::new("index.html", "/"),
1730 &mut writer,
1731 )
1732 .unwrap();
1733 match strategy {
1734 CssStrategy::Link => {
1735 let href = &result.protocol.components["my-card"].css_href;
1736 assert!(writer.buf.contains(&format!(
1737 r#"<link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
1738 )));
1739 }
1740 CssStrategy::Style | CssStrategy::Module => {
1741 let kind = if strategy == CssStrategy::Module {
1742 "module"
1743 } else {
1744 "style"
1745 };
1746 assert!(writer.buf.contains(&format!(
1747 r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{expected}</style>"#
1748 )));
1749 }
1750 }
1751 }
1752 }
1753
1754 #[test]
1755 fn build_precomputes_style_closures_with_authored_shadow_cut() {
1756 let app = create_app_dir(&[
1757 (
1758 "index.html",
1759 "<light-card></light-card><shadow-card><after-card></after-card></shadow-card>",
1760 ),
1761 ("light-card.html", "<nested-card></nested-card>"),
1762 ("light-card.css", ".light{}"),
1763 (
1764 "shadow-card.html",
1765 r#"<template shadowrootmode="open"><nested-card></nested-card></template>"#,
1766 ),
1767 ("shadow-card.css", ".shadow{}"),
1768 ("nested-card.html", "<span>nested</span>"),
1769 ("nested-card.css", ".nested{}"),
1770 ("after-card.html", "<span>after</span>"),
1771 ("after-card.css", ".after{}"),
1772 ]);
1773 let mut options = light_options(app.path());
1774 options.css = CssStrategy::Style;
1775 let result = build(options).unwrap();
1776
1777 assert_eq!(
1778 result
1779 .protocol
1780 .style_closure("index.html")
1781 .expect("entry closure"),
1782 ["light-card", "nested-card", "after-card"]
1783 );
1784 assert_eq!(
1785 result
1786 .protocol
1787 .style_closure("shadow-card")
1788 .expect("Shadow closure"),
1789 ["shadow-card", "nested-card"]
1790 );
1791 }
1792
1793 fn bundling_app() -> tempfile::TempDir {
1797 create_app_dir(&[
1798 (
1799 "index.html",
1800 "<a-card></a-card><b-card></b-card><shared-card></shared-card>\
1801 <shadow-card></shadow-card>",
1802 ),
1803 ("a-card.html", "<span>a</span>"),
1804 ("a-card.css", ".a{color:red}"),
1805 ("b-card.html", "<span>b</span>"),
1806 ("b-card.css", ".b{color:green}"),
1807 ("shared-card.html", "<span>shared</span>"),
1808 ("shared-card.css", ".shared{color:blue}"),
1809 (
1810 "shadow-card.html",
1811 r#"<template shadowrootmode="open"><shared-card></shared-card></template>"#,
1812 ),
1813 ("shadow-card.css", ".shadow{color:black}"),
1814 ])
1815 }
1816
1817 fn closure_css(protocol: &WebUIProtocol, root: &str) -> (String, String) {
1820 let closure = protocol.style_closures.get(root).expect("closure");
1821 let unbundled = closure
1822 .component_tags
1823 .iter()
1824 .filter_map(|tag| protocol.components.get(tag))
1825 .map(|component| component.css.as_str())
1826 .collect::<Vec<_>>()
1827 .join("\n");
1828 let bundled = closure
1829 .style_chunks
1830 .iter()
1831 .filter_map(|index| protocol.style_chunk_resource(*index))
1832 .map(|(_, css)| css)
1833 .collect::<Vec<_>>()
1834 .join("\n");
1835 (unbundled, bundled)
1836 }
1837
1838 #[test]
1839 fn bundling_preserves_the_cascade_of_every_closure() {
1840 let app = bundling_app();
1841 let mut options = light_options(app.path());
1842 options.css = CssStrategy::Style;
1843 options.css_bundle = true;
1844 let result = build(options).unwrap();
1845
1846 let roots: Vec<String> = result.protocol.style_closures.keys().cloned().collect();
1847 assert!(roots.len() >= 2, "fixture must produce several CSS trees");
1848 let mut chunked = 0usize;
1849 for root in roots {
1850 let (unbundled, bundled) = closure_css(&result.protocol, &root);
1851 if bundled.is_empty() && !unbundled.is_empty() {
1854 continue;
1855 }
1856 chunked += 1;
1857 assert_eq!(
1858 bundled, unbundled,
1859 "chunked cascade diverged from the per-component cascade for `{root}`"
1860 );
1861 }
1862 assert!(chunked >= 2, "fixture must chunk several CSS trees");
1863 }
1864
1865 #[test]
1866 fn bundling_merges_entry_local_styles_and_splits_shared_ones() {
1867 let app = bundling_app();
1868 let mut options = light_options(app.path());
1869 options.css = CssStrategy::Style;
1870 options.css_bundle = true;
1871 let result = build(options).unwrap();
1872
1873 let members: Vec<&[String]> = result
1874 .protocol
1875 .style_chunks
1876 .iter()
1877 .map(|chunk| chunk.component_tags.as_slice())
1878 .collect();
1879 assert!(
1880 members.contains(&["a-card".to_string(), "b-card".to_string()].as_slice()),
1881 "entry-local components should merge: {members:?}"
1882 );
1883 assert!(
1884 members.contains(&["shared-card".to_string()].as_slice()),
1885 "a component reached from two CSS trees must stay alone: {members:?}"
1886 );
1887
1888 let entry = result.protocol.style_closure_chunks("index.html").unwrap();
1891 let shadow = result.protocol.style_closure_chunks("shadow-card").unwrap();
1892 let shared = entry
1893 .iter()
1894 .find(|index| shadow.contains(index))
1895 .expect("both trees should share a chunk");
1896 assert_eq!(
1897 result.protocol.style_chunk_members(*shared).unwrap(),
1898 ["shared-card"]
1899 );
1900 }
1901
1902 #[test]
1903 fn bundling_is_off_by_default_and_leaves_the_protocol_unchanged() {
1904 let app = bundling_app();
1905 let mut options = default_options(app.path());
1906 options.css = CssStrategy::Style;
1907 let result = build(options).unwrap();
1908
1909 assert!(result.protocol.style_chunks.is_empty());
1910 for closure in result.protocol.style_closures.values() {
1911 assert!(closure.style_chunks.is_empty());
1912 }
1913 }
1914
1915 #[test]
1916 fn bundling_link_emits_chunks_and_component_fallback_files() {
1917 let app = bundling_app();
1918 let mut options = light_options(app.path());
1919 options.css_bundle = true;
1920 let result = build(options).unwrap();
1921
1922 let names: Vec<&str> = result
1923 .css_files
1924 .iter()
1925 .map(|(name, _)| name.as_str())
1926 .collect();
1927 for (tag, component) in &result.protocol.components {
1928 if component.css_href.is_empty() {
1929 continue;
1930 }
1931 assert!(
1932 names.contains(&component.css_href.as_str()),
1933 "`{tag}` needs its independently loaded fallback file: {names:?}"
1934 );
1935 }
1936 for chunk in &result.protocol.style_chunks {
1937 assert!(!chunk.css_href.is_empty(), "Link chunks need an href");
1938 assert!(
1939 names.contains(&chunk.css_href.as_str()),
1940 "every rendered chunk needs an emitted file: {names:?}"
1941 );
1942 assert!(
1943 chunk.css.is_empty(),
1944 "Link chunks ship their CSS as a file, not inline"
1945 );
1946 }
1947 }
1948
1949 #[test]
1950 fn bundling_keeps_component_files_when_component_assets_need_them() {
1951 let app = create_app_dir(&[
1952 (
1953 "index.html",
1954 "<a-card></a-card><b-card></b-card><shared-card></shared-card>",
1955 ),
1956 ("a-card.html", "<span>a</span>"),
1957 ("a-card.css", ".a{color:red}"),
1958 ("b-card.html", "<span>b</span>"),
1959 ("b-card.css", ".b{color:green}"),
1960 ("shared-card.html", "<span>shared</span>"),
1961 ("shared-card.css", ".shared{color:blue}"),
1962 ("lazy-panel.html", "<a-card></a-card><p>lazy</p>"),
1963 ("lazy-panel.css", ".lazy{color:gray}"),
1964 ("lazy-panel.ts", "export {};"),
1965 ]);
1966 let mut options = light_options(app.path());
1967 options.plugin = Some(Plugin::WebUI);
1968 options.css_bundle = true;
1969 options.component_asset_roots = vec!["lazy-panel".to_string()];
1970 let result = build(options).unwrap();
1971
1972 let names: Vec<&str> = result
1973 .css_files
1974 .iter()
1975 .map(|(name, _)| name.as_str())
1976 .collect();
1977 assert!(
1978 names.iter().any(|name| name.starts_with("lazy-panel")),
1979 "static assets fetch a component's own stylesheet: {names:?}"
1980 );
1981 assert!(
1982 names.iter().any(|name| name.starts_with("_chunk-")),
1983 "bundled chunks are still emitted: {names:?}"
1984 );
1985 assert!(
1986 result.component_asset_files[0]
1987 .content
1988 .contains(r#""a-card":{"kind":"link","href":"a-card.css"}"#),
1989 "a deferred CSS tree needs the exact external component resource even when the entry Document uses a bundle: {}",
1990 result.component_asset_files[0].content
1991 );
1992 }
1993
1994 fn render_html(protocol: &WebUIProtocol, route: &str) -> String {
1996 let protocol = Protocol::new(protocol.clone());
1997 let mut writer = StringWriter { buf: String::new() };
1998 WebUIHandler::new()
1999 .render(
2000 &protocol,
2001 &serde_json::json!({}),
2002 &RenderOptions::new("index.html", route),
2003 &mut writer,
2004 )
2005 .unwrap();
2006 writer.buf
2007 }
2008
2009 fn linked_stylesheets(html: &str) -> Vec<&str> {
2011 let mut hrefs = Vec::new();
2012 let mut rest = html;
2013 while let Some(start) = rest.find("href=\"") {
2014 rest = &rest[start + "href=\"".len()..];
2015 let Some(end) = rest.find('"') else { break };
2016 let href = &rest[..end];
2017 if href.ends_with(".css") {
2018 hrefs.push(href);
2019 }
2020 rest = &rest[end..];
2021 }
2022 hrefs
2023 }
2024
2025 fn emitted_resources(html: &str) -> Vec<&str> {
2027 let mut resources = Vec::new();
2028 let mut rest = html;
2029 while let Some(start) = rest.find("data-webui-resource=\"") {
2030 rest = &rest[start + "data-webui-resource=\"".len()..];
2031 match rest.find('"') {
2032 Some(end) => {
2033 resources.push(&rest[..end]);
2034 rest = &rest[end..];
2035 }
2036 None => break,
2037 }
2038 }
2039 resources
2040 }
2041
2042 #[test]
2043 fn bundled_rendering_emits_one_resource_per_chunk() {
2044 for strategy in [CssStrategy::Link, CssStrategy::Style] {
2045 let app = bundling_app();
2046 let mut options = light_options(app.path());
2047 options.css = strategy;
2048 options.css_bundle = true;
2049 let result = build(options).unwrap();
2050
2051 let html = render_html(&result.protocol, "/");
2052 let resources = emitted_resources(&html);
2053 assert!(
2054 resources.contains(&"_chunk-a-card-2"),
2055 "{strategy:?} should deliver the merged chunk once: {resources:?}"
2056 );
2057 assert!(
2058 !resources.contains(&"a-card") && !resources.contains(&"b-card"),
2059 "{strategy:?} must not also deliver merged members: {resources:?}"
2060 );
2061 }
2062 }
2063
2064 #[test]
2065 fn bundling_never_merges_an_authored_shadow_stylesheet() {
2066 let app = create_app_dir(&[
2069 (
2070 "index.html",
2071 "<a-card></a-card><shadow-card></shadow-card><b-card></b-card>",
2072 ),
2073 ("a-card.html", "<span>a</span>"),
2074 ("a-card.css", ".a{color:red}"),
2075 ("b-card.html", "<span>b</span>"),
2076 ("b-card.css", ".b{color:green}"),
2077 (
2078 "shadow-card.html",
2079 r#"<template shadowrootmode="open"><span>s</span></template>"#,
2080 ),
2081 ("shadow-card.css", ".shadow{color:black}"),
2082 ]);
2083 let mut options = light_options(app.path());
2084 options.css = CssStrategy::Style;
2085 options.css_bundle = true;
2086 let result = build(options).unwrap();
2087
2088 let chunk = result
2089 .protocol
2090 .style_chunks
2091 .iter()
2092 .find(|chunk| chunk.component_tags.iter().any(|tag| tag == "shadow-card"))
2093 .expect("shadow-card is styled, so it belongs to a chunk");
2094 assert_eq!(
2098 chunk.component_tags,
2099 vec!["shadow-card".to_string()],
2100 "an authored-Shadow stylesheet must stay in a chunk of its own"
2101 );
2102 assert_eq!(chunk.name, "shadow-card");
2103 assert_eq!(
2104 chunk.css, result.protocol.components["shadow-card"].css,
2105 "its chunk must carry the component's own bytes unchanged"
2106 );
2107 }
2108
2109 #[test]
2110 fn bundling_never_links_a_stylesheet_the_build_did_not_write() {
2111 let app = bundling_app();
2115 let mut options = light_options(app.path());
2116 options.css = CssStrategy::Link;
2117 options.css_bundle = true;
2118 let result = build(options).unwrap();
2119
2120 let emitted: HashSet<&str> = result
2121 .css_files
2122 .iter()
2123 .map(|(name, _)| name.as_str())
2124 .collect();
2125 let html = render_html(&result.protocol, "/");
2126 let linked = linked_stylesheets(&html);
2127 assert!(
2128 !linked.is_empty(),
2129 "the page should link at least one stylesheet: {html}"
2130 );
2131 for href in &linked {
2132 let name = href.rsplit('/').next().unwrap_or(href);
2133 assert!(
2134 emitted.contains(name),
2135 "page links `{href}`, which the bundled build did not write: {emitted:?}"
2136 );
2137 }
2138 }
2139
2140 #[test]
2141 fn bundling_is_rejected_for_the_module_strategy() {
2142 let app = bundling_app();
2143 let mut options = light_options(app.path());
2144 options.css = CssStrategy::Module;
2145 options.css_bundle = true;
2146
2147 let Err(WebUIError::InvalidBuildOptions(message)) = build(options) else {
2148 panic!("Module builds resolve import-map specifiers per component");
2149 };
2150 assert!(
2151 message.contains("--css link"),
2152 "the error should name a strategy that works: {message}"
2153 );
2154 }
2155
2156 #[test]
2157 fn bundled_rendering_delivers_the_same_css_in_the_same_order() {
2158 let extract = |html: &str| -> Vec<String> {
2161 let mut blocks = Vec::new();
2162 let mut rest = html;
2163 while let Some(start) = rest.find("data-webui-strategy=\"style\">") {
2164 rest = &rest[start + "data-webui-strategy=\"style\">".len()..];
2165 match rest.find("</style>") {
2166 Some(end) => {
2167 blocks.push(rest[..end].to_string());
2168 rest = &rest[end..];
2169 }
2170 None => break,
2171 }
2172 }
2173 blocks
2174 };
2175
2176 let mut plan = Vec::new();
2177 for bundle in [false, true] {
2178 let app = bundling_app();
2179 let mut options = light_options(app.path());
2180 options.css = CssStrategy::Style;
2181 options.css_bundle = bundle;
2182 let result = build(options).unwrap();
2183 let html = render_html(&result.protocol, "/");
2184 plan.push(extract(&html).join("\n"));
2185 }
2186
2187 assert_eq!(
2188 plan[1], plan[0],
2189 "bundling must not change the delivered cascade"
2190 );
2191 assert!(!plan[0].is_empty(), "fixture must deliver CSS");
2192 }
2193
2194 #[test]
2195 fn bundled_rendering_never_repeats_a_chunk_across_css_trees() {
2196 let app = bundling_app();
2197 let mut options = light_options(app.path());
2198 options.css = CssStrategy::Style;
2199 options.css_bundle = true;
2200 let result = build(options).unwrap();
2201
2202 let html = render_html(&result.protocol, "/");
2203 let resources = emitted_resources(&html);
2204 let mut counts: HashMap<&str, usize> = HashMap::new();
2208 for resource in &resources {
2209 *counts.entry(*resource).or_insert(0) += 1;
2210 }
2211 let shadow_start = html
2212 .find(r#"<template shadowrootmode="open""#)
2213 .expect("declarative Shadow root");
2214 let document_resources = emitted_resources(&html[..shadow_start]);
2215 let mut document_counts: HashMap<&str, usize> = HashMap::new();
2216 for resource in &document_resources {
2217 *document_counts.entry(*resource).or_insert(0) += 1;
2218 }
2219 for (resource, count) in &document_counts {
2220 assert_eq!(
2221 *count, 1,
2222 "`{resource}` was delivered {count} times in the document tree: {resources:?}"
2223 );
2224 }
2225 }
2226
2227 #[test]
2228 fn authored_shadow_css_bytes_are_not_lowered_or_trimmed() {
2229 let authored =
2230 " \n:host(.ready){color:red}:host-context(body){color:blue}::slotted(*){color:green}\n ";
2231
2232 for strategy in [CssStrategy::Link, CssStrategy::Style, CssStrategy::Module] {
2233 let app = create_app_dir(&[
2234 ("index.html", "<my-card></my-card>"),
2235 (
2236 "my-card.html",
2237 r#"<template shadowrootmode="open"><p>content</p></template>"#,
2238 ),
2239 ("my-card.css", authored),
2240 ]);
2241 let mut options = default_options(app.path());
2242 options.css = strategy;
2243 let result = build(options).unwrap();
2244
2245 match strategy {
2246 CssStrategy::Link => assert_eq!(result.css_files[0].1, authored),
2247 CssStrategy::Style | CssStrategy::Module => {
2248 assert_eq!(result.protocol.components["my-card"].css, authored);
2249 assert_eq!(
2250 result.protocol.component_style_resource("my-card"),
2251 Some(authored)
2252 );
2253 }
2254 }
2255 assert_eq!(
2256 result
2257 .protocol
2258 .style_closure("my-card")
2259 .expect("component closure"),
2260 ["my-card"],
2261 "the authored Shadow root owns its exact CSS resource"
2262 );
2263
2264 let protocol = Protocol::new(result.protocol.clone());
2265 let mut writer = StringWriter { buf: String::new() };
2266 WebUIHandler::new()
2267 .render(
2268 &protocol,
2269 &serde_json::json!({}),
2270 &RenderOptions::new("index.html", "/"),
2271 &mut writer,
2272 )
2273 .unwrap();
2274 match strategy {
2275 CssStrategy::Link => {
2276 let href = &result.protocol.components["my-card"].css_href;
2277 assert!(writer.buf.contains(&format!(
2278 r#"<template shadowrootmode="open"><link rel="stylesheet" href="{href}" data-webui-resource="my-card" data-webui-strategy="link">"#
2279 )));
2280 }
2281 CssStrategy::Style | CssStrategy::Module => {
2282 let kind = if strategy == CssStrategy::Module {
2283 "module"
2284 } else {
2285 "style"
2286 };
2287 assert!(writer.buf.contains(&format!(
2288 r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">{authored}</style>"#
2289 )));
2290 let template_start = writer
2291 .buf
2292 .find(r#"<template shadowrootmode="open""#)
2293 .expect("declarative Shadow root");
2294 let style_start = writer
2295 .buf
2296 .find(&format!(
2297 r#"<style data-webui-resource="my-card" data-webui-strategy="{kind}">"#
2298 ))
2299 .expect("tree-local style");
2300 let content_start = writer.buf.find("<p>content</p>").expect("component body");
2301 assert!(template_start < style_start && style_start < content_start);
2302 }
2303 }
2304 }
2305 }
2306
2307 #[test]
2308 fn test_build_strips_non_legal_css_comments_from_output_file() {
2309 let app = create_app_dir(&[
2310 ("index.html", "<my-card>Hello</my-card>"),
2311 (
2312 "my-card.html",
2313 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2314 ),
2315 (
2316 "my-card.css",
2317 "/* remove var(--ignored) */ .card { color: var(--textColor); }",
2318 ),
2319 ]);
2320 let result = build(default_options(app.path())).unwrap();
2321
2322 assert_eq!(result.css_files[0].1, " .card { color: var(--textColor); }");
2323 assert_eq!(result.protocol.tokens, vec!["textColor"]);
2324 }
2325
2326 #[test]
2327 fn test_build_preserves_legal_css_comments_by_default() {
2328 let app = create_app_dir(&[
2329 ("index.html", "<my-card>Hello</my-card>"),
2330 (
2331 "my-card.html",
2332 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2333 ),
2334 (
2335 "my-card.css",
2336 "/*! @license MIT */ .card { color: red; } /* remove */",
2337 ),
2338 ]);
2339 let result = build(default_options(app.path())).unwrap();
2340
2341 assert_eq!(
2342 result.css_files[0].1,
2343 "/*! @license MIT */ .card { color: red; } "
2344 );
2345 }
2346
2347 #[test]
2348 fn test_build_legal_comments_none_strips_legal_css_comments() {
2349 let app = create_app_dir(&[
2350 ("index.html", "<my-card>Hello</my-card>"),
2351 (
2352 "my-card.html",
2353 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2354 ),
2355 ("my-card.css", "/*! @license MIT */ .card { color: red; }"),
2356 ]);
2357 let mut options = default_options(app.path());
2358 options.legal_comments = LegalComments::None;
2359 let result = build(options).unwrap();
2360
2361 assert_eq!(result.css_files[0].1, " .card { color: red; }");
2362 }
2363
2364 #[test]
2365 fn test_build_with_css_hashed_filename_template() {
2366 let app = create_app_dir(&[
2367 ("index.html", "<my-card>Hello</my-card>"),
2368 (
2369 "my-card.html",
2370 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2371 ),
2372 ("my-card.css", ".card { color: red; }"),
2373 ]);
2374 let mut options = default_options(app.path());
2375 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2376 let result = build(options).unwrap();
2377
2378 assert_eq!(result.css_files.len(), 1);
2379 let generated = &result.css_files[0].0;
2380 assert!(generated.starts_with("my-card-"));
2381 assert!(generated.ends_with(".css"));
2382 assert_eq!(generated.len(), "my-card-".len() + 8 + ".css".len());
2383 assert_eq!(
2384 result
2385 .protocol
2386 .components
2387 .get("my-card")
2388 .map(|c| c.css_href.as_str()),
2389 Some(generated.as_str())
2390 );
2391 }
2392
2393 #[test]
2394 fn test_css_public_base_prefixes_css_href_only() {
2395 let app = create_app_dir(&[
2396 ("index.html", "<my-card>Hello</my-card>"),
2397 (
2398 "my-card.html",
2399 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2400 ),
2401 ("my-card.css", ".card { color: red; }"),
2402 ]);
2403 let mut options = default_options(app.path());
2404 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2405 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2406 let result = build(options).unwrap();
2407
2408 let filename = &result.css_files[0].0;
2409 assert!(filename.starts_with("my-card-"));
2410 let expected_href = format!("https://cdn.example.com/assets/{filename}");
2411 assert_eq!(
2412 result
2413 .protocol
2414 .components
2415 .get("my-card")
2416 .map(|c| c.css_href.as_str()),
2417 Some(expected_href.as_str())
2418 );
2419 }
2420
2421 #[test]
2422 fn test_css_public_base_emits_protocol_handler_and_payload_resources() {
2423 let app = create_app_dir(&[
2424 (
2425 "index.html",
2426 "<html><head></head><body><my-card>Hello</my-card></body></html>",
2427 ),
2428 (
2429 "my-card.html",
2430 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2431 ),
2432 ("my-card.css", ".card { color: red; }"),
2433 ]);
2434 let mut options = default_options(app.path());
2435 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2436 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2437 let result = build(options).unwrap();
2438
2439 let filename = &result.css_files[0].0;
2440 let expected_href = format!("https://cdn.example.com/assets/{filename}");
2441 assert_eq!(
2442 result.protocol.component_style_resource("my-card"),
2443 Some(expected_href.as_str())
2444 );
2445 assert_eq!(
2446 result
2447 .protocol
2448 .style_closure("my-card")
2449 .expect("component closure"),
2450 ["my-card"]
2451 );
2452
2453 let handler = WebUIHandler::new();
2454 let protocol = Protocol::new(result.protocol.clone());
2455 let mut writer = StringWriter { buf: String::new() };
2456 handler
2457 .render(
2458 &protocol,
2459 &serde_json::json!({}),
2460 &RenderOptions::new("index.html", "/"),
2461 &mut writer,
2462 )
2463 .unwrap();
2464 assert!(
2465 writer.buf.contains(&format!(
2466 r#"<link rel="stylesheet" href="{}" data-webui-resource="my-card" data-webui-strategy="link">"#,
2467 expected_href.replace('/', "/")
2468 )),
2469 "handler Shadow-root resource should use CDN href: {}",
2470 writer.buf
2471 );
2472 let payload = protocol
2473 .render_component_templates(&["my-card"], "")
2474 .expect("component resource payload");
2475 assert_eq!(
2476 payload["componentStyles"]["resources"]["my-card"]["href"],
2477 expected_href
2478 );
2479 }
2480
2481 #[test]
2482 fn test_css_public_base_keeps_webui_template_style_free() {
2483 let app = create_app_dir(&[
2484 ("index.html", "<my-card>Hello</my-card>"),
2485 (
2486 "my-card.html",
2487 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2488 ),
2489 ("my-card.css", ".card { color: red; }"),
2490 ("my-card.ts", "export {};"),
2491 ]);
2492 let mut options = default_options(app.path());
2493 options.plugin = Some(Plugin::WebUI);
2494 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2495 options.css_public_base = Some("https://cdn.example.com/assets".to_string());
2496 let result = build(options).unwrap();
2497
2498 let filename = &result.css_files[0].0;
2499 let expected_href = format!("https://cdn.example.com/assets/{filename}");
2500 let template = &result.protocol.components["my-card"].template_json;
2501 assert_eq!(
2502 result.protocol.component_style_resource("my-card"),
2503 Some(expected_href.as_str())
2504 );
2505 assert!(
2506 !template.contains(&expected_href),
2507 "WebUI templates must not duplicate handler-owned CSS links: {template}"
2508 );
2509 }
2510
2511 #[test]
2512 fn test_invalid_css_template_is_rejected() {
2513 let app = create_app_dir(&[
2514 ("index.html", "<my-card>Hello</my-card>"),
2515 (
2516 "my-card.html",
2517 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2518 ),
2519 ("my-card.css", ".card { color: red; }"),
2520 ]);
2521 let mut options = default_options(app.path());
2522 options.css_file_name_template = "[name]-[bogus].[ext]".to_string();
2523 let result = build(options);
2524
2525 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2526 }
2527
2528 #[test]
2529 fn test_css_filename_collision_is_rejected() {
2530 let app = create_app_dir(&[
2531 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
2532 (
2533 "card-a.html",
2534 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2535 ),
2536 ("card-a.css", ".x { color: red; }"),
2537 (
2538 "card-b.html",
2539 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
2540 ),
2541 ("card-b.css", ".x { color: red; }"),
2542 ]);
2543 let mut options = default_options(app.path());
2544 options.css_file_name_template = "[hash].[ext]".to_string();
2545 let result = build(options);
2546
2547 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2548 }
2549
2550 #[test]
2551 fn test_build_returns_static_component_asset_files() {
2552 let app = create_app_dir(&[
2553 ("index.html", "<app-shell></app-shell>"),
2554 ("app-shell.html", "<div></div>"),
2555 (
2556 "lazy-panel.html",
2557 r#"<if condition="ready"><p>{{title}}</p></if>"#,
2558 ),
2559 ("lazy-panel.ts", "export {};"),
2560 ]);
2561 let mut options = default_options(app.path());
2562 options.plugin = Some(Plugin::WebUI);
2563 options.component_asset_roots = vec!["lazy-panel".to_string()];
2564
2565 let result = build(options).unwrap();
2566
2567 assert_eq!(result.component_asset_files.len(), 1);
2568 assert_eq!(result.component_asset_files[0].name, "lazy-panel.webui.js");
2569 assert!(result.component_asset_files[0]
2570 .content
2571 .contains(r#""type":"webui-component-asset""#));
2572 assert!(result.component_asset_files[0]
2573 .content
2574 .contains(r#""version":3"#));
2575 assert!(result.component_asset_files[0]
2576 .content
2577 .contains(r#""kind":"root""#));
2578 assert!(result.component_asset_files[0]
2579 .content
2580 .contains(r#""templateFunctions":{"lazy-panel":"#));
2581 assert!(!result.protocol.fragments.contains_key("lazy-panel"));
2582 assert!(
2583 !result
2584 .protocol
2585 .fragments
2586 .keys()
2587 .any(|key| key.starts_with("__webui_asset_root_")),
2588 "synthetic asset root fragments must not be serialized"
2589 );
2590 }
2591
2592 #[test]
2593 fn component_assets_emit_enhanced_style_catalog_for_all_strategies() {
2594 for (strategy, kind) in [
2595 (CssStrategy::Link, "link"),
2596 (CssStrategy::Style, "style"),
2597 (CssStrategy::Module, "module"),
2598 ] {
2599 let app = create_app_dir(&[
2600 ("index.html", "<app-shell></app-shell>"),
2601 ("app-shell.html", "<p>Entry</p>"),
2602 ("lazy-panel.html", "<p>Lazy</p>"),
2603 ("lazy-panel.css", ".lazy{color:red}"),
2604 ]);
2605 let mut options = default_options(app.path());
2606 options.plugin = Some(Plugin::WebUI);
2607 options.css = strategy;
2608 options.component_asset_roots = vec!["lazy-panel".to_string()];
2609
2610 let result = build(options).unwrap();
2611 let asset = &result.component_asset_files[0].content;
2612 assert!(asset.contains(r#""version":3"#));
2613 assert!(asset.contains(&format!(
2614 r#""componentStyles":{{"version":1,"strategy":"{kind}""#
2615 )));
2616 assert!(asset.contains(&format!(r#""lazy-panel":{{"kind":"{kind}""#)));
2617 assert!(asset.contains(r#""closures":{"lazy-panel":["lazy-panel"]}"#));
2618 if strategy == CssStrategy::Module {
2619 assert!(asset
2620 .contains(r#""lazy-panel":{"kind":"module","specifier":"lazy-panel","css":"#,));
2621 }
2622 if strategy == CssStrategy::Link {
2623 assert!(asset.contains(r#""href":new URL("lazy-panel.css",import.meta.url).href"#,));
2624 }
2625 }
2626 }
2627
2628 #[test]
2629 fn test_build_splits_shared_assets_and_keeps_them_out_of_protocol() {
2630 let app = create_app_dir(&[
2631 ("index.html", "<app-shell></app-shell>"),
2632 ("app-shell.html", "<entry-badge></entry-badge>"),
2633 ("entry-badge.html", "<span>Entry</span>"),
2634 ("entry-badge.css", ":host { display: inline-block; }"),
2635 (
2636 "lazy-panel.html",
2637 "<entry-badge></entry-badge><shared-detail></shared-detail><panel-only></panel-only>",
2638 ),
2639 (
2640 "secondary-panel.html",
2641 "<entry-badge></entry-badge><shared-detail></shared-detail><secondary-only></secondary-only>",
2642 ),
2643 ("shared-detail.html", "<p>Shared</p>"),
2644 ("shared-detail.css", ":host { display: block; }"),
2645 ("lazy-panel.css", ":host { color: green; }"),
2646 ("secondary-panel.css", ":host { color: blue; }"),
2647 ("panel-only.html", "<p>Panel</p>"),
2648 ("secondary-only.html", "<p>Secondary</p>"),
2649 ]);
2650 let mut options = default_options(app.path());
2651 options.plugin = Some(Plugin::WebUI);
2652 options.component_asset_roots =
2653 vec!["lazy-panel".to_string(), "secondary-panel".to_string()];
2654
2655 let result = build(options).unwrap();
2656
2657 for tag in [
2658 "lazy-panel",
2659 "secondary-panel",
2660 "shared-detail",
2661 "panel-only",
2662 "secondary-only",
2663 ] {
2664 assert!(
2665 !result.protocol.fragments.contains_key(tag),
2666 "asset-only fragment <{tag}> must not be serialized"
2667 );
2668 assert!(
2669 !result.protocol.components.contains_key(tag),
2670 "asset-only component <{tag}> must not be serialized"
2671 );
2672 }
2673 assert!(result.protocol.fragments.contains_key("app-shell"));
2674 assert!(result.protocol.components.contains_key("entry-badge"));
2675 assert_eq!(
2676 result.protocol.component_asset_style_preloads,
2677 vec![
2678 webui_protocol::ComponentAssetStylePreload {
2679 root: "lazy-panel".to_string(),
2680 style_hrefs: vec![
2681 "lazy-panel.css".to_string(),
2682 "shared-detail.css".to_string(),
2683 ],
2684 },
2685 webui_protocol::ComponentAssetStylePreload {
2686 root: "secondary-panel".to_string(),
2687 style_hrefs: vec![
2688 "secondary-panel.css".to_string(),
2689 "shared-detail.css".to_string(),
2690 ],
2691 },
2692 ]
2693 );
2694
2695 let names: Vec<&str> = result
2696 .component_asset_files
2697 .iter()
2698 .map(|file| file.name.as_str())
2699 .collect();
2700 assert_eq!(
2701 names,
2702 vec![
2703 "lazy-panel.webui.js",
2704 "secondary-panel.webui.js",
2705 "chunk-shared-detail.webui.js",
2706 ]
2707 );
2708 let lazy = &result.component_asset_files[0].content;
2709 assert!(lazy.contains(r#""version":3"#));
2710 assert!(lazy.contains(r#""kind":"root""#));
2711 assert!(lazy.contains(r#""externalComponents":["entry-badge"]"#));
2712 assert!(lazy.contains(r#""entry-badge":{"kind":"link","href":"entry-badge.css"}"#));
2713 assert!(!lazy.contains(
2714 r#""entry-badge":{"kind":"link","href":new URL("entry-badge.css",import.meta.url).href}"#
2715 ));
2716 assert!(lazy
2717 .contains(r#""href":new URL("./chunk-shared-detail.webui.js",import.meta.url).href"#));
2718 assert!(lazy.contains(r#"import("./chunk-shared-detail.webui.js")"#));
2719 assert!(!lazy.contains(r#""templates":{"entry-badge":"#));
2720 assert!(!lazy.contains(r#""templates":{"shared-detail":"#));
2721
2722 let chunk = &result.component_asset_files[2].content;
2723 assert!(chunk.contains(r#""kind":"chunk""#));
2724 assert!(chunk.contains(r#""components":["shared-detail"]"#));
2725 assert!(chunk.contains(r#""templates":{"shared-detail":"#));
2726 }
2727
2728 #[test]
2729 fn test_component_asset_graph_groups_each_consumer_set() {
2730 let app = create_app_dir(&[
2731 ("index.html", "<app-shell></app-shell>"),
2732 ("app-shell.html", "<p>Entry</p>"),
2733 (
2734 "root-a.html",
2735 "<shared-ab></shared-ab><shared-all></shared-all><only-a></only-a>",
2736 ),
2737 (
2738 "root-b.html",
2739 "<shared-ab></shared-ab><shared-all></shared-all><only-b></only-b>",
2740 ),
2741 ("root-c.html", "<shared-all></shared-all><only-c></only-c>"),
2742 ("shared-ab.html", "<p>AB</p>"),
2743 ("shared-all.html", "<p>All</p>"),
2744 ("only-a.html", "<p>A</p>"),
2745 ("only-b.html", "<p>B</p>"),
2746 ("only-c.html", "<p>C</p>"),
2747 ]);
2748 let mut options = default_options(app.path());
2749 options.plugin = Some(Plugin::WebUI);
2750 options.component_asset_roots = vec![
2751 "root-a".to_string(),
2752 "root-b".to_string(),
2753 "root-c".to_string(),
2754 ];
2755
2756 let result = build(options).unwrap();
2757 let names: Vec<&str> = result
2758 .component_asset_files
2759 .iter()
2760 .map(|file| file.name.as_str())
2761 .collect();
2762 assert_eq!(
2763 names,
2764 [
2765 "root-a.webui.js",
2766 "root-b.webui.js",
2767 "root-c.webui.js",
2768 "chunk-shared-ab.webui.js",
2769 "chunk-shared-all.webui.js",
2770 ]
2771 );
2772 assert!(result.component_asset_files[0]
2773 .content
2774 .contains(r#""components":["only-a","root-a"]"#));
2775 assert!(result.component_asset_files[3]
2776 .content
2777 .contains(r#""components":["shared-ab"]"#));
2778 assert!(result.component_asset_files[4]
2779 .content
2780 .contains(r#""components":["shared-all"]"#));
2781 }
2782
2783 #[test]
2784 fn test_component_asset_graph_is_root_order_independent_with_hashed_imports() {
2785 let app = create_app_dir(&[
2786 ("index.html", "<app-shell></app-shell>"),
2787 ("app-shell.html", "<p>Entry</p>"),
2788 ("root-a.html", "<shared-detail></shared-detail>"),
2789 ("root-b.html", "<shared-detail></shared-detail>"),
2790 ("shared-detail.html", "<p>Shared</p>"),
2791 ]);
2792 let build_files = |roots: Vec<String>| {
2793 let mut options = default_options(app.path());
2794 options.plugin = Some(Plugin::WebUI);
2795 options.component_asset_roots = roots;
2796 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2797 build(options).unwrap().component_asset_files
2798 };
2799
2800 let forward = build_files(vec!["root-a".to_string(), "root-b".to_string()]);
2801 let reverse = build_files(vec!["root-b".to_string(), "root-a".to_string()]);
2802
2803 assert_eq!(forward, reverse);
2804 let chunk_name = &forward[2].name;
2805 assert!(chunk_name.starts_with("chunk-shared-detail-"));
2806 assert!(forward[0].content.contains(chunk_name));
2807 assert!(forward[1].content.contains(chunk_name));
2808 }
2809
2810 #[test]
2811 fn test_component_asset_style_preloads_use_final_css_hrefs() {
2812 let app = create_app_dir(&[
2813 ("index.html", "<app-shell></app-shell>"),
2814 ("app-shell.html", "<p>Entry</p>"),
2815 ("lazy-panel.html", "<p>Lazy</p>"),
2816 ("lazy-panel.css", ":host { display: block; }"),
2817 ]);
2818 let mut options = default_options(app.path());
2819 options.plugin = Some(Plugin::WebUI);
2820 options.component_asset_roots = vec!["lazy-panel".to_string()];
2821 options.css_file_name_template = "[name]-[hash].[ext]".to_string();
2822 options.css_public_base = Some("https://cdn.example.com/webui".to_string());
2823
2824 let result = build(options).unwrap();
2825 let preload = &result.protocol.component_asset_style_preloads[0];
2826
2827 assert_eq!(preload.root, "lazy-panel");
2828 assert_eq!(preload.style_hrefs.len(), 1);
2829 assert!(preload.style_hrefs[0].starts_with("https://cdn.example.com/webui/lazy-panel-"));
2830 assert!(preload.style_hrefs[0].ends_with(".css"));
2831 }
2832
2833 #[test]
2834 fn test_light_component_asset_styles_are_retained_for_document_links() {
2835 let app = create_app_dir(&[
2836 ("index.html", "<app-shell></app-shell>"),
2837 ("app-shell.html", "<p>Entry</p>"),
2838 ("z-lazy-panel.html", "<a-lazy-child></a-lazy-child>"),
2839 ("z-lazy-panel.css", ".z-lazy-panel { display: block; }"),
2840 ("a-lazy-child.html", "<p>Child</p>"),
2841 ("a-lazy-child.css", ".a-lazy-child { color: green; }"),
2842 ]);
2843 let mut options = default_options(app.path());
2844 options.plugin = Some(Plugin::WebUI);
2845 options.dom = DomStrategy::Light;
2846 options.component_asset_roots = vec!["z-lazy-panel".to_string()];
2847
2848 let result = build(options).unwrap();
2849
2850 assert_eq!(
2851 result.protocol.component_asset_style_preloads,
2852 vec![webui_protocol::ComponentAssetStylePreload {
2853 root: "z-lazy-panel".to_string(),
2854 style_hrefs: vec![
2855 "z-lazy-panel.css".to_string(),
2856 "a-lazy-child.css".to_string(),
2857 ],
2858 }]
2859 );
2860 }
2861
2862 #[test]
2863 fn test_metafile_describes_dynamic_import_graph() {
2864 let app = create_app_dir(&[
2865 ("index.html", "<app-shell></app-shell>"),
2866 ("app-shell.html", "<p>Entry</p>"),
2867 ("root-a.html", "<shared-detail></shared-detail>"),
2868 ("root-b.html", "<shared-detail></shared-detail>"),
2869 ("shared-detail.html", "<p>Shared</p>"),
2870 ]);
2871 let mut options = default_options(app.path());
2872 options.plugin = Some(Plugin::WebUI);
2873 options.component_asset_roots = vec!["root-a".to_string(), "root-b".to_string()];
2874 options.metafile = true;
2875
2876 let result = build(options).unwrap();
2877 let metafile = result.metafile.as_deref().unwrap();
2878 let value: serde_json::Value = serde_json::from_str(metafile).unwrap();
2879 assert!(value["inputs"]
2880 .get("webui:component/shared-detail")
2881 .is_some());
2882 assert_eq!(
2883 value["outputs"]["root-a.webui.js"]["entryPoint"],
2884 "webui:component/root-a"
2885 );
2886 assert_eq!(
2887 value["outputs"]["root-a.webui.js"]["imports"][0]["path"],
2888 "chunk-shared-detail.webui.js"
2889 );
2890 assert_eq!(
2891 value["outputs"]["root-a.webui.js"]["imports"][0]["kind"],
2892 "dynamic-import"
2893 );
2894 assert_eq!(
2895 value["inputs"]["webui:component/root-a"]["imports"][0]["kind"],
2896 "dynamic-import"
2897 );
2898 assert_eq!(
2899 value["outputs"]["chunk-shared-detail.webui.js"]["bytes"],
2900 result.component_asset_files[2].content.len()
2901 );
2902 }
2903
2904 #[test]
2905 fn test_component_assets_reject_routes_with_stable_diagnostic() {
2906 let app = create_app_dir(&[
2907 (
2908 "index.html",
2909 r#"<route path="/" component="app-shell" exact />"#,
2910 ),
2911 ("app-shell.html", "<p>Entry</p>"),
2912 ("lazy-panel.html", "<p>Lazy</p>"),
2913 ]);
2914 let mut options = default_options(app.path());
2915 options.plugin = Some(Plugin::WebUI);
2916 options.component_asset_roots = vec!["lazy-panel".to_string()];
2917
2918 let error = build(options).unwrap_err();
2919 match error {
2920 WebUIError::ComponentAssets(diagnostic) => {
2921 assert_eq!(
2922 diagnostic.error_code(),
2923 Some(webui_parser::codes::COMPONENT_ASSETS_WITH_ROUTES)
2924 );
2925 assert!(diagnostic.help_text().is_some());
2926 }
2927 other => panic!("expected route diagnostic, received {other}"),
2928 }
2929 }
2930
2931 #[test]
2932 fn test_component_asset_filename_collision_with_css_is_rejected() {
2933 let app = create_app_dir(&[
2934 ("index.html", "<app-shell></app-shell>"),
2935 ("app-shell.html", "<div></div>"),
2936 ("lazy-panel.html", "<p>Lazy</p>"),
2937 ("lazy-panel.css", ".panel { color: red; }"),
2938 ]);
2939 let mut options = default_options(app.path());
2940 options.plugin = Some(Plugin::WebUI);
2941 options.component_asset_roots = vec!["lazy-panel".to_string()];
2942 options.css_file_name_template = "[name]".to_string();
2943
2944 let result = build(options);
2945
2946 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2947 }
2948
2949 #[test]
2950 fn test_build_to_disk_rejects_protocol_name_collision_before_writing() {
2951 let app = create_app_dir(&[
2952 ("index.html", "<app-shell></app-shell>"),
2953 ("app-shell.html", "<div></div>"),
2954 ("lazy-panel.html", "<p>Lazy</p>"),
2955 ]);
2956 let out = TempDir::new().unwrap();
2957 let mut options = default_options(app.path());
2958 options.plugin = Some(Plugin::WebUI);
2959 options.component_asset_roots = vec!["lazy-panel".to_string()];
2960 options.css_file_name_template = "protocol.bin".to_string();
2961
2962 let result = build_to_disk(options, out.path());
2963
2964 assert!(matches!(result, Err(WebUIError::InvalidBuildOptions(_))));
2965 assert!(!out.path().join("protocol.bin").exists());
2966 }
2967
2968 #[test]
2969 fn test_css_href_set_for_light_dom_link_strategy() {
2970 let app = create_app_dir(&[
2971 ("index.html", "<has-css>A</has-css><no-css>B</no-css>"),
2972 ("has-css.html", "<p>styled</p>"),
2973 ("has-css.css", ".yes { color: green; }"),
2974 ("no-css.html", "<p>plain</p>"),
2975 ]);
2976 let options = default_options(app.path());
2977 let result = build(options).unwrap();
2978
2979 let href = result
2980 .protocol
2981 .components
2982 .get("has-css")
2983 .map(|c| c.css_href.as_str())
2984 .unwrap_or("");
2985 assert_eq!(
2986 href, "has-css.css",
2987 "Light×Link component with CSS should have css_href"
2988 );
2989
2990 let no_href = result
2991 .protocol
2992 .components
2993 .get("no-css")
2994 .map(|c| c.css_href.as_str())
2995 .unwrap_or("");
2996 assert!(
2997 no_href.is_empty(),
2998 "Component without CSS should have empty css_href"
2999 );
3000 }
3001
3002 #[test]
3003 fn test_plugin_free_build_persists_component_shadow_usage() {
3004 let app = create_app_dir(&[
3005 (
3006 "index.html",
3007 "<light-card></light-card><shadow-card></shadow-card>",
3008 ),
3009 ("light-card.html", "<p>light</p>"),
3010 (
3011 "shadow-card.html",
3012 "<template shadowrootmode=\"open\"><slot></slot></template>",
3013 ),
3014 ]);
3015 let mut options = default_options(app.path());
3016 options.dom = DomStrategy::Light;
3017 options.plugin = None;
3018 let result = build(options).unwrap();
3019
3020 assert!(!result.protocol.component_uses_shadow_dom("light-card"));
3021 assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3022 assert!(!result.protocol.components["light-card"].uses_shadow_dom);
3023 assert!(result.protocol.components["shadow-card"].uses_shadow_dom);
3024
3025 let entry_html: String = result.protocol.fragments["index.html"]
3026 .fragments
3027 .iter()
3028 .filter_map(|fragment| match fragment.fragment.as_ref() {
3029 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3030 _ => None,
3031 })
3032 .collect();
3033 assert!(entry_html.contains("<light-card>"));
3034 assert!(entry_html.contains("<shadow-card>"));
3035 assert!(!entry_html.contains("<shadow-card data-wl"));
3036 }
3037
3038 #[test]
3039 fn test_authored_shadow_under_light_matches_ssr_protocol_and_webui_metadata() {
3040 let app = create_app_dir(&[
3041 ("index.html", "<shadow-card>projected</shadow-card>"),
3042 (
3043 "shadow-card.html",
3044 "<!-- lead --><template shadowrootmode=\"open\"><slot></slot></template><!-- tail -->",
3045 ),
3046 ]);
3047 let mut options = default_options(app.path());
3048 options.plugin = Some(Plugin::WebUI);
3049 let result = build(options).unwrap();
3050
3051 assert!(result.protocol.component_uses_shadow_dom("shadow-card"));
3052 assert!(result.protocol.components["shadow-card"]
3053 .template_json
3054 .contains("\"sd\":1"));
3055
3056 let component_html: String = result.protocol.fragments["shadow-card"]
3057 .fragments
3058 .iter()
3059 .filter_map(|fragment| match fragment.fragment.as_ref() {
3060 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3061 _ => None,
3062 })
3063 .collect();
3064 assert_eq!(
3065 component_html,
3066 "<template shadowrootmode=\"open\"><slot></slot></template>"
3067 );
3068 let entry_html: String = result.protocol.fragments["index.html"]
3069 .fragments
3070 .iter()
3071 .filter_map(|fragment| match fragment.fragment.as_ref() {
3072 Some(Fragment::Raw(raw)) => Some(raw.value.as_str()),
3073 _ => None,
3074 })
3075 .collect();
3076 assert!(!entry_html.contains("data-wl"));
3077 }
3078
3079 #[test]
3080 fn test_shadow_dom_link_strategy_sets_css_href() {
3081 let app = create_app_dir(&[
3085 ("index.html", "<my-card>A</my-card>"),
3086 (
3087 "my-card.html",
3088 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3089 ),
3090 ("my-card.css", ".card { color: red; }"),
3091 ]);
3092 let result = build(default_options(app.path())).unwrap();
3093
3094 let comp = result.protocol.components.get("my-card").unwrap();
3095 assert_eq!(
3096 comp.css_href, "my-card.css",
3097 "Shadow×Link should set css_href"
3098 );
3099
3100 assert_eq!(
3102 result.protocol.css_strategy(),
3103 webui_protocol::CssStrategy::Link,
3104 );
3105
3106 assert_eq!(
3108 result.css_files.len(),
3109 1,
3110 "CSS file should still be produced"
3111 );
3112 }
3113
3114 #[test]
3115 fn test_css_href_empty_for_style_strategy() {
3116 let app = create_app_dir(&[
3117 ("index.html", "<my-card>A</my-card>"),
3118 (
3119 "my-card.html",
3120 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3121 ),
3122 ("my-card.css", ".card { color: red; }"),
3123 ]);
3124 let mut options = default_options(app.path());
3125 options.css = CssStrategy::Style;
3126 let result = build(options).unwrap();
3127
3128 let href = result
3129 .protocol
3130 .components
3131 .get("my-card")
3132 .map(|c| c.css_href.as_str())
3133 .unwrap_or("");
3134 assert!(href.is_empty(), "Style-strategy should not set css_href");
3135 }
3136
3137 #[test]
3138 fn test_css_href_empty_for_module_strategy() {
3139 let app = create_app_dir(&[
3140 ("index.html", "<my-card>A</my-card>"),
3141 (
3142 "my-card.html",
3143 r#"<template shadowrootmode="open"><p><slot></slot></p></template>"#,
3144 ),
3145 ("my-card.css", ".card { color: red; }"),
3146 ]);
3147 let mut options = default_options(app.path());
3148 options.css = CssStrategy::Module;
3149 let result = build(options).unwrap();
3150
3151 let href = result
3152 .protocol
3153 .components
3154 .get("my-card")
3155 .map(|c| c.css_href.as_str())
3156 .unwrap_or("");
3157 assert!(href.is_empty(), "Module-strategy should not set css_href");
3158 assert!(
3159 !result.protocol.components["my-card"].css.is_empty(),
3160 "Module-strategy should still populate css content"
3161 );
3162 }
3163
3164 #[test]
3165 fn test_build_to_disk_writes_files() {
3166 let app = create_app_dir(&[
3167 ("index.html", "<my-card>Hello</my-card>"),
3168 (
3169 "my-card.html",
3170 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3171 ),
3172 ("my-card.css", ".card { color: red; }"),
3173 ("my-card.ts", "export {};"),
3174 ]);
3175 let out = TempDir::new().unwrap();
3176
3177 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
3178
3179 assert!(out.path().join("protocol.bin").exists());
3180 assert!(out.path().join("my-card.css").exists());
3181 assert_eq!(stats.css_file_count, 1);
3182 assert!(stats.fragment_count > 0);
3183 }
3184
3185 #[test]
3186 fn test_build_to_disk_creates_nested_output_dir() {
3187 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3188 let out = TempDir::new().unwrap();
3189 let nested = out.path().join("nested").join("output");
3190
3191 let stats = build_to_disk(default_options(app.path()), &nested).unwrap();
3192 assert!(nested.join("protocol.bin").exists());
3193 assert!(stats.fragment_count > 0);
3194 }
3195
3196 #[test]
3197 fn test_build_missing_entry() {
3198 let app = create_app_dir(&[("other.html", "<h1>Not index</h1>")]);
3199 let result = build(default_options(app.path()));
3200
3201 assert!(result.is_err());
3202 let err = result.unwrap_err();
3203 assert!(matches!(err, WebUIError::Io { .. }));
3204 }
3205
3206 #[test]
3207 fn test_build_stats_populated() {
3208 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3209 let result = build(default_options(app.path())).unwrap();
3210
3211 assert!(result.stats.fragment_count > 0);
3212 assert!(result.stats.protocol_size_bytes > 0);
3213 assert_eq!(result.stats.css_file_count, 0);
3214 }
3215
3216 #[test]
3217 fn test_build_inline_css() {
3218 let app = create_app_dir(&[
3219 ("index.html", "<my-card>Hello</my-card>"),
3220 (
3221 "my-card.html",
3222 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3223 ),
3224 ("my-card.css", ".card { color: red; }"),
3225 ]);
3226 let mut options = default_options(app.path());
3227 options.css = CssStrategy::Style;
3228
3229 let result = build(options).unwrap();
3230 assert!(result.css_files.is_empty());
3232 assert_eq!(result.stats.css_file_count, 0);
3233 assert!(result.stats.fragment_count > 0);
3234 }
3235
3236 #[test]
3237 fn test_build_module_css() {
3238 let app = create_app_dir(&[
3239 ("index.html", "<my-card>Hello</my-card>"),
3240 (
3241 "my-card.html",
3242 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3243 ),
3244 ("my-card.css", ".card { color: red; }"),
3245 ]);
3246 let mut options = default_options(app.path());
3247 options.css = CssStrategy::Module;
3248
3249 let result = build(options).unwrap();
3250 assert!(result.css_files.is_empty());
3252 assert_eq!(result.stats.css_file_count, 0);
3253 assert!(result.stats.fragment_count > 0);
3254 }
3255
3256 #[test]
3257 fn test_build_custom_entry() {
3258 let app = create_app_dir(&[("page.html", "<h1>Custom</h1>")]);
3259 let mut options = default_options(app.path());
3260 options.entry = "page.html".to_string();
3261
3262 let result = build(options).unwrap();
3263 assert!(result.protocol.fragments.contains_key("page.html"));
3264 assert!(!result.protocol.fragments.contains_key("index.html"));
3265 }
3266
3267 #[test]
3268 fn test_build_protocol_roundtrip() {
3269 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3270 let result = build(default_options(app.path())).unwrap();
3271
3272 let restored = WebUIProtocol::from_protobuf(&result.protocol_bytes).unwrap();
3273 assert!(restored.fragments.contains_key("index.html"));
3274 }
3275
3276 #[test]
3277 fn protocol_is_available_from_facade() {
3278 let protocol = Protocol::new(WebUIProtocol::new(HashMap::new()));
3279 assert!(protocol.tokens().is_empty());
3280 }
3281
3282 #[test]
3283 fn test_inspect_bytes_valid() {
3284 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3285 let result = build(default_options(app.path())).unwrap();
3286
3287 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3288 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3289 assert!(parsed.get("fragments").is_some());
3290 }
3291
3292 #[test]
3293 fn test_inspect_bytes_invalid() {
3294 let result = inspect_bytes(b"not a protobuf");
3295 assert!(result.is_err());
3296 assert!(matches!(result.unwrap_err(), WebUIError::Protocol(_)));
3297 }
3298
3299 #[test]
3300 fn test_inspect_file() {
3301 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3302 let out = TempDir::new().unwrap();
3303 build_to_disk(default_options(app.path()), out.path()).unwrap();
3304
3305 let json_str = inspect(&out.path().join("protocol.bin")).unwrap();
3306 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3307 assert!(parsed["fragments"]["index.html"]["fragments"].is_array());
3308 }
3309
3310 #[test]
3311 fn test_inspect_missing_file() {
3312 let result = inspect(Path::new("/nonexistent/protocol.bin"));
3313 assert!(result.is_err());
3314 assert!(matches!(result.unwrap_err(), WebUIError::Io { .. }));
3315 }
3316
3317 #[test]
3318 fn test_build_with_components_local_path() {
3319 let app = create_app_dir(&[("index.html", "<ext-card>Hello</ext-card>")]);
3320 let ext_dir = TempDir::new().unwrap();
3321 fs::write(
3322 ext_dir.path().join("ext-card.html"),
3323 r#"<template shadowrootmode="open"><div class="card"><slot></slot></div></template>"#,
3324 )
3325 .unwrap();
3326 fs::write(
3327 ext_dir.path().join("ext-card.css"),
3328 ".card { border: 1px solid #ccc; }",
3329 )
3330 .unwrap();
3331
3332 let mut options = default_options(app.path());
3333 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3334
3335 let result = build(options).unwrap();
3336 assert!(result.protocol.fragments.contains_key("index.html"));
3337 assert_eq!(result.css_files.len(), 1);
3338 assert!(result.css_files[0].1.contains("border"));
3339 }
3340
3341 #[test]
3342 fn test_build_with_scriptless_component_path_emits_dormant_template() {
3343 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3344 let ext_dir = TempDir::new().unwrap();
3345 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3346
3347 let mut options = default_options(app.path());
3348 options.plugin = Some(Plugin::WebUI);
3349 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3350
3351 let result = build(options).unwrap();
3352 assert!(result
3353 .component_templates
3354 .iter()
3355 .any(|template| template.tag_name == "ext-card"));
3356 let component = result
3357 .protocol
3358 .components
3359 .get("ext-card")
3360 .expect("external scriptless component metadata should be retained");
3361 assert!(component.hydration_keys.is_empty());
3362 assert_eq!(component.navigation_keys, ["title"]);
3363 assert!(component.template_json.contains(r#""th":1"#));
3364 }
3365
3366 #[test]
3367 fn test_build_with_scripted_component_path_emits_client_artifact() {
3368 let app = create_app_dir(&[("index.html", "<ext-card></ext-card>")]);
3369 let ext_dir = TempDir::new().unwrap();
3370 fs::write(ext_dir.path().join("ext-card.html"), "<p>{{title}}</p>").unwrap();
3371 fs::write(ext_dir.path().join("ext-card.ts"), "export {};").unwrap();
3372
3373 let mut options = default_options(app.path());
3374 options.plugin = Some(Plugin::WebUI);
3375 options.components = vec![ext_dir.path().to_string_lossy().to_string()];
3376
3377 let result = build(options).unwrap();
3378 let template = &result.protocol.components["ext-card"].template_json;
3379 assert!(!template.is_empty(), "template should be emitted");
3380 }
3381
3382 #[test]
3383 fn test_build_rejects_projection_manifest_with_non_webui_plugin() {
3384 let app = create_app_dir(&[
3385 ("index.html", "<demo-card></demo-card>"),
3386 ("demo-card.html", "<p>{{name}}</p>"),
3387 ("demo-card.ts", "export {};"),
3388 ]);
3389 let manifest_json = projection::test_support::build_valid_manifest_json(
3390 app.path(),
3391 &[("demo-card.ts", "export {};")],
3392 &[("projection-bundle.js", "output")],
3393 &[(
3394 "demo-card",
3395 "demo-card.ts",
3396 &["projection-bundle.js"],
3397 &["name"],
3398 &["name"],
3399 )],
3400 );
3401 let manifest = projection::test_support::write_manifest(
3402 app.path(),
3403 "webui-projection.json",
3404 &manifest_json,
3405 );
3406 let mut options = default_options(app.path());
3407 options.projection_manifests = vec![manifest.into()];
3410 let err = build(options).expect_err("non-WebUI plugin + manifest should be rejected");
3411 assert!(
3412 err.chain_message().contains("PROJ-B002"),
3413 "msg: {}",
3414 err.chain_message()
3415 );
3416 }
3417
3418 #[test]
3419 fn test_build_rejects_missing_scripted_component_coverage() {
3420 let app = create_app_dir(&[
3421 ("index.html", "<demo-card></demo-card>"),
3422 ("demo-card.html", "<p>{{name}}</p>"),
3423 ("demo-card.ts", "export {};"),
3424 ]);
3425 let manifest_json =
3428 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3429 let manifest = projection::test_support::write_manifest(
3430 app.path(),
3431 "webui-projection.json",
3432 &manifest_json,
3433 );
3434 let mut options = default_options(app.path());
3435 options.plugin = Some(Plugin::WebUI);
3436 options.projection_manifests = vec![manifest.into()];
3437 let err = build(options).expect_err("missing coverage should fail the build");
3438 let message = err.chain_message();
3439 assert!(message.contains("PROJ-B001"), "msg: {message}");
3440 assert!(message.contains("demo-card"), "msg: {message}");
3441 }
3442
3443 #[test]
3444 fn test_build_allows_unused_discovered_scripted_component_without_coverage() {
3445 let app = create_app_dir(&[
3446 ("index.html", "<demo-card></demo-card>"),
3447 ("demo-card.html", "<p>{{name}}</p>"),
3448 ("demo-card.ts", "export {};"),
3449 ("unused-card.html", "<p>{{title}}</p>"),
3453 ("unused-card.ts", "export {};"),
3454 ]);
3455 let manifest_json = projection::test_support::build_valid_manifest_json(
3456 app.path(),
3457 &[("demo-card.ts", "export {};")],
3458 &[("projection-bundle.js", "output")],
3459 &[(
3460 "demo-card",
3461 "demo-card.ts",
3462 &["projection-bundle.js"],
3463 &["name"],
3464 &["name"],
3465 )],
3466 );
3467 let manifest = projection::test_support::write_manifest(
3468 app.path(),
3469 "webui-projection.json",
3470 &manifest_json,
3471 );
3472 let mut options = default_options(app.path());
3473 options.plugin = Some(Plugin::WebUI);
3474 options.projection_manifests = vec![manifest.into()];
3475 let result = build(options).unwrap();
3476
3477 assert!(!result.protocol.fragments.contains_key("unused-card"));
3478 let component = result.protocol.components.get("demo-card").unwrap();
3479 assert_eq!(component.hydration_keys, vec!["name"]);
3480 }
3481
3482 #[test]
3483 fn test_build_allows_asset_only_scripted_component_without_projection_coverage() {
3484 let app = create_app_dir(&[
3485 ("index.html", "<app-shell></app-shell>"),
3486 ("app-shell.html", "<p>Entry</p>"),
3487 ("lazy-panel.html", "<p>{{title}}</p>"),
3488 ("lazy-panel.ts", "export {};"),
3489 ]);
3490 let manifest_json =
3491 projection::test_support::build_valid_manifest_json(app.path(), &[], &[], &[]);
3492 let manifest = projection::test_support::write_manifest(
3493 app.path(),
3494 "webui-projection.json",
3495 &manifest_json,
3496 );
3497 let mut options = default_options(app.path());
3498 options.plugin = Some(Plugin::WebUI);
3499 options.component_asset_roots = vec!["lazy-panel".to_string()];
3500 options.projection_manifests = vec![manifest.into()];
3501
3502 let result = build(options).unwrap();
3503
3504 assert_eq!(result.component_asset_files.len(), 1);
3505 assert!(!result.protocol.fragments.contains_key("lazy-panel"));
3506 assert!(!result.protocol.components.contains_key("lazy-panel"));
3507 }
3508
3509 #[test]
3510 fn test_build_merges_multiple_projection_manifest_fragments() {
3511 let app = create_app_dir(&[
3512 (
3513 "index.html",
3514 "<html><body><card-a></card-a><card-b></card-b></body></html>",
3515 ),
3516 ("card-a.html", "<p>{{alpha}}</p>"),
3517 ("card-a.ts", "export {};"),
3518 ("card-b.html", "<p>{{beta}}</p>"),
3519 ("card-b.ts", "export {};"),
3520 ]);
3521 let json_a = projection::test_support::build_valid_manifest_json(
3522 app.path(),
3523 &[("card-a.ts", "export {};")],
3524 &[("card-a.js", "output-a")],
3525 &[(
3526 "card-a",
3527 "card-a.ts",
3528 &["card-a.js"],
3529 &["alpha"],
3530 &["alpha"],
3531 )],
3532 );
3533 let manifest_a =
3534 projection::test_support::write_manifest(app.path(), "fragment-a.json", &json_a);
3535 let json_b = projection::test_support::build_valid_manifest_json(
3536 app.path(),
3537 &[("card-b.ts", "export {};")],
3538 &[("card-b.js", "output-b")],
3539 &[("card-b", "card-b.ts", &["card-b.js"], &["beta"], &["beta"])],
3540 );
3541 let manifest_b =
3542 projection::test_support::write_manifest(app.path(), "fragment-b.json", &json_b);
3543
3544 let mut options = default_options(app.path());
3545 options.plugin = Some(Plugin::WebUI);
3546 options.projection_manifests = vec![manifest_a.into(), manifest_b.into()];
3547 let result = build(options).unwrap();
3548
3549 assert_eq!(
3550 result.protocol.initial_state_strategy,
3551 webui_protocol::InitialStateStrategy::Components as i32
3552 );
3553 let card_a = result.protocol.components.get("card-a").unwrap();
3554 assert_eq!(card_a.hydration_keys, vec!["alpha"]);
3555 let card_b = result.protocol.components.get("card-b").unwrap();
3556 assert_eq!(card_b.hydration_keys, vec!["beta"]);
3557 }
3558
3559 #[test]
3560 fn test_build_hello_world_example() {
3561 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
3562 let app_dir = manifest_dir.join("../../examples/app/hello-world/src");
3563
3564 let result = build(BuildOptions {
3565 app_dir,
3566 entry: "index.html".to_string(),
3567 ..BuildOptions::default()
3568 })
3569 .unwrap();
3570
3571 let index = &result.protocol.fragments["index.html"].fragments;
3572 assert!(index.iter().any(
3573 |f| matches!(f.fragment.as_ref(), Some(Fragment::ForLoop(fl)) if fl.collection == "people")
3574 ));
3575 assert!(index
3576 .iter()
3577 .any(|f| matches!(f.fragment.as_ref(), Some(Fragment::IfCond(_)))));
3578 }
3579
3580 #[test]
3583 fn test_css_filename_sanitizes_path_separators() {
3584 let app = create_app_dir(&[("index.html", "<h1>Hello</h1>")]);
3587 let result = build(default_options(app.path())).unwrap();
3588
3589 for (filename, _) in &result.css_files {
3591 assert!(
3592 !filename.contains('/') && !filename.contains('\\'),
3593 "CSS filename contains path separator: {filename}"
3594 );
3595 }
3596 }
3597
3598 #[test]
3599 fn test_build_to_disk_css_stays_in_output_dir() {
3600 let app = create_app_dir(&[
3601 ("index.html", "<my-card>Hello</my-card>"),
3602 (
3603 "my-card.html",
3604 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3605 ),
3606 ("my-card.css", ".card { color: red; }"),
3607 ]);
3608 let out = TempDir::new().unwrap();
3609
3610 build_to_disk(default_options(app.path()), out.path()).unwrap();
3611
3612 let css_path = out.path().join("my-card.css");
3614 assert!(css_path.exists());
3615 let canonical = css_path.canonicalize().unwrap();
3616 let out_canonical = out.path().canonicalize().unwrap();
3617 assert!(
3618 canonical.starts_with(&out_canonical),
3619 "CSS file escaped output directory"
3620 );
3621 }
3622
3623 #[test]
3626 fn test_build_empty_html() {
3627 let app = create_app_dir(&[("index.html", "")]);
3628 let result = build(default_options(app.path())).unwrap();
3629
3630 assert!(result.protocol.fragments.contains_key("index.html"));
3631 assert_eq!(result.stats.fragment_count, 0);
3632 assert!(result.stats.protocol_size_bytes > 0);
3633 }
3634
3635 #[test]
3636 fn test_build_large_fragment_count() {
3637 let mut html = String::with_capacity(2000);
3639 for i in 0..50 {
3640 html.push_str(&format!("<p>Item {i}</p>\n"));
3641 }
3642 let app = create_app_dir(&[("index.html", &html)]);
3643 let result = build(default_options(app.path())).unwrap();
3644
3645 assert!(result.stats.fragment_count > 0);
3646 assert_eq!(result.stats.css_file_count, 0);
3647 }
3648
3649 #[test]
3650 fn test_build_to_disk_inline_mode_no_css_files() {
3651 let app = create_app_dir(&[
3652 ("index.html", "<my-card>Hello</my-card>"),
3653 (
3654 "my-card.html",
3655 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3656 ),
3657 ("my-card.css", ".card { color: red; }"),
3658 ]);
3659 let out = TempDir::new().unwrap();
3660 let mut options = default_options(app.path());
3661 options.css = CssStrategy::Style;
3662
3663 let stats = build_to_disk(options, out.path()).unwrap();
3664
3665 assert!(out.path().join("protocol.bin").exists());
3666 assert!(!out.path().join("my-card.css").exists());
3667 assert_eq!(stats.css_file_count, 0);
3668 }
3669
3670 #[test]
3671 fn test_build_stats_duration_is_nonzero() {
3672 let app = create_app_dir(&[("index.html", "<h1>Hello {{name}}</h1>")]);
3673 let result = build(default_options(app.path())).unwrap();
3674
3675 assert!(!result.stats.duration.is_zero());
3676 }
3677
3678 #[test]
3679 fn test_build_multiple_components_css() {
3680 let app = create_app_dir(&[
3681 ("index.html", "<card-a>A</card-a><card-b>B</card-b>"),
3682 (
3683 "card-a.html",
3684 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3685 ),
3686 ("card-a.css", ".a { color: red; }"),
3687 (
3688 "card-b.html",
3689 r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3690 ),
3691 ("card-b.css", ".b { color: blue; }"),
3692 ]);
3693 let result = build(default_options(app.path())).unwrap();
3694
3695 assert_eq!(result.css_files.len(), 2);
3696 assert_eq!(result.stats.css_file_count, 2);
3697 let filenames: Vec<&str> = result.css_files.iter().map(|(n, _)| n.as_str()).collect();
3698 assert!(filenames.contains(&"card-a.css"));
3699 assert!(filenames.contains(&"card-b.css"));
3700 }
3701
3702 #[test]
3703 fn test_build_unused_component_css_not_emitted() {
3704 let app = create_app_dir(&[
3706 ("index.html", "<card-a>A</card-a>"),
3707 (
3708 "card-a.html",
3709 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
3710 ),
3711 ("card-a.css", ".a { color: red; }"),
3712 (
3713 "card-b.html",
3714 r#"<template shadowrootmode="open"><span><slot></slot></span></template>"#,
3715 ),
3716 ("card-b.css", ".b { color: blue; }"),
3717 ]);
3718 let result = build(default_options(app.path())).unwrap();
3719
3720 assert_eq!(result.css_files.len(), 1);
3721 assert_eq!(result.css_files[0].0, "card-a.css");
3722 }
3723
3724 #[test]
3725 fn test_build_unused_component_render_policy_not_emitted() {
3726 let app = create_app_dir(&[
3727 ("index.html", "<card-a>A</card-a>"),
3728 (
3729 "card-a.html",
3730 r#"<template w-render="lazy" w-reserve-block-size="10px"><div>a</div></template>"#,
3731 ),
3732 (
3733 "card-b.html",
3734 r#"<template w-render="lazy" w-reserve-block-size="20px"><div>b</div></template>"#,
3735 ),
3736 ]);
3737 let result = build(default_options(app.path())).unwrap();
3738
3739 assert!(result.protocol.component_render_css.contains("card-a"));
3740 assert!(!result.protocol.component_render_css.contains("card-b"));
3741 }
3742
3743 #[test]
3744 fn test_build_persists_interaction_work_policy() {
3745 let app = create_app_dir(&[
3746 ("index.html", "<interaction-shell></interaction-shell>"),
3747 (
3748 "interaction-shell.html",
3749 r#"<template w-hydrate="interaction"><button>Open</button></template>"#,
3750 ),
3751 (
3752 "interaction-shell.ts",
3753 "customElements.define('interaction-shell', class extends HTMLElement {});",
3754 ),
3755 ]);
3756 for plugin in [Plugin::WebUI, Plugin::FastV3] {
3757 let is_webui = matches!(plugin, Plugin::WebUI);
3758 let mut options = default_options(app.path());
3759 options.plugin = Some(plugin);
3760 let result = build(options).expect("interaction policy build");
3761 let component = &result.protocol.components["interaction-shell"];
3762 assert_eq!(
3763 component.work_policy,
3764 webui_protocol::ComponentWorkPolicy::Interaction as i32
3765 );
3766 if is_webui {
3767 assert!(component.template_json.contains(r#""wp":3"#));
3768 }
3769 }
3770 }
3771
3772 #[test]
3773 fn test_build_persists_combined_render_interaction_policy() {
3774 let app = create_app_dir(&[
3775 ("index.html", "<interaction-panel></interaction-panel>"),
3776 (
3777 "interaction-panel.html",
3778 concat!(
3779 r#"<template w-render="lazy" w-reserve-block-size="18rem" "#,
3780 r#"w-hydrate="interaction"><button>Open</button></template>"#,
3781 ),
3782 ),
3783 (
3784 "interaction-panel.ts",
3785 "customElements.define('interaction-panel', class extends HTMLElement {});",
3786 ),
3787 ]);
3788 for plugin in [Plugin::WebUI, Plugin::FastV3] {
3789 let is_webui = matches!(plugin, Plugin::WebUI);
3790 let mut options = default_options(app.path());
3791 options.plugin = Some(plugin);
3792 let result = build(options).expect("combined interaction policy build");
3793 let component = &result.protocol.components["interaction-panel"];
3794 assert_eq!(
3795 component.work_policy,
3796 webui_protocol::ComponentWorkPolicy::LazyRenderInteraction as i32
3797 );
3798 assert!(result
3799 .protocol
3800 .component_render_css
3801 .contains("interaction-panel"));
3802 if is_webui {
3803 assert!(component.template_json.contains(r#""wp":4"#));
3804 }
3805 }
3806 }
3807
3808 #[test]
3809 fn test_inspect_roundtrip_preserves_content() {
3810 let app = create_app_dir(&[("index.html", "<h1>{{title}}</h1>")]);
3811 let result = build(default_options(app.path())).unwrap();
3812
3813 let json_str = inspect_bytes(&result.protocol_bytes).unwrap();
3814 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
3815
3816 let fragments = &parsed["fragments"]["index.html"]["fragments"];
3817 assert!(fragments.is_array());
3818 assert!(!fragments.as_array().unwrap().is_empty());
3819 }
3820
3821 #[test]
3822 fn test_build_nonexistent_app_dir() {
3823 let options = BuildOptions {
3824 app_dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
3825 ..BuildOptions::default()
3826 };
3827 let result = build(options);
3828 assert!(result.is_err());
3829 }
3830
3831 #[test]
3832 fn test_build_allows_fallback_chain_when_all_theme_tokens_exist() {
3833 let app = create_app_dir(&[
3834 ("index.html", "<my-card></my-card>"),
3835 ("my-card.html", "<div>Card</div>"),
3836 (
3837 "my-card.css",
3838 ":host { color: var(--token-a, var(--token-b, var(--token-c))); }",
3839 ),
3840 ]);
3841 let mut options = default_options(app.path());
3842 options.theme = Some(TokenFile {
3843 themes: HashMap::from([(
3844 "light".to_string(),
3845 HashMap::from([
3846 ("token-a".to_string(), "red".to_string()),
3847 ("token-b".to_string(), "blue".to_string()),
3848 ("token-c".to_string(), "green".to_string()),
3849 ]),
3850 )]),
3851 });
3852
3853 let result = build(options).expect("all fallback tokens are defined");
3854 assert!(result.protocol.tokens.contains(&"token-a".to_string()));
3855 assert!(result.protocol.tokens.contains(&"token-b".to_string()));
3856 assert!(result.protocol.tokens.contains(&"token-c".to_string()));
3857 }
3858
3859 #[test]
3860 fn test_build_allows_literal_fallback_token_absent_from_theme() {
3861 let app = create_app_dir(&[
3862 ("index.html", "<my-card></my-card>"),
3863 ("my-card.html", "<div>Card</div>"),
3864 ("my-card.css", ":host { color: var(--brand, #000); }"),
3865 ]);
3866 let mut options = default_options(app.path());
3867 options.theme = Some(TokenFile {
3868 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3869 });
3870
3871 let result = build(options).expect("literal fallback should not fail the build");
3875 assert!(result.protocol.tokens.contains(&"brand".to_string()));
3876 assert_eq!(result.warnings.len(), 1, "warnings: {:?}", result.warnings);
3879 assert!(result.warnings[0].body().contains("--brand"));
3880 }
3881
3882 #[test]
3883 fn test_build_ancestor_inline_style_definition_after_comment_satisfies_descendant() {
3884 let app = create_app_dir(&[
3889 (
3890 "index.html",
3891 "<html><head><style>:root {\n /*{{{tokens.light}}}*/\n --foo-bar: 100px;\n}</style></head><body><my-card></my-card></body></html>",
3892 ),
3893 ("my-card.html", "<div>Card</div>"),
3894 ("my-card.css", ":host { padding: var(--foo-bar); }"),
3895 ]);
3896 let mut options = default_options(app.path());
3897 options.theme = Some(TokenFile {
3898 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3899 });
3900
3901 let result = build(options)
3902 .expect("ancestor inline-style definition (after a comment) should satisfy the child");
3903 assert!(
3904 !result.protocol.tokens.contains(&"foo-bar".to_string()),
3905 "ancestor-defined token should not be hoisted: {:?}",
3906 result.protocol.tokens
3907 );
3908 }
3909
3910 #[test]
3911 fn test_build_no_warning_when_literal_fallback_token_is_themed() {
3912 let app = create_app_dir(&[
3913 ("index.html", "<my-card></my-card>"),
3914 ("my-card.html", "<div>Card</div>"),
3915 ("my-card.css", ":host { color: var(--brand, #000); }"),
3916 ]);
3917 let mut options = default_options(app.path());
3918 options.theme = Some(TokenFile {
3919 themes: HashMap::from([(
3920 "light".to_string(),
3921 HashMap::from([("brand".to_string(), "#123456".to_string())]),
3922 )]),
3923 });
3924
3925 let result = build(options).expect("themed literal-fallback token builds");
3926 assert!(
3927 result.warnings.is_empty(),
3928 "a themed token must not warn: {:?}",
3929 result.warnings
3930 );
3931 }
3932
3933 #[test]
3934 fn test_build_without_theme_produces_no_warnings() {
3935 let app = create_app_dir(&[
3936 ("index.html", "<my-card></my-card>"),
3937 ("my-card.html", "<div>Card</div>"),
3938 ("my-card.css", ":host { color: var(--brand, #000); }"),
3939 ]);
3940 let result = build(default_options(app.path())).unwrap();
3941 assert!(result.warnings.is_empty());
3942 }
3943
3944 #[test]
3945 fn test_build_excludes_tokens_defined_by_ancestor_component_css() {
3946 let app = create_app_dir(&[
3947 ("index.html", "<component-a></component-a>"),
3948 ("component-a.html", "<component-b></component-b>"),
3949 ("component-a.css", ":host { --brand-color: red; }"),
3950 ("component-b.html", "<div>Child</div>"),
3951 ("component-b.css", ":host { color: var(--brand-color); }"),
3952 ]);
3953 let mut options = default_options(app.path());
3954 options.theme = Some(TokenFile {
3955 themes: HashMap::from([("light".to_string(), HashMap::new())]),
3956 });
3957
3958 let result =
3959 build(options).expect("ancestor component custom property should satisfy child");
3960 assert!(
3961 !result.protocol.tokens.contains(&"brand-color".to_string()),
3962 "ancestor-defined token should not be exposed: {:?}",
3963 result.protocol.tokens
3964 );
3965 }
3966
3967 #[test]
3968 fn test_build_scoped_parent_overrides_preserve_theme_defaults() {
3969 for selector in [
3970 ".green",
3971 ":host([split]) plain-control",
3972 ":host([active])",
3973 "@media (width > 10000px) { :host",
3974 "@supports (display: unknown) { :host",
3975 "@container (width > 10000px) { :host",
3976 ] {
3977 let mut css = format!("{selector} {{ --brand: var(--green); }}");
3978 if selector.starts_with('@') {
3979 css.push('}');
3980 }
3981 let app = create_app_dir(&[
3982 ("index.html", "<layout-container></layout-container>"),
3983 (
3984 "layout-container.html",
3985 "<plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
3986 ),
3987 ("layout-container.css", &css),
3988 ("plain-control.html", "<button>Primary</button>"),
3989 ("plain-control.css", "button { color: var(--brand); }"),
3990 ]);
3991 let mut options = default_options(app.path());
3992 options.theme = Some(TokenFile {
3993 themes: HashMap::from([(
3994 "light".to_string(),
3995 HashMap::from([
3996 ("brand".to_string(), "black".to_string()),
3997 ("green".to_string(), "green".to_string()),
3998 ]),
3999 )]),
4000 });
4001 let result = build(options).expect("scoped overrides should build");
4002 assert_eq!(result.protocol.tokens, ["brand", "green"], "{selector}");
4003
4004 let mut options = default_options(app.path());
4005 options.theme = Some(TokenFile {
4006 themes: HashMap::from([(
4007 "light".to_string(),
4008 HashMap::from([("green".to_string(), "green".to_string())]),
4009 )]),
4010 });
4011 let error = build(options).expect_err("uncovered child still requires --brand");
4012 assert!(error.chain_message().contains("missing-theme-token"));
4013 }
4014 }
4015
4016 #[test]
4017 fn test_build_scoped_inline_overrides_preserve_theme_defaults() {
4018 let app = create_app_dir(&[
4019 (
4020 "index.html",
4021 "<style>.green { --brand: var(--green); }</style><plain-control></plain-control><div class=\"green\"><plain-control></plain-control></div>",
4022 ),
4023 ("plain-control.html", "<button>Primary</button>"),
4024 ("plain-control.css", "button { color: var(--brand); }"),
4025 ]);
4026 let result = build(default_options(app.path())).expect("build");
4027 assert_eq!(result.protocol.tokens, ["brand", "green"]);
4028 }
4029
4030 #[test]
4031 fn test_build_does_not_use_sibling_component_definitions_for_theme_validation() {
4032 let app = create_app_dir(&[
4033 (
4034 "index.html",
4035 "<component-a></component-a><component-b></component-b>",
4036 ),
4037 ("component-a.html", "<div>A</div>"),
4038 ("component-a.css", ":host { --brand-color: red; }"),
4039 ("component-b.html", "<div>B</div>"),
4040 ("component-b.css", ":host { color: var(--brand-color); }"),
4041 ]);
4042 let mut options = default_options(app.path());
4043 options.theme = Some(TokenFile {
4044 themes: HashMap::from([("light".to_string(), HashMap::new())]),
4045 });
4046
4047 let Err(err) = build(options) else {
4048 panic!("sibling custom property must not satisfy component-b");
4049 };
4050 let message = err.chain_message();
4051 assert!(message.contains("missing theme token"), "msg: {message}");
4052 assert!(message.contains("missing-theme-token"), "msg: {message}");
4053 assert!(message.contains("--brand-color"), "msg: {message}");
4054 }
4055
4056 #[test]
4057 fn test_build_rejects_missing_theme_token_after_local_definition_filter() {
4058 let app = create_app_dir(&[
4059 ("index.html", "<my-card></my-card>"),
4060 ("my-card.html", "<div>Card</div>"),
4061 (
4062 "my-card.css",
4063 ":host { --token-a: red; --foo-bar: var(--token-a, var(--token-b, var(--token-c))); }",
4064 ),
4065 ]);
4066 let mut options = default_options(app.path());
4067 options.theme = Some(TokenFile {
4068 themes: HashMap::from([(
4069 "light".to_string(),
4070 HashMap::from([("token-b".to_string(), "green".to_string())]),
4071 )]),
4072 });
4073
4074 let Err(err) = build(options) else {
4075 panic!("missing --token-c in the theme must fail the build");
4076 };
4077 let message = err.chain_message();
4078 assert!(message.contains("missing-theme-token"), "msg: {message}");
4079 assert!(message.contains("--token-c"), "msg: {message}");
4080 assert!(!message.contains("add --token-a"), "msg: {message}");
4083 }
4084
4085 #[test]
4086 fn test_build_to_disk_returns_accurate_stats() {
4087 let app = create_app_dir(&[
4088 ("index.html", "<my-card>Hello</my-card><p>{{name}}</p>"),
4089 (
4090 "my-card.html",
4091 r#"<template shadowrootmode="open"><div><slot></slot></div></template>"#,
4092 ),
4093 ("my-card.css", ".card { color: red; }"),
4094 ]);
4095 let out = TempDir::new().unwrap();
4096
4097 let stats = build_to_disk(default_options(app.path()), out.path()).unwrap();
4098
4099 assert!(stats.fragment_count > 0);
4100 assert_eq!(stats.css_file_count, 1);
4101 assert!(stats.component_count > 0);
4102 assert!(stats.protocol_size_bytes > 0);
4103 assert!(!stats.duration.is_zero());
4104 }
4105}