htmlforge 0.0.1__tar.gz → 0.0.3__tar.gz
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-0.0.3/PKG-INFO +427 -0
- htmlforge-0.0.3/README.md +409 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/pyproject.toml +1 -1
- {htmlforge-0.0.1 → htmlforge-0.0.3}/src/htmlforge/__init__.py +3 -2
- htmlforge-0.0.3/src/htmlforge/cli.py +360 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/src/htmlforge/core.py +111 -9
- htmlforge-0.0.3/src/htmlforge/simple.py +734 -0
- htmlforge-0.0.3/src/htmlforge/transpiler.py +639 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/tests/test_cli.py +51 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/tests/test_core.py +93 -1
- {htmlforge-0.0.1 → htmlforge-0.0.3}/tests/test_simple.py +172 -1
- htmlforge-0.0.3/tests/test_transpiler.py +446 -0
- htmlforge-0.0.1/PKG-INFO +0 -205
- htmlforge-0.0.1/README.md +0 -187
- htmlforge-0.0.1/src/htmlforge/cli.py +0 -194
- htmlforge-0.0.1/src/htmlforge/simple.py +0 -331
- {htmlforge-0.0.1 → htmlforge-0.0.3}/.gitignore +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/LICENSE +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/demo.py +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/demo_simple.py +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/src/htmlforge/__main__.py +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/style_demo.py +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/tests/__init__.py +0 -0
- {htmlforge-0.0.1 → htmlforge-0.0.3}/tests/buttons.py +0 -0
htmlforge-0.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: htmlforge
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Build web pages with Python, export to HTML with a single command
|
|
5
|
+
Project-URL: Homepage, https://github.com/htmlforge/htmlforge
|
|
6
|
+
Author: htmlforge contributors
|
|
7
|
+
License-Expression: MPL-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: builder,html,page,static-site,web
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# htmlforge
|
|
20
|
+
|
|
21
|
+
Build web pages with Python, export to HTML with a single command — like manim generates videos.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
manim scene.py MyScene → mp4
|
|
25
|
+
htmlforge page.py MyPage → html
|
|
26
|
+
htmlforge script app.py → js
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install htmlforge
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
### 1. Write a Python page script `mysite.py`
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from htmlforge import Doc
|
|
41
|
+
|
|
42
|
+
page = Doc("My Site")
|
|
43
|
+
|
|
44
|
+
# Generate styles with Python kwargs (no CSS strings!)
|
|
45
|
+
page.style("body", background="#f5f5f5", font_family="sans-serif")
|
|
46
|
+
page.style(".py-card", border_radius="12px", box_shadow="0 2px 8px rgba(0,0,0,.1)")
|
|
47
|
+
|
|
48
|
+
# Build the page
|
|
49
|
+
page.heading("Welcome to My Site")
|
|
50
|
+
page.text("Built with **htmlforge**, supports *Markdown* syntax")
|
|
51
|
+
|
|
52
|
+
page.card("Features",
|
|
53
|
+
"40+ HTML components",
|
|
54
|
+
"Generate styles with Python code",
|
|
55
|
+
"Export HTML with one command",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
page.table(
|
|
59
|
+
["Feature", "Description"],
|
|
60
|
+
[
|
|
61
|
+
["Components", "40+ HTML components"],
|
|
62
|
+
["Built-in theme", "Works out of the box"],
|
|
63
|
+
["CLI tools", "render / serve / watch / script"],
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
page.button("Click me", on_click="alert('Hello!')", color="#4CAF50")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 2. Run the command to export HTML
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Basic render
|
|
74
|
+
htmlforge render mysite.py
|
|
75
|
+
|
|
76
|
+
# Specify output directory
|
|
77
|
+
htmlforge render mysite.py -o build
|
|
78
|
+
|
|
79
|
+
# Render and start a local server
|
|
80
|
+
htmlforge render mysite.py -s
|
|
81
|
+
|
|
82
|
+
# Watch for file changes and auto-rebuild
|
|
83
|
+
htmlforge render mysite.py -w
|
|
84
|
+
|
|
85
|
+
# Render a specific page (when file has multiple pages)
|
|
86
|
+
htmlforge render mysite.py MyPage
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## CLI Commands
|
|
90
|
+
|
|
91
|
+
| Command | Description |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `htmlforge render <file.py>` | Render all pages to `dist/` |
|
|
94
|
+
| `htmlforge render <file.py> -o <dir>` | Specify output directory |
|
|
95
|
+
| `htmlforge render <file.py> <page>` | Render only the specified page |
|
|
96
|
+
| `htmlforge render <file.py> -s` | Start HTTP server after render |
|
|
97
|
+
| `htmlforge render <file.py> -s -p 3000` | Specify server port |
|
|
98
|
+
| `htmlforge render <file.py> -w` | Watch for changes and auto-rebuild |
|
|
99
|
+
| `htmlforge render <file.py> -s -w` | Serve + watch, browser auto-reloads on change |
|
|
100
|
+
| `htmlforge script <file.py>` | Convert Python file to JavaScript |
|
|
101
|
+
| `htmlforge script <file.py> -o app.js` | Specify output JS filename |
|
|
102
|
+
| `htmlforge script <file.py> --helpers` | Include DOM helper functions |
|
|
103
|
+
|
|
104
|
+
## Doc API — Ultra Simple
|
|
105
|
+
|
|
106
|
+
`Doc` is a high-level API designed for Python programmers who don't know HTML. It comes with a built-in theme — no CSS required to build great-looking pages.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from htmlforge import Doc
|
|
110
|
+
|
|
111
|
+
page = Doc("Title")
|
|
112
|
+
|
|
113
|
+
# Generate styles with Python kwargs (no CSS strings!)
|
|
114
|
+
page.style("body", background="linear-gradient(135deg, #667eea, #764ba2)", color="white")
|
|
115
|
+
page.style("h1", font_size="3rem", text_align="center")
|
|
116
|
+
page.style(".py-card", background="rgba(255,255,255,.15)", backdrop_filter="blur(10px)")
|
|
117
|
+
|
|
118
|
+
# Content (supports **Markdown** syntax)
|
|
119
|
+
page.heading("Hello World")
|
|
120
|
+
page.text("Supports **bold**, *italic*, `code`, [links](url)")
|
|
121
|
+
|
|
122
|
+
page.card("Card Title", "Card content, **supports Markdown**")
|
|
123
|
+
page.list(["Item A", "Item B", "Item C"])
|
|
124
|
+
page.table(["Col 1", "Col 2"], [["a", "b"], ["c", "d"]])
|
|
125
|
+
page.button("Button", on_click="alert('hi')", color="#e74c3c")
|
|
126
|
+
page.input("Type here...", name="name")
|
|
127
|
+
page.select(["Option A", "Option B", "Option C"])
|
|
128
|
+
page.code("print('hello')", lang="python")
|
|
129
|
+
page.details("Collapsible", "Hidden content...")
|
|
130
|
+
page.nav(("Home", "/"), ("About", "/about"))
|
|
131
|
+
page.divider()
|
|
132
|
+
|
|
133
|
+
# Row / grid
|
|
134
|
+
page.row(Doc.make_card("A"), Doc.make_card("B"), Doc.make_card("C"))
|
|
135
|
+
|
|
136
|
+
# Head / SEO
|
|
137
|
+
page.set_meta(description="My site", author="Me", keywords="python, web")
|
|
138
|
+
page.meta("My Site", property="og:title")
|
|
139
|
+
page.favicon("favicon.ico")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Block-level Markdown
|
|
143
|
+
|
|
144
|
+
`page.markdown()` renders full Markdown — not just inline — including headings,
|
|
145
|
+
lists, fenced code, tables, blockquotes and rules:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
page.markdown('''
|
|
149
|
+
# Release Notes
|
|
150
|
+
|
|
151
|
+
- **Fast** rendering
|
|
152
|
+
- Zero dependencies
|
|
153
|
+
|
|
154
|
+
| Command | Purpose |
|
|
155
|
+
|---------|---------|
|
|
156
|
+
| render | Build HTML |
|
|
157
|
+
|
|
158
|
+
> Tip: pull in a whole README with `markdown_file`.
|
|
159
|
+
''')
|
|
160
|
+
|
|
161
|
+
# Or load straight from a file
|
|
162
|
+
page.markdown_file("README.md")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Static Assets
|
|
166
|
+
|
|
167
|
+
`page.asset()` registers a local file (image / css / js). It returns the URL to
|
|
168
|
+
reference, and `htmlforge render` copies the file into `dist/assets/` so the
|
|
169
|
+
exported site is fully self-contained:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
logo = page.asset("logo.png") # → "assets/logo.png"
|
|
173
|
+
page.image(logo, "Logo")
|
|
174
|
+
|
|
175
|
+
page.add_stylesheet(page.asset("theme.css"))
|
|
176
|
+
page.js_file(page.asset("app.js"))
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
After `htmlforge render site.py`, `dist/` holds both `index.html` and
|
|
180
|
+
`assets/logo.png`.
|
|
181
|
+
|
|
182
|
+
## Layouts — Share Header / Footer
|
|
183
|
+
|
|
184
|
+
A `Layout` is a reusable page skeleton: every page built from it shares the
|
|
185
|
+
same head tags, header and footer.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from htmlforge import Doc, Layout, Div, H1, P
|
|
189
|
+
|
|
190
|
+
site = Layout(
|
|
191
|
+
head=lambda doc: doc.set_meta(author="Me"),
|
|
192
|
+
header=lambda doc: Div(class_="top").add(H1("My Site")),
|
|
193
|
+
footer=lambda doc: P(f"© 2026 — {doc.title}"),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
home = site.page("Home") # → a Doc with the shared chrome
|
|
197
|
+
home.heading("Welcome")
|
|
198
|
+
|
|
199
|
+
about = site.page("About")
|
|
200
|
+
about.text("About us...")
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
htmlforge render site.py
|
|
205
|
+
# → dist/home.html, dist/about.html (both wrapped in the shared header/footer)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Each slot accepts an `Element`, a list of elements, or a callable receiving the
|
|
209
|
+
page — so you can highlight the current nav item, inject the title, and more.
|
|
210
|
+
|
|
211
|
+
## JavaScript & DOM Interaction
|
|
212
|
+
|
|
213
|
+
### Bind events to elements
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from htmlforge import Button
|
|
217
|
+
|
|
218
|
+
btn = Button("Click me")
|
|
219
|
+
|
|
220
|
+
# Bind raw JavaScript
|
|
221
|
+
btn.on("click", "alert('hello')")
|
|
222
|
+
btn.on("mouseover", "this.style.color='red'")
|
|
223
|
+
|
|
224
|
+
# Bind Python code (auto-converted to JS)
|
|
225
|
+
btn.on_py("click", "print('clicked!')")
|
|
226
|
+
# → onclick="console.log('clicked!');"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Add JavaScript to the page
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
# Raw JavaScript
|
|
233
|
+
page.js("document.title = 'Dynamic'")
|
|
234
|
+
page.js("function greet() { alert('hello'); }")
|
|
235
|
+
|
|
236
|
+
# External JS file
|
|
237
|
+
page.js_file("app.js")
|
|
238
|
+
|
|
239
|
+
# Python code → JavaScript (auto-converted)
|
|
240
|
+
page.py_script('''
|
|
241
|
+
def greet(name):
|
|
242
|
+
print(f"Hello {name}")
|
|
243
|
+
greet("World")
|
|
244
|
+
''')
|
|
245
|
+
|
|
246
|
+
# Run on page load (DOMContentLoaded)
|
|
247
|
+
page.on_ready("console.log('Page loaded!')")
|
|
248
|
+
page.on_ready_py('print("Ready!")')
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### DOM manipulation helpers
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# Get element properties (returns JS expressions)
|
|
255
|
+
val = page.get_attr("#input", "value") # → document.querySelector('#input').getAttribute('value')
|
|
256
|
+
text = page.get_text("#title") # → document.querySelector('#title').textContent
|
|
257
|
+
html = page.get_html("#content") # → document.querySelector('#content').innerHTML
|
|
258
|
+
value = page.get_value("#myInput") # → document.querySelector('#myInput').value
|
|
259
|
+
|
|
260
|
+
# Set element properties (generates JS scripts)
|
|
261
|
+
page.set_attr("#el", "class", "active")
|
|
262
|
+
page.set_text("#title", "New Title")
|
|
263
|
+
page.set_html("#content", "<b>bold</b>")
|
|
264
|
+
page.set_value("#input", "hello")
|
|
265
|
+
|
|
266
|
+
# Visibility
|
|
267
|
+
page.show("#modal")
|
|
268
|
+
page.hide("#loading")
|
|
269
|
+
page.toggle("#sidebar")
|
|
270
|
+
|
|
271
|
+
# CSS class manipulation
|
|
272
|
+
page.add_class("#el", "active")
|
|
273
|
+
page.remove_class("#el", "hidden")
|
|
274
|
+
page.toggle_class("#menu", "open")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
> **Security:** every selector and value passed to these helpers is
|
|
278
|
+
> automatically escaped, so it is safe to feed them dynamic / user data.
|
|
279
|
+
|
|
280
|
+
## Python → JavaScript Transpiler
|
|
281
|
+
|
|
282
|
+
Use `htmlforge script` to convert Python files to JavaScript:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
htmlforge script app.py # → app.js
|
|
286
|
+
htmlforge script app.py -o out.js # → out.js
|
|
287
|
+
htmlforge script app.py --helpers # include DOM helpers ($, $$, getAttr, setAttr, ...)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Or use it programmatically:
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
from htmlforge import py_to_js, convert_file
|
|
294
|
+
|
|
295
|
+
# Convert Python source string to JS
|
|
296
|
+
js = py_to_js("""
|
|
297
|
+
def greet(name):
|
|
298
|
+
print(f"Hello {name}")
|
|
299
|
+
|
|
300
|
+
for i in range(10):
|
|
301
|
+
greet("World")
|
|
302
|
+
""")
|
|
303
|
+
|
|
304
|
+
# Convert a file
|
|
305
|
+
convert_file("app.py", "app.js")
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Supported Python → JS conversions
|
|
309
|
+
|
|
310
|
+
| Python | JavaScript |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `print(...)` | `console.log(...)` |
|
|
313
|
+
| `input(...)` | `prompt(...)` |
|
|
314
|
+
| `def f(x):` | `function f(x) {` |
|
|
315
|
+
| `class Foo(Bar):` | `class Foo extends Bar {` |
|
|
316
|
+
| `self.x` | `this.x` |
|
|
317
|
+
| `if/elif/else:` | `if/else if/else {` |
|
|
318
|
+
| `for i in range(n):` | `for (let i = 0; i < n; i++) {` |
|
|
319
|
+
| `for x in items:` | `for (let x of items) {` |
|
|
320
|
+
| `while cond:` | `while (cond) {` |
|
|
321
|
+
| `try/except/finally:` | `try/catch/finally {` |
|
|
322
|
+
| `True / False / None` | `true / false / null` |
|
|
323
|
+
| `and / or / not` | `&& / \|\| / !` |
|
|
324
|
+
| `a // b` | `Math.floor(a / b)` |
|
|
325
|
+
| `a ** b` | `Math.pow(a, b)` |
|
|
326
|
+
| `len(x)` | `x.length` |
|
|
327
|
+
| `f"Hello {name}"` | `` `Hello ${name}` `` |
|
|
328
|
+
| `lambda x: x + 1` | `(x) => x + 1` |
|
|
329
|
+
| `x in items` | `items.includes(x)` |
|
|
330
|
+
| `sorted(items)` | `[...items].sort()` |
|
|
331
|
+
|
|
332
|
+
## Page API — Fine-grained Control
|
|
333
|
+
|
|
334
|
+
Use `Page` + the component system when you need more control:
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
from htmlforge import Page, H1, P, Div, Table, Button
|
|
338
|
+
|
|
339
|
+
page = Page("Title", lang="en")
|
|
340
|
+
page.add_css("body { font-family: sans-serif; }")
|
|
341
|
+
page.add(
|
|
342
|
+
H1("Welcome"),
|
|
343
|
+
P("Hello World"),
|
|
344
|
+
Div(class_="card").add(
|
|
345
|
+
H1("Features", level=2),
|
|
346
|
+
P("Content..."),
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Element — Chainable API
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
div = Div(id="main", class_="wrapper")
|
|
355
|
+
div.add(H1("Title"), P("Content")) # Add child elements
|
|
356
|
+
div.css(color="red", font_size="16px") # Set inline styles (camelCase → kebab-case)
|
|
357
|
+
div.attr("data-id", "123") # Set attributes
|
|
358
|
+
div.on("click", "alert('clicked')") # Bind JS event
|
|
359
|
+
div.on_py("click", "print('clicked')") # Bind Python event (auto-converted)
|
|
360
|
+
html = div.render() # → HTML string
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Components
|
|
364
|
+
|
|
365
|
+
| Category | Components |
|
|
366
|
+
|---|---|
|
|
367
|
+
| **Structure** | `Div`, `Section`, `Header`, `Footer`, `Nav`, `Main`, `Article`, `Aside`, `Span`, `Container` |
|
|
368
|
+
| **Text** | `H1`-`H6`, `P`, `Strong`, `Em`, `Blockquote`, `Code`, `Pre`, `Small`, `Mark` |
|
|
369
|
+
| **Links/Media** | `Link`, `Img`, `Video`, `Audio`, `Iframe`, `Canvas` |
|
|
370
|
+
| **Lists** | `Ul`, `Ol`, `Li`, `List` |
|
|
371
|
+
| **Tables** | `Table`, `Thead`, `Tbody`, `Tr`, `Th`, `Td`, `Caption` |
|
|
372
|
+
| **Forms** | `Form`, `Input`, `Textarea`, `Select`, `Option`, `Checkbox`, `Radio`, `Label`, `Button` |
|
|
373
|
+
| **Other** | `Hr`, `Br`, `Text`, `Progress`, `Details`, `Summary` |
|
|
374
|
+
|
|
375
|
+
## Example: Multi-page Site
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
# site.py
|
|
379
|
+
from htmlforge import Doc
|
|
380
|
+
|
|
381
|
+
home = Doc("Home")
|
|
382
|
+
home.heading("Home")
|
|
383
|
+
home.text("Welcome to the home page")
|
|
384
|
+
|
|
385
|
+
about = Doc("About")
|
|
386
|
+
about.heading("About Us")
|
|
387
|
+
about.text("This is the about page")
|
|
388
|
+
|
|
389
|
+
blog = Doc("Blog")
|
|
390
|
+
blog.heading("Blog")
|
|
391
|
+
blog.text("Latest posts...")
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
htmlforge render site.py
|
|
396
|
+
# → dist/home.html, dist/about.html, dist/blog.html
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## Changelog
|
|
400
|
+
|
|
401
|
+
### 0.0.3
|
|
402
|
+
|
|
403
|
+
**Security**
|
|
404
|
+
|
|
405
|
+
- `_esc` now also escapes single quotes; Markdown links reject
|
|
406
|
+
`javascript:` / `vbscript:` / `data:text/html` URLs.
|
|
407
|
+
- All DOM helpers (`set_text`, `set_attr`, `set_value`, `add_class`, ...)
|
|
408
|
+
escape their selectors and values, preventing JS injection and syntax
|
|
409
|
+
breakage from dynamic data.
|
|
410
|
+
|
|
411
|
+
**New**
|
|
412
|
+
|
|
413
|
+
- **SEO / meta:** `page.meta()`, `page.set_meta()`, `page.favicon()`.
|
|
414
|
+
- **Block-level Markdown:** `page.markdown()` / `page.markdown_file()` —
|
|
415
|
+
headings, lists, fenced code, tables, blockquotes and rules.
|
|
416
|
+
- **Static assets:** `page.asset()` copies local files into `dist/assets/`
|
|
417
|
+
at build time, so the exported site is self-contained.
|
|
418
|
+
- **Layouts:** reusable `Layout` shares head / header / footer across pages.
|
|
419
|
+
- **Live reload:** `htmlforge render <file> -s -w` auto-refreshes the browser.
|
|
420
|
+
|
|
421
|
+
**Fixed**
|
|
422
|
+
|
|
423
|
+
- `Video` builds its `<source>` child correctly (no more fragile `__new__`).
|
|
424
|
+
|
|
425
|
+
## License
|
|
426
|
+
|
|
427
|
+
[MPL-2.0](LICENSE)
|