diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d67f18c..bf41131f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,66 @@
# Release notes
+## v0.125
+
+#### Feat
+
+- `@template_tag` and `BaseNode` - A decorator and a class that allow you to define
+ custom template tags that will behave similarly to django-components' own template tags.
+
+ Read more on [Template tags](https://EmilStenstrom.github.io/django-components/0.125/concepts/advanced/template_tags/).
+
+ Template tags defined with `@template_tag` and `BaseNode` will have the following features:
+
+ - Accepting args, kwargs, and flags.
+
+ - Allowing literal lists and dicts as inputs as:
+
+ `key=[1, 2, 3]` or `key={"a": 1, "b": 2}`
+ - Using template tags tag inputs as:
+
+ `{% my_tag key="{% lorem 3 w %}" / %}`
+ - Supporting the flat dictionary definition:
+
+ `attr:key=value`
+ - Spreading args and kwargs with `...`:
+
+ `{% my_tag ...args ...kwargs / %}`
+ - Being able to call the template tag as:
+
+ `{% my_tag %} ... {% endmy_tag %}` or `{% my_tag / %}`
+
+
+#### Refactor
+
+- Refactored template tag input validation. When you now call template tags like
+ `{% slot %}`, `{% fill %}`, `{% html_attrs %}`, and others, their inputs are now
+ validated the same way as Python function inputs are.
+
+ So, for example
+
+ ```django
+ {% slot "my_slot" name="content" / %}
+ ```
+
+ will raise an error, because the positional argument `name` is given twice.
+
+ NOTE: Special kwargs whose keys are not valid Python variable names are not affected by this change.
+ So when you define:
+
+ ```django
+ {% component data-id=123 / %}
+ ```
+
+ The `data-id` will still be accepted as a valid kwarg, assuming that your `get_context_data()`
+ accepts `**kwargs`:
+
+ ```py
+ def get_context_data(self, **kwargs):
+ return {
+ "data_id": kwargs["data-id"],
+ }
+ ```
+
## v0.124
#### Feat
diff --git a/docs/concepts/advanced/authoring_component_libraries.md b/docs/concepts/advanced/authoring_component_libraries.md
index fdf3493b..242bbbb5 100644
--- a/docs/concepts/advanced/authoring_component_libraries.md
+++ b/docs/concepts/advanced/authoring_component_libraries.md
@@ -1,6 +1,6 @@
---
title: Authoring component libraries
-weight: 8
+weight: 9
---
You can publish and share your components for others to use. Below you will find the steps to do so.
diff --git a/docs/concepts/advanced/tag_formatter.md b/docs/concepts/advanced/tag_formatter.md
index a5af24d6..ae441956 100644
--- a/docs/concepts/advanced/tag_formatter.md
+++ b/docs/concepts/advanced/tag_formatter.md
@@ -1,6 +1,6 @@
---
title: Tag formatters
-weight: 7
+weight: 8
---
## Customizing component tags with TagFormatter
diff --git a/docs/concepts/advanced/template_tags.md b/docs/concepts/advanced/template_tags.md
new file mode 100644
index 00000000..578db78f
--- /dev/null
+++ b/docs/concepts/advanced/template_tags.md
@@ -0,0 +1,197 @@
+---
+title: Custom template tags
+weight: 7
+---
+
+Template tags introduced by django-components, such as `{% component %}` and `{% slot %}`,
+offer additional features over the default Django template tags:
+
+
+- [Self-closing tags `{% mytag / %}`](../../fundamentals/template_tag_syntax#self-closing-tags)
+- [Allowing the use of `:`, `-` (and more) in keys](../../fundamentals/template_tag_syntax#special-characters)
+- [Spread operator `...`](../../fundamentals/template_tag_syntax#spread-operator)
+- [Using template tags as inputs to other template tags](../../fundamentals/template_tag_syntax#use-template-tags-inside-component-inputs)
+- [Flat definition of dictionaries `attr:key=val`](../../fundamentals/template_tag_syntax#pass-dictonary-by-its-key-value-pairs)
+- Function-like input validation
+
+You too can easily create custom template tags that use the above features.
+
+## Defining template tags with `@template_tag`
+
+The simplest way to create a custom template tag is using
+the [`template_tag`](../../../reference/api#django_components.template_tag) decorator.
+This decorator allows you to define a template tag by just writing a function that returns the rendered content.
+
+```python
+from django.template import Context, Library
+from django_components import BaseNode, template_tag
+
+library = Library()
+
+@template_tag(
+ library,
+ tag="mytag",
+ end_tag="endmytag",
+ allowed_flags=["required"]
+)
+def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str:
+ return f"Hello, {name}!"
+```
+
+This will allow you to use the tag in your templates like this:
+
+```django
+{% mytag name="John" %}
+{% endmytag %}
+
+{# or with self-closing syntax #}
+{% mytag name="John" / %}
+
+{# or with flags #}
+{% mytag name="John" required %}
+{% endmytag %}
+```
+
+### Parameters
+
+The `@template_tag` decorator accepts the following parameters:
+
+- `library`: The Django template library to register the tag with
+- `tag`: The name of the template tag (e.g. `"mytag"` for `{% mytag %}`)
+- `end_tag`: Optional. The name of the end tag (e.g. `"endmytag"` for `{% endmytag %}`)
+- `allowed_flags`: Optional. List of flags that can be used with the tag (e.g. `["required"]` for `{% mytag required %}`)
+
+### Function signature
+
+The function decorated with `@template_tag` must accept at least two arguments:
+
+1. `node`: The node instance (we'll explain this in detail in the next section)
+2. `context`: The Django template context
+
+Any additional parameters in your function's signature define what inputs your template tag accepts. For example:
+
+```python
+@template_tag(library, tag="greet")
+def greet(
+ node: BaseNode,
+ context: Context,
+ name: str, # required positional argument
+ count: int = 1, # optional positional argument
+ *, # keyword-only arguments marker
+ msg: str, # required keyword argument
+ mode: str = "default", # optional keyword argument
+) -> str:
+ return f"{msg}, {name}!" * count
+```
+
+This allows the tag to be used like:
+
+```django
+{# All parameters #}
+{% greet "John" count=2 msg="Hello" mode="custom" %}
+
+{# Only required parameters #}
+{% greet "John" msg="Hello" %}
+
+{# Missing required parameter - will raise error #}
+{% greet "John" %} {# Error: missing 'msg' #}
+```
+
+When you pass input to a template tag, it behaves the same way as if you passed the input to a function:
+
+- If required parameters are missing, an error is raised
+- If unexpected parameters are passed, an error is raised
+
+To accept keys that are not valid Python identifiers (e.g. `data-id`), or would conflict with Python keywords (e.g. `is`), you can use the `**kwargs` syntax:
+
+```python
+@template_tag(library, tag="greet")
+def greet(
+ node: BaseNode,
+ context: Context,
+ **kwargs,
+) -> str:
+ attrs = kwargs.copy()
+ is_var = attrs.pop("is", None)
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
+
+ return mark_safe(f"""
+
+ Hello, {is_var}!
+
+ """)
+```
+
+This allows you to use the tag like this:
+
+```django
+{% greet is="John" data-id="123" %}
+```
+
+## Defining template tags with `BaseNode`
+
+For more control over your template tag, you can subclass [`BaseNode`](../../../reference/api#django_components.BaseNode) directly instead of using the decorator. This gives you access to additional features like the node's internal state and parsing details.
+
+```python
+from django_components import BaseNode
+
+class GreetNode(BaseNode):
+ tag = "greet"
+ end_tag = "endgreet"
+ allowed_flags = ["required"]
+
+ def render(self, context: Context, name: str, **kwargs) -> str:
+ # Access node properties
+ if self.flags["required"]:
+ return f"Required greeting: Hello, {name}!"
+ return f"Hello, {name}!"
+
+# Register the node
+GreetNode.register(library)
+```
+
+### Node properties
+
+When using `BaseNode`, you have access to several useful properties:
+
+- `node_id`: A unique identifier for this node instance
+- `flags`: Dictionary of flag values (e.g. `{"required": True}`)
+- `params`: List of raw parameters passed to the tag
+- `nodelist`: The template nodes between the start and end tags
+- `active_flags`: List of flags that are currently set to True
+
+This is what the `node` parameter in the `@template_tag` decorator gives you access to - it's the instance of the node class that was automatically created for your template tag.
+
+### Rendering content between tags
+
+When your tag has an end tag, you can access and render the content between the tags using `nodelist`:
+
+```python
+class WrapNode(BaseNode):
+ tag = "wrap"
+ end_tag = "endwrap"
+
+ def render(self, context: Context, tag: str = "div", **attrs) -> str:
+ # Render the content between tags
+ inner = self.nodelist.render(context)
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
+ return f"<{tag} {attrs_str}>{inner}{tag}>"
+
+# Usage:
+{% wrap tag="section" class="content" %}
+ Hello, world!
+{% endwrap %}
+```
+
+### Unregistering nodes
+
+You can unregister a node from a library using the `unregister` method:
+
+```python
+GreetNode.unregister(library)
+```
+
+This is particularly useful in testing when you want to clean up after registering temporary tags.
diff --git a/docs/scripts/reference.py b/docs/scripts/reference.py
index 967df5d9..c15de89f 100644
--- a/docs/scripts/reference.py
+++ b/docs/scripts/reference.py
@@ -49,8 +49,8 @@
from django_components import ComponentVars, TagFormatterABC
from django_components.component import Component
+from django_components.node import BaseNode
from django_components.util.misc import get_import_path
-from django_components.util.template_tag import TagSpec
# NOTE: This file is an entrypoint for the `gen-files` plugin in `mkdocs.yml`.
# However, `gen-files` plugin runs this file as a script, NOT as a module.
@@ -504,17 +504,18 @@ def gen_reference_templatetags():
f"Import as\n```django\n{{% load {mod_name} %}}\n```\n\n"
)
- for name, obj in inspect.getmembers(tags_module):
+ for _, obj in inspect.getmembers(tags_module):
if not _is_template_tag(obj):
continue
- tag_spec: TagSpec = obj._tag_spec
- tag_signature = _format_tag_signature(tag_spec)
- obj_lineno = inspect.findsource(obj)[1]
+ node_cls: BaseNode = obj
+ name = node_cls.tag
+ tag_signature = _format_tag_signature(node_cls)
+ obj_lineno = inspect.findsource(node_cls)[1]
source_code_link = _format_source_code_html(module_rel_path, obj_lineno)
# Use the tag's function's docstring
- docstring = dedent(obj.__doc__ or "").strip()
+ docstring = dedent(node_cls.__doc__ or "").strip()
# Rebuild (almost) the same documentation than as if we used
# mkdocstrings' `::: path.to.module` syntax.
@@ -585,29 +586,29 @@ def _list_urls(urlpatterns: Sequence[Union[URLPattern, URLResolver]], prefix="")
return urls
-def _format_tag_signature(tag_spec: TagSpec) -> str:
+def _format_tag_signature(node_cls: BaseNode) -> str:
"""
- Given the TagSpec instance, format the tag's function signature like:
+ Given the Node class, format the tag's function signature like:
```django
- {% component [arg, ...] **kwargs [only] %}
+ {% component arg1: int, arg2: str, *args, **kwargs: Any [only] %}
{% endcomponent %}
```
"""
# The signature returns a string like:
# `(arg: Any, **kwargs: Any) -> None`
- params_str = str(tag_spec.signature)
+ params_str = str(node_cls._signature)
# Remove the return type annotation, the `-> None` part
params_str = params_str.rsplit("->", 1)[0]
# Remove brackets around the params, to end up only with `arg: Any, **kwargs: Any`
params_str = params_str.strip()[1:-1]
- if tag_spec.flags:
- params_str += " " + " ".join([f"[{name}]" for name in tag_spec.flags])
+ if node_cls.allowed_flags:
+ params_str += " " + " ".join([f"[{name}]" for name in node_cls.allowed_flags])
# Create the function signature
- full_tag = "{% " + tag_spec.tag + " " + params_str + " %}"
- if tag_spec.end_tag:
- full_tag += f"\n{{% {tag_spec.end_tag} %}}"
+ full_tag = "{% " + node_cls.tag + " " + params_str + " %}"
+ if node_cls.end_tag:
+ full_tag += f"\n{{% {node_cls.end_tag} %}}"
return full_tag
@@ -722,7 +723,7 @@ def _is_tag_formatter_instance(obj: Any) -> bool:
def _is_template_tag(obj: Any) -> bool:
- return callable(obj) and hasattr(obj, "_tag_spec")
+ return inspect.isclass(obj) and issubclass(obj, BaseNode)
def gen_reference():
diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py
index 20087a1c..2ea9083e 100644
--- a/src/django_components/__init__.py
+++ b/src/django_components/__init__.py
@@ -19,6 +19,7 @@
from django_components.components import DynamicComponent
from django_components.dependencies import render_dependencies
from django_components.library import TagProtectedError
+from django_components.node import BaseNode, template_tag
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
from django_components.tag_formatter import (
ComponentFormatter,
@@ -40,6 +41,7 @@
"AlreadyRegistered",
"autodiscover",
"cached_template",
+ "BaseNode",
"ContextBehavior",
"ComponentsSettings",
"Component",
@@ -72,5 +74,6 @@
"TagFormatterABC",
"TagProtectedError",
"TagResult",
+ "template_tag",
"types",
]
diff --git a/src/django_components/attributes.py b/src/django_components/attributes.py
index 32307a6e..d6926139 100644
--- a/src/django_components/attributes.py
+++ b/src/django_components/attributes.py
@@ -2,39 +2,84 @@
# See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501
# And https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/attributes.py # noqa: E501
-from typing import Any, Dict, List, Mapping, Optional, Tuple
+from typing import Any, Dict, Mapping, Optional, Tuple
from django.template import Context
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe
from django_components.node import BaseNode
-from django_components.util.template_tag import TagParams
-
-HTML_ATTRS_DEFAULTS_KEY = "defaults"
-HTML_ATTRS_ATTRS_KEY = "attrs"
class HtmlAttrsNode(BaseNode):
- def __init__(
+ """
+ Generate HTML attributes (`key="value"`), combining data from multiple sources,
+ whether its template variables or static text.
+
+ It is designed to easily merge HTML attributes passed from outside with the internal.
+ See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
+
+ **Args:**
+
+ - `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
+ values in the `default` dictionary.
+ - `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
+ with values in the `attrs` dictionary.
+ - Any extra kwargs will be appended to the corresponding keys
+
+ The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
+ (`key="value"`).
+
+ Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
+
+ ```python
+ attrs = {"class": "my-class"}
+ ```
+
+ Then
+
+ ```django
+ {% html_attrs attrs class="extra-class" %}
+ ```
+
+ will result in `class="my-class extra-class"`.
+
+ **Example:**
+ ```django
+