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

1"""HTML Document base class. 

2 

3Provides the fundamental HTML document structure that all layouts inherit from. 

4Handles DOCTYPE, html, head, and body with common meta tags. 

5""" 

6 

7from __future__ import annotations 

8 

9from abc import ABC, abstractmethod 

10from typing import Any 

11 

12from markupsafe import Markup, escape 

13 

14from lexigram.ui.config import HTMLDocumentConfig 

15 

16 

17class HTMLDocument(ABC): 

18 """Abstract base class for HTML document generation. 

19 

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. 

23 

24 Features: 

25 - DOCTYPE html5 

26 - Configurable lang, charset, meta tags 

27 - Extensible head and body sections 

28 - Escape-safe by default 

29 """ 

30 

31 def __init__(self, config: HTMLDocumentConfig | None = None): 

32 """Initialize the document. 

33 

34 Args: 

35 config: Document configuration 

36 """ 

37 self.config = config or HTMLDocumentConfig() 

38 

39 def render(self, title: str = "", **context: Any) -> Markup: 

40 """Render the complete HTML document. 

41 

42 Args: 

43 title: Document title 

44 **context: Additional context for subclass rendering 

45 

46 Returns: 

47 Complete HTML document as Markup 

48 """ 

49 parts: list[str] = [] 

50 

51 # DOCTYPE 

52 parts.append("<!DOCTYPE html>") 

53 

54 # HTML open with lang 

55 parts.append(f'<html lang="{escape(self.config.lang)}">') 

56 

57 # Head 

58 parts.append(self._render_head(title, **context)) 

59 

60 # Body 

61 parts.append(self._render_body(**context)) 

62 

63 # Close HTML 

64 parts.append("</html>") 

65 

66 return Markup("\n".join(parts)) 

67 

68 def _render_head(self, title: str, **context: Any) -> str: 

69 """Render the head section.""" 

70 parts: list[str] = [] 

71 

72 parts.append("<head>") 

73 

74 # Charset (must be first) 

75 parts.append(f'<meta charset="{escape(self.config.charset)}">') 

76 

77 # Viewport 

78 if self.config.viewport: 

79 parts.append( 

80 f'<meta name="viewport" content="{escape(self.config.viewport)}">', 

81 ) 

82 

83 # Title 

84 if title: 

85 parts.append(f"<title>{escape(title)}</title>") 

86 

87 # Description 

88 if self.config.description: 

89 parts.append( 

90 f'<meta name="description" content="{escape(self.config.description)}">', 

91 ) 

92 

93 # Keywords 

94 if self.config.keywords: 

95 keywords = ", ".join(self.config.keywords) 

96 parts.append(f'<meta name="keywords" content="{escape(keywords)}">') 

97 

98 # Author 

99 if self.config.author: 

100 parts.append(f'<meta name="author" content="{escape(self.config.author)}">') 

101 

102 # Robots 

103 if self.config.robots: 

104 parts.append(f'<meta name="robots" content="{escape(self.config.robots)}">') 

105 

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 ) 

111 

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 ) 

117 

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 ) 

139 

140 # Subclass head content (CSS, JS, etc.) 

141 head_content = self.render_head_content(**context) 

142 if head_content: 

143 parts.append(head_content) 

144 

145 # Extra head content 

146 if self.config.extra_head: 

147 parts.append(self.config.extra_head) 

148 

149 parts.append("</head>") 

150 

151 return "\n".join(parts) 

152 

153 def _render_body(self, **context: Any) -> str: 

154 """Render the body section.""" 

155 parts: list[str] = [] 

156 

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

163 

164 # Body content from subclass 

165 body_content = self.render_body_content(**context) 

166 if body_content: 

167 parts.append(str(body_content)) 

168 

169 # Body end content (scripts, etc.) 

170 body_end = self.render_body_end(**context) 

171 if body_end: 

172 parts.append(body_end) 

173 

174 parts.append("</body>") 

175 

176 return "\n".join(parts) 

177 

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

179 """Get body element attributes. 

180 

181 Override in subclasses to add classes, data attributes, etc. 

182 

183 Returns: 

184 String of HTML attributes 

185 """ 

186 return "" 

187 

188 @abstractmethod 

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

190 """Render content for the head section. 

191 

192 Subclasses should implement this to add CSS links, inline styles, etc. 

193 

194 Returns: 

195 HTML string for head section 

196 """ 

197 

198 @abstractmethod 

199 def render_body_content(self, **context: Any) -> str | Markup: 

200 """Render the main body content. 

201 

202 Subclasses should implement this to render the page content. 

203 

204 Returns: 

205 HTML string or Markup for body content 

206 """ 

207 

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

209 """Render content at the end of body (before </body>). 

210 

211 Subclasses can override to add scripts, etc. 

212 

213 Returns: 

214 HTML string for body end 

215 """ 

216 return "" 

217 

218 

219__all__ = ["HTMLDocument", "HTMLDocumentConfig"]