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::resolve::ResolvedComponent;
10
11fn content_hash(content: &str) -> String {
13 let mut hasher = DefaultHasher::new();
14 content.hash(&mut hasher);
15 format!("{:08x}", hasher.finish() as u32)
16}
17
18fn inject_before_close(html: &mut String, close_tag: &str, content: &str) {
21 if content.is_empty() {
22 return;
23 }
24 if let Some(pos) = html.find(close_tag) {
25 let before = &html[..pos];
26 let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
27 let line_prefix = &before[line_start..];
28 let indent_len = line_prefix.len() - line_prefix.trim_start().len();
29 let child_indent = format!("{} ", &line_prefix[..indent_len]);
30 let mut injection = String::new();
31 for line in content.lines() {
32 injection.push_str(&child_indent);
33 injection.push_str(line);
34 injection.push('\n');
35 }
36 html.insert_str(line_start, &injection);
37 }
38}
39
40fn augment_data_with_signals(data: &Value, script_setup: Option<&str>) -> Value {
45 let Some(script) = script_setup else {
46 return data.clone();
47 };
48 let initial_values = extract_initial_values(script);
49 if initial_values.is_empty() {
50 return data.clone();
51 }
52 let mut augmented = data.clone();
53 if let Value::Object(ref mut map) = augmented {
54 for (name, value) in initial_values {
55 if !map.contains_key(&name) {
57 map.insert(name, Value::String(value));
58 }
59 }
60 }
61 augmented
62}
63
64pub struct PageAssets {
66 pub html: String,
68 pub assets: HashMap<String, String>,
70}
71
72pub fn render_page(resolved: &ResolvedComponent, data: &Value) -> Result<String, String> {
82 let style_block: String = resolved
83 .styles
84 .iter()
85 .map(|css| format!("<style>{css}</style>"))
86 .collect::<Vec<_>>()
87 .join("\n");
88
89 let module_code: Vec<String> = resolved
91 .module_imports
92 .iter()
93 .filter(|m| !m.is_type_only)
94 .map(|m| m.content.clone())
95 .collect();
96
97 let signal_scripts = if let Some(ref script_setup) = resolved.script_setup {
99 if let Some(signal_js) = generate_signals(script_setup, &resolved.html, &module_code) {
100 format!(
101 "<script>{RUNTIME_JS}</script>\n<script>{signal_js}</script>"
102 )
103 } else {
104 String::new()
105 }
106 } else {
107 String::new()
108 };
109
110 let augmented_data =
112 augment_data_with_signals(data, resolved.script_setup.as_deref());
113
114 let clean_html = cleanup_html(&resolved.html, &augmented_data);
116
117 if clean_html.contains("<html") {
118 let mut html = clean_html;
120 inject_before_close(&mut html, "</head>", &style_block);
121 inject_before_close(&mut html, "</body>", &signal_scripts);
122 Ok(html)
123 } else {
124 let html = format!(
126 r#"<!DOCTYPE html>
127<html lang="en">
128<head>
129<meta charset="UTF-8" />
130<meta name="viewport" content="width=device-width, initial-scale=1.0" />
131<title>Van Playground</title>
132{style_block}
133</head>
134<body>
135{clean_html}
136{signal_scripts}
137</body>
138</html>"#
139 );
140
141 Ok(html)
142 }
143}
144
145pub fn render_page_assets(
153 resolved: &ResolvedComponent,
154 data: &Value,
155 page_name: &str,
156 asset_prefix: &str,
157) -> Result<PageAssets, String> {
158 let mut assets = HashMap::new();
159
160 let css_ref = if !resolved.styles.is_empty() {
162 let css_content: String = resolved.styles.join("\n");
163 let hash = content_hash(&css_content);
164 let css_path = format!("{}/css/{}.{}.css", asset_prefix, page_name, hash);
165 assets.insert(css_path.clone(), css_content);
166 format!(r#"<link rel="stylesheet" href="{css_path}">"#)
167 } else {
168 String::new()
169 };
170
171 let module_code: Vec<String> = resolved
173 .module_imports
174 .iter()
175 .filter(|m| !m.is_type_only)
176 .map(|m| m.content.clone())
177 .collect();
178
179 let js_ref = if let Some(ref script_setup) = resolved.script_setup {
181 if let Some(signal_js) = generate_signals(script_setup, &resolved.html, &module_code) {
182 let runtime_hash = content_hash(RUNTIME_JS);
183 let runtime_path = format!("{}/js/van-runtime.{}.js", asset_prefix, runtime_hash);
184 let js_hash = content_hash(&signal_js);
185 let js_path = format!("{}/js/{}.{}.js", asset_prefix, page_name, js_hash);
186 assets.insert(runtime_path.clone(), RUNTIME_JS.to_string());
187 assets.insert(js_path.clone(), signal_js);
188 format!(
189 r#"<script src="{runtime_path}"></script>
190<script src="{js_path}"></script>"#
191 )
192 } else {
193 String::new()
194 }
195 } else {
196 String::new()
197 };
198
199 let augmented_data =
201 augment_data_with_signals(data, resolved.script_setup.as_deref());
202
203 let clean_html = cleanup_html(&resolved.html, &augmented_data);
205
206 let html = if clean_html.contains("<html") {
207 let mut html = clean_html;
208 inject_before_close(&mut html, "</head>", &css_ref);
209 inject_before_close(&mut html, "</body>", &js_ref);
210 html
211 } else {
212 format!(
213 r#"<!DOCTYPE html>
214<html lang="en">
215<head>
216<meta charset="UTF-8" />
217<meta name="viewport" content="width=device-width, initial-scale=1.0" />
218<title>Van App</title>
219{css_ref}
220</head>
221<body>
222{clean_html}
223{js_ref}
224</body>
225</html>"#
226 )
227 };
228
229 Ok(PageAssets { html, assets })
230}
231
232fn cleanup_html(html: &str, data: &Value) -> String {
238 let mut result = html.to_string();
239
240 let event_re = Regex::new(r#"\s*@\w+="[^"]*""#).unwrap();
242 result = event_re.replace_all(&result, "").to_string();
243
244 let transition_re = Regex::new(r#"</?[Tt]ransition[^>]*>"#).unwrap();
246 result = transition_re.replace_all(&result, "").to_string();
247
248 let key_re = Regex::new(r#"\s*:key="[^"]*""#).unwrap();
250 result = key_re.replace_all(&result, "").to_string();
251
252 let show_re = Regex::new(r#"\s*v-(?:show|if)="([^"]*)""#).unwrap();
254 result = show_re
255 .replace_all(&result, |caps: ®ex::Captures| {
256 let expr = &caps[1];
257 let value = resolve_path(data, expr);
258 let is_falsy = value == "0"
259 || value == "false"
260 || value.is_empty()
261 || value == "null"
262 || value.contains("{{");
263 if is_falsy {
264 r#" style="display:none""#.to_string()
265 } else {
266 String::new()
267 }
268 })
269 .to_string();
270
271 let else_if_re = Regex::new(r#"\s*v-else-if="([^"]*)""#).unwrap();
273 result = else_if_re
274 .replace_all(&result, |caps: ®ex::Captures| {
275 let expr = &caps[1];
276 let value = resolve_path(data, expr);
277 let is_falsy = value == "0"
278 || value == "false"
279 || value.is_empty()
280 || value == "null"
281 || value.contains("{{");
282 if is_falsy {
283 r#" style="display:none""#.to_string()
284 } else {
285 String::new()
286 }
287 })
288 .to_string();
289
290 let else_re = Regex::new(r#"\s+v-else"#).unwrap();
292 result = else_re.replace_all(&result, "").to_string();
293
294 let vhtml_re = Regex::new(r#"\s*v-html="[^"]*""#).unwrap();
296 result = vhtml_re.replace_all(&result, "").to_string();
297 let vtext_re = Regex::new(r#"\s*v-text="[^"]*""#).unwrap();
298 result = vtext_re.replace_all(&result, "").to_string();
299
300 let bind_class_re = Regex::new(r#"\s*:class="[^"]*""#).unwrap();
302 result = bind_class_re.replace_all(&result, "").to_string();
303 let bind_style_re = Regex::new(r#"\s*:style="[^"]*""#).unwrap();
304 result = bind_style_re.replace_all(&result, "").to_string();
305
306 let model_re = Regex::new(r#"\s*v-model="([^"]*)""#).unwrap();
308 result = model_re
309 .replace_all(&result, |caps: ®ex::Captures| {
310 let expr = &caps[1];
311 let value = resolve_path(data, expr);
312 if value.contains("{{") {
313 String::new()
314 } else {
315 format!(r#" value="{}""#, value)
316 }
317 })
318 .to_string();
319
320 result = interpolate(&result, data);
322
323 result
324}
325
326pub fn escape_html(text: &str) -> String {
328 let mut result = String::with_capacity(text.len());
329 for ch in text.chars() {
330 match ch {
331 '&' => result.push_str("&"),
332 '<' => result.push_str("<"),
333 '>' => result.push_str(">"),
334 '"' => result.push_str("""),
335 '\'' => result.push_str("'"),
336 _ => result.push(ch),
337 }
338 }
339 result
340}
341
342pub fn interpolate(template: &str, data: &Value) -> String {
350 let mut result = String::with_capacity(template.len());
351 let mut rest = template;
352
353 while let Some(start) = rest.find("{{") {
354 result.push_str(&rest[..start]);
355
356 if rest[start..].starts_with("{{{") {
358 let after_open = &rest[start + 3..];
359 if let Some(end) = after_open.find("}}}") {
360 let expr = after_open[..end].trim();
361 let value = resolve_path(data, expr);
362 result.push_str(&value);
363 rest = &after_open[end + 3..];
364 } else {
365 result.push_str("{{{");
366 rest = &rest[start + 3..];
367 }
368 } else {
369 let after_open = &rest[start + 2..];
370 if let Some(end) = after_open.find("}}") {
371 let expr = after_open[..end].trim();
372 let value = resolve_path(data, expr);
373 result.push_str(&escape_html(&value));
374 rest = &after_open[end + 2..];
375 } else {
376 result.push_str("{{");
377 rest = after_open;
378 }
379 }
380 }
381 result.push_str(rest);
382 result
383}
384
385pub fn resolve_path(data: &Value, path: &str) -> String {
387 let mut current = data;
388 for key in path.split('.') {
389 let key = key.trim();
390 match current.get(key) {
391 Some(v) => current = v,
392 None => return format!("{{{{{}}}}}", path),
393 }
394 }
395 match current {
396 Value::String(s) => s.clone(),
397 Value::Null => String::new(),
398 other => other.to_string(),
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use serde_json::json;
406
407 #[test]
408 fn test_interpolate_simple() {
409 let data = json!({"name": "World"});
410 assert_eq!(interpolate("Hello {{ name }}!", &data), "Hello World!");
411 }
412
413 #[test]
414 fn test_interpolate_dot_path() {
415 let data = json!({"user": {"name": "Alice"}});
416 assert_eq!(interpolate("Hi {{ user.name }}!", &data), "Hi Alice!");
417 }
418
419 #[test]
420 fn test_interpolate_missing_key() {
421 let data = json!({});
422 assert_eq!(interpolate("{{ missing }}", &data), "{{missing}}");
423 }
424
425 #[test]
426 fn test_cleanup_html_strips_events() {
427 let html = r#"<button @click="increment">+1</button>"#;
428 let data = json!({});
429 let clean = cleanup_html(html, &data);
430 assert_eq!(clean, "<button>+1</button>");
431 }
432
433 #[test]
434 fn test_cleanup_html_v_show_falsy() {
435 let html = r#"<p v-show="visible">Hello</p>"#;
436 let data = json!({"visible": false});
437 let clean = cleanup_html(html, &data);
438 assert!(!clean.contains("v-show"));
439 assert!(clean.contains(r#"style="display:none""#));
440 }
441
442 #[test]
443 fn test_cleanup_html_v_show_truthy() {
444 let html = r#"<p v-show="visible">Hello</p>"#;
445 let data = json!({"visible": true});
446 let clean = cleanup_html(html, &data);
447 assert!(!clean.contains("v-show"));
448 assert_eq!(clean, "<p>Hello</p>");
449 }
450
451 #[test]
452 fn test_cleanup_html_strips_transition_tags() {
453 let html = r#"<div><Transition name="slide"><p v-show="open">Hi</p></Transition></div>"#;
454 let data = json!({"open": false});
455 let clean = cleanup_html(html, &data);
456 assert!(!clean.contains("Transition"));
457 assert!(!clean.contains("transition"));
458 assert!(clean.contains("<p"));
459 }
460
461 #[test]
462 fn test_interpolate_escapes_html() {
463 let data = json!({"desc": "<script>alert('xss')</script>"});
464 assert_eq!(
465 interpolate("{{ desc }}", &data),
466 "<script>alert('xss')</script>"
467 );
468 }
469
470 #[test]
471 fn test_interpolate_triple_mustache_raw() {
472 let data = json!({"html": "<b>bold</b>"});
473 assert_eq!(interpolate("{{{ html }}}", &data), "<b>bold</b>");
474 }
475
476 #[test]
477 fn test_interpolate_mixed_escaped_and_raw() {
478 let data = json!({"safe": "<b>bold</b>", "text": "<em>hi</em>"});
479 assert_eq!(
480 interpolate("{{{ safe }}} and {{ text }}", &data),
481 "<b>bold</b> and <em>hi</em>"
482 );
483 }
484
485 #[test]
486 fn test_render_page_basic() {
487 let resolved = ResolvedComponent {
488 html: "<h1>Hello</h1>".to_string(),
489 styles: vec!["h1 { color: red; }".to_string()],
490 script_setup: None,
491 module_imports: Vec::new(),
492 };
493 let data = json!({});
494 let html = render_page(&resolved, &data).unwrap();
495 assert!(html.contains("<h1>Hello</h1>"));
496 assert!(html.contains("h1 { color: red; }"));
497 assert!(!html.contains("__van/ws"));
499 }
500}