1use std::collections::HashMap;
13use std::sync::{OnceLock, RwLock};
14
15use crate::render::html_escape;
16
17pub struct LayoutContext<'a> {
25 pub title: &'a str,
27 pub content: &'a str,
29 pub head: &'a str,
31 pub body_class: &'a str,
33 pub view_json: &'a str,
35 pub data_json: &'a str,
37 pub scripts: &'a str,
39}
40
41pub trait Layout: Send + Sync {
49 fn render(&self, ctx: &LayoutContext) -> String;
51}
52
53fn 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
89fn 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
99pub 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
115pub 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
146pub 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
172pub struct NavItem {
176 pub label: String,
178 pub url: String,
180 pub active: bool,
182}
183
184impl NavItem {
185 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 pub fn active(mut self) -> Self {
196 self.active = true;
197 self
198 }
199}
200
201pub struct SidebarSection {
203 pub title: String,
205 pub items: Vec<NavItem>,
207}
208
209impl SidebarSection {
210 pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
212 Self {
213 title: title.into(),
214 items,
215 }
216 }
217}
218
219pub 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
245pub 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(§ion.title),
258 ));
259 html.push_str("<ul class=\"space-y-1\">");
260 for item in §ion.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
280pub 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
290pub struct LayoutRegistry {
298 layouts: HashMap<String, Box<dyn Layout>>,
299 default: String,
300}
301
302impl LayoutRegistry {
303 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 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 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 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
345static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
348
349pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
353 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
354}
355
356pub 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
366pub 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#[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 #[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 & Jerry <script></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"b\""));
420 }
421
422 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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(§ions);
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 & Jerry"));
660 assert!(html.contains("href=\"/a&b\""));
661
662 let sections = vec![SidebarSection::new(
663 "A<B",
664 vec![NavItem::new("<script>", "/x\"y")],
665 )];
666 let html = sidebar(§ions);
667 assert!(html.contains("A<B"));
668 assert!(html.contains("<script>"));
669
670 let html = footer("<script>alert('xss')</script>");
671 assert!(html.contains("<script>"));
672 }
673
674 #[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}