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 is_client_owned: bool,
39}
40
41#[derive(Debug, Clone)]
46pub struct ComponentRegistration<'a> {
47 pub tag_name: &'a str,
49 pub html_content: &'a str,
51 pub css_content: Option<&'a str>,
53 pub is_client_owned: bool,
55}
56
57impl<'a> ComponentRegistration<'a> {
58 #[must_use]
60 pub fn new(
61 tag_name: &'a str,
62 html_content: &'a str,
63 css_content: Option<&'a str>,
64 is_client_owned: bool,
65 ) -> Self {
66 Self {
67 tag_name,
68 html_content,
69 css_content,
70 is_client_owned,
71 }
72 }
73}
74
75#[derive(Debug)]
77pub struct ComponentRegistry {
78 components: HashMap<String, Component>,
80 css_parser: CssParser,
82 legal_comments: LegalComments,
84}
85
86#[cfg(feature = "fs")]
87fn has_component_script(html_path: &Path) -> Result<bool> {
93 for ext in ["ts", "js"] {
94 let candidate = html_path.with_extension(ext);
95 if candidate.try_exists().map_err(|source| ParserError::IO {
96 context: format!(
97 "Failed to inspect component script: {}",
98 candidate.display()
99 ),
100 source,
101 })? {
102 return Ok(true);
103 }
104 }
105 Ok(false)
106}
107
108impl Default for ComponentRegistry {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114impl ComponentRegistry {
115 pub fn new() -> Self {
117 Self::with_legal_comments(LegalComments::default())
118 }
119
120 pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
121 Self {
122 components: HashMap::new(),
123 css_parser: CssParser::new(),
124 legal_comments,
125 }
126 }
127
128 #[cfg(feature = "fs")]
130 pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
131 for dir in directories {
132 for entry in WalkDir::new(dir.as_ref())
133 .into_iter()
134 .filter_map(|e| e.ok())
135 {
136 let path = entry.path();
137 if !path.is_file() {
138 continue;
139 }
140 if path.extension().is_some_and(|ext| ext == "html") {
142 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
144 if filename.contains('-') {
145 let css_path = path.with_extension("css");
147 self.register_component_from_paths(
149 path,
150 if css_path.exists() {
151 Some(&css_path)
152 } else {
153 None
154 },
155 )?;
156 }
157 }
158 }
159 }
160 }
161 Ok(self)
162 }
163
164 #[cfg(feature = "fs")]
166 pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
167 &mut self,
168 html_path: P,
169 css_path: Option<Q>,
170 ) -> Result<()> {
171 let html_path = html_path.as_ref();
172
173 let tag_name = html_path
175 .file_stem()
176 .and_then(|s| s.to_str())
177 .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
178
179 if !tag_name.contains('-') {
181 return Err(ParserError::Component(format!(
182 "Component name '{}' must contain a hyphen",
183 tag_name
184 )));
185 }
186
187 if self.components.contains_key(tag_name) {
189 return Err(ParserError::Component(format!(
190 "Component '{}' is already registered",
191 tag_name
192 )));
193 }
194
195 let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
197 context: format!("Failed to read HTML file: {}", html_path.display()),
198 source,
199 })?;
200
201 let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
203 let css_path = css_path.as_ref();
204 if css_path.exists() {
205 let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
206 context: format!("Failed to read CSS file: {}", css_path.display()),
207 source,
208 })?;
209 let (content, definitions, requirements) = self.process_css_content(&content)?;
210 (Some(content), definitions, requirements)
211 } else {
212 (None, Vec::new(), Vec::new())
213 }
214 } else {
215 (None, Vec::new(), Vec::new())
216 };
217
218 let is_client_owned = has_component_script(html_path)?;
219
220 let component = Component {
222 tag_name: tag_name.to_string(),
223 html_content,
224 css_content,
225 css_definitions,
226 css_fallback_chains,
227 is_client_owned,
228 };
229
230 self.components.insert(tag_name.to_string(), component);
231 Ok(())
232 }
233
234 pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
239 let ComponentRegistration {
240 tag_name,
241 html_content,
242 css_content,
243 is_client_owned,
244 } = registration;
245
246 if !tag_name.contains('-') {
248 return Err(ParserError::Component(format!(
249 "Component name '{}' must contain a hyphen",
250 tag_name
251 )));
252 }
253
254 if self.components.contains_key(tag_name) {
256 return Err(ParserError::Component(format!(
257 "Component '{}' is already registered",
258 tag_name
259 )));
260 }
261
262 let (css_content, css_definitions, css_fallback_chains) = match css_content {
264 Some(css) => {
265 let (content, definitions, requirements) = self.process_css_content(css)?;
266 (Some(content), definitions, requirements)
267 }
268 None => (None, Vec::new(), Vec::new()),
269 };
270
271 let component: Component = Component {
272 tag_name: tag_name.to_string(),
273 html_content: html_content.to_string(),
274 css_content,
275 css_definitions,
276 css_fallback_chains,
277 is_client_owned,
278 };
279
280 self.components.insert(tag_name.to_string(), component);
282 Ok(())
283 }
284
285 fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
287 let (_tokens, definitions, requirements, stripped) = self
288 .css_parser
289 .extract_tokens_definitions_requirements_and_strip_comments(
290 css_content,
291 self.legal_comments,
292 )?;
293 let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
294 sorted_definitions.sort();
295 Ok((stripped.into_owned(), sorted_definitions, requirements))
296 }
297
298 pub fn contains(&self, tag_name: &str) -> bool {
300 self.components.contains_key(tag_name)
301 }
302
303 pub fn get(&self, tag_name: &str) -> Option<&Component> {
305 self.components.get(tag_name)
306 }
307
308 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
310 self.components.values()
311 }
312
313 pub fn names(&self) -> impl Iterator<Item = &str> {
318 self.components.keys().map(String::as_str)
319 }
320
321 pub fn len(&self) -> usize {
323 self.components.len()
324 }
325
326 pub fn is_empty(&self) -> bool {
328 self.components.is_empty()
329 }
330}
331
332#[cfg(test)]
333#[allow(clippy::disallowed_methods)]
334mod tests {
335 use super::*;
336 use webui_test_utils::TestFileSystem;
337
338 #[test]
339 fn test_register_component() {
340 let html_content = "<p>Hello World</p>";
341 let css_content = "p { color: red; }";
342
343 let mut fs = TestFileSystem::new();
345 let html_path = fs.add_file("components/test-component.html", html_content);
346 let css_path = fs.add_file("components/test-component.css", css_content);
347
348 let mut registry = ComponentRegistry::new();
350 let result = registry.register_component_from_paths(&html_path, Some(&css_path));
351
352 assert!(result.is_ok());
353 assert!(registry.contains("test-component"));
354
355 let component = registry
356 .get("test-component")
357 .expect("Failed to retrieve registered component");
358 assert_eq!(component.html_content, html_content);
359 assert_eq!(component.css_content.as_deref(), Some(css_content));
360 assert!(!component.is_client_owned);
361 }
362
363 #[test]
364 fn test_register_component_detects_ts_sibling_script() {
365 let mut fs = TestFileSystem::new();
366 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
367 std::fs::write(html_path.with_extension("ts"), "export {};")
368 .expect("Failed to write sibling script");
369
370 let mut registry = ComponentRegistry::new();
371 registry
372 .register_component_from_paths(&html_path, None::<&str>)
373 .expect("register failed");
374
375 let component = registry
376 .get("scripted-card")
377 .expect("Failed to retrieve registered component");
378 assert!(component.is_client_owned);
379 }
380
381 #[test]
382 fn test_register_component_detects_js_sibling_script() {
383 let mut fs = TestFileSystem::new();
384 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
385 std::fs::write(html_path.with_extension("js"), "export {};")
386 .expect("Failed to write sibling script");
387
388 let mut registry = ComponentRegistry::new();
389 registry
390 .register_component_from_paths(&html_path, None::<&str>)
391 .expect("register failed");
392
393 let component = registry
394 .get("scripted-card")
395 .expect("Failed to retrieve registered component");
396 assert!(component.is_client_owned);
397 }
398
399 #[test]
400 fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
401 let mut fs = TestFileSystem::new();
402 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
403 std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
404 .expect("Failed to write invalid sibling script");
405
406 let mut registry = ComponentRegistry::new();
407 registry
408 .register_component_from_paths(&html_path, None::<&str>)
409 .expect("registration should inspect presence without decoding source");
410
411 let component = registry
412 .get("scripted-card")
413 .expect("Failed to retrieve registered component");
414 assert!(component.is_client_owned);
415 }
416
417 #[test]
418 fn test_register_component_ignores_tsx_sibling_script() {
419 let mut fs = TestFileSystem::new();
420 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
421 std::fs::write(html_path.with_extension("tsx"), "export {};")
422 .expect("Failed to write sibling script");
423
424 let mut registry = ComponentRegistry::new();
425 registry
426 .register_component_from_paths(&html_path, None::<&str>)
427 .expect("register failed");
428
429 let component = registry
430 .get("scripted-card")
431 .expect("Failed to retrieve registered component");
432 assert!(!component.is_client_owned);
433 }
434
435 #[test]
436 fn test_component_name_validation() {
437 let html_content = "<p>Invalid</p>";
438
439 let mut fs = TestFileSystem::new();
441 let html_path = fs.add_file("invalid_name.html", html_content);
442
443 let mut registry = ComponentRegistry::new();
445 let result = registry.register_component_from_paths(&html_path, None::<&str>);
446
447 assert!(result.is_err());
448 assert_eq!(registry.len(), 0);
449 }
450
451 #[test]
452 fn test_missing_css_file() {
453 let html_content = "<p>CSS Optional</p>";
454
455 let mut fs = TestFileSystem::new();
457 let html_path = fs.add_file("test-component.html", html_content);
458
459 let mut registry = ComponentRegistry::new();
461 let result = registry.register_component_from_paths(&html_path, None::<&str>);
462
463 assert!(result.is_ok());
464 let component = registry
465 .get("test-component")
466 .expect("Failed to retrieve registered component");
467 assert_eq!(component.html_content, html_content);
468 assert_eq!(component.css_content, None);
469 }
470
471 #[test]
472 fn test_register_component_from_strings() {
473 let html_content = "<p>Hello from string!</p>";
474 let css_content = "p { color: green; }";
475
476 let mut registry = ComponentRegistry::new();
478 let result = registry.register_component(ComponentRegistration::new(
479 "string-component",
480 html_content,
481 Some(css_content),
482 true,
483 ));
484
485 assert!(result.is_ok());
486 assert!(registry.contains("string-component"));
487
488 let component = registry
489 .get("string-component")
490 .expect("Failed to retrieve registered component");
491 assert_eq!(component.html_content, html_content);
492 assert_eq!(component.css_content.as_deref(), Some(css_content));
493 assert!(component.is_client_owned);
494 }
495
496 #[test]
497 fn test_register_component_strips_css_comments() {
498 let mut registry = ComponentRegistry::new();
499 registry
500 .register_component(ComponentRegistration::new(
501 "style-component",
502 "<p>Styled</p>",
503 Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
504 true,
505 ))
506 .expect("register failed");
507
508 let component = registry
509 .get("style-component")
510 .expect("Failed to retrieve registered component");
511 assert_eq!(
512 component.css_content.as_deref(),
513 Some(" p { color: var(--textColor); } ")
514 );
515 assert_eq!(component.css_fallback_chains.len(), 1);
516 }
517
518 #[test]
519 fn test_register_component_preserves_legal_css_comments_by_default() {
520 let mut registry = ComponentRegistry::new();
521 registry
522 .register_component(ComponentRegistration::new(
523 "legal-component",
524 "<p>Styled</p>",
525 Some("/*! @license MIT */ p { color: red; } /* remove */"),
526 true,
527 ))
528 .expect("register failed");
529
530 let component = registry
531 .get("legal-component")
532 .expect("Failed to retrieve registered component");
533 assert_eq!(
534 component.css_content.as_deref(),
535 Some("/*! @license MIT */ p { color: red; } ")
536 );
537 }
538
539 #[test]
540 fn test_register_component_strips_legal_css_comments_when_disabled() {
541 let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
542 registry
543 .register_component(ComponentRegistration::new(
544 "legal-component",
545 "<p>Styled</p>",
546 Some("/*! @license MIT */ p { color: red; }"),
547 true,
548 ))
549 .expect("register failed");
550
551 let component = registry
552 .get("legal-component")
553 .expect("Failed to retrieve registered component");
554 assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
555 }
556
557 #[test]
558 fn test_invalid_component_name_from_strings() {
559 let html_content = "<p>Invalid component</p>";
560
561 let mut registry = ComponentRegistry::new();
563 let result = registry.register_component(ComponentRegistration::new(
564 "invalid",
565 html_content,
566 None,
567 true,
568 ));
569
570 assert!(result.is_err(), "Expected error for invalid component name");
572
573 assert!(
575 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
576 "Wrong error type or message: {:?}",
577 result
578 );
579 }
580
581 #[test]
582 fn test_duplicate_component_from_strings() {
583 let html_content = "<p>First component</p>";
584 let html_content2 = "<p>Second component with same name</p>";
585
586 let mut registry = ComponentRegistry::new();
588 let result1 = registry.register_component(ComponentRegistration::new(
589 "dupe-component",
590 html_content,
591 None,
592 true,
593 ));
594 assert!(result1.is_ok());
595
596 let result2 = registry.register_component(ComponentRegistration::new(
598 "dupe-component",
599 html_content2,
600 None,
601 true,
602 ));
603 assert!(result2.is_err());
604
605 assert!(
607 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
608 "Expected 'already registered' error, got: {:?}",
609 result2
610 );
611
612 let component = registry
614 .get("dupe-component")
615 .expect("Failed to retrieve registered component");
616 assert_eq!(component.html_content, html_content);
617 }
618
619 #[test]
620 fn test_duplicate_component_from_paths() {
621 let html_content1 = "<p>Component from dir A</p>";
622 let html_content2 = "<p>Component from dir B</p>";
623
624 let mut fs = TestFileSystem::new();
626 let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
627 let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
628
629 let mut registry = ComponentRegistry::new();
631 let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
632 assert!(result1.is_ok());
633
634 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
636 assert!(result2.is_err());
637
638 assert!(
640 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
641 "Expected 'already registered' error, got: {:?}",
642 result2
643 );
644
645 let component = registry
647 .get("my-comp")
648 .expect("Failed to retrieve registered component");
649 assert_eq!(component.html_content, html_content1);
650 }
651
652 #[test]
653 fn test_exclude_dot_in_component_name() {
654 let mut registry = ComponentRegistry::new();
655 let result = registry.register_component(ComponentRegistration::new(
656 "fluent.button",
657 "<p>Dot name</p>",
658 None,
659 true,
660 ));
661
662 assert!(
663 result.is_err(),
664 "Component name with dot but no hyphen should be rejected"
665 );
666 assert!(
667 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
668 "Wrong error type or message: {:?}",
669 result
670 );
671 assert_eq!(registry.len(), 0);
672 }
673
674 #[test]
675 fn test_exclude_no_hyphen_html() {
676 let mut registry = ComponentRegistry::new();
677 let result = registry.register_component(ComponentRegistration::new(
678 "foobar",
679 "<p>No hyphen</p>",
680 None,
681 true,
682 ));
683
684 assert!(
685 result.is_err(),
686 "Component name without hyphen should be rejected"
687 );
688 assert!(
689 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
690 "Wrong error type or message: {:?}",
691 result
692 );
693 assert_eq!(registry.len(), 0);
694 }
695
696 #[test]
697 fn test_valid_component_with_hyphen() {
698 let mut registry = ComponentRegistry::new();
699 let result = registry.register_component(ComponentRegistration::new(
700 "fluent-button",
701 "<button>Click me</button>",
702 None,
703 true,
704 ));
705
706 assert!(
707 result.is_ok(),
708 "Component name with hyphen should be accepted"
709 );
710 assert!(registry.contains("fluent-button"));
711 assert_eq!(registry.len(), 1);
712 }
713
714 #[test]
715 fn test_valid_component_css_only() {
716 let mut registry = ComponentRegistry::new();
717 let result = registry.register_component(ComponentRegistration::new(
718 "styled-widget",
719 "",
720 Some(".widget { color: blue; }"),
721 true,
722 ));
723
724 assert!(
725 result.is_ok(),
726 "Component with empty HTML and CSS should be accepted"
727 );
728 assert!(registry.contains("styled-widget"));
729
730 let component = registry
731 .get("styled-widget")
732 .expect("Failed to retrieve registered component");
733 assert_eq!(component.html_content, "");
734 assert_eq!(
735 component.css_content.as_deref(),
736 Some(".widget { color: blue; }")
737 );
738 }
739
740 #[test]
741 fn test_component_name_requires_hyphen() {
742 let mut registry = ComponentRegistry::new();
743 let result = registry.register_component(ComponentRegistration::new(
744 "single",
745 "<p>Single word</p>",
746 None,
747 true,
748 ));
749
750 assert!(
751 result.is_err(),
752 "Single-word component name should be rejected"
753 );
754 assert!(
755 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
756 "Wrong error type or message: {:?}",
757 result
758 );
759 assert_eq!(registry.len(), 0);
760 }
761
762 #[test]
763 fn test_multiple_hyphens_valid() {
764 let mut registry = ComponentRegistry::new();
765 let result = registry.register_component(ComponentRegistration::new(
766 "my-custom-element",
767 "<div>Custom element</div>",
768 None,
769 true,
770 ));
771
772 assert!(
773 result.is_ok(),
774 "Component name with multiple hyphens should be accepted"
775 );
776 assert!(registry.contains("my-custom-element"));
777 assert_eq!(registry.len(), 1);
778 }
779
780 #[test]
781 fn test_empty_component_name_rejected() {
782 let mut registry = ComponentRegistry::new();
783 let result = registry.register_component(ComponentRegistration::new(
784 "",
785 "<p>Empty name</p>",
786 None,
787 true,
788 ));
789
790 assert!(result.is_err(), "Empty component name should be rejected");
791 assert!(
792 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
793 "Wrong error type or message: {:?}",
794 result
795 );
796 assert_eq!(registry.len(), 0);
797 }
798
799 #[test]
800 fn test_register_component_extracts_css_fallback_requirements() {
801 let mut registry = ComponentRegistry::new();
802 registry
803 .register_component(ComponentRegistration::new(
804 "my-btn",
805 "<button>Click</button>",
806 Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
807 true,
808 ))
809 .expect("register failed");
810
811 let component = registry.get("my-btn").expect("component not found");
812 assert_eq!(component.css_fallback_chains.len(), 2);
813 }
814
815 #[test]
816 fn test_register_component_no_css_no_requirements() {
817 let mut registry = ComponentRegistry::new();
818 registry
819 .register_component(ComponentRegistration::new(
820 "my-card",
821 "<div>Card</div>",
822 None,
823 true,
824 ))
825 .expect("register failed");
826
827 let component = registry.get("my-card").expect("component not found");
828 assert!(component.css_fallback_chains.is_empty());
829 }
830
831 #[test]
832 fn test_register_component_tracks_css_fallback_requirements() {
833 let mut registry = ComponentRegistry::new();
834 registry
835 .register_component(ComponentRegistration::new(
836 "my-widget",
837 "<div>W</div>",
838 Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
839 true,
840 ))
841 .expect("register failed");
842
843 let component = registry.get("my-widget").expect("component not found");
844 assert_eq!(component.css_fallback_chains.len(), 2);
845 }
846}