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