Skip to main content

what_core/components/
mod.rs

1//! Component registry and rendering system
2//!
3//! Handles custom component definitions and rendering.
4//!
5//! ## Component Format
6//!
7//! Components can be defined in two ways:
8//!
9//! ### New format (filename-based, recommended):
10//! ```html
11//! <!-- components/nav.html → <what-nav> -->
12//! <what>
13//! props = "active, theme"
14//! defaults.active = "home"
15//! defaults.theme = "light"
16//! </what>
17//! <nav class="navigation" data-active="#active#">
18//!   <slot/>
19//! </nav>
20//! ```
21//!
22//! ### Legacy format (with wrapper tag):
23//! ```html
24//! <component name="card" props="title">
25//!   <div class="card"><h2>#title#</h2><slot/></div>
26//! </component>
27//! ```
28
29use 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/// A custom component definition
37#[derive(Debug, Clone)]
38pub struct Component {
39    /// Component name (e.g., "what-card", "what-modal")
40    pub name: String,
41    /// Expected props
42    pub props: Vec<String>,
43    /// Default values for props
44    pub defaults: HashMap<String, String>,
45    /// Template content
46    pub template: String,
47}
48
49impl Component {
50    /// Load a component from an HTML template file (legacy format with wrapper tag)
51    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    /// Load a component from a file, deriving the name from the filename
57    ///
58    /// This is the new recommended format where:
59    /// - Filename becomes the component name: `nav.html` → `nav`
60    /// - Props and defaults are defined in a `<what>` block
61    /// - Template content is everything outside the `<what>` block
62    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        // Derive name from filename (without extension)
67        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    /// Parse component content with a provided name (filename-based)
77    ///
78    /// Supports both new `<what>` block format and legacy wrapper tag format.
79    pub fn parse_with_name(content: &str, name: String) -> Result<Self> {
80        // Check for legacy wrapper tag format first
81        if content.contains("<component") || content.contains("<tag") {
82            let mut component = Self::parse(content)?;
83            // Override name with provided filename-based name
84            component.name = name;
85            return Ok(component);
86        }
87
88        // New format: <what> block + template content
89        Self::parse_what_format(content, name)
90    }
91
92    /// Parse new format with `<what>` block for props/defaults
93    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        // Check for <what> block
99        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                // Parse the <what> block using the parser
111                let config = parse_what_file(what_content);
112
113                // Extract props
114                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                // Extract defaults (keys starting with "defaults.")
125                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                            // Convert other types to string
131                            defaults.insert(prop_name.to_string(), value.to_string());
132                        }
133                    }
134                }
135            }
136        }
137
138        // Also check for self-closing <what ... /> format
139        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                // Extract props from attribute
153                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                // Extract defaults from attribute (format: "prop: value, prop2: value2")
162                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    /// Parse a component definition from HTML template content (legacy format)
183    ///
184    /// Expected format:
185    /// ```html
186    /// <component name="card" props="title, slug">
187    ///   <article class="card">
188    ///     <h3>#title#</h3>
189    ///     <slot/>
190    ///   </article>
191    /// </component>
192    /// ```
193    pub fn parse(content: &str) -> Result<Self> {
194        // Support both <component> and legacy <tag> elements
195        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        // Parse defaults from attribute (format: "prop: value, prop2: value2")
222        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        // Extract template content between start and end tags
234        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    /// Render the component with given props and children
250    ///
251    /// Priority order (highest to lowest):
252    /// 1. Passed props (from usage)
253    /// 2. Defaults (from component definition)
254    /// 3. Empty string (for undeclared props)
255    pub fn render(
256        &self,
257        props: &HashMap<String, String>,
258        children: Option<&str>,
259        context: &HashMap<String, serde_json::Value>,
260    ) -> String {
261        // Build context from props
262        let mut render_context = context.clone();
263
264        // Set default empty string for all declared props (lowest priority)
265        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        // Apply defaults (medium priority)
272        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        // Set provided prop values (highest priority - overrides defaults)
279        // JSON array/object props are parsed into serde_json::Value for loop resolution
280        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                // Parse JSON props so engine loops can resolve them
286                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        // Process loops in the component template before variable replacement
297        // This allows JSON array props to be iterated via <loop data="#var#" as="alias">
298        let processed_template = Self::process_component_loops(&self.template, &render_context);
299
300        // Replace variables in template
301        let mut output = replace_variables(&processed_template, &render_context);
302
303        // Replace <slot/> with children content
304        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    /// Process <loop> tags within a component template using the component's context.
316    /// This handles JSON array props that are passed to components.
317    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        // Process all loop tags (iterate because loops may be nested)
331        for _ in 0..10 {
332            let prev = output.clone();
333            output = LOOP_RE
334                .replace_all(&output, |caps: &regex::Captures| {
335                    let data_expr = &caps[1];
336                    let alias = &caps[2];
337                    let body = &caps[3];
338
339                    // Extract variable name from #var# syntax
340                    let var_name = data_expr.trim_matches('#');
341                    let parts: Vec<&str> = var_name.split('.').collect();
342
343                    // Resolve from context
344                    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                                    // Replace #alias.field# patterns
368                                    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                                    // Replace #alias# with the item itself
381                                    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                                    // Replace index variables
390                                    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                            // Can't resolve — leave the loop tag for the engine to handle
399                            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/// Registry of all available components
415#[derive(Debug, Default, Clone)]
416pub struct ComponentRegistry {
417    components: HashMap<String, Arc<Component>>,
418}
419
420impl ComponentRegistry {
421    /// Create a new empty registry
422    pub fn new() -> Self {
423        Self::default()
424    }
425
426    /// Register a component
427    pub fn register(&mut self, component: Component) {
428        self.components
429            .insert(component.name.clone(), Arc::new(component));
430    }
431
432    /// Get a component by name
433    pub fn get(&self, name: &str) -> Option<Arc<Component>> {
434        self.components.get(name).cloned()
435    }
436
437    /// Get all registered component names
438    pub fn component_names(&self) -> Vec<String> {
439        self.components.keys().cloned().collect()
440    }
441
442    /// Load all components from a directory with what- prefix
443    ///
444    /// Files in /components/card.html become <what-card>
445    /// Supports both new format (filename-based) and legacy format (wrapper tag)
446    /// Recursively loads from subdirectories (flat namespace - folders for organization only)
447    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    /// Recursively load components from a directory
457    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                // Recursively load from subdirectories
464                self.load_from_directory_recursive(&file_path)?;
465            } else if file_path.extension().map(|e| e == "html").unwrap_or(false) {
466                // Use new filename-based loading
467                match Component::from_file_with_name(&file_path) {
468                    Ok(mut component) => {
469                        // Apply what- prefix
470                        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    /// Register built-in components
485    pub fn register_builtins(&mut self) {
486        // <page> component - wraps content with HTML structure (shorthand, no prefix)
487        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        // Register as <page> (primary, for user convenience)
503        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        // Also register as <what-page> for consistency
511        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        // <what-modal> component - modal dialog with backdrop and close button
519        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        // <what-drawer> component - slide-in panel with backdrop
546        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        // <what-tabs> component - tab container
573        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        // <what-accordion> component - native <details>/<summary>, zero JS
588        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">&#9660;</span>
604  </summary>
605  <div class="w-accordion-content py-3">
606    <slot/>
607  </div>
608</details>"###
609                .to_string(),
610        });
611
612        // <what-dropdown> component - native details-based dropdown menu
613        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">&#9662;</span>
630  </summary>
631  <div class="dropdown-menu">
632    <slot/>
633  </div>
634</details>"##
635                .to_string(),
636        });
637
638        // <what-tooltip> component - CSS-positioned hover tooltip
639        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        // <what-pagination> component - page navigation links
653        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        // <what-wire-status> component - wired connection status indicator
673        // Shows a green/grey dot and optionally the number of connected users
674        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        // Still support <tag> for backwards compatibility
712        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        // No props passed - should use defaults
769        let result = component.render(&HashMap::new(), Some("Content"), &HashMap::new());
770        assert_eq!(result, "<div class=\"dark medium\">Content</div>");
771
772        // Override one default
773        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        // Override both defaults
779        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        // Test with exact content like the actual nav.html file
814        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        // Test loading the actual nav.html file from demo directory
849        let nav_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
850            .parent()
851            .unwrap() // crates/
852            .parent()
853            .unwrap() // root
854            .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        // Test render
878        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        // Component without <what> block - just template content
911        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        // Create a test component file (legacy format)
930        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        // Should be registered as what-card (filename-based, not from name attribute)
943        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        // Create a test component file (new format)
955        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        // Should be registered as what-nav
970        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        // Create component in root
984        let mut file = std::fs::File::create(comp_dir.join("card.html")).unwrap();
985        writeln!(file, "<div class=\"card\"><slot/></div>").unwrap();
986
987        // Create component in subdirectory
988        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        // Both should be loaded with flat namespace
995        assert!(registry.get("what-card").is_some());
996        assert!(registry.get("what-input").is_some());
997        // Should NOT be scoped by folder
998        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        // The JSON array should be parsed and available in the context
1020        // (loop processing happens in the engine, not here, but the context should have the array)
1021        // Verify the prop is inserted as a JSON array, not a string
1022        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    // =========================================================================
1037    // Built-in Component Tests
1038    // =========================================================================
1039
1040    #[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}