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

1"""Base layout class for lexigram-admin. 

2 

3Provides the foundation for all admin layouts with common functionality. 

4""" 

5 

6from __future__ import annotations 

7 

8from dataclasses import dataclass, field 

9from typing import Any 

10 

11from markupsafe import Markup, escape 

12 

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) 

21 

22 

23@dataclass 

24class BaseLayoutContext: 

25 """Base context for all layouts. 

26 

27 Common context data used across layouts. 

28 """ 

29 

30 # Page info 

31 title: str = "" 

32 description: str = "" 

33 

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 

39 

40 # URLs 

41 base_url: str = "/admin" 

42 logout_url: str = "/admin/logout" 

43 login_url: str = "/admin/login" 

44 

45 # Flash messages 

46 flash_messages: list[tuple[str, str]] = field(default_factory=list) 

47 

48 # CSRF 

49 csrf_token: str | None = None 

50 

51 # Custom data 

52 extra: dict[str, Any] = field(default_factory=dict) 

53 

54 

55class LayoutBase(HTMLDocument): 

56 """Base layout class for all admin layouts. 

57 

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. 

61 

62 Example: 

63 class MyLayout(LayoutBase): 

64 def render_body_content(self, content: str = "", **context) -> str: 

65 return f'<main>{content}</main>' 

66 """ 

67 

68 def __init__( 

69 self, 

70 config: BaseLayoutConfig | None = None, 

71 context: BaseLayoutContext | None = None, 

72 ): 

73 """Initialize the layout. 

74 

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) 

82 

83 # Initialize asset managers via composition 

84 self._css = CSSManager() 

85 self._js = JSManager() 

86 

87 # Store typed config and context 

88 self.layout_config: BaseLayoutConfig = resolved_config 

89 self.context = context or BaseLayoutContext() 

90 

91 # Theme attributes 

92 self.theme: str = self.layout_config.theme 

93 self.primary_color: str = self.layout_config.primary_color 

94 

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" 

100 

101 # Setup default CSS/JS 

102 self._setup_defaults() 

103 

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) 

109 

110 # Add configured JS files 

111 for js_file in self.layout_config.js_files: 

112 self._js.add_js(js_file, defer=True) 

113 

114 # CSS delegation methods 

115 def add_css(self, href: str, **attrs: str) -> None: 

116 """Add a CSS file link. 

117 

118 Args: 

119 href: URL to CSS file 

120 **attrs: Additional attributes (media, crossorigin, etc.) 

121 """ 

122 self._css.add_css(href, **attrs) 

123 

124 def add_inline_style(self, css: str) -> None: 

125 """Add inline CSS. 

126 

127 Args: 

128 css: CSS rules 

129 """ 

130 self._css.add_inline_style(css) 

131 

132 def render_css(self) -> str: 

133 """Render all CSS as HTML. 

134 

135 Returns: 

136 HTML string with link and style tags 

137 """ 

138 return self._css.render_css() 

139 

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. 

149 

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) 

157 

158 def add_inline_script(self, script: str, defer: bool = False) -> None: 

159 """Add inline JavaScript. 

160 

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) 

166 

167 def render_js_head(self) -> str: 

168 """Render JS for head section. 

169 

170 Returns: 

171 HTML string with script tags 

172 """ 

173 return self._js.render_js_head() 

174 

175 def render_js_body_end(self) -> str: 

176 """Render deferred JS for end of body. 

177 

178 Returns: 

179 HTML string with script tags 

180 """ 

181 return self._js.render_js_body_end() 

182 

183 # HTMX methods (inlined from HTMXMixin) 

184 def get_htmx_config(self) -> dict[str, Any]: 

185 """Get HTMX configuration. 

186 

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 } 

203 

204 def render_htmx_head(self) -> str: 

205 """Render HTMX script tag for head. 

206 

207 Returns: 

208 HTML string with HTMX script 

209 """ 

210 if not self.htmx_enabled: 

211 return "" 

212 

213 return f'<script src="https://unpkg.com/htmx.org@{self.htmx_version}"></script>' 

214 

215 def get_htmx_body_attrs(self) -> str: 

216 """Get HTMX-related body attributes. 

217 

218 Returns: 

219 String of HTML attributes 

220 """ 

221 if not self.htmx_enabled: 

222 return "" 

223 

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)}"') 

229 

230 return " ".join(attrs) 

231 

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 

236 

237 return render_all_tokens() 

238 

239 def get_theme_html_attrs(self) -> str: 

240 """Get theme-related HTML element attributes. 

241 

242 Returns: 

243 String of HTML attributes 

244 """ 

245 return f'data-theme="{escape(self.theme)}"' 

246 

247 def get_dark_mode_script(self) -> str: 

248 """Inline script that applies dark class before paint (prevents FOUC). 

249 

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>""" 

261 

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>""" 

293 

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. 

301 

302 Args: 

303 content: Main page content 

304 title: Page title (overrides context title) 

305 **extra_context: Additional context 

306 

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 

316 

317 # Merge extra context 

318 ctx = { 

319 "content": content, 

320 "context": self.context, 

321 **extra_context, 

322 } 

323 

324 return super().render(title=full_title, **ctx) 

325 

326 def get_body_attributes(self, **context: Any) -> str: 

327 """Get body element attributes including theme and HTMX.""" 

328 attrs = [] 

329 

330 # Theme 

331 attrs.append(self.get_theme_html_attrs()) 

332 

333 # HTMX 

334 htmx_attrs = self.get_htmx_body_attrs() 

335 if htmx_attrs: 

336 attrs.append(htmx_attrs) 

337 

338 return " ".join(filter(None, attrs)) 

339 

340 def render_head_content(self, **context: Any) -> str: 

341 """Render head content (CSS, theme, HTMX).""" 

342 parts: list[str] = [] 

343 

344 # Dark mode — apply before paint to prevent FOUC 

345 parts.append(self.get_dark_mode_script()) 

346 

347 # Theme CSS variables 

348 parts.append("<style>") 

349 parts.append(self.get_theme_css_variables()) 

350 parts.append("</style>") 

351 

352 # HTMX 

353 htmx_head = self.render_htmx_head() 

354 if htmx_head: 

355 parts.append(htmx_head) 

356 

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 ) 

362 

363 # External CSS 

364 css_html = self.render_css() 

365 if css_html: 

366 parts.append(css_html) 

367 

368 # JS in head 

369 js_head = self.render_js_head() 

370 if js_head: 

371 parts.append(js_head) 

372 

373 return "\n".join(parts) 

374 

375 def render_body_content(self, content: str = "", **context: Any) -> str | Markup: 

376 """Render body content. 

377 

378 Default implementation just returns content. 

379 Subclasses should override to add layout structure. 

380 

381 Args: 

382 content: Main content 

383 **context: Additional context 

384 

385 Returns: 

386 HTML string or Markup 

387 """ 

388 return content 

389 

390 def render_body_end(self, **context: Any) -> str: 

391 """Render content at end of body (deferred scripts).""" 

392 parts: list[str] = [] 

393 

394 # Flash messages as toast script 

395 if self.context.flash_messages: 

396 parts.append(self._render_flash_script()) 

397 

398 # Dark mode Alpine.js data 

399 parts.append(self.get_alpine_theme_data()) 

400 

401 # Deferred JS 

402 js_end = self.render_js_body_end() 

403 if js_end: 

404 parts.append(js_end) 

405 

406 return "\n".join(parts) 

407 

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 "" 

412 

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 ) 

419 

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""" 

434 

435 

436__all__ = ["BaseLayoutConfig", "BaseLayoutContext", "LayoutBase"]