From dc8d62968625420d79a8464258317545ce409296 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Fri, 28 Nov 2025 00:06:16 -0500
Subject: [PATCH 01/18] bugfix: Watcher rule matching
Matching paths outside of cwd would raise an exception without relpath
Flatten all paths as relative to make abs paths work
---
src/html_compose/live/watcher.py | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/src/html_compose/live/watcher.py b/src/html_compose/live/watcher.py
index b12b129..608eafe 100644
--- a/src/html_compose/live/watcher.py
+++ b/src/html_compose/live/watcher.py
@@ -235,9 +235,10 @@ def __init__(
server_reload:
If False, do not reload the daemon process after a change; just the browser.
"""
- self.path_glob = path_glob
if isinstance(path_glob, str):
self.path_glob = [path_glob]
+ else:
+ self.path_glob = path_glob
if not ignore_glob:
ignore_glob = []
@@ -246,6 +247,17 @@ def __init__(
self.ignore_glob = [ignore_glob]
else:
self.ignore_glob = ignore_glob
+
+ # We assume you want all paths to be relative to CWD
+
+ for i, p in enumerate(self.path_glob):
+ p = os.path.relpath(p, CWD) + ("/" if p.endswith("/") else "")
+ self.path_glob[i] = p
+
+ for i, p in enumerate(self.ignore_glob):
+ p = os.path.relpath(p, CWD) + ("/" if p.endswith("/") else "")
+ self.ignore_glob[i] = p
+
self.server_reload = server_reload
self.reload = reload
if action is None:
@@ -375,6 +387,7 @@ def _get_fswatch_dirs(self):
continue
dirs.add(as_t)
+
return dirs
def _get_matching_rules(self, path) -> list[WatchCond]:
@@ -474,7 +487,7 @@ def changed(self) -> list[Hit]:
result_tuples: set[tuple[int, str]] = result
for watch_id, path in result_tuples:
# RustWatch returns full paths, convert to relative
- path = str(Path(path).relative_to(CWD))
+ path = os.path.relpath(path, CWD)
matching_rules = self._get_matching_rules(path)
if matching_rules:
changes.append(Hit(path, matching_rules))
From bea3c09c2e1c94f34682075599abfe71fcbc07cd Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Mon, 15 Dec 2025 21:15:11 -0500
Subject: [PATCH 02/18] Add module docstring
---
src/html_compose/document.py | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/src/html_compose/document.py b/src/html_compose/document.py
index cc3c8b1..6d4a130 100644
--- a/src/html_compose/document.py
+++ b/src/html_compose/document.py
@@ -1,3 +1,20 @@
+"""
+HTML5 document generation functions and classes.
+
+The most featureful way to generate a full HTML5 document is to use
+the `HTML5Document` class with `html_compose.resource` types managing
+imports. The benefit of this is automatic and correct ordering of resources,
+preloads, and import mapping.
+
+The most control over the document generation process is to use
+`document_generator` which yields parts of the document as strings.
+
+Streaming alternatives are available via `document_streamer`, or
+`HTML5Document.stream()`.
+
+These will yield chunks as they are generated.
+"""
+
from typing import Any, Generator, Iterable, Literal, TypeAlias
from urllib.parse import urlencode
From fa9aecaf851d99aa5442d68feab500a386658127 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Mon, 15 Dec 2025 21:20:40 -0500
Subject: [PATCH 03/18] Don't output constructor for attr-less elements
---
src/html_compose/cli.py | 11 ++++++++++-
src/html_compose/translate_html.py | 8 ++++++--
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/src/html_compose/cli.py b/src/html_compose/cli.py
index fe5662f..5c20d5c 100644
--- a/src/html_compose/cli.py
+++ b/src/html_compose/cli.py
@@ -26,7 +26,9 @@ def from_html(args):
if is_stdin:
print("---\n")
- tresult = translate_html.translate(html_content, args.noimport)
+ tresult = translate_html.translate(
+ html_content, args.noimport, args.constructor
+ )
if tresult is None:
print("Failed to translate HTML content")
@@ -61,6 +63,13 @@ def parse_html_translate(parser):
help="Instead of importing each element, use the specified module name",
)
+ parser.add_argument(
+ "-c",
+ "--constructor",
+ action="store_true",
+ help="Always output element constructor",
+ )
+
def html_convert():
parser = argparse.ArgumentParser(description="HTML to python translator")
diff --git a/src/html_compose/translate_html.py b/src/html_compose/translate_html.py
index dda79f1..2e24ed4 100644
--- a/src/html_compose/translate_html.py
+++ b/src/html_compose/translate_html.py
@@ -142,7 +142,9 @@ def is_preformatted(tag_name):
return tag_name in {"pre", "textarea"}
-def translate(html: str, import_module: str | None = None) -> TranslateResult:
+def translate(
+ html: str, import_module: str | None = None, constructor: bool = False
+) -> TranslateResult:
"""
Translate HTML string into Python code representing a similar HTML structure
@@ -233,7 +235,9 @@ def process_element(element: PageElement) -> str | None:
result.append(")")
else:
- result.append("()")
+ # If no attributes, we can skip the constructor call unless
+ if constructor:
+ result.append("()")
children: list[str] = []
child_nodes = list(element.children)
From 52e3c5c62bead0e5b0e94d4146da00531b928409 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Thu, 18 Dec 2025 22:05:40 -0500
Subject: [PATCH 04/18] Spec parser now identifies special elements
---
tools/spec_generator.py | 61 ++++++++++++++++++++++++++++++++++++++++-
1 file changed, 60 insertions(+), 1 deletion(-)
diff --git a/tools/spec_generator.py b/tools/spec_generator.py
index 9565114..9dbafdc 100644
--- a/tools/spec_generator.py
+++ b/tools/spec_generator.py
@@ -128,6 +128,61 @@ def parse_element_table(html):
for el in Hextract.parse_table(columns, column_data)
}
+ def parse_special_elements(html):
+ """
+ Parse the following from the spec:
+ Void elements
+ area, base, br, col, embed, hr, img, input,
+ link, meta, source, track, wbr
+ The template element
+ template
+ Raw text elements
+ script, style
+ Escapable raw text elements
+ textarea, title
+ """
+ rule = hext.Rule(
+ """
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+ """
+ )
+
+ results = rule.extract(html)
+ payload = results[0]
+ html_elements = payload["html"]
+ # so they should come in pairs,
+ # dfn, then code, dfn, then code, etc.
+
+ title_rule = hext.Rule("")
+ element_rule = hext.Rule("")
+ parsed = {}
+ for i in range(0, len(html_elements), 2):
+ title_res = title_rule.extract(hext.Html(html_elements[i]))
+ element_res = element_rule.extract(hext.Html(html_elements[i + 1]))
+ assert len(title_res) == 1, "Expected one title"
+
+ title = title_res[0]["title"]
+ elements = [el["element"] for el in element_res]
+ parsed[title] = elements
+ return {
+ "void elements": parsed.get("Void elements"),
+ "raw text elements": parsed.get("Raw text elements"),
+ "escapable raw text elements": parsed.get(
+ "Escapable raw text elements"
+ ),
+ }
+
def parse_attr_table(html):
"""
Return all html attrs in format
@@ -249,7 +304,11 @@ def hextract(spec_doc):
if element == this_element_name:
this_element["attributes"].append(attr)
- spec = {"_global_attributes": global_atttrs} | elements
+ special_elements = Hextract.parse_special_elements(html)
+ spec = {
+ "_global_attributes": global_atttrs,
+ "_special_elements": special_elements,
+ } | elements
return spec
From 07dfb06a11fbf966df7f108e7f8794942441818c Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Thu, 18 Dec 2025 22:11:23 -0500
Subject: [PATCH 05/18] bugfix: Correctly detect void elements, fix
iframe/template
---
src/html_compose/elements/iframe_element.py | 2 +-
src/html_compose/elements/template_element.py | 2 +-
tools/generate_attributes.py | 2 +-
tools/generate_elements.py | 10 ++++++++--
tools/generated/elements/iframe_element.py | 2 +-
tools/generated/elements/template_element.py | 2 +-
6 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/html_compose/elements/iframe_element.py b/src/html_compose/elements/iframe_element.py
index deded54..ecbc55a 100644
--- a/src/html_compose/elements/iframe_element.py
+++ b/src/html_compose/elements/iframe_element.py
@@ -591,7 +591,7 @@ def __init__(
""" # fmt: skip
super().__init__(
- "iframe", void_element=True, attrs=attrs, children=children
+ "iframe", void_element=False, attrs=attrs, children=children
)
if not (id is None or id is False):
self._process_attr("id", id)
diff --git a/src/html_compose/elements/template_element.py b/src/html_compose/elements/template_element.py
index e4bb87f..a144ae4 100644
--- a/src/html_compose/elements/template_element.py
+++ b/src/html_compose/elements/template_element.py
@@ -562,7 +562,7 @@ def __init__(
""" # fmt: skip
super().__init__(
- "template", void_element=True, attrs=attrs, children=children
+ "template", void_element=False, attrs=attrs, children=children
)
if not (id is None or id is False):
self._process_attr("id", id)
diff --git a/tools/generate_attributes.py b/tools/generate_attributes.py
index 857ab0f..e75443c 100644
--- a/tools/generate_attributes.py
+++ b/tools/generate_attributes.py
@@ -80,7 +80,7 @@ def global_attrs():
def other_attrs():
for element in spec:
result = []
- if element == "_global_attributes":
+ if element in ("_global_attributes", "_special_elements"):
continue
attrs = spec[element]["spec"]["attributes"]
if not attrs:
diff --git a/tools/generate_elements.py b/tools/generate_elements.py
index 8984504..44ed94a 100644
--- a/tools/generate_elements.py
+++ b/tools/generate_elements.py
@@ -203,9 +203,15 @@ def generate_attrs(attr_class, attr_list) -> list[processed_attr]: # -> list:
def gen_elements():
result = []
attr_imports = []
+ special_elements = spec["_special_elements"]["spec"]
+ void_elements = special_elements["void elements"]
global_attrs = spec["_global_attributes"]["spec"]
for element in spec:
- if element in ("_global_attributes", "autonomous custom elements"):
+ if element in (
+ "_global_attributes",
+ "_special_elements",
+ "autonomous custom elements",
+ ):
continue
split_elements = element.split(", ")
for real_element in split_elements:
@@ -292,7 +298,7 @@ def add_param(p):
extra_attrs = "\n".join(attr_list)
attr_assignment = "\n".join(assign_list)
fixed_name = safe_name(real_element)
- is_void_element = children == "empty"
+ is_void_element = real_element in void_elements
comment = ""
if real_element in ("link", "input", "style"):
# Duplicate "title" definition
diff --git a/tools/generated/elements/iframe_element.py b/tools/generated/elements/iframe_element.py
index 2ba01fb..1bef276 100644
--- a/tools/generated/elements/iframe_element.py
+++ b/tools/generated/elements/iframe_element.py
@@ -560,7 +560,7 @@ def __init__(
""" #fmt: skip
super().__init__(
"iframe",
- void_element=True,
+ void_element=False,
attrs=attrs,
children=children
)
diff --git a/tools/generated/elements/template_element.py b/tools/generated/elements/template_element.py
index 1484a20..f0d38d0 100644
--- a/tools/generated/elements/template_element.py
+++ b/tools/generated/elements/template_element.py
@@ -531,7 +531,7 @@ def __init__(
""" #fmt: skip
super().__init__(
"template",
- void_element=True,
+ void_element=False,
attrs=attrs,
children=children
)
From d668bfcec8978c82dd0bd3195220e74d9ffcc64a Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Fri, 19 Dec 2025 20:10:10 -0500
Subject: [PATCH 06/18] Docstring improvement
---
src/html_compose/__init__.py | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/src/html_compose/__init__.py b/src/html_compose/__init__.py
index aeab72f..5b4878b 100644
--- a/src/html_compose/__init__.py
+++ b/src/html_compose/__init__.py
@@ -110,18 +110,26 @@
document string:
```python
-from html_compose import HTML5Document, p, script, link
+from html_compose import HTML5Document, p, script, link, meta
doc: str = HTML5Document(
"Site Title",
lang="en",
- head=[
- script(src="/public/bundle.js"),
- link(rel="stylesheet", href="/public/style.css"),
+ js=[
+ '/public/bundle.js',
+ ],
+ css=[
+ '/public/style.css',
+ ],
+ head_extra=[
+ meta(name='robots', content='index, follow'),
],
body=[p["Hello, world!"]],
)
```
+See `html_compose.resource` for advanced resource configuration,
+or view `html_compose.document` for for more on document generation.
+
### Composing Elements
From d459b8c1def39f7566173284ee97002234842da3 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Fri, 19 Dec 2025 20:16:40 -0500
Subject: [PATCH 07/18] Offer render/stream public APIs for element lists
This is particularly useful when rendering fragments htmx style
---
src/html_compose/__init__.py | 47 ++++++++++++++++++++++++++++++++++++
tests/test_element.py | 10 ++++++++
2 files changed, 57 insertions(+)
diff --git a/src/html_compose/__init__.py b/src/html_compose/__init__.py
index 5b4878b..b5b9e39 100644
--- a/src/html_compose/__init__.py
+++ b/src/html_compose/__init__.py
@@ -216,8 +216,12 @@
.. include:: ../../doc/ideas/06_resource_imports.md
"""
+from typing import Any, Generator, Iterable, cast
+
from markupsafe import Markup, escape
+from .base_types import Node
+
def escape_text(value) -> Markup:
"""
@@ -279,6 +283,49 @@ def doctype(dtype: str = "html"):
from .base_element import BaseElement as BaseElement
from .custom_element import CustomElement as CustomElement
+
+def stream(html_content: Iterable[Node] | Node) -> Generator[str, Any, None]:
+ """
+ Stream one or more HTML elements as a generator of strings
+
+ :param html_content: An iterable of elements that could be rendered as HTML
+
+ :return: Generator of HTML strings
+ """
+ last = object()
+
+ def generator() -> Generator[str, Any, None]:
+ # We use a dummy element to implement .resolve()
+ dummy = BaseElement(tag="dummy")
+ dummy.append(html_content)
+
+ element_source = dummy.resolve()
+ next(element_source, None) # Skip dummy start tag
+
+ next_item = next(element_source, last)
+
+ while True:
+ item = cast(str, next_item)
+ next_item = next(element_source, last)
+ if next_item is last:
+ # skip dummy end tag
+ break
+ yield cast(str, item)
+
+ return generator()
+
+
+def render(html_content: Iterable[Node] | Node) -> str:
+ """
+ Render one or more HTML elements into a single string
+
+ :param html_content: An iterable of elements that could be rendered as HTML
+
+ :return: A single HTML string
+ """
+ return "".join(stream(html_content))
+
+
create_element = CustomElement.create
# Document features
from .document import HTML5Document as HTML5Document
diff --git a/tests/test_element.py b/tests/test_element.py
index 3c818ff..116e96a 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -256,3 +256,13 @@ def test_custom_element():
def test_callable_br():
a = div()[h.p["hi"], h.br, h.p["there"]]
assert a.render() == ""
+
+
+def test_list_render():
+ a = [h.li[x] for x in range(5)]
+ assert h.render(a) == "01234"
+
+
+def test_el_render():
+ a = h.div()["content"]
+ assert h.render(a) == "content
"
From 0a5000b929b6bc864b5b6283b494c95e65310cae Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Fri, 19 Dec 2025 20:17:28 -0500
Subject: [PATCH 08/18] Move startup message to websocket server
---
src/html_compose/live/livereload_server.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/html_compose/live/livereload_server.py b/src/html_compose/live/livereload_server.py
index 8ee1dbf..6f4fd4e 100644
--- a/src/html_compose/live/livereload_server.py
+++ b/src/html_compose/live/livereload_server.py
@@ -50,6 +50,7 @@ def live_reloader(host, port):
with serve(run_ws, host, port) as srv:
global server
server = srv
+ print(f"Running livereload WebSocket server at ws://{host}:{port}")
server.serve_forever()
@@ -59,7 +60,6 @@ def close():
def run_server(host, port) -> Server:
- print(f"Starting livereload WebSocket server at ws://{host}:{port}")
threading.Thread(
target=live_reloader, args=(host, port), daemon=True
).start()
From 995746dcb2a90f55793a6ecbdd5674da965c917e Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 4 Jan 2026 21:54:38 -0500
Subject: [PATCH 09/18] bugfix: nested callables are now called appropriately
---
src/html_compose/base_element.py | 2 +-
tests/test_element.py | 18 ++++++++++++++++++
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/src/html_compose/base_element.py b/src/html_compose/base_element.py
index ba40e5a..f5d26ec 100644
--- a/src/html_compose/base_element.py
+++ b/src/html_compose/base_element.py
@@ -309,7 +309,7 @@ def _resolve_child(
else:
result = child
while callable(result):
- result = self._call_callable(child, parent) # type: ignore[assignment]
+ result = self._call_callable(result, parent) # type: ignore[assignment]
yield from self._resolve_child(result, call_callables, parent)
else:
diff --git a/tests/test_element.py b/tests/test_element.py
index 116e96a..b30fbf0 100644
--- a/tests/test_element.py
+++ b/tests/test_element.py
@@ -72,6 +72,24 @@ def test_nested_callables():
assert a.render() == ""
+def test_callable_returning_callable():
+ """Callables that return callables should fully resolve."""
+ calls: list[str] = []
+
+ def first(parent):
+ calls.append("first")
+
+ def second(current):
+ calls.append("second")
+ return current.tag
+
+ return second
+
+ el = div()[first]
+ assert el.render() == "div
"
+ assert calls == ["first", "second"]
+
+
def test_resolve_none():
el = div()[None].render()
assert el == "", "Nonetype should result in empty string"
From 330a6215e5f6c69b03a4cdacac3d751fb3cca983 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 4 Jan 2026 22:41:14 -0500
Subject: [PATCH 10/18] bugfix: improve whitespace round trip
There was a navigabletring case where the next/prev sibling
logic didn't fire, incorrectly collapsing whitespace.
The robot found this bug for me.
---
src/html_compose/translate_html.py | 33 +++++++++++---------
tests/test_translator.py | 50 +++++++++++++++++++++++++-----
2 files changed, 60 insertions(+), 23 deletions(-)
diff --git a/src/html_compose/translate_html.py b/src/html_compose/translate_html.py
index 2e24ed4..5490882 100644
--- a/src/html_compose/translate_html.py
+++ b/src/html_compose/translate_html.py
@@ -14,6 +14,21 @@
SPEC_WS = r"[\t\n\r ]"
+def _neighbor_tags(node: NavigableString) -> tuple[Tag | None, Tag | None]:
+ """Return the closest Tag siblings to the left and right of a text node."""
+
+ def _find_tag_sibling(node, attr) -> Tag | None:
+ sibling = getattr(node, attr)
+ while sibling and not isinstance(sibling, Tag):
+ sibling = getattr(sibling, attr)
+ return sibling if isinstance(sibling, Tag) else None
+
+ return (
+ _find_tag_sibling(node, "previous_sibling"),
+ _find_tag_sibling(node, "next_sibling"),
+ )
+
+
@cache
def get_phrasing_tags():
"""
@@ -169,7 +184,8 @@ def process_element(element: PageElement) -> str | None:
tags["doctype"] = None
return f"doctype({repr(dt)})"
elif isinstance(element, NavigableString):
- return read_string(element, None, None, phrasing_tags)
+ prev_tag, next_tag = _neighbor_tags(element)
+ return read_string(element, prev_tag, next_tag, phrasing_tags)
assert isinstance(element, Tag)
safe_tag_name = safe_name(element.name)
@@ -251,20 +267,7 @@ def process_element(element: PageElement) -> str | None:
continue
if isinstance(child, NavigableString):
- # We step backwards until we find a tag
- prev_tag = next(
- (
- j
- for j in reversed(child_nodes[:i])
- if isinstance(j, Tag)
- ),
- None,
- )
- # Same deal, forwards until we find a tag
- next_tag = next(
- (j for j in child_nodes[i + 1 :] if isinstance(j, Tag)),
- None,
- )
+ prev_tag, next_tag = _neighbor_tags(child)
processed = read_string(
child, prev_tag, next_tag, phrasing_tags
)
diff --git a/tests/test_translator.py b/tests/test_translator.py
index 169d867..10a2666 100644
--- a/tests/test_translator.py
+++ b/tests/test_translator.py
@@ -1,6 +1,18 @@
from bs4 import BeautifulSoup
import html_compose.translate_html as t
+from html_compose import render as render_nodes
+
+
+def _render_translated(html: str) -> str:
+ tresult = t.translate(html)
+ env: dict[str, object] = {"__builtins__": __builtins__}
+ if tresult.import_statement:
+ exec(tresult.import_statement, env, env)
+ if tresult.custom_elements:
+ exec("\n".join(tresult.custom_elements), env, env)
+ nodes = [eval(expr, env, env) for expr in tresult.elements]
+ return render_nodes(nodes)
def test_translate():
@@ -104,17 +116,17 @@ def test_translate():
tresult = t.translate(html)
def _test_translation(r: t.TranslateResult):
- lines = "\n\n".join(tresult.elements) + ".render()"
+ render_expr = "\n\n".join(tresult.elements) + ".render()"
exec(tresult.import_statement)
- output = eval(lines)
+ output = eval(render_expr)
soup1 = BeautifulSoup(output, "html.parser")
soup2 = BeautifulSoup(expected.render(), "html.parser")
- lines = str(soup1).splitlines()
- lines2 = str(soup2).splitlines()
- assert len(lines) == len(lines2)
- for i, line in enumerate(lines):
- assert line.strip() == lines2[i].strip(), (
- f"Line {i + 1} mismatch: {line.strip()} != {lines2[i].strip()}"
+ rendered_lines = str(soup1).splitlines()
+ expected_lines = str(soup2).splitlines()
+ assert len(rendered_lines) == len(expected_lines)
+ for i, line in enumerate(rendered_lines):
+ assert line.strip() == expected_lines[i].strip(), (
+ f"Line {i + 1} mismatch: {line.strip()} != {expected_lines[i].strip()}"
)
_test_translation(t.translate(html))
@@ -153,3 +165,25 @@ def test_script_round_trip():
print(lines)
output = eval(lines)
assert output == html
+
+
+def test_inline_whitespace_between_phrasing_tags():
+ html = "foo bar
"
+ assert _render_translated(html) == html
+
+
+def test_inline_whitespace_trimmed_outside_phrasing():
+ html = "
block
"
+ expected = "block
"
+ assert _render_translated(html) == expected
+
+
+def test_whitespace_only_between_phrasing_tags():
+ html = "foo bar
"
+ expected = "foo bar
"
+ assert _render_translated(html) == expected
+
+
+def test_inline_whitespace_between_root_phrasing_tags():
+ html = "foo bar"
+ assert _render_translated(html) == html
From 863381b615f889d56da0daab043ebf20ade1ecf8 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Mon, 5 Jan 2026 21:31:41 -0500
Subject: [PATCH 11/18] js_import supports multiple scopes
---
src/html_compose/resource/__init__.py | 12 ++++----
src/html_compose/resource/js_import.py | 38 +++++++++++++++++++-------
tests/test_resource.py | 37 +++++++++++++++++++++++++
3 files changed, 70 insertions(+), 17 deletions(-)
create mode 100644 tests/test_resource.py
diff --git a/src/html_compose/resource/__init__.py b/src/html_compose/resource/__init__.py
index e9ffc81..9b5c621 100644
--- a/src/html_compose/resource/__init__.py
+++ b/src/html_compose/resource/__init__.py
@@ -108,14 +108,12 @@ def to_elements(
entry = jsi.import_map_entry()
if entry:
# Add to import map
- name, src = entry[0:2]
+ name, src = entry.name, entry.src
js_imports[name] = src
- if len(entry) == 3:
- # Scopes feature, although I can't imagine anyone
- # using it.
- scope = str(entry[2])
- sdict = scopes.setdefault(scope, {})
- sdict[name] = src
+ if entry.scope_urls:
+ for scope_key in entry.scope_urls:
+ sdict = scopes.setdefault(scope_key, {})
+ sdict[name] = src
else:
if not isinstance(js_src, str):
diff --git a/src/html_compose/resource/js_import.py b/src/html_compose/resource/js_import.py
index 192e703..c8ef052 100644
--- a/src/html_compose/resource/js_import.py
+++ b/src/html_compose/resource/js_import.py
@@ -1,10 +1,20 @@
-from typing import Iterable, Literal
+from typing import Iterable, Literal, NamedTuple
from .. import elements as el
from ..util_funcs import flatten_iterable, is_iterable_but_not_str
from .util_funcs import _cachebust_resource_uri
+class _ImportMapEntry(NamedTuple):
+ """
+ Common structure for import map entries
+ """
+
+ name: str
+ src: str
+ scope_urls: Iterable[str] | None = None
+
+
class js_import:
"""
A javascript import helper class which employs one or more of:
@@ -188,9 +198,16 @@ def __init__(
self.source = source
self.preload = preload
self.hash = hash
- self.scope_url = scope_url
- if scope_url and is_iterable_but_not_str(scope_url):
- self.scope_url = flatten_iterable(scope_url)
+ self.scope_urls = scope_url
+ if scope_url:
+ if isinstance(scope_url, str):
+ self.scope_urls = [scope_url]
+ elif is_iterable_but_not_str(scope_url):
+ # Materialize scope URLs so they can be iterated multiple times
+ # (flatten_iterable returns a generator by default).
+ self.scope_urls = list(flatten_iterable(scope_url))
+ else:
+ self.scope_urls = [str(scope_url)]
self.crossorigin = crossorigin
self.cache_bust = cache_bust
self.has_link = preload
@@ -219,17 +236,18 @@ def uri(self) -> str:
return _cachebust_resource_uri(self.source)
- def import_map_entry(
- self,
- ) -> tuple[str, str] | tuple[str, str, Iterable] | None:
+ def import_map_entry(self) -> _ImportMapEntry | None:
"""
Returns a tuple of (name, source, scope_url) for use in an import map
"""
+
if self.name:
- if self.scope_url is None:
- return (self.name, self._src)
+ if self.scope_urls is None:
+ return _ImportMapEntry(name=self.name, src=self._src)
else:
- return (self.name, self._src, self.scope_url)
+ return _ImportMapEntry(
+ name=self.name, src=self._src, scope_urls=self.scope_urls
+ )
return None
diff --git a/tests/test_resource.py b/tests/test_resource.py
new file mode 100644
index 0000000..05b38c5
--- /dev/null
+++ b/tests/test_resource.py
@@ -0,0 +1,37 @@
+import json
+
+from html_compose.resource import js_import, to_elements
+
+
+def _extract_import_map(elements):
+ for element in elements:
+ if element.tag == "script" and element.attrs.get("type") == "importmap":
+ rendered = element.render()
+ payload = rendered.split(">", 1)[1].rsplit("<", 1)[0]
+ return json.loads(payload)
+ raise AssertionError("importmap script not found")
+
+
+def test_js_import_scoped_single():
+ elements = to_elements(
+ js=[js_import("/static/mod.js", name="mod", scope_url="/admin")]
+ )
+ import_map = _extract_import_map(elements)
+ assert import_map["imports"]["mod"].startswith("/static/mod.js")
+ assert import_map["scopes"]["/admin"]["mod"].startswith("/static/mod.js")
+
+
+def test_js_import_scoped_iterable():
+ elements = to_elements(
+ js=[
+ js_import(
+ "/static/feature.js",
+ name="feature",
+ scope_url=["/settings", "/profile"],
+ )
+ ]
+ )
+ import_map = _extract_import_map(elements)
+ scopes = import_map["scopes"]
+ assert scopes["/settings"]["feature"].startswith("/static/feature.js")
+ assert scopes["/profile"]["feature"].startswith("/static/feature.js")
From 5cc2a7ea12f9bc6e5e62d383869bf3ea0c33d652 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Thu, 8 Jan 2026 22:56:31 -0500
Subject: [PATCH 12/18] linter
---
src/html_compose/elements/__init__.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/html_compose/elements/__init__.py b/src/html_compose/elements/__init__.py
index b659107..7445393 100644
--- a/src/html_compose/elements/__init__.py
+++ b/src/html_compose/elements/__init__.py
@@ -102,8 +102,6 @@ def get(value: str) -> BaseAttribute:
"""
-import os
-
from .a_element import a as a
from .abbr_element import abbr as abbr
from .address_element import address as address
@@ -218,6 +216,8 @@ def get(value: str) -> BaseAttribute:
from .video_element import video as video
from .wbr_element import wbr as wbr
+import os
+
# hack: force PDOC to treat elements as submodules
if not os.environ.get("PDOC_GENERATING", False):
__all__ = [
From 532592ffc75ceb912b5d50b52a042c6f0d6e6b0e Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 15 Mar 2026 22:20:46 -0400
Subject: [PATCH 13/18] Add gallery feature: component showcase server with
live reload
---
src/html_compose/cli.py | 73 +++++++
src/html_compose/document.py | 21 +-
src/html_compose/gallery/__init__.py | 132 ++++++++++++
src/html_compose/gallery/app.py | 197 ++++++++++++++++++
src/html_compose/gallery/impl.py | 165 +++++++++++++++
src/html_compose/gallery/resources.py | 185 +++++++++++++++++
src/html_compose/gallery/server.py | 283 ++++++++++++++++++++++++++
src/html_compose/live/live_server.py | 12 +-
tests/test_assumptions.py | 9 +
tests/test_gallery.py | 273 +++++++++++++++++++++++++
10 files changed, 1337 insertions(+), 13 deletions(-)
create mode 100644 src/html_compose/gallery/__init__.py
create mode 100644 src/html_compose/gallery/app.py
create mode 100644 src/html_compose/gallery/impl.py
create mode 100644 src/html_compose/gallery/resources.py
create mode 100644 src/html_compose/gallery/server.py
create mode 100644 tests/test_gallery.py
diff --git a/src/html_compose/cli.py b/src/html_compose/cli.py
index 5c20d5c..50026bd 100644
--- a/src/html_compose/cli.py
+++ b/src/html_compose/cli.py
@@ -1,5 +1,6 @@
import argparse
import fileinput
+import sys
from . import translate_html
@@ -78,6 +79,72 @@ def html_convert():
from_html(args)
+def parse_gallery(parser):
+ parser.add_argument("module", help="Path to the module to run")
+ parser.add_argument(
+ "--host",
+ default="localhost",
+ help="Host to run the gallery server on (default: localhost)",
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=8000,
+ help="Port to run the gallery server on (default: 8000)",
+ )
+ parser.add_argument(
+ "--livereload-host",
+ default="(host)",
+ help="Host to run the gallery server on (defaults to host value)",
+ )
+ parser.add_argument(
+ "--livereload-port",
+ type=int,
+ default=51353,
+ help="Port to run the gallery server on (default: 51353)",
+ )
+ parser.add_argument(
+ "--force-polling",
+ action="store_true",
+ help="Force polling for file changes instead of using inotify or similar",
+ )
+ parser.add_argument(
+ "--watch-extra-path",
+ action="append",
+ default=[],
+ help="Additional paths to watch for changes (can be specified multiple times)",
+ )
+ parser.add_argument(
+ "--python-path",
+ nargs="?",
+ help="Add path to Python search path",
+ default=".",
+ )
+
+
+def gallery(args):
+ from .gallery.server import run
+
+ if args.python_path:
+ if args.python_path not in sys.path:
+ sys.path.insert(0, args.python_path)
+
+ try:
+ run(
+ module_path=args.module,
+ host=args.host,
+ port=args.port,
+ force_polling=args.force_polling,
+ extra_paths=args.watch_extra_path,
+ livereload_host=args.livereload_host
+ if args.livereload_host != "(host)"
+ else args.host,
+ livereload_port=args.livereload_port,
+ )
+ except KeyboardInterrupt:
+ print("\nExiting...")
+
+
def cli():
"""
Command-line tool to translate HTML to Python code using html_compose
@@ -92,8 +159,14 @@ def cli():
HTML_CONVERT, help="Translate HTML to html-compose"
)
parse_html_translate(html_parser)
+ gallery_parser = subparsers.add_parser(
+ "gallery", help="Run an HTML gallery server"
+ )
+ parse_gallery(gallery_parser)
args = parser.parse_args()
if args.command == HTML_CONVERT:
from_html(args)
+ elif args.command == "gallery":
+ gallery(args)
else:
parser.print_help()
diff --git a/src/html_compose/document.py b/src/html_compose/document.py
index 6d4a130..e6ccf63 100644
--- a/src/html_compose/document.py
+++ b/src/html_compose/document.py
@@ -15,6 +15,7 @@
These will yield chunks as they are generated.
"""
+import os
from typing import Any, Generator, Iterable, Literal, TypeAlias
from urllib.parse import urlencode
@@ -180,17 +181,25 @@ def document_generator(
def get_livereload_uri() -> str:
"""
- Generally this is just the neat place to store the livereload URI.
-
+ Return livereload-js compatible resource URI
But if the user wants they can override this function to return a local
resource i.e.
html_compose.document.get_live_reload_uri =
lambda: "mydomain.com/static/livereload.js";
+ Or just set env var HTMLCOMPOSE_LIVERELOAD_URL to the desired URL.
+
+ We default to livereload-morph, which fits our use case better.
+
"""
- VERSION = "v4.0.2"
- return f"cdn.jsdelivr.net/npm/livereload-js@{VERSION}/dist/livereload.js"
+ env_url = os.getenv("HTMLCOMPOSE_LIVERELOAD_URL", None)
+ # allow user override.
+ if env_url:
+ return env_url
+ VERSION = "0.3.0"
+ # We ported from livereload-js to livereload-morph to better fit our use case
+ return f"cdn.jsdelivr.net/npm/livereload-morph@{VERSION}/dist/livereload-morph.js"
def _livereload_script_tag(live_reload_settings):
@@ -213,7 +222,9 @@ def _livereload_script_tag(live_reload_settings):
# Regular development enviroment with no proxy. host:port will do.
host = live_reload_settings["host"]
port = live_reload_settings["port"]
- uri_encoded_flags = urlencode({"host": host, "port": port})
+ uri_encoded_flags = urlencode(
+ {"host": host, "port": port, "verbose": True}
+ )
# This scriptlet auto-inserts the livereload script and detects protocol
return el.script()[
diff --git a/src/html_compose/gallery/__init__.py b/src/html_compose/gallery/__init__.py
new file mode 100644
index 0000000..38a6481
--- /dev/null
+++ b/src/html_compose/gallery/__init__.py
@@ -0,0 +1,132 @@
+from collections.abc import Callable
+from typing import Any, Literal
+
+from .. import resource
+from ..base_types import Node
+from . import impl
+
+
+def showcase(
+ *fixtures,
+ kwargs: dict[str, Any] | None = None,
+ name: str | None = None,
+ category: str | None = None,
+ tags: list[str] | None = None,
+ env: str = "default",
+) -> Callable:
+ """
+ Register example fixtures for component preview and testing.
+
+ You may set multiple showcases on the same component function with multiple
+ decorator uses.
+
+ Args:
+ *fixtures:
+ Zero or more arguments to be used as example inputs for the decorated function.
+
+ kwargs:
+ Optional dict of keyword arguments to be used as example inputs.
+
+ name:
+ Optional human-readable name for this showcase entry.
+ Useful for distinguishing multiple showcases on the same component.
+
+ category:
+ Optional category string for grouping showcases in galleries or documentation.
+
+ tags:
+ Optional list of tag strings for filtering and organizing showcase entries.
+
+ env:
+ The environment in which this showcase should be active.
+ Defaults to ``"default"``.
+
+ Usage:
+ ```python
+ @showcase(example_post)
+ @showcase(minimal_post, name="minimal")
+ def post(post: blog_models.Post):
+ return section(...)[...]
+ ```
+ This populates a gallery that can be viewed by running `html-compose gallery`
+ """
+
+ def decorator(func: Callable) -> Callable:
+ func_fixtures: list[impl.ShowcaseEntry] = (
+ impl.showcase_registry.setdefault(func, [])
+ )
+ func_fixtures.append(
+ impl.ShowcaseEntry(fixtures, kwargs, name, category, tags, env)
+ )
+
+ # Attach metadata for introspection
+ if not hasattr(func, "__showcases__"):
+ func.__showcases__ = [] # type: ignore[attr-defined]
+ func.__showcases__.extend(fixtures) # type: ignore[attr-defined]
+
+ return func
+
+ return decorator
+
+
+def declare_environment(
+ env: str = "default",
+ css: list[resource.css_import | str] | None = None,
+ js: list[resource.js_import | str] | None = None,
+ fonts: list[resource.font_import_manual | resource.font_import_provider]
+ | None = None,
+ isolation: Literal["shadow-dom", "iframe", "none"] = "none",
+ parent: str | None = None,
+ body_override: Node | None = None,
+ parent_element: Node | None = None,
+):
+ """
+ Declare resources for a named environment.
+ Environments can be referenced by showcases to control their setup.
+
+ Args:
+ env: Name of the environment.
+
+ css: List of CSS imports to include.
+
+ js: List of JS imports to include.
+
+ fonts: List of font imports to include.
+
+ isolation: Isolation mode for the environment.
+
+ parent: Name of a parent environment to inherit resources from.
+
+ body_override: Optional body content to replace the default.
+
+ parent_element: Optional parent element for the environment.
+ If body_override is set, this is required and must be
+ an element within the body_override.
+ """
+ if env in impl.env_registry:
+ raise ValueError(f"Environment '{env}' is already declared")
+
+ if isolation == "none":
+ for e in impl.env_registry.values():
+ if e.isolation == "none":
+ raise ValueError(
+ "Only one environment can have 'none' isolation"
+ )
+ if body_override is not None and parent_element is None:
+ raise ValueError(
+ "If body_override is set, parent_element must also be provided"
+ )
+ if parent_element is not None and body_override is None:
+ raise ValueError(
+ "If parent_element is set, body_override must also be provided"
+ )
+
+ impl.env_registry[env] = impl.ShowcaseEnvironment(
+ css=css or [],
+ js=js or [],
+ fonts=fonts or [],
+ isolation=isolation,
+ parent=parent,
+ body_override=body_override,
+ parent_element=parent_element,
+ )
diff --git a/src/html_compose/gallery/app.py b/src/html_compose/gallery/app.py
new file mode 100644
index 0000000..bec70a2
--- /dev/null
+++ b/src/html_compose/gallery/app.py
@@ -0,0 +1,197 @@
+import re
+from typing import cast
+from urllib.parse import urlencode
+
+import html_compose as ht
+from html_compose.base_types import Node
+from html_compose.document import _livereload_script_tag, document_generator
+from html_compose.elements import div, meta, title
+
+from .. import resource
+from .impl import ShowcaseResult, resolve_environment
+from .resources import GALLERY_STYLES, NAV_STYLES, IframeScripts
+
+
+class Settings:
+ livereload_host = "localhost"
+ livereload_port = 51353
+
+
+def doc(body):
+ return document_generator(
+ head=[
+ meta(charset="UTF-8"),
+ meta(
+ name="viewport", content="width=device-width, initial-scale=1"
+ ),
+ ht.style()[GALLERY_STYLES],
+ title["Gallery"],
+ _livereload_script_tag(
+ dict(
+ host=Settings.livereload_host,
+ port=Settings.livereload_port,
+ proxy_host=None,
+ proxy_uri=None,
+ )
+ ),
+ ],
+ body=body,
+ )
+
+
+def showcase_id(result: ShowcaseResult) -> str:
+ """Generate a stable ID for a showcase, used for anchors and element IDs."""
+ base = f"{result.func_module}-{result.func_name}"
+ if result.name:
+ base += f"-{result.name}"
+ # Sanitize for use as HTML id - keep only alphanumeric and hyphens
+ sanitized = re.sub(r"[^a-zA-Z0-9-]", "-", base)
+ # Collapse multiple hyphens
+ sanitized = re.sub(r"-+", "-", sanitized)
+ return sanitized.lower().strip("-")
+
+
+def main_left_panel(modules: dict[str, list[ShowcaseResult]]):
+ panel_sections = []
+
+ for module, results in modules.items():
+ showcases_by_function: dict[str, list[tuple[str, ShowcaseResult]]] = {}
+ function_groups = []
+ for r in results:
+ grouped_showcases = showcases_by_function.setdefault(
+ r.func_name, []
+ )
+ # Use showcase name, or function name with variant number if unnamed
+ if r.name:
+ display = r.name
+ else:
+ variant_num = len(grouped_showcases) + 1
+ display = f"{r.func_name.replace('_', ' ')} #{variant_num}"
+ grouped_showcases.append((display, r))
+
+ for call_name, grouped_showcases in showcases_by_function.items():
+ showcase_links = [
+ ht.li()[ht.a(href=f"#{showcase_id(result)}")[name]]
+ for name, result in grouped_showcases
+ ]
+ function_groups.append(
+ ht.details()[ht.summary()[call_name], ht.ul()[showcase_links]]
+ )
+
+ module_section = ht.details(open=True)[
+ ht.summary()[module], ht.ul()[function_groups]
+ ]
+ panel_sections.append(module_section)
+ return [ht.style()[NAV_STYLES], ht.ul()[panel_sections]]
+
+
+def get_showcase(result: ShowcaseResult) -> Node:
+ """Create a self-contained page for a single showcase entry."""
+ env = resolve_environment(result.env)
+ # Use the showcase name if provided, otherwise use the function name
+ display_name = result.name or result.func_name.replace("_", " ")
+ resource_elements = resource.to_elements(
+ js=env.js, css=env.css, fonts=env.fonts
+ )
+ broken = result.success is False
+ card_id = showcase_id(result)
+ standalone_url = f"/showcase/{result.func_module}/{result.func_name}"
+ if result.name:
+ standalone_url += f"?{urlencode({'name': result.name})}"
+
+ header = ht.div(class_="o-showcase-header")[
+ ht.span(class_="o-showcase-title")[
+ "[broken] " if broken else "", display_name
+ ],
+ ht.a(
+ class_="o-showcase-link",
+ href=standalone_url,
+ target="_blank",
+ title="Open standalone",
+ )["↗"],
+ ]
+
+ if env.isolation == "shadow-dom":
+ content = ht.div(class_="o-showcase-content")[
+ ht.template(shadowrootmode="open")[
+ resource_elements, ht.unsafe_text(cast(str, result.result))
+ ]
+ ]
+ elif env.isolation == "iframe":
+ iframe_content = [
+ # inject CSS to reliably hide scrollbars
+ ht.style["html, body { overflow: hidden; }"],
+ resource_elements,
+ ht.unsafe_text(cast(str, result.result)),
+ ]
+ content = ht.iframe(
+ id=f"iframe-{card_id}",
+ class_="o-showcase-content",
+ srcdoc=ht.render(iframe_content),
+ )
+ else:
+ # no isolation
+ content = ht.div(class_="o-showcase-content")[
+ ht.unsafe_text(cast(str, result.result))
+ ]
+
+ return ht.div(class_="o-showcase-card", id=card_id)[header, content]
+
+
+def get_showcases(content: list[ShowcaseResult]) -> list[Node]:
+ return [get_showcase(result) for result in content]
+
+
+def main_body(
+ modules: dict[str, list[ShowcaseResult]], content: list[ShowcaseResult]
+):
+ resources = None
+ for e in content:
+ env = resolve_environment(e.env)
+ # There can only be one 'none' isolation environment and it
+ # sets up the whole page
+ if env.isolation == "none":
+ resources = resource.to_elements(
+ js=env.js, css=env.css, fonts=env.fonts
+ )
+
+ return [
+ resources,
+ ht.script()[ht.unsafe_text(IframeScripts.parent)],
+ div(class_="o-gallery-container")[
+ div(class_="o-left-panel")[
+ ht.template(shadowrootmode="open")[main_left_panel(modules)]
+ ],
+ div(class_="o-center-panel")[get_showcases(content)],
+ div(class_="o-right-panel"),
+ ],
+ ]
+
+
+def main_route(content: list[ShowcaseResult]) -> str:
+ """
+ Main gallery page showing all showcases
+ """
+ modules: dict[str, list[ShowcaseResult]] = {}
+ for result in content:
+ modules.setdefault(result.func_module, []).append(result)
+
+ body_content = main_body(modules, content)
+ return doc(body_content)
+
+
+def generate_showcase(result: ShowcaseResult) -> str:
+ """
+ Single page view of a showcase
+ """
+ assert result.success, "Cannot generate showcase for failed result"
+ assert isinstance(result.result, str)
+ env = resolve_environment(result.env)
+ resources = resource.to_elements(js=env.js, css=env.css, fonts=env.fonts)
+ return doc(
+ [
+ resources,
+ ht.script()[ht.unsafe_text(IframeScripts.parent)],
+ ht.unsafe_text(result.result),
+ ]
+ )
diff --git a/src/html_compose/gallery/impl.py b/src/html_compose/gallery/impl.py
new file mode 100644
index 0000000..e1bf7ac
--- /dev/null
+++ b/src/html_compose/gallery/impl.py
@@ -0,0 +1,165 @@
+import weakref
+from collections.abc import Callable
+from typing import Any, Literal, NamedTuple
+
+from .. import render, resource
+from ..base_types import Node
+
+
+class ShowcaseEntry(NamedTuple):
+ fixtures: Any
+ kwargs: dict[str, Any] | None
+ name: str | None
+ category: str | None
+ tags: list[str] | None
+ env: str
+
+
+class ShowcaseResult(NamedTuple):
+ name: str | None
+ func_name: str
+ func_module: str
+ category: str | None
+ tags: list[str] | None
+ success: bool
+ result: str | Exception
+ env: str
+
+
+class ShowcaseEnvironment(NamedTuple):
+ css: list[resource.css_import | str]
+ js: list[resource.js_import | str]
+ fonts: list[resource.font_import_manual | resource.font_import_provider]
+ isolation: Literal["shadow-dom", "iframe", "none"]
+ parent: str | None
+ body_override: Node | None
+ parent_element: Node | None
+
+
+showcase_registry: weakref.WeakKeyDictionary[Callable, list[ShowcaseEntry]] = (
+ weakref.WeakKeyDictionary()
+)
+env_registry: dict[str, ShowcaseEnvironment] = {}
+
+
+def resolve_environment(env_name: str) -> ShowcaseEnvironment:
+ """
+ Resolve the environment by name, including inheritance.
+ """
+ if env_name not in env_registry:
+ if env_name == "default":
+ # Default environment
+ return ShowcaseEnvironment(
+ css=[],
+ js=[],
+ fonts=[],
+ isolation="none",
+ parent=None,
+ body_override=None,
+ parent_element=None,
+ )
+
+ raise ValueError(f"Environment '{env_name}' is not declared")
+
+ env = env_registry[env_name]
+
+ css: list[resource.css_import | str] = env.css
+ js: list[resource.js_import | str] = env.js
+ fonts: list[resource.font_import_manual | resource.font_import_provider] = (
+ env.fonts
+ )
+ parent = env.parent
+ parent_set = set()
+ body_inherited = None
+ parent_element_inherited = None
+ while parent:
+ # prevent infinite loop
+ if parent in parent_set:
+ raise ValueError(
+ f"Circular environment inheritance detected at '{parent}'"
+ )
+ parent_set.add(parent)
+
+ # Resolve parent
+ parent_env = env_registry.get(parent)
+ if not parent_env:
+ raise ValueError(f"Environment '{parent}' is not declared")
+
+ # Add its resources
+ css = parent_env.css + css
+ js = parent_env.js + js
+ fonts = parent_env.fonts + fonts
+ # If we haven't previously inherited a body, take this one
+ if body_inherited is None and parent_env.body_override is not None:
+ body_inherited = parent_env.body_override
+ parent_element_inherited = parent_env.parent_element
+
+ parent = parent_env.parent
+
+ body_override = (
+ env.body_override if env.body_override is not None else body_inherited
+ )
+ parent_element = (
+ env.parent_element
+ if env.parent_element is not None
+ else parent_element_inherited
+ )
+ return ShowcaseEnvironment(
+ css=css,
+ js=js,
+ fonts=fonts,
+ isolation=env.isolation,
+ parent=env.parent,
+ body_override=body_override,
+ parent_element=parent_element,
+ )
+
+
+def prepare(fn, showcase_entry: ShowcaseEntry) -> tuple[bool, str | Exception]:
+ """
+ Prepare the function with the given showcase entry.
+ This may involve setting up the environment, injecting fixtures, etc.
+ """
+ try:
+ env = resolve_environment(showcase_entry.env)
+ component = fn(
+ *showcase_entry.fixtures, **(showcase_entry.kwargs or {})
+ )
+
+ if env.body_override is not None and env.parent_element is not None:
+ env.parent_element.append(component) # type: ignore
+ try:
+ html = render(env.body_override)
+ finally:
+ env.parent_element._children.pop() # type: ignore
+ else:
+ html = render(component)
+ except Exception as e:
+ return False, e
+ return True, html
+
+
+def show_showcases(
+ filter_allows: Callable[[ShowcaseEntry], bool] | None = None,
+) -> list[ShowcaseResult]:
+ """
+ Return all registered showcases, optionally filtered by a predicate.
+ """
+ all_showcases: list[ShowcaseResult] = []
+ for func, entries in showcase_registry.items():
+ for entry in entries:
+ if filter_allows is None or filter_allows(entry):
+ success, result = prepare(func, entry)
+ all_showcases.append(
+ ShowcaseResult(
+ name=entry.name,
+ func_name=func.__qualname__,
+ func_module=func.__module__,
+ category=entry.category,
+ tags=entry.tags,
+ success=success,
+ result=result,
+ env=entry.env,
+ )
+ )
+ return all_showcases
diff --git a/src/html_compose/gallery/resources.py b/src/html_compose/gallery/resources.py
new file mode 100644
index 0000000..eb5ec4f
--- /dev/null
+++ b/src/html_compose/gallery/resources.py
@@ -0,0 +1,185 @@
+class IframeScripts:
+ parent = """
+// Iframe auto-sizing: uses adoptedStyleSheets to survive HTML morphs
+window._iframeSizer ??= (() => {
+ const SELECTOR = "iframe.o-showcase-content";
+ const sheet = new CSSStyleSheet();
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
+
+ const sync = () => {
+ const rules = [];
+ for (const frame of document.querySelectorAll(SELECTOR)) {
+ const height = frame.contentDocument?.body?.scrollHeight;
+ if (height && frame.id) rules.push(`#${CSS.escape(frame.id)} { height: ${height}px; }`);
+ }
+ // Only update when we have heights - preserves rules when iframe not yet ready
+ if (rules.length) sheet.replaceSync(rules.join("\\n"));
+ };
+
+ const resizeObs = new ResizeObserver(sync);
+
+ const observe = () => {
+ for (const frame of document.querySelectorAll(SELECTOR)) {
+ if (frame.contentDocument?.body) resizeObs.observe(frame.contentDocument.body);
+ frame.onload = () => {
+ if (frame.contentDocument?.body) resizeObs.observe(frame.contentDocument.body);
+ sync();
+ };
+ }
+ sync();
+ };
+
+ // MutationObserver runs indefinitely (never disconnected)
+ new MutationObserver(observe).observe(document.body, { childList: true, subtree: true, attributes: true });
+ return { observe };
+})();
+window._iframeSizer.observe();"""
+
+
+NAV_STYLES = """
+:host {
+ display: block;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ font-size: 14px;
+ padding: 12px 0;
+}
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+details {
+ margin: 0;
+}
+summary {
+ cursor: pointer;
+ padding: 6px 16px;
+ color: #333;
+ font-weight: 500;
+ user-select: none;
+}
+summary:hover {
+ background: #f5f5f5;
+}
+details > ul {
+ padding-left: 12px;
+}
+details details summary {
+ font-weight: 400;
+ color: #666;
+ padding: 4px 16px;
+ font-size: 13px;
+}
+a {
+ display: block;
+ padding: 4px 16px;
+ color: #0066cc;
+ text-decoration: none;
+ font-size: 13px;
+}
+a:hover {
+ background: #e8f4fc;
+}
+"""
+
+GALLERY_STYLES = """
+/* Gallery base styles */
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #f5f5f5;
+ color: #333;
+}
+
+/* Main layout */
+.o-gallery-container {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ height: 100vh;
+}
+
+/* Left navigation panel */
+.o-left-panel {
+ flex: 0 0 240px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: #fff;
+ border-right: 1px solid #e0e0e0;
+}
+
+/* Hide the empty right panel */
+.o-right-panel {
+ display: none;
+}
+
+/* Center content panel */
+.o-center-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 24px;
+ overflow-y: auto;
+ background: #f5f5f5;
+}
+
+/* Showcase card container */
+.o-showcase-card {
+ flex-shrink: 0; /* Don't shrink - overflow and scroll instead */
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
+}
+
+/* Showcase header */
+.o-showcase-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ color: #555;
+ background: #fafafa;
+ border-bottom: 1px solid #e0e0e0;
+}
+
+.o-showcase-title {
+ flex: 1;
+}
+
+.o-showcase-link {
+ color: #888;
+ text-decoration: none;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 13px;
+}
+
+.o-showcase-link:hover {
+ background: #e8e8e8;
+ color: #333;
+}
+
+/* Showcase content container */
+.o-showcase-content {
+ background: #fff;
+}
+
+/* Iframe showcases */
+iframe.o-showcase-content {
+ display: block;
+ border: none;
+ width: 100%;
+ flex-shrink: 0;
+ overflow: hidden;
+}
+"""
diff --git a/src/html_compose/gallery/server.py b/src/html_compose/gallery/server.py
new file mode 100644
index 0000000..7d159e3
--- /dev/null
+++ b/src/html_compose/gallery/server.py
@@ -0,0 +1,283 @@
+import http.server
+import importlib
+import mimetypes
+import os.path
+import sys
+import threading
+from pathlib import Path
+from time import sleep
+from typing import cast
+from urllib.parse import parse_qs, urlparse
+
+from ..live import WatchCond, Watcher, livereload_server
+from . import app
+from .impl import (
+ ShowcaseResult,
+ env_registry,
+ show_showcases,
+ showcase_registry,
+)
+
+
+class Settings:
+ resource_prefix = "/"
+ static_dir = Path.cwd() / "static"
+
+ @staticmethod
+ def set_static_dir(path: str) -> None:
+ Settings.static_dir = Path(path)
+
+
+# Common web mimetypes if the system mimetypes db fails
+WEB_MIMETYPES = {
+ ".html": "text/html",
+ ".css": "text/css",
+ ".js": "application/javascript",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".gif": "image/gif",
+ ".svg": "image/svg+xml",
+}
+
+
+class GalleryRequestHandler(http.server.BaseHTTPRequestHandler):
+ content: list[ShowcaseResult] = []
+
+ def _handle(self) -> None:
+ parsed = urlparse(self.path)
+ path = parsed.path
+ query = parse_qs(parsed.query)
+ content = GalleryRequestHandler.content
+
+ if path == "/" or path == "/index.html":
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(app.main_route(content).encode("utf-8"))
+ return
+
+ for c in content:
+ if path == f"/showcase/{c.func_module}/{c.func_name}":
+ # Filter on name if provided
+ test_name = query.get("name", None)
+ if test_name:
+ if c.name != test_name[0]:
+ continue
+
+ if not c.success:
+ self.send_response(500)
+ self.send_header("Content-Type", "text/plain")
+ self.end_headers()
+ self.wfile.write(f"Showcase broken: {c.result}".encode())
+ return
+
+ # Serve the showcase
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(app.generate_showcase(c).encode("utf-8"))
+ return
+
+ # Try to serve static files
+ if self._serve_static(path):
+ return
+
+ # Nothing matched
+ self.send_response(404)
+ self.end_headers()
+
+ def do_GET(self):
+ # This method handles all GET requests
+ self._handle()
+
+ def _serve_static(self, path: str) -> bool:
+ if not Settings.static_dir:
+ return False
+ prefix = Settings.resource_prefix
+ if path.startswith(prefix):
+ normalized = path.removeprefix(prefix).lstrip("/")
+ else:
+ return False
+
+ # Collapse .. to prevent directory traversal without resolving symlinks
+ candidate = Path(os.path.normpath(Settings.static_dir / normalized))
+
+ # Not relative to static dir
+ if not candidate.is_relative_to(
+ Path(os.path.normpath(Settings.static_dir))
+ ):
+ return False
+
+ # Resolve symlinks for the actual file read
+ # as a developer server, we assume your symlinks are safe
+ candidate = candidate.resolve()
+
+ if not candidate.is_file():
+ return False
+
+ mime, _ = mimetypes.guess_type(candidate.name)
+ if not mime:
+ # Fall through if the mimetype db failed to identify
+ # basic web types
+ ext = candidate.suffix
+ # fallback to application/octet-stream if we really
+ # have no idea
+ mime = WEB_MIMETYPES.get(ext, "application/octet-stream")
+
+ self.send_response(200)
+ self.send_header("Content-Type", mime)
+ self.send_header("Content-Length", str(candidate.stat().st_size))
+ self.end_headers()
+
+ # Stream the file in chunks to avoid loading large files into memory
+ with candidate.open("rb") as f:
+ while True:
+ data = f.read(8192)
+ if not data:
+ break
+ self.wfile.write(data)
+
+ return True
+
+
+def run(
+ module_path: str | Path,
+ host: str,
+ port: int,
+ livereload_port: int,
+ livereload_host: str,
+ force_polling: bool = False,
+ extra_paths: list[str] | None = None,
+):
+ # Resolve to absolute before accessing parts to handle absolute file paths safely
+ # If the user passed an absolute path, we inject its directory into sys.path
+ mp_raw = Path(module_path)
+ if mp_raw.is_absolute():
+ if str(mp_raw.parent) not in sys.path:
+ sys.path.insert(0, str(mp_raw.parent))
+ mp = Path(mp_raw.name)
+ else:
+ mp = mp_raw
+
+ if mp.suffix == ".py":
+ mp = mp.with_suffix("")
+
+ showcase_registry.clear()
+ module_name = ".".join(mp.parts)
+
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+
+ importlib.invalidate_caches()
+
+ app.Settings.livereload_host = livereload_host
+ app.Settings.livereload_port = livereload_port
+
+ try:
+ module = importlib.import_module(module_name)
+ except Exception as e:
+ print(f"Error importing module {module_name}: {e}")
+ raise
+ assert module is not None
+
+ watch_globs = []
+ ignore_glob = []
+ if extra_paths:
+ for ep in extra_paths:
+ watch_globs.append(ep)
+ if hasattr(module, "__path__"):
+ pathlist = module.__path__
+ elif hasattr(module, "__file__"):
+ pathlist = [os.path.dirname(cast(str, module.__file__))]
+ else:
+ pathlist = []
+ print("Warning: could not determine module path for file watching")
+
+ for watch_path in pathlist:
+ loaded_mod_path = Path(watch_path)
+ watch_globs.extend([f"{loaded_mod_path / '**/*.py'}"])
+ ignore_glob.extend(
+ [
+ f"{loaded_mod_path / venv}"
+ for venv in [
+ "venv",
+ ".venv",
+ ".virtualenv",
+ "virtualenv",
+ "env",
+ ".env",
+ ]
+ ]
+ )
+ fs_watcher = WatchCond(
+ path_glob=watch_globs, ignore_glob=ignore_glob, action=None
+ )
+
+ watcher = Watcher([fs_watcher], force_polling=force_polling)
+
+ def serve():
+ server_address = (host, port)
+ httpd = http.server.ThreadingHTTPServer(
+ server_address, GalleryRequestHandler
+ )
+ print(f"Serving HTTP on http://{server_address[0]}:{server_address[1]}")
+ httpd.serve_forever()
+
+ server_thread = threading.Thread(target=serve, daemon=True)
+ server_thread.start()
+ livereload_thread = threading.Thread(
+ target=livereload_server.live_reloader,
+ args=(livereload_host, livereload_port),
+ daemon=True,
+ )
+ livereload_thread.start()
+ healthy = True
+ backoff = 1.0
+ last_error_str = None
+
+ GalleryRequestHandler.content = show_showcases()
+ changed_files = []
+ while True:
+ if healthy:
+ while True:
+ changed_files = watcher.changed()
+ if changed_files:
+ print("Changes detected, reloading module...")
+ break
+ sleep(0.1)
+ else:
+ # Poll for changes but with progressive sleep limit (backoff)
+ waited = 0.0
+ while waited < backoff:
+ changed_files = watcher.changed()
+ if changed_files:
+ break
+ sleep(0.1)
+ waited += 0.1
+
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+
+ importlib.invalidate_caches()
+
+ try:
+ showcase_registry.clear()
+ env_registry.clear()
+ module = importlib.import_module(module_name)
+ GalleryRequestHandler.content.clear()
+ GalleryRequestHandler.content.extend(show_showcases())
+ healthy = True
+ backoff = 1.0
+ last_error_str = None
+
+ if changed_files:
+ livereload_server.reload_because(
+ [f.path for f in changed_files]
+ )
+ except Exception as e:
+ healthy = False
+ current_error_str = f"Error re-importing module {module_name}: {e}"
+ if current_error_str != last_error_str:
+ print(current_error_str)
+ last_error_str = current_error_str
+ backoff = min(backoff * 2, 10.0)
diff --git a/src/html_compose/live/live_server.py b/src/html_compose/live/live_server.py
index b1105d1..ce13e1b 100644
--- a/src/html_compose/live/live_server.py
+++ b/src/html_compose/live/live_server.py
@@ -4,14 +4,10 @@
from ..util_funcs import generate_livereload_env
from .livereload_server import reload_because, run_server
-from .watcher import (
- ProcessTask,
- ShellCommand,
- Task,
- TaskRunner,
- WatchCond,
- Watcher,
-)
+from .watcher import ProcessTask, Task, TaskRunner
+from .watcher import ShellCommand as ShellCommand
+from .watcher import WatchCond as WatchCond
+from .watcher import Watcher as Watcher
def _wait_for_server(
diff --git a/tests/test_assumptions.py b/tests/test_assumptions.py
index d1dbe9f..5e3d69a 100644
--- a/tests/test_assumptions.py
+++ b/tests/test_assumptions.py
@@ -1189,3 +1189,12 @@ def control_func(
assert test3_delta < test1_delta * 0.5, (
"Control function should be at least 50% faster than Test 1"
)
+
+
+def test_fn_name():
+ from html_compose.elements import div
+
+ fn = div().render
+ assert fn.__name__ == "render"
+ assert fn.__module__ == "html_compose.base_element"
+ assert fn.__qualname__ == "BaseElement.render"
diff --git a/tests/test_gallery.py b/tests/test_gallery.py
new file mode 100644
index 0000000..41038d5
--- /dev/null
+++ b/tests/test_gallery.py
@@ -0,0 +1,273 @@
+import pytest
+
+import html_compose as ht
+from html_compose.gallery import declare_environment, showcase
+from html_compose.gallery.impl import (
+ env_registry,
+ resolve_environment,
+ show_showcases,
+ showcase_registry,
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_registries():
+ """Clear global registries between tests"""
+ showcase_registry.clear()
+ env_registry.clear()
+ yield
+ showcase_registry.clear()
+ env_registry.clear()
+
+
+# --- showcase decorator ---
+
+
+def test_showcase_decorator():
+ @showcase("example2", name="example_2", category="text")
+ @showcase("example1", name="example_1", category="text")
+ def my_component(text: str) -> list:
+ return [ht.li()[text], ht.li["Static item"]]
+
+ showcases = showcase_registry.get(my_component, [])
+ assert len(showcases) == 2
+ assert showcases[0].name == "example_1"
+ assert showcases[0].category == "text"
+
+ assert showcases[1].name == "example_2"
+ assert showcases[1].category == "text"
+
+
+def test_showcase_no_fixtures():
+ """@showcase() with no fixtures registers with empty tuple"""
+
+ @showcase()
+ def my_component():
+ return []
+
+ showcases = showcase_registry.get(my_component, [])
+ assert len(showcases) == 1
+ assert showcases[0].fixtures == ()
+ assert showcases[0].name is None
+ assert showcases[0].category is None
+
+
+def test_showcase_callable_fixture():
+ """@showcase(callable) correctly treats the callable as a fixture, not the decorated function"""
+
+ def my_fixture():
+ return "test data"
+
+ @showcase(my_fixture)
+ def my_component(data):
+ return [ht.div[data]]
+
+ showcases = showcase_registry.get(my_component, [])
+ assert len(showcases) == 1
+ assert showcases[0].fixtures == (my_fixture,)
+
+
+def test_showcase_kwargs():
+ @showcase("hello", kwargs={"style": "bold"}, name="with_style")
+ def my_component(text, style="normal"):
+ return ht.div[f"{text} ({style})"]
+
+ results = show_showcases()
+ assert len(results) == 1
+ assert results[0].success is True
+ assert "bold" in results[0].result
+
+
+# --- prepare / show_showcases ---
+
+
+def test_prepare_renders_component():
+ @showcase("world")
+ def greeting(name):
+ return ht.span[f"hello {name}"]
+
+ results = show_showcases()
+ assert len(results) == 1
+ assert results[0].success is True
+ assert "hello world" in results[0].result
+
+
+def test_prepare_catches_exceptions():
+ @showcase("bad input")
+ def broken(x):
+ raise ValueError("intentional")
+
+ results = show_showcases()
+ assert len(results) == 1
+ assert results[0].success is False
+ assert isinstance(results[0].result, ValueError)
+
+
+# --- declare_environment validation ---
+
+
+def test_declare_environment_body_override_requires_parent_element():
+ with pytest.raises(
+ ValueError, match="parent_element must also be provided"
+ ):
+ declare_environment(
+ env="test", isolation="iframe", body_override=ht.div["wrapper"]
+ )
+
+
+def test_declare_environment_parent_element_requires_body_override():
+ with pytest.raises(ValueError, match="body_override must also be provided"):
+ declare_environment(
+ env="test", isolation="iframe", parent_element=ht.div()
+ )
+
+
+def test_declare_environment_duplicate_raises():
+ declare_environment(env="dup", isolation="iframe")
+ with pytest.raises(ValueError, match="already declared"):
+ declare_environment(env="dup", isolation="iframe")
+
+
+# --- body_override rendering ---
+
+
+def test_body_override_wraps_component():
+ """Component renders inside the parent_element within body_override"""
+ container = ht.main(class_="content")
+ layout = ht.div(class_="app-shell")[ht.nav["sidebar"], container]
+ declare_environment(
+ env="wrapped",
+ isolation="iframe",
+ body_override=layout,
+ parent_element=container,
+ )
+
+ @showcase("test content", env="wrapped")
+ def my_component(text):
+ return ht.p[text]
+
+ results = show_showcases()
+ assert len(results) == 1
+ assert results[0].success is True
+ html = results[0].result
+ # Component should be inside the layout
+ assert "app-shell" in html
+ assert "sidebar" in html
+ assert "test content" in html
+
+
+def test_body_override_cleans_up_after_render():
+ """parent_element is not permanently mutated after prepare()"""
+ container = ht.main()
+ layout = ht.div[container]
+ declare_environment(
+ env="cleanup",
+ isolation="iframe",
+ body_override=layout,
+ parent_element=container,
+ )
+
+ @showcase("first", env="cleanup")
+ def comp_a(text):
+ return ht.span[text]
+
+ # Render once
+ results = show_showcases()
+ assert results[0].success is True
+
+ # The container should be clean — no leftover children from previous render
+ assert len(container._children) == 0
+
+
+def test_body_override_multiple_showcases_same_env():
+ """Multiple showcases using the same body_override env render independently"""
+ container = ht.main()
+ layout = ht.div(class_="shell")[container]
+ declare_environment(
+ env="shared",
+ isolation="iframe",
+ body_override=layout,
+ parent_element=container,
+ )
+
+ @showcase("alpha", name="a", env="shared")
+ @showcase("beta", name="b", env="shared")
+ def my_component(text):
+ return ht.p[text]
+
+ results = show_showcases()
+ assert len(results) == 2
+ assert results[0].success is True
+ assert results[1].success is True
+ # Decorator order: beta registered first (inner), alpha second (outer)
+ assert results[0].name == "b"
+ assert results[1].name == "a"
+ # Each should contain only its own content, not the other's
+ assert "beta" in results[0].result
+ assert "alpha" not in results[0].result
+ assert "alpha" in results[1].result
+ assert "beta" not in results[1].result
+
+
+# --- environment inheritance ---
+
+
+def test_environment_inheritance_css():
+ declare_environment(env="base", isolation="iframe", css=["base.css"])
+ declare_environment(
+ env="child", isolation="shadow-dom", parent="base", css=["child.css"]
+ )
+
+ resolved = resolve_environment("child")
+ assert resolved.css == ["base.css", "child.css"]
+
+
+def test_environment_inheritance_body_override_child_wins():
+ """Child's body_override takes precedence over parent's"""
+ parent_container = ht.div(class_="parent-container")
+ parent_layout = ht.div(class_="parent-layout")[parent_container]
+ declare_environment(
+ env="parent",
+ isolation="iframe",
+ body_override=parent_layout,
+ parent_element=parent_container,
+ )
+
+ child_container = ht.div(class_="child-container")
+ child_layout = ht.div(class_="child-layout")[child_container]
+ declare_environment(
+ env="child",
+ isolation="iframe",
+ parent="parent",
+ body_override=child_layout,
+ parent_element=child_container,
+ )
+
+ resolved = resolve_environment("child")
+ assert resolved.body_override is child_layout
+ assert resolved.parent_element is child_container
+
+
+def test_environment_inheritance_body_override_falls_back_to_parent():
+ """If child doesn't set body_override, inherits from parent"""
+ parent_container = ht.div(class_="parent-container")
+ parent_layout = ht.div(class_="parent-layout")[parent_container]
+ declare_environment(
+ env="parent2",
+ isolation="iframe",
+ body_override=parent_layout,
+ parent_element=parent_container,
+ )
+
+ declare_environment(env="child2", isolation="iframe", parent="parent2")
+
+ resolved = resolve_environment("child2")
+ assert resolved.body_override is parent_layout
+ assert resolved.parent_element is parent_container
+
+
+def test_environment_circular_inheritance_raises():
+ declare_environment(env="a", isolation="iframe", parent="b")
+ declare_environment(env="b", isolation="shadow-dom", parent="a")
+ with pytest.raises(ValueError, match="Circular"):
+ resolve_environment("a")
From 32e34893533b8098acaaaca62a03fa265ecdbb44 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 15 Mar 2026 22:21:56 -0400
Subject: [PATCH 14/18] formatting
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index dcfb8d7..1a08b77 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,7 @@ a([tab]
#
div({"data-for-something": "foo"})
#
+
## With class dictionary resolution
is_dark_mode = False
div(class_={"dark-mode": is_dark_mode, "flex": True})
From 01179956307b97c93361f64b5a56ec2d6bce6782 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 15 Mar 2026 22:26:27 -0400
Subject: [PATCH 15/18] Do not generate superfluous list in deferred_resolve
---
src/html_compose/base_element.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/html_compose/base_element.py b/src/html_compose/base_element.py
index f5d26ec..662b35c 100644
--- a/src/html_compose/base_element.py
+++ b/src/html_compose/base_element.py
@@ -405,7 +405,7 @@ def deferred_resolve(
children = None
if not self.is_void_element:
- children = [child for child in self._resolve_tree(parent)]
+ children = (child for child in self._resolve_tree(parent))
# join_attrs has a configurable lru_cache
join_attrs = self.get_attr_join()
From fa650fcacee56eb016ed921b0d0d9038f547c3ad Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 15 Mar 2026 22:28:35 -0400
Subject: [PATCH 16/18] HasHtml isinstance -> hasattr
Python isinstance checking for protocols is slow; prefer hasattr __html__
---
src/html_compose/base_element.py | 5 +++--
src/html_compose/base_types.py | 2 ++
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/html_compose/base_element.py b/src/html_compose/base_element.py
index 662b35c..cebaae4 100644
--- a/src/html_compose/base_element.py
+++ b/src/html_compose/base_element.py
@@ -269,8 +269,9 @@ def _resolve_child(
inst: BaseElement = child()
yield from inst.resolve()
- elif isinstance(child, _HasHtml):
- yield unsafe_text(child.__html__())
+ elif hasattr(child, "__html__"):
+ # Duck typing is faster than isinstance checks on runtime Protocols
+ yield unsafe_text(cast(_HasHtml, child).__html__())
elif isinstance(child, str):
# Magic: If the string is already escaped, this never has to fire.
diff --git a/src/html_compose/base_types.py b/src/html_compose/base_types.py
index b6a138a..5cf2fcd 100644
--- a/src/html_compose/base_types.py
+++ b/src/html_compose/base_types.py
@@ -5,6 +5,8 @@
from . import util_funcs
+# Note: base_element actually does runtime checking via hasattr
+# This is for the type checker.
@typing.runtime_checkable
class _HasHtml(typing.Protocol):
def __html__(self) -> str:
From d3d25f4fd2f2390f87d0c3d4abfd629d8738c64a Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 15 Mar 2026 22:31:45 -0400
Subject: [PATCH 17/18] Use collections.abc for runtime iterable checks
---
src/html_compose/util_funcs.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/html_compose/util_funcs.py b/src/html_compose/util_funcs.py
index e69481b..595e5db 100644
--- a/src/html_compose/util_funcs.py
+++ b/src/html_compose/util_funcs.py
@@ -1,15 +1,16 @@
"""
Library utility functions
-Not inteded to be used directly by library users.
+Not intended to be used directly by library users.
"""
import inspect
import json
+from collections.abc import Iterable
from functools import lru_cache
from os import getenv
from pathlib import PurePath
-from typing import Any, Generator, Iterable
+from typing import Any, Generator
def join_attrs(k, value_trusted):
From 135f667e3cc7afae2027d534986e8b63f9c0ccb8 Mon Sep 17 00:00:00 2001
From: jealouscloud
Date: Sun, 15 Mar 2026 22:33:55 -0400
Subject: [PATCH 18/18] 0.12.0 release
---
changelog.txt | 19 +++++++++++++++++++
pyproject.toml | 2 +-
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/changelog.txt b/changelog.txt
index 7f1f49f..894c0d1 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,3 +1,22 @@
+# 0.12.0
+* Add gallery: component showcase server with live reload (`html-compose gallery`)
+ * `@showcase` decorator to register components with example fixtures
+ * `declare_environment` for managing CSS/JS/fonts and isolation modes (shadow-dom, iframe, none)
+ * Environment inheritance with resource merging and body_override support
+ * Hot-reloading dev server with file watching
+* Switch livereload client from livereload-js to livereload-morph
+* js_import supports multiple scopes
+* render/stream public APIs now accept element lists
+* Spec parser now identifies special elements
+* HTML converter: don't output constructor for attr-less elements
+* bugfix: improve whitespace round trip
+* bugfix: nested callables are now called appropriately
+* bugfix: correctly detect void elements, fix iframe/template
+* bugfix: watcher rule matching
+* Performance: deferred_resolve no longer generates extra list
+* Performance: detect _HasHtml via hasattr instead of Protocol isinstance
+* Prefer collections.abc for runtime Iterable checking
+
# 0.11.2
* resource module correctly places import map before preload links
* bugfix: relative paths were stripped in cache_bust=true
diff --git a/pyproject.toml b/pyproject.toml
index 214ec8d..dbc1264 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "html-compose"
-version = "0.11.2"
+version = "0.12.0"
description = "Composable HTML generation in python"
authors = [
{ name = "jealouscloud", email = "github@noaha.org" }