1use 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#[derive(Debug, Clone)]
20pub struct Component {
21 pub tag_name: String,
23
24 pub html_content: String,
26
27 pub css_content: Option<String>,
29
30 pub css_tokens: Vec<String>,
33
34 pub source_path: PathBuf,
36
37 pub class_name: Option<String>,
39}
40
41#[derive(Debug)]
43pub struct ComponentRegistry {
44 components: HashMap<String, Component>,
46 css_parser: CssParser,
48}
49
50impl Default for ComponentRegistry {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl ComponentRegistry {
57 pub fn new() -> Self {
59 Self {
60 components: HashMap::new(),
61 css_parser: CssParser::new(),
62 }
63 }
64
65 #[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 if path.extension().is_some_and(|ext| ext == "html") {
79 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
81 if filename.contains('-') {
82 let css_path = path.with_extension("css");
84 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 #[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 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 if !tag_name.contains('-') {
118 return Err(ParserError::Component(format!(
119 "Component name '{}' must contain a hyphen",
120 tag_name
121 )));
122 }
123
124 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 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 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 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 pub fn register_component(
171 &mut self,
172 tag_name: &str,
173 html_content: &str,
174 css_content: Option<&str>,
175 ) -> Result<()> {
176 if !tag_name.contains('-') {
178 return Err(ParserError::Component(format!(
179 "Component name '{}' must contain a hyphen",
180 tag_name
181 )));
182 }
183
184 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 let css_tokens = match css_content {
194 Some(css) => self.extract_sorted_tokens(css)?,
195 None => Vec::new(),
196 };
197
198 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(), class_name: None,
206 };
207
208 self.components.insert(tag_name.to_string(), component);
210 Ok(())
211 }
212
213 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 pub fn contains(&self, tag_name: &str) -> bool {
223 self.components.contains_key(tag_name)
224 }
225
226 pub fn get(&self, tag_name: &str) -> Option<&Component> {
228 self.components.get(tag_name)
229 }
230
231 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
233 self.components.values()
234 }
235
236 pub fn len(&self) -> usize {
238 self.components.len()
239 }
240
241 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 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 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 let mut fs = TestFileSystem::new();
282 let html_path = fs.add_file("invalid_name.html", html_content);
283
284 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 let mut fs = TestFileSystem::new();
298 let html_path = fs.add_file("test-component.html", html_content);
299
300 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 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 let mut registry = ComponentRegistry::new();
338 let result = registry.register_component("invalid", html_content, None);
339
340 assert!(result.is_err(), "Expected error for invalid component name");
342
343 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 let mut registry = ComponentRegistry::new();
358 let result1 = registry.register_component("dupe-component", html_content, None);
359 assert!(result1.is_ok());
360
361 let result2 = registry.register_component("dupe-component", html_content2, None);
363 assert!(result2.is_err());
364
365 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 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 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 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 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
396 assert!(result2.is_err());
397
398 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 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 assert_eq!(component.css_tokens, vec!["external"]);
567 }
568}