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::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
9use std::collections::HashMap;
10#[cfg(feature = "fs")]
11use std::fs;
12#[cfg(feature = "fs")]
13use std::path::Path;
14#[cfg(feature = "fs")]
15use walkdir::WalkDir;
16
17type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
18
19/// Represents a web component in the registry.
20#[derive(Debug, Clone)]
21pub struct Component {
22    /// The custom element tag name (e.g., "hello-world")
23    pub tag_name: String,
24
25    /// The HTML content of the component
26    pub html_content: String,
27
28    /// The CSS content of the component, if any
29    pub css_content: Option<String>,
30
31    /// CSS custom property definitions from this component's CSS.
32    pub css_definitions: Vec<String>,
33
34    /// CSS `var()` fallback chains from this component's CSS.
35    pub css_fallback_chains: Vec<CssFallbackChain>,
36
37    /// Whether this component has an authored client script.
38    pub has_script: bool,
39}
40
41/// Registry of web components.
42#[derive(Debug)]
43pub struct ComponentRegistry {
44    /// Map of component tag names to their component data
45    components: HashMap<String, Component>,
46    /// Reusable CSS parser for token extraction during registration.
47    css_parser: CssParser,
48    /// Legal comment preservation policy for component CSS.
49    legal_comments: LegalComments,
50}
51
52#[cfg(feature = "fs")]
53/// Return true when a component has an authored browser module next to its HTML.
54///
55/// Only `.ts` and `.js` are recognized component implementations. Other
56/// extensions are intentionally ignored so static-host eligibility is
57/// deterministic and matches the documented authoring model.
58fn component_has_script(html_path: &Path) -> bool {
59    html_path.with_extension("ts").exists() || html_path.with_extension("js").exists()
60}
61
62impl Default for ComponentRegistry {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl ComponentRegistry {
69    /// Create a new component registry.
70    pub fn new() -> Self {
71        Self::with_legal_comments(LegalComments::default())
72    }
73
74    pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
75        Self {
76            components: HashMap::new(),
77            css_parser: CssParser::new(),
78            legal_comments,
79        }
80    }
81
82    /// Register multiple components from directories recursively.
83    #[cfg(feature = "fs")]
84    pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
85        for dir in directories {
86            for entry in WalkDir::new(dir.as_ref())
87                .into_iter()
88                .filter_map(|e| e.ok())
89            {
90                let path = entry.path();
91                if !path.is_file() {
92                    continue;
93                }
94                // Only process HTML files
95                if path.extension().is_some_and(|ext| ext == "html") {
96                    // Check for a component name (must contain a hyphen)
97                    if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
98                        if filename.contains('-') {
99                            // Find associated CSS file
100                            let css_path = path.with_extension("css");
101                            // Register the component (key is the file name without extension)
102                            self.register_component_from_paths(
103                                path,
104                                if css_path.exists() {
105                                    Some(&css_path)
106                                } else {
107                                    None
108                                },
109                            )?;
110                        }
111                    }
112                }
113            }
114        }
115        Ok(self)
116    }
117
118    /// Register a web component from paths to HTML and CSS files.
119    #[cfg(feature = "fs")]
120    pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
121        &mut self,
122        html_path: P,
123        css_path: Option<Q>,
124    ) -> Result<()> {
125        let html_path = html_path.as_ref();
126
127        // Extract component name from file name (without extension)
128        let tag_name = html_path
129            .file_stem()
130            .and_then(|s| s.to_str())
131            .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
132
133        // Validate component name (must contain a hyphen)
134        if !tag_name.contains('-') {
135            return Err(ParserError::Component(format!(
136                "Component name '{}' must contain a hyphen",
137                tag_name
138            )));
139        }
140
141        // Check for duplicate component
142        if self.components.contains_key(tag_name) {
143            return Err(ParserError::Component(format!(
144                "Component '{}' is already registered",
145                tag_name
146            )));
147        }
148
149        // Read HTML content
150        let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
151            context: format!("Failed to read HTML file: {}", html_path.display()),
152            source,
153        })?;
154
155        // Read CSS content and extract definitions/fallback requirements if available
156        let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
157            let css_path = css_path.as_ref();
158            if css_path.exists() {
159                let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
160                    context: format!("Failed to read CSS file: {}", css_path.display()),
161                    source,
162                })?;
163                let (content, definitions, requirements) = self.process_css_content(&content)?;
164                (Some(content), definitions, requirements)
165            } else {
166                (None, Vec::new(), Vec::new())
167            }
168        } else {
169            (None, Vec::new(), Vec::new())
170        };
171
172        // Create and register the component
173        let component = Component {
174            tag_name: tag_name.to_string(),
175            html_content,
176            css_content,
177            css_definitions,
178            css_fallback_chains,
179            has_script: component_has_script(html_path),
180        };
181
182        self.components.insert(tag_name.to_string(), component);
183        Ok(())
184    }
185
186    /// Register a component directly from provided content strings.
187    pub fn register_component(
188        &mut self,
189        tag_name: &str,
190        html_content: &str,
191        css_content: Option<&str>,
192        has_script: bool,
193    ) -> Result<()> {
194        // Validate component name (must contain a hyphen)
195        if !tag_name.contains('-') {
196            return Err(ParserError::Component(format!(
197                "Component name '{}' must contain a hyphen",
198                tag_name
199            )));
200        }
201
202        // Check for duplicate component
203        if self.components.contains_key(tag_name) {
204            return Err(ParserError::Component(format!(
205                "Component '{}' is already registered",
206                tag_name
207            )));
208        }
209
210        // Extract CSS definitions/fallback requirements if CSS content is provided
211        let (css_content, css_definitions, css_fallback_chains) = match css_content {
212            Some(css) => {
213                let (content, definitions, requirements) = self.process_css_content(css)?;
214                (Some(content), definitions, requirements)
215            }
216            None => (None, Vec::new(), Vec::new()),
217        };
218
219        let component: Component = Component {
220            tag_name: tag_name.to_string(),
221            html_content: html_content.to_string(),
222            css_content,
223            css_definitions,
224            css_fallback_chains,
225            has_script,
226        };
227
228        // Register the component
229        self.components.insert(tag_name.to_string(), component);
230        Ok(())
231    }
232
233    /// Strip comments and extract CSS definitions/fallback requirements.
234    fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
235        let (_tokens, definitions, requirements, stripped) = self
236            .css_parser
237            .extract_tokens_definitions_requirements_and_strip_comments(
238                css_content,
239                self.legal_comments,
240            )?;
241        let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
242        sorted_definitions.sort();
243        Ok((stripped.into_owned(), sorted_definitions, requirements))
244    }
245
246    /// Check if a tag name is registered as a component.
247    pub fn contains(&self, tag_name: &str) -> bool {
248        self.components.contains_key(tag_name)
249    }
250
251    /// Get a component by its tag name.
252    pub fn get(&self, tag_name: &str) -> Option<&Component> {
253        self.components.get(tag_name)
254    }
255
256    /// Get all registered components.
257    pub fn get_all(&self) -> impl Iterator<Item = &Component> {
258        self.components.values()
259    }
260
261    /// Iterate the registered component tag names (e.g. `mp-button`).
262    ///
263    /// Used to offer "did you mean …?" suggestions when an unknown component
264    /// tag is encountered during parsing.
265    pub fn names(&self) -> impl Iterator<Item = &str> {
266        self.components.keys().map(String::as_str)
267    }
268
269    /// Get the number of registered components.
270    pub fn len(&self) -> usize {
271        self.components.len()
272    }
273
274    /// Check if the registry has no registered components.
275    pub fn is_empty(&self) -> bool {
276        self.components.is_empty()
277    }
278}
279
280#[cfg(test)]
281#[allow(clippy::disallowed_methods)]
282mod tests {
283    use super::*;
284    use webui_test_utils::TestFileSystem;
285
286    #[test]
287    fn test_register_component() {
288        let html_content = "<p>Hello World</p>";
289        let css_content = "p { color: red; }";
290
291        // Create temporary files with proper names directly
292        let mut fs = TestFileSystem::new();
293        let html_path = fs.add_file("components/test-component.html", html_content);
294        let css_path = fs.add_file("components/test-component.css", css_content);
295
296        // Register the component (no rename needed)
297        let mut registry = ComponentRegistry::new();
298        let result = registry.register_component_from_paths(&html_path, Some(&css_path));
299
300        assert!(result.is_ok());
301        assert!(registry.contains("test-component"));
302
303        let component = registry
304            .get("test-component")
305            .expect("Failed to retrieve registered component");
306        assert_eq!(component.html_content, html_content);
307        assert_eq!(component.css_content.as_deref(), Some(css_content));
308        assert!(!component.has_script);
309    }
310
311    #[test]
312    fn test_register_component_detects_ts_sibling_script() {
313        let mut fs = TestFileSystem::new();
314        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
315        std::fs::write(html_path.with_extension("ts"), "export {};")
316            .expect("Failed to write sibling script");
317
318        let mut registry = ComponentRegistry::new();
319        registry
320            .register_component_from_paths(&html_path, None::<&str>)
321            .expect("register failed");
322
323        let component = registry
324            .get("scripted-card")
325            .expect("Failed to retrieve registered component");
326        assert!(component.has_script);
327    }
328
329    #[test]
330    fn test_register_component_detects_js_sibling_script() {
331        let mut fs = TestFileSystem::new();
332        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
333        std::fs::write(html_path.with_extension("js"), "export {};")
334            .expect("Failed to write sibling script");
335
336        let mut registry = ComponentRegistry::new();
337        registry
338            .register_component_from_paths(&html_path, None::<&str>)
339            .expect("register failed");
340
341        let component = registry
342            .get("scripted-card")
343            .expect("Failed to retrieve registered component");
344        assert!(component.has_script);
345    }
346
347    #[test]
348    fn test_register_component_ignores_tsx_sibling_script() {
349        let mut fs = TestFileSystem::new();
350        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
351        std::fs::write(html_path.with_extension("tsx"), "export {};")
352            .expect("Failed to write sibling script");
353
354        let mut registry = ComponentRegistry::new();
355        registry
356            .register_component_from_paths(&html_path, None::<&str>)
357            .expect("register failed");
358
359        let component = registry
360            .get("scripted-card")
361            .expect("Failed to retrieve registered component");
362        assert!(!component.has_script);
363    }
364
365    #[test]
366    fn test_component_name_validation() {
367        let html_content = "<p>Invalid</p>";
368
369        // Create temporary file with invalid name (no hyphen)
370        let mut fs = TestFileSystem::new();
371        let html_path = fs.add_file("invalid_name.html", html_content);
372
373        // Try to register the component
374        let mut registry = ComponentRegistry::new();
375        let result = registry.register_component_from_paths(&html_path, None::<&str>);
376
377        assert!(result.is_err());
378        assert_eq!(registry.len(), 0);
379    }
380
381    #[test]
382    fn test_missing_css_file() {
383        let html_content = "<p>CSS Optional</p>";
384
385        // Create temporary HTML file
386        let mut fs = TestFileSystem::new();
387        let html_path = fs.add_file("test-component.html", html_content);
388
389        // Register with non-existent CSS file
390        let mut registry = ComponentRegistry::new();
391        let result = registry.register_component_from_paths(&html_path, None::<&str>);
392
393        assert!(result.is_ok());
394        let component = registry
395            .get("test-component")
396            .expect("Failed to retrieve registered component");
397        assert_eq!(component.html_content, html_content);
398        assert_eq!(component.css_content, None);
399    }
400
401    #[test]
402    fn test_register_component_from_strings() {
403        let html_content = "<p>Hello from string!</p>";
404        let css_content = "p { color: green; }";
405
406        // Register component directly from strings
407        let mut registry = ComponentRegistry::new();
408        let result =
409            registry.register_component("string-component", html_content, Some(css_content), true);
410
411        assert!(result.is_ok());
412        assert!(registry.contains("string-component"));
413
414        let component = registry
415            .get("string-component")
416            .expect("Failed to retrieve registered component");
417        assert_eq!(component.html_content, html_content);
418        assert_eq!(component.css_content.as_deref(), Some(css_content));
419        assert!(component.has_script);
420    }
421
422    #[test]
423    fn test_register_component_strips_css_comments() {
424        let mut registry = ComponentRegistry::new();
425        registry
426            .register_component(
427                "style-component",
428                "<p>Styled</p>",
429                Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
430                true,
431            )
432            .expect("register failed");
433
434        let component = registry
435            .get("style-component")
436            .expect("Failed to retrieve registered component");
437        assert_eq!(
438            component.css_content.as_deref(),
439            Some(" p { color: var(--textColor); } ")
440        );
441        assert_eq!(component.css_fallback_chains.len(), 1);
442    }
443
444    #[test]
445    fn test_register_component_preserves_legal_css_comments_by_default() {
446        let mut registry = ComponentRegistry::new();
447        registry
448            .register_component(
449                "legal-component",
450                "<p>Styled</p>",
451                Some("/*! @license MIT */ p { color: red; } /* remove */"),
452                true,
453            )
454            .expect("register failed");
455
456        let component = registry
457            .get("legal-component")
458            .expect("Failed to retrieve registered component");
459        assert_eq!(
460            component.css_content.as_deref(),
461            Some("/*! @license MIT */ p { color: red; } ")
462        );
463    }
464
465    #[test]
466    fn test_register_component_strips_legal_css_comments_when_disabled() {
467        let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
468        registry
469            .register_component(
470                "legal-component",
471                "<p>Styled</p>",
472                Some("/*! @license MIT */ p { color: red; }"),
473                true,
474            )
475            .expect("register failed");
476
477        let component = registry
478            .get("legal-component")
479            .expect("Failed to retrieve registered component");
480        assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
481    }
482
483    #[test]
484    fn test_invalid_component_name_from_strings() {
485        let html_content = "<p>Invalid component</p>";
486
487        // Try registering with invalid name (no hyphen)
488        let mut registry = ComponentRegistry::new();
489        let result = registry.register_component("invalid", html_content, None, true);
490
491        // More idiomatic approach using assert!() with message
492        assert!(result.is_err(), "Expected error for invalid component name");
493
494        // Better pattern matching using matches!() macro
495        assert!(
496            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
497            "Wrong error type or message: {:?}",
498            result
499        );
500    }
501
502    #[test]
503    fn test_duplicate_component_from_strings() {
504        let html_content = "<p>First component</p>";
505        let html_content2 = "<p>Second component with same name</p>";
506
507        // Register the first component
508        let mut registry = ComponentRegistry::new();
509        let result1 = registry.register_component("dupe-component", html_content, None, true);
510        assert!(result1.is_ok());
511
512        // Try to register a second component with the same name
513        let result2 = registry.register_component("dupe-component", html_content2, None, true);
514        assert!(result2.is_err());
515
516        // Verify the error message
517        assert!(
518            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
519            "Expected 'already registered' error, got: {:?}",
520            result2
521        );
522
523        // Verify the original component is still there unchanged
524        let component = registry
525            .get("dupe-component")
526            .expect("Failed to retrieve registered component");
527        assert_eq!(component.html_content, html_content);
528    }
529
530    #[test]
531    fn test_duplicate_component_from_paths() {
532        let html_content1 = "<p>Component from dir A</p>";
533        let html_content2 = "<p>Component from dir B</p>";
534
535        // Create temporary directories and files.
536        let mut fs = TestFileSystem::new();
537        let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
538        let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
539
540        // Register the first component
541        let mut registry = ComponentRegistry::new();
542        let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
543        assert!(result1.is_ok());
544
545        // Try to register the second component with the same name from a different path
546        let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
547        assert!(result2.is_err());
548
549        // Verify the error message
550        assert!(
551            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
552            "Expected 'already registered' error, got: {:?}",
553            result2
554        );
555
556        // Verify the original component is still there unchanged
557        let component = registry
558            .get("my-comp")
559            .expect("Failed to retrieve registered component");
560        assert_eq!(component.html_content, html_content1);
561    }
562
563    #[test]
564    fn test_exclude_dot_in_component_name() {
565        let mut registry = ComponentRegistry::new();
566        let result = registry.register_component("fluent.button", "<p>Dot name</p>", None, true);
567
568        assert!(
569            result.is_err(),
570            "Component name with dot but no hyphen should be rejected"
571        );
572        assert!(
573            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
574            "Wrong error type or message: {:?}",
575            result
576        );
577        assert_eq!(registry.len(), 0);
578    }
579
580    #[test]
581    fn test_exclude_no_hyphen_html() {
582        let mut registry = ComponentRegistry::new();
583        let result = registry.register_component("foobar", "<p>No hyphen</p>", None, true);
584
585        assert!(
586            result.is_err(),
587            "Component name without hyphen should be rejected"
588        );
589        assert!(
590            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
591            "Wrong error type or message: {:?}",
592            result
593        );
594        assert_eq!(registry.len(), 0);
595    }
596
597    #[test]
598    fn test_valid_component_with_hyphen() {
599        let mut registry = ComponentRegistry::new();
600        let result =
601            registry.register_component("fluent-button", "<button>Click me</button>", None, true);
602
603        assert!(
604            result.is_ok(),
605            "Component name with hyphen should be accepted"
606        );
607        assert!(registry.contains("fluent-button"));
608        assert_eq!(registry.len(), 1);
609    }
610
611    #[test]
612    fn test_valid_component_css_only() {
613        let mut registry = ComponentRegistry::new();
614        let result = registry.register_component(
615            "styled-widget",
616            "",
617            Some(".widget { color: blue; }"),
618            true,
619        );
620
621        assert!(
622            result.is_ok(),
623            "Component with empty HTML and CSS should be accepted"
624        );
625        assert!(registry.contains("styled-widget"));
626
627        let component = registry
628            .get("styled-widget")
629            .expect("Failed to retrieve registered component");
630        assert_eq!(component.html_content, "");
631        assert_eq!(
632            component.css_content.as_deref(),
633            Some(".widget { color: blue; }")
634        );
635    }
636
637    #[test]
638    fn test_component_name_requires_hyphen() {
639        let mut registry = ComponentRegistry::new();
640        let result = registry.register_component("single", "<p>Single word</p>", None, true);
641
642        assert!(
643            result.is_err(),
644            "Single-word component name should be rejected"
645        );
646        assert!(
647            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
648            "Wrong error type or message: {:?}",
649            result
650        );
651        assert_eq!(registry.len(), 0);
652    }
653
654    #[test]
655    fn test_multiple_hyphens_valid() {
656        let mut registry = ComponentRegistry::new();
657        let result = registry.register_component(
658            "my-custom-element",
659            "<div>Custom element</div>",
660            None,
661            true,
662        );
663
664        assert!(
665            result.is_ok(),
666            "Component name with multiple hyphens should be accepted"
667        );
668        assert!(registry.contains("my-custom-element"));
669        assert_eq!(registry.len(), 1);
670    }
671
672    #[test]
673    fn test_empty_component_name_rejected() {
674        let mut registry = ComponentRegistry::new();
675        let result = registry.register_component("", "<p>Empty name</p>", None, true);
676
677        assert!(result.is_err(), "Empty component name should be rejected");
678        assert!(
679            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
680            "Wrong error type or message: {:?}",
681            result
682        );
683        assert_eq!(registry.len(), 0);
684    }
685
686    #[test]
687    fn test_register_component_extracts_css_fallback_requirements() {
688        let mut registry = ComponentRegistry::new();
689        registry
690            .register_component(
691                "my-btn",
692                "<button>Click</button>",
693                Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
694                true,
695            )
696            .expect("register failed");
697
698        let component = registry.get("my-btn").expect("component not found");
699        assert_eq!(component.css_fallback_chains.len(), 2);
700    }
701
702    #[test]
703    fn test_register_component_no_css_no_requirements() {
704        let mut registry = ComponentRegistry::new();
705        registry
706            .register_component("my-card", "<div>Card</div>", None, true)
707            .expect("register failed");
708
709        let component = registry.get("my-card").expect("component not found");
710        assert!(component.css_fallback_chains.is_empty());
711    }
712
713    #[test]
714    fn test_register_component_tracks_css_fallback_requirements() {
715        let mut registry = ComponentRegistry::new();
716        registry
717            .register_component(
718                "my-widget",
719                "<div>W</div>",
720                Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
721                true,
722            )
723            .expect("register failed");
724
725        let component = registry.get("my-widget").expect("component not found");
726        assert_eq!(component.css_fallback_chains.len(), 2);
727    }
728}