Skip to main content

van_dev/
render.rs

1use anyhow::Result;
2use serde_json::Value;
3use std::collections::HashMap;
4use van_parser::PropDef;
5
6const CLIENT_JS: &str = include_str!("client.js");
7
8/// Insert `content` before a closing tag (e.g. `</head>`, `</body>`),
9/// with indentation matching the surrounding HTML structure.
10fn inject_before_close(html: &mut String, close_tag: &str, content: &str) {
11    if content.is_empty() {
12        return;
13    }
14    if let Some(pos) = html.find(close_tag) {
15        let before = &html[..pos];
16        let line_start = before.rfind('\n').map(|i| i + 1).unwrap_or(0);
17        let line_prefix = &before[line_start..];
18        let indent_len = line_prefix.len() - line_prefix.trim_start().len();
19        let child_indent = format!("{}  ", &line_prefix[..indent_len]);
20        let mut injection = String::new();
21        for line in content.lines() {
22            injection.push_str(&child_indent);
23            injection.push_str(line);
24            injection.push('\n');
25        }
26        html.insert_str(line_start, &injection);
27    }
28}
29
30/// Render a page from pre-collected files with live reload client and debug comments.
31///
32/// Delegates compilation to `van_compiler`, then injects the WebSocket-based
33/// live reload `client.js` before `</body>`.
34pub fn render_from_files(
35    entry_path: &str,
36    files: &HashMap<String, String>,
37    mock_data: &Value,
38    file_origins: &HashMap<String, String>,
39) -> Result<String> {
40    let mock_json = serde_json::to_string(mock_data)?;
41    let mut html =
42        van_compiler::compile_page_debug(entry_path, files, &mock_json, file_origins)
43            .map_err(|e| anyhow::anyhow!("{e}"))?;
44
45    let client_script = format!("<script>{CLIENT_JS}</script>");
46    inject_before_close(&mut html, "</body>", &client_script);
47    Ok(html)
48}
49
50/// Render a page from pre-collected files for static output (no live reload).
51pub fn render_static_from_files(
52    entry_path: &str,
53    files: &HashMap<String, String>,
54    mock_data: &Value,
55) -> Result<String> {
56    let mock_json = serde_json::to_string(mock_data)?;
57    van_compiler::compile_page(entry_path, files, &mock_json)
58        .map_err(|e| anyhow::anyhow!("{e}"))
59}
60
61/// Validate mock data against `defineProps` declarations.
62///
63/// Prints warnings to stderr for:
64/// - Missing required props
65/// - Extra keys not declared in defineProps
66/// - Type mismatches (String vs Number vs Boolean vs Array vs Object)
67///
68/// Never blocks rendering -- warnings only.
69pub(crate) fn validate_mock_data(props: &[PropDef], data: &Value, page_label: &str) {
70    let yellow = "\x1b[33m";
71    let reset = "\x1b[0m";
72
73    let map = match data.as_object() {
74        Some(m) => m,
75        None => return,
76    };
77
78    // Check for missing required props
79    for prop in props {
80        if prop.required && !map.contains_key(&prop.name) {
81            let type_hint = prop.prop_type.as_deref().unwrap_or("any");
82            eprintln!(
83                "{yellow}  \u{26a0} {page_label}: missing required prop \"{}\" ({type_hint}){reset}",
84                prop.name
85            );
86        }
87    }
88
89    // Check for extra keys not in defineProps
90    let prop_names: std::collections::HashSet<&str> =
91        props.iter().map(|p| p.name.as_str()).collect();
92    for key in map.keys() {
93        if !prop_names.contains(key.as_str()) {
94            eprintln!(
95                "{yellow}  \u{26a0} {page_label}: extra mock key \"{key}\" not in defineProps{reset}"
96            );
97        }
98    }
99
100    // Check type mismatches
101    for prop in props {
102        let Some(ref expected_type) = prop.prop_type else {
103            continue;
104        };
105        let Some(value) = map.get(&prop.name) else {
106            continue;
107        };
108        let actual_type = json_value_type_name(value);
109        let expected_lower = expected_type.to_lowercase();
110        if actual_type != expected_lower {
111            eprintln!(
112                "{yellow}  \u{26a0} {page_label}: prop \"{}\" expects {expected_type}, got {actual_type}{reset}",
113                prop.name
114            );
115        }
116    }
117}
118
119/// Map a serde_json::Value to a lowercase type name matching Vue prop types.
120fn json_value_type_name(value: &Value) -> &'static str {
121    match value {
122        Value::String(_) => "string",
123        Value::Number(_) => "number",
124        Value::Bool(_) => "boolean",
125        Value::Array(_) => "array",
126        Value::Object(_) => "object",
127        Value::Null => "null",
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use serde_json::json;
135
136    #[test]
137    fn test_render_from_files_basic() {
138        let source = r#"
139<template>
140  <h1>{{ title }}</h1>
141</template>
142
143<style scoped>
144h1 { color: red; }
145</style>
146"#;
147        let mut files = HashMap::new();
148        files.insert("pages/index.van".to_string(), source.to_string());
149        let data = json!({"title": "Hello"});
150        let html =
151            render_from_files("pages/index.van", &files, &data, &HashMap::new()).unwrap();
152        assert!(html.contains("Hello"), "Should contain interpolated title");
153        assert!(html.contains("color: red"), "Should contain scoped CSS");
154        assert!(html.contains("__van/ws"), "Should contain live reload client");
155    }
156
157    #[test]
158    fn test_render_static_from_files() {
159        let source = r#"
160<template>
161  <h1>{{ title }}</h1>
162</template>
163"#;
164        let mut files = HashMap::new();
165        files.insert("pages/index.van".to_string(), source.to_string());
166        let data = json!({"title": "World"});
167        let html = render_static_from_files("pages/index.van", &files, &data).unwrap();
168        assert!(html.contains("World"));
169        assert!(!html.contains("__van/ws"), "Static output should not have live reload");
170    }
171
172    // --- validate_mock_data tests ---
173
174    #[test]
175    fn test_validate_all_good() {
176        let props = vec![
177            PropDef { name: "title".into(), prop_type: Some("String".into()), required: true },
178            PropDef { name: "count".into(), prop_type: Some("Number".into()), required: false },
179        ];
180        let data = json!({"title": "Hello", "count": 42});
181        // Should produce no warnings (no panic)
182        validate_mock_data(&props, &data, "pages/index.van");
183    }
184
185    #[test]
186    fn test_validate_missing_required() {
187        let props = vec![
188            PropDef { name: "user".into(), prop_type: Some("Object".into()), required: true },
189        ];
190        let data = json!({});
191        validate_mock_data(&props, &data, "pages/index.van");
192    }
193
194    #[test]
195    fn test_validate_extra_keys() {
196        let props = vec![
197            PropDef { name: "title".into(), prop_type: Some("String".into()), required: false },
198        ];
199        let data = json!({"title": "Hi", "typo": "oops"});
200        validate_mock_data(&props, &data, "pages/index.van");
201    }
202
203    #[test]
204    fn test_validate_type_mismatch() {
205        let props = vec![
206            PropDef { name: "count".into(), prop_type: Some("Number".into()), required: false },
207        ];
208        let data = json!({"count": "not a number"});
209        validate_mock_data(&props, &data, "pages/index.van");
210    }
211
212    #[test]
213    fn test_json_value_type_name() {
214        assert_eq!(json_value_type_name(&json!("hello")), "string");
215        assert_eq!(json_value_type_name(&json!(42)), "number");
216        assert_eq!(json_value_type_name(&json!(true)), "boolean");
217        assert_eq!(json_value_type_name(&json!([1, 2])), "array");
218        assert_eq!(json_value_type_name(&json!({"a": 1})), "object");
219        assert_eq!(json_value_type_name(&json!(null)), "null");
220    }
221}