-
-
Notifications
You must be signed in to change notification settings - Fork 83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: @template_tag and refactor how template tags are defined #910
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
--- | ||
title: Tag formatters | ||
weight: 7 | ||
weight: 8 | ||
--- | ||
|
||
## Customizing component tags with TagFormatter | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: | ||
|
||
<!-- # TODO - Update docs regarding literal lists and dictionaries | ||
- Using literal lists and dictionaries | ||
- Comments inside and tag with `{# ... #}` | ||
--> | ||
- [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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This section describes what we outlined in #900 (reply in thread), that kwargs like |
||
|
||
```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""" | ||
<div {attrs_str}> | ||
Hello, {is_var}! | ||
</div> | ||
""") | ||
``` | ||
|
||
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. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before, the API reference for template tags was created from the |
||
""" | ||
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(): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a documentation page on creating custom template tags that make use of django-component's features.
Here's a preview:
Screen.Recording.2025-01-16.at.10.15.25.mov