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    /// Get the number of registered components.
249    pub fn len(&self) -> usize {
250        self.components.len()
251    }
252
253    /// Check if the registry is empty.
254    pub fn is_empty(&self) -> bool {
255        self.components.is_empty()
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use webui_test_utils::TestFileSystem;
263
264    #[test]
265    fn test_register_component() {
266        let html_content = "<p>Hello World</p>";
267        let css_content = "p { color: red; }";
268
269        // Create temporary files with proper names directly
270        let mut fs = TestFileSystem::new();
271        let html_path = fs.add_file("components/test-component.html", html_content);
272        let css_path = fs.add_file("components/test-component.css", css_content);
273
274        // Register the component (no rename needed)
275        let mut registry = ComponentRegistry::new();
276        let result = registry.register_component_from_paths(&html_path, Some(&css_path));
277
278        assert!(result.is_ok());
279        assert!(registry.contains("test-component"));
280
281        let component = registry
282            .get("test-component")
283            .expect("Failed to retrieve registered component");
284        assert_eq!(component.html_content, html_content);
285        assert_eq!(component.css_content.as_deref(), Some(css_content));
286    }
287
288    #[test]
289    fn test_component_name_validation() {
290        let html_content = "<p>Invalid</p>";
291
292        // Create temporary file with invalid name (no hyphen)
293        let mut fs = TestFileSystem::new();
294        let html_path = fs.add_file("invalid_name.html", html_content);
295
296        // Try to register the component
297        let mut registry = ComponentRegistry::new();
298        let result = registry.register_component_from_paths(&html_path, None::<&str>);
299
300        assert!(result.is_err());
301        assert_eq!(registry.len(), 0);
302    }
303
304    #[test]
305    fn test_missing_css_file() {
306        let html_content = "<p>CSS Optional</p>";
307
308        // Create temporary HTML file
309        let mut fs = TestFileSystem::new();
310        let html_path = fs.add_file("test-component.html", html_content);
311
312        // Register with non-existent CSS file
313        let mut registry = ComponentRegistry::new();
314        let result = registry.register_component_from_paths(&html_path, None::<&str>);
315
316        assert!(result.is_ok());
317        let component = registry
318            .get("test-component")
319            .expect("Failed to retrieve registered component");
320        assert_eq!(component.html_content, html_content);
321        assert_eq!(component.css_content, None);
322    }
323
324    #[test]
325    fn test_register_component_from_strings() {
326        let html_content = "<p>Hello from string!</p>";
327        let css_content = "p { color: green; }";
328
329        // Register component directly from strings
330        let mut registry = ComponentRegistry::new();
331        let result =
332            registry.register_component("string-component", html_content, Some(css_content));
333
334        assert!(result.is_ok());
335        assert!(registry.contains("string-component"));
336
337        let component = registry
338            .get("string-component")
339            .expect("Failed to retrieve registered component");
340        assert_eq!(component.html_content, html_content);
341        assert_eq!(component.css_content.as_deref(), Some(css_content));
342    }
343
344    #[test]
345    fn test_register_component_strips_css_comments() {
346        let mut registry = ComponentRegistry::new();
347        registry
348            .register_component(
349                "style-component",
350                "<p>Styled</p>",
351                Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
352            )
353            .expect("register failed");
354
355        let component = registry
356            .get("style-component")
357            .expect("Failed to retrieve registered component");
358        assert_eq!(
359            component.css_content.as_deref(),
360            Some(" p { color: var(--textColor); } ")
361        );
362        assert_eq!(component.css_tokens, vec!["textColor"]);
363    }
364
365    #[test]
366    fn test_register_component_preserves_legal_css_comments_by_default() {
367        let mut registry = ComponentRegistry::new();
368        registry
369            .register_component(
370                "legal-component",
371                "<p>Styled</p>",
372                Some("/*! @license MIT */ p { color: red; } /* remove */"),
373            )
374            .expect("register failed");
375
376        let component = registry
377            .get("legal-component")
378            .expect("Failed to retrieve registered component");
379        assert_eq!(
380            component.css_content.as_deref(),
381            Some("/*! @license MIT */ p { color: red; } ")
382        );
383    }
384
385    #[test]
386    fn test_register_component_strips_legal_css_comments_when_disabled() {
387        let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
388        registry
389            .register_component(
390                "legal-component",
391                "<p>Styled</p>",
392                Some("/*! @license MIT */ p { color: red; }"),
393            )
394            .expect("register failed");
395
396        let component = registry
397            .get("legal-component")
398            .expect("Failed to retrieve registered component");
399        assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
400    }
401
402    #[test]
403    fn test_invalid_component_name_from_strings() {
404        let html_content = "<p>Invalid component</p>";
405
406        // Try registering with invalid name (no hyphen)
407        let mut registry = ComponentRegistry::new();
408        let result = registry.register_component("invalid", html_content, None);
409
410        // More idiomatic approach using assert!() with message
411        assert!(result.is_err(), "Expected error for invalid component name");
412
413        // Better pattern matching using matches!() macro
414        assert!(
415            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
416            "Wrong error type or message: {:?}",
417            result
418        );
419    }
420
421    #[test]
422    fn test_duplicate_component_from_strings() {
423        let html_content = "<p>First component</p>";
424        let html_content2 = "<p>Second component with same name</p>";
425
426        // Register the first component
427        let mut registry = ComponentRegistry::new();
428        let result1 = registry.register_component("dupe-component", html_content, None);
429        assert!(result1.is_ok());
430
431        // Try to register a second component with the same name
432        let result2 = registry.register_component("dupe-component", html_content2, None);
433        assert!(result2.is_err());
434
435        // Verify the error message
436        assert!(
437            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
438            "Expected 'already registered' error, got: {:?}",
439            result2
440        );
441
442        // Verify the original component is still there unchanged
443        let component = registry
444            .get("dupe-component")
445            .expect("Failed to retrieve registered component");
446        assert_eq!(component.html_content, html_content);
447    }
448
449    #[test]
450    fn test_duplicate_component_from_paths() {
451        let html_content1 = "<p>Component from dir A</p>";
452        let html_content2 = "<p>Component from dir B</p>";
453
454        // Create temporary directories and files.
455        let mut fs = TestFileSystem::new();
456        let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
457        let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
458
459        // Register the first component
460        let mut registry = ComponentRegistry::new();
461        let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
462        assert!(result1.is_ok());
463
464        // Try to register the second component with the same name from a different path
465        let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
466        assert!(result2.is_err());
467
468        // Verify the error message
469        assert!(
470            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
471            "Expected 'already registered' error, got: {:?}",
472            result2
473        );
474
475        // Verify the original component is still there unchanged
476        let component = registry
477            .get("my-comp")
478            .expect("Failed to retrieve registered component");
479        assert_eq!(component.html_content, html_content1);
480    }
481
482    #[test]
483    fn test_exclude_dot_in_component_name() {
484        let mut registry = ComponentRegistry::new();
485        let result = registry.register_component("fluent.button", "<p>Dot name</p>", None);
486
487        assert!(
488            result.is_err(),
489            "Component name with dot but no hyphen should be rejected"
490        );
491        assert!(
492            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
493            "Wrong error type or message: {:?}",
494            result
495        );
496        assert_eq!(registry.len(), 0);
497    }
498
499    #[test]
500    fn test_exclude_no_hyphen_html() {
501        let mut registry = ComponentRegistry::new();
502        let result = registry.register_component("foobar", "<p>No hyphen</p>", None);
503
504        assert!(
505            result.is_err(),
506            "Component name without hyphen should be rejected"
507        );
508        assert!(
509            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
510            "Wrong error type or message: {:?}",
511            result
512        );
513        assert_eq!(registry.len(), 0);
514    }
515
516    #[test]
517    fn test_valid_component_with_hyphen() {
518        let mut registry = ComponentRegistry::new();
519        let result =
520            registry.register_component("fluent-button", "<button>Click me</button>", None);
521
522        assert!(
523            result.is_ok(),
524            "Component name with hyphen should be accepted"
525        );
526        assert!(registry.contains("fluent-button"));
527        assert_eq!(registry.len(), 1);
528    }
529
530    #[test]
531    fn test_valid_component_css_only() {
532        let mut registry = ComponentRegistry::new();
533        let result =
534            registry.register_component("styled-widget", "", Some(".widget { color: blue; }"));
535
536        assert!(
537            result.is_ok(),
538            "Component with empty HTML and CSS should be accepted"
539        );
540        assert!(registry.contains("styled-widget"));
541
542        let component = registry
543            .get("styled-widget")
544            .expect("Failed to retrieve registered component");
545        assert_eq!(component.html_content, "");
546        assert_eq!(
547            component.css_content.as_deref(),
548            Some(".widget { color: blue; }")
549        );
550    }
551
552    #[test]
553    fn test_component_name_requires_hyphen() {
554        let mut registry = ComponentRegistry::new();
555        let result = registry.register_component("single", "<p>Single word</p>", None);
556
557        assert!(
558            result.is_err(),
559            "Single-word component name should be rejected"
560        );
561        assert!(
562            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
563            "Wrong error type or message: {:?}",
564            result
565        );
566        assert_eq!(registry.len(), 0);
567    }
568
569    #[test]
570    fn test_multiple_hyphens_valid() {
571        let mut registry = ComponentRegistry::new();
572        let result =
573            registry.register_component("my-custom-element", "<div>Custom element</div>", None);
574
575        assert!(
576            result.is_ok(),
577            "Component name with multiple hyphens should be accepted"
578        );
579        assert!(registry.contains("my-custom-element"));
580        assert_eq!(registry.len(), 1);
581    }
582
583    #[test]
584    fn test_empty_component_name_rejected() {
585        let mut registry = ComponentRegistry::new();
586        let result = registry.register_component("", "<p>Empty name</p>", None);
587
588        assert!(result.is_err(), "Empty component name should be rejected");
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_register_component_extracts_css_tokens() {
599        let mut registry = ComponentRegistry::new();
600        registry
601            .register_component(
602                "my-btn",
603                "<button>Click</button>",
604                Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
605            )
606            .expect("register failed");
607
608        let component = registry.get("my-btn").expect("component not found");
609        assert_eq!(component.css_tokens, vec!["spacing-m", "text-color"]);
610    }
611
612    #[test]
613    fn test_register_component_no_css_no_tokens() {
614        let mut registry = ComponentRegistry::new();
615        registry
616            .register_component("my-card", "<div>Card</div>", None)
617            .expect("register failed");
618
619        let component = registry.get("my-card").expect("component not found");
620        assert!(component.css_tokens.is_empty());
621    }
622
623    #[test]
624    fn test_register_component_excludes_local_defs_from_tokens() {
625        let mut registry = ComponentRegistry::new();
626        registry
627            .register_component(
628                "my-widget",
629                "<div>W</div>",
630                Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
631            )
632            .expect("register failed");
633
634        let component = registry.get("my-widget").expect("component not found");
635        // --local is defined locally so excluded; only --external is a token
636        assert_eq!(component.css_tokens, vec!["external"]);
637    }
638}