Skip to main content

seam_engine/
render.rs

1/* src/server/engine/rust/src/render.rs */
2
3use crate::escape::ascii_escape_json;
4use crate::page::{
5  I18nOpts, PageConfig, build_seam_data, flatten_for_slots, inject_data_script, inject_head_meta,
6  inject_html_lang, replace_asset_slots, strip_asset_slots,
7};
8
9/// Render a page: inject data into template, assemble 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: Replace asset slot markers before injector sees them.
35  // When page_assets is present, replace with actual tags.
36  // When absent, strip markers (empty replacement) to prevent injector
37  // from treating them as data slots.
38  let working = match config.page_assets {
39    Some(ref assets) => replace_asset_slots(template, assets),
40    None => strip_asset_slots(template),
41  };
42
43  // Step 2: Flatten loader data for slot resolution
44  let flat_data = flatten_for_slots(&loader_data);
45
46  // Step 3: Inject slots into template (no data script)
47  let mut html = seam_injector::inject_no_script(&working, &flat_data);
48
49  // Step 4: Inject page-level head metadata
50  if let Some(ref meta) = config.head_meta {
51    // Inject the head_meta with slot data resolved
52    let injected_meta = seam_injector::inject_no_script(meta, &flat_data);
53    html = inject_head_meta(&html, &injected_meta);
54  }
55
56  // Step 5: Set <html lang="..."> when locale is known
57  if let Some(ref opts) = i18n_opts {
58    html = inject_html_lang(&html, &opts.locale);
59  }
60
61  // Step 6: Build data JSON and inject script
62  let seam_data = build_seam_data(&loader_data, &config, i18n_opts.as_ref());
63  let json = serde_json::to_string(&seam_data).unwrap_or_default();
64  let escaped = ascii_escape_json(&json);
65  inject_data_script(&html, &config.data_id, &escaped)
66}
67
68#[cfg(test)]
69mod tests {
70  use super::*;
71  use serde_json::json;
72
73  fn simple_template() -> String {
74    r#"<html><head><meta charset="utf-8"><title>Test</title></head><body><p><!--seam:title--></p></body></html>"#.to_string()
75  }
76
77  #[test]
78  fn render_basic_page() {
79    let template = simple_template();
80    let data = json!({"title": "Hello"}).to_string();
81    let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
82
83    let result = render_page(&template, &data, &config, None);
84    assert!(result.contains("<p>Hello</p>"));
85    assert!(result.contains(r#"<script id="__data""#));
86    assert!(result.contains(r#""title":"Hello""#));
87  }
88
89  #[test]
90  fn render_with_layout() {
91    let template = simple_template();
92    let data = json!({"title": "Page", "nav": "NavData"}).to_string();
93    let config = json!({
94      "layout_chain": [{"id": "root", "loader_keys": ["nav"]}],
95      "data_id": "__data"
96    })
97    .to_string();
98
99    let result = render_page(&template, &data, &config, None);
100    // nav should be under _layouts.root, not at top level
101    assert!(result.contains(r#""_layouts""#), "missing _layouts key");
102    assert!(result.contains(r#""root""#), "missing root layout key");
103    // Page data should be at top level
104    assert!(result.contains(r#""title":"Page""#), "missing page-level title");
105  }
106
107  #[test]
108  fn render_with_i18n() {
109    let template = simple_template();
110    let data = json!({"title": "Hello"}).to_string();
111    let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
112    let i18n = json!({
113      "locale": "zh",
114      "default_locale": "en",
115      "messages": {"hello": "你好"}
116    })
117    .to_string();
118
119    let result = render_page(&template, &data, &config, Some(&i18n));
120    assert!(result.contains(r#"<html lang="zh""#));
121    assert!(result.contains(r#""_i18n""#));
122  }
123
124  #[test]
125  fn render_with_head_meta() {
126    let template = simple_template();
127    let data = json!({"title": "Hello"}).to_string();
128    let config = json!({
129      "layout_chain": [],
130      "data_id": "__data",
131      "head_meta": r#"<title><!--seam:title--></title>"#
132    })
133    .to_string();
134
135    let result = render_page(&template, &data, &config, None);
136    // head_meta should be injected after <meta charset="utf-8">
137    assert!(result.contains(r#"<meta charset="utf-8"><title>Hello</title>"#));
138  }
139
140  #[test]
141  fn render_invalid_config_returns_template() {
142    let template = "plain html";
143    let result = render_page(template, "{}", "invalid json", None);
144    assert_eq!(result, "plain html");
145  }
146
147  #[test]
148  fn render_with_page_assets() {
149    let template = concat!(
150      r#"<html><head><meta charset="utf-8">"#,
151      r#"<link rel="stylesheet" href="/_seam/static/main.css">"#,
152      "<!--seam:page-styles--><!--seam:prefetch-->",
153      "</head><body>",
154      r#"<div id="__seam"><p><!--seam:title--></p></div>"#,
155      r#"<script type="module" src="/_seam/static/main.js"></script>"#,
156      "<!--seam:page-scripts-->",
157      "</body></html>"
158    );
159    let data = json!({"title": "Hello"}).to_string();
160    let config = json!({
161      "layout_chain": [],
162      "data_id": "__data",
163      "page_assets": {
164        "styles": ["page-home.css"],
165        "scripts": ["page-home.js"],
166        "preload": ["shared.js"],
167        "prefetch": ["page-other.js"]
168      }
169    })
170    .to_string();
171
172    let result = render_page(template, &data, &config, None);
173
174    // Asset slots replaced
175    assert!(result.contains(r#"href="/_seam/static/page-home.css""#));
176    assert!(result.contains(r#"src="/_seam/static/page-home.js""#));
177    assert!(result.contains(r#"modulepreload"#));
178    assert!(result.contains(r#"prefetch"#));
179    // Slot markers gone
180    assert!(!result.contains("<!--seam:page-styles-->"));
181    assert!(!result.contains("<!--seam:page-scripts-->"));
182    assert!(!result.contains("<!--seam:prefetch-->"));
183    // Data injection still works
184    assert!(result.contains("<p>Hello</p>"));
185    assert!(result.contains(r#"<script id="__data""#));
186  }
187
188  #[test]
189  fn render_without_page_assets_strips_slots() {
190    let template = concat!(
191      r#"<html><head><meta charset="utf-8">"#,
192      "<!--seam:page-styles--><!--seam:prefetch-->",
193      "</head><body>",
194      r#"<div id="__seam"><p><!--seam:title--></p></div>"#,
195      "<!--seam:page-scripts-->",
196      "</body></html>"
197    );
198    let data = json!({"title": "Hello"}).to_string();
199    let config = json!({"layout_chain": [], "data_id": "__data"}).to_string();
200
201    let result = render_page(template, &data, &config, None);
202
203    // Slot markers stripped before injector (prevents misinterpretation)
204    assert!(!result.contains("<!--seam:page-styles-->"));
205    assert!(!result.contains("<!--seam:page-scripts-->"));
206    assert!(!result.contains("<!--seam:prefetch-->"));
207    // Data injection still works
208    assert!(result.contains("<p>Hello</p>"));
209  }
210}