use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::parser::{parse_attributes, parse_what_file, replace_variables};
use crate::{Error, Result};
#[derive(Debug, Clone)]
pub struct Component {
pub name: String,
pub props: Vec<String>,
pub defaults: HashMap<String, String>,
pub template: String,
}
impl Component {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let content = std::fs::read_to_string(&path)?;
Self::parse(&content)
}
pub fn from_file_with_name(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)?;
let name = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| Error::Component("Invalid filename".to_string()))?
.to_string();
Self::parse_with_name(&content, name)
}
pub fn parse_with_name(content: &str, name: String) -> Result<Self> {
if content.contains("<component") || content.contains("<tag") {
let mut component = Self::parse(content)?;
component.name = name;
return Ok(component);
}
Self::parse_what_format(content, name)
}
fn parse_what_format(content: &str, name: String) -> Result<Self> {
let mut props = Vec::new();
let mut defaults = HashMap::new();
let mut template = content.to_string();
if let Some(what_start) = content.find("<what>") {
if let Some(what_end) = content.find("</what>") {
let what_content = &content[what_start + 6..what_end];
template = format!(
"{}{}",
content[..what_start].trim(),
content[what_end + 7..].trim()
)
.trim()
.to_string();
let config = parse_what_file(what_content);
if let Some(props_value) = config.values.get("props") {
if let Some(props_str) = props_value.as_str() {
props = props_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
}
for (key, value) in &config.values {
if let Some(prop_name) = key.strip_prefix("defaults.") {
if let Some(default_value) = value.as_str() {
defaults.insert(prop_name.to_string(), default_value.to_string());
} else {
defaults.insert(prop_name.to_string(), value.to_string());
}
}
}
}
}
if let Some(what_start) = content.find("<what ") {
if let Some(what_end) = content[what_start..].find("/>") {
let what_attrs = &content[what_start + 5..what_start + what_end];
template = format!(
"{}{}",
content[..what_start].trim(),
content[what_start + what_end + 2..].trim()
)
.trim()
.to_string();
let attrs = parse_attributes(what_attrs);
if let Some(props_str) = attrs.get("props") {
props = props_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
if let Some(defaults_str) = attrs.get("defaults") {
for pair in defaults_str.split(',') {
if let Some(colon_idx) = pair.find(':') {
let key = pair[..colon_idx].trim().to_string();
let value = pair[colon_idx + 1..].trim().to_string();
defaults.insert(key, value);
}
}
}
}
}
Ok(Self {
name,
props,
defaults,
template,
})
}
pub fn parse(content: &str) -> Result<Self> {
let (start_tag, end_tag) = if content.contains("<component") {
("<component", "</component>")
} else {
("<tag", "</tag>")
};
let tag_start = content
.find(start_tag)
.ok_or_else(|| Error::Component(format!("Missing {} element", start_tag)))?;
let tag_end = content[tag_start..]
.find('>')
.ok_or_else(|| Error::Component(format!("Malformed {} element", start_tag)))?;
let tag_attrs = &content[tag_start..tag_start + tag_end + 1];
let attrs = parse_attributes(tag_attrs);
let name = attrs
.get("name")
.ok_or_else(|| Error::Component(format!("Missing 'name' attribute on {}", start_tag)))?
.clone();
let props: Vec<String> = attrs
.get("props")
.map(|p| p.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let mut defaults = HashMap::new();
if let Some(defaults_str) = attrs.get("defaults") {
for pair in defaults_str.split(',') {
if let Some(colon_idx) = pair.find(':') {
let key = pair[..colon_idx].trim().to_string();
let value = pair[colon_idx + 1..].trim().to_string();
defaults.insert(key, value);
}
}
}
let content_start = tag_start + tag_end + 1;
let content_end = content
.rfind(end_tag)
.ok_or_else(|| Error::Component(format!("Missing {}", end_tag)))?;
let template = content[content_start..content_end].trim().to_string();
Ok(Self {
name,
props,
defaults,
template,
})
}
pub fn render(
&self,
props: &HashMap<String, String>,
children: Option<&str>,
context: &HashMap<String, serde_json::Value>,
) -> String {
let mut render_context = context.clone();
for prop_name in &self.props {
if !props.contains_key(prop_name) && !self.defaults.contains_key(prop_name) {
render_context.insert(prop_name.clone(), serde_json::Value::String(String::new()));
}
}
for (key, value) in &self.defaults {
if !props.contains_key(key) {
render_context.insert(key.clone(), serde_json::Value::String(value.clone()));
}
}
for (key, value) in props {
let trimmed = value.trim();
if (trimmed.starts_with('[') && trimmed.ends_with(']'))
|| (trimmed.starts_with('{') && trimmed.ends_with('}'))
{
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
render_context.insert(key.clone(), parsed);
} else {
render_context.insert(key.clone(), serde_json::Value::String(value.clone()));
}
continue;
}
render_context.insert(key.clone(), serde_json::Value::String(value.clone()));
}
let processed_template = Self::process_component_loops(&self.template, &render_context);
let mut output = replace_variables(&processed_template, &render_context);
if let Some(children_content) = children {
output = output.replace("<slot/>", children_content);
output = output.replace("<slot />", children_content);
} else {
output = output.replace("<slot/>", "");
output = output.replace("<slot />", "");
}
output
}
fn process_component_loops(
template: &str,
context: &HashMap<String, serde_json::Value>,
) -> String {
use regex::Regex;
use std::sync::LazyLock;
static LOOP_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?s)<loop\s+data="([^"]+)"\s+as="([^"]+)"\s*>(.*?)</loop>"#).unwrap()
});
let mut output = template.to_string();
for _ in 0..10 {
let prev = output.clone();
output = LOOP_RE
.replace_all(&output, |caps: ®ex::Captures| {
let data_expr = &caps[1];
let alias = &caps[2];
let body = &caps[3];
let var_name = data_expr.trim_matches('#');
let parts: Vec<&str> = var_name.split('.').collect();
let data = if let Some(first) = parts.first() {
let mut current = context.get(*first);
for part in parts.iter().skip(1) {
current = current.and_then(|v| {
if let serde_json::Value::Object(obj) = v {
obj.get(*part)
} else {
None
}
});
}
current
} else {
None
};
match data {
Some(serde_json::Value::Array(items)) => {
items
.iter()
.enumerate()
.map(|(index, item)| {
let mut result = body.to_string();
if let serde_json::Value::Object(obj) = item {
for (key, val) in obj {
let pattern = format!("#{}{}{}#", alias, ".", key);
let replacement = match val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
};
result = result.replace(&pattern, &replacement);
}
}
let alias_pattern = format!("#{}#", alias);
let alias_val = match item {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
};
result = result.replace(&alias_pattern, &alias_val);
result = result.replace("#index#", &index.to_string());
result = result.replace("#index1#", &(index + 1).to_string());
result
})
.collect::<Vec<_>>()
.join("\n")
}
_ => {
caps[0].to_string()
}
}
})
.to_string();
if output == prev {
break;
}
}
output
}
}
#[derive(Debug, Default, Clone)]
pub struct ComponentRegistry {
components: HashMap<String, Arc<Component>>,
}
impl ComponentRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, component: Component) {
self.components
.insert(component.name.clone(), Arc::new(component));
}
pub fn get(&self, name: &str) -> Option<Arc<Component>> {
self.components.get(name).cloned()
}
pub fn component_names(&self) -> Vec<String> {
self.components.keys().cloned().collect()
}
pub fn load_from_directory(&mut self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if !path.exists() {
return Ok(());
}
self.load_from_directory_recursive(path)
}
fn load_from_directory_recursive(&mut self, path: &Path) -> Result<()> {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let file_path = entry.path();
if file_path.is_dir() {
self.load_from_directory_recursive(&file_path)?;
} else if file_path.extension().map(|e| e == "html").unwrap_or(false) {
match Component::from_file_with_name(&file_path) {
Ok(mut component) => {
component.name = format!("what-{}", component.name);
tracing::info!("Loaded component: {}", component.name);
self.register(component);
}
Err(e) => {
tracing::warn!("Failed to load component from {:?}: {}", file_path, e);
}
}
}
}
Ok(())
}
pub fn register_builtins(&mut self) {
let page_template = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>#title#</title>
<link rel="stylesheet" href="/static/what.css">
</head>
<body>
<slot/>
<script src="/static/what.js"></script>
</body>
</html>"#
.to_string();
self.register(Component {
name: "page".to_string(),
props: vec!["title".to_string()],
defaults: HashMap::new(),
template: page_template.clone(),
});
self.register(Component {
name: "what-page".to_string(),
props: vec!["title".to_string()],
defaults: HashMap::new(),
template: page_template,
});
self.register(Component {
name: "what-modal".to_string(),
props: vec!["id".to_string(), "title".to_string(), "size".to_string()],
defaults: {
let mut d = HashMap::new();
d.insert("size".to_string(), String::new());
d
},
template: r##"<div id="#id#" class="modal-backdrop">
<div class="modal #size#">
<div class="modal-header">
<h3 class="modal-title">#title#</h3>
<button type="button" class="modal-close" w-modal-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="modal-body">
<slot/>
</div>
</div>
</div>"##.to_string(),
});
self.register(Component {
name: "what-drawer".to_string(),
props: vec!["id".to_string(), "title".to_string(), "position".to_string(), "size".to_string()],
defaults: {
let mut d = HashMap::new();
d.insert("position".to_string(), "right".to_string());
d
},
template: r##"<div id="#id#" class="drawer-backdrop">
<div class="drawer drawer-#position# #size#">
<div class="drawer-header">
<h3 class="drawer-title">#title#</h3>
<button type="button" class="drawer-close" w-modal-close aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="drawer-body">
<slot/>
</div>
</div>
</div>"##.to_string(),
});
self.register(Component {
name: "what-tabs".to_string(),
props: vec!["id".to_string(), "style".to_string()],
defaults: {
let mut d = HashMap::new();
d.insert("style".to_string(), String::new());
d
},
template: r##"<div class="tab-list #style#" id="#id#-tabs">
<slot/>
</div>"##
.to_string(),
});
self.register(Component {
name: "what-accordion".to_string(),
props: vec![
"id".to_string(),
"title".to_string(),
"expanded".to_string(),
],
defaults: {
let mut d = HashMap::new();
d.insert("expanded".to_string(), String::new());
d
},
template: r###"<details id="#id#" class="w-accordion"<if expanded> open</if>>
<summary class="w-accordion-header">
<span class="font-semibold">#title#</span>
<span class="w-accordion-icon">▼</span>
</summary>
<div class="w-accordion-content py-3">
<slot/>
</div>
</details>"###
.to_string(),
});
self.register(Component {
name: "what-dropdown".to_string(),
props: vec![
"label".to_string(),
"align".to_string(),
"variant".to_string(),
],
defaults: {
let mut d = HashMap::new();
d.insert("label".to_string(), "Menu".to_string());
d.insert("variant".to_string(), "btn btn-secondary".to_string());
d.insert("align".to_string(), String::new());
d
},
template: r##"<details class="dropdown #align#">
<summary class="#variant#">
#label# <span style="font-size:0.75em">▾</span>
</summary>
<div class="dropdown-menu">
<slot/>
</div>
</details>"##
.to_string(),
});
self.register(Component {
name: "what-tooltip".to_string(),
props: vec!["text".to_string(), "position".to_string()],
defaults: {
let mut d = HashMap::new();
d.insert("position".to_string(), "top".to_string());
d
},
template: r##"<span data-tooltip="#text#" data-tooltip-position="#position#" style="cursor:help">
<slot/>
</span>"##.to_string(),
});
self.register(Component {
name: "what-pagination".to_string(),
props: vec![
"base_url".to_string(),
"current".to_string(),
"total".to_string(),
],
defaults: {
let mut d = HashMap::new();
d.insert("current".to_string(), "1".to_string());
d.insert("total".to_string(), "5".to_string());
d
},
template: r##"<nav class="navigation-pagination">
<slot/>
</nav>"##
.to_string(),
});
self.register(Component {
name: "what-wire-status".to_string(),
props: vec!["show-users".to_string()],
defaults: {
let mut d = HashMap::new();
d.insert("show-users".to_string(), "false".to_string());
d
},
template: r##"<span class="w-wire-status" data-show-users="#show-users#"><span class="w-wire-dot"></span><span class="w-wire-users" w-bind="wired._clients"></span></span>"##.to_string(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_component() {
let content = r##"
<component name="greeting" props="name, title">
<div class="greeting">
<h1>#title#</h1>
<p>Hello, #name#!</p>
<slot/>
</div>
</component>
"##;
let component = Component::parse(content).unwrap();
assert_eq!(component.name, "greeting");
assert_eq!(component.props, vec!["name", "title"]);
assert!(component.template.contains("#title#"));
assert!(component.defaults.is_empty());
}
#[test]
fn test_parse_legacy_tag() {
let content = r##"
<tag name="greeting" props="name, title">
<div class="greeting">
<h1>#title#</h1>
<p>Hello, #name#!</p>
<slot/>
</div>
</tag>
"##;
let component = Component::parse(content).unwrap();
assert_eq!(component.name, "greeting");
}
#[test]
fn test_parse_component_with_defaults() {
let content = r##"
<component name="nav" props="active, theme" defaults="active: home, theme: light">
<nav data-active="#active#" data-theme="#theme#"><slot/></nav>
</component>
"##;
let component = Component::parse(content).unwrap();
assert_eq!(component.name, "nav");
assert_eq!(component.props, vec!["active", "theme"]);
assert_eq!(component.defaults.get("active"), Some(&"home".to_string()));
assert_eq!(component.defaults.get("theme"), Some(&"light".to_string()));
}
#[test]
fn test_render_component() {
let component = Component {
name: "test".to_string(),
props: vec!["name".to_string()],
defaults: HashMap::new(),
template: "<p>Hello #name#!</p><slot/>".to_string(),
};
let mut props = HashMap::new();
props.insert("name".to_string(), "World".to_string());
let result = component.render(&props, Some("<span>Child</span>"), &HashMap::new());
assert_eq!(result, "<p>Hello World!</p><span>Child</span>");
}
#[test]
fn test_render_component_with_defaults() {
let mut defaults = HashMap::new();
defaults.insert("theme".to_string(), "dark".to_string());
defaults.insert("size".to_string(), "medium".to_string());
let component = Component {
name: "test".to_string(),
props: vec!["theme".to_string(), "size".to_string()],
defaults,
template: "<div class=\"#theme# #size#\"><slot/></div>".to_string(),
};
let result = component.render(&HashMap::new(), Some("Content"), &HashMap::new());
assert_eq!(result, "<div class=\"dark medium\">Content</div>");
let mut props = HashMap::new();
props.insert("theme".to_string(), "light".to_string());
let result = component.render(&props, Some("Content"), &HashMap::new());
assert_eq!(result, "<div class=\"light medium\">Content</div>");
let mut props = HashMap::new();
props.insert("theme".to_string(), "blue".to_string());
props.insert("size".to_string(), "large".to_string());
let result = component.render(&props, Some("Content"), &HashMap::new());
assert_eq!(result, "<div class=\"blue large\">Content</div>");
}
#[test]
fn test_parse_what_format() {
let content = r##"<what>
props = "active, theme"
defaults.active = "home"
defaults.theme = "light"
</what>
<nav data-active="#active#" data-theme="#theme#">
<slot/>
</nav>"##;
let component = Component::parse_with_name(content, "nav".to_string()).unwrap();
assert_eq!(component.name, "nav");
assert_eq!(component.props, vec!["active", "theme"]);
assert_eq!(component.defaults.get("active"), Some(&"home".to_string()));
assert_eq!(component.defaults.get("theme"), Some(&"light".to_string()));
println!("Template: '{}'", component.template);
assert!(
component.template.contains("data-active="),
"Template should contain data-active=, got: {}",
component.template
);
assert!(!component.template.contains("<what>"));
}
#[test]
fn test_parse_what_format_real_nav() {
let content = r##"<what>
props = "active"
defaults.active = "home"
</what>
<header class="navigation-top navigation-top-sticky bg-white shadow-sm">
<a href="/" class="nav-brand">What Framework?</a>
<nav class="nav-links" data-active="#active#">
<a href="/" class="nav-link" data-page="home">Home</a>
</nav>
</header>
"##;
let component = Component::parse_with_name(content, "nav".to_string()).unwrap();
println!("Nav component template: '{}'", component.template);
assert_eq!(component.name, "nav");
assert_eq!(component.props, vec!["active"]);
assert_eq!(component.defaults.get("active"), Some(&"home".to_string()));
assert!(
component.template.contains("<header"),
"Template should contain <header>, got: '{}'",
component.template
);
assert!(
component.template.contains("nav-brand"),
"Template should contain nav-brand"
);
assert!(
!component.template.contains("<what>"),
"Template should not contain <what>"
);
}
#[test]
fn test_load_real_nav_from_file() {
let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap() .parent()
.unwrap() .join("examples/demo/components/nav.html");
println!("Loading nav from: {:?}", nav_path);
let component = Component::from_file_with_name(&nav_path).expect("Failed to load nav.html");
println!("Loaded component name: {}", component.name);
println!("Props: {:?}", component.props);
println!("Defaults: {:?}", component.defaults);
println!("Template length: {}", component.template.len());
println!("Template: '{}'", component.template);
assert_eq!(component.name, "nav");
assert_eq!(component.props, vec!["active"]);
assert!(
component.template.contains("<header"),
"Template should contain <header>"
);
assert!(
component.template.len() > 100,
"Template should have content, got len={}",
component.template.len()
);
let mut props = std::collections::HashMap::new();
props.insert("active".to_string(), "home".to_string());
let ctx = std::collections::HashMap::new();
let rendered = component.render(&props, None, &ctx);
println!("Rendered: '{}'", rendered);
assert!(
rendered.contains("<header"),
"Rendered should contain <header>"
);
assert!(
rendered.contains("nav-brand"),
"Rendered should contain nav-brand"
);
}
#[test]
fn test_parse_what_format_no_defaults() {
let content = r##"<what>
props = "title"
</what>
<h1>#title#</h1>"##;
let component = Component::parse_with_name(content, "heading".to_string()).unwrap();
assert_eq!(component.name, "heading");
assert_eq!(component.props, vec!["title"]);
assert!(component.defaults.is_empty());
assert_eq!(component.template, "<h1>#title#</h1>");
}
#[test]
fn test_parse_what_format_no_what_block() {
let content = r##"<div class="simple">
<slot/>
</div>"##;
let component = Component::parse_with_name(content, "simple".to_string()).unwrap();
assert_eq!(component.name, "simple");
assert!(component.props.is_empty());
assert!(component.defaults.is_empty());
assert!(component.template.contains("simple"));
}
#[test]
fn test_load_components_with_prefix() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let comp_dir = temp_dir.path().join("components");
std::fs::create_dir(&comp_dir).unwrap();
let mut file = std::fs::File::create(comp_dir.join("card.html")).unwrap();
writeln!(
file,
r##"<component name="card" props="title">
<div class="card"><h2>#title#</h2><slot/></div>
</component>"##
)
.unwrap();
let mut registry = ComponentRegistry::new();
registry.load_from_directory(&comp_dir).unwrap();
assert!(registry.get("what-card").is_some());
assert!(registry.get("card").is_none());
}
#[test]
fn test_load_components_new_format() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let comp_dir = temp_dir.path().join("components");
std::fs::create_dir(&comp_dir).unwrap();
let mut file = std::fs::File::create(comp_dir.join("nav.html")).unwrap();
writeln!(
file,
r##"<what>
props = "active"
defaults.active = "home"
</what>
<nav data-active="#active#"><slot/></nav>"##
)
.unwrap();
let mut registry = ComponentRegistry::new();
registry.load_from_directory(&comp_dir).unwrap();
let nav = registry.get("what-nav").expect("what-nav should exist");
assert_eq!(nav.props, vec!["active"]);
assert_eq!(nav.defaults.get("active"), Some(&"home".to_string()));
}
#[test]
fn test_load_components_recursive() {
use std::io::Write;
let temp_dir = tempfile::tempdir().unwrap();
let comp_dir = temp_dir.path().join("components");
let sub_dir = comp_dir.join("forms");
std::fs::create_dir_all(&sub_dir).unwrap();
let mut file = std::fs::File::create(comp_dir.join("card.html")).unwrap();
writeln!(file, "<div class=\"card\"><slot/></div>").unwrap();
let mut file = std::fs::File::create(sub_dir.join("input.html")).unwrap();
writeln!(file, "<input type=\"text\" />").unwrap();
let mut registry = ComponentRegistry::new();
registry.load_from_directory(&comp_dir).unwrap();
assert!(registry.get("what-card").is_some());
assert!(registry.get("what-input").is_some());
assert!(registry.get("what-forms-input").is_none());
}
#[test]
fn test_render_component_json_array_prop() {
let component = Component {
name: "groups".to_string(),
props: vec!["groups".to_string()],
defaults: HashMap::new(),
template: "<ul><loop data=\"#groups#\" as=\"g\"><li>#g.name#</li></loop></ul>"
.to_string(),
};
let mut props = HashMap::new();
props.insert(
"groups".to_string(),
r#"[{"id":1,"name":"Admins"},{"id":2,"name":"Editors"}]"#.to_string(),
);
let _result = component.render(&props, None, &HashMap::new());
let mut ctx = HashMap::new();
let trimmed = props["groups"].trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&props["groups"]) {
ctx.insert("groups".to_string(), json_val);
}
}
assert!(
ctx["groups"].is_array(),
"JSON array prop should be parsed as array"
);
assert_eq!(ctx["groups"].as_array().unwrap().len(), 2);
}
#[test]
fn builtin_modal_renders() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let modal = registry.get("what-modal").unwrap();
let mut props = HashMap::new();
props.insert("id".to_string(), "my-modal".to_string());
props.insert("title".to_string(), "Confirm".to_string());
let result = modal.render(&props, Some("<p>Are you sure?</p>"), &HashMap::new());
assert!(result.contains("id=\"my-modal\""));
assert!(result.contains("Confirm"));
assert!(result.contains("Are you sure?"));
assert!(result.contains("modal-backdrop"));
}
#[test]
fn builtin_drawer_defaults_right() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let drawer = registry.get("what-drawer").unwrap();
let mut props = HashMap::new();
props.insert("id".to_string(), "side".to_string());
props.insert("title".to_string(), "Menu".to_string());
let result = drawer.render(&props, Some("<ul><li>Home</li></ul>"), &HashMap::new());
assert!(result.contains("drawer-right"));
assert!(result.contains("Menu"));
}
#[test]
fn builtin_tabs_renders() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let tabs = registry.get("what-tabs").unwrap();
let mut props = HashMap::new();
props.insert("id".to_string(), "main".to_string());
let result = tabs.render(&props, Some("<button>Tab 1</button>"), &HashMap::new());
assert!(result.contains("id=\"main-tabs\""));
assert!(result.contains("tab-list"));
assert!(result.contains("Tab 1"));
}
#[test]
fn builtin_accordion_renders() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let acc = registry.get("what-accordion").unwrap();
let mut props = HashMap::new();
props.insert("id".to_string(), "faq1".to_string());
props.insert("title".to_string(), "What is this?".to_string());
let result = acc.render(&props, Some("<p>An accordion</p>"), &HashMap::new());
assert!(result.contains("What is this?"));
assert!(result.contains("w-accordion-header"));
assert!(result.contains("An accordion"));
}
#[test]
fn builtin_dropdown_defaults() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let dd = registry.get("what-dropdown").unwrap();
let result = dd.render(&HashMap::new(), Some("<a>Item 1</a>"), &HashMap::new());
assert!(result.contains("Menu"));
assert!(result.contains("btn btn-secondary"));
assert!(result.contains("dropdown-menu"));
assert!(result.contains("Item 1"));
}
#[test]
fn builtin_tooltip_renders() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let tt = registry.get("what-tooltip").unwrap();
let mut props = HashMap::new();
props.insert("text".to_string(), "Help text".to_string());
let result = tt.render(&props, Some("Hover me"), &HashMap::new());
assert!(result.contains("data-tooltip=\"Help text\""));
assert!(result.contains("data-tooltip-position=\"top\""));
assert!(result.contains("Hover me"));
}
#[test]
fn builtin_pagination_renders() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
let pg = registry.get("what-pagination").unwrap();
let result = pg.render(&HashMap::new(), Some("<a>1</a><a>2</a>"), &HashMap::new());
assert!(result.contains("navigation-pagination"));
assert!(result.contains("<a>1</a>"));
}
#[test]
fn builtin_no_jumbo_or_left_nav() {
let mut registry = ComponentRegistry::new();
registry.register_builtins();
assert!(
registry.get("what-jumbo").is_none(),
"what-jumbo should be removed"
);
assert!(
registry.get("what-left-nav").is_none(),
"what-left-nav should be removed"
);
}
}