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/// Per-page asset references for resource splitting.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct PageAssets {
16  #[serde(default)]
17  pub styles: Vec<String>,
18  #[serde(default)]
19  pub scripts: Vec<String>,
20  #[serde(default)]
21  pub preload: Vec<String>,
22  #[serde(default)]
23  pub prefetch: Vec<String>,
24}
25
26/// Configuration for page assembly, passed as JSON.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PageConfig {
29  pub layout_chain: Vec<LayoutChainEntry>,
30  pub data_id: String,
31  #[serde(default)]
32  pub head_meta: Option<String>,
33  #[serde(default)]
34  pub page_assets: Option<PageAssets>,
35}
36
37/// i18n options for page rendering, passed as JSON.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct I18nOpts {
40  pub locale: String,
41  pub default_locale: String,
42  pub messages: serde_json::Value,
43  /// Content hash (4 hex) for cache validation
44  #[serde(default, skip_serializing_if = "Option::is_none")]
45  pub hash: Option<String>,
46  /// Full route→locale→hash table for client cache layer
47  #[serde(default, skip_serializing_if = "Option::is_none")]
48  pub router: Option<serde_json::Value>,
49}
50
51/// Flatten keyed loader results for slot resolution: spread nested object
52/// values to the top level so slots like `<!--seam:tagline-->` can resolve from
53/// data like `{page: {tagline: "..."}}`.
54pub fn flatten_for_slots(keyed: &serde_json::Value) -> serde_json::Value {
55  let Some(obj) = keyed.as_object() else {
56    return keyed.clone();
57  };
58  let mut merged = obj.clone();
59  for value in obj.values() {
60    if let serde_json::Value::Object(nested) = value {
61      for (nk, nv) in nested {
62        merged.entry(nk.clone()).or_insert_with(|| nv.clone());
63      }
64    }
65  }
66  serde_json::Value::Object(merged)
67}
68
69/// Build the data script JSON object with correct per-layout `_layouts` grouping.
70///
71/// Unlike the old single-layout-id approach, this groups data under each layout
72/// in the chain independently, matching the TS reference implementation.
73pub fn build_seam_data(
74  loader_data: &serde_json::Value,
75  config: &PageConfig,
76  i18n_opts: Option<&I18nOpts>,
77) -> serde_json::Value {
78  let Some(data_obj) = loader_data.as_object() else {
79    return loader_data.clone();
80  };
81
82  if config.layout_chain.is_empty() {
83    // No layouts: all data at top level
84    let mut result = data_obj.clone();
85    inject_i18n_data(&mut result, i18n_opts);
86    return serde_json::Value::Object(result);
87  }
88
89  // Collect all layout-claimed keys
90  let mut claimed_keys = std::collections::HashSet::new();
91  for entry in &config.layout_chain {
92    for key in &entry.loader_keys {
93      claimed_keys.insert(key.as_str());
94    }
95  }
96
97  // Page data = keys not claimed by any layout
98  let mut script_data = serde_json::Map::new();
99  for (k, v) in data_obj {
100    if !claimed_keys.contains(k.as_str()) {
101      script_data.insert(k.clone(), v.clone());
102    }
103  }
104
105  // Build per-layout _layouts grouping
106  let mut layouts_map = serde_json::Map::new();
107  for entry in &config.layout_chain {
108    let mut layout_data = serde_json::Map::new();
109    for key in &entry.loader_keys {
110      if let Some(v) = data_obj.get(key) {
111        layout_data.insert(key.clone(), v.clone());
112      }
113    }
114    if !layout_data.is_empty() {
115      layouts_map.insert(entry.id.clone(), serde_json::Value::Object(layout_data));
116    }
117  }
118  if !layouts_map.is_empty() {
119    script_data.insert("_layouts".to_string(), serde_json::Value::Object(layouts_map));
120  }
121
122  inject_i18n_data(&mut script_data, i18n_opts);
123  serde_json::Value::Object(script_data)
124}
125
126/// Inject `_i18n` data into the script data map for client hydration.
127fn inject_i18n_data(
128  script_data: &mut serde_json::Map<String, serde_json::Value>,
129  i18n_opts: Option<&I18nOpts>,
130) {
131  let Some(opts) = i18n_opts else { return };
132
133  let mut i18n_data = serde_json::Map::new();
134  i18n_data.insert("locale".into(), serde_json::Value::String(opts.locale.clone()));
135  i18n_data.insert("messages".into(), opts.messages.clone());
136  if let Some(ref h) = opts.hash {
137    i18n_data.insert("hash".into(), serde_json::Value::String(h.clone()));
138  }
139  if let Some(ref r) = opts.router {
140    i18n_data.insert("router".into(), r.clone());
141  }
142
143  script_data.insert("_i18n".into(), serde_json::Value::Object(i18n_data));
144}
145
146/// Filter i18n messages to only include keys in the allow list.
147/// Empty list means include all messages.
148pub fn filter_i18n_messages(messages: &serde_json::Value, keys: &[String]) -> serde_json::Value {
149  if keys.is_empty() {
150    return messages.clone();
151  }
152  let Some(obj) = messages.as_object() else {
153    return messages.clone();
154  };
155  let filtered: serde_json::Map<String, serde_json::Value> =
156    keys.iter().filter_map(|k| obj.get(k).map(|v| (k.clone(), v.clone()))).collect();
157  serde_json::Value::Object(filtered)
158}
159
160/// Inject a `<script>` tag with JSON data before `</body>`.
161pub fn inject_data_script(html: &str, data_id: &str, json: &str) -> String {
162  let script = format!(r#"<script id="{data_id}" type="application/json">{json}</script>"#);
163  if let Some(pos) = html.rfind("</body>") {
164    let mut result = String::with_capacity(html.len() + script.len());
165    result.push_str(&html[..pos]);
166    result.push_str(&script);
167    result.push_str(&html[pos..]);
168    result
169  } else {
170    format!("{html}{script}")
171  }
172}
173
174/// Set `<html lang="...">` attribute.
175pub fn inject_html_lang(html: &str, locale: &str) -> String {
176  html.replacen("<html", &format!("<html lang=\"{locale}\""), 1)
177}
178
179/// Inject page-level head metadata after `<meta charset="utf-8">`.
180pub fn inject_head_meta(html: &str, meta_html: &str) -> String {
181  let charset = r#"<meta charset="utf-8">"#;
182  if let Some(pos) = html.find(charset) {
183    let insert_at = pos + charset.len();
184    let mut result = String::with_capacity(html.len() + meta_html.len());
185    result.push_str(&html[..insert_at]);
186    result.push_str(meta_html);
187    result.push_str(&html[insert_at..]);
188    result
189  } else {
190    html.to_string()
191  }
192}
193
194/// Process an i18n query: look up requested keys from locale messages,
195/// with per-key fallback to default locale, then key itself.
196pub fn i18n_query(
197  keys: &[String],
198  locale: &str,
199  default_locale: &str,
200  all_messages: &serde_json::Value,
201) -> serde_json::Value {
202  let empty = serde_json::Value::Object(Default::default());
203  let target_msgs = all_messages.get(locale).unwrap_or(&empty);
204  let default_msgs = all_messages.get(default_locale).unwrap_or(&empty);
205
206  let mut messages = serde_json::Map::new();
207  for key in keys {
208    let val = target_msgs
209      .get(key)
210      .or_else(|| default_msgs.get(key))
211      .and_then(|v| v.as_str())
212      .unwrap_or(key)
213      .to_string();
214    messages.insert(key.clone(), serde_json::Value::String(val));
215  }
216  serde_json::json!({ "messages": messages })
217}
218
219#[cfg(test)]
220mod tests {
221  use super::*;
222  use serde_json::json;
223
224  #[test]
225  fn flatten_spreads_nested() {
226    let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
227    let flat = flatten_for_slots(&input);
228    assert_eq!(flat["title"], "Hello");
229    assert_eq!(flat["tagline"], "World");
230    assert_eq!(flat["other"], 42);
231    assert_eq!(flat["page"]["title"], "Hello");
232  }
233
234  #[test]
235  fn flatten_no_override() {
236    // Top-level keys should not be overridden by nested ones
237    let input = json!({"title": "Top", "page": {"title": "Nested"}});
238    let flat = flatten_for_slots(&input);
239    assert_eq!(flat["title"], "Top");
240  }
241
242  #[test]
243  fn build_seam_data_no_layout() {
244    let data = json!({"title": "Hello", "count": 42});
245    let config = PageConfig {
246      layout_chain: vec![],
247      data_id: "__data".into(),
248      head_meta: None,
249      page_assets: None,
250    };
251    let result = build_seam_data(&data, &config, None);
252    assert_eq!(result["title"], "Hello");
253    assert_eq!(result["count"], 42);
254    assert!(result.get("_layouts").is_none());
255  }
256
257  #[test]
258  fn build_seam_data_single_layout() {
259    let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
260    let config = PageConfig {
261      layout_chain: vec![LayoutChainEntry {
262        id: "root".into(),
263        loader_keys: vec!["layoutKey".into()],
264      }],
265      data_id: "__data".into(),
266      head_meta: None,
267      page_assets: None,
268    };
269    let result = build_seam_data(&data, &config, None);
270    assert_eq!(result["pageKey"], "page_val");
271    assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
272    assert!(result.get("layoutKey").is_none());
273  }
274
275  #[test]
276  fn build_seam_data_multi_layout() {
277    // Two layouts: outer claims "nav", inner claims "sidebar"
278    let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
279    let config = PageConfig {
280      layout_chain: vec![
281        LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
282        LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
283      ],
284      data_id: "__data".into(),
285      head_meta: None,
286      page_assets: None,
287    };
288    let result = build_seam_data(&data, &config, None);
289    assert_eq!(result["page_data"], "p");
290    assert_eq!(result["_layouts"]["outer"]["nav"], "n");
291    assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
292    // Page-level should not have layout keys
293    assert!(result.get("nav").is_none());
294    assert!(result.get("sidebar").is_none());
295  }
296
297  #[test]
298  fn build_seam_data_with_i18n() {
299    let data = json!({"title": "Hello"});
300    let config = PageConfig {
301      layout_chain: vec![],
302      data_id: "__data".into(),
303      head_meta: None,
304      page_assets: None,
305    };
306    let i18n = I18nOpts {
307      locale: "zh".into(),
308      default_locale: "en".into(),
309      messages: json!({"hello": "你好"}),
310      hash: None,
311      router: None,
312    };
313    let result = build_seam_data(&data, &config, Some(&i18n));
314    assert_eq!(result["_i18n"]["locale"], "zh");
315    assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
316    assert!(result["_i18n"].get("hash").is_none());
317    assert!(result["_i18n"].get("router").is_none());
318  }
319
320  #[test]
321  fn filter_messages_all() {
322    let msgs = json!({"hello": "Hello", "bye": "Bye"});
323    let filtered = filter_i18n_messages(&msgs, &[]);
324    assert_eq!(filtered, msgs);
325  }
326
327  #[test]
328  fn filter_messages_subset() {
329    let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
330    let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
331    assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
332  }
333
334  #[test]
335  fn inject_data_script_before_body() {
336    let html = "<html><body><p>Content</p></body></html>";
337    let result = inject_data_script(html, "__data", r#"{"a":1}"#);
338    assert!(
339      result.contains(r#"<script id="__data" type="application/json">{"a":1}</script></body>"#)
340    );
341  }
342
343  #[test]
344  fn inject_data_script_no_body() {
345    let html = "<html><p>Content</p></html>";
346    let result = inject_data_script(html, "__data", r#"{"a":1}"#);
347    assert!(result.ends_with(r#"<script id="__data" type="application/json">{"a":1}</script>"#));
348  }
349
350  #[test]
351  fn inject_html_lang_test() {
352    let html = "<html><head></head></html>";
353    let result = inject_html_lang(html, "zh");
354    assert!(result.starts_with(r#"<html lang="zh""#));
355  }
356
357  #[test]
358  fn inject_head_meta_test() {
359    let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
360    let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
361    assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
362  }
363
364  #[test]
365  fn i18n_query_basic() {
366    let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
367    let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
368    assert_eq!(result["messages"]["hello"], "你好");
369    // "bye" not in zh, falls back to default locale (en)
370    assert_eq!(result["messages"]["bye"], "Bye");
371  }
372
373  #[test]
374  fn i18n_query_fallback_to_default() {
375    let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
376    let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
377    assert_eq!(result["messages"]["hello"], "Hello");
378  }
379
380  #[test]
381  fn page_config_deserializes_without_page_assets() {
382    let json = r#"{"layout_chain": [], "data_id": "__data"}"#;
383    let config: PageConfig = serde_json::from_str(json).unwrap();
384    assert!(config.page_assets.is_none());
385  }
386
387  #[test]
388  fn page_config_deserializes_with_page_assets() {
389    let json = r#"{
390      "layout_chain": [],
391      "data_id": "__data",
392      "page_assets": {
393        "styles": ["page.css"],
394        "scripts": ["page.js"],
395        "preload": ["shared.js"],
396        "prefetch": ["other.js"]
397      }
398    }"#;
399    let config: PageConfig = serde_json::from_str(json).unwrap();
400    let assets = config.page_assets.unwrap();
401    assert_eq!(assets.styles, vec!["page.css"]);
402    assert_eq!(assets.scripts, vec!["page.js"]);
403    assert_eq!(assets.preload, vec!["shared.js"]);
404    assert_eq!(assets.prefetch, vec!["other.js"]);
405  }
406}