Skip to content
80 changes: 62 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,29 +305,30 @@ content and attributes. Use these like custom HTML elements in your templates.
The basic form of all component functions is:

```python
from typing import Any
from typing import Any, Iterable
from tdom import Node, html

def MyComponent(*children: Node, **attrs: Any) -> Template:
# Build your template using the provided props
return t"<div {attrs}>{children}</div>"
def MyComponent(children: Iterable[Node], **attrs: Any) -> Node:
return html(t"<div {attrs}>Cool: {children}</div>")
```

To _invoke_ your component within an HTML template, use the special
`<{ComponentName} ... />` syntax:

```python
result = html(t"<{MyComponent} id='comp1'>Hello, Component!</{MyComponent}>")
# <div id="comp1">Hello, Component!</div>
# <div id="comp1">Cool: Hello, Component!</div>
```

Because attributes are passed as keyword arguments, you can explicitly provide
type hints for better editor support:

```python
from typing import Any
from tdom import Node, html

def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Template:
return t'<a href="{href}" {attrs}>{text}: {data_value}</a>'
def Link(*, href: str, text: str, data_value: int, **attrs: Any) -> Node:
return html(t'<a href="{href}" {attrs}>{text}: {data_value}</a>')

result = html(t'<{Link} href="https://example.com" text="Example" data-value={42} target="_blank" />')
# <a href="https://example.com" target="_blank">Example: 42</a>
Expand All @@ -336,26 +337,27 @@ result = html(t'<{Link} href="https://example.com" text="Example" data-value={42
Note that attributes with hyphens (like `data-value`) are converted to
underscores (`data_value`) in the function signature.

In addition to returning `Template`, component functions may also return any
`Node` type found in [`tdom.nodes`](https://github.com/t-strings/tdom/blob/main/tdom/nodes.py):
Component functions build children and can return _any_ type of value; the returned value will be treated exactly as if it were placed directly in a child position in the template.

Among other things, this means you can return a `Template` directly from a component function:

```python
def Link(*, href: str, text: str) -> Node:
return html(t'<a href="{href}">{text}</a>')
def Greeting(name: str) -> Template:
return t"<h1>Hello, {name}!</h1>"

result = html(t'<{Link} href="https://example.com" text="Example" />')
# <a href="https://example.com">Example</a>
result = html(t"<{Greeting} name='Alice' />")
# <h1>Hello, Alice!</h1>
```

You may also return an `Iterable[Node | Template]` if you want to return multiple
elements; this is treated as implicitly wrapping the children in a `Fragment`:
You may also return an iterable:

```python
from typing import Iterable

def Items() -> Iterable[Template]:
for item in ["first", "second"]:
yield t'<li>{item}</li>'
return [t"<li>first</li>", t"<li>second</li>"]

result = html(t'<ul><{Items} /></ul>')
result = html(t"<ul><{Items} /></ul>")
# <ul><li>first</li><li>second</li></ul>
```

Expand All @@ -369,6 +371,48 @@ result = html(t'<ul><{Items} /></ul>')
# <ul><li>first</li><li>second</li></ul>
```

This is not required, but it can make your intent clearer.

#### Class-based components

Component functions are great for simple use cases, but for more complex components
you may want to use a class-based approach. Remember that the component
invocation syntax (`<{ComponentName} ... />`) works with any callable. That includes
the `__init__` method or `__call__` method of a class.

One particularly useful pattern is to build class-based components with dataclasses:

```python
from dataclasses import dataclass, field
from typing import Any, Iterable
from tdom import Node, html

@dataclass
class Card:
children: Iterable[Node]
title: str
subtitle: str | None = None

def __call__(self) -> Node:
return html(t"""
<div class='card'>
<h2>{self.title}</h2>
{self.subtitle and t'<h3>{self.subtitle}</h3>'}
<div class="content">{self.children}</div>
</div>
""")

result = html(t"<{Card} title='My Card' subtitle='A subtitle'><p>Card content</p></{Card}>")
# <div class='card'>
# <h2>My Card</h2>
# <h3>A subtitle</h3>
# <div class="content"><p>Card content</p></div>
# </div>
```

This approach allows you to encapsulate component logic and state within a class,
making it easier to manage complex components.

#### SVG Support

TODO: say more about SVG support
Expand Down
12 changes: 6 additions & 6 deletions docs/usage/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ a `Node`:

