1use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
9use std::collections::HashMap;
10#[cfg(feature = "fs")]
11use std::fs;
12#[cfg(feature = "fs")]
13use std::path::Path;
14#[cfg(feature = "fs")]
15use walkdir::WalkDir;
16
17type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
18
19#[derive(Debug, Clone)]
21pub struct Component {
22 pub tag_name: String,
24
25 pub html_content: String,
27
28 pub css_content: Option<String>,
30
31 pub css_definitions: Vec<String>,
33
34 pub css_fallback_chains: Vec<CssFallbackChain>,
36
37 pub has_script: bool,
39}
40
41#[derive(Debug)]
43pub struct ComponentRegistry {
44 components: HashMap<String, Component>,
46 css_parser: CssParser,
48 legal_comments: LegalComments,
50}
51
52#[cfg(feature = "fs")]
53fn component_has_script(html_path: &Path) -> bool {
59 html_path.with_extension("ts").exists() || html_path.with_extension("js").exists()
60}
61
62impl Default for ComponentRegistry {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl ComponentRegistry {
69 pub fn new() -> Self {
71 Self::with_legal_comments(LegalComments::default())
72 }
73
74 pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
75 Self {
76 components: HashMap::new(),
77 css_parser: CssParser::new(),
78 legal_comments,
79 }
80 }
81
82 #[cfg(feature = "fs")]
84 pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
85 for dir in directories {
86 for entry in WalkDir::new(dir.as_ref())
87 .into_iter()
88 .filter_map(|e| e.ok())
89 {
90 let path = entry.path();
91 if !path.is_file() {
92 continue;
93 }
94 if path.extension().is_some_and(|ext| ext == "html") {
96 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
98 if filename.contains('-') {
99 let css_path = path.with_extension("css");
101 self.register_component_from_paths(
103 path,
104 if css_path.exists() {
105 Some(&css_path)
106 } else {
107 None
108 },
109 )?;
110 }
111 }
112 }
113 }
114 }
115 Ok(self)
116 }
117
118 #[cfg(feature = "fs")]
120 pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
121 &mut self,
122 html_path: P,
123 css_path: Option<Q>,
124 ) -> Result<()> {
125 let html_path = html_path.as_ref();
126
127 let tag_name = html_path
129 .file_stem()
130 .and_then(|s| s.to_str())
131 .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
132
133 if !tag_name.contains('-') {
135 return Err(ParserError::Component(format!(
136 "Component name '{}' must contain a hyphen",
137 tag_name
138 )));
139 }
140
141 if self.components.contains_key(tag_name) {
143 return Err(ParserError::Component(format!(
144 "Component '{}' is already registered",
145 tag_name
146 )));
147 }
148
149 let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
151 context: format!("Failed to read HTML file: {}", html_path.display()),
152 source,
153 })?;
154
155 let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
157 let css_path = css_path.as_ref();
158 if css_path.exists() {
159 let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
160 context: format!("Failed to read CSS file: {}", css_path.display()),
161 source,
162 })?;
163 let (content, definitions, requirements) = self.process_css_content(&content)?;
164 (Some(content), definitions, requirements)
165 } else {
166 (None, Vec::new(), Vec::new())
167 }
168 } else {
169 (None, Vec::new(), Vec::new())
170 };
171
172 let component = Component {
174 tag_name: tag_name.to_string(),
175 html_content,
176 css_content,
177 css_definitions,
178 css_fallback_chains,
179 has_script: component_has_script(html_path),
180 };
181
182 self.components.insert(tag_name.to_string(), component);
183 Ok(())
184 }
185
186 pub fn register_component(
188 &mut self,
189 tag_name: &str,
190 html_content: &str,
191 css_content: Option<&str>,
192 has_script: bool,
193 ) -> Result<()> {
194 if !tag_name.contains('-') {
196 return Err(ParserError::Component(format!(
197 "Component name '{}' must contain a hyphen",
198 tag_name
199 )));
200 }
201
202 if self.components.contains_key(tag_name) {
204 return Err(ParserError::Component(format!(
205 "Component '{}' is already registered",
206 tag_name
207 )));
208 }
209
210 let (css_content, css_definitions, css_fallback_chains) = match css_content {
212 Some(css) => {
213 let (content, definitions, requirements) = self.process_css_content(css)?;
214 (Some(content), definitions, requirements)
215 }
216 None => (None, Vec::new(), Vec::new()),
217 };
218
219 let component: Component = Component {
220 tag_name: tag_name.to_string(),
221 html_content: html_content.to_string(),
222 css_content,
223 css_definitions,
224 css_fallback_chains,
225 has_script,
226 };
227
228 self.components.insert(tag_name.to_string(), component);
230 Ok(())
231 }
232
233 fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
235 let (_tokens, definitions, requirements, stripped) = self
236 .css_parser
237 .extract_tokens_definitions_requirements_and_strip_comments(
238 css_content,
239 self.legal_comments,
240 )?;
241 let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
242 sorted_definitions.sort();
243 Ok((stripped.into_owned(), sorted_definitions, requirements))
244 }
245
246 pub fn contains(&self, tag_name: &str) -> bool {
248 self.components.contains_key(tag_name)
249 }
250
251 pub fn get(&self, tag_name: &str) -> Option<&Component> {
253 self.components.get(tag_name)
254 }
255
256 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
258 self.components.values()
259 }
260
261 pub fn names(&self) -> impl Iterator<Item = &str> {
266 self.components.keys().map(String::as_str)
267 }
268
269 pub fn len(&self) -> usize {
271 self.components.len()
272 }
273
274 pub fn is_empty(&self) -> bool {
276 self.components.is_empty()
277 }
278}
279
280#[cfg(test)]
281#[allow(clippy::disallowed_methods)]
282mod tests {
283 use super::*;
284 use webui_test_utils::TestFileSystem;
285
286 #[test]
287 fn test_register_component() {
288 let html_content = "<p>Hello World</p>";
289 let css_content = "p { color: red; }";
290
291 let mut fs = TestFileSystem::new();
293 let html_path = fs.add_file("components/test-component.html", html_content);
294 let css_path = fs.add_file("components/test-component.css", css_content);
295
296 let mut registry = ComponentRegistry::new();
298 let result = registry.register_component_from_paths(&html_path, Some(&css_path));
299
300 assert!(result.is_ok());
301 assert!(registry.contains("test-component"));
302
303 let component = registry
304 .get("test-component")
305 .expect("Failed to retrieve registered component");
306 assert_eq!(component.html_content, html_content);
307 assert_eq!(component.css_content.as_deref(), Some(css_content));
308 assert!(!component.has_script);
309 }
310
311 #[test]
312 fn test_register_component_detects_ts_sibling_script() {
313 let mut fs = TestFileSystem::new();
314 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
315 std::fs::write(html_path.with_extension("ts"), "export {};")
316 .expect("Failed to write sibling script");
317
318 let mut registry = ComponentRegistry::new();
319 registry
320 .register_component_from_paths(&html_path, None::<&str>)
321 .expect("register failed");
322
323 let component = registry
324 .get("scripted-card")
325 .expect("Failed to retrieve registered component");
326 assert!(component.has_script);
327 }
328
329 #[test]
330 fn test_register_component_detects_js_sibling_script() {
331 let mut fs = TestFileSystem::new();
332 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
333 std::fs::write(html_path.with_extension("js"), "export {};")
334 .expect("Failed to write sibling script");
335
336 let mut registry = ComponentRegistry::new();
337 registry
338 .register_component_from_paths(&html_path, None::<&str>)
339 .expect("register failed");
340
341 let component = registry
342 .get("scripted-card")
343 .expect("Failed to retrieve registered component");
344 assert!(component.has_script);
345 }
346
347 #[test]
348 fn test_register_component_ignores_tsx_sibling_script() {
349 let mut fs = TestFileSystem::new();
350 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
351 std::fs::write(html_path.with_extension("tsx"), "export {};")
352 .expect("Failed to write sibling script");
353
354 let mut registry = ComponentRegistry::new();
355 registry
356 .register_component_from_paths(&html_path, None::<&str>)
357 .expect("register failed");
358
359 let component = registry
360 .get("scripted-card")
361 .expect("Failed to retrieve registered component");
362 assert!(!component.has_script);
363 }
364
365 #[test]
366 fn test_component_name_validation() {
367 let html_content = "<p>Invalid</p>";
368
369 let mut fs = TestFileSystem::new();
371 let html_path = fs.add_file("invalid_name.html", html_content);
372
373 let mut registry = ComponentRegistry::new();
375 let result = registry.register_component_from_paths(&html_path, None::<&str>);
376
377 assert!(result.is_err());
378 assert_eq!(registry.len(), 0);
379 }
380
381 #[test]
382 fn test_missing_css_file() {
383 let html_content = "<p>CSS Optional</p>";
384
385 let mut fs = TestFileSystem::new();
387 let html_path = fs.add_file("test-component.html", html_content);
388
389 let mut registry = ComponentRegistry::new();
391 let result = registry.register_component_from_paths(&html_path, None::<&str>);
392
393 assert!(result.is_ok());
394 let component = registry
395 .get("test-component")
396 .expect("Failed to retrieve registered component");
397 assert_eq!(component.html_content, html_content);
398 assert_eq!(component.css_content, None);
399 }
400
401 #[test]
402 fn test_register_component_from_strings() {
403 let html_content = "<p>Hello from string!</p>";
404 let css_content = "p { color: green; }";
405
406 let mut registry = ComponentRegistry::new();
408 let result =
409 registry.register_component("string-component", html_content, Some(css_content), true);
410
411 assert!(result.is_ok());
412 assert!(registry.contains("string-component"));
413
414 let component = registry
415 .get("string-component")
416 .expect("Failed to retrieve registered component");
417 assert_eq!(component.html_content, html_content);
418 assert_eq!(component.css_content.as_deref(), Some(css_content));
419 assert!(component.has_script);
420 }
421
422 #[test]
423 fn test_register_component_strips_css_comments() {
424 let mut registry = ComponentRegistry::new();
425 registry
426 .register_component(
427 "style-component",
428 "<p>Styled</p>",
429 Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
430 true,
431 )
432 .expect("register failed");
433
434 let component = registry
435 .get("style-component")
436 .expect("Failed to retrieve registered component");
437 assert_eq!(
438 component.css_content.as_deref(),
439 Some(" p { color: var(--textColor); } ")
440 );
441 assert_eq!(component.css_fallback_chains.len(), 1);
442 }
443
444 #[test]
445 fn test_register_component_preserves_legal_css_comments_by_default() {
446 let mut registry = ComponentRegistry::new();
447 registry
448 .register_component(
449 "legal-component",
450 "<p>Styled</p>",
451 Some("/*! @license MIT */ p { color: red; } /* remove */"),
452 true,
453 )
454 .expect("register failed");
455
456 let component = registry
457 .get("legal-component")
458 .expect("Failed to retrieve registered component");
459 assert_eq!(
460 component.css_content.as_deref(),
461 Some("/*! @license MIT */ p { color: red; } ")
462 );
463 }
464
465 #[test]
466 fn test_register_component_strips_legal_css_comments_when_disabled() {
467 let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
468 registry
469 .register_component(
470 "legal-component",
471 "<p>Styled</p>",
472 Some("/*! @license MIT */ p { color: red; }"),
473 true,
474 )
475 .expect("register failed");
476
477 let component = registry
478 .get("legal-component")
479 .expect("Failed to retrieve registered component");
480 assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
481 }
482
483 #[test]
484 fn test_invalid_component_name_from_strings() {
485 let html_content = "<p>Invalid component</p>";
486
487 let mut registry = ComponentRegistry::new();
489 let result = registry.register_component("invalid", html_content, None, true);
490
491 assert!(result.is_err(), "Expected error for invalid component name");
493
494 assert!(
496 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
497 "Wrong error type or message: {:?}",
498 result
499 );
500 }
501
502 #[test]
503 fn test_duplicate_component_from_strings() {
504 let html_content = "<p>First component</p>";
505 let html_content2 = "<p>Second component with same name</p>";
506
507 let mut registry = ComponentRegistry::new();
509 let result1 = registry.register_component("dupe-component", html_content, None, true);
510 assert!(result1.is_ok());
511
512 let result2 = registry.register_component("dupe-component", html_content2, None, true);
514 assert!(result2.is_err());
515
516 assert!(
518 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
519 "Expected 'already registered' error, got: {:?}",
520 result2
521 );
522
523 let component = registry
525 .get("dupe-component")
526 .expect("Failed to retrieve registered component");
527 assert_eq!(component.html_content, html_content);
528 }
529
530 #[test]
531 fn test_duplicate_component_from_paths() {
532 let html_content1 = "<p>Component from dir A</p>";
533 let html_content2 = "<p>Component from dir B</p>";
534
535 let mut fs = TestFileSystem::new();
537 let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
538 let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
539
540 let mut registry = ComponentRegistry::new();
542 let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
543 assert!(result1.is_ok());
544
545 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
547 assert!(result2.is_err());
548
549 assert!(
551 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
552 "Expected 'already registered' error, got: {:?}",
553 result2
554 );
555
556 let component = registry
558 .get("my-comp")
559 .expect("Failed to retrieve registered component");
560 assert_eq!(component.html_content, html_content1);
561 }
562
563 #[test]
564 fn test_exclude_dot_in_component_name() {
565 let mut registry = ComponentRegistry::new();
566 let result = registry.register_component("fluent.button", "<p>Dot name</p>", None, true);
567
568 assert!(
569 result.is_err(),
570 "Component name with dot but no hyphen should be rejected"
571 );
572 assert!(
573 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
574 "Wrong error type or message: {:?}",
575 result
576 );
577 assert_eq!(registry.len(), 0);
578 }
579
580 #[test]
581 fn test_exclude_no_hyphen_html() {
582 let mut registry = ComponentRegistry::new();
583 let result = registry.register_component("foobar", "<p>No hyphen</p>", None, true);
584
585 assert!(
586 result.is_err(),
587 "Component name without hyphen should be rejected"
588 );
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_valid_component_with_hyphen() {
599 let mut registry = ComponentRegistry::new();
600 let result =
601 registry.register_component("fluent-button", "<button>Click me</button>", None, true);
602
603 assert!(
604 result.is_ok(),
605 "Component name with hyphen should be accepted"
606 );
607 assert!(registry.contains("fluent-button"));
608 assert_eq!(registry.len(), 1);
609 }
610
611 #[test]
612 fn test_valid_component_css_only() {
613 let mut registry = ComponentRegistry::new();
614 let result = registry.register_component(
615 "styled-widget",
616 "",
617 Some(".widget { color: blue; }"),
618 true,
619 );
620
621 assert!(
622 result.is_ok(),
623 "Component with empty HTML and CSS should be accepted"
624 );
625 assert!(registry.contains("styled-widget"));
626
627 let component = registry
628 .get("styled-widget")
629 .expect("Failed to retrieve registered component");
630 assert_eq!(component.html_content, "");
631 assert_eq!(
632 component.css_content.as_deref(),
633 Some(".widget { color: blue; }")
634 );
635 }
636
637 #[test]
638 fn test_component_name_requires_hyphen() {
639 let mut registry = ComponentRegistry::new();
640 let result = registry.register_component("single", "<p>Single word</p>", None, true);
641
642 assert!(
643 result.is_err(),
644 "Single-word component name should be rejected"
645 );
646 assert!(
647 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
648 "Wrong error type or message: {:?}",
649 result
650 );
651 assert_eq!(registry.len(), 0);
652 }
653
654 #[test]
655 fn test_multiple_hyphens_valid() {
656 let mut registry = ComponentRegistry::new();
657 let result = registry.register_component(
658 "my-custom-element",
659 "<div>Custom element</div>",
660 None,
661 true,
662 );
663
664 assert!(
665 result.is_ok(),
666 "Component name with multiple hyphens should be accepted"
667 );
668 assert!(registry.contains("my-custom-element"));
669 assert_eq!(registry.len(), 1);
670 }
671
672 #[test]
673 fn test_empty_component_name_rejected() {
674 let mut registry = ComponentRegistry::new();
675 let result = registry.register_component("", "<p>Empty name</p>", None, true);
676
677 assert!(result.is_err(), "Empty component name should be rejected");
678 assert!(
679 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
680 "Wrong error type or message: {:?}",
681 result
682 );
683 assert_eq!(registry.len(), 0);
684 }
685
686 #[test]
687 fn test_register_component_extracts_css_fallback_requirements() {
688 let mut registry = ComponentRegistry::new();
689 registry
690 .register_component(
691 "my-btn",
692 "<button>Click</button>",
693 Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
694 true,
695 )
696 .expect("register failed");
697
698 let component = registry.get("my-btn").expect("component not found");
699 assert_eq!(component.css_fallback_chains.len(), 2);
700 }
701
702 #[test]
703 fn test_register_component_no_css_no_requirements() {
704 let mut registry = ComponentRegistry::new();
705 registry
706 .register_component("my-card", "<div>Card</div>", None, true)
707 .expect("register failed");
708
709 let component = registry.get("my-card").expect("component not found");
710 assert!(component.css_fallback_chains.is_empty());
711 }
712
713 #[test]
714 fn test_register_component_tracks_css_fallback_requirements() {
715 let mut registry = ComponentRegistry::new();
716 registry
717 .register_component(
718 "my-widget",
719 "<div>W</div>",
720 Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
721 true,
722 )
723 .expect("register failed");
724
725 let component = registry.get("my-widget").expect("component not found");
726 assert_eq!(component.css_fallback_chains.len(), 2);
727 }
728}