Skip to main content

van_compiler/
render.rs

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
11/// Compute a short content hash (8 hex chars) for cache busting.
12fn 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
18/// Insert `content` before a closing tag (e.g. `</head>`, `</body>`),
19/// with indentation matching the surrounding HTML structure.
20fn 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
40/// Augment data with initial signal values from `<script setup>`.
41///
42/// This allows `cleanup_html()` to replace reactive `{{ count }}` with `0`
43/// instead of leaving raw mustache tags in the output (bad for SEO).
44fn 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            // Don't override existing data (server data takes priority)
56            if !map.contains_key(&name) {
57                map.insert(name, Value::String(value));
58            }
59        }
60    }
61    augmented
62}
63
64/// Result of compiling a `.van` page with separated assets.
65pub struct PageAssets {
66    /// HTML with external `<link>`/`<script src>` references (no inline CSS/JS)
67    pub html: String,
68    /// Asset path → content (e.g. "/themes/van1/assets/js/pages/index.js" → "var Van=...")
69    pub assets: HashMap<String, String>,
70}
71
72/// Render a resolved `.van` component into a full HTML page.
73///
74/// Pipeline:
75/// 1. `resolve_single()` → "dirty" HTML (still has @click, v-show, {{ reactive }})
76/// 2. `generate_signals()` → positional signal JS from the dirty HTML
77/// 3. `cleanup_html()` → strip directives, interpolate remaining {{ }}, producing clean HTML
78/// 4. Inject styles + scripts into clean HTML
79///
80/// Unlike `van-dev-server`'s render, this does NOT inject `client.js` (WebSocket live reload).
81pub 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    // Collect module code for signal generation (skip type-only)
90    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    // Generate signal JS from the dirty HTML (before cleanup)
98    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    // Augment data with signal initial values for SSR (e.g. {{ count }} → 0)
111    let augmented_data =
112        augment_data_with_signals(data, resolved.script_setup.as_deref());
113
114    // Clean up HTML: strip directives, interpolate remaining {{ }}
115    let clean_html = cleanup_html(&resolved.html, &augmented_data);
116
117    if clean_html.contains("<html") {
118        // Layout mode: inject styles before </head> and scripts before </body>
119        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        // Default HTML shell
125        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
145/// Render a resolved `.van` component with separated assets.
146///
147/// Instead of inlining CSS/JS into the HTML, returns them as separate entries
148/// in the `assets` map, with HTML referencing them via `<link>` / `<script src>`.
149///
150/// `asset_prefix` determines the URL path prefix for assets,
151/// e.g. "/themes/van1/assets" → produces "/themes/van1/assets/css/page.css".
152pub 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    // CSS asset (with content hash for cache busting)
161    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    // Collect module code for signal generation (skip type-only)
172    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    // Generate signal JS from the dirty HTML (before cleanup)
180    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    // Augment data with signal initial values for SSR (e.g. {{ count }} → 0)
200    let augmented_data =
201        augment_data_with_signals(data, resolved.script_setup.as_deref());
202
203    // Clean up HTML: strip directives, interpolate remaining {{ }}
204    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
232/// Clean up "dirty" resolved HTML by:
233/// 1. Stripping `@event="..."` attributes
234/// 2. Processing `v-show="expr"` / `v-if="expr"` → evaluate initial value, add
235///    `style="display:none"` if falsy, remove the directive attribute
236/// 3. Interpolating remaining `{{ expr }}` expressions
237fn cleanup_html(html: &str, data: &Value) -> String {
238    let mut result = html.to_string();
239
240    // 1. Strip @event="..." attributes
241    let event_re = Regex::new(r#"\s*@\w+="[^"]*""#).unwrap();
242    result = event_re.replace_all(&result, "").to_string();
243
244    // 1b. Strip <Transition> / </Transition> wrapper tags (keep inner content)
245    let transition_re = Regex::new(r#"</?[Tt]ransition[^>]*>"#).unwrap();
246    result = transition_re.replace_all(&result, "").to_string();
247
248    // 1c. Strip :key="..." attributes (from v-for)
249    let key_re = Regex::new(r#"\s*:key="[^"]*""#).unwrap();
250    result = key_re.replace_all(&result, "").to_string();
251
252    // 2. Process v-show/v-if: evaluate initial value, add display:none if falsy
253    let show_re = Regex::new(r#"\s*v-(?:show|if)="([^"]*)""#).unwrap();
254    result = show_re
255        .replace_all(&result, |caps: &regex::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    // 2b. Process v-else-if="expr" (same as v-if)
272    let else_if_re = Regex::new(r#"\s*v-else-if="([^"]*)""#).unwrap();
273    result = else_if_re
274        .replace_all(&result, |caps: &regex::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    // 2c. Strip v-else (unconditional — attribute with no value)
291    let else_re = Regex::new(r#"\s+v-else"#).unwrap();
292    result = else_re.replace_all(&result, "").to_string();
293
294    // 2d. Strip v-html="..." and v-text="..." attributes
295    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    // 2e. Strip :class="..." and :style="..." attributes
301    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    // 2f. Strip v-model="..." and optionally set initial value
307    let model_re = Regex::new(r#"\s*v-model="([^"]*)""#).unwrap();
308    result = model_re
309        .replace_all(&result, |caps: &regex::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    // 3. Interpolate remaining {{ expr }}
321    result = interpolate(&result, data);
322
323    result
324}
325
326/// Escape HTML special characters in text content.
327pub 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("&amp;"),
332            '<' => result.push_str("&lt;"),
333            '>' => result.push_str("&gt;"),
334            '"' => result.push_str("&quot;"),
335            '\'' => result.push_str("&#39;"),
336            _ => result.push(ch),
337        }
338    }
339    result
340}
341
342/// Perform `{{ expr }}` / `{{{ expr }}}` interpolation with dot-path resolution.
343///
344/// - `{{ expr }}` — HTML-escaped output (default, safe)
345/// - `{{{ expr }}}` — raw output (no escaping, for trusted HTML content)
346///
347/// Supports paths like `user.name` which resolve to `data["user"]["name"]`.
348/// Unresolved expressions are left as-is.
349pub 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        // Check for triple mustache {{{ }}} (raw, unescaped output)
357        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
385/// Resolve a dot-separated path like `user.name` against a JSON value.
386pub 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            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
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 &lt;em&gt;hi&lt;/em&gt;"
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        // Should NOT contain client.js WebSocket reload
498        assert!(!html.contains("__van/ws"));
499    }
500}