Skip to main content

seam_engine/
page.rs

1/* src/server/engine/rust/src/page.rs */
2
3use serde::{Deserialize, Serialize};
4
5/// One entry in a layout chain (outer to inner order).
6/// Each layout owns a set of loader data keys.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct LayoutChainEntry {
9  pub id: String,
10  pub loader_keys: Vec<String>,
11}
12
13/// Configuration for page assembly, passed as JSON.
14#[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/// i18n options for page rendering, passed as JSON.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct I18nOpts {
25  pub locale: String,
26  pub default_locale: String,
27  pub messages: serde_json::Value,
28  /// Content hash (4 hex) for cache validation
29  #[serde(default, skip_serializing_if = "Option::is_none")]
30  pub hash: Option<String>,
31  /// Full route→locale→hash table for client cache layer
32  #[serde(default, skip_serializing_if = "Option::is_none")]
33  pub router: Option<serde_json::Value>,
34}
35
36/// Flatten keyed loader results for slot resolution: spread nested object
37/// values to the top level so slots like `<!--seam:tagline-->` can resolve from
38/// data like `{page: {tagline: "..."}}`.
39pub 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
54/// Build the data script JSON object with correct per-layout `_layouts` grouping.
55///
56/// Unlike the old single-layout-id approach, this groups data under each layout
57/// in the chain independently, matching the TS reference implementation.
58pub 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    // No layouts: all data at top level
69    let mut result = data_obj.clone();
70    inject_i18n_data(&mut result, i18n_opts);
71    return serde_json::Value::Object(result);
72  }
73
74  // Collect all layout-claimed keys
75  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  // Page data = keys not claimed by any layout
83  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  // Build per-layout _layouts grouping
91  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
111/// Inject `_i18n` data into the script data map for client hydration.
112fn 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
131/// Filter i18n messages to only include keys in the allow list.
132/// Empty list means include all messages.
133pub 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
145/// Inject a `<script>` tag with JSON data before `</body>`.
146pub 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
159/// Set `<html lang="...">` attribute.
160pub fn inject_html_lang(html: &str, locale: &str) -> String {
161  html.replacen("<html", &format!("<html lang=\"{locale}\""), 1)
162}
163
164/// Inject page-level head metadata after `<meta charset="utf-8">`.
165pub 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
179/// Process an i18n query: look up requested keys from locale messages,
180/// with per-key fallback to default locale, then key itself.
181pub 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    // Top-level keys should not be overridden by nested ones
222    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 = PageConfig { layout_chain: vec![], data_id: "__data".into(), head_meta: None };
231    let result = build_seam_data(&data, &config, None);
232    assert_eq!(result["title"], "Hello");
233    assert_eq!(result["count"], 42);
234    assert!(result.get("_layouts").is_none());
235  }
236
237  #[test]
238  fn build_seam_data_single_layout() {
239    let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
240    let config = PageConfig {
241      layout_chain: vec![LayoutChainEntry {
242        id: "root".into(),
243        loader_keys: vec!["layoutKey".into()],
244      }],
245      data_id: "__data".into(),
246      head_meta: None,
247    };
248    let result = build_seam_data(&data, &config, None);
249    assert_eq!(result["pageKey"], "page_val");
250    assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
251    assert!(result.get("layoutKey").is_none());
252  }
253
254  #[test]
255  fn build_seam_data_multi_layout() {
256    // Two layouts: outer claims "nav", inner claims "sidebar"
257    let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
258    let config = PageConfig {
259      layout_chain: vec![
260        LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
261        LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
262      ],
263      data_id: "__data".into(),
264      head_meta: None,
265    };
266    let result = build_seam_data(&data, &config, None);
267    assert_eq!(result["page_data"], "p");
268    assert_eq!(result["_layouts"]["outer"]["nav"], "n");
269    assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
270    // Page-level should not have layout keys
271    assert!(result.get("nav").is_none());
272    assert!(result.get("sidebar").is_none());
273  }
274
275  #[test]
276  fn build_seam_data_with_i18n() {
277    let data = json!({"title": "Hello"});
278    let config = PageConfig { layout_chain: vec![], data_id: "__data".into(), head_meta: None };
279    let i18n = I18nOpts {
280      locale: "zh".into(),
281      default_locale: "en".into(),
282      messages: json!({"hello": "你好"}),
283      hash: None,
284      router: None,
285    };
286    let result = build_seam_data(&data, &config, Some(&i18n));
287    assert_eq!(result["_i18n"]["locale"], "zh");
288    assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
289    assert!(result["_i18n"].get("hash").is_none());
290    assert!(result["_i18n"].get("router").is_none());
291  }
292
293  #[test]
294  fn filter_messages_all() {
295    let msgs = json!({"hello": "Hello", "bye": "Bye"});
296    let filtered = filter_i18n_messages(&msgs, &[]);
297    assert_eq!(filtered, msgs);
298  }
299
300  #[test]
301  fn filter_messages_subset() {
302    let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
303    let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
304    assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
305  }
306
307  #[test]
308  fn inject_data_script_before_body() {
309    let html = "<html><body><p>Content</p></body></html>";
310    let result = inject_data_script(html, "__data", r#"{"a":1}"#);
311    assert!(
312      result.contains(r#"<script id="__data" type="application/json">{"a":1}</script></body>"#)
313    );
314  }
315
316  #[test]
317  fn inject_data_script_no_body() {
318    let html = "<html><p>Content</p></html>";
319    let result = inject_data_script(html, "__data", r#"{"a":1}"#);
320    assert!(result.ends_with(r#"<script id="__data" type="application/json">{"a":1}</script>"#));
321  }
322
323  #[test]
324  fn inject_html_lang_test() {
325    let html = "<html><head></head></html>";
326    let result = inject_html_lang(html, "zh");
327    assert!(result.starts_with(r#"<html lang="zh""#));
328  }
329
330  #[test]
331  fn inject_head_meta_test() {
332    let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
333    let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
334    assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
335  }
336
337  #[test]
338  fn i18n_query_basic() {
339    let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
340    let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
341    assert_eq!(result["messages"]["hello"], "你好");
342    // "bye" not in zh, falls back to default locale (en)
343    assert_eq!(result["messages"]["bye"], "Bye");
344  }
345
346  #[test]
347  fn i18n_query_fallback_to_default() {
348    let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
349    let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
350    assert_eq!(result["messages"]["hello"], "Hello");
351  }
352}