feat: TagFormatter - Allow users to customize component template tags (#572)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Juro Oravec 2024-08-18 16:58:56 +02:00 committed by GitHub
parent b89c09aa5f
commit 71d8679e8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1593 additions and 474 deletions

238
README.md
View file

@ -37,6 +37,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
- [Rendering HTML attributes](#rendering-html-attributes) - [Rendering HTML attributes](#rendering-html-attributes)
- [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject) - [Prop drilling and dependency injection (provide / inject)](#prop-drilling-and-dependency-injection-provide--inject)
- [Component context and scope](#component-context-and-scope) - [Component context and scope](#component-context-and-scope)
- [Customizing component tags with TagFormatter](#customizing-component-tags-with-tagformatter)
- [Defining HTML/JS/CSS files](#defining-htmljscss-files) - [Defining HTML/JS/CSS files](#defining-htmljscss-files)
- [Rendering JS/CSS dependencies](#rendering-jscss-dependencies) - [Rendering JS/CSS dependencies](#rendering-jscss-dependencies)
- [Available settings](#available-settings) - [Available settings](#available-settings)
@ -48,6 +49,34 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
## Release notes ## Release notes
**Version 0.90**
- All tags (`component`, `slot`, `fill`, ...) now support "self-closing" or "inline" form, where you can omit the closing tag:
```django
{# Before #}
{% component "button" %}{% endcomponent %}
{# After #}
{% component "button" / %}
```
- All tags now support the "dictionary key" or "aggregate" syntax (`kwarg:key=val`):
```django
{% component "button" attrs:class="hidden" %}
```
- You can change how the components are written in the template with [TagFormatter](#customizing-component-tags-with-tagformatter).
The default is `django_components.component_formatter`:
```django
{% component "button" href="..." disabled %}
Click me!
{% endcomponent %}
```
While `django_components.shorthand_component_formatter` allows you to write components like so:
```django
{% button href="..." disabled %}
Click me!
{% endbutton %}
🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed: 🚨📢 **Version 0.85** Autodiscovery module resolution changed. Following undocumented behavior was removed:
- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs. - Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs.
@ -420,6 +449,10 @@ First load the `component_tags` tag library, then use the `component_[js/css]_de
<html> <html>
``` ```
> NOTE: Instead of writing `{% endcomponent %}` at the end, you can use a self-closing tag:
>
> `{% component "calendar" date="2015-06-19" / %}`
The output from the above template will be: The output from the above template will be:
```html ```html
@ -461,7 +494,7 @@ class SimpleComponent(Component):
hello: {{ hello }} hello: {{ hello }}
foo: {{ foo }} foo: {{ foo }}
kwargs: {{ kwargs|safe }} kwargs: {{ kwargs|safe }}
slot_first: {% slot "first" required %}{% endslot %} slot_first: {% slot "first" required / %}
""" """
def get_context_data(self, arg1, arg2, **kwargs): def get_context_data(self, arg1, arg2, **kwargs):
@ -597,7 +630,7 @@ class Calendar(Component):
template = """ template = """
<div class="calendar-component"> <div class="calendar-component">
<div class="header"> <div class="header">
{% slot "header" %}{% endslot %} {% slot "header" / %}
</div> </div>
<div class="body"> <div class="body">
Today's date is <span>{{ date }}</span> Today's date is <span>{{ date }}</span>
@ -1134,7 +1167,7 @@ To negate the meaning of `component_vars.is_filled`, simply treat it as boolean
```htmldjango ```htmldjango
{% if not component_vars.is_filled.subtitle %} {% if not component_vars.is_filled.subtitle %}
<div class="subtitle"> <div class="subtitle">
{% slot "subtitle" %}{% endslot %} {% slot "subtitle" / %}
</div> </div>
{% endif %} {% endif %}
``` ```
@ -1266,8 +1299,7 @@ so are still valid:
```django ```django
<body> <body>
{% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True %} {% component "calendar" my-date="2015-06-19" @click.native=do_something #some_id=True / %}
{% endcomponent %}
</body> </body>
``` ```
@ -1302,8 +1334,7 @@ But for that, we need to define this dictionary on Python side:
@register("my_comp") @register("my_comp")
class MyComp(Component): class MyComp(Component):
template = """ template = """
{% component "other" attrs=attrs %} {% component "other" attrs=attrs / %}
{% endcomponent %}
""" """
def get_context_data(self, some_id: str): def get_context_data(self, some_id: str):
@ -1334,8 +1365,7 @@ class MyComp(Component):
attrs:class="pa-4 flex" attrs:class="pa-4 flex"
attrs:data-some-id=some_id attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler" attrs:@click.stop="onClickHandler"
%} / %}
{% endcomponent %}
""" """
def get_context_data(self, some_id: str): def get_context_data(self, some_id: str):
@ -1349,8 +1379,7 @@ Sweet! Now all the relevant HTML is inside the template, and we can move it to a
attrs:class="pa-4 flex" attrs:class="pa-4 flex"
attrs:data-some-id=some_id attrs:data-some-id=some_id
attrs:@click.stop="onClickHandler" attrs:@click.stop="onClickHandler"
%} / %}
{% endcomponent %}
``` ```
> Note: It is NOT possible to define nested dictionaries, so > Note: It is NOT possible to define nested dictionaries, so
@ -1646,8 +1675,7 @@ class Parent(Component):
attrs:class="pa-0 border-solid border-red" attrs:class="pa-0 border-solid border-red"
attrs:data-json=json_data attrs:data-json=json_data
attrs:@click="(e) => onClick(e, 'from_parent')" attrs:@click="(e) => onClick(e, 'from_parent')"
%} / %}
{% endcomponent %}
""" """
def get_context_data(self, date: Date): def get_context_data(self, date: Date):
@ -1775,12 +1803,10 @@ First we use the `{% provide %}` tag to define the data we want to "provide" (ma
```django ```django
{% provide "my_data" key="hi" another=123 %} {% provide "my_data" key="hi" another=123 %}
{% component "child" %} <--- Can access "my_data" {% component "child" / %} <--- Can access "my_data"
{% endcomponent %}
{% endprovide %} {% endprovide %}
{% component "child" %} <--- Cannot access "my_data" {% component "child" / %} <--- Cannot access "my_data"
{% endcomponent %}
``` ```
Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag. Notice that the `provide` tag REQUIRES a name as a first argument. This is the _key_ by which we can then access the data passed to this tag.
@ -1854,8 +1880,7 @@ class ChildComponent(Component):
template_str = """ template_str = """
{% load component_tags %} {% load component_tags %}
{% provide "my_data" key="hi" another=123 %} {% provide "my_data" key="hi" another=123 %}
{% component "child" %} {% component "child" / %}
{% endcomponent %}
{% endprovide %} {% endprovide %}
""" """
``` ```
@ -1873,7 +1898,7 @@ By default, context variables are passed down the template as in regular Django
With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it. With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it.
And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the end of the `{% component %}` tag: And just like with `{% include %}`, if you don't want a specific component template to have access to the parent context, add `only` to the `{% component %}` tag:
```htmldjango ```htmldjango
{% component "calendar" date="2015-06-19" only %}{% endcomponent %} {% component "calendar" date="2015-06-19" only %}{% endcomponent %}
@ -1885,6 +1910,155 @@ If you find yourself using the `only` modifier often, you can set the [context_b
Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`. Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
## Customizing component tags with TagFormatter
_New in version 0.89_
By default, components are rendered using the pair of `{% component %}` / `{% endcomponent %}` template tags:
```django
{% component "button" href="..." disabled %}
Click me!
{% endcomponent %}
{# or #}
{% component "button" href="..." disabled / %}
```
You can change this behaviour in the settings under the [`COMPONENTS.tag_formatter`](#tag-formatter-setting).
For example, if you set the tag formatter to `django_components.shorthand_component_formatter`, the components will use their name as the template tags:
```django
{% button href="..." disabled %}
Click me!
{% endbutton %}
{# or #}
{% button href="..." disabled / %}
```
### Available TagFormatters
django_components provides following predefined TagFormatters:
- **`ComponentFormatter` (`django_components.component_formatter`)**
Default
Uses the `component` and `endcomponent` tags, and the component name is gives as the first positional argument.
Example as block:
```django
{% component "button" href="..." %}
{% fill "content" %}
...
{% endfill %}
{% endcomponent %}
```
Example as inlined tag:
```django
{% component "button" href="..." / %}
```
- **`ShorthandComponentFormatter` (`django_components.shorthand_component_formatter`)**
Uses the component name as start tag, and `end<component_name>`
as an end tag.
Example as block:
```django
{% button href="..." %}
Click me!
{% endbutton %}
```
Example as inlined tag:
```django
{% button href="..." / %}
```
### Writing your own TagFormatter
#### Background
First, let's discuss how TagFormatters work, and how components are rendered in django_components.
When you render a component with `{% component %}` (or your own tag), the following happens:
1. `component` must be registered as a Django's template tag
2. Django triggers django_components's tag handler for tag `component`.
3. The tag handler passes the tag contents for pre-processing to `TagFormatter.parse()`.
So if you render this:
```django
{% component "button" href="..." disabled %}
{% endcomponent %}
```
Then `TagFormatter.parse()` will receive a following input:
```py
["component", '"button"', 'href="..."', 'disabled']
```
4. `TagFormatter` extracts the component name and the remaining input.
So, given the above, `TagFormatter.parse()` returns the following:
```py
TagResult(
component_name="button",
tokens=['href="..."', 'disabled']
)
```
5. The tag handler resumes, using the tokens returned from `TagFormatter`.
So, continuing the example, at this point the tag handler practically behaves as if you rendered:
```django
{% component href="..." disabled %}
```
6. Tag handler looks up the component `button`, and passes the args, kwargs, and slots to it.
#### TagFormatter
`TagFormatter` handles following parts of the process above:
- Generates start/end tags, given a component. This is what you then call from within your template as `{% component %}`.
- When you `{% component %}`, tag formatter pre-processes the tag contents, so it can link back the custom template tag to the right component.
To do so, subclass from `TagFormatterABC` and implement following method:
- `start_tag`
- `end_tag`
- `parse`
For example, this is the implementation of [`ShorthandComponentFormatter`](#available-tagformatters)
```py
class ShorthandComponentFormatter(TagFormatterABC):
# Given a component name, generate the start template tag
def start_tag(self, name: str) -> str:
return name # e.g. 'button'
# Given a component name, generate the start template tag
def end_tag(self, name: str) -> str:
return f"end{name}" # e.g. 'endbutton'
# Given a tag, e.g.
# `{% button href="..." disabled %}`
#
# The parser receives:
# `['button', 'href="..."', 'disabled']`
def parse(self, tokens: List[str]) -> TagResult:
tokens = [*tokens]
name = tokens.pop(0)
return TagResult(
name, # e.g. 'button'
tokens # e.g. ['href="..."', 'disabled']
)
```
That's it! And once your `TagFormatter` is ready, don't forget to update the settings!
## Defining HTML/JS/CSS files ## Defining HTML/JS/CSS files
django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/). django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
@ -2187,7 +2361,7 @@ COMPONENTS = {
} }
``` ```
### Context behavior ### Context behavior setting
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70. > NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
> >
@ -2292,6 +2466,28 @@ But since `"cheese"` is not defined there, it's empty.
Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode. Notice that the variables defined with the `{% with %}` tag are ignored inside the `{% fill %}` tag with the `"isolated"` mode.
### Tag formatter setting
Set the [`TagFormatter`](#available-tagformatters) instance.
Can be set either as direct reference, or as an import string;
```py
COMPONENTS = {
"tag_formatter": "django_components.component_formatter"
}
```
Or
```py
from django_components import component_formatter
COMPONENTS = {
"tag_formatter": component_formatter
}
```
## Logging and debugging ## Logging and debugging
Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting. Django components supports [logging with Django](https://docs.djangoproject.com/en/5.0/howto/logging/#logging-how-to). This can help with troubleshooting.

View file

@ -3,14 +3,27 @@ import django
# Public API # Public API
# isort: off # isort: off
from django_components.autodiscover import autodiscover as autodiscover from django_components.autodiscover import (
from django_components.autodiscover import import_libraries as import_libraries autodiscover as autodiscover,
import_libraries as import_libraries,
)
from django_components.component import Component as Component from django_components.component import Component as Component
from django_components.component_registry import AlreadyRegistered as AlreadyRegistered from django_components.component_registry import (
from django_components.component_registry import ComponentRegistry as ComponentRegistry AlreadyRegistered as AlreadyRegistered,
from django_components.component_registry import NotRegistered as NotRegistered ComponentRegistry as ComponentRegistry,
from django_components.component_registry import register as register NotRegistered as NotRegistered,
from django_components.component_registry import registry as registry register as register,
registry as registry,
)
from django_components.library import TagProtectedError as TagProtectedError
from django_components.tag_formatter import (
ComponentFormatter as ComponentFormatter,
ShorthandComponentFormatter as ShorthandComponentFormatter,
TagFormatterABC as TagFormatterABC,
TagResult as TagResult,
component_formatter as component_formatter,
component_shorthand_formatter as component_shorthand_formatter,
)
import django_components.types as types import django_components.types as types
# isort: on # isort: on

View file

@ -1,8 +1,11 @@
from enum import Enum from enum import Enum
from typing import Dict, List from typing import TYPE_CHECKING, Dict, List, Union
from django.conf import settings from django.conf import settings
if TYPE_CHECKING:
from django_components.tag_formatter import TagFormatterABC
class ContextBehavior(str, Enum): class ContextBehavior(str, Enum):
DJANGO = "django" DJANGO = "django"
@ -115,5 +118,9 @@ class AppSettings:
valid_values = [behavior.value for behavior in ContextBehavior] valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}") raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
@property
def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]:
return self.settings.get("tag_formatter", "django_components.component_formatter")
app_settings = AppSettings() app_settings = AppSettings()

View file

@ -5,69 +5,42 @@
from typing import Any, Dict, List, Mapping, Optional, Tuple from typing import Any, Dict, List, Mapping, Optional, Tuple
from django.template import Context, Node from django.template import Context, Node
from django.template.base import FilterExpression
from django.utils.html import conditional_escape, format_html from django.utils.html import conditional_escape, format_html
from django.utils.safestring import SafeString, mark_safe from django.utils.safestring import SafeString, mark_safe
from django_components.template_parser import process_aggregate_kwargs from django_components.expression import Expression, safe_resolve
HTML_ATTRS_DEFAULTS_KEY = "defaults" HTML_ATTRS_DEFAULTS_KEY = "defaults"
HTML_ATTRS_ATTRS_KEY = "attrs" HTML_ATTRS_ATTRS_KEY = "attrs"
def _default(val: Any, default_val: Any) -> Any:
return val if val is not None else default_val
class HtmlAttrsNode(Node): class HtmlAttrsNode(Node):
def __init__( def __init__(
self, self,
attributes: Optional[FilterExpression], attributes: Optional[Expression],
default_attrs: Optional[FilterExpression], defaults: Optional[Expression],
kwargs: List[Tuple[str, FilterExpression]], kwargs: List[Tuple[str, Expression]],
): ):
self.attributes = attributes self.attributes = attributes
self.default_attrs = default_attrs self.defaults = defaults
self.kwargs = kwargs self.kwargs = kwargs
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
append_attrs: List[Tuple[str, Any]] = [] append_attrs: List[Tuple[str, Any]] = []
attrs_and_defaults_from_kwargs = {}
# Resolve kwargs, while also extracting attrs and defaults keys # Resolve all data
for key, value in self.kwargs: for key, value in self.kwargs:
resolved_value = value.resolve(context) resolved_value = safe_resolve(value, context)
if key.startswith(f"{HTML_ATTRS_ATTRS_KEY}:") or key.startswith(f"{HTML_ATTRS_DEFAULTS_KEY}:"):
attrs_and_defaults_from_kwargs[key] = resolved_value
continue
# NOTE: These were already extracted into separate variables, so
# ignore them here.
elif key == HTML_ATTRS_ATTRS_KEY or key == HTML_ATTRS_DEFAULTS_KEY:
continue
append_attrs.append((key, resolved_value)) append_attrs.append((key, resolved_value))
# NOTE: Here we delegate validation to `process_aggregate_kwargs`, which should defaults = safe_resolve(self.defaults, context) if self.defaults else {}
# raise error if the dict includes both `attrs` and `attrs:` keys. attrs = safe_resolve(self.attributes, context) if self.attributes else {}
#
# So by assigning the `attrs` and `defaults` keys, users are forced to use only
# one approach or the other, but not both simultaneously.
if self.attributes:
attrs_and_defaults_from_kwargs[HTML_ATTRS_ATTRS_KEY] = self.attributes.resolve(context)
if self.default_attrs:
attrs_and_defaults_from_kwargs[HTML_ATTRS_DEFAULTS_KEY] = self.default_attrs.resolve(context)
# Turn `{"attrs:blabla": 1}` into `{"attrs": {"blabla": 1}}` # Merge it
attrs_and_defaults_from_kwargs = process_aggregate_kwargs(attrs_and_defaults_from_kwargs) final_attrs = {**defaults, **attrs}
# NOTE: We want to allow to use `html_attrs` even without `attrs` or `defaults` params
# Or when they are None
attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_ATTRS_KEY, None), {})
default_attrs = _default(attrs_and_defaults_from_kwargs.get(HTML_ATTRS_DEFAULTS_KEY, None), {})
final_attrs = {**default_attrs, **attrs}
final_attrs = append_attributes(*final_attrs.items(), *append_attrs) final_attrs = append_attributes(*final_attrs.items(), *append_attrs)
# Render to HTML attributes
return attributes_to_string(final_attrs) return attributes_to_string(final_attrs)

View file

@ -477,7 +477,7 @@ class ComponentNode(Node):
def __init__( def __init__(
self, self,
name_fexp: FilterExpression, name: str,
context_args: List[FilterExpression], context_args: List[FilterExpression],
context_kwargs: Mapping[str, FilterExpression], context_kwargs: Mapping[str, FilterExpression],
isolated_context: bool = False, isolated_context: bool = False,
@ -485,7 +485,7 @@ class ComponentNode(Node):
component_id: Optional[str] = None, component_id: Optional[str] = None,
) -> None: ) -> None:
self.component_id = component_id or gen_id() self.component_id = component_id or gen_id()
self.name_fexp = name_fexp self.name = name
self.context_args = context_args or [] self.context_args = context_args or []
self.context_kwargs = context_kwargs or {} self.context_kwargs = context_kwargs or {}
self.isolated_context = isolated_context self.isolated_context = isolated_context
@ -494,15 +494,14 @@ class ComponentNode(Node):
def __repr__(self) -> str: def __repr__(self) -> str:
return "<ComponentNode: {}. Contents: {!r}>".format( return "<ComponentNode: {}. Contents: {!r}>".format(
self.name_fexp, self.name,
getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later. getattr(self, "nodelist", None), # 'nodelist' attribute only assigned later.
) )
def render(self, context: Context) -> str: def render(self, context: Context) -> str:
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id) trace_msg("RENDR", "COMP", self.name, self.component_id)
resolved_component_name = self.name_fexp.resolve(context) component_cls: Type[Component] = registry.get(self.name)
component_cls: Type[Component] = registry.get(resolved_component_name)
# Resolve FilterExpressions and Variables that were passed as args to the # Resolve FilterExpressions and Variables that were passed as args to the
# component, then call component's context method # component, then call component's context method
@ -532,8 +531,8 @@ class ComponentNode(Node):
f"Detected duplicate fill tag name '{resolved_name}'." f"Detected duplicate fill tag name '{resolved_name}'."
) )
resolved_slot_default_var = fill_node.resolve_slot_default(context, resolved_component_name) resolved_slot_default_var = fill_node.resolve_slot_default(context, self.name)
resolved_slot_data_var = fill_node.resolve_slot_data(context, resolved_component_name) resolved_slot_data_var = fill_node.resolve_slot_data(context, self.name)
fill_content[resolved_name] = FillContent( fill_content[resolved_name] = FillContent(
content_func=_nodelist_to_slot_render_func(fill_node.nodelist), content_func=_nodelist_to_slot_render_func(fill_node.nodelist),
slot_default_var=resolved_slot_default_var, slot_default_var=resolved_slot_default_var,
@ -541,7 +540,7 @@ class ComponentNode(Node):
) )
component: Component = component_cls( component: Component = component_cls(
registered_name=resolved_component_name, registered_name=self.name,
outer_context=context, outer_context=context,
fill_content=fill_content, fill_content=fill_content,
component_id=self.component_id, component_id=self.component_id,
@ -557,7 +556,7 @@ class ComponentNode(Node):
kwargs=resolved_context_kwargs, kwargs=resolved_context_kwargs,
) )
trace_msg("RENDR", "COMP", self.name_fexp, self.component_id, "...Done!") trace_msg("RENDR", "COMP", self.name, self.component_id, "...Done!")
return output return output

View file

@ -1,25 +1,16 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar from typing import TYPE_CHECKING, Callable, Dict, NamedTuple, Optional, Set, Type, TypeVar
from django.template import Library from django.template import Library
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
from django_components.tag_formatter import get_tag_formatter
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
_TComp = TypeVar("_TComp", bound=Type["Component"]) _TComp = TypeVar("_TComp", bound=Type["Component"])
PROTECTED_TAGS = [
"component",
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]
class AlreadyRegistered(Exception): class AlreadyRegistered(Exception):
pass pass
@ -28,20 +19,10 @@ class NotRegistered(Exception):
pass pass
# Why do we store the tags with the component? # Why do we store the tags with the components?
# #
# Each component may be associated with two template tags - One for "block" # With the addition of TagFormatter, each component class may have a unique
# and one for "inline" usage. E.g. in the following snippets, the template # set of template tags.
# tags are `component` and `#component`:
#
# `{% component "abc" %}{% endcomponent %}`
# `{% #component "abc" %}`
#
# (NOTE: While `endcomponent` also looks like a template tag, we don't have to register
# it, because it simply marks the end of body.)
#
# With the component tag formatter (configurable tags per component class),
# each component may have a unique set of template tags.
# #
# For user's convenience, we automatically add/remove the tags from Django's tag Library, # For user's convenience, we automatically add/remove the tags from Django's tag Library,
# when a component is (un)registered. # when a component is (un)registered.
@ -49,12 +30,7 @@ class NotRegistered(Exception):
# Thus we need to remember which component used which template tags. # Thus we need to remember which component used which template tags.
class ComponentRegistryEntry(NamedTuple): class ComponentRegistryEntry(NamedTuple):
cls: Type["Component"] cls: Type["Component"]
block_tag: str tag: str
inline_tag: str
@property
def tags(self) -> List[str]:
return [self.block_tag, self.inline_tag]
class ComponentRegistry: class ComponentRegistry:
@ -111,7 +87,7 @@ class ComponentRegistry:
# On the other hand, if user provided their own Library instance, # On the other hand, if user provided their own Library instance,
# it is up to the user to use `mark_protected_tags` if they want # it is up to the user to use `mark_protected_tags` if they want
# to protect any tags. # to protect any tags.
mark_protected_tags(tag_library, PROTECTED_TAGS) mark_protected_tags(tag_library)
lib = self._library = tag_library lib = self._library = tag_library
return lib return lib
@ -137,18 +113,11 @@ class ComponentRegistry:
if existing_component and existing_component.cls._class_hash != component._class_hash: if existing_component and existing_component.cls._class_hash != component._class_hash:
raise AlreadyRegistered('The component "%s" has already been registered' % name) raise AlreadyRegistered('The component "%s" has already been registered' % name)
block_tag = "component" entry = self._register_to_library(name, component)
inline_tag = "#component"
entry = ComponentRegistryEntry(
cls=component,
block_tag=block_tag,
inline_tag=inline_tag,
)
# Keep track of which components use which tags, because multiple components may # Keep track of which components use which tags, because multiple components may
# use the same tag. # use the same tag.
for tag in entry.tags: tag = entry.tag
if tag not in self._tags: if tag not in self._tags:
self._tags[tag] = set() self._tags[tag] = set()
self._tags[tag].add(name) self._tags[tag].add(name)
@ -180,9 +149,9 @@ class ComponentRegistry:
self.get(name) self.get(name)
entry = self._registry[name] entry = self._registry[name]
tag = entry.tag
# Unregister the tag from library if this was the last component using this tag # Unregister the tag from library if this was the last component using this tag
for tag in entry.tags:
# Unlink component from tag # Unlink component from tag
self._tags[tag].remove(name) self._tags[tag].remove(name)
@ -191,11 +160,9 @@ class ComponentRegistry:
if is_tag_empty: if is_tag_empty:
del self._tags[tag] del self._tags[tag]
# Do NOT unregister tag if it's protected # Only unregister a tag if it's NOT protected
is_protected = is_tag_protected(self.library, tag) is_protected = is_tag_protected(self.library, tag)
if is_protected: if not is_protected:
continue
# Unregister the tag from library if this was the last component using this tag # Unregister the tag from library if this was the last component using this tag
if is_tag_empty and tag in self.library.tags: if is_tag_empty and tag in self.library.tags:
del self.library.tags[tag] del self.library.tags[tag]
@ -268,6 +235,19 @@ class ComponentRegistry:
self._registry = {} self._registry = {}
self._tags = {} self._tags = {}
def _register_to_library(
self,
comp_name: str,
component: Type["Component"],
) -> ComponentRegistryEntry:
# Lazily import to avoid circular dependencies
from django_components.templatetags.component_tags import component as do_component
formatter = get_tag_formatter()
tag = register_tag_from_formatter(self.library, do_component, formatter, comp_name)
return ComponentRegistryEntry(cls=component, tag=tag)
# This variable represents the global component registry # This variable represents the global component registry
registry: ComponentRegistry = ComponentRegistry() registry: ComponentRegistry = ComponentRegistry()
@ -326,13 +306,3 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl
return component return component
return decorator return decorator
def mark_protected_tags(lib: Library, tags: List[str]) -> None:
# By marking the library as default,
lib._protected_tags = [*tags]
def is_tag_protected(lib: Library, tag: str) -> bool:
protected_tags = getattr(lib, "_protected_tags", [])
return tag in protected_tags

View file

@ -4,6 +4,14 @@ from django.template import Context
from django.template.base import FilterExpression, Parser from django.template.base import FilterExpression, Parser
class AggregateFilterExpression:
def __init__(self, dict: Dict[str, FilterExpression]) -> None:
self.dict = dict
Expression = Union[FilterExpression, AggregateFilterExpression]
def resolve_expression_as_identifier( def resolve_expression_as_identifier(
context: Context, context: Context,
fexp: FilterExpression, fexp: FilterExpression,
@ -20,19 +28,22 @@ def resolve_expression_as_identifier(
return resolved return resolved
def safe_resolve_list(args: List[FilterExpression], context: Context) -> List: def safe_resolve_list(args: List[Expression], context: Context) -> List:
return [safe_resolve(arg, context) for arg in args] return [safe_resolve(arg, context) for arg in args]
def safe_resolve_dict( def safe_resolve_dict(
kwargs: Union[Mapping[str, FilterExpression], Dict[str, FilterExpression]], kwargs: Union[Mapping[str, Expression], Dict[str, Expression]],
context: Context, context: Context,
) -> Dict: ) -> Dict:
return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()} return {key: safe_resolve(kwarg, context) for key, kwarg in kwargs.items()}
def safe_resolve(context_item: FilterExpression, context: Context) -> Any: def safe_resolve(context_item: Expression, context: Context) -> Any:
"""Resolve FilterExpressions and Variables in context if possible. Return other items unchanged.""" """Resolve FilterExpressions and Variables in context if possible. Return other items unchanged."""
if isinstance(context_item, AggregateFilterExpression):
return safe_resolve_dict(context_item.dict, context)
return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item return context_item.resolve(context) if hasattr(context_item, "resolve") else context_item
@ -42,5 +53,11 @@ def resolve_string(
context: Optional[Mapping[str, Any]] = None, context: Optional[Mapping[str, Any]] = None,
) -> str: ) -> str:
parser = parser or Parser([]) parser = parser or Parser([])
context = context or {} context = Context(context or {})
return parser.compile_filter(s).resolve(context) return parser.compile_filter(s).resolve(context)
def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")

View file

@ -0,0 +1,60 @@
"""Module for interfacing with Django's Library (`django.template.library`)"""
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
class TagProtectedError(Exception):
pass
PROTECTED_TAGS = [
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]
"""
These are the names that users cannot choose for their components,
as they would conflict with other tags in the Library.
"""
def register_tag(
library: Library,
tag: str,
tag_fn: Callable[[Parser, Token, str], Node],
) -> None:
# Register inline tag
if is_tag_protected(library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
else:
library.tag(tag, lambda parser, token: tag_fn(parser, token, tag))
def register_tag_from_formatter(
library: Library,
tag_fn: Callable[[Parser, Token, str], Node],
formatter: InternalTagFormatter,
component_name: str,
) -> str:
tag = formatter.start_tag(component_name)
register_tag(library, tag, tag_fn)
return tag
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:
protected_tags = tags if tags is not None else PROTECTED_TAGS
lib._protected_tags = [*protected_tags]
def is_tag_protected(lib: Library, tag: str) -> bool:
protected_tags = getattr(lib, "_protected_tags", [])
return tag in protected_tags

View file

@ -16,10 +16,9 @@ from django_components.context import (
_INJECT_CONTEXT_KEY_PREFIX, _INJECT_CONTEXT_KEY_PREFIX,
_ROOT_CTX_CONTEXT_KEY, _ROOT_CTX_CONTEXT_KEY,
) )
from django_components.expression import resolve_expression_as_identifier, safe_resolve_dict from django_components.expression import Expression, resolve_expression_as_identifier, safe_resolve_dict
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist from django_components.node import NodeTraverse, nodelist_has_content, walk_nodelist
from django_components.template_parser import process_aggregate_kwargs
from django_components.utils import gen_id from django_components.utils import gen_id
DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT" DEFAULT_SLOT_KEY = "_DJANGO_COMPONENTS_DEFAULT_SLOT"
@ -119,7 +118,7 @@ class SlotNode(Node):
is_required: bool = False, is_required: bool = False,
is_default: bool = False, is_default: bool = False,
node_id: Optional[str] = None, node_id: Optional[str] = None,
slot_kwargs: Optional[Dict[str, FilterExpression]] = None, slot_kwargs: Optional[Dict[str, Expression]] = None,
): ):
self.name = name self.name = name
self.nodelist = nodelist self.nodelist = nodelist
@ -173,7 +172,6 @@ class SlotNode(Node):
# are made available through a variable name that was set on the `{% fill %}` # are made available through a variable name that was set on the `{% fill %}`
# tag. # tag.
slot_kwargs = safe_resolve_dict(self.slot_kwargs, context) slot_kwargs = safe_resolve_dict(self.slot_kwargs, context)
slot_kwargs = process_aggregate_kwargs(slot_kwargs)
data_var = slot_fill.slot_data_var data_var = slot_fill.slot_data_var
if data_var: if data_var:
if not data_var.isidentifier(): if not data_var.isidentifier():

View file

@ -0,0 +1,219 @@
import abc
import re
from typing import List, NamedTuple
from django.template import TemplateSyntaxError
from django.utils.module_loading import import_string
from django_components.app_settings import app_settings
from django_components.expression import resolve_string
from django_components.template_parser import VAR_CHARS
from django_components.utils import is_str_wrapped_in_quotes
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=VAR_CHARS))
class TagResult(NamedTuple):
"""The return value from `TagFormatter.parse()`"""
component_name: str
"""Component name extracted from the template tag"""
tokens: List[str]
"""Remaining tokens (words) that were passed to the tag, with component name removed"""
class TagFormatterABC(abc.ABC):
@abc.abstractmethod
def start_tag(self, name: str) -> str:
"""Formats the start tag of a component."""
...
@abc.abstractmethod
def end_tag(self, name: str) -> str:
"""Formats the end tag of a block component."""
...
@abc.abstractmethod
def parse(self, tokens: List[str]) -> TagResult:
"""
Given the tokens (words) of a component start tag, this function extracts
the component name from the tokens list, and returns `TagResult`, which
is a tuple of `(component_name, remaining_tokens)`.
Example:
Given a component declarations:
`{% component "my_comp" key=val key2=val2 %}`
This function receives a list of tokens
`['component', '"my_comp"', 'key=val', 'key2=val2']`
`component` is the tag name, which we drop. `"my_comp"` is the component name,
but we must remove the extra quotes. And we pass remaining tokens unmodified,
as that's the input to the component.
So in the end, we return a tuple:
`('my_comp', ['key=val', 'key2=val2'])`
"""
...
class InternalTagFormatter:
"""
Internal wrapper around user-provided TagFormatters, so that we validate the outputs.
"""
def __init__(self, tag_formatter: TagFormatterABC):
self.tag_formatter = tag_formatter
def start_tag(self, name: str) -> str:
tag = self.tag_formatter.start_tag(name)
self._validate_tag(tag, "start_tag")
return tag
def end_tag(self, name: str) -> str:
tag = self.tag_formatter.end_tag(name)
self._validate_tag(tag, "end_tag")
return tag
def parse(self, tokens: List[str]) -> TagResult:
return self.tag_formatter.parse(tokens)
# NOTE: We validate the generated tags, so they contain only valid characters (\w - : . @ #)
# and NO SPACE. Otherwise we wouldn't be able to distinguish a "multi-word" tag from several
# single-word tags.
def _validate_tag(self, tag: str, tag_type: str) -> None:
if not tag:
raise ValueError(
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
f" Tag cannot be empty"
)
if not TAG_RE.match(tag):
raise ValueError(
f"{self.tag_formatter.__class__.__name__} returned an invalid tag for {tag_type}: '{tag}'."
f" Tag must contain only following chars: {VAR_CHARS}"
)
class ComponentFormatter(TagFormatterABC):
"""
The original django_component's component tag formatter, it uses the `component`
and `endcomponent` tags, and the component name is gives as the first positional arg.
Example as block:
```django
{% component "mycomp" abc=123 %}
{% fill "myfill" %}
...
{% endfill %}
{% endcomponent %}
```
Example as inlined tag:
```django
{% component "mycomp" abc=123 / %}
```
"""
def __init__(self, tag: str):
self.tag = tag
def start_tag(self, name: str) -> str:
return self.tag
def end_tag(self, name: str) -> str:
return f"end{self.tag}"
def parse(self, tokens: List[str]) -> TagResult:
tag, *args = tokens
if not args:
raise TemplateSyntaxError(f"{self.__class__.__name__}: Component tag did not receive tag name")
# If the first arg is a kwarg, not a positional arg, then look for the "name" kwarg
# for component name.
if "=" in args[0]:
comp_name = None
final_args = []
for kwarg in args:
if not kwarg.startswith("name="):
final_args.append(kwarg)
continue
if comp_name:
raise TemplateSyntaxError(
f"ComponentFormatter: 'name' kwarg for component '{comp_name}'" " was defined more than once."
)
# NOTE: We intentionally do NOT add to `final_args` here
# because we want to remove the the `name=` kwarg from args list
comp_name = kwarg[5:]
else:
comp_name = args.pop(0)
final_args = args
if not comp_name:
raise TemplateSyntaxError("Component name must be a non-empty quoted string, e.g. 'my_comp'")
if not is_str_wrapped_in_quotes(comp_name):
raise TemplateSyntaxError(f"Component name must be a string 'literal', got: {comp_name}")
# Remove the quotes
comp_name = resolve_string(comp_name)
return TagResult(comp_name, final_args)
class ShorthandComponentFormatter(TagFormatterABC):
"""
The component tag formatter that uses `<name>` / `end<name>` tags.
This is similar to django-web-components and django-slippers syntax.
Example as block:
```django
{% mycomp abc=123 %}
{% fill "myfill" %}
...
{% endfill %}
{% endmycomp %}
```
Example as inlined tag:
```django
{% mycomp abc=123 / %}
```
"""
def start_tag(self, name: str) -> str:
return name
def end_tag(self, name: str) -> str:
return f"end{name}"
def parse(self, tokens: List[str]) -> TagResult:
tokens = [*tokens]
name = tokens.pop(0)
return TagResult(name, tokens)
def get_tag_formatter() -> InternalTagFormatter:
"""Returns an instance of the currently configured component tag formatter."""
# Allow users to configure the component TagFormatter
formatter_cls_or_str = app_settings.TAG_FORMATTER
if isinstance(formatter_cls_or_str, str):
tag_formatter: TagFormatterABC = import_string(formatter_cls_or_str)
else:
tag_formatter = formatter_cls_or_str
return InternalTagFormatter(tag_formatter)
# Default formatters
component_formatter = ComponentFormatter("component")
component_shorthand_formatter = ShorthandComponentFormatter()

View file

@ -25,6 +25,9 @@ from django.utils.regex_helper import _lazy_re_compile
# This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special # This is a copy of the original FilterExpression. The only difference is to allow variable names to have extra special
# characters: - : . @ # # characters: - : . @ #
###################################################################################################################### ######################################################################################################################
VAR_CHARS = r"\w\-\:\@\.\#"
filter_raw_string = r""" filter_raw_string = r"""
^(?P<constant>{constant})| ^(?P<constant>{constant})|
^(?P<var>[{var_chars}]+|{num})| ^(?P<var>[{var_chars}]+|{num})|
@ -41,7 +44,7 @@ filter_raw_string = r"""
num=r"[-+\.]?\d[\d\.e]*", num=r"[-+\.]?\d[\d\.e]*",
# The following is the only difference from the original FilterExpression. We allow variable names to have extra # The following is the only difference from the original FilterExpression. We allow variable names to have extra
# special characters: - : . @ # # special characters: - : . @ #
var_chars=r"\w\-\:\@\.\#", var_chars=VAR_CHARS,
filter_sep=re.escape(FILTER_SEPARATOR), filter_sep=re.escape(FILTER_SEPARATOR),
arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR), arg_sep=re.escape(FILTER_ARGUMENT_SEPARATOR),
) )
@ -102,7 +105,7 @@ class ComponentsFilterExpression(FilterExpression):
###################################################################################################################### ######################################################################################################################
# Regex for token keyword arguments # Regex for token keyword arguments
kwarg_re = _lazy_re_compile(r"(?:([\w\-\:\@\.\#]+)=)?(.+)") kwarg_re = _lazy_re_compile(r"(?:([{var_chars}]+)=)?(.+)".format(var_chars=VAR_CHARS))
def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]: def token_kwargs(bits: List[str], parser: Parser) -> Dict[str, FilterExpression]:
@ -261,9 +264,7 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
processed_kwargs = {} processed_kwargs = {}
nested_kwargs: Dict[str, Dict[str, Any]] = {} nested_kwargs: Dict[str, Dict[str, Any]] = {}
for key, val in kwargs.items(): for key, val in kwargs.items():
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it. if not is_aggregate_key(key):
# This syntax is used by Vue and AlpineJS.
if ":" not in key or key.startswith(":"):
processed_kwargs[key] = val processed_kwargs[key] = val
continue continue
@ -283,3 +284,9 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
processed_kwargs[key] = val processed_kwargs[key] = val
return processed_kwargs return processed_kwargs
def is_aggregate_key(key: str) -> bool:
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")

View file

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Dict, List, Mapping, Optional, Tuple from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Tuple, Union
import django.template import django.template
from django.template.base import FilterExpression, NodeList, Parser, Token from django.template.base import FilterExpression, NodeList, Parser, Token
@ -10,7 +10,7 @@ from django_components.attributes import HtmlAttrsNode
from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode from django_components.component import RENDERED_COMMENT_TEMPLATE, ComponentNode
from django_components.component_registry import ComponentRegistry from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as component_registry from django_components.component_registry import registry as component_registry
from django_components.expression import resolve_string from django_components.expression import AggregateFilterExpression, Expression, resolve_string
from django_components.logger import trace_msg from django_components.logger import trace_msg
from django_components.middleware import ( from django_components.middleware import (
CSS_DEPENDENCY_PLACEHOLDER, CSS_DEPENDENCY_PLACEHOLDER,
@ -19,13 +19,16 @@ from django_components.middleware import (
) )
from django_components.provide import ProvideNode from django_components.provide import ProvideNode
from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist from django_components.slots import FillNode, SlotNode, parse_slot_fill_nodes_from_component_nodelist
from django_components.template_parser import parse_bits from django_components.tag_formatter import get_tag_formatter
from django_components.utils import gen_id from django_components.template_parser import is_aggregate_key, parse_bits, process_aggregate_kwargs
from django_components.utils import gen_id, is_str_wrapped_in_quotes
if TYPE_CHECKING: if TYPE_CHECKING:
from django_components.component import Component from django_components.component import Component
# 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() register = django.template.Library()
@ -35,7 +38,7 @@ SLOT_DATA_ATTR = "data"
SLOT_DEFAULT_ATTR = "default" SLOT_DEFAULT_ATTR = "default"
def get_components_from_registry(registry: ComponentRegistry) -> List["Component"]: def _get_components_from_registry(registry: ComponentRegistry) -> List["Component"]:
"""Returns a list unique components from the registry.""" """Returns a list unique components from the registry."""
unique_component_classes = set(registry.all().values()) unique_component_classes = set(registry.all().values())
@ -47,7 +50,7 @@ def get_components_from_registry(registry: ComponentRegistry) -> List["Component
return components return components
def get_components_from_preload_str(preload_str: str) -> List["Component"]: def _get_components_from_preload_str(preload_str: str) -> List["Component"]:
"""Returns a list of unique components from a comma-separated str""" """Returns a list of unique components from a comma-separated str"""
components = [] components = []
@ -62,83 +65,89 @@ def get_components_from_preload_str(preload_str: str) -> List["Component"]:
@register.simple_tag(name="component_dependencies") @register.simple_tag(name="component_dependencies")
def component_dependencies_tag(preload: str = "") -> SafeString: def component_dependencies(preload: str = "") -> SafeString:
"""Marks location where CSS link and JS script tags should be rendered.""" """Marks location where CSS link and JS script tags should be rendered."""
if is_dependency_middleware_active(): if is_dependency_middleware_active():
preloaded_dependencies = [] preloaded_dependencies = []
for component in get_components_from_preload_str(preload): for component in _get_components_from_preload_str(preload):
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER) return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER + JS_DEPENDENCY_PLACEHOLDER)
else: else:
rendered_dependencies = [] rendered_dependencies = []
for component in get_components_from_registry(component_registry): for component in _get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_dependencies()) rendered_dependencies.append(component.render_dependencies())
return mark_safe("\n".join(rendered_dependencies)) return mark_safe("\n".join(rendered_dependencies))
@register.simple_tag(name="component_css_dependencies") @register.simple_tag(name="component_css_dependencies")
def component_css_dependencies_tag(preload: str = "") -> SafeString: def component_css_dependencies(preload: str = "") -> SafeString:
"""Marks location where CSS link tags should be rendered.""" """Marks location where CSS link tags should be rendered."""
if is_dependency_middleware_active(): if is_dependency_middleware_active():
preloaded_dependencies = [] preloaded_dependencies = []
for component in get_components_from_preload_str(preload): for component in _get_components_from_preload_str(preload):
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER) return mark_safe("\n".join(preloaded_dependencies) + CSS_DEPENDENCY_PLACEHOLDER)
else: else:
rendered_dependencies = [] rendered_dependencies = []
for component in get_components_from_registry(component_registry): for component in _get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_css_dependencies()) rendered_dependencies.append(component.render_css_dependencies())
return mark_safe("\n".join(rendered_dependencies)) return mark_safe("\n".join(rendered_dependencies))
@register.simple_tag(name="component_js_dependencies") @register.simple_tag(name="component_js_dependencies")
def component_js_dependencies_tag(preload: str = "") -> SafeString: def component_js_dependencies(preload: str = "") -> SafeString:
"""Marks location where JS script tags should be rendered.""" """Marks location where JS script tags should be rendered."""
if is_dependency_middleware_active(): if is_dependency_middleware_active():
preloaded_dependencies = [] preloaded_dependencies = []
for component in get_components_from_preload_str(preload): for component in _get_components_from_preload_str(preload):
preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name)) preloaded_dependencies.append(RENDERED_COMMENT_TEMPLATE.format(name=component.registered_name))
return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER) return mark_safe("\n".join(preloaded_dependencies) + JS_DEPENDENCY_PLACEHOLDER)
else: else:
rendered_dependencies = [] rendered_dependencies = []
for component in get_components_from_registry(component_registry): for component in _get_components_from_registry(component_registry):
rendered_dependencies.append(component.render_js_dependencies()) rendered_dependencies.append(component.render_js_dependencies())
return mark_safe("\n".join(rendered_dependencies)) return mark_safe("\n".join(rendered_dependencies))
@register.tag("slot") @register.tag("slot")
def do_slot(parser: Parser, token: Token) -> SlotNode: def slot(parser: Parser, token: Token) -> SlotNode:
# e.g. {% slot <name> ... %} bits = token.split_contents()
tag_name, *args = token.split_contents() tag = _parse_tag(
slot_name, is_default, is_required, slot_kwargs = _parse_slot_args(parser, args, tag_name) "slot",
# Use a unique ID to be able to tie the fill nodes with components and slots parser,
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering bits,
slot_id = gen_id() params=["name"],
trace_msg("PARSE", "SLOT", slot_name, slot_id) flags=[SLOT_DEFAULT_OPTION_KEYWORD, SLOT_REQUIRED_OPTION_KEYWORD],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag="endslot",
)
data = _parse_slot_args(parser, tag)
nodelist = parser.parse(parse_until=["endslot"]) trace_msg("PARSE", "SLOT", data.name, tag.id)
parser.delete_first_token()
body = tag.parse_body()
slot_node = SlotNode( slot_node = SlotNode(
slot_name, name=data.name,
nodelist, nodelist=body,
is_required=is_required, is_required=tag.flags[SLOT_REQUIRED_OPTION_KEYWORD],
is_default=is_default, is_default=tag.flags[SLOT_DEFAULT_OPTION_KEYWORD],
node_id=slot_id, node_id=tag.id,
slot_kwargs=slot_kwargs, slot_kwargs=tag.kwargs,
) )
trace_msg("PARSE", "SLOT", slot_name, slot_id, "...Done!") trace_msg("PARSE", "SLOT", data.name, tag.id, "...Done!")
return slot_node return slot_node
@register.tag("fill") @register.tag("fill")
def do_fill(parser: Parser, token: Token) -> FillNode: def fill(parser: Parser, token: Token) -> FillNode:
""" """
Block tag whose contents 'fill' (are inserted into) an identically named Block tag whose contents 'fill' (are inserted into) an identically named
'slot'-block in the component template referred to by a parent component. 'slot'-block in the component template referred to by a parent component.
@ -147,32 +156,34 @@ def do_fill(parser: Parser, token: Token) -> FillNode:
This tag is available only within a {% component %}..{% endcomponent %} block. This tag is available only within a {% component %}..{% endcomponent %} block.
Runtime checks should prohibit other usages. Runtime checks should prohibit other usages.
""" """
# e.g. {% fill <name> %} bits = token.split_contents()
tag_name, *args = token.split_contents() tag = _parse_tag(
slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp = _parse_fill_args(parser, args, tag_name) "fill",
parser,
bits,
params=["name"],
keywordonly_kwargs=[SLOT_DATA_ATTR, SLOT_DEFAULT_ATTR],
repeatable_kwargs=False,
end_tag="endfill",
)
data = _parse_fill_args(parser, tag)
# Use a unique ID to be able to tie the fill nodes with components and slots trace_msg("PARSE", "FILL", str(data.slot_name), tag.id)
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
fill_id = gen_id()
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id)
nodelist = parser.parse(parse_until=["endfill"])
parser.delete_first_token()
body = tag.parse_body()
fill_node = FillNode( fill_node = FillNode(
nodelist, nodelist=body,
name_fexp=slot_name_fexp, name_fexp=data.slot_name,
slot_default_var_fexp=slot_default_var_fexp, slot_default_var_fexp=data.slot_default_var,
slot_data_var_fexp=slot_data_var_fexp, slot_data_var_fexp=data.slot_data_var,
node_id=fill_id, node_id=tag.id,
) )
trace_msg("PARSE", "FILL", str(slot_name_fexp), fill_id, "...Done!") trace_msg("PARSE", "FILL", str(data.slot_name), tag.id, "...Done!")
return fill_node return fill_node
@register.tag(name="component") def component(parser: Parser, token: Token, tag_name: str) -> ComponentNode:
def do_component(parser: Parser, token: Token) -> ComponentNode:
""" """
To give the component access to the template context: To give the component access to the template context:
{% component "name" positional_arg keyword_arg=value ... %} {% component "name" positional_arg keyword_arg=value ... %}
@ -185,64 +196,83 @@ def do_component(parser: Parser, token: Token) -> ComponentNode:
be either the first positional argument or, if there are no positional be either the first positional argument or, if there are no positional
arguments, passed as 'name'. arguments, passed as 'name'.
""" """
bits = token.split_contents() bits = token.split_contents()
bits, isolated_context = _check_for_isolated_context_keyword(bits)
component_name, context_args, context_kwargs = _parse_component_with_args(parser, bits, "component")
# Use a unique ID to be able to tie the fill nodes with components and slots # Let the TagFormatter pre-process the tokens
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering formatter = get_tag_formatter()
component_id = gen_id() result = formatter.parse([*bits])
trace_msg("PARSE", "COMP", component_name, component_id) end_tag = formatter.end_tag(result.component_name)
body: NodeList = parser.parse(parse_until=["endcomponent"]) # NOTE: The tokens returned from TagFormatter.parse do NOT include the tag itself
parser.delete_first_token() bits = [bits[0], *result.tokens]
tag = _parse_tag(
tag_name,
parser,
bits,
params=True, # Allow many args
flags=["only"],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag=end_tag,
)
data = _parse_component_args(parser, tag)
trace_msg("PARSE", "COMP", result.component_name, tag.id)
body = tag.parse_body()
fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode) fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
# Tag all fill nodes as children of this particular component instance # Tag all fill nodes as children of this particular component instance
for node in fill_nodes: for node in fill_nodes:
trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=component_id) trace_msg("ASSOC", "FILL", node.name_fexp, node.node_id, component_id=tag.id)
node.component_id = component_id node.component_id = tag.id
component_node = ComponentNode( component_node = ComponentNode(
FilterExpression(component_name, parser), name=result.component_name,
context_args, context_args=tag.args,
context_kwargs, context_kwargs=tag.kwargs,
isolated_context=isolated_context, isolated_context=data.isolated_context,
fill_nodes=fill_nodes, fill_nodes=fill_nodes,
component_id=component_id, component_id=tag.id,
) )
trace_msg("PARSE", "COMP", component_name, component_id, "...Done!") trace_msg("PARSE", "COMP", result.component_name, tag.id, "...Done!")
return component_node return component_node
@register.tag("provide") @register.tag("provide")
def do_provide(parser: Parser, token: Token) -> SlotNode: def provide(parser: Parser, token: Token) -> ProvideNode:
# e.g. {% provide <name> key=val key2=val2 %} # e.g. {% provide <name> key=val key2=val2 %}
tag_name, *args = token.split_contents() bits = token.split_contents()
provide_key, kwargs = _parse_provide_args(parser, args, tag_name) tag = _parse_tag(
"provide",
parser,
bits,
params=["name"],
flags=[],
keywordonly_kwargs=True,
repeatable_kwargs=False,
end_tag="endprovide",
)
data = _parse_provide_args(parser, tag)
# Use a unique ID to be able to tie the fill nodes with components and slots trace_msg("PARSE", "PROVIDE", data.key, tag.id)
# NOTE: MUST be called BEFORE `parser.parse()` to ensure predictable numbering
slot_id = gen_id()
trace_msg("PARSE", "PROVIDE", provide_key, slot_id)
nodelist = parser.parse(parse_until=["endprovide"]) body = tag.parse_body()
parser.delete_first_token()
slot_node = ProvideNode( slot_node = ProvideNode(
provide_key, name=data.key,
nodelist, nodelist=body,
node_id=slot_id, node_id=tag.id,
provide_kwargs=kwargs, provide_kwargs=tag.kwargs,
) )
trace_msg("PARSE", "PROVIDE", provide_key, slot_id, "...Done!") trace_msg("PARSE", "PROVIDE", data.key, tag.id, "...Done!")
return slot_node return slot_node
@register.tag("html_attrs") @register.tag("html_attrs")
def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode: def html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
""" """
This tag takes: This tag takes:
- Optional dictionary of attributes (`attrs`) - Optional dictionary of attributes (`attrs`)
@ -270,243 +300,315 @@ def do_html_attrs(parser: Parser, token: Token) -> HtmlAttrsNode:
``` ```
""" """
bits = token.split_contents() bits = token.split_contents()
attributes, default_attrs, append_attrs = _parse_html_attrs_args(parser, bits, "html_attrs")
return HtmlAttrsNode(attributes, default_attrs, append_attrs) tag = _parse_tag(
"html_attrs",
parser,
bits,
params=["attrs", "defaults"],
optional_params=["attrs", "defaults"],
flags=[],
keywordonly_kwargs=True,
repeatable_kwargs=True,
)
return HtmlAttrsNode(
attributes=tag.kwargs.get("attrs"),
defaults=tag.kwargs.get("defaults"),
kwargs=[(key, val) for key, val in tag.kwarg_pairs if key != "attrs" and key != "defaults"],
)
def _check_for_isolated_context_keyword(bits: List[str]) -> Tuple[List[str], bool]: class ParsedTag(NamedTuple):
"""Return True and strip the last word if token ends with 'only' keyword or if CONTEXT_BEHAVIOR is 'isolated'.""" id: str
name: str
if bits[-1] == "only": bits: List[str]
return bits[:-1], True flags: Dict[str, bool]
args: List[FilterExpression]
if app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED: named_args: Dict[str, FilterExpression]
return bits, True kwargs: Dict[str, Expression]
kwarg_pairs: List[Tuple[str, Expression]]
return bits, False is_inline: bool
parse_body: Callable[[], NodeList]
def _parse_component_with_args( def _parse_tag(
parser: Parser, bits: List[str], tag_name: str tag: str,
) -> Tuple[str, List[FilterExpression], Mapping[str, FilterExpression]]: parser: Parser,
tag_args, tag_kwarg_pairs = parse_bits( bits: List[str],
params: Union[List[str], bool] = False,
flags: Optional[List[str]] = None,
end_tag: Optional[str] = None,
optional_params: Optional[List[str]] = None,
keywordonly_kwargs: Optional[Union[bool, List[str]]] = False,
repeatable_kwargs: Optional[Union[bool, List[str]]] = False,
) -> ParsedTag:
# Use a unique ID to be able to tie the fill nodes with components and slots
# NOTE: MUST be called BEFORE `parse_body()` to ensure predictable numbering
tag_id = gen_id()
params = params or []
# e.g. {% slot <name> ... %}
tag_name, *bits = bits
if tag_name != tag:
raise TemplateSyntaxError(f"Start tag parser received tag '{tag_name}', expected '{tag}'")
# Decide if the template tag is inline or block and strip the trailing slash
last_token = bits[-1] if len(bits) else None
if last_token == "/":
bits.pop()
is_inline = True
else:
# If no end tag was given, we assume that the tag is inline-only
is_inline = not end_tag
parsed_flags = {flag: False for flag in (flags or [])}
bits_without_flags: List[str] = []
seen_kwargs: Set[str] = set()
seen_agg_keys: Set[str] = set()
def mark_kwarg_key(key: str, is_agg_key: bool) -> None:
if (is_agg_key and key in seen_kwargs) or (not is_agg_key and key in seen_agg_keys):
raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
)
if is_agg_key:
seen_agg_keys.add(key)
else:
seen_kwargs.add(key)
for bit in bits:
# Extract flags, which are like keywords but without the value part
if bit in parsed_flags:
parsed_flags[bit] = True
continue
else:
bits_without_flags.append(bit)
# Record which kwargs we've seen, to detect if kwargs were passed in
# as both aggregate and regular kwargs
if "=" in bit:
key = bit.split("=")[0]
# Also pick up on aggregate keys like `attr:key=val`
if is_aggregate_key(key):
key = key.split(":")[0]
mark_kwarg_key(key, True)
else:
mark_kwarg_key(key, False)
bits = bits_without_flags
# To support optional args, we need to convert these to kwargs, so `parse_bits`
# can handle them. So we assign the keys to matched positional args,
# and then move the kwarg AFTER the pos args.
#
# TODO: This following section should live in `parse_bits`, but I don't want to
# modify it much to maintain some sort of compatibility with Django's version of
# `parse_bits`.
# Ideally, Django's parser would be expanded to support our use cases.
if params != True: # noqa F712
params_to_sort = [param for param in params if param not in seen_kwargs]
new_args = []
new_params = []
new_kwargs = []
for index, bit in enumerate(bits):
if "=" in bit or not len(params_to_sort):
# Pass all remaining bits (including current one) as kwargs
new_kwargs.extend(bits[index:])
break
param = params_to_sort.pop(0)
if optional_params and param in optional_params:
mark_kwarg_key(param, False)
new_kwargs.append(f"{param}={bit}")
continue
new_args.append(bit)
new_params.append(param)
bits = [*new_args, *new_kwargs]
params = [*new_params, *params_to_sort]
# Remove any remaining optional positional args if they were not given
if optional_params:
params = [param for param in params_to_sort if param not in optional_params]
# Parse args/kwargs that will be passed to the fill
args, raw_kwarg_pairs = parse_bits(
parser=parser, parser=parser,
bits=bits, bits=bits,
params=["tag_name", "name"], params=[] if isinstance(params, bool) else params,
name=tag_name, name=tag_name,
) )
tag_kwargs = {} # Post-process args/kwargs - Mark special cases like aggregate dicts
for key, val in tag_kwarg_pairs: # or dynamic expressions
if key in tag_kwargs: pre_aggregate_kwargs: Dict[str, FilterExpression] = {}
kwarg_pairs: List[Tuple[str, Expression]] = []
for key, val in raw_kwarg_pairs:
# NOTE: If a tag allows mutliple kwargs, and we provide a same aggregate key
# multiple times (e.g. `attr:class="hidden" and `attr:class="another"`), then
# we take only the last instance.
if is_aggregate_key(key):
pre_aggregate_kwargs[key] = val
else:
kwarg_pairs.append((key, val))
aggregate_kwargs: Dict[str, Dict[str, FilterExpression]] = process_aggregate_kwargs(pre_aggregate_kwargs)
for key, agg_dict in aggregate_kwargs.items():
entry = (key, AggregateFilterExpression(agg_dict))
kwarg_pairs.append(entry)
# Allow only as many positional args as given
if params != True and len(args) > len(params): # noqa F712
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {args[len(params):]}")
# For convenience, allow to access named args by their name instead of index
if params != True: # noqa F712
named_args = {param: args[index] for index, param in enumerate(params)}
else:
named_args = {}
# Validate kwargs
kwargs: Dict[str, Expression] = {}
extra_keywords: Set[str] = set()
for key, val in kwarg_pairs:
# Check if key allowed
if not keywordonly_kwargs:
is_key_allowed = False
else:
is_key_allowed = keywordonly_kwargs == True or key in keywordonly_kwargs # noqa: E712
if not is_key_allowed:
extra_keywords.add(key)
# Check for repeated keys
if key in kwargs:
if not repeatable_kwargs:
is_key_repeatable = False
else:
is_key_repeatable = repeatable_kwargs == True or key in repeatable_kwargs # noqa: E712
if not is_key_repeatable:
# The keyword argument has already been supplied once # The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'") raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val # All ok
kwargs[key] = val
if tag_name != tag_args[0].token: if len(extra_keywords):
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}") extra_keys = ", ".join(extra_keywords)
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}")
component_name = _get_positional_param(tag_name, "name", 1, tag_args, tag_kwargs).token return ParsedTag(
if len(tag_args) > 1: id=tag_id,
# Positional args given. Skip tag and component name and take the rest
context_args = tag_args[2:]
else: # No positional args
context_args = []
return component_name, context_args, tag_kwargs
def _parse_html_attrs_args(
parser: Parser, bits: List[str], tag_name: str
) -> Tuple[Optional[FilterExpression], Optional[FilterExpression], List[Tuple[str, FilterExpression]]]:
tag_args, tag_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=["tag_name"],
name=tag_name, name=tag_name,
bits=bits,
flags=parsed_flags,
args=args,
named_args=named_args,
kwargs=kwargs,
kwarg_pairs=kwarg_pairs,
# 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, end_tag, is_inline) if end_tag else NodeList(),
is_inline=is_inline,
) )
# NOTE: Unlike in the `component` tag, in this case we don't care about duplicates,
# as we're constructing the dict simply to find the `attrs` kwarg.
tag_kwargs = {key: val for key, val in tag_kwarg_pairs}
if tag_name != tag_args[0].token: def _parse_tag_body(parser: Parser, end_tag: str, inline: bool) -> NodeList:
raise RuntimeError(f"Internal error: Expected tag_name to be {tag_name}, but it was {tag_args[0].token}") if inline:
body = NodeList()
else:
body = parser.parse(parse_until=[end_tag])
parser.delete_first_token()
return body
# Allow to optioanlly provide `attrs` as positional arg `{% html_attrs attrs %}`
try:
attrs = _get_positional_param(tag_name, "attrs", 1, tag_args, tag_kwargs)
except TemplateSyntaxError:
attrs = None
# Allow to optionally provide `defaults` as positional arg `{% html_attrs attrs defaults %}` class ParsedComponentTag(NamedTuple):
try: isolated_context: bool
defaults = _get_positional_param(tag_name, "defaults", 2, tag_args, tag_kwargs)
except TemplateSyntaxError:
defaults = None
# Allow only up to 2 positional args - [0] == tag name, [1] == attrs, [2] == defaults
if len(tag_args) > 3:
raise TemplateSyntaxError(f"Tag '{tag_name}' received unexpected positional arguments: {tag_args[2:]}")
return attrs, defaults, tag_kwarg_pairs def _parse_component_args(
parser: Parser,
tag: ParsedTag,
) -> ParsedComponentTag:
# Check for isolated context keyword
isolated_context = tag.flags["only"] or app_settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED
return ParsedComponentTag(isolated_context=isolated_context)
class ParsedSlotTag(NamedTuple):
name: str
def _parse_slot_args( def _parse_slot_args(
parser: Parser, parser: Parser,
bits: List[str], tag: ParsedTag,
tag_name: str, ) -> ParsedSlotTag:
) -> Tuple[str, bool, bool, Dict[str, FilterExpression]]: slot_name = tag.named_args["name"].token
if not len(bits): if not is_str_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError( raise TemplateSyntaxError(f"'{tag.name}' name must be a string 'literal', got {slot_name}.")
"'slot' tag does not match pattern "
"{% slot <name> ['default'] ['required'] [key=val, ...] %}. "
"Order of options is free."
)
slot_name, *options = bits
if not is_wrapped_in_quotes(slot_name):
raise TemplateSyntaxError(f"'{tag_name}' name must be a string 'literal'.")
slot_name = resolve_string(slot_name, parser) slot_name = resolve_string(slot_name, parser)
# Parse flags - Since `parse_bits` doesn't handle "shorthand" kwargs return ParsedSlotTag(name=slot_name)
# (AKA `required` for `required=True`), we have to first get the flags out
# of the way.
def extract_value(lst: List[str], value: str) -> bool:
"""Check if value exists in list, and if so, remove it from said list"""
try:
lst.remove(value)
return True
except ValueError:
return False
is_default = extract_value(options, SLOT_DEFAULT_OPTION_KEYWORD)
is_required = extract_value(options, SLOT_REQUIRED_OPTION_KEYWORD)
# Parse kwargs that will be passed to the fill class ParsedFillTag(NamedTuple):
_, tag_kwarg_pairs = parse_bits( slot_name: FilterExpression
parser=parser, slot_default_var: Optional[FilterExpression]
bits=options, slot_data_var: Optional[FilterExpression]
params=[],
name=tag_name,
)
tag_kwargs: Dict[str, FilterExpression] = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
# The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
return slot_name, is_default, is_required, tag_kwargs
def _parse_fill_args( def _parse_fill_args(
parser: Parser, parser: Parser,
bits: List[str], tag: ParsedTag,
tag_name: str, ) -> ParsedFillTag:
) -> Tuple[FilterExpression, Optional[FilterExpression], Optional[FilterExpression]]: slot_name_fexp = tag.named_args["name"]
if not len(bits):
raise TemplateSyntaxError(
"'fill' tag does not match pattern "
f"{{% fill <name> [{SLOT_DATA_ATTR}=val] [{SLOT_DEFAULT_ATTR}=slot_var] %}} "
)
slot_name = bits.pop(0)
slot_name_fexp = parser.compile_filter(slot_name)
# Even tho we want to parse only single kwarg, we use the same logic for parsing
# as we use for other tags, for consistency.
_, tag_kwarg_pairs = parse_bits(
parser=parser,
bits=bits,
params=[],
name=tag_name,
)
tag_kwargs: Dict[str, FilterExpression] = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
# Extract known kwargs # Extract known kwargs
slot_data_var_fexp: Optional[FilterExpression] = None slot_data_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DATA_ATTR)
if SLOT_DATA_ATTR in tag_kwargs: if slot_data_var_fexp and not is_str_wrapped_in_quotes(slot_data_var_fexp.token):
slot_data_var_fexp = tag_kwargs.pop(SLOT_DATA_ATTR)
if not is_wrapped_in_quotes(slot_data_var_fexp.token):
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Value of '{SLOT_DATA_ATTR}' in '{tag_name}' tag must be a string literal, got '{slot_data_var_fexp}'" f"Value of '{SLOT_DATA_ATTR}' in '{tag.name}' tag must be a string literal, got '{slot_data_var_fexp}'"
) )
slot_default_var_fexp: Optional[FilterExpression] = None slot_default_var_fexp: Optional[FilterExpression] = tag.kwargs.get(SLOT_DEFAULT_ATTR)
if SLOT_DEFAULT_ATTR in tag_kwargs: if slot_default_var_fexp and not is_str_wrapped_in_quotes(slot_default_var_fexp.token):
slot_default_var_fexp = tag_kwargs.pop(SLOT_DEFAULT_ATTR)
if not is_wrapped_in_quotes(slot_default_var_fexp.token):
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag_name}' tag must be a string literal," f"Value of '{SLOT_DEFAULT_ATTR}' in '{tag.name}' tag must be a string literal,"
f" got '{slot_default_var_fexp}'" f" got '{slot_default_var_fexp}'"
) )
# data and default cannot be bound to the same variable # data and default cannot be bound to the same variable
if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token: if slot_data_var_fexp and slot_default_var_fexp and slot_data_var_fexp.token == slot_default_var_fexp.token:
raise TemplateSyntaxError( raise TemplateSyntaxError(
f"'{tag_name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)" f"'{tag.name}' received the same string for slot default ({SLOT_DEFAULT_ATTR}=...)"
f" and slot data ({SLOT_DATA_ATTR}=...)" f" and slot data ({SLOT_DATA_ATTR}=...)"
) )
if len(tag_kwargs): return ParsedFillTag(
extra_keywords = tag_kwargs.keys() slot_name=slot_name_fexp,
extra_keys = ", ".join(extra_keywords) slot_default_var=slot_default_var_fexp,
raise TemplateSyntaxError(f"'{tag_name}' received unexpected kwargs: {extra_keys}") slot_data_var=slot_data_var_fexp,
)
return slot_name_fexp, slot_default_var_fexp, slot_data_var_fexp
class ParsedProvideTag(NamedTuple):
key: str
def _parse_provide_args( def _parse_provide_args(
parser: Parser, parser: Parser,
bits: List[str], tag: ParsedTag,
tag_name: str, ) -> ParsedProvideTag:
) -> Tuple[str, Dict[str, FilterExpression]]: provide_key = tag.named_args["name"].token
if not len(bits): if not is_str_wrapped_in_quotes(provide_key):
raise TemplateSyntaxError("'provide' tag does not match pattern {% provide <key> [key=val, ...] %}. ") raise TemplateSyntaxError(f"'{tag.name}' key must be a string 'literal'.")
provide_key, *options = bits
if not is_wrapped_in_quotes(provide_key):
raise TemplateSyntaxError(f"'{tag_name}' key must be a string 'literal'.")
provide_key = resolve_string(provide_key, parser) provide_key = resolve_string(provide_key, parser)
# Parse kwargs that will be 'provided' under the given key return ParsedProvideTag(key=provide_key)
_, tag_kwarg_pairs = parse_bits(parser=parser, bits=options, params=[], name=tag_name)
tag_kwargs: Dict[str, FilterExpression] = {}
for key, val in tag_kwarg_pairs:
if key in tag_kwargs:
# The keyword argument has already been supplied once
raise TemplateSyntaxError(f"'{tag_name}' received multiple values for keyword argument '{key}'")
tag_kwargs[key] = val
return provide_key, tag_kwargs
def _get_positional_param(
tag_name: str,
param_name: str,
param_index: int,
args: List[FilterExpression],
kwargs: Dict[str, FilterExpression],
) -> FilterExpression:
# Param is given as positional arg, e.g. `{% tag param %}`
if len(args) > param_index:
param = args[param_index]
return param
# Check if param was given as kwarg, e.g. `{% tag param_name=param %}`
elif param_name in kwargs:
param = kwargs.pop(param_name)
return param
raise TemplateSyntaxError(f"Param '{param_name}' not found in '{tag_name}' tag")
def is_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1]

View file

@ -19,3 +19,7 @@ def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
if predicate(elem): if predicate(elem):
return len(lst) - 1 - r_idx return len(lst) - 1 - r_idx
return -1 return -1
def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2

View file

@ -128,7 +128,7 @@ class HtmlAttrsTests(BaseTestCase):
template = Template(self.template_str) template = Template(self.template_str)
with self.assertRaisesMessage( with self.assertRaisesMessage(
TemplateSyntaxError, "Tag 'html_attrs' received unexpected positional arguments" TemplateSyntaxError, "'html_attrs' received some positional argument(s) after some keyword"
): ):
template.render(Context({"class_var": "padding-top-8"})) template.render(Context({"class_var": "padding-top-8"}))

View file

@ -5,7 +5,7 @@ from django_components import Component, registry, types
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config() setup_test_config({"autodiscover": False})
######################### #########################

View file

@ -1,12 +1,21 @@
import unittest import unittest
from django.template import Library from django.template import Library
from django.test import override_settings
from django_components import AlreadyRegistered, Component, ComponentRegistry, NotRegistered, register, registry from django_components import (
AlreadyRegistered,
Component,
ComponentRegistry,
NotRegistered,
TagProtectedError,
register,
registry,
)
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
setup_test_config() setup_test_config({"autodiscover": False})
class MockComponent(Component): class MockComponent(Component):
@ -69,7 +78,7 @@ class ComponentRegistryTest(unittest.TestCase):
def test_unregisters_only_unused_tags(self): def test_unregisters_only_unused_tags(self):
self.assertDictEqual(self.registry._tags, {}) self.assertDictEqual(self.registry._tags, {})
# NOTE: We preserve the default component tags # NOTE: We preserve the default component tags
self.assertIn("component", self.registry.library.tags) self.assertNotIn("component", self.registry.library.tags)
# Register two components that use the same tag # Register two components that use the same tag
self.registry.register(name="testcomponent", component=MockComponent) self.registry.register(name="testcomponent", component=MockComponent)
@ -78,7 +87,6 @@ class ComponentRegistryTest(unittest.TestCase):
self.assertDictEqual( self.assertDictEqual(
self.registry._tags, self.registry._tags,
{ {
"#component": {"testcomponent", "testcomponent2"},
"component": {"testcomponent", "testcomponent2"}, "component": {"testcomponent", "testcomponent2"},
}, },
) )
@ -91,7 +99,6 @@ class ComponentRegistryTest(unittest.TestCase):
self.assertDictEqual( self.assertDictEqual(
self.registry._tags, self.registry._tags,
{ {
"#component": {"testcomponent2"},
"component": {"testcomponent2"}, "component": {"testcomponent2"},
}, },
) )
@ -102,7 +109,7 @@ class ComponentRegistryTest(unittest.TestCase):
self.registry.unregister(name="testcomponent2") self.registry.unregister(name="testcomponent2")
self.assertDictEqual(self.registry._tags, {}) self.assertDictEqual(self.registry._tags, {})
self.assertIn("component", self.registry.library.tags) self.assertNotIn("component", self.registry.library.tags)
def test_prevent_registering_different_components_with_the_same_name(self): def test_prevent_registering_different_components_with_the_same_name(self):
self.registry.register(name="testcomponent", component=MockComponent) self.registry.register(name="testcomponent", component=MockComponent)
@ -124,3 +131,35 @@ class ComponentRegistryTest(unittest.TestCase):
def test_raises_on_failed_unregister(self): def test_raises_on_failed_unregister(self):
with self.assertRaises(NotRegistered): with self.assertRaises(NotRegistered):
self.registry.unregister(name="testcomponent") self.registry.unregister(name="testcomponent")
class ProtectedTagsTest(unittest.TestCase):
def setUp(self):
super().setUp()
self.registry = ComponentRegistry()
# NOTE: Use the `component_shorthand_formatter` formatter, so the components
# are registered under that tag
@override_settings(COMPONENTS={"tag_formatter": "django_components.component_shorthand_formatter"})
def test_raises_on_overriding_our_tags(self):
for tag in [
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",
"html_attrs",
"provide",
"slot",
]:
with self.assertRaises(TagProtectedError):
@register(tag)
class TestComponent(Component):
pass
@register("sth_else")
class TestComponent2(Component):
pass
# Cleanup
registry.unregister("sth_else")

402
tests/test_tag_formatter.py Normal file
View file

@ -0,0 +1,402 @@
from django.template import Context, Template
from django_components import Component, register, types
from django_components.tag_formatter import ShorthandComponentFormatter
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config({"autodiscover": False})
class MultiwordStartTagFormatter(ShorthandComponentFormatter):
def start_tag(self, name):
return f"{name} comp"
class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter):
def end_tag(self, name):
return f"end {name}"
# Create a TagFormatter class to validate the public interface
def create_validator_tag_formatter(tag_name: str):
class ValidatorTagFormatter(ShorthandComponentFormatter):
def start_tag(self, name):
assert name == tag_name
return super().start_tag(name)
def end_tag(self, name):
assert name == tag_name
return super().end_tag(name)
def parse(self, tokens):
assert isinstance(tokens, list)
assert tokens[0] == tag_name
return super().parse(tokens)
return ValidatorTagFormatter()
class ComponentTagTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_formatter_default_inline(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_formatter_default_block(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" %}
OVERRIDEN!
{% endcomponent %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_formatter",
},
},
)
def test_formatter_component_inline(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_formatter",
},
},
)
def test_formatter_component_block(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% component "simple" %}
OVERRIDEN!
{% endcomponent %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_shorthand_formatter",
},
},
)
def test_formatter_shorthand_inline(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% simple / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": "django_components.component_shorthand_formatter",
},
},
)
def test_formatter_shorthand_block(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% endsimple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": ShorthandComponentFormatter(),
},
},
)
def test_import_formatter_by_value(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
"""
template = Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% endsimple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
<div>
OVERRIDEN!
</div>
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": MultiwordStartTagFormatter(),
},
},
)
def test_raises_on_invalid_start_tag(self):
with self.assertRaisesMessage(
ValueError, "MultiwordStartTagFormatter returned an invalid tag for start_tag: 'simple comp'"
):
@register("simple")
class SimpleComponent(Component):
template = """{% load component_tags %}"""
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": MultiwordBlockEndTagFormatter(),
},
},
)
def test_raises_on_invalid_block_end_tag(self):
with self.assertRaisesMessage(
ValueError, "MultiwordBlockEndTagFormatter returned an invalid tag for end_tag: 'end simple'"
):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
"""
Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% bar %}
"""
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": create_validator_tag_formatter("simple"),
},
},
)
def test_method_args(self):
@register("simple")
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
hello1
<div>
{% slot "content" default %} SLOT_DEFAULT {% endslot %}
</div>
hello2
"""
template = Template(
"""
{% load component_tags %}
{% simple / %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
SLOT_DEFAULT
</div>
hello2
""",
)
template = Template(
"""
{% load component_tags %}
{% simple %}
OVERRIDEN!
{% endsimple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)

View file

@ -3,50 +3,49 @@ from django.template.base import Parser
from django_components import Component, registry, types from django_components import Component, registry, types
from django_components.component import safe_resolve_dict, safe_resolve_list from django_components.component import safe_resolve_dict, safe_resolve_list
from django_components.template_parser import process_aggregate_kwargs from django_components.template_parser import is_aggregate_key, process_aggregate_kwargs
from django_components.templatetags.component_tags import _parse_component_with_args from django_components.templatetags.component_tags import _parse_tag
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config() setup_test_config({"autodiscover": False})
class ParserTest(BaseTestCase): class ParserTest(BaseTestCase):
def test_parses_args_kwargs(self): def test_parses_args_kwargs(self):
bits = ["component", "my_component", "42", "myvar", "key='val'", "key2=val2"] bits = ["component", "42", "myvar", "key='val'", "key2=val2"]
name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component") tag = _parse_tag("component", Parser(""), bits, params=["num", "var"], keywordonly_kwargs=True)
ctx = {"myvar": {"a": "b"}, "val2": 1} ctx = {"myvar": {"a": "b"}, "val2": 1}
args = safe_resolve_list(raw_args, ctx) args = safe_resolve_list(tag.args, ctx)
kwargs = safe_resolve_dict(raw_kwargs, ctx) named_args = safe_resolve_dict(tag.named_args, ctx)
kwargs = safe_resolve_dict(tag.kwargs, ctx)
self.assertEqual(name, "my_component")
self.assertListEqual(args, [42, {"a": "b"}]) self.assertListEqual(args, [42, {"a": "b"}])
self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}})
self.assertDictEqual(kwargs, {"key": "val", "key2": 1}) self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
def test_parses_special_kwargs(self): def test_parses_special_kwargs(self):
bits = [ bits = [
"component", "component",
"my_component",
"date=date", "date=date",
"@lol=2", "@lol=2",
"na-me=bzz", "na-me=bzz",
"@event:na-me.mod=bzz", "@event:na-me.mod=bzz",
"#my-id=True", "#my-id=True",
] ]
name, raw_args, raw_kwargs = _parse_component_with_args(Parser(""), bits, "component") tag = _parse_tag("component", Parser(""), bits, keywordonly_kwargs=True)
ctx = Context({"date": 2024, "bzz": "fzz"}) ctx = Context({"date": 2024, "bzz": "fzz"})
args = safe_resolve_list(raw_args, ctx) args = safe_resolve_list(tag.args, ctx)
kwargs = safe_resolve_dict(raw_kwargs, ctx) kwargs = safe_resolve_dict(tag.kwargs, ctx)
self.assertEqual(name, "my_component")
self.assertListEqual(args, []) self.assertListEqual(args, [])
self.assertDictEqual( self.assertDictEqual(
kwargs, kwargs,
{ {
"@event:na-me.mod": "fzz", "@event": {"na-me.mod": "fzz"},
"@lol": 2, "@lol": 2,
"date": 2024, "date": 2024,
"na-me": "fzz", "na-me": "fzz",
@ -117,3 +116,15 @@ class AggregateKwargsTest(BaseTestCase):
":placeholder": "No text", ":placeholder": "No text",
}, },
) )
def is_aggregate_key(self):
self.assertEqual(is_aggregate_key(""), False)
self.assertEqual(is_aggregate_key(" "), False)
self.assertEqual(is_aggregate_key(" : "), False)
self.assertEqual(is_aggregate_key("attrs"), False)
self.assertEqual(is_aggregate_key(":attrs"), False)
self.assertEqual(is_aggregate_key(" :attrs "), False)
self.assertEqual(is_aggregate_key("attrs:"), False)
self.assertEqual(is_aggregate_key(":attrs:"), False)
self.assertEqual(is_aggregate_key("at:trs"), True)
self.assertEqual(is_aggregate_key(":at:trs"), False)

