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::{parse_component_render_policy, ComponentRenderPolicy};
9use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
10use std::collections::HashMap;
11#[cfg(feature = "fs")]
12use std::fs;
13#[cfg(feature = "fs")]
14use std::path::Path;
15#[cfg(feature = "fs")]
16use walkdir::WalkDir;
17
18type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
19
20/// Represents a web component in the registry.
21///
22/// ```
23/// use webui_parser::Component;
24///
25/// let component = Component {
26///     tag_name: "example-card".to_string(),
27///     html_content: "<p>Example</p>".to_string(),
28///     css_content: None,
29///     css_definitions: Vec::new(),
30///     css_fallback_chains: Vec::new(),
31///     is_client_owned: false,
32/// };
33/// assert_eq!(component.tag_name, "example-card");
34/// ```
35#[derive(Debug, Clone)]
36pub struct Component {
37    /// The custom element tag name (e.g., "hello-world")
38    pub tag_name: String,
39
40    /// The HTML content of the component
41    pub html_content: String,
42
43    /// The CSS content of the component, if any
44    pub css_content: Option<String>,
45
46    /// CSS custom property definitions from this component's CSS.
47    pub css_definitions: Vec<String>,
48
49    /// CSS `var()` fallback chains from this component's CSS.
50    pub css_fallback_chains: Vec<CssFallbackChain>,
51
52    /// Whether authored browser code owns this custom element tag.
53    pub is_client_owned: bool,
54}
55
56/// Inputs for registering a component from content strings.
57///
58/// Grouping the fields keeps [`ComponentRegistry::register_component`] a
59/// single-argument call without growing its arity.
60#[derive(Debug, Clone)]
61pub struct ComponentRegistration<'a> {
62    /// The custom element tag name (must contain a hyphen).
63    pub tag_name: &'a str,
64    /// The component's HTML template content.
65    pub html_content: &'a str,
66    /// The component's CSS content, if any.
67    pub css_content: Option<&'a str>,
68    /// Whether authored browser code owns this custom element tag.
69    pub is_client_owned: bool,
70}
71
72impl<'a> ComponentRegistration<'a> {
73    /// Create a component registration.
74    #[must_use]
75    pub fn new(
76        tag_name: &'a str,
77        html_content: &'a str,
78        css_content: Option<&'a str>,
79        is_client_owned: bool,
80    ) -> Self {
81        Self {
82            tag_name,
83            html_content,
84            css_content,
85            is_client_owned,
86        }
87    }
88}
89
90/// Registry of web components.
91#[derive(Debug)]
92pub struct ComponentRegistry {
93    /// Map of component tag names to their component data
94    components: HashMap<String, Component>,
95    /// Compiler-owned rendering policies kept outside the public component API.
96    render_policies: HashMap<String, ComponentRenderPolicy>,
97    /// Reusable CSS parser for token extraction during registration.
98    css_parser: CssParser,
99    /// Legal comment preservation policy for component CSS.
100    legal_comments: LegalComments,
101}
102
103#[cfg(feature = "fs")]
104/// Return whether a component has an authored sibling module.
105///
106/// Use `try_exists()` rather than `exists()`: `exists()` converts metadata
107/// errors into `false`, which could silently classify an inaccessible authored
108/// component as scriptless and bypass projection-manifest coverage.
109fn has_component_script(html_path: &Path) -> Result<bool> {
110    for ext in ["ts", "js"] {
111        let candidate = html_path.with_extension(ext);
112        if candidate.try_exists().map_err(|source| ParserError::IO {
113            context: format!(
114                "Failed to inspect component script: {}",
115                candidate.display()
116            ),
117            source,
118        })? {
119            return Ok(true);
120        }
121    }
122    Ok(false)
123}
124
125impl Default for ComponentRegistry {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl ComponentRegistry {
132    /// Create a new component registry.
133    pub fn new() -> Self {
134        Self::with_legal_comments(LegalComments::default())
135    }
136
137    pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
138        Self {
139            components: HashMap::new(),
140            render_policies: HashMap::new(),
141            css_parser: CssParser::new(),
142            legal_comments,
143        }
144    }
145
146    /// Register multiple components from directories recursively.
147    #[cfg(feature = "fs")]
148    pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
149        for dir in directories {
150            for entry in WalkDir::new(dir.as_ref())
151                .into_iter()
152                .filter_map(|e| e.ok())
153            {
154                let path = entry.path();
155                if !path.is_file() {
156                    continue;
157                }
158                // Only process HTML files
159                if path.extension().is_some_and(|ext| ext == "html") {
160                    // Check for a component name (must contain a hyphen)
161                    if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
162                        if filename.contains('-') {
163                            // Find associated CSS file
164                            let css_path = path.with_extension("css");
165                            // Register the component (key is the file name without extension)
166                            self.register_component_from_paths(
167                                path,
168                                if css_path.exists() {
169                                    Some(&css_path)
170                                } else {
171                                    None
172                                },
173                            )?;
174                        }
175                    }
176                }
177            }
178        }
179        Ok(self)
180    }
181
182    /// Register a web component from paths to HTML and CSS files.
183    #[cfg(feature = "fs")]
184    pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
185        &mut self,
186        html_path: P,
187        css_path: Option<Q>,
188    ) -> Result<()> {
189        let html_path = html_path.as_ref();
190
191        // Extract component name from file name (without extension)
192        let tag_name = html_path
193            .file_stem()
194            .and_then(|s| s.to_str())
195            .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
196
197        // Validate component name (must contain a hyphen)
198        if !tag_name.contains('-') {
199            return Err(ParserError::Component(format!(
200                "Component name '{}' must contain a hyphen",
201                tag_name
202            )));
203        }
204
205        // Check for duplicate component
206        if self.components.contains_key(tag_name) {
207            return Err(ParserError::Component(format!(
208                "Component '{}' is already registered",
209                tag_name
210            )));
211        }
212
213        // Read HTML content
214        let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
215            context: format!("Failed to read HTML file: {}", html_path.display()),
216            source,
217        })?;
218
219        // Read CSS content and extract definitions/fallback requirements if available
220        let (mut css_content, css_definitions, css_fallback_chains) = if let Some(css_path) =
221            css_path
222        {
223            let css_path = css_path.as_ref();
224            if css_path.exists() {
225                let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
226                    context: format!("Failed to read CSS file: {}", css_path.display()),
227                    source,
228                })?;
229                let (content, definitions, requirements) = self.process_css_content(&content)?;
230                (Some(content), definitions, requirements)
231            } else {
232                (None, Vec::new(), Vec::new())
233            }
234        } else {
235            (None, Vec::new(), Vec::new())
236        };
237
238        let is_client_owned = has_component_script(html_path)?;
239
240        let render_policy = parse_component_render_policy(tag_name, &html_content)?;
241        Self::append_scoped_policy_css(&mut css_content, &render_policy, tag_name);
242
243        // Create and register the component
244        let component = Component {
245            tag_name: tag_name.to_string(),
246            html_content,
247            css_content,
248            css_definitions,
249            css_fallback_chains,
250            is_client_owned,
251        };
252
253        self.render_policies
254            .insert(tag_name.to_string(), render_policy);
255        self.components.insert(tag_name.to_string(), component);
256        Ok(())
257    }
258
259    /// Register a component directly from provided content strings.
260    ///
261    /// Exact JavaScript state ownership is supplied later by a bundler projection
262    /// manifest; the parser stores only whether this tag is client-owned.
263    pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
264        let ComponentRegistration {
265            tag_name,
266            html_content,
267            css_content,
268            is_client_owned,
269        } = registration;
270
271        // Validate component name (must contain a hyphen)
272        if !tag_name.contains('-') {
273            return Err(ParserError::Component(format!(
274                "Component name '{}' must contain a hyphen",
275                tag_name
276            )));
277        }
278
279        // Check for duplicate component
280        if self.components.contains_key(tag_name) {
281            return Err(ParserError::Component(format!(
282                "Component '{}' is already registered",
283                tag_name
284            )));
285        }
286
287        // Extract CSS definitions/fallback requirements if CSS content is provided
288        let (mut css_content, css_definitions, css_fallback_chains) = match css_content {
289            Some(css) => {
290                let (content, definitions, requirements) = self.process_css_content(css)?;
291                (Some(content), definitions, requirements)
292            }
293            None => (None, Vec::new(), Vec::new()),
294        };
295
296        let render_policy = parse_component_render_policy(tag_name, html_content)?;
297        Self::append_scoped_policy_css(&mut css_content, &render_policy, tag_name);
298        let component: Component = Component {
299            tag_name: tag_name.to_string(),
300            html_content: html_content.to_string(),
301            css_content,
302            css_definitions,
303            css_fallback_chains,
304            is_client_owned,
305        };
306
307        // Register the component
308        self.render_policies
309            .insert(tag_name.to_string(), render_policy);
310        self.components.insert(tag_name.to_string(), component);
311        Ok(())
312    }
313
314    /// Strip comments and extract CSS definitions/fallback requirements.
315    fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
316        let (_tokens, definitions, requirements, stripped) = self
317            .css_parser
318            .extract_tokens_definitions_requirements_and_strip_comments(
319                css_content,
320                self.legal_comments,
321            )?;
322        let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
323        sorted_definitions.sort();
324        Ok((stripped.into_owned(), sorted_definitions, requirements))
325    }
326
327    fn append_scoped_policy_css(
328        css_content: &mut Option<String>,
329        policy: &ComponentRenderPolicy,
330        raw_tag_name: &str,
331    ) {
332        if policy.reserve_block_size().is_none() {
333            return;
334        }
335        let css = css_content.get_or_insert_with(|| String::with_capacity(112));
336        css.reserve(144);
337        let mut tag_name = String::with_capacity(32);
338        Self::push_css_identifier(&mut tag_name, raw_tag_name);
339        policy.append_scoped_css(css, &tag_name);
340    }
341
342    /// Check if a tag name is registered as a component.
343    pub fn contains(&self, tag_name: &str) -> bool {
344        self.components.contains_key(tag_name)
345    }
346
347    /// Get a component by its tag name.
348    pub fn get(&self, tag_name: &str) -> Option<&Component> {
349        self.components.get(tag_name)
350    }
351
352    /// Get all registered components.
353    pub fn get_all(&self) -> impl Iterator<Item = &Component> {
354        self.components.values()
355    }
356
357    /// Build deterministic document-level CSS for the supplied component tags
358    /// that opt into browser-managed lazy rendering.
359    #[must_use]
360    pub fn render_policy_css<'a>(&self, tag_names: impl IntoIterator<Item = &'a str>) -> String {
361        let mut policies: Vec<(&str, &ComponentRenderPolicy)> = tag_names
362            .into_iter()
363            .filter_map(|tag_name| {
364                self.render_policies
365                    .get(tag_name)
366                    .map(|policy| (tag_name, policy))
367            })
368            .filter(|(_, policy)| policy.reserve_block_size().is_some())
369            .collect();
370        policies.sort_unstable_by(|left, right| left.0.cmp(right.0));
371        policies.dedup_by(|left, right| left.0 == right.0);
372
373        let mut css = String::with_capacity(policies.len() * 112);
374        for (tag_name, policy) in policies {
375            Self::push_css_identifier(&mut css, tag_name);
376            css.push_str(r#":not([w-render="eager"]){"#);
377            policy.append_declarations(&mut css);
378            css.push('}');
379        }
380        css
381    }
382
383    /// Iterate the registered component tag names (e.g. `mp-button`).
384    ///
385    /// Used to offer "did you mean …?" suggestions when an unknown component
386    /// tag is encountered during parsing.
387    pub fn names(&self) -> impl Iterator<Item = &str> {
388        self.components.keys().map(String::as_str)
389    }
390
391    fn push_css_identifier(output: &mut String, value: &str) {
392        for (index, ch) in value.chars().enumerate() {
393            if ch.is_ascii_alphabetic()
394                || ch == '-'
395                || ch == '_'
396                || (index > 0 && ch.is_ascii_digit())
397            {
398                output.push(ch);
399            } else {
400                Self::push_css_escape(output, ch as u32);
401            }
402        }
403    }
404
405    fn push_css_escape(output: &mut String, mut value: u32) {
406        const HEX: &[u8; 16] = b"0123456789abcdef";
407        let mut digits = [0u8; 8];
408        let mut index = digits.len();
409        loop {
410            index -= 1;
411            digits[index] = HEX[(value & 0x0f) as usize];
412            value >>= 4;
413            if value == 0 {
414                break;
415            }
416        }
417        output.push('\\');
418        for digit in &digits[index..] {
419            output.push(char::from(*digit));
420        }
421        output.push(' ');
422    }
423
424    /// Get the number of registered components.
425    pub fn len(&self) -> usize {
426        self.components.len()
427    }
428
429    /// Check if the registry has no registered components.
430    pub fn is_empty(&self) -> bool {
431        self.components.is_empty()
432    }
433}
434
435#[cfg(test)]
436#[allow(clippy::disallowed_methods)]
437mod tests {
438    use super::*;
439    use crate::codes;
440    use webui_test_utils::TestFileSystem;
441
442    #[test]
443    fn test_register_component() {
444        let html_content = "<p>Hello World</p>";
445        let css_content = "p { color: red; }";
446
447        // Create temporary files with proper names directly
448        let mut fs = TestFileSystem::new();
449        let html_path = fs.add_file("components/test-component.html", html_content);
450        let css_path = fs.add_file("components/test-component.css", css_content);
451
452        // Register the component (no rename needed)
453        let mut registry = ComponentRegistry::new();
454        let result = registry.register_component_from_paths(&html_path, Some(&css_path));
455
456        assert!(result.is_ok());
457        assert!(registry.contains("test-component"));
458
459        let component = registry
460            .get("test-component")
461            .expect("Failed to retrieve registered component");
462        assert_eq!(component.html_content, html_content);
463        assert_eq!(component.css_content.as_deref(), Some(css_content));
464        assert!(!component.is_client_owned);
465    }
466
467    #[test]
468    fn test_register_component_detects_ts_sibling_script() {
469        let mut fs = TestFileSystem::new();
470        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
471        std::fs::write(html_path.with_extension("ts"), "export {};")
472            .expect("Failed to write sibling script");
473
474        let mut registry = ComponentRegistry::new();
475        registry
476            .register_component_from_paths(&html_path, None::<&str>)
477            .expect("register failed");
478
479        let component = registry
480            .get("scripted-card")
481            .expect("Failed to retrieve registered component");
482        assert!(component.is_client_owned);
483    }
484
485    #[test]
486    fn test_register_component_detects_js_sibling_script() {
487        let mut fs = TestFileSystem::new();
488        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
489        std::fs::write(html_path.with_extension("js"), "export {};")
490            .expect("Failed to write sibling script");
491
492        let mut registry = ComponentRegistry::new();
493        registry
494            .register_component_from_paths(&html_path, None::<&str>)
495            .expect("register failed");
496
497        let component = registry
498            .get("scripted-card")
499            .expect("Failed to retrieve registered component");
500        assert!(component.is_client_owned);
501    }
502
503    #[test]
504    fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
505        let mut fs = TestFileSystem::new();
506        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
507        std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
508            .expect("Failed to write invalid sibling script");
509
510        let mut registry = ComponentRegistry::new();
511        registry
512            .register_component_from_paths(&html_path, None::<&str>)
513            .expect("registration should inspect presence without decoding source");
514
515        let component = registry
516            .get("scripted-card")
517            .expect("Failed to retrieve registered component");
518        assert!(component.is_client_owned);
519    }
520
521    #[test]
522    fn test_register_component_ignores_tsx_sibling_script() {
523        let mut fs = TestFileSystem::new();
524        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
525        std::fs::write(html_path.with_extension("tsx"), "export {};")
526            .expect("Failed to write sibling script");
527
528        let mut registry = ComponentRegistry::new();
529        registry
530            .register_component_from_paths(&html_path, None::<&str>)
531            .expect("register failed");
532
533        let component = registry
534            .get("scripted-card")
535            .expect("Failed to retrieve registered component");
536        assert!(!component.is_client_owned);
537    }
538
539    #[test]
540    fn test_component_name_validation() {
541        let html_content = "<p>Invalid</p>";
542
543        // Create temporary file with invalid name (no hyphen)
544        let mut fs = TestFileSystem::new();
545        let html_path = fs.add_file("invalid_name.html", html_content);
546
547        // Try to register the component
548        let mut registry = ComponentRegistry::new();
549        let result = registry.register_component_from_paths(&html_path, None::<&str>);
550
551        assert!(result.is_err());
552        assert_eq!(registry.len(), 0);
553    }
554
555    #[test]
556    fn test_missing_css_file() {
557        let html_content = "<p>CSS Optional</p>";
558
559        // Create temporary HTML file
560        let mut fs = TestFileSystem::new();
561        let html_path = fs.add_file("test-component.html", html_content);
562
563        // Register with non-existent CSS file
564        let mut registry = ComponentRegistry::new();
565        let result = registry.register_component_from_paths(&html_path, None::<&str>);
566
567        assert!(result.is_ok());
568        let component = registry
569            .get("test-component")
570            .expect("Failed to retrieve registered component");
571        assert_eq!(component.html_content, html_content);
572        assert_eq!(component.css_content, None);
573    }
574
575    #[test]
576    fn test_register_component_from_strings() {
577        let html_content = "<p>Hello from string!</p>";
578        let css_content = "p { color: green; }";
579
580        // Register component directly from strings
581        let mut registry = ComponentRegistry::new();
582        let result = registry.register_component(ComponentRegistration::new(
583            "string-component",
584            html_content,
585            Some(css_content),
586            true,
587        ));
588
589        assert!(result.is_ok());
590        assert!(registry.contains("string-component"));
591
592        let component = registry
593            .get("string-component")
594            .expect("Failed to retrieve registered component");
595        assert_eq!(component.html_content, html_content);
596        assert_eq!(component.css_content.as_deref(), Some(css_content));
597        assert!(component.is_client_owned);
598    }
599
600    #[test]
601    fn lazy_render_policy_generates_stable_document_css() {
602        let mut registry = ComponentRegistry::new();
603        for (tag, size) in [("z-card", "18rem"), ("a-row", "72px")] {
604            let html = format!(
605                r#"<template w-render="lazy" w-reserve-block-size="{size}"><p>x</p></template>"#
606            );
607            registry
608                .register_component(ComponentRegistration::new(tag, &html, None, true))
609                .expect("valid lazy render policy");
610        }
611
612        assert_eq!(
613            registry.render_policy_css(["z-card", "a-row"]),
614            concat!(
615                r#"a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
616                r#"z-card:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 18rem;}"#,
617            )
618        );
619        assert_eq!(
620            registry
621                .get("a-row")
622                .and_then(|component| component.css_content.as_deref()),
623            Some(
624                r#":host(a-row:not([w-render="eager"])),a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
625            )
626        );
627    }
628
629    #[test]
630    fn lazy_hydration_policy_emits_no_rendering_css() {
631        let mut registry = ComponentRegistry::new();
632        registry
633            .register_component(ComponentRegistration::new(
634                "lazy-row",
635                r#"<template w-hydrate="lazy"><p>x</p></template>"#,
636                None,
637                true,
638            ))
639            .expect("valid hydration policy");
640        assert!(registry.render_policy_css(["lazy-row"]).is_empty());
641    }
642
643    #[test]
644    fn lazy_render_policy_requires_a_valid_reservation() {
645        for (html, code) in [
646            (
647                r#"<template w-render="lazy"><p>x</p></template>"#,
648                codes::MISSING_RENDER_RESERVATION,
649            ),
650            (
651                r#"<template w-render="lazy" w-reserve-block-size="50%"><p>x</p></template>"#,
652                codes::INVALID_RENDER_RESERVATION,
653            ),
654            (
655                r#"<div w-render="lazy" w-reserve-block-size="10px"></div>"#,
656                codes::INVALID_COMPONENT_RENDER_POLICY,
657            ),
658        ] {
659            let mut registry = ComponentRegistry::new();
660            let Err(ParserError::Template(diagnostic)) = registry
661                .register_component(ComponentRegistration::new("bad-card", html, None, true))
662            else {
663                panic!("invalid component policy must produce a template diagnostic");
664            };
665            assert_eq!(diagnostic.error_code(), Some(code));
666            assert!(diagnostic.help_text().is_some());
667        }
668    }
669
670    #[test]
671    fn test_register_component_strips_css_comments() {
672        let mut registry = ComponentRegistry::new();
673        registry
674            .register_component(ComponentRegistration::new(
675                "style-component",
676                "<p>Styled</p>",
677                Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
678                true,
679            ))
680            .expect("register failed");
681
682        let component = registry
683            .get("style-component")
684            .expect("Failed to retrieve registered component");
685        assert_eq!(
686            component.css_content.as_deref(),
687            Some(" p { color: var(--textColor); } ")
688        );
689        assert_eq!(component.css_fallback_chains.len(), 1);
690    }
691
692    #[test]
693    fn test_register_component_preserves_legal_css_comments_by_default() {
694        let mut registry = ComponentRegistry::new();
695        registry
696            .register_component(ComponentRegistration::new(
697                "legal-component",
698                "<p>Styled</p>",
699                Some("/*! @license MIT */ p { color: red; } /* remove */"),
700                true,
701            ))
702            .expect("register failed");
703
704        let component = registry
705            .get("legal-component")
706            .expect("Failed to retrieve registered component");
707        assert_eq!(
708            component.css_content.as_deref(),
709            Some("/*! @license MIT */ p { color: red; } ")
710        );
711    }
712
713    #[test]
714    fn test_register_component_strips_legal_css_comments_when_disabled() {
715        let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
716        registry
717            .register_component(ComponentRegistration::new(
718                "legal-component",
719                "<p>Styled</p>",
720                Some("/*! @license MIT */ p { color: red; }"),
721                true,
722            ))
723            .expect("register failed");
724
725        let component = registry
726            .get("legal-component")
727            .expect("Failed to retrieve registered component");
728        assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
729    }
730
731    #[test]
732    fn test_invalid_component_name_from_strings() {
733        let html_content = "<p>Invalid component</p>";
734
735        // Try registering with invalid name (no hyphen)
736        let mut registry = ComponentRegistry::new();
737        let result = registry.register_component(ComponentRegistration::new(
738            "invalid",
739            html_content,
740            None,
741            true,
742        ));
743
744        // More idiomatic approach using assert!() with message
745        assert!(result.is_err(), "Expected error for invalid component name");
746
747        // Better pattern matching using matches!() macro
748        assert!(
749            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
750            "Wrong error type or message: {:?}",
751            result
752        );
753    }
754
755    #[test]
756    fn test_duplicate_component_from_strings() {
757        let html_content = "<p>First component</p>";
758        let html_content2 = "<p>Second component with same name</p>";
759
760        // Register the first component
761        let mut registry = ComponentRegistry::new();
762        let result1 = registry.register_component(ComponentRegistration::new(
763            "dupe-component",
764            html_content,
765            None,
766            true,
767        ));
768        assert!(result1.is_ok());
769
770        // Try to register a second component with the same name
771        let result2 = registry.register_component(ComponentRegistration::new(
772            "dupe-component",
773            html_content2,
774            None,
775            true,
776        ));
777        assert!(result2.is_err());
778
779        // Verify the error message
780        assert!(
781            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
782            "Expected 'already registered' error, got: {:?}",
783            result2
784        );
785
786        // Verify the original component is still there unchanged
787        let component = registry
788            .get("dupe-component")
789            .expect("Failed to retrieve registered component");
790        assert_eq!(component.html_content, html_content);
791    }
792
793    #[test]
794    fn test_duplicate_component_from_paths() {
795        let html_content1 = "<p>Component from dir A</p>";
796        let html_content2 = "<p>Component from dir B</p>";
797
798        // Create temporary directories and files.
799        let mut fs = TestFileSystem::new();
800        let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
801        let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
802
803        // Register the first component
804        let mut registry = ComponentRegistry::new();
805        let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
806        assert!(result1.is_ok());
807
808        // Try to register the second component with the same name from a different path
809        let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
810        assert!(result2.is_err());
811
812        // Verify the error message
813        assert!(
814            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
815            "Expected 'already registered' error, got: {:?}",
816            result2
817        );
818
819        // Verify the original component is still there unchanged
820        let component = registry
821            .get("my-comp")
822            .expect("Failed to retrieve registered component");
823        assert_eq!(component.html_content, html_content1);
824    }
825
826    #[test]
827    fn test_exclude_dot_in_component_name() {
828        let mut registry = ComponentRegistry::new();
829        let result = registry.register_component(ComponentRegistration::new(
830            "fluent.button",
831            "<p>Dot name</p>",
832            None,
833            true,
834        ));
835
836        assert!(
837            result.is_err(),
838            "Component name with dot but no hyphen should be rejected"
839        );
840        assert!(
841            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
842            "Wrong error type or message: {:?}",
843            result
844        );
845        assert_eq!(registry.len(), 0);
846    }
847
848    #[test]
849    fn test_exclude_no_hyphen_html() {
850        let mut registry = ComponentRegistry::new();
851        let result = registry.register_component(ComponentRegistration::new(
852            "foobar",
853            "<p>No hyphen</p>",
854            None,
855            true,
856        ));
857
858        assert!(
859            result.is_err(),
860            "Component name without hyphen should be rejected"
861        );
862        assert!(
863            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
864            "Wrong error type or message: {:?}",
865            result
866        );
867        assert_eq!(registry.len(), 0);
868    }
869
870    #[test]
871    fn test_valid_component_with_hyphen() {
872        let mut registry = ComponentRegistry::new();
873        let result = registry.register_component(ComponentRegistration::new(
874            "fluent-button",
875            "<button>Click me</button>",
876            None,
877            true,
878        ));
879
880        assert!(
881            result.is_ok(),
882            "Component name with hyphen should be accepted"
883        );
884        assert!(registry.contains("fluent-button"));
885        assert_eq!(registry.len(), 1);
886    }
887
888    #[test]
889    fn test_valid_component_css_only() {
890        let mut registry = ComponentRegistry::new();
891        let result = registry.register_component(ComponentRegistration::new(
892            "styled-widget",
893            "",
894            Some(".widget { color: blue; }"),
895            true,
896        ));
897
898        assert!(
899            result.is_ok(),
900            "Component with empty HTML and CSS should be accepted"
901        );
902        assert!(registry.contains("styled-widget"));
903
904        let component = registry
905            .get("styled-widget")
906            .expect("Failed to retrieve registered component");
907        assert_eq!(component.html_content, "");
908        assert_eq!(
909            component.css_content.as_deref(),
910            Some(".widget { color: blue; }")
911        );
912    }
913
914    #[test]
915    fn test_component_name_requires_hyphen() {
916        let mut registry = ComponentRegistry::new();
917        let result = registry.register_component(ComponentRegistration::new(
918            "single",
919            "<p>Single word</p>",
920            None,
921            true,
922        ));
923
924        assert!(
925            result.is_err(),
926            "Single-word component name should be rejected"
927        );
928        assert!(
929            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
930            "Wrong error type or message: {:?}",
931            result
932        );
933        assert_eq!(registry.len(), 0);
934    }
935
936    #[test]
937    fn test_multiple_hyphens_valid() {
938        let mut registry = ComponentRegistry::new();
939        let result = registry.register_component(ComponentRegistration::new(
940            "my-custom-element",
941            "<div>Custom element</div>",
942            None,
943            true,
944        ));
945
946        assert!(
947            result.is_ok(),
948            "Component name with multiple hyphens should be accepted"
949        );
950        assert!(registry.contains("my-custom-element"));
951        assert_eq!(registry.len(), 1);
952    }
953
954    #[test]
955    fn test_empty_component_name_rejected() {
956        let mut registry = ComponentRegistry::new();
957        let result = registry.register_component(ComponentRegistration::new(
958            "",
959            "<p>Empty name</p>",
960            None,
961            true,
962        ));
963
964        assert!(result.is_err(), "Empty component name should be rejected");
965        assert!(
966            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
967            "Wrong error type or message: {:?}",
968            result
969        );
970        assert_eq!(registry.len(), 0);
971    }
972
973    #[test]
974    fn test_register_component_extracts_css_fallback_requirements() {
975        let mut registry = ComponentRegistry::new();
976        registry
977            .register_component(ComponentRegistration::new(
978                "my-btn",
979                "<button>Click</button>",
980                Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
981                true,
982            ))
983            .expect("register failed");
984
985        let component = registry.get("my-btn").expect("component not found");
986        assert_eq!(component.css_fallback_chains.len(), 2);
987    }
988
989    #[test]
990    fn test_register_component_no_css_no_requirements() {
991        let mut registry = ComponentRegistry::new();
992        registry
993            .register_component(ComponentRegistration::new(
994                "my-card",
995                "<div>Card</div>",
996                None,
997                true,
998            ))
999            .expect("register failed");
1000
1001        let component = registry.get("my-card").expect("component not found");
1002        assert!(component.css_fallback_chains.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_register_component_tracks_css_fallback_requirements() {
1007        let mut registry = ComponentRegistry::new();
1008        registry
1009            .register_component(ComponentRegistration::new(
1010                "my-widget",
1011                "<div>W</div>",
1012                Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
1013                true,
1014            ))
1015            .expect("register failed");
1016
1017        let component = registry.get("my-widget").expect("component not found");
1018        assert_eq!(component.css_fallback_chains.len(), 2);
1019    }
1020}