1use crate::escape::ascii_escape_json;
4use crate::page::{
5 I18nOpts, PageConfig, build_seam_data, flatten_for_slots, inject_data_script, inject_head_meta,
6 inject_html_lang,
7};
8use crate::slots::{replace_asset_slots, strip_asset_slots};
9
10pub fn render_page(
23 template: &str,
24 loader_data_json: &str,
25 config_json: &str,
26 i18n_opts_json: Option<&str>,
27) -> String {
28 let loader_data: serde_json::Value =
29 serde_json::from_str(loader_data_json).unwrap_or(serde_json::Value::Null);
30 let config: PageConfig = match serde_json::from_str(config_json) {
31 Ok(c) => c,
32 Err(_) => return template.to_string(),
33 };
34 let i18n_opts: Option<I18nOpts> = i18n_opts_json.and_then(|s| serde_json::from_str(s).ok());
35
36 let working = match config.page_assets {
38 Some(ref assets) => replace_asset_slots(template, assets),
39 None => strip_asset_slots(template),
40 };
41
42 let flat_data = flatten_for_slots(&loader_data);
44
45 let mut html = seam_injector::inject_no_script(&working, &flat_data);
47
48 if let Some(ref meta) = config.head_meta {
50 let injected_meta = seam_injector::inject_no_script(meta, &flat_data);
51 html = inject_head_meta(&html, &injected_meta);
52 }
53
54 if let Some(ref opts) = i18n_opts {
56 html = inject_html_lang(&html, &opts.locale);
57 }
58
59 let seam_data = build_seam_data(&loader_data, &config, i18n_opts.as_ref());
61 let json = serde_json::to_string(&seam_data).unwrap_or_default();
62 let escaped = ascii_escape_json(&json);
63 inject_data_script(&html, &config.data_id, &escaped)
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69 use serde_json::json;
70
71 fn simple_template() -> String {
72 r#"<html><head><meta charset="utf-8"><title>Test</title></head><body><p><!--seam:title--></p></body></html>"#.to_string()
73 }
74
75 #[test]
76 fn render_basic_page() {
77 let template = simple_template();
78 let data = json!({"title": "Hello"}).to_string();
79 let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
80
81 let result = render_page(&template, &data, &config, None);
82 assert!(result.contains("<p>Hello</p>"));
83 assert!(result.contains(r#"<script id="__data""#));
84 assert!(result.contains(r#""title":"Hello""#));
85 }
86
87 #[test]
88 fn render_with_layout() {
89 let template = simple_template();
90 let data = json!({"title": "Page", "nav": "NavData"}).to_string();
91 let config = json!({
92 "layout_chain": [{"id": "root", "loader_keys": ["nav"]}],
93 "data_id": "__data"
94 })
95 .to_string();
96
97 let result = render_page(&template, &data, &config, None);
98 assert!(result.contains(r#""_layouts""#), "missing _layouts key");
100 assert!(result.contains(r#""root""#), "missing root layout key");
101 assert!(result.contains(r#""title":"Page""#), "missing page-level title");
103 }
104
105 #[test]
106 fn render_with_i18n() {
107 let template = simple_template();
108 let data = json!({"title": "Hello"}).to_string();
109 let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
110 let i18n = json!({
111 "locale": "zh",
112 "default_locale": "en",
113 "messages": {"hello": "你好"}
114 })
115 .to_string();
116
117 let result = render_page(&template, &data, &config, Some(&i18n));
118 assert!(result.contains(r#"<html lang="zh""#));
119 assert!(result.contains(r#""_i18n""#));
120 }
121
122 #[test]
123 fn render_with_head_meta() {
124 let template = simple_template();
125 let data = json!({"title": "Hello"}).to_string();
126 let config = json!({
127 "layout_chain": [],
128 "data_id": "__data",
129 "head_meta": r#"<title><!--seam:title--></title>"#
130 })
131 .to_string();
132
133 let result = render_page(&template, &data, &config, None);
134 assert!(result.contains(r#"<meta charset="utf-8"><title>Hello</title>"#));
136 }
137
138 #[test]
139 fn render_invalid_config_returns_template() {
140 let template = "plain html";
141 let result = render_page(template, "{}", "invalid json", None);
142 assert_eq!(result, "plain html");
143 }
144
145 #[test]
146 fn render_with_page_assets() {
147 let template = concat!(
148 r#"<html><head><meta charset="utf-8">"#,
149 r#"<link rel="stylesheet" href="/_seam/static/main.css">"#,
150 "<!--seam:page-styles--><!--seam:prefetch-->",
151 "</head><body>",
152 r#"<div id="__seam"><p><!--seam:title--></p></div>"#,
153 r#"<script type="module" src="/_seam/static/main.js"></script>"#,
154 "<!--seam:page-scripts-->",
155 "</body></html>"
156 );
157 let data = json!({"title": "Hello"}).to_string();
158 let config = json!({
159 "layout_chain": [],
160 "data_id": "__data",
161 "page_assets": {
162 "styles": ["page-home.css"],
163 "scripts": ["page-home.js"],
164 "preload": ["shared.js"],
165 "prefetch": ["page-other.js"]
166 }
167 })
168 .to_string();
169
170 let result = render_page(template, &data, &config, None);
171
172 assert!(result.contains(r#"href="/_seam/static/page-home.css""#));
174 assert!(result.contains(r#"src="/_seam/static/page-home.js""#));
175 assert!(result.contains(r#"modulepreload"#));
176 assert!(result.contains(r#"prefetch"#));
177 assert!(!result.contains("<!--seam:page-styles-->"));
179 assert!(!result.contains("<!--seam:page-scripts-->"));
180 assert!(!result.contains("<!--seam:prefetch-->"));
181 assert!(result.contains("<p>Hello</p>"));
183 assert!(result.contains(r#"<script id="__data""#));
184 }
185
186 #[test]
187 fn render_without_page_assets_strips_slots() {
188 let template = concat!(
189 r#"<html><head><meta charset="utf-8">"#,
190 "<!--seam:page-styles--><!--seam:prefetch-->",
191 "</head><body>",
192 r#"<div id="__seam"><p><!--seam:title--></p></div>"#,
193 "<!--seam:page-scripts-->",
194 "</body></html>"
195 );
196 let data = json!({"title": "Hello"}).to_string();
197 let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
198
199 let result = render_page(template, &data, &config, None);
200
201 assert!(!result.contains("<!--seam:page-styles-->"));
203 assert!(!result.contains("<!--seam:page-scripts-->"));
204 assert!(!result.contains("<!--seam:prefetch-->"));
205 assert!(result.contains("<p>Hello</p>"));
207 }
208
209 #[test]
210 fn render_with_precomputed_derived() {
211 let template = r#"<html><head><meta charset="utf-8"></head><body><p><!--seam:__derived.stats.total--></p></body></html>"#;
212 let data = json!({
214 "repos": [{"stars": 100}],
215 "__derived": {"stats": {"total": 100}}
216 })
217 .to_string();
218 let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
219
220 let result = render_page(template, &data, &config, None);
221
222 assert!(result.contains("<p>100</p>"), "precomputed slot not replaced");
223 assert!(result.contains(r#""__derived""#), "missing __derived in data");
224 }
225}