use crate::component_policy::{
parse_component_render_policy, validate_policy_client_ownership, ComponentRenderPolicy,
};
use crate::plugin::{ComponentSource, ComponentSourceTransform, TransformedComponentSource};
use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
use std::collections::{HashMap, HashSet};
#[cfg(feature = "fs")]
use std::fs;
#[cfg(feature = "fs")]
use std::path::Path;
use std::sync::Arc;
#[cfg(feature = "fs")]
use walkdir::WalkDir;
type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
#[derive(Debug, Clone)]
pub struct Component {
pub tag_name: String,
pub html_content: String,
pub css_content: Option<String>,
pub css_definitions: Vec<String>,
pub css_fallback_chains: Vec<CssFallbackChain>,
pub is_client_owned: bool,
}
#[derive(Debug, Clone)]
pub struct ComponentRegistration<'a> {
pub tag_name: &'a str,
pub html_content: &'a str,
pub css_content: Option<&'a str>,
pub is_client_owned: bool,
}
impl<'a> ComponentRegistration<'a> {
#[must_use]
pub fn new(
tag_name: &'a str,
html_content: &'a str,
css_content: Option<&'a str>,
is_client_owned: bool,
) -> Self {
Self {
tag_name,
html_content,
css_content,
is_client_owned,
}
}
}
#[derive(Debug)]
pub struct ComponentRegistry {
components: HashMap<String, Component>,
render_policies: HashMap<String, ComponentRenderPolicy>,
policy_css: HashSet<String>,
component_artifact_sources: HashMap<String, String>,
component_authored_sources: HashMap<String, Arc<str>>,
source_transform: Option<ComponentSourceTransform>,
css_parser: CssParser,
legal_comments: LegalComments,
}
#[cfg(feature = "fs")]
fn has_component_script(html_path: &Path) -> Result<bool> {
for ext in ["ts", "js"] {
let candidate = html_path.with_extension(ext);
if candidate.try_exists().map_err(|source| ParserError::IO {
context: format!(
"Failed to inspect component script: {}",
candidate.display()
),
source,
})? {
return Ok(true);
}
}
Ok(false)
}
impl Default for ComponentRegistry {
fn default() -> Self {
Self::new()
}
}
impl ComponentRegistry {
pub fn new() -> Self {
Self::with_legal_comments(LegalComments::default())
}
pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
Self {
components: HashMap::new(),
render_policies: HashMap::new(),
policy_css: HashSet::new(),
component_artifact_sources: HashMap::new(),
component_authored_sources: HashMap::new(),
source_transform: None,
css_parser: CssParser::new(),
legal_comments,
}
}
pub(crate) fn set_component_source_transform(
&mut self,
transform: Option<ComponentSourceTransform>,
) {
self.source_transform = transform;
}
fn resolve_component_source(
&self,
tag_name: &str,
html_content: &str,
) -> Result<Option<TransformedComponentSource>> {
if let Some(transform) = self.source_transform {
return transform(ComponentSource {
tag_name,
html_content,
});
}
Ok(None)
}
#[cfg(feature = "fs")]
pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
for dir in directories {
for entry in WalkDir::new(dir.as_ref())
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if !path.extension().is_some_and(|ext| ext == "html") {
continue;
}
let Some(filename) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if !filename.contains('-') && self.source_transform.is_none() {
continue;
}
let css_path = path.with_extension("css");
let css_path = css_path.exists().then_some(css_path);
self.register_component_from_paths_inner(path, css_path.as_deref(), true)?;
}
}
Ok(self)
}
#[cfg(feature = "fs")]
pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
&mut self,
html_path: P,
css_path: Option<Q>,
) -> Result<()> {
let css_path = css_path.as_ref().map(AsRef::as_ref);
self.register_component_from_paths_inner(html_path.as_ref(), css_path, false)
}
#[cfg(feature = "fs")]
fn register_component_from_paths_inner(
&mut self,
html_path: &Path,
css_path: Option<&Path>,
allow_skip_unresolved: bool,
) -> Result<()> {
let tag_name = html_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
context: format!("Failed to read HTML file: {}", html_path.display()),
source,
})?;
let (resolved, authored_source) =
match self.resolve_component_source(tag_name, &html_content)? {
Some(transformed) => (transformed, Some(Arc::<str>::from(html_content))),
None => {
if allow_skip_unresolved && !tag_name.contains('-') {
return Ok(());
}
(
TransformedComponentSource {
tag_name: tag_name.to_string(),
parser_content: html_content,
artifact_content: None,
},
None,
)
}
};
Self::validate_component_name(&resolved.tag_name)?;
if self.components.contains_key(&resolved.tag_name) {
return Err(ParserError::Component(format!(
"Component '{}' is already registered",
resolved.tag_name
)));
}
let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
if css_path.exists() {
let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
context: format!("Failed to read CSS file: {}", css_path.display()),
source,
})?;
let (content, definitions, requirements) = self.process_css_content(&content)?;
(Some(content), definitions, requirements)
} else {
(None, Vec::new(), Vec::new())
}
} else {
(None, Vec::new(), Vec::new())
};
let is_client_owned = has_component_script(html_path)?;
let render_policy =
parse_component_render_policy(&resolved.tag_name, &resolved.parser_content)?;
validate_policy_client_ownership(
&resolved.tag_name,
&resolved.parser_content,
&render_policy,
is_client_owned,
)?;
let component = Component {
tag_name: resolved.tag_name,
html_content: resolved.parser_content,
css_content,
css_definitions,
css_fallback_chains,
is_client_owned,
};
self.render_policies
.insert(component.tag_name.clone(), render_policy);
if let Some(artifact) = resolved.artifact_content {
self.component_artifact_sources
.insert(component.tag_name.clone(), artifact);
}
if let Some(authored_source) = authored_source {
self.component_authored_sources
.insert(component.tag_name.clone(), authored_source);
}
self.components
.insert(component.tag_name.clone(), component);
Ok(())
}
pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
let ComponentRegistration {
tag_name,
html_content,
css_content,
is_client_owned,
} = registration;
let (resolved, authored_source) =
match self.resolve_component_source(tag_name, html_content)? {
Some(transformed) => (transformed, Some(Arc::<str>::from(html_content))),
None => (
TransformedComponentSource {
tag_name: tag_name.to_string(),
parser_content: html_content.to_string(),
artifact_content: None,
},
None,
),
};
Self::validate_component_name(&resolved.tag_name)?;
if self.components.contains_key(&resolved.tag_name) {
return Err(ParserError::Component(format!(
"Component '{}' is already registered",
resolved.tag_name
)));
}
let (css_content, css_definitions, css_fallback_chains) = match css_content {
Some(css) => {
let (content, definitions, requirements) = self.process_css_content(css)?;
(Some(content), definitions, requirements)
}
None => (None, Vec::new(), Vec::new()),
};
let render_policy =
parse_component_render_policy(&resolved.tag_name, &resolved.parser_content)?;
validate_policy_client_ownership(
&resolved.tag_name,
&resolved.parser_content,
&render_policy,
is_client_owned,
)?;
let component: Component = Component {
tag_name: resolved.tag_name,
html_content: resolved.parser_content,
css_content,
css_definitions,
css_fallback_chains,
is_client_owned,
};
self.render_policies
.insert(component.tag_name.clone(), render_policy);
if let Some(artifact) = resolved.artifact_content {
self.component_artifact_sources
.insert(component.tag_name.clone(), artifact);
}
if let Some(authored_source) = authored_source {
self.component_authored_sources
.insert(component.tag_name.clone(), authored_source);
}
self.components
.insert(component.tag_name.clone(), component);
Ok(())
}
fn validate_component_name(tag_name: &str) -> Result<()> {
if tag_name.contains('-') {
return Ok(());
}
Err(ParserError::Component(format!(
"Component name '{}' must contain a hyphen",
tag_name
)))
}
fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
let (_tokens, definitions, requirements, stripped) = self
.css_parser
.extract_tokens_definitions_requirements_and_strip_comments(
css_content,
self.legal_comments,
)?;
let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
sorted_definitions.sort();
Ok((stripped.into_owned(), sorted_definitions, requirements))
}
pub(crate) fn prepare_policy_css(
&mut self,
tag_name: &str,
uses_shadow_dom: bool,
) -> Result<()> {
let Some(policy) = self.render_policies.get(tag_name) else {
return Err(ParserError::NotFound(format!(
"component <{tag_name}> disappeared before render policy preparation"
)));
};
if policy.reserve_block_size().is_none() || self.policy_css.contains(tag_name) {
return Ok(());
}
let component_tag = {
let component = self.components.get_mut(tag_name).ok_or_else(|| {
ParserError::NotFound(format!(
"component <{tag_name}> disappeared before render policy preparation"
))
})?;
let css = component
.css_content
.get_or_insert_with(|| String::with_capacity(112));
css.reserve(144);
let mut escaped_tag = String::with_capacity(32);
crate::css_light::push_css_identifier(&mut escaped_tag, component.tag_name.as_str());
if uses_shadow_dom {
policy.append_shadow_css(css, &escaped_tag);
} else {
policy.append_light_css(css, &escaped_tag);
}
component.tag_name.clone()
};
self.policy_css.insert(component_tag);
Ok(())
}
pub fn contains(&self, tag_name: &str) -> bool {
self.components.contains_key(tag_name)
}
pub fn get(&self, tag_name: &str) -> Option<&Component> {
self.components.get(tag_name)
}
pub(crate) fn render_policy(&self, tag_name: &str) -> Option<&ComponentRenderPolicy> {
self.render_policies.get(tag_name)
}
pub(crate) fn work_policies(&self) -> impl Iterator<Item = (&str, u8)> {
self.render_policies
.iter()
.filter_map(|(tag, policy)| policy.metadata_code().map(|code| (tag.as_str(), code)))
}
pub(crate) fn diagnostic_css_content(&self, tag_name: &str) -> Option<&str> {
self.components
.get(tag_name)
.and_then(|component| component.css_content.as_deref())
}
pub(crate) fn component_artifact_source(&self, tag_name: &str) -> Option<&str> {
self.component_artifact_sources
.get(tag_name)
.map(String::as_str)
}
pub(crate) fn component_authored_source(&self, tag_name: &str) -> Option<Arc<str>> {
self.component_authored_sources.get(tag_name).cloned()
}
pub fn get_all(&self) -> impl Iterator<Item = &Component> {
self.components.values()
}
#[must_use]
pub fn render_policy_css<'a>(&self, tag_names: impl IntoIterator<Item = &'a str>) -> String {
let mut policies: Vec<(&str, &ComponentRenderPolicy)> = tag_names
.into_iter()
.filter_map(|tag_name| {
self.render_policies
.get(tag_name)
.map(|policy| (tag_name, policy))
})
.filter(|(_, policy)| policy.reserve_block_size().is_some())
.collect();
policies.sort_unstable_by(|left, right| left.0.cmp(right.0));
policies.dedup_by(|left, right| left.0 == right.0);
let mut css = String::with_capacity(policies.len() * 112);
for (tag_name, policy) in policies {
crate::css_light::push_css_identifier(&mut css, tag_name);
css.push_str(r#":not([w-render="eager"]){"#);
policy.append_declarations(&mut css);
css.push('}');
}
css
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.components.keys().map(String::as_str)
}
pub fn len(&self) -> usize {
self.components.len()
}
pub fn is_empty(&self) -> bool {
self.components.is_empty()
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use crate::codes;
use crate::plugin::TransformedComponentSource;
use webui_test_utils::TestFileSystem;
#[test]
fn test_register_component() {
let html_content = "<p>Hello World</p>";
let css_content = "p { color: red; }";
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/test-component.html", html_content);
let css_path = fs.add_file("components/test-component.css", css_content);
let mut registry = ComponentRegistry::new();
let result = registry.register_component_from_paths(&html_path, Some(&css_path));
assert!(result.is_ok());
assert!(registry.contains("test-component"));
let component = registry
.get("test-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
assert_eq!(component.css_content.as_deref(), Some(css_content));
assert!(!component.is_client_owned);
}
#[test]
#[cfg(feature = "fs")]
fn transform_applies_to_path_registration() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file(
"components/file-card.html",
r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
);
let mut registry = ComponentRegistry::new();
registry.set_component_source_transform(Some(mock_rename_transform));
registry
.register_component_from_paths(&html_path, None::<&str>)
.expect("register");
assert!(!registry.contains("file-card"));
let component = registry.get("renamed-card").expect("component");
assert_eq!(
component.html_content,
"<template><span>parser</span></template>"
);
assert_eq!(
registry.component_artifact_source("renamed-card"),
Some("<template><span>artifact</span></template>")
);
assert_eq!(
registry
.component_authored_source("renamed-card")
.as_deref(),
Some(r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#)
);
}
#[test]
fn test_register_component_detects_ts_sibling_script() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
std::fs::write(html_path.with_extension("ts"), "export {};")
.expect("Failed to write sibling script");
let mut registry = ComponentRegistry::new();
registry
.register_component_from_paths(&html_path, None::<&str>)
.expect("register failed");
let component = registry
.get("scripted-card")
.expect("Failed to retrieve registered component");
assert!(component.is_client_owned);
}
#[test]
fn test_register_component_detects_js_sibling_script() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
std::fs::write(html_path.with_extension("js"), "export {};")
.expect("Failed to write sibling script");
let mut registry = ComponentRegistry::new();
registry
.register_component_from_paths(&html_path, None::<&str>)
.expect("register failed");
let component = registry
.get("scripted-card")
.expect("Failed to retrieve registered component");
assert!(component.is_client_owned);
}
#[test]
fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
.expect("Failed to write invalid sibling script");
let mut registry = ComponentRegistry::new();
registry
.register_component_from_paths(&html_path, None::<&str>)
.expect("registration should inspect presence without decoding source");
let component = registry
.get("scripted-card")
.expect("Failed to retrieve registered component");
assert!(component.is_client_owned);
}
#[test]
fn test_register_component_ignores_tsx_sibling_script() {
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
std::fs::write(html_path.with_extension("tsx"), "export {};")
.expect("Failed to write sibling script");
let mut registry = ComponentRegistry::new();
registry
.register_component_from_paths(&html_path, None::<&str>)
.expect("register failed");
let component = registry
.get("scripted-card")
.expect("Failed to retrieve registered component");
assert!(!component.is_client_owned);
}
#[test]
fn test_component_name_validation() {
let html_content = "<p>Invalid</p>";
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("invalid_name.html", html_content);
let mut registry = ComponentRegistry::new();
let result = registry.register_component_from_paths(&html_path, None::<&str>);
assert!(result.is_err());
assert_eq!(registry.len(), 0);
}
#[test]
fn test_missing_css_file() {
let html_content = "<p>CSS Optional</p>";
let mut fs = TestFileSystem::new();
let html_path = fs.add_file("test-component.html", html_content);
let mut registry = ComponentRegistry::new();
let result = registry.register_component_from_paths(&html_path, None::<&str>);
assert!(result.is_ok());
let component = registry
.get("test-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
assert_eq!(component.css_content, None);
}
#[test]
fn test_register_component_from_strings() {
let html_content = "<p>Hello from string!</p>";
let css_content = "p { color: green; }";
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"string-component",
html_content,
Some(css_content),
true,
));
assert!(result.is_ok());
assert!(registry.contains("string-component"));
let component = registry
.get("string-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
assert_eq!(component.css_content.as_deref(), Some(css_content));
assert!(component.is_client_owned);
}
#[test]
fn lazy_render_policy_generates_stable_document_css() {
let mut registry = ComponentRegistry::new();
for (tag, size) in [("z-card", "18rem"), ("a-row", "72px")] {
let html = format!(
r#"<template w-render="lazy" w-reserve-block-size="{size}"><p>x</p></template>"#
);
registry
.register_component(ComponentRegistration::new(tag, &html, None, true))
.expect("valid lazy render policy");
}
assert_eq!(
registry.render_policy_css(["z-card", "a-row"]),
concat!(
r#"a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
r#"z-card:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 18rem;}"#,
)
);
assert_eq!(
registry
.get("a-row")
.and_then(|component| component.css_content.as_deref()),
None
);
registry
.prepare_policy_css("a-row", true)
.expect("prepare Shadow policy CSS");
assert_eq!(
registry
.get("a-row")
.and_then(|component| component.css_content.as_deref()),
Some(
r#":host(a-row:not([w-render="eager"])),a-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
)
);
}
#[test]
fn lazy_render_policy_uses_a_normal_selector_for_light_css() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"light-row",
r#"<template w-render="lazy" w-reserve-block-size="72px"><p>x</p></template>"#,
None,
false,
))
.expect("valid lazy render policy");
registry
.prepare_policy_css("light-row", false)
.expect("prepare Light policy CSS");
assert_eq!(
registry
.get("light-row")
.and_then(|component| component.css_content.as_deref()),
Some(
r#"light-row:not([w-render="eager"]){content-visibility:auto;contain-intrinsic-block-size:auto 72px;}"#,
)
);
}
#[test]
fn lazy_hydration_policy_emits_no_rendering_css() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"lazy-row",
r#"<template w-hydrate="lazy"><p>x</p></template>"#,
None,
true,
))
.expect("valid hydration policy");
assert!(registry.render_policy_css(["lazy-row"]).is_empty());
}
#[test]
fn lazy_render_policy_requires_a_valid_reservation() {
for (html, code) in [
(
r#"<template w-render="lazy"><p>x</p></template>"#,
codes::MISSING_RENDER_RESERVATION,
),
(
r#"<template w-render="lazy" w-reserve-block-size="50%"><p>x</p></template>"#,
codes::INVALID_RENDER_RESERVATION,
),
(
r#"<div w-render="lazy" w-reserve-block-size="10px"></div>"#,
codes::INVALID_COMPONENT_RENDER_POLICY,
),
] {
let mut registry = ComponentRegistry::new();
let Err(ParserError::Template(diagnostic)) = registry
.register_component(ComponentRegistration::new("bad-card", html, None, true))
else {
panic!("invalid component policy must produce a template diagnostic");
};
assert_eq!(diagnostic.error_code(), Some(code));
assert!(diagnostic.help_text().is_some());
}
}
#[test]
fn test_register_component_strips_css_comments() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"style-component",
"<p>Styled</p>",
Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
true,
))
.expect("register failed");
let component = registry
.get("style-component")
.expect("Failed to retrieve registered component");
assert_eq!(
component.css_content.as_deref(),
Some(" p { color: var(--textColor); } ")
);
assert_eq!(component.css_fallback_chains.len(), 1);
}
#[test]
fn test_register_component_preserves_legal_css_comments_by_default() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"legal-component",
"<p>Styled</p>",
Some("/*! @license MIT */ p { color: red; } /* remove */"),
true,
))
.expect("register failed");
let component = registry
.get("legal-component")
.expect("Failed to retrieve registered component");
assert_eq!(
component.css_content.as_deref(),
Some("/*! @license MIT */ p { color: red; } ")
);
}
#[test]
fn test_register_component_strips_legal_css_comments_when_disabled() {
let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
registry
.register_component(ComponentRegistration::new(
"legal-component",
"<p>Styled</p>",
Some("/*! @license MIT */ p { color: red; }"),
true,
))
.expect("register failed");
let component = registry
.get("legal-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
}
#[test]
fn test_invalid_component_name_from_strings() {
let html_content = "<p>Invalid component</p>";
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"invalid",
html_content,
None,
true,
));
assert!(result.is_err(), "Expected error for invalid component name");
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
}
#[test]
fn test_duplicate_component_from_strings() {
let html_content = "<p>First component</p>";
let html_content2 = "<p>Second component with same name</p>";
let mut registry = ComponentRegistry::new();
let result1 = registry.register_component(ComponentRegistration::new(
"dupe-component",
html_content,
None,
true,
));
assert!(result1.is_ok());
let result2 = registry.register_component(ComponentRegistration::new(
"dupe-component",
html_content2,
None,
true,
));
assert!(result2.is_err());
assert!(
matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
"Expected 'already registered' error, got: {:?}",
result2
);
let component = registry
.get("dupe-component")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content);
}
#[test]
fn test_duplicate_component_from_paths() {
let html_content1 = "<p>Component from dir A</p>";
let html_content2 = "<p>Component from dir B</p>";
let mut fs = TestFileSystem::new();
let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
let mut registry = ComponentRegistry::new();
let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
assert!(result1.is_ok());
let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
assert!(result2.is_err());
assert!(
matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
"Expected 'already registered' error, got: {:?}",
result2
);
let component = registry
.get("my-comp")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, html_content1);
}
fn mock_rename_transform(
source: ComponentSource<'_>,
) -> Result<Option<TransformedComponentSource>> {
if !source.html_content.contains("<mock-template") {
return Ok(None);
}
Ok(Some(TransformedComponentSource {
tag_name: "renamed-card".to_string(),
parser_content: "<template><span>parser</span></template>".to_string(),
artifact_content: Some("<template><span>artifact</span></template>".to_string()),
}))
}
fn mock_failing_transform(
_source: ComponentSource<'_>,
) -> Result<Option<TransformedComponentSource>> {
Err(ParserError::Component("mock transform failure".to_string()))
}
#[test]
fn default_registry_leaves_authored_source_unchanged() {
let html = r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#;
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new("file-card", html, None, true))
.expect("register");
assert!(registry.contains("file-card"));
assert!(!registry.contains("renamed-card"));
assert_eq!(
registry
.get("file-card")
.map(|component| component.html_content.as_str()),
Some(html)
);
assert_eq!(registry.component_artifact_source("file-card"), None);
}
#[test]
fn transform_renames_and_provides_parser_and_artifact_views() {
let mut registry = ComponentRegistry::new();
registry.set_component_source_transform(Some(mock_rename_transform));
registry
.register_component(ComponentRegistration::new(
"file-card",
r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
Some(".root { color: red; }"),
true,
))
.expect("register");
assert!(!registry.contains("file-card"));
let component = registry.get("renamed-card").expect("component");
assert_eq!(component.tag_name, "renamed-card");
assert_eq!(
component.html_content,
"<template><span>parser</span></template>"
);
assert_eq!(
registry.component_artifact_source("renamed-card"),
Some("<template><span>artifact</span></template>")
);
}
#[test]
fn transform_returning_unchanged_preserves_source() {
let mut registry = ComponentRegistry::new();
registry.set_component_source_transform(Some(mock_rename_transform));
let html = r#"<template><span>{{title}}</span></template>"#;
registry
.register_component(ComponentRegistration::new("plain-card", html, None, true))
.expect("register");
assert!(registry.contains("plain-card"));
assert_eq!(
registry
.get("plain-card")
.map(|component| component.html_content.as_str()),
Some(html)
);
assert_eq!(registry.component_artifact_source("plain-card"), None);
assert_eq!(registry.component_authored_source("plain-card"), None);
}
#[test]
fn transform_error_is_transactional() {
let mut registry = ComponentRegistry::new();
registry.set_component_source_transform(Some(mock_failing_transform));
let err = registry
.register_component(ComponentRegistration::new(
"file-card",
"<template>x</template>",
None,
true,
))
.expect_err("failing transform should abort registration");
assert!(matches!(err, ParserError::Component(ref msg) if msg.contains("mock transform")));
assert!(!registry.contains("file-card"));
assert!(registry.is_empty());
}
#[test]
fn transform_duplicate_resolved_name_is_rejected() {
let mut registry = ComponentRegistry::new();
registry.set_component_source_transform(Some(mock_rename_transform));
registry
.register_component(ComponentRegistration::new(
"first-card",
r#"<mock-template name="renamed-card"><template>x</template></mock-template>"#,
None,
true,
))
.expect("first register");
let err = registry
.register_component(ComponentRegistration::new(
"second-card",
r#"<mock-template name="renamed-card"><template>y</template></mock-template>"#,
None,
true,
))
.expect_err("duplicate resolved name should error");
assert!(
matches!(err, ParserError::Component(ref msg) if msg.contains("already registered"))
);
}
#[test]
fn test_exclude_dot_in_component_name() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"custom.button",
"<p>Dot name</p>",
None,
true,
));
assert!(
result.is_err(),
"Component name with dot but no hyphen should be rejected"
);
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_exclude_no_hyphen_html() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"foobar",
"<p>No hyphen</p>",
None,
true,
));
assert!(
result.is_err(),
"Component name without hyphen should be rejected"
);
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_valid_component_with_hyphen() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"custom-button",
"<button>Click me</button>",
None,
true,
));
assert!(
result.is_ok(),
"Component name with hyphen should be accepted"
);
assert!(registry.contains("custom-button"));
assert_eq!(registry.len(), 1);
}
#[test]
fn test_valid_component_css_only() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"styled-widget",
"",
Some(".widget { color: blue; }"),
true,
));
assert!(
result.is_ok(),
"Component with empty HTML and CSS should be accepted"
);
assert!(registry.contains("styled-widget"));
let component = registry
.get("styled-widget")
.expect("Failed to retrieve registered component");
assert_eq!(component.html_content, "");
assert_eq!(
component.css_content.as_deref(),
Some(".widget { color: blue; }")
);
}
#[test]
fn test_component_name_requires_hyphen() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"single",
"<p>Single word</p>",
None,
true,
));
assert!(
result.is_err(),
"Single-word component name should be rejected"
);
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_multiple_hyphens_valid() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"my-custom-element",
"<div>Custom element</div>",
None,
true,
));
assert!(
result.is_ok(),
"Component name with multiple hyphens should be accepted"
);
assert!(registry.contains("my-custom-element"));
assert_eq!(registry.len(), 1);
}
#[test]
fn test_empty_component_name_rejected() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"",
"<p>Empty name</p>",
None,
true,
));
assert!(result.is_err(), "Empty component name should be rejected");
assert!(
matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
"Wrong error type or message: {:?}",
result
);
assert_eq!(registry.len(), 0);
}
#[test]
fn test_register_component_extracts_css_fallback_requirements() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"my-btn",
"<button>Click</button>",
Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
true,
))
.expect("register failed");
let component = registry.get("my-btn").expect("component not found");
assert_eq!(component.css_fallback_chains.len(), 2);
}
#[test]
fn test_register_component_no_css_no_requirements() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"my-card",
"<div>Card</div>",
None,
true,
))
.expect("register failed");
let component = registry.get("my-card").expect("component not found");
assert!(component.css_fallback_chains.is_empty());
}
#[test]
fn test_register_component_tracks_css_fallback_requirements() {
let mut registry = ComponentRegistry::new();
registry
.register_component(ComponentRegistration::new(
"my-widget",
"<div>W</div>",
Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
true,
))
.expect("register failed");
let component = registry.get("my-widget").expect("component not found");
assert_eq!(component.css_fallback_chains.len(), 2);
}
}