Skip to main content

seam_engine/
build.rs

1/* packages/server/engine/rust/src/build.rs */
2
3//! Build output parsing: manifest + templates -> page definitions.
4//! Pure functions operating on JSON strings, no filesystem I/O.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::page::LayoutChainEntry;
11
12// --- Manifest types ---
13
14#[derive(Deserialize)]
15struct RouteManifest {
16  #[serde(default)]
17  layouts: HashMap<String, LayoutEntry>,
18  routes: HashMap<String, RouteEntry>,
19  #[serde(default)]
20  data_id: Option<String>,
21  #[serde(default)]
22  i18n: Option<I18nManifest>,
23}
24
25#[derive(Deserialize)]
26struct I18nManifest {
27  #[serde(default)]
28  locales: Vec<String>,
29  #[serde(default)]
30  default: String,
31}
32
33#[derive(Deserialize)]
34struct LayoutEntry {
35  #[serde(default)]
36  loaders: serde_json::Value,
37  #[serde(default)]
38  parent: Option<String>,
39  #[serde(default)]
40  i18n_keys: Vec<String>,
41}
42
43#[derive(Deserialize)]
44struct RouteEntry {
45  #[serde(default)]
46  layout: Option<String>,
47  #[serde(default)]
48  loaders: serde_json::Value,
49  #[serde(default)]
50  head_meta: Option<String>,
51  #[serde(default)]
52  i18n_keys: Vec<String>,
53}
54
55// --- Output types ---
56
57/// Page definition produced by parse_build_output.
58#[derive(Debug, Clone, Serialize)]
59pub struct PageDefOutput {
60  pub route: String,
61  pub data_id: String,
62  pub layout_chain: Vec<LayoutChainEntry>,
63  pub page_loader_keys: Vec<String>,
64  pub i18n_keys: Vec<String>,
65  pub head_meta: Option<String>,
66}
67
68/// Parse route-manifest.json and produce per-page definitions with
69/// layout chains, loader key assignments, and merged i18n_keys.
70///
71/// This replaces the layout-chain walking logic duplicated across
72/// Rust/TS/Go build loaders with a single source of truth.
73pub fn parse_build_output(manifest_json: &str) -> Result<Vec<PageDefOutput>, String> {
74  let manifest: RouteManifest =
75    serde_json::from_str(manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
76
77  let data_id = manifest.data_id.unwrap_or_else(|| "__SEAM_DATA__".to_string());
78
79  let mut pages = Vec::new();
80  for (route_path, entry) in &manifest.routes {
81    // Build layout chain with loader key assignments
82    let layout_chain = if let Some(ref layout_id) = entry.layout {
83      build_layout_chain(layout_id, &manifest.layouts)
84    } else {
85      vec![]
86    };
87
88    // Page loader keys: extract data_key from route's loaders
89    let page_loader_keys = extract_loader_keys(&entry.loaders);
90
91    // Merge i18n_keys: layout chain (outer->inner) + route
92    let mut i18n_keys = Vec::new();
93    for lce in &layout_chain {
94      if let Some(layout_entry) = manifest.layouts.get(&lce.id) {
95        i18n_keys.extend(layout_entry.i18n_keys.iter().cloned());
96      }
97    }
98    i18n_keys.extend(entry.i18n_keys.iter().cloned());
99
100    pages.push(PageDefOutput {
101      route: route_path.clone(),
102      data_id: data_id.clone(),
103      layout_chain,
104      page_loader_keys,
105      i18n_keys,
106      head_meta: entry.head_meta.clone(),
107    });
108  }
109
110  Ok(pages)
111}
112
113/// Walk the layout chain from inner to outer, then reverse to get outer->inner order.
114/// Each entry records which loader data keys belong to that layout.
115fn build_layout_chain(
116  layout_id: &str,
117  layouts: &HashMap<String, LayoutEntry>,
118) -> Vec<LayoutChainEntry> {
119  let mut chain = Vec::new();
120  let mut current = Some(layout_id.to_string());
121
122  while let Some(id) = current {
123    if let Some(entry) = layouts.get(&id) {
124      let loader_keys = extract_loader_keys(&entry.loaders);
125      chain.push(LayoutChainEntry { id, loader_keys });
126      current = entry.parent.clone();
127    } else {
128      break;
129    }
130  }
131
132  // Walked inner->outer; reverse to outer->inner (matching TS)
133  chain.reverse();
134  chain
135}
136
137/// Extract data keys from a loaders JSON object.
138fn extract_loader_keys(loaders: &serde_json::Value) -> Vec<String> {
139  loaders.as_object().map(|obj| obj.keys().cloned().collect()).unwrap_or_default()
140}
141
142/// Parse i18n configuration from manifest JSON.
143/// Returns a structured JSON for runtime use.
144pub fn parse_i18n_config(manifest_json: &str) -> Option<serde_json::Value> {
145  let manifest: RouteManifest = serde_json::from_str(manifest_json).ok()?;
146  let i18n = manifest.i18n?;
147  Some(serde_json::json!({
148    "locales": i18n.locales,
149    "default": i18n.default,
150  }))
151}
152
153/// Parse an RPC hash map JSON and produce a reverse lookup (hash -> original name).
154pub fn parse_rpc_hash_map(hash_map_json: &str) -> Result<serde_json::Value, String> {
155  #[derive(Deserialize)]
156  struct RpcHashMap {
157    batch: String,
158    procedures: HashMap<String, String>,
159  }
160
161  let map: RpcHashMap =
162    serde_json::from_str(hash_map_json).map_err(|e| format!("parse rpc hash map: {e}"))?;
163
164  let reverse: HashMap<String, String> =
165    map.procedures.into_iter().map(|(name, hash)| (hash, name)).collect();
166
167  Ok(serde_json::json!({
168    "batch": map.batch,
169    "reverse_lookup": reverse,
170  }))
171}
172
173#[cfg(test)]
174mod tests {
175  use super::*;
176  use serde_json::json;
177
178  fn sample_manifest() -> String {
179    json!({
180      "layouts": {
181        "root": {
182          "template": "layouts/root.html",
183          "loaders": {"nav": {"procedure": "getNav", "params": {}}},
184          "i18n_keys": ["nav_title"]
185        },
186        "sidebar": {
187          "template": "layouts/sidebar.html",
188          "loaders": {"menu": {"procedure": "getMenu", "params": {}}},
189          "parent": "root",
190          "i18n_keys": ["menu_label"]
191        }
192      },
193      "routes": {
194        "/dashboard": {
195          "template": "pages/dashboard.html",
196          "layout": "sidebar",
197          "loaders": {"stats": {"procedure": "getStats", "params": {}}},
198          "head_meta": "<title>Dashboard</title>",
199          "i18n_keys": ["page_title"]
200        },
201        "/about": {
202          "template": "pages/about.html",
203          "loaders": {}
204        }
205      },
206      "data_id": "__SEAM_DATA__"
207    })
208    .to_string()
209  }
210
211  #[test]
212  fn parse_build_output_layout_chain() {
213    let pages = parse_build_output(&sample_manifest()).unwrap();
214    let dashboard = pages.iter().find(|p| p.route == "/dashboard").unwrap();
215
216    // Layout chain: outer(root) -> inner(sidebar)
217    assert_eq!(dashboard.layout_chain.len(), 2);
218    assert_eq!(dashboard.layout_chain[0].id, "root");
219    assert_eq!(dashboard.layout_chain[0].loader_keys, vec!["nav"]);
220    assert_eq!(dashboard.layout_chain[1].id, "sidebar");
221    assert_eq!(dashboard.layout_chain[1].loader_keys, vec!["menu"]);
222
223    // Page loader keys
224    assert_eq!(dashboard.page_loader_keys, vec!["stats"]);
225
226    // Merged i18n_keys: root + sidebar + route
227    assert!(dashboard.i18n_keys.contains(&"nav_title".to_string()));
228    assert!(dashboard.i18n_keys.contains(&"menu_label".to_string()));
229    assert!(dashboard.i18n_keys.contains(&"page_title".to_string()));
230  }
231
232  #[test]
233  fn parse_build_output_no_layout() {
234    let pages = parse_build_output(&sample_manifest()).unwrap();
235    let about = pages.iter().find(|p| p.route == "/about").unwrap();
236    assert!(about.layout_chain.is_empty());
237    assert!(about.page_loader_keys.is_empty());
238  }
239
240  #[test]
241  fn parse_i18n_config_present() {
242    let manifest = json!({
243      "layouts": {},
244      "routes": {},
245      "i18n": {"locales": ["en", "zh"], "default": "en"}
246    })
247    .to_string();
248    let config = parse_i18n_config(&manifest).unwrap();
249    assert_eq!(config["locales"], json!(["en", "zh"]));
250    assert_eq!(config["default"], "en");
251  }
252
253  #[test]
254  fn parse_i18n_config_absent() {
255    let manifest = json!({"layouts": {}, "routes": {}}).to_string();
256    assert!(parse_i18n_config(&manifest).is_none());
257  }
258
259  #[test]
260  fn parse_rpc_hash_map_test() {
261    let input = json!({
262      "salt": "abc",
263      "batch": "hash_batch",
264      "procedures": {"getUser": "hash_1", "getStats": "hash_2"}
265    })
266    .to_string();
267    let result = parse_rpc_hash_map(&input).unwrap();
268    assert_eq!(result["batch"], "hash_batch");
269    let lookup = result["reverse_lookup"].as_object().unwrap();
270    assert_eq!(lookup["hash_1"], "getUser");
271    assert_eq!(lookup["hash_2"], "getStats");
272  }
273}