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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: htmlforge
3
- Version: 0.0.2
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
- # Layout
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
- # Layout
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)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "htmlforge"
7
- version = "0.0.2"
7
+ version = "0.0.3"
8
8
  description = "Build web pages with Python, export to HTML with a single command"
9
9
  readme = "README.md"
10
10
  license = "MPL-2.0"
@@ -1,8 +1,8 @@
1
1
  """htmlforge — Build web pages with Python, export to HTML."""
2
2
 
3
- __version__ = "0.0.2"
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
- out_dir = filepath.parent / args.output
100
- _serve_dir(str(out_dir), args.port)
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 = http.server.SimpleHTTPRequestHandler
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(Element.__new__(Source).__init_tag(src))
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("&", "&amp;")
711
785
  .replace('"', "&quot;")
786
+ .replace("'", "&#39;")
712
787
  .replace("<", "&lt;")
713
788
  .replace(">", "&gt;")
714
789
  )