1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct LayoutChainEntry {
9 pub id: String,
10 pub loader_keys: Vec<String>,
11}
12
13#[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#[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 #[serde(default)]
38 pub loader_metadata: Option<serde_json::Map<String, serde_json::Value>>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct I18nOpts {
44 pub locale: String,
45 pub default_locale: String,
46 pub messages: serde_json::Value,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub hash: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub router: Option<serde_json::Value>,
53}
54
55pub 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
73pub 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 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 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 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 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
132fn 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
152fn 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
162pub 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
176pub 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
190pub fn inject_html_lang(html: &str, locale: &str) -> String {
192 html.replacen("<html", &format!("<html lang=\"{locale}\""), 1)
193}
194
195pub 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
210pub 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 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 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 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 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 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 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 assert!(result["__loaders"].is_object());
466 assert_eq!(result["__loaders"]["nav"]["procedure"], "getNav");
467 assert!(result["_layouts"]["root"].get("__loaders").is_none());
468 }
469}