Skip to main content

van_compiler/
lib.rs

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