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