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 escape_html(s: &str) -> String {
14    s.replace('&', "&amp;")
15     .replace('<', "&lt;")
16     .replace('>', "&gt;")
17     .replace('"', "&quot;")
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    // Font preloads
32    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    // CSS stylesheets
39    for css in &assets.css_urls {
40        head.push_str(&format!("<link rel=\"stylesheet\" href=\"{css}\">\n"));
41    }
42
43    // JS modulepreloads
44    for js in &assets.js_urls {
45        head.push_str(&format!("<link rel=\"modulepreload\" href=\"{js}\">\n"));
46    }
47
48    // Personality CSS (inline, small)
49    if let Some(css) = personality_css {
50        head.push_str(&format!("<style nonce=\"{nonce}\">{css}</style>\n"));
51    }
52
53    head
54}
55
56/// Resolve asset URLs from the manifest for a given route pattern.
57fn 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
72/// Build shared body fragments (class attr, prefix, config script, page JS tag).
73struct 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
133/// Phase 1: empty `<div id="app"></div>` — client JS mounts from scratch.
134fn 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
156/// Phase 2: server-render the IR module into `<div id="app" data-forma-ssr>`.
157/// Falls back to Phase 1 on any error (missing IR module, walk failure, etc.).
158fn render_page_phase2(config: &PageConfig) -> PageOutput {
159    // Both ir_module and slots must be present for SSR
160    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    // Attempt IR walk
172    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    // SSR succeeded — build the full page with SSR content
185    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    /// Build a minimal AssetManifest with no routes for testing.
216    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    /// Build a valid IR module that renders "<p>hello</p>".
227    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, &[])); // <p>
234        opcodes.extend_from_slice(&encode_text(1)); // hello
235        opcodes.extend_from_slice(&encode_close_tag(0)); // </p>
236
237        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        // Check that the #app div does NOT have the SSR attribute
259        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        // Should fall back to Phase 1: empty #app, no data-forma-ssr
304        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        // Build an IR module with deliberately corrupt opcodes
334        let data = forma_ir::parser::test_helpers::build_minimal_ir(
335            &["x"],
336            &[],
337            &[0xFF], // invalid opcode byte
338            &[],
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        // Should fall back to Phase 1
357        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, // no IR
465            },
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        // Build IR with DYN_TEXT referencing slot 0
500        let mut opcodes = Vec::new();
501        opcodes.extend_from_slice(&encode_open_tag(0, &[])); // <span>
502        // DYN_TEXT opcode: 0x05 + slot_id(u16) + marker_id(u16)
503        opcodes.push(0x05);
504        opcodes.extend_from_slice(&0u16.to_le_bytes()); // slot_id = 0
505        opcodes.extend_from_slice(&0u16.to_le_bytes()); // marker_id = 0
506        opcodes.extend_from_slice(&encode_close_tag(0)); // </span>
507
508        let data = build_minimal_ir(
509            &["span", "name"],           // strings
510            &[(0, 1, 0x01, 0x00, &[])], // slot: id=0, name_str_idx=1, type=Text, source=Server
511            &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        // DYN_TEXT wraps content in marker comments for client reconciliation
535        assert!(page.html.contains("<span><!--f:t0-->World<!--/f:t0--></span>"));
536    }
537}