Skip to main content

ferro_json_ui/
layout.rs

1//! Layout system for JSON-UI page rendering.
2//!
3//! Provides a trait-based layout system where named layouts wrap rendered
4//! component HTML in full page shells. Three built-in layouts are provided:
5//! `DefaultLayout` (minimal), `AppLayout` (dashboard with nav + sidebar),
6//! and `AuthLayout` (centered card).
7//!
8//! A global `LayoutRegistry` maps layout names to implementations. Views
9//! specify a layout via `JsonUiView.layout`, and the render pipeline looks
10//! it up in the registry.
11
12use std::collections::HashMap;
13use std::sync::{OnceLock, RwLock};
14
15use crate::render::html_escape;
16
17// ── Layout context ──────────────────────────────────────────────────────
18
19/// Context passed to layout render functions.
20///
21/// Contains all data a layout needs to produce a complete HTML page:
22/// the rendered component HTML, page metadata, and serialized view/data
23/// for potential frontend hydration.
24pub struct LayoutContext<'a> {
25    /// Page title for the `<title>` element.
26    pub title: &'a str,
27    /// Rendered component HTML fragment (output of `render_to_html`).
28    pub content: &'a str,
29    /// Additional `<head>` content (Tailwind CDN link, custom styles).
30    pub head: &'a str,
31    /// CSS classes for the `<body>` element.
32    pub body_class: &'a str,
33    /// Serialized view JSON for the `data-view` attribute.
34    pub view_json: &'a str,
35    /// Serialized data JSON for the `data-props` attribute.
36    pub data_json: &'a str,
37    /// JS assets and init scripts for plugins, injected before closing body tag.
38    pub scripts: &'a str,
39}
40
41// ── Layout trait ────────────────────────────────────────────────────────
42
43/// Trait for layout implementations.
44///
45/// Layouts produce a complete HTML page string wrapping the rendered
46/// component content. They must be `Send + Sync` for use in the global
47/// registry across threads.
48pub trait Layout: Send + Sync {
49    /// Render a complete HTML page using the provided context.
50    fn render(&self, ctx: &LayoutContext) -> String;
51}
52
53// ── Base document helper ────────────────────────────────────────────────
54
55/// Produce the common `<!DOCTYPE html>` shell shared by all built-in layouts.
56///
57/// All three built-in layouts delegate to this function to avoid duplicating
58/// the HTML/head/body boilerplate. The `body_content` parameter receives the
59/// inner body HTML which varies per layout.
60fn base_document(
61    title: &str,
62    head: &str,
63    body_class: &str,
64    body_content: &str,
65    scripts: &str,
66) -> String {
67    format!(
68        r#"<!DOCTYPE html>
69<html lang="en">
70<head>
71    <meta charset="UTF-8">
72    <meta name="viewport" content="width=device-width, initial-scale=1.0">
73    <title>{title}</title>
74    {head}
75</head>
76<body class="{body_class}">
77    {body_content}
78    {scripts}
79</body>
80</html>"#,
81        title = html_escape(title),
82        head = head,
83        body_class = html_escape(body_class),
84        body_content = body_content,
85        scripts = scripts,
86    )
87}
88
89/// Produce the ferro-json-ui wrapper div with data attributes.
90fn ferro_wrapper(ctx: &LayoutContext) -> String {
91    format!(
92        r#"<div id="ferro-json-ui" data-view="{view}" data-props="{props}">{content}</div>"#,
93        view = html_escape(ctx.view_json),
94        props = html_escape(ctx.data_json),
95        content = ctx.content,
96    )
97}
98
99// ── DefaultLayout ───────────────────────────────────────────────────────
100
101/// Minimal layout wrapping content in a valid HTML page.
102///
103/// Produces the same structure as the existing framework HTML shell:
104/// doctype, meta tags, title, head content, body with the ferro-json-ui
105/// wrapper div containing the rendered components.
106pub struct DefaultLayout;
107
108impl Layout for DefaultLayout {
109    fn render(&self, ctx: &LayoutContext) -> String {
110        let wrapper = ferro_wrapper(ctx);
111        base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, ctx.scripts)
112    }
113}
114
115// ── AppLayout ───────────────────────────────────────────────────────────
116
117/// Dashboard-style layout with navigation bar, sidebar, and main content area.
118///
119/// Uses a flex layout with the sidebar on the left and main content on the
120/// right. The ferro-json-ui wrapper div is placed inside the `<main>` element.
121///
122/// By default, renders empty navigation and sidebar placeholders. Users create
123/// custom Layout implementations that call the partial functions with real data.
124pub struct AppLayout;
125
126impl Layout for AppLayout {
127    fn render(&self, ctx: &LayoutContext) -> String {
128        let nav = navigation(&[]);
129        let side = sidebar(&[]);
130        let wrapper = ferro_wrapper(ctx);
131
132        let body = format!(
133            r#"{nav}
134    <div class="flex">
135        {side}
136        <main class="flex-1 p-6">
137            {wrapper}
138        </main>
139    </div>"#,
140        );
141
142        base_document(ctx.title, ctx.head, ctx.body_class, &body, ctx.scripts)
143    }
144}
145
146// ── AuthLayout ──────────────────────────────────────────────────────────
147
148/// Centered card layout for authentication pages (login, register).
149///
150/// Centers the content vertically and horizontally within a max-width
151/// container. No navigation or sidebar.
152pub struct AuthLayout;
153
154impl Layout for AuthLayout {
155    fn render(&self, ctx: &LayoutContext) -> String {
156        let wrapper = ferro_wrapper(ctx);
157
158        let body = format!(
159            r#"<div class="min-h-screen flex items-center justify-center">
160        <div class="w-full max-w-md">
161            <div class="bg-white rounded-lg shadow-md p-8">
162                {wrapper}
163            </div>
164        </div>
165    </div>"#,
166        );
167
168        base_document(ctx.title, ctx.head, ctx.body_class, &body, ctx.scripts)
169    }
170}
171
172// ── Partial types and functions ─────────────────────────────────────────
173
174/// A navigation link item.
175pub struct NavItem {
176    /// Display label for the link.
177    pub label: String,
178    /// URL the link points to.
179    pub url: String,
180    /// Whether this item represents the current page.
181    pub active: bool,
182}
183
184impl NavItem {
185    /// Create a new navigation item (inactive by default).
186    pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
187        Self {
188            label: label.into(),
189            url: url.into(),
190            active: false,
191        }
192    }
193
194    /// Mark this navigation item as active (builder pattern).
195    pub fn active(mut self) -> Self {
196        self.active = true;
197        self
198    }
199}
200
201/// A sidebar section containing a title and a list of navigation items.
202pub struct SidebarSection {
203    /// Section heading.
204    pub title: String,
205    /// Navigation items in this section.
206    pub items: Vec<NavItem>,
207}
208
209impl SidebarSection {
210    /// Create a new sidebar section.
211    pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
212        Self {
213            title: title.into(),
214            items,
215        }
216    }
217}
218
219/// Render a horizontal navigation bar.
220///
221/// Produces a `<nav>` element with Tailwind CSS classes. Active items
222/// are highlighted with blue text and medium font weight.
223pub fn navigation(items: &[NavItem]) -> String {
224    let mut html =
225        String::from("<nav class=\"bg-white border-b border-gray-200 px-4 py-3\"><div class=\"flex items-center space-x-6\">");
226
227    for item in items {
228        let class = if item.active {
229            "text-blue-600 font-medium"
230        } else {
231            "text-gray-600 hover:text-gray-900"
232        };
233        html.push_str(&format!(
234            "<a href=\"{}\" class=\"{}\">{}</a>",
235            html_escape(&item.url),
236            class,
237            html_escape(&item.label),
238        ));
239    }
240
241    html.push_str("</div></nav>");
242    html
243}
244
245/// Render a vertical sidebar with sections.
246///
247/// Produces an `<aside>` element with sections, each containing a heading
248/// and a list of navigation links.
249pub fn sidebar(sections: &[SidebarSection]) -> String {
250    let mut html =
251        String::from("<aside class=\"w-64 bg-gray-50 border-r border-gray-200 p-4 min-h-screen\">");
252
253    for section in sections {
254        html.push_str("<div class=\"mb-6\">");
255        html.push_str(&format!(
256            "<h3 class=\"text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2\">{}</h3>",
257            html_escape(&section.title),
258        ));
259        html.push_str("<ul class=\"space-y-1\">");
260        for item in &section.items {
261            let class = if item.active {
262                "text-blue-600 font-medium"
263            } else {
264                "text-gray-600 hover:text-gray-900"
265            };
266            html.push_str(&format!(
267                "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm {}\">{}</a></li>",
268                html_escape(&item.url),
269                class,
270                html_escape(&item.label),
271            ));
272        }
273        html.push_str("</ul></div>");
274    }
275
276    html.push_str("</aside>");
277    html
278}
279
280/// Render a simple footer.
281///
282/// Produces a `<footer>` element with centered text.
283pub fn footer(text: &str) -> String {
284    format!(
285        "<footer class=\"border-t border-gray-200 px-4 py-3 text-center text-sm text-gray-500\">{}</footer>",
286        html_escape(text),
287    )
288}
289
290// ── Layout registry ─────────────────────────────────────────────────────
291
292/// Registry mapping layout names to implementations.
293///
294/// Created with three built-in layouts: "default" (`DefaultLayout`),
295/// "app" (`AppLayout`), and "auth" (`AuthLayout`). Additional layouts
296/// can be registered at application startup.
297pub struct LayoutRegistry {
298    layouts: HashMap<String, Box<dyn Layout>>,
299    default: String,
300}
301
302impl LayoutRegistry {
303    /// Create a new registry with the three built-in layouts.
304    pub fn new() -> Self {
305        let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
306        layouts.insert("default".to_string(), Box::new(DefaultLayout));
307        layouts.insert("app".to_string(), Box::new(AppLayout));
308        layouts.insert("auth".to_string(), Box::new(AuthLayout));
309
310        Self {
311            layouts,
312            default: "default".to_string(),
313        }
314    }
315
316    /// Register a layout by name. Replaces any existing layout with the same name.
317    pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
318        self.layouts.insert(name.into(), Box::new(layout));
319    }
320
321    /// Render using the named layout. Falls back to default if name is None
322    /// or the name is not found in the registry.
323    pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
324        let layout_name = name.unwrap_or(&self.default);
325        let layout = self
326            .layouts
327            .get(layout_name)
328            .or_else(|| self.layouts.get(&self.default))
329            .expect("default layout must exist in registry");
330        layout.render(ctx)
331    }
332
333    /// Check whether a layout with the given name is registered.
334    pub fn has(&self, name: &str) -> bool {
335        self.layouts.contains_key(name)
336    }
337}
338
339impl Default for LayoutRegistry {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345// ── Global registry ─────────────────────────────────────────────────────
346
347static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
348
349/// Access the global layout registry.
350///
351/// Lazily initialized on first call with the three built-in layouts.
352pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
353    GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
354}
355
356/// Register a layout in the global registry.
357///
358/// Convenience wrapper around `global_registry().write()`.
359pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
360    global_registry()
361        .write()
362        .expect("layout registry poisoned")
363        .register(name, layout);
364}
365
366/// Render using the global registry.
367///
368/// Convenience wrapper around `global_registry().read()`.
369pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
370    global_registry()
371        .read()
372        .expect("layout registry poisoned")
373        .render(name, ctx)
374}
375
376// ── Tests ───────────────────────────────────────────────────────────────
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn test_ctx() -> LayoutContext<'static> {
383        LayoutContext {
384            title: "Test Page",
385            content: "<p>Hello</p>",
386            head: "<link rel=\"stylesheet\" href=\"/style.css\">",
387            body_class: "bg-white",
388            view_json: "{\"schema\":\"v1\"}",
389            data_json: "{\"key\":\"value\"}",
390            scripts: "",
391        }
392    }
393
394    // ── base_document tests ─────────────────────────────────────────
395
396    #[test]
397    fn base_document_produces_valid_html_structure() {
398        let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
399        assert!(html.starts_with("<!DOCTYPE html>"));
400        assert!(html.contains("<html lang=\"en\">"));
401        assert!(html.contains("<meta charset=\"UTF-8\">"));
402        assert!(html.contains("<meta name=\"viewport\""));
403        assert!(html.contains("<title>Title</title>"));
404        assert!(html.contains("<style></style>"));
405        assert!(html.contains("<body class=\"my-class\">"));
406        assert!(html.contains("<p>body</p>"));
407        assert!(html.contains("</html>"));
408    }
409
410    #[test]
411    fn base_document_escapes_title() {
412        let html = base_document("Tom & Jerry <script>", "", "", "", "");
413        assert!(html.contains("<title>Tom &amp; Jerry &lt;script&gt;</title>"));
414    }
415
416    #[test]
417    fn base_document_escapes_body_class() {
418        let html = base_document("T", "", "a\"b", "", "");
419        assert!(html.contains("class=\"a&quot;b\""));
420    }
421
422    // ── DefaultLayout tests ─────────────────────────────────────────
423
424    #[test]
425    fn default_layout_renders_all_context_fields() {
426        let ctx = test_ctx();
427        let html = DefaultLayout.render(&ctx);
428
429        assert!(html.contains("<!DOCTYPE html>"));
430        assert!(html.contains("<title>Test Page</title>"));
431        assert!(html.contains("href=\"/style.css\""));
432        assert!(html.contains("class=\"bg-white\""));
433        assert!(html.contains("id=\"ferro-json-ui\""));
434        assert!(html.contains("data-view=\""));
435        assert!(html.contains("data-props=\""));
436        assert!(html.contains("<p>Hello</p>"));
437    }
438
439    #[test]
440    fn default_layout_contains_ferro_wrapper() {
441        let ctx = test_ctx();
442        let html = DefaultLayout.render(&ctx);
443        assert!(html.contains("<div id=\"ferro-json-ui\""));
444    }
445
446    // ── AppLayout tests ─────────────────────────────────────────────
447
448    #[test]
449    fn app_layout_includes_nav_and_sidebar() {
450        let ctx = test_ctx();
451        let html = AppLayout.render(&ctx);
452
453        assert!(html.contains("<nav"));
454        assert!(html.contains("<aside"));
455        assert!(html.contains("<main class=\"flex-1 p-6\">"));
456        assert!(html.contains("<div id=\"ferro-json-ui\""));
457        assert!(html.contains("<p>Hello</p>"));
458    }
459
460    #[test]
461    fn app_layout_has_flex_structure() {
462        let ctx = test_ctx();
463        let html = AppLayout.render(&ctx);
464        assert!(html.contains("class=\"flex\""));
465    }
466
467    // ── AuthLayout tests ────────────────────────────────────────────
468
469    #[test]
470    fn auth_layout_centers_content() {
471        let ctx = test_ctx();
472        let html = AuthLayout.render(&ctx);
473
474        assert!(html.contains("flex items-center justify-center"));
475        assert!(html.contains("max-w-md"));
476        assert!(html.contains("rounded-lg shadow-md"));
477        assert!(html.contains("<div id=\"ferro-json-ui\""));
478    }
479
480    #[test]
481    fn auth_layout_has_no_nav_or_sidebar() {
482        let ctx = test_ctx();
483        let html = AuthLayout.render(&ctx);
484        assert!(!html.contains("<nav"));
485        assert!(!html.contains("<aside"));
486    }
487
488    // ── LayoutRegistry tests ────────────────────────────────────────
489
490    #[test]
491    fn registry_returns_default_for_none_name() {
492        let registry = LayoutRegistry::new();
493        let ctx = test_ctx();
494        let html = registry.render(None, &ctx);
495        // DefaultLayout produces the simple wrapper (no nav/sidebar)
496        assert!(html.contains("<div id=\"ferro-json-ui\""));
497        assert!(!html.contains("<nav"));
498    }
499
500    #[test]
501    fn registry_returns_default_for_unknown_name() {
502        let registry = LayoutRegistry::new();
503        let ctx = test_ctx();
504        let html = registry.render(Some("nonexistent"), &ctx);
505        // Falls back to default
506        assert!(html.contains("<div id=\"ferro-json-ui\""));
507        assert!(!html.contains("<nav"));
508    }
509
510    #[test]
511    fn registry_renders_named_layout() {
512        let registry = LayoutRegistry::new();
513        let ctx = test_ctx();
514        let html = registry.render(Some("app"), &ctx);
515        assert!(html.contains("<nav"));
516        assert!(html.contains("<aside"));
517    }
518
519    #[test]
520    fn registry_renders_auth_layout() {
521        let registry = LayoutRegistry::new();
522        let ctx = test_ctx();
523        let html = registry.render(Some("auth"), &ctx);
524        assert!(html.contains("flex items-center justify-center"));
525    }
526
527    #[test]
528    fn registry_has_returns_true_for_registered() {
529        let registry = LayoutRegistry::new();
530        assert!(registry.has("default"));
531        assert!(registry.has("app"));
532        assert!(registry.has("auth"));
533    }
534
535    #[test]
536    fn registry_has_returns_false_for_unknown() {
537        let registry = LayoutRegistry::new();
538        assert!(!registry.has("nonexistent"));
539    }
540
541    #[test]
542    fn registry_register_adds_custom_layout() {
543        let mut registry = LayoutRegistry::new();
544        struct Custom;
545        impl Layout for Custom {
546            fn render(&self, _ctx: &LayoutContext) -> String {
547                "CUSTOM".to_string()
548            }
549        }
550        registry.register("custom", Custom);
551        assert!(registry.has("custom"));
552
553        let ctx = test_ctx();
554        let html = registry.render(Some("custom"), &ctx);
555        assert_eq!(html, "CUSTOM");
556    }
557
558    #[test]
559    fn registry_register_replaces_existing() {
560        let mut registry = LayoutRegistry::new();
561        struct Replacement;
562        impl Layout for Replacement {
563            fn render(&self, _ctx: &LayoutContext) -> String {
564                "REPLACED".to_string()
565            }
566        }
567        registry.register("default", Replacement);
568        let ctx = test_ctx();
569        let html = registry.render(None, &ctx);
570        assert_eq!(html, "REPLACED");
571    }
572
573    // ── Global registry tests ───────────────────────────────────────
574
575    #[test]
576    fn global_registry_returns_valid_registry() {
577        let reg = global_registry();
578        let guard = reg.read().unwrap();
579        assert!(guard.has("default"));
580        assert!(guard.has("app"));
581        assert!(guard.has("auth"));
582    }
583
584    #[test]
585    fn render_layout_global_function_works() {
586        let ctx = test_ctx();
587        let html = render_layout(None, &ctx);
588        assert!(html.contains("<!DOCTYPE html>"));
589        assert!(html.contains("<div id=\"ferro-json-ui\""));
590    }
591
592    // ── Partial tests ───────────────────────────────────────────────
593
594    #[test]
595    fn navigation_renders_empty_gracefully() {
596        let html = navigation(&[]);
597        assert!(html.contains("<nav"));
598        assert!(html.contains("</nav>"));
599    }
600
601    #[test]
602    fn navigation_renders_items_with_correct_classes() {
603        let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
604        let html = navigation(&items);
605        assert!(html.contains("href=\"/\""));
606        assert!(html.contains(">Home</a>"));
607        assert!(html.contains("href=\"/users\""));
608        assert!(html.contains(">Users</a>"));
609        // Both should be inactive
610        assert!(html.contains("text-gray-600 hover:text-gray-900"));
611    }
612
613    #[test]
614    fn navigation_marks_active_item() {
615        let items = vec![
616            NavItem::new("Home", "/").active(),
617            NavItem::new("Users", "/users"),
618        ];
619        let html = navigation(&items);
620        assert!(html.contains("text-blue-600 font-medium"));
621    }
622
623    #[test]
624    fn sidebar_renders_sections_with_headers() {
625        let sections = vec![SidebarSection::new(
626            "Main Menu",
627            vec![
628                NavItem::new("Dashboard", "/"),
629                NavItem::new("Settings", "/settings"),
630            ],
631        )];
632        let html = sidebar(&sections);
633        assert!(html.contains("<aside"));
634        assert!(html.contains("Main Menu"));
635        assert!(html.contains("Dashboard"));
636        assert!(html.contains("Settings"));
637        assert!(html.contains("</aside>"));
638    }
639
640    #[test]
641    fn sidebar_renders_empty_gracefully() {
642        let html = sidebar(&[]);
643        assert!(html.contains("<aside"));
644        assert!(html.contains("</aside>"));
645    }
646
647    #[test]
648    fn footer_renders_text() {
649        let html = footer("Copyright 2026");
650        assert!(html.contains("<footer"));
651        assert!(html.contains("Copyright 2026"));
652        assert!(html.contains("</footer>"));
653    }
654
655    #[test]
656    fn partials_escape_user_strings() {
657        let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
658        let html = navigation(&items);
659        assert!(html.contains("Tom &amp; Jerry"));
660        assert!(html.contains("href=\"/a&amp;b\""));
661
662        let sections = vec![SidebarSection::new(
663            "A<B",
664            vec![NavItem::new("<script>", "/x\"y")],
665        )];
666        let html = sidebar(&sections);
667        assert!(html.contains("A&lt;B"));
668        assert!(html.contains("&lt;script&gt;"));
669
670        let html = footer("<script>alert('xss')</script>");
671        assert!(html.contains("&lt;script&gt;"));
672    }
673
674    // ── ferro_wrapper tests ─────────────────────────────────────────
675
676    #[test]
677    fn ferro_wrapper_includes_data_attributes() {
678        let ctx = test_ctx();
679        let html = ferro_wrapper(&ctx);
680        assert!(html.contains("id=\"ferro-json-ui\""));
681        assert!(html.contains("data-view=\""));
682        assert!(html.contains("data-props=\""));
683        assert!(html.contains("<p>Hello</p>"));
684    }
685}