1use crate::component_policy::{parse_component_render_policy, ComponentRenderPolicy};
9use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
10use std::collections::HashMap;
11#[cfg(feature = "fs")]
12use std::fs;
13#[cfg(feature = "fs")]
14use std::path::Path;
15#[cfg(feature = "fs")]
16use walkdir::WalkDir;
17
18type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
19
20#[derive(Debug, Clone)]
36pub struct Component {
37 pub tag_name: String,
39
40 pub html_content: String,
42
43 pub css_content: Option<String>,
45
46 pub css_definitions: Vec<String>,
48
49 pub css_fallback_chains: Vec<CssFallbackChain>,
51
52 pub is_client_owned: bool,
54}
55
56#[derive(Debug, Clone)]
61pub struct ComponentRegistration<'a> {
62 pub tag_name: &'a str,
64 pub html_content: &'a str,
66 pub css_content: Option<&'a str>,
68 pub is_client_owned: bool,
70}
71
72impl<'a> ComponentRegistration<'a> {
73 #[must_use]
75 pub fn new(
76 tag_name: &'a str,
77 html_content: &'a str,
78 css_content: Option<&'a str>,
79 is_client_owned: bool,
80 ) -> Self {
81 Self {
82 tag_name,
83 html_content,
84 css_content,
85 is_client_owned,
86 }
87 }
88}
89
90#[derive(Debug)]
92pub struct ComponentRegistry {
93 components: HashMap<String, Component>,
95 render_policies: HashMap<String, ComponentRenderPolicy>,
97 css_parser: CssParser,
99 legal_comments: LegalComments,
101}
102
103#[cfg(feature = "fs")]
104fn has_component_script(html_path: &Path) -> Result<bool> {
110 for ext in ["ts", "js"] {
111 let candidate = html_path.with_extension(ext);
112 if candidate.try_exists().map_err(|source| ParserError::IO {
113 context: format!(
114 "Failed to inspect component script: {}",
115 candidate.display()
116 ),
117 source,
118 })? {
119 return Ok(true);
120 }
121 }
122 Ok(false)
123}
124
125impl Default for ComponentRegistry {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131impl ComponentRegistry {
132 pub fn new() -> Self {
134 Self::with_legal_comments(LegalComments::default())
135 }
136
137 pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
138 Self {
139 components: HashMap::new(),
140 render_policies: HashMap::new(),
141 css_parser: CssParser::new(),
142 legal_comments,
143 }
144 }
145
146 #[cfg(feature = "fs")]
148 pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
149 for dir in directories {
150 for entry in WalkDir::new(dir.as_ref())
151 .into_iter()
152 .filter_map(|e| e.ok())
153 {
154 let path = entry.path();
155 if !path.is_file() {
156 continue;
157 }
158 if path.extension().is_some_and(|ext| ext == "html") {
160 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
162 if filename.contains('-') {
163 let css_path = path.with_extension("css");
165 self.register_component_from_paths(
167 path,
168 if css_path.exists() {
169 Some(&css_path)
170 } else {
171 None
172 },
173 )?;
174 }
175 }
176 }
177 }
178 }
179 Ok(self)
180 }
181
182 #[cfg(feature = "fs")]
184 pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
185 &mut self,
186 html_path: P,
187 css_path: Option<Q>,
188 ) -> Result<()> {
189 let html_path = html_path.as_ref();
190
191 let tag_name = html_path
193 .file_stem()
194 .and_then(|s| s.to_str())
195 .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
196
197 if !tag_name.contains('-') {
199 return Err(ParserError::Component(format!(
200 "Component name '{}' must contain a hyphen",
201 tag_name
202 )));
203 }
204
205 if self.components.contains_key(tag_name) {
207 return Err(ParserError::Component(format!(
208 "Component '{}' is already registered",
209 tag_name
210 )));
211 }
212
213 let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
215 context: format!("Failed to read HTML file: {}", html_path.display()),
216 source,
217 })?;
218
219 let (mut css_content, css_definitions, css_fallback_chains) = if let Some(css_path) =
221 css_path
222 {
223 let css_path = css_path.as_ref();
224 if css_path.exists() {
225 let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
226 context: format!("Failed to read CSS file: {}", css_path.display()),
227 source,
228 })?;
229 let (content, definitions, requirements) = self.process_css_content(&content)?;
230 (Some(content), definitions, requirements)
231 } else {
232 (None, Vec::new(), Vec::new())
233 }
234 } else {
235 (None, Vec::new(), Vec::new())
236 };
237
238 let is_client_owned = has_component_script(html_path)?;
239
240 let render_policy = parse_component_render_policy(tag_name, &html_content)?;
241 Self::append_scoped_policy_css(&mut css_content, &render_policy, tag_name);
242
243 let component = Component {
245 tag_name: tag_name.to_string(),
246 html_content,
247 css_content,
248 css_definitions,
249 css_fallback_chains,
250 is_client_owned,
251 };
252
253 self.render_policies
254 .insert(tag_name.to_string(), render_policy);
255 self.components.insert(tag_name.to_string(), component);
256 Ok(())
257 }
258
259 pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
264 let ComponentRegistration {
265 tag_name,
266 html_content,
267 css_content,
268 is_client_owned,
269 } = registration;
270
271 if !tag_name.contains('-') {
273 return Err(ParserError::Component(format!(
274 "Component name '{}' must contain a hyphen",
275 tag_name
276 )));
277 }
278
279 if self.components.contains_key(tag_name) {
281 return Err(ParserError::Component(format!(
282 "Component '{}' is already registered",
283 tag_name
284 )));
285 }
286
287 let (mut css_content, css_definitions, css_fallback_chains) = match css_content {
289 Some(css) => {
290 let (content, definitions, requirements) = self.process_css_content(css)?;
291 (Some(content), definitions, requirements)
292 }
293 None => (None, Vec::new(), Vec::new()),
294 };
295
296 let render_policy = parse_component_render_policy(tag_name, html_content)?;
297 Self::append_scoped_policy_css(&mut css_content, &render_policy, tag_name);
298 let component: Component = Component {
299 tag_name: tag_name.to_string(),
300 html_content: html_content.to_string(),
301 css_content,
302 css_definitions,
303 css_fallback_chains,
304 is_client_owned,
305 };
306
307 self.render_policies
309 .insert(tag_name.to_string(), render_policy);
310 self.components.insert(tag_name.to_string(), component);
311 Ok(())
312 }
313
314 fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
316 let (_tokens, definitions, requirements, stripped) = self
317 .css_parser
318 .extract_tokens_definitions_requirements_and_strip_comments(
319 css_content,
320 self.legal_comments,
321 )?;
322 let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
323 sorted_definitions.sort();
324 Ok((stripped.into_owned(), sorted_definitions, requirements))
325 }
326
327 fn append_scoped_policy_css(
328 css_content: &mut Option<String>,
329 policy: &ComponentRenderPolicy,
330 raw_tag_name: &str,
331 ) {
332 if policy.reserve_block_size().is_none() {
333 return;
334 }
335 let css = css_content.get_or_insert_with(|| String::with_capacity(112));
336 css.reserve(144);
337 let mut tag_name = String::with_capacity(32);
338 Self::push_css_identifier(&mut tag_name, raw_tag_name);
339 policy.append_scoped_css(css, &tag_name);
340 }
341
342 pub fn contains(&self, tag_name: &str) -> bool {
344 self.components.contains_key(tag_name)
345 }
346
347 pub fn get(&self, tag_name: &str) -> Option<&Component> {
349 self.components.get(tag_name)
350 }
351
352 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
354 self.components.values()
355 }
356
357 #[must_use]
360 pub fn render_policy_css<'a>(&self, tag_names: impl IntoIterator<Item = &'a str>) -> String {
361 let mut policies: Vec<(&str, &ComponentRenderPolicy)> = tag_names
362 .into_iter()
363 .filter_map(|tag_name| {
364 self.render_policies
365 .get(tag_name)
366 .map(|policy| (tag_name, policy))
367 })
368 .filter(|(_, policy)| policy.reserve_block_size().is_some())
369 .collect();
370 policies.sort_unstable_by(|left, right| left.0.cmp(right.0));
371 policies.dedup_by(|left, right| left.0 == right.0);
372
373 let mut css = String::with_capacity(policies.len() * 112);
374 for (tag_name, policy) in policies {
375 Self::push_css_identifier(&mut css, tag_name);
376 css.push_str(r#":not([w-render="eager"]){"#);
377 policy.append_declarations(&mut css);
378 css.push('}');
379 }
380 css
381 }
382
383 pub fn names(&self) -> impl Iterator<Item = &str> {
388 self.components.keys().map(String::as_str)
389 }
390
391 fn push_css_identifier(output: &mut String, value: &str) {
392 for (index, ch) in value.chars().enumerate() {
393 if ch.is_ascii_alphabetic()
394 || ch == '-'
395 || ch == '_'
396 || (index > 0 && ch.is_ascii_digit())
397 {
398 output.push(ch);
399 } else {
400 Self::push_css_escape(output, ch as u32);
401 }
402 }
403 }
404
405 fn push_css_escape(output: &mut String, mut value: u32) {
406 const HEX: &[u8; 16] = b"0123456789abcdef";
407 let mut digits = [0u8; 8];
408 let mut index = digits.len();
409 loop {
410 index -= 1;
411 digits[index] = HEX[(value & 0x0f) as usize];
412 value >>= 4;
413 if value == 0 {
414 break;
415 }
416 }
417 output.push('\\');
418 for digit in &digits[index..] {
419 output.push(char::from(*digit));
420 }
421 output.push(' ');
422 }
423
424 pub fn len(&self) -> usize {
426 self.components.len()
427 }
428
429 pub fn is_empty(&self) -> bool {
431 self.components.is_empty()
432 }
433}
434
435#[cfg(test)]
436#[allow(clippy::disallowed_methods)]
437mod tests {
438 use super::*;
439 use crate::codes;
440 use webui_test_utils::TestFileSystem;
441
442 #[test]
443 fn test_register_component() {
444 let html_content = "<p>Hello World</p>";
445 let css_content = "p { color: red; }";
446
447 let mut fs = TestFileSystem::new();
449 let html_path = fs.add_file("components/test-component.html", html_content);
450 let css_path = fs.add_file("components/test-component.css", css_content);
451
452 let mut registry = ComponentRegistry::new();
454 let result = registry.register_component_from_paths(&html_path, Some(&css_path));
455
456 assert!(result.is_ok());
457 assert!(registry.contains("test-component"));
458
459 let component = registry
460 .get("test-component")
461 .expect("Failed to retrieve registered component");
462 assert_eq!(component.html_content, html_content);
463 assert_eq!(component.css_content.as_deref(), Some(css_content));
464 assert!(!component.is_client_owned);
465 }
466
467 #[test]
468 fn test_register_component_detects_ts_sibling_script() {
469 let mut fs = TestFileSystem::new();
470 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
471 std::fs::write(html_path.with_extension("ts"), "export {};")
472 .expect("Failed to write sibling script");
473
474 let mut registry = ComponentRegistry::new();
475 registry
476 .register_component_from_paths(&html_path, None::<&str>)
477 .expect("register failed");
478
479 let component = registry
480 .get("scripted-card")
481 .expect("Failed to retrieve registered component");
482 assert!(component.is_client_owned);
483 }
484
485 #[test]
486 fn test_register_component_detects_js_sibling_script() {
487 let mut fs = TestFileSystem::new();
488 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
489 std::fs::write(html_path.with_extension("js"), "export {};")
490 .expect("Failed to write sibling script");
491
492 let mut registry = ComponentRegistry::new();
493 registry
494 .register_component_from_paths(&html_path, None::<&str>)
495 .expect("register failed");
496
497 let component = registry
498 .get("scripted-card")
499 .expect("Failed to retrieve registered component");
500 assert!(component.is_client_owned);
501 }
502
503 #[test]
504 fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
505 let mut fs = TestFileSystem::new();
506 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
507 std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
508 .expect("Failed to write invalid sibling script");
509
510 let mut registry = ComponentRegistry::new();
511 registry
512 .register_component_from_paths(&html_path, None::<&str>)
513 .expect("registration should inspect presence without decoding source");
514
515 let component = registry
516 .get("scripted-card")
517 .expect("Failed to retrieve registered component");
518 assert!(component.is_client_owned);
519 }
520
521 #[test]
522 fn test_register_component_ignores_tsx_sibling_script() {
523 let mut fs = TestFileSystem::new();
524 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
525 std::fs::write(html_path.with_extension("tsx"), "export {};")
526 .expect("Failed to write sibling script");
527
528 let mut registry = ComponentRegistry::new();
529 registry
530 .register_component_from_paths(&html_path, None::<&str>)
531 .expect("register failed");
532
533 let component = registry
534 .get("scripted-card")
535 .expect("Failed to retrieve registered component");
536 assert!(!component.is_client_owned);
537 }
538
539 #[test]
540 fn test_component_name_validation() {
541 let html_content = "<p>Invalid</p>";
542
543 let mut fs = TestFileSystem::new();
545 let html_path = fs.add_file("invalid_name.html", html_content);
546
547 let mut registry = ComponentRegistry::new();
549 let result = registry.register_component_from_paths(&html_path, None::<&str>);
550
551 assert!(result.is_err());
552 assert_eq!(registry.len(), 0);
553 }
554
555 #[test]
556 fn test_missing_css_file() {
557 let html_content = "<p>CSS Optional</p>";
558
559 let mut fs = TestFileSystem::new();
561 let html_path = fs.add_file("test-component.html", html_content);
562
563 let mut registry = ComponentRegistry::new();
565 let result = registry.register_component_from_paths(&html_path, None::<&str>);
566
567 assert!(result.is_ok());
568 let component = registry
569 .get("test-component")
570 .expect("Failed to retrieve registered component");
571 assert_eq!(component.html_content, html_content);
572 assert_eq!(component.css_content, None);
573 }
574
575 #[test]
576 fn test_register_component_from_strings() {
577 let html_content = "<p>Hello from string!</p>";
578 let css_content = "p { color: green; }";
579
580 let mut registry = ComponentRegistry::new();
582 let result = registry.register_component(ComponentRegistration::new(
583 "string-component",
584 html_content,
585 Some(css_content),
586 true,
587 ));
588
589 assert!(result.is_ok());
590 assert!(registry.contains("string-component"));
591
592 let component = registry
593 .get("string-component")
594 .expect("Failed to retrieve registered component");
595 assert_eq!(component.html_content, html_content);
596 assert_eq!(component.css_content.as_deref(), Some(css_content));
597 assert!(component.is_client_owned);
598 }
599
600 #[test]
601 fn lazy_render_policy_generates_stable_document_css() {
602 let mut registry = ComponentRegistry::new();
603 for (tag, size) in [("z-card", "18rem"), ("a-row", "72px")] {
604 let html = format!(
605 r#"<template w-render="lazy" w-reserve-block-size="{size}"><p>x</p></template>"#
606 );
607 registry
608 .register_component(ComponentRegistration::new(tag, &html, None, true))
609 .expect("valid lazy render policy");
610 }
611
612 assert_eq!(
613 registry.render_policy_css(["z-card", "a-row"]),
614 concat!(
615 r#"a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
616 r#"z-card:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 18rem;}"#,
617 )
618 );
619 assert_eq!(
620 registry
621 .get("a-row")
622 .and_then(|component| component.css_content.as_deref()),
623 Some(
624 r#":host(a-row:not([w-render="eager"])),a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
625 )
626 );
627 }
628
629 #[test]
630 fn lazy_hydration_policy_emits_no_rendering_css() {
631 let mut registry = ComponentRegistry::new();
632 registry
633 .register_component(ComponentRegistration::new(
634 "lazy-row",
635 r#"<template w-hydrate="lazy"><p>x</p></template>"#,
636 None,
637 true,
638 ))
639 .expect("valid hydration policy");
640 assert!(registry.render_policy_css(["lazy-row"]).is_empty());
641 }
642
643 #[test]
644 fn lazy_render_policy_requires_a_valid_reservation() {
645 for (html, code) in [
646 (
647 r#"<template w-render="lazy"><p>x</p></template>"#,
648 codes::MISSING_RENDER_RESERVATION,
649 ),
650 (
651 r#"<template w-render="lazy" w-reserve-block-size="50%"><p>x</p></template>"#,
652 codes::INVALID_RENDER_RESERVATION,
653 ),
654 (
655 r#"<div w-render="lazy" w-reserve-block-size="10px"></div>"#,
656 codes::INVALID_COMPONENT_RENDER_POLICY,
657 ),
658 ] {
659 let mut registry = ComponentRegistry::new();
660 let Err(ParserError::Template(diagnostic)) = registry
661 .register_component(ComponentRegistration::new("bad-card", html, None, true))
662 else {
663 panic!("invalid component policy must produce a template diagnostic");
664 };
665 assert_eq!(diagnostic.error_code(), Some(code));
666 assert!(diagnostic.help_text().is_some());
667 }
668 }
669
670 #[test]
671 fn test_register_component_strips_css_comments() {
672 let mut registry = ComponentRegistry::new();
673 registry
674 .register_component(ComponentRegistration::new(
675 "style-component",
676 "<p>Styled</p>",
677 Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
678 true,
679 ))
680 .expect("register failed");
681
682 let component = registry
683 .get("style-component")
684 .expect("Failed to retrieve registered component");
685 assert_eq!(
686 component.css_content.as_deref(),
687 Some(" p { color: var(--textColor); } ")
688 );
689 assert_eq!(component.css_fallback_chains.len(), 1);
690 }
691
692 #[test]
693 fn test_register_component_preserves_legal_css_comments_by_default() {
694 let mut registry = ComponentRegistry::new();
695 registry
696 .register_component(ComponentRegistration::new(
697 "legal-component",
698 "<p>Styled</p>",
699 Some("/*! @license MIT */ p { color: red; } /* remove */"),
700 true,
701 ))
702 .expect("register failed");
703
704 let component = registry
705 .get("legal-component")
706 .expect("Failed to retrieve registered component");
707 assert_eq!(
708 component.css_content.as_deref(),
709 Some("/*! @license MIT */ p { color: red; } ")
710 );
711 }
712
713 #[test]
714 fn test_register_component_strips_legal_css_comments_when_disabled() {
715 let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
716 registry
717 .register_component(ComponentRegistration::new(
718 "legal-component",
719 "<p>Styled</p>",
720 Some("/*! @license MIT */ p { color: red; }"),
721 true,
722 ))
723 .expect("register failed");
724
725 let component = registry
726 .get("legal-component")
727 .expect("Failed to retrieve registered component");
728 assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
729 }
730
731 #[test]
732 fn test_invalid_component_name_from_strings() {
733 let html_content = "<p>Invalid component</p>";
734
735 let mut registry = ComponentRegistry::new();
737 let result = registry.register_component(ComponentRegistration::new(
738 "invalid",
739 html_content,
740 None,
741 true,
742 ));
743
744 assert!(result.is_err(), "Expected error for invalid component name");
746
747 assert!(
749 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
750 "Wrong error type or message: {:?}",
751 result
752 );
753 }
754
755 #[test]
756 fn test_duplicate_component_from_strings() {
757 let html_content = "<p>First component</p>";
758 let html_content2 = "<p>Second component with same name</p>";
759
760 let mut registry = ComponentRegistry::new();
762 let result1 = registry.register_component(ComponentRegistration::new(
763 "dupe-component",
764 html_content,
765 None,
766 true,
767 ));
768 assert!(result1.is_ok());
769
770 let result2 = registry.register_component(ComponentRegistration::new(
772 "dupe-component",
773 html_content2,
774 None,
775 true,
776 ));
777 assert!(result2.is_err());
778
779 assert!(
781 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
782 "Expected 'already registered' error, got: {:?}",
783 result2
784 );
785
786 let component = registry
788 .get("dupe-component")
789 .expect("Failed to retrieve registered component");
790 assert_eq!(component.html_content, html_content);
791 }
792
793 #[test]
794 fn test_duplicate_component_from_paths() {
795 let html_content1 = "<p>Component from dir A</p>";
796 let html_content2 = "<p>Component from dir B</p>";
797
798 let mut fs = TestFileSystem::new();
800 let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
801 let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
802
803 let mut registry = ComponentRegistry::new();
805 let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
806 assert!(result1.is_ok());
807
808 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
810 assert!(result2.is_err());
811
812 assert!(
814 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
815 "Expected 'already registered' error, got: {:?}",
816 result2
817 );
818
819 let component = registry
821 .get("my-comp")
822 .expect("Failed to retrieve registered component");
823 assert_eq!(component.html_content, html_content1);
824 }
825
826 #[test]
827 fn test_exclude_dot_in_component_name() {
828 let mut registry = ComponentRegistry::new();
829 let result = registry.register_component(ComponentRegistration::new(
830 "fluent.button",
831 "<p>Dot name</p>",
832 None,
833 true,
834 ));
835
836 assert!(
837 result.is_err(),
838 "Component name with dot but no hyphen should be rejected"
839 );
840 assert!(
841 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
842 "Wrong error type or message: {:?}",
843 result
844 );
845 assert_eq!(registry.len(), 0);
846 }
847
848 #[test]
849 fn test_exclude_no_hyphen_html() {
850 let mut registry = ComponentRegistry::new();
851 let result = registry.register_component(ComponentRegistration::new(
852 "foobar",
853 "<p>No hyphen</p>",
854 None,
855 true,
856 ));
857
858 assert!(
859 result.is_err(),
860 "Component name without hyphen should be rejected"
861 );
862 assert!(
863 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
864 "Wrong error type or message: {:?}",
865 result
866 );
867 assert_eq!(registry.len(), 0);
868 }
869
870 #[test]
871 fn test_valid_component_with_hyphen() {
872 let mut registry = ComponentRegistry::new();
873 let result = registry.register_component(ComponentRegistration::new(
874 "fluent-button",
875 "<button>Click me</button>",
876 None,
877 true,
878 ));
879
880 assert!(
881 result.is_ok(),
882 "Component name with hyphen should be accepted"
883 );
884 assert!(registry.contains("fluent-button"));
885 assert_eq!(registry.len(), 1);
886 }
887
888 #[test]
889 fn test_valid_component_css_only() {
890 let mut registry = ComponentRegistry::new();
891 let result = registry.register_component(ComponentRegistration::new(
892 "styled-widget",
893 "",
894 Some(".widget { color: blue; }"),
895 true,
896 ));
897
898 assert!(
899 result.is_ok(),
900 "Component with empty HTML and CSS should be accepted"
901 );
902 assert!(registry.contains("styled-widget"));
903
904 let component = registry
905 .get("styled-widget")
906 .expect("Failed to retrieve registered component");
907 assert_eq!(component.html_content, "");
908 assert_eq!(
909 component.css_content.as_deref(),
910 Some(".widget { color: blue; }")
911 );
912 }
913
914 #[test]
915 fn test_component_name_requires_hyphen() {
916 let mut registry = ComponentRegistry::new();
917 let result = registry.register_component(ComponentRegistration::new(
918 "single",
919 "<p>Single word</p>",
920 None,
921 true,
922 ));
923
924 assert!(
925 result.is_err(),
926 "Single-word component name should be rejected"
927 );
928 assert!(
929 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
930 "Wrong error type or message: {:?}",
931 result
932 );
933 assert_eq!(registry.len(), 0);
934 }
935
936 #[test]
937 fn test_multiple_hyphens_valid() {
938 let mut registry = ComponentRegistry::new();
939 let result = registry.register_component(ComponentRegistration::new(
940 "my-custom-element",
941 "<div>Custom element</div>",
942 None,
943 true,
944 ));
945
946 assert!(
947 result.is_ok(),
948 "Component name with multiple hyphens should be accepted"
949 );
950 assert!(registry.contains("my-custom-element"));
951 assert_eq!(registry.len(), 1);
952 }
953
954 #[test]
955 fn test_empty_component_name_rejected() {
956 let mut registry = ComponentRegistry::new();
957 let result = registry.register_component(ComponentRegistration::new(
958 "",
959 "<p>Empty name</p>",
960 None,
961 true,
962 ));
963
964 assert!(result.is_err(), "Empty component name should be rejected");
965 assert!(
966 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
967 "Wrong error type or message: {:?}",
968 result
969 );
970 assert_eq!(registry.len(), 0);
971 }
972
973 #[test]
974 fn test_register_component_extracts_css_fallback_requirements() {
975 let mut registry = ComponentRegistry::new();
976 registry
977 .register_component(ComponentRegistration::new(
978 "my-btn",
979 "<button>Click</button>",
980 Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
981 true,
982 ))
983 .expect("register failed");
984
985 let component = registry.get("my-btn").expect("component not found");
986 assert_eq!(component.css_fallback_chains.len(), 2);
987 }
988
989 #[test]
990 fn test_register_component_no_css_no_requirements() {
991 let mut registry = ComponentRegistry::new();
992 registry
993 .register_component(ComponentRegistration::new(
994 "my-card",
995 "<div>Card</div>",
996 None,
997 true,
998 ))
999 .expect("register failed");
1000
1001 let component = registry.get("my-card").expect("component not found");
1002 assert!(component.css_fallback_chains.is_empty());
1003 }
1004
1005 #[test]
1006 fn test_register_component_tracks_css_fallback_requirements() {
1007 let mut registry = ComponentRegistry::new();
1008 registry
1009 .register_component(ComponentRegistration::new(
1010 "my-widget",
1011 "<div>W</div>",
1012 Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
1013 true,
1014 ))
1015 .expect("register failed");
1016
1017 let component = registry.get("my-widget").expect("component not found");
1018 assert_eq!(component.css_fallback_chains.len(), 2);
1019 }
1020}