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::i18n;
10use crate::resolve::ResolvedComponent;
11
12/// Compute a short content hash (8 hex chars) for cache busting.
13fn 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
19/// Insert `content` before a closing tag (e.g. `</head>`, `</body>`),
20/// with indentation matching the surrounding HTML structure.
21fn 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
41/// Augment data with initial signal values from `<script setup>`.
42///
43/// This allows `cleanup_html()` to replace reactive `{{ count }}` with `0`
44/// instead of leaving raw mustache tags in the output (bad for SEO).
45fn 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            // Don't override existing data (server data takes priority)
57            if !map.contains_key(&name) {
58                map.insert(name, Value::String(value));
59            }
60        }
61    }
62    augmented
63}
64
65/// Result of compiling a `.van` page with separated assets.
66pub struct PageAssets {
67    /// HTML with external `<link>`/`<script src>` references (no inline CSS/JS)
68    pub html: String,
69    /// Asset path → content (e.g. "/themes/van1/assets/js/pages/index.js" → "var Van=...")
70    pub assets: HashMap<String, String>,
71}
72
73/// Render a resolved `.van` component into a full HTML page.
74///
75/// Pipeline:
76/// 1. `resolve_single()` → "dirty" HTML (still has @click, v-show, {{ reactive }})
77/// 2. `generate_signals()` → positional signal JS from the dirty HTML
78/// 3. `cleanup_html()` → strip directives, interpolate remaining {{ }}, producing clean HTML
79/// 4. Inject styles + scripts into clean HTML
80///
81/// Unlike `van-dev-server`'s render, this does NOT inject `client.js` (WebSocket live reload).
82pub 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    // Collect module code for signal generation (skip type-only)
91    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    // Generate signal JS from the dirty HTML (before cleanup)
99    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    // Augment data with signal initial values for SSR (e.g. {{ count }} → 0)
113    let augmented_data =
114        augment_data_with_signals(data, resolved.script_setup.as_deref());
115
116    // Clean up HTML: strip directives, interpolate remaining {{ }}
117    let clean_html = cleanup_html(&resolved.html, &augmented_data);
118
119    if clean_html.contains("<html") {
120        // Layout mode: inject styles before </head> and scripts before </body>
121        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        // Default HTML shell
127        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
147/// Render a resolved `.van` component with separated assets.
148///
149/// Instead of inlining CSS/JS into the HTML, returns them as separate entries
150/// in the `assets` map, with HTML referencing them via `<link>` / `<script src>`.
151///
152/// `asset_prefix` determines the URL path prefix for assets,
153/// e.g. "/themes/van1/assets" → produces "/themes/van1/assets/css/page.css".
154pub 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    // CSS asset (with content hash for cache busting)
164    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    // Collect module code for signal generation (skip type-only)
175    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    // Generate signal JS from the dirty HTML (before cleanup)
183    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    // Augment data with signal initial values for SSR (e.g. {{ count }} → 0)
204    let augmented_data =
205        augment_data_with_signals(data, resolved.script_setup.as_deref());
206
207    // Clean up HTML: strip directives, interpolate remaining {{ }}
208    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
236/// Clean up "dirty" resolved HTML by:
237/// 1. Stripping `@event="..."` attributes
238/// 2. Processing `v-show="expr"` / `v-if="expr"` → evaluate initial value, add
239///    `style="display:none"` if falsy, remove the directive attribute
240/// 3. Interpolating remaining `{{ expr }}` expressions
241fn cleanup_html(html: &str, data: &Value) -> String {
242    let mut result = html.to_string();
243
244    // 1. Strip @event="..." attributes
245    let event_re = Regex::new(r#"\s*@\w+="[^"]*""#).unwrap();
246    result = event_re.replace_all(&result, "").to_string();
247
248    // 1b. Strip <Transition> / </Transition> wrapper tags (keep inner content)
249    let transition_re = Regex::new(r#"</?[Tt]ransition[^>]*>"#).unwrap();
250    result = transition_re.replace_all(&result, "").to_string();
251
252    // 1c. Strip :key="..." attributes (from v-for)
253    let key_re = Regex::new(r#"\s*:key="[^"]*""#).unwrap();
254    result = key_re.replace_all(&result, "").to_string();
255
256    // 2. Process v-show/v-if: evaluate initial value, add display:none if falsy
257    let show_re = Regex::new(r#"\s*v-(?:show|if)="([^"]*)""#).unwrap();
258    result = show_re
259        .replace_all(&result, |caps: &regex::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    // 2b. Process v-else-if="expr" (same as v-if)
276    let else_if_re = Regex::new(r#"\s*v-else-if="([^"]*)""#).unwrap();
277    result = else_if_re
278        .replace_all(&result, |caps: &regex::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    // 2c. Strip v-else (unconditional — attribute with no value)
295    let else_re = Regex::new(r#"\s+v-else"#).unwrap();
296    result = else_re.replace_all(&result, "").to_string();
297
298    // 2d. Strip v-html="..." and v-text="..." attributes
299    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    // 2e. Strip :class="..." and :style="..." attributes
305    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    // 2f. Strip v-model="..." and optionally set initial value
311    let model_re = Regex::new(r#"\s*v-model="([^"]*)""#).unwrap();
312    result = model_re
313        .replace_all(&result, |caps: &regex::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    // 3. Interpolate remaining {{ expr }}
325    result = interpolate(&result, data);
326
327    result
328}
329
330/// Escape HTML special characters in text content.
331pub 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("&amp;"),
336            '<' => result.push_str("&lt;"),
337            '>' => result.push_str("&gt;"),
338            '"' => result.push_str("&quot;"),
339            '\'' => result.push_str("&#39;"),
340            _ => result.push(ch),
341        }
342    }
343    result
344}
345
346/// Perform `{{ expr }}` / `{{{ expr }}}` interpolation with dot-path resolution.
347///
348/// - `{{ expr }}` — HTML-escaped output (default, safe)
349/// - `{{{ expr }}}` — raw output (no escaping, for trusted HTML content)
350///
351/// Supports paths like `user.name` which resolve to `data["user"]["name"]`.
352/// Unresolved expressions are left as-is.
353pub 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        // Check for triple mustache {{{ }}} (raw, unescaped output)
361        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                    // $t() but no $i18n data — preserve for runtime resolution
369                    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                    // $t() but no $i18n data — preserve for runtime resolution
386                    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
401/// Try to resolve a `$t(...)` expression. Returns `Some(translated)` if the
402/// expression is a valid `$t()` call, `None` otherwise.
403pub(crate) fn try_resolve_t(expr: &str, data: &Value) -> Option<String> {
404    let (key, params_str) = i18n::parse_t_call(expr)?;
405
406    // No $i18n data → return None so the expression is preserved for runtime resolution
407    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 unresolved (still has {{ }}), use the path name as-is
417                    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
431/// Resolve a dot-separated path like `user.name` against a JSON value.
432pub 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            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
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 &lt;em&gt;hi&lt;/em&gt;"
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        // Triple mustache: raw output, no escaping
569        assert_eq!(
570            interpolate("{{{ $t('html_content') }}}", &data),
571            "<b>粗体</b>"
572        );
573        // Double mustache: HTML escaped
574        assert_eq!(
575            interpolate("{{ $t('html_content') }}", &data),
576            "&lt;b&gt;粗体&lt;/b&gt;"
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        // Should NOT contain client.js WebSocket reload
617        assert!(!html.contains("__van/ws"));
618    }
619}