Skip to main content

seam_engine/
render.rs

1/* packages/server/engine/rust/src/render.rs */
2
3use crate::escape::ascii_escape_json;
4use crate::page::{
5  build_seam_data, flatten_for_slots, inject_data_script, inject_head_meta, inject_html_lang,
6  I18nOpts, PageConfig,
7};
8
9/// Render a page: inject data into template, assemble `__SEAM_DATA__` script,
10/// apply head metadata and locale attributes.
11///
12/// This is the single entry point that replaces ~60 lines of duplicated logic
13/// across TS, Rust, and Go backends.
14///
15/// Arguments are JSON strings for cross-language compatibility:
16/// - `template`: pre-resolved HTML template (layout chain already applied)
17/// - `loader_data_json`: `{"key": value, ...}` from all loaders (layout + page)
18/// - `config_json`: serialized `PageConfig`
19/// - `i18n_opts_json`: optional serialized `I18nOpts`
20pub fn render_page(
21  template: &str,
22  loader_data_json: &str,
23  config_json: &str,
24  i18n_opts_json: Option<&str>,
25) -> String {
26  let loader_data: serde_json::Value =
27    serde_json::from_str(loader_data_json).unwrap_or(serde_json::Value::Null);
28  let config: PageConfig = match serde_json::from_str(config_json) {
29    Ok(c) => c,
30    Err(_) => return template.to_string(),
31  };
32  let i18n_opts: Option<I18nOpts> = i18n_opts_json.and_then(|s| serde_json::from_str(s).ok());
33
34  // Step 1: Flatten loader data for slot resolution
35  let flat_data = flatten_for_slots(&loader_data);
36
37  // Step 2: Inject slots into template (no data script)
38  let mut html = seam_injector::inject_no_script(template, &flat_data);
39
40  // Step 3: Inject page-level head metadata
41  if let Some(ref meta) = config.head_meta {
42    // Inject the head_meta with slot data resolved
43    let injected_meta = seam_injector::inject_no_script(meta, &flat_data);
44    html = inject_head_meta(&html, &injected_meta);
45  }
46
47  // Step 4: Set <html lang="..."> when locale is known
48  if let Some(ref opts) = i18n_opts {
49    html = inject_html_lang(&html, &opts.locale);
50  }
51
52  // Step 5: Build __SEAM_DATA__ JSON and inject script
53  let seam_data = build_seam_data(&loader_data, &config, i18n_opts.as_ref());
54  let json = serde_json::to_string(&seam_data).unwrap_or_default();
55  let escaped = ascii_escape_json(&json);
56  inject_data_script(&html, &config.data_id, &escaped)
57}
58
59#[cfg(test)]
60mod tests {
61  use super::*;
62  use serde_json::json;
63
64  fn simple_template() -> String {
65    r#"<html><head><meta charset="utf-8"><title>Test</title></head><body><p><!--seam:title--></p></body></html>"#.to_string()
66  }
67
68  #[test]
69  fn render_basic_page() {
70    let template = simple_template();
71    let data = json!({"title": "Hello"}).to_string();
72    let config = json!({"layout_chain": [], "data_id": "__SEAM_DATA__"}).to_string();
73
74    let result = render_page(&template, &data, &config, None);
75    assert!(result.contains("<p>Hello</p>"));
76    assert!(result.contains(r#"<script id="__SEAM_DATA__""#));
77    assert!(result.contains(r#""title":"Hello""#));
78  }
79
80  #[test]
81  fn render_with_layout() {
82    let template = simple_template();
83    let data = json!({"title": "Page", "nav": "NavData"}).to_string();
84    let config = json!({
85      "layout_chain": [{"id": "root", "loader_keys": ["nav"]}],
86      "data_id": "__SEAM_DATA__"
87    })
88    .to_string();
89
90    let result = render_page(&template, &data, &config, None);
91    // nav should be under _layouts.root, not at top level
92    assert!(result.contains(r#""_layouts""#), "missing _layouts key");
93    assert!(result.contains(r#""root""#), "missing root layout key");
94    // Page data should be at top level
95    assert!(result.contains(r#""title":"Page""#), "missing page-level title");
96  }
97
98  #[test]
99  fn render_with_i18n() {
100    let template = simple_template();
101    let data = json!({"title": "Hello"}).to_string();
102    let config = json!({"layout_chain": [], "data_id": "__SEAM_DATA__"}).to_string();
103    let i18n = json!({
104      "locale": "zh",
105      "default_locale": "en",
106      "messages": {"hello": "你好"}
107    })
108    .to_string();
109
110    let result = render_page(&template, &data, &config, Some(&i18n));
111    assert!(result.contains(r#"<html lang="zh""#));
112    assert!(result.contains(r#""_i18n""#));
113  }
114
115  #[test]
116  fn render_with_head_meta() {
117    let template = simple_template();
118    let data = json!({"title": "Hello"}).to_string();
119    let config = json!({
120      "layout_chain": [],
121      "data_id": "__SEAM_DATA__",
122      "head_meta": r#"<title><!--seam:title--></title>"#
123    })
124    .to_string();
125
126    let result = render_page(&template, &data, &config, None);
127    // head_meta should be injected after <meta charset="utf-8">
128    assert!(result.contains(r#"<meta charset="utf-8"><title>Hello</title>"#));
129  }
130
131  #[test]
132  fn render_invalid_config_returns_template() {
133    let template = "plain html";
134    let result = render_page(template, "{}", "invalid json", None);
135    assert_eq!(result, "plain html");
136  }
137}