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)]
29 pub fallback_messages: Option<serde_json::Value>,
30 #[serde(default)]
31 pub versions: Option<serde_json::Map<String, serde_json::Value>>,
32}
33
34pub fn flatten_for_slots(keyed: &serde_json::Value) -> serde_json::Value {
38 let Some(obj) = keyed.as_object() else {
39 return keyed.clone();
40 };
41 let mut merged = obj.clone();
42 for value in obj.values() {
43 if let serde_json::Value::Object(nested) = value {
44 for (nk, nv) in nested {
45 merged.entry(nk.clone()).or_insert_with(|| nv.clone());
46 }
47 }
48 }
49 serde_json::Value::Object(merged)
50}
51
52pub fn build_seam_data(
57 loader_data: &serde_json::Value,
58 config: &PageConfig,
59 i18n_opts: Option<&I18nOpts>,
60) -> serde_json::Value {
61 let Some(data_obj) = loader_data.as_object() else {
62 return loader_data.clone();
63 };
64
65 if config.layout_chain.is_empty() {
66 let mut result = data_obj.clone();
68 inject_i18n_data(&mut result, i18n_opts);
69 return serde_json::Value::Object(result);
70 }
71
72 let mut claimed_keys = std::collections::HashSet::new();
74 for entry in &config.layout_chain {
75 for key in &entry.loader_keys {
76 claimed_keys.insert(key.as_str());
77 }
78 }
79
80 let mut script_data = serde_json::Map::new();
82 for (k, v) in data_obj {
83 if !claimed_keys.contains(k.as_str()) {
84 script_data.insert(k.clone(), v.clone());
85 }
86 }
87
88 let mut layouts_map = serde_json::Map::new();
90 for entry in &config.layout_chain {
91 let mut layout_data = serde_json::Map::new();
92 for key in &entry.loader_keys {
93 if let Some(v) = data_obj.get(key) {
94 layout_data.insert(key.clone(), v.clone());
95 }
96 }
97 if !layout_data.is_empty() {
98 layouts_map.insert(entry.id.clone(), serde_json::Value::Object(layout_data));
99 }
100 }
101 if !layouts_map.is_empty() {
102 script_data.insert("_layouts".to_string(), serde_json::Value::Object(layouts_map));
103 }
104
105 inject_i18n_data(&mut script_data, i18n_opts);
106 serde_json::Value::Object(script_data)
107}
108
109fn inject_i18n_data(
111 script_data: &mut serde_json::Map<String, serde_json::Value>,
112 i18n_opts: Option<&I18nOpts>,
113) {
114 let Some(opts) = i18n_opts else { return };
115
116 let mut i18n_data = serde_json::Map::new();
117 i18n_data.insert("locale".into(), serde_json::Value::String(opts.locale.clone()));
118 i18n_data.insert("messages".into(), opts.messages.clone());
119
120 if let Some(ref fallback) = opts.fallback_messages {
121 i18n_data.insert("fallbackMessages".into(), fallback.clone());
122 }
123
124 if let Some(ref versions) = opts.versions {
125 i18n_data.insert("versions".into(), serde_json::Value::Object(versions.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 msgs =
189 all_messages.get(locale).or_else(|| 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 = msgs.get(key).and_then(|v| v.as_str()).unwrap_or(key).to_string();
194 messages.insert(key.clone(), serde_json::Value::String(val));
195 }
196 serde_json::json!({ "messages": messages })
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde_json::json;
203
204 #[test]
205 fn flatten_spreads_nested() {
206 let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
207 let flat = flatten_for_slots(&input);
208 assert_eq!(flat["title"], "Hello");
209 assert_eq!(flat["tagline"], "World");
210 assert_eq!(flat["other"], 42);
211 assert_eq!(flat["page"]["title"], "Hello");
212 }
213
214 #[test]
215 fn flatten_no_override() {
216 let input = json!({"title": "Top", "page": {"title": "Nested"}});
218 let flat = flatten_for_slots(&input);
219 assert_eq!(flat["title"], "Top");
220 }
221
222 #[test]
223 fn build_seam_data_no_layout() {
224 let data = json!({"title": "Hello", "count": 42});
225 let config =
226 PageConfig { layout_chain: vec![], data_id: "__SEAM_DATA__".into(), head_meta: None };
227 let result = build_seam_data(&data, &config, None);
228 assert_eq!(result["title"], "Hello");
229 assert_eq!(result["count"], 42);
230 assert!(result.get("_layouts").is_none());
231 }
232
233 #[test]
234 fn build_seam_data_single_layout() {
235 let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
236 let config = PageConfig {
237 layout_chain: vec![LayoutChainEntry {
238 id: "root".into(),
239 loader_keys: vec!["layoutKey".into()],
240 }],
241 data_id: "__SEAM_DATA__".into(),
242 head_meta: None,
243 };
244 let result = build_seam_data(&data, &config, None);
245 assert_eq!(result["pageKey"], "page_val");
246 assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
247 assert!(result.get("layoutKey").is_none());
248 }
249
250 #[test]
251 fn build_seam_data_multi_layout() {
252 let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
254 let config = PageConfig {
255 layout_chain: vec![
256 LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
257 LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
258 ],
259 data_id: "__SEAM_DATA__".into(),
260 head_meta: None,
261 };
262 let result = build_seam_data(&data, &config, None);
263 assert_eq!(result["page_data"], "p");
264 assert_eq!(result["_layouts"]["outer"]["nav"], "n");
265 assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
266 assert!(result.get("nav").is_none());
268 assert!(result.get("sidebar").is_none());
269 }
270
271 #[test]
272 fn build_seam_data_with_i18n() {
273 let data = json!({"title": "Hello"});
274 let config =
275 PageConfig { layout_chain: vec![], data_id: "__SEAM_DATA__".into(), head_meta: None };
276 let i18n = I18nOpts {
277 locale: "zh".into(),
278 default_locale: "en".into(),
279 messages: json!({"hello": "你好"}),
280 fallback_messages: Some(json!({"hello": "Hello"})),
281 versions: None,
282 };
283 let result = build_seam_data(&data, &config, Some(&i18n));
284 assert_eq!(result["_i18n"]["locale"], "zh");
285 assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
286 assert_eq!(result["_i18n"]["fallbackMessages"]["hello"], "Hello");
287 }
288
289 #[test]
290 fn filter_messages_all() {
291 let msgs = json!({"hello": "Hello", "bye": "Bye"});
292 let filtered = filter_i18n_messages(&msgs, &[]);
293 assert_eq!(filtered, msgs);
294 }
295
296 #[test]
297 fn filter_messages_subset() {
298 let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
299 let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
300 assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
301 }
302
303 #[test]
304 fn inject_data_script_before_body() {
305 let html = "<html><body><p>Content</p></body></html>";
306 let result = inject_data_script(html, "__SEAM_DATA__", r#"{"a":1}"#);
307 assert!(result
308 .contains(r#"<script id="__SEAM_DATA__" type="application/json">{"a":1}</script></body>"#));
309 }
310
311 #[test]
312 fn inject_data_script_no_body() {
313 let html = "<html><p>Content</p></html>";
314 let result = inject_data_script(html, "__SEAM_DATA__", r#"{"a":1}"#);
315 assert!(
316 result.ends_with(r#"<script id="__SEAM_DATA__" type="application/json">{"a":1}</script>"#)
317 );
318 }
319
320 #[test]
321 fn inject_html_lang_test() {
322 let html = "<html><head></head></html>";
323 let result = inject_html_lang(html, "zh");
324 assert!(result.starts_with(r#"<html lang="zh""#));
325 }
326
327 #[test]
328 fn inject_head_meta_test() {
329 let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
330 let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
331 assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
332 }
333
334 #[test]
335 fn i18n_query_basic() {
336 let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
337 let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
338 assert_eq!(result["messages"]["hello"], "你好");
339 assert_eq!(result["messages"]["bye"], "bye");
341 }
342
343 #[test]
344 fn i18n_query_fallback_to_default() {
345 let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
346 let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
347 assert_eq!(result["messages"]["hello"], "Hello");
348 }
349}