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