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}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct I18nOpts {
40 pub locale: String,
41 pub default_locale: String,
42 pub messages: serde_json::Value,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub hash: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub router: Option<serde_json::Value>,
49}
50
51pub fn flatten_for_slots(keyed: &serde_json::Value) -> serde_json::Value {
55 let Some(obj) = keyed.as_object() else {
56 return keyed.clone();
57 };
58 let mut merged = obj.clone();
59 for value in obj.values() {
60 if let serde_json::Value::Object(nested) = value {
61 for (nk, nv) in nested {
62 merged.entry(nk.clone()).or_insert_with(|| nv.clone());
63 }
64 }
65 }
66 serde_json::Value::Object(merged)
67}
68
69pub fn build_seam_data(
74 loader_data: &serde_json::Value,
75 config: &PageConfig,
76 i18n_opts: Option<&I18nOpts>,
77) -> serde_json::Value {
78 let Some(data_obj) = loader_data.as_object() else {
79 return loader_data.clone();
80 };
81
82 if config.layout_chain.is_empty() {
83 let mut result = data_obj.clone();
85 inject_i18n_data(&mut result, i18n_opts);
86 return serde_json::Value::Object(result);
87 }
88
89 let mut claimed_keys = std::collections::HashSet::new();
91 for entry in &config.layout_chain {
92 for key in &entry.loader_keys {
93 claimed_keys.insert(key.as_str());
94 }
95 }
96
97 let mut script_data = serde_json::Map::new();
99 for (k, v) in data_obj {
100 if !claimed_keys.contains(k.as_str()) {
101 script_data.insert(k.clone(), v.clone());
102 }
103 }
104
105 let mut layouts_map = serde_json::Map::new();
107 for entry in &config.layout_chain {
108 let mut layout_data = serde_json::Map::new();
109 for key in &entry.loader_keys {
110 if let Some(v) = data_obj.get(key) {
111 layout_data.insert(key.clone(), v.clone());
112 }
113 }
114 if !layout_data.is_empty() {
115 layouts_map.insert(entry.id.clone(), serde_json::Value::Object(layout_data));
116 }
117 }
118 if !layouts_map.is_empty() {
119 script_data.insert("_layouts".to_string(), serde_json::Value::Object(layouts_map));
120 }
121
122 inject_i18n_data(&mut script_data, i18n_opts);
123 serde_json::Value::Object(script_data)
124}
125
126fn inject_i18n_data(
128 script_data: &mut serde_json::Map<String, serde_json::Value>,
129 i18n_opts: Option<&I18nOpts>,
130) {
131 let Some(opts) = i18n_opts else { return };
132
133 let mut i18n_data = serde_json::Map::new();
134 i18n_data.insert("locale".into(), serde_json::Value::String(opts.locale.clone()));
135 i18n_data.insert("messages".into(), opts.messages.clone());
136 if let Some(ref h) = opts.hash {
137 i18n_data.insert("hash".into(), serde_json::Value::String(h.clone()));
138 }
139 if let Some(ref r) = opts.router {
140 i18n_data.insert("router".into(), r.clone());
141 }
142
143 script_data.insert("_i18n".into(), serde_json::Value::Object(i18n_data));
144}
145
146pub fn filter_i18n_messages(messages: &serde_json::Value, keys: &[String]) -> serde_json::Value {
149 if keys.is_empty() {
150 return messages.clone();
151 }
152 let Some(obj) = messages.as_object() else {
153 return messages.clone();
154 };
155 let filtered: serde_json::Map<String, serde_json::Value> =
156 keys.iter().filter_map(|k| obj.get(k).map(|v| (k.clone(), v.clone()))).collect();
157 serde_json::Value::Object(filtered)
158}
159
160pub fn inject_data_script(html: &str, data_id: &str, json: &str) -> String {
162 let script = format!(r#"<script id="{data_id}" type="application/json">{json}</script>"#);
163 if let Some(pos) = html.rfind("</body>") {
164 let mut result = String::with_capacity(html.len() + script.len());
165 result.push_str(&html[..pos]);
166 result.push_str(&script);
167 result.push_str(&html[pos..]);
168 result
169 } else {
170 format!("{html}{script}")
171 }
172}
173
174pub fn inject_html_lang(html: &str, locale: &str) -> String {
176 html.replacen("<html", &format!("<html lang=\"{locale}\""), 1)
177}
178
179pub fn inject_head_meta(html: &str, meta_html: &str) -> String {
181 let charset = r#"<meta charset="utf-8">"#;
182 if let Some(pos) = html.find(charset) {
183 let insert_at = pos + charset.len();
184 let mut result = String::with_capacity(html.len() + meta_html.len());
185 result.push_str(&html[..insert_at]);
186 result.push_str(meta_html);
187 result.push_str(&html[insert_at..]);
188 result
189 } else {
190 html.to_string()
191 }
192}
193
194pub fn i18n_query(
197 keys: &[String],
198 locale: &str,
199 default_locale: &str,
200 all_messages: &serde_json::Value,
201) -> serde_json::Value {
202 let empty = serde_json::Value::Object(Default::default());
203 let target_msgs = all_messages.get(locale).unwrap_or(&empty);
204 let default_msgs = all_messages.get(default_locale).unwrap_or(&empty);
205
206 let mut messages = serde_json::Map::new();
207 for key in keys {
208 let val = target_msgs
209 .get(key)
210 .or_else(|| default_msgs.get(key))
211 .and_then(|v| v.as_str())
212 .unwrap_or(key)
213 .to_string();
214 messages.insert(key.clone(), serde_json::Value::String(val));
215 }
216 serde_json::json!({ "messages": messages })
217}
218
219pub fn generate_style_tags(styles: &[String]) -> String {
221 let mut out = String::new();
222 for f in styles {
223 out.push_str(&format!(r#"<link rel="stylesheet" href="/_seam/static/{f}">"#));
224 }
225 out
226}
227
228pub fn generate_script_tags(scripts: &[String], preloads: &[String]) -> String {
230 let mut out = String::new();
231 for f in preloads {
232 out.push_str(&format!(r#"<link rel="modulepreload" href="/_seam/static/{f}">"#));
233 }
234 for f in scripts {
235 out.push_str(&format!(r#"<script type="module" src="/_seam/static/{f}"></script>"#));
236 }
237 out
238}
239
240pub fn generate_prefetch_tags(prefetch: &[String]) -> String {
242 let mut out = String::new();
243 for f in prefetch {
244 let as_attr = if f.ends_with(".css") { "style" } else { "script" };
245 out.push_str(&format!(r#"<link rel="prefetch" href="/_seam/static/{f}" as="{as_attr}">"#));
246 }
247 out
248}
249
250pub fn strip_asset_slots(template: &str) -> String {
254 template
255 .replace("<!--seam:page-styles-->", "")
256 .replace("<!--seam:page-scripts-->", "")
257 .replace("<!--seam:prefetch-->", "")
258}
259
260pub fn replace_asset_slots(template: &str, assets: &PageAssets) -> String {
262 template
263 .replace("<!--seam:page-styles-->", &generate_style_tags(&assets.styles))
264 .replace("<!--seam:page-scripts-->", &generate_script_tags(&assets.scripts, &assets.preload))
265 .replace("<!--seam:prefetch-->", &generate_prefetch_tags(&assets.prefetch))
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use serde_json::json;
272
273 #[test]
274 fn flatten_spreads_nested() {
275 let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
276 let flat = flatten_for_slots(&input);
277 assert_eq!(flat["title"], "Hello");
278 assert_eq!(flat["tagline"], "World");
279 assert_eq!(flat["other"], 42);
280 assert_eq!(flat["page"]["title"], "Hello");
281 }
282
283 #[test]
284 fn flatten_no_override() {
285 let input = json!({"title": "Top", "page": {"title": "Nested"}});
287 let flat = flatten_for_slots(&input);
288 assert_eq!(flat["title"], "Top");
289 }
290
291 #[test]
292 fn build_seam_data_no_layout() {
293 let data = json!({"title": "Hello", "count": 42});
294 let config = PageConfig {
295 layout_chain: vec![],
296 data_id: "__data".into(),
297 head_meta: None,
298 page_assets: None,
299 };
300 let result = build_seam_data(&data, &config, None);
301 assert_eq!(result["title"], "Hello");
302 assert_eq!(result["count"], 42);
303 assert!(result.get("_layouts").is_none());
304 }
305
306 #[test]
307 fn build_seam_data_single_layout() {
308 let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
309 let config = PageConfig {
310 layout_chain: vec![LayoutChainEntry {
311 id: "root".into(),
312 loader_keys: vec!["layoutKey".into()],
313 }],
314 data_id: "__data".into(),
315 head_meta: None,
316 page_assets: None,
317 };
318 let result = build_seam_data(&data, &config, None);
319 assert_eq!(result["pageKey"], "page_val");
320 assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
321 assert!(result.get("layoutKey").is_none());
322 }
323
324 #[test]
325 fn build_seam_data_multi_layout() {
326 let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
328 let config = PageConfig {
329 layout_chain: vec![
330 LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
331 LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
332 ],
333 data_id: "__data".into(),
334 head_meta: None,
335 page_assets: None,
336 };
337 let result = build_seam_data(&data, &config, None);
338 assert_eq!(result["page_data"], "p");
339 assert_eq!(result["_layouts"]["outer"]["nav"], "n");
340 assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
341 assert!(result.get("nav").is_none());
343 assert!(result.get("sidebar").is_none());
344 }
345
346 #[test]
347 fn build_seam_data_with_i18n() {
348 let data = json!({"title": "Hello"});
349 let config = PageConfig {
350 layout_chain: vec![],
351 data_id: "__data".into(),
352 head_meta: None,
353 page_assets: None,
354 };
355 let i18n = I18nOpts {
356 locale: "zh".into(),
357 default_locale: "en".into(),
358 messages: json!({"hello": "你好"}),
359 hash: None,
360 router: None,
361 };
362 let result = build_seam_data(&data, &config, Some(&i18n));
363 assert_eq!(result["_i18n"]["locale"], "zh");
364 assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
365 assert!(result["_i18n"].get("hash").is_none());
366 assert!(result["_i18n"].get("router").is_none());
367 }
368
369 #[test]
370 fn filter_messages_all() {
371 let msgs = json!({"hello": "Hello", "bye": "Bye"});
372 let filtered = filter_i18n_messages(&msgs, &[]);
373 assert_eq!(filtered, msgs);
374 }
375
376 #[test]
377 fn filter_messages_subset() {
378 let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
379 let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
380 assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
381 }
382
383 #[test]
384 fn inject_data_script_before_body() {
385 let html = "<html><body><p>Content</p></body></html>";
386 let result = inject_data_script(html, "__data", r#"{"a":1}"#);
387 assert!(
388 result.contains(r#"<script id="__data" type="application/json">{"a":1}</script></body>"#)
389 );
390 }
391
392 #[test]
393 fn inject_data_script_no_body() {
394 let html = "<html><p>Content</p></html>";
395 let result = inject_data_script(html, "__data", r#"{"a":1}"#);
396 assert!(result.ends_with(r#"<script id="__data" type="application/json">{"a":1}</script>"#));
397 }
398
399 #[test]
400 fn inject_html_lang_test() {
401 let html = "<html><head></head></html>";
402 let result = inject_html_lang(html, "zh");
403 assert!(result.starts_with(r#"<html lang="zh""#));
404 }
405
406 #[test]
407 fn inject_head_meta_test() {
408 let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
409 let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
410 assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
411 }
412
413 #[test]
414 fn i18n_query_basic() {
415 let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
416 let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
417 assert_eq!(result["messages"]["hello"], "你好");
418 assert_eq!(result["messages"]["bye"], "Bye");
420 }
421
422 #[test]
423 fn i18n_query_fallback_to_default() {
424 let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
425 let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
426 assert_eq!(result["messages"]["hello"], "Hello");
427 }
428
429 #[test]
430 fn generate_style_tags_output() {
431 let tags = generate_style_tags(&["page-home.css".into()]);
432 assert_eq!(tags, r#"<link rel="stylesheet" href="/_seam/static/page-home.css">"#);
433 }
434
435 #[test]
436 fn generate_script_tags_output() {
437 let tags = generate_script_tags(&["page-home.js".into()], &["shared.js".into()]);
438 assert!(tags.contains(r#"<link rel="modulepreload" href="/_seam/static/shared.js">"#));
439 assert!(tags.contains(r#"<script type="module" src="/_seam/static/page-home.js"></script>"#));
440 let preload_pos = tags.find("modulepreload").unwrap();
442 let script_pos = tags.find("type=\"module\"").unwrap();
443 assert!(preload_pos < script_pos);
444 }
445
446 #[test]
447 fn generate_prefetch_tags_output() {
448 let tags = generate_prefetch_tags(&["other.js".into(), "other.css".into()]);
449 assert!(tags.contains(r#"as="script""#));
450 assert!(tags.contains(r#"as="style""#));
451 }
452
453 #[test]
454 fn replace_asset_slots_test() {
455 let template = concat!(
456 "<head><!--seam:page-styles--><!--seam:prefetch--></head>",
457 "<body><!--seam:page-scripts--></body>"
458 );
459 let assets = PageAssets {
460 styles: vec!["page.css".into()],
461 scripts: vec!["page.js".into()],
462 preload: vec!["shared.js".into()],
463 prefetch: vec!["other.js".into()],
464 };
465 let result = replace_asset_slots(template, &assets);
466 assert!(result.contains(r#"href="/_seam/static/page.css""#));
467 assert!(result.contains(r#"src="/_seam/static/page.js""#));
468 assert!(result.contains(r#"modulepreload"#));
469 assert!(result.contains(r#"prefetch"#));
470 assert!(!result.contains("<!--seam:page-styles-->"));
471 assert!(!result.contains("<!--seam:page-scripts-->"));
472 assert!(!result.contains("<!--seam:prefetch-->"));
473 }
474
475 #[test]
476 fn page_config_deserializes_without_page_assets() {
477 let json = r#"{"layout_chain": [], "data_id": "__data"}"#;
478 let config: PageConfig = serde_json::from_str(json).unwrap();
479 assert!(config.page_assets.is_none());
480 }
481
482 #[test]
483 fn page_config_deserializes_with_page_assets() {
484 let json = r#"{
485 "layout_chain": [],
486 "data_id": "__data",
487 "page_assets": {
488 "styles": ["page.css"],
489 "scripts": ["page.js"],
490 "preload": ["shared.js"],
491 "prefetch": ["other.js"]
492 }
493 }"#;
494 let config: PageConfig = serde_json::from_str(json).unwrap();
495 let assets = config.page_assets.unwrap();
496 assert_eq!(assets.styles, vec!["page.css"]);
497 assert_eq!(assets.scripts, vec!["page.js"]);
498 assert_eq!(assets.preload, vec!["shared.js"]);
499 assert_eq!(assets.prefetch, vec!["other.js"]);
500 }
501}