Make it easier to declare tags and filters available only within the context of another tag

This may be a fring use-case, but please hear me out. Several times on a project, I ended up having to create tags for complex components with several options. For instance, a <table> styled with a a design-system like Bootstrap, that allows other complex components attached like a header with buttons to sort the table.

Now, in a normal use-case, you may declare such a component with:

{% design_system_component option1=True option2="Title" %}
  <div>My component content here</div>
{% end_design_system_component %}

But in such cases, using tag’s parameters to declare the options could prove impractical. For instance, one of the options takes HTML content. In such case, you may want something like this:

{% design_system_component option1=True %}
  {% header do_something=True %}
    <button>Sort by name</button>
    <button>Sort bydate</button>
  {% endheader %}

  <div>My component content here</div>
{% end_design_system_component %}

In such context, you may want the {% header %} tag to be available only within the {% design_system_component %}tag and not leak outside of it.

Currently, the only way to add new library to the parser like so:

register = template.Library()

@register.tag
def design_system_component(parser: Parser, token: Token):
    local_lib = template.Library()
    local_lib.simple_block_tag(header_fun, takes_context=True, name="header")
    parser.add_library(local_lib)

Now, sadly, this solution leaks {% header %} out of {% design_system_component %} after the first call because there is no Parser.remove_library counterpart.

To solve this I see two solutions:

  1. Add a __copy__method to Parser to allow create a local parser to parse the content of a specific tag like so:
    @register.tag
    def design_system_component(parser: Parser, token: Token):
        local_lib = template.Library()
        local_lib.simple_block_tag(header_fun, takes_context=True, name="header")
        local_parser = copy(parser)
        local_parser.add_library(local_lib)
    
        local_parser.parse(("end_design_system_component",))
        …
    
  2. Introduce a context method to Parser to add library that will be removed immediately when exiting the context:
    @register.tag
    def design_system_component(parser: Parser, token: Token):
        
        local_lib = template.Library()
        local_lib.simple_block_tag(header_fun, takes_context=True, name="header")
    
         with parser.temporary_library(local_lib):
             local_parser.parse(("end_design_system_component",))
    

Both are relatively easy to add to Python and document.

What are your inputs on this?

This seems like you could put together a proof-of-concept quite easily, defining a copy_parser() function that did the necessary there.

Reflecting on this, I don’t think this would be sufficient. If I also want to prevent tags from also leaking into children like:

register = template.Library()

def temporarytag(parser: Parser, token: Token):
    ....

