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