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 names(&self) -> impl Iterator<Item = &str> {
253 self.components.keys().map(String::as_str)
254 }
255
256 pub fn len(&self) -> usize {
258 self.components.len()
259 }
260
261 pub fn is_empty(&self) -> bool {
263 self.components.is_empty()
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use webui_test_utils::TestFileSystem;
271
272 #[test]
273 fn test_register_component() {
274 let html_content = "<p>Hello World</p>";
275 let css_content = "p { color: red; }";
276
277 let mut fs = TestFileSystem::new();
279 let html_path = fs.add_file("components/test-component.html", html_content);
280 let css_path = fs.add_file("components/test-component.css", css_content);
281
282 let mut registry = ComponentRegistry::new();
284 let result = registry.register_component_from_paths(&html_path, Some(&css_path));
285
286 assert!(result.is_ok());
287 assert!(registry.contains("test-component"));
288
289 let component = registry
290 .get("test-component")
291 .expect("Failed to retrieve registered component");
292 assert_eq!(component.html_content, html_content);
293 assert_eq!(component.css_content.as_deref(), Some(css_content));
294 }
295
296 #[test]
297 fn test_component_name_validation() {
298 let html_content = "<p>Invalid</p>";
299
300 let mut fs = TestFileSystem::new();
302 let html_path = fs.add_file("invalid_name.html", html_content);
303
304 let mut registry = ComponentRegistry::new();
306 let result = registry.register_component_from_paths(&html_path, None::<&str>);
307
308 assert!(result.is_err());
309 assert_eq!(registry.len(), 0);
310 }
311
312 #[test]
313 fn test_missing_css_file() {
314 let html_content = "<p>CSS Optional</p>";
315
316 let mut fs = TestFileSystem::new();
318 let html_path = fs.add_file("test-component.html", html_content);
319
320 let mut registry = ComponentRegistry::new();
322 let result = registry.register_component_from_paths(&html_path, None::<&str>);
323
324 assert!(result.is_ok());
325 let component = registry
326 .get("test-component")
327 .expect("Failed to retrieve registered component");
328 assert_eq!(component.html_content, html_content);
329 assert_eq!(component.css_content, None);
330 }
331
332 #[test]
333 fn test_register_component_from_strings() {
334 let html_content = "<p>Hello from string!</p>";
335 let css_content = "p { color: green; }";
336
337 let mut registry = ComponentRegistry::new();
339 let result =
340 registry.register_component("string-component", html_content, Some(css_content));
341
342 assert!(result.is_ok());
343 assert!(registry.contains("string-component"));
344
345 let component = registry
346 .get("string-component")
347 .expect("Failed to retrieve registered component");
348 assert_eq!(component.html_content, html_content);
349 assert_eq!(component.css_content.as_deref(), Some(css_content));
350 }
351
352 #[test]
353 fn test_register_component_strips_css_comments() {
354 let mut registry = ComponentRegistry::new();
355 registry
356 .register_component(
357 "style-component",
358 "<p>Styled</p>",
359 Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
360 )
361 .expect("register failed");
362
363 let component = registry
364 .get("style-component")
365 .expect("Failed to retrieve registered component");
366 assert_eq!(
367 component.css_content.as_deref(),
368 Some(" p { color: var(--textColor); } ")
369 );
370 assert_eq!(component.css_tokens, vec!["textColor"]);
371 }
372
373 #[test]
374 fn test_register_component_preserves_legal_css_comments_by_default() {
375 let mut registry = ComponentRegistry::new();
376 registry
377 .register_component(
378 "legal-component",
379 "<p>Styled</p>",
380 Some("/*! @license MIT */ p { color: red; } /* remove */"),
381 )
382 .expect("register failed");
383
384 let component = registry
385 .get("legal-component")
386 .expect("Failed to retrieve registered component");
387 assert_eq!(
388 component.css_content.as_deref(),
389 Some("/*! @license MIT */ p { color: red; } ")
390 );
391 }
392
393 #[test]
394 fn test_register_component_strips_legal_css_comments_when_disabled() {
395 let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
396 registry
397 .register_component(
398 "legal-component",
399 "<p>Styled</p>",
400 Some("/*! @license MIT */ p { color: red; }"),
401 )
402 .expect("register failed");
403
404 let component = registry
405 .get("legal-component")
406 .expect("Failed to retrieve registered component");
407 assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
408 }
409
410 #[test]
411 fn test_invalid_component_name_from_strings() {
412 let html_content = "<p>Invalid component</p>";
413
414 let mut registry = ComponentRegistry::new();
416 let result = registry.register_component("invalid", html_content, None);
417
418 assert!(result.is_err(), "Expected error for invalid component name");
420
421 assert!(
423 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
424 "Wrong error type or message: {:?}",
425 result
426 );
427 }
428
429 #[test]
430 fn test_duplicate_component_from_strings() {
431 let html_content = "<p>First component</p>";
432 let html_content2 = "<p>Second component with same name</p>";
433
434 let mut registry = ComponentRegistry::new();
436 let result1 = registry.register_component("dupe-component", html_content, None);
437 assert!(result1.is_ok());
438
439 let result2 = registry.register_component("dupe-component", html_content2, None);
441 assert!(result2.is_err());
442
443 assert!(
445 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
446 "Expected 'already registered' error, got: {:?}",
447 result2
448 );
449
450 let component = registry
452 .get("dupe-component")
453 .expect("Failed to retrieve registered component");
454 assert_eq!(component.html_content, html_content);
455 }
456
457 #[test]
458 fn test_duplicate_component_from_paths() {
459 let html_content1 = "<p>Component from dir A</p>";
460 let html_content2 = "<p>Component from dir B</p>";
461
462 let mut fs = TestFileSystem::new();
464 let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
465 let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
466
467 let mut registry = ComponentRegistry::new();
469 let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
470 assert!(result1.is_ok());
471
472 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
474 assert!(result2.is_err());
475
476 assert!(
478 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
479 "Expected 'already registered' error, got: {:?}",
480 result2
481 );
482
483 let component = registry
485 .get("my-comp")
486 .expect("Failed to retrieve registered component");
487 assert_eq!(component.html_content, html_content1);
488 }
489
490 #[test]
491 fn test_exclude_dot_in_component_name() {
492 let mut registry = ComponentRegistry::new();
493 let result = registry.register_component("fluent.button", "<p>Dot name</p>", None);
494
495 assert!(
496 result.is_err(),
497 "Component name with dot but no hyphen should be rejected"
498 );
499 assert!(
500 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
501 "Wrong error type or message: {:?}",
502 result
503 );
504 assert_eq!(registry.len(), 0);
505 }
506
507 #[test]
508 fn test_exclude_no_hyphen_html() {
509 let mut registry = ComponentRegistry::new();
510 let result = registry.register_component("foobar", "<p>No hyphen</p>", None);
511
512 assert!(
513 result.is_err(),
514 "Component name without hyphen should be rejected"
515 );
516 assert!(
517 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
518 "Wrong error type or message: {:?}",
519 result
520 );
521 assert_eq!(registry.len(), 0);
522 }
523
524 #[test]
525 fn test_valid_component_with_hyphen() {
526 let mut registry = ComponentRegistry::new();
527 let result =
528 registry.register_component("fluent-button", "<button>Click me</button>", None);
529
530 assert!(
531 result.is_ok(),
532 "Component name with hyphen should be accepted"
533 );
534 assert!(registry.contains("fluent-button"));
535 assert_eq!(registry.len(), 1);
536 }
537
538 #[test]
539 fn test_valid_component_css_only() {
540 let mut registry = ComponentRegistry::new();
541 let result =
542 registry.register_component("styled-widget", "", Some(".widget { color: blue; }"));
543
544 assert!(
545 result.is_ok(),
546 "Component with empty HTML and CSS should be accepted"
547 );
548 assert!(registry.contains("styled-widget"));
549
550 let component = registry
551 .get("styled-widget")
552 .expect("Failed to retrieve registered component");
553 assert_eq!(component.html_content, "");
554 assert_eq!(
555 component.css_content.as_deref(),
556 Some(".widget { color: blue; }")
557 );
558 }
559
560 #[test]
561 fn test_component_name_requires_hyphen() {
562 let mut registry = ComponentRegistry::new();
563 let result = registry.register_component("single", "<p>Single word</p>", None);
564
565 assert!(
566 result.is_err(),
567 "Single-word component name should be rejected"
568 );
569 assert!(
570 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
571 "Wrong error type or message: {:?}",
572 result
573 );
574 assert_eq!(registry.len(), 0);
575 }
576
577 #[test]
578 fn test_multiple_hyphens_valid() {
579 let mut registry = ComponentRegistry::new();
580 let result =
581 registry.register_component("my-custom-element", "<div>Custom element</div>", None);
582
583 assert!(
584 result.is_ok(),
585 "Component name with multiple hyphens should be accepted"
586 );
587 assert!(registry.contains("my-custom-element"));
588 assert_eq!(registry.len(), 1);
589 }
590
591 #[test]
592 fn test_empty_component_name_rejected() {
593 let mut registry = ComponentRegistry::new();
594 let result = registry.register_component("", "<p>Empty name</p>", None);
595
596 assert!(result.is_err(), "Empty component name should be rejected");
597 assert!(
598 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
599 "Wrong error type or message: {:?}",
600 result
601 );
602 assert_eq!(registry.len(), 0);
603 }
604
605 #[test]
606 fn test_register_component_extracts_css_tokens() {
607 let mut registry = ComponentRegistry::new();
608 registry
609 .register_component(
610 "my-btn",
611 "<button>Click</button>",
612 Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
613 )
614 .expect("register failed");
615
616 let component = registry.get("my-btn").expect("component not found");
617 assert_eq!(component.css_tokens, vec!["spacing-m", "text-color"]);
618 }
619
620 #[test]
621 fn test_register_component_no_css_no_tokens() {
622 let mut registry = ComponentRegistry::new();
623 registry
624 .register_component("my-card", "<div>Card</div>", None)
625 .expect("register failed");
626
627 let component = registry.get("my-card").expect("component not found");
628 assert!(component.css_tokens.is_empty());
629 }
630
631 #[test]
632 fn test_register_component_excludes_local_defs_from_tokens() {
633 let mut registry = ComponentRegistry::new();
634 registry
635 .register_component(
636 "my-widget",
637 "<div>W</div>",
638 Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
639 )
640 .expect("register failed");
641
642 let component = registry.get("my-widget").expect("component not found");
643 assert_eq!(component.css_tokens, vec!["external"]);
645 }
646}