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
8fn 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
30pub fn render_from_files(
35 entry_path: &str,
36 files: &HashMap<String, String>,
37 data: &Value,
38 file_origins: &HashMap<String, String>,
39) -> Result<String> {
40 let data_json = serde_json::to_string(data)?;
41 let mut html =
42 van_compiler::compile_page_debug(entry_path, files, &data_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
50pub fn render_static_from_files(
52 entry_path: &str,
53 files: &HashMap<String, String>,
54 data: &Value,
55) -> Result<String> {
56 let data_json = serde_json::to_string(data)?;
57 van_compiler::compile_page(entry_path, files, &data_json)
58 .map_err(|e| anyhow::anyhow!("{e}"))
59}
60
61pub(crate) fn validate_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 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 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 data key \"{key}\" not in defineProps{reset}"
96 );
97 }
98 }
99
100 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
119fn 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 #[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 validate_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_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_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_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}