feat: Expose slot input as Slot.contents (#1180)

* feat: expose slot input as Slot.contents

* refactor: fix linter errors
This commit is contained in:
Juro Oravec 2025-05-14 11:17:09 +02:00 committed by GitHub
parent 53d80684bb
commit 0d05ef4cb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 576 additions and 222 deletions

View file

@ -24,7 +24,7 @@ from weakref import ReferenceType, WeakValueDictionary, finalize
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
from django.http import HttpRequest, HttpResponse
from django.template.base import NodeList, Origin, Parser, Template, TextNode, Token
from django.template.base import NodeList, Origin, Parser, Template, Token
from django.template.context import Context, RequestContext
from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY, BlockContext
@ -74,7 +74,6 @@ from django_components.slots import (
SlotRef,
SlotResult,
_is_extracting_fill,
_nodelist_to_slot_render_func,
resolve_fills,
)
from django_components.template import cached_template
@ -2716,14 +2715,14 @@ class Component(metaclass=ComponentMeta):
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within
# the forloop, because the value the forloop variable points to changes with each loop iteration.
def gen_escaped_content_func(content: SlotFunc, slot_name: str) -> Slot:
# Case: Already Slot, already escaped, and names tracing names assigned, so nothing to do.
# Case: Already Slot, already escaped, and names assigned, so nothing to do.
if isinstance(content, Slot) and content.escaped and content.slot_name and content.component_name:
return content
# Otherwise, we create a new instance of Slot, whether `content` was already Slot or not.
# This is so that user can potentially define a single `Slot` and use it in multiple components.
# so we can assign metadata to our internal copies.
if not isinstance(content, Slot) or not content.escaped:
# We wrap the original function so we post-process it by escaping the result.
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
rendered = content(ctx, slot_data, slot_ref)
return conditional_escape(rendered) if escape_content else rendered
@ -2737,12 +2736,15 @@ class Component(metaclass=ComponentMeta):
used_component_name = content.component_name or self.name
used_slot_name = content.slot_name or slot_name
used_nodelist = content.nodelist
used_contents = content.contents if content.contents is not None else content_func
else:
used_component_name = self.name
used_slot_name = slot_name
used_nodelist = None
used_contents = content_func
slot = Slot(
contents=used_contents,
content_func=content_func,
component_name=used_component_name,
slot_name=used_slot_name,
@ -2753,16 +2755,17 @@ class Component(metaclass=ComponentMeta):
return slot
for slot_name, content in fills.items():
# Case: No content, so nothing to do.
if content is None:
continue
# Case: Content is a string / scalar
elif not callable(content):
slot = _nodelist_to_slot_render_func(
component_name=self.name,
slot_name=slot_name,
nodelist=NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
data_var=None,
default_var=None,
escaped_content = conditional_escape(content) if escape_content else content
# NOTE: `Slot.content_func` and `Slot.nodelist` are set in `Slot.__init__()`
slot: Slot = Slot(
contents=escaped_content, component_name=self.name, slot_name=slot_name, escaped=True
)
# Case: Content is a callable, so either a plain function or a `Slot` instance.
else:
slot = gen_escaped_content_func(content, slot_name)
@ -2943,7 +2946,7 @@ class ComponentNode(BaseNode):
component_cls: Type[Component] = self.registry.get(self.name)
slot_fills = resolve_fills(context, self.nodelist, self.name)
slot_fills = resolve_fills(context, self, self.name)
component: Component = component_cls(
registered_name=self.name,

View file

@ -13,6 +13,7 @@ from typing import (
Optional,
Protocol,
Set,
Tuple,
TypeVar,
Union,
cast,
@ -34,17 +35,17 @@ from django_components.util.logger import trace_component_msg
from django_components.util.misc import get_index, get_last_index, is_identifier
if TYPE_CHECKING:
from django_components.component import ComponentContext
from django_components.component import ComponentContext, ComponentNode
TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
DEFAULT_SLOT_KEY = "default"
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
SLOT_DATA_KWARG = "data"
SLOT_NAME_KWARG = "name"
SLOT_DEFAULT_KWARG = "default"
SLOT_REQUIRED_KEYWORD = "required"
SLOT_DEFAULT_KEYWORD = "default"
SLOT_REQUIRED_FLAG = "required"
SLOT_DEFAULT_FLAG = "default"
FILL_DATA_KWARG = "data"
FILL_DEFAULT_KWARG = "default"
# Public types
@ -60,7 +61,13 @@ class SlotFunc(Protocol, Generic[TSlotData]):
class Slot(Generic[TSlotData]):
"""This class holds the slot content function along with related metadata."""
content_func: SlotFunc[TSlotData]
contents: Any
"""
The original value that was passed to the `Slot` constructor.
If the slot was defined with `{% fill %}` tag, this will be the raw string contents of the slot.
"""
content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None)
escaped: bool = False
"""Whether the slot content has been escaped."""
@ -70,17 +77,30 @@ class Slot(Generic[TSlotData]):
slot_name: Optional[str] = None
"""Name of the slot that originally defined or accepted this slot fill."""
nodelist: Optional[NodeList] = None
"""Nodelist of the slot content."""
"""If the slot was defined with `{% fill %} tag, this will be the Nodelist of the slot content."""
def __post_init__(self) -> None:
# Since the `Slot` instance is treated as a function, it may be passed as `contents`
# to the `Slot()` constructor. In that case we need to unwrap to the original value
# if `Slot()` constructor got another Slot instance.
# NOTE: If `Slot` was passed as `contents`, we do NOT take the metadata from the inner Slot instance.
# Instead we treat is simply as a function.
# NOTE: Try to avoid infinite loop if `Slot.contents` points to itself.
seen_contents = set()
while isinstance(self.contents, Slot) and self.contents not in seen_contents:
seen_contents.add(id(self.contents))
self.contents = self.contents.contents
if id(self.contents) in seen_contents:
raise ValueError("Detected infinite loop in `Slot.contents` pointing to itself")
if self.content_func is None:
self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
if self.nodelist is None:
self.nodelist = new_nodelist
if not callable(self.content_func):
raise ValueError(f"Slot content must be a callable, got: {self.content_func}")
# Allow passing Slot instances as content functions
if isinstance(self.content_func, Slot):
inner_slot = self.content_func
self.content_func = inner_slot.content_func
# Allow to treat the instances as functions
def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult:
return self.content_func(ctx, slot_data, slot_ref)
@ -96,6 +116,22 @@ class Slot(Generic[TSlotData]):
slot_name = f"'{self.slot_name}'" if self.slot_name else None
return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
def _resolve_contents(self, contents: Any) -> Tuple[Any, NodeList, SlotFunc[TSlotData]]:
# Case: Content is a string / scalar, so we can use `TextNode` to render it.
if not callable(contents):
slot = _nodelist_to_slot(
component_name=self.component_name or "<Slot._resolve_contents>",
slot_name=self.slot_name,
nodelist=NodeList([TextNode(contents)]),
contents=contents,
data_var=None,
default_var=None,
)
return slot.contents, slot.nodelist, slot.content_func
# Otherwise, we're dealing with a function.
return contents, None, contents
# NOTE: This must be defined here, so we don't have any forward references
# otherwise Pydantic has problem resolving the types.
@ -313,7 +349,7 @@ class SlotNode(BaseNode):
tag = "slot"
end_tag = "endslot"
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG]
# NOTE:
# In the current implementation, the slots are resolved only at the render time.
@ -356,8 +392,8 @@ class SlotNode(BaseNode):
component_path = component_ctx.component_path
slot_fills = component_ctx.fills
slot_name = name
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
is_default = self.flags[SLOT_DEFAULT_FLAG]
is_required = self.flags[SLOT_REQUIRED_FLAG]
trace_component_msg(
"RENDER_SLOT_START",
@ -515,12 +551,15 @@ class SlotNode(BaseNode):
slot_fill = SlotFill(
name=slot_name,
is_filled=False,
slot=_nodelist_to_slot_render_func(
slot=_nodelist_to_slot(
component_name=component_name,
slot_name=slot_name,
nodelist=self.nodelist,
contents=self.contents,
data_var=None,
default_var=None,
# Escaped because this was defined in the template
escaped=True,
),
)
@ -752,28 +791,28 @@ class FillNode(BaseNode):
if data is not None:
if not isinstance(data, str):
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data}")
raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}")
if not is_identifier(data):
raise RuntimeError(
f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
f"Fill tag kwarg '{FILL_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
)
if default is not None:
if not isinstance(default, str):
raise TemplateSyntaxError(
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
f"Fill tag '{FILL_DEFAULT_KWARG}' kwarg must resolve to a string, got {default}"
)
if not is_identifier(default):
raise RuntimeError(
f"Fill tag kwarg '{SLOT_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
f"Fill tag kwarg '{FILL_DEFAULT_KWARG}' does not resolve to a valid Python identifier,"
f" got '{default}'"
)
# data and default cannot be bound to the same variable
if data and default and data == default:
raise RuntimeError(
f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
f" and slot data ({SLOT_DATA_KWARG}=...)"
f"Fill '{name}' received the same string for slot default ({FILL_DEFAULT_KWARG}=...)"
f" and slot data ({FILL_DATA_KWARG}=...)"
)
fill_data = FillWithData(
@ -873,7 +912,7 @@ class FillWithData(NamedTuple):
def resolve_fills(
context: Context,
nodelist: NodeList,
component_node: "ComponentNode",
component_name: str,
) -> Dict[SlotName, Slot]:
"""
@ -924,6 +963,9 @@ def resolve_fills(
"""
slots: Dict[SlotName, Slot] = {}
nodelist = component_node.nodelist
contents = component_node.contents
if not nodelist:
return slots
@ -941,12 +983,15 @@ def resolve_fills(
)
if not nodelist_is_empty:
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func(
slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot(
component_name=component_name,
slot_name=None, # Will be populated later
nodelist=nodelist,
contents=contents,
data_var=None,
default_var=None,
# Escaped because this was defined in the template
escaped=True,
)
# The content has fills
@ -954,13 +999,16 @@ def resolve_fills(
# NOTE: If slot fills are explicitly defined, we use them even if they are empty (or only whitespace).
# This is different from the default slot, where we ignore empty content.
for fill in maybe_fills:
slots[fill.name] = _nodelist_to_slot_render_func(
slots[fill.name] = _nodelist_to_slot(
component_name=component_name,
slot_name=fill.name,
nodelist=fill.fill.nodelist,
contents=fill.fill.contents,
data_var=fill.data_var,
default_var=fill.default_var,
extra_context=fill.extra_context,
# Escaped because this was defined in the template
escaped=True,
)
return slots
@ -1026,12 +1074,14 @@ def _escape_slot_name(name: str) -> str:
return escaped_name
def _nodelist_to_slot_render_func(
def _nodelist_to_slot(
component_name: str,
slot_name: Optional[str],
nodelist: NodeList,
contents: Optional[str] = None,
data_var: Optional[str] = None,
default_var: Optional[str] = None,
escaped: bool = False,
extra_context: Optional[Dict[str, Any]] = None,
) -> Slot:
if data_var:
@ -1117,8 +1167,13 @@ def _nodelist_to_slot_render_func(
content_func=cast(SlotFunc, render_func),
component_name=component_name,
slot_name=slot_name,
escaped=False,
escaped=escaped,
nodelist=nodelist,
# The `contents` param passed to this function may be `None`, because it's taken from
# `BaseNode.contents` which is `None` for self-closing tags like `{% fill "footer" / %}`.
# But `Slot(contents=None)` would result in `Slot.contents` being the render function.
# So we need to special-case this.
contents=contents if contents is not None else "",
)