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/// - `config_json`: serialized `PageConfig`
20/// - `i18n_opts_json`: optional serialized `I18nOpts`
21pub fn render_page(
22	template: &str,
23	loader_data_json: &str,
24	config_json: &str,
25	i18n_opts_json: Option<&str>,
26) -> String {
27	let loader_data: serde_json::Value =
28		serde_json::from_str(loader_data_json).unwrap_or(serde_json::Value::Null);
29	let config: PageConfig = match serde_json::from_str(config_json) {
30		Ok(c) => c,
31		Err(_) => return template.to_string(),
32	};
33	let i18n_opts: Option<I18nOpts> = i18n_opts_json.and_then(|s| serde_json::from_str(s).ok());
34
35	// Step 1: Replace asset slot markers before injector sees them.
36	// When page_assets is present, replace with actual tags.
37	// When absent, strip markers (empty replacement) to prevent injector
38	// from treating them as data slots.
39	let working = match config.page_assets {
40		Some(ref assets) => replace_asset_slots(template, assets),
41		None => strip_asset_slots(template),
42	};
43
44	// Step 2: Flatten loader data for slot resolution
45	let flat_data = flatten_for_slots(&loader_data);
46
47	// Step 3: Inject slots into template (no data script)
48	let mut html = seam_injector::inject_no_script(&working, &flat_data);
49
50	// Step 4: Inject page-level head metadata
51	if let Some(ref meta) = config.head_meta {
52		// Inject the head_meta with slot data resolved
53		let injected_meta = seam_injector::inject_no_script(meta, &flat_data);
54		html = inject_head_meta(&html, &injected_meta);
55	}
56
57	// Step 5: Set <html lang="..."> when locale is known
58	if let Some(ref opts) = i18n_opts {
59		html = inject_html_lang(&html, &opts.locale);
60	}
61
62	// Step 6: Build data JSON and inject script
63	let seam_data = build_seam_data(&loader_data, &config, i18n_opts.as_ref());
64	let json = serde_json::to_string(&seam_data).unwrap_or_default();
65	let escaped = ascii_escape_json(&json);
66	inject_data_script(&html, &config.data_id, &escaped)
67}
68
69#[cfg(test)]
70mod tests {
71	use super::*;
72	use serde_json::json;
73
74	fn simple_template() -> String {
75		r#"<html><head><meta charset="utf-8"><title>Test</title></head><body><p><!--seam:title--></p></body></html>"#.to_string()
76	}
77
78	#[test]
79	fn render_basic_page() {
80		let template = simple_template();
81		let data = json!({"title": "Hello"}).to_string();
82		let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
83
84		let result = render_page(&template, &data, &config, None);
85		assert!(result.contains("<p>Hello</p>"));
86		assert!(result.contains(r#"<script id="__data""#));
87		assert!(result.contains(r#""title":"Hello""#));
88	}
89
90	#[test]
91	fn render_with_layout() {
92		let template = simple_template();
93		let data = json!({"title": "Page", "nav": "NavData"}).to_string();
94		let config = json!({
95			"layout_chain": [{"id": "root", "loader_keys": ["nav"]}],
96			"data_id": "__data"
97		})
98		.to_string();
99
100		let result = render_page(&template, &data, &config, None);
101		// nav should be under _layouts.root, not at top level
102		assert!(result.contains(r#""_layouts""#), "missing _layouts key");
103		assert!(result.contains(r#""root""#), "missing root layout key");
104		// Page data should be at top level
105		assert!(result.contains(r#""title":"Page""#), "missing page-level title");
106	}
107
108	#[test]
109	fn render_with_i18n() {
110		let template = simple_template();
111		let data = json!({"title": "Hello"}).to_string();
112		let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
113		let i18n = json!({
114			"locale": "zh",
115			"default_locale": "en",
116			"messages": {"hello": "你好"}
117		})
118		.to_string();
119
120		let result = render_page(&template, &data, &config, Some(&i18n));
121		assert!(result.contains(r#"<html lang="zh""#));
122		assert!(result.contains(r#""_i18n""#));
123	}
124
125	#[test]
126	fn render_with_head_meta() {
127		let template = simple_template();
128		let data = json!({"title": "Hello"}).to_string();
129		let config = json!({
130			"layout_chain": [],
131			"data_id": "__data",
132			"head_meta": r#"<title><!--seam:title--></title>"#
133		})
134		.to_string();
135
136		let result = render_page(&template, &data, &config, None);
137		// head_meta should be injected after <meta charset="utf-8">
138		assert!(result.contains(r#"<meta charset="utf-8"><title>Hello</title>"#));
139	}
140
141	#[test]
142	fn render_invalid_config_returns_template() {
143		let template = "plain html";
144		let result = render_page(template, "{}", "invalid json", None);
145		assert_eq!(result, "plain html");
146	}
147
148	#[test]
149	fn render_with_page_assets() {
150		let template = concat!(
151			r#"<html><head><meta charset="utf-8">"#,
152			r#"<link rel="stylesheet" href="/_seam/static/main.css">"#,
153			"<!--seam:page-styles--><!--seam:prefetch-->",
154			"</head><body>",
155			r#"<div id="__seam"><p><!--seam:title--></p></div>"#,
156			r#"<script type="module" src="/_seam/static/main.js"></script>"#,
157			"<!--seam:page-scripts-->",
158			"</body></html>"
159		);
160		let data = json!({"title": "Hello"}).to_string();
161		let config = json!({
162			"layout_chain": [],
163			"data_id": "__data",
164			"page_assets": {
165				"styles": ["page-home.css"],
166				"scripts": ["page-home.js"],
167				"preload": ["shared.js"],
168				"prefetch": ["page-other.js"]
169			}
170		})
171		.to_string();
172
173		let result = render_page(template, &data, &config, None);
174
175		// Asset slots replaced
176		assert!(result.contains(r#"href="/_seam/static/page-home.css""#));
177		assert!(result.contains(r#"src="/_seam/static/page-home.js""#));
178		assert!(result.contains(r#"modulepreload"#));
179		assert!(result.contains(r#"prefetch"#));
180		// Slot markers gone
181		assert!(!result.contains("<!--seam:page-styles-->"));
182		assert!(!result.contains("<!--seam:page-scripts-->"));
183		assert!(!result.contains("<!--seam:prefetch-->"));
184		// Data injection still works
185		assert!(result.contains("<p>Hello</p>"));
186		assert!(result.contains(r#"<script id="__data""#));
187	}
188
189	#[test]
190	fn render_without_page_assets_strips_slots() {
191		let template = concat!(
192			r#"<html><head><meta charset="utf-8">"#,
193			"<!--seam:page-styles--><!--seam:prefetch-->",
194			"</head><body>",
195			r#"<div id="__seam"><p><!--seam:title--></p></div>"#,
196			"<!--seam:page-scripts-->",
197			"</body></html>"
198		);
199		let data = json!({"title": "Hello"}).to_string();
200		let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
201
202		let result = render_page(template, &data, &config, None);
203
204		// Slot markers stripped before injector (prevents misinterpretation)
205		assert!(!result.contains("<!--seam:page-styles-->"));
206		assert!(!result.contains("<!--seam:page-scripts-->"));
207		assert!(!result.contains("<!--seam:prefetch-->"));
208		// Data injection still works
209		assert!(result.contains("<p>Hello</p>"));
210	}
211}