use crate::component_policy::{parse_component_render_policy, ComponentRenderPolicy};
use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
use std::collections::HashMap;
#[cfg(feature = "fs")]
use std::fs;
#[cfg(feature = "fs")]
use std::path::Path;
#[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>,
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(),
css_parser: CssParser::new(),
legal_comments,
}
}
#[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") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if filename.contains('-') {
let css_path = path.with_extension("css");
self.register_component_from_paths(
path,
if css_path.exists() {
Some(&css_path)
} else {
None
},
)?;
}
}
}
}
}
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 html_path = html_path.as_ref();
let tag_name = html_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
if !tag_name.contains('-') {
return Err(ParserError::Component(format!(
"Component name '{}' must contain a hyphen",
tag_name
)));
}
if self.components.contains_key(tag_name) {
return Err(ParserError::Component(format!(
"Component '{}' is already registered",
tag_name
)));
}
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 (mut css_content, css_definitions, css_fallback_chains) = if let Some(css_path) =
css_path
{
let css_path = css_path.as_ref();
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(tag_name, &html_content)?;
Self::append_scoped_policy_css(&mut css_content, &render_policy, tag_name);
let component = Component {
tag_name: tag_name.to_string(),
html_content,
css_content,
css_definitions,
css_fallback_chains,
is_client_owned,
};
self.render_policies
.insert(tag_name.to_string(), render_policy);
self.components.insert(tag_name.to_string(), component);
Ok(())
}
pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
let ComponentRegistration {
tag_name,
html_content,
css_content,
is_client_owned,
} = registration;
if !tag_name.contains('-') {
return Err(ParserError::Component(format!(
"Component name '{}' must contain a hyphen",
tag_name
)));
}
if self.components.contains_key(tag_name) {
return Err(ParserError::Component(format!(
"Component '{}' is already registered",
tag_name
)));
}
let (mut 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(tag_name, html_content)?;
Self::append_scoped_policy_css(&mut css_content, &render_policy, tag_name);
let component: Component = Component {
tag_name: tag_name.to_string(),
html_content: html_content.to_string(),
css_content,
css_definitions,
css_fallback_chains,
is_client_owned,
};
self.render_policies
.insert(tag_name.to_string(), render_policy);
self.components.insert(tag_name.to_string(), component);
Ok(())
}
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))
}
fn append_scoped_policy_css(
css_content: &mut Option<String>,
policy: &ComponentRenderPolicy,
raw_tag_name: &str,
) {
if policy.reserve_block_size().is_none() {
return;
}
let css = css_content.get_or_insert_with(|| String::with_capacity(112));
css.reserve(144);
let mut tag_name = String::with_capacity(32);
Self::push_css_identifier(&mut tag_name, raw_tag_name);
policy.append_scoped_css(css, &tag_name);
}
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 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 {
Self::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)
}
fn push_css_identifier(output: &mut String, value: &str) {
for (index, ch) in value.chars().enumerate() {
if ch.is_ascii_alphabetic()
|| ch == '-'
|| ch == '_'
|| (index > 0 && ch.is_ascii_digit())
{
output.push(ch);
} else {
Self::push_css_escape(output, ch as u32);
}
}
}
fn push_css_escape(output: &mut String, mut value: u32) {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut digits = [0u8; 8];
let mut index = digits.len();
loop {
index -= 1;
digits[index] = HEX[(value & 0x0f) as usize];
value >>= 4;
if value == 0 {
break;
}
}
output.push('\\');
for digit in &digits[index..] {
output.push(char::from(*digit));
}
output.push(' ');
}
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 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]
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()),
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_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);
}
#[test]
fn test_exclude_dot_in_component_name() {
let mut registry = ComponentRegistry::new();
let result = registry.register_component(ComponentRegistration::new(
"fluent.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(
"fluent-button",
"<button>Click me</button>",
None,
true,
));
assert!(
result.is_ok(),
"Component name with hyphen should be accepted"
);
assert!(registry.contains("fluent-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);
}
}