1use 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#[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 legal_comments: LegalComments,
50}
51
52impl Default for ComponentRegistry {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl ComponentRegistry {
59 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 #[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 if path.extension().is_some_and(|ext| ext == "html") {
86 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
88 if filename.contains('-') {
89 let css_path = path.with_extension("css");
91 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 #[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 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 if !tag_name.contains('-') {
125 return Err(ParserError::Component(format!(
126 "Component name '{}' must contain a hyphen",
127 tag_name
128 )));
129 }
130
131 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 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 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 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 pub fn register_component(
178 &mut self,
179 tag_name: &str,
180 html_content: &str,
181 css_content: Option<&str>,
182 ) -> Result<()> {
183 if !tag_name.contains('-') {
185 return Err(ParserError::Component(format!(
186 "Component name '{}' must contain a hyphen",
187 tag_name
188 )));
189 }
190
191 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 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 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(), class_name: None,
216 };
217
218 self.components.insert(tag_name.to_string(), component);
220 Ok(())
221 }
222
223 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 pub fn contains(&self, tag_name: &str) -> bool {
235 self.components.contains_key(tag_name)
236 }
237
238 pub fn get(&self, tag_name: &str) -> Option<&Component> {
240 self.components.get(tag_name)
241 }
242
243 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
245 self.components.values()
246 }
247
248 pub fn len(&self) -> usize {
250 self.components.len()
251 }
252
253 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 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 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 let mut fs = TestFileSystem::new();
294 let html_path = fs.add_file("invalid_name.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_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 let mut fs = TestFileSystem::new();
310 let html_path = fs.add_file("test-component.html", html_content);
311
312 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 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 let mut registry = ComponentRegistry::new();
408 let result = registry.register_component("invalid", html_content, None);
409
410 assert!(result.is_err(), "Expected error for invalid component name");
412
413 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 let mut registry = ComponentRegistry::new();
428 let result1 = registry.register_component("dupe-component", html_content, None);
429 assert!(result1.is_ok());
430
431 let result2 = registry.register_component("dupe-component", html_content2, None);
433 assert!(result2.is_err());
434
435 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 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 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 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 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
466 assert!(result2.is_err());
467
468 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 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 assert_eq!(component.css_tokens, vec!["external"]);
637 }
638}