htmlforge 0.0.2__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.2 → htmlforge-0.0.3}/PKG-INFO +106 -2
- {htmlforge-0.0.2 → htmlforge-0.0.3}/README.md +105 -1
- {htmlforge-0.0.2 → htmlforge-0.0.3}/pyproject.toml +1 -1
- {htmlforge-0.0.2 → htmlforge-0.0.3}/src/htmlforge/__init__.py +2 -2
- {htmlforge-0.0.2 → htmlforge-0.0.3}/src/htmlforge/cli.py +125 -8
- {htmlforge-0.0.2 → htmlforge-0.0.3}/src/htmlforge/core.py +84 -9
- {htmlforge-0.0.2 → htmlforge-0.0.3}/src/htmlforge/simple.py +300 -17
- {htmlforge-0.0.2 → htmlforge-0.0.3}/tests/test_cli.py +51 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/tests/test_core.py +93 -1
- {htmlforge-0.0.2 → htmlforge-0.0.3}/tests/test_simple.py +172 -1
- {htmlforge-0.0.2 → htmlforge-0.0.3}/.gitignore +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/LICENSE +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/demo.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/demo_simple.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/src/htmlforge/__main__.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/src/htmlforge/transpiler.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/style_demo.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/tests/__init__.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/tests/buttons.py +0 -0
- {htmlforge-0.0.2 → htmlforge-0.0.3}/tests/test_transpiler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: htmlforge
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.3
|
|
4
4
|
Summary: Build web pages with Python, export to HTML with a single command
|
|
5
5
|
Project-URL: Homepage, https://github.com/htmlforge/htmlforge
|
|
6
6
|
Author: htmlforge contributors
|
|
@@ -96,6 +96,7 @@ htmlforge render mysite.py MyPage
|
|
|
96
96
|
| `htmlforge render <file.py> -s` | Start HTTP server after render |
|
|
97
97
|
| `htmlforge render <file.py> -s -p 3000` | Specify server port |
|
|
98
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 |
|
|
99
100
|
| `htmlforge script <file.py>` | Convert Python file to JavaScript |
|
|
100
101
|
| `htmlforge script <file.py> -o app.js` | Specify output JS filename |
|
|
101
102
|
| `htmlforge script <file.py> --helpers` | Include DOM helper functions |
|
|
@@ -129,10 +130,84 @@ page.details("Collapsible", "Hidden content...")
|
|
|
129
130
|
page.nav(("Home", "/"), ("About", "/about"))
|
|
130
131
|
page.divider()
|
|
131
132
|
|
|
132
|
-
#
|
|
133
|
+
# Row / grid
|
|
133
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")
|
|
134
163
|
```
|
|
135
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
|
+
|
|
136
211
|
## JavaScript & DOM Interaction
|
|
137
212
|
|
|
138
213
|
### Bind events to elements
|
|
@@ -199,6 +274,9 @@ page.remove_class("#el", "hidden")
|
|
|
199
274
|
page.toggle_class("#menu", "open")
|
|
200
275
|
```
|
|
201
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
|
+
|
|
202
280
|
## Python → JavaScript Transpiler
|
|
203
281
|
|
|
204
282
|
Use `htmlforge script` to convert Python files to JavaScript:
|
|
@@ -318,6 +396,32 @@ htmlforge render site.py
|
|
|
318
396
|
# → dist/home.html, dist/about.html, dist/blog.html
|
|
319
397
|
```
|
|
320
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
|
+
|
|
321
425
|
## License
|
|
322
426
|
|
|
323
427
|
[MPL-2.0](LICENSE)
|
|
@@ -78,6 +78,7 @@ htmlforge render mysite.py MyPage
|
|
|
78
78
|
| `htmlforge render <file.py> -s` | Start HTTP server after render |
|
|
79
79
|
| `htmlforge render <file.py> -s -p 3000` | Specify server port |
|
|
80
80
|
| `htmlforge render <file.py> -w` | Watch for changes and auto-rebuild |
|
|
81
|
+
| `htmlforge render <file.py> -s -w` | Serve + watch, browser auto-reloads on change |
|
|
81
82
|
| `htmlforge script <file.py>` | Convert Python file to JavaScript |
|
|
82
83
|
| `htmlforge script <file.py> -o app.js` | Specify output JS filename |
|
|
83
84
|
| `htmlforge script <file.py> --helpers` | Include DOM helper functions |
|
|
@@ -111,10 +112,84 @@ page.details("Collapsible", "Hidden content...")
|
|
|
111
112
|
page.nav(("Home", "/"), ("About", "/about"))
|
|
112
113
|
page.divider()
|
|
113
114
|
|
|
114
|
-
#
|
|
115
|
+
# Row / grid
|
|
115
116
|
page.row(Doc.make_card("A"), Doc.make_card("B"), Doc.make_card("C"))
|
|
117
|
+
|
|
118
|
+
# Head / SEO
|
|
119
|
+
page.set_meta(description="My site", author="Me", keywords="python, web")
|
|
120
|
+
page.meta("My Site", property="og:title")
|
|
121
|
+
page.favicon("favicon.ico")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Block-level Markdown
|
|
125
|
+
|
|
126
|
+
`page.markdown()` renders full Markdown — not just inline — including headings,
|
|
127
|
+
lists, fenced code, tables, blockquotes and rules:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
page.markdown('''
|
|
131
|
+
# Release Notes
|
|
132
|
+
|
|
133
|
+
- **Fast** rendering
|
|
134
|
+
- Zero dependencies
|
|
135
|
+
|
|
136
|
+
| Command | Purpose |
|
|
137
|
+
|---------|---------|
|
|
138
|
+
| render | Build HTML |
|
|
139
|
+
|
|
140
|
+
> Tip: pull in a whole README with `markdown_file`.
|
|
141
|
+
''')
|
|
142
|
+
|
|
143
|
+
# Or load straight from a file
|
|
144
|
+
page.markdown_file("README.md")
|
|
116
145
|
```
|
|
117
146
|
|
|
147
|
+
## Static Assets
|
|
148
|
+
|
|
149
|
+
`page.asset()` registers a local file (image / css / js). It returns the URL to
|
|
150
|
+
reference, and `htmlforge render` copies the file into `dist/assets/` so the
|
|
151
|
+
exported site is fully self-contained:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
logo = page.asset("logo.png") # → "assets/logo.png"
|
|
155
|
+
page.image(logo, "Logo")
|
|
156
|
+
|
|
157
|
+
page.add_stylesheet(page.asset("theme.css"))
|
|
158
|
+
page.js_file(page.asset("app.js"))
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
After `htmlforge render site.py`, `dist/` holds both `index.html` and
|
|
162
|
+
`assets/logo.png`.
|
|
163
|
+
|
|
164
|
+
## Layouts — Share Header / Footer
|
|
165
|
+
|
|
166
|
+
A `Layout` is a reusable page skeleton: every page built from it shares the
|
|
167
|
+
same head tags, header and footer.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from htmlforge import Doc, Layout, Div, H1, P
|
|
171
|
+
|
|
172
|
+
site = Layout(
|
|
173
|
+
head=lambda doc: doc.set_meta(author="Me"),
|
|
174
|
+
header=lambda doc: Div(class_="top").add(H1("My Site")),
|
|
175
|
+
footer=lambda doc: P(f"© 2026 — {doc.title}"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
home = site.page("Home") # → a Doc with the shared chrome
|
|
179
|
+
home.heading("Welcome")
|
|
180
|
+
|
|
181
|
+
about = site.page("About")
|
|
182
|
+
about.text("About us...")
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
htmlforge render site.py
|
|
187
|
+
# → dist/home.html, dist/about.html (both wrapped in the shared header/footer)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Each slot accepts an `Element`, a list of elements, or a callable receiving the
|
|
191
|
+
page — so you can highlight the current nav item, inject the title, and more.
|
|
192
|
+
|
|
118
193
|
## JavaScript & DOM Interaction
|
|
119
194
|
|
|
120
195
|
### Bind events to elements
|
|
@@ -181,6 +256,9 @@ page.remove_class("#el", "hidden")
|
|
|
181
256
|
page.toggle_class("#menu", "open")
|
|
182
257
|
```
|
|
183
258
|
|
|
259
|
+
> **Security:** every selector and value passed to these helpers is
|
|
260
|
+
> automatically escaped, so it is safe to feed them dynamic / user data.
|
|
261
|
+
|
|
184
262
|
## Python → JavaScript Transpiler
|
|
185
263
|
|
|
186
264
|
Use `htmlforge script` to convert Python files to JavaScript:
|
|
@@ -300,6 +378,32 @@ htmlforge render site.py
|
|
|
300
378
|
# → dist/home.html, dist/about.html, dist/blog.html
|
|
301
379
|
```
|
|
302
380
|
|
|
381
|
+
## Changelog
|
|
382
|
+
|
|
383
|
+
### 0.0.3
|
|
384
|
+
|
|
385
|
+
**Security**
|
|
386
|
+
|
|
387
|
+
- `_esc` now also escapes single quotes; Markdown links reject
|
|
388
|
+
`javascript:` / `vbscript:` / `data:text/html` URLs.
|
|
389
|
+
- All DOM helpers (`set_text`, `set_attr`, `set_value`, `add_class`, ...)
|
|
390
|
+
escape their selectors and values, preventing JS injection and syntax
|
|
391
|
+
breakage from dynamic data.
|
|
392
|
+
|
|
393
|
+
**New**
|
|
394
|
+
|
|
395
|
+
- **SEO / meta:** `page.meta()`, `page.set_meta()`, `page.favicon()`.
|
|
396
|
+
- **Block-level Markdown:** `page.markdown()` / `page.markdown_file()` —
|
|
397
|
+
headings, lists, fenced code, tables, blockquotes and rules.
|
|
398
|
+
- **Static assets:** `page.asset()` copies local files into `dist/assets/`
|
|
399
|
+
at build time, so the exported site is self-contained.
|
|
400
|
+
- **Layouts:** reusable `Layout` shares head / header / footer across pages.
|
|
401
|
+
- **Live reload:** `htmlforge render <file> -s -w` auto-refreshes the browser.
|
|
402
|
+
|
|
403
|
+
**Fixed**
|
|
404
|
+
|
|
405
|
+
- `Video` builds its `<source>` child correctly (no more fragile `__new__`).
|
|
406
|
+
|
|
303
407
|
## License
|
|
304
408
|
|
|
305
409
|
[MPL-2.0](LICENSE)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""htmlforge — Build web pages with Python, export to HTML."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.0.
|
|
3
|
+
__version__ = "0.0.3"
|
|
4
4
|
|
|
5
|
-
from htmlforge.simple import Doc
|
|
5
|
+
from htmlforge.simple import Doc, Layout
|
|
6
6
|
from htmlforge.transpiler import py_to_js, convert_file, JS_HELPERS
|
|
7
7
|
|
|
8
8
|
from htmlforge.core import (
|
|
@@ -19,8 +19,10 @@ import http.server
|
|
|
19
19
|
import importlib.util
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
|
+
import shutil
|
|
22
23
|
import socketserver
|
|
23
24
|
import sys
|
|
25
|
+
import threading
|
|
24
26
|
import time
|
|
25
27
|
from pathlib import Path
|
|
26
28
|
|
|
@@ -90,20 +92,26 @@ def _do_render(args):
|
|
|
90
92
|
_err(f"File not found: {filepath}")
|
|
91
93
|
sys.exit(1)
|
|
92
94
|
|
|
95
|
+
out_dir = filepath.parent / args.output
|
|
96
|
+
# Live reload only makes sense when a server is running to poll.
|
|
97
|
+
livereload = bool(args.serve and args.watch)
|
|
98
|
+
|
|
93
99
|
# Initial build
|
|
94
|
-
ok = _build(filepath, args.page, args.output, args.verbose
|
|
100
|
+
ok = _build(filepath, args.page, args.output, args.verbose,
|
|
101
|
+
livereload=livereload)
|
|
95
102
|
if not ok:
|
|
96
103
|
sys.exit(1)
|
|
97
104
|
|
|
98
105
|
if args.serve:
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
if args.watch:
|
|
107
|
+
_start_watch_thread(filepath, args.page, args.output, args.verbose)
|
|
108
|
+
_serve_dir(str(out_dir), args.port, livereload=livereload)
|
|
101
109
|
elif args.watch:
|
|
102
110
|
_watch_loop(filepath, args.page, args.output, args.verbose)
|
|
103
111
|
|
|
104
112
|
|
|
105
113
|
def _build(filepath: Path, page_name: str | None,
|
|
106
|
-
output: str, verbose: bool) -> bool:
|
|
114
|
+
output: str, verbose: bool, livereload: bool = False) -> bool:
|
|
107
115
|
try:
|
|
108
116
|
mod = _load_module(filepath)
|
|
109
117
|
except Exception as exc:
|
|
@@ -131,23 +139,55 @@ def _build(filepath: Path, page_name: str | None,
|
|
|
131
139
|
|
|
132
140
|
for name, page in pages.items():
|
|
133
141
|
html = page.render()
|
|
142
|
+
if livereload:
|
|
143
|
+
html = _inject_livereload(html)
|
|
134
144
|
fname = "index.html" if len(pages) == 1 else f"{_slug(name)}.html"
|
|
135
145
|
(out_dir / fname).write_text(html, encoding="utf-8")
|
|
136
146
|
_ok(f" {fname}")
|
|
137
147
|
|
|
148
|
+
_copy_assets(filepath.parent, out_dir, pages)
|
|
149
|
+
|
|
150
|
+
if livereload:
|
|
151
|
+
_bump_livereload()
|
|
138
152
|
_info(f"Done. {len(pages)} page(s) → {out_dir}/")
|
|
139
153
|
return True
|
|
140
154
|
|
|
141
155
|
|
|
156
|
+
def _copy_assets(base_dir: Path, out_dir: Path, pages: dict):
|
|
157
|
+
"""Copy every file registered via ``page.asset()`` into ``out_dir``."""
|
|
158
|
+
copied: set = set()
|
|
159
|
+
for page in pages.values():
|
|
160
|
+
collect = getattr(page, "_collect_assets", None)
|
|
161
|
+
if not collect:
|
|
162
|
+
continue
|
|
163
|
+
for src, rel in collect():
|
|
164
|
+
if (src, rel) in copied:
|
|
165
|
+
continue
|
|
166
|
+
copied.add((src, rel))
|
|
167
|
+
src_path = Path(src)
|
|
168
|
+
if not src_path.is_absolute():
|
|
169
|
+
src_path = (base_dir / src).resolve()
|
|
170
|
+
if not src_path.exists():
|
|
171
|
+
_err(f" Asset not found, skipped: {src}")
|
|
172
|
+
continue
|
|
173
|
+
dst_path = out_dir / rel
|
|
174
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
shutil.copy2(src_path, dst_path)
|
|
176
|
+
_ok(f" {rel}")
|
|
177
|
+
|
|
178
|
+
|
|
142
179
|
# ============================================================
|
|
143
180
|
# Serve
|
|
144
181
|
# ============================================================
|
|
145
182
|
|
|
146
|
-
def _serve_dir(directory: str, port: int):
|
|
183
|
+
def _serve_dir(directory: str, port: int, livereload: bool = False):
|
|
147
184
|
os.chdir(directory)
|
|
148
|
-
Handler =
|
|
185
|
+
Handler = _make_handler(livereload)
|
|
186
|
+
socketserver.TCPServer.allow_reuse_address = True
|
|
149
187
|
with socketserver.TCPServer(("", port), Handler) as httpd:
|
|
150
188
|
_info(f" Serving at http://localhost:{port}")
|
|
189
|
+
if livereload:
|
|
190
|
+
_info(" Live reload on — save the file to refresh the browser.")
|
|
151
191
|
_info(" Press Ctrl+C to stop.")
|
|
152
192
|
try:
|
|
153
193
|
httpd.serve_forever()
|
|
@@ -155,12 +195,38 @@ def _serve_dir(directory: str, port: int):
|
|
|
155
195
|
_info("\n Server stopped.")
|
|
156
196
|
|
|
157
197
|
|
|
198
|
+
def _make_handler(livereload: bool):
|
|
199
|
+
"""HTTP handler; optionally answers ``/__livereload`` with the version."""
|
|
200
|
+
|
|
201
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
202
|
+
def do_GET(self):
|
|
203
|
+
path = self.path.split("?", 1)[0]
|
|
204
|
+
if livereload and path == "/__livereload":
|
|
205
|
+
body = str(_LIVERELOAD_STATE["version"]).encode("utf-8")
|
|
206
|
+
self.send_response(200)
|
|
207
|
+
self.send_header("Content-Type", "text/plain")
|
|
208
|
+
self.send_header("Cache-Control", "no-store")
|
|
209
|
+
self.send_header("Content-Length", str(len(body)))
|
|
210
|
+
self.end_headers()
|
|
211
|
+
self.wfile.write(body)
|
|
212
|
+
return
|
|
213
|
+
super().do_GET()
|
|
214
|
+
|
|
215
|
+
def log_message(self, fmt, *fmt_args):
|
|
216
|
+
# Keep the 1 Hz polling requests out of the console.
|
|
217
|
+
if livereload and fmt_args and "/__livereload" in str(fmt_args[0]):
|
|
218
|
+
return
|
|
219
|
+
super().log_message(fmt, *fmt_args)
|
|
220
|
+
|
|
221
|
+
return Handler
|
|
222
|
+
|
|
223
|
+
|
|
158
224
|
# ============================================================
|
|
159
225
|
# Watch
|
|
160
226
|
# ============================================================
|
|
161
227
|
|
|
162
228
|
def _watch_loop(filepath: Path, page_name: str | None,
|
|
163
|
-
output: str, verbose: bool):
|
|
229
|
+
output: str, verbose: bool, livereload: bool = False):
|
|
164
230
|
_info(" Watching for changes… (Ctrl+C to stop)")
|
|
165
231
|
last = filepath.stat().st_mtime
|
|
166
232
|
try:
|
|
@@ -173,11 +239,62 @@ def _watch_loop(filepath: Path, page_name: str | None,
|
|
|
173
239
|
if cur != last:
|
|
174
240
|
last = cur
|
|
175
241
|
_info(f" Change detected — {time.strftime('%H:%M:%S')}")
|
|
176
|
-
_build(filepath, page_name, output, verbose
|
|
242
|
+
_build(filepath, page_name, output, verbose,
|
|
243
|
+
livereload=livereload)
|
|
177
244
|
except KeyboardInterrupt:
|
|
178
245
|
_info("\n Watch stopped.")
|
|
179
246
|
|
|
180
247
|
|
|
248
|
+
def _start_watch_thread(filepath: Path, page_name: str | None,
|
|
249
|
+
output: str, verbose: bool) -> threading.Thread:
|
|
250
|
+
"""Run :func:`_watch_loop` in a daemon thread (used with ``-s -w``)."""
|
|
251
|
+
t = threading.Thread(
|
|
252
|
+
target=_watch_loop,
|
|
253
|
+
args=(filepath, page_name, output, verbose),
|
|
254
|
+
kwargs={"livereload": True},
|
|
255
|
+
daemon=True,
|
|
256
|
+
)
|
|
257
|
+
t.start()
|
|
258
|
+
return t
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ============================================================
|
|
262
|
+
# Live reload
|
|
263
|
+
# ============================================================
|
|
264
|
+
|
|
265
|
+
_LIVERELOAD_STATE = {"version": 0}
|
|
266
|
+
|
|
267
|
+
LIVERELOAD_SNIPPET = """<script>
|
|
268
|
+
(function () {
|
|
269
|
+
var current = null;
|
|
270
|
+
function poll() {
|
|
271
|
+
fetch('/__livereload')
|
|
272
|
+
.then(function (r) { return r.text(); })
|
|
273
|
+
.then(function (v) {
|
|
274
|
+
if (current === null) { current = v; }
|
|
275
|
+
else if (v !== current) { location.reload(); }
|
|
276
|
+
})
|
|
277
|
+
.catch(function () {})
|
|
278
|
+
.then(function () { setTimeout(poll, 1000); });
|
|
279
|
+
}
|
|
280
|
+
poll();
|
|
281
|
+
})();
|
|
282
|
+
</script>"""
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _bump_livereload():
|
|
286
|
+
"""Advance the version so connected browsers reload."""
|
|
287
|
+
_LIVERELOAD_STATE["version"] += 1
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _inject_livereload(html: str) -> str:
|
|
291
|
+
"""Insert the polling snippet just before ``</body>``."""
|
|
292
|
+
idx = html.rfind("</body>")
|
|
293
|
+
if idx == -1:
|
|
294
|
+
return html + LIVERELOAD_SNIPPET
|
|
295
|
+
return html[:idx] + LIVERELOAD_SNIPPET + "\n" + html[idx:]
|
|
296
|
+
|
|
297
|
+
|
|
181
298
|
# ============================================================
|
|
182
299
|
# Script (Python → JS)
|
|
183
300
|
# ============================================================
|
|
@@ -278,7 +278,7 @@ class Video(Element):
|
|
|
278
278
|
def __init__(self, src: str = "", controls: bool = True, **attrs: str):
|
|
279
279
|
super().__init__(**attrs)
|
|
280
280
|
if src:
|
|
281
|
-
self.children.append(
|
|
281
|
+
self.children.append(Source(src))
|
|
282
282
|
if controls:
|
|
283
283
|
self.attrs["controls"] = ""
|
|
284
284
|
|
|
@@ -305,12 +305,6 @@ class Source(Element):
|
|
|
305
305
|
if type_:
|
|
306
306
|
self.attrs["type"] = type_
|
|
307
307
|
|
|
308
|
-
def __init_tag(self, src: str) -> Source:
|
|
309
|
-
"""Internal fast-init used by Video."""
|
|
310
|
-
self.children = []
|
|
311
|
-
self.attrs = {"src": src}
|
|
312
|
-
return self
|
|
313
|
-
|
|
314
308
|
|
|
315
309
|
class Iframe(Element):
|
|
316
310
|
tag = "iframe"
|
|
@@ -571,6 +565,10 @@ class Page:
|
|
|
571
565
|
self._script_srcs: list[str] = []
|
|
572
566
|
self._ext_css: list[str] = []
|
|
573
567
|
self._meta_tags: list[str] = []
|
|
568
|
+
self._head_links: list[str] = []
|
|
569
|
+
self._assets: list[tuple[str, str]] = []
|
|
570
|
+
self._body_prefix: list[Union[Element, str]] = []
|
|
571
|
+
self._body_suffix: list[Union[Element, str]] = []
|
|
574
572
|
|
|
575
573
|
# --- CSS ---
|
|
576
574
|
|
|
@@ -584,6 +582,75 @@ class Page:
|
|
|
584
582
|
self._ext_css.append(href)
|
|
585
583
|
return self
|
|
586
584
|
|
|
585
|
+
# --- meta / SEO ---
|
|
586
|
+
|
|
587
|
+
def meta(self, content: str, *, name: str = "", property: str = "",
|
|
588
|
+
**attrs: str) -> Page:
|
|
589
|
+
"""Add a ``<meta>`` tag.
|
|
590
|
+
|
|
591
|
+
::
|
|
592
|
+
|
|
593
|
+
page.meta("A great site", name="description")
|
|
594
|
+
page.meta("My Site", property="og:title")
|
|
595
|
+
"""
|
|
596
|
+
parts: list[str] = []
|
|
597
|
+
if name:
|
|
598
|
+
parts.append(f'name="{_esc(name)}"')
|
|
599
|
+
if property:
|
|
600
|
+
parts.append(f'property="{_esc(property)}"')
|
|
601
|
+
for k, v in attrs.items():
|
|
602
|
+
parts.append(f'{k.rstrip("_")}="{_esc(v)}"')
|
|
603
|
+
parts.append(f'content="{_esc(content)}"')
|
|
604
|
+
self._meta_tags.append("<meta " + " ".join(parts) + ">")
|
|
605
|
+
return self
|
|
606
|
+
|
|
607
|
+
def set_meta(self, *, description: str = "", author: str = "",
|
|
608
|
+
keywords: str = "") -> Page:
|
|
609
|
+
"""Convenience wrapper for the three most common ``<meta name>`` tags.
|
|
610
|
+
|
|
611
|
+
::
|
|
612
|
+
|
|
613
|
+
page.set_meta(description="A great site", author="Me",
|
|
614
|
+
keywords="python, web")
|
|
615
|
+
"""
|
|
616
|
+
if description:
|
|
617
|
+
self.meta(description, name="description")
|
|
618
|
+
if author:
|
|
619
|
+
self.meta(author, name="author")
|
|
620
|
+
if keywords:
|
|
621
|
+
self.meta(keywords, name="keywords")
|
|
622
|
+
return self
|
|
623
|
+
|
|
624
|
+
def favicon(self, href: str, type_: str = "") -> Page:
|
|
625
|
+
"""Add a favicon ``<link rel="icon">``."""
|
|
626
|
+
tag = f'<link rel="icon" href="{_esc(href)}"'
|
|
627
|
+
if type_:
|
|
628
|
+
tag += f' type="{_esc(type_)}"'
|
|
629
|
+
self._head_links.append(tag + ">")
|
|
630
|
+
return self
|
|
631
|
+
|
|
632
|
+
# --- assets ---
|
|
633
|
+
|
|
634
|
+
def asset(self, src: str, name: str = "") -> str:
|
|
635
|
+
"""Register a local file to be copied next to the built HTML.
|
|
636
|
+
|
|
637
|
+
Returns the relative URL to use in ``src`` / ``href``; the actual
|
|
638
|
+
copy happens at build time (``htmlforge render``).
|
|
639
|
+
|
|
640
|
+
::
|
|
641
|
+
|
|
642
|
+
logo = page.asset("logo.png") # → "assets/logo.png"
|
|
643
|
+
page.image(logo, "Logo")
|
|
644
|
+
"""
|
|
645
|
+
fname = name or _basename(src)
|
|
646
|
+
rel = f"assets/{fname}"
|
|
647
|
+
self._assets.append((src, rel))
|
|
648
|
+
return rel
|
|
649
|
+
|
|
650
|
+
def _collect_assets(self) -> list[tuple[str, str]]:
|
|
651
|
+
"""``(source, relative-dest)`` pairs registered via :meth:`asset`."""
|
|
652
|
+
return list(self._assets)
|
|
653
|
+
|
|
587
654
|
# --- JS ---
|
|
588
655
|
|
|
589
656
|
def add_script(self, code: str = "", src: str = "") -> Page:
|
|
@@ -658,6 +725,8 @@ class Page:
|
|
|
658
725
|
|
|
659
726
|
for href in self._ext_css:
|
|
660
727
|
head.append(f'<link rel="stylesheet" href="{_esc(href)}">')
|
|
728
|
+
for lnk in self._head_links:
|
|
729
|
+
head.append(lnk)
|
|
661
730
|
for m in self._meta_tags:
|
|
662
731
|
head.append(m)
|
|
663
732
|
for el in self.head_elements:
|
|
@@ -668,7 +737,7 @@ class Page:
|
|
|
668
737
|
head.append(f"<style>\n{css}\n</style>")
|
|
669
738
|
|
|
670
739
|
body_parts: list[str] = []
|
|
671
|
-
for c in self.body_children:
|
|
740
|
+
for c in self._body_prefix + self.body_children + self._body_suffix:
|
|
672
741
|
if isinstance(c, Element):
|
|
673
742
|
body_parts.append(c.render())
|
|
674
743
|
else:
|
|
@@ -703,12 +772,18 @@ def _to_kebab(name: str) -> str:
|
|
|
703
772
|
return re.sub(r"(?<=[a-z])(?=[A-Z])", "-", name).lower().replace("_", "-")
|
|
704
773
|
|
|
705
774
|
|
|
775
|
+
def _basename(path: str) -> str:
|
|
776
|
+
"""``"a/b/logo.png"`` (or with backslashes) → ``"logo.png"``."""
|
|
777
|
+
return path.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
778
|
+
|
|
779
|
+
|
|
706
780
|
def _esc(s: str) -> str:
|
|
707
|
-
"""Escape HTML-special characters in attribute values."""
|
|
781
|
+
"""Escape HTML-special characters in text and attribute values."""
|
|
708
782
|
return (
|
|
709
783
|
str(s)
|
|
710
784
|
.replace("&", "&")
|
|
711
785
|
.replace('"', """)
|
|
786
|
+
.replace("'", "'")
|
|
712
787
|
.replace("<", "<")
|
|
713
788
|
.replace(">", ">")
|
|
714
789
|
)
|