<!-- invisible-code-block: python
from string.templatelib import Template
from tdom import html, ComponentCallable, Node
from typing import Iterable
from tdom import html, Node
from typing import Callable, Iterable
-->

```python
Expand Down Expand Up @@ -61,7 +61,7 @@ If your template has children inside the component element, your component will
receive them as `*children` positional arguments:

```python
def Heading(*children: Node, title: str) -> Node:
def Heading(children: Iterable[Node], title: str) -> Node:
return html(t"<h1>{title}</h1><div>{children}</div>")

result = html(t'<{Heading} title="My Title">Child</{Heading}>')
Expand Down Expand Up @@ -97,7 +97,7 @@ driving:
def DefaultHeading() -> Template:
return t"<h1>Default Heading</h1>"

def Body(heading: str) -> Template:
def Body(heading: Callable) -> Template:
return t"<body><{heading} /></body>"

result = html(t"<{Body} heading={DefaultHeading} />")
Expand All @@ -116,7 +116,7 @@ def DefaultHeading() -> Template:
def OtherHeading() -> Template:
return t"<h1>Other Heading</h1>"

def Body(heading: ComponentCallable) -> Template:
def Body(heading: Callable) -> Template:
return html(t"<body><{heading} /></body>")

result = html(t"<{Body} heading={OtherHeading}></{Body}>")
Expand All @@ -135,7 +135,7 @@ def DefaultHeading() -> Template:
def OtherHeading() -> Template:
return t"<h1>Other Heading</h1>"

def Body(heading: ComponentCallable | None = None) -> Template:
def Body(heading: Callable | None = None) -> Template:
return t"<body><{heading if heading else DefaultHeading} /></body>"

result = html(t"<{Body} heading={OtherHeading}></{Body}>")
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "tdom"
version = "0.1.6"
version = "0.1.7"
description = "A 🤘 rockin' t-string HTML templating system for Python 3.14."
readme = "README.md"
requires-python = ">=3.14"
Expand Down
3 changes: 1 addition & 2 deletions tdom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from markupsafe import Markup, escape

from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
from .processor import ComponentCallable, html
from .processor import html

# We consider `Markup` and `escape` to be part of this module's public API

__all__ = [
"Comment",
"ComponentCallable",
"DocumentType",
"Element",
"escape",
Expand Down
76 changes: 76 additions & 0 deletions tdom/callables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import sys
import typing as t
from dataclasses import dataclass
from functools import lru_cache


@dataclass(slots=True, frozen=True)
class CallableInfo:
"""Information about a callable necessary for `tdom` to safely invoke it."""

id: int
"""The unique identifier of the callable (from id())."""

named_params: frozenset[str]
"""The names of the callable's named arguments."""

required_named_params: frozenset[str]
"""The names of the callable's required named arguments."""

requires_positional: bool
"""Whether the callable requires positional-only argument values."""

kwargs: bool
"""Whether the callable accepts **kwargs."""

@classmethod
def from_callable(cls, c: t.Callable) -> t.Self:
"""Create a CallableInfo from a callable."""
import inspect

sig = inspect.signature(c)
named_params = []
required_named_params = []
requires_positional = False
kwargs = False

for param in sig.parameters.values():
match param.kind:
case inspect.Parameter.POSITIONAL_ONLY:
if param.default is param.empty:
requires_positional = True
case inspect.Parameter.POSITIONAL_OR_KEYWORD:
named_params.append(param.name)
if param.default is param.empty:
required_named_params.append(param.name)
case inspect.Parameter.VAR_POSITIONAL:
# Does this necessarily mean it requires positional args?
# Answer: No, but we have no way of knowing how many
# positional args it *might* expect, so we have to assume
# that it does.
requires_positional = True
case inspect.Parameter.KEYWORD_ONLY:
named_params.append(param.name)
if param.default is param.empty:
required_named_params.append(param.name)
case inspect.Parameter.VAR_KEYWORD:
kwargs = True

return cls(
id=id(c),
named_params=frozenset(named_params),
required_named_params=frozenset(required_named_params),
requires_positional=requires_positional,
kwargs=kwargs,
)

@property
def supports_zero_args(self) -> bool:
"""Whether the callable can be called with zero arguments."""
return not self.requires_positional and not self.required_named_params


@lru_cache(maxsize=0 if "pytest" in sys.modules else 512)
def get_callable_info(c: t.Callable) -> CallableInfo:
"""Get the CallableInfo for a callable, caching the result."""
return CallableInfo.from_callable(c)
Loading