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 interpolate(template: &str, data: &Value) -> String {
331 let mut result = String::with_capacity(template.len());
332 let mut rest = template;
333
334 while let Some(start) = rest.find("{{") {
335 result.push_str(&rest[..start]);
336 let after_open = &rest[start + 2..];
337
338 if let Some(end) = after_open.find("}}") {
339 let expr = after_open[..end].trim();
340 let value = resolve_path(data, expr);
341 result.push_str(&value);
342 rest = &after_open[end + 2..];
343 } else {
344 result.push_str("{{");
346 rest = after_open;
347 }
348 }
349 result.push_str(rest);
350 result
351}
352
353pub fn resolve_path(data: &Value, path: &str) -> String {
355 let mut current = data;
356 for key in path.split('.') {
357 let key = key.trim();
358 match current.get(key) {
359 Some(v) => current = v,
360 None => return format!("{{{{{}}}}}", path),
361 }
362 }
363 match current {
364 Value::String(s) => s.clone(),
365 Value::Null => String::new(),
366 other => other.to_string(),
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use serde_json::json;
374
375 #[test]
376 fn test_interpolate_simple() {
377 let data = json!({"name": "World"});
378 assert_eq!(interpolate("Hello {{ name }}!", &data), "Hello World!");
379 }
380
381 #[test]
382 fn test_interpolate_dot_path() {
383 let data = json!({"user": {"name": "Alice"}});
384 assert_eq!(interpolate("Hi {{ user.name }}!", &data), "Hi Alice!");
385 }
386
387 #[test]
388 fn test_interpolate_missing_key() {
389 let data = json!({});
390 assert_eq!(interpolate("{{ missing }}", &data), "{{missing}}");
391 }
392
393 #[test]
394 fn test_cleanup_html_strips_events() {
395 let html = r#"<button @click="increment">+1</button>"#;
396 let data = json!({});
397 let clean = cleanup_html(html, &data);
398 assert_eq!(clean, "<button>+1</button>");
399 }
400
401 #[test]
402 fn test_cleanup_html_v_show_falsy() {
403 let html = r#"<p v-show="visible">Hello</p>"#;
404 let data = json!({"visible": false});
405 let clean = cleanup_html(html, &data);
406 assert!(!clean.contains("v-show"));
407 assert!(clean.contains(r#"style="display:none""#));
408 }
409
410 #[test]
411 fn test_cleanup_html_v_show_truthy() {
412 let html = r#"<p v-show="visible">Hello</p>"#;
413 let data = json!({"visible": true});
414 let clean = cleanup_html(html, &data);
415 assert!(!clean.contains("v-show"));
416 assert_eq!(clean, "<p>Hello</p>");
417 }
418
419 #[test]
420 fn test_cleanup_html_strips_transition_tags() {
421 let html = r#"<div><Transition name="slide"><p v-show="open">Hi</p></Transition></div>"#;
422 let data = json!({"open": false});
423 let clean = cleanup_html(html, &data);
424 assert!(!clean.contains("Transition"));
425 assert!(!clean.contains("transition"));
426 assert!(clean.contains("<p"));
427 }
428
429 #[test]
430 fn test_render_page_basic() {
431 let resolved = ResolvedComponent {
432 html: "<h1>Hello</h1>".to_string(),
433 styles: vec!["h1 { color: red; }".to_string()],
434 script_setup: None,
435 module_imports: Vec::new(),
436 };
437 let data = json!({});
438 let html = render_page(&resolved, &data).unwrap();
439 assert!(html.contains("<h1>Hello</h1>"));
440 assert!(html.contains("h1 { color: red; }"));
441 assert!(!html.contains("__van/ws"));
443 }
444}