diff --git a/docs/usage.md b/docs/usage.md
index 3e4b247..90b517a 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -290,6 +290,30 @@ snippets as attributes:
```
+## Render elements without a parent (orphans)
+
+In some cases such as returning partial content it is useful to render elements
+without a parent element. This is useful in HTMX partial responses.
+
+You may use `render_node` to achieve this:
+
+```pycon title="Render elements without a parent"
+>>> from htpy import render_orphans, tr
+>>> print(render_node([tr["a"], tr["b"]]))
+a
b
+```
+
+`render_node()` accepts all kinds of [`Node`](static-typing.md#node) objects.
+You may use it to render anything that would normally be a children of another
+element.
+
+!!! note "Best practice: Only use render_node() to render non-Elements"
+
+ You can render regular elements by using `str()`, e.g. `str(p["hi"])`. While
+ `render_node()` would give the same result, it is more straightforward and
+ better practice to just use `str()` when rendering a regular element. Only
+ use `render_node()` when you do not have a parent element.
+
## Iterating of the Output
Iterating over a htpy element will yield the resulting contents in chunks as
@@ -297,13 +321,32 @@ they are rendered:
```pycon
>>> from htpy import ul, li
->>> for chunk in ul[li["a", "b"]]:
+>>> for chunk in ul[li["a"], li["b"]]:
... print(f"got a chunk: {chunk!r}")
...
got a chunk: ''
got a chunk: '- '
got a chunk: 'a'
+got a chunk: '
'
+got a chunk: '- '
got a chunk: 'b'
got a chunk: '
'
got a chunk: '
'
```
+
+Just like [render_node()](#render-elements-without-a-parent-orphans), there is
+`iter_node()` that can be used when you need to iterate over a list of elements
+without a parent:
+
+```
+>>> from htpy import li, iter_node
+>>> for chunk in iter_node([li["a"], li["b"]]):
+... print(f"got a chunk: {chunk!r}")
+...
+got a chunk: ''
+got a chunk: 'a'
+got a chunk: ''
+got a chunk: ''
+got a chunk: 'b'
+got a chunk: ''
+```
diff --git a/htpy/__init__.py b/htpy/__init__.py
index 20b1d05..a5af5ac 100644
--- a/htpy/__init__.py
+++ b/htpy/__init__.py
@@ -103,7 +103,7 @@ def _attrs_string(attrs: dict[str, Attribute]) -> str:
return " " + result
-def _iter_children(x: Node) -> Iterator[str]:
+def iter_node(x: Node) -> Iterator[str]:
while not isinstance(x, BaseElement) and callable(x):
x = x()
@@ -116,7 +116,7 @@ def _iter_children(x: Node) -> Iterator[str]:
yield str(_escape(x))
elif isinstance(x, Iterable):
for child in x:
- yield from _iter_children(child)
+ yield from iter_node(child)
else:
raise ValueError(f"{x!r} is not a valid child element")
@@ -188,7 +188,7 @@ def __call__(self: BaseElementSelf, *args: Any, **kwargs: Any) -> BaseElementSel
def __iter__(self) -> Iterator[str]:
yield f"<{self._name}{_attrs_string(self._attrs)}>"
- yield from _iter_children(self._children)
+ yield from iter_node(self._children)
yield f"{self._name}>"
def __repr__(self) -> str:
@@ -217,6 +217,10 @@ def __iter__(self) -> Iterator[str]:
yield f"<{self._name}{_attrs_string(self._attrs)}>"
+def render_node(node: Node) -> _Markup:
+ return _Markup("".join(iter_node(node)))
+
+
class _HasHtml(Protocol):
def __html__(self) -> str: ...
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
new file mode 100644
index 0000000..897bfa0
--- /dev/null
+++ b/tests/test_nodes.py
@@ -0,0 +1,50 @@
+from typing import Any
+
+from markupsafe import Markup
+
+from htpy import div, iter_node, render_node, tr
+
+
+def assert_markup(result: Any, expected: str) -> None:
+ assert isinstance(result, Markup)
+ assert result == expected
+
+
+class Test_render_node:
+ def test_element(self) -> None:
+ result = render_node(div["a"])
+ assert_markup(result, "a
")
+
+ def test_list(self) -> None:
+ result = render_node([tr["a"], tr["b"]])
+
+ assert_markup(result, "a
b
")
+
+ def test_none(self) -> None:
+ result = render_node(None)
+ assert_markup(result, "")
+
+ def test_string(self) -> None:
+ result = render_node("hej!")
+ assert_markup(result, "hej!")
+
+
+class Test_iter_node:
+ def test_element(self) -> None:
+ result = list(iter_node(div["a"]))
+
+ # Ensure we get str back, not markup.
+ assert type(result[0]) is str
+ assert result == ["", "a", "
"]
+
+ def test_list(self) -> None:
+ result = list(iter_node([tr["a"], tr["b"]]))
+ assert result == ["", "a", "
", "", "b", "
"]
+
+ def test_none(self) -> None:
+ result = list(iter_node(None))
+ assert result == []
+
+ def test_string(self) -> None:
+ result = list(iter_node("hej!"))
+ assert result == ["hej!"]