@register.tag
def customtag(parser: Parser, token: Token):
    local_library = template.Library()
    local_library.tag(temporarytag)

    with parser.local_library(local_library):
        parser.parse(("endcustomtag",))
    ....
{% customtag %}
    {% temporarytag %}

    {% blocktrans %}   
        {# ideally, I want a TemplateSyntaxError here #}
        {% temporarytag %}
    {% endblocktrans %}

{% endcustomtag %}
This can be implemented fairly simply with the following diff
diff --git a/django/template/base.py b/django/template/base.py
index 5e541c3960..46db18eb1e 100644
--- a/django/template/base.py
+++ b/django/template/base.py
@@ -49,7 +49,7 @@ times with multiple contexts)
 >>> t.render(c)
 '<html></html>'
 """
-
+import contextlib
 import inspect
 import logging
 import re
@@ -498,7 +498,7 @@ class DebugLexer(Lexer):
 
 
 class Parser:
-    def __init__(self, tokens, libraries=None, builtins=None, origin=None):
+    def __init__(self, tokens, libraries=None, builtins=None, origin=None, parent=None):
         # Reverse the tokens so delete_first_token(), prepend_token(), and
         # next_token() can operate at the end of the list in constant time.
         self.tokens = list(reversed(tokens))
@@ -522,6 +522,8 @@ class Parser:
             self.add_library(builtin)
         self.origin = origin
 
+        self.parent = parent
+
     def __repr__(self):
         return "<%s tokens=%r>" % (self.__class__.__qualname__, self.tokens)
 
@@ -579,7 +581,9 @@ class Parser:
                 # Compile the callback into a node object and add it to
                 # the node list.
                 try:
-                    compiled_result = compile_func(self, token)
+                    # Passing self.parent prevents from local libraries to leak into
+                    # children tags and filters
+                    compiled_result = compile_func(self.parent or self, token)
                 except Exception as e:
                     raise self.error(token, e)
                 self.extend_nodelist(nodelist, compiled_result, token)
@@ -680,6 +684,21 @@ class Parser:
         else:
             raise TemplateSyntaxError("Invalid filter: '%s'" % filter_name)
 
+    @contextlib.contextmanager
+    def local_library(self, library):
+        # These fields are copied to prevent messing up with parent's
+        local_parser = Parser([], libraries=self.libraries.copy(), origin=self.origin, parent=self.parent)
+        local_parser.extra_data = self.extra_data.copy()
+
+        # This allows to directly manipulate parent's tokens when calling
+        # parser.next_token() or parser.parse()
+        local_parser.tokens = self.tokens
+        local_parser.command_stack = self.command_stack
+
+        local_parser.add_library(library)
+        yield local_parser
+
+
 
 # This only matches constant *strings* (things in quotes or marked for
 # translation). Numbers are treated as variables for implementation reasons

But, sadly, I don’t think it can be properly done from the outside. It has to be accepted in Django core to be properly implemented. I personnally think it is a good QoL improvement.

I’d suggest you open a ticket on the new features repo to progress.

I’m not sure what I think of it per se. I’d confer with the various components folks to see what they’re doing.

Oh I’m sorry, I thought proposing new features happened on this forum. Has it changed since the last elections or have I been doing it wrong the entire time?

It’s new this year.

1 Like

Also, related to that, I would like to propose another evolution to the Parser to allow tags to not only render HTML, but also modify the behavior of a parent tag. Here is the use-case: when working with design systems, you may end up implementing complex components with tags. But these component may enable an infinite combination of customization. For instance, the french governement’s design system provides of custom <table> component. The component is implemented as follows:

<div class="fr-table">
  <div class="fr-table__wrapper">
    <div class="fr-table__container">
      <div class="fr-table__content">
        <table>
          <!-- The actual table HTML here-->
        </table>
      </div>
    </div>
  </div>
</div>

So the table is enclosed within 3 levels of <div>. In such circumstances, you may be tempted to hide that comlexity behind a custom {% dsfr_table %}:

{% dsfr_table %}
    <caption> Titre du tableau (caption) </caption>
    <thead>
        …
    </thead>
{% end_dsfr_table %}

But then the 3 levels of <div> containers are rendered inaccessible and it may get in the way of implementing a custom behavior with frameworks like htmx and Stimulus because you can’t add HTML attributes to these containers.

To solve this, you may be tempted toj just pass a dictionnary of HTML attributes to {% dsfr_table %}. But it’s impossible to declare dictionnaries in Django templates and impractical to declare them in the View or the Form because it breaks locality and makes the code more obscure.

My solution would be to enable a new template pattern where you can use subtags to configure a parent tag. For instance:

{% dsfr_table %}
    {% wrapperattrs class="my-css-class" data_controller="my-stimulus-controller" %}
    {% containerattrs class="container-class" data_my_stimulus_controller_target="container" %}
    {% contentattrs class="content-class" data_my_stimulus_controller_target="content" %}
{% end_dsfr_table %}

Here {% wrapperattrs %}, {% containerattrs %} and {% contentattrs %}, are not used to render any HTML at any point but rather to pass additionnal data to {% dsfr_table %}. In a way, it works a bit like the builder pattern.

I have in mind a proposition to implement as an ergonomic API to users but I get it may be controversial and may need discussion.

Should I open a DEP for this of a new ticket on django/new-features would suffice?

Have you seen django-components (and similar packages like Slippers)? They seem to be targeting this kind of thing directly.

I was aware of Slippers but I just discovered django-components thanks to you. However, I’m working on a third-party library and it sounds like a good practice to reduce dependencies. Especially sicne Slippers and django-components both make opiniated choices on syntax and structure. And they both require to be added to INSTALLED_APPS.

While I could copy some parts of either libraries into the project I work on, it sounded like a good idea to have a solution directly integrated into Django so it benefits to everyone.

Perhaps…

Equally, it may be that it’s a overly complex use-case that has no real place in the core DTL, which is designed to be simple, and has done that very well for the last 20 years.

There are already people working in this space in the ecosystem. There are several other options beyond the ones mentioned already.

The django-bird site has a nice list:

I’d be inclined to work with these folks to push forwards the state of the art here. :person_shrugging:

I’m not fixated on that particular solution. It can also go in the direction of one of the many component libraries you mentioned. In the propositionI opened on new-features, it was mentioned that the proposition looks like slots. And I agree, it may very well be slots. The DTL can already do that, I’m just proposing some form of syntactic sugar.