Skip to main content

seam_server/build_loader/
loader.rs

1/* packages/server/core/rust/src/build_loader/loader.rs */
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use crate::page::{LoaderDef, PageDef};
8
9use super::types::{pick_template, LoaderConfig, ParamConfig, RouteManifest, RpcHashMap};
10
11/// Build a LoaderInputFn closure from the loader config's param mappings.
12/// For params with `from: "route"`, extracts the value from route params.
13pub(super) fn build_input_fn(params: &HashMap<String, ParamConfig>) -> crate::page::LoaderInputFn {
14  let params: Vec<(String, String, String)> = params
15    .iter()
16    .map(|(key, cfg)| (key.clone(), cfg.from.clone(), cfg.param_type.clone()))
17    .collect();
18
19  Arc::new(move |route_params: &HashMap<String, String>| {
20    let mut obj = serde_json::Map::new();
21    for (key, from, param_type) in &params {
22      let value = match from.as_str() {
23        "route" => {
24          let raw = route_params.get(key).cloned().unwrap_or_default();
25          match param_type.as_str() {
26            "uint32" | "int32" | "number" => {
27              if let Ok(n) = raw.parse::<i64>() {
28                serde_json::Value::Number(serde_json::Number::from(n))
29              } else {
30                serde_json::Value::String(raw)
31              }
32            }
33            _ => serde_json::Value::String(raw),
34          }
35        }
36        _ => serde_json::Value::Null,
37      };
38      obj.insert(key.clone(), value);
39    }
40    serde_json::Value::Object(obj)
41  })
42}
43
44/// Parse loaders JSON object into Vec<LoaderDef>.
45pub(super) fn parse_loaders(loaders: &serde_json::Value) -> Vec<LoaderDef> {
46  let Some(obj) = loaders.as_object() else {
47    return Vec::new();
48  };
49
50  obj
51    .iter()
52    .filter_map(|(data_key, loader_val)| {
53      let config: LoaderConfig = serde_json::from_value(loader_val.clone()).ok()?;
54      Some(LoaderDef {
55        data_key: data_key.clone(),
56        procedure: config.procedure,
57        input_fn: build_input_fn(&config.params),
58      })
59    })
60    .collect()
61}
62
63/// Resolve a layout chain: walk from child to root, collecting templates.
64/// Returns the full document template with <!--seam:outlet--> replaced by page content.
65pub(super) fn resolve_layout_chain(
66  layout_id: &str,
67  page_template: &str,
68  layouts: &HashMap<String, (String, Option<String>)>,
69) -> String {
70  let mut result = page_template.to_string();
71  let mut current = Some(layout_id.to_string());
72
73  while let Some(id) = current {
74    if let Some((tmpl, parent)) = layouts.get(&id) {
75      result = tmpl.replace("<!--seam:outlet-->", &result);
76      current = parent.clone();
77    } else {
78      break;
79    }
80  }
81
82  result
83}
84
85/// Load page definitions from seam build output on disk.
86/// Reads route-manifest.json, loads templates, constructs PageDef with loaders.
87#[allow(clippy::too_many_lines)]
88pub fn load_build_output(dir: &str) -> Result<Vec<PageDef>, Box<dyn std::error::Error>> {
89  let base = Path::new(dir);
90  let manifest_path = base.join("route-manifest.json");
91  let content = std::fs::read_to_string(&manifest_path)?;
92  let manifest: RouteManifest = serde_json::from_str(&content)?;
93  let default_locale = manifest.i18n.as_ref().map(|c| c.default.as_str());
94
95  // Load layout templates (default locale)
96  let mut layout_templates: HashMap<String, (String, Option<String>)> = HashMap::new();
97  for (id, entry) in &manifest.layouts {
98    if let Some(tmpl_path) = pick_template(&entry.template, &entry.templates, default_locale) {
99      let full_path = base.join(&tmpl_path);
100      let tmpl = std::fs::read_to_string(&full_path)?;
101      layout_templates.insert(id.clone(), (tmpl, entry.parent.clone()));
102    }
103  }
104
105  // Load layout templates per locale for locale-specific resolution
106  let mut layout_locale_templates: HashMap<String, HashMap<String, (String, Option<String>)>> =
107    HashMap::new();
108  if manifest.i18n.is_some() {
109    for (id, entry) in &manifest.layouts {
110      if let Some(ref templates) = entry.templates {
111        for (locale, tmpl_path) in templates {
112          let full_path = base.join(tmpl_path);
113          let tmpl = std::fs::read_to_string(&full_path)?;
114          layout_locale_templates
115            .entry(locale.clone())
116            .or_default()
117            .insert(id.clone(), (tmpl, entry.parent.clone()));
118        }
119      }
120    }
121  }
122
123  let mut pages = Vec::new();
124
125  for (route_path, entry) in &manifest.routes {
126    // Load page template (default locale)
127    let page_template =
128      if let Some(tmpl_path) = pick_template(&entry.template, &entry.templates, default_locale) {
129        let full_path = base.join(&tmpl_path);
130        std::fs::read_to_string(&full_path)?
131      } else {
132        continue;
133      };
134
135    // Resolve layout chain if this page has a layout
136    let template = if let Some(ref layout_id) = entry.layout {
137      let mut full = resolve_layout_chain(layout_id, &page_template, &layout_templates);
138      // Inject head_meta into layout's <head> if present
139      if let Some(ref meta) = entry.head_meta {
140        full = full.replace("</head>", &format!("{meta}</head>"));
141      }
142      full
143    } else {
144      page_template
145    };
146
147    // Build locale-specific pre-resolved templates when i18n is active
148    let locale_templates = if manifest.i18n.is_some() {
149      if let Some(ref templates) = entry.templates {
150        let mut lt = HashMap::new();
151        for (locale, tmpl_path) in templates {
152          let full_path = base.join(tmpl_path);
153          let page_tmpl = std::fs::read_to_string(&full_path)?;
154          let resolved = if let Some(ref layout_id) = entry.layout {
155            let locale_layouts = layout_locale_templates.get(locale).unwrap_or(&layout_templates);
156            let mut full = resolve_layout_chain(layout_id, &page_tmpl, locale_layouts);
157            if let Some(ref meta) = entry.head_meta {
158              full = full.replace("</head>", &format!("{meta}</head>"));
159            }
160            full
161          } else {
162            page_tmpl
163          };
164          lt.insert(locale.clone(), resolved);
165        }
166        if lt.is_empty() {
167          None
168        } else {
169          Some(lt)
170        }
171      } else {
172        None
173      }
174    } else {
175      None
176    };
177
178    // Convert route path from client format (/:param) to Axum format (/{param})
179    let axum_route = convert_route_path(route_path);
180
181    // Parse loaders: combine layout loaders + route loaders
182    // Also build layout chain with per-layout loader key assignments
183    let mut all_loaders = Vec::new();
184    let mut layout_chain = Vec::new();
185    if let Some(ref layout_id) = entry.layout {
186      // Collect loaders from the layout chain (inner->outer walk)
187      let mut chain = Some(layout_id.clone());
188      while let Some(id) = chain {
189        if let Some(layout_entry) = manifest.layouts.get(&id) {
190          let layout_loaders = parse_loaders(&layout_entry.loaders);
191          let loader_keys: Vec<String> =
192            layout_loaders.iter().map(|l| l.data_key.clone()).collect();
193          layout_chain.push(crate::page::LayoutChainEntry { id, loader_keys });
194          all_loaders.extend(layout_loaders);
195          chain = layout_entry.parent.clone();
196        } else {
197          break;
198        }
199      }
200      // Reverse: walked inner->outer, want outer->inner (matching TS)
201      layout_chain.reverse();
202    }
203    let page_loaders = parse_loaders(&entry.loaders);
204    let page_loader_keys: Vec<String> = page_loaders.iter().map(|l| l.data_key.clone()).collect();
205    all_loaders.extend(page_loaders);
206
207    // Merge i18n_keys from layout chain + route
208    let mut i18n_keys = Vec::new();
209    if let Some(ref layout_id) = entry.layout {
210      let mut chain = Some(layout_id.clone());
211      while let Some(id) = chain {
212        if let Some(layout_entry) = manifest.layouts.get(&id) {
213          i18n_keys.extend(layout_entry.i18n_keys.iter().cloned());
214          chain = layout_entry.parent.clone();
215        } else {
216          break;
217        }
218      }
219    }
220    i18n_keys.extend(entry.i18n_keys.iter().cloned());
221
222    let data_id = manifest.data_id.clone().unwrap_or_else(|| "__SEAM_DATA__".to_string());
223    pages.push(PageDef {
224      route: axum_route,
225      template,
226      locale_templates,
227      loaders: all_loaders,
228      data_id: data_id.clone(),
229      layout_chain,
230      page_loader_keys,
231      i18n_keys,
232    });
233  }
234
235  Ok(pages)
236}
237
238/// Load i18n configuration and locale messages from build output.
239/// Returns None when i18n is not configured.
240pub fn load_i18n_config(dir: &str) -> Option<crate::page::I18nConfig> {
241  let base = Path::new(dir);
242  let manifest_path = base.join("route-manifest.json");
243  let content = std::fs::read_to_string(&manifest_path).ok()?;
244  let manifest: RouteManifest = serde_json::from_str(&content).ok()?;
245  let i18n = manifest.i18n?;
246
247  let mode = i18n.mode.unwrap_or_else(|| "memory".to_string());
248
249  // Memory mode: preload route-keyed messages per locale from i18n/{locale}.json
250  // Paged mode: store dist_dir for on-demand reads
251  let mut messages = HashMap::new();
252  if mode == "memory" {
253    let i18n_dir = base.join("i18n");
254    for locale in &i18n.locales {
255      let locale_path = i18n_dir.join(format!("{locale}.json"));
256      let parsed: HashMap<String, serde_json::Value> = std::fs::read_to_string(&locale_path)
257        .ok()
258        .and_then(|c| serde_json::from_str(&c).ok())
259        .unwrap_or_default();
260      messages.insert(locale.clone(), parsed);
261    }
262  }
263
264  let dist_dir = if mode == "paged" { Some(base.to_path_buf()) } else { None };
265
266  Some(crate::page::I18nConfig {
267    locales: i18n.locales,
268    default: i18n.default,
269    mode,
270    cache: i18n.cache,
271    route_hashes: i18n.route_hashes,
272    content_hashes: i18n.content_hashes,
273    messages,
274    dist_dir,
275  })
276}
277
278/// Load the RPC hash map from build output (returns None when not present).
279pub fn load_rpc_hash_map(dir: &str) -> Option<RpcHashMap> {
280  let path = Path::new(dir).join("rpc-hash-map.json");
281  let content = std::fs::read_to_string(&path).ok()?;
282  serde_json::from_str(&content).ok()
283}
284
285/// Convert client route path to Axum format: /:param -> /{param}
286pub(super) fn convert_route_path(path: &str) -> String {
287  path
288    .split('/')
289    .map(|seg| {
290      if let Some(param) = seg.strip_prefix(':') {
291        format!("{{{param}}}")
292      } else {
293        seg.to_string()
294      }
295    })
296    .collect::<Vec<_>>()
297    .join("/")
298}