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)
134 .map_err(|e| ParserError::IO(format!("Failed to read HTML file: {}", e)))?;
135
136 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 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 pub fn register_component(
167 &mut self,
168 tag_name: &str,
169 html_content: &str,
170 css_content: Option<&str>,
171 ) -> Result<()> {
172 if !tag_name.contains('-') {
174 return Err(ParserError::Component(format!(
175 "Component name '{}' must contain a hyphen",
176 tag_name
177 )));
178 }
179
180 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 let css_tokens = match css_content {
190 Some(css) => self.extract_sorted_tokens(css)?,
191 None => Vec::new(),
192 };
193
194 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(), class_name: None,
202 };
203
204 self.components.insert(tag_name.to_string(), component);
206 Ok(())
207 }
208
209 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 pub fn contains(&self, tag_name: &str) -> bool {
219 self.components.contains_key(tag_name)
220 }
221
222 pub fn get(&self, tag_name: &str) -> Option<&Component> {
224 self.components.get(tag_name)
225 }
226
227 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
229 self.components.values()
230 }
231
232 pub fn len(&self) -> usize {
234 self.components.len()
235 }
236
237 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 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 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 let mut fs = TestFileSystem::new();
278 let html_path = fs.add_file("invalid_name.html", html_content);
279
280 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 let mut fs = TestFileSystem::new();
294 let html_path = fs.add_file("test-component.html", html_content);
295
296 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 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 let mut registry = ComponentRegistry::new();
334 let result = registry.register_component("invalid", html_content, None);
335
336 assert!(result.is_err(), "Expected error for invalid component name");
338
339 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 let mut registry = ComponentRegistry::new();
354 let result1 = registry.register_component("dupe-component", html_content, None);
355 assert!(result1.is_ok());
356
357 let result2 = registry.register_component("dupe-component", html_content2, None);
359 assert!(result2.is_err());
360
361 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 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 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 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 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
392 assert!(result2.is_err());
393
394 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 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 assert_eq!(component.css_tokens, vec!["external"]);
563 }
564}