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 blocks = parse_blocks(source);
73 let reactive_names = if let Some(ref script) = blocks.script_setup {
74 extract_reactive_names(script)
75 } else {
76 Vec::new()
77 };
78
79 resolve_recursive(source, data, entry_path, files, 0, &reactive_names, debug, file_origins)
80}
81
82fn resolve_recursive(
84 source: &str,
85 data: &Value,
86 current_path: &str,
87 files: &HashMap<String, String>,
88 depth: usize,
89 reactive_names: &[String],
90 debug: bool,
91 file_origins: &HashMap<String, String>,
92) -> Result<ResolvedComponent, String> {
93 if depth > MAX_DEPTH {
94 return Err(format!(
95 "Component nesting exceeded maximum depth of {MAX_DEPTH}"
96 ));
97 }
98
99 let blocks = parse_blocks(source);
100 let mut template = blocks
101 .template
102 .unwrap_or_else(|| "<p>No template block found.</p>".to_string());
103
104 let mut styles: Vec<String> = Vec::new();
105 if let Some(css) = &blocks.style {
106 if blocks.style_scoped {
107 let id = scope_id(css);
108 template = add_scope_class(&template, &id);
109 styles.push(scope_css(css, &id));
110 } else {
111 styles.push(css.clone());
112 }
113 }
114
115 let imports = if let Some(ref script) = blocks.script_setup {
117 parse_imports(script)
118 } else {
119 Vec::new()
120 };
121
122 let import_map: HashMap<String, &VanImport> = imports
123 .iter()
124 .map(|imp| (imp.tag_name.clone(), imp))
125 .collect();
126
127 template = expand_v_for(&template, data);
129
130 loop {
132 let tag_match = find_component_tag(&template, &import_map);
133 let Some(tag_info) = tag_match else {
134 break;
135 };
136
137 let imp = &import_map[&tag_info.tag_name];
138
139 let resolved_key = resolve_virtual_path(current_path, &imp.path);
141 let component_source = files
142 .get(&resolved_key)
143 .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
144
145 let child_data = parse_props(&tag_info.attrs, data);
147
148 let slot_result = parse_slot_content(
150 &tag_info.children,
151 data,
152 &imports,
153 current_path,
154 files,
155 depth,
156 reactive_names,
157 debug,
158 file_origins,
159 )?;
160
161 let child_resolved = resolve_recursive(
163 component_source,
164 &child_data,
165 &resolved_key,
166 files,
167 depth + 1,
168 reactive_names,
169 debug,
170 file_origins,
171 )?;
172
173 let file_theme = file_origins.get(current_path).cloned().unwrap_or_default();
176 let mut slot_themes: HashMap<String, String> = HashMap::new();
177 for slot_name in slot_result.slots.keys() {
178 let slot_key = format!("{}#{}", current_path, slot_name);
179 let theme = file_origins.get(&slot_key).unwrap_or(&file_theme);
180 slot_themes.insert(slot_name.clone(), theme.clone());
181 }
182 let with_slots = distribute_slots(&child_resolved.html, &slot_result.slots, debug, &slot_themes);
183
184 let replacement = if debug {
186 let theme_prefix = file_origins.get(&resolved_key)
187 .map(|t| format!("[{t}] "))
188 .unwrap_or_default();
189 format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
190 } else {
191 with_slots
192 };
193
194 template = format!(
195 "{}{}{}",
196 &template[..tag_info.start],
197 replacement,
198 &template[tag_info.end..],
199 );
200
201 styles.extend(child_resolved.styles);
203 styles.extend(slot_result.styles);
204 }
205
206 let html = if !reactive_names.is_empty() {
209 interpolate_skip_reactive(&template, data, reactive_names)
210 } else {
211 interpolate(&template, data)
212 };
213
214 let script_setup = if depth == 0 {
216 blocks.script_setup.clone()
217 } else {
218 None
219 };
220
221 let module_imports = if depth == 0 {
222 if let Some(ref script) = blocks.script_setup {
223 let script_imports = parse_script_imports(script);
224 script_imports
225 .into_iter()
226 .filter_map(|imp| {
227 if imp.is_type_only {
228 return None; }
230 let resolved_key = resolve_virtual_path(current_path, &imp.path);
231 let content = files.get(&resolved_key)?;
232 Some(ResolvedModule {
233 path: resolved_key,
234 content: content.clone(),
235 is_type_only: false,
236 })
237 })
238 .collect()
239 } else {
240 Vec::new()
241 }
242 } else {
243 Vec::new()
244 };
245
246 Ok(ResolvedComponent {
247 html,
248 styles,
249 script_setup,
250 module_imports,
251 })
252}
253
254pub fn resolve_single(source: &str, data: &Value) -> Result<ResolvedComponent, String> {
260 resolve_single_with_path(source, data, "")
261}
262
263pub fn resolve_single_with_path(source: &str, data: &Value, _path: &str) -> Result<ResolvedComponent, String> {
265 let blocks = parse_blocks(source);
266
267 let mut template = blocks
268 .template
269 .unwrap_or_else(|| "<p>No template block found.</p>".to_string());
270
271 let mut styles: Vec<String> = Vec::new();
272 if let Some(css) = &blocks.style {
273 if blocks.style_scoped {
274 let id = scope_id(css);
275 template = add_scope_class(&template, &id);
276 styles.push(scope_css(css, &id));
277 } else {
278 styles.push(css.clone());
279 }
280 }
281
282 let reactive_names = if let Some(ref script) = blocks.script_setup {
284 extract_reactive_names(script)
285 } else {
286 Vec::new()
287 };
288
289 let html = if !reactive_names.is_empty() {
291 interpolate_skip_reactive(&template, data, &reactive_names)
292 } else {
293 interpolate(&template, data)
294 };
295
296 Ok(ResolvedComponent {
297 html,
298 styles,
299 script_setup: blocks.script_setup.clone(),
300 module_imports: Vec::new(),
301 })
302}
303
304fn resolve_virtual_path(current_file: &str, import_path: &str) -> String {
315 if import_path.starts_with('@') {
317 return import_path.to_string();
318 }
319
320 let dir = if let Some(pos) = current_file.rfind('/') {
322 ¤t_file[..pos]
323 } else {
324 "" };
326
327 let combined = if dir.is_empty() {
328 import_path.to_string()
329 } else {
330 format!("{}/{}", dir, import_path)
331 };
332
333 normalize_virtual_path(&combined)
334}
335
336fn normalize_virtual_path(path: &str) -> String {
338 let mut parts: Vec<&str> = Vec::new();
339 for part in path.split('/') {
340 match part {
341 "." | "" => {}
342 ".." => {
343 parts.pop();
344 }
345 other => parts.push(other),
346 }
347 }
348 parts.join("/")
349}
350
351pub fn extract_reactive_names(script: &str) -> Vec<String> {
355 let ref_re = Regex::new(r#"const\s+(\w+)\s*=\s*ref\("#).unwrap();
356 let computed_re = Regex::new(r#"const\s+(\w+)\s*=\s*computed\("#).unwrap();
357 let mut names = Vec::new();
358 for cap in ref_re.captures_iter(script) {
359 names.push(cap[1].to_string());
360 }
361 for cap in computed_re.captures_iter(script) {
362 names.push(cap[1].to_string());
363 }
364 names
365}
366
367fn interpolate_skip_reactive(template: &str, data: &Value, reactive_names: &[String]) -> String {
372 let mut result = String::with_capacity(template.len());
373 let mut rest = template;
374
375 let check_reactive = |expr: &str| -> bool {
377 reactive_names.iter().any(|name| {
378 let bytes = expr.as_bytes();
379 let name_bytes = name.as_bytes();
380 let name_len = name.len();
381 let mut i = 0;
382 while i + name_len <= bytes.len() {
383 if &bytes[i..i + name_len] == name_bytes {
384 let before_ok = i == 0 || !(bytes[i - 1] as char).is_alphanumeric();
385 let after_ok = i + name_len == bytes.len()
386 || !(bytes[i + name_len] as char).is_alphanumeric();
387 if before_ok && after_ok {
388 return true;
389 }
390 }
391 i += 1;
392 }
393 false
394 })
395 };
396
397 while let Some(start) = rest.find("{{") {
398 result.push_str(&rest[..start]);
399
400 if rest[start..].starts_with("{{{") {
402 let after_open = &rest[start + 3..];
403 if let Some(end) = after_open.find("}}}") {
404 let expr = after_open[..end].trim();
405 if check_reactive(expr) {
406 result.push_str(&format!("{{{{ {expr} }}}}"));
408 } else {
409 let value = resolve_json_path(data, expr);
410 result.push_str(&value);
411 }
412 rest = &after_open[end + 3..];
413 } else {
414 result.push_str("{{{");
415 rest = &rest[start + 3..];
416 }
417 } else {
418 let after_open = &rest[start + 2..];
419 if let Some(end) = after_open.find("}}") {
420 let expr = after_open[..end].trim();
421 if check_reactive(expr) {
422 result.push_str(&format!("{{{{ {expr} }}}}"));
423 } else {
424 let value = resolve_json_path(data, expr);
425 result.push_str(&escape_html(&value));
426 }
427 rest = &after_open[end + 2..];
428 } else {
429 result.push_str("{{");
430 rest = after_open;
431 }
432 }
433 }
434 result.push_str(rest);
435 result
436}
437
438struct TagInfo {
442 tag_name: String,
443 attrs: String,
444 children: String,
445 start: usize,
446 end: usize,
447}
448
449fn find_component_tag(template: &str, import_map: &HashMap<String, &VanImport>) -> Option<TagInfo> {
451 for tag_name in import_map.keys() {
452 if let Some(info) = extract_component_tag(template, tag_name) {
453 return Some(info);
454 }
455 }
456 None
457}
458
459fn extract_component_tag(template: &str, tag_name: &str) -> Option<TagInfo> {
461 let open_pattern = format!("<{}", tag_name);
462
463 let start = template.find(&open_pattern)?;
464
465 let after_tag = start + open_pattern.len();
467 if after_tag < template.len() {
468 let next_ch = template.as_bytes()[after_tag] as char;
469 if next_ch != ' '
470 && next_ch != '/'
471 && next_ch != '>'
472 && next_ch != '\n'
473 && next_ch != '\r'
474 && next_ch != '\t'
475 {
476 return None;
477 }
478 }
479
480 let rest = &template[start..];
482 let gt_pos = rest.find('>')?;
483
484 let is_self_closing = rest[..gt_pos].ends_with('/');
486
487 if is_self_closing {
488 let attr_start = open_pattern.len();
489 let attr_end = gt_pos;
490 let attrs_str = &rest[attr_start..attr_end].trim_end_matches('/').trim();
491
492 return Some(TagInfo {
493 tag_name: tag_name.to_string(),
494 attrs: attrs_str.to_string(),
495 children: String::new(),
496 start,
497 end: start + gt_pos + 1,
498 });
499 }
500
501 let content_start = start + gt_pos + 1;
503 let close_tag = format!("</{}>", tag_name);
504
505 let remaining = &template[content_start..];
506 let close_pos = remaining.find(&close_tag)?;
507
508 let attrs_raw = &rest[tag_name.len() + 1..gt_pos];
509 let children = remaining[..close_pos].to_string();
510
511 Some(TagInfo {
512 tag_name: tag_name.to_string(),
513 attrs: attrs_raw.trim().to_string(),
514 children,
515 start,
516 end: content_start + close_pos + close_tag.len(),
517 })
518}
519
520fn parse_props(attrs: &str, parent_data: &Value) -> Value {
524 let re = Regex::new(r#":(\w+)="([^"]*)""#).unwrap();
525 let mut map = serde_json::Map::new();
526
527 for cap in re.captures_iter(attrs) {
528 let key = &cap[1];
529 let expr = &cap[2];
530 let value_str = resolve_json_path(parent_data, expr);
531 map.insert(key.to_string(), Value::String(value_str));
532 }
533
534 Value::Object(map)
535}
536
537type SlotMap = HashMap<String, String>;
541
542struct SlotResult {
544 slots: SlotMap,
545 styles: Vec<String>,
546}
547
548fn parse_slot_content(
550 children: &str,
551 parent_data: &Value,
552 parent_imports: &[VanImport],
553 current_path: &str,
554 files: &HashMap<String, String>,
555 depth: usize,
556 reactive_names: &[String],
557 debug: bool,
558 file_origins: &HashMap<String, String>,
559) -> Result<SlotResult, String> {
560 let mut slots = SlotMap::new();
561 let mut styles: Vec<String> = Vec::new();
562 let mut default_parts: Vec<String> = Vec::new();
563 let mut rest = children;
564
565 let named_slot_re = Regex::new(r#"<template\s+#(\w+)\s*>"#).unwrap();
566
567 loop {
568 let Some(cap) = named_slot_re.captures(rest) else {
569 let trimmed = rest.trim();
570 if !trimmed.is_empty() {
571 default_parts.push(trimmed.to_string());
572 }
573 break;
574 };
575
576 let full_match = cap.get(0).unwrap();
577 let slot_name = cap[1].to_string();
578
579 let before = rest[..full_match.start()].trim();
581 if !before.is_empty() {
582 default_parts.push(before.to_string());
583 }
584
585 let after_open = &rest[full_match.end()..];
587 let close_pos = after_open.find("</template>");
588 let slot_content = if let Some(pos) = close_pos {
589 let content = after_open[..pos].trim().to_string();
590 rest = &after_open[pos + "</template>".len()..];
591 content
592 } else {
593 let content = after_open.trim().to_string();
594 rest = "";
595 content
596 };
597
598 let interpolated = if !reactive_names.is_empty() {
600 interpolate_skip_reactive(&slot_content, parent_data, reactive_names)
601 } else {
602 interpolate(&slot_content, parent_data)
603 };
604 slots.insert(slot_name, interpolated);
605 }
606
607 if !default_parts.is_empty() {
609 let default_content = default_parts.join("\n");
610
611 let parent_import_map: HashMap<String, &VanImport> = parent_imports
612 .iter()
613 .map(|imp| (imp.tag_name.clone(), imp))
614 .collect();
615
616 let resolved = resolve_slot_components(
617 &default_content,
618 parent_data,
619 &parent_import_map,
620 current_path,
621 files,
622 depth,
623 reactive_names,
624 debug,
625 file_origins,
626 )?;
627
628 slots.insert("default".to_string(), resolved.html);
629 styles.extend(resolved.styles);
630 }
631
632 Ok(SlotResult { slots, styles })
633}
634
635fn resolve_slot_components(
637 content: &str,
638 data: &Value,
639 import_map: &HashMap<String, &VanImport>,
640 current_path: &str,
641 files: &HashMap<String, String>,
642 depth: usize,
643 reactive_names: &[String],
644 debug: bool,
645 file_origins: &HashMap<String, String>,
646) -> Result<ResolvedComponent, String> {
647 let mut result = content.to_string();
648 let mut styles: Vec<String> = Vec::new();
649
650 loop {
651 let tag_match = find_component_tag(&result, import_map);
652 let Some(tag_info) = tag_match else {
653 break;
654 };
655
656 let imp = &import_map[&tag_info.tag_name];
657 let resolved_key = resolve_virtual_path(current_path, &imp.path);
658 let component_source = files
659 .get(&resolved_key)
660 .ok_or_else(|| format!("Component not found: {} (resolved from '{}')", resolved_key, imp.path))?;
661
662 let child_data = parse_props(&tag_info.attrs, data);
663
664 let child_resolved = resolve_recursive(
665 component_source,
666 &child_data,
667 &resolved_key,
668 files,
669 depth + 1,
670 reactive_names,
671 debug,
672 file_origins,
673 )?;
674
675 let with_slots = distribute_slots(&child_resolved.html, &HashMap::new(), debug, &HashMap::new());
676 styles.extend(child_resolved.styles);
677
678 let replacement = if debug {
679 let theme_prefix = file_origins.get(&resolved_key)
680 .map(|t| format!("[{t}] "))
681 .unwrap_or_default();
682 format!("<!-- START: {theme_prefix}{resolved_key} -->{with_slots}<!-- END: {theme_prefix}{resolved_key} -->")
683 } else {
684 with_slots
685 };
686
687 result = format!(
688 "{}{}{}",
689 &result[..tag_info.start],
690 replacement,
691 &result[tag_info.end..],
692 );
693 }
694
695 let html = if !reactive_names.is_empty() {
697 interpolate_skip_reactive(&result, data, reactive_names)
698 } else {
699 interpolate(&result, data)
700 };
701
702 Ok(ResolvedComponent {
703 html,
704 styles,
705 script_setup: None,
706 module_imports: Vec::new(),
707 })
708}
709
710fn distribute_slots(html: &str, slots: &SlotMap, debug: bool, slot_themes: &HashMap<String, String>) -> String {
715 let mut result = html.to_string();
716
717 let tp = |name: &str| -> String {
719 slot_themes.get(name)
720 .filter(|t| !t.is_empty())
721 .map(|t| format!("[{t}] "))
722 .unwrap_or_default()
723 };
724
725 let named_re = Regex::new(r#"<slot\s+name="(\w+)">([\s\S]*?)</slot>"#).unwrap();
727 result = named_re
728 .replace_all(&result, |caps: ®ex::Captures| {
729 let name = &caps[1];
730 let fallback = &caps[2];
731 let provided = slots.get(name);
732 let content = provided
733 .cloned()
734 .unwrap_or_else(|| fallback.trim().to_string());
735 if debug {
736 let p = if provided.is_some() { tp(name) } else { String::new() };
737 format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
738 } else {
739 content
740 }
741 })
742 .to_string();
743
744 let named_sc_re = Regex::new(r#"<slot\s+name="(\w+)"\s*/>"#).unwrap();
746 result = named_sc_re
747 .replace_all(&result, |caps: ®ex::Captures| {
748 let name = &caps[1];
749 let provided = slots.get(name);
750 let content = provided.cloned().unwrap_or_default();
751 if debug {
752 let p = if provided.is_some() { tp(name) } else { String::new() };
753 format!("<!-- START: {p}#{name} -->{content}<!-- END: {p}#{name} -->")
754 } else {
755 content
756 }
757 })
758 .to_string();
759
760 let default_sc_re = Regex::new(r#"<slot\s*/>"#).unwrap();
762 result = default_sc_re
763 .replace_all(&result, |_: ®ex::Captures| {
764 let provided = slots.get("default");
765 let content = provided.cloned().unwrap_or_default();
766 if debug {
767 let p = if provided.is_some() { tp("default") } else { String::new() };
768 format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
769 } else {
770 content
771 }
772 })
773 .to_string();
774
775 let default_re = Regex::new(r#"<slot>([\s\S]*?)</slot>"#).unwrap();
777 result = default_re
778 .replace_all(&result, |caps: ®ex::Captures| {
779 let fallback = &caps[1];
780 let provided = slots.get("default");
781 let content = provided
782 .cloned()
783 .unwrap_or_else(|| fallback.trim().to_string());
784 if debug {
785 let p = if provided.is_some() { tp("default") } else { String::new() };
786 format!("<!-- START: {p}#default -->{content}<!-- END: {p}#default -->")
787 } else {
788 content
789 }
790 })
791 .to_string();
792
793 result
794}
795
796fn resolve_path_value<'a>(data: &'a Value, path: &str) -> Option<&'a Value> {
798 let mut current = data;
799 for key in path.split('.') {
800 let key = key.trim();
801 match current.get(key) {
802 Some(v) => current = v,
803 None => return None,
804 }
805 }
806 Some(current)
807}
808
809fn expand_v_for(template: &str, data: &Value) -> String {
811 let vfor_re = Regex::new(r#"<(\w[\w-]*)([^>]*)\sv-for="([^"]*)"([^>]*)>"#).unwrap();
812 let mut result = template.to_string();
813
814 for _ in 0..20 {
815 let Some(cap) = vfor_re.captures(&result) else {
816 break;
817 };
818
819 let full_match = cap.get(0).unwrap();
820 let tag_name = &cap[1];
821 let attrs_before = &cap[2];
822 let vfor_expr = &cap[3];
823 let attrs_after = &cap[4];
824
825 let (item_var, index_var, array_expr) = parse_vfor_expr(vfor_expr);
826 let open_tag_no_vfor = format!("<{}{}{}>", tag_name, attrs_before, attrs_after);
827 let match_start = full_match.start();
828 let after_open = full_match.end();
829 let is_self_closing = result[match_start..after_open].trim_end_matches('>').ends_with('/');
830
831 if is_self_closing {
832 let sc_tag = format!("<{}{}{} />", tag_name, attrs_before, attrs_after);
833 let array = resolve_path_value(data, &array_expr);
834 let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
835 let mut expanded = String::new();
836 for (idx, item) in items.iter().enumerate() {
837 let mut item_data = data.clone();
838 if let Value::Object(ref mut map) = item_data {
839 map.insert(item_var.clone(), item.clone());
840 if let Some(ref idx_var) = index_var {
841 map.insert(idx_var.clone(), Value::Number(idx.into()));
842 }
843 }
844 expanded.push_str(&interpolate(&sc_tag, &item_data));
845 }
846 result = format!("{}{}{}", &result[..match_start], expanded, &result[after_open..]);
847 continue;
848 }
849
850 let close_tag = format!("</{}>", tag_name);
851 let remaining = &result[after_open..];
852 let close_pos = find_matching_close_tag(remaining, tag_name);
853 let inner_content = remaining[..close_pos].to_string();
854 let element_end = after_open + close_pos + close_tag.len();
855
856 let array = resolve_path_value(data, &array_expr);
857 let items = array.and_then(|v| v.as_array()).cloned().unwrap_or_default();
858 let mut expanded = String::new();
859 for (idx, item) in items.iter().enumerate() {
860 let mut item_data = data.clone();
861 if let Value::Object(ref mut map) = item_data {
862 map.insert(item_var.clone(), item.clone());
863 if let Some(ref idx_var) = index_var {
864 map.insert(idx_var.clone(), Value::Number(idx.into()));
865 }
866 }
867 let tag_interpolated = interpolate(&open_tag_no_vfor, &item_data);
868 let inner_interpolated = interpolate(&inner_content, &item_data);
869 expanded.push_str(&format!("{}{}</{}>", tag_interpolated, inner_interpolated, tag_name));
870 }
871
872 result = format!("{}{}{}", &result[..match_start], expanded, &result[element_end..]);
873 }
874
875 result
876}
877
878fn parse_vfor_expr(expr: &str) -> (String, Option<String>, String) {
879 let parts: Vec<&str> = expr.splitn(2, " in ").collect();
880 if parts.len() != 2 {
881 return (expr.to_string(), None, String::new());
882 }
883 let lhs = parts[0].trim();
884 let array_expr = parts[1].trim().to_string();
885 if lhs.starts_with('(') && lhs.ends_with(')') {
886 let inner = &lhs[1..lhs.len() - 1];
887 let vars: Vec<&str> = inner.split(',').collect();
888 let item_var = vars[0].trim().to_string();
889 let index_var = vars.get(1).map(|v| v.trim().to_string());
890 (item_var, index_var, array_expr)
891 } else {
892 (lhs.to_string(), None, array_expr)
893 }
894}
895
896fn find_matching_close_tag(html: &str, tag_name: &str) -> usize {
897 let open = format!("<{}", tag_name);
898 let close = format!("</{}>", tag_name);
899 let mut depth = 0;
900 let mut pos = 0;
901 while pos < html.len() {
902 if html[pos..].starts_with(&close) {
903 if depth == 0 {
904 return pos;
905 }
906 depth -= 1;
907 pos += close.len();
908 } else if html[pos..].starts_with(&open) {
909 let after = pos + open.len();
910 if after < html.len() {
911 let ch = html.as_bytes()[after] as char;
912 if ch == ' ' || ch == '>' || ch == '/' || ch == '\n' || ch == '\t' {
913 depth += 1;
914 }
915 }
916 pos += open.len();
917 } else {
918 pos += 1;
919 }
920 }
921 html.len()
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use serde_json::json;
928
929 #[test]
930 fn test_extract_reactive_names() {
931 let script = r#"
932const count = ref(0)
933const doubled = computed(() => count * 2)
934"#;
935 let names = extract_reactive_names(script);
936 assert_eq!(names, vec!["count", "doubled"]);
937 }
938
939 #[test]
940 fn test_resolve_single_basic() {
941 let source = r#"
942<template>
943 <h1>{{ title }}</h1>
944</template>
945"#;
946 let data = json!({"title": "Hello"});
947 let resolved = resolve_single(source, &data).unwrap();
948 assert!(resolved.html.contains("<h1>Hello</h1>"));
949 assert!(resolved.styles.is_empty());
950 assert!(resolved.script_setup.is_none());
951 }
952
953 #[test]
954 fn test_resolve_single_with_style() {
955 let source = r#"
956<template>
957 <h1>Hello</h1>
958</template>
959
960<style scoped>
961h1 { color: red; }
962</style>
963"#;
964 let data = json!({});
965 let resolved = resolve_single(source, &data).unwrap();
966 assert_eq!(resolved.styles.len(), 1);
967 assert!(resolved.styles[0].contains("color: red"));
968 }
969
970 #[test]
971 fn test_resolve_single_reactive() {
972 let source = r#"
973<template>
974 <p>Count: {{ count }}</p>
975</template>
976
977<script setup>
978const count = ref(0)
979</script>
980"#;
981 let data = json!({});
982 let resolved = resolve_single(source, &data).unwrap();
983 assert!(resolved.html.contains("{{ count }}"));
984 assert!(resolved.script_setup.is_some());
985 }
986
987 #[test]
990 fn test_resolve_virtual_path_same_dir() {
991 assert_eq!(
992 resolve_virtual_path("index.van", "./hello.van"),
993 "hello.van"
994 );
995 }
996
997 #[test]
998 fn test_resolve_virtual_path_parent_dir() {
999 assert_eq!(
1000 resolve_virtual_path("pages/index.van", "../components/hello.van"),
1001 "components/hello.van"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_resolve_virtual_path_subdir() {
1007 assert_eq!(
1008 resolve_virtual_path("pages/index.van", "./sub.van"),
1009 "pages/sub.van"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_normalize_virtual_path() {
1015 assert_eq!(normalize_virtual_path("./hello.van"), "hello.van");
1016 assert_eq!(
1017 normalize_virtual_path("pages/../components/hello.van"),
1018 "components/hello.van"
1019 );
1020 assert_eq!(normalize_virtual_path("a/b/./c"), "a/b/c");
1021 }
1022
1023 #[test]
1024 fn test_resolve_virtual_path_scoped_package() {
1025 assert_eq!(
1027 resolve_virtual_path("pages/index.van", "@van-ui/button/button.van"),
1028 "@van-ui/button/button.van"
1029 );
1030 assert_eq!(
1031 resolve_virtual_path("index.van", "@van-ui/utils/format.ts"),
1032 "@van-ui/utils/format.ts"
1033 );
1034 }
1035
1036 #[test]
1037 fn test_resolve_with_files_scoped_import() {
1038 let mut files = HashMap::new();
1039 files.insert(
1040 "index.van".to_string(),
1041 r#"
1042<template>
1043 <van-button :label="title" />
1044</template>
1045
1046<script setup>
1047import VanButton from '@van-ui/button/button.van'
1048</script>
1049"#
1050 .to_string(),
1051 );
1052 files.insert(
1054 "@van-ui/button/button.van".to_string(),
1055 r#"
1056<template>
1057 <button>{{ label }}</button>
1058</template>
1059"#
1060 .to_string(),
1061 );
1062
1063 let data = json!({"title": "Click me"});
1064 let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1065 assert!(resolved.html.contains("<button>Click me</button>"));
1066 }
1067
1068 #[test]
1071 fn test_resolve_with_files_basic_import() {
1072 let mut files = HashMap::new();
1073 files.insert(
1074 "index.van".to_string(),
1075 r#"
1076<template>
1077 <hello :name="title" />
1078</template>
1079
1080<script setup>
1081import Hello from './hello.van'
1082</script>
1083"#
1084 .to_string(),
1085 );
1086 files.insert(
1087 "hello.van".to_string(),
1088 r#"
1089<template>
1090 <h1>Hello, {{ name }}!</h1>
1091</template>
1092"#
1093 .to_string(),
1094 );
1095
1096 let data = json!({"title": "World"});
1097 let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1098 assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1099 }
1100
1101 #[test]
1102 fn test_resolve_with_files_missing_component() {
1103 let mut files = HashMap::new();
1104 files.insert(
1105 "index.van".to_string(),
1106 r#"
1107<template>
1108 <hello />
1109</template>
1110
1111<script setup>
1112import Hello from './hello.van'
1113</script>
1114"#
1115 .to_string(),
1116 );
1117
1118 let data = json!({});
1119 let result = resolve_with_files("index.van", &files, &data);
1120 assert!(result.is_err());
1121 assert!(result.unwrap_err().contains("Component not found"));
1122 }
1123
1124 #[test]
1125 fn test_resolve_with_files_slots() {
1126 let mut files = HashMap::new();
1127 files.insert(
1128 "index.van".to_string(),
1129 r#"
1130<template>
1131 <wrapper>
1132 <p>Default slot content</p>
1133 </wrapper>
1134</template>
1135
1136<script setup>
1137import Wrapper from './wrapper.van'
1138</script>
1139"#
1140 .to_string(),
1141 );
1142 files.insert(
1143 "wrapper.van".to_string(),
1144 r#"
1145<template>
1146 <div class="wrapper"><slot /></div>
1147</template>
1148"#
1149 .to_string(),
1150 );
1151
1152 let data = json!({});
1153 let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1154 assert!(resolved.html.contains("<div class=\"wrapper\">"));
1155 assert!(resolved.html.contains("<p>Default slot content</p>"));
1156 }
1157
1158 #[test]
1159 fn test_resolve_with_files_styles_collected() {
1160 let mut files = HashMap::new();
1161 files.insert(
1162 "index.van".to_string(),
1163 r#"
1164<template>
1165 <hello />
1166</template>
1167
1168<script setup>
1169import Hello from './hello.van'
1170</script>
1171
1172<style>
1173.app { color: blue; }
1174</style>
1175"#
1176 .to_string(),
1177 );
1178 files.insert(
1179 "hello.van".to_string(),
1180 r#"
1181<template>
1182 <h1>Hello</h1>
1183</template>
1184
1185<style>
1186h1 { color: red; }
1187</style>
1188"#
1189 .to_string(),
1190 );
1191
1192 let data = json!({});
1193 let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1194 assert_eq!(resolved.styles.len(), 2);
1195 assert!(resolved.styles[0].contains("color: blue"));
1196 assert!(resolved.styles[1].contains("color: red"));
1197 }
1198
1199 #[test]
1200 fn test_resolve_with_files_reactive_preserved() {
1201 let mut files = HashMap::new();
1202 files.insert(
1203 "index.van".to_string(),
1204 r#"
1205<template>
1206 <div>
1207 <p>Count: {{ count }}</p>
1208 <hello :name="title" />
1209 </div>
1210</template>
1211
1212<script setup>
1213import Hello from './hello.van'
1214const count = ref(0)
1215</script>
1216"#
1217 .to_string(),
1218 );
1219 files.insert(
1220 "hello.van".to_string(),
1221 r#"
1222<template>
1223 <h1>Hello, {{ name }}!</h1>
1224</template>
1225"#
1226 .to_string(),
1227 );
1228
1229 let data = json!({"title": "World"});
1230 let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1231 assert!(resolved.html.contains("{{ count }}"));
1233 assert!(resolved.html.contains("<h1>Hello, World!</h1>"));
1235 assert!(resolved.script_setup.is_some());
1236 }
1237
1238 #[test]
1241 fn test_extract_self_closing_tag() {
1242 let template = r#"<div><hello :name="title" /></div>"#;
1243 let info = extract_component_tag(template, "hello").unwrap();
1244 assert_eq!(info.tag_name, "hello");
1245 assert_eq!(info.attrs, r#":name="title""#);
1246 assert!(info.children.is_empty());
1247 }
1248
1249 #[test]
1250 fn test_extract_paired_tag() {
1251 let template = r#"<default-layout><h1>Content</h1></default-layout>"#;
1252 let info = extract_component_tag(template, "default-layout").unwrap();
1253 assert_eq!(info.tag_name, "default-layout");
1254 assert_eq!(info.children, "<h1>Content</h1>");
1255 }
1256
1257 #[test]
1258 fn test_extract_no_match() {
1259 let template = r#"<div>no components here</div>"#;
1260 assert!(extract_component_tag(template, "hello").is_none());
1261 }
1262
1263 #[test]
1264 fn test_parse_props() {
1265 let data = json!({"title": "World", "count": 42});
1266 let attrs = r#":name="title" :num="count""#;
1267 let result = parse_props(attrs, &data);
1268 assert_eq!(result["name"], "World");
1269 assert_eq!(result["num"], "42");
1270 }
1271
1272 #[test]
1273 fn test_distribute_slots_default() {
1274 let html = r#"<div><slot /></div>"#;
1275 let mut slots = HashMap::new();
1276 slots.insert("default".to_string(), "Hello World".to_string());
1277 let result = distribute_slots(html, &slots, false, &HashMap::new());
1278 assert_eq!(result, "<div>Hello World</div>");
1279 }
1280
1281 #[test]
1282 fn test_distribute_slots_named() {
1283 let html =
1284 r#"<title><slot name="title">Fallback</slot></title><div><slot /></div>"#;
1285 let mut slots = HashMap::new();
1286 slots.insert("title".to_string(), "My Title".to_string());
1287 slots.insert("default".to_string(), "Body".to_string());
1288 let result = distribute_slots(html, &slots, false, &HashMap::new());
1289 assert_eq!(result, "<title>My Title</title><div>Body</div>");
1290 }
1291
1292 #[test]
1293 fn test_distribute_slots_fallback() {
1294 let html = r#"<title><slot name="title">Fallback Title</slot></title>"#;
1295 let slots = HashMap::new();
1296 let result = distribute_slots(html, &slots, false, &HashMap::new());
1297 assert_eq!(result, "<title>Fallback Title</title>");
1298 }
1299
1300 #[test]
1301 fn test_expand_v_for_basic() {
1302 let data = json!({"items": ["Alice", "Bob", "Charlie"]});
1303 let template = r#"<ul><li v-for="item in items">{{ item }}</li></ul>"#;
1304 let result = expand_v_for(template, &data);
1305 assert!(result.contains("<li>Alice</li>"));
1306 assert!(result.contains("<li>Bob</li>"));
1307 assert!(result.contains("<li>Charlie</li>"));
1308 assert!(!result.contains("v-for"));
1309 }
1310
1311 #[test]
1312 fn test_expand_v_for_with_index() {
1313 let data = json!({"items": ["A", "B"]});
1314 let template = r#"<ul><li v-for="(item, index) in items">{{ index }}: {{ item }}</li></ul>"#;
1315 let result = expand_v_for(template, &data);
1316 assert!(result.contains("0: A"));
1317 assert!(result.contains("1: B"));
1318 }
1319
1320 #[test]
1321 fn test_expand_v_for_nested_path() {
1322 let data = json!({"user": {"hobbies": ["coding", "reading"]}});
1323 let template = r#"<span v-for="h in user.hobbies">{{ h }}</span>"#;
1324 let result = expand_v_for(template, &data);
1325 assert!(result.contains("<span>coding</span>"));
1326 assert!(result.contains("<span>reading</span>"));
1327 }
1328
1329 #[test]
1332 fn test_resolve_scoped_style_single() {
1333 let source = r#"
1334<template>
1335 <div class="card"><h1>{{ title }}</h1></div>
1336</template>
1337
1338<style scoped>
1339.card { border: 1px solid; }
1340h1 { color: navy; }
1341</style>
1342"#;
1343 let data = json!({"title": "Hello"});
1344 let css = ".card { border: 1px solid; }\nh1 { color: navy; }";
1345 let id = van_parser::scope_id(css);
1346 let resolved = resolve_single_with_path(source, &data, "components/card.van").unwrap();
1347 assert!(resolved.html.contains(&format!("class=\"card {id}\"")), "Root should have scope class appended");
1349 assert!(resolved.html.contains(&format!("class=\"{id}\"")), "Child h1 should have scope class");
1350 assert_eq!(resolved.styles.len(), 1);
1352 assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1353 assert!(resolved.styles[0].contains(&format!("h1.{id}")));
1354 }
1355
1356 #[test]
1357 fn test_resolve_scoped_style_multi_file() {
1358 let mut files = HashMap::new();
1359 files.insert(
1360 "index.van".to_string(),
1361 r#"
1362<template>
1363 <card :title="title" />
1364</template>
1365
1366<script setup>
1367import Card from './card.van'
1368</script>
1369"#.to_string(),
1370 );
1371 files.insert(
1372 "card.van".to_string(),
1373 r#"
1374<template>
1375 <div class="card"><h1>{{ title }}</h1></div>
1376</template>
1377
1378<style scoped>
1379.card { border: 1px solid; }
1380</style>
1381"#.to_string(),
1382 );
1383
1384 let data = json!({"title": "Test"});
1385 let id = van_parser::scope_id(".card { border: 1px solid; }");
1386 let resolved = resolve_with_files("index.van", &files, &data).unwrap();
1387 assert!(resolved.html.contains(&format!("card {id}")), "Should contain scope class");
1389 assert_eq!(resolved.styles.len(), 1);
1391 assert!(resolved.styles[0].contains(&format!(".card.{id}")));
1392 }
1393
1394 #[test]
1395 fn test_resolve_unscoped_style_unchanged() {
1396 let source = r#"
1397<template>
1398 <div class="app"><p>Hello</p></div>
1399</template>
1400
1401<style>
1402.app { margin: 0; }
1403</style>
1404"#;
1405 let data = json!({});
1406 let resolved = resolve_single(source, &data).unwrap();
1407 assert_eq!(resolved.html.matches("class=").count(), 1, "Only the original class attr");
1409 assert!(resolved.html.contains("class=\"app\""), "Original class preserved");
1410 assert_eq!(resolved.styles[0], ".app { margin: 0; }");
1411 }
1412}