Skip to main content

van_compiler/
resolve.rs

1use regex::Regex;
2use serde_json::Value;
3use std::collections::HashMap;
4use van_parser::{add_scope_class, parse_blocks, parse_imports, parse_script_imports, scope_css, scope_id, VanImport};
5
6use crate::render::{escape_html, interpolate, resolve_path as resolve_json_path, try_resolve_t};
7
8const MAX_DEPTH: usize = 10;
9
10/// A resolved non-component module import (.ts/.js file).
11#[derive(Debug, Clone)]
12pub struct ResolvedModule {
13    /// Resolved virtual path.
14    pub path: String,
15    /// File content from the files map.
16    pub content: String,
17    /// Whether this is a type-only import (should be erased).
18    pub is_type_only: bool,
19}
20
21/// The result of resolving a `.van` file (with or without imports).
22#[derive(Debug)]
23pub struct ResolvedComponent {
24    /// The fully rendered HTML content.
25    pub html: String,
26    /// Collected CSS styles from this component and all descendants.
27    pub styles: Vec<String>,
28    /// The `<script setup>` content (for signal generation).
29    pub script_setup: Option<String>,
30    /// Resolved non-component module imports (.ts/.js files).
31    pub module_imports: Vec<ResolvedModule>,
32}
33
34// ─── Multi-file resolve (HashMap-based, no FS) ─────────────────────────
35
36/// Resolve a `.van` entry file with all its component imports from an in-memory file map.
37///
38/// This is the main API for multi-file compilation (playground, WASM, tests).
39/// Files are keyed by virtual path (e.g. `"index.van"`, `"hello.van"`).
40pub fn resolve_with_files(
41    entry_path: &str,
42    files: &HashMap<String, String>,
43    data: &Value,
44) -> Result<ResolvedComponent, String> {
45    resolve_with_files_inner(entry_path, files, data, false, &HashMap::new())
46}
47
48/// Like `resolve_with_files`, but with debug HTML comments showing component/slot boundaries.
49///
50/// `file_origins` maps each file path to its theme name (e.g. `"components/header.van" → "van1"`).
51/// When present, debug comments include the theme: `<!-- START: [van1] components/header.van -->`.
52pub fn resolve_with_files_debug(
53    entry_path: &str,
54    files: &HashMap<String, String>,
55    data: &Value,
56    file_origins: &HashMap<String, String>,
57) -> Result<ResolvedComponent, String> {
58    resolve_with_files_inner(entry_path, files, data, true, file_origins)
59}
60
61fn resolve_with_files_inner(
62    entry_path: &str,
63    files: &HashMap<String, String>,
64    data: &Value,
65    debug: bool,
66    file_origins: &HashMap<String, String>,
67) -> Result<ResolvedComponent, String> {
68    let source = files
69        .get(entry_path)
70        .ok_or_else(|| format!("Entry file not found: {entry_path}"))?;
71
72    // Collect reactive names from ALL .van files (entry + children),
73    // so that child-component reactive variables (e.g. `menuOpen`) are
74    // preserved during interpolation and not replaced by server data.
75    let mut reactive_names = Vec::new();
76    for (path, content) in files {
77        if path.ends_with(".van") {
78            let blk = parse_blocks(content);
79            if let Some(ref script) = blk.script_setup {
80                reactive_names.extend(extract_reactive_names(script));
81            }
82        }
83    }
84
85    resolve_recursive(source, data, entry_path, files, 0, &reactive_names, debug, file_origins)
86}
87
88/// Recursively resolve component tags in a `.van` source using in-memory files.
89fn resolve_recursive(
90    source: &str,
91    data: &Value,
92    current_path: &str,
93    files: &HashMap<String, String>,
94    depth: usize,
95    reactive_names: &[String],
96    debug: bool,
97    file_origins: &HashMap<String, String>,
98) -> Result<ResolvedComponent, String> {
99    if depth > MAX_DEPTH {
100        return Err(format!(
101            "Component nesting exceeded maximum depth of {MAX_DEPTH}"
102        ));
103    }
104
105    let blocks = parse_blocks(source);
106    let mut template = blocks
107        .template
108        .unwrap_or_else(|| "<p>No template block found.</p>".to_string());
109
110    let mut styles: Vec<String> = Vec::new();
111    if let Some(css) = &blocks.style {
112        if blocks.style_scoped {
113            let id = scope_id(css);
114            template = add_scope_class(&template, &id);
115            styles.push(scope_css(css, &id));
116        } else {
117            styles.push(css.clone());
118        }
119    }
120
121    // Parse imports from script setup to build tag -> import mapping
122    let imports = if let Some(ref script) = blocks.script_setup {
123        parse_imports(script)
124    } else {
125        Vec::new()
126    };
127
128    let import_map: HashMap<String, &VanImport> = imports
129        .iter()
130        .map(|imp| (imp.tag_name.clone(), imp))
131        .collect();
132
133    // Expand v-for directives before component resolution
134    template = expand_v_for(&template, data);
135
136    // Collect child script_setup and module_imports for merging
137    let mut child_scripts: Vec<String> = Vec::new();
138    let mut child_module_imports: Vec<ResolvedModule> = Vec::new();
139
140    // Repeatedly find and replace component tags until none remain
141    loop {
142        let tag_match = find_component_tag(&template, &import_map);
143        let Some(tag_info) = tag_match else {
144            break;
145        };
146
147        let imp = &import_map[&tag_info.tag_name];
148
149        // Resolve the component .van file via virtual path
150        let resolved_key = resolve_virtual_path(current_path, &imp.path);
151        let component_source = files
152            .get(&resolved_key)
153            .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
154
155        // Parse props from the tag and build child data context
156        let child_data = parse_props(&tag_info.attrs, data);
157
158        // Parse slot content from children (using parent data + parent import_map)
159        let slot_result = parse_slot_content(
160            &tag_info.children,
161            data,
162            &imports,
163            current_path,
164            files,
165            depth,
166            reactive_names,
167            debug,
168            file_origins,
169        )?;
170
171        // Recursively resolve the child component
172        let child_resolved = resolve_recursive(
173            component_source,
174            &child_data,
175            &resolved_key,
176            files,
177            depth + 1,
178            reactive_names,
179            debug,
180            file_origins,
181        )?;
182
183        // Distribute slots into the child's rendered HTML
184        // Build per-slot theme map: check slot-specific origin first, then file-level origin
185        let file_theme = file_origins.get(current_path).cloned().unwrap_or_default();
186        let mut slot_themes: HashMap<String, String> = HashMap::new();
187        for slot_name in slot_result.slots.keys() {
188            let slot_key = format!("{}#{}", current_path, slot_name);
189            let theme = file_origins.get(&slot_key).unwrap_or(&file_theme);
190            slot_themes.insert(slot_name.clone(), theme.clone());
191        }
192        let with_slots = distribute_slots(&child_resolved.html, &slot_result.slots, debug, &slot_themes);
193
194        // Replace the component tag with the resolved content
195        let replacement = if debug {
196            let theme_prefix = file_origins.get(&resolved_key)
197                .map(|t| format!("[{t}] "))
198                .unwrap_or_default();
199            format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
200        } else {
201            with_slots
202        };
203
204        template = format!(
205            "{}{}{}",
206            &template[..tag_info.start],
207            replacement,
208            &template[tag_info.end..],
209        );
210
211        // Collect child script_setup and module_imports for merging
212        if let Some(ref cs) = child_resolved.script_setup {
213            child_scripts.push(cs.clone());
214        }
215        child_module_imports.extend(child_resolved.module_imports);
216
217        // Collect slot component script_setup and module_imports
218        if let Some(ref ss) = slot_result.script_setup {
219            child_scripts.push(ss.clone());
220        }
221        child_module_imports.extend(slot_result.module_imports);
222
223        // Collect child styles and slot component styles
224        styles.extend(child_resolved.styles);
225        styles.extend(slot_result.styles);
226    }
227
228    // Reactive-aware interpolation: leave reactive {{ expr }} as-is for
229    // signal gen to find via tree walking; interpolate non-reactive ones.
230    let html = if !reactive_names.is_empty() {
231        interpolate_skip_reactive(&template, data, reactive_names)
232    } else {
233        interpolate(&template, data)
234    };
235
236    // Merge this component's script_setup with collected child scripts
237    let mut script_setup = blocks.script_setup.clone();
238    if !child_scripts.is_empty() {
239        let merged = child_scripts.join("\n");
240        script_setup = Some(match script_setup {
241            Some(s) => format!("{s}\n{merged}"),
242            None => merged,
243        });
244    }
245
246    // Resolve module imports from this component and merge child module imports
247    let mut module_imports: Vec<ResolvedModule> = if let Some(ref script) = blocks.script_setup {
248        let script_imports = parse_script_imports(script);
249        script_imports
250            .into_iter()
251            .filter_map(|imp| {
252                if imp.is_type_only {
253                    return None; // type-only imports are erased
254                }
255                let resolved_key = resolve_virtual_path(current_path, &imp.path);
256                let content = files.get(&resolved_key)?;
257                Some(ResolvedModule {
258                    path: resolved_key,
259                    content: content.clone(),
260                    is_type_only: false,
261                })
262            })
263            .collect()
264    } else {
265        Vec::new()
266    };
267    module_imports.extend(child_module_imports);
268
269    Ok(ResolvedComponent {
270        html,
271        styles,
272        script_setup,
273        module_imports,
274    })
275}
276
277// ─── Single-file resolve (no imports, no FS) ────────────────────────────
278
279/// Resolve a single `.van` source into HTML + styles (no import resolution).
280///
281/// This is the backward-compatible entry point for single-file usage.
282pub fn resolve_single(source: &str, data: &Value) -> Result<ResolvedComponent, String> {
283    resolve_single_with_path(source, data, "")
284}
285
286/// Like `resolve_single`, but kept for API compatibility.
287pub fn resolve_single_with_path(source: &str, data: &Value, _path: &str) -> Result<ResolvedComponent, String> {
288    let blocks = parse_blocks(source);
289
290    let mut template = blocks
291        .template
292        .unwrap_or_else(|| "<p>No template block found.</p>".to_string());
293
294    let mut styles: Vec<String> = Vec::new();
295    if let Some(css) = &blocks.style {
296        if blocks.style_scoped {
297            let id = scope_id(css);
298            template = add_scope_class(&template, &id);
299            styles.push(scope_css(css, &id));
300        } else {
301            styles.push(css.clone());
302        }
303    }
304
305    // Extract reactive names from script setup
306    let reactive_names = if let Some(ref script) = blocks.script_setup {
307        extract_reactive_names(script)
308    } else {
309        Vec::new()
310    };
311
312    // Reactive-aware interpolation
313    let html = if !reactive_names.is_empty() {
314        interpolate_skip_reactive(&template, data, &reactive_names)
315    } else {
316        interpolate(&template, data)
317    };
318
319    Ok(ResolvedComponent {
320        html,
321        styles,
322        script_setup: blocks.script_setup.clone(),
323        module_imports: Vec::new(),
324    })
325}
326
327// ─── Virtual path resolution ────────────────────────────────────────────
328
329/// Resolve a relative import path against a current file's virtual path.
330///
331/// ```text
332/// current_file="index.van", import="./hello.van" → "hello.van"
333/// current_file="pages/index.van", import="../components/hello.van" → "components/hello.van"
334/// current_file="pages/index.van", import="./sub.van" → "pages/sub.van"
335/// import="@van-ui/button/button.van" → "@van-ui/button/button.van" (scoped package, returned as-is)
336/// ```
337fn resolve_virtual_path(current_file: &str, import_path: &str) -> String {
338    // @scope/pkg paths are absolute references into node_modules — return as-is
339    if import_path.starts_with('@') {
340        return import_path.to_string();
341    }
342
343    // Get the directory of the current file
344    let dir = if let Some(pos) = current_file.rfind('/') {
345        &current_file[..pos]
346    } else {
347        "" // root level
348    };
349
350    let combined = if dir.is_empty() {
351        import_path.to_string()
352    } else {
353        format!("{}/{}", dir, import_path)
354    };
355
356    normalize_virtual_path(&combined)
357}
358
359/// Normalize a virtual path by resolving `.` and `..` segments.
360fn normalize_virtual_path(path: &str) -> String {
361    let mut parts: Vec<&str> = Vec::new();
362    for part in path.split('/') {
363        match part {
364            "." | "" => {}
365            ".." => {
366                parts.pop();
367            }
368            other => parts.push(other),
369        }
370    }
371    parts.join("/")
372}
373
374// ─── Shared helpers ─────────────────────────────────────────────────────
375
376/// Extract reactive signal names from script setup (ref/computed declarations).
377pub fn extract_reactive_names(script: &str) -> Vec<String> {
378    let ref_re = Regex::new(r#"const\s+(\w+)\s*=\s*ref\("#).unwrap();
379    let computed_re = Regex::new(r#"const\s+(\w+)\s*=\s*computed\("#).unwrap();
380    let mut names = Vec::new();
381    for cap in ref_re.captures_iter(script) {
382        names.push(cap[1].to_string());
383    }
384    for cap in computed_re.captures_iter(script) {
385        names.push(cap[1].to_string());
386    }
387    names
388}
389
390/// Interpolate `{{ expr }}` / `{{{ expr }}}` but leave reactive expressions as-is.
391///
392/// - `{{ expr }}` — HTML-escaped output (default, safe)
393/// - `{{{ expr }}}` — raw output (no escaping, for trusted HTML content)
394fn interpolate_skip_reactive(template: &str, data: &Value, reactive_names: &[String]) -> String {
395    let mut result = String::with_capacity(template.len());
396    let mut rest = template;
397
398    // Closure: check if expression references any reactive signal
399    let check_reactive = |expr: &str| -> bool {
400        reactive_names.iter().any(|name| {
401            let bytes = expr.as_bytes();
402            let name_bytes = name.as_bytes();
403            let name_len = name.len();
404            let mut i = 0;
405            while i + name_len <= bytes.len() {
406                if &bytes[i..i + name_len] == name_bytes {
407                    let before_ok = i == 0 || !(bytes[i - 1] as char).is_alphanumeric();
408                    let after_ok = i + name_len == bytes.len()
409                        || !(bytes[i + name_len] as char).is_alphanumeric();
410                    if before_ok && after_ok {
411                        return true;
412                    }
413                }
414                i += 1;
415            }
416            false
417        })
418    };
419
420    while let Some(start) = rest.find("{{") {
421        result.push_str(&rest[..start]);
422
423        // Check for triple mustache {{{ }}} (raw, unescaped output)
424        if rest[start..].starts_with("{{{") {
425            let after_open = &rest[start + 3..];
426            if let Some(end) = after_open.find("}}}") {
427                let expr = after_open[..end].trim();
428                // $t() is always resolved immediately (never reactive)
429                if let Some(translated) = try_resolve_t(expr, data) {
430                    result.push_str(&translated);
431                } else if expr.trim().starts_with("$t(") {
432                    // $t() but no $i18n data — preserve for runtime resolution
433                    result.push_str(&format!("{{{{{{{}}}}}}}", expr));
434                } else if check_reactive(expr) {
435                    // Keep reactive as double-mustache for signal runtime
436                    result.push_str(&format!("{{{{ {expr} }}}}"));
437                } else {
438                    let value = resolve_json_path(data, expr);
439                    result.push_str(&value);
440                }
441                rest = &after_open[end + 3..];
442            } else {
443                result.push_str("{{{");
444                rest = &rest[start + 3..];
445            }
446        } else {
447            let after_open = &rest[start + 2..];
448            if let Some(end) = after_open.find("}}") {
449                let expr = after_open[..end].trim();
450                // $t() is always resolved immediately (never reactive)
451                if let Some(translated) = try_resolve_t(expr, data) {
452                    result.push_str(&escape_html(&translated));
453                } else if expr.trim().starts_with("$t(") {
454                    // $t() but no $i18n data — preserve for runtime resolution
455                    result.push_str(&format!("{{{{{}}}}}", expr));
456                } else if check_reactive(expr) {
457                    result.push_str(&format!("{{{{ {expr} }}}}"));
458                } else {
459                    let value = resolve_json_path(data, expr);
460                    result.push_str(&escape_html(&value));
461                }
462                rest = &after_open[end + 2..];
463            } else {
464                result.push_str("{{");
465                rest = after_open;
466            }
467        }
468    }
469    result.push_str(rest);
470    result
471}
472
473// ─── Component tag matching ─────────────────────────────────────────────
474
475/// Information about a matched component tag in the template.
476struct TagInfo {
477    tag_name: String,
478    attrs: String,
479    children: String,
480    start: usize,
481    end: usize,
482}
483
484/// Find the first component tag in the template that matches an import.
485/// Searches both kebab-case (`default-layout`) and PascalCase (`DefaultLayout`) forms.
486fn find_component_tag(template: &str, import_map: &HashMap<String, &VanImport>) -> Option<TagInfo> {
487    for (tag_name, imp) in import_map {
488        // Try kebab-case first (e.g. `<default-layout>`)
489        if let Some(info) = extract_component_tag(template, tag_name) {
490            return Some(info);
491        }
492        // Try PascalCase (e.g. `<DefaultLayout>`)
493        if imp.name != *tag_name {
494            if let Some(mut info) = extract_component_tag(template, &imp.name) {
495                info.tag_name = tag_name.clone(); // normalize to kebab for import_map lookup
496                return Some(info);
497            }
498        }
499    }
500    None
501}
502
503/// Extract a component tag (self-closing or paired) from the template.
504fn extract_component_tag(template: &str, tag_name: &str) -> Option<TagInfo> {
505    let open_pattern = format!("<{}", tag_name);
506
507    let start = template.find(&open_pattern)?;
508
509    // Verify it's a complete tag name (next char must be space, /, or >)
510    let after_tag = start + open_pattern.len();
511    if after_tag < template.len() {
512        let next_ch = template.as_bytes()[after_tag] as char;
513        if next_ch != ' '
514            && next_ch != '/'
515            && next_ch != '>'
516            && next_ch != '\n'
517            && next_ch != '\r'
518            && next_ch != '\t'
519        {
520            return None;
521        }
522    }
523
524    // Find the end of the opening tag '>'
525    let rest = &template[start..];
526    let gt_pos = rest.find('>')?;
527
528    // Check for self-closing: ends with />
529    let is_self_closing = rest[..gt_pos].ends_with('/');
530
531    if is_self_closing {
532        let attr_start = open_pattern.len();
533        let attr_end = gt_pos;
534        let attrs_str = &rest[attr_start..attr_end].trim_end_matches('/').trim();
535
536        return Some(TagInfo {
537            tag_name: tag_name.to_string(),
538            attrs: attrs_str.to_string(),
539            children: String::new(),
540            start,
541            end: start + gt_pos + 1,
542        });
543    }
544
545    // Paired tag: find matching closing tag </tag-name>
546    let content_start = start + gt_pos + 1;
547    let close_tag = format!("</{}>", tag_name);
548
549    let remaining = &template[content_start..];
550    let close_pos = remaining.find(&close_tag)?;
551
552    let attrs_raw = &rest[tag_name.len() + 1..gt_pos];
553    let children = remaining[..close_pos].to_string();
554
555    Some(TagInfo {
556        tag_name: tag_name.to_string(),
557        attrs: attrs_raw.trim().to_string(),
558        children,
559        start,
560        end: content_start + close_pos + close_tag.len(),
561    })
562}
563
564// ─── Props ──────────────────────────────────────────────────────────────
565
566/// Parse `:prop="expr"` attributes and resolve them against parent data.
567fn parse_props(attrs: &str, parent_data: &Value) -> Value {
568    let re = Regex::new(r#":(\w+)="([^"]*)""#).unwrap();
569    let mut map = serde_json::Map::new();
570
571    for cap in re.captures_iter(attrs) {
572        let key = &cap[1];
573        let expr = &cap[2];
574        // Resolve $t() calls in prop bindings
575        let value_str = if let Some(translated) = try_resolve_t(expr, parent_data) {
576            translated
577        } else {
578            resolve_json_path(parent_data, expr)
579        };
580        map.insert(key.to_string(), Value::String(value_str));
581    }
582
583    // Inherit $i18n from parent so child components can use $t()
584    if let Some(i18n_data) = parent_data.get("$i18n") {
585        map.insert("$i18n".to_string(), i18n_data.clone());
586    }
587
588    Value::Object(map)
589}
590
591// ─── Slots ──────────────────────────────────────────────────────────────
592
593/// Parsed slot content keyed by slot name ("default" for unnamed).
594type SlotMap = HashMap<String, String>;
595
596/// Result of parsing slot content, including collected styles from resolved components.
597struct SlotResult {
598    slots: SlotMap,
599    styles: Vec<String>,
600    script_setup: Option<String>,
601    module_imports: Vec<ResolvedModule>,
602}
603
604/// Parse `<template #name>...</template>` blocks and default content from children.
605fn parse_slot_content(
606    children: &str,
607    parent_data: &Value,
608    parent_imports: &[VanImport],
609    current_path: &str,
610    files: &HashMap<String, String>,
611    depth: usize,
612    reactive_names: &[String],
613    debug: bool,
614    file_origins: &HashMap<String, String>,
615) -> Result<SlotResult, String> {
616    let mut slots = SlotMap::new();
617    let mut styles: Vec<String> = Vec::new();
618    let mut default_parts: Vec<String> = Vec::new();
619    let mut rest = children;
620
621    let named_slot_re = Regex::new(r#"<template\s+#(\w+)\s*>"#).unwrap();
622
623    loop {
624        let Some(cap) = named_slot_re.captures(rest) else {
625            let trimmed = rest.trim();
626            if !trimmed.is_empty() {
627                default_parts.push(trimmed.to_string());
628            }
629            break;
630        };
631
632        let full_match = cap.get(0).unwrap();
633        let slot_name = cap[1].to_string();
634
635        // Content before this named slot is default content
636        let before = rest[..full_match.start()].trim();
637        if !before.is_empty() {
638            default_parts.push(before.to_string());
639        }
640
641        // Find closing </template>
642        let after_open = &rest[full_match.end()..];
643        let close_pos = after_open.find("</template>");
644        let slot_content = if let Some(pos) = close_pos {
645            let content = after_open[..pos].trim().to_string();
646            rest = &after_open[pos + "</template>".len()..];
647            content
648        } else {
649            let content = after_open.trim().to_string();
650            rest = "";
651            content
652        };
653
654        // Interpolate named slot content with parent data
655        let interpolated = if !reactive_names.is_empty() {
656            interpolate_skip_reactive(&slot_content, parent_data, reactive_names)
657        } else {
658            interpolate(&slot_content, parent_data)
659        };
660        slots.insert(slot_name, interpolated);
661    }
662
663    // Process default slot content: resolve any child components using parent's import context
664    let mut script_setup = None;
665    let mut module_imports = Vec::new();
666    if !default_parts.is_empty() {
667        let default_content = default_parts.join("\n");
668
669        let parent_import_map: HashMap<String, &VanImport> = parent_imports
670            .iter()
671            .map(|imp| (imp.tag_name.clone(), imp))
672            .collect();
673
674        let resolved = resolve_slot_components(
675            &default_content,
676            parent_data,
677            &parent_import_map,
678            current_path,
679            files,
680            depth,
681            reactive_names,
682            debug,
683            file_origins,
684        )?;
685
686        slots.insert("default".to_string(), resolved.html);
687        styles.extend(resolved.styles);
688        script_setup = resolved.script_setup;
689        module_imports = resolved.module_imports;
690    }
691
692    Ok(SlotResult { slots, styles, script_setup, module_imports })
693}
694
695/// Resolve component tags within slot content using the parent's import context.
696fn resolve_slot_components(
697    content: &str,
698    data: &Value,
699    import_map: &HashMap<String, &VanImport>,
700    current_path: &str,
701    files: &HashMap<String, String>,
702    depth: usize,
703    reactive_names: &[String],
704    debug: bool,
705    file_origins: &HashMap<String, String>,
706) -> Result<ResolvedComponent, String> {
707    let mut result = content.to_string();
708    let mut styles: Vec<String> = Vec::new();
709    let mut child_scripts: Vec<String> = Vec::new();
710    let mut child_module_imports: Vec<ResolvedModule> = Vec::new();
711
712    loop {
713        let tag_match = find_component_tag(&result, import_map);
714        let Some(tag_info) = tag_match else {
715            break;
716        };
717
718        let imp = &import_map[&tag_info.tag_name];
719        let resolved_key = resolve_virtual_path(current_path, &imp.path);
720        let component_source = files
721            .get(&resolved_key)
722            .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
723
724        let child_data = parse_props(&tag_info.attrs, data);
725
726        let child_resolved = resolve_recursive(
727            component_source,
728            &child_data,
729            &resolved_key,
730            files,
731            depth + 1,
732            reactive_names,
733            debug,
734            file_origins,
735        )?;
736
737        let with_slots = distribute_slots(&child_resolved.html, &HashMap::new(), debug, &HashMap::new());
738        styles.extend(child_resolved.styles);
739
740        // Collect child script_setup and module_imports for merging
741        if let Some(ref cs) = child_resolved.script_setup {
742            child_scripts.push(cs.clone());
743        }
744        child_module_imports.extend(child_resolved.module_imports);
745
746        let replacement = if debug {
747            let theme_prefix = file_origins.get(&resolved_key)
748                .map(|t| format!("[{t}] "))
749                .unwrap_or_default();
750            format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
751        } else {
752            with_slots
753        };
754
755        result = format!(
756            "{}{}{}",
757            &result[..tag_info.start],
758            replacement,
759            &result[tag_info.end..],
760        );
761    }
762
763    // Interpolate remaining {{ }} with parent data (reactive-aware)
764    let html = if !reactive_names.is_empty() {
765        interpolate_skip_reactive(&result, data, reactive_names)
766    } else {
767        interpolate(&result, data)
768    };
769
770    // Merge collected child scripts
771    let script_setup = if !child_scripts.is_empty() {
772        Some(child_scripts.join("\n"))
773    } else {
774        None
775    };
776
777    Ok(ResolvedComponent {
778        html,
779        styles,
780        script_setup,
781        module_imports: child_module_imports,
782    })
783}
784
785/// Replace `<slot />` and `<slot name="x">fallback</slot>` with provided content.
786///
787/// `slot_themes` maps slot_name → theme_name for debug comments.
788/// Only shown for explicitly provided slots, not for fallback defaults.
789fn distribute_slots(html: &str, slots: &SlotMap, debug: bool, slot_themes: &HashMap<String, String>) -> String {
790    let mut result = html.to_string();
791
792    // Helper: build theme prefix for a given slot
793    let tp = |name: &str| -> String {
794        slot_themes.get(name)
795            .filter(|t| !t.is_empty())
796            .map(|t| format!("[{t}] "))
797            .unwrap_or_default()
798    };
799
800    // Handle named slots: <slot name="x">fallback</slot>
801    let named_re = Regex::new(r#"<slot\s+name="(\w+)">([\s\S]*?)</slot>"#).unwrap();
802    result = named_re
803        .replace_all(&result, |caps: &regex::Captures| {
804            let name = &caps[1];
805            let fallback = &caps[2];
806            let provided = slots.get(name);
807            let content = provided
808                .cloned()
809                .unwrap_or_else(|| fallback.trim().to_string());
810            if debug {
811                let p = if provided.is_some() { tp(name) } else { String::new() };
812                format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
813            } else {
814                content
815            }
816        })
817        .to_string();
818
819    // Handle named self-closing slots: <slot name="x" />
820    let named_sc_re = Regex::new(r#"<slot\s+name="(\w+)"\s*/>"#).unwrap();
821    result = named_sc_re
822        .replace_all(&result, |caps: &regex::Captures| {
823            let name = &caps[1];
824            let provided = slots.get(name);
825            let content = provided.cloned().unwrap_or_default();
826            if debug {
827                let p = if provided.is_some() { tp(name) } else { String::new() };
828                format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
829            } else {
830                content
831            }
832        })
833        .to_string();
834
835    // Handle default slot: <slot /> (self-closing)
836    let default_sc_re = Regex::new(r#"<slot\s*/>"#).unwrap();
837    result = default_sc_re
838        .replace_all(&result, |_: &regex::Captures| {
839            let provided = slots.get("default");
840            let content = provided.cloned().unwrap_or_default();
841            if debug {
842                let p = if provided.is_some() { tp("default") } else { String::new() };
843                format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
844            } else {
845                content
846            }
847        })
848        .to_string();
849
850    // Handle default slot with fallback: <slot>fallback</slot>
851    let default_re = Regex::new(r#"<slot>([\s\S]*?)</slot>"#).unwrap();
852    result = default_re
853        .replace_all(&result, |caps: &regex::Captures| {
854            let fallback = &caps[1];
855            let provided = slots.get("default");
856            let content = provided
857                .cloned()
858                .unwrap_or_else(|| fallback.trim().to_string());
859            if debug {
860                let p = if provided.is_some() { tp("default") } else { String::new() };
861                format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
862            } else {
863                content
864            }
865        })
866        .to_string();
867
868    result
869}
870
871/// Resolve a dot-separated path and return the raw JSON Value.
872fn resolve_path_value<'a>(data: &'a Value, path: &str) -> Option<&'a Value> {
873    let mut current = data;
874    for key in path.split('.') {
875        let key = key.trim();
876        match current.get(key) {
877            Some(v) => current = v,
878            None => return None,
879        }
880    }
881    Some(current)
882}
883
884/// Expand `v-for` directives by repeating elements for each array item.
885fn expand_v_for(template: &str, data: &Value) -> String {
886    let vfor_re = Regex::new(r#"<(\w[\w-]*)([^>]*)\sv-for="([^"]*)"([^>]*)>"#).unwrap();
887    let mut result = template.to_string();
888
889    for _ in 0..20 {
890        let Some(cap) = vfor_re.captures(&result) else {
891            break;
892        };
893
894        let full_match = cap.get(0).unwrap();
895        let tag_name = &cap[1];
896        let attrs_before = &cap[2];
897        let vfor_expr = &cap[3];
898        let attrs_after = &cap[4];
899
900        let (item_var, index_var, array_expr) = parse_vfor_expr(vfor_expr);
901        let open_tag_no_vfor = format!("<{}{}{}>", tag_name, attrs_before, attrs_after);
902        let match_start = full_match.start();
903        let after_open = full_match.end();
904        let is_self_closing = result[match_start..after_open].trim_end_matches('>').ends_with('/');
905
906        if is_self_closing {
907            let sc_tag = format!("<{}{}{} />", tag_name, attrs_before, attrs_after);
908            let array = resolve_path_value(data, &array_expr);
909            let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
910            let mut expanded = String::new();
911            for (idx, item) in items.iter().enumerate() {
912                let mut item_data = data.clone();
913                if let Value::Object(ref mut map) = item_data {
914                    map.insert(item_var.clone(), item.clone());
915                    if let Some(ref idx_var) = index_var {
916                        map.insert(idx_var.clone(), Value::Number(idx.into()));
917                    }
918                }
919                expanded.push_str(&interpolate(&sc_tag, &item_data));
920            }
921            result = format!("{}{}{}", &result[..match_start], expanded, &result[after_open..]);
922            continue;
923        }
924
925        let close_tag = format!("</{}>", tag_name);
926        let remaining = &result[after_open..];
927        let close_pos = find_matching_close_tag(remaining, tag_name);
928        let inner_content = remaining[..close_pos].to_string();
929        let element_end = after_open + close_pos + close_tag.len();
930
931        let array = resolve_path_value(data, &array_expr);
932        let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
933        let mut expanded = String::new();
934        for (idx, item) in items.iter().enumerate() {
935            let mut item_data = data.clone();
936            if let Value::Object(ref mut map) = item_data {
937                map.insert(item_var.clone(), item.clone());
938                if let Some(ref idx_var) = index_var {
939                    map.insert(idx_var.clone(), Value::Number(idx.into()));
940                }
941            }
942            let tag_interpolated = interpolate(&open_tag_no_vfor, &item_data);
943            let inner_interpolated = interpolate(&inner_content, &item_data);
944            expanded.push_str(&format!("{}{}</{}>", tag_interpolated, inner_interpolated, tag_name));
945        }
946
947        result = format!("{}{}{}", &result[..match_start], expanded, &result[element_end..]);
948    }
949
950    result
951}
952
953fn parse_vfor_expr(expr: &str) -> (String, Option<String>, String) {
954    let parts: Vec<&str> = expr.splitn(2, " in ").collect();
955    if parts.len() != 2 {
956        return (expr.to_string(), None, String::new());
957    }
958    let lhs = parts[0].trim();
959    let array_expr = parts[1].trim().to_string();
960    if lhs.starts_with('(') && lhs.ends_with(')') {
961        let inner = &lhs[1..lhs.len() - 1];
962        let vars: Vec<&str> = inner.split(',').collect();
963        let item_var = vars[0].trim().to_string();
964        let index_var = vars.get(1).map(|v| v.trim().to_string());
965        (item_var, index_var, array_expr)
966    } else {
967        (lhs.to_string(), None, array_expr)
968    }
969}
970
971fn find_matching_close_tag(html: &str, tag_name: &str) -> usize {
972    let open = format!("<{}", tag_name);
973    let close = format!("</{}>", tag_name);
974    let mut depth = 0;
975    let mut pos = 0;
976    while pos < html.len() {
977        if html[pos..].starts_with(&close) {
978            if depth == 0 {
979                return pos;
980            }
981            depth -= 1;
982            pos += close.len();
983        } else if html[pos..].starts_with(&open) {
984            let after = pos + open.len();
985            if after < html.len() {
986                let ch = html.as_bytes()[after] as char;
987                if ch == ' ' || ch == '>' || ch == '/' || ch == '\n' || ch == '\t' {
988                    depth += 1;
989                }
990            }
991            pos += open.len();
992        } else {
993            pos += 1;
994        }
995    }
996    html.len()
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use serde_json::json;
1003
1004    #[test]
1005    fn test_extract_reactive_names() {
1006        let script = r#"
1007const count = ref(0)
1008const doubled = computed(() => count * 2)
1009"#;
1010        let names = extract_reactive_names(script);
1011        assert_eq!(names, vec!["count", "doubled"]);
1012    }
1013
1014    #[test]
1015    fn test_resolve_single_basic() {
1016        let source = r#"
1017<template>
1018  <h1>{{ title }}</h1>
1019</template>
1020"#;
1021        let data = json!({"title": "Hello"});
1022        let resolved = resolve_single(source, &data).unwrap();
1023        assert!(resolved.html.contains("<h1>Hello</h1>"));
1024        assert!(resolved.styles.is_empty());
1025        assert!(resolved.script_setup.is_none());
1026    }
1027
1028    #[test]
1029    fn test_resolve_single_with_style() {
1030        let source = r#"
1031<template>
1032  <h1>Hello</h1>
1033</template>
1034
1035<style scoped>
1036h1 { color: red; }
1037</style>
1038"#;
1039        let data = json!({});
1040        let resolved = resolve_single(source, &data).unwrap();
1041        assert_eq!(resolved.styles.len(), 1);
1042        assert!(resolved.styles[0].contains("color: red"));
1043    }
1044
1045    #[test]
1046    fn test_resolve_single_reactive() {
1047        let source = r#"
1048<template>
1049  <p>Count: {{ count }}</p>
1050</template>
1051
1052<script setup>
1053const count = ref(0)
1054</script>
1055"#;
1056        let data = json!({});
1057        let resolved = resolve_single(source, &data).unwrap();
1058        assert!(resolved.html.contains("{{ count }}"));
1059        assert!(resolved.script_setup.is_some());
1060    }
1061
1062    // ─── Virtual path tests ─────────────────────────────────────────
1063
1064    #[test]
1065    fn test_resolve_virtual_path_same_dir() {
1066        assert_eq!(
1067            resolve_virtual_path("index.van", "./hello.van"),
1068            "hello.van"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_resolve_virtual_path_parent_dir() {
1074        assert_eq!(
1075            resolve_virtual_path("pages/index.van", "../components/hello.van"),
1076            "components/hello.van"
1077        );
1078    }
1079
1080    #[test]
1081    fn test_resolve_virtual_path_subdir() {
1082        assert_eq!(
1083            resolve_virtual_path("pages/index.van", "./sub.van"),
1084            "pages/sub.van"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_normalize_virtual_path() {
1090        assert_eq!(normalize_virtual_path("./hello.van"), "hello.van");
1091        assert_eq!(
1092            normalize_virtual_path("pages/../components/hello.van"),
1093            "components/hello.van"
1094        );
1095        assert_eq!(normalize_virtual_path("a/b/./c"), "a/b/c");
1096    }
1097
1098    #[test]
1099    fn test_resolve_virtual_path_scoped_package() {
1100        // @scope/pkg paths should be returned as-is regardless of current file
1101        assert_eq!(
1102            resolve_virtual_path("pages/index.van", "@van-ui/button/button.van"),
1103            "@van-ui/button/button.van"
1104        );
1105        assert_eq!(
1106            resolve_virtual_path("index.van", "@van-ui/utils/format.ts"),
1107            "@van-ui/utils/format.ts"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_resolve_with_files_scoped_import() {
1113        let mut files = HashMap::new();
1114        files.insert(
1115            "index.van".to_string(),
1116            r#"
1117<template>
1118  <van-button :label="title" />
1119</template>
1120
1121<script setup>
1122import VanButton from '@van-ui/button/button.van'
1123</script>
1124"#
1125            .to_string(),
1126        );
1127        // In-memory file map: key is "@van-ui/button/button.van"
1128        files.insert(
1129            "@van-ui/button/button.van".to_string(),
1130            r#"
1131<template>
1132  <button>{{ label }}</button>
1133</template>
1134"#
1135            .to_string(),
1136        );
1137
1138        let data = json!({"title": "Click me"});
1139        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1140        assert!(resolved.html.contains("<button>Click me</button>"));
1141    }
1142
1143    // ─── Multi-file resolve tests ───────────────────────────────────
1144
1145    #[test]
1146    fn test_resolve_with_files_basic_import() {
1147        let mut files = HashMap::new();
1148        files.insert(
1149            "index.van".to_string(),
1150            r#"
1151<template>
1152  <hello :name="title" />
1153</template>
1154
1155<script setup>
1156import Hello from './hello.van'
1157</script>
1158"#
1159            .to_string(),
1160        );
1161        files.insert(
1162            "hello.van".to_string(),
1163            r#"
1164<template>
1165  <h1>Hello, {{ name }}!</h1>
1166</template>
1167"#
1168            .to_string(),
1169        );
1170
1171        let data = json!({"title": "World"});
1172        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1173        assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1174    }
1175
1176    #[test]
1177    fn test_resolve_with_files_missing_component() {
1178        let mut files = HashMap::new();
1179        files.insert(
1180            "index.van".to_string(),
1181            r#"
1182<template>
1183  <hello />
1184</template>
1185
1186<script setup>
1187import Hello from './hello.van'
1188</script>
1189"#
1190            .to_string(),
1191        );
1192
1193        let data = json!({});
1194        let result = resolve_with_files("index.van", &files, &data);
1195        assert!(result.is_err());
1196        assert!(result.unwrap_err().contains("Component not found"));
1197    }
1198
1199    #[test]
1200    fn test_resolve_with_files_slots() {
1201        let mut files = HashMap::new();
1202        files.insert(
1203            "index.van".to_string(),
1204            r#"
1205<template>
1206  <wrapper>
1207    <p>Default slot content</p>
1208  </wrapper>
1209</template>
1210
1211<script setup>
1212import Wrapper from './wrapper.van'
1213</script>
1214"#
1215            .to_string(),
1216        );
1217        files.insert(
1218            "wrapper.van".to_string(),
1219            r#"
1220<template>
1221  <div class="wrapper"><slot /></div>
1222</template>
1223"#
1224            .to_string(),
1225        );
1226
1227        let data = json!({});
1228        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1229        assert!(resolved.html.contains("<div class=\"wrapper\">"));
1230        assert!(resolved.html.contains("<p>Default slot content</p>"));
1231    }
1232
1233    #[test]
1234    fn test_resolve_with_files_styles_collected() {
1235        let mut files = HashMap::new();
1236        files.insert(
1237            "index.van".to_string(),
1238            r#"
1239<template>
1240  <hello />
1241</template>
1242
1243<script setup>
1244import Hello from './hello.van'
1245</script>
1246
1247<style>
1248.app { color: blue; }
1249</style>
1250"#
1251            .to_string(),
1252        );
1253        files.insert(
1254            "hello.van".to_string(),
1255            r#"
1256<template>
1257  <h1>Hello</h1>
1258</template>
1259
1260<style>
1261h1 { color: red; }
1262</style>
1263"#
1264            .to_string(),
1265        );
1266
1267        let data = json!({});
1268        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1269        assert_eq!(resolved.styles.len(), 2);
1270        assert!(resolved.styles[0].contains("color: blue"));
1271        assert!(resolved.styles[1].contains("color: red"));
1272    }
1273
1274    #[test]
1275    fn test_resolve_with_files_reactive_preserved() {
1276        let mut files = HashMap::new();
1277        files.insert(
1278            "index.van".to_string(),
1279            r#"
1280<template>
1281  <div>
1282    <p>Count: {{ count }}</p>
1283    <hello :name="title" />
1284  </div>
1285</template>
1286
1287<script setup>
1288import Hello from './hello.van'
1289const count = ref(0)
1290</script>
1291"#
1292            .to_string(),
1293        );
1294        files.insert(
1295            "hello.van".to_string(),
1296            r#"
1297<template>
1298  <h1>Hello, {{ name }}!</h1>
1299</template>
1300"#
1301            .to_string(),
1302        );
1303
1304        let data = json!({"title": "World"});
1305        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1306        // Reactive expression should be preserved
1307        assert!(resolved.html.contains("{{ count }}"));
1308        // Non-reactive prop should be interpolated
1309        assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1310        assert!(resolved.script_setup.is_some());
1311    }
1312
1313    // ─── Component tag extraction tests ─────────────────────────────
1314
1315    #[test]
1316    fn test_extract_self_closing_tag() {
1317        let template = r#"<div><hello :name="title" /></div>"#;
1318        let info = extract_component_tag(template, "hello").unwrap();
1319        assert_eq!(info.tag_name, "hello");
1320        assert_eq!(info.attrs, r#":name="title""#);
1321        assert!(info.children.is_empty());
1322    }
1323
1324    #[test]
1325    fn test_extract_paired_tag() {
1326        let template = r#"<default-layout><h1>Content</h1></default-layout>"#;
1327        let info = extract_component_tag(template, "default-layout").unwrap();
1328        assert_eq!(info.tag_name, "default-layout");
1329        assert_eq!(info.children, "<h1>Content</h1>");
1330    }
1331
1332    #[test]
1333    fn test_extract_no_match() {
1334        let template = r#"<div>no components here</div>"#;
1335        assert!(extract_component_tag(template, "hello").is_none());
1336    }
1337
1338    #[test]
1339    fn test_parse_props() {
1340        let data = json!({"title": "World", "count": 42});
1341        let attrs = r#":name="title" :num="count""#;
1342        let result = parse_props(attrs, &data);
1343        assert_eq!(result["name"], "World");
1344        assert_eq!(result["num"], "42");
1345    }
1346
1347    #[test]
1348    fn test_distribute_slots_default() {
1349        let html = r#"<div><slot /></div>"#;
1350        let mut slots = HashMap::new();
1351        slots.insert("default".to_string(), "Hello World".to_string());
1352        let result = distribute_slots(html, &slots, false, &HashMap::new());
1353        assert_eq!(result, "<div>Hello World</div>");
1354    }
1355
1356    #[test]
1357    fn test_distribute_slots_named() {
1358        let html =
1359            r#"<title><slot name="title">Fallback</slot></title><div><slot /></div>"#;
1360        let mut slots = HashMap::new();
1361        slots.insert("title".to_string(), "My Title".to_string());
1362        slots.insert("default".to_string(), "Body".to_string());
1363        let result = distribute_slots(html, &slots, false, &HashMap::new());
1364        assert_eq!(result, "<title>My Title</title><div>Body</div>");
1365    }
1366
1367    #[test]
1368    fn test_distribute_slots_fallback() {
1369        let html = r#"<title><slot name="title">Fallback Title</slot></title>"#;
1370        let slots = HashMap::new();
1371        let result = distribute_slots(html, &slots, false, &HashMap::new());
1372        assert_eq!(result, "<title>Fallback Title</title>");
1373    }
1374
1375    #[test]
1376    fn test_expand_v_for_basic() {
1377        let data = json!({"items": ["Alice", "Bob", "Charlie"]});
1378        let template = r#"<ul><li v-for="item in items">{{ item }}</li></ul>"#;
1379        let result = expand_v_for(template, &data);
1380        assert!(result.contains("<li>Alice</li>"));
1381        assert!(result.contains("<li>Bob</li>"));
1382        assert!(result.contains("<li>Charlie</li>"));
1383        assert!(!result.contains("v-for"));
1384    }
1385
1386    #[test]
1387    fn test_expand_v_for_with_index() {
1388        let data = json!({"items": ["A", "B"]});
1389        let template = r#"<ul><li v-for="(item, index) in items">{{ index }}: {{ item }}</li></ul>"#;
1390        let result = expand_v_for(template, &data);
1391        assert!(result.contains("0: A"));
1392        assert!(result.contains("1: B"));
1393    }
1394
1395    #[test]
1396    fn test_expand_v_for_nested_path() {
1397        let data = json!({"user": {"hobbies": ["coding", "reading"]}});
1398        let template = r#"<span v-for="h in user.hobbies">{{ h }}</span>"#;
1399        let result = expand_v_for(template, &data);
1400        assert!(result.contains("<span>coding</span>"));
1401        assert!(result.contains("<span>reading</span>"));
1402    }
1403
1404    // ─── Scoped style tests ──────────────────────────────────────────
1405
1406    #[test]
1407    fn test_resolve_scoped_style_single() {
1408        let source = r#"
1409<template>
1410  <div class="card"><h1>{{ title }}</h1></div>
1411</template>
1412
1413<style scoped>
1414.card { border: 1px solid; }
1415h1 { color: navy; }
1416</style>
1417"#;
1418        let data = json!({"title": "Hello"});
1419        let css = ".card { border: 1px solid; }\nh1 { color: navy; }";
1420        let id = van_parser::scope_id(css);
1421        let resolved = resolve_single_with_path(source, &data, "components/card.van").unwrap();
1422        // All elements should have scope class
1423        assert!(resolved.html.contains(&format!("class=\"card {id}\"")), "Root should have scope class appended");
1424        assert!(resolved.html.contains(&format!("class=\"{id}\"")), "Child h1 should have scope class");
1425        // CSS selectors should have .{id} appended
1426        assert_eq!(resolved.styles.len(), 1);
1427        assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1428        assert!(resolved.styles[0].contains(&format!("h1.{id}")));
1429    }
1430
1431    #[test]
1432    fn test_resolve_scoped_style_multi_file() {
1433        let mut files = HashMap::new();
1434        files.insert(
1435            "index.van".to_string(),
1436            r#"
1437<template>
1438  <card :title="title" />
1439</template>
1440
1441<script setup>
1442import Card from './card.van'
1443</script>
1444"#.to_string(),
1445        );
1446        files.insert(
1447            "card.van".to_string(),
1448            r#"
1449<template>
1450  <div class="card"><h1>{{ title }}</h1></div>
1451</template>
1452
1453<style scoped>
1454.card { border: 1px solid; }
1455</style>
1456"#.to_string(),
1457        );
1458
1459        let data = json!({"title": "Test"});
1460        let id = van_parser::scope_id(".card { border: 1px solid; }");
1461        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1462        // Child component HTML should have scope class on all elements
1463        assert!(resolved.html.contains(&format!("card {id}")), "Should contain scope class");
1464        // CSS selectors should have .{id} appended
1465        assert_eq!(resolved.styles.len(), 1);
1466        assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1467    }
1468
1469    #[test]
1470    fn test_resolve_unscoped_style_unchanged() {
1471        let source = r#"
1472<template>
1473  <div class="app"><p>Hello</p></div>
1474</template>
1475
1476<style>
1477.app { margin: 0; }
1478</style>
1479"#;
1480        let data = json!({});
1481        let resolved = resolve_single(source, &data).unwrap();
1482        // HTML should be unchanged — no extra scope classes
1483        assert_eq!(resolved.html.matches("class=").count(), 1, "Only the original class attr");
1484        assert!(resolved.html.contains("class=\"app\""), "Original class preserved");
1485        assert_eq!(resolved.styles[0], ".app { margin: 0; }");
1486    }
1487}