Skip to content

Instantly share code, notes, and snippets.

@pvanliefland
Last active July 12, 2021 16:44
Show Gist options
  • Save pvanliefland/bb676a906b900c7e29e46b474038a6ca to your computer and use it in GitHub Desktop.
Save pvanliefland/bb676a906b900c7e29e46b474038a6ca to your computer and use it in GitHub Desktop.
Django embed template tag

embed template tag

Rationale

The issue

When working on a web application, you may have, on different screens, a variable number of similar components with minor differences. A "card" component is a good example: every card has a wrapper section element, a title, and an arbitrary content wrapped inside a div element.

Consider the following simplified HTML example of an application :

<body>
  <section class="card">
    <h1>Stats</h1>
    <div class="card-content">
      <ul>
        <li>A stat</li>
        ...
      </ul>
    </div>
  </section>
  <section class="card">
    <h1>Activity</h1>
    <div class="card-content">
      <h2>Yesterday</h2>
      <p>All my troubles seem so far away</p>
    </div>
  </section>
</body>

If you have dozens of such "cards" in your application, you will want to create a partial template for it in order to avoid code duplication. Here is how such card_section.html partial would look like:

<section class="card">
    <h1>{% block title %}Default title{% endblock %}</h1>
    <div>{% block content %}Default content{% endblock %}</div>
</section>

Now, let's see how you can re-use this partial in your code.

Using extends and include

Today, here is how you would do that using Django built-in tags:

  1. First, extends the card_section.html partial for every type of card:

In stats_card_section.html:

{% extends "card_section.html" %}
{% block title %}Stats{% endblock %}
{% block content %}
  <ul>
    <li>A stat</li>
    ...
  </ul>
{% endblock %}

In activity_card_section.html:

{% extends "card_section.html" %}
{% block tititle %}Activity{% endblock %}
{% block content %}
  <h2>Yesterday</h2>
  <p>All my troubles seem so far away</p>
{% endblock %}
  1. Then, include the extended partials:
 <body>
   {% include "stats_card_section.html" %}
   {% include "activity_card_section.html" %}
 </body>

This will work, but it presents the following drawbacks:

  • It requires the creation of a lot of "extended" partials to override the title and content blocks
  • Often, these extended templates will only be used once (they re created to override the title and the content but are not meant to be re-used)

Proposal: using embed and slot

Now, imagine a new tag callled embed, that allows you to include a template and override its slots (node similar to blocks) at the same time:

<body>
  {% embed "section.html" %}
    {% slot title %}Stats{% endslot %}
    {% slot content %}
       <ul>
          <li>A stat</li>
          ...
      </ul> 
    {% endslot %}
  {% endembed %}
  {% embed "section.html" %}
    {% slot title %}Activity{% endslot %}
    {% slot content %}
       <h2>Yesterday</h2>
       <p>All my troubles seem so far away</p>
    {% endslot %}
  {% endembed %}
</body>

This approach favors composition over inheritance: no need to create one "extended partial" by type of card.

