mirror of
https://github.com/django-components/django-components.git
synced 2025-08-08 08:17:59 +00:00
refactor: move slots logic to own file (#416)
* refactor: move slots logic to own file * docs: add readme section for how slots work * chore: isort * refactor: fix type alias * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor: fix isort issues again --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
ae5cda9f72
commit
9aa446acc8
5 changed files with 587 additions and 527 deletions
15
README.md
15
README.md
|
@ -858,3 +858,18 @@ Use the [sampleproject](./sampleproject/) demo project to validate the changes:
|
||||||
Once the server is up, it should be available at <http://127.0.0.1:8000>.
|
Once the server is up, it should be available at <http://127.0.0.1:8000>.
|
||||||
|
|
||||||
To display individual components, add them to the `urls.py`, like in the case of <http://127.0.0.1:8000/greeting>
|
To display individual components, add them to the `urls.py`, like in the case of <http://127.0.0.1:8000/greeting>
|
||||||
|
|
||||||
|
## Development guides
|
||||||
|
|
||||||
|
### Slot rendering flow
|
||||||
|
|
||||||
|
1. Flow starts when a template string is being parsed into Django Template instance.
|
||||||
|
|
||||||
|
2. When a `{% component %}` template tag is encountered, its body is searched for all `{% fill %}` nodes (explicit or implicit). and this is attached to the created `ComponentNode`.
|
||||||
|
|
||||||
|
See the implementation of `component` template tag for details.
|
||||||
|
|
||||||
|
3. Template rendering is a separate action from template parsing. When the template is being rendered, the `ComponentNode` creates an instance of the `Component` class and passes it the slot fills.
|
||||||
|
|
||||||
|
It's at this point when `Component.render` is called, and the slots are
|
||||||
|
rendered.
|
||||||
|
|
|
@ -1,30 +1,13 @@
|
||||||
import difflib
|
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from collections import ChainMap
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import (
|
from typing import Any, ClassVar, Dict, Iterable, List, Mapping, MutableMapping, Optional, Tuple, Type, Union
|
||||||
Any,
|
|
||||||
ClassVar,
|
|
||||||
Dict,
|
|
||||||
Iterable,
|
|
||||||
List,
|
|
||||||
Mapping,
|
|
||||||
MutableMapping,
|
|
||||||
Optional,
|
|
||||||
Set,
|
|
||||||
Tuple,
|
|
||||||
Type,
|
|
||||||
TypeVar,
|
|
||||||
Union,
|
|
||||||
)
|
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.forms.widgets import Media, MediaDefiningClass
|
from django.forms.widgets import Media, MediaDefiningClass
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.template.base import NodeList, Template, TextNode
|
from django.template.base import FilterExpression, Node, NodeList, Template, TextNode
|
||||||
from django.template.context import Context
|
from django.template.context import Context
|
||||||
from django.template.exceptions import TemplateSyntaxError
|
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
@ -34,31 +17,30 @@ from django.views import View
|
||||||
# Defining them here made little sense, since 1) component_tags.py and component.py
|
# Defining them here made little sense, since 1) component_tags.py and component.py
|
||||||
# rely on them equally, and 2) it made it difficult to avoid circularity in the
|
# rely on them equally, and 2) it made it difficult to avoid circularity in the
|
||||||
# way the two modules depend on one another.
|
# way the two modules depend on one another.
|
||||||
from django_components.component_registry import ( # NOQA
|
from django_components.component_registry import registry # NOQA
|
||||||
AlreadyRegistered,
|
from django_components.component_registry import AlreadyRegistered, ComponentRegistry, NotRegistered, register # NOQA
|
||||||
ComponentRegistry,
|
|
||||||
NotRegistered,
|
|
||||||
register,
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
from django_components.logger import logger
|
from django_components.logger import logger
|
||||||
from django_components.templatetags.component_tags import (
|
from django_components.middleware import is_dependency_middleware_active
|
||||||
FILLED_SLOTS_CONTENT_CONTEXT_KEY,
|
from django_components.slots import (
|
||||||
DefaultFillContent,
|
DefaultFillContent,
|
||||||
FillContent,
|
ImplicitFillNode,
|
||||||
FilledSlotsContext,
|
|
||||||
IfSlotFilledConditionBranchNode,
|
|
||||||
NamedFillContent,
|
NamedFillContent,
|
||||||
|
NamedFillNode,
|
||||||
SlotName,
|
SlotName,
|
||||||
SlotNode,
|
render_component_template_with_slots,
|
||||||
)
|
)
|
||||||
from django_components.utils import search
|
from django_components.utils import search
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
||||||
|
|
||||||
|
|
||||||
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
class SimplifiedInterfaceMediaDefiningClass(MediaDefiningClass):
|
||||||
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
|
||||||
|
# NOTE: Skip template/media file resolution when then Component class ITSELF
|
||||||
|
# is being created.
|
||||||
|
if "__module__" in attrs and attrs["__module__"] == "django_components.component":
|
||||||
|
return super().__new__(mcs, name, bases, attrs)
|
||||||
|
|
||||||
if "Media" in attrs:
|
if "Media" in attrs:
|
||||||
media: Component.Media = attrs["Media"]
|
media: Component.Media = attrs["Media"]
|
||||||
|
|
||||||
|
@ -270,13 +252,7 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
if slots_data:
|
if slots_data:
|
||||||
self._fill_slots(slots_data, escape_slots_content)
|
self._fill_slots(slots_data, escape_slots_content)
|
||||||
|
|
||||||
prev_filled_slots_context: Optional[FilledSlotsContext] = context.get(FILLED_SLOTS_CONTENT_CONTEXT_KEY)
|
return render_component_template_with_slots(template, context, self.fill_content, self.registered_name)
|
||||||
updated_filled_slots_context = self._process_template_and_update_filled_slot_context(
|
|
||||||
template,
|
|
||||||
prev_filled_slots_context,
|
|
||||||
)
|
|
||||||
with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}):
|
|
||||||
return template.render(context)
|
|
||||||
|
|
||||||
def render_to_response(
|
def render_to_response(
|
||||||
self,
|
self,
|
||||||
|
@ -307,105 +283,78 @@ class Component(View, metaclass=SimplifiedInterfaceMediaDefiningClass):
|
||||||
for (slot_name, content) in slots_data.items()
|
for (slot_name, content) in slots_data.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
def _process_template_and_update_filled_slot_context(
|
|
||||||
|
class ComponentNode(Node):
|
||||||
|
"""Django.template.Node subclass that renders a django-components component"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
self,
|
self,
|
||||||
template: Template,
|
name_fexp: FilterExpression,
|
||||||
slots_context: Optional[FilledSlotsContext],
|
context_args: List[FilterExpression],
|
||||||
) -> FilledSlotsContext:
|
context_kwargs: Mapping[str, FilterExpression],
|
||||||
if isinstance(self.fill_content, NodeList):
|
isolated_context: bool = False,
|
||||||
default_fill_content = (self.fill_content, None)
|
fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (),
|
||||||
named_fills_content = {}
|
) -> None:
|
||||||
|
self.name_fexp = name_fexp
|
||||||
|
self.context_args = context_args or []
|
||||||
|
self.context_kwargs = context_kwargs or {}
|
||||||
|
self.isolated_context = isolated_context
|
||||||
|
self.fill_nodes = fill_nodes
|
||||||
|
self.nodelist = self._create_nodelist(fill_nodes)
|
||||||
|
|
||||||
|
def _create_nodelist(self, fill_nodes: Union[Iterable[Node], ImplicitFillNode]) -> NodeList:
|
||||||
|
if isinstance(fill_nodes, ImplicitFillNode):
|
||||||
|
return NodeList([fill_nodes])
|
||||||
else:
|
else:
|
||||||
default_fill_content = None
|
return NodeList(fill_nodes)
|
||||||
named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(self.fill_content)}
|
|
||||||
|
|
||||||
# If value is `None`, then slot is unfilled.
|
def __repr__(self) -> str:
|
||||||
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
|
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||||
default_slot_encountered: bool = False
|
self.name_fexp,
|
||||||
required_slot_names: Set[str] = set()
|
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
|
||||||
|
)
|
||||||
|
|
||||||
for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore
|
def render(self, context: Context) -> str:
|
||||||
if isinstance(node, SlotNode):
|
resolved_component_name = self.name_fexp.resolve(context)
|
||||||
# Give slot node knowledge of its parent template.
|
component_cls: Type[Component] = registry.get(resolved_component_name)
|
||||||
node.template = template
|
|
||||||
slot_name = node.name
|
|
||||||
if slot_name in slot_name2fill_content:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Slot name '{slot_name}' re-used within the same template. "
|
|
||||||
f"Slot names must be unique."
|
|
||||||
f"To fix, check template '{template.name}' "
|
|
||||||
f"of component '{self.registered_name}'."
|
|
||||||
)
|
|
||||||
content_data: Optional[FillContent] = None # `None` -> unfilled
|
|
||||||
if node.is_required:
|
|
||||||
required_slot_names.add(node.name)
|
|
||||||
if node.is_default:
|
|
||||||
if default_slot_encountered:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
"Only one component slot may be marked as 'default'. "
|
|
||||||
f"To fix, check template '{template.name}' "
|
|
||||||
f"of component '{self.registered_name}'."
|
|
||||||
)
|
|
||||||
content_data = default_fill_content
|
|
||||||
default_slot_encountered = True
|
|
||||||
if not content_data:
|
|
||||||
content_data = named_fills_content.get(node.name)
|
|
||||||
slot_name2fill_content[slot_name] = content_data
|
|
||||||
elif isinstance(node, IfSlotFilledConditionBranchNode):
|
|
||||||
node.template = template
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Node of {type(node).__name__} does not require linking.")
|
|
||||||
|
|
||||||
# Check: Only component templates that include a 'default' slot
|
# Resolve FilterExpressions and Variables that were passed as args to the
|
||||||
# can be invoked with implicit filling.
|
# component, then call component's context method
|
||||||
if default_fill_content and not default_slot_encountered:
|
# to get values to insert into the context
|
||||||
raise TemplateSyntaxError(
|
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
|
||||||
f"Component '{self.registered_name}' passed default fill content '{default_fill_content}'"
|
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
|
||||||
f"(i.e. without explicit 'fill' tag), "
|
|
||||||
f"even though none of its slots is marked as 'default'."
|
|
||||||
)
|
|
||||||
|
|
||||||
unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None}
|
if isinstance(self.fill_nodes, ImplicitFillNode):
|
||||||
unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys()
|
fill_content = self.fill_nodes.nodelist
|
||||||
|
|
||||||
# Check that 'required' slots are filled.
|
|
||||||
for slot_name in unfilled_slots:
|
|
||||||
if slot_name in required_slot_names:
|
|
||||||
msg = (
|
|
||||||
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
|
|
||||||
f"yet no fill is provided. Check template.'"
|
|
||||||
)
|
|
||||||
if unmatched_fills:
|
|
||||||
msg = f"{msg}\nPossible typo in unresolvable fills: {unmatched_fills}."
|
|
||||||
raise TemplateSyntaxError(msg)
|
|
||||||
|
|
||||||
# Check that all fills can be matched to a slot on the component template.
|
|
||||||
# To help with easy-to-overlook typos, we fuzzy match unresolvable fills to
|
|
||||||
# those slots for which no matching fill was encountered. In the event of
|
|
||||||
# a close match, we include the name of the matched unfilled slot as a
|
|
||||||
# hint in the error message.
|
|
||||||
#
|
|
||||||
# Note: Finding a good `cutoff` value may require further trial-and-error.
|
|
||||||
# Higher values make matching stricter. This is probably preferable, as it
|
|
||||||
# reduces false positives.
|
|
||||||
for fill_name in unmatched_fills:
|
|
||||||
fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7)
|
|
||||||
msg = (
|
|
||||||
f"Component '{self.registered_name}' passed fill "
|
|
||||||
f"that refers to undefined slot: '{fill_name}'."
|
|
||||||
f"\nUnfilled slot names are: {sorted(unfilled_slots)}."
|
|
||||||
)
|
|
||||||
if fuzzy_slot_name_matches:
|
|
||||||
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
|
|
||||||
raise TemplateSyntaxError(msg)
|
|
||||||
|
|
||||||
# Return updated FILLED_SLOTS_CONTEXT map
|
|
||||||
filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
|
|
||||||
(slot_name, template): content_data
|
|
||||||
for slot_name, content_data in slot_name2fill_content.items()
|
|
||||||
if content_data # Slots whose content is None (i.e. unfilled) are dropped.
|
|
||||||
}
|
|
||||||
if slots_context is not None:
|
|
||||||
return slots_context.new_child(filled_slots_map)
|
|
||||||
else:
|
else:
|
||||||
return ChainMap(filled_slots_map)
|
fill_content = []
|
||||||
|
for fill_node in self.fill_nodes:
|
||||||
|
# Note that outer component context is used to resolve variables in
|
||||||
|
# fill tag.
|
||||||
|
resolved_name = fill_node.name_fexp.resolve(context)
|
||||||
|
resolved_fill_alias = fill_node.resolve_alias(context, resolved_component_name)
|
||||||
|
fill_content.append((resolved_name, fill_node.nodelist, resolved_fill_alias))
|
||||||
|
|
||||||
|
component: Component = component_cls(
|
||||||
|
registered_name=resolved_component_name,
|
||||||
|
outer_context=context,
|
||||||
|
fill_content=fill_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs)
|
||||||
|
|
||||||
|
if self.isolated_context:
|
||||||
|
context = context.new()
|
||||||
|
with context.update(component_context):
|
||||||
|
rendered_component = component.render(context)
|
||||||
|
|
||||||
|
if is_dependency_middleware_active():
|
||||||
|
return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component
|
||||||
|
else:
|
||||||
|
return rendered_component
|
||||||
|
|
||||||
|
|
||||||
|
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
|
||||||
|
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
||||||
|
|
||||||
|
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
||||||
|
|
|
@ -82,3 +82,7 @@ def join_media(components: Iterable["Component"]) -> Media:
|
||||||
"""Return combined media object for iterable of components."""
|
"""Return combined media object for iterable of components."""
|
||||||
|
|
||||||
return sum([component.media for component in components], Media())
|
return sum([component.media for component in components], Media())
|
||||||
|
|
||||||
|
|
||||||
|
def is_dependency_middleware_active() -> bool:
|
||||||
|
return getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False)
|
||||||
|
|
467
src/django_components/slots.py
Normal file
467
src/django_components/slots.py
Normal file
|
@ -0,0 +1,467 @@
|
||||||
|
import difflib
|
||||||
|
import sys
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set, Tuple, Type, Union
|
||||||
|
|
||||||
|
if sys.version_info[:2] < (3, 9):
|
||||||
|
from typing import ChainMap
|
||||||
|
else:
|
||||||
|
from collections import ChainMap
|
||||||
|
|
||||||
|
if sys.version_info[:2] < (3, 10):
|
||||||
|
from typing_extensions import TypeAlias
|
||||||
|
else:
|
||||||
|
from typing import TypeAlias
|
||||||
|
|
||||||
|
from django.template import Context, Template
|
||||||
|
from django.template.base import FilterExpression, Node, NodeList, TextNode
|
||||||
|
from django.template.defaulttags import CommentNode
|
||||||
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
|
FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
|
||||||
|
|
||||||
|
# Type aliases
|
||||||
|
|
||||||
|
SlotName = str
|
||||||
|
AliasName = str
|
||||||
|
|
||||||
|
DefaultFillContent: TypeAlias = NodeList
|
||||||
|
NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
|
||||||
|
|
||||||
|
FillContent = Tuple[NodeList, Optional[AliasName]]
|
||||||
|
FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent]
|
||||||
|
|
||||||
|
|
||||||
|
class UserSlotVar:
|
||||||
|
"""
|
||||||
|
Extensible mechanism for offering 'fill' blocks in template access to properties
|
||||||
|
of parent slot.
|
||||||
|
|
||||||
|
How it works: At render time, SlotNode(s) that have been aliased in the fill tag
|
||||||
|
of the component instance create an instance of UserSlotVar. This instance is made
|
||||||
|
available to the rendering context on a key matching the slot alias (see
|
||||||
|
SlotNode.render() for implementation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, slot: "SlotNode", context: Context):
|
||||||
|
self._slot = slot
|
||||||
|
self._context = context
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default(self) -> str:
|
||||||
|
return mark_safe(self._slot.nodelist.render(self._context))
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateAwareNodeMixin:
|
||||||
|
_template: Template
|
||||||
|
|
||||||
|
@property
|
||||||
|
def template(self) -> Template:
|
||||||
|
try:
|
||||||
|
return self._template
|
||||||
|
except AttributeError:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Internal error: Instance of {type(self).__name__} was not "
|
||||||
|
"linked to Template before use in render() context."
|
||||||
|
)
|
||||||
|
|
||||||
|
@template.setter
|
||||||
|
def template(self, value: Template) -> None:
|
||||||
|
self._template = value
|
||||||
|
|
||||||
|
|
||||||
|
class SlotNode(Node, TemplateAwareNodeMixin):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
nodelist: NodeList,
|
||||||
|
is_required: bool = False,
|
||||||
|
is_default: bool = False,
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.nodelist = nodelist
|
||||||
|
self.is_required = is_required
|
||||||
|
self.is_default = is_default
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_flags(self) -> List[str]:
|
||||||
|
m = []
|
||||||
|
if self.is_required:
|
||||||
|
m.append("required")
|
||||||
|
if self.is_default:
|
||||||
|
m.append("default")
|
||||||
|
return m
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
||||||
|
|
||||||
|
def render(self, context: Context) -> SafeString:
|
||||||
|
try:
|
||||||
|
filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
||||||
|
except KeyError:
|
||||||
|
raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.")
|
||||||
|
|
||||||
|
extra_context = {}
|
||||||
|
try:
|
||||||
|
slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)]
|
||||||
|
except KeyError:
|
||||||
|
if self.is_required:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
|
||||||
|
)
|
||||||
|
nodelist = self.nodelist
|
||||||
|
else:
|
||||||
|
nodelist, alias = slot_fill_content
|
||||||
|
if alias:
|
||||||
|
if not alias.isidentifier():
|
||||||
|
raise TemplateSyntaxError()
|
||||||
|
extra_context[alias] = UserSlotVar(self, context)
|
||||||
|
|
||||||
|
with context.update(extra_context):
|
||||||
|
return nodelist.render(context)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFillNode(Node):
|
||||||
|
def __init__(self, nodelist: NodeList):
|
||||||
|
self.nodelist: NodeList = nodelist
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def render(self, context: Context) -> str:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"{% fill ... %} block cannot be rendered directly. "
|
||||||
|
"You are probably seeing this because you have used one outside "
|
||||||
|
"a {% component %} context."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NamedFillNode(BaseFillNode):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nodelist: NodeList,
|
||||||
|
name_fexp: FilterExpression,
|
||||||
|
alias_fexp: Optional[FilterExpression] = None,
|
||||||
|
):
|
||||||
|
super().__init__(nodelist)
|
||||||
|
self.name_fexp = name_fexp
|
||||||
|
self.alias_fexp = alias_fexp
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
|
||||||
|
|
||||||
|
def resolve_alias(self, context: Context, component_name: Optional[str] = None) -> Optional[str]:
|
||||||
|
if not self.alias_fexp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
resolved_alias: Optional[str] = self.alias_fexp.resolve(context)
|
||||||
|
if resolved_alias and not resolved_alias.isidentifier():
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Fill tag alias '{self.alias_fexp.var}' in component "
|
||||||
|
f"{component_name} does not resolve to "
|
||||||
|
f"a valid Python identifier. Got: '{resolved_alias}'."
|
||||||
|
)
|
||||||
|
return resolved_alias
|
||||||
|
|
||||||
|
|
||||||
|
class ImplicitFillNode(BaseFillNode):
|
||||||
|
"""
|
||||||
|
Instantiated when a `component` tag pair is passed template content that
|
||||||
|
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
|
||||||
|
as 'default'.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{type(self)} Contents: {repr(self.nodelist)}.>"
|
||||||
|
|
||||||
|
|
||||||
|
class _IfSlotFilledBranchNode(Node):
|
||||||
|
def __init__(self, nodelist: NodeList) -> None:
|
||||||
|
self.nodelist = nodelist
|
||||||
|
|
||||||
|
def render(self, context: Context) -> str:
|
||||||
|
return self.nodelist.render(context)
|
||||||
|
|
||||||
|
def evaluate(self, context: Context) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
slot_name: str,
|
||||||
|
nodelist: NodeList,
|
||||||
|
is_positive: Union[bool, None] = True,
|
||||||
|
) -> None:
|
||||||
|
self.slot_name = slot_name
|
||||||
|
self.is_positive: Optional[bool] = is_positive
|
||||||
|
super().__init__(nodelist)
|
||||||
|
|
||||||
|
def evaluate(self, context: Context) -> bool:
|
||||||
|
try:
|
||||||
|
filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
||||||
|
except KeyError:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Attempted to render {type(self).__name__} outside a Component rendering context."
|
||||||
|
)
|
||||||
|
slot_key = (self.slot_name, self.template)
|
||||||
|
is_filled = filled_slots.get(slot_key, None) is not None
|
||||||
|
# Make polarity switchable.
|
||||||
|
# i.e. if slot name is NOT filled and is_positive=False,
|
||||||
|
# then False == False -> True
|
||||||
|
return is_filled == self.is_positive
|
||||||
|
|
||||||
|
|
||||||
|
class IfSlotFilledElseBranchNode(_IfSlotFilledBranchNode):
|
||||||
|
def evaluate(self, context: Context) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class IfSlotFilledNode(Node):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
branches: List[_IfSlotFilledBranchNode],
|
||||||
|
):
|
||||||
|
self.branches = branches
|
||||||
|
self.nodelist = self._create_nodelist(branches)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.__class__.__name__}>"
|
||||||
|
|
||||||
|
def _create_nodelist(self, branches: List[_IfSlotFilledBranchNode]) -> NodeList:
|
||||||
|
return NodeList(branches)
|
||||||
|
|
||||||
|
def render(self, context: Context) -> str:
|
||||||
|
for node in self.branches:
|
||||||
|
if isinstance(node, IfSlotFilledElseBranchNode):
|
||||||
|
return node.render(context)
|
||||||
|
elif isinstance(node, IfSlotFilledConditionBranchNode):
|
||||||
|
if node.evaluate(context):
|
||||||
|
return node.render(context)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_slot_fill_nodes_from_component_nodelist(
|
||||||
|
component_nodelist: NodeList,
|
||||||
|
ComponentNodeCls: Type[Node],
|
||||||
|
) -> Union[Iterable[NamedFillNode], ImplicitFillNode]:
|
||||||
|
"""
|
||||||
|
Given a component body (`django.template.NodeList`), find all slot fills,
|
||||||
|
whether defined explicitly with `{% fill %}` or implicitly.
|
||||||
|
|
||||||
|
So if we have a component body:
|
||||||
|
```django
|
||||||
|
{% component "mycomponent" %}
|
||||||
|
{% fill "first_fill" %}
|
||||||
|
Hello!
|
||||||
|
{% endfill %}
|
||||||
|
{% fill "second_fill" %}
|
||||||
|
Hello too!
|
||||||
|
{% endfill %}
|
||||||
|
{% endcomponent %}
|
||||||
|
```
|
||||||
|
Then this function returns the nodes (`django.template.Node`) for `fill "first_fill"`
|
||||||
|
and `fill "second_fill"`.
|
||||||
|
"""
|
||||||
|
fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = []
|
||||||
|
if _block_has_content(component_nodelist):
|
||||||
|
for parse_fn in (
|
||||||
|
_try_parse_as_default_fill,
|
||||||
|
_try_parse_as_named_fill_tag_set,
|
||||||
|
):
|
||||||
|
curr_fill_nodes = parse_fn(component_nodelist, ComponentNodeCls)
|
||||||
|
if curr_fill_nodes:
|
||||||
|
fill_nodes = curr_fill_nodes
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"Illegal content passed to 'component' tag pair. "
|
||||||
|
"Possible causes: 1) Explicit 'fill' tags cannot occur alongside other "
|
||||||
|
"tags except comment tags; 2) Default (default slot-targeting) content "
|
||||||
|
"is mixed with explict 'fill' tags."
|
||||||
|
)
|
||||||
|
return fill_nodes
|
||||||
|
|
||||||
|
|
||||||
|
def _try_parse_as_named_fill_tag_set(
|
||||||
|
nodelist: NodeList,
|
||||||
|
ComponentNodeCls: Type[Node],
|
||||||
|
) -> Optional[Iterable[NamedFillNode]]:
|
||||||
|
result = []
|
||||||
|
seen_name_fexps: Set[FilterExpression] = set()
|
||||||
|
for node in nodelist:
|
||||||
|
if isinstance(node, NamedFillNode):
|
||||||
|
if node.name_fexp 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)
|
||||||
|
result.append(node)
|
||||||
|
elif isinstance(node, CommentNode):
|
||||||
|
pass
|
||||||
|
elif isinstance(node, TextNode) and node.s.isspace():
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _try_parse_as_default_fill(
|
||||||
|
nodelist: NodeList,
|
||||||
|
ComponentNodeCls: Type[Node],
|
||||||
|
) -> Optional[ImplicitFillNode]:
|
||||||
|
nodes_stack: List[Node] = list(nodelist)
|
||||||
|
while nodes_stack:
|
||||||
|
node = nodes_stack.pop()
|
||||||
|
if isinstance(node, NamedFillNode):
|
||||||
|
return None
|
||||||
|
elif isinstance(node, ComponentNodeCls):
|
||||||
|
# Stop searching here, as fill tags are permitted inside component blocks
|
||||||
|
# embedded within a default fill node.
|
||||||
|
continue
|
||||||
|
for nodelist_attr_name in node.child_nodelists:
|
||||||
|
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
|
||||||
|
else:
|
||||||
|
return ImplicitFillNode(nodelist=nodelist)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_has_content(nodelist: NodeList) -> bool:
|
||||||
|
for node in nodelist:
|
||||||
|
if isinstance(node, TextNode) and node.s.isspace():
|
||||||
|
pass
|
||||||
|
elif isinstance(node, CommentNode):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def render_component_template_with_slots(
|
||||||
|
template: Template,
|
||||||
|
context: Context,
|
||||||
|
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]],
|
||||||
|
registered_name: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Given a template, context, and slot fills, this function first prepares
|
||||||
|
the template to be able to render the fills in the place of slots, and then
|
||||||
|
renders the template with given context.
|
||||||
|
|
||||||
|
NOTE: The template is mutated in the process!
|
||||||
|
"""
|
||||||
|
prev_filled_slots_context: Optional[FilledSlotsContext] = context.get(FILLED_SLOTS_CONTENT_CONTEXT_KEY)
|
||||||
|
updated_filled_slots_context = _prepare_component_template_filled_slot_context(
|
||||||
|
template,
|
||||||
|
fill_content,
|
||||||
|
prev_filled_slots_context,
|
||||||
|
registered_name,
|
||||||
|
)
|
||||||
|
with context.update({FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_filled_slots_context}):
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_component_template_filled_slot_context(
|
||||||
|
template: Template,
|
||||||
|
fill_content: Union[DefaultFillContent, Iterable[NamedFillContent]],
|
||||||
|
slots_context: Optional[FilledSlotsContext],
|
||||||
|
registered_name: Optional[str],
|
||||||
|
) -> FilledSlotsContext:
|
||||||
|
if isinstance(fill_content, NodeList):
|
||||||
|
default_fill_content = (fill_content, None)
|
||||||
|
named_fills_content = {}
|
||||||
|
else:
|
||||||
|
default_fill_content = None
|
||||||
|
named_fills_content = {name: (nodelist, alias) for name, nodelist, alias in list(fill_content)}
|
||||||
|
|
||||||
|
# If value is `None`, then slot is unfilled.
|
||||||
|
slot_name2fill_content: Dict[SlotName, Optional[FillContent]] = {}
|
||||||
|
default_slot_encountered: bool = False
|
||||||
|
required_slot_names: Set[str] = set()
|
||||||
|
|
||||||
|
# Collect fills and check for errors
|
||||||
|
for node in template.nodelist.get_nodes_by_type((SlotNode, IfSlotFilledConditionBranchNode)): # type: ignore
|
||||||
|
if isinstance(node, SlotNode):
|
||||||
|
# Give slot node knowledge of its parent template.
|
||||||
|
node.template = template
|
||||||
|
slot_name = node.name
|
||||||
|
if slot_name in slot_name2fill_content:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Slot name '{slot_name}' re-used within the same template. "
|
||||||
|
f"Slot names must be unique."
|
||||||
|
f"To fix, check template '{template.name}' "
|
||||||
|
f"of component '{registered_name}'."
|
||||||
|
)
|
||||||
|
content_data: Optional[FillContent] = None # `None` -> unfilled
|
||||||
|
if node.is_required:
|
||||||
|
required_slot_names.add(node.name)
|
||||||
|
if node.is_default:
|
||||||
|
if default_slot_encountered:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"Only one component slot may be marked as 'default'. "
|
||||||
|
f"To fix, check template '{template.name}' "
|
||||||
|
f"of component '{registered_name}'."
|
||||||
|
)
|
||||||
|
content_data = default_fill_content
|
||||||
|
default_slot_encountered = True
|
||||||
|
if not content_data:
|
||||||
|
content_data = named_fills_content.get(node.name)
|
||||||
|
slot_name2fill_content[slot_name] = content_data
|
||||||
|
elif isinstance(node, IfSlotFilledConditionBranchNode):
|
||||||
|
node.template = template
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Node of {type(node).__name__} does not require linking.")
|
||||||
|
|
||||||
|
# Check: Only component templates that include a 'default' slot
|
||||||
|
# can be invoked with implicit filling.
|
||||||
|
if default_fill_content and not default_slot_encountered:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
f"Component '{registered_name}' passed default fill content '{default_fill_content}'"
|
||||||
|
f"(i.e. without explicit 'fill' tag), "
|
||||||
|
f"even though none of its slots is marked as 'default'."
|
||||||
|
)
|
||||||
|
|
||||||
|
unfilled_slots: Set[str] = {k for k, v in slot_name2fill_content.items() if v is None}
|
||||||
|
unmatched_fills: Set[str] = named_fills_content.keys() - slot_name2fill_content.keys()
|
||||||
|
|
||||||
|
# Check that 'required' slots are filled.
|
||||||
|
for slot_name in unfilled_slots:
|
||||||
|
if slot_name in required_slot_names:
|
||||||
|
msg = (
|
||||||
|
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
|
||||||
|
f"yet no fill is provided. Check template.'"
|
||||||
|
)
|
||||||
|
if unmatched_fills:
|
||||||
|
msg = f"{msg}\nPossible typo in unresolvable fills: {unmatched_fills}."
|
||||||
|
raise TemplateSyntaxError(msg)
|
||||||
|
|
||||||
|
# Check that all fills can be matched to a slot on the component template.
|
||||||
|
# To help with easy-to-overlook typos, we fuzzy match unresolvable fills to
|
||||||
|
# those slots for which no matching fill was encountered. In the event of
|
||||||
|
# a close match, we include the name of the matched unfilled slot as a
|
||||||
|
# hint in the error message.
|
||||||
|
#
|
||||||
|
# Note: Finding a good `cutoff` value may require further trial-and-error.
|
||||||
|
# Higher values make matching stricter. This is probably preferable, as it
|
||||||
|
# reduces false positives.
|
||||||
|
for fill_name in unmatched_fills:
|
||||||
|
fuzzy_slot_name_matches = difflib.get_close_matches(fill_name, unfilled_slots, n=1, cutoff=0.7)
|
||||||
|
msg = (
|
||||||
|
f"Component '{registered_name}' passed fill "
|
||||||
|
f"that refers to undefined slot: '{fill_name}'."
|
||||||
|
f"\nUnfilled slot names are: {sorted(unfilled_slots)}."
|
||||||
|
)
|
||||||
|
if fuzzy_slot_name_matches:
|
||||||
|
msg += f"\nDid you mean '{fuzzy_slot_name_matches[0]}'?"
|
||||||
|
raise TemplateSyntaxError(msg)
|
||||||
|
|
||||||
|
# Return updated FILLED_SLOTS_CONTEXT map
|
||||||
|
filled_slots_map: Dict[Tuple[SlotName, Template], FillContent] = {
|
||||||
|
(slot_name, template): content_data
|
||||||
|
for slot_name, content_data in slot_name2fill_content.items()
|
||||||
|
if content_data # Slots whose content is None (i.e. unfilled) are dropped.
|
||||||
|
}
|
||||||
|
if slots_context is not None:
|
||||||
|
return slots_context.new_child(filled_slots_map)
|
||||||
|
else:
|
||||||
|
return ChainMap(filled_slots_map)
|
|
@ -1,24 +1,29 @@
|
||||||
import sys
|
from typing import TYPE_CHECKING, List, Mapping, Optional, Tuple
|
||||||
from typing import TYPE_CHECKING, Any, Iterable, List, Mapping, Optional, Set, Tuple, Type, Union
|
|
||||||
|
|
||||||
if sys.version_info[:2] < (3, 9):
|
|
||||||
from typing import ChainMap
|
|
||||||
else:
|
|
||||||
from collections import ChainMap
|
|
||||||
|
|
||||||
import django.template
|
import django.template
|
||||||
from django.conf import settings
|
|
||||||
from django.template import Context, Template
|
|
||||||
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType
|
from django.template.base import FilterExpression, Node, NodeList, Parser, TextNode, Token, TokenType
|
||||||
from django.template.defaulttags import CommentNode
|
|
||||||
from django.template.exceptions import TemplateSyntaxError
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.template.library import parse_bits
|
from django.template.library import parse_bits
|
||||||
from django.utils.safestring import SafeString, mark_safe
|
from django.utils.safestring import SafeString, mark_safe
|
||||||
|
|
||||||
from django_components.app_settings import app_settings
|
from django_components.app_settings import app_settings
|
||||||
|
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
|
||||||
from django_components.component_registry import ComponentRegistry
|
from django_components.component_registry import ComponentRegistry
|
||||||
from django_components.component_registry import registry as component_registry
|
from django_components.component_registry import registry as component_registry
|
||||||
from django_components.middleware import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
|
from django_components.middleware import (
|
||||||
|
CSS_DEPENDENCY_PLACEHOLDER,
|
||||||
|
JS_DEPENDENCY_PLACEHOLDER,
|
||||||
|
is_dependency_middleware_active,
|
||||||
|
)
|
||||||
|
from django_components.slots import (
|
||||||
|
IfSlotFilledConditionBranchNode,
|
||||||
|
IfSlotFilledElseBranchNode,
|
||||||
|
IfSlotFilledNode,
|
||||||
|
NamedFillNode,
|
||||||
|
SlotNode,
|
||||||
|
_IfSlotFilledBranchNode,
|
||||||
|
parse_slot_fill_nodes_from_component_nodelist,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django_components.component import Component
|
from django_components.component import Component
|
||||||
|
@ -27,24 +32,9 @@ if TYPE_CHECKING:
|
||||||
register = django.template.Library()
|
register = django.template.Library()
|
||||||
|
|
||||||
|
|
||||||
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
|
|
||||||
|
|
||||||
SLOT_REQUIRED_OPTION_KEYWORD = "required"
|
SLOT_REQUIRED_OPTION_KEYWORD = "required"
|
||||||
SLOT_DEFAULT_OPTION_KEYWORD = "default"
|
SLOT_DEFAULT_OPTION_KEYWORD = "default"
|
||||||
|
|
||||||
FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
|
|
||||||
|
|
||||||
# Type aliases
|
|
||||||
|
|
||||||
SlotName = str
|
|
||||||
AliasName = str
|
|
||||||
|
|
||||||
DefaultFillContent = NodeList
|
|
||||||
NamedFillContent = Tuple[SlotName, NodeList, Optional[AliasName]]
|
|
||||||
|
|
||||||
FillContent = Tuple[NodeList, Optional[AliasName]]
|
|
||||||
FilledSlotsContext = ChainMap[Tuple[SlotName, Template], FillContent]
|
|
||||||
|
|
||||||
|
|
||||||
def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
|
||||||
"""Returns a list unique components from the registry."""
|
"""Returns a list unique components from the registry."""
|
||||||
|
@ -123,95 +113,6 @@ def component_js_dependencies_tag(preload: str = "") -> SafeString:
|
||||||
return mark_safe("\n".join(rendered_dependencies))
|
return mark_safe("\n".join(rendered_dependencies))
|
||||||
|
|
||||||
|
|
||||||
class UserSlotVar:
|
|
||||||
"""
|
|
||||||
Extensible mechanism for offering 'fill' blocks in template access to properties
|
|
||||||
of parent slot.
|
|
||||||
|
|
||||||
How it works: At render time, SlotNode(s) that have been aliased in the fill tag
|
|
||||||
of the component instance create an instance of UserSlotVar. This instance is made
|
|
||||||
available to the rendering context on a key matching the slot alias (see
|
|
||||||
SlotNode.render() for implementation).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, slot: "SlotNode", context: Context):
|
|
||||||
self._slot = slot
|
|
||||||
self._context = context
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default(self) -> str:
|
|
||||||
return mark_safe(self._slot.nodelist.render(self._context))
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateAwareNodeMixin:
|
|
||||||
_template: Template
|
|
||||||
|
|
||||||
@property
|
|
||||||
def template(self) -> Template:
|
|
||||||
try:
|
|
||||||
return self._template
|
|
||||||
except AttributeError:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Internal error: Instance of {type(self).__name__} was not "
|
|
||||||
"linked to Template before use in render() context."
|
|
||||||
)
|
|
||||||
|
|
||||||
@template.setter
|
|
||||||
def template(self, value: Template) -> None:
|
|
||||||
self._template = value
|
|
||||||
|
|
||||||
|
|
||||||
class SlotNode(Node, TemplateAwareNodeMixin):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
nodelist: NodeList,
|
|
||||||
is_required: bool = False,
|
|
||||||
is_default: bool = False,
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.nodelist = nodelist
|
|
||||||
self.is_required = is_required
|
|
||||||
self.is_default = is_default
|
|
||||||
|
|
||||||
@property
|
|
||||||
def active_flags(self) -> List[str]:
|
|
||||||
m = []
|
|
||||||
if self.is_required:
|
|
||||||
m.append("required")
|
|
||||||
if self.is_default:
|
|
||||||
m.append("default")
|
|
||||||
return m
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<Slot Node: {self.name}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
|
||||||
|
|
||||||
def render(self, context: Context) -> SafeString:
|
|
||||||
try:
|
|
||||||
filled_slots_map: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
|
||||||
except KeyError:
|
|
||||||
raise TemplateSyntaxError(f"Attempted to render SlotNode '{self.name}' outside a parent component.")
|
|
||||||
|
|
||||||
extra_context = {}
|
|
||||||
try:
|
|
||||||
slot_fill_content: FillContent = filled_slots_map[(self.name, self.template)]
|
|
||||||
except KeyError:
|
|
||||||
if self.is_required:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Slot '{self.name}' is marked as 'required' (i.e. non-optional), " f"yet no fill is provided. "
|
|
||||||
)
|
|
||||||
nodelist = self.nodelist
|
|
||||||
else:
|
|
||||||
nodelist, alias = slot_fill_content
|
|
||||||
if alias:
|
|
||||||
if not alias.isidentifier():
|
|
||||||
raise TemplateSyntaxError()
|
|
||||||
extra_context[alias] = UserSlotVar(self, context)
|
|
||||||
|
|
||||||
with context.update(extra_context):
|
|
||||||
return nodelist.render(context)
|
|
||||||
|
|
||||||
|
|
||||||
@register.tag("slot")
|
@register.tag("slot")
|
||||||
def do_slot(parser: Parser, token: Token) -> SlotNode:
|
def do_slot(parser: Parser, token: Token) -> SlotNode:
|
||||||
bits = token.split_contents()
|
bits = token.split_contents()
|
||||||
|
@ -254,47 +155,6 @@ def do_slot(parser: Parser, token: Token) -> SlotNode:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaseFillNode(Node):
|
|
||||||
def __init__(self, nodelist: NodeList):
|
|
||||||
self.nodelist: NodeList = nodelist
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
"{% fill ... %} block cannot be rendered directly. "
|
|
||||||
"You are probably seeing this because you have used one outside "
|
|
||||||
"a {% component %} context."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NamedFillNode(BaseFillNode):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
nodelist: NodeList,
|
|
||||||
name_fexp: FilterExpression,
|
|
||||||
alias_fexp: Optional[FilterExpression] = None,
|
|
||||||
):
|
|
||||||
super().__init__(nodelist)
|
|
||||||
self.name_fexp = name_fexp
|
|
||||||
self.alias_fexp = alias_fexp
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<{type(self)} Name: {self.name_fexp}. Contents: {repr(self.nodelist)}.>"
|
|
||||||
|
|
||||||
|
|
||||||
class ImplicitFillNode(BaseFillNode):
|
|
||||||
"""
|
|
||||||
Instantiated when a `component` tag pair is passed template content that
|
|
||||||
excludes `fill` tags. Nodes of this type contribute their nodelists to slots marked
|
|
||||||
as 'default'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<{type(self)} Contents: {repr(self.nodelist)}.>"
|
|
||||||
|
|
||||||
|
|
||||||
@register.tag("fill")
|
@register.tag("fill")
|
||||||
def do_fill(parser: Parser, token: Token) -> NamedFillNode:
|
def do_fill(parser: Parser, token: Token) -> NamedFillNode:
|
||||||
"""Block tag whose contents 'fill' (are inserted into) an identically named
|
"""Block tag whose contents 'fill' (are inserted into) an identically named
|
||||||
|
@ -329,84 +189,6 @@ def do_fill(parser: Parser, token: Token) -> NamedFillNode:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ComponentNode(Node):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name_fexp: FilterExpression,
|
|
||||||
context_args: List[FilterExpression],
|
|
||||||
context_kwargs: Mapping[str, FilterExpression],
|
|
||||||
isolated_context: bool = False,
|
|
||||||
fill_nodes: Union[ImplicitFillNode, Iterable[NamedFillNode]] = (),
|
|
||||||
) -> None:
|
|
||||||
self.name_fexp = name_fexp
|
|
||||||
self.context_args = context_args or []
|
|
||||||
self.context_kwargs = context_kwargs or {}
|
|
||||||
self.isolated_context = isolated_context
|
|
||||||
self.fill_nodes = fill_nodes
|
|
||||||
self.nodelist = self._create_nodelist(fill_nodes)
|
|
||||||
|
|
||||||
def _create_nodelist(self, fill_nodes: Union[Iterable[Node], ImplicitFillNode]) -> NodeList:
|
|
||||||
if isinstance(fill_nodes, ImplicitFillNode):
|
|
||||||
return NodeList([fill_nodes])
|
|
||||||
else:
|
|
||||||
return NodeList(fill_nodes)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
|
||||||
self.name_fexp,
|
|
||||||
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
|
|
||||||
)
|
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
|
||||||
resolved_component_name = self.name_fexp.resolve(context)
|
|
||||||
component_cls: Type[Component] = component_registry.get(resolved_component_name)
|
|
||||||
|
|
||||||
# Resolve FilterExpressions and Variables that were passed as args to the
|
|
||||||
# component, then call component's context method
|
|
||||||
# to get values to insert into the context
|
|
||||||
resolved_context_args = [safe_resolve(arg, context) for arg in self.context_args]
|
|
||||||
resolved_context_kwargs = {key: safe_resolve(kwarg, context) for key, kwarg in self.context_kwargs.items()}
|
|
||||||
|
|
||||||
if isinstance(self.fill_nodes, ImplicitFillNode):
|
|
||||||
fill_content = self.fill_nodes.nodelist
|
|
||||||
else:
|
|
||||||
fill_content = []
|
|
||||||
for fill_node in self.fill_nodes:
|
|
||||||
# Note that outer component context is used to resolve variables in
|
|
||||||
# fill tag.
|
|
||||||
resolved_name = fill_node.name_fexp.resolve(context)
|
|
||||||
resolved_alias: Optional[str]
|
|
||||||
if fill_node.alias_fexp:
|
|
||||||
resolved_alias = fill_node.alias_fexp.resolve(context)
|
|
||||||
if resolved_alias and not resolved_alias.isidentifier():
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Fill tag alias '{fill_node.alias_fexp.var}' in component "
|
|
||||||
f"{resolved_component_name} does not resolve to "
|
|
||||||
f"a valid Python identifier. Got: '{resolved_alias}'."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
resolved_alias = None
|
|
||||||
fill_content.append((resolved_name, fill_node.nodelist, resolved_alias))
|
|
||||||
|
|
||||||
component: Component = component_cls(
|
|
||||||
registered_name=resolved_component_name,
|
|
||||||
outer_context=context,
|
|
||||||
fill_content=fill_content,
|
|
||||||
)
|
|
||||||
|
|
||||||
component_context: dict = component.get_context_data(*resolved_context_args, **resolved_context_kwargs)
|
|
||||||
|
|
||||||
if self.isolated_context:
|
|
||||||
context = context.new()
|
|
||||||
with context.update(component_context):
|
|
||||||
rendered_component = component.render(context)
|
|
||||||
|
|
||||||
if is_dependency_middleware_active():
|
|
||||||
return RENDERED_COMMENT_TEMPLATE.format(name=resolved_component_name) + rendered_component
|
|
||||||
else:
|
|
||||||
return rendered_component
|
|
||||||
|
|
||||||
|
|
||||||
@register.tag(name="component")
|
@register.tag(name="component")
|
||||||
def do_component(parser: Parser, token: Token) -> ComponentNode:
|
def do_component(parser: Parser, token: Token) -> ComponentNode:
|
||||||
"""
|
"""
|
||||||
|
@ -427,23 +209,7 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
|
||||||
component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component")
|
component_name, context_args, context_kwargs = parse_component_with_args(parser, bits, "component")
|
||||||
body: NodeList = parser.parse(parse_until=["endcomponent"])
|
body: NodeList = parser.parse(parse_until=["endcomponent"])
|
||||||
parser.delete_first_token()
|
parser.delete_first_token()
|
||||||
fill_nodes: Union[Iterable[NamedFillNode], ImplicitFillNode] = []
|
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
|
||||||
if block_has_content(body):
|
|
||||||
for parse_fn in (
|
|
||||||
try_parse_as_default_fill,
|
|
||||||
try_parse_as_named_fill_tag_set,
|
|
||||||
):
|
|
||||||
curr_fill_nodes = parse_fn(body)
|
|
||||||
if curr_fill_nodes:
|
|
||||||
fill_nodes = curr_fill_nodes
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
"Illegal content passed to 'component' tag pair. "
|
|
||||||
"Possible causes: 1) Explicit 'fill' tags cannot occur alongside other "
|
|
||||||
"tags except comment tags; 2) Default (default slot-targeting) content "
|
|
||||||
"is mixed with explict 'fill' tags."
|
|
||||||
)
|
|
||||||
component_node = ComponentNode(
|
component_node = ComponentNode(
|
||||||
FilterExpression(component_name, parser),
|
FilterExpression(component_name, parser),
|
||||||
context_args,
|
context_args,
|
||||||
|
@ -455,59 +221,6 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
|
||||||
return component_node
|
return component_node
|
||||||
|
|
||||||
|
|
||||||
def try_parse_as_named_fill_tag_set(
|
|
||||||
nodelist: NodeList,
|
|
||||||
) -> Optional[Iterable[NamedFillNode]]:
|
|
||||||
result = []
|
|
||||||
seen_name_fexps: Set[FilterExpression] = set()
|
|
||||||
for node in nodelist:
|
|
||||||
if isinstance(node, NamedFillNode):
|
|
||||||
if node.name_fexp 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)
|
|
||||||
result.append(node)
|
|
||||||
elif isinstance(node, CommentNode):
|
|
||||||
pass
|
|
||||||
elif isinstance(node, TextNode) and node.s.isspace():
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def try_parse_as_default_fill(
|
|
||||||
nodelist: NodeList,
|
|
||||||
) -> Optional[ImplicitFillNode]:
|
|
||||||
# nodelist.get_nodes_by_type()
|
|
||||||
nodes_stack: List[Node] = list(nodelist)
|
|
||||||
while nodes_stack:
|
|
||||||
node = nodes_stack.pop()
|
|
||||||
if isinstance(node, NamedFillNode):
|
|
||||||
return None
|
|
||||||
elif isinstance(node, ComponentNode):
|
|
||||||
# Stop searching here, as fill tags are permitted inside component blocks
|
|
||||||
# embedded within a default fill node.
|
|
||||||
continue
|
|
||||||
for nodelist_attr_name in node.child_nodelists:
|
|
||||||
nodes_stack.extend(getattr(node, nodelist_attr_name, []))
|
|
||||||
else:
|
|
||||||
return ImplicitFillNode(nodelist=nodelist)
|
|
||||||
|
|
||||||
|
|
||||||
def block_has_content(nodelist: NodeList) -> bool:
|
|
||||||
for node in nodelist:
|
|
||||||
if isinstance(node, TextNode) and node.s.isspace():
|
|
||||||
pass
|
|
||||||
elif isinstance(node, CommentNode):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def is_whitespace_node(node: Node) -> bool:
|
def is_whitespace_node(node: Node) -> bool:
|
||||||
return isinstance(node, TextNode) and node.s.isspace()
|
return isinstance(node, TextNode) and node.s.isspace()
|
||||||
|
|
||||||
|
@ -616,72 +329,6 @@ def parse_if_filled_bits(
|
||||||
return slot_name, is_positive
|
return slot_name, is_positive
|
||||||
|
|
||||||
|
|
||||||
class _IfSlotFilledBranchNode(Node):
|
|
||||||
def __init__(self, nodelist: NodeList) -> None:
|
|
||||||
self.nodelist = nodelist
|
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
|
||||||
return self.nodelist.render(context)
|
|
||||||
|
|
||||||
def evaluate(self, context: Context) -> bool:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class IfSlotFilledConditionBranchNode(_IfSlotFilledBranchNode, TemplateAwareNodeMixin):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
slot_name: str,
|
|
||||||
nodelist: NodeList,
|
|
||||||
is_positive: Union[bool, None] = True,
|
|
||||||
) -> None:
|
|
||||||
self.slot_name = slot_name
|
|
||||||
self.is_positive: Optional[bool] = is_positive
|
|
||||||
super().__init__(nodelist)
|
|
||||||
|
|
||||||
def evaluate(self, context: Context) -> bool:
|
|
||||||
try:
|
|
||||||
filled_slots: FilledSlotsContext = context[FILLED_SLOTS_CONTENT_CONTEXT_KEY]
|
|
||||||
except KeyError:
|
|
||||||
raise TemplateSyntaxError(
|
|
||||||
f"Attempted to render {type(self).__name__} outside a Component rendering context."
|
|
||||||
)
|
|
||||||
slot_key = (self.slot_name, self.template)
|
|
||||||
is_filled = filled_slots.get(slot_key, None) is not None
|
|
||||||
# Make polarity switchable.
|
|
||||||
# i.e. if slot name is NOT filled and is_positive=False,
|
|
||||||
# then False == False -> True
|
|
||||||
return is_filled == self.is_positive
|
|
||||||
|
|
||||||
|
|
||||||
class IfSlotFilledElseBranchNode(_IfSlotFilledBranchNode):
|
|
||||||
def evaluate(self, context: Context) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class IfSlotFilledNode(Node):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
branches: List[_IfSlotFilledBranchNode],
|
|
||||||
):
|
|
||||||
self.branches = branches
|
|
||||||
self.nodelist = self._create_nodelist(branches)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"<{self.__class__.__name__}>"
|
|
||||||
|
|
||||||
def _create_nodelist(self, branches: List[_IfSlotFilledBranchNode]) -> NodeList:
|
|
||||||
return NodeList(branches)
|
|
||||||
|
|
||||||
def render(self, context: Context) -> str:
|
|
||||||
for node in self.branches:
|
|
||||||
if isinstance(node, IfSlotFilledElseBranchNode):
|
|
||||||
return node.render(context)
|
|
||||||
elif isinstance(node, IfSlotFilledConditionBranchNode):
|
|
||||||
if node.evaluate(context):
|
|
||||||
return node.render(context)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]:
|
def check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]:
|
||||||
"""Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'."""
|
"""Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'."""
|
||||||
|
|
||||||
|
@ -728,32 +375,10 @@ def parse_component_with_args(
|
||||||
return component_name, context_args, context_kwargs
|
return component_name, context_args, context_kwargs
|
||||||
|
|
||||||
|
|
||||||
def safe_resolve(context_item: FilterExpression, context: Context) -> Any:
|
|
||||||
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
|
|
||||||
|
|
||||||
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
|
|
||||||
|
|
||||||
|
|
||||||
def is_wrapped_in_quotes(s: str) -> bool:
|
def is_wrapped_in_quotes(s: str) -> bool:
|
||||||
return s.startswith(('"', "'")) and s[0] == s[-1]
|
return s.startswith(('"', "'")) and s[0] == s[-1]
|
||||||
|
|
||||||
|
|
||||||
def is_dependency_middleware_active() -> bool:
|
|
||||||
return getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False)
|
|
||||||
|
|
||||||
|
|
||||||
def norm_and_validate_name(name: str, tag: str, context: Optional[str] = None) -> str:
|
|
||||||
"""
|
|
||||||
Notes:
|
|
||||||
- Value of `tag` in {"slot", "fill", "alias"}
|
|
||||||
"""
|
|
||||||
name = strip_quotes(name)
|
|
||||||
if not name.isidentifier():
|
|
||||||
context = f" in '{context}'" if context else ""
|
|
||||||
raise TemplateSyntaxError(f"{tag} name '{name}'{context} " "is not a valid Python identifier.")
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def strip_quotes(s: str) -> str:
|
def strip_quotes(s: str) -> str:
|
||||||
return s.strip("\"'")
|
return s.strip("\"'")
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue