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/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("&", "&amp;")
684
+ .replace('"', "&quot;")
685
+ .replace("<", "&lt;")
686
+ .replace(">", "&gt;")
687
+ )