htmlforge 0.0.1__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.
- htmlforge/__init__.py +27 -0
- htmlforge/__main__.py +5 -0
- htmlforge/cli.py +194 -0
- htmlforge/core.py +687 -0
- htmlforge/simple.py +331 -0
- htmlforge-0.0.1.dist-info/METADATA +205 -0
- htmlforge-0.0.1.dist-info/RECORD +10 -0
- htmlforge-0.0.1.dist-info/WHEEL +4 -0
- htmlforge-0.0.1.dist-info/entry_points.txt +2 -0
- htmlforge-0.0.1.dist-info/licenses/LICENSE +1 -0
htmlforge/core.py
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"""webforge core — Elements, Components, and Page."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any, Union
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ============================================================
|
|
9
|
+
# Base Element
|
|
10
|
+
# ============================================================
|
|
11
|
+
|
|
12
|
+
class Element:
|
|
13
|
+
"""Base class for all HTML elements."""
|
|
14
|
+
|
|
15
|
+
tag: str = "div"
|
|
16
|
+
_self_closing: bool = False
|
|
17
|
+
|
|
18
|
+
def __init__(self, *children: Union[Element, str],
|
|
19
|
+
class_: str = "", id: str = "", style: str = "", **attrs: str):
|
|
20
|
+
self.children: list[Union[Element, str]] = list(children)
|
|
21
|
+
self.attrs: dict[str, str] = {}
|
|
22
|
+
if class_:
|
|
23
|
+
self.attrs["class"] = class_
|
|
24
|
+
if id:
|
|
25
|
+
self.attrs["id"] = id
|
|
26
|
+
if style:
|
|
27
|
+
self.attrs["style"] = style
|
|
28
|
+
for k, v in attrs.items():
|
|
29
|
+
self.attrs[k.rstrip("_")] = v
|
|
30
|
+
|
|
31
|
+
# --- builder API (all chainable) ---
|
|
32
|
+
|
|
33
|
+
def add(self, *children: Union[Element, str]) -> Element:
|
|
34
|
+
"""Append child elements or text."""
|
|
35
|
+
for c in children:
|
|
36
|
+
self.children.append(c)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def css(self, **props: str) -> Element:
|
|
40
|
+
"""Merge inline CSS properties."""
|
|
41
|
+
parts = self.attrs.get("style", "").rstrip("; ")
|
|
42
|
+
new = "; ".join(
|
|
43
|
+
f"{_to_kebab(k)}: {v}" for k, v in props.items()
|
|
44
|
+
)
|
|
45
|
+
self.attrs["style"] = f"{parts}; {new}".strip("; ")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def attr(self, key: str, value: str) -> Element:
|
|
49
|
+
"""Set a single HTML attribute."""
|
|
50
|
+
self.attrs[key] = value
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def remove_attr(self, key: str) -> Element:
|
|
54
|
+
self.attrs.pop(key, None)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
# --- rendering ---
|
|
58
|
+
|
|
59
|
+
def _open_tag(self) -> str:
|
|
60
|
+
parts = [self.tag]
|
|
61
|
+
for k, v in self.attrs.items():
|
|
62
|
+
parts.append(f'{k}="{_esc(v)}"')
|
|
63
|
+
return "<" + " ".join(parts) + ">"
|
|
64
|
+
|
|
65
|
+
def render(self, indent: int = 0) -> str:
|
|
66
|
+
pad = " " * indent
|
|
67
|
+
tag_attrs = " ".join(f'{k}="{_esc(v)}"' for k, v in self.attrs.items())
|
|
68
|
+
open_str = f"<{self.tag}" + (f" {tag_attrs}" if tag_attrs else "") + ">"
|
|
69
|
+
|
|
70
|
+
if self._self_closing:
|
|
71
|
+
return f"{pad}{open_str}"
|
|
72
|
+
|
|
73
|
+
if not self.children:
|
|
74
|
+
return f"{pad}{open_str}</{self.tag}>"
|
|
75
|
+
|
|
76
|
+
inner_parts: list[str] = []
|
|
77
|
+
for c in self.children:
|
|
78
|
+
if isinstance(c, Element):
|
|
79
|
+
inner_parts.append(c.render(indent + 1))
|
|
80
|
+
else:
|
|
81
|
+
inner_parts.append(" " * (indent + 1) + _esc(str(c)))
|
|
82
|
+
inner = "\n".join(inner_parts)
|
|
83
|
+
return f"{pad}{open_str}\n{inner}\n{pad}</{self.tag}>"
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
return self.render()
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"<{self.tag} children={len(self.children)}>"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ============================================================
|
|
93
|
+
# Generic / Structural
|
|
94
|
+
# ============================================================
|
|
95
|
+
|
|
96
|
+
class Div(Element):
|
|
97
|
+
tag = "div"
|
|
98
|
+
|
|
99
|
+
class Section(Element):
|
|
100
|
+
tag = "section"
|
|
101
|
+
|
|
102
|
+
class Header(Element):
|
|
103
|
+
tag = "header"
|
|
104
|
+
|
|
105
|
+
class Footer(Element):
|
|
106
|
+
tag = "footer"
|
|
107
|
+
|
|
108
|
+
class Nav(Element):
|
|
109
|
+
tag = "nav"
|
|
110
|
+
|
|
111
|
+
class Main(Element):
|
|
112
|
+
tag = "main"
|
|
113
|
+
|
|
114
|
+
class Article(Element):
|
|
115
|
+
tag = "article"
|
|
116
|
+
|
|
117
|
+
class Aside(Element):
|
|
118
|
+
tag = "aside"
|
|
119
|
+
|
|
120
|
+
class Span(Element):
|
|
121
|
+
tag = "span"
|
|
122
|
+
|
|
123
|
+
class Container(Div):
|
|
124
|
+
"""A ``div`` with ``class="container"`` by default."""
|
|
125
|
+
def __init__(self, *children: Union[Element, str], **attrs: str):
|
|
126
|
+
attrs.setdefault("class_", "container")
|
|
127
|
+
super().__init__(*children, **attrs)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ============================================================
|
|
131
|
+
# Text
|
|
132
|
+
# ============================================================
|
|
133
|
+
|
|
134
|
+
class H1(Element):
|
|
135
|
+
tag = "h1"
|
|
136
|
+
|
|
137
|
+
class H2(Element):
|
|
138
|
+
tag = "h2"
|
|
139
|
+
|
|
140
|
+
class H3(Element):
|
|
141
|
+
tag = "h3"
|
|
142
|
+
|
|
143
|
+
class H4(Element):
|
|
144
|
+
tag = "h4"
|
|
145
|
+
|
|
146
|
+
class H5(Element):
|
|
147
|
+
tag = "h5"
|
|
148
|
+
|
|
149
|
+
class H6(Element):
|
|
150
|
+
tag = "h6"
|
|
151
|
+
|
|
152
|
+
class P(Element):
|
|
153
|
+
tag = "p"
|
|
154
|
+
|
|
155
|
+
class Strong(Element):
|
|
156
|
+
tag = "strong"
|
|
157
|
+
|
|
158
|
+
class Em(Element):
|
|
159
|
+
tag = "em"
|
|
160
|
+
|
|
161
|
+
class Blockquote(Element):
|
|
162
|
+
tag = "blockquote"
|
|
163
|
+
|
|
164
|
+
class Small(Element):
|
|
165
|
+
tag = "small"
|
|
166
|
+
|
|
167
|
+
class Mark(Element):
|
|
168
|
+
tag = "mark"
|
|
169
|
+
|
|
170
|
+
class Sub(Element):
|
|
171
|
+
tag = "sub"
|
|
172
|
+
|
|
173
|
+
class Sup(Element):
|
|
174
|
+
tag = "sup"
|
|
175
|
+
|
|
176
|
+
class Code(Element):
|
|
177
|
+
tag = "code"
|
|
178
|
+
|
|
179
|
+
class Pre(Element):
|
|
180
|
+
tag = "pre"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Text(Element):
|
|
184
|
+
"""Raw HTML passthrough — ``render()`` returns the string verbatim."""
|
|
185
|
+
tag = ""
|
|
186
|
+
_self_closing = True
|
|
187
|
+
|
|
188
|
+
def __init__(self, html: str = ""):
|
|
189
|
+
super().__init__()
|
|
190
|
+
self._html = html
|
|
191
|
+
|
|
192
|
+
def render(self, indent: int = 0) -> str:
|
|
193
|
+
return " " * indent + self._html
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Link(Element):
|
|
197
|
+
tag = "a"
|
|
198
|
+
|
|
199
|
+
def __init__(self, text: str = "", href: str = "#",
|
|
200
|
+
target: str = "", **attrs: str):
|
|
201
|
+
super().__init__(text, **attrs)
|
|
202
|
+
self.attrs["href"] = href
|
|
203
|
+
if target:
|
|
204
|
+
self.attrs["target"] = target
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ============================================================
|
|
208
|
+
# Lists
|
|
209
|
+
# ============================================================
|
|
210
|
+
|
|
211
|
+
class Ul(Element):
|
|
212
|
+
tag = "ul"
|
|
213
|
+
|
|
214
|
+
class Ol(Element):
|
|
215
|
+
tag = "ol"
|
|
216
|
+
|
|
217
|
+
class Li(Element):
|
|
218
|
+
tag = "li"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class List(Element):
|
|
222
|
+
"""Convenience: auto-wraps strings in ``<li>`` elements."""
|
|
223
|
+
tag = "ul"
|
|
224
|
+
|
|
225
|
+
def __init__(self, items: list | None = None, ordered: bool = False, **attrs: str):
|
|
226
|
+
super().__init__(**attrs)
|
|
227
|
+
self.tag = "ol" if ordered else "ul"
|
|
228
|
+
for item in (items or []):
|
|
229
|
+
self.children.append(Li(item) if isinstance(item, str) else item)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ============================================================
|
|
233
|
+
# Media
|
|
234
|
+
# ============================================================
|
|
235
|
+
|
|
236
|
+
class Img(Element):
|
|
237
|
+
tag = "img"
|
|
238
|
+
_self_closing = True
|
|
239
|
+
|
|
240
|
+
def __init__(self, src: str = "", alt: str = "", **attrs: str):
|
|
241
|
+
super().__init__(**attrs)
|
|
242
|
+
if src:
|
|
243
|
+
self.attrs["src"] = src
|
|
244
|
+
if alt:
|
|
245
|
+
self.attrs["alt"] = alt
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Video(Element):
|
|
249
|
+
tag = "video"
|
|
250
|
+
|
|
251
|
+
def __init__(self, src: str = "", controls: bool = True, **attrs: str):
|
|
252
|
+
super().__init__(**attrs)
|
|
253
|
+
if src:
|
|
254
|
+
self.children.append(Element.__new__(Source).__init_tag(src))
|
|
255
|
+
if controls:
|
|
256
|
+
self.attrs["controls"] = ""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Audio(Element):
|
|
260
|
+
tag = "audio"
|
|
261
|
+
|
|
262
|
+
def __init__(self, src: str = "", controls: bool = True, **attrs: str):
|
|
263
|
+
super().__init__(**attrs)
|
|
264
|
+
if src:
|
|
265
|
+
self.attrs["src"] = src
|
|
266
|
+
if controls:
|
|
267
|
+
self.attrs["controls"] = ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Source(Element):
|
|
271
|
+
tag = "source"
|
|
272
|
+
_self_closing = True
|
|
273
|
+
|
|
274
|
+
def __init__(self, src: str = "", type_: str = "", **attrs: str):
|
|
275
|
+
super().__init__(**attrs)
|
|
276
|
+
if src:
|
|
277
|
+
self.attrs["src"] = src
|
|
278
|
+
if type_:
|
|
279
|
+
self.attrs["type"] = type_
|
|
280
|
+
|
|
281
|
+
def __init_tag(self, src: str) -> Source:
|
|
282
|
+
"""Internal fast-init used by Video."""
|
|
283
|
+
self.children = []
|
|
284
|
+
self.attrs = {"src": src}
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class Iframe(Element):
|
|
289
|
+
tag = "iframe"
|
|
290
|
+
|
|
291
|
+
def __init__(self, src: str = "", width: str = "", height: str = "", **attrs: str):
|
|
292
|
+
super().__init__(**attrs)
|
|
293
|
+
if src:
|
|
294
|
+
self.attrs["src"] = src
|
|
295
|
+
if width:
|
|
296
|
+
self.attrs["width"] = width
|
|
297
|
+
if height:
|
|
298
|
+
self.attrs["height"] = height
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Canvas(Element):
|
|
302
|
+
tag = "canvas"
|
|
303
|
+
|
|
304
|
+
def __init__(self, width: str = "", height: str = "", **attrs: str):
|
|
305
|
+
super().__init__(**attrs)
|
|
306
|
+
if width:
|
|
307
|
+
self.attrs["width"] = width
|
|
308
|
+
if height:
|
|
309
|
+
self.attrs["height"] = height
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ============================================================
|
|
313
|
+
# Table
|
|
314
|
+
# ============================================================
|
|
315
|
+
|
|
316
|
+
class Table(Element):
|
|
317
|
+
tag = "table"
|
|
318
|
+
|
|
319
|
+
def __init__(self, headers: list[str] | None = None,
|
|
320
|
+
rows: list[list[str]] | None = None, **attrs: str):
|
|
321
|
+
super().__init__(**attrs)
|
|
322
|
+
if headers:
|
|
323
|
+
thead = Thead().add(Tr(*[Th(h) for h in headers]))
|
|
324
|
+
self.children.append(thead)
|
|
325
|
+
if rows:
|
|
326
|
+
tbody = Tbody()
|
|
327
|
+
for row in rows:
|
|
328
|
+
tbody.add(Tr(*[Td(str(cell)) for cell in row]))
|
|
329
|
+
self.children.append(tbody)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Thead(Element):
|
|
333
|
+
tag = "thead"
|
|
334
|
+
|
|
335
|
+
class Tbody(Element):
|
|
336
|
+
tag = "tbody"
|
|
337
|
+
|
|
338
|
+
class Tr(Element):
|
|
339
|
+
tag = "tr"
|
|
340
|
+
|
|
341
|
+
class Th(Element):
|
|
342
|
+
tag = "th"
|
|
343
|
+
|
|
344
|
+
class Td(Element):
|
|
345
|
+
tag = "td"
|
|
346
|
+
|
|
347
|
+
class Caption(Element):
|
|
348
|
+
tag = "caption"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ============================================================
|
|
352
|
+
# Forms
|
|
353
|
+
# ============================================================
|
|
354
|
+
|
|
355
|
+
class Form(Element):
|
|
356
|
+
tag = "form"
|
|
357
|
+
|
|
358
|
+
def __init__(self, *children: Union[Element, str],
|
|
359
|
+
action: str = "", method: str = "", **attrs: str):
|
|
360
|
+
super().__init__(*children, **attrs)
|
|
361
|
+
if action:
|
|
362
|
+
self.attrs["action"] = action
|
|
363
|
+
if method:
|
|
364
|
+
self.attrs["method"] = method
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class Input(Element):
|
|
368
|
+
tag = "input"
|
|
369
|
+
_self_closing = True
|
|
370
|
+
|
|
371
|
+
def __init__(self, type_: str = "text", name: str = "",
|
|
372
|
+
placeholder: str = "", value: str = "", **attrs: str):
|
|
373
|
+
super().__init__(**attrs)
|
|
374
|
+
self.attrs["type"] = type_
|
|
375
|
+
if name:
|
|
376
|
+
self.attrs["name"] = name
|
|
377
|
+
if placeholder:
|
|
378
|
+
self.attrs["placeholder"] = placeholder
|
|
379
|
+
if value:
|
|
380
|
+
self.attrs["value"] = value
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class Textarea(Element):
|
|
384
|
+
tag = "textarea"
|
|
385
|
+
|
|
386
|
+
def __init__(self, text: str = "", name: str = "",
|
|
387
|
+
rows: str = "", cols: str = "", **attrs: str):
|
|
388
|
+
super().__init__(**attrs)
|
|
389
|
+
if text:
|
|
390
|
+
self.children.append(text)
|
|
391
|
+
if name:
|
|
392
|
+
self.attrs["name"] = name
|
|
393
|
+
if rows:
|
|
394
|
+
self.attrs["rows"] = rows
|
|
395
|
+
if cols:
|
|
396
|
+
self.attrs["cols"] = cols
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class Select(Element):
|
|
400
|
+
tag = "select"
|
|
401
|
+
|
|
402
|
+
def __init__(self, options: list[tuple[str, str]] | None = None,
|
|
403
|
+
name: str = "", **attrs: str):
|
|
404
|
+
super().__init__(**attrs)
|
|
405
|
+
if name:
|
|
406
|
+
self.attrs["name"] = name
|
|
407
|
+
for val, label in (options or []):
|
|
408
|
+
self.children.append(Option(label, value=val))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class Option(Element):
|
|
412
|
+
tag = "option"
|
|
413
|
+
|
|
414
|
+
def __init__(self, text: str = "", value: str = "",
|
|
415
|
+
selected: bool = False, **attrs: str):
|
|
416
|
+
super().__init__(text, **attrs)
|
|
417
|
+
if value:
|
|
418
|
+
self.attrs["value"] = value
|
|
419
|
+
if selected:
|
|
420
|
+
self.attrs["selected"] = ""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class Checkbox(Element):
|
|
424
|
+
tag = "input"
|
|
425
|
+
_self_closing = True
|
|
426
|
+
|
|
427
|
+
def __init__(self, name: str = "", label: str = "",
|
|
428
|
+
checked: bool = False, **attrs: str):
|
|
429
|
+
super().__init__(**attrs)
|
|
430
|
+
self.attrs["type"] = "checkbox"
|
|
431
|
+
if name:
|
|
432
|
+
self.attrs["name"] = name
|
|
433
|
+
if checked:
|
|
434
|
+
self.attrs["checked"] = ""
|
|
435
|
+
self._label = label
|
|
436
|
+
|
|
437
|
+
def render(self, indent: int = 0) -> str:
|
|
438
|
+
base = super().render(indent)
|
|
439
|
+
if self._label:
|
|
440
|
+
return f'{base} <label>{_esc(self._label)}</label>'
|
|
441
|
+
return base
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class Radio(Element):
|
|
445
|
+
tag = "input"
|
|
446
|
+
_self_closing = True
|
|
447
|
+
|
|
448
|
+
def __init__(self, name: str = "", value: str = "",
|
|
449
|
+
label: str = "", checked: bool = False, **attrs: str):
|
|
450
|
+
super().__init__(**attrs)
|
|
451
|
+
self.attrs["type"] = "radio"
|
|
452
|
+
if name:
|
|
453
|
+
self.attrs["name"] = name
|
|
454
|
+
if value:
|
|
455
|
+
self.attrs["value"] = value
|
|
456
|
+
if checked:
|
|
457
|
+
self.attrs["checked"] = ""
|
|
458
|
+
self._label = label
|
|
459
|
+
|
|
460
|
+
def render(self, indent: int = 0) -> str:
|
|
461
|
+
base = super().render(indent)
|
|
462
|
+
if self._label:
|
|
463
|
+
return f'{base} <label>{_esc(self._label)}</label>'
|
|
464
|
+
return base
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class Label(Element):
|
|
468
|
+
tag = "label"
|
|
469
|
+
|
|
470
|
+
def __init__(self, text: str = "", for_: str = "", **attrs: str):
|
|
471
|
+
super().__init__(text, **attrs)
|
|
472
|
+
if for_:
|
|
473
|
+
self.attrs["for"] = for_
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class Button(Element):
|
|
477
|
+
tag = "button"
|
|
478
|
+
|
|
479
|
+
def __init__(self, text: str = "", type_: str = "button",
|
|
480
|
+
onclick: str = "", **attrs: str):
|
|
481
|
+
super().__init__(text, **attrs)
|
|
482
|
+
if type_:
|
|
483
|
+
self.attrs["type"] = type_
|
|
484
|
+
if onclick:
|
|
485
|
+
self.attrs["onclick"] = onclick
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ============================================================
|
|
489
|
+
# Misc
|
|
490
|
+
# ============================================================
|
|
491
|
+
|
|
492
|
+
class Hr(Element):
|
|
493
|
+
tag = "hr"
|
|
494
|
+
_self_closing = True
|
|
495
|
+
|
|
496
|
+
class Br(Element):
|
|
497
|
+
tag = "br"
|
|
498
|
+
_self_closing = True
|
|
499
|
+
|
|
500
|
+
class Meta(Element):
|
|
501
|
+
tag = "meta"
|
|
502
|
+
_self_closing = True
|
|
503
|
+
|
|
504
|
+
class Progress(Element):
|
|
505
|
+
tag = "progress"
|
|
506
|
+
|
|
507
|
+
def __init__(self, value: float = 0, max_: float = 100, **attrs: str):
|
|
508
|
+
super().__init__(**attrs)
|
|
509
|
+
self.attrs["value"] = str(value)
|
|
510
|
+
self.attrs["max"] = str(max_)
|
|
511
|
+
|
|
512
|
+
class Details(Element):
|
|
513
|
+
tag = "details"
|
|
514
|
+
|
|
515
|
+
class Summary(Element):
|
|
516
|
+
tag = "summary"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ============================================================
|
|
520
|
+
# Page
|
|
521
|
+
# ============================================================
|
|
522
|
+
|
|
523
|
+
class Page:
|
|
524
|
+
"""Top-level HTML document builder.
|
|
525
|
+
|
|
526
|
+
Usage::
|
|
527
|
+
|
|
528
|
+
page = Page("My Site")
|
|
529
|
+
page.add(H1("Hello"), P("World"))
|
|
530
|
+
html = page.render()
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
def __init__(self, title: str = "Untitled", *,
|
|
534
|
+
lang: str = "zh", charset: str = "utf-8",
|
|
535
|
+
viewport: bool = True):
|
|
536
|
+
self.title = title
|
|
537
|
+
self.lang = lang
|
|
538
|
+
self.charset = charset
|
|
539
|
+
self.viewport = viewport
|
|
540
|
+
self.head_elements: list[Element] = []
|
|
541
|
+
self.body_children: list[Union[Element, str]] = []
|
|
542
|
+
self._css_rules: list[str] = []
|
|
543
|
+
self._inline_scripts: list[str] = []
|
|
544
|
+
self._script_srcs: list[str] = []
|
|
545
|
+
self._ext_css: list[str] = []
|
|
546
|
+
self._meta_tags: list[str] = []
|
|
547
|
+
|
|
548
|
+
# --- CSS ---
|
|
549
|
+
|
|
550
|
+
def add_css(self, css: str) -> Page:
|
|
551
|
+
"""Add raw CSS rules."""
|
|
552
|
+
self._css_rules.append(css)
|
|
553
|
+
return self
|
|
554
|
+
|
|
555
|
+
def add_stylesheet(self, href: str) -> Page:
|
|
556
|
+
"""Link an external CSS file."""
|
|
557
|
+
self._ext_css.append(href)
|
|
558
|
+
return self
|
|
559
|
+
|
|
560
|
+
# --- JS ---
|
|
561
|
+
|
|
562
|
+
def add_script(self, code: str = "", src: str = "") -> Page:
|
|
563
|
+
if src:
|
|
564
|
+
self._script_srcs.append(src)
|
|
565
|
+
elif code:
|
|
566
|
+
self._inline_scripts.append(code)
|
|
567
|
+
return self
|
|
568
|
+
|
|
569
|
+
# --- body content ---
|
|
570
|
+
|
|
571
|
+
def add(self, *children: Union[Element, str]) -> Page:
|
|
572
|
+
for c in children:
|
|
573
|
+
self.body_children.append(c)
|
|
574
|
+
return self
|
|
575
|
+
|
|
576
|
+
def add_raw(self, html: str) -> Page:
|
|
577
|
+
self.body_children.append(html)
|
|
578
|
+
return self
|
|
579
|
+
|
|
580
|
+
# --- convenience (shortcuts) ---
|
|
581
|
+
|
|
582
|
+
def header(self, text: str, level: int = 1, **attrs: str) -> Page:
|
|
583
|
+
tags = {1: H1, 2: H2, 3: H3, 4: H4, 5: H5, 6: H6}
|
|
584
|
+
return self.add(tags.get(level, H1)(text, **attrs))
|
|
585
|
+
|
|
586
|
+
def paragraph(self, text: str, **attrs: str) -> Page:
|
|
587
|
+
return self.add(P(text, **attrs))
|
|
588
|
+
|
|
589
|
+
def image(self, src: str, alt: str = "", **attrs: str) -> Page:
|
|
590
|
+
return self.add(Img(src, alt, **attrs))
|
|
591
|
+
|
|
592
|
+
def link(self, text: str, href: str, **attrs: str) -> Page:
|
|
593
|
+
return self.add(Link(text, href, **attrs))
|
|
594
|
+
|
|
595
|
+
def button(self, text: str, **attrs: str) -> Page:
|
|
596
|
+
return self.add(Button(text, **attrs))
|
|
597
|
+
|
|
598
|
+
def line_break(self) -> Page:
|
|
599
|
+
return self.add(Br())
|
|
600
|
+
|
|
601
|
+
def horizontal_rule(self) -> Page:
|
|
602
|
+
return self.add(Hr())
|
|
603
|
+
|
|
604
|
+
def divider(self) -> Page:
|
|
605
|
+
return self.add(Hr())
|
|
606
|
+
|
|
607
|
+
def list(self, items: list[str], ordered: bool = False, **attrs: str) -> Page:
|
|
608
|
+
return self.add(List(items, ordered, **attrs))
|
|
609
|
+
|
|
610
|
+
def table(self, headers: list[str], rows: list[list[str]], **attrs: str) -> Page:
|
|
611
|
+
return self.add(Table(headers, rows, **attrs))
|
|
612
|
+
|
|
613
|
+
def code_block(self, code: str, language: str = "") -> Page:
|
|
614
|
+
code_el = Code(code)
|
|
615
|
+
if language:
|
|
616
|
+
code_el.attr("class", f"language-{language}")
|
|
617
|
+
return self.add(Pre().add(code_el))
|
|
618
|
+
|
|
619
|
+
# --- rendering ---
|
|
620
|
+
|
|
621
|
+
def render(self) -> str:
|
|
622
|
+
head: list[str] = [
|
|
623
|
+
f'<meta charset="{self.charset}">',
|
|
624
|
+
]
|
|
625
|
+
if self.viewport:
|
|
626
|
+
head.append(
|
|
627
|
+
'<meta name="viewport" '
|
|
628
|
+
'content="width=device-width, initial-scale=1.0">'
|
|
629
|
+
)
|
|
630
|
+
head.append(f"<title>{_esc(self.title)}</title>")
|
|
631
|
+
|
|
632
|
+
for href in self._ext_css:
|
|
633
|
+
head.append(f'<link rel="stylesheet" href="{_esc(href)}">')
|
|
634
|
+
for m in self._meta_tags:
|
|
635
|
+
head.append(m)
|
|
636
|
+
for el in self.head_elements:
|
|
637
|
+
head.append(el.render())
|
|
638
|
+
|
|
639
|
+
if self._css_rules:
|
|
640
|
+
css = "\n".join(self._css_rules)
|
|
641
|
+
head.append(f"<style>\n{css}\n</style>")
|
|
642
|
+
|
|
643
|
+
body_parts: list[str] = []
|
|
644
|
+
for c in self.body_children:
|
|
645
|
+
if isinstance(c, Element):
|
|
646
|
+
body_parts.append(c.render())
|
|
647
|
+
else:
|
|
648
|
+
body_parts.append(str(c))
|
|
649
|
+
|
|
650
|
+
for src in self._script_srcs:
|
|
651
|
+
body_parts.append(f'<script src="{_esc(src)}"></script>')
|
|
652
|
+
for code in self._inline_scripts:
|
|
653
|
+
body_parts.append(f"<script>\n{code}\n</script>")
|
|
654
|
+
|
|
655
|
+
head_str = "\n".join(f" {l}" for l in head)
|
|
656
|
+
body_str = "\n".join(f" {l}" for l in body_parts)
|
|
657
|
+
|
|
658
|
+
return (
|
|
659
|
+
f"<!DOCTYPE html>\n"
|
|
660
|
+
f'<html lang="{self.lang}">\n'
|
|
661
|
+
f"<head>\n{head_str}\n</head>\n"
|
|
662
|
+
f"<body>\n{body_str}\n</body>\n"
|
|
663
|
+
f"</html>"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
def __str__(self) -> str:
|
|
667
|
+
return self.render()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# ============================================================
|
|
671
|
+
# Helpers
|
|
672
|
+
# ============================================================
|
|
673
|
+
|
|
674
|
+
def _to_kebab(name: str) -> str:
|
|
675
|
+
"""``backgroundColor`` → ``background-color``."""
|
|
676
|
+
return re.sub(r"(?<=[a-z])(?=[A-Z])", "-", name).lower().replace("_", "-")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _esc(s: str) -> str:
|
|
680
|
+
"""Escape HTML-special characters in attribute values."""
|
|
681
|
+
return (
|
|
682
|
+
str(s)
|
|
683
|
+
.replace("&", "&")
|
|
684
|
+
.replace('"', """)
|
|
685
|
+
.replace("<", "<")
|
|
686
|
+
.replace(">", ">")
|
|
687
|
+
)
|