View file

@ -35,7 +35,9 @@ class SlottedComponentWithContext(Component):
class ComponentTemplateTagTest(BaseTestCase): class ComponentTemplateTagTest(BaseTestCase):
class SimpleComponent(Component): class SimpleComponent(Component):
template_name = "simple_template.html" template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
def get_context_data(self, variable, variable2="default"): def get_context_data(self, variable, variable2="default"):
return { return {
@ -61,7 +63,20 @@ class ComponentTemplateTagTest(BaseTestCase):
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n") self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_call_with_invalid_name(self): def test_single_component_self_closing(self):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component name="test" variable="variable" /%}
"""
template = Template(simple_tag_template)
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_no_registered_components(self):
# Note: No tag registered # Note: No tag registered
simple_tag_template: types.django_html = """ simple_tag_template: types.django_html = """
@ -69,6 +84,18 @@ class ComponentTemplateTagTest(BaseTestCase):
{% component name="test" variable="variable" %}{% endcomponent %} {% component name="test" variable="variable" %}{% endcomponent %}
""" """
with self.assertRaisesMessage(TemplateSyntaxError, "Invalid block tag on line 3: 'component'"):
Template(simple_tag_template)
@parametrize_context_behavior(["django", "isolated"])
def test_call_with_invalid_name(self):
registry.register(name="test_one", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% component name="test" variable="variable" %}{% endcomponent %}
"""
template = Template(simple_tag_template) template = Template(simple_tag_template)
with self.assertRaises(NotRegistered): with self.assertRaises(NotRegistered):
template.render(Context({})) template.render(Context({}))
@ -131,7 +158,7 @@ class ComponentTemplateTagTest(BaseTestCase):
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n") self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n")
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_component_called_with_variable_as_name(self): def test_raises_on_component_called_with_variable_as_name(self):
registry.register(name="test", component=self.SimpleComponent) registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """ simple_tag_template: types.django_html = """
@ -141,24 +168,11 @@ class ComponentTemplateTagTest(BaseTestCase):
{% endwith %} {% endwith %}
""" """
template = Template(simple_tag_template) with self.assertRaisesMessage(
rendered = template.render(Context({})) TemplateSyntaxError,
self.assertHTMLEqual(rendered, "Variable: <strong>variable</strong>\n") "Component name must be a string 'literal', got: component_name",
):
@parametrize_context_behavior(["django", "isolated"]) Template(simple_tag_template)
def test_component_called_with_invalid_variable_as_name(self):
registry.register(name="test", component=self.SimpleComponent)
simple_tag_template: types.django_html = """
{% load component_tags %}
{% with component_name="BLAHONGA" %}
{% component component_name variable="variable" %}{% endcomponent %}
{% endwith %}
"""
template = Template(simple_tag_template)
with self.assertRaises(NotRegistered):
template.render(Context({}))
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_component_accepts_provided_and_default_parameters(self): def test_component_accepts_provided_and_default_parameters(self):

View file

@ -5,7 +5,7 @@ from django_components import Component, register, types
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config() setup_test_config({"autodiscover": False})
class ProvideTemplateTagTest(BaseTestCase): class ProvideTemplateTagTest(BaseTestCase):
@ -38,6 +38,24 @@ class ProvideTemplateTagTest(BaseTestCase):
""", """,
) )
@parametrize_context_behavior(["django", "isolated"])
def test_provide_basic_self_closing(self):
template_str: types.django_html = """
{% load component_tags %}
<div>
{% provide "my_provide" key="hi" another=123 / %}
</div>
"""
template = Template(template_str)
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
<div></div>
""",
)
@parametrize_context_behavior(["django", "isolated"]) @parametrize_context_behavior(["django", "isolated"])
def test_provide_access_keys_in_python(self): def test_provide_access_keys_in_python(self):
@register("injectee") @register("injectee")

View file

@ -7,7 +7,7 @@ from django_components import Component, register, registry, types
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config() setup_test_config({"autodiscover": False})
class SlottedComponent(Component): class SlottedComponent(Component):
@ -75,6 +75,56 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
""", """,
) )
@parametrize_context_behavior(["django", "isolated"])
def test_slotted_template_basic_self_closing(self):
@register("test1")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<custom-template>
<header>{% slot "header" / %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" / %}</footer>
</custom-template>
"""
registry.register(name="test1", component=SlottedComponent)
@register("test2")
class SimpleComponent(Component):
template = """Variable: <strong>{{ variable }}</strong>"""
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
template_str: types.django_html = """
{% load component_tags %}
{% component "test1" %}
{% fill "header" %}
{% component "test2" variable="variable" / %}
{% endfill %}
{% fill "main" / %}
{% fill "footer" / %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context({}))
# NOTE: <main> is empty, because the fill is provided, even if empty
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header> Variable: <strong>variable</strong> </header>
<main></main>
<footer></footer>
</custom-template>
""",
)
# NOTE: Second arg is the expected output of `{{ variable }}` # NOTE: Second arg is the expected output of `{{ variable }}`
@parametrize_context_behavior([("django", "test456"), ("isolated", "")]) @parametrize_context_behavior([("django", "test456"), ("isolated", "")])
def test_slotted_template_with_context_var(self, context_behavior_data): def test_slotted_template_with_context_var(self, context_behavior_data):

