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/// Generate `<link rel="stylesheet">` tags for page-specific CSS.
220pub fn generate_style_tags(styles: &[String]) -> String {
221  let mut out = String::new();
222  for f in styles {
223    out.push_str(&format!(r#"<link rel="stylesheet" href="/_seam/static/{f}">"#));
224  }
225  out
226}
227
228/// Generate `<link rel="modulepreload">` and `<script type="module">` tags for page JS.
229pub fn generate_script_tags(scripts: &[String], preloads: &[String]) -> String {
230  let mut out = String::new();
231  for f in preloads {
232    out.push_str(&format!(r#"<link rel="modulepreload" href="/_seam/static/{f}">"#));
233  }
234  for f in scripts {
235    out.push_str(&format!(r#"<script type="module" src="/_seam/static/{f}"></script>"#));
236  }
237  out
238}
239
240/// Generate `<link rel="prefetch">` tags for other pages' assets (idle prefetch).
241pub fn generate_prefetch_tags(prefetch: &[String]) -> String {
242  let mut out = String::new();
243  for f in prefetch {
244    let as_attr = if f.ends_with(".css") { "style" } else { "script" };
245    out.push_str(&format!(r#"<link rel="prefetch" href="/_seam/static/{f}" as="{as_attr}">"#));
246  }
247  out
248}
249
250/// Strip asset slot markers from template (replace with empty string).
251/// Used when page_assets is not configured to prevent the injector
252/// from treating these markers as data slots.
253pub fn strip_asset_slots(template: &str) -> String {
254  template
255    .replace("<!--seam:page-styles-->", "")
256    .replace("<!--seam:page-scripts-->", "")
257    .replace("<!--seam:prefetch-->", "")
258}
259
260/// Replace asset slot markers in template with actual tags.
261pub fn replace_asset_slots(template: &str, assets: &PageAssets) -> String {
262  template
263    .replace("<!--seam:page-styles-->", &generate_style_tags(&assets.styles))
264    .replace("<!--seam:page-scripts-->", &generate_script_tags(&assets.scripts, &assets.preload))
265    .replace("<!--seam:prefetch-->", &generate_prefetch_tags(&assets.prefetch))
266}
267
268#[cfg(test)]
269mod tests {
270  use super::*;
271  use serde_json::json;
272
273  #[test]
274  fn flatten_spreads_nested() {
275    let input = json!({"page": {"title": "Hello", "tagline": "World"}, "other": 42});
276    let flat = flatten_for_slots(&input);
277    assert_eq!(flat["title"], "Hello");
278    assert_eq!(flat["tagline"], "World");
279    assert_eq!(flat["other"], 42);
280    assert_eq!(flat["page"]["title"], "Hello");
281  }
282
283  #[test]
284  fn flatten_no_override() {
285    // Top-level keys should not be overridden by nested ones
286    let input = json!({"title": "Top", "page": {"title": "Nested"}});
287    let flat = flatten_for_slots(&input);
288    assert_eq!(flat["title"], "Top");
289  }
290
291  #[test]
292  fn build_seam_data_no_layout() {
293    let data = json!({"title": "Hello", "count": 42});
294    let config = PageConfig {
295      layout_chain: vec![],
296      data_id: "__data".into(),
297      head_meta: None,
298      page_assets: None,
299    };
300    let result = build_seam_data(&data, &config, None);
301    assert_eq!(result["title"], "Hello");
302    assert_eq!(result["count"], 42);
303    assert!(result.get("_layouts").is_none());
304  }
305
306  #[test]
307  fn build_seam_data_single_layout() {
308    let data = json!({"pageKey": "page_val", "layoutKey": "layout_val"});
309    let config = PageConfig {
310      layout_chain: vec![LayoutChainEntry {
311        id: "root".into(),
312        loader_keys: vec!["layoutKey".into()],
313      }],
314      data_id: "__data".into(),
315      head_meta: None,
316      page_assets: None,
317    };
318    let result = build_seam_data(&data, &config, None);
319    assert_eq!(result["pageKey"], "page_val");
320    assert_eq!(result["_layouts"]["root"]["layoutKey"], "layout_val");
321    assert!(result.get("layoutKey").is_none());
322  }
323
324  #[test]
325  fn build_seam_data_multi_layout() {
326    // Two layouts: outer claims "nav", inner claims "sidebar"
327    let data = json!({"page_data": "p", "nav": "n", "sidebar": "s"});
328    let config = PageConfig {
329      layout_chain: vec![
330        LayoutChainEntry { id: "outer".into(), loader_keys: vec!["nav".into()] },
331        LayoutChainEntry { id: "inner".into(), loader_keys: vec!["sidebar".into()] },
332      ],
333      data_id: "__data".into(),
334      head_meta: None,
335      page_assets: None,
336    };
337    let result = build_seam_data(&data, &config, None);
338    assert_eq!(result["page_data"], "p");
339    assert_eq!(result["_layouts"]["outer"]["nav"], "n");
340    assert_eq!(result["_layouts"]["inner"]["sidebar"], "s");
341    // Page-level should not have layout keys
342    assert!(result.get("nav").is_none());
343    assert!(result.get("sidebar").is_none());
344  }
345
346  #[test]
347  fn build_seam_data_with_i18n() {
348    let data = json!({"title": "Hello"});
349    let config = PageConfig {
350      layout_chain: vec![],
351      data_id: "__data".into(),
352      head_meta: None,
353      page_assets: None,
354    };
355    let i18n = I18nOpts {
356      locale: "zh".into(),
357      default_locale: "en".into(),
358      messages: json!({"hello": "你好"}),
359      hash: None,
360      router: None,
361    };
362    let result = build_seam_data(&data, &config, Some(&i18n));
363    assert_eq!(result["_i18n"]["locale"], "zh");
364    assert_eq!(result["_i18n"]["messages"]["hello"], "你好");
365    assert!(result["_i18n"].get("hash").is_none());
366    assert!(result["_i18n"].get("router").is_none());
367  }
368
369  #[test]
370  fn filter_messages_all() {
371    let msgs = json!({"hello": "Hello", "bye": "Bye"});
372    let filtered = filter_i18n_messages(&msgs, &[]);
373    assert_eq!(filtered, msgs);
374  }
375
376  #[test]
377  fn filter_messages_subset() {
378    let msgs = json!({"hello": "Hello", "bye": "Bye", "ok": "OK"});
379    let filtered = filter_i18n_messages(&msgs, &["hello".into(), "ok".into()]);
380    assert_eq!(filtered, json!({"hello": "Hello", "ok": "OK"}));
381  }
382
383  #[test]
384  fn inject_data_script_before_body() {
385    let html = "<html><body><p>Content</p></body></html>";
386    let result = inject_data_script(html, "__data", r#"{"a":1}"#);
387    assert!(
388      result.contains(r#"<script id="__data" type="application/json">{"a":1}</script></body>"#)
389    );
390  }
391
392  #[test]
393  fn inject_data_script_no_body() {
394    let html = "<html><p>Content</p></html>";
395    let result = inject_data_script(html, "__data", r#"{"a":1}"#);
396    assert!(result.ends_with(r#"<script id="__data" type="application/json">{"a":1}</script>"#));
397  }
398
399  #[test]
400  fn inject_html_lang_test() {
401    let html = "<html><head></head></html>";
402    let result = inject_html_lang(html, "zh");
403    assert!(result.starts_with(r#"<html lang="zh""#));
404  }
405
406  #[test]
407  fn inject_head_meta_test() {
408    let html = r#"<html><head><meta charset="utf-8"><title>Test</title></head></html>"#;
409    let result = inject_head_meta(html, r#"<meta name="desc" content="x">"#);
410    assert!(result.contains(r#"<meta charset="utf-8"><meta name="desc" content="x"><title>"#));
411  }
412
413  #[test]
414  fn i18n_query_basic() {
415    let msgs = json!({"en": {"hello": "Hello", "bye": "Bye"}, "zh": {"hello": "你好"}});
416    let result = i18n_query(&["hello".into(), "bye".into()], "zh", "en", &msgs);
417    assert_eq!(result["messages"]["hello"], "你好");
418    // "bye" not in zh, falls back to default locale (en)
419    assert_eq!(result["messages"]["bye"], "Bye");
420  }
421
422  #[test]
423  fn i18n_query_fallback_to_default() {
424    let msgs = json!({"en": {"hello": "Hello"}, "zh": {}});
425    let result = i18n_query(&["hello".into()], "fr", "en", &msgs);
426    assert_eq!(result["messages"]["hello"], "Hello");
427  }
428
429  #[test]
430  fn generate_style_tags_output() {
431    let tags = generate_style_tags(&["page-home.css".into()]);
432    assert_eq!(tags, r#"<link rel="stylesheet" href="/_seam/static/page-home.css">"#);
433  }
434
435  #[test]
436  fn generate_script_tags_output() {
437    let tags = generate_script_tags(&["page-home.js".into()], &["shared.js".into()]);
438    assert!(tags.contains(r#"<link rel="modulepreload" href="/_seam/static/shared.js">"#));
439    assert!(tags.contains(r#"<script type="module" src="/_seam/static/page-home.js"></script>"#));
440    // modulepreload should come before script
441    let preload_pos = tags.find("modulepreload").unwrap();
442    let script_pos = tags.find("type=\"module\"").unwrap();
443    assert!(preload_pos < script_pos);
444  }
445
446  #[test]
447  fn generate_prefetch_tags_output() {
448    let tags = generate_prefetch_tags(&["other.js".into(), "other.css".into()]);
449    assert!(tags.contains(r#"as="script""#));
450    assert!(tags.contains(r#"as="style""#));
451  }
452
453  #[test]
454  fn replace_asset_slots_test() {
455    let template = concat!(
456      "<head><!--seam:page-styles--><!--seam:prefetch--></head>",
457      "<body><!--seam:page-scripts--></body>"
458    );
459    let assets = PageAssets {
460      styles: vec!["page.css".into()],
461      scripts: vec!["page.js".into()],
462      preload: vec!["shared.js".into()],
463      prefetch: vec!["other.js".into()],
464    };
465    let result = replace_asset_slots(template, &assets);
466    assert!(result.contains(r#"href="/_seam/static/page.css""#));
467    assert!(result.contains(r#"src="/_seam/static/page.js""#));
468    assert!(result.contains(r#"modulepreload"#));
469    assert!(result.contains(r#"prefetch"#));
470    assert!(!result.contains("<!--seam:page-styles-->"));
471    assert!(!result.contains("<!--seam:page-scripts-->"));
472    assert!(!result.contains("<!--seam:prefetch-->"));
473  }
474
475  #[test]
476  fn page_config_deserializes_without_page_assets() {
477    let json = r#"{"layout_chain": [], "data_id": "__data"}"#;
478    let config: PageConfig = serde_json::from_str(json).unwrap();
479    assert!(config.page_assets.is_none());
480  }
481
482  #[test]
483  fn page_config_deserializes_with_page_assets() {
484    let json = r#"{
485      "layout_chain": [],
486      "data_id": "__data",
487      "page_assets": {
488        "styles": ["page.css"],
489        "scripts": ["page.js"],
490        "preload": ["shared.js"],
491        "prefetch": ["other.js"]
492      }
493    }"#;
494    let config: PageConfig = serde_json::from_str(json).unwrap();
495    let assets = config.page_assets.unwrap();
496    assert_eq!(assets.styles, vec!["page.css"]);
497    assert_eq!(assets.scripts, vec!["page.js"]);
498    assert_eq!(assets.preload, vec!["shared.js"]);
499    assert_eq!(assets.prefetch, vec!["other.js"]);
500  }
501}