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