1use std::collections::HashMap;
30use std::path::Path;
31use std::sync::Arc;
32
33use crate::parser::{parse_attributes, parse_what_file, replace_variables};
34use crate::{Error, Result};
35
36#[derive(Debug, Clone)]
38pub struct Component {
39 pub name: String,
41 pub props: Vec<String>,
43 pub defaults: HashMap<String, String>,
45 pub template: String,
47}
48
49impl Component {
50 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
52 let content = std::fs::read_to_string(&path)?;
53 Self::parse(&content)
54 }
55
56 pub fn from_file_with_name(path: impl AsRef<Path>) -> Result<Self> {
63 let path = path.as_ref();
64 let content = std::fs::read_to_string(path)?;
65
66 let name = path
68 .file_stem()
69 .and_then(|s| s.to_str())
70 .ok_or_else(|| Error::Component("Invalid filename".to_string()))?
71 .to_string();
72
73 Self::parse_with_name(&content, name)
74 }
75
76 pub fn parse_with_name(content: &str, name: String) -> Result<Self> {
80 if content.contains("<component") || content.contains("<tag") {
82 let mut component = Self::parse(content)?;
83 component.name = name;
85 return Ok(component);
86 }
87
88 Self::parse_what_format(content, name)
90 }
91
92 fn parse_what_format(content: &str, name: String) -> Result<Self> {
94 let mut props = Vec::new();
95 let mut defaults = HashMap::new();
96 let mut template = content.to_string();
97
98 if let Some(what_start) = content.find("<what>") {
100 if let Some(what_end) = content.find("</what>") {
101 let what_content = &content[what_start + 6..what_end];
102 template = format!(
103 "{}{}",
104 content[..what_start].trim(),
105 content[what_end + 7..].trim()
106 )
107 .trim()
108 .to_string();
109
110 let config = parse_what_file(what_content);
112
113 if let Some(props_value) = config.values.get("props") {
115 if let Some(props_str) = props_value.as_str() {
116 props = props_str
117 .split(',')
118 .map(|s| s.trim().to_string())
119 .filter(|s| !s.is_empty())
120 .collect();
121 }
122 }
123
124 for (key, value) in &config.values {
126 if let Some(prop_name) = key.strip_prefix("defaults.") {
127 if let Some(default_value) = value.as_str() {
128 defaults.insert(prop_name.to_string(), default_value.to_string());
129 } else {
130 defaults.insert(prop_name.to_string(), value.to_string());
132 }
133 }
134 }
135 }
136 }
137
138 if let Some(what_start) = content.find("<what ") {
140 if let Some(what_end) = content[what_start..].find("/>") {
141 let what_attrs = &content[what_start + 5..what_start + what_end];
142 template = format!(
143 "{}{}",
144 content[..what_start].trim(),
145 content[what_start + what_end + 2..].trim()
146 )
147 .trim()
148 .to_string();
149
150 let attrs = parse_attributes(what_attrs);
151
152 if let Some(props_str) = attrs.get("props") {
154 props = props_str
155 .split(',')
156 .map(|s| s.trim().to_string())
157 .filter(|s| !s.is_empty())
158 .collect();
159 }
160
161 if let Some(defaults_str) = attrs.get("defaults") {
163 for pair in defaults_str.split(',') {
164 if let Some(colon_idx) = pair.find(':') {
165 let key = pair[..colon_idx].trim().to_string();
166 let value = pair[colon_idx + 1..].trim().to_string();
167 defaults.insert(key, value);
168 }
169 }
170 }
171 }
172 }
173
174 Ok(Self {
175 name,
176 props,
177 defaults,
178 template,
179 })
180 }
181
182 pub fn parse(content: &str) -> Result<Self> {
194 let (start_tag, end_tag) = if content.contains("<component") {
196 ("<component", "</component>")
197 } else {
198 ("<tag", "</tag>")
199 };
200
201 let tag_start = content
202 .find(start_tag)
203 .ok_or_else(|| Error::Component(format!("Missing {} element", start_tag)))?;
204 let tag_end = content[tag_start..]
205 .find('>')
206 .ok_or_else(|| Error::Component(format!("Malformed {} element", start_tag)))?;
207
208 let tag_attrs = &content[tag_start..tag_start + tag_end + 1];
209 let attrs = parse_attributes(tag_attrs);
210
211 let name = attrs
212 .get("name")
213 .ok_or_else(|| Error::Component(format!("Missing 'name' attribute on {}", start_tag)))?
214 .clone();
215
216 let props: Vec<String> = attrs
217 .get("props")
218 .map(|p| p.split(',').map(|s| s.trim().to_string()).collect())
219 .unwrap_or_default();
220
221 let mut defaults = HashMap::new();
223 if let Some(defaults_str) = attrs.get("defaults") {
224 for pair in defaults_str.split(',') {
225 if let Some(colon_idx) = pair.find(':') {
226 let key = pair[..colon_idx].trim().to_string();
227 let value = pair[colon_idx + 1..].trim().to_string();
228 defaults.insert(key, value);
229 }
230 }
231 }
232
233 let content_start = tag_start + tag_end + 1;
235 let content_end = content
236 .rfind(end_tag)
237 .ok_or_else(|| Error::Component(format!("Missing {}", end_tag)))?;
238
239 let template = content[content_start..content_end].trim().to_string();
240
241 Ok(Self {
242 name,
243 props,
244 defaults,
245 template,
246 })
247 }
248
249 pub fn render(
256 &self,
257 props: &HashMap<String, String>,
258 children: Option<&str>,
259 context: &HashMap<String, serde_json::Value>,
260 ) -> String {
261 let mut render_context = context.clone();
263
264 for prop_name in &self.props {
266 if !props.contains_key(prop_name) && !self.defaults.contains_key(prop_name) {
267 render_context.insert(prop_name.clone(), serde_json::Value::String(String::new()));
268 }
269 }
270
271 for (key, value) in &self.defaults {
273 if !props.contains_key(key) {
274 render_context.insert(key.clone(), serde_json::Value::String(value.clone()));
275 }
276 }
277
278 for (key, value) in props {
281 let trimmed = value.trim();
282 if (trimmed.starts_with('[') && trimmed.ends_with(']'))
283 || (trimmed.starts_with('{') && trimmed.ends_with('}'))
284 {
285 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
287 render_context.insert(key.clone(), parsed);
288 } else {
289 render_context.insert(key.clone(), serde_json::Value::String(value.clone()));
290 }
291 continue;
292 }
293 render_context.insert(key.clone(), serde_json::Value::String(value.clone()));
294 }
295
296 let processed_template = Self::process_component_loops(&self.template, &render_context);
299
300 let mut output = replace_variables(&processed_template, &render_context);
302
303 if let Some(children_content) = children {
305 output = output.replace("<slot/>", children_content);
306 output = output.replace("<slot />", children_content);
307 } else {
308 output = output.replace("<slot/>", "");
309 output = output.replace("<slot />", "");
310 }
311
312 output
313 }
314
315 fn process_component_loops(
318 template: &str,
319 context: &HashMap<String, serde_json::Value>,
320 ) -> String {
321 use regex::Regex;
322 use std::sync::LazyLock;
323
324 static LOOP_RE: LazyLock<Regex> = LazyLock::new(|| {
325 Regex::new(r#"(?s)<loop\s+data="([^"]+)"\s+as="([^"]+)"\s*>(.*?)</loop>"#).unwrap()
326 });
327
328 let mut output = template.to_string();
329
330 for _ in 0..10 {
332 let prev = output.clone();
333 output = LOOP_RE
334 .replace_all(&output, |caps: ®ex::Captures| {
335 let data_expr = &caps[1];
336 let alias = &caps[2];
337 let body = &caps[3];
338
339 let var_name = data_expr.trim_matches('#');
341 let parts: Vec<&str> = var_name.split('.').collect();
342
343 let data = if let Some(first) = parts.first() {
345 let mut current = context.get(*first);
346 for part in parts.iter().skip(1) {
347 current = current.and_then(|v| {
348 if let serde_json::Value::Object(obj) = v {
349 obj.get(*part)
350 } else {
351 None
352 }
353 });
354 }
355 current
356 } else {
357 None
358 };
359
360 match data {
361 Some(serde_json::Value::Array(items)) => {
362 items
363 .iter()
364 .enumerate()
365 .map(|(index, item)| {
366 let mut result = body.to_string();
367 if let serde_json::Value::Object(obj) = item {
369 for (key, val) in obj {
370 let pattern = format!("#{}{}{}#", alias, ".", key);
371 let replacement = match val {
372 serde_json::Value::String(s) => s.clone(),
373 serde_json::Value::Number(n) => n.to_string(),
374 serde_json::Value::Bool(b) => b.to_string(),
375 other => other.to_string(),
376 };
377 result = result.replace(&pattern, &replacement);
378 }
379 }
380 let alias_pattern = format!("#{}#", alias);
382 let alias_val = match item {
383 serde_json::Value::String(s) => s.clone(),
384 serde_json::Value::Number(n) => n.to_string(),
385 serde_json::Value::Bool(b) => b.to_string(),
386 other => other.to_string(),
387 };
388 result = result.replace(&alias_pattern, &alias_val);
389 result = result.replace("#index#", &index.to_string());
391 result = result.replace("#index1#", &(index + 1).to_string());
392 result
393 })
394 .collect::<Vec<_>>()
395 .join("\n")
396 }
397 _ => {
398 caps[0].to_string()
400 }
401 }
402 })
403 .to_string();
404
405 if output == prev {
406 break;
407 }
408 }
409
410 output
411 }
412}
413
414#[derive(Debug, Default, Clone)]
416pub struct ComponentRegistry {
417 components: HashMap<String, Arc<Component>>,
418}
419
420impl ComponentRegistry {
421 pub fn new() -> Self {
423 Self::default()
424 }
425
426 pub fn register(&mut self, component: Component) {
428 self.components
429 .insert(component.name.clone(), Arc::new(component));
430 }
431
432 pub fn get(&self, name: &str) -> Option<Arc<Component>> {
434 self.components.get(name).cloned()
435 }
436
437 pub fn component_names(&self) -> Vec<String> {
439 self.components.keys().cloned().collect()
440 }
441
442 pub fn load_from_directory(&mut self, path: impl AsRef<Path>) -> Result<()> {
448 let path = path.as_ref();
449 if !path.exists() {
450 return Ok(());
451 }
452
453 self.load_from_directory_recursive(path)
454 }
455
456 fn load_from_directory_recursive(&mut self, path: &Path) -> Result<()> {
458 for entry in std::fs::read_dir(path)? {
459 let entry = entry?;
460 let file_path = entry.path();
461
462 if file_path.is_dir() {
463 self.load_from_directory_recursive(&file_path)?;
465 } else if file_path.extension().map(|e| e == "html").unwrap_or(false) {
466 match Component::from_file_with_name(&file_path) {
468 Ok(mut component) => {
469 component.name = format!("what-{}", component.name);
471 tracing::info!("Loaded component: {}", component.name);
472 self.register(component);
473 }
474 Err(e) => {
475 tracing::warn!("Failed to load component from {:?}: {}", file_path, e);
476 }
477 }
478 }
479 }
480
481 Ok(())
482 }
483
484 pub fn register_builtins(&mut self) {
486 let page_template = r#"<!DOCTYPE html>
488<html lang="en">
489<head>
490 <meta charset="UTF-8">
491 <meta name="viewport" content="width=device-width, initial-scale=1.0">
492 <title>#title#</title>
493 <link rel="stylesheet" href="/static/what.css">
494</head>
495<body>
496 <slot/>
497 <script src="/static/what.js"></script>
498</body>
499</html>"#
500 .to_string();
501
502 self.register(Component {
504 name: "page".to_string(),
505 props: vec!["title".to_string()],
506 defaults: HashMap::new(),
507 template: page_template.clone(),
508 });
509
510 self.register(Component {
512 name: "what-page".to_string(),
513 props: vec!["title".to_string()],
514 defaults: HashMap::new(),
515 template: page_template,
516 });
517
518 self.register(Component {
520 name: "what-modal".to_string(),
521 props: vec!["id".to_string(), "title".to_string(), "size".to_string()],
522 defaults: {
523 let mut d = HashMap::new();
524 d.insert("size".to_string(), String::new());
525 d
526 },
527 template: r##"<div id="#id#" class="modal-backdrop">
528 <div class="modal #size#">
529 <div class="modal-header">
530 <h3 class="modal-title">#title#</h3>
531 <button type="button" class="modal-close" w-modal-close aria-label="Close">
532 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
533 <line x1="18" y1="6" x2="6" y2="18"></line>
534 <line x1="6" y1="6" x2="18" y2="18"></line>
535 </svg>
536 </button>
537 </div>
538 <div class="modal-body">
539 <slot/>
540 </div>
541 </div>
542</div>"##.to_string(),
543 });
544
545 self.register(Component {
547 name: "what-drawer".to_string(),
548 props: vec!["id".to_string(), "title".to_string(), "position".to_string(), "size".to_string()],
549 defaults: {
550 let mut d = HashMap::new();
551 d.insert("position".to_string(), "right".to_string());
552 d
553 },
554 template: r##"<div id="#id#" class="drawer-backdrop">
555 <div class="drawer drawer-#position# #size#">
556 <div class="drawer-header">
557 <h3 class="drawer-title">#title#</h3>
558 <button type="button" class="drawer-close" w-modal-close aria-label="Close">
559 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
560 <line x1="18" y1="6" x2="6" y2="18"></line>
561 <line x1="6" y1="6" x2="18" y2="18"></line>
562 </svg>
563 </button>
564 </div>
565 <div class="drawer-body">
566 <slot/>
567 </div>
568 </div>
569</div>"##.to_string(),
570 });
571
572 self.register(Component {
574 name: "what-tabs".to_string(),
575 props: vec!["id".to_string(), "style".to_string()],
576 defaults: {
577 let mut d = HashMap::new();
578 d.insert("style".to_string(), String::new());
579 d
580 },
581 template: r##"<div class="tab-list #style#" id="#id#-tabs">
582 <slot/>
583</div>"##
584 .to_string(),
585 });
586
587 self.register(Component {
589 name: "what-accordion".to_string(),
590 props: vec![
591 "id".to_string(),
592 "title".to_string(),
593 "expanded".to_string(),
594 ],
595 defaults: {
596 let mut d = HashMap::new();
597 d.insert("expanded".to_string(), String::new());
598 d
599 },
600 template: r###"<details id="#id#" class="w-accordion"<if expanded> open</if>>
601 <summary class="w-accordion-header">
602 <span class="font-semibold">#title#</span>
603 <span class="w-accordion-icon">▼</span>
604 </summary>
605 <div class="w-accordion-content py-3">
606 <slot/>
607 </div>
608</details>"###
609 .to_string(),
610 });
611
612 self.register(Component {
614 name: "what-dropdown".to_string(),
615 props: vec![
616 "label".to_string(),
617 "align".to_string(),
618 "variant".to_string(),
619 ],
620 defaults: {
621 let mut d = HashMap::new();
622 d.insert("label".to_string(), "Menu".to_string());
623 d.insert("variant".to_string(), "btn btn-secondary".to_string());
624 d.insert("align".to_string(), String::new());
625 d
626 },
627 template: r##"<details class="dropdown #align#">
628 <summary class="#variant#">
629 #label# <span style="font-size:0.75em">▾</span>
630 </summary>
631 <div class="dropdown-menu">
632 <slot/>
633 </div>
634</details>"##
635 .to_string(),
636 });
637
638 self.register(Component {
640 name: "what-tooltip".to_string(),
641 props: vec!["text".to_string(), "position".to_string()],
642 defaults: {
643 let mut d = HashMap::new();
644 d.insert("position".to_string(), "top".to_string());
645 d
646 },
647 template: r##"<span data-tooltip="#text#" data-tooltip-position="#position#" style="cursor:help">
648 <slot/>
649</span>"##.to_string(),
650 });
651
652 self.register(Component {
654 name: "what-pagination".to_string(),
655 props: vec![
656 "base_url".to_string(),
657 "current".to_string(),
658 "total".to_string(),
659 ],
660 defaults: {
661 let mut d = HashMap::new();
662 d.insert("current".to_string(), "1".to_string());
663 d.insert("total".to_string(), "5".to_string());
664 d
665 },
666 template: r##"<nav class="navigation-pagination">
667 <slot/>
668</nav>"##
669 .to_string(),
670 });
671
672 self.register(Component {
675 name: "what-wire-status".to_string(),
676 props: vec!["show-users".to_string()],
677 defaults: {
678 let mut d = HashMap::new();
679 d.insert("show-users".to_string(), "false".to_string());
680 d
681 },
682 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(),
683 });
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn test_parse_component() {
693 let content = r##"
694<component name="greeting" props="name, title">
695 <div class="greeting">
696 <h1>#title#</h1>
697 <p>Hello, #name#!</p>
698 <slot/>
699 </div>
700</component>
701"##;
702 let component = Component::parse(content).unwrap();
703 assert_eq!(component.name, "greeting");
704 assert_eq!(component.props, vec!["name", "title"]);
705 assert!(component.template.contains("#title#"));
706 assert!(component.defaults.is_empty());
707 }
708
709 #[test]
710 fn test_parse_legacy_tag() {
711 let content = r##"
713<tag name="greeting" props="name, title">
714 <div class="greeting">
715 <h1>#title#</h1>
716 <p>Hello, #name#!</p>
717 <slot/>
718 </div>
719</tag>
720"##;
721 let component = Component::parse(content).unwrap();
722 assert_eq!(component.name, "greeting");
723 }
724
725 #[test]
726 fn test_parse_component_with_defaults() {
727 let content = r##"
728<component name="nav" props="active, theme" defaults="active: home, theme: light">
729 <nav data-active="#active#" data-theme="#theme#"><slot/></nav>
730</component>
731"##;
732 let component = Component::parse(content).unwrap();
733 assert_eq!(component.name, "nav");
734 assert_eq!(component.props, vec!["active", "theme"]);
735 assert_eq!(component.defaults.get("active"), Some(&"home".to_string()));
736 assert_eq!(component.defaults.get("theme"), Some(&"light".to_string()));
737 }
738
739 #[test]
740 fn test_render_component() {
741 let component = Component {
742 name: "test".to_string(),
743 props: vec!["name".to_string()],
744 defaults: HashMap::new(),
745 template: "<p>Hello #name#!</p><slot/>".to_string(),
746 };
747
748 let mut props = HashMap::new();
749 props.insert("name".to_string(), "World".to_string());
750
751 let result = component.render(&props, Some("<span>Child</span>"), &HashMap::new());
752 assert_eq!(result, "<p>Hello World!</p><span>Child</span>");
753 }
754
755 #[test]
756 fn test_render_component_with_defaults() {
757 let mut defaults = HashMap::new();
758 defaults.insert("theme".to_string(), "dark".to_string());
759 defaults.insert("size".to_string(), "medium".to_string());
760
761 let component = Component {
762 name: "test".to_string(),
763 props: vec!["theme".to_string(), "size".to_string()],
764 defaults,
765 template: "<div class=\"#theme# #size#\"><slot/></div>".to_string(),
766 };
767
768 let result = component.render(&HashMap::new(), Some("Content"), &HashMap::new());
770 assert_eq!(result, "<div class=\"dark medium\">Content</div>");
771
772 let mut props = HashMap::new();
774 props.insert("theme".to_string(), "light".to_string());
775 let result = component.render(&props, Some("Content"), &HashMap::new());
776 assert_eq!(result, "<div class=\"light medium\">Content</div>");
777
778 let mut props = HashMap::new();
780 props.insert("theme".to_string(), "blue".to_string());
781 props.insert("size".to_string(), "large".to_string());
782 let result = component.render(&props, Some("Content"), &HashMap::new());
783 assert_eq!(result, "<div class=\"blue large\">Content</div>");
784 }
785
786 #[test]
787 fn test_parse_what_format() {
788 let content = r##"<what>
789props = "active, theme"
790defaults.active = "home"
791defaults.theme = "light"
792</what>
793<nav data-active="#active#" data-theme="#theme#">
794 <slot/>
795</nav>"##;
796
797 let component = Component::parse_with_name(content, "nav".to_string()).unwrap();
798 assert_eq!(component.name, "nav");
799 assert_eq!(component.props, vec!["active", "theme"]);
800 assert_eq!(component.defaults.get("active"), Some(&"home".to_string()));
801 assert_eq!(component.defaults.get("theme"), Some(&"light".to_string()));
802 println!("Template: '{}'", component.template);
803 assert!(
804 component.template.contains("data-active="),
805 "Template should contain data-active=, got: {}",
806 component.template
807 );
808 assert!(!component.template.contains("<what>"));
809 }
810
811 #[test]
812 fn test_parse_what_format_real_nav() {
813 let content = r##"<what>
815props = "active"
816defaults.active = "home"
817</what>
818<header class="navigation-top navigation-top-sticky bg-white shadow-sm">
819 <a href="/" class="nav-brand">What Framework?</a>
820 <nav class="nav-links" data-active="#active#">
821 <a href="/" class="nav-link" data-page="home">Home</a>
822 </nav>
823</header>
824"##;
825
826 let component = Component::parse_with_name(content, "nav".to_string()).unwrap();
827 println!("Nav component template: '{}'", component.template);
828 assert_eq!(component.name, "nav");
829 assert_eq!(component.props, vec!["active"]);
830 assert_eq!(component.defaults.get("active"), Some(&"home".to_string()));
831 assert!(
832 component.template.contains("<header"),
833 "Template should contain <header>, got: '{}'",
834 component.template
835 );
836 assert!(
837 component.template.contains("nav-brand"),
838 "Template should contain nav-brand"
839 );
840 assert!(
841 !component.template.contains("<what>"),
842 "Template should not contain <what>"
843 );
844 }
845
846 #[test]
847 fn test_load_real_nav_from_file() {
848 let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
850 .parent()
851 .unwrap() .parent()
853 .unwrap() .join("examples/demo/components/nav.html");
855
856 println!("Loading nav from: {:?}", nav_path);
857
858 let component = Component::from_file_with_name(&nav_path).expect("Failed to load nav.html");
859 println!("Loaded component name: {}", component.name);
860 println!("Props: {:?}", component.props);
861 println!("Defaults: {:?}", component.defaults);
862 println!("Template length: {}", component.template.len());
863 println!("Template: '{}'", component.template);
864
865 assert_eq!(component.name, "nav");
866 assert_eq!(component.props, vec!["active"]);
867 assert!(
868 component.template.contains("<header"),
869 "Template should contain <header>"
870 );
871 assert!(
872 component.template.len() > 100,
873 "Template should have content, got len={}",
874 component.template.len()
875 );
876
877 let mut props = std::collections::HashMap::new();
879 props.insert("active".to_string(), "home".to_string());
880 let ctx = std::collections::HashMap::new();
881
882 let rendered = component.render(&props, None, &ctx);
883 println!("Rendered: '{}'", rendered);
884 assert!(
885 rendered.contains("<header"),
886 "Rendered should contain <header>"
887 );
888 assert!(
889 rendered.contains("nav-brand"),
890 "Rendered should contain nav-brand"
891 );
892 }
893
894 #[test]
895 fn test_parse_what_format_no_defaults() {
896 let content = r##"<what>
897props = "title"
898</what>
899<h1>#title#</h1>"##;
900
901 let component = Component::parse_with_name(content, "heading".to_string()).unwrap();
902 assert_eq!(component.name, "heading");
903 assert_eq!(component.props, vec!["title"]);
904 assert!(component.defaults.is_empty());
905 assert_eq!(component.template, "<h1>#title#</h1>");
906 }
907
908 #[test]
909 fn test_parse_what_format_no_what_block() {
910 let content = r##"<div class="simple">
912 <slot/>
913</div>"##;
914
915 let component = Component::parse_with_name(content, "simple".to_string()).unwrap();
916 assert_eq!(component.name, "simple");
917 assert!(component.props.is_empty());
918 assert!(component.defaults.is_empty());
919 assert!(component.template.contains("simple"));
920 }
921
922 #[test]
923 fn test_load_components_with_prefix() {
924 use std::io::Write;
925 let temp_dir = tempfile::tempdir().unwrap();
926 let comp_dir = temp_dir.path().join("components");
927 std::fs::create_dir(&comp_dir).unwrap();
928
929 let mut file = std::fs::File::create(comp_dir.join("card.html")).unwrap();
931 writeln!(
932 file,
933 r##"<component name="card" props="title">
934 <div class="card"><h2>#title#</h2><slot/></div>
935 </component>"##
936 )
937 .unwrap();
938
939 let mut registry = ComponentRegistry::new();
940 registry.load_from_directory(&comp_dir).unwrap();
941
942 assert!(registry.get("what-card").is_some());
944 assert!(registry.get("card").is_none());
945 }
946
947 #[test]
948 fn test_load_components_new_format() {
949 use std::io::Write;
950 let temp_dir = tempfile::tempdir().unwrap();
951 let comp_dir = temp_dir.path().join("components");
952 std::fs::create_dir(&comp_dir).unwrap();
953
954 let mut file = std::fs::File::create(comp_dir.join("nav.html")).unwrap();
956 writeln!(
957 file,
958 r##"<what>
959props = "active"
960defaults.active = "home"
961</what>
962<nav data-active="#active#"><slot/></nav>"##
963 )
964 .unwrap();
965
966 let mut registry = ComponentRegistry::new();
967 registry.load_from_directory(&comp_dir).unwrap();
968
969 let nav = registry.get("what-nav").expect("what-nav should exist");
971 assert_eq!(nav.props, vec!["active"]);
972 assert_eq!(nav.defaults.get("active"), Some(&"home".to_string()));
973 }
974
975 #[test]
976 fn test_load_components_recursive() {
977 use std::io::Write;
978 let temp_dir = tempfile::tempdir().unwrap();
979 let comp_dir = temp_dir.path().join("components");
980 let sub_dir = comp_dir.join("forms");
981 std::fs::create_dir_all(&sub_dir).unwrap();
982
983 let mut file = std::fs::File::create(comp_dir.join("card.html")).unwrap();
985 writeln!(file, "<div class=\"card\"><slot/></div>").unwrap();
986
987 let mut file = std::fs::File::create(sub_dir.join("input.html")).unwrap();
989 writeln!(file, "<input type=\"text\" />").unwrap();
990
991 let mut registry = ComponentRegistry::new();
992 registry.load_from_directory(&comp_dir).unwrap();
993
994 assert!(registry.get("what-card").is_some());
996 assert!(registry.get("what-input").is_some());
997 assert!(registry.get("what-forms-input").is_none());
999 }
1000
1001 #[test]
1002 fn test_render_component_json_array_prop() {
1003 let component = Component {
1004 name: "groups".to_string(),
1005 props: vec!["groups".to_string()],
1006 defaults: HashMap::new(),
1007 template: "<ul><loop data=\"#groups#\" as=\"g\"><li>#g.name#</li></loop></ul>"
1008 .to_string(),
1009 };
1010
1011 let mut props = HashMap::new();
1012 props.insert(
1013 "groups".to_string(),
1014 r#"[{"id":1,"name":"Admins"},{"id":2,"name":"Editors"}]"#.to_string(),
1015 );
1016
1017 let _result = component.render(&props, None, &HashMap::new());
1018
1019 let mut ctx = HashMap::new();
1023 let trimmed = props["groups"].trim();
1024 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1025 if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&props["groups"]) {
1026 ctx.insert("groups".to_string(), json_val);
1027 }
1028 }
1029 assert!(
1030 ctx["groups"].is_array(),
1031 "JSON array prop should be parsed as array"
1032 );
1033 assert_eq!(ctx["groups"].as_array().unwrap().len(), 2);
1034 }
1035
1036 #[test]
1041 fn builtin_modal_renders() {
1042 let mut registry = ComponentRegistry::new();
1043 registry.register_builtins();
1044 let modal = registry.get("what-modal").unwrap();
1045 let mut props = HashMap::new();
1046 props.insert("id".to_string(), "my-modal".to_string());
1047 props.insert("title".to_string(), "Confirm".to_string());
1048 let result = modal.render(&props, Some("<p>Are you sure?</p>"), &HashMap::new());
1049 assert!(result.contains("id=\"my-modal\""));
1050 assert!(result.contains("Confirm"));
1051 assert!(result.contains("Are you sure?"));
1052 assert!(result.contains("modal-backdrop"));
1053 }
1054
1055 #[test]
1056 fn builtin_drawer_defaults_right() {
1057 let mut registry = ComponentRegistry::new();
1058 registry.register_builtins();
1059 let drawer = registry.get("what-drawer").unwrap();
1060 let mut props = HashMap::new();
1061 props.insert("id".to_string(), "side".to_string());
1062 props.insert("title".to_string(), "Menu".to_string());
1063 let result = drawer.render(&props, Some("<ul><li>Home</li></ul>"), &HashMap::new());
1064 assert!(result.contains("drawer-right"));
1065 assert!(result.contains("Menu"));
1066 }
1067
1068 #[test]
1069 fn builtin_tabs_renders() {
1070 let mut registry = ComponentRegistry::new();
1071 registry.register_builtins();
1072 let tabs = registry.get("what-tabs").unwrap();
1073 let mut props = HashMap::new();
1074 props.insert("id".to_string(), "main".to_string());
1075 let result = tabs.render(&props, Some("<button>Tab 1</button>"), &HashMap::new());
1076 assert!(result.contains("id=\"main-tabs\""));
1077 assert!(result.contains("tab-list"));
1078 assert!(result.contains("Tab 1"));
1079 }
1080
1081 #[test]
1082 fn builtin_accordion_renders() {
1083 let mut registry = ComponentRegistry::new();
1084 registry.register_builtins();
1085 let acc = registry.get("what-accordion").unwrap();
1086 let mut props = HashMap::new();
1087 props.insert("id".to_string(), "faq1".to_string());
1088 props.insert("title".to_string(), "What is this?".to_string());
1089 let result = acc.render(&props, Some("<p>An accordion</p>"), &HashMap::new());
1090 assert!(result.contains("What is this?"));
1091 assert!(result.contains("w-accordion-header"));
1092 assert!(result.contains("An accordion"));
1093 }
1094
1095 #[test]
1096 fn builtin_dropdown_defaults() {
1097 let mut registry = ComponentRegistry::new();
1098 registry.register_builtins();
1099 let dd = registry.get("what-dropdown").unwrap();
1100 let result = dd.render(&HashMap::new(), Some("<a>Item 1</a>"), &HashMap::new());
1101 assert!(result.contains("Menu"));
1102 assert!(result.contains("btn btn-secondary"));
1103 assert!(result.contains("dropdown-menu"));
1104 assert!(result.contains("Item 1"));
1105 }
1106
1107 #[test]
1108 fn builtin_tooltip_renders() {
1109 let mut registry = ComponentRegistry::new();
1110 registry.register_builtins();
1111 let tt = registry.get("what-tooltip").unwrap();
1112 let mut props = HashMap::new();
1113 props.insert("text".to_string(), "Help text".to_string());
1114 let result = tt.render(&props, Some("Hover me"), &HashMap::new());
1115 assert!(result.contains("data-tooltip=\"Help text\""));
1116 assert!(result.contains("data-tooltip-position=\"top\""));
1117 assert!(result.contains("Hover me"));
1118 }
1119
1120 #[test]
1121 fn builtin_pagination_renders() {
1122 let mut registry = ComponentRegistry::new();
1123 registry.register_builtins();
1124 let pg = registry.get("what-pagination").unwrap();
1125 let result = pg.render(&HashMap::new(), Some("<a>1</a><a>2</a>"), &HashMap::new());
1126 assert!(result.contains("navigation-pagination"));
1127 assert!(result.contains("<a>1</a>"));
1128 }
1129
1130 #[test]
1131 fn builtin_no_jumbo_or_left_nav() {
1132 let mut registry = ComponentRegistry::new();
1133 registry.register_builtins();
1134 assert!(
1135 registry.get("what-jumbo").is_none(),
1136 "what-jumbo should be removed"
1137 );
1138 assert!(
1139 registry.get("what-left-nav").is_none(),
1140 "what-left-nav should be removed"
1141 );
1142 }
1143}