Repository: django-components/django-components
Pull request #1177: feat: add BaseNode.contents
Author: login: JuroOravec
Created: 2025-05-09T08:35:58Z | Merged: 2025-05-11T06:11:08Z

Another part towards https://github.com/django-components/django-components/issues/1164.

This modifies our implementation of template tags so that the Node (well, `BaseNode`) class remembers what was the content of the body (the string between `{% tag %}` / `{% endtag %}`), and makes it accessible via `BaseNode.contents`.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2da2f7fa9..5cf521c5f 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 cc2426f93..4ea7efb4b 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 367343468..4f9e54cfe 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -2888,8 +2888,9 @@ def __init__(
         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 eb69d1c94..9e2276da8 100644
--- a/src/django_components/node.py
+++ b/src/django_components/node.py
@@ -313,11 +313,13 @@ def __init__(
         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 @@ def parse(cls, parser: Parser, token: Token, **kwargs: Any) -> "BaseNode":
 
         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 3e9184ba0..6f382384e 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_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
         # 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 744f365fa..a5dab35a3 100644
--- a/tests/test_node.py
+++ b/tests/test_node.py
@@ -2,11 +2,14 @@
 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 @@ def render(self, context: Context, name: str, **kwargs) -> str:
 
         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


Reviews:
user: login: JuroOravec
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T08:37:11Z
user: login: JuroOravec
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T08:50:34Z
user: login: EmilStenstrom
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-10T22:58:25Z

Review comments:
diff_hunk: @@ -836,6 +839,96 @@ def render(self, context: Context, name: str, **kwargs) -> str:
 
         TestNode.unregister(component_tags.register)
 
+    def test_node_class_attributes(self):
path: tests/test_node.py
user: login: JuroOravec
body: Added test to check also for other attributes on `BaseNode` as we don't check for that yet.
created_at: 2025-05-09T08:37:11Z
author_association: COLLABORATOR
diff_hunk: @@ -155,10 +157,53 @@ def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
         # 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:
path: src/django_components/util/template_tag.py
user: login: JuroOravec
body: Basically, before we pass template parsing to Django's `Parser.parse()`, which mutates the state, we use similar logic to obtain the raw string of the template tag body (string between `{% tag %}` / `{% endtag %}`
created_at: 2025-05-09T08:50:34Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-09T08:38:11Z
body: ## Performance Benchmark Results

Comparing PR changes against master branch:


Benchmarks that have stayed the same:

| Change   | Before [661413d4]    | After [f02b2a78]    |   Ratio | Benchmark (Parameter)                                             |
|----------|----------------------|---------------------|---------|-------------------------------------------------------------------|
|          | 80.3±0.7ms           | 79.9±0.7ms          |    0.99 | Components vs Django.timeraw_render_lg_first('django')            |
|          | 273±2ms              | 269±2ms             |    0.99 | Components vs Django.timeraw_render_lg_first('django-components') |

author_association: CONTRIBUTOR
user: login: JuroOravec
created_at: 2025-05-09T08:51:18Z
body: @EmilStenstrom Another rather small one.
author_association: COLLABORATOR