1use crate::csp;
2use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
3use forma_ir as ir;
4
5struct ResolvedAssets {
7 fonts: Vec<String>,
8 css_urls: Vec<String>,
9 js_urls: Vec<String>,
10}
11
12fn build_head(
14 title: &str,
15 nonce: &str,
16 assets: &ResolvedAssets,
17 personality_css: Option<&str>,
18) -> String {
19 let mut head = String::with_capacity(2048);
20 head.push_str("<meta charset=\"utf-8\">\n");
21 head.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
22 head.push_str(&format!("<title>{}</title>\n", title));
23
24 for font in &assets.fonts {
26 head.push_str(&format!(
27 "<link rel=\"preload\" href=\"{font}\" as=\"font\" type=\"font/woff2\" crossorigin>\n"
28 ));
29 }
30
31 for css in &assets.css_urls {
33 head.push_str(&format!("<link rel=\"stylesheet\" href=\"{css}\">\n"));
34 }
35
36 for js in &assets.js_urls {
38 head.push_str(&format!("<link rel=\"modulepreload\" href=\"{js}\">\n"));
39 }
40
41 if let Some(css) = personality_css {
43 head.push_str(&format!("<style nonce=\"{nonce}\">{css}</style>\n"));
44 }
45
46 head
47}
48
49fn resolve_assets(manifest: &AssetManifest, route_pattern: &str) -> ResolvedAssets {
51 let route = manifest.route(route_pattern);
52 ResolvedAssets {
53 fonts: route
54 .map(|r| r.fonts.iter().map(|f| format!("/_assets/{f}")).collect())
55 .unwrap_or_default(),
56 css_urls: route
57 .map(|r| r.css.iter().map(|f| format!("/_assets/{f}")).collect())
58 .unwrap_or_default(),
59 js_urls: route
60 .map(|r| r.js.iter().map(|f| format!("/_assets/{f}")).collect())
61 .unwrap_or_default(),
62 }
63}
64
65struct BodyParts {
67 body_class_attr: String,
68 body_prefix: String,
69 config_script_tag: String,
70 page_js_tag: String,
71 wasm_script: String,
72 sw_script: String,
73}
74
75fn build_body_parts(nonce: &str, config: &PageConfig, js_urls: &[String]) -> BodyParts {
76 let body_class_attr = config
77 .body_class
78 .map(|c| format!(" class=\"{c}\""))
79 .unwrap_or_default();
80 let body_prefix = config.body_prefix.unwrap_or("").to_string();
81 let config_script_tag = config
82 .config_script
83 .map(|s| format!("<script nonce=\"{nonce}\">{s}</script>\n"))
84 .unwrap_or_default();
85
86 let page_js_tag = js_urls
87 .last()
88 .map(|url| format!("<script type=\"module\" nonce=\"{nonce}\" src=\"{url}\"></script>"))
89 .unwrap_or_default();
90
91 let wasm_script = match (
92 &config.manifest.wasm,
93 config.manifest.route(config.route_pattern),
94 ) {
95 (Some(wasm), Some(route)) if route.ir.is_some() => {
96 let ir_name = route.ir.as_ref().unwrap();
97 format!(
98 "<script nonce=\"{nonce}\">window.__FORMA_WASM__={{loader:\"/_assets/{}\",binary:\"/_assets/{}\",ir:\"/_assets/{}\"}};</script>\n",
99 wasm.loader, wasm.binary, ir_name
100 )
101 }
102 _ => String::new(),
103 };
104
105 let sw_script = format!(
106 "<script nonce=\"{nonce}\">if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js');</script>\n"
107 );
108
109 BodyParts {
110 body_class_attr,
111 body_prefix,
112 config_script_tag,
113 page_js_tag,
114 wasm_script,
115 sw_script,
116 }
117}
118
119pub fn render_page(config: &PageConfig) -> PageOutput {
120 match config.render_mode {
121 RenderMode::Phase1ClientMount => render_page_phase1(config),
122 RenderMode::Phase2SsrReconcile => render_page_phase2(config),
123 }
124}
125
126fn render_page_phase1(config: &PageConfig) -> PageOutput {
128 let nonce = csp::generate_csp_nonce();
129 let assets = resolve_assets(config.manifest, config.route_pattern);
130 let head = build_head(config.title, &nonce, &assets, config.personality_css);
131 let parts = build_body_parts(&nonce, config, &assets.js_urls);
132
133 let html = format!(
134 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n{head}</head>\n<body{bc}>\n{bp}<div id=\"app\"></div>\n{cs}{pj}\n{wasm}{sw}</body>\n</html>",
135 bc = parts.body_class_attr,
136 bp = parts.body_prefix,
137 cs = parts.config_script_tag,
138 pj = parts.page_js_tag,
139 wasm = parts.wasm_script,
140 sw = parts.sw_script,
141 );
142
143 PageOutput {
144 html,
145 csp: csp::build_csp_header(&nonce),
146 }
147}
148
149fn render_page_phase2(config: &PageConfig) -> PageOutput {
152 let (ir_module, slots) = match (config.ir_module, config.slots) {
154 (Some(m), Some(s)) => (m, s),
155 _ => {
156 tracing::warn!(
157 route = config.route_pattern,
158 "Phase 2 SSR: missing IR module or slots, falling back to Phase 1"
159 );
160 return render_page_phase1(config);
161 }
162 };
163
164 let ssr_body = match ir::walker::walk_to_html(ir_module, slots) {
166 Ok(html) => html,
167 Err(err) => {
168 tracing::warn!(
169 route = config.route_pattern,
170 error = %err,
171 "Phase 2 SSR: IR walk failed, falling back to Phase 1"
172 );
173 return render_page_phase1(config);
174 }
175 };
176
177 let nonce = csp::generate_csp_nonce();
179 let assets = resolve_assets(config.manifest, config.route_pattern);
180 let head = build_head(config.title, &nonce, &assets, config.personality_css);
181 let parts = build_body_parts(&nonce, config, &assets.js_urls);
182
183 let html = format!(
184 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n{head}</head>\n<body{bc}>\n{bp}<div id=\"app\" data-forma-ssr>{ssr}</div>\n{cs}{pj}\n{wasm}{sw}</body>\n</html>",
185 bc = parts.body_class_attr,
186 bp = parts.body_prefix,
187 ssr = ssr_body,
188 cs = parts.config_script_tag,
189 pj = parts.page_js_tag,
190 wasm = parts.wasm_script,
191 sw = parts.sw_script,
192 );
193
194 PageOutput {
195 html,
196 csp: csp::build_csp_header(&nonce),
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::types::{RouteAssets, WasmAssets};
204 use forma_ir::parser::IrModule;
205 use forma_ir::slot::{SlotData, SlotValue};
206 use std::collections::HashMap;
207
208 fn empty_manifest() -> AssetManifest {
210 AssetManifest {
211 version: 1,
212 build_hash: "test".to_string(),
213 assets: HashMap::new(),
214 routes: HashMap::new(),
215 wasm: None,
216 }
217 }
218
219 fn hello_ir_module() -> IrModule {
221 use forma_ir::parser::test_helpers::{
222 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
223 };
224
225 let mut opcodes = Vec::new();
226 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.extend_from_slice(&encode_text(1)); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(&["p", "hello"], &[], &opcodes, &[]);
231 IrModule::parse(&data).unwrap()
232 }
233
234 #[test]
235 fn phase1_renders_empty_app_div() {
236 let manifest = empty_manifest();
237 let page = render_page(&PageConfig {
238 title: "Test",
239 route_pattern: "/test",
240 manifest: &manifest,
241 config_script: None,
242 body_class: None,
243 personality_css: None,
244 body_prefix: None,
245 render_mode: RenderMode::Phase1ClientMount,
246 ir_module: None,
247 slots: None,
248 });
249
250 assert!(page.html.contains("<div id=\"app\"></div>"));
251 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
253 }
254
255 #[test]
256 fn phase2_renders_ssr_content_with_data_attr() {
257 let manifest = empty_manifest();
258 let ir = hello_ir_module();
259 let slots = SlotData::new(0);
260
261 let page = render_page(&PageConfig {
262 title: "SSR Test",
263 route_pattern: "/test",
264 manifest: &manifest,
265 config_script: None,
266 body_class: None,
267 personality_css: None,
268 body_prefix: None,
269 render_mode: RenderMode::Phase2SsrReconcile,
270 ir_module: Some(&ir),
271 slots: Some(&slots),
272 });
273
274 assert!(page.html.contains("data-forma-ssr"));
275 assert!(page.html.contains("<p>hello</p>"));
276 assert!(page.html.contains("<div id=\"app\" data-forma-ssr>"));
277 }
278
279 #[test]
280 fn phase2_falls_back_to_phase1_when_ir_module_missing() {
281 let manifest = empty_manifest();
282
283 let page = render_page(&PageConfig {
284 title: "Fallback Test",
285 route_pattern: "/test",
286 manifest: &manifest,
287 config_script: None,
288 body_class: None,
289 personality_css: None,
290 body_prefix: None,
291 render_mode: RenderMode::Phase2SsrReconcile,
292 ir_module: None,
293 slots: None,
294 });
295
296 assert!(page.html.contains("<div id=\"app\"></div>"));
298 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
299 }
300
301 #[test]
302 fn phase2_falls_back_to_phase1_when_slots_missing() {
303 let manifest = empty_manifest();
304 let ir = hello_ir_module();
305
306 let page = render_page(&PageConfig {
307 title: "Fallback Test",
308 route_pattern: "/test",
309 manifest: &manifest,
310 config_script: None,
311 body_class: None,
312 personality_css: None,
313 body_prefix: None,
314 render_mode: RenderMode::Phase2SsrReconcile,
315 ir_module: Some(&ir),
316 slots: None,
317 });
318
319 assert!(page.html.contains("<div id=\"app\"></div>"));
320 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
321 }
322
323 #[test]
324 fn phase2_falls_back_on_ir_walk_error() {
325 let manifest = empty_manifest();
326 let data = forma_ir::parser::test_helpers::build_minimal_ir(
328 &["x"],
329 &[],
330 &[0xFF], &[],
332 );
333 let ir = IrModule::parse(&data).unwrap();
334 let slots = SlotData::new(0);
335
336 let page = render_page(&PageConfig {
337 title: "Walk Error Test",
338 route_pattern: "/test",
339 manifest: &manifest,
340 config_script: None,
341 body_class: None,
342 personality_css: None,
343 body_prefix: None,
344 render_mode: RenderMode::Phase2SsrReconcile,
345 ir_module: Some(&ir),
346 slots: Some(&slots),
347 });
348
349 assert!(page.html.contains("<div id=\"app\"></div>"));
351 assert!(!page.html.contains("<div id=\"app\" data-forma-ssr>"));
352 }
353
354 #[test]
355 fn phase2_ssr_preserves_head_content() {
356 let manifest = empty_manifest();
357 let ir = hello_ir_module();
358 let slots = SlotData::new(0);
359
360 let page = render_page(&PageConfig {
361 title: "Head Test",
362 route_pattern: "/test",
363 manifest: &manifest,
364 config_script: Some("window.__TEST__=true;"),
365 body_class: Some("dark"),
366 personality_css: Some(":root{--c:red}"),
367 body_prefix: Some("<nav>nav</nav>"),
368 render_mode: RenderMode::Phase2SsrReconcile,
369 ir_module: Some(&ir),
370 slots: Some(&slots),
371 });
372
373 assert!(page.html.contains("<title>Head Test</title>"));
374 assert!(page.html.contains("window.__TEST__=true;"));
375 assert!(page.html.contains("class=\"dark\""));
376 assert!(page.html.contains(":root{--c:red}"));
377 assert!(page.html.contains("<nav>nav</nav>"));
378 assert!(page.html.contains("<p>hello</p>"));
379 assert!(page.html.contains("data-forma-ssr"));
380 }
381
382 #[test]
383 fn wasm_script_injected_when_manifest_has_wasm_and_route_has_ir() {
384 let mut routes = HashMap::new();
385 routes.insert(
386 "/test".to_string(),
387 RouteAssets {
388 js: vec!["test.abc123.js".to_string()],
389 css: vec![],
390 fonts: vec![],
391 total_size_br: 0,
392 budget_warn_threshold: 204800,
393 ir: Some("test.def456.ir".to_string()),
394 },
395 );
396
397 let manifest = AssetManifest {
398 version: 1,
399 build_hash: "test".to_string(),
400 assets: HashMap::new(),
401 routes,
402 wasm: Some(WasmAssets {
403 loader: "forma_ir.abc.js".to_string(),
404 binary: "forma_ir_bg.def.wasm".to_string(),
405 }),
406 };
407
408 let page = render_page(&PageConfig {
409 title: "WASM Test",
410 route_pattern: "/test",
411 manifest: &manifest,
412 config_script: None,
413 body_class: None,
414 personality_css: None,
415 body_prefix: None,
416 render_mode: RenderMode::Phase1ClientMount,
417 ir_module: None,
418 slots: None,
419 });
420
421 assert!(page.html.contains("__FORMA_WASM__"));
422 assert!(page.html.contains("/_assets/forma_ir.abc.js"));
423 assert!(page.html.contains("/_assets/forma_ir_bg.def.wasm"));
424 assert!(page.html.contains("/_assets/test.def456.ir"));
425 }
426
427 #[test]
428 fn wasm_script_not_injected_when_no_wasm_in_manifest() {
429 let manifest = empty_manifest();
430 let page = render_page(&PageConfig {
431 title: "No WASM",
432 route_pattern: "/test",
433 manifest: &manifest,
434 config_script: None,
435 body_class: None,
436 personality_css: None,
437 body_prefix: None,
438 render_mode: RenderMode::Phase1ClientMount,
439 ir_module: None,
440 slots: None,
441 });
442
443 assert!(!page.html.contains("__FORMA_WASM__"));
444 }
445
446 #[test]
447 fn wasm_script_not_injected_when_route_has_no_ir() {
448 let mut routes = HashMap::new();
449 routes.insert(
450 "/test".to_string(),
451 RouteAssets {
452 js: vec!["test.abc123.js".to_string()],
453 css: vec![],
454 fonts: vec![],
455 total_size_br: 0,
456 budget_warn_threshold: 204800,
457 ir: None, },
459 );
460
461 let manifest = AssetManifest {
462 version: 1,
463 build_hash: "test".to_string(),
464 assets: HashMap::new(),
465 routes,
466 wasm: Some(WasmAssets {
467 loader: "forma_ir.abc.js".to_string(),
468 binary: "forma_ir_bg.def.wasm".to_string(),
469 }),
470 };
471
472 let page = render_page(&PageConfig {
473 title: "No IR",
474 route_pattern: "/test",
475 manifest: &manifest,
476 config_script: None,
477 body_class: None,
478 personality_css: None,
479 body_prefix: None,
480 render_mode: RenderMode::Phase1ClientMount,
481 ir_module: None,
482 slots: None,
483 });
484
485 assert!(!page.html.contains("__FORMA_WASM__"));
486 }
487
488 #[test]
489 fn phase2_with_slot_data() {
490 use forma_ir::parser::test_helpers::{build_minimal_ir, encode_close_tag, encode_open_tag};
491
492 let mut opcodes = Vec::new();
494 opcodes.extend_from_slice(&encode_open_tag(0, &[])); opcodes.push(0x05);
497 opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&0u16.to_le_bytes()); opcodes.extend_from_slice(&encode_close_tag(0)); let data = build_minimal_ir(
502 &["span", "name"], &[(0, 1, 0x01, 0x00, &[])], &opcodes,
505 &[],
506 );
507 let ir = IrModule::parse(&data).unwrap();
508
509 let mut slots = SlotData::new(1);
510 slots.set(0, SlotValue::Text("World".to_string()));
511
512 let manifest = empty_manifest();
513 let page = render_page(&PageConfig {
514 title: "Slot Test",
515 route_pattern: "/test",
516 manifest: &manifest,
517 config_script: None,
518 body_class: None,
519 personality_css: None,
520 body_prefix: None,
521 render_mode: RenderMode::Phase2SsrReconcile,
522 ir_module: Some(&ir),
523 slots: Some(&slots),
524 });
525
526 assert!(page.html.contains("data-forma-ssr"));
527 assert!(page.html.contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
529 }
530}