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