diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da2f7fa..5cf521c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/docs/concepts/advanced/template_tags.md b/docs/concepts/advanced/template_tags.md index cc2426f9..4ea7efb4 100644 --- a/docs/concepts/advanced/template_tags.md +++ b/docs/concepts/advanced/template_tags.md @@ -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 diff --git a/src/django_components/component.py b/src/django_components/component.py index 36734346..4f9e54cf 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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 diff --git a/src/django_components/node.py b/src/django_components/node.py index eb69d1c9..9e2276da 100644 --- a/src/django_components/node.py +++ b/src/django_components/node.py @@ -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, ) diff --git a/src/django_components/util/template_tag.py b/src/django_components/util/template_tag.py index 3e9184ba..6f382384 100644 --- a/src/django_components/util/template_tag.py +++ b/src/django_components/util/template_tag.py @@ -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]]: diff --git a/tests/test_node.py b/tests/test_node.py index 744f365f..a5dab35a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -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