1use crate::component_policy::{
9 parse_component_render_policy, validate_policy_client_ownership, ComponentRenderPolicy,
10};
11use crate::plugin::{ComponentSource, ComponentSourceTransform, TransformedComponentSource};
12use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
13use std::collections::{HashMap, HashSet};
14#[cfg(feature = "fs")]
15use std::fs;
16#[cfg(feature = "fs")]
17use std::path::Path;
18use std::sync::Arc;
19#[cfg(feature = "fs")]
20use walkdir::WalkDir;
21
22type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
23
24#[derive(Debug, Clone)]
40pub struct Component {
41 pub tag_name: String,
43
44 pub html_content: String,
46
47 pub css_content: Option<String>,
49
50 pub css_definitions: Vec<String>,
52
53 pub css_fallback_chains: Vec<CssFallbackChain>,
55
56 pub is_client_owned: bool,
58}
59
60#[derive(Debug, Clone)]
65pub struct ComponentRegistration<'a> {
66 pub tag_name: &'a str,
68 pub html_content: &'a str,
70 pub css_content: Option<&'a str>,
72 pub is_client_owned: bool,
74}
75
76impl<'a> ComponentRegistration<'a> {
77 #[must_use]
79 pub fn new(
80 tag_name: &'a str,
81 html_content: &'a str,
82 css_content: Option<&'a str>,
83 is_client_owned: bool,
84 ) -> Self {
85 Self {
86 tag_name,
87 html_content,
88 css_content,
89 is_client_owned,
90 }
91 }
92}
93
94#[derive(Debug)]
96pub struct ComponentRegistry {
97 components: HashMap<String, Component>,
99 render_policies: HashMap<String, ComponentRenderPolicy>,
101 policy_css: HashSet<String>,
103 component_artifact_sources: HashMap<String, String>,
105 component_authored_sources: HashMap<String, Arc<str>>,
107 source_transform: Option<ComponentSourceTransform>,
109 css_parser: CssParser,
111 legal_comments: LegalComments,
113}
114
115#[cfg(feature = "fs")]
116fn has_component_script(html_path: &Path) -> Result<bool> {
118 for ext in ["ts", "js"] {
119 let candidate = html_path.with_extension(ext);
120 if candidate.try_exists().map_err(|source| ParserError::IO {
121 context: format!(
122 "Failed to inspect component script: {}",
123 candidate.display()
124 ),
125 source,
126 })? {
127 return Ok(true);
128 }
129 }
130 Ok(false)
131}
132
133impl Default for ComponentRegistry {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139impl ComponentRegistry {
140 pub fn new() -> Self {
142 Self::with_legal_comments(LegalComments::default())
143 }
144
145 pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
146 Self {
147 components: HashMap::new(),
148 render_policies: HashMap::new(),
149 policy_css: HashSet::new(),
150 component_artifact_sources: HashMap::new(),
151 component_authored_sources: HashMap::new(),
152 source_transform: None,
153 css_parser: CssParser::new(),
154 legal_comments,
155 }
156 }
157
158 pub(crate) fn set_component_source_transform(
160 &mut self,
161 transform: Option<ComponentSourceTransform>,
162 ) {
163 self.source_transform = transform;
164 }
165
166 fn resolve_component_source(
168 &self,
169 tag_name: &str,
170 html_content: &str,
171 ) -> Result<Option<TransformedComponentSource>> {
172 if let Some(transform) = self.source_transform {
173 return transform(ComponentSource {
174 tag_name,
175 html_content,
176 });
177 }
178 Ok(None)
179 }
180
181 #[cfg(feature = "fs")]
183 pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
184 for dir in directories {
185 for entry in WalkDir::new(dir.as_ref())
186 .into_iter()
187 .filter_map(|e| e.ok())
188 {
189 let path = entry.path();
190 if !path.is_file() {
191 continue;
192 }
193 if !path.extension().is_some_and(|ext| ext == "html") {
195 continue;
196 }
197 let Some(filename) = path.file_stem().and_then(|s| s.to_str()) else {
198 continue;
199 };
200 if !filename.contains('-') && self.source_transform.is_none() {
202 continue;
203 }
204 let css_path = path.with_extension("css");
205 let css_path = css_path.exists().then_some(css_path);
206 self.register_component_from_paths_inner(path, css_path.as_deref(), true)?;
208 }
209 }
210 Ok(self)
211 }
212
213 #[cfg(feature = "fs")]
215 pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
216 &mut self,
217 html_path: P,
218 css_path: Option<Q>,
219 ) -> Result<()> {
220 let css_path = css_path.as_ref().map(AsRef::as_ref);
221 self.register_component_from_paths_inner(html_path.as_ref(), css_path, false)
222 }
223
224 #[cfg(feature = "fs")]
226 fn register_component_from_paths_inner(
227 &mut self,
228 html_path: &Path,
229 css_path: Option<&Path>,
230 allow_skip_unresolved: bool,
231 ) -> Result<()> {
232 let tag_name = html_path
234 .file_stem()
235 .and_then(|s| s.to_str())
236 .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
237
238 let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
240 context: format!("Failed to read HTML file: {}", html_path.display()),
241 source,
242 })?;
243 let (resolved, authored_source) =
244 match self.resolve_component_source(tag_name, &html_content)? {
245 Some(transformed) => (transformed, Some(Arc::<str>::from(html_content))),
246 None => {
247 if allow_skip_unresolved && !tag_name.contains('-') {
249 return Ok(());
250 }
251 (
253 TransformedComponentSource {
254 tag_name: tag_name.to_string(),
255 parser_content: html_content,
256 artifact_content: None,
257 },
258 None,
259 )
260 }
261 };
262 Self::validate_component_name(&resolved.tag_name)?;
263
264 if self.components.contains_key(&resolved.tag_name) {
266 return Err(ParserError::Component(format!(
267 "Component '{}' is already registered",
268 resolved.tag_name
269 )));
270 }
271
272 let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
274 if css_path.exists() {
275 let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
276 context: format!("Failed to read CSS file: {}", css_path.display()),
277 source,
278 })?;
279 let (content, definitions, requirements) = self.process_css_content(&content)?;
280 (Some(content), definitions, requirements)
281 } else {
282 (None, Vec::new(), Vec::new())
283 }
284 } else {
285 (None, Vec::new(), Vec::new())
286 };
287
288 let is_client_owned = has_component_script(html_path)?;
289
290 let render_policy =
291 parse_component_render_policy(&resolved.tag_name, &resolved.parser_content)?;
292 validate_policy_client_ownership(
293 &resolved.tag_name,
294 &resolved.parser_content,
295 &render_policy,
296 is_client_owned,
297 )?;
298
299 let component = Component {
301 tag_name: resolved.tag_name,
302 html_content: resolved.parser_content,
303 css_content,
304 css_definitions,
305 css_fallback_chains,
306 is_client_owned,
307 };
308
309 self.render_policies
310 .insert(component.tag_name.clone(), render_policy);
311 if let Some(artifact) = resolved.artifact_content {
312 self.component_artifact_sources
313 .insert(component.tag_name.clone(), artifact);
314 }
315 if let Some(authored_source) = authored_source {
316 self.component_authored_sources
317 .insert(component.tag_name.clone(), authored_source);
318 }
319 self.components
320 .insert(component.tag_name.clone(), component);
321 Ok(())
322 }
323
324 pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
329 let ComponentRegistration {
330 tag_name,
331 html_content,
332 css_content,
333 is_client_owned,
334 } = registration;
335
336 let (resolved, authored_source) =
337 match self.resolve_component_source(tag_name, html_content)? {
338 Some(transformed) => (transformed, Some(Arc::<str>::from(html_content))),
339 None => (
342 TransformedComponentSource {
343 tag_name: tag_name.to_string(),
344 parser_content: html_content.to_string(),
345 artifact_content: None,
346 },
347 None,
348 ),
349 };
350 Self::validate_component_name(&resolved.tag_name)?;
351
352 if self.components.contains_key(&resolved.tag_name) {
354 return Err(ParserError::Component(format!(
355 "Component '{}' is already registered",
356 resolved.tag_name
357 )));
358 }
359
360 let (css_content, css_definitions, css_fallback_chains) = match css_content {
362 Some(css) => {
363 let (content, definitions, requirements) = self.process_css_content(css)?;
364 (Some(content), definitions, requirements)
365 }
366 None => (None, Vec::new(), Vec::new()),
367 };
368 let render_policy =
369 parse_component_render_policy(&resolved.tag_name, &resolved.parser_content)?;
370 validate_policy_client_ownership(
371 &resolved.tag_name,
372 &resolved.parser_content,
373 &render_policy,
374 is_client_owned,
375 )?;
376 let component: Component = Component {
377 tag_name: resolved.tag_name,
378 html_content: resolved.parser_content,
379 css_content,
380 css_definitions,
381 css_fallback_chains,
382 is_client_owned,
383 };
384
385 self.render_policies
387 .insert(component.tag_name.clone(), render_policy);
388 if let Some(artifact) = resolved.artifact_content {
389 self.component_artifact_sources
390 .insert(component.tag_name.clone(), artifact);
391 }
392 if let Some(authored_source) = authored_source {
393 self.component_authored_sources
394 .insert(component.tag_name.clone(), authored_source);
395 }
396 self.components
397 .insert(component.tag_name.clone(), component);
398 Ok(())
399 }
400
401 fn validate_component_name(tag_name: &str) -> Result<()> {
402 if tag_name.contains('-') {
403 return Ok(());
404 }
405
406 Err(ParserError::Component(format!(
407 "Component name '{}' must contain a hyphen",
408 tag_name
409 )))
410 }
411
412 fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
414 let (definitions, requirements, stripped) = self
415 .css_parser
416 .extract_definitions_requirements_and_strip_comments(
417 css_content,
418 self.legal_comments,
419 )?;
420 let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
421 sorted_definitions.sort();
422 Ok((stripped.into_owned(), sorted_definitions, requirements))
423 }
424
425 pub(crate) fn prepare_policy_css(
427 &mut self,
428 tag_name: &str,
429 uses_shadow_dom: bool,
430 ) -> Result<()> {
431 let Some(policy) = self.render_policies.get(tag_name) else {
432 return Err(ParserError::NotFound(format!(
433 "component <{tag_name}> disappeared before render policy preparation"
434 )));
435 };
436 if policy.reserve_block_size().is_none() || self.policy_css.contains(tag_name) {
437 return Ok(());
438 }
439 let component_tag = {
440 let component = self.components.get_mut(tag_name).ok_or_else(|| {
441 ParserError::NotFound(format!(
442 "component <{tag_name}> disappeared before render policy preparation"
443 ))
444 })?;
445 let css = component
446 .css_content
447 .get_or_insert_with(|| String::with_capacity(112));
448 css.reserve(144);
449 let mut escaped_tag = String::with_capacity(32);
450 crate::css_light::push_css_identifier(&mut escaped_tag, component.tag_name.as_str());
451 if uses_shadow_dom {
452 policy.append_shadow_css(css, &escaped_tag);
453 } else {
454 policy.append_light_css(css, &escaped_tag);
455 }
456 component.tag_name.clone()
457 };
458 self.policy_css.insert(component_tag);
459 Ok(())
460 }
461
462 pub fn contains(&self, tag_name: &str) -> bool {
464 self.components.contains_key(tag_name)
465 }
466
467 pub fn get(&self, tag_name: &str) -> Option<&Component> {
469 self.components.get(tag_name)
470 }
471
472 pub(crate) fn render_policy(&self, tag_name: &str) -> Option<&ComponentRenderPolicy> {
473 self.render_policies.get(tag_name)
474 }
475
476 pub(crate) fn work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
477 self.render_policies
478 .iter()
479 .filter_map(|(tag, policy)| policy.metadata_code().map(|code| (tag.as_str(), code)))
480 }
481
482 pub(crate) fn diagnostic_css_content(&self, tag_name: &str) -> Option<&str> {
484 self.components
485 .get(tag_name)
486 .and_then(|component| component.css_content.as_deref())
487 }
488
489 pub(crate) fn component_artifact_source(&self, tag_name: &str) -> Option<&str> {
491 self.component_artifact_sources
492 .get(tag_name)
493 .map(String::as_str)
494 }
495
496 pub(crate) fn component_authored_source(&self, tag_name: &str) -> Option<Arc<str>> {
497 self.component_authored_sources.get(tag_name).cloned()
498 }
499
500 pub fn get_all(&self) -> impl Iterator<Item = &Component> {
502 self.components.values()
503 }
504
505 #[must_use]
508 pub fn render_policy_css<'a>(&self, tag_names: impl IntoIterator<Item = &'a str>) -> String {
509 let mut policies: Vec<(&str, &ComponentRenderPolicy)> = tag_names
510 .into_iter()
511 .filter_map(|tag_name| {
512 self.render_policies
513 .get(tag_name)
514 .map(|policy| (tag_name, policy))
515 })
516 .filter(|(_, policy)| policy.reserve_block_size().is_some())
517 .collect();
518 policies.sort_unstable_by(|left, right| left.0.cmp(right.0));
519 policies.dedup_by(|left, right| left.0 == right.0);
520
521 let mut css = String::with_capacity(policies.len() * 112);
522 for (tag_name, policy) in policies {
523 crate::css_light::push_css_identifier(&mut css, tag_name);
524 css.push_str(r#":not([w-render="eager"]){"#);
525 policy.append_declarations(&mut css);
526 css.push('}');
527 }
528 css
529 }
530
531 pub fn names(&self) -> impl Iterator<Item = &str> {
536 self.components.keys().map(String::as_str)
537 }
538
539 pub fn len(&self) -> usize {
541 self.components.len()
542 }
543
544 pub fn is_empty(&self) -> bool {
546 self.components.is_empty()
547 }
548}
549
550#[cfg(test)]
551#[allow(clippy::disallowed_methods)]
552mod tests {
553 use super::*;
554 use crate::codes;
555 use crate::plugin::TransformedComponentSource;
556 use webui_test_utils::TestFileSystem;
557
558 #[test]
559 fn test_register_component() {
560 let html_content = "<p>Hello World</p>";
561 let css_content = "p { color: red; }";
562
563 let mut fs = TestFileSystem::new();
565 let html_path = fs.add_file("components/test-component.html", html_content);
566 let css_path = fs.add_file("components/test-component.css", css_content);
567
568 let mut registry = ComponentRegistry::new();
570 let result = registry.register_component_from_paths(&html_path, Some(&css_path));
571
572 assert!(result.is_ok());
573 assert!(registry.contains("test-component"));
574
575 let component = registry
576 .get("test-component")
577 .expect("Failed to retrieve registered component");
578 assert_eq!(component.html_content, html_content);
579 assert_eq!(component.css_content.as_deref(), Some(css_content));
580 assert!(!component.is_client_owned);
581 }
582
583 #[test]
584 #[cfg(feature = "fs")]
585 fn transform_applies_to_path_registration() {
586 let mut fs = TestFileSystem::new();
587 let html_path = fs.add_file(
588 "components/file-card.html",
589 r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
590 );
591
592 let mut registry = ComponentRegistry::new();
593 registry.set_component_source_transform(Some(mock_rename_transform));
594 registry
595 .register_component_from_paths(&html_path, None::<&str>)
596 .expect("register");
597
598 assert!(!registry.contains("file-card"));
599 let component = registry.get("renamed-card").expect("component");
600 assert_eq!(
601 component.html_content,
602 "<template><span>parser</span></template>"
603 );
604 assert_eq!(
605 registry.component_artifact_source("renamed-card"),
606 Some("<template><span>artifact</span></template>")
607 );
608 assert_eq!(
609 registry
610 .component_authored_source("renamed-card")
611 .as_deref(),
612 Some(r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#)
613 );
614 }
615
616 #[test]
617 fn test_register_component_detects_ts_sibling_script() {
618 let mut fs = TestFileSystem::new();
619 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
620 std::fs::write(html_path.with_extension("ts"), "export {};")
621 .expect("Failed to write sibling script");
622
623 let mut registry = ComponentRegistry::new();
624 registry
625 .register_component_from_paths(&html_path, None::<&str>)
626 .expect("register failed");
627
628 let component = registry
629 .get("scripted-card")
630 .expect("Failed to retrieve registered component");
631 assert!(component.is_client_owned);
632 }
633
634 #[test]
635 fn test_register_component_detects_js_sibling_script() {
636 let mut fs = TestFileSystem::new();
637 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
638 std::fs::write(html_path.with_extension("js"), "export {};")
639 .expect("Failed to write sibling script");
640
641 let mut registry = ComponentRegistry::new();
642 registry
643 .register_component_from_paths(&html_path, None::<&str>)
644 .expect("register failed");
645
646 let component = registry
647 .get("scripted-card")
648 .expect("Failed to retrieve registered component");
649 assert!(component.is_client_owned);
650 }
651
652 #[test]
653 fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
654 let mut fs = TestFileSystem::new();
655 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
656 std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
657 .expect("Failed to write invalid sibling script");
658
659 let mut registry = ComponentRegistry::new();
660 registry
661 .register_component_from_paths(&html_path, None::<&str>)
662 .expect("registration should inspect presence without decoding source");
663
664 let component = registry
665 .get("scripted-card")
666 .expect("Failed to retrieve registered component");
667 assert!(component.is_client_owned);
668 }
669
670 #[test]
671 fn test_register_component_ignores_tsx_sibling_script() {
672 let mut fs = TestFileSystem::new();
673 let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
674 std::fs::write(html_path.with_extension("tsx"), "export {};")
675 .expect("Failed to write sibling script");
676
677 let mut registry = ComponentRegistry::new();
678 registry
679 .register_component_from_paths(&html_path, None::<&str>)
680 .expect("register failed");
681
682 let component = registry
683 .get("scripted-card")
684 .expect("Failed to retrieve registered component");
685 assert!(!component.is_client_owned);
686 }
687
688 #[test]
689 fn test_component_name_validation() {
690 let html_content = "<p>Invalid</p>";
691
692 let mut fs = TestFileSystem::new();
694 let html_path = fs.add_file("invalid_name.html", html_content);
695
696 let mut registry = ComponentRegistry::new();
698 let result = registry.register_component_from_paths(&html_path, None::<&str>);
699
700 assert!(result.is_err());
701 assert_eq!(registry.len(), 0);
702 }
703
704 #[test]
705 fn test_missing_css_file() {
706 let html_content = "<p>CSS Optional</p>";
707
708 let mut fs = TestFileSystem::new();
710 let html_path = fs.add_file("test-component.html", html_content);
711
712 let mut registry = ComponentRegistry::new();
714 let result = registry.register_component_from_paths(&html_path, None::<&str>);
715
716 assert!(result.is_ok());
717 let component = registry
718 .get("test-component")
719 .expect("Failed to retrieve registered component");
720 assert_eq!(component.html_content, html_content);
721 assert_eq!(component.css_content, None);
722 }
723
724 #[test]
725 fn test_register_component_from_strings() {
726 let html_content = "<p>Hello from string!</p>";
727 let css_content = "p { color: green; }";
728
729 let mut registry = ComponentRegistry::new();
731 let result = registry.register_component(ComponentRegistration::new(
732 "string-component",
733 html_content,
734 Some(css_content),
735 true,
736 ));
737
738 assert!(result.is_ok());
739 assert!(registry.contains("string-component"));
740
741 let component = registry
742 .get("string-component")
743 .expect("Failed to retrieve registered component");
744 assert_eq!(component.html_content, html_content);
745 assert_eq!(component.css_content.as_deref(), Some(css_content));
746 assert!(component.is_client_owned);
747 }
748
749 #[test]
750 fn lazy_render_policy_generates_stable_document_css() {
751 let mut registry = ComponentRegistry::new();
752 for (tag, size) in [("z-card", "18rem"), ("a-row", "72px")] {
753 let html = format!(
754 r#"<template w-render="lazy" w-reserve-block-size="{size}"><p>x</p></template>"#
755 );
756 registry
757 .register_component(ComponentRegistration::new(tag, &html, None, true))
758 .expect("valid lazy render policy");
759 }
760
761 assert_eq!(
762 registry.render_policy_css(["z-card", "a-row"]),
763 concat!(
764 r#"a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
765 r#"z-card:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 18rem;}"#,
766 )
767 );
768 assert_eq!(
769 registry
770 .get("a-row")
771 .and_then(|component| component.css_content.as_deref()),
772 None
773 );
774 registry
775 .prepare_policy_css("a-row", true)
776 .expect("prepare Shadow policy CSS");
777 assert_eq!(
778 registry
779 .get("a-row")
780 .and_then(|component| component.css_content.as_deref()),
781 Some(
782 r#":host(a-row:not([w-render="eager"])),a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
783 )
784 );
785 }
786
787 #[test]
788 fn lazy_render_policy_uses_a_normal_selector_for_light_css() {
789 let mut registry = ComponentRegistry::new();
790 registry
791 .register_component(ComponentRegistration::new(
792 "light-row",
793 r#"<template w-render="lazy" w-reserve-block-size="72px"><p>x</p></template>"#,
794 None,
795 false,
796 ))
797 .expect("valid lazy render policy");
798
799 registry
800 .prepare_policy_css("light-row", false)
801 .expect("prepare Light policy CSS");
802 assert_eq!(
803 registry
804 .get("light-row")
805 .and_then(|component| component.css_content.as_deref()),
806 Some(
807 r#"light-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
808 )
809 );
810 }
811
812 #[test]
813 fn lazy_hydration_policy_emits_no_rendering_css() {
814 let mut registry = ComponentRegistry::new();
815 registry
816 .register_component(ComponentRegistration::new(
817 "lazy-row",
818 r#"<template w-hydrate="lazy"><p>x</p></template>"#,
819 None,
820 true,
821 ))
822 .expect("valid hydration policy");
823 assert!(registry.render_policy_css(["lazy-row"]).is_empty());
824 }
825
826 #[test]
827 fn lazy_render_policy_requires_a_valid_reservation() {
828 for (html, code) in [
829 (
830 r#"<template w-render="lazy"><p>x</p></template>"#,
831 codes::MISSING_RENDER_RESERVATION,
832 ),
833 (
834 r#"<template w-render="lazy" w-reserve-block-size="50%"><p>x</p></template>"#,
835 codes::INVALID_RENDER_RESERVATION,
836 ),
837 (
838 r#"<div w-render="lazy" w-reserve-block-size="10px"></div>"#,
839 codes::INVALID_COMPONENT_RENDER_POLICY,
840 ),
841 ] {
842 let mut registry = ComponentRegistry::new();
843 let Err(ParserError::Template(diagnostic)) = registry
844 .register_component(ComponentRegistration::new("bad-card", html, None, true))
845 else {
846 panic!("invalid component policy must produce a template diagnostic");
847 };
848 assert_eq!(diagnostic.error_code(), Some(code));
849 assert!(diagnostic.help_text().is_some());
850 }
851 }
852
853 #[test]
854 fn test_register_component_strips_css_comments() {
855 let mut registry = ComponentRegistry::new();
856 registry
857 .register_component(ComponentRegistration::new(
858 "style-component",
859 "<p>Styled</p>",
860 Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
861 true,
862 ))
863 .expect("register failed");
864
865 let component = registry
866 .get("style-component")
867 .expect("Failed to retrieve registered component");
868 assert_eq!(
869 component.css_content.as_deref(),
870 Some(" p { color: var(--textColor); } ")
871 );
872 assert_eq!(component.css_fallback_chains.len(), 1);
873 }
874
875 #[test]
876 fn test_register_component_preserves_legal_css_comments_by_default() {
877 let mut registry = ComponentRegistry::new();
878 registry
879 .register_component(ComponentRegistration::new(
880 "legal-component",
881 "<p>Styled</p>",
882 Some("/*! @license MIT */ p { color: red; } /* remove */"),
883 true,
884 ))
885 .expect("register failed");
886
887 let component = registry
888 .get("legal-component")
889 .expect("Failed to retrieve registered component");
890 assert_eq!(
891 component.css_content.as_deref(),
892 Some("/*! @license MIT */ p { color: red; } ")
893 );
894 }
895
896 #[test]
897 fn test_register_component_strips_legal_css_comments_when_disabled() {
898 let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
899 registry
900 .register_component(ComponentRegistration::new(
901 "legal-component",
902 "<p>Styled</p>",
903 Some("/*! @license MIT */ p { color: red; }"),
904 true,
905 ))
906 .expect("register failed");
907
908 let component = registry
909 .get("legal-component")
910 .expect("Failed to retrieve registered component");
911 assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
912 }
913
914 #[test]
915 fn test_invalid_component_name_from_strings() {
916 let html_content = "<p>Invalid component</p>";
917
918 let mut registry = ComponentRegistry::new();
920 let result = registry.register_component(ComponentRegistration::new(
921 "invalid",
922 html_content,
923 None,
924 true,
925 ));
926
927 assert!(result.is_err(), "Expected error for invalid component name");
929
930 assert!(
932 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
933 "Wrong error type or message: {:?}",
934 result
935 );
936 }
937
938 #[test]
939 fn test_duplicate_component_from_strings() {
940 let html_content = "<p>First component</p>";
941 let html_content2 = "<p>Second component with same name</p>";
942
943 let mut registry = ComponentRegistry::new();
945 let result1 = registry.register_component(ComponentRegistration::new(
946 "dupe-component",
947 html_content,
948 None,
949 true,
950 ));
951 assert!(result1.is_ok());
952
953 let result2 = registry.register_component(ComponentRegistration::new(
955 "dupe-component",
956 html_content2,
957 None,
958 true,
959 ));
960 assert!(result2.is_err());
961
962 assert!(
964 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
965 "Expected 'already registered' error, got: {:?}",
966 result2
967 );
968
969 let component = registry
971 .get("dupe-component")
972 .expect("Failed to retrieve registered component");
973 assert_eq!(component.html_content, html_content);
974 }
975
976 #[test]
977 fn test_duplicate_component_from_paths() {
978 let html_content1 = "<p>Component from dir A</p>";
979 let html_content2 = "<p>Component from dir B</p>";
980
981 let mut fs = TestFileSystem::new();
983 let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
984 let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
985
986 let mut registry = ComponentRegistry::new();
988 let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
989 assert!(result1.is_ok());
990
991 let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
993 assert!(result2.is_err());
994
995 assert!(
997 matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
998 "Expected 'already registered' error, got: {:?}",
999 result2
1000 );
1001
1002 let component = registry
1004 .get("my-comp")
1005 .expect("Failed to retrieve registered component");
1006 assert_eq!(component.html_content, html_content1);
1007 }
1008
1009 fn mock_rename_transform(
1010 source: ComponentSource<'_>,
1011 ) -> Result<Option<TransformedComponentSource>> {
1012 if !source.html_content.contains("<mock-template") {
1013 return Ok(None);
1014 }
1015 Ok(Some(TransformedComponentSource {
1016 tag_name: "renamed-card".to_string(),
1017 parser_content: "<template><span>parser</span></template>".to_string(),
1018 artifact_content: Some("<template><span>artifact</span></template>".to_string()),
1019 }))
1020 }
1021
1022 fn mock_failing_transform(
1023 _source: ComponentSource<'_>,
1024 ) -> Result<Option<TransformedComponentSource>> {
1025 Err(ParserError::Component("mock transform failure".to_string()))
1026 }
1027
1028 #[test]
1029 fn default_registry_leaves_authored_source_unchanged() {
1030 let html = r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#;
1033 let mut registry = ComponentRegistry::new();
1034 registry
1035 .register_component(ComponentRegistration::new("file-card", html, None, true))
1036 .expect("register");
1037
1038 assert!(registry.contains("file-card"));
1039 assert!(!registry.contains("renamed-card"));
1040 assert_eq!(
1041 registry
1042 .get("file-card")
1043 .map(|component| component.html_content.as_str()),
1044 Some(html)
1045 );
1046 assert_eq!(registry.component_artifact_source("file-card"), None);
1047 }
1048
1049 #[test]
1050 fn transform_renames_and_provides_parser_and_artifact_views() {
1051 let mut registry = ComponentRegistry::new();
1052 registry.set_component_source_transform(Some(mock_rename_transform));
1053 registry
1054 .register_component(ComponentRegistration::new(
1055 "file-card",
1056 r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
1057 Some(".root { color: red; }"),
1058 true,
1059 ))
1060 .expect("register");
1061
1062 assert!(!registry.contains("file-card"));
1063 let component = registry.get("renamed-card").expect("component");
1064 assert_eq!(component.tag_name, "renamed-card");
1065 assert_eq!(
1066 component.html_content,
1067 "<template><span>parser</span></template>"
1068 );
1069 assert_eq!(
1070 registry.component_artifact_source("renamed-card"),
1071 Some("<template><span>artifact</span></template>")
1072 );
1073 }
1074
1075 #[test]
1076 fn transform_returning_unchanged_preserves_source() {
1077 let mut registry = ComponentRegistry::new();
1078 registry.set_component_source_transform(Some(mock_rename_transform));
1079 let html = r#"<template><span>{{title}}</span></template>"#;
1080 registry
1081 .register_component(ComponentRegistration::new("plain-card", html, None, true))
1082 .expect("register");
1083
1084 assert!(registry.contains("plain-card"));
1085 assert_eq!(
1086 registry
1087 .get("plain-card")
1088 .map(|component| component.html_content.as_str()),
1089 Some(html)
1090 );
1091 assert_eq!(registry.component_artifact_source("plain-card"), None);
1092 assert_eq!(registry.component_authored_source("plain-card"), None);
1093 }
1094
1095 #[test]
1096 fn transform_error_is_transactional() {
1097 let mut registry = ComponentRegistry::new();
1098 registry.set_component_source_transform(Some(mock_failing_transform));
1099 let err = registry
1100 .register_component(ComponentRegistration::new(
1101 "file-card",
1102 "<template>x</template>",
1103 None,
1104 true,
1105 ))
1106 .expect_err("failing transform should abort registration");
1107
1108 assert!(matches!(err, ParserError::Component(ref msg) if msg.contains("mock transform")));
1109 assert!(!registry.contains("file-card"));
1110 assert!(registry.is_empty());
1111 }
1112
1113 #[test]
1114 fn transform_duplicate_resolved_name_is_rejected() {
1115 let mut registry = ComponentRegistry::new();
1116 registry.set_component_source_transform(Some(mock_rename_transform));
1117 registry
1118 .register_component(ComponentRegistration::new(
1119 "first-card",
1120 r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
1121 None,
1122 true,
1123 ))
1124 .expect("first register");
1125 let err = registry
1126 .register_component(ComponentRegistration::new(
1127 "second-card",
1128 r#"<mock-template name="renamed-card"><template>y</template></mock-template>"#,
1129 None,
1130 true,
1131 ))
1132 .expect_err("duplicate resolved name should error");
1133
1134 assert!(
1135 matches!(err, ParserError::Component(ref msg) if msg.contains("already registered"))
1136 );
1137 }
1138
1139 #[test]
1140 fn test_exclude_dot_in_component_name() {
1141 let mut registry = ComponentRegistry::new();
1142 let result = registry.register_component(ComponentRegistration::new(
1143 "custom.button",
1144 "<p>Dot name</p>",
1145 None,
1146 true,
1147 ));
1148
1149 assert!(
1150 result.is_err(),
1151 "Component name with dot but no hyphen should be rejected"
1152 );
1153 assert!(
1154 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1155 "Wrong error type or message: {:?}",
1156 result
1157 );
1158 assert_eq!(registry.len(), 0);
1159 }
1160
1161 #[test]
1162 fn test_exclude_no_hyphen_html() {
1163 let mut registry = ComponentRegistry::new();
1164 let result = registry.register_component(ComponentRegistration::new(
1165 "foobar",
1166 "<p>No hyphen</p>",
1167 None,
1168 true,
1169 ));
1170
1171 assert!(
1172 result.is_err(),
1173 "Component name without hyphen should be rejected"
1174 );
1175 assert!(
1176 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1177 "Wrong error type or message: {:?}",
1178 result
1179 );
1180 assert_eq!(registry.len(), 0);
1181 }
1182
1183 #[test]
1184 fn test_valid_component_with_hyphen() {
1185 let mut registry = ComponentRegistry::new();
1186 let result = registry.register_component(ComponentRegistration::new(
1187 "custom-button",
1188 "<button>Click me</button>",
1189 None,
1190 true,
1191 ));
1192
1193 assert!(
1194 result.is_ok(),
1195 "Component name with hyphen should be accepted"
1196 );
1197 assert!(registry.contains("custom-button"));
1198 assert_eq!(registry.len(), 1);
1199 }
1200
1201 #[test]
1202 fn test_valid_component_css_only() {
1203 let mut registry = ComponentRegistry::new();
1204 let result = registry.register_component(ComponentRegistration::new(
1205 "styled-widget",
1206 "",
1207 Some(".widget { color: blue; }"),
1208 true,
1209 ));
1210
1211 assert!(
1212 result.is_ok(),
1213 "Component with empty HTML and CSS should be accepted"
1214 );
1215 assert!(registry.contains("styled-widget"));
1216
1217 let component = registry
1218 .get("styled-widget")
1219 .expect("Failed to retrieve registered component");
1220 assert_eq!(component.html_content, "");
1221 assert_eq!(
1222 component.css_content.as_deref(),
1223 Some(".widget { color: blue; }")
1224 );
1225 }
1226
1227 #[test]
1228 fn test_component_name_requires_hyphen() {
1229 let mut registry = ComponentRegistry::new();
1230 let result = registry.register_component(ComponentRegistration::new(
1231 "single",
1232 "<p>Single word</p>",
1233 None,
1234 true,
1235 ));
1236
1237 assert!(
1238 result.is_err(),
1239 "Single-word component name should be rejected"
1240 );
1241 assert!(
1242 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1243 "Wrong error type or message: {:?}",
1244 result
1245 );
1246 assert_eq!(registry.len(), 0);
1247 }
1248
1249 #[test]
1250 fn test_multiple_hyphens_valid() {
1251 let mut registry = ComponentRegistry::new();
1252 let result = registry.register_component(ComponentRegistration::new(
1253 "my-custom-element",
1254 "<div>Custom element</div>",
1255 None,
1256 true,
1257 ));
1258
1259 assert!(
1260 result.is_ok(),
1261 "Component name with multiple hyphens should be accepted"
1262 );
1263 assert!(registry.contains("my-custom-element"));
1264 assert_eq!(registry.len(), 1);
1265 }
1266
1267 #[test]
1268 fn test_empty_component_name_rejected() {
1269 let mut registry = ComponentRegistry::new();
1270 let result = registry.register_component(ComponentRegistration::new(
1271 "",
1272 "<p>Empty name</p>",
1273 None,
1274 true,
1275 ));
1276
1277 assert!(result.is_err(), "Empty component name should be rejected");
1278 assert!(
1279 matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
1280 "Wrong error type or message: {:?}",
1281 result
1282 );
1283 assert_eq!(registry.len(), 0);
1284 }
1285
1286 #[test]
1287 fn test_register_component_extracts_css_fallback_requirements() {
1288 let mut registry = ComponentRegistry::new();
1289 registry
1290 .register_component(ComponentRegistration::new(
1291 "my-btn",
1292 "<button>Click</button>",
1293 Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
1294 true,
1295 ))
1296 .expect("register failed");
1297
1298 let component = registry.get("my-btn").expect("component not found");
1299 assert_eq!(component.css_fallback_chains.len(), 2);
1300 }
1301
1302 #[test]
1303 fn test_register_component_no_css_no_requirements() {
1304 let mut registry = ComponentRegistry::new();
1305 registry
1306 .register_component(ComponentRegistration::new(
1307 "my-card",
1308 "<div>Card</div>",
1309 None,
1310 true,
1311 ))
1312 .expect("register failed");
1313
1314 let component = registry.get("my-card").expect("component not found");
1315 assert!(component.css_fallback_chains.is_empty());
1316 }
1317
1318 #[test]
1319 fn test_register_component_tracks_css_fallback_requirements() {
1320 let mut registry = ComponentRegistry::new();
1321 registry
1322 .register_component(ComponentRegistration::new(
1323 "my-widget",
1324 "<div>W</div>",
1325 Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
1326 true,
1327 ))
1328 .expect("register failed");
1329
1330 let component = registry.get("my-widget").expect("component not found");
1331 assert_eq!(component.css_fallback_chains.len(), 2);
1332 }
1333}