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 #[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#[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
70pub 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 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 let page_loader_keys = extract_loader_keys(&entry.loaders);
92
93 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
115fn 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 chain.reverse();
136 chain
137}
138
139fn extract_loader_keys(loaders: &serde_json::Value) -> Vec<String> {
141 loaders.as_object().map(|obj| obj.keys().cloned().collect()).unwrap_or_default()
142}
143
144pub 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
156pub 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 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 assert_eq!(dashboard.page_loader_keys, vec!["stats"]);
228
229 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}