View file

@ -9,7 +9,7 @@ from django_components import Component, registry, types
from .django_test_setup import setup_test_config from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior from .testutils import BaseTestCase, parametrize_context_behavior
setup_test_config() setup_test_config({"autodiscover": False})
class SlottedComponent(Component): class SlottedComponent(Component):

20
tests/test_utils.py Normal file
View file

@ -0,0 +1,20 @@
from django_components.utils import is_str_wrapped_in_quotes
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
setup_test_config({"autodiscover": False})
class UtilsTest(BaseTestCase):
def test_is_str_wrapped_in_quotes(self):
self.assertEqual(is_str_wrapped_in_quotes("word"), False)
self.assertEqual(is_str_wrapped_in_quotes('word"'), False)
self.assertEqual(is_str_wrapped_in_quotes('"word'), False)
self.assertEqual(is_str_wrapped_in_quotes('"word"'), True)
self.assertEqual(is_str_wrapped_in_quotes("\"word'"), False)
self.assertEqual(is_str_wrapped_in_quotes('"word" '), False)
self.assertEqual(is_str_wrapped_in_quotes('"'), False)
self.assertEqual(is_str_wrapped_in_quotes(""), False)
self.assertEqual(is_str_wrapped_in_quotes('""'), True)
self.assertEqual(is_str_wrapped_in_quotes("\"'"), False)