mirror of
https://github.com/django-components/django-components.git
synced 2025-08-09 16:57:59 +00:00
feat: @template_tag and refactor how template tags are defined (#910)
This commit is contained in:
parent
a047908189
commit
f908197850
23 changed files with 2149 additions and 1148 deletions
|
@ -19,6 +19,7 @@ from django_components.component_registry import (
|
|||
from django_components.components import DynamicComponent
|
||||
from django_components.dependencies import render_dependencies
|
||||
from django_components.library import TagProtectedError
|
||||
from django_components.node import BaseNode, template_tag
|
||||
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
|
||||
from django_components.tag_formatter import (
|
||||
ComponentFormatter,
|
||||
|
@ -40,6 +41,7 @@ __all__ = [
|
|||
"AlreadyRegistered",
|
||||
"autodiscover",
|
||||
"cached_template",
|
||||
"BaseNode",
|
||||
"ContextBehavior",
|
||||
"ComponentsSettings",
|
||||
"Component",
|
||||
|
@ -72,5 +74,6 @@ __all__ = [
|
|||
"TagFormatterABC",
|
||||
"TagProtectedError",
|
||||
"TagResult",
|
||||
"template_tag",
|
||||
"types",
|
||||
]
|
||||
|
|
|
@ -2,39 +2,84 @@
|
|||
# See https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/components.py # noqa: E501
|
||||
# And https://github.com/Xzya/django-web-components/blob/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/attributes.py # noqa: E501
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from django.template import Context
|
||||
from django.utils.html import conditional_escape, format_html
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.template_tag import TagParams
|
||||
|
||||
HTML_ATTRS_DEFAULTS_KEY = "defaults"
|
||||
HTML_ATTRS_ATTRS_KEY = "attrs"
|
||||
|
||||
|
||||
class HtmlAttrsNode(BaseNode):
|
||||
def __init__(
|
||||
"""
|
||||
Generate HTML attributes (`key="value"`), combining data from multiple sources,
|
||||
whether its template variables or static text.
|
||||
|
||||
It is designed to easily merge HTML attributes passed from outside with the internal.
|
||||
See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
|
||||
values in the `default` dictionary.
|
||||
- `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
|
||||
with values in the `attrs` dictionary.
|
||||
- Any extra kwargs will be appended to the corresponding keys
|
||||
|
||||
The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
|
||||
(`key="value"`).
|
||||
|
||||
Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
|
||||
|
||||
```python
|
||||
attrs = {"class": "my-class"}
|
||||
```
|
||||
|
||||
Then
|
||||
|
||||
```django
|
||||
{% html_attrs attrs class="extra-class" %}
|
||||
```
|
||||
|
||||
will result in `class="my-class extra-class"`.
|
||||
|
||||
**Example:**
|
||||
```django
|
||||
<div {% html_attrs
|
||||
attrs
|
||||
defaults:class="default-class"
|
||||
class="extra-class"
|
||||
data-id="123"
|
||||
%}>
|
||||
```
|
||||
|
||||
renders
|
||||
|
||||
```html
|
||||
<div class="my-class extra-class" data-id="123">
|
||||
```
|
||||
|
||||
**See more usage examples in
|
||||
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
|
||||
"""
|
||||
|
||||
tag = "html_attrs"
|
||||
end_tag = None # inline-only
|
||||
allowed_flags = []
|
||||
|
||||
def render(
|
||||
self,
|
||||
params: TagParams,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(nodelist=None, params=params, node_id=node_id)
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
append_attrs: List[Tuple[str, Any]] = []
|
||||
|
||||
# Resolve all data
|
||||
args, kwargs = self.params.resolve(context)
|
||||
attrs = kwargs.pop(HTML_ATTRS_ATTRS_KEY, None) or {}
|
||||
defaults = kwargs.pop(HTML_ATTRS_DEFAULTS_KEY, None) or {}
|
||||
append_attrs = list(kwargs.items())
|
||||
|
||||
# Merge it
|
||||
final_attrs = {**defaults, **attrs}
|
||||
final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
|
||||
context: Context,
|
||||
attrs: Optional[Dict] = None,
|
||||
defaults: Optional[Dict] = None,
|
||||
**kwargs: Any,
|
||||
) -> SafeString:
|
||||
# Merge
|
||||
final_attrs = {}
|
||||
final_attrs.update(defaults or {})
|
||||
final_attrs.update(attrs or {})
|
||||
final_attrs = append_attributes(*final_attrs.items(), *kwargs.items())
|
||||
|
||||
# Render to HTML attributes
|
||||
return attributes_to_string(final_attrs)
|
||||
|
|
|
@ -11,6 +11,7 @@ from typing import (
|
|||
Dict,
|
||||
Generator,
|
||||
Generic,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
NamedTuple,
|
||||
|
@ -26,7 +27,7 @@ from typing import (
|
|||
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, Template, TextNode
|
||||
from django.template.base import NodeList, Parser, Template, TextNode, Token
|
||||
from django.template.context import Context, RequestContext
|
||||
from django.template.loader import get_template
|
||||
from django.template.loader_tags import BLOCK_CONTEXT_KEY
|
||||
|
@ -69,9 +70,8 @@ from django_components.slots import (
|
|||
)
|
||||
from django_components.template import cached_template
|
||||
from django_components.util.django_monkeypatch import is_template_cls_patched
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import TagParams
|
||||
from django_components.util.template_tag import TagAttr
|
||||
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
|
||||
|
||||
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
|
||||
|
@ -1209,32 +1209,147 @@ class Component(
|
|||
|
||||
|
||||
class ComponentNode(BaseNode):
|
||||
"""Django.template.Node subclass that renders a django-components component"""
|
||||
"""
|
||||
Renders one of the components that was previously registered with
|
||||
[`@register()`](./api.md#django_components.register)
|
||||
decorator.
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- All other args and kwargs are defined based on the component itself.
|
||||
|
||||
If you defined a component `"my_table"`
|
||||
|
||||
```python
|
||||
from django_component import Component, register
|
||||
|
||||
@register("my_table")
|
||||
class MyTable(Component):
|
||||
template = \"\"\"
|
||||
<table>
|
||||
<thead>
|
||||
{% for header in headers %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tbody>
|
||||
</table>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, rows: List, headers: List):
|
||||
return {
|
||||
"rows": rows,
|
||||
"headers": headers,
|
||||
}
|
||||
```
|
||||
|
||||
Then you can render this component by referring to `MyTable` via its
|
||||
registered name `"my_table"`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
### Component input
|
||||
|
||||
Positional and keyword arguments can be literals or template variables.
|
||||
|
||||
The component name must be a single- or double-quotes string and must
|
||||
be either:
|
||||
|
||||
- The first positional argument after `component`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
- Passed as kwarg `name`:
|
||||
|
||||
```django
|
||||
{% component rows=rows headers=headers name="my_table" ... / %}
|
||||
```
|
||||
|
||||
### Inserting into slots
|
||||
|
||||
If the component defined any [slots](../concepts/fundamentals/slots.md), you can
|
||||
pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags,
|
||||
directly within the `{% component %}` tag:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Isolating components
|
||||
|
||||
By default, components behave similarly to Django's
|
||||
[`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include),
|
||||
and the template inside the component has access to the variables defined in the outer template.
|
||||
|
||||
You can selectively isolate a component, using the `only` flag, so that the inner template
|
||||
can access only the data that was explicitly passed to it:
|
||||
|
||||
```django
|
||||
{% component "name" positional_arg keyword_arg=value ... only %}
|
||||
```
|
||||
"""
|
||||
|
||||
tag = "component"
|
||||
end_tag = "endcomponent"
|
||||
allowed_flags = [COMP_ONLY_FLAG]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# ComponentNode inputs
|
||||
name: str,
|
||||
registry: ComponentRegistry, # noqa F811
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
isolated_context: bool = False,
|
||||
# BaseNode inputs
|
||||
params: List[TagAttr],
|
||||
flags: Optional[Dict[str, bool]] = None,
|
||||
nodelist: Optional[NodeList] = None,
|
||||
node_id: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(nodelist=nodelist or NodeList(), params=params, node_id=node_id)
|
||||
super().__init__(params=params, flags=flags, nodelist=nodelist, node_id=node_id)
|
||||
|
||||
self.name = name
|
||||
self.isolated_context = isolated_context
|
||||
self.registry = registry
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "<ComponentNode: {}. Contents: {!r}>".format(
|
||||
self.name,
|
||||
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
|
||||
@classmethod
|
||||
def parse( # type: ignore[override]
|
||||
cls,
|
||||
parser: Parser,
|
||||
token: Token,
|
||||
registry: ComponentRegistry, # noqa F811
|
||||
name: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
) -> "ComponentNode":
|
||||
# Set the component-specific start and end tags by subclassing the base node
|
||||
subcls_name = cls.__name__ + "_" + name
|
||||
subcls: Type[ComponentNode] = type(subcls_name, (cls,), {"tag": start_tag, "end_tag": end_tag})
|
||||
|
||||
# Call `BaseNode.parse()` as if with the context of subcls.
|
||||
node: ComponentNode = super(cls, subcls).parse( # type: ignore[attr-defined]
|
||||
parser,
|
||||
token,
|
||||
registry=registry,
|
||||
name=name,
|
||||
)
|
||||
return node
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
trace_msg("RENDR", "COMP", self.name, self.node_id)
|
||||
|
||||
def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
|
||||
# Do not render nested `{% component %}` tags in other `{% component %}` tags
|
||||
# at the stage when we are determining if the latter has named fills or not.
|
||||
if _is_extracting_fill(context):
|
||||
|
@ -1242,11 +1357,6 @@ class ComponentNode(BaseNode):
|
|||
|
||||
component_cls: Type[Component] = self.registry.get(self.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
|
||||
args, kwargs = self.params.resolve(context)
|
||||
|
||||
slot_fills = resolve_fills(context, self.nodelist, self.name)
|
||||
|
||||
component: Component = component_cls(
|
||||
|
@ -1256,7 +1366,7 @@ class ComponentNode(BaseNode):
|
|||
)
|
||||
|
||||
# Prevent outer context from leaking into the template of the component
|
||||
if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
|
||||
if self.flags[COMP_ONLY_FLAG] or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
|
||||
context = make_isolated_context_copy(context)
|
||||
|
||||
output = component._render(
|
||||
|
@ -1269,7 +1379,6 @@ class ComponentNode(BaseNode):
|
|||
render_dependencies=False,
|
||||
)
|
||||
|
||||
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")
|
||||
return output
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
|
||||
|
||||
from django.template import Library
|
||||
from django.template.base import Parser, Token
|
||||
|
||||
from django_components.app_settings import ContextBehaviorType, app_settings
|
||||
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
|
||||
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
|
||||
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
@ -462,12 +463,39 @@ class ComponentRegistry:
|
|||
component: Type["Component"],
|
||||
) -> ComponentRegistryEntry:
|
||||
# Lazily import to avoid circular dependencies
|
||||
from django_components.templatetags.component_tags import component as do_component
|
||||
from django_components.component import ComponentNode
|
||||
|
||||
formatter = get_tag_formatter(self)
|
||||
tag = register_tag_from_formatter(self, do_component, formatter, comp_name)
|
||||
registry = self
|
||||
|
||||
return ComponentRegistryEntry(cls=component, tag=tag)
|
||||
# Define a tag function that pre-processes the tokens, extracting
|
||||
# the component name and passing the rest to the actual tag function.
|
||||
def tag_fn(parser: Parser, token: Token) -> ComponentNode:
|
||||
# Let the TagFormatter pre-process the tokens
|
||||
bits = token.split_contents()
|
||||
formatter = get_tag_formatter(registry)
|
||||
result = formatter.parse([*bits])
|
||||
start_tag = formatter.start_tag(result.component_name)
|
||||
end_tag = formatter.end_tag(result.component_name)
|
||||
|
||||
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
|
||||
# so we add it back in.
|
||||
bits = [bits[0], *result.tokens]
|
||||
token.contents = " ".join(bits)
|
||||
|
||||
return ComponentNode.parse(
|
||||
parser,
|
||||
token,
|
||||
registry=registry,
|
||||
name=result.component_name,
|
||||
start_tag=start_tag,
|
||||
end_tag=end_tag,
|
||||
)
|
||||
|
||||
formatter = get_tag_formatter(registry)
|
||||
start_tag = formatter.start_tag(comp_name)
|
||||
register_tag(self.library, start_tag, tag_fn)
|
||||
|
||||
return ComponentRegistryEntry(cls=component, tag=start_tag)
|
||||
|
||||
|
||||
# This variable represents the global component registry
|
||||
|
|
|
@ -28,11 +28,13 @@ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|||
from django.forms import Media
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse
|
||||
from django.http.response import HttpResponseBase
|
||||
from django.template import Context, TemplateSyntaxError
|
||||
from django.templatetags.static import static
|
||||
from django.urls import path, reverse
|
||||
from django.utils.decorators import sync_and_async_middleware
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.html import SoupNode
|
||||
from django_components.util.misc import get_import_path, is_nonempty_str
|
||||
|
||||
|
@ -1036,3 +1038,66 @@ class ComponentDependencyMiddleware:
|
|||
response.content = render_dependencies(response.content, type="document")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
#########################################################
|
||||
# 6. Template tags
|
||||
#########################################################
|
||||
|
||||
|
||||
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
|
||||
"""Marks location where CSS link and JS script tags should be rendered."""
|
||||
if type == "css":
|
||||
placeholder = CSS_DEPENDENCY_PLACEHOLDER
|
||||
elif type == "js":
|
||||
placeholder = JS_DEPENDENCY_PLACEHOLDER
|
||||
else:
|
||||
raise TemplateSyntaxError(
|
||||
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
|
||||
)
|
||||
|
||||
return mark_safe(placeholder)
|
||||
|
||||
|
||||
class ComponentCssDependenciesNode(BaseNode):
|
||||
"""
|
||||
Marks location where CSS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted into the `<head>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
|
||||
are by default inserted into the `<head>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
|
||||
tag = "component_css_dependencies"
|
||||
end_tag = None # inline-only
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
return _component_dependencies("css")
|
||||
|
||||
|
||||
class ComponentJsDependenciesNode(BaseNode):
|
||||
"""
|
||||
Marks location where JS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted at the end of the `<body>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
|
||||
are by default inserted at the end of the `<body>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
|
||||
tag = "component_js_dependencies"
|
||||
end_tag = None # inline-only
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
return _component_dependencies("js")
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
"""Module for interfacing with Django's Library (`django.template.library`)"""
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from django.template.base import Node, Parser, Token
|
||||
from django.template.library import Library
|
||||
|
||||
from django_components.tag_formatter import InternalTagFormatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
|
||||
|
||||
class TagProtectedError(Exception):
|
||||
"""
|
||||
|
@ -56,26 +51,15 @@ as they would conflict with other tags in the Library.
|
|||
|
||||
|
||||
def register_tag(
|
||||
registry: "ComponentRegistry",
|
||||
library: Library,
|
||||
tag: str,
|
||||
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
|
||||
tag_fn: Callable[[Parser, Token], Node],
|
||||
) -> None:
|
||||
# Register inline tag
|
||||
if is_tag_protected(registry.library, tag):
|
||||
if is_tag_protected(library, tag):
|
||||
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
|
||||
else:
|
||||
registry.library.tag(tag, lambda parser, token: tag_fn(parser, token, registry, tag))
|
||||
|
||||
|
||||
def register_tag_from_formatter(
|
||||
registry: "ComponentRegistry",
|
||||
tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
|
||||
formatter: InternalTagFormatter,
|
||||
component_name: str,
|
||||
) -> str:
|
||||
tag = formatter.start_tag(component_name)
|
||||
register_tag(registry, tag, tag_fn)
|
||||
return tag
|
||||
library.tag(tag, tag_fn)
|
||||
|
||||
|
||||
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:
|
||||
|
|
|
@ -1,20 +1,475 @@
|
|||
from typing import Optional
|
||||
import functools
|
||||
import inspect
|
||||
import keyword
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast
|
||||
|
||||
from django.template.base import Node, NodeList
|
||||
from django.template import Context, Library
|
||||
from django.template.base import Node, NodeList, Parser, Token
|
||||
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import TagParams
|
||||
from django_components.util.template_tag import (
|
||||
TagAttr,
|
||||
TagParam,
|
||||
apply_params_in_original_order,
|
||||
parse_template_tag,
|
||||
resolve_params,
|
||||
validate_params,
|
||||
)
|
||||
|
||||
|
||||
class BaseNode(Node):
|
||||
"""Shared behavior for our subclasses of Django's `Node`"""
|
||||
# Normally, when `Node.render()` is called, it receives only a single argument `context`.
|
||||
#
|
||||
# ```python
|
||||
# def render(self, context: Context) -> str:
|
||||
# return self.nodelist.render(context)
|
||||
# ```
|
||||
#
|
||||
# In django-components, the input to template tags is treated as function inputs, e.g.
|
||||
#
|
||||
# `{% component name="John" age=20 %}`
|
||||
#
|
||||
# And, for convenience, we want to allow the `render()` method to accept these extra parameters.
|
||||
# That way, user can define just the `render()` method and have access to all the information:
|
||||
#
|
||||
# ```python
|
||||
# def render(self, context: Context, name: str, **kwargs: Any) -> str:
|
||||
# return f"Hello, {name}!"
|
||||
# ```
|
||||
#
|
||||
# So we need to wrap the `render()` method, and for that we need the metaclass.
|
||||
#
|
||||
# The outer `render()` (our wrapper) will match the `Node.render()` signature (accepting only `context`),
|
||||
# while the inner `render()` (the actual implementation) will match the user-defined `render()` method's signature
|
||||
# (accepting all the parameters).
|
||||
class NodeMeta(type):
|
||||
def __new__(
|
||||
mcs,
|
||||
name: str,
|
||||
bases: Tuple[Type, ...],
|
||||
attrs: Dict[str, Any],
|
||||
) -> Type["BaseNode"]:
|
||||
cls = cast(Type["BaseNode"], super().__new__(mcs, name, bases, attrs))
|
||||
|
||||
# Ignore the `BaseNode` class itself
|
||||
if attrs.get("__module__", None) == "django_components.node":
|
||||
return cls
|
||||
|
||||
if not hasattr(cls, "tag"):
|
||||
raise ValueError(f"Node {name} must have a 'tag' attribute")
|
||||
|
||||
# Skip if already wrapped
|
||||
orig_render = cls.render
|
||||
if getattr(orig_render, "_djc_wrapped", False):
|
||||
return cls
|
||||
|
||||
signature = inspect.signature(orig_render)
|
||||
|
||||
# A full signature of `BaseNode.render()` may look like this:
|
||||
#
|
||||
# `def render(self, context: Context, name: str, **kwargs) -> str:`
|
||||
#
|
||||
# We need to remove the first two parameters from the signature.
|
||||
# So we end up only with
|
||||
#
|
||||
# `def render(name: str, **kwargs) -> str:`
|
||||
#
|
||||
# And this becomes the signature that defines what params the template tag accepts, e.g.
|
||||
#
|
||||
# `{% component name="John" age=20 %}`
|
||||
if len(signature.parameters) < 2:
|
||||
raise TypeError(f"`render()` method of {name} must have at least two parameters")
|
||||
|
||||
validation_params = list(signature.parameters.values())
|
||||
validation_params = validation_params[2:]
|
||||
validation_signature = signature.replace(parameters=validation_params)
|
||||
|
||||
# NOTE: This is used for creating docs by `_format_tag_signature()` in `docs/scripts/reference.py`
|
||||
cls._signature = validation_signature
|
||||
|
||||
@functools.wraps(orig_render)
|
||||
def wrapper_render(self: "BaseNode", context: Context) -> str:
|
||||
trace_msg("RENDR", self.tag, self.node_id)
|
||||
|
||||
resolved_params = resolve_params(self.tag, self.params, context)
|
||||
|
||||
# Template tags may accept kwargs that are not valid Python identifiers, e.g.
|
||||
# `{% component data-id="John" class="pt-4" :href="myVar" %}`
|
||||
#
|
||||
# Passing them in is still useful, as user may want to pass in arbitrary data
|
||||
# to their `{% component %}` tags as HTML attributes. E.g. example below passes
|
||||
# `data-id`, `class` and `:href` as HTML attributes to the `<div>` element:
|
||||
#
|
||||
# ```py
|
||||
# class MyComponent(Component):
|
||||
# def get_context_data(self, name: str, **kwargs: Any) -> str:
|
||||
# return {
|
||||
# "name": name,
|
||||
# "attrs": kwargs,
|
||||
# }
|
||||
# template = """
|
||||
# <div {% html_attrs attrs %}>
|
||||
# {{ name }}
|
||||
# </div>
|
||||
# """
|
||||
# ```
|
||||
#
|
||||
# HOWEVER, these kwargs like `data-id`, `class` and `:href` may not be valid Python identifiers,
|
||||
# or like in case of `class`, may be a reserved keyword. Thus, we cannot pass them in to the `render()`
|
||||
# method as regular kwargs, because that will raise Python's native errors like
|
||||
# `SyntaxError: invalid syntax`. E.g.
|
||||
#
|
||||
# ```python
|
||||
# def render(self, context: Context, data-id: str, class: str, :href: str) -> str:
|
||||
# ```
|
||||
#
|
||||
# So instead, we filter out any invalid kwargs, and pass those in through a dictionary spread.
|
||||
# We can do so, because following is allowed in Python:
|
||||
#
|
||||
# ```python
|
||||
# def x(**kwargs):
|
||||
# print(kwargs)
|
||||
#
|
||||
# d = {"data-id": 1}
|
||||
# x(**d)
|
||||
# # {'data-id': 1}
|
||||
# ```
|
||||
#
|
||||
# See https://github.com/EmilStenstrom/django-components/discussions/900#discussioncomment-11859970
|
||||
resolved_params_without_invalid_kwargs = []
|
||||
invalid_kwargs = {}
|
||||
did_see_special_kwarg = False
|
||||
for resolved_param in resolved_params:
|
||||
key = resolved_param.key
|
||||
if key is not None:
|
||||
# Case: Special kwargs
|
||||
if not key.isidentifier() or keyword.iskeyword(key):
|
||||
# NOTE: Since these keys are not part of signature validation,
|
||||
# we have to check ourselves if any args follow them.
|
||||
invalid_kwargs[key] = resolved_param.value
|
||||
did_see_special_kwarg = True
|
||||
else:
|
||||
# Case: Regular kwargs
|
||||
resolved_params_without_invalid_kwargs.append(resolved_param)
|
||||
else:
|
||||
# Case: Regular positional args
|
||||
if did_see_special_kwarg:
|
||||
raise SyntaxError("positional argument follows keyword argument")
|
||||
resolved_params_without_invalid_kwargs.append(resolved_param)
|
||||
|
||||
# Validate the params against the signature
|
||||
#
|
||||
# Unlike the call to `apply_params_in_original_order()` further below, this uses a signature
|
||||
# that has been stripped of the `self` and `context` parameters. E.g.
|
||||
#
|
||||
# `def render(name: str, **kwargs: Any) -> None`
|
||||
#
|
||||
# If there are any errors in the input, this will trigger Python's
|
||||
# native error handling (e.g. `TypeError: render() got multiple values for argument 'context'`)
|
||||
#
|
||||
# But because we stripped the two parameters, then these errors will correctly
|
||||
# point to the actual error in the template tag.
|
||||
#
|
||||
# E.g. if we supplied one too many positional args,
|
||||
# `{% mytag "John" 20 %}`
|
||||
#
|
||||
# Then without stripping the two parameters, then the error could be:
|
||||
# `render() takes from 3 positional arguments but 4 were given`
|
||||
#
|
||||
# Which is confusing, because we supplied only two positional args.
|
||||
#
|
||||
# But cause we stripped the two parameters, then the error will be:
|
||||
# `render() takes from 1 positional arguments but 2 were given`
|
||||
validate_params(self.tag, validation_signature, resolved_params_without_invalid_kwargs, invalid_kwargs)
|
||||
|
||||
# The code below calls the `orig_render()` function like so:
|
||||
# `orig_render(self, context, arg1, arg2, kwarg1=val1, kwarg2=val2)`
|
||||
#
|
||||
# So it's called in the same order as what was passed to the template tag, e.g.
|
||||
# `{% component arg1 arg2 kwarg1=val1 kwarg2=val2 %}`
|
||||
#
|
||||
# That's why we don't simply spread all args and kwargs as `*args, **kwargs`,
|
||||
# because then Python's validation wouldn't catch such errors.
|
||||
resolved_params_with_context = [
|
||||
TagParam(key=None, value=self),
|
||||
TagParam(key=None, value=context),
|
||||
] + resolved_params_without_invalid_kwargs
|
||||
output = apply_params_in_original_order(orig_render, resolved_params_with_context, invalid_kwargs)
|
||||
|
||||
trace_msg("RENDR", self.tag, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
# Wrap cls.render() so we resolve the args and kwargs and pass them to the
|
||||
# actual render method.
|
||||
cls.render = wrapper_render # type: ignore
|
||||
cls.render._djc_wrapped = True # type: ignore
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
class BaseNode(Node, metaclass=NodeMeta):
|
||||
"""
|
||||
Node class for all django-components custom template tags.
|
||||
|
||||
This class has a dual role:
|
||||
|
||||
1. It declares how a particular template tag should be parsed - By setting the
|
||||
[`tag`](../api#django_components.BaseNode.tag),
|
||||
[`end_tag`](../api#django_components.BaseNode.end_tag),
|
||||
and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags)
|
||||
attributes:
|
||||
|
||||
```python
|
||||
class SlotNode(BaseNode):
|
||||
tag = "slot"
|
||||
end_tag = "endslot"
|
||||
allowed_flags = ["required"]
|
||||
```
|
||||
|
||||
This will allow the template tag `{% slot %}` to be used like this:
|
||||
|
||||
```django
|
||||
{% slot required %} ... {% endslot %}
|
||||
```
|
||||
|
||||
2. The [`render`](../api#django_components.BaseNode.render) method is
|
||||
the actual implementation of the template tag.
|
||||
|
||||
This is where the tag's logic is implemented:
|
||||
|
||||
```python
|
||||
class MyNode(BaseNode):
|
||||
tag = "mynode"
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
This will allow the template tag `{% mynode %}` to be used like this:
|
||||
|
||||
```django
|
||||
{% mynode name="John" %}
|
||||
```
|
||||
|
||||
The template tag accepts parameters as defined on the
|
||||
[`render`](../api#django_components.BaseNode.render) method's signature.
|
||||
|
||||
For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
|
||||
"""
|
||||
|
||||
# #####################################
|
||||
# PUBLIC API (Configurable by users)
|
||||
# #####################################
|
||||
|
||||
tag: str
|
||||
"""
|
||||
The tag name.
|
||||
|
||||
E.g. `"component"` or `"slot"` will make this class match
|
||||
template tags `{% component %}` or `{% slot %}`.
|
||||
"""
|
||||
|
||||
end_tag: Optional[str] = None
|
||||
"""
|
||||
The end tag name.
|
||||
|
||||
E.g. `"endcomponent"` or `"endslot"` will make this class match
|
||||
template tags `{% endcomponent %}` or `{% endslot %}`.
|
||||
|
||||
If not set, then this template tag has no end tag.
|
||||
|
||||
So instead of `{% component %} ... {% endcomponent %}`, you'd use only
|
||||
`{% component %}`.
|
||||
"""
|
||||
|
||||
allowed_flags: Optional[List[str]] = None
|
||||
"""
|
||||
The allowed flags for this tag.
|
||||
|
||||
E.g. `["required"]` will allow this tag to be used like `{% slot required %}`.
|
||||
"""
|
||||
|
||||
def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
|
||||
"""
|
||||
Render the node. This method is meant to be overridden by subclasses.
|
||||
|
||||
The signature of this function decides what input the template tag accepts.
|
||||
|
||||
The `render()` method MUST accept a `context` argument. Any arguments after that
|
||||
will be part of the tag's input parameters.
|
||||
|
||||
So if you define a `render` method like this:
|
||||
|
||||
```python
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> str:
|
||||
```
|
||||
|
||||
Then the tag will require the `name` parameter, and accept any extra keyword arguments:
|
||||
|
||||
```django
|
||||
{% component name="John" age=20 %}
|
||||
```
|
||||
"""
|
||||
return self.nodelist.render(context)
|
||||
|
||||
# #####################################
|
||||
# MISC
|
||||
# #####################################
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
params: TagParams,
|
||||
params: List[TagAttr],
|
||||
flags: Optional[Dict[str, bool]] = None,
|
||||
nodelist: Optional[NodeList] = None,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
self.params = params
|
||||
self.flags = flags or {flag: False for flag in self.allowed_flags or []}
|
||||
self.nodelist = nodelist or NodeList()
|
||||
self.node_id = node_id or gen_id()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}."
|
||||
f" Flags: {self.active_flags}>"
|
||||
)
|
||||
|
||||
@property
|
||||
def active_flags(self) -> List[str]:
|
||||
"""Flags that were set for this specific instance."""
|
||||
flags = []
|
||||
for flag, value in self.flags.items():
|
||||
if value:
|
||||
flags.append(flag)
|
||||
return flags
|
||||
|
||||
@classmethod
|
||||
def parse(cls, parser: Parser, token: Token, **kwargs: Any) -> "BaseNode":
|
||||
"""
|
||||
This function is what is passed to Django's `Library.tag()` when
|
||||
[registering the tag](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag).
|
||||
|
||||
In other words, this method is called by Django's template parser when we encounter
|
||||
a tag that matches this node's tag, e.g. `{% component %}` or `{% slot %}`.
|
||||
|
||||
To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register).
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
|
||||
|
||||
trace_msg("PARSE", cls.tag, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
node = cls(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
flags=tag.flags,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", cls.tag, tag_id, "...Done!")
|
||||
return node
|
||||
|
||||
@classmethod
|
||||
def register(cls, library: Library) -> None:
|
||||
"""
|
||||
A convenience method for registering the tag with the given library.
|
||||
|
||||
```python
|
||||
class MyNode(BaseNode):
|
||||
tag = "mynode"
|
||||
|
||||
MyNode.register(library)
|
||||
```
|
||||
|
||||
Allows you to then use the node in templates like so:
|
||||
|
||||
```django
|
||||
{% load mylibrary %}
|
||||
{% mynode %}
|
||||
```
|
||||
"""
|
||||
library.tag(cls.tag, cls.parse)
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, library: Library) -> None:
|
||||
"""Unregisters the node from the given library."""
|
||||
library.tags.pop(cls.tag, None)
|
||||
|
||||
|
||||
def template_tag(
|
||||
library: Library,
|
||||
tag: str,
|
||||
end_tag: Optional[str] = None,
|
||||
allowed_flags: Optional[List[str]] = None,
|
||||
) -> Callable[[Callable], Callable]:
|
||||
"""
|
||||
A simplified version of creating a template tag based on [`BaseNode`](../api#django_components.BaseNode).
|
||||
|
||||
Instead of defining the whole class, you can just define the
|
||||
[`render()`](../api#django_components.BaseNode.render) method.
|
||||
|
||||
```python
|
||||
from django.template import Context, Library
|
||||
from django_components import BaseNode, template_tag
|
||||
|
||||
library = Library()
|
||||
|
||||
@template_tag(
|
||||
library,
|
||||
tag="mytag",
|
||||
end_tag="endmytag",
|
||||
allowed_flags=["required"],
|
||||
)
|
||||
def mytag(node: BaseNode, context: Context, name: str, **kwargs: Any) -> str:
|
||||
return f"Hello, {name}!"
|
||||
```
|
||||
|
||||
This will allow the template tag `{% mytag %}` to be used like this:
|
||||
|
||||
```django
|
||||
{% mytag name="John" %}
|
||||
{% mytag name="John" required %} ... {% endmytag %}
|
||||
```
|
||||
|
||||
The given function will be wrapped in a class that inherits from [`BaseNode`](../api#django_components.BaseNode).
|
||||
|
||||
And this class will be registered with the given library.
|
||||
|
||||
The function MUST accept at least two positional arguments: `node` and `context`
|
||||
|
||||
- `node` is the [`BaseNode`](../api#django_components.BaseNode) instance.
|
||||
- `context` is the [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context)
|
||||
of the template.
|
||||
|
||||
Any extra parameters defined on this function will be part of the tag's input parameters.
|
||||
|
||||
For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
subcls_name = fn.__name__.title().replace("_", "").replace("-", "") + "Node"
|
||||
|
||||
try:
|
||||
subcls: Type[BaseNode] = type(
|
||||
subcls_name,
|
||||
(BaseNode,),
|
||||
{
|
||||
"tag": tag,
|
||||
"end_tag": end_tag,
|
||||
"allowed_flags": allowed_flags or [],
|
||||
"render": fn,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
raise e.__class__(f"Failed to create node class in 'template_tag()' for '{fn.__name__}'") from e
|
||||
|
||||
subcls.register(library)
|
||||
|
||||
# Allow to access the node class
|
||||
fn._node = subcls # type: ignore[attr-defined]
|
||||
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
|
|
@ -1,42 +1,87 @@
|
|||
from typing import Dict, Optional, Tuple
|
||||
from typing import Any
|
||||
|
||||
from django.template import Context
|
||||
from django.template.base import NodeList
|
||||
from django.utils.safestring import SafeString
|
||||
|
||||
from django_components.context import set_provided_context_var
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.template_tag import TagParams
|
||||
|
||||
PROVIDE_NAME_KWARG = "name"
|
||||
|
||||
|
||||
class ProvideNode(BaseNode):
|
||||
"""
|
||||
Implementation of the `{% provide %}` tag.
|
||||
For more info see `Component.inject`.
|
||||
The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject).
|
||||
Pass kwargs to this tag to define the provider's data.
|
||||
Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
|
||||
with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
|
||||
or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Provider name. This is the name you will then use in
|
||||
[`Component.inject()`](../api#django_components.Component.inject).
|
||||
- `**kwargs`: Any extra kwargs will be passed as the provided data.
|
||||
|
||||
**Example:**
|
||||
|
||||
Provide the "user_data" in parent component:
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% provide "user_data" user=user %}
|
||||
{% component "child" / %}
|
||||
{% endprovide %}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, user: User):
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
|
||||
we can request the "user_data" using `Component.inject("user_data")`:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
User is: {{ user }}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self):
|
||||
user = self.inject("user_data").user
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes
|
||||
when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
✅ Do this
|
||||
```python
|
||||
user = self.inject("user_data").user
|
||||
```
|
||||
|
||||
❌ Don't do this
|
||||
```python
|
||||
user = self.inject("user_data")["user"]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
trace_id: str,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
|
||||
|
||||
self.trace_id = trace_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Provide Node: {self.node_id}. Contents: {repr(self.nodelist)}>"
|
||||
|
||||
def render(self, context: Context) -> SafeString:
|
||||
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id)
|
||||
|
||||
name, kwargs = self.resolve_kwargs(context)
|
||||
tag = "provide"
|
||||
end_tag = "endprovide"
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
|
||||
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
|
||||
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
|
||||
# add the provided kwargs into the Context.
|
||||
|
@ -46,14 +91,4 @@ class ProvideNode(BaseNode):
|
|||
|
||||
output = self.nodelist.render(context)
|
||||
|
||||
trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]:
|
||||
args, kwargs = self.params.resolve(context)
|
||||
name = kwargs.pop(PROVIDE_NAME_KWARG, None)
|
||||
|
||||
if not name:
|
||||
raise RuntimeError("Provide tag kwarg 'name' is missing")
|
||||
|
||||
return (name, kwargs)
|
||||
|
|
|
@ -13,7 +13,6 @@ from typing import (
|
|||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
|
@ -33,9 +32,7 @@ from django_components.context import (
|
|||
_ROOT_CTX_CONTEXT_KEY,
|
||||
)
|
||||
from django_components.node import BaseNode
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import get_last_index, is_identifier
|
||||
from django_components.util.template_tag import TagParams
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
|
@ -150,34 +147,132 @@ class ComponentSlotContext:
|
|||
|
||||
|
||||
class SlotNode(BaseNode):
|
||||
"""Node corresponding to `{% slot %}`"""
|
||||
"""
|
||||
Slot tag marks a place inside a component where content can be inserted
|
||||
from outside.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
trace_id: str,
|
||||
node_id: Optional[str] = None,
|
||||
is_required: bool = False,
|
||||
is_default: bool = False,
|
||||
):
|
||||
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
|
||||
[Learn more](../../concepts/fundamentals/slots) about using slots.
|
||||
|
||||
self.is_required = is_required
|
||||
self.is_default = is_default
|
||||
self.trace_id = trace_id
|
||||
This is similar to slots as seen in
|
||||
[Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
|
||||
[Vue](https://vuejs.org/guide/components/slots.html)
|
||||
or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
|
||||
|
||||
@property
|
||||
def active_flags(self) -> List[str]:
|
||||
flags = []
|
||||
if self.is_required:
|
||||
flags.append("required")
|
||||
if self.is_default:
|
||||
flags.append("default")
|
||||
return flags
|
||||
**Args:**
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Slot Node: {self.node_id}. Contents: {repr(self.nodelist)}. Options: {self.active_flags}>"
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- `default`: Optional flag. If there is a default slot, you can pass the component slot content
|
||||
without using the [`{% fill %}`](#fill) tag. See
|
||||
[Default slot](../../concepts/fundamentals/slots#default-slot)
|
||||
- `required`: Optional flag. Will raise an error if a slot is required but not given.
|
||||
- `**kwargs`: Any extra kwargs will be passed as the slot data.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
<aside>
|
||||
{% slot "sidebar" required / %}
|
||||
</aside>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% component "child" %}
|
||||
{% fill "content" %}
|
||||
🗞️📰
|
||||
{% endfill %}
|
||||
|
||||
{% fill "sidebar" %}
|
||||
🍷🧉🍾
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Passing data to slots
|
||||
|
||||
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
|
||||
tag via fill's `data` kwarg:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{# Passing data to the slot #}
|
||||
{% slot "content" user=user %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot data #}
|
||||
{% component "child" %}
|
||||
{% fill "content" data="data" %}
|
||||
<div class="wrapper-class">
|
||||
{{ data.user }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Accessing default slot content
|
||||
|
||||
The content between the `{% slot %}..{% endslot %}` tags is the default content that
|
||||
will be rendered if no fill is given for the slot.
|
||||
|
||||
This default content can then be accessed from within the [`{% fill %}`](#fill) tag using
|
||||
the fill's `default` kwarg.
|
||||
This is useful if you need to wrap / prepend / append the original slot's content.
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" %}
|
||||
This is default content!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot's default content #}
|
||||
{% component "child" %}
|
||||
{% fill "content" default="default" %}
|
||||
{{ default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
"""
|
||||
|
||||
tag = "slot"
|
||||
end_tag = "endslot"
|
||||
allowed_flags = [SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD]
|
||||
|
||||
# NOTE:
|
||||
# In the current implementation, the slots are resolved only at the render time.
|
||||
|
@ -200,9 +295,7 @@ class SlotNode(BaseNode):
|
|||
# for unfilled slots (rendered slots WILL raise an error if the fill is missing).
|
||||
# 2. User may provide extra fills, but these may belong to slots we haven't
|
||||
# encountered in this render run. So we CANNOT say which ones are extra.
|
||||
def render(self, context: Context) -> SafeString:
|
||||
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id)
|
||||
|
||||
def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
|
||||
# Do not render `{% slot %}` tags within the `{% component %} .. {% endcomponent %}` tags
|
||||
# at the fill discovery stage (when we render the component's body to determine if the body
|
||||
# is a default slot, or contains named slots).
|
||||
|
@ -217,10 +310,12 @@ class SlotNode(BaseNode):
|
|||
)
|
||||
|
||||
component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
|
||||
slot_name, kwargs = self.resolve_kwargs(context, component_ctx.component_name)
|
||||
slot_name = name
|
||||
is_default = self.flags[SLOT_DEFAULT_KEYWORD]
|
||||
is_required = self.flags[SLOT_REQUIRED_KEYWORD]
|
||||
|
||||
# Check for errors
|
||||
if self.is_default and not component_ctx.is_dynamic_component:
|
||||
if is_default and not component_ctx.is_dynamic_component:
|
||||
# Allow one slot to be marked as 'default', or multiple slots but with
|
||||
# the same name. If there is multiple 'default' slots with different names, raise.
|
||||
default_slot_name = component_ctx.default_slot
|
||||
|
@ -249,7 +344,7 @@ class SlotNode(BaseNode):
|
|||
|
||||
# If slot is marked as 'default', we use the name 'default' for the fill,
|
||||
# IF SUCH FILL EXISTS. Otherwise, we use the slot's name.
|
||||
if self.is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
|
||||
if is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
|
||||
fill_name = DEFAULT_SLOT_KEY
|
||||
else:
|
||||
fill_name = slot_name
|
||||
|
@ -284,7 +379,7 @@ class SlotNode(BaseNode):
|
|||
# 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.
|
||||
if self.is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
|
||||
if is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
|
||||
msg = (
|
||||
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
|
||||
f"yet no fill is provided. Check template.'"
|
||||
|
@ -349,7 +444,6 @@ class SlotNode(BaseNode):
|
|||
# the render function ALWAYS receives them.
|
||||
output = slot_fill.slot(used_ctx, kwargs, slot_ref)
|
||||
|
||||
trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!")
|
||||
return output
|
||||
|
||||
def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
|
||||
|
@ -368,99 +462,150 @@ class SlotNode(BaseNode):
|
|||
else:
|
||||
raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'")
|
||||
|
||||
def resolve_kwargs(
|
||||
self,
|
||||
context: Context,
|
||||
component_name: Optional[str] = None,
|
||||
) -> Tuple[str, Dict[str, Optional[str]]]:
|
||||
_, kwargs = self.params.resolve(context)
|
||||
name = kwargs.pop(SLOT_NAME_KWARG, None)
|
||||
|
||||
if not name:
|
||||
raise RuntimeError(f"Slot tag kwarg 'name' is missing in component {component_name}")
|
||||
|
||||
return (name, kwargs)
|
||||
|
||||
|
||||
class FillNode(BaseNode):
|
||||
"""Node corresponding to `{% fill %}`"""
|
||||
"""
|
||||
Use this tag to insert content into component's slots.
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nodelist: NodeList,
|
||||
params: TagParams,
|
||||
trace_id: str,
|
||||
node_id: Optional[str] = None,
|
||||
):
|
||||
super().__init__(nodelist=nodelist, params=params, node_id=node_id)
|
||||
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
|
||||
Runtime checks should prohibit other usages.
|
||||
|
||||
self.trace_id = trace_id
|
||||
**Args:**
|
||||
|
||||
def render(self, context: Context) -> str:
|
||||
if _is_extracting_fill(context):
|
||||
self._extract_fill(context)
|
||||
return ""
|
||||
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
|
||||
the default slot.
|
||||
- `default` (str, optional): This argument allows you to access the original content of the slot
|
||||
under the specified variable name. See
|
||||
[Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots)
|
||||
- `data` (str, optional): This argument allows you to access the data passed to the slot
|
||||
under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots)
|
||||
|
||||
raise TemplateSyntaxError(
|
||||
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
|
||||
"Make sure that the {% fill %} tags are nested within {% component %} tags."
|
||||
)
|
||||
**Examples:**
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
|
||||
Basic usage:
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
def resolve_kwargs(self, context: Context) -> "FillWithData":
|
||||
_, kwargs = self.params.resolve(context)
|
||||
### Accessing slot's default content with the `default` kwarg
|
||||
|
||||
name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False)
|
||||
default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG)
|
||||
data_var = self._process_kwarg(kwargs, SLOT_DATA_KWARG)
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" default="default_pag" %}
|
||||
<div class="my-class">
|
||||
{{ default_pag }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's data with the `data` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" pages=pages %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" data="slot_data" %}
|
||||
{% for page in slot_data.pages %}
|
||||
<a href="{{ page.link }}">
|
||||
{{ page.index }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot data and default content on the default slot
|
||||
|
||||
To access slot data and the default slot content on the default slot,
|
||||
use `{% fill %}` with `name` set to `"default"`:
|
||||
|
||||
```django
|
||||
{% component "button" %}
|
||||
{% fill name="default" data="slot_data" default="default_slot" %}
|
||||
You clicked me {{ slot_data.count }} times!
|
||||
{{ default_slot }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
"""
|
||||
|
||||
tag = "fill"
|
||||
end_tag = "endfill"
|
||||
allowed_flags = []
|
||||
|
||||
def render(self, context: Context, name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> str:
|
||||
if not _is_extracting_fill(context):
|
||||
raise TemplateSyntaxError(
|
||||
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
|
||||
"Make sure that the {% fill %} tags are nested within {% component %} tags."
|
||||
)
|
||||
|
||||
# Validate inputs
|
||||
if not isinstance(name, str):
|
||||
raise TemplateSyntaxError(f"Fill tag '{SLOT_NAME_KWARG}' kwarg must resolve to a string, got {name}")
|
||||
|
||||
if data_var is not None and not isinstance(data_var, str):
|
||||
raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data_var}")
|
||||
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}")
|
||||
if not is_identifier(data):
|
||||
raise RuntimeError(
|
||||
f"Fill tag kwarg '{SLOT_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
|
||||
)
|
||||
|
||||
if default_var is not None and not isinstance(default_var, str):
|
||||
raise TemplateSyntaxError(
|
||||
f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default_var}"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
if not is_identifier(default):
|
||||
raise RuntimeError(
|
||||
f"Fill tag kwarg '{SLOT_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_var and default_var and data_var == default_var:
|
||||
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}=...)"
|
||||
)
|
||||
|
||||
return FillWithData(
|
||||
fill_data = FillWithData(
|
||||
fill=self,
|
||||
name=name,
|
||||
default_var=default_var,
|
||||
data_var=data_var,
|
||||
default_var=default,
|
||||
data_var=data,
|
||||
extra_context={},
|
||||
)
|
||||
|
||||
def _process_kwarg(
|
||||
self,
|
||||
kwargs: Dict[str, Any],
|
||||
key: str,
|
||||
identifier: bool = True,
|
||||
) -> Optional[Any]:
|
||||
if key not in kwargs:
|
||||
return None
|
||||
self._extract_fill(context, fill_data)
|
||||
|
||||
value = kwargs[key]
|
||||
if value is None:
|
||||
return None
|
||||
return ""
|
||||
|
||||
if identifier and not is_identifier(value):
|
||||
raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'")
|
||||
|
||||
return value
|
||||
|
||||
def _extract_fill(self, context: Context) -> None:
|
||||
def _extract_fill(self, context: Context, data: "FillWithData") -> None:
|
||||
# `FILL_GEN_CONTEXT_KEY` is only ever set when we are rendering content between the
|
||||
# `{% component %}...{% endcomponent %}` tags. This is done in order to collect all fill tags.
|
||||
# E.g.
|
||||
|
@ -474,10 +619,6 @@ class FillNode(BaseNode):
|
|||
if collected_fills is None:
|
||||
return
|
||||
|
||||
# NOTE: It's important that we use the context given to the fill tag, so it accounts
|
||||
# for any variables set via e.g. for-loops.
|
||||
data = self.resolve_kwargs(context)
|
||||
|
||||
# To allow using variables which were defined within the template and to which
|
||||
# the `{% fill %}` tag has access, we need to capture those variables too.
|
||||
#
|
||||
|
|
|
@ -1,707 +1,43 @@
|
|||
# Notes on documentation:
|
||||
# - For intuitive use via Python imports, keep the tag names same as the function name.
|
||||
# E.g. so if the tag name is `slot`, one can also do
|
||||
# `from django_components.templatetags.component_tags import slot`
|
||||
#
|
||||
# - All tags are defined using `@register.tag`. Do NOT use `@register.simple_tag`.
|
||||
# The reason for this is so that we use `TagSpec` and `parse_template_tag`. When generating
|
||||
# documentation, we extract the `TagSpecs` to be able to describe each tag's function signature.
|
||||
#
|
||||
# - Use `with_tag_spec` for defining `TagSpecs`. This will make it available to the function
|
||||
# as the last argument, and will also set the `TagSpec` instance to `fn._tag_spec`.
|
||||
# During documentation generation, we access the `fn._tag_spec`.
|
||||
|
||||
import inspect
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
import django.template
|
||||
from django.template.base import Parser, TextNode, Token
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.safestring import SafeString, mark_safe
|
||||
|
||||
from django_components.attributes import HtmlAttrsNode
|
||||
from django_components.component import COMP_ONLY_FLAG, ComponentNode
|
||||
from django_components.component_registry import ComponentRegistry
|
||||
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
|
||||
from django_components.component import ComponentNode
|
||||
from django_components.dependencies import ComponentCssDependenciesNode, ComponentJsDependenciesNode
|
||||
from django_components.provide import ProvideNode
|
||||
from django_components.slots import SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD, FillNode, SlotNode
|
||||
from django_components.tag_formatter import get_tag_formatter
|
||||
from django_components.util.logger import trace_msg
|
||||
from django_components.util.misc import gen_id
|
||||
from django_components.util.template_tag import TagSpec, parse_template_tag, with_tag_spec
|
||||
from django_components.slots import FillNode, SlotNode
|
||||
|
||||
# NOTE: Variable name `register` is required by Django to recognize this as a template tag library
|
||||
# See https://docs.djangoproject.com/en/dev/howto/custom-template-tags
|
||||
register = django.template.Library()
|
||||
|
||||
|
||||
def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
|
||||
"""Marks location where CSS link and JS script tags should be rendered."""
|
||||
if type == "css":
|
||||
placeholder = CSS_DEPENDENCY_PLACEHOLDER
|
||||
elif type == "js":
|
||||
placeholder = JS_DEPENDENCY_PLACEHOLDER
|
||||
else:
|
||||
raise TemplateSyntaxError(
|
||||
f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
|
||||
)
|
||||
|
||||
return TextNode(mark_safe(placeholder))
|
||||
|
||||
|
||||
def component_dependencies_signature() -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("component_css_dependencies")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="component_css_dependencies",
|
||||
end_tag=None, # inline-only
|
||||
signature=inspect.Signature.from_callable(component_dependencies_signature),
|
||||
)
|
||||
)
|
||||
def component_css_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
|
||||
"""
|
||||
Marks location where CSS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted into the `<head>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
|
||||
are by default inserted into the `<head>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
# Parse to check that the syntax is valid
|
||||
parse_template_tag(parser, token, tag_spec)
|
||||
return _component_dependencies("css")
|
||||
|
||||
|
||||
@register.tag("component_js_dependencies")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="component_js_dependencies",
|
||||
end_tag=None, # inline-only
|
||||
signature=inspect.Signature.from_callable(component_dependencies_signature),
|
||||
)
|
||||
)
|
||||
def component_js_dependencies(parser: Parser, token: Token, tag_spec: TagSpec) -> TextNode:
|
||||
"""
|
||||
Marks location where JS link tags should be rendered after the whole HTML has been generated.
|
||||
|
||||
Generally, this should be inserted at the end of the `<body>` tag of the HTML.
|
||||
|
||||
If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
|
||||
are by default inserted at the end of the `<body>` tag of the HTML. (See
|
||||
[JS and CSS output locations](../../concepts/advanced/rendering_js_css/#js-and-css-output-locations))
|
||||
|
||||
Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
|
||||
If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
|
||||
"""
|
||||
# Parse to check that the syntax is valid
|
||||
parse_template_tag(parser, token, tag_spec)
|
||||
return _component_dependencies("js")
|
||||
|
||||
|
||||
def slot_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("slot")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="slot",
|
||||
end_tag="endslot",
|
||||
signature=inspect.Signature.from_callable(slot_signature),
|
||||
flags=[SLOT_DEFAULT_KEYWORD, SLOT_REQUIRED_KEYWORD],
|
||||
)
|
||||
)
|
||||
def slot(parser: Parser, token: Token, tag_spec: TagSpec) -> SlotNode:
|
||||
"""
|
||||
Slot tag marks a place inside a component where content can be inserted
|
||||
from outside.
|
||||
|
||||
[Learn more](../../concepts/fundamentals/slots) about using slots.
|
||||
|
||||
This is similar to slots as seen in
|
||||
[Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
|
||||
[Vue](https://vuejs.org/guide/components/slots.html)
|
||||
or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- `default`: Optional flag. If there is a default slot, you can pass the component slot content
|
||||
without using the [`{% fill %}`](#fill) tag. See
|
||||
[Default slot](../../concepts/fundamentals/slots#default-slot)
|
||||
- `required`: Optional flag. Will raise an error if a slot is required but not given.
|
||||
- `**kwargs`: Any extra kwargs will be passed as the slot data.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" default %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
<aside>
|
||||
{% slot "sidebar" required / %}
|
||||
</aside>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% component "child" %}
|
||||
{% fill "content" %}
|
||||
🗞️📰
|
||||
{% endfill %}
|
||||
|
||||
{% fill "sidebar" %}
|
||||
🍷🧉🍾
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Passing data to slots
|
||||
|
||||
Any extra kwargs will be considered as slot data, and will be accessible in the [`{% fill %}`](#fill)
|
||||
tag via fill's `data` kwarg:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{# Passing data to the slot #}
|
||||
{% slot "content" user=user %}
|
||||
This is shown if not overriden!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot data #}
|
||||
{% component "child" %}
|
||||
{% fill "content" data="data" %}
|
||||
<div class="wrapper-class">
|
||||
{{ data.user }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
### Accessing default slot content
|
||||
|
||||
The content between the `{% slot %}..{% endslot %}` tags is the default content that
|
||||
will be rendered if no fill is given for the slot.
|
||||
|
||||
This default content can then be accessed from within the [`{% fill %}`](#fill) tag using
|
||||
the fill's `default` kwarg.
|
||||
This is useful if you need to wrap / prepend / append the original slot's content.
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% slot "content" %}
|
||||
This is default content!
|
||||
{% endslot %}
|
||||
</div>
|
||||
\"\"\"
|
||||
```
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
{# Parent can access the slot's default content #}
|
||||
{% component "child" %}
|
||||
{% fill "content" default="default" %}
|
||||
{{ default }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
\"\"\"
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
trace_id = f"slot-id-{tag_id}"
|
||||
trace_msg("PARSE", "SLOT", trace_id, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
slot_node = SlotNode(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
is_required=tag.flags[SLOT_REQUIRED_KEYWORD],
|
||||
is_default=tag.flags[SLOT_DEFAULT_KEYWORD],
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "SLOT", trace_id, tag_id, "...Done!")
|
||||
return slot_node
|
||||
|
||||
|
||||
def fill_signature(name: str, *, data: Optional[str] = None, default: Optional[str] = None) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("fill")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="fill",
|
||||
end_tag="endfill",
|
||||
signature=inspect.Signature.from_callable(fill_signature),
|
||||
)
|
||||
)
|
||||
def fill(parser: Parser, token: Token, tag_spec: TagSpec) -> FillNode:
|
||||
"""
|
||||
Use this tag to insert content into component's slots.
|
||||
|
||||
`{% fill %}` tag may be used only within a `{% component %}..{% endcomponent %}` block.
|
||||
Runtime checks should prohibit other usages.
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
|
||||
the default slot.
|
||||
- `default` (str, optional): This argument allows you to access the original content of the slot
|
||||
under the specified variable name. See
|
||||
[Accessing original content of slots](../../concepts/fundamentals/slots#accessing-original-content-of-slots)
|
||||
- `data` (str, optional): This argument allows you to access the data passed to the slot
|
||||
under the specified variable name. See [Scoped slots](../../concepts/fundamentals/slots#scoped-slots)
|
||||
|
||||
**Examples:**
|
||||
|
||||
Basic usage:
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's default content with the `default` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" default="default_pag" %}
|
||||
<div class="my-class">
|
||||
{{ default_pag }}
|
||||
</div>
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot's data with the `data` kwarg
|
||||
|
||||
```django
|
||||
{# my_table.html #}
|
||||
<table>
|
||||
...
|
||||
{% slot "pagination" pages=pages %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endslot %}
|
||||
</table>
|
||||
```
|
||||
|
||||
```django
|
||||
{% component "my_table" %}
|
||||
{% fill "pagination" data="slot_data" %}
|
||||
{% for page in slot_data.pages %}
|
||||
<a href="{{ page.link }}">
|
||||
{{ page.index }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Accessing slot data and default content on the default slot
|
||||
|
||||
To access slot data and the default slot content on the default slot,
|
||||
use `{% fill %}` with `name` set to `"default"`:
|
||||
|
||||
```django
|
||||
{% component "button" %}
|
||||
{% fill name="default" data="slot_data" default="default_slot" %}
|
||||
You clicked me {{ slot_data.count }} times!
|
||||
{{ default_slot }}
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
trace_id = f"fill-id-{tag_id}"
|
||||
trace_msg("PARSE", "FILL", trace_id, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
fill_node = FillNode(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "FILL", trace_id, tag_id, "...Done!")
|
||||
return fill_node
|
||||
|
||||
|
||||
def component_signature(*args: Any, **kwargs: Any) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="component",
|
||||
end_tag="endcomponent",
|
||||
signature=inspect.Signature.from_callable(component_signature),
|
||||
flags=[COMP_ONLY_FLAG],
|
||||
)
|
||||
)
|
||||
def component(
|
||||
parser: Parser,
|
||||
token: Token,
|
||||
registry: ComponentRegistry,
|
||||
tag_name: str,
|
||||
tag_spec: TagSpec,
|
||||
) -> ComponentNode:
|
||||
"""
|
||||
Renders one of the components that was previously registered with
|
||||
[`@register()`](./api.md#django_components.register)
|
||||
decorator.
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Registered name of the component to render
|
||||
- All other args and kwargs are defined based on the component itself.
|
||||
|
||||
If you defined a component `"my_table"`
|
||||
|
||||
```python
|
||||
from django_component import Component, register
|
||||
|
||||
@register("my_table")
|
||||
class MyTable(Component):
|
||||
template = \"\"\"
|
||||
<table>
|
||||
<thead>
|
||||
{% for header in headers %}
|
||||
<th>{{ header }}</th>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td>{{ cell }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tbody>
|
||||
</table>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, rows: List, headers: List):
|
||||
return {
|
||||
"rows": rows,
|
||||
"headers": headers,
|
||||
}
|
||||
```
|
||||
|
||||
Then you can render this component by referring to `MyTable` via its
|
||||
registered name `"my_table"`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
### Component input
|
||||
|
||||
Positional and keyword arguments can be literals or template variables.
|
||||
|
||||
The component name must be a single- or double-quotes string and must
|
||||
be either:
|
||||
|
||||
- The first positional argument after `component`:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
```
|
||||
|
||||
- Passed as kwarg `name`:
|
||||
|
||||
```django
|
||||
{% component rows=rows headers=headers name="my_table" ... / %}
|
||||
```
|
||||
|
||||
### Inserting into slots
|
||||
|
||||
If the component defined any [slots](../concepts/fundamentals/slots.md), you can
|
||||
pass in the content to be placed inside those slots by inserting [`{% fill %}`](#fill) tags,
|
||||
directly within the `{% component %}` tag:
|
||||
|
||||
```django
|
||||
{% component "my_table" rows=rows headers=headers ... / %}
|
||||
{% fill "pagination" %}
|
||||
< 1 | 2 | 3 >
|
||||
{% endfill %}
|
||||
{% endcomponent %}
|
||||
```
|
||||
|
||||
### Isolating components
|
||||
|
||||
By default, components behave similarly to Django's
|
||||
[`{% include %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#include),
|
||||
and the template inside the component has access to the variables defined in the outer template.
|
||||
|
||||
You can selectively isolate a component, using the `only` flag, so that the inner template
|
||||
can access only the data that was explicitly passed to it:
|
||||
|
||||
```django
|
||||
{% component "name" positional_arg keyword_arg=value ... only %}
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
|
||||
bits = token.split_contents()
|
||||
|
||||
# Let the TagFormatter pre-process the tokens
|
||||
formatter = get_tag_formatter(registry)
|
||||
result = formatter.parse([*bits])
|
||||
end_tag = formatter.end_tag(result.component_name)
|
||||
|
||||
# NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself,
|
||||
# so we add it back in.
|
||||
bits = [bits[0], *result.tokens]
|
||||
token.contents = " ".join(bits)
|
||||
|
||||
# Set the component-specific start and end tags
|
||||
component_tag_spec = tag_spec.copy()
|
||||
component_tag_spec.tag = tag_name
|
||||
component_tag_spec.end_tag = end_tag
|
||||
|
||||
tag = parse_template_tag(parser, token, component_tag_spec)
|
||||
|
||||
trace_msg("PARSE", "COMP", result.component_name, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
|
||||
component_node = ComponentNode(
|
||||
name=result.component_name,
|
||||
params=tag.params,
|
||||
isolated_context=tag.flags[COMP_ONLY_FLAG],
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "COMP", result.component_name, tag_id, "...Done!")
|
||||
return component_node
|
||||
|
||||
|
||||
def provide_signature(name: str, **kwargs: Any) -> None: ... # noqa: E704
|
||||
|
||||
|
||||
@register.tag("provide")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="provide",
|
||||
end_tag="endprovide",
|
||||
signature=inspect.Signature.from_callable(provide_signature),
|
||||
flags=[],
|
||||
)
|
||||
)
|
||||
def provide(parser: Parser, token: Token, tag_spec: TagSpec) -> ProvideNode:
|
||||
"""
|
||||
The "provider" part of the [provide / inject feature](../../concepts/advanced/provide_inject).
|
||||
Pass kwargs to this tag to define the provider's data.
|
||||
Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
|
||||
with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
|
||||
or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `name` (str, required): Provider name. This is the name you will then use in
|
||||
[`Component.inject()`](../api#django_components.Component.inject).
|
||||
- `**kwargs`: Any extra kwargs will be passed as the provided data.
|
||||
|
||||
**Example:**
|
||||
|
||||
Provide the "user_data" in parent component:
|
||||
|
||||
```python
|
||||
@register("parent")
|
||||
class Parent(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
{% provide "user_data" user=user %}
|
||||
{% component "child" / %}
|
||||
{% endprovide %}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self, user: User):
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
|
||||
we can request the "user_data" using `Component.inject("user_data")`:
|
||||
|
||||
```python
|
||||
@register("child")
|
||||
class Child(Component):
|
||||
template = \"\"\"
|
||||
<div>
|
||||
User is: {{ user }}
|
||||
</div>
|
||||
\"\"\"
|
||||
|
||||
def get_context_data(self):
|
||||
user = self.inject("user_data").user
|
||||
return {
|
||||
"user": user,
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the keys defined on the `{% provide %}` tag are then accessed as attributes
|
||||
when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
|
||||
|
||||
✅ Do this
|
||||
```python
|
||||
user = self.inject("user_data").user
|
||||
```
|
||||
|
||||
❌ Don't do this
|
||||
```python
|
||||
user = self.inject("user_data")["user"]
|
||||
```
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
|
||||
# e.g. {% provide <name> key=val key2=val2 %}
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
trace_id = f"fill-id-{tag_id}"
|
||||
trace_msg("PARSE", "PROVIDE", trace_id, tag_id)
|
||||
|
||||
body = tag.parse_body()
|
||||
provide_node = ProvideNode(
|
||||
nodelist=body,
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
trace_msg("PARSE", "PROVIDE", trace_id, tag_id, "...Done!")
|
||||
return provide_node
|
||||
|
||||
|
||||
def html_attrs_signature( # noqa: E704
|
||||
attrs: Optional[Dict] = None, defaults: Optional[Dict] = None, **kwargs: Any
|
||||
) -> None: ...
|
||||
|
||||
|
||||
@register.tag("html_attrs")
|
||||
@with_tag_spec(
|
||||
TagSpec(
|
||||
tag="html_attrs",
|
||||
end_tag=None, # inline-only
|
||||
signature=inspect.Signature.from_callable(html_attrs_signature),
|
||||
flags=[],
|
||||
)
|
||||
)
|
||||
def html_attrs(parser: Parser, token: Token, tag_spec: TagSpec) -> HtmlAttrsNode:
|
||||
"""
|
||||
Generate HTML attributes (`key="value"`), combining data from multiple sources,
|
||||
whether its template variables or static text.
|
||||
|
||||
It is designed to easily merge HTML attributes passed from outside with the internal.
|
||||
See how to in [Passing HTML attributes to components](../../guides/howto/passing_html_attrs/).
|
||||
|
||||
**Args:**
|
||||
|
||||
- `attrs` (dict, optional): Optional dictionary that holds HTML attributes. On conflict, overrides
|
||||
values in the `default` dictionary.
|
||||
- `default` (str, optional): Optional dictionary that holds HTML attributes. On conflict, is overriden
|
||||
with values in the `attrs` dictionary.
|
||||
- Any extra kwargs will be appended to the corresponding keys
|
||||
|
||||
The attributes in `attrs` and `defaults` are merged and resulting dict is rendered as HTML attributes
|
||||
(`key="value"`).
|
||||
|
||||
Extra kwargs (`key=value`) are concatenated to existing keys. So if we have
|
||||
|
||||
```python
|
||||
attrs = {"class": "my-class"}
|
||||
```
|
||||
|
||||
Then
|
||||
|
||||
```django
|
||||
{% html_attrs attrs class="extra-class" %}
|
||||
```
|
||||
|
||||
will result in `class="my-class extra-class"`.
|
||||
|
||||
**Example:**
|
||||
```django
|
||||
<div {% html_attrs
|
||||
attrs
|
||||
defaults:class="default-class"
|
||||
class="extra-class"
|
||||
data-id="123"
|
||||
%}>
|
||||
```
|
||||
|
||||
renders
|
||||
|
||||
```html
|
||||
<div class="my-class extra-class" data-id="123">
|
||||
```
|
||||
|
||||
**See more usage examples in
|
||||
[HTML attributes](../../concepts/fundamentals/html_attributes#examples-for-html_attrs).**
|
||||
"""
|
||||
tag_id = gen_id()
|
||||
tag = parse_template_tag(parser, token, tag_spec)
|
||||
|
||||
return HtmlAttrsNode(
|
||||
node_id=tag_id,
|
||||
params=tag.params,
|
||||
)
|
||||
# All tags are defined with our BaseNode class. Reasons for that are:
|
||||
# - This ensures they all have the same set of features, like supporting flags,
|
||||
# or literal lists and dicts as parameters.
|
||||
# - The individual Node classes double as a source of truth for the tag's documentation.
|
||||
#
|
||||
# NOTE: The documentation generation script in `docs/scripts/reference.py` actually
|
||||
# searches this file for all `Node` classes and uses them to generate the documentation.
|
||||
# The docstring on the Node classes is used as the tag's documentation.
|
||||
ComponentNode.register(register)
|
||||
ComponentCssDependenciesNode.register(register)
|
||||
ComponentJsDependenciesNode.register(register)
|
||||
FillNode.register(register)
|
||||
HtmlAttrsNode.register(register)
|
||||
ProvideNode.register(register)
|
||||
SlotNode.register(register)
|
||||
|
||||
|
||||
# For an intuitive use via Python imports, the tags are aliased to the function name.
|
||||
# E.g. so if the tag name is `slot`, one can also do:
|
||||
# `from django_components.templatetags.component_tags import slot`
|
||||
component = ComponentNode.parse
|
||||
component_css_dependencies = ComponentCssDependenciesNode.parse
|
||||
component_js_dependencies = ComponentJsDependenciesNode.parse
|
||||
fill = FillNode.parse
|
||||
html_attrs = HtmlAttrsNode.parse
|
||||
provide = ProvideNode.parse
|
||||
slot = SlotNode.parse
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
from typing import Any, Dict, Literal
|
||||
|
||||
DEFAULT_TRACE_LEVEL_NUM = 5 # NOTE: MUST be lower than DEBUG which is 10
|
||||
|
||||
|
@ -62,27 +62,18 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
|
|||
|
||||
|
||||
def trace_msg(
|
||||
action: Literal["PARSE", "RENDR", "GET", "SET"],
|
||||
node_type: Literal["COMP", "FILL", "SLOT", "PROVIDE", "N/A"],
|
||||
node_name: str,
|
||||
action: Literal["PARSE", "RENDR"],
|
||||
node_type: str,
|
||||
node_id: str,
|
||||
msg: str = "",
|
||||
component_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
TRACE level logger with opinionated format for tracing interaction of components,
|
||||
nodes, and slots. Formats messages like so:
|
||||
|
||||
`"ASSOC SLOT test_slot ID 0088 TO COMP 0087"`
|
||||
`"PARSE slot ID 0088 ...Done!"`
|
||||
"""
|
||||
msg_prefix = ""
|
||||
if action == "RENDR" and node_type == "FILL":
|
||||
if not component_id:
|
||||
raise ValueError("component_id must be set for the RENDER action")
|
||||
msg_prefix = f"FOR COMP {component_id}"
|
||||
|
||||
msg_parts = [f"{action} {node_type} {node_name} ID {node_id}", *([msg_prefix] if msg_prefix else []), msg]
|
||||
full_msg = " ".join(msg_parts)
|
||||
full_msg = f"{action} {node_type} ID {node_id} {msg}"
|
||||
|
||||
# NOTE: When debugging tests during development, it may be easier to change
|
||||
# this to `print()`
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import functools
|
||||
"""
|
||||
This file is for logic that focuses on transforming the AST of template tags
|
||||
(as parsed from tag_parser) into a form that can be used by the Nodes.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple
|
||||
from typing import Any, Callable, Dict, Iterable, List, Mapping, NamedTuple, Optional, Set, Tuple, Union
|
||||
|
||||
from django.template import Context, NodeList
|
||||
from django.template.base import Parser, Token
|
||||
|
@ -11,152 +16,36 @@ from django_components.expression import process_aggregate_kwargs
|
|||
from django_components.util.tag_parser import TagAttr, parse_tag
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagSpec:
|
||||
"""Definition of args, kwargs, flags, etc, for a template tag."""
|
||||
|
||||
signature: inspect.Signature
|
||||
"""Input to the tag as a Python function signature."""
|
||||
tag: str
|
||||
"""Tag name. E.g. `"slot"` means the tag is written like so `{% slot ... %}`"""
|
||||
end_tag: Optional[str] = None
|
||||
# For details see https://github.com/EmilStenstrom/django-components/pull/902#discussion_r1913611633
|
||||
# and following comments
|
||||
def validate_params(
|
||||
tag: str,
|
||||
signature: inspect.Signature,
|
||||
params: List["TagParam"],
|
||||
extra_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
End tag.
|
||||
Validates a list of TagParam objects against this tag's function signature.
|
||||
|
||||
E.g. `"endslot"` means anything between the start tag and `{% endslot %}`
|
||||
is considered the slot's body.
|
||||
"""
|
||||
flags: Optional[List[str]] = None
|
||||
"""
|
||||
List of allowed flags.
|
||||
The validation preserves the order of parameters as they appeared in the template.
|
||||
|
||||
Flags are like kwargs, but without the value part. E.g. in `{% mytag only required %}`:
|
||||
- `only` and `required` are treated as `only=True` and `required=True` if present
|
||||
- and treated as `only=False` and `required=False` if omitted
|
||||
Raises `TypeError` if the parameters don't match the tag's signature.
|
||||
"""
|
||||
|
||||
def copy(self) -> "TagSpec":
|
||||
sig_parameters_copy = [param.replace() for param in self.signature.parameters.values()]
|
||||
signature = inspect.Signature(sig_parameters_copy)
|
||||
flags = self.flags.copy() if self.flags else None
|
||||
return self.__class__(
|
||||
signature=signature,
|
||||
tag=self.tag,
|
||||
end_tag=self.end_tag,
|
||||
flags=flags,
|
||||
)
|
||||
# Create a function that uses the given signature
|
||||
def validator(*args: Any, **kwargs: Any) -> None:
|
||||
# Let Python do the signature validation
|
||||
bound = signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
|
||||
# For details see https://github.com/EmilStenstrom/django-components/pull/902
|
||||
def validate_params(self, params: List["TagParam"]) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
"""
|
||||
Validates a list of TagParam objects against this tag spec's function signature.
|
||||
validator.__signature__ = signature # type: ignore[attr-defined]
|
||||
|
||||
The validation preserves the order of parameters as they appeared in the template.
|
||||
|
||||
Args:
|
||||
params: List of TagParam objects representing the parameters as they appeared
|
||||
in the template tag.
|
||||
|
||||
Returns:
|
||||
A tuple of (args, kwargs) containing the validated parameters.
|
||||
|
||||
Raises:
|
||||
TypeError: If the parameters don't match the tag spec's rules.
|
||||
"""
|
||||
|
||||
# Create a function with this signature that captures the input and sorts
|
||||
# it into args and kwargs
|
||||
def validator(*args: Any, **kwargs: Any) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
# Let Python do the signature validation
|
||||
bound = self.signature.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
|
||||
# Extract positional args
|
||||
pos_args: List[Any] = []
|
||||
for name, param in self.signature.parameters.items():
|
||||
# Case: `name` (positional)
|
||||
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
|
||||
pos_args.append(bound.arguments[name])
|
||||
# Case: `*args`
|
||||
elif param.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
pos_args.extend(bound.arguments[name])
|
||||
|
||||
# Extract kwargs
|
||||
kw_args: Dict[str, Any] = {}
|
||||
for name, param in self.signature.parameters.items():
|
||||
# Case: `name=...`
|
||||
if param.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY):
|
||||
if name in bound.arguments:
|
||||
kw_args[name] = bound.arguments[name]
|
||||
# Case: `**kwargs`
|
||||
elif param.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
kw_args.update(bound.arguments[name])
|
||||
|
||||
return pos_args, kw_args
|
||||
|
||||
# Set the signature on the function
|
||||
validator.__signature__ = self.signature # type: ignore[attr-defined]
|
||||
|
||||
# Call the validator with our args and kwargs, in such a way to
|
||||
# let the Python interpreter validate on repeated kwargs. E.g.
|
||||
#
|
||||
# ```
|
||||
# args, kwargs = validator(
|
||||
# *call_args,
|
||||
# **call_kwargs[0],
|
||||
# **call_kwargs[1],
|
||||
# ...
|
||||
# )
|
||||
# ```
|
||||
call_args = []
|
||||
call_kwargs = []
|
||||
for param in params:
|
||||
if param.key is None:
|
||||
call_args.append(param.value)
|
||||
else:
|
||||
call_kwargs.append({param.key: param.value})
|
||||
|
||||
# NOTE: Although we use `exec()` here, it's safe, because we control the input -
|
||||
# we make dynamic only the list index.
|
||||
#
|
||||
# We MUST use the indices, because we can't trust neither the param keys nor values,
|
||||
# so we MUST NOT reference them directly in the exec script, otherwise we'd be at risk
|
||||
# of injection attack.
|
||||
validator_call_script = "args, kwargs = validator(*call_args, "
|
||||
for kw_index, _ in enumerate(call_kwargs):
|
||||
validator_call_script += f"**call_kwargs[{kw_index}], "
|
||||
validator_call_script += ")"
|
||||
|
||||
try:
|
||||
# Create function namespace
|
||||
namespace: Dict[str, Any] = {"validator": validator, "call_args": call_args, "call_kwargs": call_kwargs}
|
||||
exec(validator_call_script, namespace)
|
||||
new_args, new_kwargs = namespace["args"], namespace["kwargs"]
|
||||
return new_args, new_kwargs
|
||||
except TypeError as e:
|
||||
# Enhance the error message
|
||||
raise TypeError(f"Invalid parameters for tag '{self.tag}': {str(e)}") from None
|
||||
|
||||
|
||||
def with_tag_spec(tag_spec: TagSpec) -> Callable:
|
||||
"""
|
||||
Decorator that binds a `tag_spec` to a template tag function,
|
||||
there's a single source of truth for the tag spec, while also:
|
||||
|
||||
1. Making the tag spec available inside the tag function as `tag_spec`.
|
||||
2. Making the tag spec accessible from outside as `_tag_spec` for documentation generation.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> Any:
|
||||
fn._tag_spec = tag_spec # type: ignore[attr-defined]
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
return fn(*args, **kwargs, tag_spec=tag_spec)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
# Call the validator with our args and kwargs in the same order as they appeared
|
||||
# in the template, to let the Python interpreter validate on repeated kwargs.
|
||||
try:
|
||||
apply_params_in_original_order(validator, params, extra_kwargs)
|
||||
except TypeError as e:
|
||||
raise TypeError(f"Invalid parameters for tag '{tag}': {str(e)}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -180,64 +69,54 @@ class TagParam:
|
|||
value: Any
|
||||
|
||||
|
||||
class TagParams(NamedTuple):
|
||||
"""
|
||||
TagParams holds the parsed tag attributes and the tag spec, so that, at render time,
|
||||
when we are able to resolve the tag inputs with the given Context, we are also able to validate
|
||||
the inputs against the tag spec.
|
||||
def resolve_params(
|
||||
tag: str,
|
||||
params: List[TagAttr],
|
||||
context: Context,
|
||||
) -> List[TagParam]:
|
||||
# First, resolve any spread operators. Spreads can introduce both positional
|
||||
# args (e.g. `*args`) and kwargs (e.g. `**kwargs`).
|
||||
resolved_params: List[TagParam] = []
|
||||
for param in params:
|
||||
resolved = param.value.resolve(context)
|
||||
|
||||
This is done so that the tag's public API (as defined in the tag spec) can be defined
|
||||
next to the tag implementation. Otherwise the input validation would have to be defined by
|
||||
the internal `Node` classes.
|
||||
"""
|
||||
if param.value.spread:
|
||||
if param.key:
|
||||
raise ValueError(f"Cannot spread a value onto a key: {param.key}")
|
||||
|
||||
params: List[TagAttr]
|
||||
tag_spec: TagSpec
|
||||
|
||||
def resolve(self, context: Context) -> Tuple[List[Any], Dict[str, Any]]:
|
||||
# First, resolve any spread operators. Spreads can introduce both positional
|
||||
# args (e.g. `*args`) and kwargs (e.g. `**kwargs`).
|
||||
resolved_params: List[TagParam] = []
|
||||
for param in self.params:
|
||||
resolved = param.value.resolve(context)
|
||||
|
||||
if param.value.spread:
|
||||
if param.key:
|
||||
raise ValueError(f"Cannot spread a value onto a key: {param.key}")
|
||||
|
||||
if isinstance(resolved, Mapping):
|
||||
for key, value in resolved.items():
|
||||
resolved_params.append(TagParam(key=key, value=value))
|
||||
elif isinstance(resolved, Iterable):
|
||||
for value in resolved:
|
||||
resolved_params.append(TagParam(key=None, value=value))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}"
|
||||
)
|
||||
if isinstance(resolved, Mapping):
|
||||
for key, value in resolved.items():
|
||||
resolved_params.append(TagParam(key=key, value=value))
|
||||
elif isinstance(resolved, Iterable):
|
||||
for value in resolved:
|
||||
resolved_params.append(TagParam(key=None, value=value))
|
||||
else:
|
||||
resolved_params.append(TagParam(key=param.key, value=resolved))
|
||||
raise ValueError(
|
||||
f"Cannot spread non-iterable value: '{param.value.serialize()}' resolved to {resolved}"
|
||||
)
|
||||
else:
|
||||
resolved_params.append(TagParam(key=param.key, value=resolved))
|
||||
|
||||
if self.tag_spec.tag == "html_attrs":
|
||||
resolved_params = merge_repeated_kwargs(resolved_params)
|
||||
resolved_params = process_aggregate_kwargs(resolved_params)
|
||||
if tag == "html_attrs":
|
||||
resolved_params = merge_repeated_kwargs(resolved_params)
|
||||
resolved_params = process_aggregate_kwargs(resolved_params)
|
||||
|
||||
args, kwargs = self.tag_spec.validate_params(resolved_params)
|
||||
return args, kwargs
|
||||
return resolved_params
|
||||
|
||||
|
||||
# Data obj to give meaning to the parsed tag fields
|
||||
class ParsedTag(NamedTuple):
|
||||
tag_name: str
|
||||
flags: Dict[str, bool]
|
||||
params: TagParams
|
||||
params: List[TagAttr]
|
||||
parse_body: Callable[[], NodeList]
|
||||
|
||||
|
||||
def parse_template_tag(
|
||||
tag: str,
|
||||
end_tag: Optional[str],
|
||||
allowed_flags: Optional[List[str]],
|
||||
parser: Parser,
|
||||
token: Token,
|
||||
tag_spec: TagSpec,
|
||||
) -> ParsedTag:
|
||||
_, attrs = parse_tag(token.contents, parser)
|
||||
|
||||
|
@ -246,13 +125,14 @@ def parse_template_tag(
|
|||
tag_name = tag_name_attr.serialize(omit_key=True)
|
||||
|
||||
# Sanity check
|
||||
if tag_name != tag_spec.tag:
|
||||
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag_spec.tag}'")
|
||||
if tag_name != tag:
|
||||
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
|
||||
|
||||
# There's 3 ways how we tell when a tag ends:
|
||||
# 1. If the tag contains `/` at the end, it's a self-closing tag (like `<div />`),
|
||||
# and it doesn't have an end tag. In this case we strip the trailing slash.
|
||||
# Otherwise, depending on the tag spec, the tag may be:
|
||||
#
|
||||
# Otherwise, depending on the end_tag, the tag may be:
|
||||
# 2. Block tag - With corresponding end tag, e.g. `{% endslot %}`
|
||||
# 3. Inlined tag - Without the end tag.
|
||||
last_token = attrs[-1].value if len(attrs) else None
|
||||
|
@ -260,9 +140,9 @@ def parse_template_tag(
|
|||
attrs.pop()
|
||||
is_inline = True
|
||||
else:
|
||||
is_inline = not tag_spec.end_tag
|
||||
is_inline = not end_tag
|
||||
|
||||
raw_params, flags = _extract_flags(tag_name, attrs, tag_spec.flags or [])
|
||||
raw_params, flags = _extract_flags(tag_name, attrs, allowed_flags or [])
|
||||
|
||||
def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
|
||||
if inline:
|
||||
|
@ -273,14 +153,13 @@ def parse_template_tag(
|
|||
return body
|
||||
|
||||
return ParsedTag(
|
||||
tag_name=tag_name,
|
||||
params=TagParams(params=raw_params, tag_spec=tag_spec),
|
||||
params=raw_params,
|
||||
flags=flags,
|
||||
# NOTE: We defer parsing of the body, so we have the chance to call the tracing
|
||||
# loggers before the parsing. This is because, if the body contains any other
|
||||
# tags, it will trigger their tag handlers. So the code called AFTER
|
||||
# `parse_body()` is already after all the nested tags were processed.
|
||||
parse_body=lambda: _parse_tag_body(parser, tag_spec.end_tag, is_inline) if tag_spec.end_tag else NodeList(),
|
||||
parse_body=lambda: _parse_tag_body(parser, end_tag, is_inline) if end_tag else NodeList(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -305,7 +184,7 @@ def _extract_flags(
|
|||
found_flags.add(value)
|
||||
|
||||
flags_dict: Dict[str, bool] = {
|
||||
# Base state, as defined in the tag spec
|
||||
# Base state - all flags False
|
||||
**{flag: False for flag in (allowed_flags or [])},
|
||||
# Flags found on the template tag
|
||||
**{flag: True for flag in found_flags},
|
||||
|
@ -348,3 +227,99 @@ def merge_repeated_kwargs(params: List[TagParam]) -> List[TagParam]:
|
|||
params_by_key[param.key].value += " " + str(param.value)
|
||||
|
||||
return resolved_params
|
||||
|
||||
|
||||
def apply_params_in_original_order(
|
||||
fn: Callable[..., Any],
|
||||
params: List[TagParam],
|
||||
extra_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Apply a list of `TagParams` to another function, keeping the order of the params as they
|
||||
appeared in the template.
|
||||
|
||||
If a template tag was called like this:
|
||||
|
||||
```django
|
||||
{% component key1=value1 arg1 arg2 key2=value2 key3=value3 %}
|
||||
```
|
||||
|
||||
Then `apply_params_in_original_order()` will call the `fn` like this:
|
||||
```
|
||||
component(
|
||||
key1=call_params[0], # kwarg 1
|
||||
call_params[1], # arg 1
|
||||
call_params[2], # arg 2
|
||||
key2=call_params[3], # kwarg 2
|
||||
key3=call_params[4], # kwarg 3
|
||||
...
|
||||
**extra_kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
This way, this will be effectively the same as:
|
||||
|
||||
```python
|
||||
component(key1=value1, arg1, arg2, key2=value2, key3=value3, ..., **extra_kwargs)
|
||||
```
|
||||
|
||||
The problem this works around is that, dynamically, args and kwargs in Python
|
||||
can be passed only with `*args` and `**kwargs`. But in such case, we're already
|
||||
grouping all args and kwargs, which may not represent the original order of the params
|
||||
as they appeared in the template tag.
|
||||
|
||||
If you need to pass kwargs that are not valid Python identifiers, e.g. `data-id`, `class`, `:href`,
|
||||
you can pass them in via `extra_kwargs`. These kwargs will be exempt from the validation, and will be
|
||||
passed to the function as a dictionary spread.
|
||||
"""
|
||||
# Generate a script like so:
|
||||
# ```py
|
||||
# component(
|
||||
# key1=call_params[0],
|
||||
# call_params[1],
|
||||
# call_params[2],
|
||||
# key2=call_params[3],
|
||||
# key3=call_params[4],
|
||||
# ...
|
||||
# **extra_kwargs,
|
||||
# )
|
||||
# ```
|
||||
#
|
||||
# NOTE: Instead of grouping params into args and kwargs, we preserve the original order
|
||||
# of the params as they appeared in the template.
|
||||
#
|
||||
# NOTE: Because we use `eval()` here, we can't trust neither the param keys nor values.
|
||||
# So we MUST NOT reference them directly in the exec script, otherwise we'd be at risk
|
||||
# of injection attack.
|
||||
#
|
||||
# Currently, the use of `eval()` is safe, because we control the input:
|
||||
# - List with indices is used so that we don't have to reference directly or try to print the values.
|
||||
# and instead refer to them as `call_params[0]`, `call_params[1]`, etc.
|
||||
# - List indices are safe, because we generate them.
|
||||
# - Kwarg names come from the user. But Python expects the kwargs to be valid identifiers.
|
||||
# So if a key is not a valid identifier, we'll raise an error. Before passing it to `eval()`
|
||||
validator_call_script = "fn("
|
||||
call_params: List[Union[List, Dict]] = []
|
||||
for index, param in enumerate(params):
|
||||
call_params.append(param.value)
|
||||
if param.key is None:
|
||||
validator_call_script += f"call_params[{index}], "
|
||||
else:
|
||||
validator_call_script += f"{param.key}=call_params[{index}], "
|
||||
|
||||
validator_call_script += "**extra_kwargs, "
|
||||
validator_call_script += ")"
|
||||
|
||||
def applier(fn: Callable[..., Any]) -> Any:
|
||||
locals = {
|
||||
"fn": fn,
|
||||
"call_params": call_params,
|
||||
"extra_kwargs": extra_kwargs or {},
|
||||
}
|
||||
# NOTE: `eval()` changed API in Python 3.13
|
||||
if sys.version_info >= (3, 13):
|
||||
return eval(validator_call_script, globals={}, locals=locals)
|
||||
else:
|
||||
return eval(validator_call_script, {}, locals)
|
||||
|
||||
return applier(fn)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue