Skip to main content

seam_engine/
render.rs

1/* src/server/engine/rust/src/render.rs */
2
3use 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
10/// Render a page: inject data into template, assemble data script,
11/// apply head metadata and locale attributes.
12///
13/// This is the single entry point that replaces ~60 lines of duplicated logic
14/// across TS, Rust, and Go backends.
15///
16/// Arguments are JSON strings for cross-language compatibility:
17/// - `template`: pre-resolved HTML template (layout chain already applied)
18/// - `loader_data_json`: `{"key": value, ...}` from all loaders (layout + page);
19///   if derives were executed by the adapter, `__derived` is already present
20/// - `config_json`: serialized `PageConfig`
21/// - `i18n_opts_json`: optional serialized `I18nOpts`
22pub 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	// Step 1: Replace asset slot markers before injector sees them.
37	let working = match config.page_assets {
38		Some(ref assets) => replace_asset_slots(template, assets),
39		None => strip_asset_slots(template),
40	};
41
42	// Step 2: Flatten loader data for slot resolution
43	let flat_data = flatten_for_slots(&loader_data);
44
45	// Step 3: Inject slots into template (no data script)
46	let mut html = seam_injector::inject_no_script(&working, &flat_data);
47
48	// Step 4: Inject page-level head metadata
49	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	// Step 5: Set <html lang="..."> when locale is known
55	if let Some(ref opts) = i18n_opts {
56		html = inject_html_lang(&html, &opts.locale);
57	}
58
59	// Step 6: Build data JSON and inject script
60	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		// nav should be under _layouts.root, not at top level
99		assert!(result.contains(r#""_layouts""#), "missing _layouts key");
100		assert!(result.contains(r#""root""#), "missing root layout key");
101		// Page data should be at top level
102		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		// head_meta should be injected after <meta charset="utf-8">
135		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		// Asset slots replaced
173		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		// Slot markers gone
178		assert!(!result.contains("<!--seam:page-styles-->"));
179		assert!(!result.contains("<!--seam:page-scripts-->"));
180		assert!(!result.contains("<!--seam:prefetch-->"));
181		// Data injection still works
182		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		// Slot markers stripped before injector (prevents misinterpretation)
202		assert!(!result.contains("<!--seam:page-styles-->"));
203		assert!(!result.contains("<!--seam:page-scripts-->"));
204		assert!(!result.contains("<!--seam:prefetch-->"));
205		// Data injection still works
206		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		// __derived already in loader data (adapter pre-computed)
213		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}