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)]
15pub struct PageConfig {
16 pub layout_chain: Vec<LayoutChainEntry>,
17 pub data_id: String,
18 #[serde(default)]
19 pub head_meta: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct I18nOpts {
25 pub locale: String,
26 pub default_locale: String,
27 pub messages: serde_json::Value,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub hash: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub router: Option<serde_json::Value>,
34}
35
36pub fn flatten_for_slots(keyed: &serde_json::Value) -> serde_json::Value {
40 let Some(obj) = keyed.as_object() else {
41 return keyed.clone();
42 };
43 let mut merged = obj.clone();
44 for value in obj.values() {
45 if let serde_json::Value::Object(nested) = value {
46 for (nk, nv) in nested {
47 merged.entry(nk.clone()).or_insert_with(|| nv.clone());
48 }
49 }
50 }
51 serde_json::Value::Object(merged)
52}
53
54pub fn build_seam_data(
59 loader_data: &serde_json::Value,
60 config: &PageConfig,
61 i18n_opts: Option<&I18nOpts>,
62) -> serde_json::Value {
63 let Some(data_obj) = loader_data.as_object() else {
64 return loader_data.clone();
65 };
66
67 if config.layout_chain.is_empty() {
68 let mut result = data_obj.clone();
70 inject_i18n_data(&mut result, i18n_opts);
71 return serde_json::Value::Object(result);
72 }
73
74 let mut claimed_keys = std::collections::HashSet::new();
76 for entry in &config.layout_chain {
77 for key in &entry.loader_keys {
78 claimed_keys.insert(key.as_str());
79 }
80 }
81
82 let mut script_data = serde_json::Map::new();
84 for (k, v) in data_obj {
85 if !claimed_keys.contains(k.as_str()) {
86 script_data.insert(k.clone(), v.clone());
87 }
88 }
89
90 let mut layouts_map = serde_json::Map::new();
92 for entry in &config.layout_chain {
93 let mut layout_data = serde_json::Map::new();
94 for key in &entry.loader_keys {
95 if let Some(v) = data_obj.get(key) {
96 layout_data.insert(key.clone(), v.clone());
97 }
98 }
99 if !layout_data.is_empty() {
100 layouts_map.insert(entry.id.clone(), serde_json::Value::Object(layout_data));
101 }
102 }
103 if !layouts_map.is_empty() {
104 script_data.insert("_layouts".to_string(), serde_json::Value::Object(layouts_map));
105 }
106
107 inject_i18n_data(&mut script_data, i18n_opts);
108 serde_json::Value::Object(script_data)
109}
110
111fn inject_i18n_data(
113 script_data: &mut serde_json::Map<String, serde_json::Value>,
114 i18n_opts: Option<&I18nOpts>,
115) {
116 let Some(opts) = i18n_opts else { return };
117
118 let mut i18n_data = serde_json::Map::new();
119 i18n_data.insert("locale".into(), serde_json::Value::String(opts.locale.clone()));
120 i18n_data.insert("messages".into(), opts.messages.clone());
121 if let Some(ref h) = opts.hash {
122 i18n_data.insert("hash".into(), serde_json::Value::String(h.clone()));
123 }
124 if let Some(ref r) = opts.router {
125 i18n_data.insert("router".into(), r.clone());
126 }
127
128 script_data.insert("_i18n".into(), serde_json::Value::Object(i18n_data));
129}
130
131pub fn filter_i18n_messages(messages: &serde_json::Value, keys: &[String]) -> serde_json::Value {
134 if keys.is_empty() {
135 return messages.clone();
136 }
137 let Some(obj) = messages.as_object() else {
138 return messages.clone();
139 };
140 let filtered: serde_json::Map<String, serde_json::Value> =
141 keys.iter().filter_map(|k| obj.get(k).map(|v| (k.clone(), v.clone()))).collect();
142 serde_json::Value::Object(filtered)
143}
144
145pub fn inject_data_script(html: &str, data_id: &str, json: &str) -> String {
147 let script = format!(r#"<script id="{data_id}" type="application/json">{json}</script>"#);
148 if let Some(pos) = html.rfind("</body>") {
149 let mut result = String::with_capacity(html.len() + script.len());
150 result.push_str(&html[..pos]);
151 result.push_str(&script);
152 result.push_str(&html[pos..]);
153 result
154 } else {
155 format!("{html}{script}")
156 }
157}
158
159pub fn inject_html_lang(html: &str, locale: &str) -> String {
161 html.replacen("<html", &format!("<html lang=\"{locale}\""), 1)
162}
163
164pub fn inject_head_meta(html: &str, meta_html: &str) -> String {
166 let charset = r#"<meta charset="utf-8">"#;
167 if let Some(pos) = html.find(charset) {
168 let insert_at = pos + charset.len();
169 let mut result = String::with_capacity(html.len() + meta_html.len());
170 result.push_str(&html[..insert_at]);
171 result.push_str(meta_html);
172 result.push_str(&html[insert_at..]);
173 result
174 } else {
175 html.to_string()
176 }
177}
178
179pub fn i18n_query(
182 keys: &[String],
183 locale: &str,
184 default_locale: &str,
185 all_messages: &serde_json::Value,
186) -> serde_json::Value {
187 let empty = serde_json::Value::Object(Default::default());
188 let target_msgs = all_messages.get(locale).unwrap_or(&empty);
189 let default_msgs = all_messages.get(default_locale).unwrap_or(&empty);
190
191 let mut messages = serde_json::Map::new();
192 for key in keys {
193 let val = target_msgs
194 .get(key)
195 .or_else(|| default_msgs.get(key))
196 .and_then(|v| v.as_str())
197 .unwrap_or(key)
198 .to_string();
199 messages.insert(key.clone(), serde_json::Value::String(val));
200 }
201 serde_json::json!({ "messages": messages })
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use serde_json::json;
208
209 #[test]
210 fn flatten_spreads_nested() {
211 let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
212 let flat = flatten_for_slots(&input);
213 assert_eq!(flat["title"], "Hello");
214 assert_eq!(flat["tagline"], "World");
215 assert_eq!(flat["other"], 42);
216 assert_eq!(flat["page"]["title"], "Hello");
217 }
218
219 #[test]
220 fn flatten_no_override() {
221 let input = json!({"title": "Top", "page": {"title": "Nested"}});
223 let flat = flatten_for_slots(&input);
224 assert_eq!(flat["title"], "Top");
225 }
226
227 #[test]
228 fn build_seam_data_no_layout() {
229 let data = json!({"title": "Hello", "count": 42});
230 let config =
231 PageConfig { layout_chain: vec![], data_id: "__SEAM_DATA__".into(), head_meta: None };
232 let result = build_seam_data(&data, &config, None);
233 assert_eq!(result["title"], "Hello");
234 assert_eq!(result["count"], 42);
235 assert!(result.get("_layouts").is_none());
236 }
237
238 #[test]
239 fn build_seam_data_single_layout() {
240 let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
241 let config = PageConfig {
242 layout_chain: vec![LayoutChainEntry {
243 id: "root".into(),
244 loader_keys: vec!["layoutKey".into()],
245 }],
246 data_id: "__SEAM_DATA__".into(),
247 head_meta: None,
248 };
249 let result = build_seam_data(&data, &config, None);
250 assert_eq!(result["pageKey"], "page_val");
251 assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
252 assert!(result.get("layoutKey").is_none());
253 }
254
255 #[test]
256 fn build_seam_data_multi_layout() {
257 let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
259 let config = PageConfig {
260 layout_chain: vec![
261 LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
262 LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
263 ],
264 data_id: "__SEAM_DATA__".into(),
265 head_meta: None,
266 };
267 let result = build_seam_data(&data, &config, None);
268 assert_eq!(result["page_data"], "p");
269 assert_eq!(result["_layouts"]["outer"]["nav"], "n");
270 assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
271 assert!(result.get("nav").is_none());
273 assert!(result.get("sidebar").is_none());
274 }
275
276 #[test]
277 fn build_seam_data_with_i18n() {
278 let data = json!({"title": "Hello"});
279 let config =
280 PageConfig { layout_chain: vec![], data_id: "__SEAM_DATA__".into(), head_meta: None };
281 let i18n = I18nOpts {
282 locale: "zh".into(),
283 default_locale: "en".into(),
284 messages: json!({"hello": "你好"}),
285 hash: None,
286 router: None,
287 };
288 let result = build_seam_data(&data, &config, Some(&i18n));
289 assert_eq!(result["_i18n"]["locale"], "zh");
290 assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
291 assert!(result["_i18n"].get("hash").is_none());
292 assert!(result["_i18n"].get("router").is_none());
293 }
294
295 #[test]
296 fn filter_messages_all() {
297 let msgs = json!({"hello": "Hello", "bye": "Bye"});
298 let filtered = filter_i18n_messages(&msgs, &[]);
299 assert_eq!(filtered, msgs);
300 }
301
302 #[test]
303 fn filter_messages_subset() {
304 let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
305 let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
306 assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
307 }
308
309 #[test]
310 fn inject_data_script_before_body() {
311 let html = "<html><body><p>Content</p></body></html>";
312 let result = inject_data_script(html, "__SEAM_DATA__", r#"{"a":1}"#);
313 assert!(result
314 .contains(r#"<script id="__SEAM_DATA__" type="application/json">{"a":1}</script></body>"#));
315 }
316
317 #[test]
318 fn inject_data_script_no_body() {
319 let html = "<html><p>Content</p></html>";
320 let result = inject_data_script(html, "__SEAM_DATA__", r#"{"a":1}"#);
321 assert!(
322 result.ends_with(r#"<script id="__SEAM_DATA__" type="application/json">{"a":1}</script>"#)
323 );
324 }
325
326 #[test]
327 fn inject_html_lang_test() {
328 let html = "<html><head></head></html>";
329 let result = inject_html_lang(html, "zh");
330 assert!(result.starts_with(r#"<html lang="zh""#));
331 }
332
333 #[test]
334 fn inject_head_meta_test() {
335 let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
336 let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
337 assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
338 }
339
340 #[test]
341 fn i18n_query_basic() {
342 let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
343 let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
344 assert_eq!(result["messages"]["hello"], "你好");
345 assert_eq!(result["messages"]["bye"], "Bye");
347 }
348
349 #[test]
350 fn i18n_query_fallback_to_default() {
351 let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
352 let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
353 assert_eq!(result["messages"]["hello"], "Hello");
354 }
355}