Coverage for src / lexigram / ui / layouts / base_layout.py: 96%
130 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""Base layout class for lexigram-admin.
3Provides the foundation for all admin layouts with common functionality.
4"""
6from __future__ import annotations
8from dataclasses import dataclass, field
9from typing import Any
11from markupsafe import Markup, escape
13from lexigram.ui.config import BaseLayoutConfig
14from lexigram.ui.layouts.html_document import (
15 HTMLDocument,
16)
17from lexigram.ui.layouts.mixins import (
18 CSSManager,
19 JSManager,
20)
23@dataclass
24class BaseLayoutContext:
25 """Base context for all layouts.
27 Common context data used across layouts.
28 """
30 # Page info
31 title: str = ""
32 description: str = ""
34 # Current user (if authenticated)
35 user_name: str | None = None
36 user_email: str | None = None
37 user_avatar: str | None = None
38 user_role: str | None = None
40 # URLs
41 base_url: str = "/admin"
42 logout_url: str = "/admin/logout"
43 login_url: str = "/admin/login"
45 # Flash messages
46 flash_messages: list[tuple[str, str]] = field(default_factory=list)
48 # CSRF
49 csrf_token: str | None = None
51 # Custom data
52 extra: dict[str, Any] = field(default_factory=dict)
55class LayoutBase(HTMLDocument):
56 """Base layout class for all admin layouts.
58 Combines HTMLDocument with CSS, JS, HTMX, and theming via composition.
59 Subclasses should implement render_body_content() and optionally override
60 other methods for customization.
62 Example:
63 class MyLayout(LayoutBase):
64 def render_body_content(self, content: str = "", **context) -> str:
65 return f'<main>{content}</main>'
66 """
68 def __init__(
69 self,
70 config: BaseLayoutConfig | None = None,
71 context: BaseLayoutContext | None = None,
72 ):
73 """Initialize the layout.
75 Args:
76 config: Layout configuration
77 context: Layout context
78 """
79 # Initialize parent document
80 resolved_config = config or BaseLayoutConfig()
81 super().__init__(config=resolved_config)
83 # Initialize asset managers via composition
84 self._css = CSSManager()
85 self._js = JSManager()
87 # Store typed config and context
88 self.layout_config: BaseLayoutConfig = resolved_config
89 self.context = context or BaseLayoutContext()
91 # Theme attributes
92 self.theme: str = self.layout_config.theme
93 self.primary_color: str = self.layout_config.primary_color
95 # HTMX attributes
96 self.htmx_enabled: bool = self.layout_config.htmx_enabled
97 self.htmx_boost: bool = self.layout_config.htmx_boost
98 self.htmx_version: str = self.layout_config.htmx_version
99 self.htmx_indicator: str = ".htmx-indicator"
101 # Setup default CSS/JS
102 self._setup_defaults()
104 def _setup_defaults(self) -> None:
105 """Setup default CSS and JS files."""
106 # Add configured CSS files
107 for css_file in self.layout_config.css_files:
108 self._css.add_css(css_file)
110 # Add configured JS files
111 for js_file in self.layout_config.js_files:
112 self._js.add_js(js_file, defer=True)
114 # CSS delegation methods
115 def add_css(self, href: str, **attrs: str) -> None:
116 """Add a CSS file link.
118 Args:
119 href: URL to CSS file
120 **attrs: Additional attributes (media, crossorigin, etc.)
121 """
122 self._css.add_css(href, **attrs)
124 def add_inline_style(self, css: str) -> None:
125 """Add inline CSS.
127 Args:
128 css: CSS rules
129 """
130 self._css.add_inline_style(css)
132 def render_css(self) -> str:
133 """Render all CSS as HTML.
135 Returns:
136 HTML string with link and style tags
137 """
138 return self._css.render_css()
140 # JS delegation methods
141 def add_js(
142 self,
143 src: str,
144 defer: bool = False,
145 async_: bool = False,
146 **attrs: str,
147 ) -> None:
148 """Add a JavaScript file.
150 Args:
151 src: URL to JS file
152 defer: Add defer attribute
153 async_: Add async attribute
154 **attrs: Additional attributes
155 """
156 self._js.add_js(src, defer=defer, async_=async_, **attrs)
158 def add_inline_script(self, script: str, defer: bool = False) -> None:
159 """Add inline JavaScript.
161 Args:
162 script: JavaScript code
163 defer: If True, render at end of body
164 """
165 self._js.add_inline_script(script, defer=defer)
167 def render_js_head(self) -> str:
168 """Render JS for head section.
170 Returns:
171 HTML string with script tags
172 """
173 return self._js.render_js_head()
175 def render_js_body_end(self) -> str:
176 """Render deferred JS for end of body.
178 Returns:
179 HTML string with script tags
180 """
181 return self._js.render_js_body_end()
183 # HTMX methods (inlined from HTMXMixin)
184 def get_htmx_config(self) -> dict[str, Any]:
185 """Get HTMX configuration.
187 Returns:
188 Configuration dict for htmx.config
189 """
190 return {
191 "historyCacheSize": 10,
192 "refreshOnHistoryMiss": True,
193 "defaultSwapStyle": "innerHTML",
194 "defaultSwapDelay": 0,
195 "defaultSettleDelay": 20,
196 "includeIndicatorStyles": True,
197 "indicatorClass": "htmx-indicator",
198 "requestClass": "htmx-request",
199 "addedClass": "htmx-added",
200 "swappingClass": "htmx-swapping",
201 "settlingClass": "htmx-settling",
202 }
204 def render_htmx_head(self) -> str:
205 """Render HTMX script tag for head.
207 Returns:
208 HTML string with HTMX script
209 """
210 if not self.htmx_enabled:
211 return ""
213 return f'<script src="https://unpkg.com/htmx.org@{self.htmx_version}"></script>'
215 def get_htmx_body_attrs(self) -> str:
216 """Get HTMX-related body attributes.
218 Returns:
219 String of HTML attributes
220 """
221 if not self.htmx_enabled:
222 return ""
224 attrs = []
225 if self.htmx_boost:
226 attrs.append('hx-boost="true"')
227 if self.htmx_indicator:
228 attrs.append(f'hx-indicator="{escape(self.htmx_indicator)}"')
230 return " ".join(attrs)
232 # Theme methods (inlined from ThemeMixin)
233 def get_theme_css_variables(self) -> str:
234 """Generate ShadCN-compatible CSS variable declarations."""
235 from lexigram.ui.styles.design_tokens import render_all_tokens
237 return render_all_tokens()
239 def get_theme_html_attrs(self) -> str:
240 """Get theme-related HTML element attributes.
242 Returns:
243 String of HTML attributes
244 """
245 return f'data-theme="{escape(self.theme)}"'
247 def get_dark_mode_script(self) -> str:
248 """Inline script that applies dark class before paint (prevents FOUC).
250 Must run synchronously in ``<head>`` before any CSS paints.
251 """
252 return """
253<script>
254(function() {
255 var theme = localStorage.getItem('theme');
256 if (theme === 'dark' || ((!theme || theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
257 document.documentElement.classList.add('dark');
258 }
259})();
260</script>"""
262 def get_alpine_theme_data(self) -> str:
263 """Register Alpine.js theme toggle component data."""
264 return """
265<script>
266document.addEventListener('alpine:init', function() {
267 Alpine.data('themeToggle', function() {
268 return {
269 theme: localStorage.getItem('theme') || 'system',
270 init: function() {
271 var val = this.theme;
272 if (val === 'dark' || (val === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
273 document.documentElement.classList.add('dark');
274 }
275 this.$watch('theme', function(val) {
276 localStorage.setItem('theme', val);
277 if (val === 'dark' || (val === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
278 document.documentElement.classList.add('dark');
279 } else {
280 document.documentElement.classList.remove('dark');
281 }
282 });
283 },
284 cycleTheme: function() {
285 var modes = ['light', 'dark', 'system'];
286 var idx = modes.indexOf(this.theme);
287 this.theme = modes[(idx + 1) % modes.length];
288 }
289 };
290 });
291});
292</script>"""
294 def render( # type: ignore[override]
295 self,
296 content: str | Markup = "",
297 title: str | None = None,
298 **extra_context: Any,
299 ) -> Markup:
300 """Render the complete layout.
302 Args:
303 content: Main page content
304 title: Page title (overrides context title)
305 **extra_context: Additional context
307 Returns:
308 Complete HTML document as Markup
309 """
310 # Use provided title or fall back to context
311 page_title = title or self.context.title
312 if page_title and self.layout_config.site_name:
313 full_title = f"{page_title} - {self.layout_config.site_name}"
314 else:
315 full_title = page_title or self.layout_config.site_name
317 # Merge extra context
318 ctx = {
319 "content": content,
320 "context": self.context,
321 **extra_context,
322 }
324 return super().render(title=full_title, **ctx)
326 def get_body_attributes(self, **context: Any) -> str:
327 """Get body element attributes including theme and HTMX."""
328 attrs = []
330 # Theme
331 attrs.append(self.get_theme_html_attrs())
333 # HTMX
334 htmx_attrs = self.get_htmx_body_attrs()
335 if htmx_attrs:
336 attrs.append(htmx_attrs)
338 return " ".join(filter(None, attrs))
340 def render_head_content(self, **context: Any) -> str:
341 """Render head content (CSS, theme, HTMX)."""
342 parts: list[str] = []
344 # Dark mode — apply before paint to prevent FOUC
345 parts.append(self.get_dark_mode_script())
347 # Theme CSS variables
348 parts.append("<style>")
349 parts.append(self.get_theme_css_variables())
350 parts.append("</style>")
352 # HTMX
353 htmx_head = self.render_htmx_head()
354 if htmx_head:
355 parts.append(htmx_head)
357 # Alpine.js if enabled
358 if self.layout_config.include_alpine:
359 parts.append(
360 f'<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@{self.layout_config.alpine_version}/dist/cdn.min.js"></script>',
361 )
363 # External CSS
364 css_html = self.render_css()
365 if css_html:
366 parts.append(css_html)
368 # JS in head
369 js_head = self.render_js_head()
370 if js_head:
371 parts.append(js_head)
373 return "\n".join(parts)
375 def render_body_content(self, content: str = "", **context: Any) -> str | Markup:
376 """Render body content.
378 Default implementation just returns content.
379 Subclasses should override to add layout structure.
381 Args:
382 content: Main content
383 **context: Additional context
385 Returns:
386 HTML string or Markup
387 """
388 return content
390 def render_body_end(self, **context: Any) -> str:
391 """Render content at end of body (deferred scripts)."""
392 parts: list[str] = []
394 # Flash messages as toast script
395 if self.context.flash_messages:
396 parts.append(self._render_flash_script())
398 # Dark mode Alpine.js data
399 parts.append(self.get_alpine_theme_data())
401 # Deferred JS
402 js_end = self.render_js_body_end()
403 if js_end:
404 parts.append(js_end)
406 return "\n".join(parts)
408 def _render_flash_script(self) -> str:
409 """Render JavaScript to display flash messages as toasts."""
410 if not self.context.flash_messages:
411 return ""
413 # Convert flash messages to JS
414 messages = []
415 for msg_type, message in self.context.flash_messages:
416 messages.append(
417 f'{{type: "{escape(msg_type)}", message: "{escape(message)}"}}',
418 )
420 return f"""
421<script>
422document.addEventListener('DOMContentLoaded', function() {{
423 const messages = [{", ".join(messages)}];
424 messages.forEach(function(m) {{
425 if (window.showToast) {{
426 window.showToast(m.message, m.type);
427 }} else {{
428 console.log('[' + m.type + ']', m.message);
429 }}
430 }});
431}});
432</script>
433"""
436__all__ = ["BaseLayoutConfig", "BaseLayoutContext", "LayoutBase"]