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>,
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(),
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 (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 component = Component {
tag_name: tag_name.to_string(),
html_content,
css_content,
css_definitions,
css_fallback_chains,
is_client_owned,
};
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 (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 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.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))
}
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()
}
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 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 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);
}
}