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
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223
224 #[test]
225 fn flatten_spreads_nested() {
226 let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
227 let flat = flatten_for_slots(&input);
228 assert_eq!(flat["title"], "Hello");
229 assert_eq!(flat["tagline"], "World");
230 assert_eq!(flat["other"], 42);
231 assert_eq!(flat["page"]["title"], "Hello");
232 }
233
234 #[test]
235 fn flatten_no_override() {
236 let input = json!({"title": "Top", "page": {"title": "Nested"}});
238 let flat = flatten_for_slots(&input);
239 assert_eq!(flat["title"], "Top");
240 }
241
242 #[test]
243 fn build_seam_data_no_layout() {
244 let data = json!({"title": "Hello", "count": 42});
245 let config = PageConfig {
246 layout_chain: vec![],
247 data_id: "__data".into(),
248 head_meta: None,
249 page_assets: None,
250 };
251 let result = build_seam_data(&data, &config, None);
252 assert_eq!(result["title"], "Hello");
253 assert_eq!(result["count"], 42);
254 assert!(result.get("_layouts").is_none());
255 }
256
257 #[test]
258 fn build_seam_data_single_layout() {
259 let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
260 let config = PageConfig {
261 layout_chain: vec![LayoutChainEntry {
262 id: "root".into(),
263 loader_keys: vec!["layoutKey".into()],
264 }],
265 data_id: "__data".into(),
266 head_meta: None,
267 page_assets: None,
268 };
269 let result = build_seam_data(&data, &config, None);
270 assert_eq!(result["pageKey"], "page_val");
271 assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
272 assert!(result.get("layoutKey").is_none());
273 }
274
275 #[test]
276 fn build_seam_data_multi_layout() {
277 let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
279 let config = PageConfig {
280 layout_chain: vec![
281 LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
282 LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
283 ],
284 data_id: "__data".into(),
285 head_meta: None,
286 page_assets: None,
287 };
288 let result = build_seam_data(&data, &config, None);
289 assert_eq!(result["page_data"], "p");
290 assert_eq!(result["_layouts"]["outer"]["nav"], "n");
291 assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
292 assert!(result.get("nav").is_none());
294 assert!(result.get("sidebar").is_none());
295 }
296
297 #[test]
298 fn build_seam_data_with_i18n() {
299 let data = json!({"title": "Hello"});
300 let config = PageConfig {
301 layout_chain: vec![],
302 data_id: "__data".into(),
303 head_meta: None,
304 page_assets: None,
305 };
306 let i18n = I18nOpts {
307 locale: "zh".into(),
308 default_locale: "en".into(),
309 messages: json!({"hello": "你好"}),
310 hash: None,
311 router: None,
312 };
313 let result = build_seam_data(&data, &config, Some(&i18n));
314 assert_eq!(result["_i18n"]["locale"], "zh");
315 assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
316 assert!(result["_i18n"].get("hash").is_none());
317 assert!(result["_i18n"].get("router").is_none());
318 }
319
320 #[test]
321 fn filter_messages_all() {
322 let msgs = json!({"hello": "Hello", "bye": "Bye"});
323 let filtered = filter_i18n_messages(&msgs, &[]);
324 assert_eq!(filtered, msgs);
325 }
326
327 #[test]
328 fn filter_messages_subset() {
329 let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
330 let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
331 assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
332 }
333
334 #[test]
335 fn inject_data_script_before_body() {
336 let html = "<html><body><p>Content</p></body></html>";
337 let result = inject_data_script(html, "__data", r#"{"a":1}"#);
338 assert!(
339 result.contains(r#"<script id="__data" type="application/json">{"a":1}</script></body>"#)
340 );
341 }
342
343 #[test]
344 fn inject_data_script_no_body() {
345 let html = "<html><p>Content</p></html>";
346 let result = inject_data_script(html, "__data", r#"{"a":1}"#);
347 assert!(result.ends_with(r#"<script id="__data" type="application/json">{"a":1}</script>"#));
348 }
349
350 #[test]
351 fn inject_html_lang_test() {
352 let html = "<html><head></head></html>";
353 let result = inject_html_lang(html, "zh");
354 assert!(result.starts_with(r#"<html lang="zh""#));
355 }
356
357 #[test]
358 fn inject_head_meta_test() {
359 let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
360 let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
361 assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
362 }
363
364 #[test]
365 fn i18n_query_basic() {
366 let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
367 let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
368 assert_eq!(result["messages"]["hello"], "你好");
369 assert_eq!(result["messages"]["bye"], "Bye");
371 }
372
373 #[test]
374 fn i18n_query_fallback_to_default() {
375 let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
376 let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
377 assert_eq!(result["messages"]["hello"], "Hello");
378 }
379
380 #[test]
381 fn page_config_deserializes_without_page_assets() {
382 let json = r#"{"layout_chain": [], "data_id": "__data"}"#;
383 let config: PageConfig = serde_json::from_str(json).unwrap();
384 assert!(config.page_assets.is_none());
385 }
386
387 #[test]
388 fn page_config_deserializes_with_page_assets() {
389 let json = r#"{
390 "layout_chain": [],
391 "data_id": "__data",
392 "page_assets": {
393 "styles": ["page.css"],
394 "scripts": ["page.js"],
395 "preload": ["shared.js"],
396 "prefetch": ["other.js"]
397 }
398 }"#;
399 let config: PageConfig = serde_json::from_str(json).unwrap();
400 let assets = config.page_assets.unwrap();
401 assert_eq!(assets.styles, vec!["page.css"]);
402 assert_eq!(assets.scripts, vec!["page.js"]);
403 assert_eq!(assets.preload, vec!["shared.js"]);
404 assert_eq!(assets.prefetch, vec!["other.js"]);
405 }
406}