Is is inspired by the embed tag from the Twig template library (https://twig.symfony.com/doc/3.x/tags/embed.html).

Alternative: adapting include

A reasonable alternative would be to adapt include so that it can be used in two fashions:

  1. Combined with endinclude, it would allow you to override blocks (similar behaviour to the proposed embed tag
  2. As a simple tag like today, without endinclude (current behaviour)

Implementation

Given the similarities between this embed tag and the existing include built-in tag, I have tried to reuse as much code as possible, by copying code from IncludeNode and do_include.

I tried using block tags inside embed tags, but block nodes are very specific and have been designed with (possibly multiple) inheritance in mind, and reusing them would require a lot of hacky workarounds. Instead, I have implemented a slot tag with a much simpler implementation.

Depending on the complexity, using block tags is still an option but would require more research.

As mentioned above, a susbtantial amount of code has been copied from IncludeNode and do_include. If this shoud lead to a PR against Django core, copied code could be encapsulated in base classes / helper functions to avoid duplication.

import string
from django import template as django_template
from django.template.base import Node, TextNode, token_kwargs
from django.template.exceptions import TemplateSyntaxError
from django.template.loader_tags import construct_relative_path
register = django_template.Library()
def is_valid_embedded_node(node):
if isinstance(node, TextNode):
return node.s.translate(({ord(c): None for c in string.whitespace})) == ""
elif isinstance(node, SlotNode):
return True
return False
SLOT_CONTEXT_KEY_PREFIX = "slot_context_"
class EmbedNode(Node):
"""Embedding a template is a lot like including it, with a few differences:
- We accept a node list: the {% slot %} nodes found between {% embed %} and {% endembed %}
- As slots can be overridden, we need to store slot values in the context
"""
def __init__(self, template, nodelist, extra_context=None, isolated_context=False):
self.template = template
self.nodelist = nodelist
self.extra_context = extra_context or {}
self.isolated_context = isolated_context
def render(self, context):
"""Heavily inspired/borrowed from IncludeNode.render()"""
# Exact copy from IncludeNode.render()
template = self.template.resolve(context)
# Does this quack like a Template?
if not callable(getattr(template, "render", None)):
# If not, try the cache and select_template().
template_name = template or ()
if isinstance(template_name, str):
template_name = (template_name,)
else:
template_name = tuple(template_name)
cache = context.render_context.dicts[0].setdefault(self, {})
template = cache.get(template_name)
if template is None:
template = context.template.engine.select_template(template_name)
cache[template_name] = template
# Use the base.Template of a backends.django.Template.
elif hasattr(template, "template"):
template = template.template
values = {
name: var.resolve(context) for name, var in self.extra_context.items()
}
# End exact copy from IncludeNode.render()
# First, make sure that we only have valid slot nodes in the node list or empty text nodes as children
try:
invalid_node = next(
n for n in self.nodelist if not (is_valid_embedded_node(n))
)
raise TemplateSyntaxError(
'Invalid node "%s" found within the embed tag (only "Slot" nodes are allowed)'
% invalid_node.__class__.__name__
)
except StopIteration:
slot_nodes = [n for n in self.nodelist if isinstance(n, SlotNode)]
# Finally, render the node, taking isolated_context into account
# (This is similar to IncludeNode.render() but we also need to store overridden slot values in th context)
embedded_context = context.new() if self.isolated_context else context
with embedded_context.push(values):
slot_values = {
SLOT_CONTEXT_KEY_PREFIX + n.name: n.render(embedded_context)
for n in slot_nodes
}
with context.push(**slot_values):
return template.render(context)
class SlotNode(Node):
"""Slot nodes are simple nodes meant to be defined in embedded templates, and overridden at embed time."""
def __init__(self, name, nodelist):
self.name = name
self.nodelist = nodelist
def render(self, context):
# If slot has been overridden, fetch its rendered value from the context
if SLOT_CONTEXT_KEY_PREFIX + self.name in context:
return context[SLOT_CONTEXT_KEY_PREFIX + self.name]
# Otherwise, render default slot content
return self.nodelist.render(context)
@register.tag("embed")
def do_embed(parser, token):
"""{% embed %} template tag. Allows to include a template and optionally override its slots, in a similar fashion
to block tags in extended templates.
Example::
{% embed 'section.html' %}
{% slot title %}<h1>Title</h1>{% endslot %}
{% slot content %}<p>Content</p>{% endslot %}
{% endembed %}
You may use the ``only`` argument and keyword arguments using ``with`` like when using ``{% include %}``
"""
# Exact copy from do_include_node()
bits = token.split_contents()
if len(bits) < 2:
raise TemplateSyntaxError(
"%r tag takes at least one argument: the name of the template to "
"be included." % bits[0]
)
options = {}
remaining_bits = bits[2:]
while remaining_bits:
option = remaining_bits.pop(0)
if option in options:
raise TemplateSyntaxError(
"The %r option was specified more " "than once." % option
)
if option == "with":
value = token_kwargs(remaining_bits, parser, support_legacy=False)
if not value:
raise TemplateSyntaxError(
'"with" in %r tag needs at least ' "one keyword argument." % bits[0]
)
elif option == "only":
value = True
else:
raise TemplateSyntaxError(
"Unknown argument for %r tag: %r." % (bits[0], option)
)
options[option] = value
isolated_context = options.get("only", False)
namemap = options.get("with", {})
bits[1] = construct_relative_path(parser.origin.template_name, bits[1])
# End exact copy from do_include_node()
nodelist = parser.parse(("endembed",))
parser.delete_first_token()
return EmbedNode(
parser.compile_filter(bits[1]),
nodelist,
extra_context=namemap,
isolated_context=isolated_context,
)
@register.tag("slot")
def do_slot(parser, token):
"""{% slot %} template tag.
Example::
{% embed 'section.html' %}
{% slot title %}<h1>Title</h1>{% endslot %}
{% slot content %}<p>Content</p>{% endslot %}
{% endembed %}
"""
nodelist = parser.parse(("endslot",))
parser.delete_first_token()
bits = token.split_contents()
if len(bits) < 2:
raise TemplateSyntaxError(
"%r tag takes at least one argument: the name of the template to "
"be embedded." % bits[0]
)
return SlotNode(bits[1], nodelist)
{"mode":"full","isActive":false}
{% load embed %}
<section>
<h1>{% slot title %}Default title{% endslot title %}</h1>
<div>{% slot content %}Default content{% endslot %}</div>
</section>
from django.test import SimpleTestCase
from django.template import Context, Template, Engine
import pathlib
from hexa.core.string import remove_whitespace
ENGINE = Engine.get_default()
ENGINE.dirs.append(pathlib.Path(__file__).parent.absolute() / pathlib.Path("templates"))
class TemplatetagsTest(SimpleTestCase):
def assertTemplateContentEqual(self, first, second):
return self.assertEqual(remove_whitespace(first), remove_whitespace(second))
def test_embed_simple(self):
"""Simplest case, two subsequent embeds on a template that does not extend anything"""
context = Context(
{"title_1": "First Embedded Title", "title_2": "Second Embedded Title"}
)
template_to_render = Template(
"""
{% load embed %}
{% embed "section.html" %}
{% slot title %}{{ title_1 }}{% endslot %}
{% slot content %}<p>Some content</p>{% endslot %}
{% endembed %}
{% embed "section.html" %}
{% slot title %}{{ title_2 }}{% endslot %}
{% slot content %}<p>Another content</p>{% endslot %}
{% endembed %}
""",
engine=ENGINE,
)
rendered_template = template_to_render.render(context)
self.assertTemplateContentEqual(
"""
<section>
<h1>First Embedded Title</h1>
<div>
<p>Some content</p>
</div>
</section>
<section>
<h1>Second Embedded Title</h1>
<div>
<p>Another content</p>
</div>
</section>
""",
rendered_template,
)
def test_embed_extends(self):
"""Make sure that embed plays well with extends."""
context = Context({"title": "Embedded Title"})
template_to_render = Template(
"""
{% extends "base.html" %}
{% load embed %}
{% block page_title %}Extended Title{% endblock %}
{% block body %}
{% embed "section.html" %}
{% slot title %}{{ title }}{% endslot %}
{% slot content %}<p>Content</p>{% endslot %}
{% endembed %}
{% endblock %}
""",
engine=ENGINE,
)
rendered_template = template_to_render.render(context)
self.assertTemplateContentEqual(
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Extended Title</title>
</head>
<body>
<section>
<h1>Embedded Title</h1>
<div>
<p>Content</p>
</div>
</section>
</body>
</html>
""",
rendered_template,
)
def test_embed_isolated(self):
template_to_render = Template(
"""
{% load embed %}
{% embed "section.html" with title="Extra Title" only %}
{% slot title %}Extra Title{% endslot %}
{% slot content %}<p>Content{{ context_string }}</p>{% endslot %}
{% endembed %}
""",
engine=ENGINE,
)
rendered_template = template_to_render.render(
Context({"context_string": " (context string)"})
)
self.assertTemplateContentEqual(
"""
<section>
<h1>Extra Title</h1>
<div>
<p>Content</p>
</div>
</section>
""",
rendered_template,
)
def test_embed_extra_context(self):
template_to_render = Template(
"""
{% load embed %}
{% embed "section.html" with title="Extra Title" %}
{% slot title %}{{ title }}{% endslot %}
{% slot content %}<p>Content{{ context_string }}</p>{% endslot content %}
{% endembed %}
""",
engine=ENGINE,
)
rendered_template = template_to_render.render(
Context({"context_string": " (context string)"})
)
self.assertTemplateContentEqual(
"""
<section>
<h1>Extra Title</h1>
<div>
<p>Content (context string)</p>
</div>
</section>
""",
rendered_template,
)
{"mode":"full","isActive":false}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment