chore: Push dev to master to release v0.110 (#767)

* feat: skeleton of dependency manager backend (#688)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: selectolax update and tests cleanup (#702)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: move release notes to own file (#704)

* chore: merge changes from master (#705)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Yassin Rakha <yaso2go@gmail.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
fix for nested slots (#698) (#699)

* refactor: remove joint {% component_dependencies %} tag (#706)

Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: split up utils file and move utils to util dir (#707)

* docs: Move docs inside src/ to allow imports in python scripts (#708)

* refactor: Docs prep 1 (#715)

* refactor: Document template tags (#716)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: pass slot fills in template via slots param (#719)

* chore: Merge master to dev (#729)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Yassin Rakha <yaso2go@gmail.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
Co-authored-by: Tom Larsen <larsent@gmail.com>
fix for nested slots (#698) (#699)

* fix: Do not raise error if multiple slots with same name are flagged as default (#727)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: tag formatter - allow fwd slash in end tag (#730)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: Use lowercase names for registry settings (#731)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* docs: add docstrings (#732)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* feat: define settings as a data class for type hints, intellisense, and docs (#733)

* refactor: fix reload-on-change logic, expose autodiscover's dirs-getting logic, rename settings (#734)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* docs: document settings (#743)

* docs: document settings

* refactor: fix linter errors

* feat: passthrough slots and more (#758)

* feat: passthrough slots and more

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor: remove ComponentSlotContext.slots

* refactor: update comment

* docs: update changelog

* refactor: update docstrings

* refactor: document and test-cover more changes

* refactor: revert fill without name

* docs: update README

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* fix: apostrophes in tags (#765)

* refactor: fix merge error - duplicate code

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
This commit is contained in:
Juro Oravec 2024-11-25 09:41:57 +01:00 committed by GitHub
parent 9f891453d5
commit 5fd45ab424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 8727 additions and 3011 deletions

View file

@ -4,6 +4,7 @@ on:
push:
branches:
- 'master'
- 'dev'
pull_request:
workflow_dispatch:

1
.gitignore vendored
View file

@ -74,7 +74,6 @@ poetry.lock
.DS_Store
.python-version
site
docs/reference
# JS, NPM Dependency directories
node_modules/

585
CHANGELOG.md Normal file
View file

@ -0,0 +1,585 @@
# Release notes
## 🚨📢 v0.110
### General
#### 🚨📢 BREAKING CHANGES
- Installation changes:
- If your components include JS or CSS, you now must use the middleware and add django-components' URLs to your `urlpatterns`
(See "[Adding support for JS and CSS](https://github.com/EmilStenstrom/django-components#adding-support-for-js-and-css)")
- Component typing signature changed from
```py
Component[Args, Kwargs, Data, Slots]
```
to
```py
Component[Args, Kwargs, Slots, Data, JsData, CssData]
```
- If you rendered a component A with `Component.render()` and then inserted that into another component B, now you must pass `render_dependencies=False` to component A:
```py
prerendered_a = CompA.render(
args=[...],
kwargs={...},
render_dependencies=False,
)
html = CompB.render(
kwargs={
content=prerendered_a,
},
)
```
#### Feat
- Intellisense and mypy validation for settings:
Instead of defining the `COMPONENTS` settings as a plain dict, you can use `ComponentsSettings`:
```py
# settings.py
from django_components import ComponentsSettings
COMPONENTS = ComponentsSettings(
autodiscover=True,
...
)
```
- Use `get_component_dirs()` and `get_component_files()` to get the same list of dirs / files that would be imported by `autodiscover()`, but without actually
importing them.
#### Refactor
- For advanced use cases, use can omit the middleware and instead manage component JS and CSS dependencies yourself with [`render_dependencies`](https://github.com/EmilStenstrom/django-components#render_dependencies-and-deep-dive-into-rendering-js--css-without-the-middleware)
- The [`ComponentRegistry`](../api#django_components.ComponentRegistry) settings [`RegistrySettings`](../api#django_components.RegistrySettings)
were lowercased to align with the global settings:
- `RegistrySettings.CONTEXT_BEHAVIOR` -> `RegistrySettings.context_behavior`
- `RegistrySettings.TAG_FORMATTER` -> `RegistrySettings.tag_formatter`
The old uppercase settings `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` are deprecated and will be removed in v1.
- The setting `reload_on_template_change` was renamed to
[`reload_on_file_change`](../settings#django_components.app_settings.ComponentsSettings#reload_on_file_change).
And now it properly triggers server reload when any file in the component dirs change. The old name `reload_on_template_change`
is deprecated and will be removed in v1.
- The setting `forbidden_static_files` was renamed to
[`static_files_forbidden`](../settings#django_components.app_settings.ComponentsSettings#static_files_forbidden)
to align with [`static_files_allowed`](../settings#django_components.app_settings.ComponentsSettings#static_files_allowed)
The old name `forbidden_static_files` is deprecated and will be removed in v1.
### Tags
#### 🚨📢 BREAKING CHANGES
- `{% component_dependencies %}` tag was removed. Instead, use `{% component_js_dependencies %}` and `{% component_css_dependencies %}`
- The combined tag was removed to encourage the best practice of putting JS scripts at the end of `<body>`, and CSS styles inside `<head>`.
On the other hand, co-locating JS script and CSS styles can lead to
a [flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content),
as either JS scripts will block the rendering, or CSS will load too late.
- The undocumented keyword arg `preload` of `{% component_js_dependencies %}` and `{% component_css_dependencies %}` tags was removed.
This will be replaced with HTML fragment support.
#### Fix
- Allow using forward slash (`/`) when defining custom TagFormatter,
e.g. `{% MyComp %}..{% /MyComp %}`.
#### Refactor
- `{% component_dependencies %}` tags are now OPTIONAL - If your components use JS and CSS, but you don't use `{% component_dependencies %}` tags, the JS and CSS will now be, by default, inserted at the end of `<body>` and at the end of `<head>` respectively.
### Slots
#### Feat
- Fills can now be defined within loops (`{% for %}`) or other tags (like `{% with %}`),
or even other templates using `{% include %}`.
Following is now possible
```django
{% component "table" %}
{% for slot_name in slots %}
{% fill name=slot_name %}
{% endfill %}
{% endfor %}
{% endcomponent %}
```
- If you need to access the data or the default content of a default fill, you can
set the `name` kwarg to `"default"`.
Previously, a default fill would be defined simply by omitting the `{% fill %}` tags:
```django
{% component "child" %}
Hello world
{% endcomponent %}
```
But in that case you could not access the slot data or the default content, like it's possible
for named fills:
```django
{% component "child" %}
{% fill name="header" data="data" %}
Hello {{ data.user.name }}
{% endfill %}
{% endcomponent %}
```
Now, you can specify default tag by using `name="default"`:
```django
{% component "child" %}
{% fill name="default" data="data" %}
Hello {{ data.user.name }}
{% endfill %}
{% endcomponent %}
```
- When inside `get_context_data()` or other component methods, the default fill
can now be accessed as `Component.input.slots["default"]`, e.g.:
```py
class MyTable(Component):
def get_context_data(self, *args, **kwargs):
default_slot = self.input.slots["default"]
...
```
- You can now dynamically pass all slots to a child component. This is similar to
[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots):
```py
class MyTable(Component):
def get_context_data(self, *args, **kwargs):
return {
"slots": self.input.slots,
}
template: """
<div>
{% component "child" %}
{% for slot_name in slots %}
{% fill name=slot_name data="data" %}
{% slot name=slot_name ...data / %}
{% endfill %}
{% endfor %}
{% endcomponent %}
</div>
"""
```
#### Fix
- Slots defined with `{% fill %}` tags are now properly accessible via `self.input.slots` in `get_context_data()`
- Do not raise error if multiple slots with same name are flagged as default
- Slots can now be defined within loops (`{% for %}`) or other tags (like `{% with %}`),
or even other templates using `{% include %}`.
Previously, following would cause the kwarg `name` to be an empty string:
```django
{% for slot_name in slots %}
{% slot name=slot_name %}
{% endfor %}
```
#### Refactor
- When you define multiple slots with the same name inside a template,
you now have to set the `default` and `required` flags individually.
```htmldjango
<div class="calendar-component">
<div class="header">
{% slot "image" default required %}Image here{% endslot %}
</div>
<div class="body">
{% slot "image" default required %}Image here{% endslot %}
</div>
</div>
```
This means you can also have multiple slots with the same name but
different conditions.
E.g. in this example, we have a component that renders a user avatar
- a small circular image with a profile picture of name initials.
If the component is given `image_src` or `name_initials` variables,
the `image` slot is optional. But if neither of those are provided,
you MUST fill the `image` slot.
```htmldjango
<div class="avatar">
{% if image_src %}
{% slot "image" default %}
<img src="{{ image_src }}" />
{% endslot %}
{% elif name_initials %}
{% slot "image" default required %}
<div style="
border-radius: 25px;
width: 50px;
height: 50px;
background: blue;
">
{{ name_initials }}
</div>
{% endslot %}
{% else %}
{% slot "image" default required / %}
{% endif %}
</div>
```
- The slot fills that were passed to a component and which can be accessed as `Component.input.slots`
can now be passed through the Django template, e.g. as inputs to other tags.
Internally, django-components handles slot fills as functions.
Previously, if you tried to pass a slot fill within a template, Django would try to call it as a function.
Now, something like this is possible:
```py
class MyTable(Component):
def get_context_data(self, *args, **kwargs):
return {
"child_slot": self.input.slots["child_slot"],
}
template: """
<div>
{% component "child" content=child_slot / %}
</div>
"""
```
NOTE: Using `{% slot %}` and `{% fill %}` tags is still the preferred method, but the approach above
may be necessary in some complex or edge cases.
- The `is_filled` variable (and the `{{ component_vars.is_filled }}` context variable) now returns
`False` when you try to access a slot name which has not been defined:
Before:
```django
{{ component_vars.is_filled.header }} -> True
{{ component_vars.is_filled.footer }} -> False
{{ component_vars.is_filled.nonexist }} -> "" (empty string)
```
After:
```django
{{ component_vars.is_filled.header }} -> True
{{ component_vars.is_filled.footer }} -> False
{{ component_vars.is_filled.nonexist }} -> False
```
- Components no longer raise an error if there are extra slot fills
- Components will raise error when a slot is doubly-filled.
E.g. if we have a component with a default slot:
```django
{% slot name="content" default / %}
```
Now there is two ways how we can target this slot: Either using `name="default"`
or `name="content"`.
In case you specify BOTH, the component will raise an error:
```django
{% component "child" %}
{% fill slot="default" %}
Hello from default slot
{% endfill %}
{% fill slot="content" data="data" %}
Hello from content slot
{% endfill %}
{% endcomponent %}
```
## 🚨📢 v0.100
#### BREAKING CHANGES
- `django_components.safer_staticfiles` app was removed. It is no longer needed.
- Installation changes:
- Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](https://github.com/EmilStenstrom/django-components#dirs).
- You now must define `STATICFILES_FINDERS`
- [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md)
#### Feat
- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components`
(See [`COMPONENTS.app_dirs`](https://github.com/EmilStenstrom/django-components#app_dirs)).
#### Refactor
- When you call `as_view()` on a component instance, that instance will be passed to `View.as_view()`
## v0.97
#### Fix
- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](https://github.com/EmilStenstrom/django-components#template_cache_size---tune-the-template-cache)
#### Refactor
- The previously undocumented `get_template` was made private.
- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except
it allows to return either a string or a Template instance.
- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`.
## v0.96
#### Feat
- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](https://github.com/EmilStenstrom/django-components#runtime-input-validation-with-types))
- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](https://github.com/EmilStenstrom/django-components#component-hooks))
- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot`
## 0.95
#### Feat
- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](https://github.com/EmilStenstrom/django-components#dynamic-components))
#### Refactor
- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset.
## v0.94
#### Feat
- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](https://github.com/EmilStenstrom/django-components#multi-line-tags))
- New setting `reload_on_template_change`. Set this to `True` to reload the dev server on changes to component template files. (See [Reload dev server on component file changes](https://github.com/EmilStenstrom/django-components#reload-dev-server-on-component-file-changes))
## v0.93
#### Feat
- Spread operator `...dict` inside template tags. (See [Spread operator](https://github.com/EmilStenstrom/django-components#spread-operator))
- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](https://github.com/EmilStenstrom/django-components#use-template-tags-inside-component-inputs))
- Dynamic slots, fills and provides - The `name` argument for these can now be a variable, a template expression, or via spread operator
- Component library authors can now configure `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` settings independently from user settings.
## 🚨📢 v0.92
#### BREAKING CHANGES
- `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](https://github.com/EmilStenstrom/django-components#modifying-the-view-class))
#### Feat
- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](https://github.com/EmilStenstrom/django-components#accessing-data-passed-to-the-component))
- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](https://github.com/EmilStenstrom/django-components#adding-type-hints-with-generics))
## v0.90
#### Feat
- 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](https://github.com/EmilStenstrom/django-components#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 %}
```
## 🚨📢 v0.85
#### BREAKING CHANGES
- 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.
To migrate from:
- `[app]/components.py` - Define each module in `COMPONENTS.libraries` setting,
or import each module inside the `AppConfig.ready()` hook in respective `apps.py` files.
- `SETTINGS_MODULE` - Define component dirs using `STATICFILES_DIRS`
- Previously, autodiscovery handled relative files in `STATICFILES_DIRS`. To align with Django, `STATICFILES_DIRS` now must be full paths ([Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS)).
## 🚨📢 v0.81
#### BREAKING CHANGES
- The order of arguments to `render_to_response` has changed, to align with the (now public) `render` method of `Component` class.
#### Feat
- `Component.render()` is public and documented
- Slots passed `render_to_response` and `render` can now be rendered also as functions.
## v0.80
#### Feat
- Vue-like provide/inject with the `{% provide %}` tag and `inject()` method.
## 🚨📢 v0.79
#### BREAKING CHANGES
- Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498).
## 🚨📢 v0.77
#### BREAKING
- The syntax for accessing default slot content has changed from
```django
{% fill "my_slot" as "alias" %}
{{ alias.default }}
{% endfill %}
```
to
```django
{% fill "my_slot" default="alias" %}
{{ alias }}
{% endfill %}
```
## v0.74
#### Feat
- `{% html_attrs %}` tag for formatting data as HTML attributes
- `prefix:key=val` construct for passing dicts to components
## 🚨📢 v0.70
#### BREAKING CHANGES
- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables.
- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](https://github.com/EmilStenstrom/django-components#context-behavior) for more details.
## v0.67
#### Refactor
- Changed the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details.
## 🚨📢 v0.50
#### BREAKING CHANGES
- `{% component_block %}` is now `{% component %}`, and `{% component %}` blocks need an ending `{% endcomponent %}` tag.
The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use `--path` argument to point to each dir) of templates that use components to the new syntax automatically.
This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases.
## v0.34
#### Feat
- Components as views, which allows you to handle requests and render responses from within a component. See the [documentation](https://github.com/EmilStenstrom/django-components#use-components-as-views) for more details.
## v0.28
#### Feat
- 'implicit' slot filling and the `default` option for `slot` tags.
## v0.27
#### Feat
- A second installable app `django_components.safer_staticfiles`. It provides the same behavior as `django.contrib.staticfiles` but with extra security guarantees (more info below in [Security Notes](https://github.com/EmilStenstrom/django-components#security-notes)).
## 🚨📢 v0.26
#### BREAKING CHANGES
- Changed the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill.
We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice feature to have access to. Hoping that this will feel worth it!
## v0.22
#### Feat
- All files inside components subdirectores are autoimported to simplify setup.
An existing project might start to get `AlreadyRegistered` errors because of this. To solve this, either remove your custom loading of components, or set `"autodiscover": False` in `settings.COMPONENTS`.
## v0.17
#### BREAKING CHANGES
- Renamed `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning.
This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released.

566
README.md
View file

@ -96,128 +96,8 @@ Read on to learn about all the exciting details and configuration possibilities!
## Release notes
🚨📢 **Version 0.100**
- BREAKING CHANGE:
- `django_components.safer_staticfiles` app was removed. It is no longer needed.
- Installation changes:
- Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](#dirs).
- You now must define `STATICFILES_FINDERS`
- [See here how to migrate your settings.py](https://github.com/EmilStenstrom/django-components/blob/master/docs/migrating_from_safer_staticfiles.md)
- Beside the top-level `/components` directory, you can now define also app-level components dirs, e.g. `[app]/components`
(See [`COMPONENTS.app_dirs`](#app_dirs)).
- When you call `as_view()` on a component instance, that instance will be passed to `View.as_view()`
**Version 0.97**
- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache)
- The previously undocumented `get_template` was made private.
- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except
it allows to return either a string or a Template instance.
- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`.
**Version 0.96**
- Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types))
- Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks))
- `component_vars.is_filled` context variable can be accessed from within `on_render_before` and `on_render_after` hooks as `self.is_filled.my_slot`
**Version 0.95**
- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](#dynamic-components))
- Changed `Component.input` to raise `RuntimeError` if accessed outside of render context. Previously it returned `None` if unset.
**Version 0.94**
- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](#multi-line-tags))
- New setting `reload_on_template_change`. Set this to `True` to reload the dev server on changes to component template files. (See [Reload dev server on component file changes](#reload-dev-server-on-component-file-changes))
**Version 0.93**
- Spread operator `...dict` inside template tags. (See [Spread operator](#spread-operator))
- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](#use-template-tags-inside-component-inputs))
- Dynamic slots, fills and provides - The `name` argument for these can now be a variable, a template expression, or via spread operator
- Component library authors can now configure `CONTEXT_BEHAVIOR` and `TAG_FORMATTER` settings independently from user settings.
🚨📢 **Version 0.92**
- BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component))
- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics))
**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.component_shorthand_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:
- Previously, autodiscovery also imported any `[app]/components.py` files, and used `SETTINGS_MODULE` to search for component dirs.
- To migrate from:
- `[app]/components.py` - Define each module in `COMPONENTS.libraries` setting,
or import each module inside the `AppConfig.ready()` hook in respective `apps.py` files.
- `SETTINGS_MODULE` - Define component dirs using `STATICFILES_DIRS`
- Previously, autodiscovery handled relative files in `STATICFILES_DIRS`. To align with Django, `STATICFILES_DIRS` now must be full paths ([Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS)).
🚨📢 **Version 0.81** Aligned the `render_to_response` method with the (now public) `render` method of `Component` class. Moreover, slots passed to these can now be rendered also as functions.
- BREAKING CHANGE: The order of arguments to `render_to_response` has changed.
**Version 0.80** introduces dependency injection with the `{% provide %}` tag and `inject()` method.
🚨📢 **Version 0.79**
- BREAKING CHANGE: Default value for the `COMPONENTS.context_behavior` setting was changes from `"isolated"` to `"django"`. If you did not set this value explicitly before, this may be a breaking change. See the rationale for change [here](https://github.com/EmilStenstrom/django-components/issues/498).
🚨📢 **Version 0.77** CHANGED the syntax for accessing default slot content.
- Previously, the syntax was
`{% fill "my_slot" as "alias" %}` and `{{ alias.default }}`.
- Now, the syntax is
`{% fill "my_slot" default="alias" %}` and `{{ alias }}`.
**Version 0.74** introduces `html_attrs` tag and `prefix:key=val` construct for passing dicts to components.
🚨📢 **Version 0.70**
- `{% if_filled "my_slot" %}` tags were replaced with `{{ component_vars.is_filled.my_slot }}` variables.
- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](#context-behavior) for more details.
**Version 0.67** CHANGED the default way how context variables are resolved in slots. See the [documentation](https://github.com/EmilStenstrom/django-components/tree/0.67#isolate-components-slots) for more details.
🚨📢 **Version 0.5** CHANGES THE SYNTAX for components. `component_block` is now `component`, and `component` blocks need an ending `endcomponent` tag. The new `python manage.py upgradecomponent` command can be used to upgrade a directory (use --path argument to point to each dir) of templates that use components to the new syntax automatically.
This change is done to simplify the API in anticipation of a 1.0 release of django_components. After 1.0 we intend to be stricter with big changes like this in point releases.
**Version 0.34** adds components as views, which allows you to handle requests and render responses from within a component. See the [documentation](#use-components-as-views) for more details.
**Version 0.28** introduces 'implicit' slot filling and the `default` option for `slot` tags.
**Version 0.27** adds a second installable app: _django_components.safer_staticfiles_. It provides the same behavior as _django.contrib.staticfiles_ but with extra security guarantees (more info below in Security Notes).
**Version 0.26** changes the syntax for `{% slot %}` tags. From now on, we separate defining a slot (`{% slot %}`) from filling a slot with content (`{% fill %}`). This means you will likely need to change a lot of slot tags to fill. We understand this is annoying, but it's the only way we can get support for nested slots that fill in other slots, which is a very nice featuPpre to have access to. Hoping that this will feel worth it!
**Version 0.22** starts autoimporting all files inside components subdirectores, to simplify setup. An existing project might start to get AlreadyRegistered-errors because of this. To solve this, either remove your custom loading of components, or set "autodiscover": False in settings.COMPONENTS.
**Version 0.17** renames `Component.context` and `Component.template` to `get_context_data` and `get_template_name`. The old methods still work, but emit a deprecation warning. This change was done to sync naming with Django's class based views, and make using django-components more familiar to Django users. `Component.context` and `Component.template` will be removed when version 1.0 is released.
Read the [Release Notes](https://github.com/EmilStenstrom/django-components/tree/master/CHANGELOG.md)
to see the latest features and fixes.
## Security notes 🚨
@ -357,6 +237,34 @@ STATICFILES_FINDERS = [
]
```
### Adding support for JS and CSS
If you want to use JS or CSS with components, you will need to:
1. Add [`ComponentDependencyMiddleware`](#setting-up-componentdependencymiddleware) to `MIDDLEWARE` setting.
The middleware searches the outgoing HTML for all components that were rendered
to generate the HTML, and adds the JS and CSS associated with those components.
```py
MIDDLEWARE = [
...
"django_components.middleware.ComponentDependencyMiddleware",
]
```
Read more in [Rendering JS/CSS dependencies](#rendering-jscss-dependencies).
2. Add django-component's URL paths to your `urlpatterns`:
```py
from django.urls import include, path
urlpatterns = [
...
path("", include("django_components.urls")),
]
```
### Optional
@ -897,7 +805,7 @@ that allow you to specify the types of args, kwargs, slots, and
data:
```py
class Button(Component[Args, Kwargs, Data, Slots]):
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
...
```
@ -936,7 +844,7 @@ class Slots(TypedDict):
# SlotContent == Union[str, SafeString]
another_slot: SlotContent
class Button(Component[Args, Kwargs, Data, Slots]):
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
def get_context_data(self, variable, another):
return {
"variable": variable,
@ -1014,7 +922,7 @@ from django_components import Component, EmptyDict, EmptyTuple
Args = EmptyTuple
Kwargs = Data = Slots = EmptyDict
class Button(Component[Args, Kwargs, Data, Slots]):
class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
...
```
@ -1056,7 +964,7 @@ Or you can replace `Args` with `Any` altogether, to skip the validation of args:
```py
# Replaced `Args` with `Any`
class Button(Component[Any, Kwargs, Data, Slots]):
class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]):
...
```
@ -1250,8 +1158,8 @@ NOTE: The Library instance can be accessed under `library` attribute of `Compone
When you are creating an instance of `ComponentRegistry`, you can define the components' behavior within the template.
The registry accepts these settings:
- `CONTEXT_BEHAVIOR`
- `TAG_FORMATTER`
- `context_behavior`
- `tag_formatter`
```py
from django.template import Library
@ -1261,8 +1169,8 @@ register = library = django.template.Library()
comp_registry = ComponentRegistry(
library=library,
settings=RegistrySettings(
CONTEXT_BEHAVIOR="isolated",
TAG_FORMATTER="django_components.component_formatter",
context_behavior="isolated",
tag_formatter="django_components.component_formatter",
),
)
```
@ -1346,7 +1254,7 @@ This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/sl
In the example below we introduce two block tags that work hand in hand to make this work. These are...
- `{% slot <name> %}`/`{% endslot %}`: Declares a new slot in the component template.
- `{% fill <name> %}`/`{% endfill %}`: (Used inside a `component` tag pair.) Fills a declared slot with the specified content.
- `{% fill <name> %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content.
Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_.
@ -1365,7 +1273,9 @@ When using the component, you specify which slots you want to fill and where you
```htmldjango
{% component "calendar" date="2020-06-06" %}
{% fill "body" %}Can you believe it's already <span>{{ date }}</span>??{% endfill %}
{% fill "body" %}
Can you believe it's already <span>{{ date }}</span>??
{% endfill %}
{% endcomponent %}
```
@ -1382,13 +1292,53 @@ Since the 'header' fill is unspecified, it's taken from the base template. If yo
</div>
```
### Named slots
As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific
slot.
You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}`
tags:
```htmldjango
{% component "calendar" date="2020-06-06" %}
{% fill "header" %}
Hi this is header!
{% endfill %}
{% fill "body" %}
Can you believe it's already <span>{{ date }}</span>??
{% endfill %}
{% endcomponent %}
```
You can also use `{% for %}`, `{% with %}`, or other tags (even `{% include %}`)
to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!**
```django
{% component "table" %}
{% for slot_name in slots %}
{% fill name=slot_name %}
{{ slot_name }}
{% endfill %}
{% endfor %}
{% with slot_name="abc" %}
{% fill name=slot_name %}
{{ slot_name }}
{% endfill %}
{% endwith %}
{% endcomponent %}
```
### Default slot
_Added in version 0.28_
As you can see, component slots lets you write reusable containers that you fill in when you use a component. This makes for highly reusable components that can be used in different circumstances.
It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`. When added to the end of the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`.
It can become tedious to use `fill` tags everywhere, especially when you're using a component that declares only one slot. To make things easier, `slot` tags can be marked with an optional keyword: `default`.
When added to the tag (as shown below), this option lets you pass filling content directly in the body of a `component` tag pair without using a `fill` tag. Choose carefully, though: a component template may contain at most one slot that is marked as `default`. The `default` option can be combined with other slot options, e.g. `required`.
Here's the same example as before, except with default slots and implicit filling.
@ -1422,7 +1372,7 @@ The rendered result (exactly the same as before):
</div>
```
You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when compiled.
You may be tempted to combine implicit fills with explicit `fill` tags. This will not work. The following component template will raise an error when rendered.
```htmldjango
{# DON'T DO THIS #}
@ -1432,26 +1382,33 @@ You may be tempted to combine implicit fills with explicit `fill` tags. This wil
{% endcomponent %}
```
By contrast, it is permitted to use `fill` tags in nested components, e.g.:
Instead, you can use a named fill with name `default` to target the default fill:
```htmldjango
{# THIS WORKS #}
{% component "calendar" date="2020-06-06" %}
{% component "beautiful-box" %}
{% fill "content" %} Can you believe it's already <span>{{ date }}</span>?? {% endfill %}
{% endcomponent %}
{% fill "header" %}Totally new header!{% endfill %}
{% fill "default" %}
Can you believe it's already <span>{{ date }}</span>??
{% endfill %}
{% endcomponent %}
```
This is fine too:
NOTE: If you doubly-fill a slot, that is, that both `{% fill "default" %}` and `{% fill "header" %}`
would point to the same slot, this will raise an error when rendered.
```htmldjango
{% component "calendar" date="2020-06-06" %}
{% fill "header" %}
{% component "calendar-header" %}
Super Special Calendar Header
{% endcomponent %}
{% endfill %}
{% endcomponent %}
#### Accessing default slot in Python
Since the default slot is stored under the slot name `default`, you can access the default slot
like so:
```py
class MyTable(Component):
def get_context_data(self, *args, **kwargs):
default_slot = self.input.slots["default"]
return {
"default_slot": default_slot,
}
```
### Render fill in multiple places
@ -1498,9 +1455,7 @@ This renders:
#### Default and required slots
If you use a slot multiple times, you can still mark the slot as `default` or `required`.
For that, you must mark ONLY ONE of the identical slots.
We recommend to mark the first occurence for consistency, e.g.:
For that, you must mark each slot individually, e.g.:
```htmldjango
<div class="calendar-component">
@ -1508,12 +1463,12 @@ We recommend to mark the first occurence for consistency, e.g.:
{% slot "image" default required %}Image here{% endslot %}
</div>
<div class="body">
{% slot "image" %}Image here{% endslot %}
{% slot "image" default required %}Image here{% endslot %}
</div>
</div>
```
Which you can then use are regular default slot:
Which you can then use as regular default slot:
```htmldjango
{% component "calendar" date="2020-06-06" %}
@ -1521,6 +1476,39 @@ Which you can then use are regular default slot:
{% endcomponent %}
```
Since each slot is tagged individually, you can have multiple slots
with the same name but different conditions.
E.g. in this example, we have a component that renders a user avatar
- a small circular image with a profile picture of name initials.
If the component is given `image_src` or `name_initials` variables,
the `image` slot is optional. But if neither of those are provided,
you MUST fill the `image` slot.
```htmldjango
<div class="avatar">
{% if image_src %}
{% slot "image" default %}
<img src="{{ image_src }}" />
{% endslot %}
{% elif name_initials %}
{% slot "image" default %}
<div style="
border-radius: 25px;
width: 50px;
height: 50px;
background: blue;
">
{{ name_initials }}
</div>
{% endslot %}
{% else %}
{% slot "image" default required / %}
{% endif %}
</div>
```
### Accessing original content of slots
_Added in version 0.26_
@ -1564,6 +1552,16 @@ This produces:
</div>
```
To access the original content of a default slot, set the name to `default`:
```htmldjango
{% component "calendar" date="2020-06-06" %}
{% fill "default" default="slot_default" %}
{{ slot_default }}. Have a great day!
{% endfill %}
{% endcomponent %}
```
### Conditional slots
_Added in version 0.26._
@ -1615,6 +1613,7 @@ This is what our example looks like with `component_vars.is_filled`.
</div>
{% endif %}
</div>
```
Here's our example with more complex branching.
@ -1654,6 +1653,20 @@ However, you can still define slots with other special characters. In such case,
So a slot named `"my super-slot :)"` will be available as `component_vars.is_filled.my_super_slot___`.
Same applies when you are accessing `is_filled` from within the Python, e.g.:
```py
class MyTable(Component):
def on_render_before(self, context, template) -> None:
# ✅ Works
if self.is_filled["my_super_slot___"]:
# Do something
# ❌ Does not work
if self.is_filled["my super-slot :)"]:
# Do something
```
### Scoped slots
_Added in version 0.76_:
@ -1715,8 +1728,8 @@ the slot data. In the example below, we set it to `data`:
```django
{% component "my_comp" %}
{% fill "content" data="data" %}
{{ data.input }}
{% fill "content" data="slot_data" %}
{{ slot_data.input }}
{% endfill %}
{% endcomponent %}
```
@ -1727,8 +1740,8 @@ So this works:
```django
{% component "my_comp" %}
{% fill "content" data="data" %}
{{ data.input }}
{% fill "default" data="slot_data" %}
{{ slot_data.input }}
{% endfill %}
{% endcomponent %}
```
@ -1823,6 +1836,31 @@ So it's possible to define a `name` key on a dictionary, and then spread that on
{% slot ...slot_props / %}
```
### Pass through all the slots
You can dynamically pass all slots to a child component. This is similar to
[passing all slots in Vue](https://vue-land.github.io/faq/forwarding-slots#passing-all-slots):
```py
class MyTable(Component):
def get_context_data(self, *args, **kwargs):
return {
"slots": self.input.slots,
}
template: """
<div>
{% component "child" %}
{% for slot_name in slots %}
{% fill name=slot_name data="data" %}
{% slot name=slot_name ...data / %}
{% endfill %}
{% endfor %}
{% endcomponent %}
</div>
"""
```
## Accessing data passed to the component
When you call `Component.render` or `Component.render_to_response`, the inputs to these methods can be accessed from within the instance under `self.input`.
@ -1831,6 +1869,8 @@ This means that you can use `self.input` inside:
- `get_context_data`
- `get_template_name`
- `get_template`
- `on_render_before`
- `on_render_after`
`self.input` is only defined during the execution of `Component.render`, and raises a `RuntimeError` when called outside of this context.
@ -1841,7 +1881,7 @@ class TestComponent(Component):
def get_context_data(self, var1, var2, variable, another, **attrs):
assert self.input.args == (123, "str")
assert self.input.kwargs == {"variable": "test", "another": 1}
assert self.input.slots == {"my_slot": "MY_SLOT"}
assert self.input.slots == {"my_slot": ...}
assert isinstance(self.input.context, Context)
return {
@ -1855,6 +1895,8 @@ rendered = TestComponent.render(
)
```
NOTE: The slots in `self.input.slots` are normalized to slot functions.
## Rendering HTML attributes
_New in version 0.74_:
@ -2794,6 +2836,16 @@ Here is a list of all variables that are automatically available from within the
{% endif %}
```
This is equivalent to checking if a given key is among the slot fills:
```py
class MyTable(Component):
def get_context_data(self, *args, **kwargs):
return {
"my_slot_filled": "my_slot" in self.input.slots
}
```
## Customizing component tags with TagFormatter
_New in version 0.89_
@ -3146,15 +3198,72 @@ NOTE: The instance of the `Media` class (or it's subclass) is available under `C
## Rendering JS/CSS dependencies
The JS and CSS files included in components are not automatically rendered.
Instead, use the following tags to specify where to render the dependencies:
If:
1. Your components use JS and CSS, whether inlined via `Component.js/css` or via `Component.Media.js/css`,
2. And you use the `ComponentDependencyMiddleware` middleware
- `component_dependencies` - Renders both JS and CSS
- `component_js_dependencies` - Renders only JS
- `component_css_dependencies` - Reneders only CSS
Then, by default, the components' JS and CSS will be automatically inserted into the HTML:
- CSS styles will be inserted at the end of the `<head>`
- JS scripts will be inserted at the end of the `<body>`
JS files are rendered as `<script>` tags.<br/>
CSS files are rendered as `<style>` tags.
If you want to place the dependencies elsewhere, you can override that with following Django template tags:
- `{% component_js_dependencies %}` - Renders only JS
- `{% component_css_dependencies %}` - Renders only CSS
So if you have a component with JS and CSS:
```py
from django_components import Component, types
class MyButton(Component):
template: types.django_html = """
<button class="my-button">
Click me!
</button>
"""
js: types.js = """
for (const btnEl of document.querySelectorAll(".my-button")) {
btnEl.addEventListener("click", () => {
console.log("BUTTON CLICKED!");
});
}
"""
css: types.css """
.my-button {
background: green;
}
"""
class Media:
js = ["/extra/script.js"]
css = ["/extra/style.css"]
```
Then the inlined JS and the scripts in `Media.js` will be rendered at the default place,
or in `{% component_js_dependencies %}`.
And the inlined CSS and the styles in `Media.css` will be rendered at the default place,
or in `{% component_css_dependencies %}`.
And if you don't specify `{% component_dependencies %}` tags, it is the equivalent of:
```django
<!doctype html>
<html>
<head>
<title>MyPage</title>
...
{% component_css_dependencies %}
</head>
<body>
<main>
...
</main>
{% component_js_dependencies %}
</body>
</html>
```
### Setting Up `ComponentDependencyMiddleware`
@ -3170,15 +3279,115 @@ MIDDLEWARE = [
]
```
Then, enable `RENDER_DEPENDENCIES` in setting.py:
### `render_dependencies` and deep-dive into rendering JS / CSS without the middleware
```python
COMPONENTS = {
"RENDER_DEPENDENCIES": True,
# ... other component settings ...
}
For most scenarios, using the `ComponentDependencyMiddleware` middleware will be just fine.
However, this section is for you if you want to:
- Render HTML that will NOT be sent as a server response
- Insert pre-rendered HTML into another component
- Render HTML fragments (partials)
Every time there is an HTML string that has parts which were rendered using components,
and any of those components has JS / CSS, then this HTML string MUST be processed with `render_dependencies`.
It is actually `render_dependencies` that finds all used components in the HTML string,
and inserts the component's JS and CSS into `{% component_dependencies %}` tags, or at the default locations.
#### Render JS / CSS without the middleware
The `ComponentDependencyMiddleware` middleware just calls `render_dependencies`, passing in the HTML
content. So if you rendered a template that contained `{% components %}` tags, instead of the middleware,
you MUST pass the result through `render_dependencies`:
```py
from django.template.base import Template
from django.template.context import Context
from django_component import render_dependencies
template = Template("""
{% load component_tags %}
<!doctype html>
<html>
<head>
<title>MyPage</title>
</head>
<body>
<main>
{% component "my_button" %}
Click me!
{% endcomponent %}
</main>
</body>
</html>
""")
rendered = template.render(Context({}))
rendered = render_dependencies(rendered)
```
Same applies if you render a template using Django's [`django.shortcuts.render`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render):
```py
from django.shortcuts import render
def my_view(request):
rendered = render(request, "pages/home.html")
rendered = render_dependencies(rendered)
return rendered
```
Alternatively, when you render HTML with `Component.render()` or `Component.render_to_response()`,
these automatically call `render_dependencies()` for you, so you don't have to:
```py
from django_components import Component
class MyButton(Component):
...
# No need to call `render_dependencies()`
rendered = MyButton.render()
```
#### Inserting pre-rendered HTML into another component
In previous section we've shown that `render_dependencies()` does NOT need to be called
when you render a component via `Component.render()`.
API of django-components makes it possible to compose components in a "React-like" way,
where we pre-render a piece of HTML and then insert it into a larger structure.
To do this, you must add `render_dependencies=False` to the nested components:
```py
card_actions = CardActions.render(
kwargs={"editable": editable},
render_dependencies=False,
)
card = Card.render(
slots={"actions": card_actions},
render_dependencies=False,
)
page = MyPage.render(
slots={"card": card},
)
```
Why is `render_dependencies=False` required?
As mentioned earlier, each time we call `Component.render()`, we also call `render_dependencies()`.
However, there is a problem here - When we call `render_dependencies()` inside `CardActions.render()`,
we extract the info on components' JS and CSS from the HTML. But the template of `CardActions`
contains no `{% component_depedencies %}` tags, and nor `<head>` nor `<body>` HTML tags.
So the component's JS and CSS will NOT be inserted, and will be lost.
To work around this, you must set `render_dependencies=False` when rendering pieces of HTML with `Component.render()`
and inserting them into larger structures.
## Available settings
All library settings are handled from a global `COMPONENTS` variable that is read from `settings.py`. By default you don't need it set, there are resonable defaults.
@ -3709,17 +3918,17 @@ You can publish and share your components for others to use. Here are the steps
comp_registry = ComponentRegistry(
library=library,
settings=RegistrySettings(
CONTEXT_BEHAVIOR="isolated",
TAG_FORMATTER="django_components.component_formatter",
context_behavior="isolated",
tag_formatter="django_components.component_formatter",
),
)
```
As you can see above, this is also the place where we configure how our components should behave, using the `settings` argument. If omitted, default settings are used.
For library authors, we recommend setting `CONTEXT_BEHAVIOR` to `"isolated"`, so that the state cannot leak into the components, and so the components' behavior is configured solely through the inputs. This means that the components will be more predictable and easier to debug.
For library authors, we recommend setting `context_behavior` to `"isolated"`, so that the state cannot leak into the components, and so the components' behavior is configured solely through the inputs. This means that the components will be more predictable and easier to debug.
Next, you can decide how will others use your components by settingt the `TAG_FORMATTER` options.
Next, you can decide how will others use your components by settingt the `tag_formatter` options.
If omitted or set to `"django_components.component_formatter"`,
your components will be used like this:
@ -3785,7 +3994,7 @@ You can publish and share your components for others to use. Here are the steps
# Define the component
# NOTE: Don't forget to set the `registry`!
@register("my_menu", registry=comp_registry)
class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any]):
class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any, Any, Any]):
def get_context_data(
self,
*args,
@ -4043,5 +4252,8 @@ twine upload --repository pypi dist/* -u __token__ -p <PyPI_TOKEN>
### Development guides
- [Slot rendering flot](https://github.com/EmilStenstrom/django-components/blob/master/docs/slot_rendering.md)
- [Slots and blocks](https://github.com/EmilStenstrom/django-components/blob/master/docs/slots_and_blocks.md)
Deep dive into how django_components' features are implemented.
- [Slot rendering](https://github.com/EmilStenstrom/django-components/blob/master/docs/devguides/slot_rendering.md)
- [Slots and blocks](https://github.com/EmilStenstrom/django-components/blob/master/docs/devguides/slots_and_blocks.md)
- [JS and CSS dependency management](https://github.com/EmilStenstrom/django-components/blob/master/docs/devguides/dependency_mgmt.md)

View file

@ -1,10 +1,9 @@
from time import perf_counter
from django.template import Context, Template
from django.test import override_settings
from django_components import Component, registry, types
from django_components.middleware import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
from django_components.dependencies import CSS_DEPENDENCY_PLACEHOLDER, JS_DEPENDENCY_PLACEHOLDER
from tests.django_test_setup import * # NOQA
from tests.testutils import BaseTestCase, create_and_process_template_response
@ -89,7 +88,6 @@ EXPECTED_CSS = """<link href="test.css" media="all" rel="stylesheet">"""
EXPECTED_JS = """<script src="test.js"></script>"""
@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True})
class RenderBenchmarks(BaseTestCase):
def setUp(self):
registry.clear()
@ -122,7 +120,9 @@ class RenderBenchmarks(BaseTestCase):
def test_middleware_time_with_dependency_for_small_page(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'test_component' %}
{% slot "header" %}
{% component 'inner_component' variable='foo' %}{% endcomponent %}

View file

@ -1,13 +0,0 @@
---
hide:
- toc
---
# Release notes
{!
include-markdown "../README.md"
start="## Release notes"
end='## '
heading-offset=1
!}

View file

@ -1,238 +0,0 @@
# Slot rendering
This doc serves as a primer on how component slots and fills are resolved.
## Flow
1. Imagine you have a template. Some kind of text, maybe HTML:
```django
| ------
| ---------
| ----
| -------
```
2. The template may contain some vars, tags, etc
```django
| -- {{ my_var }} --
| ---------
| ----
| -------
```
3. The template also contains some slots, etc
```django
| -- {{ my_var }} --
| ---------
| -- {% slot "myslot" %} ---
| -- {% endslot %} ---
| ----
| -- {% slot "myslot2" %} ---
| -- {% endslot %} ---
| -------
```
4. Slots may be nested
```django
| -- {{ my_var }} --
| -- ABC
| -- {% slot "myslot" %} ---
| ----- DEF {{ my_var }}
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
| -- {% endslot %} ---
| ----
| -- {% slot "myslot2" %} ---
| ---- JKL {{ my_var }}
| -- {% endslot %} ---
| -------
```
5. Some slots may be inside fills for other components
```django
| -- {{ my_var }} --
| -- ABC
| -- {% slot "myslot" %}---
| ----- DEF {{ my_var }}
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
| -- {% endslot %} ---
| ------
| -- {% component "mycomp" %} ---
| ---- {% slot "myslot" %} ---
| ------- JKL {{ my_var }}
| ------- {% slot "myslot_inner" %}
| ---------- MNO {{ my_var }}
| ------- {% endslot %}
| ---- {% endslot %} ---
| -- {% endcomponent %} ---
| ----
| -- {% slot "myslot2" %} ---
| ---- PQR {{ my_var }}
| -- {% endslot %} ---
| -------
```
5. I want to render the slots with `{% fill %}` tag that were defined OUTSIDE of this template. How do I do that?
1. Traverse the template to collect ALL slots
- NOTE: I will also look inside `{% slot %}` and `{% fill %}` tags, since they are all still
defined within the same TEMPLATE.
I should end up with a list like this:
```txt
- Name: "myslot"
ID 0001
Content:
| ----- DEF {{ my_var }}
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
- Name: "myslot_inner"
ID 0002
Content:
| -------- GHI {{ my_var }}
- Name: "myslot"
ID 0003
Content:
| ------- JKL {{ my_var }}
| ------- {% slot "myslot_inner" %}
| ---------- MNO {{ my_var }}
| ------- {% endslot %}
- Name: "myslot_inner"
ID 0004
Content:
| ---------- MNO {{ my_var }}
- Name: "myslot2"
ID 0005
Content:
| ---- PQR {{ my_var }}
```
2. Note the relationships - which slot is nested in which one
I should end up with a graph-like data like:
```txt
- 0001: [0002]
- 0002: []
- 0003: [0004]
- 0004: []
- 0005: []
```
In other words, the data tells us that slot ID `0001` is PARENT of slot `0002`.
This is important, because, IF parent template provides slot fill for slot 0001,
then we DON'T NEED TO render it's children, AKA slot 0002.
3. Find roots of the slot relationships
The data from previous step can be understood also as a collection of
directled acyclig graphs (DAG), e.g.:
```txt
0001 --> 0002
0003 --> 0004
0005
```
So we find the roots (`0001`, `0003`, `0005`), AKA slots that are NOT nested in other slots.
We do so by going over ALL entries from previous step. Those IDs which are NOT
mentioned in ANY of the lists are the roots.
Because of the nature of nested structures, there cannot be any cycles.
4. Recursively render slots, starting from roots.
1. First we take each of the roots.
2. Then we check if there is a slot fill for given slot name.
3. If YES we replace the slot node with the fill node.
- Note: We assume slot fills are ALREADY RENDERED!
```django
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
```
becomes
```django
| ----- Bla bla
| -------- Some Other Content
| ----- ...
```
We don't continue further, because inner slots have been overriden!
4. If NO, then we will replace slot nodes with their children, e.g.:
```django
| ---- {% slot "myslot" %} ---
| ------- JKL {{ my_var }}
| ------- {% slot "myslot_inner" %}
| ---------- MNO {{ my_var }}
| ------- {% endslot %}
| ---- {% endslot %} ---
```
Becomes
```django
| ------- JKL {{ my_var }}
| ------- {% slot "myslot_inner" %}
| ---------- MNO {{ my_var }}
| ------- {% endslot %}
```
5. We check if the slot includes any children `{% slot %}` tags. If YES, then continue with step 4. for them, and wait until they finish.
5. At this point, ALL slots should be rendered and we should have something like this:
```django
| -- {{ my_var }} --
| -- ABC
| ----- DEF {{ my_var }}
| -------- GHI {{ my_var }}
| ------
| -- {% component "mycomp" %} ---
| ------- JKL {{ my_var }}
| ---- {% component "mycomp" %} ---
| ---------- MNO {{ my_var }}
| ---- {% endcomponent %} ---
| -- {% endcomponent %} ---
| ----
| -- {% component "mycomp2" %} ---
| ---- PQR {{ my_var }}
| -- {% endcomponent %} ---
| ----
```
- NOTE: Inserting fills into {% slots %} should NOT introduce new {% slots %}, as the fills should be already rendered!
## Using the correct context in {% slot/fill %} tags
In previous section, we said that the `{% fill %}` tags should be already rendered by the time they are inserted into the `{% slot %}` tags.
This is not quite true. To help you understand, consider this complex case:
```django
| -- {% for var in [1, 2, 3] %} ---
| ---- {% component "mycomp2" %} ---
| ------ {% fill "first" %}
| ------- STU {{ my_var }}
| ------- {{ var }}
| ------ {% endfill %}
| ------ {% fill "second" %}
| -------- {% component var=var my_var=my_var %}
| ---------- VWX {{ my_var }}
| -------- {% endcomponent %}
| ------ {% endfill %}
| ---- {% endcomponent %} ---
| -- {% endfor %} ---
| -------
```
We want the forloop variables to be available inside the `{% fill %}` tags. Because of that, however, we CANNOT render the fills/slots in advance.
Instead, our solution is closer to [how Vue handles slots](https://vuejs.org/guide/components/slots.html#scoped-slots). In Vue, slots are effectively functions that accept a context variables and render some content.
While we do not wrap the logic in a function, we do PREPARE IN ADVANCE:
1. The content that should be rendered for each slot
2. The context variables from `get_context_data()`
Thus, once we reach the `{% slot %}` node, in it's `render()` method, we access the data above, and, depending on the `context_behavior` setting, include the current context or not. For more info, see `SlotNode.render()`.

View file

@ -5,15 +5,14 @@ site_url: https://emilstenstrom.github.io/django-components/
repo_url: https://github.com/EmilStenstrom/django-components
repo_name: EmilStenstrom/django-components
edit_uri: https://github.com/EmilStenstrom/django-components/edit/master/docs/
edit_uri: https://github.com/EmilStenstrom/django-components/edit/master/src/docs/
dev_addr: "127.0.0.1:9000"
site_dir: site
docs_dir: docs
docs_dir: src/docs
watch:
- src
- docs
- mkdocs.yml
- README.md
- scripts
@ -26,7 +25,7 @@ validation:
theme:
name: "material"
custom_dir: docs/overrides
custom_dir: src/docs/overrides
features:
- content.action.edit
- content.action.view
@ -117,7 +116,7 @@ plugins:
closing_tag: "!}"
- gen-files:
scripts:
- scripts/gen_ref_nav.py
- src/docs/scripts/reference.py
- literate-nav:
nav_file: SUMMARY.md
tab_length: 2
@ -175,13 +174,7 @@ plugins:
signature_crossrefs: true
summary: true
unwrap_annotated: true
# show_root_heading: true
# show_signature_annotations: true
show_if_no_docstring: false
# separate_signature: true
line_length: 140
# merge_init_into_class: true
show_submodules: true
docstring_style: google
# docstring_options:
# ignore_init_summary: true

View file

@ -27,6 +27,7 @@ classifiers = [
]
dependencies = [
'Django>=4.2',
'selectolax>=0.3.24',
]
license = {text = "MIT"}
@ -79,6 +80,10 @@ exclude = [
'.tox',
'build',
]
per-file-ignores = [
'tests/test_component_media.py:E501',
'tests/test_dependency_rendering.py:E501',
]
[tool.mypy]
check_untyped_defs = true

View file

@ -10,4 +10,5 @@ mypy
playwright
requests
types-requests
whitenoise
whitenoise
selectolax

View file

@ -91,6 +91,8 @@ pyyaml==6.0.1
# via pre-commit
requests==2.32.3
# via -r requirements-dev.in
selectolax==0.3.21
# via -r requirements-dev.in
sqlparse==0.5.0
# via django
tox==4.23.2

View file

@ -5,6 +5,8 @@ import sys
from pathlib import Path
from typing import List
from django_components import ComponentsSettings
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@ -44,6 +46,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_components.middleware.ComponentDependencyMiddleware",
]
ROOT_URLCONF = "sampleproject.urls"
@ -87,14 +90,14 @@ STATICFILES_FINDERS = [
WSGI_APPLICATION = "sampleproject.wsgi.application"
COMPONENTS = {
# "autodiscover": True,
"dirs": [BASE_DIR / "components"],
# "app_dirs": ["components"],
# "libraries": [],
# "template_cache_size": 128,
# "context_behavior": "isolated", # "django" | "isolated"
}
COMPONENTS = ComponentsSettings(
# autodiscover=True,
dirs=[BASE_DIR / "components"],
# app_dirs=["components"],
# libraries=[],
# template_cache_size=128,
# context_behavior="isolated", # "django" | "isolated"
)
# Database

View file

@ -3,4 +3,5 @@ from django.urls import include, path
urlpatterns = [
path("", include("calendarapp.urls")),
path("", include("components.urls")),
path("", include("django_components.urls")),
]

View file

@ -1,49 +1,73 @@
# flake8: noqa F401
"""Main package for Django Components."""
import django
# Public API
# NOTE: Middleware is exposed via django_components.middleware
# NOTE: Some of the documentation is generated based on these exports
# isort: off
from django_components.app_settings import ContextBehavior as ContextBehavior
from django_components.autodiscover import (
autodiscover as autodiscover,
import_libraries as import_libraries,
)
from django_components.component import (
Component as Component,
ComponentView as ComponentView,
)
from django_components.app_settings import ContextBehavior, ComponentsSettings
from django_components.autodiscovery import autodiscover, import_libraries
from django_components.component import Component, ComponentVars, ComponentView
from django_components.component_registry import (
AlreadyRegistered as AlreadyRegistered,
ComponentRegistry as ComponentRegistry,
NotRegistered as NotRegistered,
RegistrySettings as RegistrySettings,
register as register,
registry as registry,
)
from django_components.components import DynamicComponent as DynamicComponent
from django_components.library import TagProtectedError as TagProtectedError
from django_components.slots import (
SlotContent as SlotContent,
SlotFunc as SlotFunc,
AlreadyRegistered,
ComponentRegistry,
NotRegistered,
RegistrySettings,
register,
registry,
)
from django_components.components import DynamicComponent
from django_components.dependencies import render_dependencies
from django_components.library import TagProtectedError
from django_components.slots import SlotContent, Slot, SlotFunc, SlotRef, SlotResult
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,
ComponentFormatter,
ShorthandComponentFormatter,
TagFormatterABC,
TagResult,
component_formatter,
component_shorthand_formatter,
)
from django_components.template import cached_template as cached_template
from django_components.template import cached_template
import django_components.types as types
from django_components.types import (
EmptyTuple as EmptyTuple,
EmptyDict as EmptyDict,
)
from django_components.util.loader import ComponentFileEntry, get_component_dirs, get_component_files
from django_components.util.types import EmptyTuple, EmptyDict
# isort: on
if django.VERSION < (3, 2):
default_app_config = "django_components.apps.ComponentsConfig"
__all__ = [
"AlreadyRegistered",
"autodiscover",
"cached_template",
"ContextBehavior",
"ComponentsSettings",
"Component",
"ComponentFileEntry",
"ComponentFormatter",
"ComponentRegistry",
"ComponentVars",
"ComponentView",
"component_formatter",
"component_shorthand_formatter",
"DynamicComponent",
"EmptyTuple",
"EmptyDict",
"get_component_dirs",
"get_component_files",
"import_libraries",
"NotRegistered",
"register",
"registry",
"RegistrySettings",
"render_dependencies",
"ShorthandComponentFormatter",
"SlotContent",
"Slot",
"SlotFunc",
"SlotRef",
"SlotResult",
"TagFormatterABC",
"TagProtectedError",
"TagResult",
"types",
]

View file

@ -1,24 +1,60 @@
import re
from dataclasses import dataclass
from enum import Enum
from os import PathLike
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Tuple, Union
from typing import (
TYPE_CHECKING,
Callable,
Generic,
List,
Literal,
NamedTuple,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
cast,
)
from django.conf import settings
from django_components.util.misc import default
if TYPE_CHECKING:
from django_components.tag_formatter import TagFormatterABC
T = TypeVar("T")
ContextBehaviorType = Literal["django", "isolated"]
class ContextBehavior(str, Enum):
"""
Configure how (and whether) the context is passed to the component fills
and what variables are available inside the [`{% fill %}`](../template_tags#fill) tags.
Also see [Component context and scope](../../concepts/fundamentals/component_context_scope#context-behavior).
**Options:**
- `django`: With this setting, component fills behave as usual Django tags.
- `isolated`: This setting makes the component fills behave similar to Vue or React.
"""
DJANGO = "django"
"""
With this setting, component fills behave as usual Django tags.
That is, they enrich the context, and pass it along.
1. Component fills use the context of the component they are within.
2. Variables from `get_context_data` are available to the component fill.
2. Variables from [`Component.get_context_data()`](../api#django_components.Component.get_context_data)
are available to the component fill.
Example:
**Example:**
Given this template
```django
@ -30,13 +66,13 @@ class ContextBehavior(str, Enum):
{% endwith %}
```
and this context returned from the `get_context_data()` method
```py
and this context returned from the `Component.get_context_data()` method
```python
{ "my_var": 123 }
```
Then if component "my_comp" defines context
```py
```python
{ "my_var": 456 }
```
@ -56,9 +92,10 @@ class ContextBehavior(str, Enum):
ISOLATED = "isolated"
"""
This setting makes the component fills behave similar to Vue or React, where
the fills use EXCLUSIVELY the context variables defined in `get_context_data`.
the fills use EXCLUSIVELY the context variables defined in
[`Component.get_context_data()`](../api#django_components.Component.get_context_data).
Example:
**Example:**
Given this template
```django
@ -71,12 +108,12 @@ class ContextBehavior(str, Enum):
```
and this context returned from the `get_context_data()` method
```py
```python
{ "my_var": 123 }
```
Then if component "my_comp" defines context
```py
```python
{ "my_var": 456 }
```
@ -91,95 +128,565 @@ class ContextBehavior(str, Enum):
"""
class AppSettings:
# This is the source of truth for the settings that are available. If the documentation
# or the defaults do NOT match this, they should be updated.
class ComponentsSettings(NamedTuple):
"""
Settings available for django_components.
**Example:**
```python
COMPONENTS = ComponentsSettings(
autodiscover=False,
dirs = [BASE_DIR / "components"],
)
```
"""
autodiscover: Optional[bool] = None
"""
Toggle whether to run [autodiscovery](../../concepts/fundamentals/autodiscovery) at the Django server startup.
Defaults to `True`
```python
COMPONENTS = ComponentsSettings(
autodiscover=False,
)
```
"""
dirs: Optional[Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]] = None
"""
Specify the directories that contain your components.
Defaults to `[Path(settings.BASE_DIR) / "components"]`. That is, the root `components/` app.
Directories must be full paths, same as with
[STATICFILES_DIRS](https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-STATICFILES_DIRS).
These locations are searched during [autodiscovery](../../concepts/fundamentals/autodiscovery),
or when you [define HTML, JS, or CSS as separate files](../../concepts/fundamentals/defining_js_css_html_files).
```python
COMPONENTS = ComponentsSettings(
dirs=[BASE_DIR / "components"],
)
```
Set to empty list to disable global components directories:
```python
COMPONENTS = ComponentsSettings(
dirs=[],
)
```
"""
app_dirs: Optional[Sequence[str]] = None
"""
Specify the app-level directories that contain your components.
Defaults to `["components"]`. That is, for each Django app, we search `<app>/components/` for components.
The paths must be relative to app, e.g.:
```python
COMPONENTS = ComponentsSettings(
app_dirs=["my_comps"],
)
```
To search for `<app>/my_comps/`.
These locations are searched during [autodiscovery](../../concepts/fundamentals/autodiscovery),
or when you [define HTML, JS, or CSS as separate files](../../concepts/fundamentals/defining_js_css_html_files).
Set to empty list to disable app-level components:
```python
COMPONENTS = ComponentsSettings(
app_dirs=[],
)
```
"""
context_behavior: Optional[ContextBehaviorType] = None
"""
Configure whether, inside a component template, you can use variables from the outside
([`"django"`](../api#django_components.ContextBehavior.DJANGO))
or not ([`"isolated"`](../api#django_components.ContextBehavior.ISOLATED)).
This also affects what variables are available inside the [`{% fill %}`](../template_tags#fill)
tags.
Also see [Component context and scope](../../concepts/fundamentals/component_context_scope#context-behavior).
Defaults to `"django"`.
```python
COMPONENTS = ComponentsSettings(
context_behavior="isolated",
)
```
> NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
>
> If you are migrating from BEFORE v0.67, set `context_behavior` to `"django"`.
> From v0.67 to v0.78 (incl) the default value was `"isolated"`.
>
> For v0.79 and later, the default is again `"django"`. See the rationale for change
> [here](https://github.com/EmilStenstrom/django-components/issues/498).
"""
dynamic_component_name: Optional[str] = None
"""
By default, the [dynamic component](../components#django_components.components.dynamic.DynamicComponent)
is registered under the name `"dynamic"`.
In case of a conflict, you can use this setting to change the component name used for
the dynamic components.
```python
# settings.py
COMPONENTS = ComponentsSettings(
dynamic_component_name="my_dynamic",
)
```
After which you will be able to use the dynamic component with the new name:
```django
{% component "my_dynamic" is=table_comp data=table_data headers=table_headers %}
{% fill "pagination" %}
{% component "pagination" / %}
{% endfill %}
{% endcomponent %}
```
"""
libraries: Optional[List[str]] = None
"""
Configure extra python modules that should be loaded.
This may be useful if you are not using the [autodiscovery feature](../../concepts/fundamentals/autodiscovery),
or you need to load components from non-standard locations. Thus you can have
a structure of components that is independent from your apps.
Expects a list of python module paths. Defaults to empty list.
**Example:**
```python
COMPONENTS = ComponentsSettings(
libraries=[
"mysite.components.forms",
"mysite.components.buttons",
"mysite.components.cards",
],
)
```
This would be the equivalent of importing these modules from within Django's
[`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready):
```python
class MyAppConfig(AppConfig):
def ready(self):
import "mysite.components.forms"
import "mysite.components.buttons"
import "mysite.components.cards"
```
# Manually loading libraries
In the rare case that you need to manually trigger the import of libraries, you can use
the [`import_libraries()`](../api/#django_components.import_libraries) function:
```python
from django_components import import_libraries
import_libraries()
```
"""
multiline_tags: Optional[bool] = None
"""
Enable / disable
[multiline support for template tags](../../concepts/fundamentals/template_tag_syntax#multiline-tags).
If `True`, template tags like `{% component %}` or `{{ my_var }}` can span multiple lines.
Defaults to `True`.
Disable this setting if you are making custom modifications to Django's
regular expression for parsing templates at `django.template.base.tag_re`.
```python
COMPONENTS = ComponentsSettings(
multiline_tags=False,
)
```
"""
# TODO_REMOVE_IN_V1
reload_on_template_change: Optional[bool] = None
"""Deprecated. Use
[`COMPONENTS.reload_on_file_change`](../settings/#django_components.app_settings.ComponentsSettings.reload_on_file_change)
instead.""" # noqa: E501
reload_on_file_change: Optional[bool] = None
"""
This is relevant if you are using the project structure where
HTML, JS, CSS and Python are in separate files and nested in a directory.
In this case you may notice that when you are running a development server,
the server sometimes does not reload when you change component files.
Django's native [live reload](https://stackoverflow.com/a/66023029/9788634) logic
handles only Python files and HTML template files. It does NOT reload when other
file types change or when template files are nested more than one level deep.
The setting `reload_on_file_change` fixes this, reloading the dev server even when your component's
HTML, JS, or CSS changes.
If `True`, django_components configures Django to reload when files inside
[`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs)
or
[`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs)
change.
See [Reload dev server on component file changes](../../guides/setup/dev_server_setup/#reload-dev-server-on-component-file-changes).
Defaults to `False`.
!!! warning
This setting should be enabled only for the dev environment!
""" # noqa: E501
static_files_allowed: Optional[List[Union[str, re.Pattern]]] = None
"""
A list of file extensions (including the leading dot) that define which files within
[`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs)
or
[`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs)
are treated as [static files](https://docs.djangoproject.com/en/5.1/howto/static-files/).
If a file is matched against any of the patterns, it's considered a static file. Such files are collected
when running [`collectstatic`](https://docs.djangoproject.com/en/5.1/ref/contrib/staticfiles/#collectstatic),
and can be accessed under the
[static file endpoint](https://docs.djangoproject.com/en/5.1/ref/settings/#static-url).
You can also pass in compiled regexes ([`re.Pattern`](https://docs.python.org/3/library/re.html#re.Pattern))
for more advanced patterns.
By default, JS, CSS, and common image and font file formats are considered static files:
```python
COMPONENTS = ComponentsSettings(
static_files_allowed=[
".css",
".js", ".jsx", ".ts", ".tsx",
# Images
".apng", ".png", ".avif", ".gif", ".jpg",
".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg",
".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff",
# Fonts
".eot", ".ttf", ".woff", ".otf", ".svg",
],
)
```
!!! warning
Exposing your Python files can be a security vulnerability.
See [Security notes](../../overview/security_notes).
"""
# TODO_REMOVE_IN_V1
forbidden_static_files: Optional[List[Union[str, re.Pattern]]] = None
"""Deprecated. Use
[`COMPONENTS.static_files_forbidden`](../settings/#django_components.app_settings.ComponentsSettings.static_files_forbidden)
instead.""" # noqa: E501
static_files_forbidden: Optional[List[Union[str, re.Pattern]]] = None
"""
A list of file extensions (including the leading dot) that define which files within
[`COMPONENTS.dirs`](../settings/#django_components.app_settings.ComponentsSettings.dirs)
or
[`COMPONENTS.app_dirs`](../settings/#django_components.app_settings.ComponentsSettings.app_dirs)
will NEVER be treated as [static files](https://docs.djangoproject.com/en/5.1/howto/static-files/).
If a file is matched against any of the patterns, it will never be considered a static file,
even if the file matches a pattern in
[`static_files_allowed`](../settings/#django_components.app_settings.ComponentsSettings.static_files_allowed).
Use this setting together with
[`static_files_allowed`](../settings/#django_components.app_settings.ComponentsSettings.static_files_allowed)
for a fine control over what file types will be exposed.
You can also pass in compiled regexes ([`re.Pattern`](https://docs.python.org/3/library/re.html#re.Pattern))
for more advanced patterns.
By default, any HTML and Python are considered NOT static files:
```python
COMPONENTS = ComponentsSettings(
static_files_forbidden=[
".html", ".django", ".dj", ".tpl",
# Python files
".py", ".pyc",
],
)
```
!!! warning
Exposing your Python files can be a security vulnerability.
See [Security notes](../../overview/security_notes).
"""
tag_formatter: Optional[Union["TagFormatterABC", str]] = None
"""
Configure what syntax is used inside Django templates to render components.
See the [available tag formatters](../tag_formatters).
Defaults to `"django_components.component_formatter"`.
Learn more about [Customizing component tags with TagFormatter](../../concepts/advanced/tag_formatter).
Can be set either as direct reference:
```python
from django_components import component_formatter
COMPONENTS = ComponentsSettings(
"tag_formatter": component_formatter
)
```
Or as an import string;
```python
COMPONENTS = ComponentsSettings(
"tag_formatter": "django_components.component_formatter"
)
```
**Examples:**
- `"django_components.component_formatter"`
Set
```python
COMPONENTS = ComponentsSettings(
"tag_formatter": "django_components.component_formatter"
)
```
To write components like this:
```django
{% component "button" href="..." %}
Click me!
{% endcomponent %}
```
- `django_components.component_shorthand_formatter`
Set
```python
COMPONENTS = ComponentsSettings(
"tag_formatter": "django_components.component_shorthand_formatter"
)
```
To write components like this:
```django
{% button href="..." %}
Click me!
{% endbutton %}
```
"""
template_cache_size: Optional[int] = None
"""
Configure the maximum amount of Django templates to be cached.
Defaults to `128`.
Each time a [Django template](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Template)
is rendered, it is cached to a global in-memory cache (using Python's
[`lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache)
decorator). This speeds up the next render of the component.
As the same component is often used many times on the same page, these savings add up.
By default the cache holds 128 component templates in memory, which should be enough for most sites.
But if you have a lot of components, or if you are overriding
[`Component.get_template()`](../api#django_components.Component.get_template)
to render many dynamic templates, you can increase this number.
```python
COMPONENTS = ComponentsSettings(
template_cache_size=256,
)
```
To remove the cache limit altogether and cache everything, set `template_cache_size` to `None`.
```python
COMPONENTS = ComponentsSettings(
template_cache_size=None,
)
```
If you want to add templates to the cache yourself, you can use
[`cached_template()`](../api/#django_components.cached_template):
```python
from django_components import cached_template
cached_template("Variable: {{ variable }}")
# You can optionally specify Template class, and other Template inputs:
class MyTemplate(Template):
pass
cached_template(
"Variable: {{ variable }}",
template_cls=MyTemplate,
name=...
origin=...
engine=...
)
```
"""
# NOTE: Some defaults depend on the Django settings, which may not yet be
# initialized at the time that these settings are generated. For such cases
# we define the defaults as a factory function, and use the `Dynamic` class to
# mark such fields.
@dataclass(frozen=True)
class Dynamic(Generic[T]):
getter: Callable[[], T]
# This is the source of truth for the settings defaults. If the documentation
# does NOT match it, the documentation should be updated.
#
# NOTE: Because we need to access Django settings to generate default dirs
# for `COMPONENTS.dirs`, we do it lazily.
# NOTE 2: We show the defaults in the documentation, together with the comments
# (except for the `Dynamic` instances and comments like `type: ignore`).
# So `fmt: off` turns off Black formatting and `snippet:defaults` allows
# us to extract the snippet from the file.
#
# fmt: off
# --snippet:defaults--
defaults = ComponentsSettings(
autodiscover=True,
context_behavior=ContextBehavior.DJANGO.value, # "django" | "isolated"
# Root-level "components" dirs, e.g. `/path/to/proj/components/`
dirs=Dynamic(lambda: [Path(settings.BASE_DIR) / "components"]), # type: ignore[arg-type]
# App-level "components" dirs, e.g. `[app]/components/`
app_dirs=["components"],
dynamic_component_name="dynamic",
libraries=[], # E.g. ["mysite.components.forms", ...]
multiline_tags=True,
reload_on_file_change=False,
static_files_allowed=[
".css",
".js", ".jsx", ".ts", ".tsx",
# Images
".apng", ".png", ".avif", ".gif", ".jpg",
".jpeg", ".jfif", ".pjpeg", ".pjp", ".svg",
".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff",
# Fonts
".eot", ".ttf", ".woff", ".otf", ".svg",
],
static_files_forbidden=[
# See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support
".html", ".django", ".dj", ".tpl",
# Python files
".py", ".pyc",
],
tag_formatter="django_components.component_formatter",
template_cache_size=128,
)
# --endsnippet:defaults--
# fmt: on
class InternalSettings:
@property
def settings(self) -> Dict:
return getattr(settings, "COMPONENTS", {})
def _settings(self) -> ComponentsSettings:
data = getattr(settings, "COMPONENTS", {})
return ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data
@property
def AUTODISCOVER(self) -> bool:
return self.settings.get("autodiscover", True)
return default(self._settings.autodiscover, cast(bool, defaults.autodiscover))
@property
def DIRS(self) -> List[Union[str, Tuple[str, str]]]:
base_dir_path = Path(settings.BASE_DIR)
return self.settings.get("dirs", [base_dir_path / "components"])
def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]:
# For DIRS we use a getter, because default values uses Django settings,
# which may not yet be initialized at the time these settings are generated.
default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs)
default_dirs = default_fn.getter()
return default(self._settings.dirs, default_dirs)
@property
def APP_DIRS(self) -> List[str]:
return self.settings.get("app_dirs", ["components"])
def APP_DIRS(self) -> Sequence[str]:
return default(self._settings.app_dirs, cast(List[str], defaults.app_dirs))
@property
def DYNAMIC_COMPONENT_NAME(self) -> str:
return self.settings.get("dynamic_component_name", "dynamic")
return default(self._settings.dynamic_component_name, cast(str, defaults.dynamic_component_name))
@property
def LIBRARIES(self) -> List[str]:
return self.settings.get("libraries", [])
return default(self._settings.libraries, cast(List[str], defaults.libraries))
@property
def MULTILINE_TAGS(self) -> bool:
return self.settings.get("multiline_tags", True)
return default(self._settings.multiline_tags, cast(bool, defaults.multiline_tags))
@property
def RELOAD_ON_TEMPLATE_CHANGE(self) -> bool:
return self.settings.get("reload_on_template_change", False)
def RELOAD_ON_FILE_CHANGE(self) -> bool:
val = self._settings.reload_on_file_change
# TODO_REMOVE_IN_V1
if val is None:
val = self._settings.reload_on_template_change
return default(val, cast(bool, defaults.reload_on_file_change))
@property
def TEMPLATE_CACHE_SIZE(self) -> int:
return self.settings.get("template_cache_size", 128)
return default(self._settings.template_cache_size, cast(int, defaults.template_cache_size))
@property
def STATIC_FILES_ALLOWED(self) -> List[Union[str, re.Pattern]]:
default_static_files = [
".css",
".js",
# Images - See https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types#common_image_file_types # noqa: E501
".apng",
".png",
".avif",
".gif",
".jpg",
".jpeg",
".jfif",
".pjpeg",
".pjp",
".svg",
".webp",
".bmp",
".ico",
".cur",
".tif",
".tiff",
# Fonts - See https://stackoverflow.com/q/30572159/9788634
".eot",
".ttf",
".woff",
".otf",
".svg",
]
return self.settings.get("static_files_allowed", default_static_files)
def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]:
return default(self._settings.static_files_allowed, cast(List[str], defaults.static_files_allowed))
@property
def STATIC_FILES_FORBIDDEN(self) -> List[Union[str, re.Pattern]]:
default_forbidden_static_files = [
".html",
# See https://marketplace.visualstudio.com/items?itemName=junstyle.vscode-django-support
".django",
".dj",
".tpl",
# Python files
".py",
".pyc",
]
return self.settings.get("forbidden_static_files", default_forbidden_static_files)
def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]:
val = self._settings.static_files_forbidden
# TODO_REMOVE_IN_V1
if val is None:
val = self._settings.forbidden_static_files
return default(val, cast(List[str], defaults.static_files_forbidden))
@property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
raw_value = self.settings.get("context_behavior", ContextBehavior.DJANGO.value)
raw_value = cast(str, default(self._settings.context_behavior, defaults.context_behavior))
return self._validate_context_behavior(raw_value)
def _validate_context_behavior(self, raw_value: ContextBehavior) -> ContextBehavior:
def _validate_context_behavior(self, raw_value: Union[ContextBehavior, str]) -> ContextBehavior:
try:
return ContextBehavior(raw_value)
except ValueError:
@ -188,7 +695,8 @@ class AppSettings:
@property
def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]:
return self.settings.get("tag_formatter", "django_components.component_formatter")
tag_formatter = default(self._settings.tag_formatter, cast(str, defaults.tag_formatter))
return cast(Union["TagFormatterABC", str], tag_formatter)
app_settings = AppSettings()
app_settings = InternalSettings()

View file

@ -1,6 +1,9 @@
import re
from pathlib import Path
from typing import Any
from django.apps import AppConfig
from django.utils.autoreload import file_changed, trigger_reload
class ComponentsConfig(AppConfig):
@ -10,10 +13,9 @@ class ComponentsConfig(AppConfig):
# to Django's INSTALLED_APPS
def ready(self) -> None:
from django_components.app_settings import app_settings
from django_components.autodiscover import autodiscover, get_dirs, import_libraries, search_dirs
from django_components.autodiscovery import autodiscover, import_libraries
from django_components.component_registry import registry
from django_components.components.dynamic import DynamicComponent
from django_components.utils import watch_files_for_autoreload
# Import modules set in `COMPONENTS.libraries` setting
import_libraries()
@ -21,13 +23,10 @@ class ComponentsConfig(AppConfig):
if app_settings.AUTODISCOVER:
autodiscover()
# Watch template files for changes, so Django dev server auto-reloads
# Auto-reload Django dev server when any component files changes
# See https://github.com/EmilStenstrom/django-components/discussions/567#discussioncomment-10273632
# And https://stackoverflow.com/questions/42907285/66673186#66673186
if app_settings.RELOAD_ON_TEMPLATE_CHANGE:
dirs = get_dirs(include_apps=False)
component_filepaths = search_dirs(dirs, "**/*")
watch_files_for_autoreload(component_filepaths)
if app_settings.RELOAD_ON_FILE_CHANGE:
_watch_component_files_for_autoreload()
# Allow tags to span multiple lines. This makes it easier to work with
# components inside Django templates, allowing us syntax like:
@ -48,3 +47,19 @@ class ComponentsConfig(AppConfig):
# Register the dynamic component under the name as given in settings
registry.register(app_settings.DYNAMIC_COMPONENT_NAME, DynamicComponent)
# See https://github.com/EmilStenstrom/django-components/issues/586#issue-2472678136
def _watch_component_files_for_autoreload() -> None:
from django_components.util.loader import get_component_dirs
component_dirs = set(get_component_dirs())
def template_changed(sender: Any, file_path: Path, **kwargs: Any) -> None:
# Reload dev server if any of the files within `COMPONENTS.dirs` or `COMPONENTS.app_dirs` changed
for dir_path in file_path.parents:
if dir_path in component_dirs:
trigger_reload(file_path)
return
file_changed.connect(template_changed)

View file

@ -1,149 +0,0 @@
import glob
import importlib
import os
from pathlib import Path
from typing import Callable, List, Optional, Union
from django.apps import apps
from django.conf import settings
from django_components.app_settings import app_settings
from django_components.logger import logger
from django_components.template_loader import get_dirs
def autodiscover(
map_module: Optional[Callable[[str], str]] = None,
) -> List[str]:
"""
Search for component files and import them. Returns a list of module
paths of imported files.
Autodiscover searches in the locations as defined by `Loader.get_dirs`.
You can map the module paths with `map_module` function. This serves
as an escape hatch for when you need to use this function in tests.
"""
dirs = get_dirs(include_apps=False)
component_filepaths = search_dirs(dirs, "**/*.py")
logger.debug(f"Autodiscover found {len(component_filepaths)} files in component directories.")
if hasattr(settings, "BASE_DIR") and settings.BASE_DIR:
project_root = str(settings.BASE_DIR)
else:
# Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634
project_root = os.path.abspath(os.path.dirname(__name__))
modules: List[str] = []
# We handle dirs from `COMPONENTS.dirs` and from individual apps separately.
#
# Because for dirs in `COMPONENTS.dirs`, we assume they will be nested under `BASE_DIR`,
# and that `BASE_DIR` is the current working dir (CWD). So the path relatively to `BASE_DIR`
# is ALSO the python import path.
for filepath in component_filepaths:
module_path = _filepath_to_python_module(filepath, project_root, None)
# Ignore files starting with dot `.` or files in dirs that start with dot.
#
# If any of the parts of the path start with a dot, e.g. the filesystem path
# is `./abc/.def`, then this gets converted to python module as `abc..def`
#
# NOTE: This approach also ignores files:
# - with two dots in the middle (ab..cd.py)
# - an extra dot at the end (abcd..py)
# - files outside of the parent component (../abcd.py).
# But all these are NOT valid python modules so that's fine.
if ".." in module_path:
continue
modules.append(module_path)
# For for apps, the directories may be outside of the project, e.g. in case of third party
# apps. So we have to resolve the python import path relative to the package name / the root
# import path for the app.
# See https://github.com/EmilStenstrom/django-components/issues/669
for conf in apps.get_app_configs():
for app_dir in app_settings.APP_DIRS:
comps_path = Path(conf.path).joinpath(app_dir)
if not comps_path.exists():
continue
app_component_filepaths = search_dirs([comps_path], "**/*.py")
for filepath in app_component_filepaths:
app_component_module = _filepath_to_python_module(filepath, conf.path, conf.name)
modules.append(app_component_module)
return _import_modules(modules, map_module)
def import_libraries(
map_module: Optional[Callable[[str], str]] = None,
) -> List[str]:
"""
Import modules set in `COMPONENTS.libraries` setting.
You can map the module paths with `map_module` function. This serves
as an escape hatch for when you need to use this function in tests.
"""
from django_components.app_settings import app_settings
return _import_modules(app_settings.LIBRARIES, map_module)
def _import_modules(
modules: List[str],
map_module: Optional[Callable[[str], str]] = None,
) -> List[str]:
imported_modules: List[str] = []
for module_name in modules:
if map_module:
module_name = map_module(module_name)
# This imports the file and runs it's code. So if the file defines any
# django components, they will be registered.
logger.debug(f'Importing module "{module_name}"')
importlib.import_module(module_name)
imported_modules.append(module_name)
return imported_modules
def _filepath_to_python_module(
file_path: Union[Path, str],
root_fs_path: Union[str, Path],
root_module_path: Optional[str],
) -> str:
"""
Derive python import path from the filesystem path.
Example:
- If project root is `/path/to/project`
- And file_path is `/path/to/project/app/components/mycomp.py`
- Then the path relative to project root is `app/components/mycomp.py`
- Which we then turn into python import path `app.components.mycomp`
"""
rel_path = os.path.relpath(file_path, start=root_fs_path)
rel_path_without_suffix = str(Path(rel_path).with_suffix(""))
# NOTE: `Path` normalizes paths to use `/` as separator, while `os.path`
# uses `os.path.sep`.
sep = os.path.sep if os.path.sep in rel_path_without_suffix else "/"
module_name = rel_path_without_suffix.replace(sep, ".")
# Combine with the base module path
full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name
if full_module_name.endswith(".__init__"):
full_module_name = full_module_name[:-9] # Remove the trailing `.__init__
return full_module_name
def search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
"""
Search the directories for the given glob pattern. Glob search results are returned
as a flattened list.
"""
matched_files: List[Path] = []
for directory in dirs:
for path in glob.iglob(str(Path(directory) / search_glob), recursive=True):
matched_files.append(Path(path))
return matched_files

View file

@ -0,0 +1,95 @@
import importlib
from typing import Callable, List, Optional
from django_components.util.loader import get_component_files
from django_components.util.logger import logger
def autodiscover(
map_module: Optional[Callable[[str], str]] = None,
) -> List[str]:
"""
Search for all python files in
[`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs)
and
[`COMPONENTS.app_dirs`](../settings#django_components.app_settings.ComponentsSettings.app_dirs)
and import them.
See [Autodiscovery](../../concepts/fundamentals/autodiscovery).
Args:
map_module (Callable[[str], str], optional): Map the module paths with `map_module` function.\
This serves as an escape hatch for when you need to use this function in tests.
Returns:
List[str]: A list of module paths of imported files.
To get the same list of modules that `autodiscover()` would return, but without importing them, use
[`get_component_files()`](../api#django_components.get_component_files):
```python
from django_components import get_component_files
modules = get_component_files(".py")
```
"""
modules = get_component_files(".py")
logger.debug(f"Autodiscover found {len(modules)} files in component directories.")
return _import_modules([entry.dot_path for entry in modules], map_module)
def import_libraries(
map_module: Optional[Callable[[str], str]] = None,
) -> List[str]:
"""
Import modules set in
[`COMPONENTS.libraries`](../settings#django_components.app_settings.ComponentsSettings.libraries)
setting.
See [Autodiscovery](../../concepts/fundamentals/autodiscovery).
Args:
map_module (Callable[[str], str], optional): Map the module paths with `map_module` function.\
This serves as an escape hatch for when you need to use this function in tests.
Returns:
List[str]: A list of module paths of imported files.
**Examples:**
Normal usage - load libraries after Django has loaded
```python
from django_components import import_libraries
class MyAppConfig(AppConfig):
def ready(self):
import_libraries()
```
Potential usage in tests
```python
from django_components import import_libraries
import_libraries(lambda path: path.replace("tests.", "myapp."))
```
"""
from django_components.app_settings import app_settings
return _import_modules(app_settings.LIBRARIES, map_module)
def _import_modules(
modules: List[str],
map_module: Optional[Callable[[str], str]] = None,
) -> List[str]:
imported_modules: List[str] = []
for module_name in modules:
if map_module:
module_name = map_module(module_name)
# This imports the file and runs it's code. So if the file defines any
# django components, they will be registered.
logger.debug(f'Importing module "{module_name}"')
importlib.import_module(module_name)
imported_modules.append(module_name)
return imported_modules

View file

@ -14,6 +14,7 @@ from typing import (
List,
Literal,
Mapping,
NamedTuple,
Optional,
Protocol,
Tuple,
@ -31,7 +32,6 @@ from django.template.context import Context
from django.template.loader import get_template
from django.template.loader_tags import BLOCK_CONTEXT_KEY
from django.utils.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe
from django.views import View
from django_components.app_settings import ContextBehavior
@ -39,31 +39,31 @@ from django_components.component_media import ComponentMediaInput, MediaMeta
from django_components.component_registry import ComponentRegistry
from django_components.component_registry import registry as registry_
from django_components.context import (
_FILLED_SLOTS_CONTENT_CONTEXT_KEY,
_COMPONENT_SLOT_CTX_CONTEXT_KEY,
_REGISTRY_CONTEXT_KEY,
_ROOT_CTX_CONTEXT_KEY,
get_injected_context_var,
make_isolated_context_copy,
prepare_context,
)
from django_components.dependencies import RenderType, cache_inlined_css, cache_inlined_js, postprocess_component_html
from django_components.expression import Expression, RuntimeKwargs, safe_resolve_list
from django_components.logger import trace_msg
from django_components.middleware import is_dependency_middleware_active
from django_components.node import BaseNode
from django_components.slots import (
DEFAULT_SLOT_KEY,
FillContent,
FillNode,
ComponentSlotContext,
Slot,
SlotContent,
SlotFunc,
SlotIsFilled,
SlotName,
SlotRef,
SlotResult,
_nodelist_to_slot_render_func,
resolve_fill_nodes,
resolve_slots,
resolve_fills,
)
from django_components.template import cached_template
from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
# isort: off
@ -75,14 +75,18 @@ from django_components.component_registry import registry as registry # NOQA
# isort: on
RENDERED_COMMENT_TEMPLATE = "<!-- _RENDERED {name} -->"
COMP_ONLY_FLAG = "only"
# Define TypeVars for args and kwargs
ArgsType = TypeVar("ArgsType", bound=tuple, contravariant=True)
KwargsType = TypeVar("KwargsType", bound=Mapping[str, Any], contravariant=True)
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True)
SlotsType = TypeVar("SlotsType", bound=Mapping[SlotName, SlotContent])
DataType = TypeVar("DataType", bound=Mapping[str, Any], covariant=True)
JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any])
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
# Rename, so we can use `type()` inside functions with kwrags of the same name
_type = type
@dataclass(frozen=True)
@ -91,19 +95,53 @@ class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
args: ArgsType
kwargs: KwargsType
slots: SlotsType
escape_slots_content: bool
type: RenderType
render_dependencies: bool
@dataclass()
class RenderStackItem(Generic[ArgsType, KwargsType, SlotsType]):
input: RenderInput[ArgsType, KwargsType, SlotsType]
is_filled: Optional[Dict[str, bool]]
is_filled: Optional[SlotIsFilled]
class ViewFn(Protocol):
def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
class ComponentVars(NamedTuple):
"""
Type for the variables available inside the component templates.
All variables here are scoped under `component_vars.`, so e.g. attribute
`is_filled` on this class is accessible inside the template as:
```django
{{ component_vars.is_filled }}
```
"""
is_filled: Dict[str, bool]
"""
Dictonary describing which component slots are filled (`True`) or are not (`False`).
<i>New in version 0.70</i>
Use as `{{ component_vars.is_filled }}`
Example:
```django
{# Render wrapping HTML only if the slot is defined #}
{% if component_vars.is_filled.my_slot %}
<div class="slot-wrapper">
{% slot "my_slot" / %}
</div>
{% endif %}
```
"""
class ComponentMeta(MediaMeta):
def __new__(mcs, name: str, bases: Tuple[Type, ...], attrs: Dict[str, Any]) -> Type:
# NOTE: Skip template/media file resolution when then Component class ITSELF
@ -149,10 +187,13 @@ class ComponentView(View, metaclass=ComponentViewMeta):
self.component = component
class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=ComponentMeta):
# Either template_name or template must be set on subclass OR subclass must implement get_template() with
# non-null return.
_class_hash: ClassVar[int]
class Component(
Generic[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType],
metaclass=ComponentMeta,
):
# #####################################
# PUBLIC API (Configurable by users)
# #####################################
template_name: Optional[str] = None
"""
@ -190,6 +231,9 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
"""
return None
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
return cast(DataType, {})
js: Optional[str] = None
"""Inlined JS associated with this component."""
css: Optional[str] = None
@ -201,14 +245,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
NOTE: This field is generated from Component.Media class.
"""
media_class: Media = Media
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""
Media = ComponentMediaInput
"""Defines JS and CSS media files associated with this component."""
response_class = HttpResponse
"""This allows to configure what class is used to generate response from `render_to_response`"""
View = ComponentView
# #####################################
# PUBLIC API - HOOKS
# #####################################
def on_render_before(self, context: Context, template: Template) -> None:
"""
Hook that runs just before the component's template is rendered.
@ -230,12 +277,17 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
"""
pass
# #####################################
# MISC
# #####################################
_class_hash: ClassVar[int]
def __init__(
self,
registered_name: Optional[str] = None,
component_id: Optional[str] = None,
outer_context: Optional[Context] = None,
fill_content: Optional[Dict[str, FillContent]] = None,
registry: Optional[ComponentRegistry] = None, # noqa F811
):
# When user first instantiates the component class before calling
@ -255,12 +307,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
self.registered_name: Optional[str] = registered_name
self.outer_context: Context = outer_context or Context()
self.fill_content = fill_content or {}
self.component_id = component_id or gen_id()
self.registry = registry or registry_
self._render_stack: Deque[RenderStackItem[ArgsType, KwargsType, SlotsType]] = deque()
# None == uninitialized, False == No types, Tuple == types
self._types: Optional[Union[Tuple[Any, Any, Any, Any], Literal[False]]] = None
self._types: Optional[Union[Tuple[Any, Any, Any, Any, Any, Any], Literal[False]]] = None
def __init_subclass__(cls, **kwargs: Any) -> None:
cls._class_hash = hash(inspect.getfile(cls) + cls.__name__)
@ -283,7 +334,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
return self._render_stack[-1].input
@property
def is_filled(self) -> Dict[str, bool]:
def is_filled(self) -> SlotIsFilled:
"""
Dictionary describing which slots have or have not been filled.
@ -304,9 +355,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
return ctx.is_filled
def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
return cast(DataType, {})
# NOTE: When the template is taken from a file (AKA specified via `template_name`),
# then we leverage Django's template caching. This means that the same instance
# of Template is reused. This is important to keep in mind, because the implication
@ -358,32 +406,6 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}."
)
def render_dependencies(self) -> SafeString:
"""Helper function to render all dependencies for a component."""
dependencies = []
css_deps = self.render_css_dependencies()
if css_deps:
dependencies.append(css_deps)
js_deps = self.render_js_dependencies()
if js_deps:
dependencies.append(js_deps)
return mark_safe("\n".join(dependencies))
def render_css_dependencies(self) -> SafeString:
"""Render only CSS dependencies available in the media class or provided as a string."""
if self.css is not None:
return mark_safe(f"<style>{self.css}</style>")
return mark_safe("\n".join(self.media.render_css()))
def render_js_dependencies(self) -> SafeString:
"""Render only JS dependencies available in the media class or provided as a string."""
if self.js is not None:
return mark_safe(f"<script>{self.js}</script>")
return mark_safe("\n".join(self.media.render_js()))
def inject(self, key: str, default: Optional[Any] = None) -> Any:
"""
Use this method to retrieve the data that was passed to a `{% provide %}` tag
@ -449,6 +471,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
# Allow the View class to access this component via `self.component`
return comp.View.as_view(**initkwargs, component=comp)
# #####################################
# RENDERING
# #####################################
@classmethod
def render_to_response(
cls,
@ -457,6 +483,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
escape_slots_content: bool = True,
args: Optional[ArgsType] = None,
kwargs: Optional[KwargsType] = None,
type: RenderType = "document",
*response_args: Any,
**response_kwargs: Any,
) -> HttpResponse:
@ -481,6 +508,10 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
is rendered. The keys on the context can be accessed from within the template.
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
component's args and kwargs.
- `type` - Configure how to handle JS and CSS dependencies.
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
or to the end of the `<body>` tag. CSS dependencies are inserted into
`{% component_css_dependencies %}`, or the end of the `<head>` tag.
Any additional args and kwargs are passed to the `response_class`.
@ -509,6 +540,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
context=context,
slots=slots,
escape_slots_content=escape_slots_content,
type=type,
render_dependencies=True,
)
return cls.response_class(content, *response_args, **response_kwargs)
@ -520,6 +553,8 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
kwargs: Optional[KwargsType] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
type: RenderType = "document",
render_dependencies: bool = True,
) -> str:
"""
Render the component into a string.
@ -537,6 +572,11 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
is rendered. The keys on the context can be accessed from within the template.
- NOTE: In "isolated" mode, context is NOT accessible, and data MUST be passed via
component's args and kwargs.
- `type` - Configure how to handle JS and CSS dependencies.
- `"document"` (default) - JS dependencies are inserted into `{% component_js_dependencies %}`,
or to the end of the `<body>` tag. CSS dependencies are inserted into
`{% component_css_dependencies %}`, or the end of the `<head>` tag.
- `render_dependencies` - Set this to `False` if you want to insert the resulting HTML into another component.
Example:
```py
@ -560,7 +600,7 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
else:
comp = cls()
return comp._render(context, args, kwargs, slots, escape_slots_content)
return comp._render(context, args, kwargs, slots, escape_slots_content, type, render_dependencies)
# This is the internal entrypoint for the render function
def _render(
@ -570,11 +610,13 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
kwargs: Optional[KwargsType] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
type: RenderType = "document",
render_dependencies: bool = True,
) -> str:
try:
return self._render_impl(context, args, kwargs, slots, escape_slots_content)
return self._render_impl(context, args, kwargs, slots, escape_slots_content, type, render_dependencies)
except Exception as err:
raise type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err
raise _type(err)(f"An error occured while rendering component '{self.name}':\n{repr(err)}") from err
def _render_impl(
self,
@ -583,20 +625,24 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
kwargs: Optional[KwargsType] = None,
slots: Optional[SlotsType] = None,
escape_slots_content: bool = True,
type: RenderType = "document",
render_dependencies: bool = True,
) -> str:
has_slots = slots is not None
# NOTE: We must run validation before we normalize the slots, because the normalization
# wraps them in functions.
self._validate_inputs(args or (), kwargs or {}, slots or {})
# Allow to provide no args/kwargs/slots/context
args = cast(ArgsType, args or ())
kwargs = cast(KwargsType, kwargs or {})
slots = cast(SlotsType, slots or {})
slots_untyped = self._normalize_slot_fills(slots or {}, escape_slots_content)
slots = cast(SlotsType, slots_untyped)
context = context or Context()
# Allow to provide a dict instead of Context
# NOTE: This if/else is important to avoid nested Contexts,
# See https://github.com/EmilStenstrom/django-components/issues/414
context = context if isinstance(context, Context) else Context(context)
prepare_context(context, self.component_id)
# By adding the current input to the stack, we temporarily allow users
# to access the provided context, slots, etc. Also required so users can
@ -608,71 +654,72 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
slots=slots,
args=args,
kwargs=kwargs,
escape_slots_content=escape_slots_content,
type=type,
render_dependencies=render_dependencies,
),
is_filled=None,
),
)
self._validate_inputs()
context_data = self.get_context_data(*args, **kwargs)
self._validate_outputs(context_data)
self._validate_outputs(data=context_data)
# Process JS and CSS files
cache_inlined_js(self.__class__, self.js or "")
cache_inlined_css(self.__class__, self.css or "")
with _prepare_template(self, context, context_data) as template:
# Support passing slots explicitly to `render` method
if has_slots:
fill_content = self._fills_from_slots_data(
slots,
escape_slots_content,
)
else:
fill_content = self.fill_content
_, resolved_fills = resolve_slots(
context,
template,
component_name=self.name,
fill_content=fill_content,
# Dynamic component has a special mark do it doesn't raise certain errors
is_dynamic_component=getattr(self, "_is_dynamic_component", False),
)
# Available slot fills - this is internal to us
updated_slots = {
**context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {}),
**resolved_fills,
}
# For users, we expose boolean variables that they may check
# to see if given slot was filled, e.g.:
# `{% if variable > 8 and component_vars.is_filled.header %}`
slot_bools = {slot_fill.escaped_name: slot_fill.is_filled for slot_fill in resolved_fills.values()}
self._render_stack[-1].is_filled = slot_bools
is_filled = SlotIsFilled(slots_untyped)
self._render_stack[-1].is_filled = is_filled
component_slot_ctx = ComponentSlotContext(
component_name=self.name,
template_name=template.name,
fills=slots_untyped,
is_dynamic_component=getattr(self, "_is_dynamic_component", False),
# This field will be modified from within `SlotNodes.render()`:
# - The `default_slot` will be set to the first slot that has the `default` attribute set.
# If multiple slots have the `default` attribute set, yet have different name, then
# we will raise an error.
default_slot=None,
)
with context.update(
{
# Private context fields
_ROOT_CTX_CONTEXT_KEY: self.outer_context,
_FILLED_SLOTS_CONTENT_CONTEXT_KEY: updated_slots,
_COMPONENT_SLOT_CTX_CONTEXT_KEY: component_slot_ctx,
_REGISTRY_CONTEXT_KEY: self.registry,
# NOTE: Public API for variables accessible from within a component's template
# See https://github.com/EmilStenstrom/django-components/issues/280#issuecomment-2081180940
"component_vars": {
"is_filled": slot_bools,
},
"component_vars": ComponentVars(
is_filled=is_filled,
),
}
):
self.on_render_before(context, template)
rendered_component = template.render(context)
new_output = self.on_render_after(context, template, rendered_component)
rendered_component = new_output if new_output is not None else rendered_component
# Get the component's HTML
html_content = template.render(context)
if is_dependency_middleware_active():
output = RENDERED_COMMENT_TEMPLATE.format(name=self.name) + rendered_component
else:
output = rendered_component
# After we've rendered the contents, we now know what slots were there,
# and thus we can validate that.
component_slot_ctx.post_render_validation()
# Allow to optionally override/modify the rendered content
new_output = self.on_render_after(context, template, html_content)
html_content = new_output if new_output is not None else html_content
output = postprocess_component_html(
component_cls=self.__class__,
component_id=self.component_id,
html_content=html_content,
type=type,
render_dependencies=render_dependencies,
)
# After rendering is done, remove the current state from the stack, which means
# properties like `self.context` will no longer return the current state.
@ -680,51 +727,57 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
return output
def _fills_from_slots_data(
def _normalize_slot_fills(
self,
slots_data: Mapping[SlotName, SlotContent],
fills: Mapping[SlotName, SlotContent],
escape_content: bool = True,
) -> Dict[SlotName, FillContent]:
"""Fill component slots outside of template rendering."""
slot_fills = {}
for slot_name, content in slots_data.items():
if not callable(content):
content_func = _nodelist_to_slot_render_func(
NodeList([TextNode(conditional_escape(content) if escape_content else content)])
) -> Dict[SlotName, Slot]:
# Preprocess slots to escape content if `escape_content=True`
norm_fills = {}
# NOTE: `gen_escaped_content_func` is defined as a separate function, instead of being inlined within
# the forloop, because the value the forloop variable points to changes with each loop iteration.
def gen_escaped_content_func(content: SlotFunc) -> Slot:
def content_fn(ctx: Context, slot_data: Dict, slot_ref: SlotRef) -> SlotResult:
rendered = content(ctx, slot_data, slot_ref)
return conditional_escape(rendered) if escape_content else rendered
slot = Slot(content_func=cast(SlotFunc, content_fn))
return slot
for slot_name, content in fills.items():
if content is None:
continue
elif not callable(content):
slot = _nodelist_to_slot_render_func(
slot_name,
NodeList([TextNode(conditional_escape(content) if escape_content else content)]),
data_var=None,
default_var=None,
)
else:
slot = gen_escaped_content_func(content)
def content_func( # type: ignore[misc]
ctx: Context,
kwargs: Dict[str, Any],
slot_ref: SlotRef,
) -> SlotResult:
rendered = content(ctx, kwargs, slot_ref)
return conditional_escape(rendered) if escape_content else rendered
norm_fills[slot_name] = slot
slot_fills[slot_name] = FillContent(
content_func=content_func,
slot_default_var=None,
slot_data_var=None,
)
return slot_fills
return norm_fills
######################
# #####################################
# VALIDATION
######################
# #####################################
def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any]]:
def _get_types(self) -> Optional[Tuple[Any, Any, Any, Any, Any, Any]]:
"""
Extract the types passed to the Component class.
So if a component subclasses Component class like so
```py
class MyComp(Component[MyArgs, MyKwargs, Any, MySlots]):
class MyComp(Component[MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData]):
...
```
Then we want to extract the tuple (MyArgs, MyKwargs, Any, MySlots).
Then we want to extract the tuple (MyArgs, MyKwargs, MySlots, MyData, MyJsData, MyCssData).
Returns `None` if types were not provided. That is, the class was subclassed
as:
@ -770,35 +823,34 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
# If we got here, then we've found ourselves the typed Component class, e.g.
#
# `Component(Tuple[int], MyKwargs, MySlots, Any)`
# `Component(Tuple[int], MyKwargs, MySlots, Any, Any, Any)`
#
# By accessing the __args__, we access individual types between the brackets, so
#
# (Tuple[int], MyKwargs, MySlots, Any)
args_type, kwargs_type, data_type, slots_type = component_generics_base.__args__
# (Tuple[int], MyKwargs, MySlots, Any, Any, Any)
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = component_generics_base.__args__
self._types = args_type, kwargs_type, data_type, slots_type
self._types = args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type
return self._types
def _validate_inputs(self) -> None:
def _validate_inputs(self, args: Tuple, kwargs: Any, slots: Any) -> None:
maybe_inputs = self._get_types()
if maybe_inputs is None:
return
args_type, kwargs_type, data_type, slots_type = maybe_inputs
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
# Validate args
validate_typed_tuple(self.input.args, args_type, f"Component '{self.name}'", "positional argument")
validate_typed_tuple(args, args_type, f"Component '{self.name}'", "positional argument")
# Validate kwargs
validate_typed_dict(self.input.kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument")
validate_typed_dict(kwargs, kwargs_type, f"Component '{self.name}'", "keyword argument")
# Validate slots
validate_typed_dict(self.input.slots, slots_type, f"Component '{self.name}'", "slot")
validate_typed_dict(slots, slots_type, f"Component '{self.name}'", "slot")
def _validate_outputs(self, data: Any) -> None:
maybe_inputs = self._get_types()
if maybe_inputs is None:
return
args_type, kwargs_type, data_type, slots_type = maybe_inputs
args_type, kwargs_type, slots_type, data_type, js_data_type, css_data_type = maybe_inputs
# Validate data
validate_typed_dict(data, data_type, f"Component '{self.name}'", "data")
@ -814,14 +866,13 @@ class ComponentNode(BaseNode):
kwargs: RuntimeKwargs,
registry: ComponentRegistry, # noqa F811
isolated_context: bool = False,
fill_nodes: Optional[List[FillNode]] = None,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
) -> None:
super().__init__(nodelist=NodeList(fill_nodes), args=args, kwargs=kwargs, node_id=node_id)
super().__init__(nodelist=nodelist or NodeList(), args=args, kwargs=kwargs, node_id=node_id)
self.name = name
self.isolated_context = isolated_context
self.fill_nodes = fill_nodes or []
self.registry = registry
def __repr__(self) -> str:
@ -841,34 +892,27 @@ class ComponentNode(BaseNode):
args = safe_resolve_list(context, self.args)
kwargs = self.kwargs.resolve(context)
is_default_slot = len(self.fill_nodes) == 1 and self.fill_nodes[0].is_implicit
if is_default_slot:
fill_content: Dict[str, FillContent] = {
DEFAULT_SLOT_KEY: FillContent(
content_func=_nodelist_to_slot_render_func(self.fill_nodes[0].nodelist),
slot_data_var=None,
slot_default_var=None,
),
}
else:
fill_content = resolve_fill_nodes(context, self.fill_nodes, self.name)
slot_fills = resolve_fills(context, self.nodelist, self.name)
component: Component = component_cls(
registered_name=self.name,
outer_context=context,
fill_content=fill_content,
component_id=self.node_id,
registry=self.registry,
)
# Prevent outer context from leaking into the template of the component
if self.isolated_context or self.registry.settings.CONTEXT_BEHAVIOR == ContextBehavior.ISOLATED:
if self.isolated_context or self.registry.settings.context_behavior == ContextBehavior.ISOLATED:
context = make_isolated_context_copy(context)
output = component._render(
context=context,
args=args,
kwargs=kwargs,
slots=slot_fills,
# NOTE: When we render components inside the template via template tags,
# do NOT render deps, because this may be decided by outer component
render_dependencies=False,
)
trace_msg("RENDR", "COMP", self.name, self.node_id, "...Done!")

View file

@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, MutableMapping, Opt
from django.forms.widgets import Media, MediaDefiningClass
from django.utils.safestring import SafeData
from django_components.autodiscover import get_dirs
from django_components.logger import logger
from django_components.util.loader import get_component_dirs
from django_components.util.logger import logger
if TYPE_CHECKING:
from django_components.component import Component
@ -273,7 +273,7 @@ def _resolve_component_relative_files(attrs: MutableMapping) -> None:
# Prepare all possible directories we need to check when searching for
# component's template and media files
components_dirs = get_dirs()
components_dirs = get_component_dirs()
# Get the directory where the component class is defined
try:

View file

@ -1,22 +1,40 @@
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, TypeVar, Union
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
from django.template import Library
from django_components.app_settings import ContextBehavior, app_settings
from django_components.app_settings import ContextBehaviorType, app_settings
from django_components.library import is_tag_protected, mark_protected_tags, register_tag_from_formatter
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
if TYPE_CHECKING:
from django_components.component import Component
_TComp = TypeVar("_TComp", bound=Type["Component"])
from django_components.component import (
ArgsType,
Component,
CssDataType,
DataType,
JsDataType,
KwargsType,
SlotsType,
)
class AlreadyRegistered(Exception):
"""
Raised when you try to register a [Component](../api#django_components#Component),
but it's already registered with given
[ComponentRegistry](../api#django_components.ComponentRegistry).
"""
pass
class NotRegistered(Exception):
"""
Raised when you try to access a [Component](../api#django_components#Component),
but it's NOT registered with given
[ComponentRegistry](../api#django_components.ComponentRegistry).
"""
pass
@ -35,13 +53,77 @@ class ComponentRegistryEntry(NamedTuple):
class RegistrySettings(NamedTuple):
CONTEXT_BEHAVIOR: Optional[ContextBehavior] = None
"""
Configuration for a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
These settings define how the components registered with this registry will behave when rendered.
```python
from django_components import ComponentRegistry, RegistrySettings
registry_settings = RegistrySettings(
context_behavior="django",
tag_formatter="django_components.component_shorthand_formatter",
)
registry = ComponentRegistry(settings=registry_settings)
```
"""
context_behavior: Optional[ContextBehaviorType] = None
"""
Same as the global
[`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior)
setting, but for this registry.
If omitted, defaults to the global
[`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior)
setting.
"""
# TODO_REMOVE_IN_V1
CONTEXT_BEHAVIOR: Optional[ContextBehaviorType] = None
"""
_Deprecated. Use `context_behavior` instead. Will be removed in v1._
Same as the global
[`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior)
setting, but for this registry.
If omitted, defaults to the global
[`COMPONENTS.context_behavior`](../settings#django_components.app_settings.ComponentsSettings.context_behavior)
setting.
"""
tag_formatter: Optional[Union["TagFormatterABC", str]] = None
"""
Same as the global
[`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter)
setting, but for this registry.
If omitted, defaults to the global
[`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter)
setting.
"""
# TODO_REMOVE_IN_V1
TAG_FORMATTER: Optional[Union["TagFormatterABC", str]] = None
"""
_Deprecated. Use `tag_formatter` instead. Will be removed in v1._
Same as the global
[`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter)
setting, but for this registry.
If omitted, defaults to the global
[`COMPONENTS.tag_formatter`](../settings#django_components.app_settings.ComponentsSettings.tag_formatter)
setting.
"""
class InternalRegistrySettings(NamedTuple):
CONTEXT_BEHAVIOR: ContextBehavior
TAG_FORMATTER: Union["TagFormatterABC", str]
context_behavior: ContextBehaviorType
tag_formatter: Union["TagFormatterABC", str]
# We keep track of all registries that exist so that, when users want to
@ -52,21 +134,43 @@ all_registries: List["ComponentRegistry"] = []
class ComponentRegistry:
"""
Manages which components can be used in the template tags.
Manages [components](../api#django_components.Component) and makes them available
in the template, by default as [`{% component %}`](../template_tags#component)
tags.
Each ComponentRegistry instance is associated with an instance
of Django's Library. So when you register or unregister a component
to/from a component registry, behind the scenes the registry
automatically adds/removes the component's template tag to/from
the Library.
```django
{% component "my_comp" key=value %}
{% endcomponent %}
```
The Library instance can be set at instantiation. If omitted, then
the default Library instance from django_components is used. The
Library instance can be accessed under `library` attribute.
To enable a component to be used in a template, the component must be registered with a component registry.
Example:
When you register a component to a registry, behind the scenes the registry
automatically adds the component's template tag (e.g. `{% component %}` to
the [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout).
And the opposite happens when you unregister a component - the tag is removed.
```py
See [Registering components](../../concepts/advanced/component_registry).
Args:
library (Library, optional): Django\
[`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout)\
associated with this registry. If omitted, the default Library instance from django_components is used.
settings (Union[RegistrySettings, Callable[[ComponentRegistry], RegistrySettings]], optional): Configure\
how the components registered with this registry will behave when rendered.\
See [`RegistrySettings`](../api#django_components.RegistrySettings). Can be either\
a static value or a callable that returns the settings. If omitted, the settings from\
[`COMPONENTS`](../settings#django_components.app_settings.ComponentsSettings) are used.
**Notes:**
- The default registry is available as [`django_components.registry`](../api#django_components.registry).
- The default registry is used when registering components with [`@register`](../api#django_components.register)
decorator.
**Example:**
```python
# Use with default Library
registry = ComponentRegistry()
@ -81,6 +185,33 @@ class ComponentRegistry:
registry.clear()
registry.get()
```
# Using registry to share components
You can use component registry for isolating or "packaging" components:
1. Create new instance of `ComponentRegistry` and Library:
```django
my_comps = Library()
my_comps_reg = ComponentRegistry(library=my_comps)
```
2. Register components to the registry:
```django
my_comps_reg.register("my_button", ButtonComponent)
my_comps_reg.register("my_card", CardComponent)
```
3. In your target project, load the Library associated with the registry:
```django
{% load my_comps %}
```
4. Use the registered components in your templates:
```django
{% component "button" %}
{% endcomponent %}
```
"""
def __init__(
@ -99,7 +230,8 @@ class ComponentRegistry:
@property
def library(self) -> Library:
"""
The template tag library with which the component registry is associated.
The template tag [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout)
that is associated with the registry.
"""
# Lazily use the default library if none was passed
if self._library is not None:
@ -118,6 +250,9 @@ class ComponentRegistry:
@property
def settings(self) -> InternalRegistrySettings:
"""
[Registry settings](../api#django_components.RegistrySettings) configured for this registry.
"""
# This is run on subsequent calls
if self._settings is not None:
# NOTE: Registry's settings can be a function, so we always take
@ -136,10 +271,16 @@ class ComponentRegistry:
else:
settings_input = self._settings_input
if settings_input:
context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR
tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER
else:
context_behavior = None
tag_formatter = None
return InternalRegistrySettings(
CONTEXT_BEHAVIOR=(settings_input and settings_input.CONTEXT_BEHAVIOR)
or app_settings.CONTEXT_BEHAVIOR,
TAG_FORMATTER=(settings_input and settings_input.TAG_FORMATTER) or app_settings.TAG_FORMATTER,
context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value,
tag_formatter=tag_formatter or app_settings.TAG_FORMATTER,
)
self._settings = get_settings
@ -149,19 +290,27 @@ class ComponentRegistry:
def register(self, name: str, component: Type["Component"]) -> None:
"""
Register a component with this registry under the given name.
Register a [`Component`](../api#django_components.Component) class
with this registry under the given name.
A component MUST be registered before it can be used in a template such as:
```django
{% component "my_comp" %}{% endcomponent %}
{% component "my_comp" %}
{% endcomponent %}
```
Raises `AlreadyRegistered` if a different component was already registered
under the same name.
Args:
name (str): The name under which the component will be registered. Required.
component (Type[Component]): The component class to register. Required.
Example:
**Raises:**
```py
- [`AlreadyRegistered`](../exceptions#django_components.AlreadyRegistered)
if a different component was already registered under the same name.
**Example:**
```python
registry.register("button", ButtonComponent)
```
"""
@ -182,19 +331,22 @@ class ComponentRegistry:
def unregister(self, name: str) -> None:
"""
Unlinks a previously-registered component from the registry under the given name.
Unregister the [`Component`](../api#django_components.Component) class
that was registered under the given name.
Once a component is unregistered, it CANNOT be used in a template anymore.
Following would raise an error:
```django
{% component "my_comp" %}{% endcomponent %}
```
Once a component is unregistered, it is no longer available in the templates.
Raises `NotRegistered` if the given name is not registered.
Args:
name (str): The name under which the component is registered. Required.
Example:
**Raises:**
```py
- [`NotRegistered`](../exceptions#django_components.NotRegistered)
if the given name is not registered.
**Example:**
```python
# First register component
registry.register("button", ButtonComponent)
# Then unregister
@ -227,13 +379,23 @@ class ComponentRegistry:
def get(self, name: str) -> Type["Component"]:
"""
Retrieve a component class registered under the given name.
Retrieve a [`Component`](../api#django_components.Component)
class registered under the given name.
Raises `NotRegistered` if the given name is not registered.
Args:
name (str): The name under which the component was registered. Required.
Example:
Returns:
Type[Component]: The component class registered under the given name.
```py
**Raises:**
- [`NotRegistered`](../exceptions#django_components.NotRegistered)
if the given name is not registered.
**Example:**
```python
# First register component
registry.register("button", ButtonComponent)
# Then get
@ -248,11 +410,14 @@ class ComponentRegistry:
def all(self) -> Dict[str, Type["Component"]]:
"""
Retrieve all registered component classes.
Retrieve all registered [`Component`](../api#django_components.Component) classes.
Example:
Returns:
Dict[str, Type[Component]]: A dictionary of component names to component classes
```py
**Example:**
```python
# First register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
@ -273,7 +438,7 @@ class ComponentRegistry:
Example:
```py
```python
# First register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
@ -308,19 +473,25 @@ class ComponentRegistry:
# This variable represents the global component registry
registry: ComponentRegistry = ComponentRegistry()
"""
The default and global component registry. Use this instance to directly
register or remove components:
The default and global [component registry](./#django_components.ComponentRegistry).
Use this instance to directly register or remove components:
```py
See [Registering components](../../concepts/advanced/component_registry).
```python
# Register components
registry.register("button", ButtonComponent)
registry.register("card", CardComponent)
# Get single
registry.get("button")
# Get all
registry.all()
# Unregister single
registry.unregister("button")
# Unregister all
registry.clear()
```
@ -330,23 +501,43 @@ registry.clear()
_the_registry = registry
def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[[_TComp], _TComp]:
def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callable[
[Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]],
Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"],
]:
"""
Class decorator to register a component.
Class decorator for registering a [component](./#django_components.Component)
to a [component registry](./#django_components.ComponentRegistry).
Usage:
See [Registering components](../../concepts/advanced/component_registry).
Args:
name (str): Registered name. This is the name by which the component will be accessed\
from within a template when using the [`{% component %}`](../template_tags#component) tag. Required.
registry (ComponentRegistry, optional): Specify the [registry](./#django_components.ComponentRegistry)\
to which to register this component. If omitted, component is registered to the default registry.
Raises:
AlreadyRegistered: If there is already a component registered under the same name.
**Examples**:
```python
from django_components import Component, register
```py
@register("my_component")
class MyComponent(Component):
...
```
Optionally specify which `ComponentRegistry` the component should be registered to by
setting the `registry` kwarg:
Specifing [`ComponentRegistry`](./#django_components.ComponentRegistry) the component
should be registered to by setting the `registry` kwarg:
```py
my_lib = django.template.Library()
```python
from django.template import Library
from django_components import Component, ComponentRegistry, register
my_lib = Library()
my_reg = ComponentRegistry(library=my_lib)
@register("my_component", registry=my_reg)
@ -357,7 +548,9 @@ def register(name: str, registry: Optional[ComponentRegistry] = None) -> Callabl
if registry is None:
registry = _the_registry
def decorator(component: _TComp) -> _TComp:
def decorator(
component: Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"],
) -> Type["Component[ArgsType, KwargsType, SlotsType, DataType, JsDataType, CssDataType]"]:
registry.register(name=name, component=component)
return component

View file

@ -1,3 +1,4 @@
# flake8: noqa F401
# NOTE: Components exported here are documented in
from django_components.components.dynamic import DynamicComponent
from django_components.components.dynamic import DynamicComponent as DynamicComponent
__all__ = ["DynamicComponent"]

View file

@ -7,14 +7,82 @@ from django_components.component_registry import all_registries
class DynamicComponent(Component):
"""
Dynamic component - This component takes inputs and renders the outputs depending on the
`is` and `registry` arguments.
This component is given a registered name or a reference to another component,
and behaves as if the other component was in its place.
- `is` - required - The component class or registered name of the component that will be
rendered in this place.
The args, kwargs, and slot fills are all passed down to the underlying component.
- `registry` - optional - Specify the registry to search for the registered name. If omitted,
all registries are searched.
Args:
is (str | Type[Component]): Component that should be rendered. Either a registered name of a component,
or a [Component](../api#django_components.Component) class directly. Required.
registry (ComponentRegistry, optional): Specify the [registry](../api#django_components.ComponentRegistry)\
to search for the registered name. If omitted, all registries are searched until the first match.
*args: Additional data passed to the component.
**kwargs: Additional data passed to the component.
**Slots:**
* Any slots, depending on the actual component.
**Examples:**
Django
```django
{% component "dynamic" is=table_comp data=table_data headers=table_headers %}
{% fill "pagination" %}
{% component "pagination" / %}
{% endfill %}
{% endcomponent %}
```
Python
```py
from django_components import DynamicComponent
DynamicComponent.render(
kwargs={
"is": table_comp,
"data": table_data,
"headers": table_headers,
},
slots={
"pagination": PaginationComponent.render(
render_dependencies=False,
),
},
)
```
# Use cases
Dynamic components are suitable if you are writing something like a form component. You may design
it such that users give you a list of input types, and you render components depending on the input types.
While you could handle this with a series of if / else statements, that's not an extensible approach.
Instead, you can use the dynamic component in place of normal components.
# Component name
By default, the dynamic component is registered under the name `"dynamic"`. In case of a conflict,
you can set the
[`COMPONENTS.dynamic_component_name`](../settings#django_components.app_settings.ComponentsSettings.dynamic_component_name)
setting to change the name used for the dynamic components.
```py
# settings.py
COMPONENTS = ComponentsSettings(
dynamic_component_name="my_dynamic",
)
```
After which you will be able to use the dynamic component with the new name:
```django
{% component "my_dynamic" is=table_comp data=table_data headers=table_headers %}
{% fill "pagination" %}
{% component "pagination" / %}
{% endfill %}
{% endcomponent %}
```
"""
_is_dynamic_component = True
@ -32,27 +100,30 @@ class DynamicComponent(Component):
comp_class = self._resolve_component(comp_name_or_class, registry)
# NOTE: Slots are passed at component instantiation
comp = comp_class(
registered_name=self.registered_name,
component_id=self.component_id,
outer_context=self.outer_context,
fill_content=self.fill_content,
registry=self.registry,
)
output = comp.render(
context=self.input.context,
args=args,
kwargs=kwargs,
escape_slots_content=self.input.escape_slots_content,
slots=self.input.slots,
# NOTE: Since we're accessing slots as `self.input.slots`, the content of slot functions
# was already escaped (if set so).
escape_slots_content=False,
type=self.input.type,
render_dependencies=self.input.render_dependencies,
)
return {
"output": output,
}
template: types.django_html = """
{{ output }}
"""
template: types.django_html = """{{ output }}"""
def _resolve_component(
self,

View file

@ -10,34 +10,19 @@ from typing import Any, Dict, Optional
from django.template import Context, TemplateSyntaxError
from django_components.utils import find_last_index
from django_components.util.misc import find_last_index
_FILLED_SLOTS_CONTENT_CONTEXT_KEY = "_DJANGO_COMPONENTS_FILLED_SLOTS"
_COMPONENT_SLOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_COMPONENT_SLOT_CTX"
_ROOT_CTX_CONTEXT_KEY = "_DJANGO_COMPONENTS_ROOT_CTX"
_REGISTRY_CONTEXT_KEY = "_DJANGO_COMPONENTS_REGISTRY"
_CURRENT_COMP_CONTEXT_KEY = "_DJANGO_COMPONENTS_CURRENT_COMP"
_INJECT_CONTEXT_KEY_PREFIX = "_DJANGO_COMPONENTS_INJECT__"
def prepare_context(
context: Context,
component_id: str,
) -> None:
"""Initialize the internal context state."""
# Initialize mapping dicts within this rendering run.
# This is shared across the whole render chain, thus we set it only once.
if _FILLED_SLOTS_CONTENT_CONTEXT_KEY not in context:
context[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = {}
set_component_id(context, component_id)
def make_isolated_context_copy(context: Context) -> Context:
context_copy = context.new()
copy_forloop_context(context, context_copy)
# Pass through our internal keys
context_copy[_FILLED_SLOTS_CONTENT_CONTEXT_KEY] = context.get(_FILLED_SLOTS_CONTENT_CONTEXT_KEY, {})
context_copy[_REGISTRY_CONTEXT_KEY] = context.get(_REGISTRY_CONTEXT_KEY, None)
if _ROOT_CTX_CONTEXT_KEY in context:
context_copy[_ROOT_CTX_CONTEXT_KEY] = context[_ROOT_CTX_CONTEXT_KEY]
@ -51,14 +36,6 @@ def make_isolated_context_copy(context: Context) -> Context:
return context_copy
def set_component_id(context: Context, component_id: str) -> None:
"""
We use the Context object to pass down info on inside of which component
we are currently rendering.
"""
context[_CURRENT_COMP_CONTEXT_KEY] = component_id
def copy_forloop_context(from_context: Context, to_context: Context) -> None:
"""Forward the info about the current loop"""
# Note that the ForNode (which implements for loop behavior) does not

View file

@ -0,0 +1,793 @@
"""All code related to management of component dependencies (JS and CSS scripts)"""
import json
import re
import sys
from abc import ABC, abstractmethod
from functools import lru_cache
from hashlib import md5
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
List,
Literal,
NamedTuple,
Optional,
Set,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from weakref import WeakValueDictionary
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.forms import Media
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseNotFound, StreamingHttpResponse
from django.http.response import HttpResponseBase
from django.templatetags.static import static
from django.urls import path, reverse
from django.utils.decorators import sync_and_async_middleware
from django.utils.safestring import SafeString, mark_safe
from selectolax.lexbor import LexborHTMLParser
import django_components.types as types
from django_components.util.html import parse_document_or_nodes, parse_multiroot_html, parse_node
from django_components.util.misc import escape_js_string_literal, get_import_path
if TYPE_CHECKING:
from django_components.component import Component
ScriptType = Literal["css", "js"]
RenderType = Literal["document", "fragment"]
#########################################################
# 1. Cache the inlined component JS and CSS scripts,
# so they can be referenced and retrieved later via
# an ID.
#########################################################
class ComponentMediaCacheABC(ABC):
@abstractmethod
def get(self, key: str) -> Optional[str]: ... # noqa: #704
@abstractmethod
def has(self, key: str) -> bool: ... # noqa: #704
@abstractmethod
def set(self, key: str, value: str) -> None: ... # noqa: #704
class InMemoryComponentMediaCache(ComponentMediaCacheABC):
def __init__(self) -> None:
self._data: Dict[str, str] = {}
def get(self, key: str) -> Optional[str]:
return self._data.get(key, None)
def has(self, key: str) -> bool:
return key in self._data
def set(self, key: str, value: str) -> None:
self._data[key] = value
comp_media_cache = InMemoryComponentMediaCache()
# NOTE: Initially, we fetched components by their registered name, but that didn't work
# for multiple registries and unregistered components.
#
# To have unique identifiers that works across registries, we rely
# on component class' module import path (e.g. `path.to.my.MyComponent`).
#
# But we also don't want to expose the module import paths to the outside world, as
# that information could be potentially exploited. So, instead, each component is
# associated with a hash that's derived from its module import path, ensuring uniqueness,
# consistency and privacy.
#
# E.g. `path.to.my.secret.MyComponent` -> `MyComponent_ab01f32`
#
# The associations are defined as WeakValue map, so deleted components can be garbage
# collected and automatically deleted from the dict.
if sys.version_info < (3, 9):
comp_hash_mapping: WeakValueDictionary = WeakValueDictionary()
else:
comp_hash_mapping: WeakValueDictionary[str, Type["Component"]] = WeakValueDictionary()
# Convert Component class to something like `TableComp_a91d03`
@lru_cache(None)
def _hash_comp_cls(comp_cls: Type["Component"]) -> str:
full_name = get_import_path(comp_cls)
comp_cls_hash = md5(full_name.encode()).hexdigest()[0:6]
return comp_cls.__name__ + "_" + comp_cls_hash
def _gen_cache_key(
comp_cls_hash: str,
script_type: ScriptType,
) -> str:
return f"__components:{comp_cls_hash}:{script_type}"
def _is_script_in_cache(
comp_cls: Type["Component"],
script_type: ScriptType,
) -> bool:
comp_cls_hash = _hash_comp_cls(comp_cls)
cache_key = _gen_cache_key(comp_cls_hash, script_type)
return comp_media_cache.has(cache_key)
def _cache_script(
comp_cls: Type["Component"],
script: str,
script_type: ScriptType,
) -> None:
"""
Given a component and it's inlined JS or CSS, store the JS/CSS in a cache,
so it can be retrieved via URL endpoint.
"""
comp_cls_hash = _hash_comp_cls(comp_cls)
# E.g. `__components:MyButton:js:df7c6d10`
if script_type in ("js", "css"):
cache_key = _gen_cache_key(comp_cls_hash, script_type)
else:
raise ValueError(f"Unexpected script_type '{script_type}'")
# NOTE: By setting the script in the cache, we will be able to retrieve it
# via the endpoint, e.g. when we make a request to `/components/cache/MyComp_ab0c2d.js`.
comp_media_cache.set(cache_key, script.strip())
def cache_inlined_js(comp_cls: Type["Component"], content: str) -> None:
if not _is_nonempty_str(comp_cls.js):
return
# Prepare the script that's common to all instances of the same component
# E.g. `my_table.js`
if not _is_script_in_cache(comp_cls, "js"):
_cache_script(
comp_cls=comp_cls,
script=content,
script_type="js",
)
def cache_inlined_css(comp_cls: Type["Component"], content: str) -> None:
if not _is_nonempty_str(comp_cls.js):
return
# Prepare the script that's common to all instances of the same component
if not _is_script_in_cache(comp_cls, "css"):
# E.g. `my_table.css`
_cache_script(
comp_cls=comp_cls,
script=content,
script_type="css",
)
#########################################################
# 2. Modify the HTML to use the same IDs defined in previous
# step for the inlined CSS and JS scripts, so the scripts
# can be applied to the correct HTML elements. And embed
# component + JS/CSS relationships as HTML comments.
#########################################################
class Dependencies(NamedTuple):
# NOTE: We pass around the component CLASS, so the dependencies logic is not
# dependent on ComponentRegistries
component_cls: Type["Component"]
component_id: str
def _insert_component_comment(
content: str,
deps: Dependencies,
) -> str:
"""
Given some textual content, prepend it with a short string that
will be used by the ComponentDependencyMiddleware to collect all
declared JS / CSS scripts.
"""
# Add components to the cache
comp_cls_hash = _hash_comp_cls(deps.component_cls)
comp_hash_mapping[comp_cls_hash] = deps.component_cls
data = f"{comp_cls_hash},{deps.component_id}"
# NOTE: It's important that we put the comment BEFORE the content, so we can
# use the order of comments to evaluate components' instance JS code in the correct order.
output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data)) + content
return output
# Anything and everything that needs to be done with a Component's HTML
# script in order to support running JS and CSS per-instance.
def postprocess_component_html(
component_cls: Type["Component"],
component_id: str,
html_content: str,
type: RenderType,
render_dependencies: bool,
) -> str:
# NOTE: To better understand the next section, consider this:
#
# We define and cache the component's JS and CSS at the same time as
# when we render the HTML. However, the resulting HTML MAY OR MAY NOT
# be used in another component.
#
# IF the component's HTML IS used in another component, and the other
# component want to render the JS or CSS dependencies (e.g. inside <head>),
# then it's only at that point when we want to access the data about
# which JS and CSS scripts is the component's HTML associated with.
#
# This happens AFTER the rendering context, so there's no Context to rely on.
#
# Hence, we store the info about associated JS and CSS right in the HTML itself.
# As an HTML comment `<!-- -->`. Thus, the inner component can be used as many times
# and in different components, and they will all know to fetch also JS and CSS of the
# inner components.
# Mark the generated HTML so that we will know which JS and CSS
# scripts are associated with it.
output = _insert_component_comment(
html_content,
Dependencies(
component_cls=component_cls,
component_id=component_id,
),
)
if render_dependencies:
output = _render_dependencies(output, type)
return output
#########################################################
# 3. Given a FINAL HTML composed of MANY components,
# process all the HTML dependency comments (created in
# previous step), obtaining ALL JS and CSS scripts
# required by this HTML document. And post-process them,
# so the scripts are either inlined into the HTML, or
# fetched when the HTML is loaded in the browser.
#########################################################
TContent = TypeVar("TContent", bound=Union[bytes, str])
CSS_DEPENDENCY_PLACEHOLDER = '<link name="CSS_PLACEHOLDER">'
JS_DEPENDENCY_PLACEHOLDER = '<script name="JS_PLACEHOLDER"></script>'
CSS_PLACEHOLDER_BYTES = bytes(CSS_DEPENDENCY_PLACEHOLDER, encoding="utf-8")
JS_PLACEHOLDER_BYTES = bytes(JS_DEPENDENCY_PLACEHOLDER, encoding="utf-8")
COMPONENT_DEPS_COMMENT = "<!-- _RENDERED {data} -->"
# E.g. `<!-- _RENDERED table,123 -->`
COMPONENT_COMMENT_REGEX = re.compile(rb"<!-- _RENDERED (?P<data>[\w\-,/]+?) -->")
# E.g. `table,123`
SCRIPT_NAME_REGEX = re.compile(rb"^(?P<comp_cls_hash>[\w\-\./]+?),(?P<id>[\w]+?)$")
PLACEHOLDER_REGEX = re.compile(
r"{css_placeholder}|{js_placeholder}".format(
css_placeholder=CSS_DEPENDENCY_PLACEHOLDER,
js_placeholder=JS_DEPENDENCY_PLACEHOLDER,
).encode()
)
def render_dependencies(content: TContent, type: RenderType = "document") -> TContent:
"""
Given a string that contains parts that were rendered by components,
this function inserts all used JS and CSS.
By default, the string is parsed as an HTML and:
- CSS is inserted at the end of `<head>` (if present)
- JS is inserted at the end of `<body>` (if present)
If you used `{% component_js_dependencies %}` or `{% component_css_dependencies %}`,
then the JS and CSS will be inserted only at these locations.
Example:
```python
def my_view(request):
template = Template('''
{% load components %}
<!doctype html>
<html>
<head></head>
<body>
<h1>{{ table_name }}</h1>
{% component "table" name=table_name / %}
</body>
</html>
''')
html = template.render(
Context({
"table_name": request.GET["name"],
})
)
# This inserts components' JS and CSS
processed_html = render_dependencies(html)
return HttpResponse(processed_html)
```
"""
is_safestring = isinstance(content, SafeString)
if isinstance(content, str):
content_ = content.encode()
else:
content_ = cast(bytes, content)
content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type)
# Replace the placeholders with the actual content
did_find_js_placeholder = False
did_find_css_placeholder = False
def on_replace_match(match: "re.Match[bytes]") -> bytes:
nonlocal did_find_css_placeholder
nonlocal did_find_js_placeholder
if match[0] == CSS_PLACEHOLDER_BYTES:
replacement = css_dependencies
did_find_css_placeholder = True
elif match[0] == JS_PLACEHOLDER_BYTES:
replacement = js_dependencies
did_find_js_placeholder = True
else:
raise RuntimeError(
"Unexpected error: Regex for component dependencies processing"
f" matched unknown string '{match[0].decode()}'"
)
return replacement
content_ = PLACEHOLDER_REGEX.sub(on_replace_match, content_)
# By default, if user didn't specify any `{% component_dependencies %}`,
# then try to insert the JS scripts at the end of <body> and CSS sheets at the end
# of <head>
if type == "document" and (not did_find_js_placeholder or not did_find_css_placeholder):
tree = parse_document_or_nodes(content_.decode())
if isinstance(tree, LexborHTMLParser):
did_modify_html = False
if not did_find_css_placeholder and tree.head:
css_elems = parse_multiroot_html(css_dependencies.decode())
for css_elem in css_elems:
tree.head.insert_child(css_elem) # type: ignore # TODO: Update to selectolax 0.3.25
did_modify_html = True
if not did_find_js_placeholder and tree.body:
js_elems = parse_multiroot_html(js_dependencies.decode())
for js_elem in js_elems:
tree.body.insert_child(js_elem) # type: ignore # TODO: Update to selectolax 0.3.25
did_modify_html = True
transformed = cast(str, tree.html)
if did_modify_html:
content_ = transformed.encode()
# Return the same type as we were given
output = content_.decode() if isinstance(content, str) else content_
output = mark_safe(output) if is_safestring else output
return cast(TContent, output)
# Renamed so we can access use this function where there's kwarg of the same name
_render_dependencies = render_dependencies
# Overview of this function:
# 1. We extract all HTML comments like `<!-- _RENDERED table_10bac31,1234-->`.
# 2. We look up the corresponding component classes
# 3. For each component class we get the component's inlined JS and CSS,
# and the JS and CSS from `Media.js/css`
# 4. We add our client-side JS logic into the mix (`django_components/django_components.min.js`)
# - For fragments, we would skip this step.
# 5. For all the above JS and CSS, we figure out which JS / CSS needs to be inserted directly
# into the HTML, and which can be loaded with the client-side manager.
# - Components' inlined JS is inserted directly into the HTML as `<script> ... <script>`,
# to avoid having to issues 10s of requests for each component separately.
# - Components' inlined CSS is inserted directly into the HTML as `<style> ... <style>`,
# to avoid a [flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content)
# that would occur if we had to load the CSS via JS request.
# - For CSS from `Media.css` we insert that as `<link href="...">` HTML tags, also to avoid
# the flash of unstyled content
# - For JS from `Media.js`, we let the client-side manager load that, so that, even if
# multiple components link to the same JS script in their `Media.js`, the linked JS
# will be fetched and executed only once.
# 6. And lastly, we generate a JS script that will load / mark as loaded the JS and CSS
# as categorized in previous step.
def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes, bytes, bytes]:
"""
Process a textual content that may include metadata on rendered components.
The metadata has format like this
`<!-- _RENDERED component_name,component_id -->`
E.g.
`<!-- _RENDERED table_10bac31,123 -->`
"""
# Extract all matched instances of `<!-- _RENDERED ... -->` while also removing them from the text
all_parts: List[bytes] = list()
def on_replace_match(match: "re.Match[bytes]") -> bytes:
all_parts.append(match.group("data"))
return b""
content = COMPONENT_COMMENT_REGEX.sub(on_replace_match, content)
comp_hashes: Set[str] = set()
# Process individual parts. Each part is like a CSV row of `name,id`.
# E.g. something like this:
# `table_10bac31,1234`
for part in all_parts:
part_match = SCRIPT_NAME_REGEX.match(part)
if not part_match:
raise RuntimeError("Malformed dependencies data")
comp_cls_hash = part_match.group("comp_cls_hash").decode("utf-8")
comp_hashes.add(comp_cls_hash)
(
to_load_component_js_urls,
to_load_component_css_urls,
inlined_component_js_tags,
inlined_component_css_tags,
loaded_component_js_urls,
loaded_component_css_urls,
) = _prepare_tags_and_urls(comp_hashes, type)
def get_component_media(comp_cls_hash: str) -> Media:
comp_cls = comp_hash_mapping[comp_cls_hash]
# NOTE: We instantiate the component classes so the `Media` are processed into `media`
comp = comp_cls()
return comp.media
all_medias = [
# JS / CSS files from Component.Media.js/css.
*[get_component_media(comp_cls_hash) for comp_cls_hash in comp_hashes],
# All the inlined scripts that we plan to fetch / load
Media(
js=to_load_component_js_urls,
css={"all": to_load_component_css_urls},
),
]
# Once we have ALL JS and CSS URLs that we want to fetch, we can convert them to
# <script> and <link> tags. Note that this is done by the user-provided Media classes.
to_load_css_tags = [tag for media in all_medias for tag in media.render_css()]
to_load_js_tags = [tag for media in all_medias for tag in media.render_js()]
# Postprocess all <script> and <link> tags to 1) dedupe, and 2) extract URLs.
# For the deduplication, if multiple components link to the same JS/CSS, but they
# render the <script> or <link> tag differently, we go with the first tag that we come across.
to_load_css_tags, to_load_css_urls = _postprocess_media_tags("css", to_load_css_tags)
to_load_js_tags, to_load_js_urls = _postprocess_media_tags("js", to_load_js_tags)
loaded_css_urls = sorted(
[
*loaded_component_css_urls,
# NOTE: Unlike JS, the initial CSS is loaded outside of the dependency
# manager, and only marked as loaded, to avoid a flash of unstyled content.
*to_load_css_urls,
]
)
loaded_js_urls = sorted(loaded_component_js_urls)
exec_script = _gen_exec_script(
to_load_js_tags=to_load_js_tags,
to_load_css_tags=to_load_css_tags,
loaded_js_urls=loaded_js_urls,
loaded_css_urls=loaded_css_urls,
)
# Core scripts without which the rest wouldn't work
core_script_tags = Media(
js=[static("django_components/django_components.min.js")],
).render_js()
final_script_tags = b"".join(
[
*[tag.encode("utf-8") for tag in core_script_tags],
*[tag.encode("utf-8") for tag in inlined_component_js_tags],
exec_script.encode("utf-8"),
]
)
final_css_tags = b"".join(
[
*[tag.encode("utf-8") for tag in inlined_component_css_tags],
# NOTE: Unlike JS, the initial CSS is loaded outside of the dependency
# manager, and only marked as loaded, to avoid a flash of unstyled content.
*[tag.encode("utf-8") for tag in to_load_css_tags],
]
)
return content, final_script_tags, final_css_tags
def _is_nonempty_str(txt: Optional[str]) -> bool:
return txt is not None and bool(txt.strip())
# Detect duplicates by URLs, extract URLs, and sort by URLs
def _postprocess_media_tags(
script_type: ScriptType,
tags: List[str],
) -> Tuple[List[str], List[str]]:
deduped_urls: Set[str] = set()
tags_by_url: Dict[str, str] = {}
for tag in tags:
node = parse_node(tag)
# <script src="..."> vs <link href="...">
attr = "src" if script_type == "js" else "href"
maybe_url = node.attrs.get(attr, None)
if not _is_nonempty_str(maybe_url):
raise RuntimeError(
f"One of entries for `Component.Media.{script_type}` media is missing "
f"value for attribute '{attr}'. Got:\n{tag}"
)
# Skip duplicates
if maybe_url in deduped_urls:
continue
url = cast(str, maybe_url)
tags_by_url[url] = tag
deduped_urls.add(url)
urls = sorted(deduped_urls)
tags = [tags_by_url[url] for url in urls]
return tags, urls
def _prepare_tags_and_urls(
data: Iterable[str],
type: RenderType,
) -> Tuple[List[str], List[str], List[str], List[str], List[str], List[str]]:
to_load_js_urls: List[str] = []
to_load_css_urls: List[str] = []
inlined_js_tags: List[str] = []
inlined_css_tags: List[str] = []
loaded_js_urls: List[str] = []
loaded_css_urls: List[str] = []
# When `type="document"`, we insert the actual <script> and <style> tags into the HTML.
# But even in that case we still need to call `Components.manager.markScriptLoaded`,
# so the client knows NOT to fetch them again.
# So in that case we populate both `inlined` and `loaded` lists
for comp_cls_hash in data:
# NOTE: When CSS is scoped, then EVERY component instance will have different
# copy of the style, because each copy will have component's ID embedded.
# So, in that case we inline the style into the HTML (See `_link_dependencies_with_component_html`),
# which means that we are NOT going to load / inline it again.
comp_cls = comp_hash_mapping[comp_cls_hash]
if type == "document":
# NOTE: Skip fetching of inlined JS/CSS if it's not defined or empty for given component
if _is_nonempty_str(comp_cls.js):
inlined_js_tags.append(_get_script("js", comp_cls, type="tag"))
loaded_js_urls.append(_get_script("js", comp_cls, type="url"))
if _is_nonempty_str(comp_cls.css):
inlined_css_tags.append(_get_script("css", comp_cls, type="tag"))
loaded_css_urls.append(_get_script("css", comp_cls, type="url"))
# When NOT a document (AKA is a fragment), then scripts are NOT inserted into
# the HTML, and instead we fetch and load them all via our JS dependency manager.
else:
if _is_nonempty_str(comp_cls.js):
to_load_js_urls.append(_get_script("js", comp_cls, type="url"))
if _is_nonempty_str(comp_cls.css):
loaded_css_urls.append(_get_script("css", comp_cls, type="url"))
return (
to_load_js_urls,
to_load_css_urls,
inlined_js_tags,
inlined_css_tags,
loaded_js_urls,
loaded_css_urls,
)
def _get_script(
script_type: ScriptType,
comp_cls: Type["Component"],
type: Literal["url", "tag"],
) -> Union[str, SafeString]:
comp_cls_hash = _hash_comp_cls(comp_cls)
if type == "url":
# NOTE: To make sure that Media object won't modify the URLs, we need to
# resolve the full path (`/abc/def/etc`), not just the file name.
script = reverse(
CACHE_ENDPOINT_NAME,
kwargs={
"comp_cls_hash": comp_cls_hash,
"script_type": script_type,
},
)
else:
cache_key = _gen_cache_key(comp_cls_hash, script_type)
script = comp_media_cache.get(cache_key)
if script_type == "js":
script = mark_safe(f"<script>{_escape_js(script)}</script>")
elif script_type == "css":
script = mark_safe(f"<style>{script}</style>")
return script
def _gen_exec_script(
to_load_js_tags: List[str],
to_load_css_tags: List[str],
loaded_js_urls: List[str],
loaded_css_urls: List[str],
) -> str:
# Generate JS expression like so:
# ```js
# Promise.all([
# Components.manager.loadScript("js", '<script src="/abc/def1">...</script>'),
# Components.manager.loadScript("js", '<script src="/abc/def2">...</script>'),
# Components.manager.loadScript("css", '<link href="/abc/def3">'),
# ]);
# ```
#
# or
#
# ```js
# Components.manager.markScriptLoaded("css", "/abc/def1.css"),
# Components.manager.markScriptLoaded("css", "/abc/def2.css"),
# Components.manager.markScriptLoaded("js", "/abc/def3.js"),
# ```
#
# NOTE: It would be better to pass only the URL itself for `loadScript`, instead of a whole tag.
# But because we allow users to specify the Media class, and thus users can
# configure how the `<link>` or `<script>` tags are rendered, we need pass the whole tag.
#
# NOTE 2: We must NOT await for the Promises, otherwise we create a deadlock
# where the script loaded with `loadScript` (loadee) is inserted AFTER the script with `loadScript` (loader).
# But the loader will NOT finish, because it's waiting for loadee, which cannot start before loader ends.
escaped_to_load_js_tags = [_escape_js(tag, eval=False) for tag in to_load_js_tags]
escaped_to_load_css_tags = [_escape_js(tag, eval=False) for tag in to_load_css_tags]
# Make JS array whose items are interpreted as JS statements (e.g. functions)
def js_arr(lst: List) -> str:
return "[" + ", ".join(lst) + "]"
exec_script: types.js = f"""(() => {{
const loadedJsScripts = {json.dumps(loaded_js_urls)};
const loadedCssScripts = {json.dumps(loaded_css_urls)};
const toLoadJsScripts = {js_arr(escaped_to_load_js_tags)};
const toLoadCssScripts = {js_arr(escaped_to_load_css_tags)};
loadedJsScripts.forEach((s) => Components.manager.markScriptLoaded("js", s));
loadedCssScripts.forEach((s) => Components.manager.markScriptLoaded("css", s));
Promise.all(
toLoadJsScripts.map((s) => Components.manager.loadScript("js", s))
).catch(console.error);
Promise.all(
toLoadCssScripts.map((s) => Components.manager.loadScript("css", s))
).catch(console.error);
}})();
"""
exec_script = f"<script>{_escape_js(exec_script)}</script>"
return exec_script
def _escape_js(js: str, eval: bool = True) -> str:
escaped_js = escape_js_string_literal(js)
# `unescapeJs` is the function we call in the browser to parse the escaped JS
escaped_js = f"Components.unescapeJs(`{escaped_js}`)"
return f"eval({escaped_js})" if eval else escaped_js
#########################################################
# 4. Endpoints for fetching the JS / CSS scripts from within
# the browser, as defined from previous steps.
#########################################################
CACHE_ENDPOINT_NAME = "components_cached_script"
_CONTENT_TYPES = {"js": "text/javascript", "css": "text/css"}
def _get_content_types(script_type: ScriptType) -> str:
if script_type not in _CONTENT_TYPES:
raise ValueError(f"Unknown script_type '{script_type}'")
return _CONTENT_TYPES[script_type]
def cached_script_view(
req: HttpRequest,
comp_cls_hash: str,
script_type: ScriptType,
) -> HttpResponse:
if req.method != "GET":
return HttpResponseNotAllowed(["GET"])
# Otherwise check if the file is among the dynamically generated files in the cache
cache_key = _gen_cache_key(comp_cls_hash, script_type)
script = comp_media_cache.get(cache_key)
if script is None:
return HttpResponseNotFound()
content_type = _get_content_types(script_type)
return HttpResponse(content=script, content_type=content_type)
urlpatterns = [
# E.g. `/components/cache/table.js/`
path("cache/<str:comp_cls_hash>.<str:script_type>/", cached_script_view, name=CACHE_ENDPOINT_NAME),
]
#########################################################
# 5. Middleware that automatically applies the dependency-
# aggregating logic on all HTML responses.
#########################################################
@sync_and_async_middleware
class ComponentDependencyMiddleware:
"""
Middleware that inserts CSS/JS dependencies for all rendered
components at points marked with template tags.
"""
def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None:
self.get_response = get_response
# NOTE: Required to work with async
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
def __call__(self, request: HttpRequest) -> HttpResponseBase:
if iscoroutinefunction(self):
return self.__acall__(request)
response = self.get_response(request)
response = self.process_response(response)
return response
# NOTE: Required to work with async
async def __acall__(self, request: HttpRequest) -> HttpResponseBase:
response = await self.get_response(request)
response = self.process_response(response)
return response
def process_response(self, response: HttpResponse) -> HttpResponse:
if not isinstance(response, StreamingHttpResponse) and response.get("Content-Type", "").startswith(
"text/html"
):
response.content = render_dependencies(response.content, type="document")
return response

View file

@ -9,8 +9,8 @@ from django.core.files.storage import FileSystemStorage
from django.utils._os import safe_join
from django_components.app_settings import app_settings
from django_components.template_loader import get_dirs
from django_components.utils import any_regex_match, no_regex_match
from django_components.util.loader import get_component_dirs
from django_components.util.misc import any_regex_match, no_regex_match
# To keep track on which directories the finder has searched the static files.
searched_locations = []
@ -29,12 +29,12 @@ class ComponentsFileSystemFinder(BaseFinder):
Differences:
- This finder uses `COMPONENTS.dirs` setting to locate files instead of `STATICFILES_DIRS`.
- Whether a file within `COMPONENTS.dirs` is considered a STATIC file is configured
by `COMPONENTS.static_files_allowed` and `COMPONENTS.forbidden_static_files`.
by `COMPONENTS.static_files_allowed` and `COMPONENTS.static_files_forbidden`.
- If `COMPONENTS.dirs` is not set, defaults to `settings.BASE_DIR / "components"`
"""
def __init__(self, app_names: Any = None, *args: Any, **kwargs: Any) -> None:
component_dirs = [str(p) for p in get_dirs()]
component_dirs = [str(p) for p in get_component_dirs()]
# NOTE: The rest of the __init__ is the same as `django.contrib.staticfiles.finders.FileSystemFinder`,
# but using our locations instead of STATICFILES_DIRS.

View file

@ -12,11 +12,36 @@ if TYPE_CHECKING:
class TagProtectedError(Exception):
"""
The way the [`TagFormatter`](../../concepts/advanced/tag_formatter) works is that,
based on which start and end tags are used for rendering components,
the [`ComponentRegistry`](../api#django_components.ComponentRegistry) behind the scenes
[un-/registers the template tags](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#registering-the-tag)
with the associated instance of Django's
[`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#code-layout).
In other words, if I have registered a component `"table"`, and I use the shorthand
syntax:
```django
{% table ... %}
{% endtable %}
```
Then [`ComponentRegistry`](../api#django_components.ComponentRegistry)
registers the tag `table` onto the Django's Library instance.
However, that means that if we registered a component `"slot"`, then we would overwrite
the [`{% slot %}`](../template_tags#slot) tag from django_components.
Thus, this exception is raised when a component is attempted to be registered under
a forbidden name, such that it would overwrite one of django_component's own template tags.
""" # noqa: E501
pass
PROTECTED_TAGS = [
"component_dependencies",
"component_css_dependencies",
"component_js_dependencies",
"fill",

View file

@ -7,49 +7,117 @@ from django.core.management.base import BaseCommand, CommandError, CommandParser
class Command(BaseCommand):
help = "Creates a new component"
"""
### Management Command Usage
To use the command, run the following command in your terminal:
```bash
python manage.py startcomponent <name> --path <path> --js <js_filename> --css <css_filename> --template <template_filename> --force --verbose --dry-run
```
Replace `<name>`, `<path>`, `<js_filename>`, `<css_filename>`, and `<template_filename>` with your desired values.
### Management Command Examples
Here are some examples of how you can use the command:
#### Creating a Component with Default Settings
To create a component with the default settings, you only need to provide the name of the component:
```bash
python manage.py startcomponent my_component
```
This will create a new component named `my_component` in the `components` directory of your Django project. The JavaScript, CSS, and template files will be named `script.js`, `style.css`, and `template.html`, respectively.
#### Creating a Component with Custom Settings
You can also create a component with custom settings by providing additional arguments:
```bash
python manage.py startcomponent new_component --path my_components --js my_script.js --css my_style.css --template my_template.html
```
This will create a new component named `new_component` in the `my_components` directory. The JavaScript, CSS, and template files will be named `my_script.js`, `my_style.css`, and `my_template.html`, respectively.
#### Overwriting an Existing Component
If you want to overwrite an existing component, you can use the `--force` option:
```bash
python manage.py startcomponent my_component --force
```
This will overwrite the existing `my_component` if it exists.
#### Simulating Component Creation
If you want to simulate the creation of a component without actually creating any files, you can use the `--dry-run` option:
```bash
python manage.py startcomponent my_component --dry-run
```
This will simulate the creation of `my_component` without creating any files.
""" # noqa: E501
help = "Create a new django component."
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument("name", type=str, help="The name of the component to create")
parser.add_argument(
"name",
type=str,
help="The name of the component to create. This is a required argument.",
)
parser.add_argument(
"--path",
type=str,
help="The path to the components directory",
help=(
"The path to the component's directory. This is an optional argument. If not provided, "
"the command will use the `COMPONENTS.dirs` setting from your Django settings."
),
default=None,
)
parser.add_argument(
"--js",
type=str,
help="The name of the javascript file",
help="The name of the JavaScript file. This is an optional argument. The default value is `script.js`.",
default="script.js",
)
parser.add_argument(
"--css",
type=str,
help="The name of the style file",
help="The name of the CSS file. This is an optional argument. The default value is `style.css`.",
default="style.css",
)
parser.add_argument(
"--template",
type=str,
help="The name of the template file",
help="The name of the template file. This is an optional argument. The default value is `template.html`.",
default="template.html",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing files if they exist",
help="This option allows you to overwrite existing files if they exist. This is an optional argument.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print additional information during component creation",
help=(
"This option allows the command to print additional information during component "
"creation. This is an optional argument."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Simulate component creation without actually creating any files",
default=False,
help=(
"This option allows you to simulate component creation without actually creating any files. "
"This is an optional argument. The default value is `False`."
),
)
def handle(self, *args: Any, **kwargs: Any) -> None:

View file

@ -1,109 +1,4 @@
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Iterable
# These middlewares are part of public API
from django_components.dependencies import ComponentDependencyMiddleware
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.conf import settings
from django.forms import Media
from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
from django.http.response import HttpResponseBase
from django.utils.decorators import sync_and_async_middleware
from django_components.component_registry import registry
if TYPE_CHECKING:
from django_components.component import Component
RENDERED_COMPONENTS_CONTEXT_KEY = "_COMPONENT_DEPENDENCIES"
CSS_DEPENDENCY_PLACEHOLDER = '<link name="CSS_PLACEHOLDER">'
JS_DEPENDENCY_PLACEHOLDER = '<script name="JS_PLACEHOLDER"></script>'
SCRIPT_TAG_REGEX = re.compile("<script")
COMPONENT_COMMENT_REGEX = re.compile(rb"<!-- _RENDERED (?P<name>[\w\-/]+?) -->")
PLACEHOLDER_REGEX = re.compile(
rb"<!-- _RENDERED (?P<name>[\w\-/]+?) -->"
rb'|<link name="CSS_PLACEHOLDER">'
rb'|<script name="JS_PLACEHOLDER"></script>'
)
@sync_and_async_middleware
class ComponentDependencyMiddleware:
"""Middleware that inserts CSS/JS dependencies for all rendered components at points marked with template tags."""
dependency_regex = COMPONENT_COMMENT_REGEX
def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None:
self.get_response = get_response
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
def __call__(self, request: HttpRequest) -> HttpResponseBase:
if iscoroutinefunction(self):
return self.__acall__(request)
response = self.get_response(request)
response = self.process_response(response)
return response
async def __acall__(self, request: HttpRequest) -> HttpResponseBase:
response = await self.get_response(request)
response = self.process_response(response)
return response
def process_response(self, response: HttpResponse) -> HttpResponse:
if (
getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False)
and not isinstance(response, StreamingHttpResponse)
and response.get("Content-Type", "").startswith("text/html")
):
response.content = process_response_content(response.content)
return response
def process_response_content(content: bytes) -> bytes:
component_names_seen = {match.group("name") for match in COMPONENT_COMMENT_REGEX.finditer(content)}
all_components = [registry.get(name.decode("utf-8"))("") for name in component_names_seen]
all_media = join_media(all_components)
js_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_js())
css_dependencies = b"".join(media.encode("utf-8") for media in all_media.render_css())
return PLACEHOLDER_REGEX.sub(DependencyReplacer(css_dependencies, js_dependencies), content)
def add_module_attribute_to_scripts(scripts: str) -> str:
return re.sub(SCRIPT_TAG_REGEX, '<script type="module"', scripts)
class DependencyReplacer:
"""Replacer for use in re.sub that replaces the first placeholder CSS and JS
tags it encounters and removes any subsequent ones."""
CSS_PLACEHOLDER = bytes(CSS_DEPENDENCY_PLACEHOLDER, encoding="utf-8")
JS_PLACEHOLDER = bytes(JS_DEPENDENCY_PLACEHOLDER, encoding="utf-8")
def __init__(self, css_string: bytes, js_string: bytes) -> None:
self.js_string = js_string
self.css_string = css_string
def __call__(self, match: "re.Match[bytes]") -> bytes:
if match[0] == self.CSS_PLACEHOLDER:
replacement, self.css_string = self.css_string, b""
elif match[0] == self.JS_PLACEHOLDER:
replacement, self.js_string = self.js_string, b""
else:
replacement = b""
return replacement
def join_media(components: Iterable["Component"]) -> Media:
"""Return combined media object for iterable of components."""
return sum([component.media for component in components], Media())
def is_dependency_middleware_active() -> bool:
return getattr(settings, "COMPONENTS", {}).get("RENDER_DEPENDENCIES", False)
__all__ = ["ComponentDependencyMiddleware"]

View file

@ -1,12 +1,9 @@
from typing import Callable, List, NamedTuple, Optional
from typing import List, Optional
from django.template import Context, Template
from django.template.base import Node, NodeList, TextNode
from django.template.defaulttags import CommentNode
from django.template.loader_tags import ExtendsNode, IncludeNode, construct_relative_path
from django.template.base import Node, NodeList
from django_components.expression import Expression, RuntimeKwargs
from django_components.utils import gen_id
from django_components.util.misc import gen_id
class BaseNode(Node):
@ -23,99 +20,3 @@ class BaseNode(Node):
self.node_id = node_id or gen_id()
self.args = args or []
self.kwargs = kwargs or RuntimeKwargs({})
# NOTE: We consider Nodes to have content only if they have anything else
# beside whitespace and comments.
def nodelist_has_content(nodelist: NodeList) -> bool:
for node in nodelist:
if isinstance(node, TextNode) and (not node.s or node.s.isspace()):
pass
elif isinstance(node, CommentNode):
pass
else:
return True
return False
class NodeTraverse(NamedTuple):
node: Node
parent: Optional["NodeTraverse"]
def walk_nodelist(
nodes: NodeList,
callback: Callable[[Node], Optional[str]],
context: Optional[Context] = None,
) -> None:
"""Recursively walk a NodeList, calling `callback` for each Node."""
node_queue: List[NodeTraverse] = [NodeTraverse(node=node, parent=None) for node in nodes]
while len(node_queue):
traverse = node_queue.pop()
callback(traverse)
child_nodes = get_node_children(traverse.node, context)
child_traverses = [NodeTraverse(node=child_node, parent=traverse) for child_node in child_nodes]
node_queue.extend(child_traverses)
def get_node_children(node: Node, context: Optional[Context] = None) -> NodeList:
"""
Get child Nodes from Node's nodelist atribute.
This function is taken from `get_nodes_by_type` method of `django.template.base.Node`.
"""
# Special case - {% extends %} tag - Load the template and go deeper
if isinstance(node, ExtendsNode):
# NOTE: When {% extends %} node is being parsed, it collects all remaining template
# under node.nodelist.
# Hence, when we come across ExtendsNode in the template, we:
# 1. Go over all nodes in the template using `node.nodelist`
# 2. Go over all nodes in the "parent" template, via `node.get_parent`
nodes = NodeList()
nodes.extend(node.nodelist)
template = node.get_parent(context)
nodes.extend(template.nodelist)
return nodes
# Special case - {% include %} tag - Load the template and go deeper
elif isinstance(node, IncludeNode):
template = get_template_for_include_node(node, context)
return template.nodelist
nodes = NodeList()
for attr in node.child_nodelists:
nodelist = getattr(node, attr, [])
if nodelist:
nodes.extend(nodelist)
return nodes
def get_template_for_include_node(include_node: IncludeNode, context: Context) -> Template:
"""
This snippet is taken directly from `IncludeNode.render()`. Unfortunately the
render logic doesn't separate out template loading logic from rendering, so we
have to copy the method.
"""
template = include_node.template.resolve(context)
# Does this quack like a Template?
if not callable(getattr(template, "render", None)):
# If not, try the cache and select_template().
template_name = template or ()
if isinstance(template_name, str):
template_name = (
construct_relative_path(
include_node.origin.template_name,
template_name,
),
)
else:
template_name = tuple(template_name)
cache = context.render_context.dicts[0].setdefault(include_node, {})
template = cache.get(template_name)
if template is None:
template = context.template.engine.select_template(template_name)
cache[template_name] = template
# Use the base.Template of a backends.django.Template.
elif hasattr(template, "template"):
template = template.template
return template

View file

@ -6,9 +6,9 @@ from django.utils.safestring import SafeString
from django_components.context import set_provided_context_var
from django_components.expression import RuntimeKwargs
from django_components.logger import trace_msg
from django_components.node import BaseNode
from django_components.utils import gen_id
from django_components.util.logger import trace_msg
from django_components.util.misc import gen_id
PROVIDE_NAME_KWARG = "name"

File diff suppressed because it is too large Load diff

View file

@ -7,59 +7,162 @@ from django.utils.module_loading import import_string
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
from django_components.util.misc import is_str_wrapped_in_quotes
if TYPE_CHECKING:
from django_components.component_registry import ComponentRegistry
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=VAR_CHARS))
# Forward slash is added so it's possible to define components like
# `{% MyComp %}..{% /MyComp %}`
TAG_CHARS = VAR_CHARS + r"/"
TAG_RE = re.compile(r"^[{chars}]+$".format(chars=TAG_CHARS))
class TagResult(NamedTuple):
"""The return value from `TagFormatter.parse()`"""
"""
The return value from [`TagFormatter.parse()`](../api#django_components.TagFormatterABC.parse).
Read more about [Tag formatter](../../concepts/advanced/tag_formatter).
"""
component_name: str
"""Component name extracted from the template tag"""
"""
Component name extracted from the template tag
For example, if we had tag
```django
{% component "my_comp" key=val key2=val2 %}
```
Then `component_name` would be `my_comp`.
"""
tokens: List[str]
"""Remaining tokens (words) that were passed to the tag, with component name removed"""
"""
Remaining tokens (words) that were passed to the tag, with component name removed
For example, if we had tag
```django
{% component "my_comp" key=val key2=val2 %}
```
Then `tokens` would be `['key=val', 'key2=val2']`.
"""
class TagFormatterABC(abc.ABC):
"""
Abstract base class for defining custom tag formatters.
Tag formatters define how the component tags are used in the template.
Read more about [Tag formatter](../../concepts/advanced/tag_formatter).
For example, with the default tag formatter
([`ComponentFormatter`](../tag_formatters#django_components.tag_formatter.ComponentFormatter)),
components are written as:
```django
{% component "comp_name" %}
{% endcomponent %}
```
While with the shorthand tag formatter
([`ShorthandComponentFormatter`](../tag_formatters#django_components.tag_formatter.ShorthandComponentFormatter)),
components are written as:
```django
{% comp_name %}
{% endcomp_name %}
```
**Example:**
Implementation for `ShorthandComponentFormatter`:
```python
from djagno_components import TagFormatterABC, TagResult
class ShorthandComponentFormatter(TagFormatterABC):
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)
```
"""
@abc.abstractmethod
def start_tag(self, name: str) -> str:
"""Formats the start tag of a component."""
"""
Formats the start tag of a component.
Args:
name (str): Component's registered name. Required.
Returns:
str: The formatted start tag.
"""
...
@abc.abstractmethod
def end_tag(self, name: str) -> str:
"""Formats the end tag of a block component."""
"""
Formats the end tag of a block component.
Args:
name (str): Component's registered name. Required.
Returns:
str: The formatted end tag.
"""
...
@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)`.
Given the tokens (words) passed to a component start tag, this function extracts
the component name from the tokens list, and returns
[`TagResult`](../api#django_components.TagResult),
which is a tuple of `(component_name, remaining_tokens)`.
Example:
Args:
tokens [List(str]): List of tokens passed to the component tag.
Given a component declarations:
Returns:
TagResult: Parsed component name and remaining tokens.
`{% component "my_comp" key=val key2=val2 %}`
**Example:**
This function receives a list of tokens
Assuming we used a component in a template like this:
`['component', '"my_comp"', 'key=val', 'key2=val2']`
```django
{% component "my_comp" key=val key2=val2 %}
{% endcomponent %}
```
`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.
This function receives a list of tokens:
So in the end, we return a tuple:
```python
['component', '"my_comp"', 'key=val', 'key2=val2']
```
`('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.
- The remaining tokens we pass unmodified, as that's the input to the component.
So in the end, we return:
```python
TagResult('my_comp', ['key=val', 'key2=val2'])
```
"""
...
@ -98,14 +201,14 @@ class InternalTagFormatter:
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}"
f" Tag must contain only following chars: {TAG_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.
The original django_component's component tag formatter, it uses the `{% component %}`
and `{% endcomponent %}` tags, and the component name is given as the first positional arg.
Example as block:
```django
@ -173,9 +276,11 @@ class ComponentFormatter(TagFormatterABC):
class ShorthandComponentFormatter(TagFormatterABC):
"""
The component tag formatter that uses `<name>` / `end<name>` tags.
The component tag formatter that uses `{% <name> %}` / `{% end<name> %}` tags.
This is similar to django-web-components and django-slippers syntax.
This is similar to [django-web-components](https://github.com/Xzya/django-web-components)
and [django-slippers](https://github.com/mixxorz/slippers)
syntax.
Example as block:
```django
@ -207,7 +312,7 @@ class ShorthandComponentFormatter(TagFormatterABC):
def get_tag_formatter(registry: "ComponentRegistry") -> InternalTagFormatter:
"""Returns an instance of the currently configured component tag formatter."""
# Allow users to configure the component TagFormatter
formatter_cls_or_str = registry.settings.TAG_FORMATTER
formatter_cls_or_str = registry.settings.tag_formatter
if isinstance(formatter_cls_or_str, str):
tag_formatter: TagFormatterABC = import_string(formatter_cls_or_str)
@ -217,6 +322,6 @@ def get_tag_formatter(registry: "ComponentRegistry") -> InternalTagFormatter:
return InternalTagFormatter(tag_formatter)
# Default formatters
# Pre-defined formatters
component_formatter = ComponentFormatter("component")
component_shorthand_formatter = ShorthandComponentFormatter()

View file

@ -5,7 +5,7 @@ from django.template import Origin, Template
from django.template.base import UNKNOWN_SOURCE
from django_components.app_settings import app_settings
from django_components.utils import lazy_cache
from django_components.util.cache import lazy_cache
TTemplate = TypeVar("TTemplate", bound=Template)
@ -29,7 +29,39 @@ def cached_template(
name: Optional[str] = None,
engine: Optional[Any] = None,
) -> Template:
"""Create a Template instance that will be cached as per the `TEMPLATE_CACHE_SIZE` setting."""
"""
Create a Template instance that will be cached as per the
[`COMPONENTS.template_cache_size`](../settings#django_components.app_settings.ComponentsSettings.template_cache_size)
setting.
Args:
template_string (str): Template as a string, same as the first argument to Django's\
[`Template`](https://docs.djangoproject.com/en/5.1/topics/templates/#template). Required.
template_cls (Type[Template], optional): Specify the Template class that should be instantiated.\
Defaults to Django's [`Template`](https://docs.djangoproject.com/en/5.1/topics/templates/#template) class.
origin (Type[Origin], optional): Sets \
[`Template.Origin`](https://docs.djangoproject.com/en/5.1/howto/custom-template-backend/#origin-api-and-3rd-party-integration).
name (Type[str], optional): Sets `Template.name`
engine (Type[Any], optional): Sets `Template.engine`
```python
from django_components import cached_template
template = cached_template("Variable: {{ variable }}")
# You can optionally specify Template class, and other Template inputs:
class MyTemplate(Template):
pass
template = cached_template(
"Variable: {{ variable }}",
template_cls=MyTemplate,
name=...
origin=...
engine=...
)
```
""" # noqa: E501
template = _create_template(template_cls or Template, template_string, engine)
# Assign the origin and name separately, so the caching doesn't depend on them

View file

@ -3,20 +3,13 @@ Template loader that loads templates from each Django app's "components" directo
"""
from pathlib import Path
from typing import List, Optional, Set
from typing import List
from django.apps import apps
from django.conf import settings
from django.template.engine import Engine
from django.template.loaders.filesystem import Loader as FilesystemLoader
from django_components.app_settings import app_settings
from django_components.logger import logger
from django_components.util.loader import get_component_dirs
# This is the heart of all features that deal with filesystem and file lookup.
# Autodiscovery, Django template resolution, static file resolution - They all
# depend on this loader.
class Loader(FilesystemLoader):
def get_dirs(self, include_apps: bool = True) -> List[Path]:
"""
@ -32,72 +25,4 @@ class Loader(FilesystemLoader):
`BASE_DIR` setting is required.
"""
# Allow to configure from settings which dirs should be checked for components
component_dirs = app_settings.DIRS
# TODO_REMOVE_IN_V1
is_legacy_paths = (
# Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set
not getattr(settings, "COMPONENTS", {}).get("dirs", None) is not None
and hasattr(settings, "STATICFILES_DIRS")
and settings.STATICFILES_DIRS
)
if is_legacy_paths:
# NOTE: For STATICFILES_DIRS, we use the defaults even for empty list.
# We don't do this for COMPONENTS.dirs, so user can explicitly specify "NO dirs".
component_dirs = settings.STATICFILES_DIRS or [settings.BASE_DIR / "components"]
source = "STATICFILES_DIRS" if is_legacy_paths else "COMPONENTS.dirs"
logger.debug(
"Template loader will search for valid template dirs from following options:\n"
+ "\n".join([f" - {str(d)}" for d in component_dirs])
)
# Add `[app]/[APP_DIR]` to the directories. This is, by default `[app]/components`
app_paths: List[Path] = []
if include_apps:
for conf in apps.get_app_configs():
for app_dir in app_settings.APP_DIRS:
comps_path = Path(conf.path).joinpath(app_dir)
if comps_path.exists():
app_paths.append(comps_path)
directories: Set[Path] = set(app_paths)
# Validate and add other values from the config
for component_dir in component_dirs:
# Consider tuples for STATICFILES_DIRS (See #489)
# See https://docs.djangoproject.com/en/5.0/ref/settings/#prefixes-optional
if isinstance(component_dir, (tuple, list)):
component_dir = component_dir[1]
try:
Path(component_dir)
except TypeError:
logger.warning(
f"{source} expected str, bytes or os.PathLike object, or tuple/list of length 2. "
f"See Django documentation for STATICFILES_DIRS. Got {type(component_dir)} : {component_dir}"
)
continue
if not Path(component_dir).is_absolute():
raise ValueError(f"{source} must contain absolute paths, got '{component_dir}'")
else:
directories.add(Path(component_dir).resolve())
logger.debug(
"Template loader matched following template dirs:\n" + "\n".join([f" - {str(d)}" for d in directories])
)
return list(directories)
def get_dirs(include_apps: bool = True, engine: Optional[Engine] = None) -> List[Path]:
"""
Helper for using django_component's FilesystemLoader class to obtain a list
of directories where component python files may be defined.
"""
current_engine = engine
if current_engine is None:
current_engine = Engine.get_default()
loader = Loader(current_engine)
return loader.get_dirs(include_apps)
return get_component_dirs(include_apps)

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,7 @@
"""Helper types for IDEs."""
import sys
import typing
from typing import Any, Tuple
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11):
from typing import TypedDict
else:
from typing_extensions import TypedDict # for Python <3.11 with (Not)Required
try:
from typing import Annotated # type: ignore
except ImportError:
@typing.no_type_check
class Annotated: # type: ignore
def __init__(self, type_: str, *args: Any, **kwargs: Any):
self.type_ = type_
self.metadata = args, kwargs
def __repr__(self) -> str:
return f"Annotated[{self.type_}, {self.metadata[0]!r}, {self.metadata[1]!r}]"
def __getitem__(self, params: Any) -> "Annotated[Any, Any, Any]": # type: ignore
if not isinstance(params, tuple):
params = (params,)
return Annotated(self.type_, *params, **self.metadata[1]) # type: ignore
def __class_getitem__(self, *params: Any) -> "Annotated[Any, Any, Any]": # type: ignore
return Annotated(*params) # type: ignore
from django_components.util.types import Annotated
css = Annotated[str, "css"]
django_html = Annotated[str, "django_html"]
js = Annotated[str, "js"]
EmptyTuple = Tuple[()]
class EmptyDict(TypedDict):
pass

View file

@ -0,0 +1,7 @@
from django.urls import include, path
urlpatterns = [
path("components/", include("django_components.dependencies")),
]
__all__ = ["urlpatterns"]

View file

View file

@ -0,0 +1,45 @@
import functools
from typing import Any, Callable, TypeVar, cast
TFunc = TypeVar("TFunc", bound=Callable)
def lazy_cache(
make_cache: Callable[[], Callable[[Callable], Callable]],
) -> Callable[[TFunc], TFunc]:
"""
Decorator that caches the given function similarly to `functools.lru_cache`.
But the cache is instantiated only at first invocation.
`cache` argument is a function that generates the cache function,
e.g. `functools.lru_cache()`.
"""
_cached_fn = None
def decorator(fn: TFunc) -> TFunc:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# Lazily initialize the cache
nonlocal _cached_fn
if not _cached_fn:
# E.g. `lambda: functools.lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)`
cache = make_cache()
_cached_fn = cache(fn)
return _cached_fn(*args, **kwargs)
# Allow to access the LRU cache methods
# See https://stackoverflow.com/a/37654201/9788634
wrapper.cache_info = lambda: _cached_fn.cache_info() # type: ignore
wrapper.cache_clear = lambda: _cached_fn.cache_clear() # type: ignore
# And allow to remove the cache instance (mostly for tests)
def cache_remove() -> None:
nonlocal _cached_fn
_cached_fn = None
wrapper.cache_remove = cache_remove # type: ignore
return cast(TFunc, wrapper)
return decorator

View file

@ -0,0 +1,100 @@
from typing import List, Union
from selectolax.lexbor import LexborHTMLParser, LexborNode
def parse_node(html: str) -> LexborNode:
"""
Use this when you know the given HTML is a single node like
`<div> Hi </div>`
"""
tree = LexborHTMLParser(html)
# NOTE: The parser automatically places <style> tags inside <head>
# while <script> tags are inside <body>.
return tree.body.child or tree.head.child # type: ignore[union-attr, return-value]
def parse_document_or_nodes(html: str) -> Union[List[LexborNode], LexborHTMLParser]:
"""
Use this if you do NOT know whether the given HTML is a full document
with `<html>`, `<head>`, and `<body>` tags, or an HTML fragment.
"""
html = html.strip()
tree = LexborHTMLParser(html)
is_fragment = is_html_parser_fragment(html, tree)
if is_fragment:
nodes = parse_multiroot_html(html)
return nodes
else:
return tree
def parse_multiroot_html(html: str) -> List[LexborNode]:
"""
Use this when you know the given HTML is a multiple nodes like
`<div> Hi </div> <span> Hello </span>`
"""
# NOTE: HTML / XML MUST have a single root. So, to support multiple
# top-level elements, we wrap them in a dummy singular root.
parser = LexborHTMLParser(f"<root>{html}</root>")
# Get all contents of the root
root_elem = parser.css_first("root")
elems = [*root_elem.iter()] if root_elem else []
return elems
def is_html_parser_fragment(html: str, tree: LexborHTMLParser) -> bool:
# If we pass only an HTML fragment to the parser, like `<div>123</div>`, then
# the parser automatically wraps it in `<html>`, `<head>`, and `<body>` tags.
#
# <html>
# <head>
# </head>
# <body>
# <div>123</div>
# </body>
# </html>
#
# But also, as described in Lexbor (https://github.com/lexbor/lexbor/issues/183#issuecomment-1611975340),
# if the parser first comes across HTML tags that could go into the `<head>`,
# it will put them there, and then put the rest in `<body>`.
#
# So `<link href="..." /><div></div>` will be parsed as
#
# <html>
# <head>
# <link href="..." />
# </head>
# <body>
# <div>123</div>
# </body>
# </html>
#
# BUT, if we're dealing with a fragment, we want to parse it correctly as
# a multi-root fragment:
#
# <link href="..." />
# <div>123</div>
#
# The way do so is that we:
# 1. Take the original HTML string
# 2. Subtract the content of parsed `<head>` from the START of the original HTML
# 3. Subtract the content of parsed `<body>` from the END of the original HTML
# 4. Then, if we have an HTML fragment, we should be left with empty string (maybe whitespace?).
# 5. But if we have an HTML document, then the "space between" should contain text,
# because we didn't account for the length of `<html>`, `<head>`, `<body>` tags.
#
# TODO: Replace with fragment parser?
# See https://github.com/rushter/selectolax/issues/74#issuecomment-2404470344
parsed_head_html: str = tree.head.html # type: ignore
parsed_body_html: str = tree.body.html # type: ignore
head_content = parsed_head_html[len("<head>") : -len("</head>")] # noqa: E203
body_content = parsed_body_html[len("<body>") : -len("</body>")] # noqa: E203
between_content = html[len(head_content) : -len(body_content)].strip() # noqa: E203
is_fragment = not html or not between_content
return is_fragment

View file

@ -0,0 +1,240 @@
import glob
import os
from pathlib import Path
from typing import List, NamedTuple, Optional, Set, Union
from django.apps import apps
from django.conf import settings
from django_components.app_settings import ComponentsSettings, app_settings
from django_components.util.logger import logger
def get_component_dirs(include_apps: bool = True) -> List[Path]:
"""
Get directories that may contain component files.
This is the heart of all features that deal with filesystem and file lookup.
Autodiscovery, Django template resolution, static file resolution - They all use this.
Args:
include_apps (bool, optional): Include directories from installed Django apps.\
Defaults to `True`.
Returns:
List[Path]: A list of directories that may contain component files.
`get_component_dirs()` searches for dirs set in
[`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs)
settings. If none set, defaults to searching for a `"components"` app.
In addition to that, also all installed Django apps are checked whether they contain
directories as set in
[`COMPONENTS.app_dirs`](../settings#django_components.app_settings.ComponentsSettings.app_dirs)
(e.g. `[app]/components`).
**Notes:**
- Paths that do not point to directories are ignored.
- `BASE_DIR` setting is required.
- The paths in [`COMPONENTS.dirs`](../settings#django_components.app_settings.ComponentsSettings.dirs)
must be absolute paths.
"""
# Allow to configure from settings which dirs should be checked for components
component_dirs = app_settings.DIRS
# TODO_REMOVE_IN_V1
raw_component_settings = getattr(settings, "COMPONENTS", {})
if isinstance(raw_component_settings, dict):
raw_dirs_value = raw_component_settings.get("dirs", None)
elif isinstance(raw_component_settings, ComponentsSettings):
raw_dirs_value = raw_component_settings.dirs
else:
raw_dirs_value = None
is_component_dirs_set = raw_dirs_value is not None
is_legacy_paths = (
# Use value of `STATICFILES_DIRS` ONLY if `COMPONENT.dirs` not set
not is_component_dirs_set
and hasattr(settings, "STATICFILES_DIRS")
and settings.STATICFILES_DIRS
)
if is_legacy_paths:
# NOTE: For STATICFILES_DIRS, we use the defaults even for empty list.
# We don't do this for COMPONENTS.dirs, so user can explicitly specify "NO dirs".
component_dirs = settings.STATICFILES_DIRS or [settings.BASE_DIR / "components"]
# END TODO_REMOVE_IN_V1
source = "STATICFILES_DIRS" if is_legacy_paths else "COMPONENTS.dirs"
logger.debug(
"get_component_dirs will search for valid dirs from following options:\n"
+ "\n".join([f" - {str(d)}" for d in component_dirs])
)
# Add `[app]/[APP_DIR]` to the directories. This is, by default `[app]/components`
app_paths: List[Path] = []
if include_apps:
for conf in apps.get_app_configs():
for app_dir in app_settings.APP_DIRS:
comps_path = Path(conf.path).joinpath(app_dir)
if comps_path.exists():
app_paths.append(comps_path)
directories: Set[Path] = set(app_paths)
# Validate and add other values from the config
for component_dir in component_dirs:
# Consider tuples for STATICFILES_DIRS (See #489)
# See https://docs.djangoproject.com/en/5.0/ref/settings/#prefixes-optional
if isinstance(component_dir, (tuple, list)):
component_dir = component_dir[1]
try:
Path(component_dir)
except TypeError:
logger.warning(
f"{source} expected str, bytes or os.PathLike object, or tuple/list of length 2. "
f"See Django documentation for STATICFILES_DIRS. Got {type(component_dir)} : {component_dir}"
)
continue
if not Path(component_dir).is_absolute():
raise ValueError(f"{source} must contain absolute paths, got '{component_dir}'")
else:
directories.add(Path(component_dir).resolve())
logger.debug(
"get_component_dirs matched following template dirs:\n" + "\n".join([f" - {str(d)}" for d in directories])
)
return list(directories)
class ComponentFileEntry(NamedTuple):
"""Result returned by [`get_component_files()`](../api#django_components.get_component_files)."""
dot_path: str
"""The python import path for the module. E.g. `app.components.mycomp`"""
filepath: Path
"""The filesystem path to the module. E.g. `/path/to/project/app/components/mycomp.py`"""
def get_component_files(suffix: Optional[str] = None) -> List[ComponentFileEntry]:
"""
Search for files within the component directories (as defined in
[`get_component_dirs()`](../api#django_components.get_component_dirs)).
Requires `BASE_DIR` setting to be set.
Args:
suffix (Optional[str], optional): The suffix to search for. E.g. `.py`, `.js`, `.css`.\
Defaults to `None`, which will search for all files.
Returns:
List[ComponentFileEntry] A list of entries that contain both the filesystem path and \
the python import path (dot path).
**Example:**
```python
from django_components import get_component_files
modules = get_component_files(".py")
```
"""
search_glob = f"**/*{suffix}" if suffix else "**/*"
dirs = get_component_dirs(include_apps=False)
component_filepaths = _search_dirs(dirs, search_glob)
if hasattr(settings, "BASE_DIR") and settings.BASE_DIR:
project_root = str(settings.BASE_DIR)
else:
# Fallback for getting the root dir, see https://stackoverflow.com/a/16413955/9788634
project_root = os.path.abspath(os.path.dirname(__name__))
# NOTE: We handle dirs from `COMPONENTS.dirs` and from individual apps separately.
modules: List[ComponentFileEntry] = []
# First let's handle the dirs from `COMPONENTS.dirs`
#
# Because for dirs in `COMPONENTS.dirs`, we assume they will be nested under `BASE_DIR`,
# and that `BASE_DIR` is the current working dir (CWD). So the path relatively to `BASE_DIR`
# is ALSO the python import path.
for filepath in component_filepaths:
module_path = _filepath_to_python_module(filepath, project_root, None)
# Ignore files starting with dot `.` or files in dirs that start with dot.
#
# If any of the parts of the path start with a dot, e.g. the filesystem path
# is `./abc/.def`, then this gets converted to python module as `abc..def`
#
# NOTE: This approach also ignores files:
# - with two dots in the middle (ab..cd.py)
# - an extra dot at the end (abcd..py)
# - files outside of the parent component (../abcd.py).
# But all these are NOT valid python modules so that's fine.
if ".." in module_path:
continue
entry = ComponentFileEntry(dot_path=module_path, filepath=filepath)
modules.append(entry)
# For for apps, the directories may be outside of the project, e.g. in case of third party
# apps. So we have to resolve the python import path relative to the package name / the root
# import path for the app.
# See https://github.com/EmilStenstrom/django-components/issues/669
for conf in apps.get_app_configs():
for app_dir in app_settings.APP_DIRS:
comps_path = Path(conf.path).joinpath(app_dir)
if not comps_path.exists():
continue
app_component_filepaths = _search_dirs([comps_path], search_glob)
for filepath in app_component_filepaths:
app_component_module = _filepath_to_python_module(filepath, conf.path, conf.name)
entry = ComponentFileEntry(dot_path=app_component_module, filepath=filepath)
modules.append(entry)
return modules
def _filepath_to_python_module(
file_path: Union[Path, str],
root_fs_path: Union[str, Path],
root_module_path: Optional[str],
) -> str:
"""
Derive python import path from the filesystem path.
Example:
- If project root is `/path/to/project`
- And file_path is `/path/to/project/app/components/mycomp.py`
- Then the path relative to project root is `app/components/mycomp.py`
- Which we then turn into python import path `app.components.mycomp`
"""
rel_path = os.path.relpath(file_path, start=root_fs_path)
rel_path_without_suffix = str(Path(rel_path).with_suffix(""))
# NOTE: `Path` normalizes paths to use `/` as separator, while `os.path`
# uses `os.path.sep`.
sep = os.path.sep if os.path.sep in rel_path_without_suffix else "/"
module_name = rel_path_without_suffix.replace(sep, ".")
# Combine with the base module path
full_module_name = f"{root_module_path}.{module_name}" if root_module_path else module_name
if full_module_name.endswith(".__init__"):
full_module_name = full_module_name[:-9] # Remove the trailing `.__init__
return full_module_name
def _search_dirs(dirs: List[Path], search_glob: str) -> List[Path]:
"""
Search the directories for the given glob pattern. Glob search results are returned
as a flattened list.
"""
matched_files: List[Path] = []
for directory in dirs:
for path in glob.iglob(str(Path(directory) / search_glob), recursive=True):
matched_files.append(Path(path))
return matched_files

View file

@ -62,7 +62,7 @@ def trace(logger: logging.Logger, message: str, *args: Any, **kwargs: Any) -> No
def trace_msg(
action: Literal["PARSE", "ASSOC", "RENDR", "GET", "SET"],
action: Literal["PARSE", "RENDR", "GET", "SET"],
node_type: Literal["COMP", "FILL", "SLOT", "PROVIDE", "N/A"],
node_name: str,
node_id: str,
@ -76,11 +76,7 @@ def trace_msg(
`"ASSOC SLOT test_slot ID 0088 TO COMP 0087"`
"""
msg_prefix = ""
if action == "ASSOC":
if not component_id:
raise ValueError("component_id must be set for the ASSOC action")
msg_prefix = f"TO COMP {component_id}"
elif action == "RENDR" and node_type == "FILL":
if action == "RENDR" and node_type == "FILL":
if not component_id:
raise ValueError("component_id must be set for the RENDER action")
msg_prefix = f"FOR COMP {component_id}"

View file

@ -0,0 +1,79 @@
import re
from typing import Any, Callable, List, Optional, Type, TypeVar
from django.template.defaultfilters import escape
from django_components.util.nanoid import generate
T = TypeVar("T")
# Based on nanoid implementation from
# https://github.com/puyuan/py-nanoid/tree/99e5b478c450f42d713b6111175886dccf16f156/nanoid
def gen_id() -> str:
"""Generate a unique ID that can be associated with a Node"""
# Alphabet is only alphanumeric. Compared to the default alphabet used by nanoid,
# we've omitted `-` and `_`.
# With this alphabet, at 6 chars, the chance of collision is 1 in 3.3M.
# See https://zelark.github.io/nano-id-cc/
return generate(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
size=6,
)
def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
for r_idx, elem in enumerate(reversed(lst)):
if predicate(elem):
return len(lst) - 1 - r_idx
return -1
def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return any(p.search(string) is not None for p in patterns)
def no_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return all(p.search(string) is None for p in patterns)
# See https://stackoverflow.com/a/2020083/9788634
def get_import_path(cls_or_fn: Type[Any]) -> str:
"""
Get the full import path for a class or a function, e.g. `"path.to.MyClass"`
"""
module = cls_or_fn.__module__
if module == "builtins":
return cls_or_fn.__qualname__ # avoid outputs like 'builtins.str'
return module + "." + cls_or_fn.__qualname__
# See https://stackoverflow.com/a/58800331/9788634
# str.replace(/\\|`|\$/g, '\\$&');
JS_STRING_LITERAL_SPECIAL_CHARS_REGEX = re.compile(r"\\|`|\$")
# See https://stackoverflow.com/a/34064434/9788634
def escape_js_string_literal(js: str) -> str:
escaped_js = escape(js)
def on_replace_match(match: "re.Match[str]") -> str:
return f"\\{match[0]}"
escaped_js = JS_STRING_LITERAL_SPECIAL_CHARS_REGEX.sub(on_replace_match, escaped_js)
return escaped_js
def default(val: Optional[T], default: T) -> T:
return val if val is not None else default
def get_last_index(lst: List, key: Callable[[Any], bool]) -> Optional[int]:
for index, item in enumerate(reversed(lst)):
if key(item):
return len(lst) - 1 - index
return None

View file

@ -0,0 +1,29 @@
from math import ceil, log
from os import urandom
# Based on nanoid implementation from
# https://github.com/puyuan/py-nanoid/tree/99e5b478c450f42d713b6111175886dccf16f156/nanoid
#
# NOTE: This function is defined in a separate file so we can mock the import
# of this function in a singular place.
def generate(alphabet: str, size: int) -> str:
alphabet_len = len(alphabet)
mask = 1
if alphabet_len > 1:
mask = (2 << int(log(alphabet_len - 1) / log(2))) - 1
step = int(ceil(1.6 * mask * size / alphabet_len))
id = ""
while True:
random_bytes = bytearray(urandom(step))
for i in range(step):
random_byte = random_bytes[i] & mask
if random_byte < alphabet_len:
if alphabet[random_byte]:
id += alphabet[random_byte]
if len(id) == size:
return id

View file

@ -0,0 +1,198 @@
from dataclasses import dataclass
from typing import List, Optional, Sequence, Tuple, Union
TAG_WHITESPACE = (" ", "\t", "\n", "\r", "\f")
@dataclass
class TagAttr:
key: Optional[str]
value: str
start_index: int
"""
Start index of the attribute (include both key and value),
relative to the start of the owner Tag.
"""
quoted: bool
"""Whether the value is quoted (either with single or double quotes)"""
# Parse the content of a Django template tag like this:
#
# ```django
# {% component "my_comp" key="my val's" key2=val2 %}
# ```
#
# into a tag name and a list of attributes:
#
# ```python
# {
# "component": "component",
# }
# ```
def parse_tag_attrs(text: str) -> Tuple[str, List[TagAttr]]:
index = 0
normalized = ""
def add_token(token: Union[str, Tuple[str, ...]]) -> None:
nonlocal normalized
nonlocal index
text = "".join(token)
normalized += text
index += len(text)
def is_next_token(*tokens: Union[str, Tuple[str, ...]]) -> bool:
if not tokens:
raise ValueError("No tokens provided")
def is_token_match(token: Union[str, Tuple[str, ...]]) -> bool:
if not token:
raise ValueError("Empty token")
for token_index, token_char in enumerate(token):
text_char = text[index + token_index] if index + token_index < len(text) else None
if text_char is None or text_char != token_char:
return False
return True
for token in tokens:
is_match = is_token_match(token)
if is_match:
return True
return False
def taken_n(n: int) -> str:
nonlocal index
result = text[index : index + n] # noqa: E203
add_token(result)
return result
# tag_name = take_until([" ", "\t", "\n", "\r", "\f", ">", "/>"])
def take_until(
tokens: Sequence[Union[str, Tuple[str, ...]]],
ignore: Optional[Sequence[Union[str, Tuple[str, ...]],]] = None,
) -> str:
nonlocal index
nonlocal text
result = ""
while index < len(text):
char = text[index]
ignore_token_match: Optional[Union[str, Tuple[str, ...]]] = None
for ignore_token in ignore or []:
if is_next_token(ignore_token):
ignore_token_match = ignore_token
if ignore_token_match:
result += "".join(ignore_token_match)
add_token(ignore_token_match)
continue
if any(is_next_token(token) for token in tokens):
return result
result += char
add_token(char)
return result
# tag_name = take_while([" ", "\t", "\n", "\r", "\f"])
def take_while(tokens: Sequence[Union[str, Tuple[str, ...]]]) -> str:
nonlocal index
nonlocal text
result = ""
while index < len(text):
char = text[index]
if any(is_next_token(token) for token in tokens):
result += char
add_token(char)
else:
return result
return result
# Parse
attrs: List[TagAttr] = []
while index < len(text):
# Skip whitespace
take_while(TAG_WHITESPACE)
start_index = len(normalized)
# If token starts with a quote, we assume it's a value without key part.
# e.g. `component 'my_comp'`
# Otherwise, parse the key.
if is_next_token("'", '"', '_("', "_('"):
key = None
else:
key = take_until(["=", *TAG_WHITESPACE])
# We've reached the end of the text
if not key:
break
# Has value
if is_next_token("="):
add_token("=")
else:
# Actually was a value without key part
attrs.append(
TagAttr(
key=None,
value=key,
start_index=start_index,
quoted=False,
)
)
continue
# Parse the value
#
# E.g. `height="20"`
# NOTE: We don't need to parse the attributes fully. We just need to account
# for the quotes.
if is_next_token("'", '"', '_("', "_('"):
# NOTE: Strings may be wrapped in `_()` to allow for translation.
# See https://docs.djangoproject.com/en/5.1/topics/i18n/translation/#string-literals-passed-to-tags-and-filters # noqa: E501
if is_next_token("_("):
taken_n(2) # _(
is_translation = True
else:
is_translation = False
# NOTE: We assume no space between the translation syntax and the quote.
quote_char = taken_n(1) # " or '
# NOTE: Handle escaped quotes like \" or \', and continue until we reach the closing quote.
value = take_until([quote_char], ignore=["\\" + quote_char])
# Handle the case when there is a trailing quote, e.g. when a text value is not closed.
# `{% component 'my_comp' text="organis %}`
if is_next_token(quote_char):
add_token(quote_char)
if is_translation:
value += taken_n(1) # )
quoted = True
else:
quoted = False
value = quote_char + value
if is_translation:
value = "_(" + value
# E.g. `height=20`
else:
value = take_until(TAG_WHITESPACE)
quoted = False
attrs.append(
TagAttr(
key=key,
value=value,
start_index=start_index,
quoted=quoted,
)
)
return normalized, attrs

View file

@ -0,0 +1,143 @@
import sys
import typing
from typing import Any, Tuple
# See https://peps.python.org/pep-0655/#usage-in-python-3-11
if sys.version_info >= (3, 11):
from typing import TypedDict
else:
from typing_extensions import TypedDict as TypedDict # for Python <3.11 with (Not)Required
try:
from typing import Annotated # type: ignore
except ImportError:
@typing.no_type_check
class Annotated: # type: ignore
def __init__(self, type_: str, *args: Any, **kwargs: Any):
self.type_ = type_
self.metadata = args, kwargs
def __repr__(self) -> str:
return f"Annotated[{self.type_}, {self.metadata[0]!r}, {self.metadata[1]!r}]"
def __getitem__(self, params: Any) -> "Annotated[Any, Any, Any]": # type: ignore
if not isinstance(params, tuple):
params = (params,)
return Annotated(self.type_, *params, **self.metadata[1]) # type: ignore
def __class_getitem__(self, *params: Any) -> "Annotated[Any, Any, Any]": # type: ignore
return Annotated(*params) # type: ignore
EmptyTuple = Tuple[()]
"""
Tuple with no members.
You can use this to define a [Component](../api#django_components.Component)
that accepts NO positional arguments:
```python
from django_components import Component, EmptyTuple
class Table(Component(EmptyTuple, Any, Any, Any, Any, Any))
...
```
After that, when you call [`Component.render()`](../api#django_components.Component.render)
or [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
the `args` parameter will raise type error if `args` is anything else than an empty
tuple.
```python
Table.render(
args: (),
)
```
Omitting `args` is also fine:
```python
Table.render()
```
Other values are not allowed. This will raise an error with MyPy:
```python
Table.render(
args: ("one", 2, "three"),
)
```
"""
class EmptyDict(TypedDict):
"""
TypedDict with no members.
You can use this to define a [Component](../api#django_components.Component)
that accepts NO kwargs, or NO slots, or returns NO data from
[`Component.get_context_data()`](../api#django_components.Component.get_context_data)
/
[`Component.get_js_data()`](../api#django_components.Component.get_js_data)
/
[`Component.get_css_data()`](../api#django_components.Component.get_css_data):
Accepts NO kwargs:
```python
from django_components import Component, EmptyDict
class Table(Component(Any, EmptyDict, Any, Any, Any, Any))
...
```
Accepts NO slots:
```python
from django_components import Component, EmptyDict
class Table(Component(Any, Any, EmptyDict, Any, Any, Any))
...
```
Returns NO data from `get_context_data()`:
```python
from django_components import Component, EmptyDict
class Table(Component(Any, Any, Any, EmptyDict, Any, Any))
...
```
Going back to the example with NO kwargs, when you then call
[`Component.render()`](../api#django_components.Component.render)
or [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
the `kwargs` parameter will raise type error if `kwargs` is anything else than an empty
dict.
```python
Table.render(
kwargs: {},
)
```
Omitting `kwargs` is also fine:
```python
Table.render()
```
Other values are not allowed. This will raise an error with MyPy:
```python
Table.render(
kwargs: {
"one": 2,
"three": 4,
},
)
```
"""
pass

View file

@ -1,46 +1,6 @@
import functools
import re
import sys
import typing
from pathlib import Path
from typing import Any, Callable, List, Mapping, Sequence, Tuple, TypeVar, Union, cast, get_type_hints
from django.utils.autoreload import autoreload_started
# Global counter to ensure that all IDs generated by `gen_id` WILL be unique
_id = 0
def gen_id(length: int = 5) -> str:
"""Generate a unique ID that can be associated with a Node"""
# Global counter to avoid conflicts
global _id
_id += 1
# Pad the ID with `0`s up to 4 digits, e.g. `0007`
return f"{_id:04}"
def find_last_index(lst: List, predicate: Callable[[Any], bool]) -> Any:
for r_idx, elem in enumerate(reversed(lst)):
if predicate(elem):
return len(lst) - 1 - r_idx
return -1
def is_str_wrapped_in_quotes(s: str) -> bool:
return s.startswith(('"', "'")) and s[0] == s[-1] and len(s) >= 2
# See https://github.com/EmilStenstrom/django-components/issues/586#issue-2472678136
def watch_files_for_autoreload(watch_list: Sequence[Union[str, Path]]) -> None:
def autoreload_hook(sender: Any, *args: Any, **kwargs: Any) -> None:
watch = sender.extra_files.add
for file in watch_list:
watch(Path(file))
autoreload_started.connect(autoreload_hook)
from typing import Any, Mapping, Tuple, get_type_hints
# Get all types that users may use from the `typing` module.
#
@ -168,55 +128,3 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k
# `Component 'name' got unexpected slot keys 'invalid_key'`
# `Component 'name' got unexpected data keys 'invalid_key'`
raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}")
TFunc = TypeVar("TFunc", bound=Callable)
def lazy_cache(
make_cache: Callable[[], Callable[[Callable], Callable]],
) -> Callable[[TFunc], TFunc]:
"""
Decorator that caches the given function similarly to `functools.lru_cache`.
But the cache is instantiated only at first invocation.
`cache` argument is a function that generates the cache function,
e.g. `functools.lru_cache()`.
"""
_cached_fn = None
def decorator(fn: TFunc) -> TFunc:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# Lazily initialize the cache
nonlocal _cached_fn
if not _cached_fn:
# E.g. `lambda: functools.lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)`
cache = make_cache()
_cached_fn = cache(fn)
return _cached_fn(*args, **kwargs)
# Allow to access the LRU cache methods
# See https://stackoverflow.com/a/37654201/9788634
wrapper.cache_info = lambda: _cached_fn.cache_info() # type: ignore
wrapper.cache_clear = lambda: _cached_fn.cache_clear() # type: ignore
# And allow to remove the cache instance (mostly for tests)
def cache_remove() -> None:
nonlocal _cached_fn
_cached_fn = None
wrapper.cache_remove = cache_remove # type: ignore
return cast(TFunc, wrapper)
return decorator
def any_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return any(p.search(string) is not None for p in patterns)
def no_regex_match(string: str, patterns: List[re.Pattern]) -> bool:
return all(p.search(string) is None for p in patterns)

1
src/docs/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
reference/

6
src/docs/CHANGELOG.md Normal file
View file

@ -0,0 +1,6 @@
---
hide:
- toc
---
--8<-- "CHANGELOG.md"

0
src/docs/__init__.py Normal file
View file

View file

@ -0,0 +1,223 @@
# JS and CSS rendering
Aim of this doc is to share the intuition on how we manage the JS and CSS ("dependencies")
associated with components, and how we render them.
## Starting conditions
1. First of all, when we consider a component, it has two kind of dependencies - the "inlined" JS and CSS, and additional linked JS and CSS via `Media.js/css`:
```py
from django_components import Component, types
class MyTable(Component):
# Inlined JS
js: types.js = """
console.log(123);
"""
# Inlined CSS
css: types.css = """
.my-table {
color: red;
}
"""
# Linked JS / CSS
class Media:
js = [
"script-one.js", # STATIC file relative to component file
"/script-two.js", # URL path
"https://example.com/script-three.js", # URL
]
css = [
"style-one.css", # STATIC file relative to component file
"/style-two.css", # URL path
"https://example.com/style-three.css", # URL
]
```
2. Second thing to keep in mind is that all component's are eventually rendered into a string. And so, if we want to associate extra info with a rendered component, it has to be serialized to a string.
This is because a component may be embedded in a Django Template with the `{% component %}` tag, which, when rendered, is turned into a string:
```py
template = Template("""
{% load component_tags %}
<div>
{% component "my_table" / %}
</div>
""")
html_str = template.render(Context({}))
```
And for this reason, we take the same approach also when we render a component with `Component.render()` - It returns a string.
3. Thirdly, we also want to add support for JS / CSS variables. That is, that a variable defined on the component would be somehow accessible from within the JS script / CSS style.
A simple approach to this would be to modify the inlined JS / CSS directly, and insert them for each component. But if you had extremely large JS / CSS, and e.g. only a single JS / CSS variable that you want to insert, it would be extremely wasteful to copy-paste the JS / CSS for each component instance.
So instead, a preferred approach here is to defined and insert the inlined JS / CSS only once, and have some kind of mechanism on how we make correct the JS / CSS variables available only to the correct components.
4. Last important thing is that we want the JS / CSS dependencies to work also with HTML fragments.
So normally, e.g. when a user hits URL of a web page, the server renders full HTML document, with `<!doctype>`, `<html>`, `<head>`, and `<body>`. In such case, we know about ALL JS and CSS dependencies at render time, so we can e.g. insert them into `<head>` and `<body>` ourselves.
However this renders only the initial state. HTML fragments is a common pattern where interactivity is added to the web page by fetching and replacing bits of HTML on the main HTML document after some user action.
In the case of HTML fragments, the HTML is NOT a proper document, but only the HTML that will be inserted somewhere into the DOM.
The challenge here is that Django template for the HTML fragment MAY contain components, and these components MAY have inlined or linked JS and CSS.
```py
def fragment_view(request):
template = Template("""
{% load component_tags %}
<div>
{% component "my_table" / %}
</div>
""")
fragment_str = template.render(Context({}))
return HttpResponse(fragment_str, status=200)
```
User may use different libraries to fetch and insert the HTML fragments (e.g. HTMX, AlpineJS, ...). From our perspective, the only thing that we can reliably say is that we expect that the HTML fragment WILL be eventually inserted into the DOM.
So to include the corresponding JS and CSS, a simple approach could be to append them to the HTML as `<style>` and `<script>`, e.g.:
```html
<!-- Original content -->
<div>...</div>
<!-- Associated CSS files -->
<link href="http://..." />
<style>
.my-class {
color: red;
}
</style>
<!-- Associated JS files -->
<script src="http://..."></script>
<script>
console.log(123);
</script>
```
But this has a number of issues:
- The JS scripts would run for each instance of the component.
- Bloating of the HTML file, as each inlined JS or CSS would be included fully for each component.
- While this sound OK, this could really bloat the HTML files if we used a UI component library for the basic building blocks like buttons, lists, cards, etc.
## Flow
So the solution should address all the points above. To achieve that, we manage the JS / CSS dependencies ourselves in the browser. So when a full HTML document is loaded, we keep track of which JS and CSS have been loaded. And when an HTML fragment is inserted, we check which JS / CSS dependencies it has, and load only those that have NOT been loaded yet.
This is how we achieve that:
1. When a component is rendered, it inserts an HTML comment containing metadata about the rendered component.
So a template like this
```django
{% load component_tags %}
<div>
{% component "my_table" / %}
</div>
{% component "button" %}
Click me!
{% endcomponent %}
```
May actually render:
```html
<div>
<!-- _RENDERED "my_table_10bc2c,c020ad" -->
<table>
...
</table>
</div>
<!-- _RENDERED "button_309dcf,31c0da" -->
<button>Click me!</button>
```
Each `<!-- _RENDERED -->` comment includes comma-separated data - a unique hash for the component class, e.g. `my_table_10bc2c`, and the component ID, e.g. `c020ad`.
This way, we or the user can freely pass the rendered around or transform it, treating it as a string to add / remove / replace bits. As long as the `<!-- _RENDERED -->` comments remain in the rendered string, we will be able to deduce which JS and CSS dependencies the component needs.
2. Post-process the rendered HTML, extracting the `<!-- _RENDERED -->` comments, and instead inserting the corresponding JS and CSS dependencies.
If we dealt only with JS, then we could get away with processing the `<!-- _RENDERED -->` comments on the client (browser). However, the CSS needs to be processed still on the server, so the browser receives CSS styles already inserted as `<style>` or `<link>` HTML tags. Because if we do not do that, we get a [flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content), as there will be a delay between when the HTML page loaded and when the CSS was fetched and loaded.
So, assuming that a user has already rendered their template, which still contains `<!-- _RENDERED -->` comments, we need to extract and process these comments.
There's multiple ways to achieve this:
- The approach recommended to the users is to use the `ComponentDependencyMiddleware` middleware, which scans all outgoing HTML, and post-processes the `<!-- _RENDERED -->` comments.
- If users are using `Component.render()` or `Component.render_to_response()`, these post-process the `<!-- _RENDERED -->` comments by default.
- NOTE: Users are able to opt out of the post-processing by setting `render_dependencies=False`.
- For advanced use cases, users may use `render_dependencies()` directly. This is the function that both `ComponentDependencyMiddleware` and `Component.render()` call internally.
`render_dependencies()`, whether called directly, via middleware or other way, does the following:
1. Find all `<!-- _RENDERED -->` comments, and for each comment:
2. Look up the corresponding component class.
3. Get the component's inlined JS / CSS from `Component.js/css`, and linked JS / CSS from `Component.Media.js/css`.
4. Generate JS script that loads the JS / CSS dependencies.
5. Insert the JS scripts either at the end of `<body>`, or in place of `{% component_dependencies %}` / `{% component_js_dependencies %}` tags.
6. To avoid the [flash of unstyled content](https://en.wikipedia.org/wiki/Flash_of_unstyled_content), we need place the styles into the HTML instead of dynamically loading them from within a JS script. The CSS is placed either at the end of `<head>`, or in place of `{% component_dependencies %}` / `{% component_css_dependencies %}`
7. We cache the component's inlined JS and CSS, so they can be fetched via an URL, so the inlined JS / CSS an be treated the same way as the JS / CSS dependencies set in `Component.Media.js/css`.
- NOTE: While this is currently not entirely necessary, it opens up the doors for allowing plugins to post-process the inlined JS and CSS. Because after it has been post-processed, we need to store it somewhere.
3. Server returns the post-processed HTML.
4. In the browser, the generated JS script from step 2.4 is executed. It goes through all JS and CSS dependencies it was given. If some JS / CSS was already loaded, it is NOT fetched again. Otherwise it generates the corresponding `<script>` or `<link>` HTML tags to load the JS / CSS dependencies.
In the browser, the "dependency manager JS" may look like this:
```js
// Load JS or CSS script if not loaded already
Components.loadScript("js", '<script src="/abc/xyz/script.js">');
Components.loadScript("css", '<link href="/abc/xyz/style.css">');
// Or mark one as already-loaded, so it is ignored when
// we call `loadScript`
Components.markScriptLoaded("js", "/abc/def");
```
Note that `loadScript()` receives a whole `<script>` and `<link>` tags, not just the URL.
This is because when Django's `Media` class renders JS and CSS, it formats it as `<script>` and `<link>` tags.
And we allow users to modify how the JS and CSS should be rendered into the `<script>` and `<link>` tags.
So, if users decided to add an extra attribute to their `<script>` tags, e.g. `<script defer src="http://..."></script>`,
then this way we make sure that the `defer` attribute will be present on the `<script>` tag when
it is inserted into the DOM at the time of loading the JS script.
5. To be able to fetch component's inlined JS and CSS, django-components adds a URL path under:
`/components/cache/<str:comp_cls_hash>.<str:script_type>/`
E.g. `/components/cache/my_table_10bc2c.js/`
This endpoint takes the component's unique hash, e.g. `my_table_10bc2c`, and looks up the component's inlined JS or CSS.
---
Thus, with this approach, we ensure that:
1. All JS / CSS dependencies are loaded / executed only once.
2. The approach is compatible with HTML fragments
3. The approach is compatible with JS / CSS variables.
4. Inlined JS / CSS may be post-processed by plugins

View file

@ -0,0 +1,253 @@
# Slot rendering
This doc serves as a primer on how component slots and fills are resolved.
## Flow
1. Imagine you have a template. Some kind of text, maybe HTML:
```django
| ------
| ---------
| ----
| -------
```
2. The template may contain some vars, tags, etc
```django
| -- {{ my_var }} --
| ---------
| ----
| -------
```
3. The template also contains some slots, etc
```django
| -- {{ my_var }} --
| ---------
| -- {% slot "myslot" %} ---
| -- {% endslot %} ---
| ----
| -- {% slot "myslot2" %} ---
| -- {% endslot %} ---
| -------
```
4. Slots may be nested
```django
| -- {{ my_var }} --
| -- ABC
| -- {% slot "myslot" %} ---
| ----- DEF {{ my_var }}
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
| -- {% endslot %} ---
| ----
| -- {% slot "myslot2" %} ---
| ---- JKL {{ my_var }}
| -- {% endslot %} ---
| -------
```
5. Some slots may be inside fills for other components
```django
| -- {{ my_var }} --
| -- ABC
| -- {% slot "myslot" %}---
| ----- DEF {{ my_var }}
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
| -- {% endslot %} ---
| ------
| -- {% component "mycomp" %} ---
| ---- {% slot "myslot" %} ---
| ------- JKL {{ my_var }}
| ------- {% slot "myslot_inner" %}
| ---------- MNO {{ my_var }}
| ------- {% endslot %}
| ---- {% endslot %} ---
| -- {% endcomponent %} ---
| ----
| -- {% slot "myslot2" %} ---
| ---- PQR {{ my_var }}
| -- {% endslot %} ---
| -------
```
6. The names of the slots and fills may be defined using variables
```django
| -- {% slot slot_name %} ---
| ---- STU {{ my_var }}
| -- {% endslot %} ---
| -------
```
7. The slot and fill names may be defined using for loops or other variables defined within the template (e.g. `{% with %}` tag or `{% ... as var %}` syntax)
```django
| -- {% for slot_name in slots %} ---
| ---- {% slot slot_name %} ---
| ------ STU {{ slot_name }}
| ---- {% endslot %} ---
| -- {% endfor %} ---
| -------
```
8. Variables for names and for loops allow us implement "passthrough slots" - that is, taking all slots that our component received, and passing them to a child component, dynamically.
```django
| -- {% component "mycomp" %} ---
| ---- {% for slot_name in slots %} ---
| ------ {% fill slot_name %} ---
| -------- {% slot slot_name %} ---
| ---------- XYZ {{ slot_name }}
| --------- {% endslot %}
| ------- {% endfill %}
| ---- {% endfor %} ---
| -- {% endcomponent %} ---
| ----
```
9. Putting that all together, a document may look like this:
```django
| -- {{ my_var }} --
| -- ABC
| -- {% slot "myslot" %}---
| ----- DEF {{ my_var }}
| ----- {% slot "myslot_inner" %}
| -------- GHI {{ my_var }}
| ----- {% endslot %}
| -- {% endslot %} ---
| ------
| -- {% component "mycomp" %} ---
| ---- {% slot "myslot" %} ---
| ------- JKL {{ my_var }}
| ------- {% slot "myslot_inner" %}
| ---------- MNO {{ my_var }}
| ------- {% endslot %}
| ---- {% endslot %} ---
| -- {% endcomponent %} ---
| ----
| -- {% slot "myslot2" %} ---
| ---- PQR {{ my_var }}
| -- {% endslot %} ---
| -------
| -- {% for slot_name in slots %} ---
| ---- {% component "mycomp" %} ---
| ------- {% slot slot_name %}
| ---------- STU {{ slot_name }}
| ------- {% endslot %}
| ---- {% endcomponent %} ---
| -- {% endfor %} ---
| ----
| -- {% component "mycomp" %} ---
| ---- {% for slot_name in slots %} ---
| ------ {% fill slot_name %} ---
| -------- {% slot slot_name %} ---
| ---------- XYZ {{ slot_name }}
| --------- {% endslot %}
| ------- {% endfill %}
| ---- {% endfor %} ---
| -- {% endcomponent %} ---
| -------
```
10. Given the above, we want to render the slots with `{% fill %}` tag that were defined OUTSIDE of this template. How do I do that?
> _NOTE: Before v0.110, slots were resolved statically, by walking down the Django Template and Nodes. However, this did not allow for using for loops or other variables defined in the template._
Currently, this consists of 2 steps:
1. If a component is rendered within a template using `{% component %}` tag, determine the given `{% fill %}` tags in the component's body (the content in between `{% component %}` and `{% endcomponent %}`).
After this step, we know about all the fills that were passed to the component.
2. Then we simply render the template as usual. And then we reach the `{% slot %}` tag, we search the context for the available fills.
- If there IS a fill with the same name as the slot, we render the fill.
- If the slot is marked `default`, and there is a fill named `default`, then we render that.
- Otherwise, we render the slot's default content.
11. Obtaining the fills from `{% fill %}`.
When a component is rendered with `{% component %}` tag, and it has some content in between `{% component %}` and `{% endcomponent %}`, we want to figure out if that content is a default slot (no `{% fill %}` used), or if there is a collection of named `{% fill %}` tags:
Default slot:
```django
| -- {% component "mycomp" %} ---
| ---- STU {{ slot_name }}
| -- {% endcomponent %} ---
```
Named slots:
```django
| -- {% component "mycomp" %} ---
| ---- {% fill "slot_a" %}
| ------ STU
| ---- {% endslot %}
| ---- {% fill "slot_b" %}
| ------ XYZ
| ---- {% endslot %}
| -- {% endcomponent %} ---
```
To respect any forloops or other variables defined within the template to which the fills may have access,
we:
1. Render the content between `{% component %}` and `{% endcomponent %}` using the context
outside of the component.
2. When we reach a `{% fill %}` tag, we capture any variables that were created between
the `{% component %}` and `{% fill %}` tags.
3. When we reach `{% fill %}` tag, we do not continue rendering deeper. Instead we
make a record that we found the fill tag with given name, kwargs, etc.
4. After the rendering is done, we check if we've encountered any fills.
If yes, we expect only named fills. If no, we assume that the the component's body
is a default slot.
5. Lastly we process the found fills, and make them available to the context, so any
slots inside the component may access these fills.
12. Rendering slots
Slot rendering works similarly to collecting fills, in a sense that we do not search
for the slots ahead of the time, but instead let Django handle the rendering of the template,
and we step in only when Django come across as `{% slot %}` tag.
When we reach a slot tag, we search the context for the available fills.
- If there IS a fill with the same name as the slot, we render the fill.
- If the slot is marked `default`, and there is a fill named `default`, then we render that.
- Otherwise, we render the slot's default content.
## Using the correct context in {% slot/fill %} tags
In previous section, we said that the `{% fill %}` tags should be already rendered by the time they are inserted into the `{% slot %}` tags.
This is not quite true. To help you understand, consider this complex case:
```django
| -- {% for var in [1, 2, 3] %} ---
| ---- {% component "mycomp2" %} ---
| ------ {% fill "first" %}
| ------- STU {{ my_var }}
| ------- {{ var }}
| ------ {% endfill %}
| ------ {% fill "second" %}
| -------- {% component var=var my_var=my_var %}
| ---------- VWX {{ my_var }}
| -------- {% endcomponent %}
| ------ {% endfill %}
| ---- {% endcomponent %} ---
| -- {% endfor %} ---
| -------
```
We want the forloop variables to be available inside the `{% fill %}` tags. Because of that, however, we CANNOT render the fills/slots in advance.
Instead, our solution is closer to [how Vue handles slots](https://vuejs.org/guide/components/slots.html#scoped-slots). In Vue, slots are effectively functions that accept a context variables and render some content.
While we do not wrap the logic in a function, we do PREPARE IN ADVANCE:
1. The content that should be rendered for each slot
2. The context variables from `get_context_data()`
Thus, once we reach the `{% slot %}` node, in it's `render()` method, we access the data above, and, depending on the `context_behavior` setting, include the current context or not. For more info, see `SlotNode.render()`.

View file

View file

@ -15,7 +15,7 @@ import mkdocs_gen_files
nav = mkdocs_gen_files.Nav()
mod_symbol = '<code class="doc-symbol doc-symbol-nav doc-symbol-module"></code>'
root = Path(__file__).parent.parent
root = Path(__file__).parent.parent.parent.parent
src = root / "src"
for path in sorted(src.rglob("*.py")):

View file

@ -1,3 +1,6 @@
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<form method="post">
{% csrf_token %}
<input type="text" name="variable" value="{{ variable }}">

View file

@ -18,7 +18,10 @@ class PathObj:
if self.throw_on_calling_str:
raise RuntimeError("__str__ method of 'relative_file_pathobj_component' was triggered when not allow to")
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
if self.static_path.endswith(".js"):
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
else:
return format_html('<link href="{}" rel="stylesheet">', static(self.static_path))
@register("relative_file_pathobj_component")

View file

@ -36,6 +36,7 @@ def setup_test_config(
}
},
"SECRET_KEY": "secret",
"ROOT_URLCONF": "django_components.urls",
}
settings.configure(

View file

@ -0,0 +1,80 @@
from django_components import Component, register, types
@register("inner")
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong class="inner">{{ variable }}</strong>
"""
css: types.css = """
.inner {
font-size: 4px;
}
"""
js: types.js = """
globalThis.testSimpleComponent = 'kapowww!'
"""
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
class Media:
css = "style.css"
js = "script.js"
@register("outer")
class SimpleComponentNested(Component):
template: types.django_html = """
{% load component_tags %}
<div class="outer">
{% component "inner" variable=variable / %}
{% slot "default" default / %}
</div>
"""
css: types.css = """
.outer {
font-size: 40px;
}
"""
js: types.js = """
globalThis.testSimpleComponentNested = 'bongo!'
"""
def get_context_data(self, variable):
return {"variable": variable}
class Media:
css = ["style.css", "style2.css"]
js = "script2.js"
@register("other")
class OtherComponent(Component):
template: types.django_html = """
XYZ: <strong class="other">{{ variable }}</strong>
"""
css: types.css = """
.other {
display: flex;
}
"""
js: types.js = """
globalThis.testOtherComponent = 'wowzee!'
"""
def get_context_data(self, variable):
return {"variable": variable}
class Media:
css = "style.css"
js = "script.js"

View file

@ -29,6 +29,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django_components",
"testserver",
]
# Application definition

View file

@ -0,0 +1 @@
globalThis.testMsg = { hello: "world" };

View file

@ -0,0 +1 @@
globalThis.testMsg2 = { hello2: "world2" };

View file

@ -0,0 +1,3 @@
.my-style {
background: blue;
}

View file

@ -0,0 +1,3 @@
.my-style2 {
color: red;
}

View file

@ -1,3 +1,11 @@
from typing import List
from django.http import HttpResponse
from django.urls import include, path
from testserver.views import multiple_components_view, single_component_view
urlpatterns: List = []
urlpatterns = [
path("single/", single_component_view, name="single"),
path("multi/", multiple_components_view, name="multi"),
path("", include("django_components.urls")),
# Empty response with status 200 to notify other systems when the server has started
path("poll/", lambda *args, **kwargs: HttpResponse("")),
]

View file

@ -0,0 +1,51 @@
from django.http import HttpResponse
from django.template import Context, Template
from django_components import render_dependencies, types
def single_component_view(request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
</head>
<body>
{% component 'inner' variable='foo' / %}
<div class="my-style">123</div>
<div class="my-style2">xyz</div>
{% component_js_dependencies %}
</body>
</html>
"""
template = Template(template_str)
rendered_raw = template.render(Context({}))
rendered = render_dependencies(rendered_raw)
return HttpResponse(rendered)
def multiple_components_view(request):
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_css_dependencies %}
</head>
<body>
{% component 'outer' variable='variable' %}
{% component 'other' variable='variable_inner' / %}
{% endcomponent %}
<div class="my-style">123</div>
<div class="my-style2">xyz</div>
{% component_js_dependencies %}
</body>
</html>
"""
template = Template(template_str)
rendered_raw = template.render(Context({}))
rendered = render_dependencies(rendered_raw)
return HttpResponse(rendered)

View file

@ -52,7 +52,7 @@ def run_django_dev_server():
start_time = time.time()
while time.time() - start_time < 30: # timeout after 30 seconds
try:
response = requests.get(f"http://127.0.0.1:{TEST_SERVER_PORT}")
response = requests.get(f"http://127.0.0.1:{TEST_SERVER_PORT}/poll")
if response.status_code == 200:
print("Django dev server is up and running.")
break

View file

@ -5,7 +5,8 @@
"paths": {
"calendar/script.js": "calendar/script.e1815e23e0ec.js",
"calendar/style.css": "calendar/style.0eeb72042b59.css"
"calendar/style.css": "calendar/style.0eeb72042b59.css",
"django_components/django_components.min.js": "django_components/django_components.min.js"
},
"version": "1.1",
"hash": "f53e7ffd18c4"

View file

@ -1,11 +1,10 @@
import os
import sys
from unittest import TestCase, mock
from unittest import TestCase
from django.conf import settings
from django_components import AlreadyRegistered, registry
from django_components.autodiscover import _filepath_to_python_module, autodiscover, import_libraries
from django_components.autodiscovery import autodiscover, import_libraries
from .django_test_setup import setup_test_config
@ -116,50 +115,3 @@ class TestImportLibraries(_TestCase):
self.assertIn("multi_file_component", all_components)
settings.COMPONENTS["libraries"] = []
class TestFilepathToPythonModule(_TestCase):
def test_prepares_path(self):
base_path = str(settings.BASE_DIR)
the_path = os.path.join(base_path, "tests.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests",
)
the_path = os.path.join(base_path, "tests/components/relative_file/relative_file.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests.components.relative_file.relative_file",
)
def test_handles_nonlinux_paths(self):
base_path = str(settings.BASE_DIR).replace("/", "//")
with mock.patch("os.path.sep", new="//"):
the_path = os.path.join(base_path, "tests.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests",
)
the_path = os.path.join(base_path, "tests//components//relative_file//relative_file.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests.components.relative_file.relative_file",
)
base_path = str(settings.BASE_DIR).replace("//", "\\")
with mock.patch("os.path.sep", new="\\"):
the_path = os.path.join(base_path, "tests.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests",
)
the_path = os.path.join(base_path, "tests\\components\\relative_file\\relative_file.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests.components.relative_file.relative_file",
)

View file

@ -24,8 +24,9 @@ from django.test import Client
from django.urls import path
from django.utils.safestring import SafeString
from django_components import Component, ComponentView, SlotFunc, register, registry, types
from django_components import Component, ComponentView, Slot, SlotFunc, register, registry, types
from django_components.slots import SlotRef
from django_components.urls import urlpatterns as dc_urlpatterns
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
@ -40,7 +41,7 @@ class CustomClient(Client):
if urlpatterns:
urls_module = types.ModuleType("urls")
urls_module.urlpatterns = urlpatterns # type: ignore
urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore
settings.ROOT_URLCONF = urls_module
else:
settings.ROOT_URLCONF = __name__
@ -57,7 +58,7 @@ class CompData(TypedDict):
class CompSlots(TypedDict):
my_slot: Union[str, int]
my_slot: Union[str, int, Slot]
my_slot2: SlotFunc
@ -282,7 +283,8 @@ class ComponentTest(BaseTestCase):
tester.assertEqual(self.input.args, (123, "str"))
tester.assertEqual(self.input.kwargs, {"variable": "test", "another": 1})
tester.assertIsInstance(self.input.context, Context)
tester.assertEqual(self.input.slots, {"my_slot": "MY_SLOT"})
tester.assertEqual(list(self.input.slots.keys()), ["my_slot"])
tester.assertEqual(self.input.slots["my_slot"](Context(), None, None), "MY_SLOT")
return {
"variable": variable,
@ -293,7 +295,8 @@ class ComponentTest(BaseTestCase):
tester.assertEqual(self.input.args, (123, "str"))
tester.assertEqual(self.input.kwargs, {"variable": "test", "another": 1})
tester.assertIsInstance(self.input.context, Context)
tester.assertEqual(self.input.slots, {"my_slot": "MY_SLOT"})
tester.assertEqual(list(self.input.slots.keys()), ["my_slot"])
tester.assertEqual(self.input.slots["my_slot"](Context(), None, None), "MY_SLOT")
template_str: types.django_html = """
{% load component_tags %}
@ -318,7 +321,7 @@ class ComponentTest(BaseTestCase):
class ComponentValidationTest(BaseTestCase):
def test_validate_input_passes(self):
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
@ -351,7 +354,7 @@ class ComponentValidationTest(BaseTestCase):
@skipIf(sys.version_info < (3, 11), "Requires >= 3.11")
def test_validate_input_fails(self):
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
@ -426,7 +429,7 @@ class ComponentValidationTest(BaseTestCase):
with self.assertRaisesMessage(
TypeError,
"Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int], got 123.5 of type <class 'float'>", # noqa: E501
"Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int, django_components.slots.Slot], got 123.5 of type <class 'float'>", # noqa: E501
):
TestComponent.render(
kwargs={"variable": "abc", "another": 1},
@ -447,7 +450,7 @@ class ComponentValidationTest(BaseTestCase):
)
def test_validate_input_skipped(self):
class TestComponent(Component[Any, CompKwargs, CompData, Any]):
class TestComponent(Component[Any, CompKwargs, Any, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
@ -479,7 +482,7 @@ class ComponentValidationTest(BaseTestCase):
)
def test_validate_output_passes(self):
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
@ -511,7 +514,7 @@ class ComponentValidationTest(BaseTestCase):
)
def test_validate_output_fails(self):
class TestComponent(Component[CompArgs, CompKwargs, CompData, CompSlots]):
class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]):
def get_context_data(self, var1, var2, variable, another, **attrs):
return {
"variable": variable,
@ -543,7 +546,7 @@ class ComponentValidationTest(BaseTestCase):
one: Union[str, int]
self: "InnerComp" # type: ignore[misc]
InnerComp = Component[Any, InnerKwargs, InnerData, Any] # type: ignore[misc]
InnerComp = Component[Any, InnerKwargs, Any, InnerData, Any, Any] # type: ignore[misc]
class Inner(InnerComp):
def get_context_data(self, one):
@ -564,7 +567,7 @@ class ComponentValidationTest(BaseTestCase):
self: "TodoComp" # type: ignore[misc]
inner: str
TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any] # type: ignore[misc]
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any] # type: ignore[misc]
# NOTE: Since we're using ForwardRef for "TodoComp" and "InnerComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
@ -615,7 +618,7 @@ class ComponentValidationTest(BaseTestCase):
three: List[str]
four: Tuple[int, Union[str, int]]
TodoComp = Component[TodoArgs, TodoKwargs, TodoData, Any]
TodoComp = Component[TodoArgs, TodoKwargs, Any, TodoData, Any, Any]
# NOTE: Since we're using ForwardRef for "TodoComp", we need
# to ensure that the actual types are set as globals, so the ForwardRef class
@ -875,7 +878,9 @@ class ComponentRenderTest(BaseTestCase):
{% endslot %}
"""
with self.assertRaises(TemplateSyntaxError):
with self.assertRaisesMessage(
TemplateSyntaxError, "Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided."
):
SimpleComponent.render()
SimpleComponent.render(
@ -1014,7 +1019,7 @@ class ComponentRenderTest(BaseTestCase):
{% endblock %}
"""
rendered = SimpleComponent.render()
rendered = SimpleComponent.render(render_dependencies=False)
self.assertHTMLEqual(
rendered,
"""
@ -1028,7 +1033,6 @@ class ComponentRenderTest(BaseTestCase):
</main>
</body>
</html>
""",
)

View file

@ -7,6 +7,7 @@ from django.test import Client
from django.urls import path
from django_components import Component, ComponentView, register, types
from django_components.urls import urlpatterns as dc_urlpatterns
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
@ -20,7 +21,7 @@ class CustomClient(Client):
if urlpatterns:
urls_module = types.ModuleType("urls")
urls_module.urlpatterns = urlpatterns # type: ignore
urls_module.urlpatterns = urlpatterns + dc_urlpatterns # type: ignore
settings.ROOT_URLCONF = urls_module
else:
settings.ROOT_URLCONF = __name__

View file

@ -9,12 +9,12 @@ from django.test import override_settings
from django.utils.html import format_html, html_safe
from django.utils.safestring import mark_safe
from django_components import Component, registry, types
from django_components import Component, registry, render_dependencies, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, autodiscover_with_cleanup
setup_test_config()
setup_test_config({"autodiscover": False})
class InlineComponentTest(BaseTestCase):
@ -22,102 +22,35 @@ class InlineComponentTest(BaseTestCase):
class InlineHTMLComponent(Component):
template = "<div class='inline'>Hello Inline</div>"
comp = InlineHTMLComponent("inline_html_component")
self.assertHTMLEqual(
comp.render(Context({})),
InlineHTMLComponent.render(),
"<div class='inline'>Hello Inline</div>",
)
def test_html_and_css(self):
class HTMLCSSComponent(Component):
template = "<div class='html-css-only'>Content</div>"
def test_inlined_js_and_css(self):
class TestComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
<div class='html-css-only'>Content</div>
"""
css = ".html-css-only { color: blue; }"
js = "console.log('HTML and JS only');"
comp = HTMLCSSComponent("html_css_component")
self.assertHTMLEqual(
comp.render(Context({})),
rendered = TestComponent.render()
self.assertInHTML(
"<div class='html-css-only'>Content</div>",
rendered,
)
self.assertHTMLEqual(
comp.render_css_dependencies(),
self.assertInHTML(
"<style>.html-css-only { color: blue; }</style>",
rendered,
)
def test_html_and_js(self):
class HTMLJSComponent(Component):
template = "<div class='html-js-only'>Content</div>"
js = "console.log('HTML and JS only');"
comp = HTMLJSComponent("html_js_component")
self.assertHTMLEqual(
comp.render(Context({})),
"<div class='html-js-only'>Content</div>",
)
self.assertHTMLEqual(
comp.render_js_dependencies(),
"<script>console.log('HTML and JS only');</script>",
)
def test_html_inline_and_css_js_files(self):
class HTMLStringFileCSSJSComponent(Component):
template = "<div class='html-string-file'>Content</div>"
class Media:
css = "path/to/style.css"
js = "path/to/script.js"
comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component")
self.assertHTMLEqual(
comp.render(Context({})),
"<div class='html-string-file'>Content</div>",
)
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<script src="path/to/script.js"></script>
""",
)
def test_html_js_inline_and_css_file(self):
class HTMLStringFileCSSJSComponent(Component):
template = "<div class='html-string-file'>Content</div>"
js = "console.log('HTML and JS only');"
class Media:
css = "path/to/style.css"
comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component")
self.assertHTMLEqual(
comp.render(Context({})),
"<div class='html-string-file'>Content</div>",
)
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<script>console.log('HTML and JS only');</script>
""",
)
def test_html_css_inline_and_js_file(self):
class HTMLStringFileCSSJSComponent(Component):
template = "<div class='html-string-file'>Content</div>"
css = ".html-string-file { color: blue; }"
class Media:
js = "path/to/script.js"
comp = HTMLStringFileCSSJSComponent("html_string_file_css_js_component")
self.assertHTMLEqual(
comp.render(Context({})),
"<div class='html-string-file'>Content</div>",
)
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<style>.html-string-file { color: blue; }</style><script src="path/to/script.js"></script>
""",
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&#x27;HTML and JS only&#x27;);`))</script>",
rendered,
)
def test_html_variable(self):
@ -156,117 +89,78 @@ class InlineComponentTest(BaseTestCase):
class ComponentMediaTests(BaseTestCase):
def test_css_and_js(self):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
class Media:
css = "style.css"
js = "script.js"
comp = SimpleComponent("simple_component")
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="style.css" media="all" rel="stylesheet">
<script src="script.js"></script>
""",
)
def test_css_only(self):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
class Media:
css = "style.css"
comp = SimpleComponent("simple_component")
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="style.css" media="all" rel="stylesheet">
""",
)
def test_js_only(self):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
class Media:
js = "script.js"
comp = SimpleComponent("simple_component")
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<script src="script.js"></script>
""",
)
def test_empty_media(self):
class SimpleComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
Variable: <strong>{{ variable }}</strong>
"""
class Media:
pass
comp = SimpleComponent("simple_component")
rendered = SimpleComponent.render()
self.assertHTMLEqual(comp.render_dependencies(), "")
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<link"), 0)
def test_missing_media(self):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
comp = SimpleComponent("simple_component")
self.assertHTMLEqual(comp.render_dependencies(), "")
self.assertEqual(rendered.count("<script"), 2) # 2 Boilerplate scripts
def test_css_js_as_lists(self):
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = ["path/to/style.css", "path/to/style2.css"]
js = ["path/to/script.js"]
comp = SimpleComponent("")
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<script src="path/to/script.js"></script>
""",
rendered = SimpleComponent.render()
self.assertInHTML('<link href="path/to/style.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style2.css" media="all" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_css_js_as_string(self):
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = "path/to/style.css"
js = "path/to/script.js"
comp = SimpleComponent("")
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<script src="path/to/script.js"></script>
""",
rendered = SimpleComponent.render()
self.assertInHTML('<link href="path/to/style.css" media="all" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_css_as_dict(self):
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = {
"all": "path/to/style.css",
@ -275,15 +169,16 @@ class ComponentMediaTests(BaseTestCase):
}
js = ["path/to/script.js"]
comp = SimpleComponent("")
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="print" rel="stylesheet">
<link href="path/to/style3.css" media="screen" rel="stylesheet">
<script src="path/to/script.js"></script>
""",
rendered = SimpleComponent.render()
self.assertInHTML('<link href="path/to/style.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style2.css" media="print" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style3.css" media="screen" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_media_custom_render_js(self):
@ -292,22 +187,31 @@ class ComponentMediaTests(BaseTestCase):
tags: list[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
tags.append(f'<script defer src="{abs_path}"></script>')
return tags
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
media_class = MyMedia
class Media:
js = ["path/to/script.js", "path/to/script2.js"]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<my_script_tag src="path/to/script.js"></my_script_tag>
<my_script_tag src="path/to/script2.js"></my_script_tag>
""",
rendered = SimpleComponent.render()
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script defer src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script defer src=&amp;quot;path/to/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_media_custom_render_css(self):
@ -317,10 +221,16 @@ class ComponentMediaTests(BaseTestCase):
media = sorted(self._css) # type: ignore[attr-defined]
for medium in media:
for path in self._css[medium]: # type: ignore[attr-defined]
tags.append(f'<my_link href="{path}" media="{medium}" rel="stylesheet" />')
tags.append(f'<link abc href="{path}" media="{medium}" rel="stylesheet" />')
return tags
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
media_class = MyMedia
class Media:
@ -330,15 +240,11 @@ class ComponentMediaTests(BaseTestCase):
"screen": "path/to/style3.css",
}
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<my_link href="path/to/style.css" media="all" rel="stylesheet" />
<my_link href="path/to/style2.css" media="print" rel="stylesheet" />
<my_link href="path/to/style3.css" media="screen" rel="stylesheet" />
""",
)
rendered = SimpleComponent.render()
self.assertInHTML('<link abc href="path/to/style.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link abc href="path/to/style2.css" media="print" rel="stylesheet">', rendered)
self.assertInHTML('<link abc href="path/to/style3.css" media="screen" rel="stylesheet">', rendered)
class MediaPathAsObjectTests(BaseTestCase):
@ -377,6 +283,12 @@ class MediaPathAsObjectTests(BaseTestCase):
return format_html('<script type="module" src="{}"></script>', static(self.static_path))
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = {
"all": [
@ -395,20 +307,29 @@ class MediaPathAsObjectTests(BaseTestCase):
"path/to/script4.js", # Formatted by Media.render_js
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link css_tag href="path/to/style.css" rel="stylesheet" />
<link hi href="path/to/style2.css" rel="stylesheet" />
<link css_tag href="path/to/style3.css" rel="stylesheet" />
<link href="path/to/style4.css" media="screen" rel="stylesheet">
rendered = SimpleComponent.render()
<script js_tag src="path/to/script.js" type="module"></script>
<script hi src="path/to/script2.js"></script>
<script type="module" src="path/to/script3.js"></script>
<script src="path/to/script4.js"></script>
""",
self.assertInHTML('<link css_tag href="path/to/style.css" rel="stylesheet" />', rendered)
self.assertInHTML('<link hi href="path/to/style2.css" rel="stylesheet" />', rendered)
self.assertInHTML('<link css_tag href="path/to/style3.css" rel="stylesheet" />', rendered)
self.assertInHTML('<link href="path/to/style4.css" media="screen" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script js_tag src=&amp;quot;path/to/script.js&amp;quot; type=&amp;quot;module&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script hi src=&amp;quot;path/to/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script type=&amp;quot;module&amp;quot; src=&amp;quot;path/to/script3.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script4.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_pathlike(self):
@ -425,6 +346,12 @@ class MediaPathAsObjectTests(BaseTestCase):
return self.path
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = {
"all": [
@ -442,19 +369,25 @@ class MediaPathAsObjectTests(BaseTestCase):
"path/to/script3.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<link href="path/to/style3.css" media="print" rel="stylesheet">
<link href="path/to/style4.css" media="screen" rel="stylesheet">
rendered = SimpleComponent.render()
<script src="path/to/script.js"></script>
<script src="path/to/script2.js"></script>
<script src="path/to/script3.js"></script>
""",
self.assertInHTML('<link href="path/to/style.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style2.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style3.css" media="print" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style4.css" media="screen" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script3.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_str(self):
@ -467,6 +400,12 @@ class MediaPathAsObjectTests(BaseTestCase):
pass
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = {
"all": [
@ -483,18 +422,21 @@ class MediaPathAsObjectTests(BaseTestCase):
"path/to/script2.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<link href="path/to/style3.css" media="print" rel="stylesheet">
<link href="path/to/style4.css" media="screen" rel="stylesheet">
rendered = SimpleComponent.render()
<script src="path/to/script.js"></script>
<script src="path/to/script2.js"></script>
""",
self.assertInHTML('<link href="path/to/style.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style2.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style3.css" media="print" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style4.css" media="screen" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_bytes(self):
@ -507,6 +449,12 @@ class MediaPathAsObjectTests(BaseTestCase):
pass
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = {
"all": [
@ -523,22 +471,31 @@ class MediaPathAsObjectTests(BaseTestCase):
"path/to/script2.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="path/to/style.css" media="all" rel="stylesheet">
<link href="path/to/style2.css" media="all" rel="stylesheet">
<link href="path/to/style3.css" media="print" rel="stylesheet">
<link href="path/to/style4.css" media="screen" rel="stylesheet">
rendered = SimpleComponent.render()
<script src="path/to/script.js"></script>
<script src="path/to/script2.js"></script>
""",
self.assertInHTML('<link href="path/to/style.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style2.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style3.css" media="print" rel="stylesheet">', rendered)
self.assertInHTML('<link href="path/to/style4.css" media="screen" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;path/to/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
def test_function(self):
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = [
lambda: mark_safe('<link hi href="calendar/style.css" rel="stylesheet" />'), # Literal
@ -553,20 +510,29 @@ class MediaPathAsObjectTests(BaseTestCase):
lambda: b"calendar/script3.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link hi href="calendar/style.css" rel="stylesheet" />
<link href="calendar/style1.css" media="all" rel="stylesheet">
<link href="calendar/style2.css" media="all" rel="stylesheet">
<link href="calendar/style3.css" media="all" rel="stylesheet">
rendered = SimpleComponent.render()
<script hi src="calendar/script.js"></script>
<script src="calendar/script1.js"></script>
<script src="calendar/script2.js"></script>
<script src="calendar/script3.js"></script>
""",
self.assertInHTML('<link hi href="calendar/style.css" rel="stylesheet" />', rendered)
self.assertInHTML('<link href="calendar/style1.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="calendar/style2.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="calendar/style3.css" media="all" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script hi src=&amp;quot;calendar/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;calendar/script1.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;calendar/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;calendar/script3.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
@override_settings(STATIC_URL="static/")
@ -574,6 +540,12 @@ class MediaPathAsObjectTests(BaseTestCase):
"""Test that all the different ways of defining media files works with Django's staticfiles"""
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
class Media:
css = [
mark_safe(f'<link hi href="{static("calendar/style.css")}" rel="stylesheet" />'), # Literal
@ -590,22 +562,30 @@ class MediaPathAsObjectTests(BaseTestCase):
lambda: "calendar/script4.js",
]
comp = SimpleComponent()
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link hi href="/static/calendar/style.css" rel="stylesheet" />
<link href="/static/calendar/style1.css" media="all" rel="stylesheet">
<link href="/static/calendar/style2.css" media="all" rel="stylesheet">
<link href="/static/calendar/style3.css" media="all" rel="stylesheet">
<link href="/static/calendar/style4.css" media="all" rel="stylesheet">
rendered = SimpleComponent.render()
<script hi src="/static/calendar/script.js"></script>
<script src="/static/calendar/script1.js"></script>
<script src="/static/calendar/script2.js"></script>
<script src="/static/calendar/script3.js"></script>
<script src="/static/calendar/script4.js"></script>
""",
self.assertInHTML('<link hi href="/static/calendar/style.css" rel="stylesheet" />', rendered)
self.assertInHTML('<link href="/static/calendar/style1.css" media="all" rel="stylesheet" />', rendered)
self.assertInHTML('<link href="/static/calendar/style1.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="/static/calendar/style2.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML('<link href="/static/calendar/style3.css" media="all" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script hi src=&amp;quot;/static/calendar/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;/static/calendar/script1.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;/static/calendar/script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script src=&amp;quot;/static/calendar/script3.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
@ -632,26 +612,32 @@ class MediaStaticfilesTests(BaseTestCase):
tags: list[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
tags.append(f'<script defer src="{abs_path}"></script>')
return tags
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
media_class = MyMedia
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
comp = SimpleComponent()
rendered = SimpleComponent.render()
# NOTE: Since we're using the default storage class for staticfiles, the files should
# be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir.
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="/static/calendar/style.css" media="all" rel="stylesheet">
<my_script_tag src="/static/calendar/script.js"></my_script_tag>
""",
self.assertInHTML('<link href="/static/calendar/style.css" media="all" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script defer src=&amp;quot;/static/calendar/script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
# For context see https://github.com/EmilStenstrom/django-components/issues/522
@ -688,26 +674,34 @@ class MediaStaticfilesTests(BaseTestCase):
tags: list[str] = []
for path in self._js: # type: ignore[attr-defined]
abs_path = self.absolute_path(path) # type: ignore[attr-defined]
tags.append(f'<my_script_tag src="{abs_path}"></my_script_tag>')
tags.append(f'<script defer src="{abs_path}"></script>')
return tags
class SimpleComponent(Component):
template = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
media_class = MyMedia
class Media:
css = "calendar/style.css"
js = "calendar/script.js"
comp = SimpleComponent()
rendered = SimpleComponent.render()
# NOTE: Since we're using ManifestStaticFilesStorage, we expect the rendered media to link
# to the files as defined in staticfiles.json
self.assertHTMLEqual(
comp.render_dependencies(),
"""
<link href="/static/calendar/style.0eeb72042b59.css" media="all" rel="stylesheet">
<my_script_tag src="/static/calendar/script.e1815e23e0ec.js"></my_script_tag>
""",
self.assertInHTML(
'<link href="/static/calendar/style.0eeb72042b59.css" media="all" rel="stylesheet">', rendered
)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script defer src=&amp;quot;/static/calendar/script.e1815e23e0ec.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
)
@ -776,22 +770,30 @@ class MediaRelativePathTests(BaseTestCase):
registry.unregister(comp_name)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component name='relative_file_component' variable=variable %}
{% endcomponent %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component name='relative_file_component' variable=variable / %}
"""
template = Template(template_str)
rendered = template.render(Context({"variable": "test"}))
self.assertHTMLEqual(
rendered,
rendered = render_dependencies(template.render(Context({"variable": "test"})))
self.assertInHTML('<link href="relative_file/relative_file.css" media="all" rel="stylesheet">', rendered)
self.assertInHTML(
"""
<link href="relative_file/relative_file.css" media="all" rel="stylesheet">
<script src="relative_file/relative_file.js"></script>
<form method="post">
<input type="text" name="variable" value="test">
<input type="submit">
</form>
""",
rendered,
)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;link href=&amp;quot;relative_file/relative_file.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\\`)",
rendered,
)
# Settings required for autodiscover to work
@ -811,7 +813,9 @@ class MediaRelativePathTests(BaseTestCase):
registry.unregister("relative_file_pathobj_component")
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'parent_component' %}
{% fill 'content' %}
{% component name='relative_file_component' variable='hello' %}
@ -848,17 +852,19 @@ class MediaRelativePathTests(BaseTestCase):
# Fix the paths, since the "components" dir is nested
with autodiscover_with_cleanup(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p):
# Mark the PathObj instances of 'relative_file_pathobj_component' so they won raise
# error PathObj.__str__ is triggered.
# Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise
# error if PathObj.__str__ is triggered.
CompCls = registry.get("relative_file_pathobj_component")
CompCls.Media.js[0].throw_on_calling_str = False # type: ignore
CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore
rendered = CompCls().render_dependencies()
self.assertHTMLEqual(
rendered = CompCls.render(kwargs={"variable": "abc"})
self.assertInHTML('<input type="text" name="variable" value="abc">', rendered)
self.assertInHTML('<link href="relative_file_pathobj.css" rel="stylesheet">', rendered)
# Command to load the JS from Media.js
self.assertIn(
"Components.unescapeJs(\\`&amp;lt;script type=&amp;quot;module&amp;quot; src=&amp;quot;relative_file_pathobj.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\\`)",
rendered,
"""
<script type="module" src="relative_file_pathobj.css"></script>
<script type="module" src="relative_file_pathobj.js"></script>
""",
)

View file

@ -21,10 +21,6 @@ class SimpleComponent(Component):
def get_context_data(self, variable=None):
return {"variable": variable} if variable is not None else {}
@staticmethod
def expected_output(variable_value):
return "Variable: < strong > {} < / strong >".format(variable_value)
class VariableDisplay(Component):
template: types.django_html = """
@ -98,7 +94,7 @@ class ContextTests(BaseTestCase):
self,
):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'parent_component' %}{% endcomponent %}
"""
template = Template(template_str)
@ -118,7 +114,6 @@ class ContextTests(BaseTestCase):
):
template_str: types.django_html = """
{% load component_tags %}
{% component_dependencies %}
{% component name='parent_component' %}{% endcomponent %}
"""
template = Template(template_str)
@ -134,7 +129,7 @@ class ContextTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_nested_component_context_shadows_parent_with_filled_slots(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'parent_component' %}
{% fill 'content' %}
{% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}
@ -157,7 +152,6 @@ class ContextTests(BaseTestCase):
def test_nested_component_instances_have_unique_context_with_filled_slots(self):
template_str: types.django_html = """
{% load component_tags %}
{% component_dependencies %}
{% component 'parent_component' %}
{% fill 'content' %}
{% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}
@ -181,7 +175,6 @@ class ContextTests(BaseTestCase):
):
template_str: types.django_html = """
{% load component_tags %}
{% component_dependencies %}
{% component name='parent_component' %}{% endcomponent %}
"""
template = Template(template_str)
@ -200,7 +193,7 @@ class ContextTests(BaseTestCase):
self,
):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'parent_component' %}
{% fill 'content' %}
{% component name='variable_display' shadowing_variable='shadow_from_slot' new_variable='unique_from_slot' %}
@ -250,7 +243,7 @@ class ParentArgsTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_parent_args_can_be_drawn_from_context(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'parent_with_args' parent_value=parent_value %}
{% endcomponent %}
"""
@ -276,7 +269,7 @@ class ParentArgsTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_parent_args_available_outside_slots(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'parent_with_args' parent_value='passed_in' %}{%endcomponent %}
"""
template = Template(template_str)
@ -297,7 +290,7 @@ class ParentArgsTests(BaseTestCase):
first_val, second_val = context_behavior_data
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'parent_with_args' parent_value='passed_in' %}
{% fill 'content' %}
{% component name='variable_display' shadowing_variable='value_from_slot' new_variable=inner_parent_value %}
@ -331,7 +324,7 @@ class ContextCalledOnceTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_one_context_call_with_simple_component(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component name='incrementer' %}{% endcomponent %}
"""
template = Template(template_str)
@ -427,7 +420,7 @@ class ComponentsCanAccessOuterContext(BaseTestCase):
)
def test_simple_component_can_use_outer_context(self, context_behavior_data):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' %}{% endcomponent %}
"""
template = Template(template_str)
@ -448,7 +441,7 @@ class IsolatedContextTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_simple_component_can_pass_outer_context_in_args(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' variable only %}{% endcomponent %}
"""
template = Template(template_str)
@ -458,7 +451,7 @@ class IsolatedContextTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_simple_component_cannot_use_outer_context(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' only %}{% endcomponent %}
"""
template = Template(template_str)
@ -476,7 +469,7 @@ class IsolatedContextSettingTests(BaseTestCase):
self,
):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' variable %}{% endcomponent %}
"""
template = Template(template_str)
@ -488,7 +481,7 @@ class IsolatedContextSettingTests(BaseTestCase):
self,
):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' %}{% endcomponent %}
"""
template = Template(template_str)
@ -500,7 +493,7 @@ class IsolatedContextSettingTests(BaseTestCase):
self,
):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' variable %}
{% endcomponent %}
"""
@ -513,7 +506,7 @@ class IsolatedContextSettingTests(BaseTestCase):
self,
):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'simple_component' %}
{% endcomponent %}
"""
@ -538,7 +531,7 @@ class OuterContextPropertyTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_outer_context_property_with_component(self):
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component 'outer_context_component' only %}{% endcomponent %}
"""
template = Template(template_str)
@ -551,12 +544,17 @@ class ContextVarsIsFilledTests(BaseTestCase):
template: types.django_html = """
{% load component_tags %}
<div class="frontmatter-component">
{% slot "title" default %}{% endslot %}
{% slot "my_title" %}{% endslot %}
{% slot "my title 1" %}{% endslot %}
{% slot "my-title-2" %}{% endslot %}
{% slot "escape this: #$%^*()" %}{% endslot %}
{{ component_vars.is_filled|safe }}
{% slot "title" default / %}
{% slot "my-title" / %}
{% slot "my-title-1" / %}
{% slot "my-title-2" / %}
{% slot "escape this: #$%^*()" / %}
title: {{ component_vars.is_filled.title }}
my_title: {{ component_vars.is_filled.my_title }}
my_title_1: {{ component_vars.is_filled.my_title_1 }}
my_title_2: {{ component_vars.is_filled.my_title_2 }}
escape_this_________: {{ component_vars.is_filled.escape_this_________ }}
</div>
"""
@ -593,7 +591,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
def setUp(self) -> None:
super().setUp()
registry.register("is_filled_vars", self.IsFilledVarsComponent)
registry.register("conditional_slots", self.ComponentWithConditionalSlots)
registry.register(
"complex_conditional_slots",
@ -602,28 +599,34 @@ class ContextVarsIsFilledTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_is_filled_vars(self):
registry.register("is_filled_vars", self.IsFilledVarsComponent)
template: types.django_html = """
{% load component_tags %}
{% component "is_filled_vars" %}
{% fill "title" %}{% endfill %}
{% fill "my-title-2" %}{% endfill %}
{% fill "escape this: #$%^*()" %}{% endfill %}
{% fill "title" / %}
{% fill "my-title-2" / %}
{% fill "escape this: #$%^*()" / %}
{% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
<div class="frontmatter-component">
{'title': True,
'my_title': False,
'my_title_1': False,
'my_title_2': True,
'escape_this_________': True}
title: True
my_title: False
my_title_1: False
my_title_2: True
escape_this_________: True
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_is_filled_vars_default(self):
registry.register("is_filled_vars", self.IsFilledVarsComponent)
template: types.django_html = """
{% load component_tags %}
{% component "is_filled_vars" %}
@ -634,11 +637,11 @@ class ContextVarsIsFilledTests(BaseTestCase):
expected = """
<div class="frontmatter-component">
bla bla
{'title': True,
'my_title': False,
'my_title_1': False,
'my_title_2': False,
'escape_this_________': False}
title: False
my_title: False
my_title_1: False
my_title_2: False
escape_this_________: False
</div>
"""
self.assertHTMLEqual(rendered, expected)
@ -776,12 +779,6 @@ class ContextVarsIsFilledTests(BaseTestCase):
"""
Template(template).render(Context())
expected = {
"title": True,
"my_title": False,
"my_title_1": False,
"my_title_2": False,
"escape_this_________": False,
}
expected = {"default": True}
self.assertEqual(captured_before, expected)
self.assertEqual(captured_after, expected)

333
tests/test_dependencies.py Normal file
View file

@ -0,0 +1,333 @@
from unittest.mock import Mock
from django.http import HttpResponseNotModified
from django.template import Context, Template
from selectolax.lexbor import LexborHTMLParser
from django_components import Component, registry, render_dependencies, types
from django_components.components.dynamic import DynamicComponent
from django_components.middleware import ComponentDependencyMiddleware
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, create_and_process_template_response
setup_test_config({"autodiscover": False})
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
"""
css: types.css = """
.xyz {
color: red;
}
"""
js: types.js = """
console.log("xyz");
"""
def get_context_data(self, variable, variable2="default"):
return {
"variable": variable,
"variable2": variable2,
}
class Media:
css = "style.css"
js = "script.js"
class RenderDependenciesTests(BaseTestCase):
def test_standalone_render_dependencies(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'test' variable='foo' / %}
"""
template = Template(template_str)
rendered_raw = template.render(Context({}))
# Placeholders
self.assertEqual(rendered_raw.count('<link name="CSS_PLACEHOLDER">'), 1)
self.assertEqual(rendered_raw.count('<script name="JS_PLACEHOLDER"></script>'), 1)
self.assertEqual(rendered_raw.count("<script"), 1)
self.assertEqual(rendered_raw.count("<style"), 0)
self.assertEqual(rendered_raw.count("<link"), 1)
self.assertEqual(rendered_raw.count("_RENDERED"), 1)
rendered = render_dependencies(rendered_raw)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
def test_middleware_renders_dependencies(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'test' variable='foo' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template, use_middleware=True)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("<style"), 1)
def test_component_render_renders_dependencies(self):
class SimpleComponentWithDeps(SimpleComponent):
template: types.django_html = (
"""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
+ SimpleComponent.template
)
registry.register(name="test", component=SimpleComponentWithDeps)
rendered = SimpleComponentWithDeps.render(
kwargs={"variable": "foo"},
)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("<style"), 1)
def test_component_render_renders_dependencies_opt_out(self):
class SimpleComponentWithDeps(SimpleComponent):
template: types.django_html = (
"""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
+ SimpleComponent.template
)
registry.register(name="test", component=SimpleComponentWithDeps)
rendered_raw = SimpleComponentWithDeps.render(
kwargs={"variable": "foo"},
render_dependencies=False,
)
self.assertEqual(rendered_raw.count("<script"), 1)
self.assertEqual(rendered_raw.count("<style"), 0)
self.assertEqual(rendered_raw.count("<link"), 1)
self.assertEqual(rendered_raw.count("_RENDERED"), 1)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered_raw, count=0)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered_raw, count=0) # Inlined CSS
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered_raw, count=0) # Media.css
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>",
rendered_raw,
count=0,
) # Inlined JS
def test_component_render_to_response_renders_dependencies(self):
class SimpleComponentWithDeps(SimpleComponent):
template: types.django_html = (
"""
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
+ SimpleComponent.template
)
registry.register(name="test", component=SimpleComponentWithDeps)
response = SimpleComponentWithDeps.render_to_response(
kwargs={"variable": "foo"},
)
rendered = response.content.decode()
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1) # Inlined CSS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
) # Inlined JS
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("<style"), 1)
def test_inserts_styles_and_script_to_default_places_if_not_overriden(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head></head>
<body>
{% component "test" variable="foo" / %}
</body>
</html>
"""
rendered_raw = Template(template_str).render(Context({}))
rendered = render_dependencies(rendered_raw)
self.assertEqual(rendered.count("<script"), 3)
self.assertEqual(rendered.count("<style"), 1)
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("_RENDERED"), 0)
self.assertInHTML(
"""
<head>
<style>.xyz { color: red; }</style>
<link href="style.css" media="all" rel="stylesheet">
</head>
""",
rendered,
count=1,
)
rendered_body = LexborHTMLParser(rendered).body.html # type: ignore[union-attr]
self.assertInHTML(
"""<script src="django_components/django_components.min.js">""",
rendered_body,
count=1,
)
self.assertInHTML(
"""<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>""",
rendered_body,
count=1,
)
def test_does_not_insert_styles_and_script_to_default_places_if_overriden(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}
<!DOCTYPE html>
<html>
<head>
{% component_js_dependencies %}
</head>
<body>
{% component "test" variable="foo" / %}
{% component_css_dependencies %}
</body>
</html>
"""
rendered_raw = Template(template_str).render(Context({}))
rendered = render_dependencies(rendered_raw)
self.assertEqual(rendered.count("<script"), 3)
self.assertEqual(rendered.count("<style"), 1)
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("_RENDERED"), 0)
self.assertInHTML(
"""
<body>
Variable: <strong>foo</strong>
<style>.xyz { color: red; }</style>
<link href="style.css" media="all" rel="stylesheet">
</body>
""",
rendered,
count=1,
)
rendered_head = LexborHTMLParser(rendered).head.html # type: ignore[union-attr]
self.assertInHTML(
"""<script src="django_components/django_components.min.js">""",
rendered_head,
count=1,
)
self.assertInHTML(
"""<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>""",
rendered_head,
count=1,
)
class MiddlewareTests(BaseTestCase):
def test_middleware_response_without_content_type(self):
response = HttpResponseNotModified()
middleware = ComponentDependencyMiddleware(get_response=lambda _: response)
request = Mock()
self.assertEqual(response, middleware(request=request))
def test_middleware_response_with_components_with_slash_dash_and_underscore(
self,
):
registry.register("dynamic", DynamicComponent)
component_names = [
"test-component",
"test/component",
"test_component",
]
for component_name in component_names:
registry.register(name=component_name, component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}
{% component_css_dependencies %}
{% component_js_dependencies %}
{% component "dynamic" is=component_name variable='value' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(
template, context=Context({"component_name": component_name})
)
# Dependency manager script (empty)
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
# Inlined JS
self.assertInHTML(
"<script>eval(Components.unescapeJs(`console.log(&quot;xyz&quot;);`))</script>", rendered, count=1
)
# Inlined CSS
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1)
# Media.css
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1)
self.assertEqual(rendered.count("Variable: <strong>value</strong>"), 1)

View file

@ -1,16 +1,22 @@
from unittest.mock import Mock
"""
Here we check that the logic around dependency rendering outputs correct HTML.
During actual rendering, the HTML is then picked up by the JS-side dependency manager.
"""
import re
from django.http import HttpResponseNotModified
from django.template import Template
from django.test import override_settings
from django_components import Component, registry, types
from django_components.middleware import ComponentDependencyMiddleware
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, create_and_process_template_response
setup_test_config()
setup_test_config({"autodiscover": False})
def to_spaces(s: str):
return re.compile(r"\s+").sub(" ", s)
class SimpleComponent(Component):
@ -29,19 +35,56 @@ class SimpleComponent(Component):
js = "script.js"
class SimpleComponentAlternate(Component):
class SimpleComponentNested(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
{% load component_tags %}
<div>
{% component "inner" variable=variable / %}
{% slot "default" default / %}
</div>
"""
css: types.css = """
.my-class {
color: red;
}
"""
js: types.js = """
console.log("Hello");
"""
def get_context_data(self, variable):
return {}
class Media:
css = "style2.css"
css = ["style.css", "style2.css"]
js = "script2.js"
class OtherComponent(Component):
template: types.django_html = """
XYZ: <strong>{{ variable }}</strong>
"""
css: types.css = """
.xyz {
color: red;
}
"""
js: types.js = """
console.log("xyz");
"""
def get_context_data(self, variable):
return {}
class Media:
css = "xyz1.css"
js = "xyz1.js"
class SimpleComponentWithSharedDependency(Component):
template: types.django_html = """
Variable: <strong>{{ variable }}</strong>
@ -65,22 +108,29 @@ class MultistyleComponent(Component):
js = ["script.js", "script2.js"]
@override_settings(COMPONENTS={"RENDER_DEPENDENCIES": True})
class ComponentMediaRenderingTests(BaseTestCase):
class DependencyRenderingTests(BaseTestCase):
def test_no_dependencies_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=0)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(rendered.count("const loadedCssScripts = [];"), 1)
self.assertEqual(rendered.count(r"const toLoadJsScripts = [];"), 1)
self.assertEqual(rendered.count(r"const toLoadCssScripts = [];"), 1)
def test_no_js_dependencies_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)
@ -90,7 +140,18 @@ class ComponentMediaRenderingTests(BaseTestCase):
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=0)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(rendered.count("const loadedCssScripts = [];"), 1)
self.assertEqual(rendered.count(r"const toLoadJsScripts = [];"), 1)
self.assertEqual(rendered.count(r"const toLoadCssScripts = [];"), 1)
def test_no_css_dependencies_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)
@ -100,108 +161,93 @@ class ComponentMediaRenderingTests(BaseTestCase):
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
def test_preload_dependencies_render_when_no_components_used(self):
self.assertEqual(rendered.count("<script"), 0) # No JS
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
def test_single_component_dependencies(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies preload='test' %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'test' variable='foo' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=1)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 2)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(rendered.count("const loadedCssScripts = [&quot;style.css&quot;];"), 1)
self.assertEqual(
rendered.count(
r"const toLoadJsScripts = [Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`)];"
),
1,
)
def test_preload_css_dependencies_render_when_no_components_used(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_css_dependencies preload='test' %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
self.assertEqual(
rendered.count(
r"const toLoadCssScripts = [Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`)];"
),
1,
)
def test_single_component_dependencies_render_when_used(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test' variable='foo' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
self.assertInHTML('<script src="script.js">', rendered, count=1)
def test_single_component_with_dash_or_slash_in_name(self):
registry.register(name="test", component=SimpleComponent)
registry.register(name="te-s/t", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test' variable='foo' %}{% endcomponent %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'te-s/t' variable='foo' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 2)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(rendered.count("const loadedCssScripts = [&quot;style.css&quot;];"), 1)
self.assertEqual(
rendered.count(
r"const toLoadJsScripts = [Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`)];"
),
1,
)
self.assertInHTML('<script src="script.js">', rendered, count=1)
def test_preload_dependencies_render_once_when_used(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies preload='test' %}
{% component 'test' variable='foo' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
self.assertEqual(
rendered.count(
r"const toLoadCssScripts = [Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`)];"
),
1,
)
self.assertInHTML('<script src="script.js">', rendered, count=1)
def test_placeholder_removed_when_single_component_rendered(self):
def test_single_component_placeholder_removed(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test' variable='foo' %}{% endcomponent %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'test' variable='foo' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertNotIn("_RENDERED", rendered)
def test_placeholder_removed_when_preload_rendered(self):
registry.register(name="test", component=SimpleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies preload='test' %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertNotIn("_RENDERED", rendered)
def test_single_component_css_dependencies(self):
@ -209,15 +255,19 @@ class ComponentMediaRenderingTests(BaseTestCase):
template_str: types.django_html = """
{% load component_tags %}{% component_css_dependencies %}
{% component 'test' variable='foo' %}{% endcomponent %}
{% component 'test' variable='foo' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
# Dependency manager script - NOT present
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=0)
self.assertEqual(rendered.count("<link"), 1)
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 0) # No JS scripts
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
def test_single_component_js_dependencies(self):
registry.register(name="test", component=SimpleComponent)
@ -228,227 +278,167 @@ class ComponentMediaRenderingTests(BaseTestCase):
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=1)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
# CSS NOT included
self.assertEqual(rendered.count("<link"), 0)
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 2)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(rendered.count("const loadedCssScripts = [&quot;style.css&quot;];"), 1)
self.assertEqual(
rendered.count(
r"const toLoadJsScripts = [Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`)];"
),
1,
)
self.assertEqual(
rendered.count(
r"const toLoadCssScripts = [Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`)];"
),
1,
)
def test_all_dependencies_are_rendered_for_component_with_multiple_dependencies(
self,
):
registry.register(name="test", component=MultistyleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test' %}{% endcomponent %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'test' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=1)
self.assertInHTML('<script src="script2.js">', rendered, count=1)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
def test_all_js_dependencies_are_rendered_for_component_with_multiple_dependencies(
self,
):
registry.register(name="test", component=MultistyleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_js_dependencies %}
{% component 'test' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=1)
self.assertInHTML('<script src="script2.js">', rendered, count=1)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
def test_all_css_dependencies_are_rendered_for_component_with_multiple_dependencies(
self,
):
registry.register(name="test", component=MultistyleComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_css_dependencies %}
{% component 'test' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=0)
self.assertInHTML('<script src="script2.js">', rendered, count=0)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
self.assertEqual(rendered.count("<link"), 2)
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("<script"), 2) # Boilerplate scripts
self.assertEqual(rendered.count('<link href="style.css" media="all" rel="stylesheet">'), 1) # Media.css
self.assertEqual(rendered.count('<link href="style2.css" media="all" rel="stylesheet">'), 1)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(
rendered.count("const loadedCssScripts = [&quot;style.css&quot;, &quot;style2.css&quot;];"), 1
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
self.assertEqual(
rendered.count(
r"const toLoadJsScripts = [Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`), Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`)];"
),
1,
)
self.assertEqual(
rendered.count(
r"const toLoadCssScripts = [Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`), Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style2.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`)];"
),
1,
)
def test_no_dependencies_with_multiple_unused_components(self):
registry.register(name="test1", component=SimpleComponent)
registry.register(name="test2", component=SimpleComponentAlternate)
registry.register(name="inner", component=SimpleComponent)
registry.register(name="outer", component=SimpleComponentNested)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=0)
self.assertInHTML('<script src="script2.js">', rendered, count=0)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
def test_correct_css_dependencies_with_multiple_components(self):
registry.register(name="test1", component=SimpleComponent)
registry.register(name="test2", component=SimpleComponentAlternate)
# Dependency manager script
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 2) # Two 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<link"), 0) # No CSS
self.assertEqual(rendered.count("<style"), 0)
self.assertEqual(rendered.count("const loadedJsScripts = [];"), 1)
self.assertEqual(rendered.count("const loadedCssScripts = [];"), 1)
self.assertEqual(rendered.count("const toLoadJsScripts = [];"), 1)
self.assertEqual(rendered.count("const toLoadCssScripts = [];"), 1)
def test_multiple_components_dependencies(self):
registry.register(name="inner", component=SimpleComponent)
registry.register(name="outer", component=SimpleComponentNested)
registry.register(name="other", component=OtherComponent)
template_str: types.django_html = """
{% load component_tags %}{% component_css_dependencies %}
{% component 'test1' 'variable' %}{% endcomponent %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'outer' variable='variable' %}
{% component 'other' variable='variable_inner' / %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
# Dependency manager script
# NOTE: Should be present only ONCE!
self.assertInHTML('<script src="django_components/django_components.min.js"></script>', rendered, count=1)
self.assertEqual(rendered.count("<script"), 4) # Two 2 scripts belong to the boilerplate
self.assertEqual(rendered.count("<link"), 3)
self.assertEqual(rendered.count("<style"), 2)
# Components' inlined CSS
# NOTE: Each of these should be present only ONCE!
self.assertInHTML("<style>.xyz { color: red; }</style>", rendered, count=1)
self.assertInHTML("<style>.my-class { color: red; }</style>", rendered, count=1)
# Components' Media.css
# NOTE: Each of these should be present only ONCE!
self.assertInHTML('<link href="xyz1.css" media="all" rel="stylesheet">', rendered, count=1)
self.assertInHTML('<link href="style.css" media="all" rel="stylesheet">', rendered, count=1)
self.assertInHTML('<link href="style2.css" media="all" rel="stylesheet">', rendered, count=1)
self.assertEqual(
rendered.count(
"const loadedJsScripts = [&quot;/components/cache/OtherComponent_6329ae.js/&quot;, &quot;/components/cache/SimpleComponentNested_f02d32.js/&quot;];"
),
1,
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
self.assertEqual(
rendered.count(
"const loadedCssScripts = [&quot;/components/cache/OtherComponent_6329ae.css/&quot;, &quot;/components/cache/SimpleComponentNested_f02d32.css/&quot;, &quot;style.css&quot;, &quot;style2.css&quot;, &quot;xyz1.css&quot;];"
),
1,
)
self.assertEqual(
rendered.count(
r"const toLoadJsScripts = [Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`), Components.unescapeJs(\`&amp;lt;script src=&amp;quot;script2.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`), Components.unescapeJs(\`&amp;lt;script src=&amp;quot;xyz1.js&amp;quot;&amp;gt;&amp;lt;/script&amp;gt;\`)];"
),
1,
)
self.assertEqual(
rendered.count(
r"const toLoadCssScripts = [Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`), Components.unescapeJs(\`&amp;lt;link href=&amp;quot;style2.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`), Components.unescapeJs(\`&amp;lt;link href=&amp;quot;xyz1.css&amp;quot; media=&amp;quot;all&amp;quot; rel=&amp;quot;stylesheet&amp;quot;&amp;gt;\`)];"
),
1,
)
def test_correct_js_dependencies_with_multiple_components(self):
registry.register(name="test1", component=SimpleComponent)
registry.register(name="test2", component=SimpleComponentAlternate)
def test_multiple_components_all_placeholders_removed(self):
registry.register(name="inner", component=SimpleComponent)
registry.register(name="outer", component=SimpleComponentNested)
registry.register(name="test", component=SimpleComponentWithSharedDependency)
template_str: types.django_html = """
{% load component_tags %}{% component_js_dependencies %}
{% component 'test1' 'variable' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=1)
self.assertInHTML('<script src="script2.js">', rendered, count=0)
def test_correct_dependencies_with_multiple_components(self):
registry.register(name="test1", component=SimpleComponent)
registry.register(name="test2", component=SimpleComponentAlternate)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test2' variable='variable' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=0)
self.assertInHTML('<script src="script2.js">', rendered, count=1)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=0,
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
def test_shared_dependencies_rendered_once(self):
registry.register(name="test1", component=SimpleComponent)
registry.register(name="test2", component=SimpleComponentAlternate)
registry.register(name="test3", component=SimpleComponentWithSharedDependency)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test1' variable='variable' %}{% endcomponent %}
{% component 'test2' variable='variable' %}{% endcomponent %}
{% component 'test1' variable='variable' %}{% endcomponent %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertInHTML('<script src="script.js">', rendered, count=1)
self.assertInHTML('<script src="script2.js">', rendered, count=1)
self.assertInHTML(
'<link href="style.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
self.assertInHTML(
'<link href="style2.css" media="all" rel="stylesheet"/>',
rendered,
count=1,
)
def test_placeholder_removed_when_multiple_component_rendered(self):
registry.register(name="test1", component=SimpleComponent)
registry.register(name="test2", component=SimpleComponentAlternate)
registry.register(name="test3", component=SimpleComponentWithSharedDependency)
template_str: types.django_html = """
{% load component_tags %}{% component_dependencies %}
{% component 'test1' variable='variable' %}{% endcomponent %}
{% component 'test2' variable='variable' %}{% endcomponent %}
{% component 'test1' variable='variable' %}{% endcomponent %}
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
{% component 'inner' variable='variable' / %}
{% component 'outer' variable='variable' / %}
{% component 'test' variable='variable' / %}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertNotIn("_RENDERED", rendered)
def test_middleware_response_without_content_type(self):
response = HttpResponseNotModified()
middleware = ComponentDependencyMiddleware(get_response=lambda _: response)
request = Mock()
self.assertEqual(response, middleware(request=request))
def test_middleware_response_with_components_with_slash_dash_and_underscore(
self,
):
component_names = [
"test-component",
"test/component",
"test_component",
]
for component_name in component_names:
registry.register(name=component_name, component=SimpleComponent)
template_str: types.django_html = f"""
{{% load component_tags %}}
{{% component_js_dependencies %}}
{{% component_css_dependencies %}}
{{% component '{component_name}' variable='value' %}}
{{% endcomponent %}}
"""
template = Template(template_str)
rendered = create_and_process_template_response(template)
self.assertHTMLEqual(
rendered,
(
'<script src="script.js"></script>'
'<link href="style.css" media="all" rel="stylesheet">'
"Variable: <strong>value</strong>\n"
),
)

View file

@ -0,0 +1,217 @@
"""
Here we check that all parts of managing JS and CSS dependencies work together
in an actual browser.
"""
from playwright.async_api import Page
from django_components import types
from tests.django_test_setup import setup_test_config
from tests.e2e.utils import TEST_SERVER_URL, with_playwright
from tests.testutils import BaseTestCase
setup_test_config({"autodiscover": False})
# NOTE: All views, components, and associated JS and CSS are defined in
# `tests/e2e/testserver/testserver`
class E2eDependencyRenderingTests(BaseTestCase):
@with_playwright
async def test_single_component_dependencies(self):
single_comp_url = TEST_SERVER_URL + "/single"
page: Page = await self.browser.new_page()
await page.goto(single_comp_url)
test_js: types.js = """() => {
const bodyHTML = document.body.innerHTML;
const innerEl = document.querySelector(".inner");
const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size');
const myStyleEl = document.querySelector(".my-style");
const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background');
return {
bodyHTML,
componentJsMsg: globalThis.testSimpleComponent,
scriptJsMsg: globalThis.testMsg,
innerFontSize,
myStyleBg,
};
}"""
data = await page.evaluate(test_js)
# Check that the actual HTML content was loaded
self.assertIn('Variable: <strong class="inner">foo</strong>', data["bodyHTML"])
self.assertInHTML('<div class="my-style"> 123 </div>', data["bodyHTML"], count=1)
self.assertInHTML('<div class="my-style2"> xyz </div>', data["bodyHTML"], count=1)
# Check components' inlined JS got loaded
self.assertEqual(data["componentJsMsg"], "kapowww!")
# Check JS from Media.js got loaded
self.assertEqual(data["scriptJsMsg"], {"hello": "world"})
# Check components' inlined CSS got loaded
self.assertEqual(data["innerFontSize"], "4px")
# Check CSS from Media.css got loaded
self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue'
await page.close()
@with_playwright
async def test_multiple_component_dependencies(self):
single_comp_url = TEST_SERVER_URL + "/multi"
page: Page = await self.browser.new_page()
await page.goto(single_comp_url)
test_js: types.js = """() => {
const bodyHTML = document.body.innerHTML;
// Get the stylings defined via CSS
const innerEl = document.querySelector(".inner");
const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size');
const outerEl = document.querySelector(".outer");
const outerFontSize = globalThis.getComputedStyle(outerEl).getPropertyValue('font-size');
const otherEl = document.querySelector(".other");
const otherDisplay = globalThis.getComputedStyle(otherEl).getPropertyValue('display');
const myStyleEl = document.querySelector(".my-style");
const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background');
const myStyle2El = document.querySelector(".my-style2");
const myStyle2Color = globalThis.getComputedStyle(myStyle2El).getPropertyValue('color');
return {
bodyHTML,
component1JsMsg: globalThis.testSimpleComponent,
component2JsMsg: globalThis.testSimpleComponentNested,
component3JsMsg: globalThis.testOtherComponent,
scriptJs1Msg: globalThis.testMsg,
scriptJs2Msg: globalThis.testMsg2,
innerFontSize,
outerFontSize,
myStyleBg,
myStyle2Color,
otherDisplay,
};
}"""
data = await page.evaluate(test_js)
# Check that the actual HTML content was loaded
self.assertInHTML(
"""
<div class="outer">
Variable: <strong class="inner">variable</strong>
XYZ: <strong class="other">variable_inner</strong>
</div>
<div class="my-style">123</div>
<div class="my-style2">xyz</div>
""",
data["bodyHTML"],
count=1,
)
# Check components' inlined JS got loaded
self.assertEqual(data["component1JsMsg"], "kapowww!")
self.assertEqual(data["component2JsMsg"], "bongo!")
self.assertEqual(data["component3JsMsg"], "wowzee!")
# Check JS from Media.js got loaded
self.assertEqual(data["scriptJs1Msg"], {"hello": "world"})
self.assertEqual(data["scriptJs2Msg"], {"hello2": "world2"})
# Check components' inlined CSS got loaded
self.assertEqual(data["innerFontSize"], "4px")
self.assertEqual(data["outerFontSize"], "40px")
self.assertEqual(data["otherDisplay"], "flex")
# Check CSS from Media.css got loaded
self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue'
self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red'
await page.close()
@with_playwright
async def test_renders_css_nojs_env(self):
single_comp_url = TEST_SERVER_URL + "/multi"
page: Page = await self.browser.new_page(java_script_enabled=False)
await page.goto(single_comp_url)
test_js: types.js = """() => {
const bodyHTML = document.body.innerHTML;
// Get the stylings defined via CSS
const innerEl = document.querySelector(".inner");
const innerFontSize = globalThis.getComputedStyle(innerEl).getPropertyValue('font-size');
const outerEl = document.querySelector(".outer");
const outerFontSize = globalThis.getComputedStyle(outerEl).getPropertyValue('font-size');
const otherEl = document.querySelector(".other");
const otherDisplay = globalThis.getComputedStyle(otherEl).getPropertyValue('display');
const myStyleEl = document.querySelector(".my-style");
const myStyleBg = globalThis.getComputedStyle(myStyleEl).getPropertyValue('background');
const myStyle2El = document.querySelector(".my-style2");
const myStyle2Color = globalThis.getComputedStyle(myStyle2El).getPropertyValue('color');
return {
bodyHTML,
component1JsMsg: globalThis.testSimpleComponent,
component2JsMsg: globalThis.testSimpleComponentNested,
component3JsMsg: globalThis.testOtherComponent,
scriptJs1Msg: globalThis.testMsg,
scriptJs2Msg: globalThis.testMsg2,
innerFontSize,
outerFontSize,
myStyleBg,
myStyle2Color,
otherDisplay,
};
}"""
data = await page.evaluate(test_js)
# Check that the actual HTML content was loaded
self.assertInHTML(
"""
<div class="outer">
Variable: <strong class="inner">variable</strong>
XYZ: <strong class="other">variable_inner</strong>
</div>
<div class="my-style">123</div>
<div class="my-style2">xyz</div>
""",
data["bodyHTML"],
count=1,
)
# Check components' inlined JS did NOT get loaded
self.assertEqual(data["component1JsMsg"], None)
self.assertEqual(data["component2JsMsg"], None)
self.assertEqual(data["component3JsMsg"], None)
# Check JS from Media.js did NOT get loaded
self.assertEqual(data["scriptJs1Msg"], None)
self.assertEqual(data["scriptJs2Msg"], None)
# Check components' inlined CSS got loaded
self.assertEqual(data["innerFontSize"], "4px")
self.assertEqual(data["outerFontSize"], "40px")
self.assertEqual(data["otherDisplay"], "flex")
# Check CSS from Media.css got loaded
self.assertIn("rgb(0, 0, 255)", data["myStyleBg"]) # AKA 'background: blue'
self.assertEqual("rgb(255, 0, 0)", data["myStyle2Color"]) # AKA 'color: red'
await page.close()

View file

@ -150,7 +150,7 @@ class DynamicExprTests(BaseTestCase):
self.assertEqual(
rendered.strip(),
"<div>lorem</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>",
"<!-- _RENDERED SimpleComponent_5b8d97,a1bc3e -->\n <div>lorem</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>", # noqa: E501
)
@parametrize_context_behavior(["django", "isolated"])
@ -220,7 +220,7 @@ class DynamicExprTests(BaseTestCase):
self.assertEqual(
rendered.strip(),
"<div>lorem ipsum dolor</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>\n <div>{'a': 3}</div>", # noqa E501
"<!-- _RENDERED SimpleComponent_743413,a1bc3e -->\n <div>lorem ipsum dolor</div>\n <div>True</div>\n <div>[{'a': 1}, {'a': 2}]</div>\n <div>{'a': 3}</div>", # noqa E501
)
@parametrize_context_behavior(["django", "isolated"])
@ -290,7 +290,7 @@ class DynamicExprTests(BaseTestCase):
self.assertEqual(
rendered.strip(),
"<div></div>\n <div> abc</div>\n <div></div>\n <div> </div>", # noqa E501
"<!-- _RENDERED SimpleComponent_e258c0,a1bc3e -->\n <div></div>\n <div> abc</div>\n <div></div>\n <div> </div>", # noqa E501
)
@parametrize_context_behavior(["django", "isolated"])
@ -364,7 +364,7 @@ class DynamicExprTests(BaseTestCase):
self.assertEqual(
rendered.strip(),
"<div> lorem ipsum dolor </div>\n <div> lorem ipsum dolor [{&#x27;a&#x27;: 1}] </div>\n <div> True </div>\n <div> [{'a': 1}, {'a': 2}] </div>\n <div> {'a': 3} </div>", # noqa E501
"<!-- _RENDERED SimpleComponent_6c8e94,a1bc3e -->\n <div> lorem ipsum dolor </div>\n <div> lorem ipsum dolor [{&#x27;a&#x27;: 1}] </div>\n <div> True </div>\n <div> [{'a': 1}, {'a': 2}] </div>\n <div> {'a': 3} </div>", # noqa E501
)
@parametrize_context_behavior(["django", "isolated"])
@ -408,7 +408,7 @@ class DynamicExprTests(BaseTestCase):
self.assertEqual(
rendered.strip(),
'<div>"</div>\n <div>{%}</div>\n <div>True</div>',
'<!-- _RENDERED SimpleComponent_c7a5c3,a1bc3e -->\n <div>"</div>\n <div>{%}</div>\n <div>True</div>', # noqa: E501
)
@parametrize_context_behavior(["django", "isolated"])
@ -457,7 +457,7 @@ class DynamicExprTests(BaseTestCase):
self.assertEqual(
rendered.strip(),
"<div>\n <div>3</div>\n <div>True</div>\n </div>\n <div>True</div>", # noqa E501
"<!-- _RENDERED SimpleComponent_5c8766,a1bc3e -->\n <div><!-- _RENDERED SimpleComponent_5c8766,a1bc3f -->\n <div>3</div>\n <div>True</div>\n </div>\n <div>True</div>", # noqa E501
)

View file

@ -124,7 +124,7 @@ class StaticFilesFinderTests(SimpleTestCase):
"static_files_allowed": [
".js",
],
"forbidden_static_files": [],
"static_files_forbidden": [],
},
STATICFILES_FINDERS=[
# Default finders
@ -153,7 +153,7 @@ class StaticFilesFinderTests(SimpleTestCase):
"static_files_allowed": [
re.compile(r".*"),
],
"forbidden_static_files": [
"static_files_forbidden": [
re.compile(r"\.(?:js)$"),
],
},
@ -185,7 +185,7 @@ class StaticFilesFinderTests(SimpleTestCase):
".js",
".css",
],
"forbidden_static_files": [
"static_files_forbidden": [
".js",
],
},

267
tests/test_html.py Normal file
View file

@ -0,0 +1,267 @@
from typing import List, cast
from django.test import TestCase
from selectolax.lexbor import LexborHTMLParser, LexborNode
from django_components.util.html import (
is_html_parser_fragment,
parse_document_or_nodes,
parse_multiroot_html,
parse_node,
)
from .django_test_setup import setup_test_config
setup_test_config({"autodiscover": False})
class HtmlTests(TestCase):
def test_parse_node(self):
node = parse_node(
"""
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
"""
)
node.attrs["id"] = "my-id" # type: ignore[index]
node.css("li")[0].attrs["class"] = "item" # type: ignore[index]
self.assertHTMLEqual(
node.html,
"""
<div class="abc xyz" data-id="123" id="my-id">
<ul>
<li class="item">Hi</li>
</ul>
</div>
""",
)
def test_parse_multiroot_html(self):
html = """
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
<main id="123" class="one">
<div>
42
</div>
</main>
<span>
Hello
</span>
"""
nodes = parse_multiroot_html(html)
self.assertHTMLEqual(
nodes[0].html,
"""
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
""",
)
self.assertHTMLEqual(
nodes[1].html,
"""
<main id="123" class="one">
<div>
42
</div>
</main>
""",
)
self.assertHTMLEqual(
nodes[2].html,
"""
<span>
Hello
</span>
""",
)
def test_is_html_parser_fragment(self):
fragment_html = """
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
<main id="123" class="one">
<div>
42
</div>
</main>
<span>
Hello
</span>
"""
fragment_tree = LexborHTMLParser(fragment_html)
fragment_result = is_html_parser_fragment(fragment_html, fragment_tree)
self.assertEqual(fragment_result, True)
doc_html = """
<!doctype html>
<html>
<head>
<link href="https://..." />
</head>
<body>
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
</body>
</html>
"""
doc_tree = LexborHTMLParser(doc_html)
doc_result = is_html_parser_fragment(doc_html, doc_tree)
self.assertEqual(doc_result, False)
def test_parse_document_or_nodes__fragment(self):
fragment_html = """
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
<main id="123" class="one">
<div>
42
</div>
</main>
<span>
Hello
</span>
"""
fragment_result = cast(List[LexborNode], parse_document_or_nodes(fragment_html))
self.assertHTMLEqual(
fragment_result[0].html,
"""
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
""",
)
self.assertHTMLEqual(
fragment_result[1].html,
"""
<main id="123" class="one">
<div>
42
</div>
</main>
""",
)
self.assertHTMLEqual(
fragment_result[2].html,
"""
<span>
Hello
</span>
""",
)
def test_parse_document_or_nodes__mixed(self):
fragment_html = """
<link href="" />
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
<main id="123" class="one">
<div>
42
</div>
</main>
<span>
Hello
</span>
"""
fragment_result = cast(List[LexborNode], parse_document_or_nodes(fragment_html))
self.assertHTMLEqual(
fragment_result[0].html,
"""
<link href="" />
""",
)
self.assertHTMLEqual(
fragment_result[1].html,
"""
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
""",
)
self.assertHTMLEqual(
fragment_result[2].html,
"""
<main id="123" class="one">
<div>
42
</div>
</main>
""",
)
self.assertHTMLEqual(
fragment_result[3].html,
"""
<span>
Hello
</span>
""",
)
def test_parse_document_or_nodes__doc(self):
doc_html = """
<!doctype html>
<html>
<head>
<link href="https://..." />
</head>
<body>
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
</body>
</html>
"""
fragment_result = cast(LexborHTMLParser, parse_document_or_nodes(doc_html))
self.assertHTMLEqual(
fragment_result.html,
"""
<!doctype html>
<html>
<head>
<link href="https://..." />
</head>
<body>
<div class="abc xyz" data-id="123">
<ul>
<li>Hi</li>
</ul>
</div>
</body>
</html>
""",
)

View file

@ -1,11 +1,12 @@
import os
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
from django.template.engine import Engine
from django.conf import settings
from django.test import override_settings
from django_components.template_loader import Loader, get_dirs
from django_components.util.loader import _filepath_to_python_module, get_component_dirs, get_component_files
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
@ -13,14 +14,12 @@ from .testutils import BaseTestCase
setup_test_config({"autodiscover": False})
class TemplateLoaderTest(BaseTestCase):
class ComponentDirsTest(BaseTestCase):
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
)
def test_get_dirs__base_dir(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = sorted(loader.get_dirs())
dirs = sorted(get_component_dirs())
apps_dirs = [dirs[0], dirs[2]]
own_dirs = [dirs[1], *dirs[3:]]
@ -43,9 +42,7 @@ class TemplateLoaderTest(BaseTestCase):
BASE_DIR=Path(__file__).parent.resolve() / "test_structures" / "test_structure_1", # noqa
)
def test_get_dirs__base_dir__complex(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = sorted(loader.get_dirs())
dirs = sorted(get_component_dirs())
apps_dirs = dirs[:2]
own_dirs = dirs[2:]
@ -69,10 +66,10 @@ class TemplateLoaderTest(BaseTestCase):
("with_not_str_alias", 3),
], # noqa
)
@patch("django_components.template_loader.logger.warning")
@patch("django_components.util.loader.logger.warning")
def test_get_dirs__components_dirs(self, mock_warning: MagicMock):
mock_warning.reset_mock()
dirs = sorted(get_dirs())
dirs = sorted(get_component_dirs())
apps_dirs = [dirs[0], dirs[2]]
own_dirs = [dirs[1], *dirs[3:]]
@ -101,7 +98,7 @@ class TemplateLoaderTest(BaseTestCase):
},
)
def test_get_dirs__components_dirs__empty(self):
dirs = sorted(get_dirs())
dirs = sorted(get_component_dirs())
apps_dirs = dirs
@ -117,10 +114,8 @@ class TemplateLoaderTest(BaseTestCase):
},
)
def test_get_dirs__componenents_dirs__raises_on_relative_path_1(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"):
loader.get_dirs()
get_component_dirs()
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
@ -129,10 +124,8 @@ class TemplateLoaderTest(BaseTestCase):
},
)
def test_get_dirs__component_dirs__raises_on_relative_path_2(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
with self.assertRaisesMessage(ValueError, "COMPONENTS.dirs must contain absolute paths"):
loader.get_dirs()
get_component_dirs()
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
@ -141,9 +134,7 @@ class TemplateLoaderTest(BaseTestCase):
},
)
def test_get_dirs__app_dirs(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = sorted(loader.get_dirs())
dirs = sorted(get_component_dirs())
apps_dirs = dirs[1:]
own_dirs = dirs[:1]
@ -168,9 +159,7 @@ class TemplateLoaderTest(BaseTestCase):
},
)
def test_get_dirs__app_dirs_empty(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = sorted(loader.get_dirs())
dirs = sorted(get_component_dirs())
own_dirs = dirs
@ -190,9 +179,7 @@ class TemplateLoaderTest(BaseTestCase):
},
)
def test_get_dirs__app_dirs_not_found(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = sorted(loader.get_dirs())
dirs = sorted(get_component_dirs())
own_dirs = dirs
@ -210,9 +197,7 @@ class TemplateLoaderTest(BaseTestCase):
INSTALLED_APPS=("django_components", "tests.test_app_nested.app"),
)
def test_get_dirs__nested_apps(self):
current_engine = Engine.get_default()
loader = Loader(current_engine)
dirs = sorted(loader.get_dirs())
dirs = sorted(get_component_dirs())
apps_dirs = [dirs[0], *dirs[2:]]
own_dirs = [dirs[1]]
@ -230,3 +215,124 @@ class TemplateLoaderTest(BaseTestCase):
/ "components",
],
)
class ComponentFilesTest(BaseTestCase):
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
)
def test_get_files__py(self):
files = sorted(get_component_files(".py"))
dot_paths = [f.dot_path for f in files]
file_paths = [str(f.filepath) for f in files]
self.assertEqual(
dot_paths,
[
"components",
"components.multi_file.multi_file",
"components.relative_file.relative_file",
"components.relative_file_pathobj.relative_file_pathobj",
"components.single_file",
"components.staticfiles.staticfiles",
"components.urls",
"django_components.components",
"django_components.components.dynamic",
"tests.test_app.components.app_lvl_comp.app_lvl_comp",
],
)
self.assertEqual(
[
file_paths[0].endswith("tests/components/__init__.py"),
file_paths[1].endswith("tests/components/multi_file/multi_file.py"),
file_paths[2].endswith("tests/components/relative_file/relative_file.py"),
file_paths[3].endswith("tests/components/relative_file_pathobj/relative_file_pathobj.py"),
file_paths[4].endswith("tests/components/single_file.py"),
file_paths[5].endswith("tests/components/staticfiles/staticfiles.py"),
file_paths[6].endswith("tests/components/urls.py"),
file_paths[7].endswith("django_components/components/__init__.py"),
file_paths[8].endswith("django_components/components/dynamic.py"),
file_paths[9].endswith("tests/test_app/components/app_lvl_comp/app_lvl_comp.py"),
],
[True for _ in range(len(file_paths))],
)
@override_settings(
BASE_DIR=Path(__file__).parent.resolve(),
)
def test_get_files__js(self):
files = sorted(get_component_files(".js"))
dot_paths = [f.dot_path for f in files]
file_paths = [str(f.filepath) for f in files]
print(file_paths)
self.assertEqual(
dot_paths,
[
"components.relative_file.relative_file",
"components.relative_file_pathobj.relative_file_pathobj",
"components.staticfiles.staticfiles",
"tests.test_app.components.app_lvl_comp.app_lvl_comp",
],
)
self.assertEqual(
[
file_paths[0].endswith("tests/components/relative_file/relative_file.js"),
file_paths[1].endswith("tests/components/relative_file_pathobj/relative_file_pathobj.js"),
file_paths[2].endswith("tests/components/staticfiles/staticfiles.js"),
file_paths[3].endswith("tests/test_app/components/app_lvl_comp/app_lvl_comp.js"),
],
[True for _ in range(len(file_paths))],
)
class TestFilepathToPythonModule(BaseTestCase):
def test_prepares_path(self):
base_path = str(settings.BASE_DIR)
the_path = os.path.join(base_path, "tests.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests",
)
the_path = os.path.join(base_path, "tests/components/relative_file/relative_file.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests.components.relative_file.relative_file",
)
def test_handles_nonlinux_paths(self):
base_path = str(settings.BASE_DIR).replace("/", "//")
with patch("os.path.sep", new="//"):
the_path = os.path.join(base_path, "tests.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests",
)
the_path = os.path.join(base_path, "tests//components//relative_file//relative_file.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests.components.relative_file.relative_file",
)
base_path = str(settings.BASE_DIR).replace("//", "\\")
with patch("os.path.sep", new="\\"):
the_path = os.path.join(base_path, "tests.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests",
)
the_path = os.path.join(base_path, "tests\\components\\relative_file\\relative_file.py")
self.assertEqual(
_filepath_to_python_module(the_path, base_path, None),
"tests.components.relative_file.relative_file",
)

View file

@ -150,8 +150,8 @@ class MultipleComponentRegistriesTest(BaseTestCase):
registry_a = ComponentRegistry(
library=library_a,
settings=RegistrySettings(
CONTEXT_BEHAVIOR=ContextBehavior.ISOLATED,
TAG_FORMATTER=component_shorthand_formatter,
context_behavior=ContextBehavior.ISOLATED.value,
tag_formatter=component_shorthand_formatter,
),
)
@ -159,8 +159,8 @@ class MultipleComponentRegistriesTest(BaseTestCase):
registry_b = ComponentRegistry(
library=library_b,
settings=RegistrySettings(
CONTEXT_BEHAVIOR=ContextBehavior.DJANGO,
TAG_FORMATTER=component_formatter,
context_behavior=ContextBehavior.DJANGO.value,
tag_formatter=component_formatter,
),
)
@ -228,7 +228,6 @@ class ProtectedTagsTest(unittest.TestCase):
@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",

View file

@ -2,12 +2,13 @@ from pathlib import Path
from django.test import override_settings
from django_components import ComponentsSettings
from django_components.app_settings import app_settings
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
setup_test_config()
setup_test_config(components={"autodiscover": False})
class SettingsTestCase(BaseTestCase):
@ -27,3 +28,11 @@ class SettingsTestCase(BaseTestCase):
@override_settings(BASE_DIR=Path("base_dir"))
def test_works_when_base_dir_is_path(self):
self.assertEqual(app_settings.DIRS, [Path("base_dir/components")])
@override_settings(COMPONENTS={"context_behavior": "isolated"})
def test_settings_as_dict(self):
self.assertEqual(app_settings.CONTEXT_BEHAVIOR, "isolated")
@override_settings(COMPONENTS=ComponentsSettings(context_behavior="isolated"))
def test_settings_as_instance(self):
self.assertEqual(app_settings.CONTEXT_BEHAVIOR, "isolated")

View file

@ -19,6 +19,11 @@ class MultiwordBlockEndTagFormatter(ShorthandComponentFormatter):
return f"end {name}"
class SlashEndTagFormatter(ShorthandComponentFormatter):
def end_tag(self, name):
return f"/{name}"
# Create a TagFormatter class to validate the public interface
def create_validator_tag_formatter(tag_name: str):
class ValidatorTagFormatter(ShorthandComponentFormatter):
@ -259,6 +264,46 @@ class ComponentTagTests(BaseTestCase):
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={
"COMPONENTS": {
"tag_formatter": SlashEndTagFormatter(),
},
},
)
def test_forward_slash_in_end_tag(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!
{% /simple %}
"""
)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
hello1
<div>
OVERRIDEN!
</div>
hello2
""",
)
@parametrize_context_behavior(
cases=["django", "isolated"],
settings={

94
tests/test_tag_parser.py Normal file
View file

@ -0,0 +1,94 @@
from django_components.util.tag_parser import TagAttr, parse_tag_attrs
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase
setup_test_config({"autodiscover": False})
class TagParserTests(BaseTestCase):
def test_tag_parser(self):
_, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 two' ")
self.assertEqual(
attrs,
[
TagAttr(key=None, value="component", start_index=0, quoted=False),
TagAttr(key=None, value="my_comp", start_index=10, quoted=True),
TagAttr(key="key", value="val", start_index=20, quoted=False),
TagAttr(key="key2", value="val2 two", start_index=28, quoted=True),
],
)
def test_tag_parser_nested_quotes(self):
_, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" ")
self.assertEqual(
attrs,
[
TagAttr(key=None, value="component", start_index=0, quoted=False),
TagAttr(key=None, value="my_comp", start_index=10, quoted=True),
TagAttr(key="key", value="val", start_index=20, quoted=False),
TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True),
TagAttr(key="text", value="organisation's", start_index=46, quoted=True),
],
)
def test_tag_parser_trailing_quote_single(self):
_, attrs = parse_tag_attrs("component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" 'abc")
self.assertEqual(
attrs,
[
TagAttr(key=None, value="component", start_index=0, quoted=False),
TagAttr(key=None, value="my_comp", start_index=10, quoted=True),
TagAttr(key="key", value="val", start_index=20, quoted=False),
TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True),
TagAttr(key="text", value="organisation's", start_index=46, quoted=True),
TagAttr(key=None, value="'abc", start_index=68, quoted=False),
],
)
def test_tag_parser_trailing_quote_double(self):
_, attrs = parse_tag_attrs('component "my_comp" key=val key2="val2 \'two\'" text=\'organisation"s\' "abc')
self.assertEqual(
attrs,
[
TagAttr(key=None, value="component", start_index=0, quoted=False),
TagAttr(key=None, value="my_comp", start_index=10, quoted=True),
TagAttr(key="key", value="val", start_index=20, quoted=False),
TagAttr(key="key2", value="val2 'two'", start_index=28, quoted=True),
TagAttr(key="text", value='organisation"s', start_index=46, quoted=True),
TagAttr(key=None, value='"abc', start_index=68, quoted=False),
],
)
def test_tag_parser_trailing_quote_as_value_single(self):
_, attrs = parse_tag_attrs(
"component 'my_comp' key=val key2='val2 \"two\"' text=\"organisation's\" value='abc"
)
self.assertEqual(
attrs,
[
TagAttr(key=None, value="component", start_index=0, quoted=False),
TagAttr(key=None, value="my_comp", start_index=10, quoted=True),
TagAttr(key="key", value="val", start_index=20, quoted=False),
TagAttr(key="key2", value='val2 "two"', start_index=28, quoted=True),
TagAttr(key="text", value="organisation's", start_index=46, quoted=True),
TagAttr(key="value", value="'abc", start_index=68, quoted=False),
],
)
def test_tag_parser_trailing_quote_as_value_double(self):
_, attrs = parse_tag_attrs(
'component "my_comp" key=val key2="val2 \'two\'" text=\'organisation"s\' value="abc'
)
self.assertEqual(
attrs,
[
TagAttr(key=None, value="component", start_index=0, quoted=False),
TagAttr(key=None, value="my_comp", start_index=10, quoted=True),
TagAttr(key="key", value="val", start_index=20, quoted=False),
TagAttr(key="key2", value="val2 'two'", start_index=28, quoted=True),
TagAttr(key="text", value='organisation"s', start_index=46, quoted=True),
TagAttr(key="value", value='"abc', start_index=68, quoted=False),
],
)

View file

@ -8,7 +8,7 @@ from django_components.expression import (
safe_resolve_dict,
safe_resolve_list,
)
from django_components.templatetags.component_tags import _parse_tag
from django_components.templatetags.component_tags import TagSpec, _parse_tag
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
@ -21,22 +21,28 @@ class ParserTest(BaseTestCase):
template_str = "{% component 42 myvar key='val' key2=val2 %}"
tokens = Lexer(template_str).tokenize()
parser = Parser(tokens=tokens)
tag = _parse_tag("component", parser, parser.tokens[0], params=["num", "var"], keywordonly_kwargs=True)
spec = TagSpec(
tag="component",
pos_or_keyword_args=["num", "var"],
keywordonly_args=True,
)
tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec)
ctx = {"myvar": {"a": "b"}, "val2": 1}
args = safe_resolve_list(ctx, tag.args)
named_args = safe_resolve_dict(ctx, tag.named_args)
kwargs = tag.kwargs.resolve(ctx)
self.assertListEqual(args, [42, {"a": "b"}])
self.assertDictEqual(named_args, {"num": 42, "var": {"a": "b"}})
self.assertDictEqual(kwargs, {"key": "val", "key2": 1})
self.assertListEqual(args, [])
self.assertDictEqual(named_args, {})
self.assertDictEqual(kwargs, {"num": 42, "var": {"a": "b"}, "key": "val", "key2": 1})
def test_parses_special_kwargs(self):
template_str = "{% component date=date @lol=2 na-me=bzz @event:na-me.mod=bzz #my-id=True %}"
tokens = Lexer(template_str).tokenize()
parser = Parser(tokens=tokens)
tag = _parse_tag("component", parser, parser.tokens[0], keywordonly_kwargs=True)
spec = TagSpec(tag="component", keywordonly_args=True)
tag = _parse_tag(parser, parser.tokens[0], tag_spec=spec)
ctx = Context({"date": 2024, "bzz": "fzz"})
args = safe_resolve_list(ctx, tag.args)

View file

@ -30,6 +30,8 @@ class TemplateInstrumentationTest(BaseTestCase):
def setUp(self):
"""Emulate Django test instrumentation for TestCase (see setup_test_environment)"""
super().setUp()
from django.test.utils import instrumented_test_render
self.saved_render_method = Template._render
@ -514,26 +516,83 @@ class MultilineTagsTests(BaseTestCase):
class NestedTagsTests(BaseTestCase):
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ var }}</strong>
"""
def get_context_data(self, var):
return {
"var": var,
}
# See https://github.com/EmilStenstrom/django-components/discussions/671
@parametrize_context_behavior(["django", "isolated"])
def test_nested_tags(self):
@register("test_component")
class SimpleComponent(Component):
template: types.django_html = """
Variable: <strong>{{ var }}</strong>
"""
def get_context_data(self, var):
return {
"var": var,
}
registry.register("test", self.SimpleComponent)
template: types.django_html = """
{% load component_tags %}
{% component "test_component" var="{% lorem 1 w %}" %}{% endcomponent %}
{% component "test" var="{% lorem 1 w %}" %}{% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
Variable: <strong>lorem</strong>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_quote_single(self):
registry.register("test", self.SimpleComponent)
template: types.django_html = """
{% load component_tags %}
{% component "test" var=_("organisation's") %} {% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
Variable: <strong>organisation's</strong>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_quote_single_self_closing(self):
registry.register("test", self.SimpleComponent)
template: types.django_html = """
{% load component_tags %}
{% component "test" var=_("organisation's") / %}
"""
rendered = Template(template).render(Context())
expected = """
Variable: <strong>organisation's</strong>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_quote_double(self):
registry.register("test", self.SimpleComponent)
template: types.django_html = """
{% load component_tags %}
{% component "test" var=_('organisation"s') %} {% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
Variable: <strong>organisation"s</strong>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_nested_quote_double_self_closing(self):
registry.register("test", self.SimpleComponent)
template: types.django_html = """
{% load component_tags %}
{% component "test" var=_('organisation"s') / %}
"""
rendered = Template(template).render(Context())
expected = """
Variable: <strong>organisation"s</strong>
"""
self.assertHTMLEqual(rendered, expected)

View file

@ -429,7 +429,7 @@ class DynamicComponentTemplateTagTest(BaseTestCase):
)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_invalid_slots(self):
def test_ignores_invalid_slots(self):
class SimpleSlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
@ -461,11 +461,15 @@ class DynamicComponentTemplateTagTest(BaseTestCase):
"""
template = Template(simple_tag_template)
with self.assertRaisesMessage(
TemplateSyntaxError, "Component \\'dynamic\\' passed fill that refers to undefined slot: \\'three\\'"
):
template.render(Context({}))
rendered = template.render(Context({}))
self.assertHTMLEqual(
rendered,
"""
Variable: <strong>variable</strong>
Slot 1: HELLO_FROM_SLOT_1
Slot 2:
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_invalid_args(self):
@ -664,7 +668,7 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase):
Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_text_outside_fill_tag_is_not_error(self):
def test_text_outside_fill_tag_is_not_error_when_no_fill_tags(self):
# As of v0.28 this is valid, provided the component registered under "test"
# contains a slot tag marked as 'default'. This is verified outside
# template compilation time.
@ -677,21 +681,28 @@ class ComponentTemplateSyntaxErrorTests(BaseTestCase):
Template(template_str)
@parametrize_context_behavior(["django", "isolated"])
def test_nonfill_block_outside_fill_tag_is_error(self):
with self.assertRaises(TemplateSyntaxError):
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% if True %}
{% fill "header" %}{% endfill %}
{% endif %}
{% endcomponent %}
"""
Template(template_str)
def test_text_outside_fill_tag_is_error_when_fill_tags(self):
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% lorem 3 w random %}
{% fill "header" %}{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(
TemplateSyntaxError,
"Illegal content passed to component 'test'. Explicit 'fill' tags cannot occur alongside other text",
):
template.render(Context())
@parametrize_context_behavior(["django", "isolated"])
def test_unclosed_component_is_error(self):
with self.assertRaises(TemplateSyntaxError):
with self.assertRaisesMessage(
TemplateSyntaxError,
"Unclosed tag on line 3: 'component'",
):
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}

View file

@ -2,7 +2,7 @@ from typing import Any, Dict, List, Optional
from django.template import Context, Template, TemplateSyntaxError
from django_components import Component, register, registry, types
from django_components import Component, Slot, register, registry, types
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase, parametrize_context_behavior
@ -31,7 +31,7 @@ class SlottedComponentWithContext(SlottedComponent):
#######################
class ComponentSlottedTemplateTagTest(BaseTestCase):
class ComponentSlotTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_slotted_template_basic(self):
registry.register(name="test1", component=SlottedComponent)
@ -255,9 +255,284 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
"""
template = Template(template_str)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
with self.assertRaisesMessage(TemplateSyntaxError, "Slot 'title' is marked as 'required'"):
template.render(Context())
# NOTE: This is relevant only for the "isolated" mode
@parametrize_context_behavior(["isolated"])
def test_slots_of_top_level_comps_can_access_full_outer_ctx(self):
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
<main>{% slot "main" default %}Easy to override{% endslot %}</main>
</div>
"""
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"name": name,
}
registry.register("test", SlottedComponent)
template_str: types.django_html = """
{% load component_tags %}
<body>
{% component "test" %}
ABC: {{ name }} {{ some }}
{% endcomponent %}
</body>
"""
self.template = Template(template_str)
nested_ctx = Context()
# Check that the component can access vars across different context layers
nested_ctx.push({"some": "var"})
nested_ctx.push({"name": "carl"})
rendered = self.template.render(nested_ctx)
self.assertHTMLEqual(
rendered,
"""
<body>
<div>
<main> ABC: carl var </main>
</div>
</body>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_target_default_slot_as_named(self):
@register("test")
class Comp(Component):
template: types.django_html = """
{% load component_tags %}
<div>
<h1>{% slot "title" default %}Default title{% endslot %}</h1>
<h2>{% slot "subtitle" %}Default subtitle{% endslot %}</h2>
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test' %}
{% fill "default" %}Custom title{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
<div>
<h1> Custom title </h1>
<h2> Default subtitle </h2>
</div>
""",
)
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_doubly_filled_slot__same_name(self):
@register("test")
class Comp(Component):
template: types.django_html = """
{% load component_tags %}
<div class="header-box">
<h1>{% slot "title" default %}Default title{% endslot %}</h1>
<h2>{% slot "subtitle" %}Default subtitle{% endslot %}</h2>
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test' %}
{% fill "title" %}Custom title{% endfill %}
{% fill "title" %}Another title{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(
TemplateSyntaxError,
"Multiple fill tags cannot target the same slot name in component 'test': "
"Detected duplicate fill tag name 'title'",
):
template.render(Context())
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_doubly_filled_slot__named_and_default(self):
@register("test")
class Comp(Component):
template: types.django_html = """
{% load component_tags %}
<div class="header-box">
<h1>{% slot "title" default %}Default title{% endslot %}</h1>
<h2>{% slot "subtitle" %}Default subtitle{% endslot %}</h2>
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test' %}
{% fill "default" %}Custom title{% endfill %}
{% fill "title" %}Another title{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(
TemplateSyntaxError,
"Slot 'title' of component 'test' was filled twice: once explicitly and once implicitly as 'default'",
):
template.render(Context())
@parametrize_context_behavior(["django", "isolated"])
def test_raises_on_doubly_filled_slot__named_and_default_2(self):
@register("test")
class Comp(Component):
template: types.django_html = """
{% load component_tags %}
<div class="header-box">
<h1>{% slot "default" default %}Default title{% endslot %}</h1>
<h2>{% slot "subtitle" %}Default subtitle{% endslot %}</h2>
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test' %}
{% fill "default" %}Custom title{% endfill %}
{% fill "default" %}Another title{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(
TemplateSyntaxError,
"Multiple fill tags cannot target the same slot name in component 'test': "
"Detected duplicate fill tag name 'default'",
):
template.render(Context())
@parametrize_context_behavior(["django", "isolated"])
def test_multiple_slots_with_same_name_different_flags(self):
class TestComp(Component):
def get_context_data(self, required: bool) -> Any:
return {"required": required}
template: types.django_html = """
{% load component_tags %}
<div>
{% if required %}
<main>{% slot "main" required %}1{% endslot %}</main>
{% endif %}
<div>{% slot "main" default %}2{% endslot %}</div>
</div>
"""
# 1. Specify the non-required slot by its name
rendered1 = TestComp.render(
kwargs={"required": False},
slots={
"main": "MAIN",
},
render_dependencies=False,
)
# 2. Specify the non-required slot by the "default" name
rendered2 = TestComp.render(
kwargs={"required": False},
slots={
"default": "MAIN",
},
render_dependencies=False,
)
self.assertInHTML(rendered1, "<div><div>MAIN</div></div>")
self.assertInHTML(rendered2, "<div><div>MAIN</div></div>")
# 3. Specify the required slot by its name
rendered3 = TestComp.render(
kwargs={"required": True},
slots={
"main": "MAIN",
},
render_dependencies=False,
)
self.assertInHTML(rendered3, "<div><main>MAIN</main><div>MAIN</div></div>")
# 4. RAISES: Specify the required slot by the "default" name
# This raises because the slot that is marked as 'required' is NOT marked as 'default'.
with self.assertRaisesMessage(
TemplateSyntaxError,
"Slot 'main' is marked as 'required'",
):
TestComp.render(
kwargs={"required": True},
slots={
"default": "MAIN",
},
render_dependencies=False,
)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_in_include(self):
@register("slotted")
class SlottedWithIncludeComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% include 'slotted_template.html' %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "slotted" %}
{% fill "header" %}Custom header{% endfill %}
{% fill "main" %}Custom main{% endfill %}
{% fill "footer" %}Custom footer{% endfill %}
{% endcomponent %}
"""
rendered = Template(template_str).render(Context({}))
expected = """
<custom-template>
<header>Custom header</header>
<main>Custom main</main>
<footer>Custom footer</footer>
</custom-template>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_in_include_raises_if_isolated(self):
@register("broken_component")
class BrokenComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% include 'slotted_template.html' with context=None only %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "broken_component" %}
{% fill "header" %}Custom header {% endfill %}
{% fill "main" %}Custom main{% endfill %}
{% fill "footer" %}Custom footer{% endfill %}
{% endcomponent %}
"""
with self.assertRaisesMessage(
TemplateSyntaxError,
"Encountered a SlotNode outside of a ComponentNode context.",
):
Template(template_str).render(Context({}))
class ComponentSlotDefaultTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_default_slot_is_fillable_by_implicit_fill_content(self):
@register("test_comp")
@ -311,6 +586,59 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_multiple_default_slots_with_same_name(self):
@register("test_comp")
class ComponentWithDefaultSlot(Component):
template: types.django_html = """
{% load component_tags %}
<div>
<main>{% slot "main" default %}1{% endslot %}</main>
<div>{% slot "main" default %}2{% endslot %}</div>
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_comp' %}
{% fill "main" %}<p>This fills the 'main' slot.</p>{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
expected = """
<div>
<main><p>This fills the 'main' slot.</p></main>
<div><p>This fills the 'main' slot.</p></div>
</div>
"""
rendered = template.render(Context({}))
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_multiple_default_slots_with_different_names(self):
@register("test_comp")
class ComponentWithDefaultSlot(Component):
template: types.django_html = """
{% load component_tags %}
<div>
<main>{% slot "main" default %}1{% endslot %}</main>
<div>{% slot "other" default %}2{% endslot %}</div>
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_comp' %}
{% fill "main" %}<p>This fills the 'main' slot.</p>{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(
TemplateSyntaxError, "Only one component slot may be marked as 'default', found 'main' and 'other'"
):
template.render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_error_raised_when_default_and_required_slot_not_filled(self):
@register("test_comp")
@ -330,8 +658,8 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
"""
template = Template(template_str)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
with self.assertRaisesMessage(TemplateSyntaxError, "Slot 'main' is marked as 'required'"):
template.render(Context())
@parametrize_context_behavior(["django", "isolated"])
def test_fill_tag_can_occur_within_component_nested_in_implicit_fill(self):
@ -382,7 +710,10 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
</div>
"""
with self.assertRaises(TemplateSyntaxError):
with self.assertRaisesMessage(
TemplateSyntaxError,
"Illegal content passed to component 'test_comp'. Explicit 'fill' tags cannot occur alongside other text",
):
template_str: types.django_html = """
{% load component_tags %}
{% component 'test_comp' %}
@ -390,7 +721,7 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
<p>And add this too!</p>
{% endcomponent %}
"""
Template(template_str)
Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_comments_permitted_inside_implicit_fill_content(self):
@ -428,63 +759,26 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
"""
template = Template(template_str)
with self.assertRaises(TemplateSyntaxError):
template.render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_component_template_cannot_have_multiple_default_slots(self):
class BadComponent(Component):
def get_template(self, context):
template_str: types.django_html = """
{% load django_components %}
<div>
{% slot "icon" %} {% endslot default %}
{% slot "description" %} {% endslot default %}
</div>
"""
return Template(template_str)
c = BadComponent("name")
with self.assertRaises(TemplateSyntaxError):
c.render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_slot_name_fill_typo_gives_helpful_error_message(self):
registry.register(name="test1", component=SlottedComponent)
template_str: types.django_html = """
{% load component_tags %}
{% component "test1" %}
{% fill "haeder" %}
Custom header
{% endfill %}
{% fill "main" %}
main content
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(
TemplateSyntaxError,
(
"Component 'test1' passed fill that refers to undefined slot: 'haeder'.\\n"
"Unfilled slot names are: ['footer', 'header'].\\n"
"Did you mean 'header'?"
),
"Component 'test_comp' passed default fill content (i.e. without explicit 'name' kwarg), "
"even though none of its slots is marked as 'default'",
):
template.render(Context({}))
template.render(Context())
# NOTE: This is relevant only for the "isolated" mode
@parametrize_context_behavior(["isolated"])
def test_slots_of_top_level_comps_can_access_full_outer_ctx(self):
class PassthroughSlotsTest(BaseTestCase):
@parametrize_context_behavior(["isolated", "django"])
def test_if_for(self):
@register("test")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
<main>{% slot "main" default %}Easy to override{% endslot %}</main>
</div>
<custom-template>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>
"""
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
@ -492,35 +786,271 @@ class ComponentSlottedTemplateTagTest(BaseTestCase):
"name": name,
}
registry.register("test", SlottedComponent)
template_str: types.django_html = """
{% load component_tags %}
<body>
{% component "test" %}
ABC: {{ name }} {{ some }}
{% endcomponent %}
</body>
{% component "test" %}
{% if slot_names %}
{% for slot in slot_names %}
{% fill name=slot default="default" %}
OVERRIDEN_SLOT "{{ slot }}" - INDEX {{ forloop.counter0 }} - ORIGINAL "{{ default }}"
{% endfill %}
{% endfor %}
{% endif %}
{% if 1 > 2 %}
{% fill "footer" %}
FOOTER
{% endfill %}
{% endif %}
{% endcomponent %}
"""
self.template = Template(template_str)
nested_ctx = Context()
# Check that the component can access vars across different context layers
nested_ctx.push({"some": "var"})
nested_ctx.push({"name": "carl"})
rendered = self.template.render(nested_ctx)
template = Template(template_str)
rendered = template.render(Context({"slot_names": ["header", "main"]}))
self.assertHTMLEqual(
rendered,
"""
<body>
<div>
<main> ABC: carl var </main>
</div>
</body>
<custom-template>
<header>
OVERRIDEN_SLOT "header" - INDEX 0 - ORIGINAL "Default header"
</header>
<main>
OVERRIDEN_SLOT "main" - INDEX 1 - ORIGINAL "Default main"
</main>
<footer>
Default footer
</footer>
</custom-template>
""",
)
@parametrize_context_behavior(["isolated", "django"])
def test_with(self):
@register("test")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<custom-template>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>
"""
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"name": name,
}
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% with slot="header" %}
{% fill name=slot default="default" %}
OVERRIDEN_SLOT "{{ slot }}" - ORIGINAL "{{ default }}"
{% endfill %}
{% endwith %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context())
self.assertHTMLEqual(
rendered,
"""
<custom-template>
<header>
OVERRIDEN_SLOT "header" - ORIGINAL "Default header"
</header>
<main>Default main</main>
<footer>Default footer</footer>
</custom-template>
""",
)
@parametrize_context_behavior(["isolated", "django"])
def test_if_for_raises_on_content_outside_fill(self):
@register("test")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<custom-template>
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
</custom-template>
"""
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"name": name,
}
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% if slot_names %}
{% for slot in slot_names %}
{{ forloop.counter0 }}
{% fill name=slot default="default" %}
OVERRIDEN_SLOT
{% endfill %}
{% endfor %}
{% endif %}
{% if 1 > 2 %}
{% fill "footer" %}
FOOTER
{% endfill %}
{% endif %}
{% endcomponent %}
"""
template = Template(template_str)
with self.assertRaisesMessage(TemplateSyntaxError, "Illegal content passed to component 'test'"):
template.render(Context({"slot_names": ["header", "main"]}))
@parametrize_context_behavior(["django", "isolated"])
def test_slots_inside_loops(self):
@register("test_comp")
class OuterComp(Component):
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"slots": ["header", "main", "footer"],
}
template: types.django_html = """
{% load component_tags %}
{% for slot_name in slots %}
<div>
{% slot name=slot_name %}
{{ slot_name }}
{% endslot %}
</div>
{% endfor %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "test_comp" %}
{% fill "header" %}
CUSTOM HEADER
{% endfill %}
{% fill "main" %}
CUSTOM MAIN
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context())
expected = """
<div>CUSTOM HEADER</div>
<div>CUSTOM MAIN</div>
<div>footer</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_passthrough_slots(self):
registry.register("slotted", SlottedComponent)
@register("test_comp")
class OuterComp(Component):
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"slots": self.input.slots,
}
template: types.django_html = """
{% load component_tags %}
<div>
{% component "slotted" %}
{% for slot_name in slots %}
{% fill name=slot_name %}
{% slot name=slot_name / %}
{% endfill %}
{% endfor %}
{% endcomponent %}
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "test_comp" %}
{% fill "header" %}
CUSTOM HEADER
{% endfill %}
{% fill "main" %}
CUSTOM MAIN
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context())
expected = """
<div>
<custom-template>
<header>CUSTOM HEADER</header>
<main>CUSTOM MAIN</main>
<footer>Default footer</footer>
</custom-template>
</div>
"""
self.assertHTMLEqual(rendered, expected)
# NOTE: Ideally we'd (optionally) raise an error / warning here, but it's not possible
# with current implementation. So this tests serves as a documentation of the current behavior.
@parametrize_context_behavior(["django", "isolated"])
def test_passthrough_slots_unknown_fills_ignored(self):
registry.register("slotted", SlottedComponent)
@register("test_comp")
class OuterComp(Component):
def get_context_data(self, name: Optional[str] = None) -> Dict[str, Any]:
return {
"slots": self.input.slots,
}
template: types.django_html = """
{% load component_tags %}
<div>
{% component "slotted" %}
{% for slot_name in slots %}
{% fill name=slot_name %}
{% slot name=slot_name / %}
{% endfill %}
{% endfor %}
{% endcomponent %}
</div>
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "test_comp" %}
{% fill "header1" %}
CUSTOM HEADER
{% endfill %}
{% fill "main" %}
CUSTOM MAIN
{% endfill %}
{% endcomponent %}
"""
template = Template(template_str)
rendered = template.render(Context())
expected = """
<div>
<custom-template>
<header>Default header</header>
<main>CUSTOM MAIN</main>
<footer>Default footer</footer>
</custom-template>
</div>
"""
self.assertHTMLEqual(rendered, expected)
# See https://github.com/EmilStenstrom/django-components/issues/698
class NestedSlotsTests(BaseTestCase):
@ -1036,6 +1566,43 @@ class ScopedSlotTest(BaseTestCase):
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_and_default_on_default_slot(self):
@register("test")
class TestComponent(Component):
template: types.django_html = """
{% load component_tags %}
<div>
<b>{% slot "slot_a" abc=abc var123=var123 %} Default text A {% endslot %}</b>
<b>{% slot "slot_b" abc=abc var123=var123 default %} Default text B {% endslot %}</b>
</div>
"""
def get_context_data(self):
return {
"abc": "xyz",
"var123": 456,
}
template: types.django_html = """
{% load component_tags %}
{% component "test" %}
{% fill name="default" data="slot_data_in_fill" default="slot_var" %}
{{ slot_data_in_fill.abc }}
{{ slot_var }}
{{ slot_data_in_fill.var123 }}
{% endfill %}
{% endcomponent %}
"""
rendered = Template(template).render(Context())
expected = """
<div>
<b>Default text A</b>
<b>xyz Default text B 456</b>
</div>
"""
self.assertHTMLEqual(rendered, expected)
@parametrize_context_behavior(["django", "isolated"])
def test_slot_data_raises_on_slot_data_and_slot_default_same_var(self):
@register("test")
@ -1482,37 +2049,23 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_fill_with_no_parent_is_error(self):
with self.assertRaises(TemplateSyntaxError):
with self.assertRaisesMessage(
TemplateSyntaxError,
"FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context",
):
template_str: types.django_html = """
{% load component_tags %}
{% fill "header" %}contents{% endfill %}
"""
Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_isolated_slot_is_error(self):
@register("broken_component")
class BrokenComponent(Component):
template: types.django_html = """
{% load component_tags %}
{% include 'slotted_template.html' with context=None only %}
"""
template_str: types.django_html = """
{% load component_tags %}
{% component "broken_component" %}
{% fill "header" %}Custom header {% endfill %}
{% fill "main" %}Custom main{% endfill %}
{% fill "footer" %}Custom footer{% endfill %}
{% endcomponent %}
"""
with self.assertRaises(KeyError):
Template(template_str).render(Context({}))
@parametrize_context_behavior(["django", "isolated"])
def test_non_unique_fill_names_is_error(self):
with self.assertRaises(TemplateSyntaxError):
with self.assertRaisesMessage(
TemplateSyntaxError,
"Multiple fill tags cannot target the same slot name in component 'test': "
"Detected duplicate fill tag name 'header'",
):
template_str: types.django_html = """
{% load component_tags %}
{% component "test" %}
@ -1524,7 +2077,11 @@ class SlotFillTemplateSyntaxErrorTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_non_unique_fill_names_is_error_via_vars(self):
with self.assertRaises(TemplateSyntaxError):
with self.assertRaisesMessage(
TemplateSyntaxError,
"Multiple fill tags cannot target the same slot name in component 'test': "
"Detected duplicate fill tag name 'header'",
):
template_str: types.django_html = """
{% load component_tags %}
{% with var1="header" var2="header" %}
@ -1647,3 +2204,86 @@ class SlotBehaviorTests(BaseTestCase):
</custom-template>
""",
)
class SlotInputTests(BaseTestCase):
@parametrize_context_behavior(["django", "isolated"])
def test_slots_accessible_when_python_render(self):
slots: Dict = {}
@register("test")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main header{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
"""
def get_context_data(self, input: Optional[int] = None) -> Dict[str, Any]:
nonlocal slots
slots = self.input.slots
return {}
self.assertEqual(slots, {})
template_str: types.django_html = """
{% load component_tags %}
{% component "test" input=1 %}
{% fill "header" data="data1" %}
data1_in_slot1: {{ data1|safe }}
{% endfill %}
{% fill "main" / %}
{% endcomponent %}
"""
template = Template(template_str)
template.render(Context())
self.assertListEqual(
list(slots.keys()),
["header", "main"],
)
self.assertTrue(callable(slots["header"]))
self.assertTrue(callable(slots["main"]))
self.assertTrue("footer" not in slots)
@parametrize_context_behavior(["django", "isolated"])
def test_slots_normalized_as_slot_instances(self):
slots: Dict[str, Slot] = {}
@register("test")
class SlottedComponent(Component):
template: types.django_html = """
{% load component_tags %}
<header>{% slot "header" %}Default header{% endslot %}</header>
<main>{% slot "main" %}Default main header{% endslot %}</main>
<footer>{% slot "footer" %}Default footer{% endslot %}</footer>
"""
def get_context_data(self, input: Optional[int] = None) -> Dict[str, Any]:
nonlocal slots
slots = self.input.slots
return {}
self.assertEqual(slots, {})
header_slot = Slot(lambda *a, **kw: "HEADER_SLOT")
main_slot_str = "MAIN_SLOT"
footer_slot_fn = lambda *a, **kw: "FOOTER_SLOT" # noqa: E731
SlottedComponent.render(
slots={
"header": header_slot,
"main": main_slot_str,
"footer": footer_slot_fn,
}
)
self.assertIsInstance(slots["header"], Slot)
self.assertEqual(slots["header"](Context(), None, None), "HEADER_SLOT") # type: ignore[arg-type]
self.assertIsInstance(slots["main"], Slot)
self.assertEqual(slots["main"](Context(), None, None), "MAIN_SLOT") # type: ignore[arg-type]
self.assertIsInstance(slots["footer"], Slot)
self.assertEqual(slots["footer"](Context(), None, None), "FOOTER_SLOT") # type: ignore[arg-type]

View file

@ -1,4 +1,4 @@
from django_components.utils import is_str_wrapped_in_quotes
from django_components.util.misc import is_str_wrapped_in_quotes
from .django_test_setup import setup_test_config
from .testutils import BaseTestCase

View file

@ -2,7 +2,7 @@ import contextlib
import functools
import sys
from typing import Any, Dict, List, Optional, Tuple, Union
from unittest.mock import Mock
from unittest.mock import Mock, patch
from django.template import Context, Node
from django.template.loader import engines
@ -10,7 +10,7 @@ from django.template.response import TemplateResponse
from django.test import SimpleTestCase, override_settings
from django_components.app_settings import ContextBehavior
from django_components.autodiscover import autodiscover
from django_components.autodiscovery import autodiscover
from django_components.component_registry import registry
from django_components.middleware import ComponentDependencyMiddleware
@ -20,7 +20,13 @@ middleware = ComponentDependencyMiddleware(get_response=lambda _: response_stash
class BaseTestCase(SimpleTestCase):
def tearDown(self) -> None:
def setUp(self):
super().setUp()
self._start_gen_id_patch()
def tearDown(self):
self._stop_gen_id_patch()
super().tearDown()
registry.clear()
@ -28,6 +34,22 @@ class BaseTestCase(SimpleTestCase):
_create_template.cache_remove() # type: ignore[attr-defined]
# Mock the `generate` function used inside `gen_id` so it returns deterministic IDs
def _start_gen_id_patch(self):
# Random number so that the generated IDs are "hex-looking", e.g. a1bc3d
self._gen_id_count = 10599485
def mock_gen_id(*args, **kwargs):
self._gen_id_count += 1
return hex(self._gen_id_count)[2:]
self._gen_id_patch = patch("django_components.util.misc.generate", side_effect=mock_gen_id)
self._gen_id_patch.start()
def _stop_gen_id_patch(self):
self._gen_id_patch.stop()
self._gen_id_count = 10599485
request = Mock()
mock_template = Mock()
@ -142,12 +164,16 @@ def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optiona
# Because of this, we need to clear the loader cache, and, on error, we need to
# propagate the info on which test case failed.
@functools.wraps(test_func)
def wrapper(*args, **kwargs):
def wrapper(self: BaseTestCase, *args, **kwargs):
for case in cases:
# Clear loader cache, see https://stackoverflow.com/a/77531127/9788634
for engine in engines.all():
engine.engine.template_loaders[0].reset()
# Reset gen_id
self._stop_gen_id_patch()
self._start_gen_id_patch()
case_has_data = not isinstance(case, str)
if isinstance(case, str):
@ -169,9 +195,9 @@ def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optiona
# Call the test function with the fixture as an argument
try:
if case_has_data:
test_func(*args, context_behavior_data=fixture, **kwargs)
test_func(self, *args, context_behavior_data=fixture, **kwargs)
else:
test_func(*args, **kwargs)
test_func(self, *args, **kwargs)
except Exception as err:
# Give a hint on which iteration the test failed
raise RuntimeError(