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