Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: loader tags compatibility #468

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions docs/slots_and_blocks.md
Original file line number Diff line number Diff line change
@@ -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
<div>
hello
{% slot "body" %}{% endslot %}
</div>
```

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
<div>
hello
123
</div>
```

2. Slot and block

So if you have a template `abc.html` like so:

```django
<div>
hello
{% block inner %}
1
{% slot "body" %}
2
{% endslot %}
{% endblock %}
</div>
```

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
<div>hello 1 XYZ</div>
```

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
<div>hello 1 XYZ</div>
```

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 %}
```
8 changes: 8 additions & 0 deletions src/django_components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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
Expand Down Expand Up @@ -291,6 +292,7 @@ def render(
context_data = {}

slots, resolved_fills = resolve_slots(
context,
template,
component_name=self.registered_name,
context_data=context_data,
Expand Down Expand Up @@ -402,6 +404,12 @@ def render(self, context: Context) -> str:
# 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)

Expand Down
61 changes: 58 additions & 3 deletions src/django_components/node.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To resolve the parent Template referenced via {% extends %} tag, we needed to pass it a Context. That's why much of the non-test changes is adding a context argument to functions

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.
"""
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it says above, this function is a copy from Django's codebase

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
12 changes: 8 additions & 4 deletions src/django_components/slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -374,7 +378,7 @@ def on_node(entry: NodeTraverse) -> None:
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(
Expand Down
17 changes: 17 additions & 0 deletions tests/templates/block_in_slot_in_component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% load component_tags %}
<!DOCTYPE html>
<html lang="en">
<body>
{% component "slotted_component" %}
{% fill "header" %}{% endfill %}
{% fill "main" %}
{% slot "body" %}
Helloodiddoo
{% block inner %}
Default inner
{% endblock %}
{% endslot %}
{% endfill %}
{% endcomponent %}
</body>
</html>
13 changes: 13 additions & 0 deletions tests/templates/block_inside_component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% load component_tags %}
<!DOCTYPE html>
<html lang="en">
<body>
{% component "slotted_component" %}
{% fill "header" %}{% endfill %}
{% fill "main" %}
{% block body %}
{% endblock %}
{% endfill %}
{% endcomponent %}
</body>
</html>
18 changes: 9 additions & 9 deletions tests/templates/extendable_template_with_blocks.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{% load component_tags %}
<!DOCTYPE html>
<html lang="en">
<body>
<main role="main">
<div class='container main-container'>
{% block body %}
{% endblock %}
</div>
</main>
</body>
</html>
<body>
<main role="main">
<div class='container main-container'>
{% block body %}
{% endblock %}
</div>
</main>
</body>
</html>
Loading