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.
485fn find_component_tag(template: &str, import_map: &HashMap<String, &VanImport>) -> Option<TagInfo> {
486    for tag_name in import_map.keys() {
487        if let Some(info) = extract_component_tag(template, tag_name) {
488            return Some(info);
489        }
490    }
491    None
492}
493
494/// Extract a component tag (self-closing or paired) from the template.
495fn extract_component_tag(template: &str, tag_name: &str) -> Option<TagInfo> {
496    let open_pattern = format!("<{}", tag_name);
497
498    let start = template.find(&open_pattern)?;
499
500    // Verify it's a complete tag name (next char must be space, /, or >)
501    let after_tag = start + open_pattern.len();
502    if after_tag < template.len() {
503        let next_ch = template.as_bytes()[after_tag] as char;
504        if next_ch != ' '
505            && next_ch != '/'
506            && next_ch != '>'
507            && next_ch != '\n'
508            && next_ch != '\r'
509            && next_ch != '\t'
510        {
511            return None;
512        }
513    }
514
515    // Find the end of the opening tag '>'
516    let rest = &template[start..];
517    let gt_pos = rest.find('>')?;
518
519    // Check for self-closing: ends with />
520    let is_self_closing = rest[..gt_pos].ends_with('/');
521
522    if is_self_closing {
523        let attr_start = open_pattern.len();
524        let attr_end = gt_pos;
525        let attrs_str = &rest[attr_start..attr_end].trim_end_matches('/').trim();
526
527        return Some(TagInfo {
528            tag_name: tag_name.to_string(),
529            attrs: attrs_str.to_string(),
530            children: String::new(),
531            start,
532            end: start + gt_pos + 1,
533        });
534    }
535
536    // Paired tag: find matching closing tag </tag-name>
537    let content_start = start + gt_pos + 1;
538    let close_tag = format!("</{}>", tag_name);
539
540    let remaining = &template[content_start..];
541    let close_pos = remaining.find(&close_tag)?;
542
543    let attrs_raw = &rest[tag_name.len() + 1..gt_pos];
544    let children = remaining[..close_pos].to_string();
545
546    Some(TagInfo {
547        tag_name: tag_name.to_string(),
548        attrs: attrs_raw.trim().to_string(),
549        children,
550        start,
551        end: content_start + close_pos + close_tag.len(),
552    })
553}
554
555// ─── Props ──────────────────────────────────────────────────────────────
556
557/// Parse `:prop="expr"` attributes and resolve them against parent data.
558fn parse_props(attrs: &str, parent_data: &Value) -> Value {
559    let re = Regex::new(r#":(\w+)="([^"]*)""#).unwrap();
560    let mut map = serde_json::Map::new();
561
562    for cap in re.captures_iter(attrs) {
563        let key = &cap[1];
564        let expr = &cap[2];
565        // Resolve $t() calls in prop bindings
566        let value_str = if let Some(translated) = try_resolve_t(expr, parent_data) {
567            translated
568        } else {
569            resolve_json_path(parent_data, expr)
570        };
571        map.insert(key.to_string(), Value::String(value_str));
572    }
573
574    // Inherit $i18n from parent so child components can use $t()
575    if let Some(i18n_data) = parent_data.get("$i18n") {
576        map.insert("$i18n".to_string(), i18n_data.clone());
577    }
578
579    Value::Object(map)
580}
581
582// ─── Slots ──────────────────────────────────────────────────────────────
583
584/// Parsed slot content keyed by slot name ("default" for unnamed).
585type SlotMap = HashMap<String, String>;
586
587/// Result of parsing slot content, including collected styles from resolved components.
588struct SlotResult {
589    slots: SlotMap,
590    styles: Vec<String>,
591    script_setup: Option<String>,
592    module_imports: Vec<ResolvedModule>,
593}
594
595/// Parse `<template #name>...</template>` blocks and default content from children.
596fn parse_slot_content(
597    children: &str,
598    parent_data: &Value,
599    parent_imports: &[VanImport],
600    current_path: &str,
601    files: &HashMap<String, String>,
602    depth: usize,
603    reactive_names: &[String],
604    debug: bool,
605    file_origins: &HashMap<String, String>,
606) -> Result<SlotResult, String> {
607    let mut slots = SlotMap::new();
608    let mut styles: Vec<String> = Vec::new();
609    let mut default_parts: Vec<String> = Vec::new();
610    let mut rest = children;
611
612    let named_slot_re = Regex::new(r#"<template\s+#(\w+)\s*>"#).unwrap();
613
614    loop {
615        let Some(cap) = named_slot_re.captures(rest) else {
616            let trimmed = rest.trim();
617            if !trimmed.is_empty() {
618                default_parts.push(trimmed.to_string());
619            }
620            break;
621        };
622
623        let full_match = cap.get(0).unwrap();
624        let slot_name = cap[1].to_string();
625
626        // Content before this named slot is default content
627        let before = rest[..full_match.start()].trim();
628        if !before.is_empty() {
629            default_parts.push(before.to_string());
630        }
631
632        // Find closing </template>
633        let after_open = &rest[full_match.end()..];
634        let close_pos = after_open.find("</template>");
635        let slot_content = if let Some(pos) = close_pos {
636            let content = after_open[..pos].trim().to_string();
637            rest = &after_open[pos + "</template>".len()..];
638            content
639        } else {
640            let content = after_open.trim().to_string();
641            rest = "";
642            content
643        };
644
645        // Interpolate named slot content with parent data
646        let interpolated = if !reactive_names.is_empty() {
647            interpolate_skip_reactive(&slot_content, parent_data, reactive_names)
648        } else {
649            interpolate(&slot_content, parent_data)
650        };
651        slots.insert(slot_name, interpolated);
652    }
653
654    // Process default slot content: resolve any child components using parent's import context
655    let mut script_setup = None;
656    let mut module_imports = Vec::new();
657    if !default_parts.is_empty() {
658        let default_content = default_parts.join("\n");
659
660        let parent_import_map: HashMap<String, &VanImport> = parent_imports
661            .iter()
662            .map(|imp| (imp.tag_name.clone(), imp))
663            .collect();
664
665        let resolved = resolve_slot_components(
666            &default_content,
667            parent_data,
668            &parent_import_map,
669            current_path,
670            files,
671            depth,
672            reactive_names,
673            debug,
674            file_origins,
675        )?;
676
677        slots.insert("default".to_string(), resolved.html);
678        styles.extend(resolved.styles);
679        script_setup = resolved.script_setup;
680        module_imports = resolved.module_imports;
681    }
682
683    Ok(SlotResult { slots, styles, script_setup, module_imports })
684}
685
686/// Resolve component tags within slot content using the parent's import context.
687fn resolve_slot_components(
688    content: &str,
689    data: &Value,
690    import_map: &HashMap<String, &VanImport>,
691    current_path: &str,
692    files: &HashMap<String, String>,
693    depth: usize,
694    reactive_names: &[String],
695    debug: bool,
696    file_origins: &HashMap<String, String>,
697) -> Result<ResolvedComponent, String> {
698    let mut result = content.to_string();
699    let mut styles: Vec<String> = Vec::new();
700    let mut child_scripts: Vec<String> = Vec::new();
701    let mut child_module_imports: Vec<ResolvedModule> = Vec::new();
702
703    loop {
704        let tag_match = find_component_tag(&result, import_map);
705        let Some(tag_info) = tag_match else {
706            break;
707        };
708
709        let imp = &import_map[&tag_info.tag_name];
710        let resolved_key = resolve_virtual_path(current_path, &imp.path);
711        let component_source = files
712            .get(&resolved_key)
713            .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
714
715        let child_data = parse_props(&tag_info.attrs, data);
716
717        let child_resolved = resolve_recursive(
718            component_source,
719            &child_data,
720            &resolved_key,
721            files,
722            depth + 1,
723            reactive_names,
724            debug,
725            file_origins,
726        )?;
727
728        let with_slots = distribute_slots(&child_resolved.html, &HashMap::new(), debug, &HashMap::new());
729        styles.extend(child_resolved.styles);
730
731        // Collect child script_setup and module_imports for merging
732        if let Some(ref cs) = child_resolved.script_setup {
733            child_scripts.push(cs.clone());
734        }
735        child_module_imports.extend(child_resolved.module_imports);
736
737        let replacement = if debug {
738            let theme_prefix = file_origins.get(&resolved_key)
739                .map(|t| format!("[{t}] "))
740                .unwrap_or_default();
741            format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
742        } else {
743            with_slots
744        };
745
746        result = format!(
747            "{}{}{}",
748            &result[..tag_info.start],
749            replacement,
750            &result[tag_info.end..],
751        );
752    }
753
754    // Interpolate remaining {{ }} with parent data (reactive-aware)
755    let html = if !reactive_names.is_empty() {
756        interpolate_skip_reactive(&result, data, reactive_names)
757    } else {
758        interpolate(&result, data)
759    };
760
761    // Merge collected child scripts
762    let script_setup = if !child_scripts.is_empty() {
763        Some(child_scripts.join("\n"))
764    } else {
765        None
766    };
767
768    Ok(ResolvedComponent {
769        html,
770        styles,
771        script_setup,
772        module_imports: child_module_imports,
773    })
774}
775
776/// Replace `<slot />` and `<slot name="x">fallback</slot>` with provided content.
777///
778/// `slot_themes` maps slot_name → theme_name for debug comments.
779/// Only shown for explicitly provided slots, not for fallback defaults.
780fn distribute_slots(html: &str, slots: &SlotMap, debug: bool, slot_themes: &HashMap<String, String>) -> String {
781    let mut result = html.to_string();
782
783    // Helper: build theme prefix for a given slot
784    let tp = |name: &str| -> String {
785        slot_themes.get(name)
786            .filter(|t| !t.is_empty())
787            .map(|t| format!("[{t}] "))
788            .unwrap_or_default()
789    };
790
791    // Handle named slots: <slot name="x">fallback</slot>
792    let named_re = Regex::new(r#"<slot\s+name="(\w+)">([\s\S]*?)</slot>"#).unwrap();
793    result = named_re
794        .replace_all(&result, |caps: &regex::Captures| {
795            let name = &caps[1];
796            let fallback = &caps[2];
797            let provided = slots.get(name);
798            let content = provided
799                .cloned()
800                .unwrap_or_else(|| fallback.trim().to_string());
801            if debug {
802                let p = if provided.is_some() { tp(name) } else { String::new() };
803                format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
804            } else {
805                content
806            }
807        })
808        .to_string();
809
810    // Handle named self-closing slots: <slot name="x" />
811    let named_sc_re = Regex::new(r#"<slot\s+name="(\w+)"\s*/>"#).unwrap();
812    result = named_sc_re
813        .replace_all(&result, |caps: &regex::Captures| {
814            let name = &caps[1];
815            let provided = slots.get(name);
816            let content = provided.cloned().unwrap_or_default();
817            if debug {
818                let p = if provided.is_some() { tp(name) } else { String::new() };
819                format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
820            } else {
821                content
822            }
823        })
824        .to_string();
825
826    // Handle default slot: <slot /> (self-closing)
827    let default_sc_re = Regex::new(r#"<slot\s*/>"#).unwrap();
828    result = default_sc_re
829        .replace_all(&result, |_: &regex::Captures| {
830            let provided = slots.get("default");
831            let content = provided.cloned().unwrap_or_default();
832            if debug {
833                let p = if provided.is_some() { tp("default") } else { String::new() };
834                format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
835            } else {
836                content
837            }
838        })
839        .to_string();
840
841    // Handle default slot with fallback: <slot>fallback</slot>
842    let default_re = Regex::new(r#"<slot>([\s\S]*?)</slot>"#).unwrap();
843    result = default_re
844        .replace_all(&result, |caps: &regex::Captures| {
845            let fallback = &caps[1];
846            let provided = slots.get("default");
847            let content = provided
848                .cloned()
849                .unwrap_or_else(|| fallback.trim().to_string());
850            if debug {
851                let p = if provided.is_some() { tp("default") } else { String::new() };
852                format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
853            } else {
854                content
855            }
856        })
857        .to_string();
858
859    result
860}
861
862/// Resolve a dot-separated path and return the raw JSON Value.
863fn resolve_path_value<'a>(data: &'a Value, path: &str) -> Option<&'a Value> {
864    let mut current = data;
865    for key in path.split('.') {
866        let key = key.trim();
867        match current.get(key) {
868            Some(v) => current = v,
869            None => return None,
870        }
871    }
872    Some(current)
873}
874
875/// Expand `v-for` directives by repeating elements for each array item.
876fn expand_v_for(template: &str, data: &Value) -> String {
877    let vfor_re = Regex::new(r#"<(\w[\w-]*)([^>]*)\sv-for="([^"]*)"([^>]*)>"#).unwrap();
878    let mut result = template.to_string();
879
880    for _ in 0..20 {
881        let Some(cap) = vfor_re.captures(&result) else {
882            break;
883        };
884
885        let full_match = cap.get(0).unwrap();
886        let tag_name = &cap[1];
887        let attrs_before = &cap[2];
888        let vfor_expr = &cap[3];
889        let attrs_after = &cap[4];
890
891        let (item_var, index_var, array_expr) = parse_vfor_expr(vfor_expr);
892        let open_tag_no_vfor = format!("<{}{}{}>", tag_name, attrs_before, attrs_after);
893        let match_start = full_match.start();
894        let after_open = full_match.end();
895        let is_self_closing = result[match_start..after_open].trim_end_matches('>').ends_with('/');
896
897        if is_self_closing {
898            let sc_tag = format!("<{}{}{} />", tag_name, attrs_before, attrs_after);
899            let array = resolve_path_value(data, &array_expr);
900            let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
901            let mut expanded = String::new();
902            for (idx, item) in items.iter().enumerate() {
903                let mut item_data = data.clone();
904                if let Value::Object(ref mut map) = item_data {
905                    map.insert(item_var.clone(), item.clone());
906                    if let Some(ref idx_var) = index_var {
907                        map.insert(idx_var.clone(), Value::Number(idx.into()));
908                    }
909                }
910                expanded.push_str(&interpolate(&sc_tag, &item_data));
911            }
912            result = format!("{}{}{}", &result[..match_start], expanded, &result[after_open..]);
913            continue;
914        }
915
916        let close_tag = format!("</{}>", tag_name);
917        let remaining = &result[after_open..];
918        let close_pos = find_matching_close_tag(remaining, tag_name);
919        let inner_content = remaining[..close_pos].to_string();
920        let element_end = after_open + close_pos + close_tag.len();
921
922        let array = resolve_path_value(data, &array_expr);
923        let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
924        let mut expanded = String::new();
925        for (idx, item) in items.iter().enumerate() {
926            let mut item_data = data.clone();
927            if let Value::Object(ref mut map) = item_data {
928                map.insert(item_var.clone(), item.clone());
929                if let Some(ref idx_var) = index_var {
930                    map.insert(idx_var.clone(), Value::Number(idx.into()));
931                }
932            }
933            let tag_interpolated = interpolate(&open_tag_no_vfor, &item_data);
934            let inner_interpolated = interpolate(&inner_content, &item_data);
935            expanded.push_str(&format!("{}{}</{}>", tag_interpolated, inner_interpolated, tag_name));
936        }
937
938        result = format!("{}{}{}", &result[..match_start], expanded, &result[element_end..]);
939    }
940
941    result
942}
943
944fn parse_vfor_expr(expr: &str) -> (String, Option<String>, String) {
945    let parts: Vec<&str> = expr.splitn(2, " in ").collect();
946    if parts.len() != 2 {
947        return (expr.to_string(), None, String::new());
948    }
949    let lhs = parts[0].trim();
950    let array_expr = parts[1].trim().to_string();
951    if lhs.starts_with('(') && lhs.ends_with(')') {
952        let inner = &lhs[1..lhs.len() - 1];
953        let vars: Vec<&str> = inner.split(',').collect();
954        let item_var = vars[0].trim().to_string();
955        let index_var = vars.get(1).map(|v| v.trim().to_string());
956        (item_var, index_var, array_expr)
957    } else {
958        (lhs.to_string(), None, array_expr)
959    }
960}
961
962fn find_matching_close_tag(html: &str, tag_name: &str) -> usize {
963    let open = format!("<{}", tag_name);
964    let close = format!("</{}>", tag_name);
965    let mut depth = 0;
966    let mut pos = 0;
967    while pos < html.len() {
968        if html[pos..].starts_with(&close) {
969            if depth == 0 {
970                return pos;
971            }
972            depth -= 1;
973            pos += close.len();
974        } else if html[pos..].starts_with(&open) {
975            let after = pos + open.len();
976            if after < html.len() {
977                let ch = html.as_bytes()[after] as char;
978                if ch == ' ' || ch == '>' || ch == '/' || ch == '\n' || ch == '\t' {
979                    depth += 1;
980                }
981            }
982            pos += open.len();
983        } else {
984            pos += 1;
985        }
986    }
987    html.len()
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use serde_json::json;
994
995    #[test]
996    fn test_extract_reactive_names() {
997        let script = r#"
998const count = ref(0)
999const doubled = computed(() => count * 2)
1000"#;
1001        let names = extract_reactive_names(script);
1002        assert_eq!(names, vec!["count", "doubled"]);
1003    }
1004
1005    #[test]
1006    fn test_resolve_single_basic() {
1007        let source = r#"
1008<template>
1009  <h1>{{ title }}</h1>
1010</template>
1011"#;
1012        let data = json!({"title": "Hello"});
1013        let resolved = resolve_single(source, &data).unwrap();
1014        assert!(resolved.html.contains("<h1>Hello</h1>"));
1015        assert!(resolved.styles.is_empty());
1016        assert!(resolved.script_setup.is_none());
1017    }
1018
1019    #[test]
1020    fn test_resolve_single_with_style() {
1021        let source = r#"
1022<template>
1023  <h1>Hello</h1>
1024</template>
1025
1026<style scoped>
1027h1 { color: red; }
1028</style>
1029"#;
1030        let data = json!({});
1031        let resolved = resolve_single(source, &data).unwrap();
1032        assert_eq!(resolved.styles.len(), 1);
1033        assert!(resolved.styles[0].contains("color: red"));
1034    }
1035
1036    #[test]
1037    fn test_resolve_single_reactive() {
1038        let source = r#"
1039<template>
1040  <p>Count: {{ count }}</p>
1041</template>
1042
1043<script setup>
1044const count = ref(0)
1045</script>
1046"#;
1047        let data = json!({});
1048        let resolved = resolve_single(source, &data).unwrap();
1049        assert!(resolved.html.contains("{{ count }}"));
1050        assert!(resolved.script_setup.is_some());
1051    }
1052
1053    // ─── Virtual path tests ─────────────────────────────────────────
1054
1055    #[test]
1056    fn test_resolve_virtual_path_same_dir() {
1057        assert_eq!(
1058            resolve_virtual_path("index.van", "./hello.van"),
1059            "hello.van"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_resolve_virtual_path_parent_dir() {
1065        assert_eq!(
1066            resolve_virtual_path("pages/index.van", "../components/hello.van"),
1067            "components/hello.van"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_resolve_virtual_path_subdir() {
1073        assert_eq!(
1074            resolve_virtual_path("pages/index.van", "./sub.van"),
1075            "pages/sub.van"
1076        );
1077    }
1078
1079    #[test]
1080    fn test_normalize_virtual_path() {
1081        assert_eq!(normalize_virtual_path("./hello.van"), "hello.van");
1082        assert_eq!(
1083            normalize_virtual_path("pages/../components/hello.van"),
1084            "components/hello.van"
1085        );
1086        assert_eq!(normalize_virtual_path("a/b/./c"), "a/b/c");
1087    }
1088
1089    #[test]
1090    fn test_resolve_virtual_path_scoped_package() {
1091        // @scope/pkg paths should be returned as-is regardless of current file
1092        assert_eq!(
1093            resolve_virtual_path("pages/index.van", "@van-ui/button/button.van"),
1094            "@van-ui/button/button.van"
1095        );
1096        assert_eq!(
1097            resolve_virtual_path("index.van", "@van-ui/utils/format.ts"),
1098            "@van-ui/utils/format.ts"
1099        );
1100    }
1101
1102    #[test]
1103    fn test_resolve_with_files_scoped_import() {
1104        let mut files = HashMap::new();
1105        files.insert(
1106            "index.van".to_string(),
1107            r#"
1108<template>
1109  <van-button :label="title" />
1110</template>
1111
1112<script setup>
1113import VanButton from '@van-ui/button/button.van'
1114</script>
1115"#
1116            .to_string(),
1117        );
1118        // In-memory file map: key is "@van-ui/button/button.van"
1119        files.insert(
1120            "@van-ui/button/button.van".to_string(),
1121            r#"
1122<template>
1123  <button>{{ label }}</button>
1124</template>
1125"#
1126            .to_string(),
1127        );
1128
1129        let data = json!({"title": "Click me"});
1130        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1131        assert!(resolved.html.contains("<button>Click me</button>"));
1132    }
1133
1134    // ─── Multi-file resolve tests ───────────────────────────────────
1135
1136    #[test]
1137    fn test_resolve_with_files_basic_import() {
1138        let mut files = HashMap::new();
1139        files.insert(
1140            "index.van".to_string(),
1141            r#"
1142<template>
1143  <hello :name="title" />
1144</template>
1145
1146<script setup>
1147import Hello from './hello.van'
1148</script>
1149"#
1150            .to_string(),
1151        );
1152        files.insert(
1153            "hello.van".to_string(),
1154            r#"
1155<template>
1156  <h1>Hello, {{ name }}!</h1>
1157</template>
1158"#
1159            .to_string(),
1160        );
1161
1162        let data = json!({"title": "World"});
1163        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1164        assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1165    }
1166
1167    #[test]
1168    fn test_resolve_with_files_missing_component() {
1169        let mut files = HashMap::new();
1170        files.insert(
1171            "index.van".to_string(),
1172            r#"
1173<template>
1174  <hello />
1175</template>
1176
1177<script setup>
1178import Hello from './hello.van'
1179</script>
1180"#
1181            .to_string(),
1182        );
1183
1184        let data = json!({});
1185        let result = resolve_with_files("index.van", &files, &data);
1186        assert!(result.is_err());
1187        assert!(result.unwrap_err().contains("Component not found"));
1188    }
1189
1190    #[test]
1191    fn test_resolve_with_files_slots() {
1192        let mut files = HashMap::new();
1193        files.insert(
1194            "index.van".to_string(),
1195            r#"
1196<template>
1197  <wrapper>
1198    <p>Default slot content</p>
1199  </wrapper>
1200</template>
1201
1202<script setup>
1203import Wrapper from './wrapper.van'
1204</script>
1205"#
1206            .to_string(),
1207        );
1208        files.insert(
1209            "wrapper.van".to_string(),
1210            r#"
1211<template>
1212  <div class="wrapper"><slot /></div>
1213</template>
1214"#
1215            .to_string(),
1216        );
1217
1218        let data = json!({});
1219        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1220        assert!(resolved.html.contains("<div class=\"wrapper\">"));
1221        assert!(resolved.html.contains("<p>Default slot content</p>"));
1222    }
1223
1224    #[test]
1225    fn test_resolve_with_files_styles_collected() {
1226        let mut files = HashMap::new();
1227        files.insert(
1228            "index.van".to_string(),
1229            r#"
1230<template>
1231  <hello />
1232</template>
1233
1234<script setup>
1235import Hello from './hello.van'
1236</script>
1237
1238<style>
1239.app { color: blue; }
1240</style>
1241"#
1242            .to_string(),
1243        );
1244        files.insert(
1245            "hello.van".to_string(),
1246            r#"
1247<template>
1248  <h1>Hello</h1>
1249</template>
1250
1251<style>
1252h1 { color: red; }
1253</style>
1254"#
1255            .to_string(),
1256        );
1257
1258        let data = json!({});
1259        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1260        assert_eq!(resolved.styles.len(), 2);
1261        assert!(resolved.styles[0].contains("color: blue"));
1262        assert!(resolved.styles[1].contains("color: red"));
1263    }
1264
1265    #[test]
1266    fn test_resolve_with_files_reactive_preserved() {
1267        let mut files = HashMap::new();
1268        files.insert(
1269            "index.van".to_string(),
1270            r#"
1271<template>
1272  <div>
1273    <p>Count: {{ count }}</p>
1274    <hello :name="title" />
1275  </div>
1276</template>
1277
1278<script setup>
1279import Hello from './hello.van'
1280const count = ref(0)
1281</script>
1282"#
1283            .to_string(),
1284        );
1285        files.insert(
1286            "hello.van".to_string(),
1287            r#"
1288<template>
1289  <h1>Hello, {{ name }}!</h1>
1290</template>
1291"#
1292            .to_string(),
1293        );
1294
1295        let data = json!({"title": "World"});
1296        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1297        // Reactive expression should be preserved
1298        assert!(resolved.html.contains("{{ count }}"));
1299        // Non-reactive prop should be interpolated
1300        assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1301        assert!(resolved.script_setup.is_some());
1302    }
1303
1304    // ─── Component tag extraction tests ─────────────────────────────
1305
1306    #[test]
1307    fn test_extract_self_closing_tag() {
1308        let template = r#"<div><hello :name="title" /></div>"#;
1309        let info = extract_component_tag(template, "hello").unwrap();
1310        assert_eq!(info.tag_name, "hello");
1311        assert_eq!(info.attrs, r#":name="title""#);
1312        assert!(info.children.is_empty());
1313    }
1314
1315    #[test]
1316    fn test_extract_paired_tag() {
1317        let template = r#"<default-layout><h1>Content</h1></default-layout>"#;
1318        let info = extract_component_tag(template, "default-layout").unwrap();
1319        assert_eq!(info.tag_name, "default-layout");
1320        assert_eq!(info.children, "<h1>Content</h1>");
1321    }
1322
1323    #[test]
1324    fn test_extract_no_match() {
1325        let template = r#"<div>no components here</div>"#;
1326        assert!(extract_component_tag(template, "hello").is_none());
1327    }
1328
1329    #[test]
1330    fn test_parse_props() {
1331        let data = json!({"title": "World", "count": 42});
1332        let attrs = r#":name="title" :num="count""#;
1333        let result = parse_props(attrs, &data);
1334        assert_eq!(result["name"], "World");
1335        assert_eq!(result["num"], "42");
1336    }
1337
1338    #[test]
1339    fn test_distribute_slots_default() {
1340        let html = r#"<div><slot /></div>"#;
1341        let mut slots = HashMap::new();
1342        slots.insert("default".to_string(), "Hello World".to_string());
1343        let result = distribute_slots(html, &slots, false, &HashMap::new());
1344        assert_eq!(result, "<div>Hello World</div>");
1345    }
1346
1347    #[test]
1348    fn test_distribute_slots_named() {
1349        let html =
1350            r#"<title><slot name="title">Fallback</slot></title><div><slot /></div>"#;
1351        let mut slots = HashMap::new();
1352        slots.insert("title".to_string(), "My Title".to_string());
1353        slots.insert("default".to_string(), "Body".to_string());
1354        let result = distribute_slots(html, &slots, false, &HashMap::new());
1355        assert_eq!(result, "<title>My Title</title><div>Body</div>");
1356    }
1357
1358    #[test]
1359    fn test_distribute_slots_fallback() {
1360        let html = r#"<title><slot name="title">Fallback Title</slot></title>"#;
1361        let slots = HashMap::new();
1362        let result = distribute_slots(html, &slots, false, &HashMap::new());
1363        assert_eq!(result, "<title>Fallback Title</title>");
1364    }
1365
1366    #[test]
1367    fn test_expand_v_for_basic() {
1368        let data = json!({"items": ["Alice", "Bob", "Charlie"]});
1369        let template = r#"<ul><li v-for="item in items">{{ item }}</li></ul>"#;
1370        let result = expand_v_for(template, &data);
1371        assert!(result.contains("<li>Alice</li>"));
1372        assert!(result.contains("<li>Bob</li>"));
1373        assert!(result.contains("<li>Charlie</li>"));
1374        assert!(!result.contains("v-for"));
1375    }
1376
1377    #[test]
1378    fn test_expand_v_for_with_index() {
1379        let data = json!({"items": ["A", "B"]});
1380        let template = r#"<ul><li v-for="(item, index) in items">{{ index }}: {{ item }}</li></ul>"#;
1381        let result = expand_v_for(template, &data);
1382        assert!(result.contains("0: A"));
1383        assert!(result.contains("1: B"));
1384    }
1385
1386    #[test]
1387    fn test_expand_v_for_nested_path() {
1388        let data = json!({"user": {"hobbies": ["coding", "reading"]}});
1389        let template = r#"<span v-for="h in user.hobbies">{{ h }}</span>"#;
1390        let result = expand_v_for(template, &data);
1391        assert!(result.contains("<span>coding</span>"));
1392        assert!(result.contains("<span>reading</span>"));
1393    }
1394
1395    // ─── Scoped style tests ──────────────────────────────────────────
1396
1397    #[test]
1398    fn test_resolve_scoped_style_single() {
1399        let source = r#"
1400<template>
1401  <div class="card"><h1>{{ title }}</h1></div>
1402</template>
1403
1404<style scoped>
1405.card { border: 1px solid; }
1406h1 { color: navy; }
1407</style>
1408"#;
1409        let data = json!({"title": "Hello"});
1410        let css = ".card { border: 1px solid; }\nh1 { color: navy; }";
1411        let id = van_parser::scope_id(css);
1412        let resolved = resolve_single_with_path(source, &data, "components/card.van").unwrap();
1413        // All elements should have scope class
1414        assert!(resolved.html.contains(&format!("class=\"card {id}\"")), "Root should have scope class appended");
1415        assert!(resolved.html.contains(&format!("class=\"{id}\"")), "Child h1 should have scope class");
1416        // CSS selectors should have .{id} appended
1417        assert_eq!(resolved.styles.len(), 1);
1418        assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1419        assert!(resolved.styles[0].contains(&format!("h1.{id}")));
1420    }
1421
1422    #[test]
1423    fn test_resolve_scoped_style_multi_file() {
1424        let mut files = HashMap::new();
1425        files.insert(
1426            "index.van".to_string(),
1427            r#"
1428<template>
1429  <card :title="title" />
1430</template>
1431
1432<script setup>
1433import Card from './card.van'
1434</script>
1435"#.to_string(),
1436        );
1437        files.insert(
1438            "card.van".to_string(),
1439            r#"
1440<template>
1441  <div class="card"><h1>{{ title }}</h1></div>
1442</template>
1443
1444<style scoped>
1445.card { border: 1px solid; }
1446</style>
1447"#.to_string(),
1448        );
1449
1450        let data = json!({"title": "Test"});
1451        let id = van_parser::scope_id(".card { border: 1px solid; }");
1452        let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1453        // Child component HTML should have scope class on all elements
1454        assert!(resolved.html.contains(&format!("card {id}")), "Should contain scope class");
1455        // CSS selectors should have .{id} appended
1456        assert_eq!(resolved.styles.len(), 1);
1457        assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1458    }
1459
1460    #[test]
1461    fn test_resolve_unscoped_style_unchanged() {
1462        let source = r#"
1463<template>
1464  <div class="app"><p>Hello</p></div>
1465</template>
1466
1467<style>
1468.app { margin: 0; }
1469</style>
1470"#;
1471        let data = json!({});
1472        let resolved = resolve_single(source, &data).unwrap();
1473        // HTML should be unchanged — no extra scope classes
1474        assert_eq!(resolved.html.matches("class=").count(), 1, "Only the original class attr");
1475        assert!(resolved.html.contains("class=\"app\""), "Original class preserved");
1476        assert_eq!(resolved.styles[0], ".app { margin: 0; }");
1477    }
1478}