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  #[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
34/// Flatten keyed loader results for slot resolution: spread nested object
35/// values to the top level so slots like `<!--seam:tagline-->` can resolve from
36/// data like `{page: {tagline: "..."}}`.
37pub 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
52/// Build the `__SEAM_DATA__` JSON object with correct per-layout `_layouts` grouping.
53///
54/// Unlike the old single-layout-id approach, this groups data under each layout
55/// in the chain independently, matching the TS reference implementation.
56pub 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    // No layouts: all data at top level
67    let mut result = data_obj.clone();
68    inject_i18n_data(&mut result, i18n_opts);
69    return serde_json::Value::Object(result);
70  }
71
72  // Collect all layout-claimed keys
73  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  // Page data = keys not claimed by any layout
81  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  // Build per-layout _layouts grouping
89  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
109/// Inject `_i18n` data into the script data map for client hydration.
110fn 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
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/// falling back to key itself when missing.
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 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    // Top-level keys should not be overridden by nested ones
217    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    // Two layouts: outer claims "nav", inner claims "sidebar"
253    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    // Page-level should not have layout keys
267    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    // "bye" not in zh, falls back to key itself
340    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}