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 # /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
- 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)). - 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 ### 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 - `library`: The Django template library to register the tag with
- `tag`: The name of the template tag (e.g. `"mytag"` for `{% mytag %}`) - `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 ### 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) 1. `node`: The node instance (we'll explain this in detail in the next section)
2. `context`: The Django template context 2. `context`: The Django template context
@ -150,15 +151,16 @@ GreetNode.register(library)
### Node properties ### 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 - `node_id`: A unique identifier for this node instance
- `flags`: Dictionary of flag values (e.g. `{"required": True}`) - `flags`: Dictionary of flag values (e.g. `{"required": True}`)
- `params`: List of raw parameters passed to the tag - `params`: List of raw parameters passed to the tag
- `nodelist`: The template nodes between the start and end tags - `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 - `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 ### Rendering content between tags

View file

@ -2888,8 +2888,9 @@ class ComponentNode(BaseNode):
flags: Optional[Dict[str, bool]] = None, flags: Optional[Dict[str, bool]] = None,
nodelist: Optional[NodeList] = None, nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
contents: Optional[str] = None,
) -> 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.name = name
self.registry = registry self.registry = registry

View file

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

View file

@ -104,7 +104,7 @@ def resolve_params(
class ParsedTag(NamedTuple): class ParsedTag(NamedTuple):
flags: Dict[str, bool] flags: Dict[str, bool]
params: List[TagAttr] params: List[TagAttr]
parse_body: Callable[[], NodeList] parse_body: Callable[[], Tuple[NodeList, Optional[str]]]
def parse_template_tag( def parse_template_tag(
@ -140,13 +140,15 @@ def parse_template_tag(
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or []) 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: if inline:
body = NodeList() body = NodeList()
contents: Optional[str] = None
else: else:
contents = _extract_contents_until(parser, [end_tag])
body = parser.parse(parse_until=[end_tag]) body = parser.parse(parse_until=[end_tag])
parser.delete_first_token() parser.delete_first_token()
return body return body, contents
return ParsedTag( return ParsedTag(
params=raw_params, params=raw_params,
@ -155,10 +157,53 @@ def parse_template_tag(
# loggers before the parsing. This is because, if the body contains any other # 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 # 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()` 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( def _extract_flags(
tag_name: str, attrs: List[TagAttr], allowed_flags: List[str] tag_name: str, attrs: List[TagAttr], allowed_flags: List[str]
) -> Tuple[List[TagAttr], Dict[str, bool]]: ) -> Tuple[List[TagAttr], Dict[str, bool]]:

View file

@ -2,11 +2,14 @@ import inspect
import re import re
import pytest import pytest
from django.template import Context, Template 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.template.exceptions import TemplateSyntaxError
from django_components import types from django_components import types
from django_components.node import BaseNode, template_tag from django_components.node import BaseNode, template_tag
from django_components.templatetags import component_tags from django_components.templatetags import component_tags
from django_components.util.tag_parser import TagAttr
from django_components.testing import djc_test from django_components.testing import djc_test
from .testutils import setup_test_config from .testutils import setup_test_config
@ -836,6 +839,96 @@ class TestSignatureBasedValidation:
TestNode.unregister(component_tags.register) 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): def test_node_render(self):
# Check that the render function is called with the context # Check that the render function is called with the context
captured = None captured = None