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 all commits
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
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
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