Skip to main content

seam_engine/
page.rs

1/* src/server/engine/rust/src/page.rs */
2
3use serde::{Deserialize, Serialize};
4
5/// One entry in a layout chain (outer to inner order).
6/// Each layout owns a set of loader data keys.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct LayoutChainEntry {
9	pub id: String,
10	pub loader_keys: Vec<String>,
11}
12
13/// Per-page asset references for resource splitting.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct PageAssets {
16	#[serde(default)]
17	pub styles: Vec<String>,
18	#[serde(default)]
19	pub scripts: Vec<String>,
20	#[serde(default)]
21	pub preload: Vec<String>,
22	#[serde(default)]
23	pub prefetch: Vec<String>,
24}
25
26/// Configuration for page assembly, passed as JSON.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PageConfig {
29	pub layout_chain: Vec<LayoutChainEntry>,
30	pub data_id: String,
31	#[serde(default)]
32	pub head_meta: Option<String>,
33	#[serde(default)]
34	pub page_assets: Option<PageAssets>,
35	/// Per-loader procedure + input metadata, injected as `__loaders` in `__data`.
36	/// Lives in config (not loader data) to avoid `flatten_for_slots` contamination.
37	#[serde(default)]
38	pub loader_metadata: Option<serde_json::Map<String, serde_json::Value>>,
39}
40
41/// i18n options for page rendering, passed as JSON.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct I18nOpts {
44	pub locale: String,
45	pub default_locale: String,
46	pub messages: serde_json::Value,
47	/// Content hash (4 hex) for cache validation
48	#[serde(default, skip_serializing_if = "Option::is_none")]
49	pub hash: Option<String>,
50	/// Full route→locale→hash table for client cache layer
51	#[serde(default, skip_serializing_if = "Option::is_none")]
52	pub router: Option<serde_json::Value>,
53}
54
55/// Flatten keyed loader results for slot resolution: spread nested object
56/// values to the top level so slots like `<!--seam:tagline-->` can resolve from
57/// data like `{page: {tagline: "..."}}`.
58pub fn flatten_for_slots(keyed: &serde_json::Value) -> serde_json::Value {
59	let Some(obj) = keyed.as_object() else {
60		return keyed.clone();
61	};
62	let mut merged = obj.clone();
63	for value in obj.values() {
64		if let serde_json::Value::Object(nested) = value {
65			for (nk, nv) in nested {
66				merged.entry(nk.clone()).or_insert_with(|| nv.clone());
67			}
68		}
69	}
70	serde_json::Value::Object(merged)
71}
72
73/// Build the data script JSON object with correct per-layout `_layouts` grouping.
74///
75/// Unlike the old single-layout-id approach, this groups data under each layout
76/// in the chain independently, matching the TS reference implementation.
77pub fn build_seam_data(
78	loader_data: &serde_json::Value,
79	config: &PageConfig,
80	i18n_opts: Option<&I18nOpts>,
81) -> serde_json::Value {
82	let Some(data_obj) = loader_data.as_object() else {
83		return loader_data.clone();
84	};
85
86	if config.layout_chain.is_empty() {
87		// No layouts: all data at top level
88		let mut result = data_obj.clone();
89		inject_i18n_data(&mut result, i18n_opts);
90		inject_loader_metadata(&mut result, config);
91		return serde_json::Value::Object(result);
92	}
93
94	// Collect all layout-claimed keys
95	let mut claimed_keys = std::collections::HashSet::new();
96	for entry in &config.layout_chain {
97		for key in &entry.loader_keys {
98			claimed_keys.insert(key.as_str());
99		}
100	}
101
102	// Page data = keys not claimed by any layout
103	let mut script_data = serde_json::Map::new();
104	for (k, v) in data_obj {
105		if !claimed_keys.contains(k.as_str()) {
106			script_data.insert(k.clone(), v.clone());
107		}
108	}
109
110	// Build per-layout _layouts grouping
111	let mut layouts_map = serde_json::Map::new();
112	for entry in &config.layout_chain {
113		let mut layout_data = serde_json::Map::new();
114		for key in &entry.loader_keys {
115			if let Some(v) = data_obj.get(key) {
116				layout_data.insert(key.clone(), v.clone());
117			}
118		}
119		if !layout_data.is_empty() {
120			layouts_map.insert(entry.id.clone(), serde_json::Value::Object(layout_data));
121		}
122	}
123	if !layouts_map.is_empty() {
124		script_data.insert("_layouts".to_string(), serde_json::Value::Object(layouts_map));
125	}
126
127	inject_i18n_data(&mut script_data, i18n_opts);
128	inject_loader_metadata(&mut script_data, config);
129	serde_json::Value::Object(script_data)
130}
131
132/// Inject `_i18n` data into the script data map for client hydration.
133fn inject_i18n_data(
134	script_data: &mut serde_json::Map<String, serde_json::Value>,
135	i18n_opts: Option<&I18nOpts>,
136) {
137	let Some(opts) = i18n_opts else { return };
138
139	let mut i18n_data = serde_json::Map::new();
140	i18n_data.insert("locale".into(), serde_json::Value::String(opts.locale.clone()));
141	i18n_data.insert("messages".into(), opts.messages.clone());
142	if let Some(ref h) = opts.hash {
143		i18n_data.insert("hash".into(), serde_json::Value::String(h.clone()));
144	}
145	if let Some(ref r) = opts.router {
146		i18n_data.insert("router".into(), r.clone());
147	}
148
149	script_data.insert("_i18n".into(), serde_json::Value::Object(i18n_data));
150}
151
152/// Inject `__loaders` metadata from config into the script data map.
153fn inject_loader_metadata(
154	script_data: &mut serde_json::Map<String, serde_json::Value>,
155	config: &PageConfig,
156) {
157	if let Some(ref meta) = config.loader_metadata {
158		script_data.insert("__loaders".to_string(), serde_json::Value::Object(meta.clone()));
159	}
160}
161
162/// Filter i18n messages to only include keys in the allow list.
163/// Empty list means include all messages.
164pub fn filter_i18n_messages(messages: &serde_json::Value, keys: &[String]) -> serde_json::Value {
165	if keys.is_empty() {
166		return messages.clone();
167	}
168	let Some(obj) = messages.as_object() else {
169		return messages.clone();
170	};
171	let filtered: serde_json::Map<String, serde_json::Value> =
172		keys.iter().filter_map(|k| obj.get(k).map(|v| (k.clone(), v.clone()))).collect();
173	serde_json::Value::Object(filtered)
174}
175
176/// Inject a `<script>` tag with JSON data before `</body>`.
177pub fn inject_data_script(html: &str, data_id: &str, json: &str) -> String {
178	let script = format!(r#"<script id="{data_id}" type="application/json">{json}</script>"#);
179	if let Some(pos) = html.rfind("</body>") {
180		let mut result = String::with_capacity(html.len() + script.len());
181		result.push_str(&html[..pos]);
182		result.push_str(&script);
183		result.push_str(&html[pos..]);
184		result
185	} else {
186		format!("{html}{script}")
187	}
188}
189
190/// Set `<html lang="...">` attribute.
191pub fn inject_html_lang(html: &str, locale: &str) -> String {
192	html.replacen("<html", &format!("<html lang=\"{locale}\""), 1)
193}
194
195/// Inject page-level head metadata after `<meta charset="utf-8">`.
196pub fn inject_head_meta(html: &str, meta_html: &str) -> String {
197	let charset = r#"<meta charset="utf-8">"#;
198	if let Some(pos) = html.find(charset) {
199		let insert_at = pos + charset.len();
200		let mut result = String::with_capacity(html.len() + meta_html.len());
201		result.push_str(&html[..insert_at]);
202		result.push_str(meta_html);
203		result.push_str(&html[insert_at..]);
204		result
205	} else {
206		html.to_string()
207	}
208}
209
210/// Process an i18n query: look up requested keys from locale messages,
211/// with per-key fallback to default locale, then key itself.
212pub fn i18n_query(
213	keys: &[String],
214	locale: &str,
215	default_locale: &str,
216	all_messages: &serde_json::Value,
217) -> serde_json::Value {
218	let empty = serde_json::Value::Object(Default::default());
219	let target_msgs = all_messages.get(locale).unwrap_or(&empty);
220	let default_msgs = all_messages.get(default_locale).unwrap_or(&empty);
221
222	let mut messages = serde_json::Map::new();
223	for key in keys {
224		let val = target_msgs
225			.get(key)
226			.or_else(|| default_msgs.get(key))
227			.and_then(|v| v.as_str())
228			.unwrap_or(key)
229			.to_string();
230		messages.insert(key.clone(), serde_json::Value::String(val));
231	}
232	serde_json::json!({ "messages": messages })
233}
234
235#[cfg(test)]
236mod tests {
237	use super::*;
238	use serde_json::json;
239
240	#[test]
241	fn flatten_spreads_nested() {
242		let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
243		let flat = flatten_for_slots(&input);
244		assert_eq!(flat["title"], "Hello");
245		assert_eq!(flat["tagline"], "World");
246		assert_eq!(flat["other"], 42);
247		assert_eq!(flat["page"]["title"], "Hello");
248	}
249
250	#[test]
251	fn flatten_no_override() {
252		// Top-level keys should not be overridden by nested ones
253		let input = json!({"title": "Top", "page": {"title": "Nested"}});
254		let flat = flatten_for_slots(&input);
255		assert_eq!(flat["title"], "Top");
256	}
257
258	#[test]
259	fn build_seam_data_no_layout() {
260		let data = json!({"title": "Hello", "count": 42});
261		let config = PageConfig {
262			layout_chain: vec![],
263			data_id: "__data".into(),
264			head_meta: None,
265			page_assets: None,
266			loader_metadata: None,
267		};
268		let result = build_seam_data(&data, &config, None);
269		assert_eq!(result["title"], "Hello");
270		assert_eq!(result["count"], 42);
271		assert!(result.get("_layouts").is_none());
272	}
273
274	#[test]
275	fn build_seam_data_single_layout() {
276		let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
277		let config = PageConfig {
278			layout_chain: vec![LayoutChainEntry {
279				id: "root".into(),
280				loader_keys: vec!["layoutKey".into()],
281			}],
282			data_id: "__data".into(),
283			head_meta: None,
284			page_assets: None,
285			loader_metadata: None,
286		};
287		let result = build_seam_data(&data, &config, None);
288		assert_eq!(result["pageKey"], "page_val");
289		assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
290		assert!(result.get("layoutKey").is_none());
291	}
292
293	#[test]
294	fn build_seam_data_multi_layout() {
295		// Two layouts: outer claims "nav", inner claims "sidebar"
296		let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
297		let config = PageConfig {
298			layout_chain: vec![
299				LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
300				LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
301			],
302			data_id: "__data".into(),
303			head_meta: None,
304			page_assets: None,
305			loader_metadata: None,
306		};
307		let result = build_seam_data(&data, &config, None);
308		assert_eq!(result["page_data"], "p");
309		assert_eq!(result["_layouts"]["outer"]["nav"], "n");
310		assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
311		// Page-level should not have layout keys
312		assert!(result.get("nav").is_none());
313		assert!(result.get("sidebar").is_none());
314	}
315
316	#[test]
317	fn build_seam_data_with_i18n() {
318		let data = json!({"title": "Hello"});
319		let config = PageConfig {
320			layout_chain: vec![],
321			data_id: "__data".into(),
322			head_meta: None,
323			page_assets: None,
324			loader_metadata: None,
325		};
326		let i18n = I18nOpts {
327			locale: "zh".into(),
328			default_locale: "en".into(),
329			messages: json!({"hello": "你好"}),
330			hash: None,
331			router: None,
332		};
333		let result = build_seam_data(&data, &config, Some(&i18n));
334		assert_eq!(result["_i18n"]["locale"], "zh");
335		assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
336		assert!(result["_i18n"].get("hash").is_none());
337		assert!(result["_i18n"].get("router").is_none());
338	}
339
340	#[test]
341	fn filter_messages_all() {
342		let msgs = json!({"hello": "Hello", "bye": "Bye"});
343		let filtered = filter_i18n_messages(&msgs, &[]);
344		assert_eq!(filtered, msgs);
345	}
346
347	#[test]
348	fn filter_messages_subset() {
349		let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
350		let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
351		assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
352	}
353
354	#[test]
355	fn inject_data_script_before_body() {
356		let html = "<html><body><p>Content</p></body></html>";
357		let result = inject_data_script(html, "__data", r#"{"a":1}"#);
358		assert!(
359			result.contains(r#"<script id="__data" type="application/json">{"a":1}</script></body>"#)
360		);
361	}
362
363	#[test]
364	fn inject_data_script_no_body() {
365		let html = "<html><p>Content</p></html>";
366		let result = inject_data_script(html, "__data", r#"{"a":1}"#);
367		assert!(result.ends_with(r#"<script id="__data" type="application/json">{"a":1}</script>"#));
368	}
369
370	#[test]
371	fn inject_html_lang_test() {
372		let html = "<html><head></head></html>";
373		let result = inject_html_lang(html, "zh");
374		assert!(result.starts_with(r#"<html lang="zh""#));
375	}
376
377	#[test]
378	fn inject_head_meta_test() {
379		let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
380		let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
381		assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
382	}
383
384	#[test]
385	fn i18n_query_basic() {
386		let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
387		let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
388		assert_eq!(result["messages"]["hello"], "你好");
389		// "bye" not in zh, falls back to default locale (en)
390		assert_eq!(result["messages"]["bye"], "Bye");
391	}
392
393	#[test]
394	fn i18n_query_fallback_to_default() {
395		let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
396		let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
397		assert_eq!(result["messages"]["hello"], "Hello");
398	}
399
400	#[test]
401	fn page_config_deserializes_without_page_assets() {
402		let json = r#"{"layout_chain": [], "data_id": "__data"}"#;
403		let config: PageConfig = serde_json::from_str(json).unwrap();
404		assert!(config.page_assets.is_none());
405		assert!(config.loader_metadata.is_none());
406	}
407
408	#[test]
409	fn page_config_deserializes_with_page_assets() {
410		let json = r#"{
411      "layout_chain": [],
412      "data_id": "__data",
413      "page_assets": {
414        "styles": ["page.css"],
415        "scripts": ["page.js"],
416        "preload": ["shared.js"],
417        "prefetch": ["other.js"]
418      }
419    }"#;
420		let config: PageConfig = serde_json::from_str(json).unwrap();
421		let assets = config.page_assets.unwrap();
422		assert_eq!(assets.styles, vec!["page.css"]);
423		assert_eq!(assets.scripts, vec!["page.js"]);
424		assert_eq!(assets.preload, vec!["shared.js"]);
425		assert_eq!(assets.prefetch, vec!["other.js"]);
426	}
427
428	#[test]
429	fn build_seam_data_with_loader_metadata() {
430		let data = json!({"todos": [{"id": 1}], "stats": {"count": 5}});
431		let mut meta = serde_json::Map::new();
432		meta.insert("todos".into(), json!({"procedure": "listTodos", "input": {}}));
433		meta.insert("stats".into(), json!({"procedure": "getStats", "input": {"slug": "home"}}));
434		let config = PageConfig {
435			layout_chain: vec![],
436			data_id: "__data".into(),
437			head_meta: None,
438			page_assets: None,
439			loader_metadata: Some(meta),
440		};
441		let result = build_seam_data(&data, &config, None);
442		assert_eq!(result["__loaders"]["todos"]["procedure"], "listTodos");
443		assert_eq!(result["__loaders"]["stats"]["input"]["slug"], "home");
444		// Data keys are still at top level
445		assert_eq!(result["todos"][0]["id"], 1);
446		assert_eq!(result["stats"]["count"], 5);
447	}
448
449	#[test]
450	fn build_seam_data_loader_metadata_not_in_layout_claim() {
451		// __loaders must appear at top level, not claimed by layouts
452		let data = json!({"page_data": "p", "nav": "n"});
453		let mut meta = serde_json::Map::new();
454		meta.insert("page_data".into(), json!({"procedure": "getPage", "input": {}}));
455		meta.insert("nav".into(), json!({"procedure": "getNav", "input": {}}));
456		let config = PageConfig {
457			layout_chain: vec![LayoutChainEntry { id: "root".into(), loader_keys: vec!["nav".into()] }],
458			data_id: "__data".into(),
459			head_meta: None,
460			page_assets: None,
461			loader_metadata: Some(meta),
462		};
463		let result = build_seam_data(&data, &config, None);
464		// __loaders at top level, not under _layouts
465		assert!(result["__loaders"].is_object());
466		assert_eq!(result["__loaders"]["nav"]["procedure"], "getNav");
467		assert!(result["_layouts"]["root"].get("__loaders").is_none());
468	}
469}