1use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::page::LayoutChainEntry;
11
12#[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#[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
68pub 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 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 let page_loader_keys = extract_loader_keys(&entry.loaders);
90
91 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
113fn 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 chain.reverse();
134 chain
135}
136
137fn extract_loader_keys(loaders: &serde_json::Value) -> Vec<String> {
139 loaders.as_object().map(|obj| obj.keys().cloned().collect()).unwrap_or_default()
140}
141
142pub 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
153pub 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 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 assert_eq!(dashboard.page_loader_keys, vec!["stats"]);
225
226 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}