diff --git a/docs/slots_and_blocks.md b/docs/slots_and_blocks.md
new file mode 100644
index 00000000..53102ca4
--- /dev/null
+++ b/docs/slots_and_blocks.md
@@ -0,0 +1,166 @@
+# Using `slot` and `block` tags
+
+1. First let's clarify how `include` and `extends` tags work inside components.
+ So when component template includes `include` or `extends` tags, it's as if the "included"
+ template was inlined. So if the "included" template contains `slot` tags, then the component
+ uses those slots.
+
+ So if you have a template `abc.html`:
+ ```django
+
+ hello
+ {% slot "body" %}{% endslot %}
+
+ ```
+
+ And components that make use of `abc.html` via `include` or `extends`:
+ ```py
+ @component.register("my_comp_extends")
+ class MyCompWithExtends(component.Component):
+ template = """{% extends "abc.html" %}"""
+
+ @component.register("my_comp_include")
+ class MyCompWithInclude(component.Component):
+ template = """{% include "abc.html" %}"""
+ ```
+
+ Then you can set slot fill for the slot imported via `include/extends`:
+
+ ```django
+ {% component "my_comp_extends" %}
+ {% fill "body" %}
+ 123
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ And it will render:
+ ```html
+
+ hello
+ 123
+
+ ```
+
+2. Slot and block
+
+ So if you have a template `abc.html` like so:
+
+ ```django
+
+ hello
+ {% block inner %}
+ 1
+ {% slot "body" %}
+ 2
+ {% endslot %}
+ {% endblock %}
+
+ ```
+
+ and component `my_comp`:
+
+ ```py
+ @component.register("my_comp")
+ class MyComp(component.Component):
+ template_name = "abc.html"
+ ```
+
+ Then:
+
+ 1. Since the `block` wasn't overriden, you can use the `body` slot:
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "body" %}
+ XYZ
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ And we get:
+
+ ```html
+ hello 1 XYZ
+ ```
+
+ 2. `blocks` CANNOT be overriden through the `component` tag, so something like this:
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "body" %}
+ XYZ
+ {% endfill %}
+ {% endcomponent %}
+ {% block "inner" %}
+ 456
+ {% endblock %}
+ ```
+
+ Will still render the component content just the same:
+
+ ```html
+ hello 1 XYZ
+ ```
+
+ 3. You CAN override the `block` tags of `abc.html` if my component template
+ uses `extends`. In that case, just as you would expect, the `block inner` inside
+ `abc.html` will render `OVERRIDEN`:
+
+ ````py
+ @component.register("my_comp")
+ class MyComp(component.Component):
+ template_name = """
+ {% extends "abc.html" %}
+
+ {% block inner %}
+ OVERRIDEN
+ {% endblock %}
+ """
+ ```
+
+ ````
+
+ 4. This is where it gets interesting (but still intuitive). You can insert even
+ new `slots` inside these "overriding" blocks:
+
+ ```py
+ @component.register("my_comp")
+ class MyComp(component.Component):
+ template_name = """
+ {% extends "abc.html" %}
+
+ {% load component_tags %}
+ {% block "inner" %}
+ OVERRIDEN
+ {% slot "new_slot" %}
+ hello
+ {% endslot %}
+ {% endblock %}
+ """
+ ```
+
+ And you can then pass fill for this `new_slot` when rendering the component:
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "new_slot" %}
+ XYZ
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ NOTE: Currently you can supply fills for both `new_slot` and `body` slots, and you will
+ not get an error for an invalid/unknown slot name. But since `body` slot is not rendered,
+ it just won't do anything. So this renders the same as above:
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "new_slot" %}
+ XYZ
+ {% endfill %}
+ {% fill "body" %}
+ www
+ {% endfill %}
+ {% endcomponent %}
+ ```
diff --git a/src/django_components/component.py b/src/django_components/component.py
index c5db0b27..5e4abaf5 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -9,6 +9,7 @@ from django.forms.widgets import Media, MediaDefiningClass
from django.http import HttpResponse
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
from django.template.context import Context
+from django.template.exceptions import TemplateSyntaxError
from django.template.loader import get_template
from django.utils.html import escape
from django.utils.safestring import SafeString, mark_safe
@@ -291,6 +292,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
context_data = {}
slots, resolved_fills = resolve_slots(
+ context,
template,
component_name=self.registered_name,
context_data=context_data,
@@ -402,6 +404,12 @@ class ComponentNode(Node):
# Note that outer component context is used to resolve variables in
# fill tag.
resolved_name = fill_node.name_fexp.resolve(context)
+ if resolved_name in fill_content:
+ raise TemplateSyntaxError(
+ f"Multiple fill tags cannot target the same slot name: "
+ f"Detected duplicate fill tag name '{resolved_name}'."
+ )
+
resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name)
fill_content[resolved_name] = FillContent(fill_node.nodelist, resolved_fill_alias)
diff --git a/src/django_components/node.py b/src/django_components/node.py
index a89dfd5a..7d77bfa6 100644
--- a/src/django_components/node.py
+++ b/src/django_components/node.py
@@ -1,7 +1,9 @@
from typing import Callable, List, NamedTuple, Optional
+from django.template import Context, Template
from django.template.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode
+from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
def nodelist_has_content(nodelist: NodeList) -> bool:
@@ -20,26 +22,79 @@ class NodeTraverse(NamedTuple):
parent: Optional["NodeTraverse"]
-def walk_nodelist(nodes: NodeList, callback: Callable[[Node], Optional[str]]) -> None:
+def walk_nodelist(
+ nodes: NodeList,
+ callback: Callable[[Node], Optional[str]],
+ context: Optional[Context] = None,
+) -> None:
"""Recursively walk a NodeList, calling `callback` for each Node."""
node_queue: List[NodeTraverse] = [NodeTraverse(node=node, parent=None) for node in nodes]
while len(node_queue):
traverse = node_queue.pop()
callback(traverse)
- child_nodes = get_node_children(traverse.node)
+ child_nodes = get_node_children(traverse.node, context)
child_traverses = [NodeTraverse(node=child_node, parent=traverse) for child_node in child_nodes]
node_queue.extend(child_traverses)
-def get_node_children(node: Node) -> NodeList:
+def get_node_children(node: Node, context: Optional[Context] = None) -> NodeList:
"""
Get child Nodes from Node's nodelist atribute.
This function is taken from `get_nodes_by_type` method of `django.template.base.Node`.
"""
+ # Special case - {% extends %} tag - Load the template and go deeper
+ if isinstance(node, ExtendsNode):
+ # NOTE: When {% extends %} node is being parsed, it collects all remaining template
+ # under node.nodelist.
+ # Hence, when we come across ExtendsNode in the template, we:
+ # 1. Go over all nodes in the template using `node.nodelist`
+ # 2. Go over all nodes in the "parent" template, via `node.get_parent`
+ nodes = NodeList()
+ nodes.extend(node.nodelist)
+ template = node.get_parent(context)
+ nodes.extend(template.nodelist)
+ return nodes
+
+ # Special case - {% include %} tag - Load the template and go deeper
+ elif isinstance(node, IncludeNode):
+ template = get_template_for_include_node(node, context)
+ return template.nodelist
+
nodes = NodeList()
for attr in node.child_nodelists:
nodelist = getattr(node, attr, [])
if nodelist:
nodes.extend(nodelist)
return nodes
+
+
+def get_template_for_include_node(include_node: IncludeNode, context: Context) -> Template:
+ """
+ This snippet is taken directly from `IncludeNode.render()`. Unfortunately the
+ render logic doesn't separate out template loading logic from rendering, so we
+ have to copy the method.
+ """
+ template = include_node.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 = (
+ construct_relative_path(
+ include_node.origin.template_name,
+ template_name,
+ ),
+ )
+ else:
+ template_name = tuple(template_name)
+ cache = context.render_context.dicts[0].setdefault(include_node, {})
+ 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
+ return template
diff --git a/src/django_components/slots.py b/src/django_components/slots.py
index 9b3bd136..874e8a67 100644
--- a/src/django_components/slots.py
+++ b/src/django_components/slots.py
@@ -258,15 +258,18 @@ def _try_parse_as_named_fill_tag_set(
ComponentNodeCls: Type[Node],
) -> List[FillNode]:
result = []
- seen_name_fexps: Set[FilterExpression] = set()
+ seen_name_fexps: Set[str] = set()
for node in nodelist:
if isinstance(node, FillNode):
- if node.name_fexp in seen_name_fexps:
+ # Check that, after we've resolved the names, that there's still no duplicates.
+ # This makes sure that if two different variables refer to same string, we detect
+ # them.
+ if node.name_fexp.token in seen_name_fexps:
raise TemplateSyntaxError(
f"Multiple fill tags cannot target the same slot name: "
f"Detected duplicate fill tag name '{node.name_fexp}'."
)
- seen_name_fexps.add(node.name_fexp)
+ seen_name_fexps.add(node.name_fexp.token)
result.append(node)
elif isinstance(node, CommentNode):
pass
@@ -308,6 +311,7 @@ def _try_parse_as_default_fill(
def resolve_slots(
+ context: Context,
template: Template,
component_name: Optional[str],
context_data: Dict[str, Any],
@@ -374,7 +378,7 @@ def resolve_slots(
slot_children[parent_slot_id].append(node.node_id)
break
- walk_nodelist(template.nodelist, on_node)
+ walk_nodelist(template.nodelist, on_node, context)
# 3. Figure out which slot the default/implicit fill belongs to
slot_fills = _resolve_default_slot(
diff --git a/tests/templates/block_in_slot_in_component.html b/tests/templates/block_in_slot_in_component.html
new file mode 100644
index 00000000..3c264f17
--- /dev/null
+++ b/tests/templates/block_in_slot_in_component.html
@@ -0,0 +1,17 @@
+{% load component_tags %}
+
+
+
+ {% component "slotted_component" %}
+ {% fill "header" %}{% endfill %}
+ {% fill "main" %}
+ {% slot "body" %}
+ Helloodiddoo
+ {% block inner %}
+ Default inner
+ {% endblock %}
+ {% endslot %}
+ {% endfill %}
+ {% endcomponent %}
+
+
diff --git a/tests/templates/block_inside_component.html b/tests/templates/block_inside_component.html
new file mode 100644
index 00000000..c70504ba
--- /dev/null
+++ b/tests/templates/block_inside_component.html
@@ -0,0 +1,13 @@
+{% load component_tags %}
+
+
+
+ {% component "slotted_component" %}
+ {% fill "header" %}{% endfill %}
+ {% fill "main" %}
+ {% block body %}
+ {% endblock %}
+ {% endfill %}
+ {% endcomponent %}
+
+
diff --git a/tests/templates/extendable_template_with_blocks.html b/tests/templates/extendable_template_with_blocks.html
index 9cbfb9ee..01a2d3c3 100644
--- a/tests/templates/extendable_template_with_blocks.html
+++ b/tests/templates/extendable_template_with_blocks.html
@@ -1,12 +1,12 @@
{% load component_tags %}
-
-
-
- {% block body %}
- {% endblock %}
-
-
-
-
\ No newline at end of file
+
+
+
+ {% block body %}
+ {% endblock %}
+
+
+
+