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#[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 let Some(translated) = try_resolve_t(expr, data) {
430 result.push_str(&translated);
431 } else if expr.trim().starts_with("$t(") {
432 result.push_str(&format!("{{{{{{{}}}}}}}", expr));
434 } else if check_reactive(expr) {
435 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 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 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
473struct TagInfo {
477 tag_name: String,
478 attrs: String,
479 children: String,
480 start: usize,
481 end: usize,
482}
483
484fn 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
494fn 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 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 let rest = &template[start..];
517 let gt_pos = rest.find('>')?;
518
519 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 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
555fn 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 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 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
582type SlotMap = HashMap<String, String>;
586
587struct SlotResult {
589 slots: SlotMap,
590 styles: Vec<String>,
591 script_setup: Option<String>,
592 module_imports: Vec<ResolvedModule>,
593}
594
595fn 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 let before = rest[..full_match.start()].trim();
628 if !before.is_empty() {
629 default_parts.push(before.to_string());
630 }
631
632 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 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 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
686fn 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 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 let html = if !reactive_names.is_empty() {
756 interpolate_skip_reactive(&result, data, reactive_names)
757 } else {
758 interpolate(&result, data)
759 };
760
761 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
776fn distribute_slots(html: &str, slots: &SlotMap, debug: bool, slot_themes: &HashMap<String, String>) -> String {
781 let mut result = html.to_string();
782
783 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 let named_re = Regex::new(r#"<slot\s+name="(\w+)">([\s\S]*?)</slot>"#).unwrap();
793 result = named_re
794 .replace_all(&result, |caps: ®ex::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 let named_sc_re = Regex::new(r#"<slot\s+name="(\w+)"\s*/>"#).unwrap();
812 result = named_sc_re
813 .replace_all(&result, |caps: ®ex::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 let default_sc_re = Regex::new(r#"<slot\s*/>"#).unwrap();
828 result = default_sc_re
829 .replace_all(&result, |_: ®ex::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 let default_re = Regex::new(r#"<slot>([\s\S]*?)</slot>"#).unwrap();
843 result = default_re
844 .replace_all(&result, |caps: ®ex::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
862fn 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
875fn 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 #[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 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 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 #[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 assert!(resolved.html.contains("{{ count }}"));
1299 assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1301 assert!(resolved.script_setup.is_some());
1302 }
1303
1304 #[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 #[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 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 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 assert!(resolved.html.contains(&format!("card {id}")), "Should contain scope class");
1455 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 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}