Skip to main content

seam_engine/
build.rs

1/* src/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(|| "__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": "__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}