feat: add BaseNode.contents (#1177)

This commit is contained in:
Juro Oravec 2025-05-11 08:11:07 +02:00 committed by GitHub
parent 661413d4a9
commit 5f4fbe76e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 184 additions and 10 deletions

View file

@ -406,6 +406,36 @@
# /components/ext/view/components/c1ab2c3?foo=bar#baz
```
- The `BaseNode` class has a new `contents` attribute, which contains the raw contents (string) of the tag body.
This is relevant when you define custom template tags with `@template_tag` decorator or `BaseNode` class.
When you define a custom template tag like so:
```py
from django_components import BaseNode, template_tag
@template_tag(
library,
tag="mytag",
end_tag="endmytag",
allowed_flags=["required"]
)
def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str:
print(node.contents)
return f"Hello, {name}!"
```
And render it like so:
```django
{% mytag name="John" %}
Hello, world!
{% endmytag %}
```
Then, the `contents` attribute of the `BaseNode` instance will contain the string `"Hello, world!"`.
#### Fix
- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).

View file

@ -52,7 +52,7 @@ This will allow you to use the tag in your templates like this:
### Parameters
The `@template_tag` decorator accepts the following parameters:
The [`@template_tag`](../../../reference/api#django_components.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 %}`)
@ -61,7 +61,8 @@ The `@template_tag` decorator accepts the following parameters:
### Function signature
The function decorated with `@template_tag` must accept at least two arguments:
The function decorated with [`@template_tag`](../../../reference/api#django_components.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
@ -150,15 +151,16 @@ GreetNode.register(library)
### Node properties
When using `BaseNode`, you have access to several useful properties:
When using [`BaseNode`](../../../reference/api#django_components.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
- `contents`: The raw contents 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.
This is what the `node` parameter in the [`@template_tag`](../../../reference/api#django_components.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

View file

@ -2888,8 +2888,9 @@ class ComponentNode(BaseNode):
flags: Optional[Dict[str, bool]] = None,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
contents: Optional[str] = None,
) -> None:
super().__init__(params=params, flags=flags, nodelist=nodelist, node_id=node_id)
super().__init__(params=params, flags=flags, nodelist=nodelist, node_id=node_id, contents=contents)
self.name = name
self.registry = registry

View file

@ -313,11 +313,13 @@ class BaseNode(Node, metaclass=NodeMeta):
flags: Optional[Dict[str, bool]] = None,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
contents: Optional[str] = None,
):
self.params = params
self.flags = flags or {flag: False for flag in self.allowed_flags or []}
self.nodelist = nodelist or NodeList()
self.node_id = node_id or gen_id()
self.contents = contents
def __repr__(self) -> str:
return (
@ -350,12 +352,13 @@ class BaseNode(Node, metaclass=NodeMeta):
trace_node_msg("PARSE", cls.tag, tag_id)
body = tag.parse_body()
body, contents = tag.parse_body()
node = cls(
nodelist=body,
node_id=tag_id,
params=tag.params,
flags=tag.flags,
contents=contents,
**kwargs,
)

View file

@ -104,7 +104,7 @@ def resolve_params(
class ParsedTag(NamedTuple):
flags: Dict[str, bool]
params: List[TagAttr]
parse_body: Callable[[], NodeList]
parse_body: Callable[[], Tuple[NodeList, Optional[str]]]
def parse_template_tag(
@ -140,13 +140,15 @@ def parse_template_tag(
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or [])
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> Tuple[NodeList, Optional[str]]:
if inline:
body = NodeList()
contents: Optional[str] = None
else:
contents = _extract_contents_until(parser, [end_tag])
body = parser.parse(parse_until=[end_tag])
parser.delete_first_token()
return body
return body, contents
return ParsedTag(
params=raw_params,
@ -155,10 +157,53 @@ def parse_template_tag(
# loggers before the parsing. This is because, if the body contains any other
# tags, it will trigger their tag handlers. So the code called AFTER
# `parse_body()` is already after all the nested tags were processed.
parse_body=lambda: _parse_tag_body(parser, end_tag, is_inline) if end_tag else NodeList(),
parse_body=lambda: _parse_tag_body(parser, end_tag, is_inline) if end_tag else (NodeList(), None),
)
# Similar to `parser.parse(parse_until=[end_tag])`, except:
# 1. Does not remove the token it goes over (unlike `parser.parse()`, which mutates the parser state)
# 2. Returns a string, instead of a NodeList
#
# This is used so we can access the contents of the tag body as strings, for example
# to be used for caching slots.
#
# See https://github.com/django/django/blob/1fb3f57e81239a75eb8f873b392e11534c041fdc/django/template/base.py#L471
def _extract_contents_until(parser: Parser, until_blocks: List[str]) -> str:
contents: List[str] = []
for token in reversed(parser.tokens):
# Use the raw values here for TokenType.* for a tiny performance boost.
token_type = token.token_type.value
if token_type == 0: # TokenType.TEXT
contents.append(token.contents)
elif token_type == 1: # TokenType.VAR
contents.append("{{ " + token.contents + " }}")
elif token_type == 2: # TokenType.BLOCK
try:
command = token.contents.split()[0]
except IndexError:
# NOTE: Django's `Parser.parse()` raises a `TemplateSyntaxError` when there
# was a an empty block tag, e.g. `{% %}`.
# We skip raising an error here and let `Parser.parse()` raise it.
contents.append("{% " + token.contents + " %}")
if command in until_blocks:
return "".join(contents)
else:
contents.append("{% " + token.contents + " %}")
elif token_type == 3: # TokenType.COMMENT
contents.append("{# " + token.contents + " #}")
else:
raise ValueError(f"Unknown token type {token_type}")
# NOTE: If we got here, then we've reached the end of the tag body without
# encountering any of the `until_blocks`.
# Django's `Parser.parse()` raises a `TemplateSyntaxError` in such case.
#
# Currently `_extract_contents_until()` runs right before `parser.parse()`,
# so we skip raising an error here.
return "".join(contents)
def _extract_flags(
tag_name: str, attrs: List[TagAttr], allowed_flags: List[str]
) -> Tuple[List[TagAttr], Dict[str, bool]]:

View file

@ -2,11 +2,14 @@ import inspect
import re
import pytest
from django.template import Context, Template
from django.template.base import TextNode, VariableNode
from django.template.defaulttags import IfNode, LoremNode
from django.template.exceptions import TemplateSyntaxError
from django_components import types
from django_components.node import BaseNode, template_tag
from django_components.templatetags import component_tags
from django_components.util.tag_parser import TagAttr
from django_components.testing import djc_test
from .testutils import setup_test_config
@ -836,6 +839,96 @@ class TestSignatureBasedValidation:
TestNode.unregister(component_tags.register)
def test_node_class_attributes(self):
captured = None
class TestNodeWithEndTag(BaseNode):
tag = "mytag"
end_tag = "endmytag"
@force_signature_validation
def render(self, context: Context, name: str, **kwargs) -> str:
nonlocal captured
captured = self.params, self.nodelist, self.node_id, self.contents
return f"Hello, {name}!"
# Case 1 - Node with end tag and NOT self-closing
TestNodeWithEndTag.register(component_tags.register)
template_str1 = """
{% load component_tags %}
{% mytag 'John' %}
INSIDE TAG {{ my_var }} {# comment #} {% lorem 1 w %} {% if True %} henlo {% endif %}
{% endmytag %}
"""
template1 = Template(template_str1)
template1.render(Context({}))
params1, nodelist1, node_id1, contents1 = captured # type: ignore
assert len(params1) == 1
assert isinstance(params1[0], TagAttr)
# NOTE: The comment node is not included in the nodelist
assert len(nodelist1) == 8
assert isinstance(nodelist1[0], TextNode)
assert isinstance(nodelist1[1], VariableNode)
assert isinstance(nodelist1[2], TextNode)
assert isinstance(nodelist1[3], TextNode)
assert isinstance(nodelist1[4], LoremNode)
assert isinstance(nodelist1[5], TextNode)
assert isinstance(nodelist1[6], IfNode)
assert isinstance(nodelist1[7], TextNode)
assert contents1 == "\n INSIDE TAG {{ my_var }} {# comment #} {% lorem 1 w %} {% if True %} henlo {% endif %}\n " # noqa: E501
assert node_id1 == "a1bc3e"
captured = None # Reset captured
# Case 2 - Node with end tag and NOT self-closing
template_str2 = """
{% load component_tags %}
{% mytag 'John' / %}
"""
template2 = Template(template_str2)
template2.render(Context({}))
params2, nodelist2, node_id2, contents2 = captured # type: ignore
assert len(params2) == 1 # type: ignore
assert isinstance(params2[0], TagAttr) # type: ignore
assert len(nodelist2) == 0 # type: ignore
assert contents2 is None # type: ignore
assert node_id2 == "a1bc3f" # type: ignore
captured = None # Reset captured
# Case 3 - Node without end tag
class TestNodeWithoutEndTag(BaseNode):
tag = "mytag2"
@force_signature_validation
def render(self, context: Context, name: str, **kwargs) -> str:
nonlocal captured
captured = self.params, self.nodelist, self.node_id, self.contents
return f"Hello, {name}!"
TestNodeWithoutEndTag.register(component_tags.register)
template_str3 = """
{% load component_tags %}
{% mytag2 'John' %}
"""
template3 = Template(template_str3)
template3.render(Context({}))
params3, nodelist3, node_id3, contents3 = captured # type: ignore
assert len(params3) == 1
assert isinstance(params3[0], TagAttr)
assert len(nodelist3) == 0
assert contents3 is None
assert node_id3 == "a1bc40"
# Cleanup
TestNodeWithEndTag.unregister(component_tags.register)
TestNodeWithoutEndTag.unregister(component_tags.register)
def test_node_render(self):
# Check that the render function is called with the context
captured = None