html5cc 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- html5/__init__.py +42 -0
- html5/css.py +384 -0
- html5/document.py +166 -0
- html5/elements.py +397 -0
- html5/js.py +85 -0
- html5/version.py +3 -0
- html5/writer.py +48 -0
- html5cc-0.3.0.dist-info/METADATA +148 -0
- html5cc-0.3.0.dist-info/RECORD +12 -0
- html5cc-0.3.0.dist-info/WHEEL +5 -0
- html5cc-0.3.0.dist-info/licenses/LICENSE +21 -0
- html5cc-0.3.0.dist-info/top_level.txt +1 -0
html5/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""HTML5 document and CSS3 helpers."""
|
|
2
|
+
|
|
3
|
+
from .css import (
|
|
4
|
+
CSSAtRule,
|
|
5
|
+
CSSComment,
|
|
6
|
+
CSSDeclaration,
|
|
7
|
+
CSSLink,
|
|
8
|
+
CSSImportRule,
|
|
9
|
+
CSSInlineStyle,
|
|
10
|
+
CSSKeyframe,
|
|
11
|
+
CSSKeyframesRule,
|
|
12
|
+
CSSLayerRule,
|
|
13
|
+
CSSMediaRule,
|
|
14
|
+
CSSNode,
|
|
15
|
+
CSSRule,
|
|
16
|
+
CSSStyleElement,
|
|
17
|
+
CSSStyleSheet,
|
|
18
|
+
CSSSupportsRule,
|
|
19
|
+
BOOTSTRAP5_CSS_URL,
|
|
20
|
+
GOOGLE_FONTS_PRECONNECT_URL,
|
|
21
|
+
GOOGLE_FONTS_STATIC_URL,
|
|
22
|
+
TAILWIND_PLAY_CDN_URL,
|
|
23
|
+
bootstrap5_stylesheet,
|
|
24
|
+
google_fonts_assets,
|
|
25
|
+
google_fonts_url,
|
|
26
|
+
inline_style,
|
|
27
|
+
tailwind_script,
|
|
28
|
+
style_tag,
|
|
29
|
+
)
|
|
30
|
+
from .document import Comment, Doctype, Element, HtmlDocument, Node, Raw, Text, comment, doctype, doctype_node, element, raw, text
|
|
31
|
+
from .elements import *
|
|
32
|
+
from .version import __version__
|
|
33
|
+
from .writer import MarkupWriter
|
|
34
|
+
from .js import (
|
|
35
|
+
JSNode,
|
|
36
|
+
JSScript,
|
|
37
|
+
bootstrap5_bundle_script,
|
|
38
|
+
google_charts_loader,
|
|
39
|
+
google_charts_package_loader,
|
|
40
|
+
javascript_link,
|
|
41
|
+
javascript_script,
|
|
42
|
+
)
|
html5/css.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""CSS3 stylesheet helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Mapping, Sequence
|
|
7
|
+
from urllib.parse import quote_plus
|
|
8
|
+
|
|
9
|
+
from .document import Raw, element, raw
|
|
10
|
+
from .js import JSScript
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize_property_name(name: str) -> str:
|
|
14
|
+
return name.replace("_", "-")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_at_rule_name(name: str) -> str:
|
|
18
|
+
return name.removeprefix("@").replace("_", "-")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CSSNode:
|
|
22
|
+
"""Base class for CSS document nodes."""
|
|
23
|
+
|
|
24
|
+
def render(self) -> str:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CSSComment(CSSNode):
|
|
30
|
+
"""Render a CSS comment."""
|
|
31
|
+
|
|
32
|
+
value: str
|
|
33
|
+
|
|
34
|
+
def render(self) -> str:
|
|
35
|
+
return f"/* {self.value} */"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class CSSDeclaration(CSSNode):
|
|
40
|
+
"""Render a CSS declaration pair."""
|
|
41
|
+
|
|
42
|
+
property_name: str
|
|
43
|
+
value: Any
|
|
44
|
+
important: bool = False
|
|
45
|
+
|
|
46
|
+
def render(self) -> str:
|
|
47
|
+
if self.value is None:
|
|
48
|
+
return ""
|
|
49
|
+
suffix = " !important" if self.important else ""
|
|
50
|
+
return f"{_normalize_property_name(self.property_name)}: {self.value}{suffix};"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _coerce_declaration(value: CSSDeclaration | tuple[str, Any] | Mapping[str, Any]) -> CSSDeclaration:
|
|
54
|
+
if isinstance(value, CSSDeclaration):
|
|
55
|
+
return value
|
|
56
|
+
if isinstance(value, Mapping):
|
|
57
|
+
if len(value) != 1:
|
|
58
|
+
raise ValueError("CSS declaration mapping must contain exactly one property")
|
|
59
|
+
property_name, property_value = next(iter(value.items()))
|
|
60
|
+
return CSSDeclaration(property_name=str(property_name), value=property_value)
|
|
61
|
+
property_name, property_value = value
|
|
62
|
+
return CSSDeclaration(property_name=str(property_name), value=property_value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _render_declarations(declarations: tuple[CSSDeclaration | tuple[str, Any] | Mapping[str, Any], ...]) -> str:
|
|
66
|
+
parts: list[str] = []
|
|
67
|
+
for declaration in declarations:
|
|
68
|
+
rendered = _coerce_declaration(declaration).render()
|
|
69
|
+
if rendered:
|
|
70
|
+
parts.append(rendered)
|
|
71
|
+
return " ".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render_nodes(nodes: tuple[CSSNode | str, ...]) -> str:
|
|
75
|
+
parts: list[str] = []
|
|
76
|
+
for node in nodes:
|
|
77
|
+
parts.append(node.render() if isinstance(node, CSSNode) else str(node))
|
|
78
|
+
return "\n".join(parts)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, init=False)
|
|
82
|
+
class CSSInlineStyle(CSSNode):
|
|
83
|
+
"""Render a sequence of declarations for use in a style attribute."""
|
|
84
|
+
|
|
85
|
+
declarations: tuple[CSSDeclaration | tuple[str, Any] | Mapping[str, Any], ...] = ()
|
|
86
|
+
|
|
87
|
+
def __init__(self, *declarations: CSSDeclaration | tuple[str, Any] | Mapping[str, Any]) -> None:
|
|
88
|
+
object.__setattr__(self, "declarations", declarations)
|
|
89
|
+
|
|
90
|
+
def render(self) -> str:
|
|
91
|
+
return _render_declarations(self.declarations)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class CSSLink(CSSNode):
|
|
96
|
+
"""Render a stylesheet link element."""
|
|
97
|
+
|
|
98
|
+
href: str
|
|
99
|
+
rel: str = "stylesheet"
|
|
100
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
def render(self) -> str:
|
|
103
|
+
return element("link", void=True, rel=self.rel, href=self.href, **self.attributes).render()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, init=False)
|
|
107
|
+
class CSSRule(CSSNode):
|
|
108
|
+
"""Render a CSS selector rule."""
|
|
109
|
+
|
|
110
|
+
selector: str
|
|
111
|
+
declarations: tuple[CSSDeclaration | tuple[str, Any] | Mapping[str, Any], ...] = ()
|
|
112
|
+
|
|
113
|
+
def __init__(self, selector: str, *declarations: CSSDeclaration | tuple[str, Any] | Mapping[str, Any]) -> None:
|
|
114
|
+
object.__setattr__(self, "selector", selector)
|
|
115
|
+
object.__setattr__(self, "declarations", declarations)
|
|
116
|
+
|
|
117
|
+
def render(self) -> str:
|
|
118
|
+
body = _render_declarations(self.declarations)
|
|
119
|
+
return f"{self.selector} {{ {body} }}" if body else f"{self.selector} {{}}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class CSSAtRule(CSSNode):
|
|
124
|
+
"""Render a generic CSS at-rule."""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
prelude: str = ""
|
|
128
|
+
body: tuple[CSSNode | str, ...] = ()
|
|
129
|
+
block: bool = True
|
|
130
|
+
|
|
131
|
+
def render(self) -> str:
|
|
132
|
+
name = _normalize_at_rule_name(self.name)
|
|
133
|
+
prelude = f" {self.prelude}" if self.prelude else ""
|
|
134
|
+
if not self.block:
|
|
135
|
+
return f"@{name}{prelude};"
|
|
136
|
+
body = _render_nodes(self.body)
|
|
137
|
+
return f"@{name}{prelude} {{ {body} }}" if body else f"@{name}{prelude} {{}}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, init=False)
|
|
141
|
+
class CSSImportRule(CSSAtRule):
|
|
142
|
+
"""Render a CSS @import rule."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, url: str, media: str = "") -> None:
|
|
145
|
+
prelude = f'url("{url}")'
|
|
146
|
+
if media:
|
|
147
|
+
prelude = f"{prelude} {media}"
|
|
148
|
+
object.__setattr__(self, "name", "import")
|
|
149
|
+
object.__setattr__(self, "prelude", prelude)
|
|
150
|
+
object.__setattr__(self, "body", ())
|
|
151
|
+
object.__setattr__(self, "block", False)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, init=False)
|
|
155
|
+
class CSSMediaRule(CSSAtRule):
|
|
156
|
+
"""Render a CSS @media block."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, query: str, *rules: CSSNode | str) -> None:
|
|
159
|
+
object.__setattr__(self, "name", "media")
|
|
160
|
+
object.__setattr__(self, "prelude", query)
|
|
161
|
+
object.__setattr__(self, "body", rules)
|
|
162
|
+
object.__setattr__(self, "block", True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, init=False)
|
|
166
|
+
class CSSSupportsRule(CSSAtRule):
|
|
167
|
+
"""Render a CSS @supports block."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, condition: str, *rules: CSSNode | str) -> None:
|
|
170
|
+
object.__setattr__(self, "name", "supports")
|
|
171
|
+
object.__setattr__(self, "prelude", condition)
|
|
172
|
+
object.__setattr__(self, "body", rules)
|
|
173
|
+
object.__setattr__(self, "block", True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, init=False)
|
|
177
|
+
class CSSLayerRule(CSSAtRule):
|
|
178
|
+
"""Render a CSS @layer block."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, layer: str = "", *rules: CSSNode | str) -> None:
|
|
181
|
+
object.__setattr__(self, "name", "layer")
|
|
182
|
+
object.__setattr__(self, "prelude", layer)
|
|
183
|
+
object.__setattr__(self, "body", rules)
|
|
184
|
+
object.__setattr__(self, "block", True)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, init=False)
|
|
188
|
+
class CSSKeyframe(CSSNode):
|
|
189
|
+
"""Render a single frame inside a CSS keyframes rule."""
|
|
190
|
+
|
|
191
|
+
selector: str
|
|
192
|
+
declarations: tuple[CSSDeclaration | tuple[str, Any] | Mapping[str, Any], ...] = ()
|
|
193
|
+
|
|
194
|
+
def __init__(self, selector: str, *declarations: CSSDeclaration | tuple[str, Any] | Mapping[str, Any]) -> None:
|
|
195
|
+
object.__setattr__(self, "selector", selector)
|
|
196
|
+
object.__setattr__(self, "declarations", declarations)
|
|
197
|
+
|
|
198
|
+
def render(self) -> str:
|
|
199
|
+
body = _render_declarations(self.declarations)
|
|
200
|
+
return f"{self.selector} {{ {body} }}" if body else f"{self.selector} {{}}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class CSSKeyframesRule(CSSNode):
|
|
205
|
+
"""Render a CSS @keyframes rule."""
|
|
206
|
+
|
|
207
|
+
name: str
|
|
208
|
+
frames: tuple[CSSKeyframe, ...] = ()
|
|
209
|
+
|
|
210
|
+
def render(self) -> str:
|
|
211
|
+
body = " ".join(frame.render() for frame in self.frames)
|
|
212
|
+
return f"@keyframes {self.name} {{ {body} }}" if body else f"@keyframes {self.name} {{}}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class CSSStyleElement(CSSNode):
|
|
217
|
+
"""Render a complete <style> element."""
|
|
218
|
+
|
|
219
|
+
stylesheet: CSSNode | str
|
|
220
|
+
|
|
221
|
+
def render(self) -> str:
|
|
222
|
+
css = self.stylesheet.render() if isinstance(self.stylesheet, CSSNode) else self.stylesheet
|
|
223
|
+
return f"<style>{css}</style>"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
BOOTSTRAP5_CSS_URL = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
|
227
|
+
TAILWIND_PLAY_CDN_URL = "https://cdn.tailwindcss.com"
|
|
228
|
+
GOOGLE_FONTS_PRECONNECT_URL = "https://fonts.googleapis.com"
|
|
229
|
+
GOOGLE_FONTS_STATIC_URL = "https://fonts.gstatic.com"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def bootstrap5_stylesheet(version: str = "5.3.3") -> CSSLink:
|
|
233
|
+
"""Return the Bootstrap 5 stylesheet link tag."""
|
|
234
|
+
|
|
235
|
+
href = f"https://cdn.jsdelivr.net/npm/bootstrap@{version}/dist/css/bootstrap.min.css"
|
|
236
|
+
return CSSLink(href=href)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def tailwind_script() -> JSScript:
|
|
240
|
+
"""Return the Tailwind Play CDN script tag."""
|
|
241
|
+
|
|
242
|
+
return JSScript(src=TAILWIND_PLAY_CDN_URL)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def google_fonts_url(
|
|
246
|
+
*families: str,
|
|
247
|
+
weights: Sequence[int] = (400, 500, 700),
|
|
248
|
+
display: str = "swap",
|
|
249
|
+
text: str | None = None,
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Build a Google Fonts CSS2 URL for the requested families and weights."""
|
|
252
|
+
|
|
253
|
+
if not families:
|
|
254
|
+
raise ValueError("At least one Google Fonts family is required")
|
|
255
|
+
|
|
256
|
+
family_parts: list[str] = []
|
|
257
|
+
weight_part = ";".join(str(weight) for weight in weights)
|
|
258
|
+
for family in families:
|
|
259
|
+
family_parts.append(f"family={quote_plus(family)}:wght@{weight_part}")
|
|
260
|
+
|
|
261
|
+
params = [*family_parts, f"display={quote_plus(display)}"]
|
|
262
|
+
if text is not None:
|
|
263
|
+
params.append(f"text={quote_plus(text)}")
|
|
264
|
+
return "https://fonts.googleapis.com/css2?" + "&".join(params)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def google_fonts_assets(
|
|
268
|
+
*families: str,
|
|
269
|
+
weights: Sequence[int] = (400, 500, 700),
|
|
270
|
+
display: str = "swap",
|
|
271
|
+
text: str | None = None,
|
|
272
|
+
) -> tuple[CSSLink, CSSLink, CSSLink]:
|
|
273
|
+
"""Return preconnect and stylesheet links for Google Fonts."""
|
|
274
|
+
|
|
275
|
+
href = google_fonts_url(*families, weights=weights, display=display, text=text)
|
|
276
|
+
return (
|
|
277
|
+
CSSLink(href=GOOGLE_FONTS_PRECONNECT_URL, rel="preconnect"),
|
|
278
|
+
CSSLink(href=GOOGLE_FONTS_STATIC_URL, rel="preconnect", attributes={"crossorigin": "anonymous"}),
|
|
279
|
+
CSSLink(href=href),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass
|
|
284
|
+
class CSSStyleSheet(CSSNode):
|
|
285
|
+
rules: list[CSSNode | str] = field(default_factory=list)
|
|
286
|
+
|
|
287
|
+
def add(self, *items: CSSNode | str) -> "CSSStyleSheet":
|
|
288
|
+
self.rules.extend(items)
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
def add_comment(self, value: str) -> "CSSStyleSheet":
|
|
292
|
+
self.rules.append(CSSComment(value))
|
|
293
|
+
return self
|
|
294
|
+
|
|
295
|
+
def add_rule(
|
|
296
|
+
self,
|
|
297
|
+
selector: str,
|
|
298
|
+
*declarations: CSSDeclaration | tuple[str, Any] | Mapping[str, Any],
|
|
299
|
+
**keyword_declarations: Any,
|
|
300
|
+
) -> "CSSStyleSheet":
|
|
301
|
+
combined_declarations = declarations + tuple(keyword_declarations.items())
|
|
302
|
+
self.rules.append(CSSRule(selector, *combined_declarations))
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
def add_import(self, url: str, media: str = "") -> "CSSStyleSheet":
|
|
306
|
+
self.rules.append(CSSImportRule(url=url, media=media))
|
|
307
|
+
return self
|
|
308
|
+
|
|
309
|
+
def add_media(self, query: str, *rules: CSSNode | str) -> "CSSStyleSheet":
|
|
310
|
+
self.rules.append(CSSMediaRule(query, *rules))
|
|
311
|
+
return self
|
|
312
|
+
|
|
313
|
+
def add_supports(self, condition: str, *rules: CSSNode | str) -> "CSSStyleSheet":
|
|
314
|
+
self.rules.append(CSSSupportsRule(condition, *rules))
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
def add_layer(self, layer: str = "", *rules: CSSNode | str) -> "CSSStyleSheet":
|
|
318
|
+
self.rules.append(CSSLayerRule(layer, *rules))
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
def add_keyframes(self, name: str, *frames: CSSKeyframe) -> "CSSStyleSheet":
|
|
322
|
+
self.rules.append(CSSKeyframesRule(name=name, frames=frames))
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def add_raw(self, css: str) -> "CSSStyleSheet":
|
|
326
|
+
self.rules.append(css)
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
def render(self) -> str:
|
|
330
|
+
parts: list[str] = []
|
|
331
|
+
for rule in self.rules:
|
|
332
|
+
parts.append(rule.render() if isinstance(rule, CSSNode) else rule)
|
|
333
|
+
return "\n".join(parts)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def style_tag(stylesheet: CSSNode | str) -> Raw:
|
|
337
|
+
"""Render a stylesheet or CSS node into a raw <style> tag.
|
|
338
|
+
|
|
339
|
+
If *stylesheet* is already a :class:`CSSStyleElement` (which renders its
|
|
340
|
+
own ``<style>`` wrapper), return it as-is to avoid double-wrapping.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if isinstance(stylesheet, CSSStyleElement):
|
|
344
|
+
return raw(stylesheet.render())
|
|
345
|
+
css = stylesheet.render() if isinstance(stylesheet, CSSNode) else stylesheet
|
|
346
|
+
return raw(f"<style>{css}</style>")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def inline_style(
|
|
350
|
+
*declarations: CSSDeclaration | tuple[str, Any] | Mapping[str, Any],
|
|
351
|
+
**keyword_declarations: Any,
|
|
352
|
+
) -> str:
|
|
353
|
+
"""Render declarations for a style attribute."""
|
|
354
|
+
|
|
355
|
+
return CSSInlineStyle(*(declarations + tuple(keyword_declarations.items()))).render()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
__all__ = [
|
|
359
|
+
"CSSAtRule",
|
|
360
|
+
"CSSComment",
|
|
361
|
+
"CSSDeclaration",
|
|
362
|
+
"CSSLink",
|
|
363
|
+
"CSSImportRule",
|
|
364
|
+
"CSSInlineStyle",
|
|
365
|
+
"CSSKeyframe",
|
|
366
|
+
"CSSKeyframesRule",
|
|
367
|
+
"CSSLayerRule",
|
|
368
|
+
"CSSMediaRule",
|
|
369
|
+
"CSSNode",
|
|
370
|
+
"CSSRule",
|
|
371
|
+
"CSSStyleElement",
|
|
372
|
+
"CSSStyleSheet",
|
|
373
|
+
"CSSSupportsRule",
|
|
374
|
+
"BOOTSTRAP5_CSS_URL",
|
|
375
|
+
"GOOGLE_FONTS_PRECONNECT_URL",
|
|
376
|
+
"GOOGLE_FONTS_STATIC_URL",
|
|
377
|
+
"TAILWIND_PLAY_CDN_URL",
|
|
378
|
+
"bootstrap5_stylesheet",
|
|
379
|
+
"google_fonts_assets",
|
|
380
|
+
"google_fonts_url",
|
|
381
|
+
"inline_style",
|
|
382
|
+
"tailwind_script",
|
|
383
|
+
"style_tag",
|
|
384
|
+
]
|
html5/document.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""HTML5 document primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from html import escape
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def doctype() -> str:
|
|
11
|
+
"""Return the HTML5 doctype string."""
|
|
12
|
+
|
|
13
|
+
return "<!doctype html>"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_attr_name(name: str) -> str:
|
|
17
|
+
if name.endswith("_"):
|
|
18
|
+
name = name[:-1]
|
|
19
|
+
return name.replace("_", "-")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _render_attributes(attributes: Mapping[str, Any]) -> str:
|
|
23
|
+
rendered: list[str] = []
|
|
24
|
+
for key, value in attributes.items():
|
|
25
|
+
if value is None or value is False:
|
|
26
|
+
continue
|
|
27
|
+
attribute_name = _normalize_attr_name(key)
|
|
28
|
+
if value is True:
|
|
29
|
+
rendered.append(attribute_name)
|
|
30
|
+
else:
|
|
31
|
+
rendered.append(f'{attribute_name}="{escape(str(value), quote=True)}"')
|
|
32
|
+
return (" " + " ".join(rendered)) if rendered else ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Node:
|
|
36
|
+
"""Base class for HTML document nodes."""
|
|
37
|
+
|
|
38
|
+
def render(self) -> str:
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Text(Node):
|
|
44
|
+
"""Render escaped text content."""
|
|
45
|
+
|
|
46
|
+
value: str
|
|
47
|
+
|
|
48
|
+
def render(self) -> str:
|
|
49
|
+
return escape(self.value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Raw(Node):
|
|
54
|
+
"""Render raw HTML content without escaping."""
|
|
55
|
+
|
|
56
|
+
value: str
|
|
57
|
+
|
|
58
|
+
def render(self) -> str:
|
|
59
|
+
return self.value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Comment(Node):
|
|
64
|
+
"""Render an HTML comment."""
|
|
65
|
+
|
|
66
|
+
value: str
|
|
67
|
+
|
|
68
|
+
def render(self) -> str:
|
|
69
|
+
return f"<!--{self.value}-->"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Doctype(Node):
|
|
74
|
+
"""Render a doctype node."""
|
|
75
|
+
|
|
76
|
+
value: str = "html"
|
|
77
|
+
|
|
78
|
+
def render(self) -> str:
|
|
79
|
+
return f"<!doctype {self.value}>"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Element(Node):
|
|
84
|
+
"""Render an HTML element with children and attributes."""
|
|
85
|
+
|
|
86
|
+
tag: str
|
|
87
|
+
children: tuple[Node | str, ...] = ()
|
|
88
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
89
|
+
void: bool = False
|
|
90
|
+
|
|
91
|
+
def render(self) -> str:
|
|
92
|
+
attributes = _render_attributes(self.attributes)
|
|
93
|
+
if self.void:
|
|
94
|
+
return f"<{self.tag}{attributes}>"
|
|
95
|
+
|
|
96
|
+
inner_html = "".join(_coerce_node(child).render() for child in self.children)
|
|
97
|
+
return f"<{self.tag}{attributes}>{inner_html}</{self.tag}>"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _coerce_node(value: Node | str) -> Node:
|
|
101
|
+
return value if isinstance(value, Node) else Text(str(value))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def text(value: str) -> Text:
|
|
105
|
+
"""Create an escaped text node."""
|
|
106
|
+
|
|
107
|
+
return Text(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def raw(value: str) -> Raw:
|
|
111
|
+
"""Create a raw HTML node."""
|
|
112
|
+
|
|
113
|
+
return Raw(value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def comment(value: str) -> Comment:
|
|
117
|
+
"""Create an HTML comment node."""
|
|
118
|
+
|
|
119
|
+
return Comment(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def doctype_node(value: str = "html") -> Doctype:
|
|
123
|
+
"""Create a doctype node."""
|
|
124
|
+
|
|
125
|
+
return Doctype(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def element(tag: str, *children: Node | str, void: bool = False, **attributes: Any) -> Element:
|
|
129
|
+
"""Create an HTML element node."""
|
|
130
|
+
|
|
131
|
+
return Element(tag=tag, children=children, attributes=attributes, void=void)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class HtmlDocument:
|
|
136
|
+
"""Represent a complete HTML document."""
|
|
137
|
+
|
|
138
|
+
title: str = ""
|
|
139
|
+
lang: str = "en"
|
|
140
|
+
head_nodes: list[Node | str] = field(default_factory=list)
|
|
141
|
+
body_nodes: list[Node | str] = field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
def add_head(self, *nodes: Node | str) -> "HtmlDocument":
|
|
144
|
+
"""Append nodes to the document head."""
|
|
145
|
+
|
|
146
|
+
self.head_nodes.extend(nodes)
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def add_body(self, *nodes: Node | str) -> "HtmlDocument":
|
|
150
|
+
"""Append nodes to the document body."""
|
|
151
|
+
|
|
152
|
+
self.body_nodes.extend(nodes)
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def render(self) -> str:
|
|
156
|
+
"""Render the full HTML document."""
|
|
157
|
+
|
|
158
|
+
head_children: list[Node | str] = [element("meta", charset="utf-8", void=True)]
|
|
159
|
+
if self.title:
|
|
160
|
+
head_children.append(element("title", self.title))
|
|
161
|
+
head_children.extend(self.head_nodes)
|
|
162
|
+
|
|
163
|
+
body = element("body", *self.body_nodes)
|
|
164
|
+
head = element("head", *head_children)
|
|
165
|
+
html = element("html", head, body, lang=self.lang)
|
|
166
|
+
return "\n".join([doctype(), html.render()])
|
html5/elements.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Generated HTML5 element classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .document import Element, Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
VOID_ELEMENTS = {
|
|
11
|
+
"area",
|
|
12
|
+
"base",
|
|
13
|
+
"br",
|
|
14
|
+
"col",
|
|
15
|
+
"embed",
|
|
16
|
+
"hr",
|
|
17
|
+
"img",
|
|
18
|
+
"input",
|
|
19
|
+
"link",
|
|
20
|
+
"meta",
|
|
21
|
+
"param",
|
|
22
|
+
"source",
|
|
23
|
+
"track",
|
|
24
|
+
"wbr",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
HTML5_ELEMENTS = (
|
|
28
|
+
"a",
|
|
29
|
+
"abbr",
|
|
30
|
+
"address",
|
|
31
|
+
"area",
|
|
32
|
+
"article",
|
|
33
|
+
"aside",
|
|
34
|
+
"audio",
|
|
35
|
+
"b",
|
|
36
|
+
"base",
|
|
37
|
+
"bdi",
|
|
38
|
+
"bdo",
|
|
39
|
+
"blockquote",
|
|
40
|
+
"body",
|
|
41
|
+
"br",
|
|
42
|
+
"button",
|
|
43
|
+
"canvas",
|
|
44
|
+
"caption",
|
|
45
|
+
"cite",
|
|
46
|
+
"code",
|
|
47
|
+
"col",
|
|
48
|
+
"colgroup",
|
|
49
|
+
"data",
|
|
50
|
+
"datalist",
|
|
51
|
+
"dd",
|
|
52
|
+
"del",
|
|
53
|
+
"details",
|
|
54
|
+
"dfn",
|
|
55
|
+
"dialog",
|
|
56
|
+
"div",
|
|
57
|
+
"dl",
|
|
58
|
+
"dt",
|
|
59
|
+
"em",
|
|
60
|
+
"embed",
|
|
61
|
+
"fieldset",
|
|
62
|
+
"figcaption",
|
|
63
|
+
"figure",
|
|
64
|
+
"footer",
|
|
65
|
+
"form",
|
|
66
|
+
"h1",
|
|
67
|
+
"h2",
|
|
68
|
+
"h3",
|
|
69
|
+
"h4",
|
|
70
|
+
"h5",
|
|
71
|
+
"h6",
|
|
72
|
+
"head",
|
|
73
|
+
"header",
|
|
74
|
+
"hgroup",
|
|
75
|
+
"hr",
|
|
76
|
+
"html",
|
|
77
|
+
"i",
|
|
78
|
+
"iframe",
|
|
79
|
+
"img",
|
|
80
|
+
"input",
|
|
81
|
+
"ins",
|
|
82
|
+
"kbd",
|
|
83
|
+
"label",
|
|
84
|
+
"legend",
|
|
85
|
+
"li",
|
|
86
|
+
"link",
|
|
87
|
+
"main",
|
|
88
|
+
"map",
|
|
89
|
+
"mark",
|
|
90
|
+
"menu",
|
|
91
|
+
"meta",
|
|
92
|
+
"meter",
|
|
93
|
+
"nav",
|
|
94
|
+
"noscript",
|
|
95
|
+
"object",
|
|
96
|
+
"ol",
|
|
97
|
+
"optgroup",
|
|
98
|
+
"option",
|
|
99
|
+
"output",
|
|
100
|
+
"p",
|
|
101
|
+
"param",
|
|
102
|
+
"picture",
|
|
103
|
+
"pre",
|
|
104
|
+
"progress",
|
|
105
|
+
"q",
|
|
106
|
+
"rp",
|
|
107
|
+
"rt",
|
|
108
|
+
"ruby",
|
|
109
|
+
"s",
|
|
110
|
+
"samp",
|
|
111
|
+
"script",
|
|
112
|
+
"section",
|
|
113
|
+
"select",
|
|
114
|
+
"slot",
|
|
115
|
+
"small",
|
|
116
|
+
"source",
|
|
117
|
+
"span",
|
|
118
|
+
"strong",
|
|
119
|
+
"style",
|
|
120
|
+
"sub",
|
|
121
|
+
"summary",
|
|
122
|
+
"sup",
|
|
123
|
+
"table",
|
|
124
|
+
"tbody",
|
|
125
|
+
"td",
|
|
126
|
+
"template",
|
|
127
|
+
"textarea",
|
|
128
|
+
"tfoot",
|
|
129
|
+
"th",
|
|
130
|
+
"thead",
|
|
131
|
+
"time",
|
|
132
|
+
"title",
|
|
133
|
+
"tr",
|
|
134
|
+
"track",
|
|
135
|
+
"u",
|
|
136
|
+
"ul",
|
|
137
|
+
"var",
|
|
138
|
+
"video",
|
|
139
|
+
"wbr",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _class_name(tag: str) -> str:
|
|
144
|
+
return tag.replace("-", " ").title().replace(" ", "")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_init(tag: str, *, void: bool) -> Any:
|
|
148
|
+
def __init__(self, *children: Node | str, **attributes: Any) -> None:
|
|
149
|
+
if void and children:
|
|
150
|
+
raise ValueError(f"{tag} is a void HTML element and cannot have children")
|
|
151
|
+
Element.__init__(self, tag=tag, children=children, attributes=attributes, void=void)
|
|
152
|
+
|
|
153
|
+
return __init__
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _create_tag_class(tag: str) -> type[Element]:
|
|
157
|
+
cls = type(
|
|
158
|
+
_class_name(tag),
|
|
159
|
+
(Element,),
|
|
160
|
+
{
|
|
161
|
+
"__module__": __name__,
|
|
162
|
+
"__init__": _build_init(tag, void=tag in VOID_ELEMENTS),
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
cls.__doc__ = f"Render a <{tag}> HTML element."
|
|
166
|
+
return cls
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
A = _create_tag_class("a")
|
|
170
|
+
Abbr = _create_tag_class("abbr")
|
|
171
|
+
Address = _create_tag_class("address")
|
|
172
|
+
Area = _create_tag_class("area")
|
|
173
|
+
Article = _create_tag_class("article")
|
|
174
|
+
Aside = _create_tag_class("aside")
|
|
175
|
+
Audio = _create_tag_class("audio")
|
|
176
|
+
B = _create_tag_class("b")
|
|
177
|
+
Base = _create_tag_class("base")
|
|
178
|
+
Bdi = _create_tag_class("bdi")
|
|
179
|
+
Bdo = _create_tag_class("bdo")
|
|
180
|
+
Blockquote = _create_tag_class("blockquote")
|
|
181
|
+
Body = _create_tag_class("body")
|
|
182
|
+
Br = _create_tag_class("br")
|
|
183
|
+
Button = _create_tag_class("button")
|
|
184
|
+
Canvas = _create_tag_class("canvas")
|
|
185
|
+
Caption = _create_tag_class("caption")
|
|
186
|
+
Cite = _create_tag_class("cite")
|
|
187
|
+
Code = _create_tag_class("code")
|
|
188
|
+
Col = _create_tag_class("col")
|
|
189
|
+
Colgroup = _create_tag_class("colgroup")
|
|
190
|
+
Data = _create_tag_class("data")
|
|
191
|
+
Datalist = _create_tag_class("datalist")
|
|
192
|
+
Dd = _create_tag_class("dd")
|
|
193
|
+
Del = _create_tag_class("del")
|
|
194
|
+
Details = _create_tag_class("details")
|
|
195
|
+
Dfn = _create_tag_class("dfn")
|
|
196
|
+
Dialog = _create_tag_class("dialog")
|
|
197
|
+
Div = _create_tag_class("div")
|
|
198
|
+
Dl = _create_tag_class("dl")
|
|
199
|
+
Dt = _create_tag_class("dt")
|
|
200
|
+
Em = _create_tag_class("em")
|
|
201
|
+
Embed = _create_tag_class("embed")
|
|
202
|
+
Fieldset = _create_tag_class("fieldset")
|
|
203
|
+
Figcaption = _create_tag_class("figcaption")
|
|
204
|
+
Figure = _create_tag_class("figure")
|
|
205
|
+
Footer = _create_tag_class("footer")
|
|
206
|
+
Form = _create_tag_class("form")
|
|
207
|
+
H1 = _create_tag_class("h1")
|
|
208
|
+
H2 = _create_tag_class("h2")
|
|
209
|
+
H3 = _create_tag_class("h3")
|
|
210
|
+
H4 = _create_tag_class("h4")
|
|
211
|
+
H5 = _create_tag_class("h5")
|
|
212
|
+
H6 = _create_tag_class("h6")
|
|
213
|
+
Head = _create_tag_class("head")
|
|
214
|
+
Header = _create_tag_class("header")
|
|
215
|
+
Hgroup = _create_tag_class("hgroup")
|
|
216
|
+
Hr = _create_tag_class("hr")
|
|
217
|
+
Html = _create_tag_class("html")
|
|
218
|
+
I = _create_tag_class("i")
|
|
219
|
+
Iframe = _create_tag_class("iframe")
|
|
220
|
+
Img = _create_tag_class("img")
|
|
221
|
+
Input = _create_tag_class("input")
|
|
222
|
+
Ins = _create_tag_class("ins")
|
|
223
|
+
Kbd = _create_tag_class("kbd")
|
|
224
|
+
Label = _create_tag_class("label")
|
|
225
|
+
Legend = _create_tag_class("legend")
|
|
226
|
+
Li = _create_tag_class("li")
|
|
227
|
+
Link = _create_tag_class("link")
|
|
228
|
+
Main = _create_tag_class("main")
|
|
229
|
+
Map = _create_tag_class("map")
|
|
230
|
+
Mark = _create_tag_class("mark")
|
|
231
|
+
Menu = _create_tag_class("menu")
|
|
232
|
+
Meta = _create_tag_class("meta")
|
|
233
|
+
Meter = _create_tag_class("meter")
|
|
234
|
+
Nav = _create_tag_class("nav")
|
|
235
|
+
Noscript = _create_tag_class("noscript")
|
|
236
|
+
Object = _create_tag_class("object")
|
|
237
|
+
Ol = _create_tag_class("ol")
|
|
238
|
+
Optgroup = _create_tag_class("optgroup")
|
|
239
|
+
Option = _create_tag_class("option")
|
|
240
|
+
Output = _create_tag_class("output")
|
|
241
|
+
P = _create_tag_class("p")
|
|
242
|
+
Param = _create_tag_class("param")
|
|
243
|
+
Picture = _create_tag_class("picture")
|
|
244
|
+
Pre = _create_tag_class("pre")
|
|
245
|
+
Progress = _create_tag_class("progress")
|
|
246
|
+
Q = _create_tag_class("q")
|
|
247
|
+
Rp = _create_tag_class("rp")
|
|
248
|
+
Rt = _create_tag_class("rt")
|
|
249
|
+
Ruby = _create_tag_class("ruby")
|
|
250
|
+
S = _create_tag_class("s")
|
|
251
|
+
Samp = _create_tag_class("samp")
|
|
252
|
+
Script = _create_tag_class("script")
|
|
253
|
+
Section = _create_tag_class("section")
|
|
254
|
+
Select = _create_tag_class("select")
|
|
255
|
+
Slot = _create_tag_class("slot")
|
|
256
|
+
Small = _create_tag_class("small")
|
|
257
|
+
Source = _create_tag_class("source")
|
|
258
|
+
Span = _create_tag_class("span")
|
|
259
|
+
Strong = _create_tag_class("strong")
|
|
260
|
+
Style = _create_tag_class("style")
|
|
261
|
+
Sub = _create_tag_class("sub")
|
|
262
|
+
Summary = _create_tag_class("summary")
|
|
263
|
+
Sup = _create_tag_class("sup")
|
|
264
|
+
Table = _create_tag_class("table")
|
|
265
|
+
Tbody = _create_tag_class("tbody")
|
|
266
|
+
Td = _create_tag_class("td")
|
|
267
|
+
Template = _create_tag_class("template")
|
|
268
|
+
Textarea = _create_tag_class("textarea")
|
|
269
|
+
Tfoot = _create_tag_class("tfoot")
|
|
270
|
+
Th = _create_tag_class("th")
|
|
271
|
+
Thead = _create_tag_class("thead")
|
|
272
|
+
Time = _create_tag_class("time")
|
|
273
|
+
Title = _create_tag_class("title")
|
|
274
|
+
Tr = _create_tag_class("tr")
|
|
275
|
+
Track = _create_tag_class("track")
|
|
276
|
+
U = _create_tag_class("u")
|
|
277
|
+
Ul = _create_tag_class("ul")
|
|
278
|
+
Var = _create_tag_class("var")
|
|
279
|
+
Video = _create_tag_class("video")
|
|
280
|
+
Wbr = _create_tag_class("wbr")
|
|
281
|
+
|
|
282
|
+
__all__ = [
|
|
283
|
+
"VOID_ELEMENTS",
|
|
284
|
+
"HTML5_ELEMENTS",
|
|
285
|
+
"A",
|
|
286
|
+
"Abbr",
|
|
287
|
+
"Address",
|
|
288
|
+
"Area",
|
|
289
|
+
"Article",
|
|
290
|
+
"Aside",
|
|
291
|
+
"Audio",
|
|
292
|
+
"B",
|
|
293
|
+
"Base",
|
|
294
|
+
"Bdi",
|
|
295
|
+
"Bdo",
|
|
296
|
+
"Blockquote",
|
|
297
|
+
"Body",
|
|
298
|
+
"Br",
|
|
299
|
+
"Button",
|
|
300
|
+
"Canvas",
|
|
301
|
+
"Caption",
|
|
302
|
+
"Cite",
|
|
303
|
+
"Code",
|
|
304
|
+
"Col",
|
|
305
|
+
"Colgroup",
|
|
306
|
+
"Data",
|
|
307
|
+
"Datalist",
|
|
308
|
+
"Dd",
|
|
309
|
+
"Del",
|
|
310
|
+
"Details",
|
|
311
|
+
"Dfn",
|
|
312
|
+
"Dialog",
|
|
313
|
+
"Div",
|
|
314
|
+
"Dl",
|
|
315
|
+
"Dt",
|
|
316
|
+
"Em",
|
|
317
|
+
"Embed",
|
|
318
|
+
"Fieldset",
|
|
319
|
+
"Figcaption",
|
|
320
|
+
"Figure",
|
|
321
|
+
"Footer",
|
|
322
|
+
"Form",
|
|
323
|
+
"H1",
|
|
324
|
+
"H2",
|
|
325
|
+
"H3",
|
|
326
|
+
"H4",
|
|
327
|
+
"H5",
|
|
328
|
+
"H6",
|
|
329
|
+
"Head",
|
|
330
|
+
"Header",
|
|
331
|
+
"Hgroup",
|
|
332
|
+
"Hr",
|
|
333
|
+
"Html",
|
|
334
|
+
"I",
|
|
335
|
+
"Iframe",
|
|
336
|
+
"Img",
|
|
337
|
+
"Input",
|
|
338
|
+
"Ins",
|
|
339
|
+
"Kbd",
|
|
340
|
+
"Label",
|
|
341
|
+
"Legend",
|
|
342
|
+
"Li",
|
|
343
|
+
"Link",
|
|
344
|
+
"Main",
|
|
345
|
+
"Map",
|
|
346
|
+
"Mark",
|
|
347
|
+
"Menu",
|
|
348
|
+
"Meta",
|
|
349
|
+
"Meter",
|
|
350
|
+
"Nav",
|
|
351
|
+
"Noscript",
|
|
352
|
+
"Object",
|
|
353
|
+
"Ol",
|
|
354
|
+
"Optgroup",
|
|
355
|
+
"Option",
|
|
356
|
+
"Output",
|
|
357
|
+
"P",
|
|
358
|
+
"Param",
|
|
359
|
+
"Picture",
|
|
360
|
+
"Pre",
|
|
361
|
+
"Progress",
|
|
362
|
+
"Q",
|
|
363
|
+
"Rp",
|
|
364
|
+
"Rt",
|
|
365
|
+
"Ruby",
|
|
366
|
+
"S",
|
|
367
|
+
"Samp",
|
|
368
|
+
"Script",
|
|
369
|
+
"Section",
|
|
370
|
+
"Select",
|
|
371
|
+
"Slot",
|
|
372
|
+
"Small",
|
|
373
|
+
"Source",
|
|
374
|
+
"Span",
|
|
375
|
+
"Strong",
|
|
376
|
+
"Style",
|
|
377
|
+
"Sub",
|
|
378
|
+
"Summary",
|
|
379
|
+
"Sup",
|
|
380
|
+
"Table",
|
|
381
|
+
"Tbody",
|
|
382
|
+
"Td",
|
|
383
|
+
"Template",
|
|
384
|
+
"Textarea",
|
|
385
|
+
"Tfoot",
|
|
386
|
+
"Th",
|
|
387
|
+
"Thead",
|
|
388
|
+
"Time",
|
|
389
|
+
"Title",
|
|
390
|
+
"Tr",
|
|
391
|
+
"Track",
|
|
392
|
+
"U",
|
|
393
|
+
"Ul",
|
|
394
|
+
"Var",
|
|
395
|
+
"Video",
|
|
396
|
+
"Wbr",
|
|
397
|
+
]
|
html5/js.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""JavaScript helpers for HTML documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Sequence
|
|
7
|
+
|
|
8
|
+
from .document import raw, element
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JSNode:
|
|
12
|
+
"""Base class for JavaScript document nodes."""
|
|
13
|
+
|
|
14
|
+
def render(self) -> str:
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class JSScript(JSNode):
|
|
20
|
+
"""Render an inline or external JavaScript script tag.
|
|
21
|
+
|
|
22
|
+
Set *src* for an external script, or *code* for an inline script.
|
|
23
|
+
Setting both raises :exc:`ValueError`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
src: str | None = None
|
|
27
|
+
code: str = ""
|
|
28
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if self.src is not None and self.code:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"JSScript cannot set both 'src' and 'code'; use one or the other"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def render(self) -> str:
|
|
37
|
+
if self.src is not None:
|
|
38
|
+
return element("script", src=self.src, **self.attributes).render()
|
|
39
|
+
return element("script", raw(self.code), **self.attributes).render()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def javascript_script(code: str, **attributes: Any) -> JSScript:
|
|
43
|
+
"""Create an inline script node."""
|
|
44
|
+
return JSScript(code=code, attributes=attributes)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def javascript_link(src: str, **attributes: Any) -> JSScript:
|
|
48
|
+
"""Create an external script node."""
|
|
49
|
+
return JSScript(src=src, attributes=attributes)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bootstrap5_bundle_script(version: str = "5.3.3") -> JSScript:
|
|
53
|
+
"""Return the Bootstrap 5 bundle script tag."""
|
|
54
|
+
href = f"https://cdn.jsdelivr.net/npm/bootstrap@{version}/dist/js/bootstrap.bundle.min.js"
|
|
55
|
+
return javascript_link(href)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def google_charts_loader(version: str = "51") -> JSScript:
|
|
59
|
+
"""Return the Google Charts loader script tag."""
|
|
60
|
+
src = f"https://www.gstatic.com/charts/loader.js?ver={version}"
|
|
61
|
+
return javascript_link(src)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def google_charts_package_loader(packages: Sequence[str], callback: str | None = None) -> JSScript:
|
|
65
|
+
"""Return an inline Google Charts package loader snippet."""
|
|
66
|
+
package_list = ", ".join(f'"{package}"' for package in packages)
|
|
67
|
+
callback_line = f"google.charts.setOnLoadCallback({callback});" if callback else ""
|
|
68
|
+
code = (
|
|
69
|
+
"google.charts.load('current', {packages: ["
|
|
70
|
+
f"{package_list}"
|
|
71
|
+
"]});\n"
|
|
72
|
+
f"{callback_line}"
|
|
73
|
+
)
|
|
74
|
+
return javascript_script(code.strip())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"JSNode",
|
|
79
|
+
"JSScript",
|
|
80
|
+
"bootstrap5_bundle_script",
|
|
81
|
+
"google_charts_loader",
|
|
82
|
+
"google_charts_package_loader",
|
|
83
|
+
"javascript_link",
|
|
84
|
+
"javascript_script",
|
|
85
|
+
]
|
html5/version.py
ADDED
html5/writer.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Utilities for writing rendered HTML and CSS to disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Renderable(Protocol):
|
|
11
|
+
def render(self) -> str:
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class MarkupWriter:
|
|
17
|
+
"""Write rendered HTML and CSS content beneath a fixed root directory."""
|
|
18
|
+
|
|
19
|
+
root: Path = Path(".")
|
|
20
|
+
encoding: str = "utf-8"
|
|
21
|
+
|
|
22
|
+
def write_text(self, path: str | Path, content: str) -> Path:
|
|
23
|
+
"""Write text content to a file below the configured root directory."""
|
|
24
|
+
|
|
25
|
+
root = self.root.resolve()
|
|
26
|
+
relative_path = Path(path)
|
|
27
|
+
if relative_path.is_absolute():
|
|
28
|
+
raise ValueError("path must be relative to the writer root")
|
|
29
|
+
|
|
30
|
+
destination = (root / relative_path).resolve()
|
|
31
|
+
if destination == root or root not in destination.parents:
|
|
32
|
+
raise ValueError("path must be a file inside the writer root, not the root itself")
|
|
33
|
+
|
|
34
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
destination.write_text(content, encoding=self.encoding)
|
|
36
|
+
return destination
|
|
37
|
+
|
|
38
|
+
def write_html(self, path: str | Path, document: Renderable | str) -> Path:
|
|
39
|
+
"""Write rendered HTML to disk."""
|
|
40
|
+
|
|
41
|
+
content = document if isinstance(document, str) else document.render()
|
|
42
|
+
return self.write_text(path, content)
|
|
43
|
+
|
|
44
|
+
def write_css(self, path: str | Path, stylesheet: Renderable | str) -> Path:
|
|
45
|
+
"""Write rendered CSS to disk."""
|
|
46
|
+
|
|
47
|
+
content = stylesheet if isinstance(stylesheet, str) else stylesheet.render()
|
|
48
|
+
return self.write_text(path, content)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: html5cc
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A small Python module for building HTML5 documents and CSS3 stylesheets.
|
|
5
|
+
Author: Jason Miller
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://jasonmiller-cc.github.io/html5/
|
|
8
|
+
Project-URL: Documentation, https://jasonmiller-cc.github.io/html5/
|
|
9
|
+
Project-URL: Source, https://github.com/jasonmiller-cc/html5
|
|
10
|
+
Project-URL: Changelog, https://github.com/jasonmiller-cc/html5/blob/main/CHANGELOG.md
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
21
|
+
Provides-Extra: docs
|
|
22
|
+
Requires-Dist: furo>=2024.8.6; extra == "docs"
|
|
23
|
+
Requires-Dist: myst-parser>=4.0; extra == "docs"
|
|
24
|
+
Requires-Dist: sphinx>=7.4; extra == "docs"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# html5
|
|
28
|
+
|
|
29
|
+
Small Python helpers for building HTML5 documents, CSS, and JavaScript assets from Python.
|
|
30
|
+
|
|
31
|
+
Documentation is published on GitHub Pages at [jasonmiller-cc.github.io/html5](https://jasonmiller-cc.github.io/html5/).
|
|
32
|
+
|
|
33
|
+
The package ships with HTML5 element classes, CSS node classes, JavaScript helpers, CDN helpers for Bootstrap 5 and Tailwind, optimized Google Fonts helpers, a disk writer, and semantic version metadata.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
`html5cc` is published to [PyPI](https://pypi.org/project/html5cc/).
|
|
38
|
+
The import name is still `html5`.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install html5cc
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**requirements.txt**
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
html5cc==0.3.0
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**pyproject.toml (uv)**
|
|
51
|
+
|
|
52
|
+
```toml
|
|
53
|
+
[project]
|
|
54
|
+
dependencies = ["html5cc>=0.3.0"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
See the [Installation docs](https://jasonmiller-cc.github.io/html5/installation.html) for Poetry and other options.
|
|
58
|
+
|
|
59
|
+
## Example
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from html5 import (
|
|
63
|
+
CSSDeclaration,
|
|
64
|
+
CSSKeyframe,
|
|
65
|
+
CSSStyleSheet,
|
|
66
|
+
Div,
|
|
67
|
+
H1,
|
|
68
|
+
HtmlDocument,
|
|
69
|
+
Img,
|
|
70
|
+
MarkupWriter,
|
|
71
|
+
bootstrap5_bundle_script,
|
|
72
|
+
bootstrap5_stylesheet,
|
|
73
|
+
google_charts_loader,
|
|
74
|
+
google_charts_package_loader,
|
|
75
|
+
google_fonts_assets,
|
|
76
|
+
inline_style,
|
|
77
|
+
tailwind_script,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
styles = CSSStyleSheet()
|
|
81
|
+
styles.add_comment("base styles")
|
|
82
|
+
styles.add_import("reset.css")
|
|
83
|
+
styles.add_rule("body", margin="0", font_family="system-ui")
|
|
84
|
+
styles.add_rule("h1", color="#1f2937")
|
|
85
|
+
styles.add_media("screen and (min-width: 40rem)", CSSDeclaration("color", "black"))
|
|
86
|
+
styles.add_keyframes("fade", CSSKeyframe("from", ("opacity", 0)), CSSKeyframe("to", ("opacity", 1)))
|
|
87
|
+
|
|
88
|
+
doc = (
|
|
89
|
+
HtmlDocument(title="Hello HTML5")
|
|
90
|
+
.add_head(*google_fonts_assets("Inter"))
|
|
91
|
+
.add_head(bootstrap5_stylesheet())
|
|
92
|
+
.add_head(tailwind_script())
|
|
93
|
+
.add_head(bootstrap5_bundle_script())
|
|
94
|
+
.add_head(google_charts_loader())
|
|
95
|
+
.add_body(Div(H1("Hello, world!"), Img(src="hero.png", style=inline_style(("border_radius", "8px")))))
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
print(doc.render())
|
|
99
|
+
|
|
100
|
+
chart_loader = google_charts_package_loader(["corechart", "table"], callback="drawChart")
|
|
101
|
+
writer = MarkupWriter()
|
|
102
|
+
writer.write_html("dist/index.html", doc)
|
|
103
|
+
writer.write_css("dist/site.css", styles)
|
|
104
|
+
writer.write_html("dist/charts.html", HtmlDocument(title="Charts").add_head(chart_loader))
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## JavaScript Helpers
|
|
108
|
+
|
|
109
|
+
Use `javascript_link()` and `javascript_script()` for arbitrary script tags and inline code. The library also exposes `bootstrap5_bundle_script()` and Google Charts helpers so you can wire up common browser libraries without hand-building script tags.
|
|
110
|
+
|
|
111
|
+
## Asset Helpers
|
|
112
|
+
|
|
113
|
+
Tailwind is injected with the official Play CDN script because that is the fastest supported browser-side setup for this library. Bootstrap 5 is injected as a CSS stylesheet from jsDelivr. Google Fonts are emitted as a preconnect pair plus a CSS2 stylesheet link so browsers can establish connections early and only download the fonts you ask for.
|
|
114
|
+
|
|
115
|
+
Common Tailwind class families that pair well with the helpers:
|
|
116
|
+
|
|
117
|
+
- Layout: `container`, `mx-auto`, `flex`, `grid`, `gap-*`, `items-center`, `justify-between`
|
|
118
|
+
- Spacing: `p-*`, `px-*`, `py-*`, `m-*`, `space-x-*`, `space-y-*`
|
|
119
|
+
- Typography: `text-*`, `font-*`, `leading-*`, `tracking-*`
|
|
120
|
+
- Surface: `bg-*`, `text-*`, `border-*`, `rounded-*`, `shadow-*`
|
|
121
|
+
- Responsive prefixes: `sm:`, `md:`, `lg:`, `xl:`, `2xl:`
|
|
122
|
+
|
|
123
|
+
Common Bootstrap 5 class families that pair well with the helpers:
|
|
124
|
+
|
|
125
|
+
- Layout and grid: `container`, `container-fluid`, `row`, `col`, `g-*`, `row-cols-*`
|
|
126
|
+
- Spacing and display: `m-*`, `p-*`, `d-flex`, `d-grid`, `gap-*`, `justify-content-*`, `align-items-*`
|
|
127
|
+
- Typography: `lead`, `fw-bold`, `fst-italic`, `text-muted`, `text-center`, `text-nowrap`
|
|
128
|
+
- Components: `btn`, `btn-primary`, `card`, `badge`, `alert`, `navbar`, `modal`
|
|
129
|
+
- Responsive prefixes: `sm`, `md`, `lg`, `xl`, `xxl`
|
|
130
|
+
|
|
131
|
+
Google Fonts examples:
|
|
132
|
+
|
|
133
|
+
- `google_fonts_assets("Inter")`
|
|
134
|
+
- `google_fonts_assets("Inter", "Open Sans")`
|
|
135
|
+
- `google_fonts_assets("Roboto", weights=(400, 700), text="Hello world")`
|
|
136
|
+
|
|
137
|
+
## Layout
|
|
138
|
+
|
|
139
|
+
- `src/html5/document.py` contains the HTML5 document and element primitives.
|
|
140
|
+
- `src/html5/elements.py` contains generated classes for standard HTML5 tags.
|
|
141
|
+
- `src/html5/css.py` contains CSS node classes for comments, declarations, rules, at-rules, keyframes, inline styles, and `<style>` generation.
|
|
142
|
+
- `src/html5/js.py` contains JavaScript helpers for inline scripts, external scripts, Bootstrap 5, and Google Charts.
|
|
143
|
+
- `src/html5/writer.py` contains a disk writer for rendered HTML and CSS outputs.
|
|
144
|
+
- `src/html5/version.py` stores the semantic version string used by packaging.
|
|
145
|
+
|
|
146
|
+
## Versioning
|
|
147
|
+
|
|
148
|
+
This project uses semantic versioning. The current package version is `0.3.0`, and the release history is documented in [CHANGELOG.md](CHANGELOG.md).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
html5/__init__.py,sha256=5JPvAKio0VdXclTdSd6gGJh9krj_yfK-fomdVMzV5YQ,967
|
|
2
|
+
html5/css.py,sha256=Awd05kzIkg9wzibrwUWEPHRrpd44UTzGYvuZ_G6Nw6g,12206
|
|
3
|
+
html5/document.py,sha256=U0zuXlgQobg1nmjOemEsiarpchbV6zgjNYBMRW1sutw,4057
|
|
4
|
+
html5/elements.py,sha256=qq1hvTGm7UIKoGeSatGacEkbrpRtd5kMNzZA4x-FhDo,7697
|
|
5
|
+
html5/js.py,sha256=MFd6DPT1UsPwyy15zwg-V5gPSkt3WI0Hl_Qwq_pcDzo,2574
|
|
6
|
+
html5/version.py,sha256=ARHt_V5W60rkhA4dNqWOMwN6UASxrGycgPN13sngWbQ,58
|
|
7
|
+
html5/writer.py,sha256=Ul2e3A_3cfCsREnQcg_wtc1bXCMj6t24smscabyb3r0,1665
|
|
8
|
+
html5cc-0.3.0.dist-info/licenses/LICENSE,sha256=AcJ45gbn44D2lI-RgrcLGCkiDjy2XpC8JhGHH-id07k,1073
|
|
9
|
+
html5cc-0.3.0.dist-info/METADATA,sha256=G5JMlAx_WaD6bkk1VLUw1OGr4-T7bIehSntgdOOPRW4,5715
|
|
10
|
+
html5cc-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
html5cc-0.3.0.dist-info/top_level.txt,sha256=wGjDApZ61gkSmsTfbb0jalPII1ajGD__R7w7Ba5PAlw,6
|
|
12
|
+
html5cc-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jason Lee Miller
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
html5
|