Skip to main content

webui_parser/
component_registry.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Component registry for WebUI framework.
5//!
6//! This module manages the registry of web components used in the application.
7
8use crate::component_policy::{
9    parse_component_render_policy, validate_policy_client_ownership, ComponentRenderPolicy,
10};
11use crate::plugin::{ComponentSource, ComponentSourceTransform, TransformedComponentSource};
12use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
13use std::collections::{HashMap, HashSet};
14#[cfg(feature = "fs")]
15use std::fs;
16#[cfg(feature = "fs")]
17use std::path::Path;
18use std::sync::Arc;
19#[cfg(feature = "fs")]
20use walkdir::WalkDir;
21
22type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
23
24/// Represents a web component in the registry.
25///
26/// ```
27/// use webui_parser::Component;
28///
29/// let component = Component {
30///     tag_name: "example-card".to_string(),
31///     html_content: "<p>Example</p>".to_string(),
32///     css_content: None,
33///     css_definitions: Vec::new(),
34///     css_fallback_chains: Vec::new(),
35///     is_client_owned: false,
36/// };
37/// assert_eq!(component.tag_name, "example-card");
38/// ```
39#[derive(Debug, Clone)]
40pub struct Component {
41    /// The custom element tag name (e.g., "hello-world")
42    pub tag_name: String,
43
44    /// The HTML content of the component
45    pub html_content: String,
46
47    /// The CSS content of the component, if any
48    pub css_content: Option<String>,
49
50    /// Unconditional `:host`/`:root` custom property defaults from this component's CSS.
51    pub css_definitions: Vec<String>,
52
53    /// CSS `var()` fallback chains from this component's CSS.
54    pub css_fallback_chains: Vec<CssFallbackChain>,
55
56    /// Whether authored browser code owns this custom element tag.
57    pub is_client_owned: bool,
58}
59
60/// Inputs for registering a component from content strings.
61///
62/// Grouping the fields keeps [`ComponentRegistry::register_component`] a
63/// single-argument call without growing its arity.
64#[derive(Debug, Clone)]
65pub struct ComponentRegistration<'a> {
66    /// The custom element tag name (must contain a hyphen).
67    pub tag_name: &'a str,
68    /// The component's HTML template content.
69    pub html_content: &'a str,
70    /// The component's CSS content, if any.
71    pub css_content: Option<&'a str>,
72    /// Whether authored browser code owns this custom element tag.
73    pub is_client_owned: bool,
74}
75
76impl<'a> ComponentRegistration<'a> {
77    /// Create a component registration.
78    #[must_use]
79    pub fn new(
80        tag_name: &'a str,
81        html_content: &'a str,
82        css_content: Option<&'a str>,
83        is_client_owned: bool,
84    ) -> Self {
85        Self {
86            tag_name,
87            html_content,
88            css_content,
89            is_client_owned,
90        }
91    }
92}
93
94/// Registry of web components.
95#[derive(Debug)]
96pub struct ComponentRegistry {
97    /// Map of component tag names to their component data
98    components: HashMap<String, Component>,
99    /// Compiler-owned rendering policies kept outside the public component API.
100    render_policies: HashMap<String, ComponentRenderPolicy>,
101    /// Components whose render policy CSS has been appended.
102    policy_css: HashSet<String>,
103    // Plugin-retained client sources keyed by resolved tag name.
104    component_artifact_sources: HashMap<String, String>,
105    // Original authored sources retained only when a transform replaces them.
106    component_authored_sources: HashMap<String, Arc<str>>,
107    // Optional source transform applied before component insertion.
108    source_transform: Option<ComponentSourceTransform>,
109    /// Reusable CSS parser for token extraction during registration.
110    css_parser: CssParser,
111    /// Legal comment preservation policy for component CSS.
112    legal_comments: LegalComments,
113}
114
115#[cfg(feature = "fs")]
116// Return whether a component has an accessible sibling module.
117fn has_component_script(html_path: &Path) -> Result<bool> {
118    for ext in ["ts", "js"] {
119        let candidate = html_path.with_extension(ext);
120        if candidate.try_exists().map_err(|source| ParserError::IO {
121            context: format!(
122                "Failed to inspect component script: {}",
123                candidate.display()
124            ),
125            source,
126        })? {
127            return Ok(true);
128        }
129    }
130    Ok(false)
131}
132
133impl Default for ComponentRegistry {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl ComponentRegistry {
140    /// Create a new component registry.
141    pub fn new() -> Self {
142        Self::with_legal_comments(LegalComments::default())
143    }
144
145    pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
146        Self {
147            components: HashMap::new(),
148            render_policies: HashMap::new(),
149            policy_css: HashSet::new(),
150            component_artifact_sources: HashMap::new(),
151            component_authored_sources: HashMap::new(),
152            source_transform: None,
153            css_parser: CssParser::new(),
154            legal_comments,
155        }
156    }
157
158    // Install the active plugin's component-source transform.
159    pub(crate) fn set_component_source_transform(
160        &mut self,
161        transform: Option<ComponentSourceTransform>,
162    ) {
163        self.source_transform = transform;
164    }
165
166    // Apply the source transform, allocating only when it claims the source.
167    fn resolve_component_source(
168        &self,
169        tag_name: &str,
170        html_content: &str,
171    ) -> Result<Option<TransformedComponentSource>> {
172        if let Some(transform) = self.source_transform {
173            return transform(ComponentSource {
174                tag_name,
175                html_content,
176            });
177        }
178        Ok(None)
179    }
180
181    /// Register multiple components from directories recursively.
182    #[cfg(feature = "fs")]
183    pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
184        for dir in directories {
185            for entry in WalkDir::new(dir.as_ref())
186                .into_iter()
187                .filter_map(|e| e.ok())
188            {
189                let path = entry.path();
190                if !path.is_file() {
191                    continue;
192                }
193                // Only process HTML files
194                if !path.extension().is_some_and(|ext| ext == "html") {
195                    continue;
196                }
197                let Some(filename) = path.file_stem().and_then(|s| s.to_str()) else {
198                    continue;
199                };
200                // A transform may name files whose stems are not custom elements.
201                if !filename.contains('-') && self.source_transform.is_none() {
202                    continue;
203                }
204                let css_path = path.with_extension("css");
205                let css_path = css_path.exists().then_some(css_path);
206                // Ignore non-component files the transform does not claim.
207                self.register_component_from_paths_inner(path, css_path.as_deref(), true)?;
208            }
209        }
210        Ok(self)
211    }
212
213    /// Register a web component from paths to HTML and CSS files.
214    #[cfg(feature = "fs")]
215    pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
216        &mut self,
217        html_path: P,
218        css_path: Option<Q>,
219    ) -> Result<()> {
220        let css_path = css_path.as_ref().map(AsRef::as_ref);
221        self.register_component_from_paths_inner(html_path.as_ref(), css_path, false)
222    }
223
224    // Register component paths, optionally skipping unclaimed discovery files.
225    #[cfg(feature = "fs")]
226    fn register_component_from_paths_inner(
227        &mut self,
228        html_path: &Path,
229        css_path: Option<&Path>,
230        allow_skip_unresolved: bool,
231    ) -> Result<()> {
232        // Extract component name from file name (without extension)
233        let tag_name = html_path
234            .file_stem()
235            .and_then(|s| s.to_str())
236            .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
237
238        // Read HTML content
239        let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
240            context: format!("Failed to read HTML file: {}", html_path.display()),
241            source,
242        })?;
243        let (resolved, authored_source) =
244            match self.resolve_component_source(tag_name, &html_content)? {
245                Some(transformed) => (transformed, Some(Arc::<str>::from(html_content))),
246                None => {
247                    // Ignore unclaimed non-component files during discovery.
248                    if allow_skip_unresolved && !tag_name.contains('-') {
249                        return Ok(());
250                    }
251                    // Reuse the owned file contents for unchanged sources.
252                    (
253                        TransformedComponentSource {
254                            tag_name: tag_name.to_string(),
255                            parser_content: html_content,
256                            artifact_content: None,
257                        },
258                        None,
259                    )
260                }
261            };
262        Self::validate_component_name(&resolved.tag_name)?;
263
264        // Check for duplicate component
265        if self.components.contains_key(&resolved.tag_name) {
266            return Err(ParserError::Component(format!(
267                "Component '{}' is already registered",
268                resolved.tag_name
269            )));
270        }
271
272        // Read CSS content and extract definitions/fallback requirements if available
273        let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
274            if css_path.exists() {
275                let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
276                    context: format!("Failed to read CSS file: {}", css_path.display()),
277                    source,
278                })?;
279                let (content, definitions, requirements) = self.process_css_content(&content)?;
280                (Some(content), definitions, requirements)
281            } else {
282                (None, Vec::new(), Vec::new())
283            }
284        } else {
285            (None, Vec::new(), Vec::new())
286        };
287
288        let is_client_owned = has_component_script(html_path)?;
289
290        let render_policy =
291            parse_component_render_policy(&resolved.tag_name, &resolved.parser_content)?;
292        validate_policy_client_ownership(
293            &resolved.tag_name,
294            &resolved.parser_content,
295            &render_policy,
296            is_client_owned,
297        )?;
298
299        // Create and register the component
300        let component = Component {
301            tag_name: resolved.tag_name,
302            html_content: resolved.parser_content,
303            css_content,
304            css_definitions,
305            css_fallback_chains,
306            is_client_owned,
307        };
308
309        self.render_policies
310            .insert(component.tag_name.clone(), render_policy);
311        if let Some(artifact) = resolved.artifact_content {
312            self.component_artifact_sources
313                .insert(component.tag_name.clone(), artifact);
314        }
315        if let Some(authored_source) = authored_source {
316            self.component_authored_sources
317                .insert(component.tag_name.clone(), authored_source);
318        }
319        self.components
320            .insert(component.tag_name.clone(), component);
321        Ok(())
322    }
323
324    /// Register a component directly from provided content strings.
325    ///
326    /// Exact JavaScript state ownership is supplied later by a bundler projection
327    /// manifest; the parser stores only whether this tag is client-owned.
328    pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
329        let ComponentRegistration {
330            tag_name,
331            html_content,
332            css_content,
333            is_client_owned,
334        } = registration;
335
336        let (resolved, authored_source) =
337            match self.resolve_component_source(tag_name, html_content)? {
338                Some(transformed) => (transformed, Some(Arc::<str>::from(html_content))),
339                // No transform fired: allocate the owned parser view only now, not
340                // eagerly before the transform had a chance to replace it.
341                None => (
342                    TransformedComponentSource {
343                        tag_name: tag_name.to_string(),
344                        parser_content: html_content.to_string(),
345                        artifact_content: None,
346                    },
347                    None,
348                ),
349            };
350        Self::validate_component_name(&resolved.tag_name)?;
351
352        // Check for duplicate component
353        if self.components.contains_key(&resolved.tag_name) {
354            return Err(ParserError::Component(format!(
355                "Component '{}' is already registered",
356                resolved.tag_name
357            )));
358        }
359
360        // Extract CSS definitions/fallback requirements if CSS content is provided
361        let (css_content, css_definitions, css_fallback_chains) = match css_content {
362            Some(css) => {
363                let (content, definitions, requirements) = self.process_css_content(css)?;
364                (Some(content), definitions, requirements)
365            }
366            None => (None, Vec::new(), Vec::new()),
367        };
368        let render_policy =
369            parse_component_render_policy(&resolved.tag_name, &resolved.parser_content)?;
370        validate_policy_client_ownership(
371            &resolved.tag_name,
372            &resolved.parser_content,
373            &render_policy,
374            is_client_owned,
375        )?;
376        let component: Component = Component {
377            tag_name: resolved.tag_name,
378            html_content: resolved.parser_content,
379            css_content,
380            css_definitions,
381            css_fallback_chains,
382            is_client_owned,
383        };
384
385        // Register the component
386        self.render_policies
387            .insert(component.tag_name.clone(), render_policy);
388        if let Some(artifact) = resolved.artifact_content {
389            self.component_artifact_sources
390                .insert(component.tag_name.clone(), artifact);
391        }
392        if let Some(authored_source) = authored_source {
393            self.component_authored_sources
394                .insert(component.tag_name.clone(), authored_source);
395        }
396        self.components
397            .insert(component.tag_name.clone(), component);
398        Ok(())
399    }
400
401    fn validate_component_name(tag_name: &str) -> Result<()> {
402        if tag_name.contains('-') {
403            return Ok(());
404        }
405
406        Err(ParserError::Component(format!(
407            "Component name '{}' must contain a hyphen",
408            tag_name
409        )))
410    }
411
412    /// Strip comments and extract CSS definitions/fallback requirements.
413    fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
414        let (definitions, requirements, stripped) = self
415            .css_parser
416            .extract_definitions_requirements_and_strip_comments(
417                css_content,
418                self.legal_comments,
419            )?;
420        let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
421        sorted_definitions.sort();
422        Ok((stripped.into_owned(), sorted_definitions, requirements))
423    }
424
425    /// Append the render policy CSS appropriate for the component's CSS tree.
426    pub(crate) fn prepare_policy_css(
427        &mut self,
428        tag_name: &str,
429        uses_shadow_dom: bool,
430    ) -> Result<()> {
431        let Some(policy) = self.render_policies.get(tag_name) else {
432            return Err(ParserError::NotFound(format!(
433                "component <{tag_name}> disappeared before render policy preparation"
434            )));
435        };
436        if policy.reserve_block_size().is_none() || self.policy_css.contains(tag_name) {
437            return Ok(());
438        }
439        let component_tag = {
440            let component = self.components.get_mut(tag_name).ok_or_else(|| {
441                ParserError::NotFound(format!(
442                    "component <{tag_name}> disappeared before render policy preparation"
443                ))
444            })?;
445            let css = component
446                .css_content
447                .get_or_insert_with(|| String::with_capacity(112));
448            css.reserve(144);
449            let mut escaped_tag = String::with_capacity(32);
450            crate::css_light::push_css_identifier(&mut escaped_tag, component.tag_name.as_str());
451            if uses_shadow_dom {
452                policy.append_shadow_css(css, &escaped_tag);
453            } else {
454                policy.append_light_css(css, &escaped_tag);
455            }
456            component.tag_name.clone()
457        };
458        self.policy_css.insert(component_tag);
459        Ok(())
460    }
461
462    /// Check if a tag name is registered as a component.
463    pub fn contains(&self, tag_name: &str) -> bool {
464        self.components.contains_key(tag_name)
465    }
466
467    /// Get a component by its tag name.
468    pub fn get(&self, tag_name: &str) -> Option<&Component> {
469        self.components.get(tag_name)
470    }
471
472    pub(crate) fn render_policy(&self, tag_name: &str) -> Option<&ComponentRenderPolicy> {
473        self.render_policies.get(tag_name)
474    }
475
476    pub(crate) fn work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
477        self.render_policies
478            .iter()
479            .filter_map(|(tag, policy)| policy.metadata_code().map(|code| (tag.as_str(), code)))
480    }
481
482    /// Return component CSS for source diagnostics.
483    pub(crate) fn diagnostic_css_content(&self, tag_name: &str) -> Option<&str> {
484        self.components
485            .get(tag_name)
486            .and_then(|component| component.css_content.as_deref())
487    }
488
489    // Get a distinct client artifact retained by the source transform.
490    pub(crate) fn component_artifact_source(&self, tag_name: &str) -> Option<&str> {
491        self.component_artifact_sources
492            .get(tag_name)
493            .map(String::as_str)
494    }
495
496    pub(crate) fn component_authored_source(&self, tag_name: &str) -> Option<Arc<str>> {
497        self.component_authored_sources.get(tag_name).cloned()
498    }
499
500    /// Get all registered components.
501    pub fn get_all(&self) -> impl Iterator<Item = &Component> {
502        self.components.values()
503    }
504
505    /// Build deterministic document-level CSS for the supplied component tags
506    /// that opt into browser-managed lazy rendering.
507    #[must_use]
508    pub fn render_policy_css<'a>(&self, tag_names: impl IntoIterator<Item = &'a str>) -> String {
509        let mut policies: Vec<(&str, &ComponentRenderPolicy)> = tag_names
510            .into_iter()
511            .filter_map(|tag_name| {
512                self.render_policies
513                    .get(tag_name)
514                    .map(|policy| (tag_name, policy))
515            })
516            .filter(|(_, policy)| policy.reserve_block_size().is_some())
517            .collect();
518        policies.sort_unstable_by(|left, right| left.0.cmp(right.0));
519        policies.dedup_by(|left, right| left.0 == right.0);
520
521        let mut css = String::with_capacity(policies.len() * 112);
522        for (tag_name, policy) in policies {
523            crate::css_light::push_css_identifier(&mut css, tag_name);
524            css.push_str(r#":not([w-render="eager"]){"#);
525            policy.append_declarations(&mut css);
526            css.push('}');
527        }
528        css
529    }
530
531    /// Iterate the registered component tag names (e.g. `mp-button`).
532    ///
533    /// Used to offer "did you mean …?" suggestions when an unknown component
534    /// tag is encountered during parsing.
535    pub fn names(&self) -> impl Iterator<Item = &str> {
536        self.components.keys().map(String::as_str)
537    }
538
539    /// Get the number of registered components.
540    pub fn len(&self) -> usize {
541        self.components.len()
542    }
543
544    /// Check if the registry has no registered components.
545    pub fn is_empty(&self) -> bool {
546        self.components.is_empty()
547    }
548}
549
550#[cfg(test)]
551#[allow(clippy::disallowed_methods)]
552mod tests {
553    use super::*;
554    use crate::codes;
555    use crate::plugin::TransformedComponentSource;
556    use webui_test_utils::TestFileSystem;
557
558    #[test]
559    fn test_register_component() {
560        let html_content = "<p>Hello World</p>";
561        let css_content = "p { color: red; }";
562
563        // Create temporary files with proper names directly
564        let mut fs = TestFileSystem::new();
565        let html_path = fs.add_file("components/test-component.html", html_content);
566        let css_path = fs.add_file("components/test-component.css", css_content);
567
568        // Register the component (no rename needed)
569        let mut registry = ComponentRegistry::new();
570        let result = registry.register_component_from_paths(&html_path, Some(&css_path));
571
572        assert!(result.is_ok());
573        assert!(registry.contains("test-component"));
574
575        let component = registry
576            .get("test-component")
577            .expect("Failed to retrieve registered component");
578        assert_eq!(component.html_content, html_content);
579        assert_eq!(component.css_content.as_deref(), Some(css_content));
580        assert!(!component.is_client_owned);
581    }
582
583    #[test]
584    #[cfg(feature = "fs")]
585    fn transform_applies_to_path_registration() {
586        let mut fs = TestFileSystem::new();
587        let html_path = fs.add_file(
588            "components/file-card.html",
589            r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
590        );
591
592        let mut registry = ComponentRegistry::new();
593        registry.set_component_source_transform(Some(mock_rename_transform));
594        registry
595            .register_component_from_paths(&html_path, None::<&str>)
596            .expect("register");
597
598        assert!(!registry.contains("file-card"));
599        let component = registry.get("renamed-card").expect("component");
600        assert_eq!(
601            component.html_content,
602            "<template><span>parser</span></template>"
603        );
604        assert_eq!(
605            registry.component_artifact_source("renamed-card"),
606            Some("<template><span>artifact</span></template>")
607        );
608        assert_eq!(
609            registry
610                .component_authored_source("renamed-card")
611                .as_deref(),
612            Some(r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#)
613        );
614    }
615
616    #[test]
617    fn test_register_component_detects_ts_sibling_script() {
618        let mut fs = TestFileSystem::new();
619        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
620        std::fs::write(html_path.with_extension("ts"), "export {};")
621            .expect("Failed to write sibling script");
622
623        let mut registry = ComponentRegistry::new();
624        registry
625            .register_component_from_paths(&html_path, None::<&str>)
626            .expect("register failed");
627
628        let component = registry
629            .get("scripted-card")
630            .expect("Failed to retrieve registered component");
631        assert!(component.is_client_owned);
632    }
633
634    #[test]
635    fn test_register_component_detects_js_sibling_script() {
636        let mut fs = TestFileSystem::new();
637        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
638        std::fs::write(html_path.with_extension("js"), "export {};")
639            .expect("Failed to write sibling script");
640
641        let mut registry = ComponentRegistry::new();
642        registry
643            .register_component_from_paths(&html_path, None::<&str>)
644            .expect("register failed");
645
646        let component = registry
647            .get("scripted-card")
648            .expect("Failed to retrieve registered component");
649        assert!(component.is_client_owned);
650    }
651
652    #[test]
653    fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
654        let mut fs = TestFileSystem::new();
655        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
656        std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
657            .expect("Failed to write invalid sibling script");
658
659        let mut registry = ComponentRegistry::new();
660        registry
661            .register_component_from_paths(&html_path, None::<&str>)
662            .expect("registration should inspect presence without decoding source");
663
664        let component = registry
665            .get("scripted-card")
666            .expect("Failed to retrieve registered component");
667        assert!(component.is_client_owned);
668    }
669
670    #[test]
671    fn test_register_component_ignores_tsx_sibling_script() {
672        let mut fs = TestFileSystem::new();
673        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
674        std::fs::write(html_path.with_extension("tsx"), "export {};")
675            .expect("Failed to write sibling script");
676
677        let mut registry = ComponentRegistry::new();
678        registry
679            .register_component_from_paths(&html_path, None::<&str>)
680            .expect("register failed");
681
682        let component = registry
683            .get("scripted-card")
684            .expect("Failed to retrieve registered component");
685        assert!(!component.is_client_owned);
686    }
687
688    #[test]
689    fn test_component_name_validation() {
690        let html_content = "<p>Invalid</p>";
691
692        // Create temporary file with invalid name (no hyphen)
693        let mut fs = TestFileSystem::new();
694        let html_path = fs.add_file("invalid_name.html", html_content);
695
696        // Try to register the component
697        let mut registry = ComponentRegistry::new();
698        let result = registry.register_component_from_paths(&html_path, None::<&str>);
699
700        assert!(result.is_err());
701        assert_eq!(registry.len(), 0);
702    }
703
704    #[test]
705    fn test_missing_css_file() {
706        let html_content = "<p>CSS Optional</p>";
707
708        // Create temporary HTML file
709        let mut fs = TestFileSystem::new();
710        let html_path = fs.add_file("test-component.html", html_content);
711
712        // Register with non-existent CSS file
713        let mut registry = ComponentRegistry::new();
714        let result = registry.register_component_from_paths(&html_path, None::<&str>);
715
716        assert!(result.is_ok());
717        let component = registry
718            .get("test-component")
719            .expect("Failed to retrieve registered component");
720        assert_eq!(component.html_content, html_content);
721        assert_eq!(component.css_content, None);
722    }
723
724    #[test]
725    fn test_register_component_from_strings() {
726        let html_content = "<p>Hello from string!</p>";
727        let css_content = "p { color: green; }";
728
729        // Register component directly from strings
730        let mut registry = ComponentRegistry::new();
731        let result = registry.register_component(ComponentRegistration::new(
732            "string-component",
733            html_content,
734            Some(css_content),
735            true,
736        ));
737
738        assert!(result.is_ok());
739        assert!(registry.contains("string-component"));
740
741        let component = registry
742            .get("string-component")
743            .expect("Failed to retrieve registered component");
744        assert_eq!(component.html_content, html_content);
745        assert_eq!(component.css_content.as_deref(), Some(css_content));
746        assert!(component.is_client_owned);
747    }
748
749    #[test]
750    fn lazy_render_policy_generates_stable_document_css() {
751        let mut registry = ComponentRegistry::new();
752        for (tag, size) in [("z-card", "18rem"), ("a-row", "72px")] {
753            let html = format!(
754                r#"<template w-render="lazy" w-reserve-block-size="{size}"><p>x</p></template>"#
755            );
756            registry
757                .register_component(ComponentRegistration::new(tag, &html, None, true))
758                .expect("valid lazy render policy");
759        }
760
761        assert_eq!(
762            registry.render_policy_css(["z-card", "a-row"]),
763            concat!(
764                r#"a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
765                r#"z-card:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 18rem;}"#,
766            )
767        );
768        assert_eq!(
769            registry
770                .get("a-row")
771                .and_then(|component| component.css_content.as_deref()),
772            None
773        );
774        registry
775            .prepare_policy_css("a-row", true)
776            .expect("prepare Shadow policy CSS");
777        assert_eq!(
778            registry
779                .get("a-row")
780                .and_then(|component| component.css_content.as_deref()),
781            Some(
782                r#":host(a-row:not([w-render="eager"])),a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
783            )
784        );
785    }
786
787    #[test]
788    fn lazy_render_policy_uses_a_normal_selector_for_light_css() {
789        let mut registry = ComponentRegistry::new();
790        registry
791            .register_component(ComponentRegistration::new(
792                "light-row",
793                r#"<template w-render="lazy" w-reserve-block-size="72px"><p>x</p></template>"#,
794                None,
795                false,
796            ))
797            .expect("valid lazy render policy");
798
799        registry
800            .prepare_policy_css("light-row", false)
801            .expect("prepare Light policy CSS");
802        assert_eq!(
803            registry
804                .get("light-row")
805                .and_then(|component| component.css_content.as_deref()),
806            Some(
807                r#"light-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
808            )
809        );
810    }
811
812    #[test]
813    fn lazy_hydration_policy_emits_no_rendering_css() {
814        let mut registry = ComponentRegistry::new();
815        registry
816            .register_component(ComponentRegistration::new(
817                "lazy-row",
818                r#"<template w-hydrate="lazy"><p>x</p></template>"#,
819                None,
820                true,
821            ))
822            .expect("valid hydration policy");
823        assert!(registry.render_policy_css(["lazy-row"]).is_empty());
824    }
825
826    #[test]
827    fn lazy_render_policy_requires_a_valid_reservation() {
828        for (html, code) in [
829            (
830                r#"<template w-render="lazy"><p>x</p></template>"#,
831                codes::MISSING_RENDER_RESERVATION,
832            ),
833            (
834                r#"<template w-render="lazy" w-reserve-block-size="50%"><p>x</p></template>"#,
835                codes::INVALID_RENDER_RESERVATION,
836            ),
837            (
838                r#"<div w-render="lazy" w-reserve-block-size="10px"></div>"#,
839                codes::INVALID_COMPONENT_RENDER_POLICY,
840            ),
841        ] {
842            let mut registry = ComponentRegistry::new();
843            let Err(ParserError::Template(diagnostic)) = registry
844                .register_component(ComponentRegistration::new("bad-card", html, None, true))
845            else {
846                panic!("invalid component policy must produce a template diagnostic");
847            };
848            assert_eq!(diagnostic.error_code(), Some(code));
849            assert!(diagnostic.help_text().is_some());
850        }
851    }
852
853    #[test]
854    fn test_register_component_strips_css_comments() {
855        let mut registry = ComponentRegistry::new();
856        registry
857            .register_component(ComponentRegistration::new(
858                "style-component",
859                "<p>Styled</p>",
860                Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
861                true,
862            ))
863            .expect("register failed");
864
865        let component = registry
866            .get("style-component")
867            .expect("Failed to retrieve registered component");
868        assert_eq!(
869            component.css_content.as_deref(),
870            Some(" p { color: var(--textColor); } ")
871        );
872        assert_eq!(component.css_fallback_chains.len(), 1);
873    }
874
875    #[test]
876    fn test_register_component_preserves_legal_css_comments_by_default() {
877        let mut registry = ComponentRegistry::new();
878        registry
879            .register_component(ComponentRegistration::new(
880                "legal-component",
881                "<p>Styled</p>",
882                Some("/*! @license MIT */ p { color: red; } /* remove */"),
883                true,
884            ))
885            .expect("register failed");
886
887        let component = registry
888            .get("legal-component")
889            .expect("Failed to retrieve registered component");
890        assert_eq!(
891            component.css_content.as_deref(),
892            Some("/*! @license MIT */ p { color: red; } ")
893        );
894    }
895
896    #[test]
897    fn test_register_component_strips_legal_css_comments_when_disabled() {
898        let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
899        registry
900            .register_component(ComponentRegistration::new(
901                "legal-component",
902                "<p>Styled</p>",
903                Some("/*! @license MIT */ p { color: red; }"),
904                true,
905            ))
906            .expect("register failed");
907
908        let component = registry
909            .get("legal-component")
910            .expect("Failed to retrieve registered component");
911        assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
912    }
913
914    #[test]
915    fn test_invalid_component_name_from_strings() {
916        let html_content = "<p>Invalid component</p>";
917
918        // Try registering with invalid name (no hyphen)
919        let mut registry = ComponentRegistry::new();
920        let result = registry.register_component(ComponentRegistration::new(
921            "invalid",
922            html_content,
923            None,
924            true,
925        ));
926
927        // More idiomatic approach using assert!() with message
928        assert!(result.is_err(), "Expected error for invalid component name");
929
930        // Better pattern matching using matches!() macro
931        assert!(
932            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
933            "Wrong error type or message: {:?}",
934            result
935        );
936    }
937
938    #[test]
939    fn test_duplicate_component_from_strings() {
940        let html_content = "<p>First component</p>";
941        let html_content2 = "<p>Second component with same name</p>";
942
943        // Register the first component
944        let mut registry = ComponentRegistry::new();
945        let result1 = registry.register_component(ComponentRegistration::new(
946            "dupe-component",
947            html_content,
948            None,
949            true,
950        ));
951        assert!(result1.is_ok());
952
953        // Try to register a second component with the same name
954        let result2 = registry.register_component(ComponentRegistration::new(
955            "dupe-component",
956            html_content2,
957            None,
958            true,
959        ));
960        assert!(result2.is_err());
961
962        // Verify the error message
963        assert!(
964            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
965            "Expected 'already registered' error, got: {:?}",
966            result2
967        );
968
969        // Verify the original component is still there unchanged
970        let component = registry
971            .get("dupe-component")
972            .expect("Failed to retrieve registered component");
973        assert_eq!(component.html_content, html_content);
974    }
975
976    #[test]
977    fn test_duplicate_component_from_paths() {
978        let html_content1 = "<p>Component from dir A</p>";
979        let html_content2 = "<p>Component from dir B</p>";
980
981        // Create temporary directories and files.
982        let mut fs = TestFileSystem::new();
983        let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
984        let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
985
986        // Register the first component
987        let mut registry = ComponentRegistry::new();
988        let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
989        assert!(result1.is_ok());
990
991        // Try to register the second component with the same name from a different path
992        let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
993        assert!(result2.is_err());
994
995        // Verify the error message
996        assert!(
997            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
998            "Expected 'already registered' error, got: {:?}",
999            result2
1000        );
1001
1002        // Verify the original component is still there unchanged
1003        let component = registry
1004            .get("my-comp")
1005            .expect("Failed to retrieve registered component");
1006        assert_eq!(component.html_content, html_content1);
1007    }
1008
1009    fn mock_rename_transform(
1010        source: ComponentSource<'_>,
1011    ) -> Result<Option<TransformedComponentSource>> {
1012        if !source.html_content.contains("<mock-template") {
1013            return Ok(None);
1014        }
1015        Ok(Some(TransformedComponentSource {
1016            tag_name: "renamed-card".to_string(),
1017            parser_content: "<template><span>parser</span></template>".to_string(),
1018            artifact_content: Some("<template><span>artifact</span></template>".to_string()),
1019        }))
1020    }
1021
1022    fn mock_failing_transform(
1023        _source: ComponentSource<'_>,
1024    ) -> Result<Option<TransformedComponentSource>> {
1025        Err(ParserError::Component("mock transform failure".to_string()))
1026    }
1027
1028    #[test]
1029    fn default_registry_leaves_authored_source_unchanged() {
1030        // Without an installed transform, framework-shaped markup is inert:
1031        // no renaming, conversion, or extra artifact source.
1032        let html = r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#;
1033        let mut registry = ComponentRegistry::new();
1034        registry
1035            .register_component(ComponentRegistration::new("file-card", html, None, true))
1036            .expect("register");
1037
1038        assert!(registry.contains("file-card"));
1039        assert!(!registry.contains("renamed-card"));
1040        assert_eq!(
1041            registry
1042                .get("file-card")
1043                .map(|component| component.html_content.as_str()),
1044            Some(html)
1045        );
1046        assert_eq!(registry.component_artifact_source("file-card"), None);
1047    }
1048
1049    #[test]
1050    fn transform_renames_and_provides_parser_and_artifact_views() {
1051        let mut registry = ComponentRegistry::new();
1052        registry.set_component_source_transform(Some(mock_rename_transform));
1053        registry
1054            .register_component(ComponentRegistration::new(
1055                "file-card",
1056                r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
1057                Some(".root { color: red; }"),
1058                true,
1059            ))
1060            .expect("register");
1061
1062        assert!(!registry.contains("file-card"));
1063        let component = registry.get("renamed-card").expect("component");
1064        assert_eq!(component.tag_name, "renamed-card");
1065        assert_eq!(
1066            component.html_content,
1067            "<template><span>parser</span></template>"
1068        );
1069        assert_eq!(
1070            registry.component_artifact_source("renamed-card"),
1071            Some("<template><span>artifact</span></template>")
1072        );
1073    }
1074
1075    #[test]
1076    fn transform_returning_unchanged_preserves_source() {
1077        let mut registry = ComponentRegistry::new();
1078        registry.set_component_source_transform(Some(mock_rename_transform));
1079        let html = r#"<template><span>{{title}}</span></template>"#;
1080        registry
1081            .register_component(ComponentRegistration::new("plain-card", html, None, true))
1082            .expect("register");
1083
1084        assert!(registry.contains("plain-card"));
1085        assert_eq!(
1086            registry
1087                .get("plain-card")
1088                .map(|component| component.html_content.as_str()),
1089            Some(html)
1090        );
1091        assert_eq!(registry.component_artifact_source("plain-card"), None);
1092        assert_eq!(registry.component_authored_source("plain-card"), None);
1093    }
1094
1095    #[test]
1096    fn transform_error_is_transactional() {
1097        let mut registry = ComponentRegistry::new();
1098        registry.set_component_source_transform(Some(mock_failing_transform));
1099        let err = registry
1100            .register_component(ComponentRegistration::new(
1101                "file-card",
1102                "<template>x</template>",
1103                None,
1104                true,
1105            ))
1106            .expect_err("failing transform should abort registration");
1107
1108        assert!(matches!(err, ParserError::Component(ref msg) if msg.contains("mock transform")));
1109        assert!(!registry.contains("file-card"));
1110        assert!(registry.is_empty());
1111    }
1112
1113    #[test]
1114    fn transform_duplicate_resolved_name_is_rejected() {
1115        let mut registry = ComponentRegistry::new();
1116        registry.set_component_source_transform(Some(mock_rename_transform));
1117        registry
1118            .register_component(ComponentRegistration::new(
1119                "first-card",
1120                r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
1121                None,
1122                true,
1123            ))
1124            .expect("first register");
1125        let err = registry
1126            .register_component(ComponentRegistration::new(
1127                "second-card",
1128                r#"<mock-template name="renamed-card"><template>y</template></mock-template>"#,
1129                None,
1130                true,
1131            ))
1132            .expect_err("duplicate resolved name should error");
1133
1134        assert!(
1135            matches!(err, ParserError::Component(ref msg) if msg.contains("already registered"))
1136        );
1137    }
1138
1139    #[test]
1140    fn test_exclude_dot_in_component_name() {
1141        let mut registry = ComponentRegistry::new();
1142        let result = registry.register_component(ComponentRegistration::new(
1143            "custom.button",
1144            "<p>Dot name</p>",
1145            None,
1146            true,
1147        ));
1148
1149        assert!(
1150            result.is_err(),
1151            "Component name with dot but no hyphen should be rejected"
1152        );
1153        assert!(
1154            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1155            "Wrong error type or message: {:?}",
1156            result
1157        );
1158        assert_eq!(registry.len(), 0);
1159    }
1160
1161    #[test]
1162    fn test_exclude_no_hyphen_html() {
1163        let mut registry = ComponentRegistry::new();
1164        let result = registry.register_component(ComponentRegistration::new(
1165            "foobar",
1166            "<p>No hyphen</p>",
1167            None,
1168            true,
1169        ));
1170
1171        assert!(
1172            result.is_err(),
1173            "Component name without hyphen should be rejected"
1174        );
1175        assert!(
1176            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1177            "Wrong error type or message: {:?}",
1178            result
1179        );
1180        assert_eq!(registry.len(), 0);
1181    }
1182
1183    #[test]
1184    fn test_valid_component_with_hyphen() {
1185        let mut registry = ComponentRegistry::new();
1186        let result = registry.register_component(ComponentRegistration::new(
1187            "custom-button",
1188            "<button>Click me</button>",
1189            None,
1190            true,
1191        ));
1192
1193        assert!(
1194            result.is_ok(),
1195            "Component name with hyphen should be accepted"
1196        );
1197        assert!(registry.contains("custom-button"));
1198        assert_eq!(registry.len(), 1);
1199    }
1200
1201    #[test]
1202    fn test_valid_component_css_only() {
1203        let mut registry = ComponentRegistry::new();
1204        let result = registry.register_component(ComponentRegistration::new(
1205            "styled-widget",
1206            "",
1207            Some(".widget { color: blue; }"),
1208            true,
1209        ));
1210
1211        assert!(
1212            result.is_ok(),
1213            "Component with empty HTML and CSS should be accepted"
1214        );
1215        assert!(registry.contains("styled-widget"));
1216
1217        let component = registry
1218            .get("styled-widget")
1219            .expect("Failed to retrieve registered component");
1220        assert_eq!(component.html_content, "");
1221        assert_eq!(
1222            component.css_content.as_deref(),
1223            Some(".widget { color: blue; }")
1224        );
1225    }
1226
1227    #[test]
1228    fn test_component_name_requires_hyphen() {
1229        let mut registry = ComponentRegistry::new();
1230        let result = registry.register_component(ComponentRegistration::new(
1231            "single",
1232            "<p>Single word</p>",
1233            None,
1234            true,
1235        ));
1236
1237        assert!(
1238            result.is_err(),
1239            "Single-word component name should be rejected"
1240        );
1241        assert!(
1242            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1243            "Wrong error type or message: {:?}",
1244            result
1245        );
1246        assert_eq!(registry.len(), 0);
1247    }
1248
1249    #[test]
1250    fn test_multiple_hyphens_valid() {
1251        let mut registry = ComponentRegistry::new();
1252        let result = registry.register_component(ComponentRegistration::new(
1253            "my-custom-element",
1254            "<div>Custom element</div>",
1255            None,
1256            true,
1257        ));
1258
1259        assert!(
1260            result.is_ok(),
1261            "Component name with multiple hyphens should be accepted"
1262        );
1263        assert!(registry.contains("my-custom-element"));
1264        assert_eq!(registry.len(), 1);
1265    }
1266
1267    #[test]
1268    fn test_empty_component_name_rejected() {
1269        let mut registry = ComponentRegistry::new();
1270        let result = registry.register_component(ComponentRegistration::new(
1271            "",
1272            "<p>Empty name</p>",
1273            None,
1274            true,
1275        ));
1276
1277        assert!(result.is_err(), "Empty component name should be rejected");
1278        assert!(
1279            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1280            "Wrong error type or message: {:?}",
1281            result
1282        );
1283        assert_eq!(registry.len(), 0);
1284    }
1285
1286    #[test]
1287    fn test_register_component_extracts_css_fallback_requirements() {
1288        let mut registry = ComponentRegistry::new();
1289        registry
1290            .register_component(ComponentRegistration::new(
1291                "my-btn",
1292                "<button>Click</button>",
1293                Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
1294                true,
1295            ))
1296            .expect("register failed");
1297
1298        let component = registry.get("my-btn").expect("component not found");
1299        assert_eq!(component.css_fallback_chains.len(), 2);
1300    }
1301
1302    #[test]
1303    fn test_register_component_no_css_no_requirements() {
1304        let mut registry = ComponentRegistry::new();
1305        registry
1306            .register_component(ComponentRegistration::new(
1307                "my-card",
1308                "<div>Card</div>",
1309                None,
1310                true,
1311            ))
1312            .expect("register failed");
1313
1314        let component = registry.get("my-card").expect("component not found");
1315        assert!(component.css_fallback_chains.is_empty());
1316    }
1317
1318    #[test]
1319    fn test_register_component_tracks_css_fallback_requirements() {
1320        let mut registry = ComponentRegistry::new();
1321        registry
1322            .register_component(ComponentRegistration::new(
1323                "my-widget",
1324                "<div>W</div>",
1325                Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
1326                true,
1327            ))
1328            .expect("register failed");
1329
1330        let component = registry.get("my-widget").expect("component not found");
1331        assert_eq!(component.css_fallback_chains.len(), 2);
1332    }
1333}