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/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""htmlforge — Build web pages with Python, export to HTML."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.0.1"
|
|
4
|
+
|
|
5
|
+
from htmlforge.simple import Doc
|
|
6
|
+
|
|
7
|
+
from htmlforge.core import (
|
|
8
|
+
# Base
|
|
9
|
+
Element, Page,
|
|
10
|
+
# Structural
|
|
11
|
+
Div, Section, Header, Footer, Nav, Main, Article, Aside, Span, Container,
|
|
12
|
+
# Text
|
|
13
|
+
H1, H2, H3, H4, H5, H6, P, Strong, Em, Blockquote,
|
|
14
|
+
Code, Pre, Small, Mark, Sub, Sup,
|
|
15
|
+
Text, Link,
|
|
16
|
+
# Lists
|
|
17
|
+
Ul, Ol, Li, List,
|
|
18
|
+
# Media
|
|
19
|
+
Img, Video, Audio, Source, Iframe, Canvas,
|
|
20
|
+
# Table
|
|
21
|
+
Table, Thead, Tbody, Tr, Th, Td, Caption,
|
|
22
|
+
# Forms
|
|
23
|
+
Form, Input, Textarea, Select, Option,
|
|
24
|
+
Checkbox, Radio, Label, Button,
|
|
25
|
+
# Misc
|
|
26
|
+
Hr, Br, Meta, Progress, Details, Summary,
|
|
27
|
+
)
|
htmlforge/__main__.py
ADDED
htmlforge/cli.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""htmlforge CLI — build HTML from Python page scripts.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
htmlforge render <file.py> [page] -o <dir> Render pages to HTML
|
|
5
|
+
htmlforge render <file.py> -p <port> Render and serve locally
|
|
6
|
+
htmlforge render <file.py> -w Watch and auto-rebuild
|
|
7
|
+
|
|
8
|
+
Workflow is modelled after *manim*:
|
|
9
|
+
manim scene.py MyScene -qh → mp4
|
|
10
|
+
htmlforge page.py MyPage -o dist → html
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import http.server
|
|
17
|
+
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import socketserver
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from htmlforge.core import Page
|
|
26
|
+
from htmlforge.simple import Doc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ============================================================
|
|
30
|
+
# Entry point
|
|
31
|
+
# ============================================================
|
|
32
|
+
|
|
33
|
+
def render():
|
|
34
|
+
"""``[project.scripts]`` entry — installed as the ``htmlforge`` command."""
|
|
35
|
+
ap = argparse.ArgumentParser(
|
|
36
|
+
prog="htmlforge",
|
|
37
|
+
description="htmlforge — Build web pages with Python",
|
|
38
|
+
)
|
|
39
|
+
ap.add_argument(
|
|
40
|
+
"command", nargs="?", default="render",
|
|
41
|
+
help="Command: render (default)",
|
|
42
|
+
)
|
|
43
|
+
ap.add_argument("file", help="Python file containing Page objects")
|
|
44
|
+
ap.add_argument(
|
|
45
|
+
"page", nargs="?", default=None,
|
|
46
|
+
help="Specific page variable or class name to render",
|
|
47
|
+
)
|
|
48
|
+
ap.add_argument("-o", "--output", default="dist", help="Output directory (default: dist)")
|
|
49
|
+
ap.add_argument("-p", "--port", type=int, default=8080, help="Server port (default: 8080)")
|
|
50
|
+
ap.add_argument("-w", "--watch", action="store_true", help="Watch file for changes")
|
|
51
|
+
ap.add_argument("-s", "--serve", action="store_true", help="Start HTTP server after build")
|
|
52
|
+
ap.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
|
53
|
+
|
|
54
|
+
args = ap.parse_args()
|
|
55
|
+
|
|
56
|
+
if args.command == "render":
|
|
57
|
+
_do_render(args)
|
|
58
|
+
else:
|
|
59
|
+
ap.print_help()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ============================================================
|
|
63
|
+
# Render
|
|
64
|
+
# ============================================================
|
|
65
|
+
|
|
66
|
+
def _do_render(args):
|
|
67
|
+
filepath = Path(args.file).resolve()
|
|
68
|
+
if not filepath.exists():
|
|
69
|
+
_err(f"File not found: {filepath}")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
# Initial build
|
|
73
|
+
ok = _build(filepath, args.page, args.output, args.verbose)
|
|
74
|
+
if not ok:
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
|
|
77
|
+
if args.serve:
|
|
78
|
+
out_dir = filepath.parent / args.output
|
|
79
|
+
_serve_dir(str(out_dir), args.port)
|
|
80
|
+
elif args.watch:
|
|
81
|
+
_watch_loop(filepath, args.page, args.output, args.verbose)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build(filepath: Path, page_name: str | None,
|
|
85
|
+
output: str, verbose: bool) -> bool:
|
|
86
|
+
try:
|
|
87
|
+
mod = _load_module(filepath)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
_err(f"Error loading {filepath.name}: {exc}")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
pages = _discover_pages(mod)
|
|
93
|
+
|
|
94
|
+
if page_name:
|
|
95
|
+
key = page_name.lower()
|
|
96
|
+
pages = {k: v for k, v in pages.items() if k == key}
|
|
97
|
+
if not pages:
|
|
98
|
+
_err(
|
|
99
|
+
f"Page '{page_name}' not found. "
|
|
100
|
+
f"Available: {', '.join(_discover_pages(mod))}"
|
|
101
|
+
)
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
if not pages:
|
|
105
|
+
_err("No Page objects found in the file.")
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
out_dir = filepath.parent / output
|
|
109
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
for name, page in pages.items():
|
|
112
|
+
html = page.render()
|
|
113
|
+
fname = "index.html" if len(pages) == 1 else f"{_slug(name)}.html"
|
|
114
|
+
(out_dir / fname).write_text(html, encoding="utf-8")
|
|
115
|
+
_ok(f" {fname}")
|
|
116
|
+
|
|
117
|
+
_info(f"Done. {len(pages)} page(s) → {out_dir}/")
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ============================================================
|
|
122
|
+
# Serve
|
|
123
|
+
# ============================================================
|
|
124
|
+
|
|
125
|
+
def _serve_dir(directory: str, port: int):
|
|
126
|
+
os.chdir(directory)
|
|
127
|
+
Handler = http.server.SimpleHTTPRequestHandler
|
|
128
|
+
with socketserver.TCPServer(("", port), Handler) as httpd:
|
|
129
|
+
_info(f" Serving at http://localhost:{port}")
|
|
130
|
+
_info(" Press Ctrl+C to stop.")
|
|
131
|
+
try:
|
|
132
|
+
httpd.serve_forever()
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
_info("\n Server stopped.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ============================================================
|
|
138
|
+
# Watch
|
|
139
|
+
# ============================================================
|
|
140
|
+
|
|
141
|
+
def _watch_loop(filepath: Path, page_name: str | None,
|
|
142
|
+
output: str, verbose: bool):
|
|
143
|
+
_info(" Watching for changes… (Ctrl+C to stop)")
|
|
144
|
+
last = filepath.stat().st_mtime
|
|
145
|
+
try:
|
|
146
|
+
while True:
|
|
147
|
+
time.sleep(1)
|
|
148
|
+
try:
|
|
149
|
+
cur = filepath.stat().st_mtime
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
continue
|
|
152
|
+
if cur != last:
|
|
153
|
+
last = cur
|
|
154
|
+
_info(f" Change detected — {time.strftime('%H:%M:%S')}")
|
|
155
|
+
_build(filepath, page_name, output, verbose)
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
_info("\n Watch stopped.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ============================================================
|
|
161
|
+
# Helpers
|
|
162
|
+
# ============================================================
|
|
163
|
+
|
|
164
|
+
def _ok(msg: str):
|
|
165
|
+
print(f"\033[32m{msg}\033[0m")
|
|
166
|
+
|
|
167
|
+
def _info(msg: str):
|
|
168
|
+
print(f"\033[36m{msg}\033[0m")
|
|
169
|
+
|
|
170
|
+
def _err(msg: str):
|
|
171
|
+
print(f"\033[31mError: {msg}\033[0m", file=sys.stderr)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _load_module(filepath: Path):
|
|
175
|
+
"""Dynamically import a Python file as a module."""
|
|
176
|
+
spec = importlib.util.spec_from_file_location("_htmlforge_user", str(filepath))
|
|
177
|
+
if spec is None or spec.loader is None:
|
|
178
|
+
raise ImportError(f"Cannot load {filepath}")
|
|
179
|
+
mod = importlib.util.module_from_spec(spec)
|
|
180
|
+
spec.loader.exec_module(mod)
|
|
181
|
+
return mod
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _discover_pages(mod) -> dict:
|
|
185
|
+
"""Find all ``Page`` and ``Doc`` instances at module level."""
|
|
186
|
+
return {
|
|
187
|
+
name: obj
|
|
188
|
+
for name, obj in vars(mod).items()
|
|
189
|
+
if isinstance(obj, (Page, Doc)) and not name.startswith("_")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _slug(name: str) -> str:
|
|
194
|
+
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|