Skip to main content

seam_engine/
page.rs

1/* packages/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 `__SEAM_DATA__` 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 =
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    // Two layouts: outer claims "nav", inner claims "sidebar"
258    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    // Page-level should not have layout keys
272    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    // "bye" not in zh, falls back to default locale (en)
346    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}