1use std::collections::HashMap;
2use std::hash::{Hash, Hasher};
3use std::collections::hash_map::DefaultHasher;
4
5use regex::Regex;
6use serde_json::Value;
7use van_signal_gen::{extract_initial_values, generate_signals, runtime_js};
8
9use crate::i18n;
10use crate::resolve::ResolvedComponent;
11
12fn content_hash(content: &str) -> String {
14 let mut hasher = DefaultHasher::new();
15 content.hash(&mut hasher);
16 format!("{:08x}", hasher.finish() as u32)
17}
18
19fn inject_before_close(html: &mut String, close_tag: &str, content: &str) {
22 if content.is_empty() {
23 return;
24 }
25 if let Some(pos) = html.find(close_tag) {
26 let before = &html[..pos];
27 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
28 let line_prefix = &before[line_start..];
29 let indent_len = line_prefix.len() - line_prefix.trim_start().len();
30 let child_indent = format!("{} ", &line_prefix[..indent_len]);
31 let mut injection = String::new();
32 for line in content.lines() {
33 injection.push_str(&child_indent);
34 injection.push_str(line);
35 injection.push('\n');
36 }
37 html.insert_str(line_start, &injection);
38 }
39}
40
41fn augment_data_with_signals(data: &Value, script_setup: Option<&str>) -> Value {
46 let Some(script) = script_setup else {
47 return data.clone();
48 };
49 let initial_values = extract_initial_values(script);
50 if initial_values.is_empty() {
51 return data.clone();
52 }
53 let mut augmented = data.clone();
54 if let Value::Object(ref mut map) = augmented {
55 for (name, value) in initial_values {
56 if !map.contains_key(&name) {
58 map.insert(name, Value::String(value));
59 }
60 }
61 }
62 augmented
63}
64
65pub struct PageAssets {
67 pub html: String,
69 pub assets: HashMap<String, String>,
71}
72
73pub fn render_page(resolved: &ResolvedComponent, data: &Value, global_name: &str) -> Result<String, String> {
83 let style_block: String = resolved
84 .styles
85 .iter()
86 .map(|css| format!("<style>{css}</style>"))
87 .collect::<Vec<_>>()
88 .join("\n");
89
90 let module_code: Vec<String> = resolved
92 .module_imports
93 .iter()
94 .filter(|m| !m.is_type_only)
95 .map(|m| m.content.clone())
96 .collect();
97
98 let signal_scripts = if let Some(ref script_setup) = resolved.script_setup {
100 if let Some(signal_js) = generate_signals(script_setup, &resolved.html, &module_code, global_name) {
101 let runtime = runtime_js(global_name);
102 format!(
103 "<script>{runtime}</script>\n<script>{signal_js}</script>"
104 )
105 } else {
106 String::new()
107 }
108 } else {
109 String::new()
110 };
111
112 let augmented_data =
114 augment_data_with_signals(data, resolved.script_setup.as_deref());
115
116 let clean_html = cleanup_html(&resolved.html, &augmented_data);
118
119 if clean_html.contains("<html") {
120 let mut html = clean_html;
122 inject_before_close(&mut html, "</head>", &style_block);
123 inject_before_close(&mut html, "</body>", &signal_scripts);
124 Ok(html)
125 } else {
126 let html = format!(
128 r#"<!DOCTYPE html>
129<html lang="en">
130<head>
131<meta charset="UTF-8" />
132<meta name="viewport" content="width=device-width, initial-scale=1.0" />
133<title>Van Playground</title>
134{style_block}
135</head>
136<body>
137{clean_html}
138{signal_scripts}
139</body>
140</html>"#
141 );
142
143 Ok(html)
144 }
145}
146
147pub fn render_page_assets(
155 resolved: &ResolvedComponent,
156 data: &Value,
157 page_name: &str,
158 asset_prefix: &str,
159 global_name: &str,
160) -> Result<PageAssets, String> {
161 let mut assets = HashMap::new();
162
163 let css_ref = if !resolved.styles.is_empty() {
165 let css_content: String = resolved.styles.join("\n");
166 let hash = content_hash(&css_content);
167 let css_path = format!("{}/css/{}.{}.css", asset_prefix, page_name, hash);
168 assets.insert(css_path.clone(), css_content);
169 format!(r#"<link rel="stylesheet" href="{css_path}">"#)
170 } else {
171 String::new()
172 };
173
174 let module_code: Vec<String> = resolved
176 .module_imports
177 .iter()
178 .filter(|m| !m.is_type_only)
179 .map(|m| m.content.clone())
180 .collect();
181
182 let js_ref = if let Some(ref script_setup) = resolved.script_setup {
184 if let Some(signal_js) = generate_signals(script_setup, &resolved.html, &module_code, global_name) {
185 let runtime = runtime_js(global_name);
186 let runtime_hash = content_hash(&runtime);
187 let runtime_path = format!("{}/js/van-runtime.{}.js", asset_prefix, runtime_hash);
188 let js_hash = content_hash(&signal_js);
189 let js_path = format!("{}/js/{}.{}.js", asset_prefix, page_name, js_hash);
190 assets.insert(runtime_path.clone(), runtime);
191 assets.insert(js_path.clone(), signal_js);
192 format!(
193 r#"<script src="{runtime_path}"></script>
194<script src="{js_path}"></script>"#
195 )
196 } else {
197 String::new()
198 }
199 } else {
200 String::new()
201 };
202
203 let augmented_data =
205 augment_data_with_signals(data, resolved.script_setup.as_deref());
206
207 let clean_html = cleanup_html(&resolved.html, &augmented_data);
209
210 let html = if clean_html.contains("<html") {
211 let mut html = clean_html;
212 inject_before_close(&mut html, "</head>", &css_ref);
213 inject_before_close(&mut html, "</body>", &js_ref);
214 html
215 } else {
216 format!(
217 r#"<!DOCTYPE html>
218<html lang="en">
219<head>
220<meta charset="UTF-8" />
221<meta name="viewport" content="width=device-width, initial-scale=1.0" />
222<title>Van App</title>
223{css_ref}
224</head>
225<body>
226{clean_html}
227{js_ref}
228</body>
229</html>"#
230 )
231 };
232
233 Ok(PageAssets { html, assets })
234}
235
236fn cleanup_html(html: &str, data: &Value) -> String {
242 let mut result = html.to_string();
243
244 let event_re = Regex::new(r#"\s*@\w+="[^"]*""#).unwrap();
246 result = event_re.replace_all(&result, "").to_string();
247
248 let transition_re = Regex::new(r#"</?[Tt]ransition[^>]*>"#).unwrap();
250 result = transition_re.replace_all(&result, "").to_string();
251
252 let key_re = Regex::new(r#"\s*:key="[^"]*""#).unwrap();
254 result = key_re.replace_all(&result, "").to_string();
255
256 let show_re = Regex::new(r#"\s*v-(?:show|if)="([^"]*)""#).unwrap();
258 result = show_re
259 .replace_all(&result, |caps: ®ex::Captures| {
260 let expr = &caps[1];
261 let value = resolve_path(data, expr);
262 let is_falsy = value == "0"
263 || value == "false"
264 || value.is_empty()
265 || value == "null"
266 || value.contains("{{");
267 if is_falsy {
268 r#" style="display:none""#.to_string()
269 } else {
270 String::new()
271 }
272 })
273 .to_string();
274
275 let else_if_re = Regex::new(r#"\s*v-else-if="([^"]*)""#).unwrap();
277 result = else_if_re
278 .replace_all(&result, |caps: ®ex::Captures| {
279 let expr = &caps[1];
280 let value = resolve_path(data, expr);
281 let is_falsy = value == "0"
282 || value == "false"
283 || value.is_empty()
284 || value == "null"
285 || value.contains("{{");
286 if is_falsy {
287 r#" style="display:none""#.to_string()
288 } else {
289 String::new()
290 }
291 })
292 .to_string();
293
294 let else_re = Regex::new(r#"\s+v-else"#).unwrap();
296 result = else_re.replace_all(&result, "").to_string();
297
298 let vhtml_re = Regex::new(r#"\s*v-html="[^"]*""#).unwrap();
300 result = vhtml_re.replace_all(&result, "").to_string();
301 let vtext_re = Regex::new(r#"\s*v-text="[^"]*""#).unwrap();
302 result = vtext_re.replace_all(&result, "").to_string();
303
304 let bind_class_re = Regex::new(r#"\s*:class="[^"]*""#).unwrap();
306 result = bind_class_re.replace_all(&result, "").to_string();
307 let bind_style_re = Regex::new(r#"\s*:style="[^"]*""#).unwrap();
308 result = bind_style_re.replace_all(&result, "").to_string();
309
310 let model_re = Regex::new(r#"\s*v-model="([^"]*)""#).unwrap();
312 result = model_re
313 .replace_all(&result, |caps: ®ex::Captures| {
314 let expr = &caps[1];
315 let value = resolve_path(data, expr);
316 if value.contains("{{") {
317 String::new()
318 } else {
319 format!(r#" value="{}""#, value)
320 }
321 })
322 .to_string();
323
324 result = interpolate(&result, data);
326
327 result
328}
329
330pub fn escape_html(text: &str) -> String {
332 let mut result = String::with_capacity(text.len());
333 for ch in text.chars() {
334 match ch {
335 '&' => result.push_str("&"),
336 '<' => result.push_str("<"),
337 '>' => result.push_str(">"),
338 '"' => result.push_str("""),
339 '\'' => result.push_str("'"),
340 _ => result.push(ch),
341 }
342 }
343 result
344}
345
346pub fn interpolate(template: &str, data: &Value) -> String {
354 let mut result = String::with_capacity(template.len());
355 let mut rest = template;
356
357 while let Some(start) = rest.find("{{") {
358 result.push_str(&rest[..start]);
359
360 if rest[start..].starts_with("{{{") {
362 let after_open = &rest[start + 3..];
363 if let Some(end) = after_open.find("}}}") {
364 let expr = after_open[..end].trim();
365 if let Some(translated) = try_resolve_t(expr, data) {
366 result.push_str(&translated);
367 } else if expr.trim().starts_with("$t(") {
368 result.push_str(&format!("{{{{{{{}}}}}}}", expr));
370 } else {
371 result.push_str(&resolve_path(data, expr));
372 }
373 rest = &after_open[end + 3..];
374 } else {
375 result.push_str("{{{");
376 rest = &rest[start + 3..];
377 }
378 } else {
379 let after_open = &rest[start + 2..];
380 if let Some(end) = after_open.find("}}") {
381 let expr = after_open[..end].trim();
382 if let Some(translated) = try_resolve_t(expr, data) {
383 result.push_str(&escape_html(&translated));
384 } else if expr.trim().starts_with("$t(") {
385 result.push_str(&format!("{{{{{}}}}}", expr));
387 } else {
388 result.push_str(&escape_html(&resolve_path(data, expr)));
389 }
390 rest = &after_open[end + 2..];
391 } else {
392 result.push_str("{{");
393 rest = after_open;
394 }
395 }
396 }
397 result.push_str(rest);
398 result
399}
400
401pub(crate) fn try_resolve_t(expr: &str, data: &Value) -> Option<String> {
404 let (key, params_str) = i18n::parse_t_call(expr)?;
405
406 let i18n_messages = data.get("$i18n")?;
408
409 let mut resolved_params = std::collections::HashMap::new();
410 if let Some(ref ps) = params_str {
411 for (k, v) in i18n::parse_t_params(ps) {
412 let value = match v {
413 i18n::ParamValue::Literal(lit) => lit,
414 i18n::ParamValue::DataPath(path) => {
415 let resolved = resolve_path(data, &path);
416 if resolved.contains("{{") {
418 path
419 } else {
420 resolved
421 }
422 }
423 };
424 resolved_params.insert(k, value);
425 }
426 }
427
428 Some(i18n::resolve_translation(&key, &resolved_params, i18n_messages))
429}
430
431pub fn resolve_path(data: &Value, path: &str) -> String {
433 let mut current = data;
434 for key in path.split('.') {
435 let key = key.trim();
436 match current.get(key) {
437 Some(v) => current = v,
438 None => return format!("{{{{{}}}}}", path),
439 }
440 }
441 match current {
442 Value::String(s) => s.clone(),
443 Value::Null => String::new(),
444 other => other.to_string(),
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use serde_json::json;
452
453 #[test]
454 fn test_interpolate_simple() {
455 let data = json!({"name": "World"});
456 assert_eq!(interpolate("Hello {{ name }}!", &data), "Hello World!");
457 }
458
459 #[test]
460 fn test_interpolate_dot_path() {
461 let data = json!({"user": {"name": "Alice"}});
462 assert_eq!(interpolate("Hi {{ user.name }}!", &data), "Hi Alice!");
463 }
464
465 #[test]
466 fn test_interpolate_missing_key() {
467 let data = json!({});
468 assert_eq!(interpolate("{{ missing }}", &data), "{{missing}}");
469 }
470
471 #[test]
472 fn test_cleanup_html_strips_events() {
473 let html = r#"<button @click="increment">+1</button>"#;
474 let data = json!({});
475 let clean = cleanup_html(html, &data);
476 assert_eq!(clean, "<button>+1</button>");
477 }
478
479 #[test]
480 fn test_cleanup_html_v_show_falsy() {
481 let html = r#"<p v-show="visible">Hello</p>"#;
482 let data = json!({"visible": false});
483 let clean = cleanup_html(html, &data);
484 assert!(!clean.contains("v-show"));
485 assert!(clean.contains(r#"style="display:none""#));
486 }
487
488 #[test]
489 fn test_cleanup_html_v_show_truthy() {
490 let html = r#"<p v-show="visible">Hello</p>"#;
491 let data = json!({"visible": true});
492 let clean = cleanup_html(html, &data);
493 assert!(!clean.contains("v-show"));
494 assert_eq!(clean, "<p>Hello</p>");
495 }
496
497 #[test]
498 fn test_cleanup_html_strips_transition_tags() {
499 let html = r#"<div><Transition name="slide"><p v-show="open">Hi</p></Transition></div>"#;
500 let data = json!({"open": false});
501 let clean = cleanup_html(html, &data);
502 assert!(!clean.contains("Transition"));
503 assert!(!clean.contains("transition"));
504 assert!(clean.contains("<p"));
505 }
506
507 #[test]
508 fn test_interpolate_escapes_html() {
509 let data = json!({"desc": "<script>alert('xss')</script>"});
510 assert_eq!(
511 interpolate("{{ desc }}", &data),
512 "<script>alert('xss')</script>"
513 );
514 }
515
516 #[test]
517 fn test_interpolate_triple_mustache_raw() {
518 let data = json!({"html": "<b>bold</b>"});
519 assert_eq!(interpolate("{{{ html }}}", &data), "<b>bold</b>");
520 }
521
522 #[test]
523 fn test_interpolate_mixed_escaped_and_raw() {
524 let data = json!({"safe": "<b>bold</b>", "text": "<em>hi</em>"});
525 assert_eq!(
526 interpolate("{{{ safe }}} and {{ text }}", &data),
527 "<b>bold</b> and <em>hi</em>"
528 );
529 }
530
531 #[test]
532 fn test_interpolate_t_simple() {
533 let data = json!({
534 "$i18n": { "hello": "你好" }
535 });
536 assert_eq!(interpolate("{{ $t('hello') }}", &data), "你好");
537 }
538
539 #[test]
540 fn test_interpolate_t_with_params() {
541 let data = json!({
542 "userName": "Alice",
543 "$i18n": { "greeting": "你好,{name}!" }
544 });
545 assert_eq!(
546 interpolate("{{ $t('greeting', { name: userName }) }}", &data),
547 "你好,Alice!"
548 );
549 }
550
551 #[test]
552 fn test_interpolate_t_missing_key() {
553 let data = json!({ "$i18n": {} });
554 assert_eq!(interpolate("{{ $t('missing') }}", &data), "missing");
555 }
556
557 #[test]
558 fn test_interpolate_t_no_i18n_data() {
559 let data = json!({});
560 assert_eq!(interpolate("{{ $t('hello') }}", &data), "{{$t('hello')}}");
561 }
562
563 #[test]
564 fn test_interpolate_t_triple_mustache() {
565 let data = json!({
566 "$i18n": { "html_content": "<b>粗体</b>" }
567 });
568 assert_eq!(
570 interpolate("{{{ $t('html_content') }}}", &data),
571 "<b>粗体</b>"
572 );
573 assert_eq!(
575 interpolate("{{ $t('html_content') }}", &data),
576 "<b>粗体</b>"
577 );
578 }
579
580 #[test]
581 fn test_interpolate_t_plural() {
582 let data = json!({
583 "itemCount": 3,
584 "$i18n": { "items": "没有项目 | 1 个项目 | {count} 个项目" }
585 });
586 assert_eq!(
587 interpolate("{{ $t('items', { count: itemCount }) }}", &data),
588 "3 个项目"
589 );
590 }
591
592 #[test]
593 fn test_interpolate_t_mixed_with_regular() {
594 let data = json!({
595 "name": "World",
596 "$i18n": { "hello": "你好" }
597 });
598 assert_eq!(
599 interpolate("{{ $t('hello') }}, {{ name }}!", &data),
600 "你好, World!"
601 );
602 }
603
604 #[test]
605 fn test_render_page_basic() {
606 let resolved = ResolvedComponent {
607 html: "<h1>Hello</h1>".to_string(),
608 styles: vec!["h1 { color: red; }".to_string()],
609 script_setup: None,
610 module_imports: Vec::new(),
611 };
612 let data = json!({});
613 let html = render_page(&resolved, &data, "Van").unwrap();
614 assert!(html.contains("<h1>Hello</h1>"));
615 assert!(html.contains("h1 { color: red; }"));
616 assert!(!html.contains("__van/ws"));
618 }
619}