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