Skip to main content

forma_server/
template.rs

1use crate::csp;
2use crate::types::{AssetManifest, PageConfig, PageOutput, RenderMode};
3use forma_ir as ir;
4
5/// Resolved asset URLs for a route — shared between Phase 1 and Phase 2 paths.
6struct ResolvedAssets {
7    fonts: Vec<String>,
8    css_urls: Vec<String>,
9    js_urls: Vec<String>,
10}
11
12/// Build the common `<head>` content shared by both render paths.
13fn 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    // Font preloads
25    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    // CSS stylesheets
32    for css in &assets.css_urls {
33        head.push_str(&format!("<link rel=\"stylesheet\" href=\"{css}\">\n"));
34    }
35
36    // JS modulepreloads
37    for js in &assets.js_urls {
38        head.push_str(&format!("<link rel=\"modulepreload\" href=\"{js}\">\n"));
39    }
40
41    // Personality CSS (inline, small)
42    if let Some(css) = personality_css {
43        head.push_str(&format!("<style nonce=\"{nonce}\">{css}</style>\n"));
44    }
45
46    head
47}
48
49/// Resolve asset URLs from the manifest for a given route pattern.
50fn 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
65/// Build shared body fragments (class attr, prefix, config script, page JS tag).
66struct 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
126/// Phase 1: empty `<div id="app"></div>` — client JS mounts from scratch.
127fn 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
149/// Phase 2: server-render the IR module into `<div id="app" data-forma-ssr>`.
150/// Falls back to Phase 1 on any error (missing IR module, walk failure, etc.).
151fn render_page_phase2(config: &PageConfig) -> PageOutput {
152    // Both ir_module and slots must be present for SSR
153    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    // Attempt IR walk
165    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    // SSR succeeded — build the full page with SSR content
178    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    /// Build a minimal AssetManifest with no routes for testing.
209    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    /// Build a valid IR module that renders "<p>hello</p>".
220    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, &[])); // <p>
227        opcodes.extend_from_slice(&encode_text(1)); // hello
228        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
229
230        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        // Check that the #app div does NOT have the SSR attribute
252        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        // Should fall back to Phase 1: empty #app, no data-forma-ssr
297        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        // Build an IR module with deliberately corrupt opcodes
327        let data = forma_ir::parser::test_helpers::build_minimal_ir(
328            &["x"],
329            &[],
330            &[0xFF], // invalid opcode byte
331            &[],
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        // Should fall back to Phase 1
350        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, // no IR
458            },
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        // Build IR with DYN_TEXT referencing slot 0
493        let mut opcodes = Vec::new();
494        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
495        // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
496        opcodes.push(0x05);
497        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
498        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
499        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
500
501        let data = build_minimal_ir(
502            &["span", "name"],           // strings
503            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
504            &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        // DYN_TEXT wraps content in marker comments for client reconciliation
528        assert!(page.html.contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
529    }
530}