1use std::collections::HashMap;
14use std::sync::{OnceLock, RwLock};
15
16use crate::component::{HeaderProps, SidebarGroup, SidebarNavItem, SidebarProps};
17use crate::render::html_escape;
18
19pub struct LayoutContext<'a> {
27 pub title: &'a str,
29 pub content: &'a str,
31 pub head: &'a str,
33 pub body_class: &'a str,
35 pub view_json: &'a str,
37 pub data_json: &'a str,
39 pub scripts: &'a str,
41}
42
43pub trait Layout: Send + Sync {
51 fn render(&self, ctx: &LayoutContext) -> String;
53}
54
55fn base_document(
63 title: &str,
64 head: &str,
65 body_class: &str,
66 body_content: &str,
67 scripts: &str,
68) -> String {
69 format!(
70 r#"<!DOCTYPE html>
71<html lang="en">
72<head>
73 <meta charset="UTF-8">
74 <meta name="viewport" content="width=device-width, initial-scale=1.0">
75 <title>{title}</title>
76 {head}
77</head>
78<body class="{body_class}">
79 {body_content}
80 {scripts}
81</body>
82</html>"#,
83 title = html_escape(title),
84 head = head,
85 body_class = html_escape(body_class),
86 body_content = body_content,
87 scripts = scripts,
88 )
89}
90
91fn ferro_wrapper(ctx: &LayoutContext) -> String {
93 format!(
94 r#"<div id="ferro-json-ui" data-view="{view}" data-props="{props}">{content}</div>"#,
95 view = html_escape(ctx.view_json),
96 props = html_escape(ctx.data_json),
97 content = ctx.content,
98 )
99}
100
101fn base_document_ext(
106 title: &str,
107 head: &str,
108 body_class: &str,
109 body_data: &str,
110 body_content: &str,
111 scripts: &str,
112) -> String {
113 let body_data_attr = if body_data.is_empty() {
114 String::new()
115 } else {
116 format!(" {body_data}")
117 };
118 format!(
119 r#"<!DOCTYPE html>
120<html lang="en">
121<head>
122 <meta charset="UTF-8">
123 <meta name="viewport" content="width=device-width, initial-scale=1.0">
124 <title>{title}</title>
125 {head}
126</head>
127<body class="{body_class}"{body_data_attr}>
128 {body_content}
129 {scripts}
130</body>
131</html>"#,
132 title = html_escape(title),
133 head = head,
134 body_class = html_escape(body_class),
135 body_data_attr = body_data_attr,
136 body_content = body_content,
137 scripts = scripts,
138 )
139}
140
141fn layout_sidebar_nav_item(item: &SidebarNavItem) -> String {
145 let classes = if item.active {
146 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-card text-primary transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
147 } else {
148 "flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium text-text-muted hover:text-text hover:bg-surface transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
149 };
150 let mut html = format!(
151 "<a href=\"{}\" class=\"{}\">",
152 html_escape(&item.href),
153 classes
154 );
155 if let Some(ref icon) = item.icon {
156 html.push_str(&format!(
157 "<span class=\"inline-flex items-center justify-center w-5 h-5 shrink-0\">{icon}</span>" ));
159 }
160 html.push_str(&format!("{}</a>", html_escape(&item.label)));
161 html
162}
163
164fn layout_sidebar_group(group: &SidebarGroup) -> String {
166 let mut html = String::from("<div data-sidebar-group");
167 if group.collapsed {
168 html.push_str(" data-collapsed");
169 }
170 html.push('>');
171 html.push_str(&format!(
172 "<p class=\"px-2 py-1 text-xs font-semibold text-text-muted\">{}</p>",
173 html_escape(&group.label)
174 ));
175 html.push_str("<nav class=\"space-y-1\">");
176 for item in &group.items {
177 html.push_str(&layout_sidebar_nav_item(item));
178 }
179 html.push_str("</nav></div>");
180 html
181}
182
183fn layout_sidebar_html(props: &SidebarProps) -> String {
185 let mut html = String::from(
186 "<aside data-sidebar class=\"fixed inset-y-0 left-0 z-40 w-64 flex flex-col \
187 bg-background border-r border-border hidden md:flex\">",
188 );
189 if !props.fixed_top.is_empty() {
190 html.push_str("<nav class=\"p-4 space-y-1\">");
191 for item in &props.fixed_top {
192 html.push_str(&layout_sidebar_nav_item(item));
193 }
194 html.push_str("</nav>");
195 }
196 if !props.groups.is_empty() {
197 html.push_str("<div class=\"flex-1 overflow-y-auto p-4 space-y-4\">");
198 for group in &props.groups {
199 html.push_str(&layout_sidebar_group(group));
200 }
201 html.push_str("</div>");
202 }
203 if !props.fixed_bottom.is_empty() {
204 html.push_str("<nav class=\"p-4 space-y-1 border-t border-border\">");
205 for item in &props.fixed_bottom {
206 html.push_str(&layout_sidebar_nav_item(item));
207 }
208 html.push_str("</nav>");
209 }
210 html.push_str("</aside>");
211 html.push_str(
213 "<div data-sidebar-backdrop class=\"fixed inset-0 z-30 bg-black/50 hidden md:hidden\"></div>",
214 );
215 html
216}
217
218fn layout_header_html(props: &HeaderProps) -> String {
220 let mut html = String::from(
221 "<header class=\"sticky top-0 z-30 relative flex items-center \
222 px-4 py-3 bg-background border-b border-border md:pl-72\">",
223 );
224 html.push_str(
226 "<button data-sidebar-toggle class=\"md:hidden p-2 rounded-md text-text-muted \
227 hover:text-text hover:bg-surface\" aria-label=\"Toggle sidebar\">\
228 <svg class=\"h-6 w-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
229 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
230 d=\"M4 6h16M4 12h16M4 18h16\"/></svg></button>",
231 );
232 html.push_str(&format!(
235 "<span class=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-lg font-semibold text-text pointer-events-none\">{}</span>",
236 html_escape(&props.business_name)
237 ));
238 html.push_str("<div class=\"ml-auto flex items-center gap-4\">");
239 html.push_str("<div class=\"relative\">");
241 if let Some(count) = props.notification_count {
242 if count > 0 {
243 html.push_str(&format!(
244 "<button data-notification-toggle class=\"relative p-2 text-text-muted hover:text-text\">\
245 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
246 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
247 d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/></svg>\
248 <span class=\"absolute top-1 right-1 inline-flex items-center justify-center h-4 w-4 \
249 text-xs font-bold text-primary-foreground bg-destructive rounded-full\">{count}</span></button>",
250 ));
251 } else {
252 html.push_str(
253 "<button data-notification-toggle class=\"p-2 text-text-muted hover:text-text\">\
254 <svg class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\
255 <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \
256 d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\"/></svg></button>",
257 );
258 }
259 }
260 html.push_str(
261 "<div data-notification-dropdown class=\"hidden absolute right-0 top-full mt-1 w-80 \
262 bg-card rounded-lg shadow-lg border border-border z-50\"></div></div>",
263 );
264 html.push_str("<div class=\"flex items-center gap-2\">");
266 if let Some(ref avatar) = props.user_avatar {
267 html.push_str(&format!(
268 "<img src=\"{}\" alt=\"User avatar\" class=\"h-8 w-8 rounded-full object-cover\">",
269 html_escape(avatar)
270 ));
271 } else if let Some(ref name) = props.user_name {
272 let initials: String = name
273 .split_whitespace()
274 .filter_map(|w| w.chars().next())
275 .take(2)
276 .collect();
277 html.push_str(&format!(
278 "<span class=\"inline-flex items-center justify-center h-8 w-8 rounded-full \
279 bg-card text-text-muted text-sm font-medium\">{}</span>",
280 html_escape(&initials)
281 ));
282 html.push_str(&format!(
283 "<span class=\"text-sm text-text\">{}</span>",
284 html_escape(name)
285 ));
286 }
287 if let Some(ref logout) = props.logout_url {
288 html.push_str(&format!(
289 "<a href=\"{}\" class=\"text-sm text-text-muted hover:text-text\">Logout</a>",
290 html_escape(logout)
291 ));
292 }
293 html.push_str("</div></div></header>");
294 html
295}
296
297fn with_runtime(ctx_scripts: &str) -> String {
299 let runtime = format!(
300 "<script>\n{}\n</script>",
301 crate::runtime::FERRO_RUNTIME_JS.as_str()
302 );
303 if ctx_scripts.is_empty() {
304 runtime
305 } else {
306 format!("{ctx_scripts}\n{runtime}")
307 }
308}
309
310pub struct DefaultLayout;
318
319impl Layout for DefaultLayout {
320 fn render(&self, ctx: &LayoutContext) -> String {
321 let wrapper = ferro_wrapper(ctx);
322 let scripts = with_runtime(ctx.scripts);
323 base_document(ctx.title, ctx.head, ctx.body_class, &wrapper, &scripts)
324 }
325}
326
327pub struct AppLayout;
337
338impl Layout for AppLayout {
339 fn render(&self, ctx: &LayoutContext) -> String {
340 let nav = navigation(&[]);
341 let side = sidebar(&[]);
342 let wrapper = ferro_wrapper(ctx);
343
344 let body = format!(
345 r#"{nav}
346 <div class="flex">
347 {side}
348 <main class="flex-1 px-3 py-4 md:p-6">
349 {wrapper}
350 </main>
351 </div>"#,
352 );
353
354 let scripts = with_runtime(ctx.scripts);
355 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
356 }
357}
358
359pub struct AuthLayout;
366
367impl Layout for AuthLayout {
368 fn render(&self, ctx: &LayoutContext) -> String {
369 let wrapper = ferro_wrapper(ctx);
370
371 let body = format!(
372 r#"<div class="min-h-screen flex items-center justify-center">
373 <div class="w-full max-w-md">
374 <div class="bg-card rounded-lg shadow-md p-8">
375 {wrapper}
376 </div>
377 </div>
378 </div>"#,
379 );
380
381 let scripts = with_runtime(ctx.scripts);
382 base_document(ctx.title, ctx.head, ctx.body_class, &body, &scripts)
383 }
384}
385
386pub struct NavItem {
390 pub label: String,
392 pub url: String,
394 pub active: bool,
396}
397
398impl NavItem {
399 pub fn new(label: impl Into<String>, url: impl Into<String>) -> Self {
401 Self {
402 label: label.into(),
403 url: url.into(),
404 active: false,
405 }
406 }
407
408 pub fn active(mut self) -> Self {
410 self.active = true;
411 self
412 }
413}
414
415pub struct SidebarSection {
417 pub title: String,
419 pub items: Vec<NavItem>,
421}
422
423impl SidebarSection {
424 pub fn new(title: impl Into<String>, items: Vec<NavItem>) -> Self {
426 Self {
427 title: title.into(),
428 items,
429 }
430 }
431}
432
433pub fn navigation(items: &[NavItem]) -> String {
438 let mut html =
439 String::from("<nav class=\"bg-background border-b border-border px-4 py-3\"><div class=\"flex items-center space-x-6\">");
440
441 for item in items {
442 let class = if item.active {
443 "text-primary font-medium"
444 } else {
445 "text-text-muted hover:text-text"
446 };
447 html.push_str(&format!(
448 "<a href=\"{}\" class=\"{}\">{}</a>",
449 html_escape(&item.url),
450 class,
451 html_escape(&item.label),
452 ));
453 }
454
455 html.push_str("</div></nav>");
456 html
457}
458
459pub fn sidebar(sections: &[SidebarSection]) -> String {
464 let mut html =
465 String::from("<aside class=\"w-64 bg-surface border-r border-border p-4 min-h-screen\">");
466
467 for section in sections {
468 html.push_str("<div class=\"mb-6\">");
469 html.push_str(&format!(
470 "<h3 class=\"text-xs font-semibold text-text-muted uppercase tracking-wider mb-2\">{}</h3>",
471 html_escape(§ion.title),
472 ));
473 html.push_str("<ul class=\"space-y-1\">");
474 for item in §ion.items {
475 let class = if item.active {
476 "text-primary font-medium"
477 } else {
478 "text-text-muted hover:text-text"
479 };
480 html.push_str(&format!(
481 "<li><a href=\"{}\" class=\"block px-2 py-1 text-sm {}\">{}</a></li>",
482 html_escape(&item.url),
483 class,
484 html_escape(&item.label),
485 ));
486 }
487 html.push_str("</ul></div>");
488 }
489
490 html.push_str("</aside>");
491 html
492}
493
494pub fn footer(text: &str) -> String {
498 format!(
499 "<footer class=\"border-t border-border px-4 py-3 text-center text-sm text-text-muted\">{}</footer>",
500 html_escape(text),
501 )
502}
503
504pub struct DashboardLayoutConfig {
530 pub sidebar: SidebarProps,
532 pub header: HeaderProps,
534 pub sse_url: Option<String>,
538}
539
540pub struct DashboardLayout {
569 pub config: DashboardLayoutConfig,
571}
572
573impl DashboardLayout {
574 pub fn new(config: DashboardLayoutConfig) -> Self {
576 Self { config }
577 }
578}
579
580impl Layout for DashboardLayout {
581 fn render(&self, ctx: &LayoutContext) -> String {
582 let sidebar_html = layout_sidebar_html(&self.config.sidebar);
583 let header_html = layout_header_html(&self.config.header);
584 let wrapper = ferro_wrapper(ctx);
585
586 let body_data = if let Some(ref url) = self.config.sse_url {
587 format!("data-sse-url=\"{}\"", html_escape(url))
588 } else {
589 String::new()
590 };
591
592 let runtime_script = format!(
593 "<script>\n{}\n</script>",
594 crate::runtime::FERRO_RUNTIME_JS.as_str()
595 );
596 let scripts = if ctx.scripts.is_empty() {
597 runtime_script
598 } else {
599 format!("{}\n{}", ctx.scripts, runtime_script)
600 };
601
602 let body_content = format!(
603 r#"{sidebar_html}
604 <div class="flex flex-col md:pl-64">
605 {header_html}
606 <main class="flex-1 px-3 py-4 md:p-6">
607 {wrapper}
608 </main>
609 <div data-toast-container class="fixed top-4 right-4 z-50 flex flex-col gap-2"></div>
610 </div>"#,
611 );
612
613 let body_class = if ctx.body_class.is_empty() {
614 "bg-surface"
615 } else {
616 ctx.body_class
617 };
618
619 base_document_ext(
620 ctx.title,
621 ctx.head,
622 body_class,
623 &body_data,
624 &body_content,
625 &scripts,
626 )
627 }
628}
629
630pub struct LayoutRegistry {
638 layouts: HashMap<String, Box<dyn Layout>>,
639 default: String,
640}
641
642impl LayoutRegistry {
643 pub fn new() -> Self {
645 let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
646 layouts.insert("default".to_string(), Box::new(DefaultLayout));
647 layouts.insert("app".to_string(), Box::new(AppLayout));
648 layouts.insert("auth".to_string(), Box::new(AuthLayout));
649
650 Self {
651 layouts,
652 default: "default".to_string(),
653 }
654 }
655
656 pub fn register(&mut self, name: impl Into<String>, layout: impl Layout + 'static) {
658 self.layouts.insert(name.into(), Box::new(layout));
659 }
660
661 pub fn render(&self, name: Option<&str>, ctx: &LayoutContext) -> String {
664 let layout_name = name.unwrap_or(&self.default);
665 let layout = self
666 .layouts
667 .get(layout_name)
668 .or_else(|| self.layouts.get(&self.default))
669 .expect("default layout must exist in registry");
670 layout.render(ctx)
671 }
672
673 pub fn has(&self, name: &str) -> bool {
675 self.layouts.contains_key(name)
676 }
677}
678
679impl Default for LayoutRegistry {
680 fn default() -> Self {
681 Self::new()
682 }
683}
684
685static GLOBAL_REGISTRY: OnceLock<RwLock<LayoutRegistry>> = OnceLock::new();
688
689pub fn global_registry() -> &'static RwLock<LayoutRegistry> {
693 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(LayoutRegistry::new()))
694}
695
696pub fn register_layout(name: impl Into<String>, layout: impl Layout + 'static) {
700 global_registry()
701 .write()
702 .expect("layout registry poisoned")
703 .register(name, layout);
704}
705
706pub fn render_layout(name: Option<&str>, ctx: &LayoutContext) -> String {
710 global_registry()
711 .read()
712 .expect("layout registry poisoned")
713 .render(name, ctx)
714}
715
716#[cfg(test)]
719mod tests {
720 use super::*;
721
722 fn test_ctx() -> LayoutContext<'static> {
723 LayoutContext {
724 title: "Test Page",
725 content: "<p>Hello</p>",
726 head: "<link rel=\"stylesheet\" href=\"/style.css\">",
727 body_class: "bg-background",
728 view_json: "{\"schema\":\"v1\"}",
729 data_json: "{\"key\":\"value\"}",
730 scripts: "",
731 }
732 }
733
734 #[test]
737 fn base_document_produces_valid_html_structure() {
738 let html = base_document("Title", "<style></style>", "my-class", "<p>body</p>", "");
739 assert!(html.starts_with("<!DOCTYPE html>"));
740 assert!(html.contains("<html lang=\"en\">"));
741 assert!(html.contains("<meta charset=\"UTF-8\">"));
742 assert!(html.contains("<meta name=\"viewport\""));
743 assert!(html.contains("<title>Title</title>"));
744 assert!(html.contains("<style></style>"));
745 assert!(html.contains("<body class=\"my-class\">"));
746 assert!(html.contains("<p>body</p>"));
747 assert!(html.contains("</html>"));
748 }
749
750 #[test]
751 fn base_document_escapes_title() {
752 let html = base_document("Tom & Jerry <script>", "", "", "", "");
753 assert!(html.contains("<title>Tom & Jerry <script></title>"));
754 }
755
756 #[test]
757 fn base_document_escapes_body_class() {
758 let html = base_document("T", "", "a\"b", "", "");
759 assert!(html.contains("class=\"a"b\""));
760 }
761
762 #[test]
765 fn default_layout_renders_all_context_fields() {
766 let ctx = test_ctx();
767 let html = DefaultLayout.render(&ctx);
768
769 assert!(html.contains("<!DOCTYPE html>"));
770 assert!(html.contains("<title>Test Page</title>"));
771 assert!(html.contains("href=\"/style.css\""));
772 assert!(html.contains("class=\"bg-background\""));
773 assert!(html.contains("id=\"ferro-json-ui\""));
774 assert!(html.contains("data-view=\""));
775 assert!(html.contains("data-props=\""));
776 assert!(html.contains("<p>Hello</p>"));
777 }
778
779 #[test]
780 fn default_layout_contains_ferro_wrapper() {
781 let ctx = test_ctx();
782 let html = DefaultLayout.render(&ctx);
783 assert!(html.contains("<div id=\"ferro-json-ui\""));
784 }
785
786 #[test]
789 fn app_layout_includes_nav_and_sidebar() {
790 let ctx = test_ctx();
791 let html = AppLayout.render(&ctx);
792
793 assert!(html.contains("<nav"));
794 assert!(html.contains("<aside"));
795 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
796 assert!(html.contains("<div id=\"ferro-json-ui\""));
797 assert!(html.contains("<p>Hello</p>"));
798 }
799
800 #[test]
801 fn app_layout_has_flex_structure() {
802 let ctx = test_ctx();
803 let html = AppLayout.render(&ctx);
804 assert!(html.contains("class=\"flex\""));
805 }
806
807 #[test]
810 fn auth_layout_centers_content() {
811 let ctx = test_ctx();
812 let html = AuthLayout.render(&ctx);
813
814 assert!(html.contains("flex items-center justify-center"));
815 assert!(html.contains("max-w-md"));
816 assert!(html.contains("rounded-lg shadow-md"));
817 assert!(html.contains("<div id=\"ferro-json-ui\""));
818 }
819
820 #[test]
821 fn auth_layout_has_no_nav_or_sidebar() {
822 let ctx = test_ctx();
823 let html = AuthLayout.render(&ctx);
824 assert!(!html.contains("<nav"));
825 assert!(!html.contains("<aside"));
826 }
827
828 #[test]
831 fn registry_returns_default_for_none_name() {
832 let registry = LayoutRegistry::new();
833 let ctx = test_ctx();
834 let html = registry.render(None, &ctx);
835 assert!(html.contains("<div id=\"ferro-json-ui\""));
837 assert!(!html.contains("<nav"));
838 }
839
840 #[test]
841 fn registry_returns_default_for_unknown_name() {
842 let registry = LayoutRegistry::new();
843 let ctx = test_ctx();
844 let html = registry.render(Some("nonexistent"), &ctx);
845 assert!(html.contains("<div id=\"ferro-json-ui\""));
847 assert!(!html.contains("<nav"));
848 }
849
850 #[test]
851 fn registry_renders_named_layout() {
852 let registry = LayoutRegistry::new();
853 let ctx = test_ctx();
854 let html = registry.render(Some("app"), &ctx);
855 assert!(html.contains("<nav"));
856 assert!(html.contains("<aside"));
857 }
858
859 #[test]
860 fn registry_renders_auth_layout() {
861 let registry = LayoutRegistry::new();
862 let ctx = test_ctx();
863 let html = registry.render(Some("auth"), &ctx);
864 assert!(html.contains("flex items-center justify-center"));
865 }
866
867 #[test]
868 fn registry_has_returns_true_for_registered() {
869 let registry = LayoutRegistry::new();
870 assert!(registry.has("default"));
871 assert!(registry.has("app"));
872 assert!(registry.has("auth"));
873 }
874
875 #[test]
876 fn registry_has_returns_false_for_unknown() {
877 let registry = LayoutRegistry::new();
878 assert!(!registry.has("nonexistent"));
879 }
880
881 #[test]
882 fn registry_register_adds_custom_layout() {
883 let mut registry = LayoutRegistry::new();
884 struct Custom;
885 impl Layout for Custom {
886 fn render(&self, _ctx: &LayoutContext) -> String {
887 "CUSTOM".to_string()
888 }
889 }
890 registry.register("custom", Custom);
891 assert!(registry.has("custom"));
892
893 let ctx = test_ctx();
894 let html = registry.render(Some("custom"), &ctx);
895 assert_eq!(html, "CUSTOM");
896 }
897
898 #[test]
899 fn registry_register_replaces_existing() {
900 let mut registry = LayoutRegistry::new();
901 struct Replacement;
902 impl Layout for Replacement {
903 fn render(&self, _ctx: &LayoutContext) -> String {
904 "REPLACED".to_string()
905 }
906 }
907 registry.register("default", Replacement);
908 let ctx = test_ctx();
909 let html = registry.render(None, &ctx);
910 assert_eq!(html, "REPLACED");
911 }
912
913 #[test]
916 fn global_registry_returns_valid_registry() {
917 let reg = global_registry();
918 let guard = reg.read().unwrap();
919 assert!(guard.has("default"));
920 assert!(guard.has("app"));
921 assert!(guard.has("auth"));
922 }
923
924 #[test]
925 fn render_layout_global_function_works() {
926 let ctx = test_ctx();
927 let html = render_layout(None, &ctx);
928 assert!(html.contains("<!DOCTYPE html>"));
929 assert!(html.contains("<div id=\"ferro-json-ui\""));
930 }
931
932 #[test]
935 fn navigation_renders_empty_gracefully() {
936 let html = navigation(&[]);
937 assert!(html.contains("<nav"));
938 assert!(html.contains("</nav>"));
939 }
940
941 #[test]
942 fn navigation_renders_items_with_correct_classes() {
943 let items = vec![NavItem::new("Home", "/"), NavItem::new("Users", "/users")];
944 let html = navigation(&items);
945 assert!(html.contains("href=\"/\""));
946 assert!(html.contains(">Home</a>"));
947 assert!(html.contains("href=\"/users\""));
948 assert!(html.contains(">Users</a>"));
949 assert!(html.contains("text-text-muted hover:text-text"));
951 }
952
953 #[test]
954 fn navigation_marks_active_item() {
955 let items = vec![
956 NavItem::new("Home", "/").active(),
957 NavItem::new("Users", "/users"),
958 ];
959 let html = navigation(&items);
960 assert!(html.contains("text-primary font-medium"));
961 }
962
963 #[test]
964 fn sidebar_renders_sections_with_headers() {
965 let sections = vec![SidebarSection::new(
966 "Main Menu",
967 vec![
968 NavItem::new("Dashboard", "/"),
969 NavItem::new("Settings", "/settings"),
970 ],
971 )];
972 let html = sidebar(§ions);
973 assert!(html.contains("<aside"));
974 assert!(html.contains("Main Menu"));
975 assert!(html.contains("Dashboard"));
976 assert!(html.contains("Settings"));
977 assert!(html.contains("</aside>"));
978 }
979
980 #[test]
981 fn sidebar_renders_empty_gracefully() {
982 let html = sidebar(&[]);
983 assert!(html.contains("<aside"));
984 assert!(html.contains("</aside>"));
985 }
986
987 #[test]
988 fn footer_renders_text() {
989 let html = footer("Copyright 2026");
990 assert!(html.contains("<footer"));
991 assert!(html.contains("Copyright 2026"));
992 assert!(html.contains("</footer>"));
993 }
994
995 #[test]
996 fn partials_escape_user_strings() {
997 let items = vec![NavItem::new("Tom & Jerry", "/a&b")];
998 let html = navigation(&items);
999 assert!(html.contains("Tom & Jerry"));
1000 assert!(html.contains("href=\"/a&b\""));
1001
1002 let sections = vec![SidebarSection::new(
1003 "A<B",
1004 vec![NavItem::new("<script>", "/x\"y")],
1005 )];
1006 let html = sidebar(§ions);
1007 assert!(html.contains("A<B"));
1008 assert!(html.contains("<script>"));
1009
1010 let html = footer("<script>alert('xss')</script>");
1011 assert!(html.contains("<script>"));
1012 }
1013
1014 #[test]
1017 fn ferro_wrapper_includes_data_attributes() {
1018 let ctx = test_ctx();
1019 let html = ferro_wrapper(&ctx);
1020 assert!(html.contains("id=\"ferro-json-ui\""));
1021 assert!(html.contains("data-view=\""));
1022 assert!(html.contains("data-props=\""));
1023 assert!(html.contains("<p>Hello</p>"));
1024 }
1025
1026 fn dashboard_layout() -> DashboardLayout {
1029 use crate::component::{HeaderProps, SidebarProps};
1030 DashboardLayout::new(DashboardLayoutConfig {
1031 sidebar: SidebarProps {
1032 fixed_top: vec![],
1033 groups: vec![],
1034 fixed_bottom: vec![],
1035 },
1036 header: HeaderProps {
1037 business_name: "Acme".to_string(),
1038 notification_count: None,
1039 user_name: Some("Alice".to_string()),
1040 user_avatar: None,
1041 logout_url: Some("/logout".to_string()),
1042 },
1043 sse_url: None,
1044 })
1045 }
1046
1047 #[test]
1048 fn dashboard_layout_renders_full_html_structure() {
1049 let ctx = test_ctx();
1050 let html = dashboard_layout().render(&ctx);
1051
1052 assert!(html.starts_with("<!DOCTYPE html>"));
1053 assert!(html.contains("<title>Test Page</title>"));
1054 assert!(html.contains("<div id=\"ferro-json-ui\""));
1055 assert!(html.contains("<p>Hello</p>"));
1056 }
1057
1058 #[test]
1059 fn dashboard_layout_has_persistent_sidebar() {
1060 let ctx = test_ctx();
1061 let html = dashboard_layout().render(&ctx);
1062 assert!(html.contains("<aside data-sidebar"));
1063 }
1064
1065 #[test]
1066 fn dashboard_layout_has_persistent_header() {
1067 let ctx = test_ctx();
1068 let html = dashboard_layout().render(&ctx);
1069 assert!(html.contains("<header"));
1070 assert!(html.contains("Acme"));
1071 }
1072
1073 #[test]
1074 fn dashboard_layout_has_main_content_area() {
1075 let ctx = test_ctx();
1076 let html = dashboard_layout().render(&ctx);
1077 assert!(html.contains("<main class=\"flex-1 px-3 py-4 md:p-6\">"));
1078 }
1079
1080 #[test]
1081 fn dashboard_layout_has_toast_container() {
1082 let ctx = test_ctx();
1083 let html = dashboard_layout().render(&ctx);
1084 assert!(html.contains("data-toast-container"));
1085 }
1086
1087 #[test]
1088 fn dashboard_layout_injects_runtime_js() {
1089 let ctx = test_ctx();
1090 let html = dashboard_layout().render(&ctx);
1091 assert!(html.contains("<script>"));
1093 assert!(html.contains("FERRO_RUNTIME_JS") || html.contains("(function()"));
1094 }
1095
1096 #[test]
1097 fn dashboard_layout_has_mobile_hamburger_toggle() {
1098 let ctx = test_ctx();
1099 let html = dashboard_layout().render(&ctx);
1100 assert!(html.contains("data-sidebar-toggle"));
1101 }
1102
1103 #[test]
1104 fn dashboard_layout_no_sse_url_attribute_on_body_when_not_configured() {
1105 let ctx = test_ctx();
1106 let html = dashboard_layout().render(&ctx);
1107 let body_start = html.find("<body").unwrap_or(0);
1111 let body_tag_end = html[body_start..].find('>').unwrap_or(0) + body_start;
1112 let body_tag = &html[body_start..=body_tag_end];
1113 assert!(!body_tag.contains("data-sse-url="));
1114 }
1115
1116 #[test]
1117 fn dashboard_layout_adds_sse_url_to_body_when_configured() {
1118 use crate::component::{HeaderProps, SidebarProps};
1119 let layout = DashboardLayout::new(DashboardLayoutConfig {
1120 sidebar: SidebarProps {
1121 fixed_top: vec![],
1122 groups: vec![],
1123 fixed_bottom: vec![],
1124 },
1125 header: HeaderProps {
1126 business_name: "App".to_string(),
1127 notification_count: None,
1128 user_name: None,
1129 user_avatar: None,
1130 logout_url: None,
1131 },
1132 sse_url: Some("/events".to_string()),
1133 });
1134 let ctx = test_ctx();
1135 let html = layout.render(&ctx);
1136 assert!(html.contains("data-sse-url=\"/events\""));
1137 }
1138
1139 #[test]
1140 fn dashboard_layout_escapes_sse_url_xss() {
1141 use crate::component::{HeaderProps, SidebarProps};
1142 let layout = DashboardLayout::new(DashboardLayoutConfig {
1143 sidebar: SidebarProps {
1144 fixed_top: vec![],
1145 groups: vec![],
1146 fixed_bottom: vec![],
1147 },
1148 header: HeaderProps {
1149 business_name: "App".to_string(),
1150 notification_count: None,
1151 user_name: None,
1152 user_avatar: None,
1153 logout_url: None,
1154 },
1155 sse_url: Some("/events?a=1&b=2".to_string()),
1156 });
1157 let ctx = test_ctx();
1158 let html = layout.render(&ctx);
1159 assert!(html.contains("data-sse-url=\"/events?a=1&b=2\""));
1160 }
1161
1162 #[test]
1163 fn dashboard_layout_notification_toggle_present_with_count() {
1164 use crate::component::{HeaderProps, SidebarProps};
1165 let layout = DashboardLayout::new(DashboardLayoutConfig {
1166 sidebar: SidebarProps {
1167 fixed_top: vec![],
1168 groups: vec![],
1169 fixed_bottom: vec![],
1170 },
1171 header: HeaderProps {
1172 business_name: "App".to_string(),
1173 notification_count: Some(5),
1174 user_name: None,
1175 user_avatar: None,
1176 logout_url: None,
1177 },
1178 sse_url: None,
1179 });
1180 let ctx = test_ctx();
1181 let html = layout.render(&ctx);
1182 assert!(html.contains("data-notification-toggle"));
1183 }
1184
1185 #[test]
1186 fn dashboard_layout_has_sidebar_backdrop() {
1187 let ctx = test_ctx();
1188 let html = dashboard_layout().render(&ctx);
1189 assert!(html.contains("data-sidebar-backdrop"));
1190 assert!(html.contains("bg-black/50"));
1191 assert!(html.contains("md:hidden"));
1192 }
1193
1194 #[test]
1195 fn dashboard_layout_sidebar_mobile_classes() {
1196 let ctx = test_ctx();
1197 let html = dashboard_layout().render(&ctx);
1198 assert!(html.contains("hidden md:flex"));
1200 }
1201
1202 #[test]
1203 fn dashboard_layout_uses_default_body_class() {
1204 let ctx = test_ctx();
1205 let html = dashboard_layout().render(&ctx);
1206 assert!(html.contains("class=\"bg-background\""));
1208 }
1209
1210 #[test]
1211 fn sidebar_nav_item_renders_icon_as_raw_svg() {
1212 let item = SidebarNavItem {
1213 label: "Dashboard".to_string(),
1214 href: "/dashboard".to_string(),
1215 icon: Some("<svg class=\"h-5 w-5\"><path d=\"M3 12l2-2\"/></svg>".to_string()),
1216 active: false,
1217 };
1218 let html = layout_sidebar_nav_item(&item);
1219 assert!(
1220 html.contains("<svg"),
1221 "icon SVG should be rendered raw, not escaped"
1222 );
1223 assert!(
1224 !html.contains("<svg"),
1225 "icon SVG should NOT be html-escaped"
1226 );
1227 assert!(html.contains("Dashboard"), "label should still appear");
1228 }
1229
1230 #[test]
1231 fn sidebar_group_label_uses_normal_casing() {
1232 let group = SidebarGroup {
1233 label: "Cassa".to_string(),
1234 collapsed: false,
1235 items: vec![],
1236 };
1237 let html = layout_sidebar_group(&group);
1238 assert!(html.contains("Cassa"));
1239 assert!(html.contains("font-semibold"));
1240 assert!(html.contains("text-text"));
1241 assert!(
1242 !html.contains("uppercase"),
1243 "sidebar group label should not use uppercase"
1244 );
1245 assert!(
1246 !html.contains("tracking-wider"),
1247 "sidebar group label should not use letter-spacing"
1248 );
1249 }
1250
1251 #[test]
1254 fn layout_sidebar_nav_focus_ring() {
1255 let item = SidebarNavItem {
1256 label: "Dashboard".to_string(),
1257 href: "/dashboard".to_string(),
1258 icon: None,
1259 active: false,
1260 };
1261 let html = layout_sidebar_nav_item(&item);
1262 assert!(
1263 html.contains("focus-visible:ring-primary"),
1264 "layout sidebar nav <a> item should have focus-visible:ring-primary (INT-07)"
1265 );
1266 assert!(
1267 html.contains("duration-150"),
1268 "layout sidebar nav <a> item should have duration-150 (INT-07)"
1269 );
1270 }
1271}