Skip to main content

van_compiler/
lib.rs

1mod resolve;
2pub mod render;
3
4use std::collections::HashMap;
5
6pub use render::PageAssets;
7pub use resolve::ResolvedComponent;
8pub use resolve::resolve_single;
9pub use resolve::resolve_with_files;
10pub use resolve::resolve_with_files_debug;
11
12/// Compile a multi-file `.van` project into a full HTML page.
13///
14/// This is the main API: resolves imports from an in-memory file map,
15/// then renders the result into a complete HTML page.
16pub fn compile_page(
17    entry_path: &str,
18    files: &HashMap<String, String>,
19    data_json: &str,
20) -> Result<String, String> {
21    compile_page_with_debug(entry_path, files, data_json, false, &HashMap::new())
22}
23
24/// Like `compile_page`, but with debug HTML comments at component/slot boundaries.
25///
26/// `file_origins` maps file paths to theme names for debug comment attribution.
27pub fn compile_page_debug(
28    entry_path: &str,
29    files: &HashMap<String, String>,
30    data_json: &str,
31    file_origins: &HashMap<String, String>,
32) -> Result<String, String> {
33    compile_page_with_debug(entry_path, files, data_json, true, file_origins)
34}
35
36fn compile_page_with_debug(
37    entry_path: &str,
38    files: &HashMap<String, String>,
39    data_json: &str,
40    debug: bool,
41    file_origins: &HashMap<String, String>,
42) -> Result<String, String> {
43    let data: serde_json::Value = serde_json::from_str(data_json)
44        .map_err(|e| format!("Invalid JSON: {e}"))?;
45    let resolved = if debug {
46        resolve::resolve_with_files_debug(entry_path, files, &data, file_origins)?
47    } else {
48        resolve::resolve_with_files(entry_path, files, &data)?
49    };
50    render::render_page(&resolved, &data)
51}
52
53/// Compile a multi-file `.van` project with separated assets.
54///
55/// Like `compile_page`, but returns HTML + assets map instead of a single HTML string.
56/// CSS/JS are returned as separate entries, HTML references them via `<link>`/`<script src>`.
57pub fn compile_page_assets(
58    entry_path: &str,
59    files: &HashMap<String, String>,
60    data_json: &str,
61    asset_prefix: &str,
62) -> Result<PageAssets, String> {
63    compile_page_assets_with_debug(entry_path, files, data_json, asset_prefix, false, &HashMap::new())
64}
65
66/// Like `compile_page_assets`, but with debug HTML comments at component/slot boundaries.
67///
68/// `file_origins` maps file paths to theme names for debug comment attribution.
69pub fn compile_page_assets_debug(
70    entry_path: &str,
71    files: &HashMap<String, String>,
72    data_json: &str,
73    asset_prefix: &str,
74    file_origins: &HashMap<String, String>,
75) -> Result<PageAssets, String> {
76    compile_page_assets_with_debug(entry_path, files, data_json, asset_prefix, true, file_origins)
77}
78
79fn compile_page_assets_with_debug(
80    entry_path: &str,
81    files: &HashMap<String, String>,
82    data_json: &str,
83    asset_prefix: &str,
84    debug: bool,
85    file_origins: &HashMap<String, String>,
86) -> Result<PageAssets, String> {
87    let data: serde_json::Value = serde_json::from_str(data_json)
88        .map_err(|e| format!("Invalid JSON: {e}"))?;
89    let resolved = if debug {
90        resolve::resolve_with_files_debug(entry_path, files, &data, file_origins)?
91    } else {
92        resolve::resolve_with_files(entry_path, files, &data)?
93    };
94
95    // Derive page name from entry path: "pages/index.van" → "pages/index"
96    let page_name = entry_path.trim_end_matches(".van");
97
98    render::render_page_assets(&resolved, &data, page_name, asset_prefix)
99}
100
101/// Compile a single `.van` file source into a full HTML page.
102///
103/// Convenience wrapper: wraps the source into a single-file map and calls `compile_page`.
104pub fn compile_single(source: &str, data_json: &str) -> Result<String, String> {
105    let mut files = HashMap::new();
106    files.insert("main.van".to_string(), source.to_string());
107    compile_page("main.van", &files, data_json)
108}
109
110#[cfg(feature = "wasm")]
111use wasm_bindgen::prelude::*;
112
113#[cfg(feature = "wasm")]
114#[wasm_bindgen]
115pub fn compile_van(
116    entry_path: &str,
117    files_json: &str,
118    data_json: &str,
119) -> Result<String, JsValue> {
120    // Parse files_json: {"filename": "content", ...}
121    let files_value: serde_json::Value = serde_json::from_str(files_json)
122        .map_err(|e| JsValue::from_str(&format!("Invalid files JSON: {e}")))?;
123
124    let files_obj = files_value
125        .as_object()
126        .ok_or_else(|| JsValue::from_str("files_json must be a JSON object"))?;
127
128    let mut files = HashMap::new();
129    for (key, val) in files_obj {
130        let content = val
131            .as_str()
132            .ok_or_else(|| JsValue::from_str(&format!("File '{}' content must be a string", key)))?;
133        files.insert(key.clone(), content.to_string());
134    }
135
136    compile_page(entry_path, &files, data_json).map_err(|e| JsValue::from_str(&e))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_compile_single_basic() {
145        let source = r#"
146<template>
147  <h1>{{ title }}</h1>
148</template>
149"#;
150        let data = r#"{"title": "Hello World"}"#;
151        let html = compile_single(source, data).unwrap();
152        assert!(html.contains("<h1>Hello World</h1>"));
153        assert!(html.contains("<!DOCTYPE html>"));
154    }
155
156    #[test]
157    fn test_compile_single_with_signals() {
158        let source = r#"
159<template>
160  <div>
161    <p>Count: {{ count }}</p>
162    <button @click="increment">+1</button>
163  </div>
164</template>
165
166<script setup>
167const count = ref(0)
168function increment() { count.value++ }
169</script>
170"#;
171        let data = r#"{}"#;
172        let html = compile_single(source, data).unwrap();
173        assert!(html.contains("Van"));
174        assert!(!html.contains("@click"));
175        assert!(html.contains("effect"));
176    }
177
178    #[test]
179    fn test_compile_page_invalid_json() {
180        let mut files = HashMap::new();
181        files.insert("main.van".to_string(), "<template><p>Hi</p></template>".to_string());
182        let result = compile_page("main.van", &files, "not json");
183        assert!(result.is_err());
184        assert!(result.unwrap_err().contains("Invalid JSON"));
185    }
186
187    #[test]
188    fn test_compile_page_with_style() {
189        let source = r#"
190<template>
191  <h1>Hello</h1>
192</template>
193
194<style>
195h1 { color: blue; }
196</style>
197"#;
198        let html = compile_single(source, "{}").unwrap();
199        assert!(html.contains("color: blue"));
200    }
201
202    #[test]
203    fn test_compile_page_multi_file() {
204        let mut files = HashMap::new();
205        files.insert(
206            "index.van".to_string(),
207            r#"
208<template>
209  <hello :name="title" />
210</template>
211
212<script setup>
213import Hello from './hello.van'
214</script>
215"#
216            .to_string(),
217        );
218        files.insert(
219            "hello.van".to_string(),
220            r#"
221<template>
222  <h1>Hello, {{ name }}!</h1>
223</template>
224
225<style>
226h1 { color: green; }
227</style>
228"#
229            .to_string(),
230        );
231
232        let data = r#"{"title": "Van"}"#;
233        let html = compile_page("index.van", &files, data).unwrap();
234        assert!(html.contains("<h1>Hello, Van!</h1>"));
235        assert!(html.contains("color: green"));
236    }
237
238    #[test]
239    fn test_compile_page_with_ts_import() {
240        let mut files = HashMap::new();
241        files.insert(
242            "index.van".to_string(),
243            r#"
244<template>
245  <div>
246    <p>Count: {{ count }}</p>
247    <button @click="increment">+1</button>
248  </div>
249</template>
250
251<script setup lang="ts">
252import { formatDate } from './utils/format.ts'
253import type { User } from './types.ts'
254const count = ref(0)
255function increment() { count.value++ }
256</script>
257"#
258            .to_string(),
259        );
260        files.insert(
261            "utils/format.ts".to_string(),
262            r#"function formatDate(d) { return d.toISOString(); }
263return { formatDate: formatDate };"#
264                .to_string(),
265        );
266        // types.ts is NOT in files map — type-only imports are erased, so it's fine
267
268        let data = r#"{}"#;
269        let html = compile_page("index.van", &files, data).unwrap();
270        // Should contain the inlined module code
271        assert!(html.contains("__mod_0"));
272        assert!(html.contains("formatDate"));
273        // Should still have signal code
274        assert!(html.contains("Van"));
275        assert!(html.contains("effect"));
276        // Type-only import should be erased (no __mod_1)
277        assert!(!html.contains("__mod_1"));
278    }
279
280    #[test]
281    fn test_compile_page_type_only_import_erased() {
282        let mut files = HashMap::new();
283        files.insert(
284            "index.van".to_string(),
285            r#"
286<template>
287  <div><p>{{ count }}</p></div>
288</template>
289
290<script setup lang="ts">
291import type { Config } from './config.ts'
292const count = ref(0)
293</script>
294"#
295            .to_string(),
296        );
297
298        let data = r#"{}"#;
299        let html = compile_page("index.van", &files, data).unwrap();
300        // No module should be inlined (type-only)
301        assert!(!html.contains("__mod_"));
302        // Signal code still works
303        assert!(html.contains("V.signal(0)"));
304    }
305}