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