Skip to content
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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/concepts/advanced/authoring_component_libraries.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/advanced/tag_formatter.md
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
Expand Down
197 changes: 197 additions & 0 deletions docs/concepts/advanced/template_tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
---
Copy link
Collaborator Author

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

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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 data-id or class should still be accepted. But when doing so, these kwargs must be collected onto **kwargs.


```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.
33 changes: 17 additions & 16 deletions docs/scripts/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before, the API reference for template tags was created from the TagSpec objects that were atttached to the template tag definitions. Now the same information is held directly by the Node classes

"""
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

Expand Down Expand Up @@ -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():
Expand Down
3 changes: 3 additions & 0 deletions src/django_components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@
"AlreadyRegistered",
"autodiscover",
"cached_template",
"BaseNode",
"ContextBehavior",
"ComponentsSettings",
"Component",
Expand Down Expand Up @@ -72,5 +74,6 @@
"TagFormatterABC",
"TagProtectedError",
"TagResult",
"template_tag",
"types",
]
Loading
Loading