Coverage for src / lexigram / ui / layouts / html_document.py: 99%
77 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"""HTML Document base class.
3Provides the fundamental HTML document structure that all layouts inherit from.
4Handles DOCTYPE, html, head, and body with common meta tags.
5"""
7from __future__ import annotations
9from abc import ABC, abstractmethod
10from typing import Any
12from markupsafe import Markup, escape
14from lexigram.ui.config import HTMLDocumentConfig
17class HTMLDocument(ABC):
18 """Abstract base class for HTML document generation.
20 Provides the basic HTML5 document structure that all layouts build upon.
21 Subclasses implement render_head_content(), render_body_content(),
22 and render_body_end() to customize the document.
24 Features:
25 - DOCTYPE html5
26 - Configurable lang, charset, meta tags
27 - Extensible head and body sections
28 - Escape-safe by default
29 """
31 def __init__(self, config: HTMLDocumentConfig | None = None):
32 """Initialize the document.
34 Args:
35 config: Document configuration
36 """
37 self.config = config or HTMLDocumentConfig()
39 def render(self, title: str = "", **context: Any) -> Markup:
40 """Render the complete HTML document.
42 Args:
43 title: Document title
44 **context: Additional context for subclass rendering
46 Returns:
47 Complete HTML document as Markup
48 """
49 parts: list[str] = []
51 # DOCTYPE
52 parts.append("<!DOCTYPE html>")
54 # HTML open with lang
55 parts.append(f'<html lang="{escape(self.config.lang)}">')
57 # Head
58 parts.append(self._render_head(title, **context))
60 # Body
61 parts.append(self._render_body(**context))
63 # Close HTML
64 parts.append("</html>")
66 return Markup("\n".join(parts))
68 def _render_head(self, title: str, **context: Any) -> str:
69 """Render the head section."""
70 parts: list[str] = []
72 parts.append("<head>")
74 # Charset (must be first)
75 parts.append(f'<meta charset="{escape(self.config.charset)}">')
77 # Viewport
78 if self.config.viewport:
79 parts.append(
80 f'<meta name="viewport" content="{escape(self.config.viewport)}">',
81 )
83 # Title
84 if title:
85 parts.append(f"<title>{escape(title)}</title>")
87 # Description
88 if self.config.description:
89 parts.append(
90 f'<meta name="description" content="{escape(self.config.description)}">',
91 )
93 # Keywords
94 if self.config.keywords:
95 keywords = ", ".join(self.config.keywords)
96 parts.append(f'<meta name="keywords" content="{escape(keywords)}">')
98 # Author
99 if self.config.author:
100 parts.append(f'<meta name="author" content="{escape(self.config.author)}">')
102 # Robots
103 if self.config.robots:
104 parts.append(f'<meta name="robots" content="{escape(self.config.robots)}">')
106 # Theme color
107 if self.config.theme_color:
108 parts.append(
109 f'<meta name="theme-color" content="{escape(self.config.theme_color)}">',
110 )
112 # Favicon
113 if self.config.favicon:
114 parts.append(
115 f'<link rel="icon" type="{escape(self.config.favicon_type)}" href="{escape(self.config.favicon)}">',
116 )
118 # Open Graph tags
119 if self.config.og_title:
120 parts.append(
121 f'<meta property="og:title" content="{escape(self.config.og_title)}">',
122 )
123 if self.config.og_description:
124 parts.append(
125 f'<meta property="og:description" content="{escape(self.config.og_description)}">',
126 )
127 if self.config.og_image:
128 parts.append(
129 f'<meta property="og:image" content="{escape(self.config.og_image)}">',
130 )
131 if self.config.og_url:
132 parts.append(
133 f'<meta property="og:url" content="{escape(self.config.og_url)}">',
134 )
135 if self.config.og_type:
136 parts.append(
137 f'<meta property="og:type" content="{escape(self.config.og_type)}">',
138 )
140 # Subclass head content (CSS, JS, etc.)
141 head_content = self.render_head_content(**context)
142 if head_content:
143 parts.append(head_content)
145 # Extra head content
146 if self.config.extra_head:
147 parts.append(self.config.extra_head)
149 parts.append("</head>")
151 return "\n".join(parts)
153 def _render_body(self, **context: Any) -> str:
154 """Render the body section."""
155 parts: list[str] = []
157 # Body open with attributes
158 body_attrs = self.get_body_attributes(**context)
159 if body_attrs:
160 parts.append(f"<body {body_attrs}>")
161 else:
162 parts.append("<body>")
164 # Body content from subclass
165 body_content = self.render_body_content(**context)
166 if body_content:
167 parts.append(str(body_content))
169 # Body end content (scripts, etc.)
170 body_end = self.render_body_end(**context)
171 if body_end:
172 parts.append(body_end)
174 parts.append("</body>")
176 return "\n".join(parts)
178 def get_body_attributes(self, **context: Any) -> str:
179 """Get body element attributes.
181 Override in subclasses to add classes, data attributes, etc.
183 Returns:
184 String of HTML attributes
185 """
186 return ""
188 @abstractmethod
189 def render_head_content(self, **context: Any) -> str:
190 """Render content for the head section.
192 Subclasses should implement this to add CSS links, inline styles, etc.
194 Returns:
195 HTML string for head section
196 """
198 @abstractmethod
199 def render_body_content(self, **context: Any) -> str | Markup:
200 """Render the main body content.
202 Subclasses should implement this to render the page content.
204 Returns:
205 HTML string or Markup for body content
206 """
208 def render_body_end(self, **context: Any) -> str:
209 """Render content at the end of body (before </body>).
211 Subclasses can override to add scripts, etc.
213 Returns:
214 HTML string for body end
215 """
216 return ""
219__all__ = ["HTMLDocument", "HTMLDocumentConfig"]