+ {% endif %}
+ ```
+
+ NOTE: `component_vars.is_filled` automatically escaped slot names, so that even slot names that are
+ not valid python identifiers could be set as slot names. `component_vars.slots` no longer does that.
+
+- Component attribute `Component.is_filled` is now deprecated. Will be removed in v1. Use `Component.slots` instead.
+
+ Before:
+
+ ```py
+ class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ if self.is_filled.footer:
+ color = "red"
+ else:
+ color = "blue"
+
+ return {
+ "color": color,
+ }
+ ```
+
+ After:
+
+ ```py
+ class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ if "footer" in slots:
+ color = "red"
+ else:
+ color = "blue"
+
+ return {
+ "color": color,
+ }
+ ```
+
+ NOTE: `Component.is_filled` automatically escaped slot names, so that even slot names that are
+ not valid python identifiers could be set as slot names. `Component.slots` no longer does that.
+
+**Miscellaneous**
+
+- Template caching with `cached_template()` helper and `template_cache_size` setting is deprecated.
+ These will be removed in v1.
+
+ This feature made sense if you were dynamically generating templates for components using
+ `Component.get_template_string()` and `Component.get_template()`.
+
+ However, in v1, each Component will have at most one static template. This static template
+ is cached internally per component class, and reused across renders.
+
+ This makes the template caching feature obsolete.
+
+ If you relied on `cached_template()`, you should either:
+
+ 1. Wrap the templates as Components.
+ 2. Manage the cache of Templates yourself.
+
+- The `debug_highlight_components` and `debug_highlight_slots` settings are deprecated.
+ These will be removed in v1.
+
+ The debug highlighting feature was re-implemented as an extension.
+ As such, the recommended way for enabling it has changed:
+
+ Before:
+
+ ```python
+ COMPONENTS = ComponentsSettings(
+ debug_highlight_components=True,
+ debug_highlight_slots=True,
+ )
+ ```
+
+ After:
+
+ Set `extensions_defaults` in your `settings.py` file.
+
+ ```python
+ COMPONENTS = ComponentsSettings(
+ extensions_defaults={
+ "debug_highlight": {
+ "highlight_components": True,
+ "highlight_slots": True,
+ },
+ },
+ )
+ ```
+
+ Alternatively, you can enable highlighting for specific components by setting `Component.DebugHighlight.highlight_components` to `True`:
+
+ ```python
+ class MyComponent(Component):
+ class DebugHighlight:
+ highlight_components = True
+ highlight_slots = True
+ ```
+
+#### Feat
+
+- New method to render template variables - `get_template_data()`
+
+ `get_template_data()` behaves the same way as `get_context_data()`, but has
+ a different function signature to accept also slots and context.
+
+ ```py
+ class Button(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "val1": args[0],
+ "val2": kwargs["field"],
+ }
+ ```
+
+ If you define `Component.Args`, `Component.Kwargs`, `Component.Slots`, then
+ the `args`, `kwargs`, `slots` arguments will be instances of these classes:
+
+ ```py
+ class Button(Component):
+ class Args(NamedTuple):
+ field1: str
+
+ class Kwargs(NamedTuple):
+ field2: int
+
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots, context):
+ return {
+ "val1": args.field1,
+ "val2": kwargs.field2,
+ }
+ ```
+
+- Input validation is now part of the render process.
+
+ When you specify the input types (such as `Component.Args`, `Component.Kwargs`, etc),
+ the actual inputs to data methods (`Component.get_template_data()`, etc) will be instances of the types you specified.
+
+ This practically brings back input validation, because the instantiation of the types
+ will raise an error if the inputs are not valid.
+
+ Read more on [Typing and validation](https://django-components.github.io/django-components/latest/concepts/fundamentals/typing_and_validation/)
+
+- Render emails or other non-browser HTML with new "dependencies strategies"
+
+ When rendering a component with `Component.render()` or `Component.render_to_response()`,
+ the `deps_strategy` kwarg (previously `type`) now accepts additional options:
+
+ - `"simple"`
+ - `"prepend"`
+ - `"append"`
+ - `"ignore"`
+
+ ```py
+ Calendar.render_to_response(
+ request=request,
+ kwargs={
+ "date": request.GET.get("date", ""),
+ },
+ deps_strategy="append",
+ )
+ ```
+
+ Comparison of dependencies render strategies:
+
+ - `"document"`
+ - Smartly inserts JS / CSS into placeholders or into `` and `` tags.
+ - Inserts extra script to allow `fragment` strategy to work.
+ - Assumes the HTML will be rendered in a JS-enabled browser.
+ - `"fragment"`
+ - A lightweight HTML fragment to be inserted into a document with AJAX.
+ - Ignores placeholders and any `` / `` tags.
+ - No JS / CSS included.
+ - `"simple"`
+ - Smartly insert JS / CSS into placeholders or into `` and `` tags.
+ - No extra script loaded.
+ - `"prepend"`
+ - Insert JS / CSS before the rendered HTML.
+ - Ignores placeholders and any `` / `` tags.
+ - No extra script loaded.
+ - `"append"`
+ - Insert JS / CSS after the rendered HTML.
+ - Ignores placeholders and any `` / `` tags.
+ - No extra script loaded.
+ - `"ignore"`
+ - Rendered HTML is left as-is. You can still process it with a different strategy later with `render_dependencies()`.
+ - Used for inserting rendered HTML into other components.
+
+ See [Dependencies rendering](https://django-components.github.io/django-components/0.140.1/concepts/advanced/rendering_js_css/) for more info.
+
+- New `Component.args`, `Component.kwargs`, `Component.slots` attributes available on the component class itself.
+
+ These attributes are the same as the ones available in `Component.get_template_data()`.
+
+ You can use these in other methods like `Component.on_render_before()` or `Component.on_render_after()`.
+
+ ```py
+ from django_components import Component, SlotInput
+
+ class Table(Component):
+ class Args(NamedTuple):
+ page: int
+
+ class Kwargs(NamedTuple):
+ per_page: int
+
+ class Slots(NamedTuple):
+ content: SlotInput
+
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert self.args.page == 123
+ assert self.kwargs.per_page == 10
+ content_html = self.slots.content()
+ ```
+
+ Same as with the parameters in `Component.get_template_data()`, they will be instances of the `Args`, `Kwargs`, `Slots` classes
+ if defined, or plain lists / dictionaries otherwise.
+
+- 4 attributes that were previously available only under the `Component.input` attribute
+ are now available directly on the Component instance:
+
+ - `Component.raw_args`
+ - `Component.raw_kwargs`
+ - `Component.raw_slots`
+ - `Component.deps_strategy`
+
+ The first 3 attributes are the same as the deprecated `Component.input.args`, `Component.input.kwargs`, `Component.input.slots` properties.
+
+ Compared to the `Component.args` / `Component.kwargs` / `Component.slots` attributes,
+ these "raw" attributes are not typed and will remain as plain lists / dictionaries
+ even if you define the `Args`, `Kwargs`, `Slots` classes.
+
+ The `Component.deps_strategy` attribute is the same as the deprecated `Component.input.deps_strategy` property.
+
+- New template variables `{{ component_vars.args }}`, `{{ component_vars.kwargs }}`, `{{ component_vars.slots }}`
+
+ These attributes are the same as the ones available in `Component.get_template_data()`.
+
+ ```django
+ {# Typed #}
+ {% if component_vars.args.page == 123 %}
+
+ {% endif %}
+ ```
+
+ Same as with the parameters in `Component.get_template_data()`, they will be instances of the `Args`, `Kwargs`, `Slots` classes
+ if defined, or plain lists / dictionaries otherwise.
+
+- New component lifecycle hook `Component.on_render()`.
+
+ This hook is called when the component is being rendered.
+
+ You can override this method to:
+
+ - Change what template gets rendered
+ - Modify the context
+ - Modify the rendered output after it has been rendered
+ - Handle errors
+
+ See [on_render](https://django-components.github.io/django-components/0.140.1/concepts/advanced/hooks/#on_render) for more info.
+
+- `get_component_url()` now optionally accepts `query` and `fragment` arguments.
+
+ ```py
+ from django_components import get_component_url
+
+ url = get_component_url(
+ MyComponent,
+ query={"foo": "bar"},
+ fragment="baz",
+ )
+ # /components/ext/view/components/c1ab2c3?foo=bar#baz
+ ```
+
+- The `BaseNode` class has a new `contents` attribute, which contains the raw contents (string) of the tag body.
+
+ This is relevant when you define custom template tags with `@template_tag` decorator or `BaseNode` class.
+
+ When you define a custom template tag like so:
+
+ ```py
+ from django_components import BaseNode, template_tag
+
+ @template_tag(
+ library,
+ tag="mytag",
+ end_tag="endmytag",
+ allowed_flags=["required"]
+ )
+ def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str:
+ print(node.contents)
+ return f"Hello, {name}!"
+ ```
+
+ And render it like so:
+
+ ```django
+ {% mytag name="John" %}
+ Hello, world!
+ {% endmytag %}
+ ```
+
+ Then, the `contents` attribute of the `BaseNode` instance will contain the string `"Hello, world!"`.
+
+- The `BaseNode` class also has two new metadata attributes:
+
+ - `template_name` - the name of the template that rendered the node.
+ - `template_component` - the component class that the template belongs to.
+
+ This is useful for debugging purposes.
+
+- `Slot` class now has 3 new metadata fields:
+
+ 1. `Slot.contents` attribute contains the original contents:
+
+ - If `Slot` was created from `{% fill %}` tag, `Slot.contents` will contain the body of the `{% fill %}` tag.
+ - If `Slot` was created from string via `Slot("...")`, `Slot.contents` will contain that string.
+ - If `Slot` was created from a function, `Slot.contents` will contain that function.
+
+ 2. `Slot.extra` attribute where you can put arbitrary metadata about the slot.
+
+ 3. `Slot.fill_node` attribute tells where the slot comes from:
+
+ - `FillNode` instance if the slot was created from `{% fill %}` tag.
+ - `ComponentNode` instance if the slot was created as a default slot from a `{% component %}` tag.
+ - `None` if the slot was created from a string, function, or `Slot` instance.
+
+ See [Slot metadata](https://django-components.github.io/django-components/0.140.1/concepts/fundamentals/slots/#slot-metadata).
+
+- `{% fill %}` tag now accepts `body` kwarg to pass a Slot instance to fill.
+
+ First pass a `Slot` instance to the template
+ with the `get_template_data()` method:
+
+ ```python
+ from django_components import component, Slot
+
+ class Table(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "my_slot": Slot(lambda ctx: "Hello, world!"),
+ }
+ ```
+
+ Then pass the slot to the `{% fill %}` tag:
+
+ ```django
+ {% component "table" %}
+ {% fill "pagination" body=my_slot / %}
+ {% endcomponent %}
+ ```
+
+- You can now access the `{% component %}` tag (`ComponentNode` instance) from which a Component
+ was created. Use `Component.node` to access it.
+
+ This is mostly useful for extensions, which can use this to detect if the given Component
+ comes from a `{% component %}` tag or from a different source (such as `Component.render()`).
+
+ `Component.node` is `None` if the component is created by `Component.render()` (but you
+ can pass in the `node` kwarg yourself).
+
+ ```py
+ class MyComponent(Component):
+ def get_template_data(self, context, template):
+ if self.node is not None:
+ assert self.node.name == "my_component"
+ ```
+
+- Node classes `ComponentNode`, `FillNode`, `ProvideNode`, and `SlotNode` are part of the public API.
+
+ These classes are what is instantiated when you use `{% component %}`, `{% fill %}`, `{% provide %}`, and `{% slot %}` tags.
+
+ You can for example use these for type hints:
+
+ ```py
+ from django_components import Component, ComponentNode
+
+ class MyTable(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ if kwargs.get("show_owner"):
+ node: Optional[ComponentNode] = self.node
+ owner: Optional[Component] = self.node.template_component
+ else:
+ node = None
+ owner = None
+
+ return {
+ "owner": owner,
+ "node": node,
+ }
+ ```
+
+- Component caching can now take slots into account, by setting `Component.Cache.include_slots` to `True`.
+
+ ```py
+ class MyComponent(Component):
+ class Cache:
+ enabled = True
+ include_slots = True
+ ```
+
+ In which case the following two calls will generate separate cache entries:
+
+ ```django
+ {% component "my_component" position="left" %}
+ Hello, Alice
+ {% endcomponent %}
+
+ {% component "my_component" position="left" %}
+ Hello, Bob
+ {% endcomponent %}
+ ```
+
+ Same applies to `Component.render()` with string slots:
+
+ ```py
+ MyComponent.render(
+ kwargs={"position": "left"},
+ slots={"content": "Hello, Alice"}
+ )
+ MyComponent.render(
+ kwargs={"position": "left"},
+ slots={"content": "Hello, Bob"}
+ )
+ ```
+
+ Read more on [Component caching](https://django-components.github.io/django-components/0.140.1/concepts/advanced/component_caching/).
+
+- New extension hook `on_slot_rendered()`
+
+ This hook is called when a slot is rendered, and allows you to access and/or modify the rendered result.
+
+ This is used by the ["debug highlight" feature](https://django-components.github.io/django-components/0.140.1/guides/other/troubleshooting/#component-and-slot-highlighting).
+
+ To modify the rendered result, return the new value:
+
+ ```py
+ class MyExtension(ComponentExtension):
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
+ return ctx.result + ""
+ ```
+
+ If you don't want to modify the rendered result, return `None`.
+
+ See all [Extension hooks](https://django-components.github.io/django-components/0.140.1/reference/extension_hooks/).
+
+- When creating extensions, the previous syntax with `ComponentExtension.ExtensionClass` was causing
+ Mypy errors, because Mypy doesn't allow using class attributes as bases:
+
+ Before:
+
+ ```py
+ from django_components import ComponentExtension
+
+ class MyExtension(ComponentExtension):
+ class ExtensionClass(ComponentExtension.ExtensionClass): # Error!
+ pass
+ ```
+
+ Instead, you can import `ExtensionComponentConfig` directly:
+
+ After:
+
+ ```py
+ from django_components import ComponentExtension, ExtensionComponentConfig
+
+ class MyExtension(ComponentExtension):
+ class ComponentConfig(ExtensionComponentConfig):
+ pass
+ ```
+
+#### Refactor
+
+- When a component is being rendered, a proper `Component` instance is now created.
+
+ Previously, the `Component` state was managed as half-instance, half-stack.
+
+- Component's "Render API" (args, kwargs, slots, context, inputs, request, context data, etc)
+ can now be accessed also outside of the render call. So now its possible to take the component
+ instance out of `get_template_data()` (although this is not recommended).
+
+- Components can now be defined without a template.
+
+ Previously, the following would raise an error:
+
+ ```py
+ class MyComponent(Component):
+ pass
+ ```
+
+ "Template-less" components can be used together with `Component.on_render()` to dynamically
+ pick what to render:
+
+ ```py
+ class TableNew(Component):
+ template_file = "table_new.html"
+
+ class TableOld(Component):
+ template_file = "table_old.html"
+
+ class Table(Component):
+ def on_render(self, context, template):
+ if self.kwargs.get("feat_table_new_ui"):
+ return TableNew.render(args=self.args, kwargs=self.kwargs, slots=self.slots)
+ else:
+ return TableOld.render(args=self.args, kwargs=self.kwargs, slots=self.slots)
+ ```
+
+ "Template-less" components can be also used as a base class for other components, or as mixins.
+
+- Passing `Slot` instance to `Slot` constructor raises an error.
+
+- Extension hook `on_component_rendered` now receives `error` field.
+
+ `on_component_rendered` now behaves similar to `Component.on_render_after`:
+
+ - Raising error in this hook overrides what error will be returned from `Component.render()`.
+ - Returning new string overrides what will be returned from `Component.render()`.
+
+ Before:
+
+ ```py
+ class OnComponentRenderedContext(NamedTuple):
+ component: "Component"
+ component_cls: Type["Component"]
+ component_id: str
+ result: str
+ ```
+
+ After:
+
+ ```py
+ class OnComponentRenderedContext(NamedTuple):
+ component: "Component"
+ component_cls: Type["Component"]
+ component_id: str
+ result: Optional[str]
+ error: Optional[Exception]
+ ```
+
+#### Fix
+
+- Fix bug: Context processors data was being generated anew for each component. Now the data is correctly created once and reused across components with the same request ([#1165](https://github.com/django-components/django-components/issues/1165)).
+
+- Fix KeyError on `component_context_cache` when slots are rendered outside of the component's render context. ([#1189](https://github.com/django-components/django-components/issues/1189))
+
+- Component classes now have `do_not_call_in_templates=True` to prevent them from being called as functions in templates.
+
+## v0.139.1
+
+#### Fix
+
+- Fix compatibility of component caching with `{% extend %}` block ([#1135](https://github.com/django-components/django-components/issues/1135))
+
+#### Refactor
+
+- Component ID is now prefixed with `c`, e.g. `c123456`.
+
+- When typing a Component, you can now specify as few or as many parameters as you want.
+
+ ```py
+ Component[Args]
+ Component[Args, Kwargs]
+ Component[Args, Kwargs, Slots]
+ Component[Args, Kwargs, Slots, Data]
+ Component[Args, Kwargs, Slots, Data, JsData]
+ Component[Args, Kwargs, Slots, Data, JsData, CssData]
+ ```
+
+ All omitted parameters will default to `Any`.
+
+- Added `typing_extensions` to the project as a dependency
+
+- Multiple extensions with the same name (case-insensitive) now raise an error
+
+- Extension names (case-insensitive) also MUST NOT conflict with existing Component class API.
+
+ So if you name an extension `render`, it will conflict with the `render()` method of the `Component` class,
+ and thus raise an error.
+
+## v0.139.0
+
+#### Fix
+
+- Fix bug: Fix compatibility with `Finder.find()` in Django 5.2 ([#1119](https://github.com/django-components/django-components/issues/1119))
+
+## v0.138
+
+#### Fix
+
+- Fix bug: Allow components with `Url.public = True` to be defined before `django.setup()`
+
+## v0.137
+
+#### Feat
+
+- Each Component class now has a `class_id` attribute, which is unique to the component subclass.
+
+ NOTE: This is different from `Component.id`, which is unique to each rendered instance.
+
+ To look up a component class by its `class_id`, use `get_component_by_class_id()`.
+
+- It's now easier to create URLs for component views.
+
+ Before, you had to call `Component.as_view()` and pass that to `urlpatterns`.
+
+ Now this can be done for you if you set `Component.Url.public` to `True`:
+
+ ```py
+ class MyComponent(Component):
+ class Url:
+ public = True
+ ...
+ ```
+
+ Then, to get the URL for the component, use `get_component_url()`:
+
+ ```py
+ from django_components import get_component_url
+
+ url = get_component_url(MyComponent)
+ ```
+
+ This way you don't have to mix your app URLs with component URLs.
+
+ Read more on [Component views and URLs](https://django-components.github.io/django-components/0.137/concepts/fundamentals/component_views_urls/).
+
+- Per-component caching - Set `Component.Cache.enabled` to `True` to enable caching for a component.
+
+ Component caching allows you to store the rendered output of a component. Next time the component is rendered
+ with the same input, the cached output is returned instead of re-rendering the component.
+
+ ```py
+ class TestComponent(Component):
+ template = "Hello"
+
+ class Cache:
+ enabled = True
+ ttl = 0.1 # .1 seconds TTL
+ cache_name = "custom_cache"
+
+ # Custom hash method for args and kwargs
+ # NOTE: The default implementation simply serializes the input into a string.
+ # As such, it might not be suitable for complex objects like Models.
+ def hash(self, *args, **kwargs):
+ return f"{json.dumps(args)}:{json.dumps(kwargs)}"
+
+ ```
+
+ Read more on [Component caching](https://django-components.github.io/django-components/0.137/concepts/advanced/component_caching/).
+
+- `@djc_test` can now be called without first calling `django.setup()`, in which case it does it for you.
+
+- Expose `ComponentInput` class, which is a typing for `Component.input`.
+
+#### Deprecation
+
+- Currently, view request handlers such as `get()` and `post()` methods can be defined
+ directly on the `Component` class:
+
+ ```py
+ class MyComponent(Component):
+ def get(self, request):
+ return self.render_to_response()
+ ```
+
+ Or, nested within the `Component.View` class:
+
+ ```py
+ class MyComponent(Component):
+ class View:
+ def get(self, request):
+ return self.render_to_response()
+ ```
+
+ In v1, these methods should be defined only on the `Component.View` class instead.
+
+#### Refactor
+
+- `Component.get_context_data()` can now omit a return statement or return `None`.
+
+## 🚨📢 v0.136
+
+#### 🚨📢 BREAKING CHANGES
+
+- Component input validation was moved to a separate extension [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic).
+
+ If you relied on components raising errors when inputs were invalid, you need to install `djc-ext-pydantic` and add it to extensions:
+
+ ```python
+ # settings.py
+ COMPONENTS = {
+ "extensions": [
+ "djc_pydantic.PydanticExtension",
+ ],
+ }
+ ```
+
+#### Fix
+
+- Make it possible to resolve URLs added by extensions by their names
+
+## v0.135
+
+#### Feat
+
+- Add defaults for the component inputs with the `Component.Defaults` nested class. Defaults
+ are applied if the argument is not given, or if it set to `None`.
+
+ For lists, dictionaries, or other objects, wrap the value in `Default()` class to mark it as a factory
+ function:
+
+ ```python
+ from django_components import Default
+
+ class Table(Component):
+ class Defaults:
+ position = "left"
+ width = "200px"
+ options = Default(lambda: ["left", "right", "center"])
+
+ def get_context_data(self, position, width, options):
+ return {
+ "position": position,
+ "width": width,
+ "options": options,
+ }
+
+ # `position` is used as given, `"right"`
+ # `width` uses default because it's `None`
+ # `options` uses default because it's missing
+ Table.render(
+ kwargs={
+ "position": "right",
+ "width": None,
+ }
+ )
+ ```
+
+- `{% html_attrs %}` now offers a Vue-like granular control over `class` and `style` HTML attributes,
+where each class name or style property can be managed separately.
+
+ ```django
+ {% html_attrs
+ class="foo bar"
+ class={"baz": True, "foo": False}
+ class="extra"
+ %}
+ ```
+
+ ```django
+ {% html_attrs
+ style="text-align: center; background-color: blue;"
+ style={"background-color": "green", "color": None, "width": False}
+ style="position: absolute; height: 12px;"
+ %}
+ ```
+
+ Read more on [HTML attributes](https://django-components.github.io/django-components/0.135/concepts/fundamentals/html_attributes/).
+
+#### Fix
+
+- Fix compat with Windows when reading component files ([#1074](https://github.com/django-components/django-components/issues/1074))
+- Fix resolution of component media files edge case ([#1073](https://github.com/django-components/django-components/issues/1073))
+
+## v0.134
+
+#### Fix
+
+- HOTFIX: Fix the use of URLs in `Component.Media.js` and `Component.Media.css`
+
+## v0.133
+
+⚠️ Attention ⚠️ - Please update to v0.134 to fix bugs introduced in v0.132.
+
+#### Fix
+
+- HOTFIX: Fix the use of URLs in `Component.Media.js` and `Component.Media.css`
+
+## v0.132
+
+⚠️ Attention ⚠️ - Please update to v0.134 to fix bugs introduced in v0.132.
+
+#### Feat
+
+- Allow to use glob patterns as paths for additional JS / CSS in
+ `Component.Media.js` and `Component.Media.css`
+
+ ```py
+ class MyComponent(Component):
+ class Media:
+ js = ["*.js"]
+ css = ["*.css"]
+ ```
+
+#### Fix
+
+- Fix installation for Python 3.13 on Windows.
+
+## v0.131
+
+#### Feat
+
+- Support for extensions (plugins) for django-components!
+
+ - Hook into lifecycle events of django-components
+ - Pre-/post-process component inputs, outputs, and templates
+ - Add extra methods or attributes to Components
+ - Add custom extension-specific CLI commands
+ - Add custom extension-specific URL routes
+
+ Read more on [Extensions](https://django-components.github.io/django-components/0.131/concepts/advanced/extensions/).
+
+- New CLI commands:
+ - `components list` - List all components
+ - `components create ` - Create a new component (supersedes `startcomponent`)
+ - `components upgrade` - Upgrade a component (supersedes `upgradecomponent`)
+ - `components ext list` - List all extensions
+ - `components ext run ` - Run a command added by an extension
+
+- `@djc_test` decorator for writing tests that involve Components.
+
+ - The decorator manages global state, ensuring that tests don't leak.
+ - If using `pytest`, the decorator allows you to parametrize Django or Components settings.
+ - The decorator also serves as a stand-in for Django's `@override_settings`.
+
+ See the API reference for [`@djc_test`](https://django-components.github.io/django-components/0.131/reference/testing_api/#django_components.testing.djc_test) for more details.
+
+- `ComponentRegistry` now has a `has()` method to check if a component is registered
+ without raising an error.
+
+- Get all created `Component` classes with `all_components()`.
+
+- Get all created `ComponentRegistry` instances with `all_registries()`.
+
+#### Refactor
+
+- The `startcomponent` and `upgradecomponent` commands are deprecated, and will be removed in v1.
+
+ Instead, use `components create ` and `components upgrade`.
+
+#### Internal
+
+- Settings are now loaded only once, and thus are considered immutable once loaded. Previously,
+ django-components would load settings from `settings.COMPONENTS` on each access. The new behavior
+ aligns with Django's settings.
+
+## v0.130
+
+#### Feat
+
+- Access the HttpRequest object under `Component.request`.
+
+ To pass the request object to a component, either:
+ - Render a template or component with `RequestContext`,
+ - Or set the `request` kwarg to `Component.render()` or `Component.render_to_response()`.
+
+ Read more on [HttpRequest](https://django-components.github.io/django-components/0.130/concepts/fundamentals/http_request/).
+
+- Access the context processors data under `Component.context_processors_data`.
+
+ Context processors data is available only when the component has access to the `request` object,
+ either by:
+ - Passing the request to `Component.render()` or `Component.render_to_response()`,
+ - Or by rendering a template or component with `RequestContext`,
+ - Or being nested in another component that has access to the request object.
+
+ The data from context processors is automatically available within the component's template.
+
+ Read more on [HttpRequest](https://django-components.github.io/django-components/0.130/concepts/fundamentals/http_request/).
+
+## v0.129
+
+#### Fix
+
+- Fix thread unsafe media resolve validation by moving it to ComponentMedia `__post_init` ([#977](https://github.com/django-components/django-components/pull/977)
+- Fix bug: Relative path in extends and include does not work when using template_file ([#976](https://github.com/django-components/django-components/pull/976)
+- Fix error when template cache setting (`template_cache_size`) is set to 0 ([#974](https://github.com/django-components/django-components/pull/974)
+
+## v0.128
+
+#### Feat
+
+- Configurable cache - Set [`COMPONENTS.cache`](https://django-components.github.io/django-components/0.128/reference/settings/#django_components.app_settings.ComponentsSettings.cache) to change where and how django-components caches JS and CSS files. ([#946](https://github.com/django-components/django-components/pull/946))
+
+ Read more on [Caching](https://django-components.github.io/django-components/0.128/guides/setup/caching).
+
+- Highlight coponents and slots in the UI - We've added two boolean settings [`COMPONENTS.debug_highlight_components`](https://django-components.github.io/django-components/0.128/reference/settings/#django_components.app_settings.ComponentsSettings.debug_highlight_components) and [`COMPONENTS.debug_highlight_slots`](https://django-components.github.io/django-components/0.128/reference/settings/#django_components.app_settings.ComponentsSettings.debug_highlight_slots), which can be independently set to `True`. First will wrap components in a blue border, the second will wrap slots in a red border. ([#942](https://github.com/django-components/django-components/pull/942))
+
+ Read more on [Troubleshooting](https://django-components.github.io/django-components/0.128/guides/other/troubleshooting/#component-and-slot-highlighting).
+
+#### Refactor
+
+- Removed use of eval for node validation ([#944](https://github.com/django-components/django-components/pull/944))
+
+#### Perf
+
+- Components can now be infinitely nested. ([#936](https://github.com/django-components/django-components/pull/936))
+
+- Component input validation is now 6-7x faster on CPython and PyPy. This previously made up 10-30% of the total render time. ([#945](https://github.com/django-components/django-components/pull/945))
+
+## v0.127
+
+#### Fix
+
+- Fix component rendering when using `{% cache %}` with remote cache and multiple web servers ([#930](https://github.com/django-components/django-components/issues/930))
+
+## v0.126
+
+#### Refactor
+
+- Replaced [BeautifulSoup4](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) with a custom HTML parser.
+- The heuristic for inserting JS and CSS dependenies into the default place has changed.
+ - JS is still inserted at the end of the ``, and CSS at the end of ``.
+ - However, we find end of `` by searching for **last** occurrence of ``
+ - And for the end of `` we search for the **first** occurrence of ``
+
+## v0.125
+
+⚠️ Attention ⚠️ - We migrated from `EmilStenstrom/django-components` to `django-components/django-components`.
+
+**Repo name and documentation URL changed. Package name remains the same.**
+
+If you see any broken links or other issues, please report them in [#922](https://github.com/django-components/django-components/issues/922).
+
+#### Feat
+
+- `@template_tag` and `BaseNode` - A decorator and a class that allow you to define
+ custom template tags that will behave similarly to django-components' own template tags.
+
+ Read more on [Template tags](https://django-components.github.io/django-components/0.125/concepts/advanced/template_tags/).
+
+ Template tags defined with `@template_tag` and `BaseNode` will have the following features:
+
+ - Accepting args, kwargs, and flags.
+
+ - Allowing literal lists and dicts as inputs as:
+
+ `key=[1, 2, 3]` or `key={"a": 1, "b": 2}`
+ - Using template tags tag inputs as:
+
+ `{% my_tag key="{% lorem 3 w %}" / %}`
+ - Supporting the flat dictionary definition:
+
+ `attr:key=value`
+ - Spreading args and kwargs with `...`:
+
+ `{% my_tag ...args ...kwargs / %}`
+ - Being able to call the template tag as:
+
+ `{% my_tag %} ... {% endmy_tag %}` or `{% my_tag / %}`
+
+
+#### Refactor
+
+- Refactored template tag input validation. When you now call template tags like
+ `{% slot %}`, `{% fill %}`, `{% html_attrs %}`, and others, their inputs are now
+ validated the same way as Python function inputs are.
+
+ So, for example
+
+ ```django
+ {% slot "my_slot" name="content" / %}
+ ```
+
+ will raise an error, because the positional argument `name` is given twice.
+
+ NOTE: Special kwargs whose keys are not valid Python variable names are not affected by this change.
+ So when you define:
+
+ ```django
+ {% component data-id=123 / %}
+ ```
+
+ The `data-id` will still be accepted as a valid kwarg, assuming that your `get_context_data()`
+ accepts `**kwargs`:
+
+ ```py
+ def get_context_data(self, **kwargs):
+ return {
+ "data_id": kwargs["data-id"],
+ }
+ ```
+
+## v0.124
+
+#### Feat
+
+- Instead of inlining the JS and CSS under `Component.js` and `Component.css`, you can move
+ them to their own files, and link the JS/CSS files with `Component.js_file` and `Component.css_file`.
+
+ Even when you specify the JS/CSS with `Component.js_file` or `Component.css_file`, then you can still
+ access the content under `Component.js` or `Component.css` - behind the scenes, the content of the JS/CSS files
+ will be set to `Component.js` / `Component.css` upon first access.
+
+ The same applies to `Component.template_file`, which will populate `Component.template` upon first access.
+
+ With this change, the role of `Component.js/css` and the JS/CSS in `Component.Media` has changed:
+
+ - The JS/CSS defined in `Component.js/css` or `Component.js/css_file` is the "main" JS/CSS
+ - The JS/CSS defined in `Component.Media.js/css` are secondary or additional
+
+ See the updated ["Getting Started" tutorial](https://django-components.github.io/django-components/0.124/getting_started/adding_js_and_css/)
+
+#### Refactor
+
+- The canonical way to define a template file was changed from `template_name` to `template_file`, to align with the rest of the API.
+
+ `template_name` remains for backwards compatibility. When you get / set `template_name`,
+ internally this is proxied to `template_file`.
+
+- The undocumented `Component.component_id` was removed. Instead, use `Component.id`. Changes:
+
+ - While `component_id` was unique every time you instantiated `Component`, the new `id` is unique
+ every time you render the component (e.g. with `Component.render()`)
+ - The new `id` is available only during render, so e.g. from within `get_context_data()`
+
+- Component's HTML / CSS / JS are now resolved and loaded lazily. That is, if you specify `template_name`/`template_file`,
+ `js_file`, `css_file`, or `Media.js/css`, the file paths will be resolved only once you:
+
+ 1. Try to access component's HTML / CSS / JS, or
+ 2. Render the component.
+
+ Read more on [Accessing component's HTML / JS / CSS](https://django-components.github.io/django-components/0.124/concepts/fundamentals/defining_js_css_html_files/#customize-how-paths-are-rendered-into-html-tags).
+
+- Component inheritance:
+
+ - When you subclass a component, the JS and CSS defined on parent's `Media` class is now inherited by the child component.
+ - You can disable or customize Media inheritance by setting `extend` attribute on the `Component.Media` nested class. This work similarly to Django's [`Media.extend`](https://docs.djangoproject.com/en/5.2/topics/forms/media/#extend).
+ - When child component defines either `template` or `template_file`, both of parent's `template` and `template_file` are ignored. The same applies to `js_file` and `css_file`.
+
+- Autodiscovery now ignores files and directories that start with an underscore (`_`), except `__init__.py`
+
+- The [Signals](https://docs.djangoproject.com/en/5.2/topics/signals/) emitted by or during the use of django-components are now documented, together the `template_rendered` signal.
+
## v0.123
#### Fix
-- Fix edge cases around rendering components whose templates used the `{% extends %}` template tag ([#859](https://github.com/EmilStenstrom/django-components/pull/859))
+- Fix edge cases around rendering components whose templates used the `{% extends %}` template tag ([#859](https://github.com/django-components/django-components/pull/859))
## v0.122
#### Feat
-- Add support for HTML fragments. HTML fragments can be rendered by passing `type="fragment"` to `Component.render()` or `Component.render_to_response()`. Read more on how to [use HTML fragments with HTMX, AlpineJS, or vanillaJS](https://EmilStenstrom.github.io/django-components/latest/concepts/advanced/html_tragments).
+- Add support for HTML fragments. HTML fragments can be rendered by passing `type="fragment"` to `Component.render()` or `Component.render_to_response()`. Read more on how to [use HTML fragments with HTMX, AlpineJS, or vanillaJS](https://django-components.github.io/django-components/latest/concepts/advanced/html_fragments).
## v0.121
#### Fix
-- Fix the use of Django template filters (`|lower:"etc"`) with component inputs [#855](https://github.com/EmilStenstrom/django-components/pull/855).
+- Fix the use of Django template filters (`|lower:"etc"`) with component inputs [#855](https://github.com/django-components/django-components/pull/855).
## v0.120
@@ -24,23 +1929,23 @@
#### Fix
-- Fix the use of translation strings `_("bla")` as inputs to components [#849](https://github.com/EmilStenstrom/django-components/pull/849).
+- Fix the use of translation strings `_("bla")` as inputs to components [#849](https://github.com/django-components/django-components/pull/849).
## v0.119
-⚠️ Attention ⚠️ - This release introduced bugs [#849](https://github.com/EmilStenstrom/django-components/pull/849), [#855](https://github.com/EmilStenstrom/django-components/pull/855). Please update to v0.121.
+⚠️ Attention ⚠️ - This release introduced bugs [#849](https://github.com/django-components/django-components/pull/849), [#855](https://github.com/django-components/django-components/pull/855). Please update to v0.121.
#### Fix
- Fix compatibility with custom subclasses of Django's `Template` that need to access
- `origin` or other initialization arguments. (https://github.com/EmilStenstrom/django-components/pull/828)
+ `origin` or other initialization arguments. (https://github.com/django-components/django-components/pull/828)
#### Refactor
- Compatibility with `django-debug-toolbar-template-profiler`:
- - Monkeypatching of Django's `Template` now happens at `AppConfig.ready()` (https://github.com/EmilStenstrom/django-components/pull/825)
+ - Monkeypatching of Django's `Template` now happens at `AppConfig.ready()` (https://github.com/django-components/django-components/pull/825)
-- Internal parsing of template tags tag was updated. No API change. (https://github.com/EmilStenstrom/django-components/pull/827)
+- Internal parsing of template tags tag was updated. No API change. (https://github.com/django-components/django-components/pull/827)
## v0.118
@@ -75,7 +1980,7 @@
## v0.116
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
#### Fix
@@ -122,7 +2027,7 @@
## v0.115
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
#### Fix
@@ -131,7 +2036,7 @@
## v0.114
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
#### Fix
@@ -140,7 +2045,7 @@
## v0.113
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
#### Fix
@@ -148,7 +2053,7 @@
## v0.112
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
#### Fix
@@ -156,7 +2061,7 @@
## v0.111
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
#### Fix
@@ -165,7 +2070,7 @@
## 🚨📢 v0.110
-⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/EmilStenstrom/django-components/issues/791) and [#789](https://github.com/EmilStenstrom/django-components/issues/789) and [#818](https://github.com/EmilStenstrom/django-components/issues/818).
+⚠️ Attention ⚠️ - Please update to v0.117 to fix known bugs. See [#791](https://github.com/django-components/django-components/issues/791) and [#789](https://github.com/django-components/django-components/issues/789) and [#818](https://github.com/django-components/django-components/issues/818).
### General
@@ -174,7 +2079,7 @@
- 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)")
+ (See "[Adding support for JS and CSS](https://github.com/django-components/django-components#adding-support-for-js-and-css)")
- Component typing signature changed from
@@ -225,7 +2130,7 @@ 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)
+- For advanced use cases, use can omit the middleware and instead manage component JS and CSS dependencies yourself with [`render_dependencies`](https://github.com/django-components/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:
@@ -495,15 +2400,15 @@ importing them.
- Installation changes:
- - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](https://github.com/EmilStenstrom/django-components#dirs).
+ - Instead of defining component directories in `STATICFILES_DIRS`, set them to [`COMPONENTS.dirs`](https://github.com/django-components/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)
+ - [See here how to migrate your settings.py](https://github.com/django-components/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)).
+ (See [`COMPONENTS.app_dirs`](https://github.com/django-components/django-components#app_dirs)).
#### Refactor
@@ -513,7 +2418,7 @@ importing them.
#### 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)
+- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](https://github.com/django-components/django-components#template_cache_size---tune-the-template-cache)
#### Refactor
@@ -528,9 +2433,9 @@ importing them.
#### 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))
+- 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/django-components/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))
+- 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/django-components/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`
@@ -538,7 +2443,7 @@ importing them.
#### 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))
+- Added support for dynamic components, where the component name is passed as a variable. (See [Dynamic components](https://github.com/django-components/django-components#dynamic-components))
#### Refactor
@@ -548,17 +2453,17 @@ importing them.
#### 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))
+- django_components now automatically configures Django to support multi-line tags. (See [Multi-line tags](https://github.com/django-components/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))
+- 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/django-components/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))
+- Spread operator `...dict` inside template tags. (See [Spread operator](https://github.com/django-components/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))
+- Use template tags inside string literals in component inputs. (See [Use template tags inside component inputs](https://github.com/django-components/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
@@ -568,13 +2473,13 @@ importing them.
#### 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))
+- `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/django-components/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))
+- 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/django-components/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))
+- Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](https://github.com/django-components/django-components#adding-type-hints-with-generics))
## v0.90
@@ -595,7 +2500,7 @@ importing them.
{% 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).
+- You can change how the components are written in the template with [TagFormatter](https://github.com/django-components/django-components#customizing-component-tags-with-tagformatter).
The default is `django_components.component_formatter`:
@@ -628,7 +2533,7 @@ importing them.
- `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)).
+ - 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.2/ref/settings/#std-setting-STATICFILES_DIRS)).
## 🚨📢 v0.81
@@ -652,7 +2557,7 @@ importing them.
#### 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).
+- 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/django-components/django-components/issues/498).
## 🚨📢 v0.77
@@ -689,13 +2594,13 @@ importing them.
- `{% 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.
+- Simplified settings - `slot_context_behavior` and `context_behavior` were merged. See the [documentation](https://github.com/django-components/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.
+- Changed the default way how context variables are resolved in slots. See the [documentation](https://github.com/django-components/django-components/tree/0.67#isolate-components-slots) for more details.
## 🚨📢 v0.50
@@ -711,7 +2616,7 @@ importing them.
#### 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.
+- Components as views, which allows you to handle requests and render responses from within a component. See the [documentation](https://github.com/django-components/django-components#use-components-as-views) for more details.
## v0.28
@@ -723,7 +2628,7 @@ importing them.
#### 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)).
+- 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/django-components/django-components#security-notes)).
## 🚨📢 v0.26
diff --git a/README.md b/README.md
index 72de59df..8df3262e 100644
--- a/README.md
+++ b/README.md
@@ -1,77 +1,565 @@
-#
+#
-[](https://pypi.org/project/django-components/) [](https://pypi.org/project/django-components/) [](https://github.com/EmilStenstrom/django-components/blob/master/LICENSE/) [](https://pypistats.org/packages/django-components) [](https://github.com/EmilStenstrom/django-components/actions/workflows/tests.yml)
+[](https://pypi.org/project/django-components/) [](https://pypi.org/project/django-components/) [](https://github.com/django-components/django-components/blob/master/LICENSE/) [](https://pypistats.org/packages/django-components) [](https://github.com/django-components/django-components/actions/workflows/tests.yml) [](https://django-components.github.io/django-components/latest/benchmarks/)
-[**Docs (Work in progress)**](https://EmilStenstrom.github.io/django-components/latest/)
+###
[Read the full documentation](https://django-components.github.io/django-components/latest/)
-Django-components is a package that introduces component-based architecture to Django's server-side rendering. It aims to combine Django's templating system with the modularity seen in modern frontend frameworks.
+`django-components` is a modular and extensible UI framework for Django.
-## Features
+It combines Django's templating system with the modularity seen
+in modern frontend frameworks like Vue or React.
-1. 🧩 **Reusability:** Allows creation of self-contained, reusable UI elements.
-2. 📦 **Encapsulation:** Each component can include its own HTML, CSS, and JavaScript.
-3. 🚀 **Server-side rendering:** Components render on the server, improving initial load times and SEO.
-4. 🐍 **Django integration:** Works within the Django ecosystem, using familiar concepts like template tags.
-5. ⚡ **Asynchronous loading:** Components can render independently opening up for integration with JS frameworks like HTMX or AlpineJS.
-
-Potential benefits:
-
-- 🔄 Reduced code duplication
-- 🛠️ Improved maintainability through modular design
-- 🧠 Easier management of complex UIs
-- 🤝 Enhanced collaboration between frontend and backend developers
-
-Django-components can be particularly useful for larger Django projects that require a more structured approach to UI development, without necessitating a shift to a separate frontend framework.
+With `django-components` you can support Django projects small and large without leaving the Django ecosystem.
## Quickstart
-django-components lets you create reusable blocks of code needed to generate the front end code you need for a modern app.
+A component in django-components can be as simple as a Django template and Python code to declare the component:
-Define a component in `components/calendar/calendar.py` like this:
-```python
-@register("calendar")
-class Calendar(Component):
- template_name = "template.html"
-
- def get_context_data(self, date):
- return {"date": date}
+```django
+{# components/calendar/calendar.html #}
+
```
Read on to learn about all the exciting details and configuration possibilities!
-(If you instead prefer to jump right into the code, [check out the example project](https://github.com/EmilStenstrom/django-components/tree/master/sampleproject))
+(If you instead prefer to jump right into the code, [check out the example project](https://github.com/django-components/django-components/tree/master/sampleproject))
+
+## Features
+
+### Modern and modular UI
+
+- Create self-contained, reusable UI elements.
+- Each component can include its own HTML, CSS, and JS, or additional third-party JS and CSS.
+- HTML, CSS, and JS can be defined on the component class, or loaded from files.
+
+```python
+from django_components import Component
+
+@register("calendar")
+class Calendar(Component):
+ template = """
+
+```
+
+[`{% html_attrs %}`](https://django-components.github.io/django-components/latest/concepts/fundamentals/html_attributes/) offers a Vue-like granular control for
+[`class`](https://django-components.github.io/django-components/latest/concepts/fundamentals/html_attributes/#merging-class-attributes)
+and [`style`](https://django-components.github.io/django-components/latest/concepts/fundamentals/html_attributes/#merging-style-attributes)
+HTML attributes,
+where you can use a dictionary to manage each class name or style property separately.
+
+```django
+{% html_attrs
+ class="foo bar"
+ class={
+ "baz": True,
+ "foo": False,
+ }
+ class="extra"
+%}
+```
+
+```django
+{% html_attrs
+ style="text-align: center; background-color: blue;"
+ style={
+ "background-color": "green",
+ "color": None,
+ "width": False,
+ }
+ style="position: absolute; height: 12px;"
+%}
+```
+
+Read more about [HTML attributes](https://django-components.github.io/django-components/latest/concepts/fundamentals/html_attributes/).
+
+### HTML fragment support
+
+`django-components` makes integration with HTMX, AlpineJS or jQuery easy by allowing components to be rendered as [HTML fragments](https://django-components.github.io/django-components/latest/concepts/advanced/html_fragments/):
+
+- Components's JS and CSS files are loaded automatically when the fragment is inserted into the DOM.
+
+- Components can be [exposed as Django Views](https://django-components.github.io/django-components/latest/concepts/fundamentals/component_views_urls/) with `get()`, `post()`, `put()`, `patch()`, `delete()` methods
+
+- Automatically create an endpoint for a component with [`Component.View.public`](https://django-components.github.io/django-components/latest/concepts/fundamentals/component_views_urls/#register-urls-automatically)
+
+```py
+# components/calendar/calendar.py
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar.html"
+
+ class View:
+ # Register Component with `urlpatterns`
+ public = True
+
+ # Define handlers
+ def get(self, request, *args, **kwargs):
+ page = request.GET.get("page", 1)
+ return self.component.render_to_response(
+ request=request,
+ kwargs={
+ "page": page,
+ },
+ )
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "page": kwargs["page"],
+ }
+
+# Get auto-generated URL for the component
+url = get_component_url(Calendar)
+
+# Or define explicit URL in urls.py
+path("calendar/", Calendar.as_view())
+```
+
+### Provide / Inject
+
+`django-components` supports the provide / inject pattern, similarly to React's [Context Providers](https://react.dev/learn/passing-data-deeply-with-context) or Vue's [provide / inject](https://vuejs.org/guide/components/provide-inject):
+
+- Use the [`{% provide %}`](https://django-components.github.io/django-components/latest/reference/template_tags/#provide) tag to provide data to the component tree
+- Use the [`Component.inject()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.inject) method to inject data into the component
+
+Read more about [Provide / Inject](https://django-components.github.io/django-components/latest/concepts/advanced/provide_inject).
+
+```django
+
+ {% provide "theme" variant="light" %}
+ {% component "header" / %}
+ {% endprovide %}
+
+```
+
+```djc_py
+@register("header")
+class Header(Component):
+ template = "..."
+
+ def get_template_data(self, args, kwargs, slots, context):
+ theme = self.inject("theme").variant
+ return {
+ "theme": theme,
+ }
+```
+
+### Input validation and static type hints
+
+Avoid needless errors with [type hints and runtime input validation](https://django-components.github.io/django-components/latest/concepts/fundamentals/typing_and_validation/).
+
+To opt-in to input validation, define types for component's args, kwargs, slots, and more:
+
+```py
+from typing import NamedTuple, Optional
+from django.template import Context
+from django_components import Component, Slot, SlotInput
+
+class Button(Component):
+ class Args(NamedTuple):
+ size: int
+ text: str
+
+ class Kwargs(NamedTuple):
+ variable: str
+ another: int
+ maybe_var: Optional[int] = None # May be omitted
+
+ class Slots(NamedTuple):
+ my_slot: Optional[SlotInput] = None
+ another_slot: SlotInput
+
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
+ args.size # int
+ kwargs.variable # str
+ slots.my_slot # Slot[MySlotData]
+```
+
+To have type hints when calling
+[`Button.render()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.render) or
+[`Button.render_to_response()`](https://django-components.github.io/django-components/latest/reference/api/#django_components.Component.render_to_response),
+wrap the inputs in their respective `Args`, `Kwargs`, and `Slots` classes:
+
+```py
+Button.render(
+ # Error: First arg must be `int`, got `float`
+ args=Button.Args(
+ size=1.25,
+ text="abc",
+ ),
+ # Error: Key "another" is missing
+ kwargs=Button.Kwargs(
+ variable="text",
+ ),
+)
+```
+
+### Extensions
+
+Django-components functionality can be extended with [Extensions](https://django-components.github.io/django-components/latest/concepts/advanced/extensions/).
+Extensions allow for powerful customization and integrations. They can:
+
+- Tap into lifecycle events, such as when a component is created, deleted, or registered
+- Add new attributes and methods to the components
+- Add custom CLI commands
+- Add custom URLs
+
+Some of the extensions include:
+
+- [Component caching](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/cache.py)
+- [Django View integration](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/view.py)
+- [Component defaults](https://github.com/django-components/django-components/blob/master/src/django_components/extensions/defaults.py)
+- [Pydantic integration (input validation)](https://github.com/django-components/djc-ext-pydantic)
+
+Some of the planned extensions include:
+
+- AlpineJS integration
+- Storybook integration
+- Component-level benchmarking with asv
+
+### Caching
+
+- [Components can be cached](https://django-components.github.io/django-components/latest/concepts/advanced/component_caching/) using Django's cache framework.
+- Caching rules can be configured on a per-component basis.
+- Components are cached based on their input. Or you can write custom caching logic.
+
+```py
+from django_components import Component
+
+class MyComponent(Component):
+ class Cache:
+ enabled = True
+ ttl = 60 * 60 * 24 # 1 day
+
+ def hash(self, *args, **kwargs):
+ return hash(f"{json.dumps(args)}:{json.dumps(kwargs)}")
+```
+
+### Simple testing
+
+- Write tests for components with [`@djc_test`](https://django-components.github.io/django-components/latest/concepts/advanced/testing/) decorator.
+- The decorator manages global state, ensuring that tests don't leak.
+- If using `pytest`, the decorator allows you to parametrize Django or Components settings.
+- The decorator also serves as a stand-in for Django's [`@override_settings`](https://docs.djangoproject.com/en/5.2/topics/testing/tools/#django.test.override_settings).
+
+```python
+from django_components.testing import djc_test
+
+from components.my_table import MyTable
+
+@djc_test
+def test_my_table():
+ rendered = MyTable.render(
+ kwargs={
+ "title": "My table",
+ },
+ )
+ assert rendered == "
My table
"
+```
+
+### Debugging features
+
+- **Visual component inspection**: Highlight components and slots directly in your browser.
+- **Detailed tracing logs to supply AI-agents with context**: The logs include component and slot names and IDs, and their position in the tree.
+
+
+
+
+
+### Sharing components
+
+- Install and use third-party components from PyPI
+- Or publish your own "component registry"
+- Highly customizable - Choose how the components are called in the template (and more):
+
+ ```django
+ {% component "calendar" date="2024-11-06" %}
+ {% endcomponent %}
+
+ {% calendar date="2024-11-06" %}
+ {% endcalendar %}
+ ```
+
+## Documentation
+
+[Read the full documentation here](https://django-components.github.io/django-components/latest/).
+
+... or jump right into the code, [check out the example project](https://github.com/django-components/django-components/tree/master/sampleproject).
+
+## Performance
+
+Our aim is to be at least as fast as Django templates.
+
+As of `0.130`, `django-components` is ~4x slower than Django templates.
+
+| | Render time|
+|----------|----------------------|
+| django | 68.9±0.6ms |
+| django-components | 259±4ms |
+
+See the [full performance breakdown](https://django-components.github.io/django-components/latest/benchmarks/) for more information.
## Release notes
-Read the [Release Notes](https://github.com/EmilStenstrom/django-components/tree/master/CHANGELOG.md)
+Read the [Release Notes](https://github.com/django-components/django-components/tree/master/CHANGELOG.md)
to see the latest features and fixes.
## Community examples
One of our goals with `django-components` is to make it easy to share components between projects. If you have a set of components that you think would be useful to others, please open a pull request to add them to the list below.
-- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/). Try out the [live demo](https://dhc.iwanalabs.com/).
+- [django-htmx-components](https://github.com/iwanalabs/django-htmx-components): A set of components for use with [htmx](https://htmx.org/).
+
+- [djc-heroicons](https://pypi.org/project/djc-heroicons/): A component that renders icons from [Heroicons.com](https://heroicons.com/).
## Contributing and development
-Get involved or sponsor this project - [See here](https://emilstenstrom.github.io/django-components/dev/overview/contributing/)
+Get involved or sponsor this project - [See here](https://django-components.github.io/django-components/dev/overview/contributing/)
-Running django-components locally for development - [See here](https://emilstenstrom.github.io/django-components/dev/overview/development/)
+Running django-components locally for development - [See here](https://django-components.github.io/django-components/dev/overview/development/)
diff --git a/asv.conf.json b/asv.conf.json
new file mode 100644
index 00000000..0ae16e29
--- /dev/null
+++ b/asv.conf.json
@@ -0,0 +1,210 @@
+{
+ // The version of the config file format. Do not change, unless
+ // you know what you are doing
+ "version": 1,
+
+ // The name of the project being benchmarked
+ "project": "django-components",
+
+ // The project's homepage
+ // "project_url": "https://django-components.github.io/django-components/",
+ "project_url": "/django-components/", // Relative path, since benchmarks are nested under the docs site
+
+ // The URL or local path of the source code repository for the
+ // project being benchmarked
+ "repo": ".",
+
+ // The Python project's subdirectory in your repo. If missing or
+ // the empty string, the project is assumed to be located at the root
+ // of the repository.
+ // "repo_subdir": "",
+
+ // Customizable commands for building the project.
+ // See asv.conf.json documentation.
+ // To build the package using pyproject.toml (PEP518), uncomment the following lines
+ // "build_command": [
+ // "python -m pip install build",
+ // "python -m build",
+ // "python -mpip wheel -w {build_cache_dir} {build_dir}"
+ // ],
+ // To build the package using setuptools and a setup.py file, uncomment the following lines
+ // "build_command": [
+ // "python setup.py build",
+ // "python -mpip wheel -w {build_cache_dir} {build_dir}"
+ // ],
+
+ // Customizable commands for installing and uninstalling the project.
+ // See asv.conf.json documentation.
+ // "install_command": ["in-dir={env_dir} python -mpip install {wheel_file}"],
+ // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"],
+ "install_command": ["in-dir={env_dir} python -mpip install ./project"],
+
+ // List of branches to benchmark. If not provided, defaults to "main"
+ // (for git) or "default" (for mercurial).
+ // "branches": ["main"], // for git
+ // "branches": ["default"], // for mercurial
+ "branches": [
+ "master"
+ ],
+
+ // The DVCS being used. If not set, it will be automatically
+ // determined from "repo" by looking at the protocol in the URL
+ // (if remote), or by looking for special directories, such as
+ // ".git" (if local).
+ // "dvcs": "git",
+
+ // The tool to use to create environments. May be "conda",
+ // "virtualenv", "mamba" (above 3.8)
+ // or other value depending on the plugins in use.
+ // If missing or the empty string, the tool will be automatically
+ // determined by looking for tools on the PATH environment
+ // variable.
+ "environment_type": "virtualenv",
+
+ // timeout in seconds for installing any dependencies in environment
+ // defaults to 10 min
+ //"install_timeout": 600,
+
+ // the base URL to show a commit for the project.
+ // "show_commit_url": "http://github.com/owner/project/commit/",
+
+ // The Pythons you'd like to test against. If not provided, defaults
+ // to the current version of Python used to run `asv`.
+ "pythons": [
+ "3.13"
+ ],
+
+ // The list of conda channel names to be searched for benchmark
+ // dependency packages in the specified order
+ // "conda_channels": ["conda-forge", "defaults"],
+
+ // A conda environment file that is used for environment creation.
+ // "conda_environment_file": "environment.yml",
+
+ // The matrix of dependencies to test. Each key of the "req"
+ // requirements dictionary is the name of a package (in PyPI) and
+ // the values are version numbers. An empty list or empty string
+ // indicates to just test against the default (latest)
+ // version. null indicates that the package is to not be
+ // installed. If the package to be tested is only available from
+ // PyPi, and the 'environment_type' is conda, then you can preface
+ // the package name by 'pip+', and the package will be installed
+ // via pip (with all the conda available packages installed first,
+ // followed by the pip installed packages).
+ //
+ // The ``@env`` and ``@env_nobuild`` keys contain the matrix of
+ // environment variables to pass to build and benchmark commands.
+ // An environment will be created for every combination of the
+ // cartesian product of the "@env" variables in this matrix.
+ // Variables in "@env_nobuild" will be passed to every environment
+ // during the benchmark phase, but will not trigger creation of
+ // new environments. A value of ``null`` means that the variable
+ // will not be set for the current combination.
+ //
+ // "matrix": {
+ // "req": {
+ // "numpy": ["1.6", "1.7"],
+ // "six": ["", null], // test with and without six installed
+ // "pip+emcee": [""] // emcee is only available for install with pip.
+ // },
+ // "env": {"ENV_VAR_1": ["val1", "val2"]},
+ // "env_nobuild": {"ENV_VAR_2": ["val3", null]},
+ // },
+ "matrix": {
+ "req": {
+ "django": [
+ "5.1"
+ ],
+ "djc-core-html-parser": [""] // Empty string means the latest version
+ }
+ },
+
+ // Combinations of libraries/python versions can be excluded/included
+ // from the set to test. Each entry is a dictionary containing additional
+ // key-value pairs to include/exclude.
+ //
+ // An exclude entry excludes entries where all values match. The
+ // values are regexps that should match the whole string.
+ //
+ // An include entry adds an environment. Only the packages listed
+ // are installed. The 'python' key is required. The exclude rules
+ // do not apply to includes.
+ //
+ // In addition to package names, the following keys are available:
+ //
+ // - python
+ // Python version, as in the *pythons* variable above.
+ // - environment_type
+ // Environment type, as above.
+ // - sys_platform
+ // Platform, as in sys.platform. Possible values for the common
+ // cases: 'linux2', 'win32', 'cygwin', 'darwin'.
+ // - req
+ // Required packages
+ // - env
+ // Environment variables
+ // - env_nobuild
+ // Non-build environment variables
+ //
+ // "exclude": [
+ // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows
+ // {"environment_type": "conda", "req": {"six": null}}, // don't run without six on conda
+ // {"env": {"ENV_VAR_1": "val2"}}, // skip val2 for ENV_VAR_1
+ // ],
+ //
+ // "include": [
+ // // additional env for python3.12
+ // {"python": "3.12", "req": {"numpy": "1.26"}, "env_nobuild": {"FOO": "123"}},
+ // // additional env if run on windows+conda
+ // {"platform": "win32", "environment_type": "conda", "python": "3.12", "req": {"libpython": ""}},
+ // ],
+
+ // The directory (relative to the current directory) that benchmarks are
+ // stored in. If not provided, defaults to "benchmarks"
+ "benchmark_dir": "benchmarks",
+
+ // The directory (relative to the current directory) to cache the Python
+ // environments in. If not provided, defaults to "env"
+ "env_dir": ".asv/env",
+
+ // The directory (relative to the current directory) that raw benchmark
+ // results are stored in. If not provided, defaults to "results".
+ "results_dir": ".asv/results",
+
+ // The directory (relative to the current directory) that the html tree
+ // should be written to. If not provided, defaults to "html".
+ // "html_dir": ".asv/html",
+ "html_dir": "docs/benchmarks", // # TODO
+
+ // The number of characters to retain in the commit hashes.
+ // "hash_length": 8,
+
+ // `asv` will cache results of the recent builds in each
+ // environment, making them faster to install next time. This is
+ // the number of builds to keep, per environment.
+ // "build_cache_size": 2,
+
+ // The commits after which the regression search in `asv publish`
+ // should start looking for regressions. Dictionary whose keys are
+ // regexps matching to benchmark names, and values corresponding to
+ // the commit (exclusive) after which to start looking for
+ // regressions. The default is to start from the first commit
+ // with results. If the commit is `null`, regression detection is
+ // skipped for the matching benchmark.
+ //
+ // "regressions_first_commits": {
+ // "some_benchmark": "352cdf", // Consider regressions only after this commit
+ // "another_benchmark": null, // Skip regression detection altogether
+ // },
+
+ // The thresholds for relative change in results, after which `asv
+ // publish` starts reporting regressions. Dictionary of the same
+ // form as in ``regressions_first_commits``, with values
+ // indicating the thresholds. If multiple entries match, the
+ // maximum is taken. If no entry matches, the default is 5%.
+ //
+ // "regressions_thresholds": {
+ // "some_benchmark": 0.01, // Threshold of 1%
+ // "another_benchmark": 0.5, // Threshold of 50%
+ // },
+}
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 00000000..f5f5d524
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,195 @@
+# Benchmarks
+
+## Overview
+
+[`asv`](https://github.com/airspeed-velocity/) (Airspeed Velocity) is used for benchmarking performance.
+
+`asv` covers the entire benchmarking workflow. We can:
+
+1. Define benchmark tests similarly to writing pytest tests (supports both timing and memory benchmarks)
+2. Run the benchmarks and generate results for individual git commits, tags, or entire branches
+3. View results as an HTML report (dashboard with charts)
+4. Compare performance between two commits / tags / branches for CI integration
+
+
+
+django-components uses `asv` for these use cases:
+
+- Benchmarking across releases:
+
+ 1. When a git tag is created and pushed, this triggers a Github Action workflow (see `docs.yml`).
+ 2. The workflow runs the benchmarks with the latest release, and commits the results to the repository.
+ Thus, we can see how performance changes across releases.
+
+- Displaying performance results on the website:
+
+ 1. When a git tag is created and pushed, we also update the documentation website (see `docs.yml`).
+ 2. Before we publish the docs website, we generate the HTML report for the benchmark results.
+ 3. The generated report is placed in the `docs/benchmarks/` directory, and is thus
+ published with the rest of the docs website and available under [`/benchmarks/`](https://django-components.github.io/django-components/latest/benchmarks).
+ - NOTE: The location where the report is placed is defined in `asv.conf.json`.
+
+- Compare performance between commits on pull requests:
+ 1. When a pull request is made, this triggers a Github Action workflow (see `benchmark.yml`).
+ 2. The workflow compares performance between commits.
+ 3. The report is added to the PR as a comment made by a bot.
+
+## Interpreting benchmarks
+
+The results CANNOT be taken as ABSOLUTE values e.g.:
+
+"This example took 200ms to render, so my page will also take 200ms to render."
+
+Each UI may consist of different number of Django templates, template tags, and components, and all these may influence the rendering time differently.
+
+Instead, the results MUST be understood as RELATIVE values.
+
+- If a commit is 10% slower than the master branch, that's valid.
+- If Django components are 10% slower than vanilla Django templates, that's valid.
+- If "isolated" mode is 10% slower than "django" mode, that's valid.
+
+## Development
+
+Let's say we want to generate results for the last 5 commits.
+
+1. Install `asv`
+
+ ```bash
+ pip install asv
+ ```
+
+2. Run benchmarks and generate results
+
+ ```bash
+ asv run HEAD --steps 5 -e
+ ```
+
+ - `HEAD` means that we want to run benchmarks against the [current branch](https://stackoverflow.com/a/2304106/9788634).
+ - `--steps 5` means that we want to run benchmarks for the last 5 commits.
+ - `-e` to print out any errors.
+
+ The results will be stored in `.asv/results/`, as configured in `asv.conf.json`.
+
+3. Generate HTML report
+
+ ```bash
+ asv publish
+ asv preview
+ ```
+
+ - `publish` generates the HTML report and stores it in `docs/benchmarks/`, as configured in `asv.conf.json`.
+ - `preview` starts a local server and opens the report in the browser.
+
+ NOTE: Since the results are stored in `docs/benchmarks/`, you can also view the results
+ with `mkdocs serve` and navigating to `http://localhost:9000/django-components/benchmarks/`.
+
+ NOTE 2: Running `publish` will overwrite the existing contents of `docs/benchmarks/`.
+
+## Writing benchmarks
+
+`asv` supports writing different [types of benchmarks](https://asv.readthedocs.io/en/latest/writing_benchmarks.html#benchmark-types). What's relevant for us is:
+
+- [Raw timing benchmarks](https://asv.readthedocs.io/en/latest/writing_benchmarks.html#raw-timing-benchmarks)
+- [Peak memory benchmarks](https://asv.readthedocs.io/en/latest/writing_benchmarks.html#peak-memory)
+
+Notes:
+
+- The difference between "raw timing" and "timing" tests is that "raw timing" is ran in a separate process.
+ And instead of running the logic within the test function itself, we return a script (string)
+ that will be executed in the separate process.
+
+- The difference between "peak memory" and "memory" tests is that "memory" calculates the memory
+ of the object returned from the test function. On the other hand, "peak memory" detects the
+ peak memory usage during the execution of the test function (including the setup function).
+
+You can write the test file anywhere in the `benchmarks/` directory, `asv` will automatically find it.
+
+Inside the file, write a test function. Depending on the type of the benchmark,
+prefix the test function name with `timeraw_` or `peakmem_`. See [`benchmarks/benchmark_templating.py`](benchmark_templating.py) for examples.
+
+### Ensuring that the benchmarked logic is correct
+
+The approach I (Juro) took with benchmarking the overall template rendering is that
+I've defined the actual logic in `tests/test_benchmark_*.py` files. So those files
+are part of the normal pytest testing, and even contain a section with pytest tests.
+
+This ensures that the benchmarked logic remains functional and error-free.
+
+However, there's some caveats:
+
+1. I wasn't able to import files from `tests/`.
+2. When running benchmarks, we don't want to run the pytest tests.
+
+To work around that, the approach I used for loading the files from the `tests/` directory is to:
+
+1. Get the file's source code as a string.
+2. Cut out unwanted sections (like the pytest tests).
+3. Append the benchmark-specific code to the file (e.g. to actually render the templates).
+4. In case of "timeraw" benchmarks, we can simply return the remaining code as a string
+ to be run in a separate process.
+5. In case of "peakmem" benchmarks, we need to access this modified source code as Python objects.
+ So the code is made available as a "virtual" module, which makes it possible to import Python objects like so:
+ ```py
+ from my_virtual_module import run_my_benchmark
+ ```
+
+## Using `asv`
+
+### Compare latest commit against master
+
+Note: Before comparing, you must run the benchmarks first to generate the results. The `continuous` command does not generate the results by itself.
+
+```bash
+asv continuous master^! HEAD^! --factor 1.1
+```
+
+- Factor of `1.1` means that the new commit is allowed to be 10% slower/faster than the master commit.
+
+- `^` means that we mean the COMMIT of the branch, not the BRANCH itself.
+
+ Without it, we would run benchmarks for the whole branch history.
+
+ With it, we run benchmarks FROM the latest commit (incl) TO ...
+
+- `!` means that we want to select range spanning a single commit.
+
+ Without it, we would run benchmarks for all commits FROM the latest commit
+ TO the start of the branch history.
+
+ With it, we run benchmarks ONLY FOR the latest commit.
+
+### More Examples
+
+Notes:
+
+- Use `~1` to select the second-latest commit, `~2` for the third-latest, etc..
+
+Generate benchmarks for the latest commit in `master` branch.
+
+```bash
+asv run master^!
+```
+
+Generate benchmarks for second-latest commit in `master` branch.
+
+```bash
+asv run master~1^!
+```
+
+Generate benchmarks for all commits in `master` branch.
+
+```bash
+asv run master
+```
+
+Generate benchmarks for all commits in `master` branch, but exclude the latest commit.
+
+```bash
+asv run master~1
+```
+
+Generate benchmarks for the LAST 5 commits in `master` branch, but exclude the latest commit.
+
+```bash
+asv run master~1 --steps 5
+```
diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/assets/asv_dashboard.png b/benchmarks/assets/asv_dashboard.png
new file mode 100644
index 00000000..524990b8
Binary files /dev/null and b/benchmarks/assets/asv_dashboard.png differ
diff --git a/benchmarks/benchmark_templating.py b/benchmarks/benchmark_templating.py
new file mode 100644
index 00000000..779c70aa
--- /dev/null
+++ b/benchmarks/benchmark_templating.py
@@ -0,0 +1,446 @@
+# Write the benchmarking functions here
+# See "Writing benchmarks" in the asv docs for more information.
+
+import re
+from pathlib import Path
+from types import ModuleType
+from typing import Literal
+
+# Fix for for https://github.com/airspeed-velocity/asv_runner/pull/44
+import benchmarks.monkeypatch_asv # noqa: F401
+
+from benchmarks.utils import benchmark, create_virtual_module
+
+
+DJC_VS_DJ_GROUP = "Components vs Django"
+DJC_ISOLATED_VS_NON_GROUP = "isolated vs django modes"
+OTHER_GROUP = "Other"
+
+
+DjcContextMode = Literal["isolated", "django"]
+TemplatingRenderer = Literal["django", "django-components", "none"]
+TemplatingTestSize = Literal["lg", "sm"]
+TemplatingTestType = Literal[
+ "first", # Testing performance of the first time the template is rendered
+ "subsequent", # Testing performance of the subsequent times the template is rendered
+ "startup", # Testing performance of the startup time (e.g. defining classes and templates)
+]
+
+
+def _get_templating_filepath(renderer: TemplatingRenderer, size: TemplatingTestSize) -> Path:
+ if renderer == "none":
+ raise ValueError("Cannot get filepath for renderer 'none'")
+ elif renderer not in ["django", "django-components"]:
+ raise ValueError(f"Invalid renderer: {renderer}")
+
+ if size not in ("lg", "sm"):
+ raise ValueError(f"Invalid size: {size}, must be one of ('lg', 'sm')")
+
+ # At this point, we know the renderer is either "django" or "django-components"
+ root = file_path = Path(__file__).parent.parent
+ if renderer == "django":
+ if size == "lg":
+ file_path = root / "tests" / "test_benchmark_django.py"
+ else:
+ file_path = root / "tests" / "test_benchmark_django_small.py"
+ else:
+ if size == "lg":
+ file_path = root / "tests" / "test_benchmark_djc.py"
+ else:
+ file_path = root / "tests" / "test_benchmark_djc_small.py"
+
+ return file_path
+
+
+def _get_templating_script(
+ renderer: TemplatingRenderer,
+ size: TemplatingTestSize,
+ context_mode: DjcContextMode,
+ imports_only: bool,
+) -> str:
+ if renderer == "none":
+ return ""
+ elif renderer not in ["django", "django-components"]:
+ raise ValueError(f"Invalid renderer: {renderer}")
+
+ # At this point, we know the renderer is either "django" or "django-components"
+ file_path = _get_templating_filepath(renderer, size)
+ contents = file_path.read_text()
+
+ # The files with benchmarked code also have a section for testing them with pytest.
+ # We remove that pytest section, so the script is only the benchmark code.
+ contents = contents.split("# ----------- TESTS START ------------ #")[0]
+
+ if imports_only:
+ # There is a benchmark test for measuring the time it takes to import the module.
+ # For that, we exclude from the code everything AFTER this line
+ contents = contents.split("# ----------- IMPORTS END ------------ #")[0]
+ else:
+ # Set the context mode by replacing variable in the script
+ contents = re.sub(r"CONTEXT_MODE.*?\n", f"CONTEXT_MODE = '{context_mode}'\n", contents, count=1)
+
+ return contents
+
+
+def _get_templating_module(
+ renderer: TemplatingRenderer,
+ size: TemplatingTestSize,
+ context_mode: DjcContextMode,
+ imports_only: bool,
+) -> ModuleType:
+ if renderer not in ("django", "django-components"):
+ raise ValueError(f"Invalid renderer: {renderer}")
+
+ file_path = _get_templating_filepath(renderer, size)
+ script = _get_templating_script(renderer, size, context_mode, imports_only)
+
+ # This makes it possible to import the module in the benchmark function
+ # as `import test_templating`
+ module = create_virtual_module("test_templating", script, str(file_path))
+ return module
+
+
+# The `timeraw_` tests run in separate processes. But when running memory benchmarks,
+# the tested logic runs in the same process as the where we run the benchmark functions
+# (e.g. `peakmem_render_lg_first()`). Thus, the `peakmem_` functions have access to this file
+# when the tested logic runs.
+#
+# Secondly, `asv` doesn't offer any way to pass data from `setup` to actual test.
+#
+# And so we define this global, which, when running memory benchmarks, the `setup` function
+# populates. And then we trigger the actual render from within the test body.
+do_render = lambda: None # noqa: E731
+
+
+def setup_templating_memory_benchmark(
+ renderer: TemplatingRenderer,
+ size: TemplatingTestSize,
+ test_type: TemplatingTestType,
+ context_mode: DjcContextMode,
+ imports_only: bool = False,
+):
+ global do_render
+ module = _get_templating_module(renderer, size, context_mode, imports_only)
+ data = module.gen_render_data()
+ render = module.render
+ do_render = lambda: render(data) # noqa: E731
+
+ # Do the first render as part of setup if we're testing the subsequent renders
+ if test_type == "subsequent":
+ do_render()
+
+
+# The timing benchmarks run the actual code in a separate process, by using the `timeraw_` prefix.
+# As such, we don't actually load the code in this file. Instead, we only prepare a script (raw string)
+# that will be run in the new process.
+def prepare_templating_benchmark(
+ renderer: TemplatingRenderer,
+ size: TemplatingTestSize,
+ test_type: TemplatingTestType,
+ context_mode: DjcContextMode,
+ imports_only: bool = False,
+):
+ setup_script = _get_templating_script(renderer, size, context_mode, imports_only)
+
+ # If we're testing the startup time, then the setup is actually the tested code
+ if test_type == "startup":
+ return setup_script
+ else:
+ # Otherwise include also data generation as part of setup
+ setup_script += "\n\n" "render_data = gen_render_data()\n"
+
+ # Do the first render as part of setup if we're testing the subsequent renders
+ if test_type == "subsequent":
+ setup_script += "render(render_data)\n"
+
+ benchmark_script = "render(render_data)\n"
+ return benchmark_script, setup_script
+
+
+# - Group: django-components vs django
+# - time: djc vs django (startup lg)
+# - time: djc vs django (lg - FIRST)
+# - time: djc vs django (sm - FIRST)
+# - time: djc vs django (lg - SUBSEQUENT)
+# - time: djc vs django (sm - SUBSEQUENT)
+# - mem: djc vs django (lg - FIRST)
+# - mem: djc vs django (sm - FIRST)
+# - mem: djc vs django (lg - SUBSEQUENT)
+# - mem: djc vs django (sm - SUBSEQUENT)
+#
+# NOTE: While the name suggests we're comparing Django and Django-components, be aware that
+# in our "Django" tests, we still install and import django-components. We also use
+# django-components's `{% html_attrs %}` tag in the Django scenario. `{% html_attrs %}`
+# was used because the original sample code was from django-components.
+#
+# As such, these tests should seen not as "Using Django vs Using Components". But instead,
+# it should be "What is the relative cost of using Components?".
+#
+# As an example, the benchmarking for the startup time and memory usage is not comparing
+# two independent approaches. Rather, the test is checking if defining Components classes
+# is more expensive than vanilla Django templates.
+class DjangoComponentsVsDjangoTests:
+ # Testing startup time (e.g. defining classes and templates)
+ @benchmark(
+ pretty_name="startup - large",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ )
+ def timeraw_startup_lg(self, renderer: TemplatingRenderer):
+ return prepare_templating_benchmark(renderer, "lg", "startup", "isolated")
+
+ @benchmark(
+ pretty_name="render - small - first render",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ )
+ def timeraw_render_sm_first(self, renderer: TemplatingRenderer):
+ return prepare_templating_benchmark(renderer, "sm", "first", "isolated")
+
+ @benchmark(
+ pretty_name="render - small - second render",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ )
+ def timeraw_render_sm_subsequent(self, renderer: TemplatingRenderer):
+ return prepare_templating_benchmark(renderer, "sm", "subsequent", "isolated")
+
+ @benchmark(
+ pretty_name="render - large - first render",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ include_in_quick_benchmark=True,
+ )
+ def timeraw_render_lg_first(self, renderer: TemplatingRenderer):
+ return prepare_templating_benchmark(renderer, "lg", "first", "isolated")
+
+ @benchmark(
+ pretty_name="render - large - second render",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ )
+ def timeraw_render_lg_subsequent(self, renderer: TemplatingRenderer):
+ return prepare_templating_benchmark(renderer, "lg", "subsequent", "isolated")
+
+ @benchmark(
+ pretty_name="render - small - first render (mem)",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ setup=lambda renderer: setup_templating_memory_benchmark(renderer, "sm", "first", "isolated"),
+ )
+ def peakmem_render_sm_first(self, renderer: TemplatingRenderer):
+ do_render()
+
+ @benchmark(
+ pretty_name="render - small - second render (mem)",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ setup=lambda renderer: setup_templating_memory_benchmark(renderer, "sm", "subsequent", "isolated"),
+ )
+ def peakmem_render_sm_subsequent(self, renderer: TemplatingRenderer):
+ do_render()
+
+ @benchmark(
+ pretty_name="render - large - first render (mem)",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ setup=lambda renderer: setup_templating_memory_benchmark(renderer, "lg", "first", "isolated"),
+ )
+ def peakmem_render_lg_first(self, renderer: TemplatingRenderer):
+ do_render()
+
+ @benchmark(
+ pretty_name="render - large - second render (mem)",
+ group_name=DJC_VS_DJ_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "renderer": ["django", "django-components"],
+ },
+ setup=lambda renderer: setup_templating_memory_benchmark(renderer, "lg", "subsequent", "isolated"),
+ )
+ def peakmem_render_lg_subsequent(self, renderer: TemplatingRenderer):
+ do_render()
+
+
+# - Group: Django-components "isolated" vs "django" modes
+# - time: Isolated vs django djc (startup lg)
+# - time: Isolated vs django djc (lg - FIRST)
+# - time: Isolated vs django djc (sm - FIRST)
+# - time: Isolated vs django djc (lg - SUBSEQUENT)
+# - time: Isolated vs django djc (sm - SUBSEQUENT)
+# - mem: Isolated vs django djc (lg - FIRST)
+# - mem: Isolated vs django djc (sm - FIRST)
+# - mem: Isolated vs django djc (lg - SUBSEQUENT)
+# - mem: Isolated vs django djc (sm - SUBSEQUENT)
+class IsolatedVsDjangoContextModesTests:
+ # Testing startup time (e.g. defining classes and templates)
+ @benchmark(
+ pretty_name="startup - large",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ )
+ def timeraw_startup_lg(self, context_mode: DjcContextMode):
+ return prepare_templating_benchmark("django-components", "lg", "startup", context_mode)
+
+ @benchmark(
+ pretty_name="render - small - first render",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ )
+ def timeraw_render_sm_first(self, context_mode: DjcContextMode):
+ return prepare_templating_benchmark("django-components", "sm", "first", context_mode)
+
+ @benchmark(
+ pretty_name="render - small - second render",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ )
+ def timeraw_render_sm_subsequent(self, context_mode: DjcContextMode):
+ return prepare_templating_benchmark("django-components", "sm", "subsequent", context_mode)
+
+ @benchmark(
+ pretty_name="render - large - first render",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ )
+ def timeraw_render_lg_first(self, context_mode: DjcContextMode):
+ return prepare_templating_benchmark("django-components", "lg", "first", context_mode)
+
+ @benchmark(
+ pretty_name="render - large - second render",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ )
+ def timeraw_render_lg_subsequent(self, context_mode: DjcContextMode):
+ return prepare_templating_benchmark("django-components", "lg", "subsequent", context_mode)
+
+ @benchmark(
+ pretty_name="render - small - first render (mem)",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ setup=lambda context_mode: setup_templating_memory_benchmark("django-components", "sm", "first", context_mode),
+ )
+ def peakmem_render_sm_first(self, context_mode: DjcContextMode):
+ do_render()
+
+ @benchmark(
+ pretty_name="render - small - second render (mem)",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ setup=lambda context_mode: setup_templating_memory_benchmark(
+ "django-components",
+ "sm",
+ "subsequent",
+ context_mode,
+ ),
+ )
+ def peakmem_render_sm_subsequent(self, context_mode: DjcContextMode):
+ do_render()
+
+ @benchmark(
+ pretty_name="render - large - first render (mem)",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ setup=lambda context_mode: setup_templating_memory_benchmark(
+ "django-components",
+ "lg",
+ "first",
+ context_mode,
+ ),
+ )
+ def peakmem_render_lg_first(self, context_mode: DjcContextMode):
+ do_render()
+
+ @benchmark(
+ pretty_name="render - large - second render (mem)",
+ group_name=DJC_ISOLATED_VS_NON_GROUP,
+ number=1,
+ rounds=5,
+ params={
+ "context_mode": ["isolated", "django"],
+ },
+ setup=lambda context_mode: setup_templating_memory_benchmark(
+ "django-components",
+ "lg",
+ "subsequent",
+ context_mode,
+ ),
+ )
+ def peakmem_render_lg_subsequent(self, context_mode: DjcContextMode):
+ do_render()
+
+
+class OtherTests:
+ @benchmark(
+ pretty_name="import time",
+ group_name=OTHER_GROUP,
+ number=1,
+ rounds=5,
+ )
+ def timeraw_import_time(self):
+ return prepare_templating_benchmark("django-components", "lg", "startup", "isolated", imports_only=True)
diff --git a/benchmarks/component_rendering.py b/benchmarks/component_rendering.py
deleted file mode 100644
index a067e0e2..00000000
--- a/benchmarks/component_rendering.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from time import perf_counter
-
-from django.template import Context, Template
-
-from django_components import Component, registry, types
-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
-
-
-class SlottedComponent(Component):
- template: types.django_html = """
- {% load component_tags %}
-
- {% slot "header" %}Default header{% endslot %}
- {% slot "main" %}Default main{% endslot %}
-
-
- """
-
-
-class SimpleComponent(Component):
- template: types.django_html = """
- Variable: {{ variable }}
- """
-
- def get_context_data(self, variable, variable2="default"):
- return {
- "variable": variable,
- "variable2": variable2,
- }
-
- class Media:
- css = {"all": ["style.css"]}
- js = ["script.js"]
-
-
-class BreadcrumbComponent(Component):
- template: types.django_html = """
-
-
-
- """
-
- LINKS = [
- (
- "https://developer.mozilla.org/en-US/docs/Learn",
- "Learn web development",
- ),
- (
- "https://developer.mozilla.org/en-US/docs/Learn/HTML",
- "Structuring the web with HTML",
- ),
- (
- "https://developer.mozilla.org/en-US/docs/Learn/HTML/Introduction_to_HTML",
- "Introduction to HTML",
- ),
- (
- "https://developer.mozilla.org/en-US/docs/Learn/HTML/Introduction_to_HTML/Document_and_website_structure",
- "Document and website structure",
- ),
- ]
-
- def get_context_data(self, items):
- if items > 4:
- items = 4
- elif items < 0:
- items = 0
- return {"links": self.LINKS[: items - 1]}
-
- class Media:
- css = {"all": ["test.css"]}
- js = ["test.js"]
-
-
-EXPECTED_CSS = """"""
-EXPECTED_JS = """"""
-
-
-class RenderBenchmarks(BaseTestCase):
- def setUp(self):
- registry.clear()
- registry.register("test_component", SlottedComponent)
- registry.register("inner_component", SimpleComponent)
- registry.register("breadcrumb_component", BreadcrumbComponent)
-
- @staticmethod
- def timed_loop(func, iterations=1000):
- """Run func iterations times, and return the time in ms per iteration."""
- start_time = perf_counter()
- for _ in range(iterations):
- func()
- end_time = perf_counter()
- total_elapsed = end_time - start_time # NOQA
- return total_elapsed * 1000 / iterations
-
- def test_render_time_for_small_component(self):
- template_str: types.django_html = """
- {% load component_tags %}
- {% component 'test_component' %}
- {% slot "header" %}
- {% component 'inner_component' variable='foo' %}{% endcomponent %}
- {% endslot %}
- {% endcomponent %}
- """
- template = Template(template_str)
-
- print(f"{self.timed_loop(lambda: template.render(Context({})))} ms per iteration")
-
- def test_middleware_time_with_dependency_for_small_page(self):
- template_str: types.django_html = """
- {% load component_tags %}
- {% component_js_dependencies %}
- {% component_css_dependencies %}
- {% component 'test_component' %}
- {% slot "header" %}
- {% component 'inner_component' variable='foo' %}{% endcomponent %}
- {% endslot %}
- {% endcomponent %}
- """
- template = Template(template_str)
- # Sanity tests
- response_content = create_and_process_template_response(template)
- self.assertNotIn(CSS_DEPENDENCY_PLACEHOLDER, response_content)
- self.assertNotIn(JS_DEPENDENCY_PLACEHOLDER, response_content)
- self.assertIn("style.css", response_content)
- self.assertIn("script.js", response_content)
-
- without_middleware = self.timed_loop(
- lambda: create_and_process_template_response(template, use_middleware=False)
- )
- with_middleware = self.timed_loop(lambda: create_and_process_template_response(template, use_middleware=True))
-
- print("Small page middleware test")
- self.report_results(with_middleware, without_middleware)
-
- def test_render_time_with_dependency_for_large_page(self):
- from django.template.loader import get_template
-
- template = get_template("mdn_complete_page.html")
- response_content = create_and_process_template_response(template, {})
- self.assertNotIn(CSS_DEPENDENCY_PLACEHOLDER, response_content)
- self.assertNotIn(JS_DEPENDENCY_PLACEHOLDER, response_content)
- self.assertIn("test.css", response_content)
- self.assertIn("test.js", response_content)
-
- without_middleware = self.timed_loop(
- lambda: create_and_process_template_response(template, {}, use_middleware=False)
- )
- with_middleware = self.timed_loop(
- lambda: create_and_process_template_response(template, {}, use_middleware=True)
- )
-
- print("Large page middleware test")
- self.report_results(with_middleware, without_middleware)
-
- @staticmethod
- def report_results(with_middleware, without_middleware):
- print(f"Middleware active\t\t{with_middleware:.3f} ms per iteration")
- print(f"Middleware inactive\t{without_middleware:.3f} ms per iteration")
- time_difference = with_middleware - without_middleware
- if without_middleware > with_middleware:
- print(f"Decrease of {-100 * time_difference / with_middleware:.2f}%")
- else:
- print(f"Increase of {100 * time_difference / without_middleware:.2f}%")
diff --git a/benchmarks/monkeypatch_asv.py b/benchmarks/monkeypatch_asv.py
new file mode 100644
index 00000000..23003311
--- /dev/null
+++ b/benchmarks/monkeypatch_asv.py
@@ -0,0 +1,29 @@
+from asv_runner.benchmarks.timeraw import TimerawBenchmark, _SeparateProcessTimer
+
+
+# Fix for https://github.com/airspeed-velocity/asv_runner/pull/44
+def _get_timer(self, *param):
+ """
+ Returns a timer that runs the benchmark function in a separate process.
+
+ #### Parameters
+ **param** (`tuple`)
+ : The parameters to pass to the benchmark function.
+
+ #### Returns
+ **timer** (`_SeparateProcessTimer`)
+ : A timer that runs the function in a separate process.
+ """
+ if param:
+
+ def func():
+ # ---------- OUR CHANGES: ADDED RETURN STATEMENT ----------
+ return self.func(*param)
+ # ---------- OUR CHANGES END ----------
+
+ else:
+ func = self.func
+ return _SeparateProcessTimer(func)
+
+
+TimerawBenchmark._get_timer = _get_timer
diff --git a/benchmarks/monkeypatch_asv_ci.txt b/benchmarks/monkeypatch_asv_ci.txt
new file mode 100644
index 00000000..30158b6d
--- /dev/null
+++ b/benchmarks/monkeypatch_asv_ci.txt
@@ -0,0 +1,66 @@
+# ------------ FIX FOR #45 ------------
+# See https://github.com/airspeed-velocity/asv_runner/issues/45
+# This fix is applied in CI in the `benchmark.yml` file.
+# This file is intentionally named `monkeypatch_asv_ci.txt` to avoid being
+# loaded as a python file by `asv`.
+# -------------------------------------
+
+def timeit(self, number):
+ """
+ Run the function's code `number` times in a separate Python process, and
+ return the execution time.
+
+ #### Parameters
+ **number** (`int`)
+ : The number of times to execute the function's code.
+
+ #### Returns
+ **time** (`float`)
+ : The time it took to execute the function's code `number` times.
+
+ #### Notes
+ The function's code is executed in a separate Python process to avoid
+ interference from the parent process. The function can return either a
+ single string of code to be executed, or a tuple of two strings: the
+ code to be executed and the setup code to be run before timing.
+ """
+ stmt = self.func()
+ if isinstance(stmt, tuple):
+ stmt, setup = stmt
+ else:
+ setup = ""
+ stmt = textwrap.dedent(stmt)
+ setup = textwrap.dedent(setup)
+ stmt = stmt.replace(r'"""', r"\"\"\"")
+ setup = setup.replace(r'"""', r"\"\"\"")
+
+ # TODO
+ # -----------ORIGINAL CODE-----------
+ # code = self.subprocess_tmpl.format(stmt=stmt, setup=setup, number=number)
+
+ # res = subprocess.check_output([sys.executable, "-c", code])
+ # return float(res.strip())
+
+ # -----------NEW CODE-----------
+ code = self.subprocess_tmpl.format(stmt=stmt, setup=setup, number=number)
+
+ evaler = textwrap.dedent(
+ """
+ import sys
+ code = sys.stdin.read()
+ exec(code)
+ """
+ )
+
+ proc = subprocess.Popen([sys.executable, "-c", evaler],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ stdout, stderr = proc.communicate(input=code.encode("utf-8"))
+ if proc.returncode != 0:
+ raise RuntimeError(f"Subprocess failed: {stderr.decode()}")
+ return float(stdout.decode("utf-8").strip())
+
+_SeparateProcessTimer.timeit = timeit
+
+# ------------ END FIX #45 ------------
diff --git a/benchmarks/utils.py b/benchmarks/utils.py
new file mode 100644
index 00000000..eb160cb0
--- /dev/null
+++ b/benchmarks/utils.py
@@ -0,0 +1,99 @@
+import os
+import sys
+from importlib.abc import Loader
+from importlib.util import spec_from_loader, module_from_spec
+from types import ModuleType
+from typing import Any, Dict, List, Optional
+
+
+# NOTE: benchmark_name constraints:
+# - MUST BE UNIQUE
+# - MUST NOT CONTAIN `-`
+# - MUST START WITH `time_`, `mem_`, `peakmem_`
+# See https://github.com/airspeed-velocity/asv/pull/1470
+def benchmark(
+ *,
+ pretty_name: Optional[str] = None,
+ timeout: Optional[int] = None,
+ group_name: Optional[str] = None,
+ params: Optional[Dict[str, List[Any]]] = None,
+ number: Optional[int] = None,
+ min_run_count: Optional[int] = None,
+ include_in_quick_benchmark: bool = False,
+ **kwargs,
+):
+ def decorator(func):
+ # For pull requests, we want to run benchmarks only for a subset of tests,
+ # because the full set of tests takes about 10 minutes to run (5 min per commit).
+ # This is done by setting DJC_BENCHMARK_QUICK=1 in the environment.
+ if os.getenv("DJC_BENCHMARK_QUICK") and not include_in_quick_benchmark:
+ # By setting the benchmark name to something that does NOT start with
+ # valid prefixes like `time_`, `mem_`, or `peakmem_`, this function will be ignored by asv.
+ func.benchmark_name = "noop"
+ return func
+
+ # "group_name" is our custom field, which we actually convert to asv's "benchmark_name"
+ if group_name is not None:
+ benchmark_name = f"{group_name}.{func.__name__}"
+ func.benchmark_name = benchmark_name
+
+ # Also "params" is custom, so we normalize it to "params" and "param_names"
+ if params is not None:
+ func.params, func.param_names = list(params.values()), list(params.keys())
+
+ if pretty_name is not None:
+ func.pretty_name = pretty_name
+ if timeout is not None:
+ func.timeout = timeout
+ if number is not None:
+ func.number = number
+ if min_run_count is not None:
+ func.min_run_count = min_run_count
+
+ # Additional, untyped kwargs
+ for k, v in kwargs.items():
+ setattr(func, k, v)
+
+ return func
+
+ return decorator
+
+
+class VirtualModuleLoader(Loader):
+ def __init__(self, code_string):
+ self.code_string = code_string
+
+ def exec_module(self, module):
+ exec(self.code_string, module.__dict__)
+
+
+def create_virtual_module(name: str, code_string: str, file_path: str) -> ModuleType:
+ """
+ To avoid the headaches of importing the tested code from another diretory,
+ we create a "virtual" module that we can import from anywhere.
+
+ E.g.
+ ```py
+ from benchmarks.utils import create_virtual_module
+
+ create_virtual_module("my_module", "print('Hello, world!')", __file__)
+
+ # Now you can import my_module from anywhere
+ import my_module
+ ```
+ """
+ # Create the module specification
+ spec = spec_from_loader(name, VirtualModuleLoader(code_string))
+
+ # Create the module
+ module = module_from_spec(spec) # type: ignore[arg-type]
+ module.__file__ = file_path
+ module.__name__ = name
+
+ # Add it to sys.modules
+ sys.modules[name] = module
+
+ # Execute the module
+ spec.loader.exec_module(module) # type: ignore[union-attr]
+
+ return module
diff --git a/docs/.nav.yml b/docs/.nav.yml
new file mode 100644
index 00000000..3816ed34
--- /dev/null
+++ b/docs/.nav.yml
@@ -0,0 +1,10 @@
+# For navigation content inspo see Pydantic https://docs.pydantic.dev/latest
+#
+# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
+nav:
+ - overview
+ - Getting Started: getting_started
+ - concepts
+ - guides
+ - API Documentation: reference
+ - Release Notes: release_notes.md
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
deleted file mode 100644
index 6d871c4a..00000000
--- a/docs/SUMMARY.md
+++ /dev/null
@@ -1,11 +0,0 @@
-
-- [Get Started](overview/)
-- Concepts
- - [Getting started](concepts/getting_started/)
- - [Fundamentals](concepts/fundamentals/)
- - [Advanced](concepts/advanced/)
-- Guides
- - [Setup](guides/setup/)
- - [Dev guides](guides/devguides/)
-- [API Documentation](reference/)
-- [Release notes](release_notes.md)
diff --git a/docs/benchmarks/asv.css b/docs/benchmarks/asv.css
new file mode 100644
index 00000000..d7867516
--- /dev/null
+++ b/docs/benchmarks/asv.css
@@ -0,0 +1,161 @@
+/* Basic navigation */
+
+.asv-navigation {
+ padding: 2px;
+}
+
+nav ul li.active a {
+ height: 52px;
+}
+
+nav li.active span.navbar-brand {
+ background-color: #e7e7e7;
+ height: 52px;
+}
+
+nav li.active span.navbar-brand:hover {
+ background-color: #e7e7e7;
+}
+
+.navbar-default .navbar-link {
+ color: #2458D9;
+}
+
+.panel-body {
+ padding: 0;
+}
+
+.panel {
+ margin-bottom: 4px;
+ -webkit-box-shadow: none;
+ box-shadow: none;
+ border-radius: 0;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+}
+
+.panel-default>.panel-heading,
+.panel-heading {
+ font-size: 12px;
+ font-weight:bold;
+ padding: 2px;
+ text-align: center;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ background-color: #eee;
+}
+
+.btn,
+.btn-group,
+.btn-group-vertical>.btn:first-child,
+.btn-group-vertical>.btn:last-child:not(:first-child),
+.btn-group-vertical>.btn:last-child {
+ border: none;
+ border-radius: 0px;
+ overflow: hidden;
+}
+
+.btn-default:focus, .btn-default:active, .btn-default.active {
+ border: none;
+ color: #fff;
+ background-color: #99bfcd;
+}
+
+#range {
+ font-family: monospace;
+ text-align: center;
+ background: #ffffff;
+}
+
+.form-control {
+ border: none;
+ border-radius: 0px;
+ font-size: 12px;
+ padding: 0px;
+}
+
+.tooltip-inner {
+ min-width: 100px;
+ max-width: 800px;
+ text-align: left;
+ white-space: pre-wrap;
+ font-family: monospace;
+}
+
+/* Benchmark tree */
+
+.nav-list {
+ font-size: 12px;
+ padding: 0;
+ padding-left: 15px;
+}
+
+.nav-list>li {
+ overflow-x: hidden;
+}
+
+.nav-list>li>a {
+ padding: 0;
+ padding-left: 5px;
+ color: #000;
+}
+
+.nav-list>li>a:focus {
+ color: #fff;
+ background-color: #99bfcd;
+ box-shadow: inset 0 3px 5px rgba(0,0,0,.125);
+}
+
+.nav-list>li>.nav-header {
+ white-space: nowrap;
+ font-weight: 500;
+ margin-bottom: 2px;
+}
+
+.caret-right {
+ display: inline-block;
+ width: 0;
+ height: 0;
+ margin-left: 2px;
+ vertical-align: middle;
+ border-left: 4px solid;
+ border-bottom: 4px solid transparent;
+ border-top: 4px solid transparent;
+}
+
+/* Summary page */
+
+.benchmark-group > h1 {
+ text-align: center;
+}
+
+.benchmark-container {
+ width: 300px;
+ height: 116px;
+ padding: 4px;
+ border-radius: 3px;
+}
+
+.benchmark-container:hover {
+ background-color: #eee;
+}
+
+.benchmark-plot {
+ width: 292px;
+ height: 88px;
+}
+
+.benchmark-text {
+ font-size: 12px;
+ color: #000;
+ width: 292px;
+ overflow: hidden;
+}
+
+#extra-buttons {
+ margin: 1em;
+}
+
+#extra-buttons a {
+ border: solid 1px #ccc;
+}
diff --git a/docs/benchmarks/asv.js b/docs/benchmarks/asv.js
new file mode 100644
index 00000000..ac235639
--- /dev/null
+++ b/docs/benchmarks/asv.js
@@ -0,0 +1,525 @@
+'use strict';
+
+$(document).ready(function() {
+ /* GLOBAL STATE */
+ /* The index.json content as returned from the server */
+ var main_timestamp = '';
+ var main_json = {};
+ /* Extra pages: {name: show_function} */
+ var loaded_pages = {};
+ /* Previous window scroll positions */
+ var window_scroll_positions = {};
+ /* Previous window hash location */
+ var window_last_location = null;
+ /* Graph data cache */
+ var graph_cache = {};
+ var graph_cache_max_size = 5;
+
+ var colors = [
+ '#247AAD',
+ '#E24A33',
+ '#988ED5',
+ '#777777',
+ '#FBC15E',
+ '#8EBA42',
+ '#FFB5B8'
+ ];
+
+ var time_units = [
+ ['ps', 'picoseconds', 0.000000000001],
+ ['ns', 'nanoseconds', 0.000000001],
+ ['μs', 'microseconds', 0.000001],
+ ['ms', 'milliseconds', 0.001],
+ ['s', 'seconds', 1],
+ ['m', 'minutes', 60],
+ ['h', 'hours', 60 * 60],
+ ['d', 'days', 60 * 60 * 24],
+ ['w', 'weeks', 60 * 60 * 24 * 7],
+ ['y', 'years', 60 * 60 * 24 * 7 * 52],
+ ['C', 'centuries', 60 * 60 * 24 * 7 * 52 * 100]
+ ];
+
+ var mem_units = [
+ ['', 'bytes', 1],
+ ['k', 'kilobytes', 1000],
+ ['M', 'megabytes', 1000000],
+ ['G', 'gigabytes', 1000000000],
+ ['T', 'terabytes', 1000000000000]
+ ];
+
+ function pretty_second(x) {
+ for (var i = 0; i < time_units.length - 1; ++i) {
+ if (Math.abs(x) < time_units[i+1][2]) {
+ return (x / time_units[i][2]).toFixed(3) + time_units[i][0];
+ }
+ }
+
+ return 'inf';
+ }
+
+ function pretty_byte(x) {
+ for (var i = 0; i < mem_units.length - 1; ++i) {
+ if (Math.abs(x) < mem_units[i+1][2]) {
+ break;
+ }
+ }
+ if (i == 0) {
+ return x + '';
+ }
+ return (x / mem_units[i][2]).toFixed(3) + mem_units[i][0];
+ }
+
+ function pretty_unit(x, unit) {
+ if (unit == "seconds") {
+ return pretty_second(x);
+ }
+ else if (unit == "bytes") {
+ return pretty_byte(x);
+ }
+ else if (unit && unit != "unit") {
+ return '' + x.toPrecision(3) + ' ' + unit;
+ }
+ else {
+ return '' + x.toPrecision(3);
+ }
+ }
+
+ function pad_left(s, c, num) {
+ s = '' + s;
+ while (s.length < num) {
+ s = c + s;
+ }
+ return s;
+ }
+
+ function format_date_yyyymmdd(date) {
+ return (pad_left(date.getFullYear(), '0', 4)
+ + '-' + pad_left(date.getMonth() + 1, '0', 2)
+ + '-' + pad_left(date.getDate(), '0', 2));
+ }
+
+ function format_date_yyyymmdd_hhmm(date) {
+ return (format_date_yyyymmdd(date) + ' '
+ + pad_left(date.getHours(), '0', 2)
+ + ':' + pad_left(date.getMinutes(), '0', 2));
+ }
+
+ /* Convert a flat index to permutation to the corresponding value */
+ function param_selection_from_flat_idx(params, idx) {
+ var selection = [];
+ if (idx < 0) {
+ idx = 0;
+ }
+ for (var k = params.length-1; k >= 0; --k) {
+ var j = idx % params[k].length;
+ selection.unshift([j]);
+ idx = (idx - j) / params[k].length;
+ }
+ selection.unshift([null]);
+ return selection;
+ }
+
+ /* Convert a benchmark parameter value from their native Python
+ repr format to a number or a string, ready for presentation */
+ function convert_benchmark_param_value(value_repr) {
+ var match = Number(value_repr);
+ if (!isNaN(match)) {
+ return match;
+ }
+
+ /* Python str */
+ match = value_repr.match(/^'(.+)'$/);
+ if (match) {
+ return match[1];
+ }
+
+ /* Python unicode */
+ match = value_repr.match(/^u'(.+)'$/);
+ if (match) {
+ return match[1];
+ }
+
+ /* Python class */
+ match = value_repr.match(/^$/);
+ if (match) {
+ return match[1];
+ }
+
+ return value_repr;
+ }
+
+ /* Convert loaded graph data to a format flot understands, by
+ treating either time or one of the parameters as x-axis,
+ and selecting only one value of the remaining axes */
+ function filter_graph_data(raw_series, x_axis, other_indices, params) {
+ if (params.length == 0) {
+ /* Simple time series */
+ return raw_series;
+ }
+
+ /* Compute position of data entry in the results list,
+ and stride corresponding to plot x-axis parameter */
+ var stride = 1;
+ var param_stride = 0;
+ var param_idx = 0;
+ for (var k = params.length - 1; k >= 0; --k) {
+ if (k == x_axis - 1) {
+ param_stride = stride;
+ }
+ else {
+ param_idx += other_indices[k + 1] * stride;
+ }
+ stride *= params[k].length;
+ }
+
+ if (x_axis == 0) {
+ /* x-axis is time axis */
+ var series = new Array(raw_series.length);
+ for (var k = 0; k < raw_series.length; ++k) {
+ if (raw_series[k][1] === null) {
+ series[k] = [raw_series[k][0], null];
+ } else {
+ series[k] = [raw_series[k][0],
+ raw_series[k][1][param_idx]];
+ }
+ }
+ return series;
+ }
+ else {
+ /* x-axis is some parameter axis */
+ var time_idx = null;
+ if (other_indices[0] === null) {
+ time_idx = raw_series.length - 1;
+ }
+ else {
+ /* Need to search for the correct time value */
+ for (var k = 0; k < raw_series.length; ++k) {
+ if (raw_series[k][0] == other_indices[0]) {
+ time_idx = k;
+ break;
+ }
+ }
+ if (time_idx === null) {
+ /* No data points */
+ return [];
+ }
+ }
+
+ var x_values = params[x_axis - 1];
+ var series = new Array(x_values.length);
+ for (var k = 0; k < x_values.length; ++k) {
+ if (raw_series[time_idx][1] === null) {
+ series[k] = [convert_benchmark_param_value(x_values[k]),
+ null];
+ }
+ else {
+ series[k] = [convert_benchmark_param_value(x_values[k]),
+ raw_series[time_idx][1][param_idx]];
+ }
+ param_idx += param_stride;
+ }
+ return series;
+ }
+ }
+
+ function filter_graph_data_idx(raw_series, x_axis, flat_idx, params) {
+ var selection = param_selection_from_flat_idx(params, flat_idx);
+ var flat_selection = [];
+ $.each(selection, function(i, v) {
+ flat_selection.push(v[0]);
+ });
+ return filter_graph_data(raw_series, x_axis, flat_selection, params);
+ }
+
+ /* Escape special characters in graph item file names.
+ The implementation must match asv.util.sanitize_filename */
+ function sanitize_filename(name) {
+ var bad_re = /[<>:"\/\\^|?*\x00-\x1f]/g;
+ var bad_names = ["CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3",
+ "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1",
+ "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
+ "LPT9"];
+ name = name.replace(bad_re, "_");
+ if (bad_names.indexOf(name.toUpperCase()) != -1) {
+ name = name + "_";
+ }
+ return name;
+ }
+
+ /* Given a specific group of parameters, generate the URL to
+ use to load that graph.
+ The implementation must match asv.graph.Graph.get_file_path
+ */
+ function graph_to_path(benchmark_name, state) {
+ var parts = [];
+ $.each(state, function(key, value) {
+ var part;
+ if (value === null) {
+ part = key + "-null";
+ } else if (value) {
+ part = key + "-" + value;
+ } else {
+ part = key;
+ }
+ parts.push(sanitize_filename('' + part));
+ });
+ parts.sort();
+ parts.splice(0, 0, "graphs");
+ parts.push(sanitize_filename(benchmark_name));
+
+ /* Escape URI components */
+ parts = $.map(parts, function (val) { return encodeURIComponent(val); });
+ return parts.join('/') + ".json";
+ }
+
+ /*
+ Load and cache graph data (on javascript side)
+ */
+ function load_graph_data(url, success, failure) {
+ var dfd = $.Deferred();
+ if (graph_cache[url]) {
+ setTimeout(function() {
+ dfd.resolve(graph_cache[url]);
+ }, 1);
+ }
+ else {
+ $.ajax({
+ url: url + '?timestamp=' + $.asv.main_timestamp,
+ dataType: "json",
+ cache: true
+ }).done(function(data) {
+ if (Object.keys(graph_cache).length > graph_cache_max_size) {
+ $.each(Object.keys(graph_cache), function (i, key) {
+ delete graph_cache[key];
+ });
+ }
+ graph_cache[url] = data;
+ dfd.resolve(data);
+ }).fail(function() {
+ dfd.reject();
+ });
+ }
+ return dfd.promise();
+ }
+
+ /*
+ Parse hash string, assuming format similar to standard URL
+ query strings
+ */
+ function parse_hash_string(str) {
+ var info = {location: [''], params: {}};
+
+ if (str && str[0] == '#') {
+ str = str.slice(1);
+ }
+ if (str && str[0] == '/') {
+ str = str.slice(1);
+ }
+
+ var match = str.match(/^([^?]*?)\?/);
+ if (match) {
+ info['location'] = decodeURIComponent(match[1]).replace(/\/+/, '/').split('/');
+ var rest = str.slice(match[1].length+1);
+ var parts = rest.split('&');
+ for (var i = 0; i < parts.length; ++i) {
+ var part = parts[i].split('=');
+ if (part.length != 2) {
+ continue;
+ }
+ var key = decodeURIComponent(part[0].replace(/\+/g, " "));
+ var value = decodeURIComponent(part[1].replace(/\+/g, " "));
+ if (value == '[none]') {
+ value = null;
+ }
+ if (info['params'][key] === undefined) {
+ info['params'][key] = [value];
+ }
+ else {
+ info['params'][key].push(value);
+ }
+ }
+ }
+ else {
+ info['location'] = decodeURIComponent(str).replace(/\/+/, '/').split('/');
+ }
+ return info;
+ }
+
+ /*
+ Generate a hash string, inverse of parse_hash_string
+ */
+ function format_hash_string(info) {
+ var parts = info['params'];
+ var str = '#' + info['location'];
+
+ if (parts) {
+ str = str + '?';
+ var first = true;
+ $.each(parts, function (key, values) {
+ $.each(values, function (idx, value) {
+ if (!first) {
+ str = str + '&';
+ }
+ if (value === null) {
+ value = '[none]';
+ }
+ str = str + encodeURIComponent(key) + '=' + encodeURIComponent(value);
+ first = false;
+ });
+ });
+ }
+ return str;
+ }
+
+ /*
+ Dealing with sub-pages
+ */
+
+ function show_page(name, params) {
+ if (loaded_pages[name] !== undefined) {
+ $("#nav ul li.active").removeClass('active');
+ $("#nav-li-" + name).addClass('active');
+ $("#graph-display").hide();
+ $("#summarygrid-display").hide();
+ $("#summarylist-display").hide();
+ $('#regressions-display').hide();
+ $('.tooltip').remove();
+ loaded_pages[name](params);
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+
+ function hashchange() {
+ var info = parse_hash_string(window.location.hash);
+
+ /* Keep track of window scroll position; makes the back-button work */
+ var old_scroll_pos = window_scroll_positions[info.location.join('/')];
+ window_scroll_positions[window_last_location] = $(window).scrollTop();
+ window_last_location = info.location.join('/');
+
+ /* Redirect to correct handler */
+ if (show_page(info.location, info.params)) {
+ /* show_page does the work */
+ }
+ else {
+ /* Display benchmark page */
+ info.params['benchmark'] = info.location[0];
+ show_page('graphdisplay', info.params);
+ }
+
+ /* Scroll back to previous position, if any */
+ if (old_scroll_pos !== undefined) {
+ $(window).scrollTop(old_scroll_pos);
+ }
+ }
+
+ function get_commit_hash(revision) {
+ var commit_hash = main_json.revision_to_hash[revision];
+ if (commit_hash) {
+ // Return printable commit hash
+ commit_hash = commit_hash.slice(0, main_json.hash_length);
+ }
+ return commit_hash;
+ }
+
+ function get_revision(commit_hash) {
+ var rev = null;
+ $.each(main_json.revision_to_hash, function(revision, full_commit_hash) {
+ if (full_commit_hash.startsWith(commit_hash)) {
+ rev = revision;
+ // break the $.each loop
+ return false;
+ }
+ });
+ return rev;
+ }
+
+ function init_index() {
+ /* Fetch the main index.json and then set up the page elements
+ based on it. */
+ $.ajax({
+ url: "index.json" + '?timestamp=' + $.asv.main_timestamp,
+ dataType: "json",
+ cache: true
+ }).done(function (index) {
+ main_json = index;
+ $.asv.main_json = index;
+
+ /* Page title */
+ var project_name = $("#project-name")[0];
+ project_name.textContent = index.project;
+ project_name.setAttribute("href", index.project_url);
+ $("#project-name").textContent = index.project;
+ document.title = "airspeed velocity of an unladen " + index.project;
+
+ $(window).on('hashchange', hashchange);
+
+ $('#graph-display').hide();
+ $('#regressions-display').hide();
+ $('#summarygrid-display').hide();
+ $('#summarylist-display').hide();
+
+ hashchange();
+ }).fail(function () {
+ $.asv.ui.network_error();
+ });
+ }
+
+ function init() {
+ /* Fetch the info.json */
+ $.ajax({
+ url: "info.json",
+ dataType: "json",
+ cache: false
+ }).done(function (info) {
+ main_timestamp = info['timestamp'];
+ $.asv.main_timestamp = main_timestamp;
+ init_index();
+ }).fail(function () {
+ $.asv.ui.network_error();
+ });
+ }
+
+
+ /*
+ Set up $.asv
+ */
+
+ this.register_page = function(name, show_function) {
+ loaded_pages[name] = show_function;
+ }
+ this.parse_hash_string = parse_hash_string;
+ this.format_hash_string = format_hash_string;
+
+ this.filter_graph_data = filter_graph_data;
+ this.filter_graph_data_idx = filter_graph_data_idx;
+ this.convert_benchmark_param_value = convert_benchmark_param_value;
+ this.param_selection_from_flat_idx = param_selection_from_flat_idx;
+ this.graph_to_path = graph_to_path;
+ this.load_graph_data = load_graph_data;
+ this.get_commit_hash = get_commit_hash;
+ this.get_revision = get_revision;
+
+ this.main_timestamp = main_timestamp; /* Updated after info.json loads */
+ this.main_json = main_json; /* Updated after index.json loads */
+
+ this.format_date_yyyymmdd = format_date_yyyymmdd;
+ this.format_date_yyyymmdd_hhmm = format_date_yyyymmdd_hhmm;
+ this.pretty_unit = pretty_unit;
+ this.time_units = time_units;
+ this.mem_units = mem_units;
+
+ this.colors = colors;
+
+ $.asv = this;
+
+
+ /*
+ Launch it
+ */
+
+ init();
+});
diff --git a/docs/benchmarks/asv_ui.js b/docs/benchmarks/asv_ui.js
new file mode 100644
index 00000000..af757c70
--- /dev/null
+++ b/docs/benchmarks/asv_ui.js
@@ -0,0 +1,231 @@
+'use strict';
+
+$(document).ready(function() {
+ function make_panel(nav, heading) {
+ var panel = $('');
+ nav.append(panel);
+ var panel_header = $(
+ '
+
+ Can not determine continental origin of swallow.
+
+
+
+ One or more external (JavaScript) dependencies of airspeed velocity failed to load.
+
+
+
+ Make sure you have an active internet connection and enable 3rd-party scripts
+ in your browser the first time you load airspeed velocity.
+
+
+
diff --git a/docs/benchmarks/graphdisplay.js b/docs/benchmarks/graphdisplay.js
new file mode 100644
index 00000000..ba715322
--- /dev/null
+++ b/docs/benchmarks/graphdisplay.js
@@ -0,0 +1,1427 @@
+'use strict';
+
+$(document).ready(function() {
+ /* The state of the parameters in the sidebar. Dictionary mapping
+ strings to arrays containing the "enabled" configurations. */
+ var state = null;
+ /* The name of the current benchmark being displayed. */
+ var current_benchmark = null;
+ /* An array of graphs being displayed. */
+ var graphs = [];
+ var orig_graphs = [];
+ /* An array of commit revisions being displayed */
+ var current_revisions = [];
+ /* True when log scaling is enabled. */
+ var log_scale = false;
+ /* True when zooming in on the y-axis. */
+ var zoom_y_axis = false;
+ /* True when log scaling is enabled. */
+ var reference_scale = false;
+ /* True when selecting a reference point */
+ var select_reference = false;
+ /* The reference value */
+ var reference = 1.0;
+ /* Whether to show the legend */
+ var show_legend = true;
+ /* Is even commit spacing being used? */
+ var even_spacing = false;
+ var even_spacing_revisions = [];
+ /* Is date scale being used ? */
+ var date_scale = false;
+ var date_to_revision = {};
+ /* A little div to handle tooltip placement on the graph */
+ var tooltip = null;
+ /* X-axis coordinate axis in the data set; always 0 for
+ non-parameterized tests where revision and date are the only potential x-axis */
+ var x_coordinate_axis = 0;
+ var x_coordinate_is_category = false;
+ /* List of lists of value combinations to plot (apart from x-axis)
+ in parameterized tests. */
+ var benchmark_param_selection = [[null]];
+ /* Highlighted revisions */
+ var highlighted_revisions = null;
+ /* Whether benchmark graph display was set up */
+ var benchmark_graph_display_ready = false;
+
+
+ /* UTILITY FUNCTIONS */
+ function arr_remove_from(a, x) {
+ var out = [];
+ $.each(a, function(i, val) {
+ if (x !== val) {
+ out.push(val);
+ }
+ });
+ return out;
+ }
+
+ function obj_copy(obj) {
+ var newobj = {};
+ $.each(obj, function(key, val) {
+ newobj[key] = val;
+ });
+ return newobj;
+ }
+
+ function obj_length(obj) {
+ var i = 0;
+ for (var x in obj)
+ ++i;
+ return i;
+ }
+
+ function obj_get_first_key(data) {
+ for (var prop in data)
+ return prop;
+ }
+
+ function no_data(ajax, status, error) {
+ $("#error-message").text(
+ "No data for this combination of filters. ");
+ $("#error").modal('show');
+ }
+
+ function get_x_from_revision(rev) {
+ if (date_scale) {
+ return $.asv.main_json.revision_to_date[rev];
+ } else {
+ return rev;
+ }
+ }
+
+ function get_commit_hash(x) {
+ // Return the commit hash in the current graph located at position x
+ if (date_scale) {
+ x = date_to_revision[x];
+ }
+ return $.asv.get_commit_hash(x);
+ }
+
+
+ function display_benchmark(bm_name, state_selection, highlight_revisions) {
+ setup_benchmark_graph_display();
+
+ $('#graph-display').show();
+ $('#summarygrid-display').hide();
+ $('#regressions-display').hide();
+ $('.tooltip').remove();
+
+ if (reference_scale) {
+ reference_scale = false;
+ $('#reference').removeClass('active');
+ reference = 1.0;
+ }
+ current_benchmark = bm_name;
+ highlighted_revisions = highlight_revisions;
+ $("#title").text(bm_name);
+ setup_benchmark_params(state_selection);
+ replace_graphs();
+ }
+
+ function setup_benchmark_graph_display() {
+ if (benchmark_graph_display_ready) {
+ return;
+ }
+ benchmark_graph_display_ready = true;
+
+ /* When the window resizes, redraw the graphs */
+ $(window).on('resize', function() {
+ update_graphs();
+ });
+
+ var nav = $("#graphdisplay-navigation");
+
+ /* Make the static tooltips look correct */
+ $('[data-toggle="tooltip"]').tooltip({container: 'body'});
+
+ /* Add insertion point for benchmark parameters */
+ var state_params_nav = $("");
+ nav.append(state_params_nav);
+
+ /* Add insertion point for benchmark parameters */
+ var bench_params_nav = $("");
+ nav.append(bench_params_nav);
+
+ /* Benchmark panel */
+ var panel_body = $.asv.ui.make_panel(nav, 'benchmark');
+
+ var tree = $('
');
+ var cursor = [];
+ var stack = [tree];
+
+ /* Sort keys for tree construction */
+ var benchmark_keys = Object.keys($.asv.main_json.benchmarks);
+ benchmark_keys.sort();
+
+ /* Build tree */
+ $.each(benchmark_keys, function(i, bm_name) {
+ var bm = $.asv.main_json.benchmarks[bm_name];
+ var parts = bm_name.split('.');
+ var i = 0;
+ var j;
+
+ for (; i < cursor.length; ++i) {
+ if (cursor[i] !== parts[i]) {
+ break;
+ }
+ }
+
+ for (j = cursor.length - 1; j >= i; --j) {
+ stack.pop();
+ cursor.pop();
+ }
+
+ for (j = i; j < parts.length - 1; ++j) {
+ var top = $(
+ '
' +
+ '
');
+ stack[stack.length - 1].append(top);
+ stack.push($(top.children()[1]));
+ cursor.push(parts[j]);
+
+ $(top.children()[0]).on('click', function () {
+ $(this).parent().children('ul.tree').toggle(150);
+ var caret = $(this).children('b');
+ if (caret.attr('class') == 'caret') {
+ caret.removeClass().addClass("caret-right");
+ } else {
+ caret.removeClass().addClass("caret");
+ }
+ });
+ }
+
+ var name = bm.pretty_name || parts[parts.length - 1];
+ var top = $('
');
+ if (row.change_rev !== null) {
+ var date = new Date($.asv.main_json.revision_to_date[row.change_rev[1]]);
+ var commit_1 = $.asv.get_commit_hash(row.change_rev[0]);
+ var commit_2 = $.asv.get_commit_hash(row.change_rev[1]);
+ var commit_a = $('');
+ var span = $('');
+ if (commit_1) {
+ var commit_url;
+ if ($.asv.main_json.show_commit_url.match(/.*\/\/github.com\//)) {
+ commit_url = ($.asv.main_json.show_commit_url + '../compare/'
+ + commit_1 + '...' + commit_2);
+ }
+ else {
+ commit_url = $.asv.main_json.show_commit_url + commit_2;
+ }
+ commit_a.attr('href', commit_url);
+ commit_a.text(commit_1 + '...' + commit_2);
+ }
+ else {
+ commit_a.attr('href', $.asv.main_json.show_commit_url + commit_2);
+ commit_a.text(commit_2);
+ }
+ span.text($.asv.format_date_yyyymmdd(date) + ' ');
+ span.append(commit_a);
+ changed_at_td.append(span);
+ }
+
+ tr.append(name_td);
+ tr.append(value_td);
+ tr.append(change_td);
+ tr.append(changed_at_td);
+
+ table_body.append(tr);
+ });
+
+ table_body.find('[data-toggle="tooltip"]').tooltip();
+
+ /* Finalize */
+ table.append(table_body);
+ setup_sort(table);
+
+ return table;
+ }
+
+ function setup_sort(table) {
+ var info = $.asv.parse_hash_string(window.location.hash);
+
+ table.stupidtable();
+
+ table.on('aftertablesort', function (event, data) {
+ var info = $.asv.parse_hash_string(window.location.hash);
+ info.params['sort'] = [data.column];
+ info.params['dir'] = [data.direction];
+ window.location.hash = $.asv.format_hash_string(info);
+
+ /* Update appearance */
+ table.find('thead th').removeClass('asc');
+ table.find('thead th').removeClass('desc');
+ var th_to_sort = table.find("thead th").eq(parseInt(data.column));
+ if (th_to_sort) {
+ th_to_sort.addClass(data.direction);
+ }
+ });
+
+ if (info.params.sort && info.params.dir) {
+ var th_to_sort = table.find("thead th").eq(parseInt(info.params.sort[0]));
+ th_to_sort.stupidsort(info.params.dir[0]);
+ }
+ else {
+ var th_to_sort = table.find("thead th").eq(0);
+ th_to_sort.stupidsort("asc");
+ }
+ }
+
+ /*
+ * Entry point
+ */
+ $.asv.register_page('summarylist', function(params) {
+ var state_selection = null;
+
+ if (Object.keys(params).length > 0) {
+ state_selection = params;
+ }
+
+ setup_display(state_selection);
+
+ $('#summarylist-display').show();
+ $("#title").text("List of benchmarks");
+ });
+});
diff --git a/docs/benchmarks/swallow.ico b/docs/benchmarks/swallow.ico
new file mode 100644
index 00000000..b5fcc02b
Binary files /dev/null and b/docs/benchmarks/swallow.ico differ
diff --git a/docs/benchmarks/swallow.png b/docs/benchmarks/swallow.png
new file mode 100644
index 00000000..55228678
Binary files /dev/null and b/docs/benchmarks/swallow.png differ
diff --git a/docs/concepts/.nav.yml b/docs/concepts/.nav.yml
new file mode 100644
index 00000000..459808ef
--- /dev/null
+++ b/docs/concepts/.nav.yml
@@ -0,0 +1,4 @@
+# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
+nav:
+ - fundamentals
+ - advanced
diff --git a/docs/concepts/advanced/.nav.yml b/docs/concepts/advanced/.nav.yml
new file mode 100644
index 00000000..3480a1e3
--- /dev/null
+++ b/docs/concepts/advanced/.nav.yml
@@ -0,0 +1,14 @@
+# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
+nav:
+ - Rendering JS / CSS: rendering_js_css.md
+ - HTML fragments: html_fragments.md
+ - Prop drilling and provide / inject: provide_inject.md
+ - Lifecycle hooks: hooks.md
+ - Registering components: component_registry.md
+ - Component caching: component_caching.md
+ - Component context and scope: component_context_scope.md
+ - Custom template tags: template_tags.md
+ - Tag formatters: tag_formatters.md
+ - Extensions: extensions.md
+ - Testing: testing.md
+ - Component libraries: component_libraries.md
diff --git a/docs/concepts/advanced/component_caching.md b/docs/concepts/advanced/component_caching.md
new file mode 100644
index 00000000..6eb3c9bf
--- /dev/null
+++ b/docs/concepts/advanced/component_caching.md
@@ -0,0 +1,181 @@
+Component caching allows you to store the rendered output of a component. Next time the component is rendered
+with the same input, the cached output is returned instead of re-rendering the component.
+
+This is particularly useful for components that are expensive to render or do not change frequently.
+
+!!! info
+
+ Component caching uses [Django's cache framework](https://docs.djangoproject.com/en/5.2/topics/cache/),
+ so you can use any cache backend that is supported by Django.
+
+### Enabling caching
+
+Caching is disabled by default.
+
+To enable caching for a component, set [`Component.Cache.enabled`](../../reference/api.md#django_components.ComponentCache.enabled) to `True`:
+
+```python
+from django_components import Component
+
+class MyComponent(Component):
+ class Cache:
+ enabled = True
+```
+
+### Time-to-live (TTL)
+
+You can specify a time-to-live (TTL) for the cache entry with [`Component.Cache.ttl`](../../reference/api.md#django_components.ComponentCache.ttl), which determines how long the entry remains valid. The TTL is specified in seconds.
+
+```python
+class MyComponent(Component):
+ class Cache:
+ enabled = True
+ ttl = 60 * 60 * 24 # 1 day
+```
+
+- If `ttl > 0`, entries are cached for the specified number of seconds.
+- If `ttl = -1`, entries are cached indefinitely.
+- If `ttl = 0`, entries are not cached.
+- If `ttl = None`, the default TTL is used.
+
+### Custom cache name
+
+Since component caching uses Django's cache framework, you can specify a custom cache name with [`Component.Cache.cache_name`](../../reference/api.md#django_components.ComponentCache.cache_name) to use a different cache backend:
+
+```python
+class MyComponent(Component):
+ class Cache:
+ enabled = True
+ cache_name = "my_cache"
+```
+
+### Cache key generation
+
+By default, the cache key is generated based on the component's input (args and kwargs). So the following two calls would generate separate entries in the cache:
+
+```py
+MyComponent.render(name="Alice")
+MyComponent.render(name="Bob")
+```
+
+However, you have full control over the cache key generation. As such, you can:
+
+- Cache the component on all inputs (default)
+- Cache the component on particular inputs
+- Cache the component irrespective of the inputs
+
+To achieve that, you can override
+the [`Component.Cache.hash()`](../../reference/api.md#django_components.ComponentCache.hash)
+method to customize how arguments are hashed into the cache key.
+
+```python
+class MyComponent(Component):
+ class Cache:
+ enabled = True
+
+ def hash(self, *args, **kwargs):
+ return f"{json.dumps(args)}:{json.dumps(kwargs)}"
+```
+
+For even more control, you can override other methods available on the [`ComponentCache`](../../reference/api.md#django_components.ComponentCache) class.
+
+!!! warning
+
+ The default implementation of `Cache.hash()` simply serializes the input into a string.
+ As such, it might not be suitable if you need to hash complex objects like Models.
+
+### Caching slots
+
+By default, the cache key is generated based ONLY on the args and kwargs.
+
+To cache the component based on the slots, set [`Component.Cache.include_slots`](../../reference/api.md#django_components.ComponentCache.include_slots) to `True`:
+
+```python
+class MyComponent(Component):
+ class Cache:
+ enabled = True
+ include_slots = True
+```
+
+with `include_slots = True`, the cache key will be generated also based on the given slots.
+
+As such, the following two calls would generate separate entries in the cache:
+
+```django
+{% component "my_component" position="left" %}
+ Hello, Alice
+{% endcomponent %}
+
+{% component "my_component" position="left" %}
+ Hello, Bob
+{% endcomponent %}
+```
+
+Same when using [`Component.render()`](../../reference/api.md#django_components.Component.render) with string slots:
+
+```py
+MyComponent.render(
+ kwargs={"position": "left"},
+ slots={"content": "Hello, Alice"}
+)
+MyComponent.render(
+ kwargs={"position": "left"},
+ slots={"content": "Hello, Bob"}
+)
+```
+
+!!! warning
+
+ Passing slots as functions to cached components with `include_slots=True` will raise an error.
+
+ ```py
+ MyComponent.render(
+ kwargs={"position": "left"},
+ slots={"content": lambda ctx: "Hello, Alice"}
+ )
+ ```
+
+!!! warning
+
+ Slot caching DOES NOT account for context variables within
+ the [`{% fill %}`](../../reference/template_tags.md#fill) tag.
+
+ For example, the following two cases will be treated as the same entry:
+
+ ```django
+ {% with my_var="foo" %}
+ {% component "mycomponent" name="foo" %}
+ {{ my_var }}
+ {% endcomponent %}
+ {% endwith %}
+
+ {% with my_var="bar" %}
+ {% component "mycomponent" name="bar" %}
+ {{ my_var }}
+ {% endcomponent %}
+ {% endwith %}
+ ```
+
+ Currently it's impossible to capture used variables. This will be addressed in v2.
+ Read more about it in [django-components/#1164](https://github.com/django-components/django-components/issues/1164).
+
+### Example
+
+Here's a complete example of a component with caching enabled:
+
+```python
+from django_components import Component
+
+class MyComponent(Component):
+ template = "Hello, {{ name }}"
+
+ class Cache:
+ enabled = True
+ ttl = 300 # Cache for 5 minutes
+ cache_name = "my_cache"
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {"name": kwargs["name"]}
+```
+
+In this example, the component's rendered output is cached for 5 minutes using the `my_cache` backend.
diff --git a/docs/concepts/fundamentals/component_context_scope.md b/docs/concepts/advanced/component_context_scope.md
similarity index 80%
rename from docs/concepts/fundamentals/component_context_scope.md
rename to docs/concepts/advanced/component_context_scope.md
index adc5f33d..8d4ad4ca 100644
--- a/docs/concepts/fundamentals/component_context_scope.md
+++ b/docs/concepts/advanced/component_context_scope.md
@@ -1,8 +1,3 @@
----
-title: Component context and scope
-weight: 4
----
-
By default, context variables are passed down the template as in regular Django - deeper scopes can access the variables from the outer scopes. So if you have several nested forloops, then inside the deep-most loop you can access variables defined by all previous loops.
With this in mind, the `{% component %}` tag behaves similarly to `{% include %}` tag - inside the component tag, you can access all variables that were defined outside of it.
@@ -17,7 +12,7 @@ NOTE: `{% csrf_token %}` tags need access to the top-level context, and they wil
If you find yourself using the `only` modifier often, you can set the [context_behavior](#context-behavior) option to `"isolated"`, which automatically applies the `only` modifier. This is useful if you want to make sure that components don't accidentally access the outer context.
-Components can also access the outer context in their context methods like `get_context_data` by accessing the property `self.outer_context`.
+Components can also access the outer context in their context methods like `get_template_data` by accessing the property `self.outer_context`.
## Example of Accessing Outer Context
@@ -27,14 +22,14 @@ Components can also access the outer context in their context methods like `get_
```
-Assuming that the rendering context has variables such as `date`, you can use `self.outer_context` to access them from within `get_context_data`. Here's how you might implement it:
+Assuming that the rendering context has variables such as `date`, you can use `self.outer_context` to access them from within `get_template_data`. Here's how you might implement it:
```python
class Calender(Component):
...
- def get_context_data(self):
+ def get_template_data(self, args, kwargs, slots, context):
outer_field = self.outer_context["date"]
return {
"date": outer_fields,
@@ -58,10 +53,10 @@ This has two modes:
you can access are a union of:
- All the variables that were OUTSIDE the fill tag, including any\
- [`{% with %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#with) tags.
- - Any loops ([`{% for ... %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#cycle))
+ [`{% with %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#with) tags.
+ - Any loops ([`{% for ... %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#cycle))
that the `{% fill %}` tag is part of.
- - Data returned from [`Component.get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
+ - Data returned from [`Component.get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
of the component that owns the fill tag.
- `"isolated"`
@@ -72,22 +67,22 @@ This has two modes:
Inside the [`{% fill %}`](../../../reference/template_tags#fill) tag, you can ONLY access variables from 2 places:
- - Any loops ([`{% for ... %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#cycle))
+ - Any loops ([`{% for ... %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#cycle))
that the `{% fill %}` tag is part of.
- - [`Component.get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
+ - [`Component.get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
of the component which defined the template (AKA the "root" component).
!!! warning
- Notice that the component whose `get_context_data()` we use inside
+ Notice that the component whose `get_template_data()` we use inside
[`{% fill %}`](../../../reference/template_tags#fill)
is NOT the same across the two modes!
Consider this example:
- ```python
+ ```djc_py
class Outer(Component):
- template = \"\"\"
+ template = """
{% component "inner" %}
{% fill "content" %}
@@ -95,20 +90,20 @@ This has two modes:
{% endfill %}
{% endcomponent %}
- \"\"\"
+ """
```
- - `"django"` - `my_var` has access to data from `get_context_data()` of both `Inner` and `Outer`.
+ - `"django"` - `my_var` has access to data from `get_template_data()` of both `Inner` and `Outer`.
If there are variables defined in both, then `Inner` overshadows `Outer`.
- - `"isolated"` - `my_var` has access to data from `get_context_data()` of ONLY `Outer`.
+ - `"isolated"` - `my_var` has access to data from `get_template_data()` of ONLY `Outer`.
### Example "django"
Given this template:
-```python
+```djc_py
@register("root_comp")
class RootComp(Component):
template = """
@@ -120,11 +115,11 @@ class RootComp(Component):
{% endwith %}
"""
- def get_context_data(self):
+ def get_template_data(self, args, kwargs, slots, context):
return { "my_var": 123 }
```
-Then if [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
+Then if [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
of the component `"my_comp"` returns following data:
```py
@@ -148,7 +143,7 @@ all the data defined in the outer layers, like the `{% with %}` tag.
Given this template:
-```python
+```djc_py
class RootComp(Component):
template = """
{% with cheese="feta" %}
@@ -159,11 +154,11 @@ class RootComp(Component):
{% endwith %}
"""
- def get_context_data(self):
+ def get_template_data(self, args, kwargs, slots, context):
return { "my_var": 123 }
```
-Then if [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
+Then if [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
of the component `"my_comp"` returns following data:
```py
@@ -177,10 +172,10 @@ Then the template will be rendered as:
# cheese
```
-Because variables `"my_var"` and `"cheese"` are searched only inside `RootComponent.get_context_data()`.
+Because variables `"my_var"` and `"cheese"` are searched only inside `RootComponent.get_template_data()`.
But since `"cheese"` is not defined there, it's empty.
!!! info
- Notice that the variables defined with the [`{% with %}`](https://docs.djangoproject.com/en/5.1/ref/templates/builtins/#with)
+ Notice that the variables defined with the [`{% with %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#with)
tag are ignored inside the [`{% fill %}`](../../../reference/template_tags#fill) tag with the `"isolated"` mode.
diff --git a/docs/concepts/advanced/authoring_component_libraries.md b/docs/concepts/advanced/component_libraries.md
similarity index 85%
rename from docs/concepts/advanced/authoring_component_libraries.md
rename to docs/concepts/advanced/component_libraries.md
index f10693d7..44aa4577 100644
--- a/docs/concepts/advanced/authoring_component_libraries.md
+++ b/docs/concepts/advanced/component_libraries.md
@@ -1,9 +1,6 @@
----
-title: Authoring component libraries
-weight: 8
----
+You can publish and share your components for others to use. Below you will find the steps to do so.
-You can publish and share your components for others to use. Here are the steps to do so:
+For live examples, see the [Community examples](../../overview/community.md#community-examples).
## Writing component libraries
@@ -26,13 +23,13 @@ You can publish and share your components for others to use. Here are the steps
|-- mytags.py
```
-2. Create custom [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#how-to-create-custom-template-tags-and-filters)
+2. Create custom [`Library`](https://docs.djangoproject.com/en/5.2/howto/custom-template-tags/#how-to-create-custom-template-tags-and-filters)
and [`ComponentRegistry`](django_components.component_registry.ComponentRegistry) instances in `mytags.py`
This will be the entrypoint for using the components inside Django templates.
- Remember that Django requires the [`Library`](https://docs.djangoproject.com/en/5.1/howto/custom-template-tags/#how-to-create-custom-template-tags-and-filters)
- instance to be accessible under the `register` variable ([See Django docs](https://docs.djangoproject.com/en/dev/howto/custom-template-tags)):
+ Remember that Django requires the [`Library`](https://docs.djangoproject.com/en/5.2/howto/custom-template-tags/#how-to-create-custom-template-tags-and-filters)
+ instance to be accessible under the `register` variable ([See Django docs](https://docs.djangoproject.com/en/5.2/howto/custom-template-tags)):
```py
from django.template import Library
@@ -103,36 +100,31 @@ You can publish and share your components for others to use. Here are the steps
It's also a good idea to have a common prefix for your components, so they can be easily distinguished from users' components. In the example below, we use the prefix `my_` / `My`.
- ```py
- from typing import Dict, NotRequired, Optional, Tuple, TypedDict
-
- from django_components import Component, SlotFunc, register, types
+ ```djc_py
+ from typing import NamedTuple, Optional
+ from django_components import Component, SlotInput, register, types
from myapp.templatetags.mytags import comp_registry
- # Define the types
- class EmptyDict(TypedDict):
- pass
-
- type MyMenuArgs = Tuple[int, str]
-
- class MyMenuSlots(TypedDict):
- default: NotRequired[Optional[SlotFunc[EmptyDict]]]
-
- class MyMenuProps(TypedDict):
- vertical: NotRequired[bool]
- klass: NotRequired[str]
- style: NotRequired[str]
-
# Define the component
# NOTE: Don't forget to set the `registry`!
@register("my_menu", registry=comp_registry)
- class MyMenu(Component[MyMenuArgs, MyMenuProps, MyMenuSlots, Any, Any, Any]):
- def get_context_data(
- self,
- *args,
- attrs: Optional[Dict] = None,
- ):
+ class MyMenu(Component):
+ # Define the types
+ class Args(NamedTuple):
+ size: int
+ text: str
+
+ class Kwargs(NamedTuple):
+ vertical: Optional[bool] = None
+ klass: Optional[str] = None
+ style: Optional[str] = None
+
+ class Slots(NamedTuple):
+ default: Optional[SlotInput] = None
+
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
+ attrs = ...
return {
"attrs": attrs,
}
@@ -156,7 +148,7 @@ You can publish and share your components for others to use. Here are the steps
Since you, as the library author, are not in control of the file system, it is recommended to load the components manually.
- We recommend doing this in the [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready)
+ We recommend doing this in the [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.2/ref/applications/#django.apps.AppConfig.ready)
hook of your `apps.py`:
```py
@@ -178,7 +170,7 @@ You can publish and share your components for others to use. Here are the steps
```
Note that you can also include any other startup logic within
- [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready).
+ [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.2/ref/applications/#django.apps.AppConfig.ready).
And that's it! The next step is to publish it.
@@ -193,7 +185,7 @@ django_components uses the [`build`](https://build.pypa.io/en/stable/) utility t
python -m build --sdist --wheel --outdir dist/ .
```
-And to publish to PyPI, you can use [`twine`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready)
+And to publish to PyPI, you can use [`twine`](https://docs.djangoproject.com/en/5.2/ref/applications/#django.apps.AppConfig.ready)
([See Python user guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/#uploading-the-distribution-archives))
```bash
@@ -227,7 +219,7 @@ After the package has been published, all that remains is to install it in other
]
```
-3. Optionally add the template tags to the [`builtins`](https://docs.djangoproject.com/en/5.1/topics/templates/#django.template.backends.django.DjangoTemplates),
+3. Optionally add the template tags to the [`builtins`](https://docs.djangoproject.com/en/5.2/topics/templates/#django.template.backends.django.DjangoTemplates),
so you don't have to call `{% load mytags %}` in every template:
```python
diff --git a/docs/concepts/advanced/component_registry.md b/docs/concepts/advanced/component_registry.md
index be481912..f5865420 100644
--- a/docs/concepts/advanced/component_registry.md
+++ b/docs/concepts/advanced/component_registry.md
@@ -1,8 +1,3 @@
----
-title: Registering components
-weight: 5
----
-
In previous examples you could repeatedly see us using `@register()` to "register"
the components. In this section we dive deeper into what it actually means and how you can
manage (add or remove) components.
@@ -14,12 +9,12 @@ from django_components import Component, register
@register("calendar")
class Calendar(Component):
- template_name = "template.html"
+ template_file = "template.html"
# This component takes one parameter, a date string to show in the template
- def get_context_data(self, date):
+ def get_template_data(self, args, kwargs, slots, context):
return {
- "date": date,
+ "date": kwargs["date"],
}
```
@@ -85,6 +80,9 @@ registry.register("card", CardComponent)
registry.all() # {"button": ButtonComponent, "card": CardComponent}
registry.get("card") # CardComponent
+# Check if component is registered
+registry.has("button") # True
+
# Unregister single component
registry.unregister("card")
@@ -127,6 +125,7 @@ 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`
diff --git a/docs/concepts/advanced/extensions.md b/docs/concepts/advanced/extensions.md
new file mode 100644
index 00000000..50d76cd4
--- /dev/null
+++ b/docs/concepts/advanced/extensions.md
@@ -0,0 +1,902 @@
+_New in version 0.131_
+
+Django-components functionality can be extended with "extensions". Extensions allow for powerful customization and integrations. They can:
+
+- Tap into lifecycle events, such as when a component is created, deleted, registered, or unregistered.
+- Add new attributes and methods to the components under an extension-specific nested class.
+- Define custom commands that can be executed via the Django management command interface.
+
+## Live examples
+
+- [djc-ext-pydantic](https://github.com/django-components/djc-ext-pydantic)
+
+## Install extensions
+
+Extensions are configured in the Django settings under [`COMPONENTS.extensions`](../../../reference/settings#django_components.app_settings.ComponentsSettings.extensions).
+
+Extensions can be set by either as an import string or by passing in a class:
+
+```python
+# settings.py
+
+class MyExtension(ComponentExtension):
+ name = "my_extension"
+
+ class ComponentConfig(ExtensionComponentConfig):
+ ...
+
+COMPONENTS = ComponentsSettings(
+ extensions=[
+ MyExtension,
+ "another_app.extensions.AnotherExtension",
+ "my_app.extensions.ThirdExtension",
+ ],
+)
+```
+
+## Lifecycle hooks
+
+Extensions can define methods to hook into lifecycle events, such as:
+
+- Component creation or deletion
+- Un/registering a component
+- Creating or deleting a registry
+- Pre-processing data passed to a component on render
+- Post-processing data returned from [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
+ and others.
+
+See the full list in [Extension Hooks Reference](../../../reference/extension_hooks).
+
+## Per-component configuration
+
+Each extension has a corresponding nested class within the [`Component`](../../../reference/api#django_components.Component) class. These allow
+to configure the extensions on a per-component basis.
+
+E.g.:
+
+- `"view"` extension -> [`Component.View`](../../../reference/api#django_components.Component.View)
+- `"cache"` extension -> [`Component.Cache`](../../../reference/api#django_components.Component.Cache)
+- `"defaults"` extension -> [`Component.Defaults`](../../../reference/api#django_components.Component.Defaults)
+
+!!! note
+
+ **Accessing the component instance from inside the nested classes:**
+
+ Each method of the nested classes has access to the `component` attribute,
+ which points to the component instance.
+
+ ```python
+ class MyTable(Component):
+ class MyExtension:
+ def get(self, request):
+ # `self.component` points to the instance of `MyTable` Component.
+ return self.component.render_to_response(request=request)
+ ```
+
+### Example: Component as View
+
+The [Components as Views](../../fundamentals/component_views_urls) feature is actually implemented as an extension
+that is configured by a `View` nested class.
+
+You can override the `get()`, `post()`, etc methods to customize the behavior of the component as a view:
+
+```python
+class MyTable(Component):
+ class View:
+ def get(self, request):
+ return self.component_class.render_to_response(request=request)
+
+ def post(self, request):
+ return self.component_class.render_to_response(request=request)
+
+ ...
+```
+
+
+### Example: Storybook integration
+
+The Storybook integration (work in progress) is an extension that is configured by a `Storybook` nested class.
+
+You can override methods such as `title`, `parameters`, etc, to customize how to generate a Storybook
+JSON file from the component.
+
+```python
+class MyTable(Component):
+ class Storybook:
+ def title(self):
+ return self.component_cls.__name__
+
+ def parameters(self) -> Parameters:
+ return {
+ "server": {
+ "id": self.component_cls.__name__,
+ }
+ }
+
+ def stories(self) -> List[StoryAnnotations]:
+ return []
+
+ ...
+```
+
+### Extension defaults
+
+Extensions are incredibly flexible, but configuring the same extension for every component can be a pain.
+
+For this reason, django-components allows for extension defaults. This is like setting the extension config for every component.
+
+To set extension defaults, use the [`COMPONENTS.extensions_defaults`](../../../reference/settings#django_components.app_settings.ComponentsSettings.extensions_defaults) setting.
+
+The `extensions_defaults` setting is a dictionary where the key is the extension name and the value is a dictionary of config attributes:
+
+```python
+COMPONENTS = ComponentsSettings(
+ extensions=[
+ "my_extension.MyExtension",
+ "storybook.StorybookExtension",
+ ],
+ extensions_defaults={
+ "my_extension": {
+ "key": "value",
+ },
+ "view": {
+ "public": True,
+ },
+ "cache": {
+ "ttl": 60,
+ },
+ "storybook": {
+ "title": lambda self: self.component_cls.__name__,
+ },
+ },
+)
+```
+
+Which is equivalent to setting the following for every component:
+
+```python
+class MyTable(Component):
+ class MyExtension:
+ key = "value"
+
+ class View:
+ public = True
+
+ class Cache:
+ ttl = 60
+
+ class Storybook:
+ def title(self):
+ return self.component_cls.__name__
+```
+
+!!! info
+
+ If you define an attribute as a function, it is like defining a method on the extension class.
+
+ E.g. in the example above, `title` is a method on the `Storybook` extension class.
+
+As the name suggests, these are defaults, and so you can still selectively override them on a per-component basis:
+
+```python
+class MyTable(Component):
+ class View:
+ public = False
+```
+
+### Extensions in component instances
+
+Above, we've configured extensions `View` and `Storybook` for the `MyTable` component.
+
+You can access the instances of these extension classes in the component instance.
+
+Extensions are available under their names (e.g. `self.view`, `self.storybook`).
+
+For example, the View extension is available as `self.view`:
+
+```python
+class MyTable(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ # `self.view` points to the instance of `View` extension.
+ return {
+ "view": self.view,
+ }
+```
+
+And the Storybook extension is available as `self.storybook`:
+
+```python
+class MyTable(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ # `self.storybook` points to the instance of `Storybook` extension.
+ return {
+ "title": self.storybook.title(),
+ }
+```
+
+## Writing extensions
+
+Creating extensions in django-components involves defining a class that inherits from
+[`ComponentExtension`](../../../reference/api/#django_components.ComponentExtension).
+This class can implement various lifecycle hooks and define new attributes or methods to be added to components.
+
+### Extension class
+
+To create an extension, define a class that inherits from [`ComponentExtension`](../../../reference/api/#django_components.ComponentExtension)
+and implement the desired hooks.
+
+- Each extension MUST have a `name` attribute. The name MUST be a valid Python identifier.
+- The extension may implement any of the [hook methods](../../../reference/extension_hooks).
+
+ Each hook method receives a context object with relevant data.
+
+- Extension may own [URLs](#extension-urls) or [CLI commands](#extension-commands).
+
+```python
+from django_components.extension import ComponentExtension, OnComponentClassCreatedContext
+
+class MyExtension(ComponentExtension):
+ name = "my_extension"
+
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ # Custom logic for when a component class is created
+ ctx.component_cls.my_attr = "my_value"
+```
+
+!!! warning
+
+ The `name` attribute MUST be unique across all extensions.
+
+ Moreover, the `name` attribute MUST NOT conflict with existing Component class API.
+
+ So if you name an extension `render`, it will conflict with the [`render()`](../../../reference/api/#django_components.Component.render) method of the `Component` class.
+
+### Component config
+
+In previous sections we've seen the `View` and `Storybook` extensions classes that were nested
+within the [`Component`](../../../reference/api/#django_components.Component) class:
+
+```python
+class MyComponent(Component):
+ class View:
+ ...
+
+ class Storybook:
+ ...
+```
+
+These can be understood as component-specific overrides or configuration.
+
+Whether it's `Component.View` or `Component.Storybook`, their respective extensions
+defined how these nested classes will behave.
+
+For example, the View extension defines the API that users may override in `ViewExtension.ComponentConfig`:
+
+```python
+from django_components.extension import ComponentExtension, ExtensionComponentConfig
+
+class ViewExtension(ComponentExtension):
+ name = "view"
+
+ # The default behavior of the `View` extension class.
+ class ComponentConfig(ExtensionComponentConfig):
+ def get(self, request):
+ raise NotImplementedError("You must implement the `get` method.")
+
+ def post(self, request):
+ raise NotImplementedError("You must implement the `post` method.")
+
+ ...
+```
+
+In any component that then defines a nested `Component.View` extension class, the resulting `View` class
+will actually subclass from the `ViewExtension.ComponentConfig` class.
+
+In other words, when you define a component like this:
+
+```python
+class MyTable(Component):
+ class View:
+ def get(self, request):
+ # Do something
+ ...
+```
+
+Behind the scenes it is as if you defined the following:
+
+```python
+class MyTable(Component):
+ class View(ViewExtension.ComponentConfig):
+ def get(self, request):
+ # Do something
+ ...
+```
+
+!!! warning
+
+ When writing an extension, the `ComponentConfig` MUST subclass the base class [`ExtensionComponentConfig`](../../../reference/api/#django_components.ExtensionComponentConfig).
+
+ This base class ensures that the extension class will have access to the component instance.
+
+### Install your extension
+
+Once the extension is defined, it needs to be installed in the Django settings to be used by the application.
+
+Extensions can be given either as an extension class, or its import string:
+
+```python
+# settings.py
+COMPONENTS = {
+ "extensions": [
+ "my_app.extensions.MyExtension",
+ ],
+}
+```
+
+Or by reference:
+
+```python
+# settings.py
+from my_app.extensions import MyExtension
+
+COMPONENTS = {
+ "extensions": [
+ MyExtension,
+ ],
+}
+```
+
+### Full example: Custom logging extension
+
+To tie it all together, here's an example of a custom logging extension that logs when components are created, deleted, or rendered:
+
+- Each component can specify which color to use for the logging by setting `Component.ColorLogger.color`.
+- The extension will log the component name and color when the component is created, deleted, or rendered.
+
+```python
+from django_components.extension import (
+ ComponentExtension,
+ ExtensionComponentConfig,
+ OnComponentClassCreatedContext,
+ OnComponentClassDeletedContext,
+ OnComponentInputContext,
+)
+
+
+class ColorLoggerExtension(ComponentExtension):
+ name = "color_logger"
+
+ # All `Component.ColorLogger` classes will inherit from this class.
+ class ComponentConfig(ExtensionComponentConfig):
+ color: str
+
+ # These hooks don't have access to the Component instance,
+ # only to the Component class, so we access the color
+ # as `Component.ColorLogger.color`.
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext):
+ log.info(
+ f"Component {ctx.component_cls} created.",
+ color=ctx.component_cls.ColorLogger.color,
+ )
+
+ def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext):
+ log.info(
+ f"Component {ctx.component_cls} deleted.",
+ color=ctx.component_cls.ColorLogger.color,
+ )
+
+ # This hook has access to the Component instance, so we access the color
+ # as `self.component.color_logger.color`.
+ def on_component_input(self, ctx: OnComponentInputContext):
+ log.info(
+ f"Rendering component {ctx.component_cls}.",
+ color=ctx.component.color_logger.color,
+ )
+```
+
+To use the `ColorLoggerExtension`, add it to your settings:
+
+```python
+# settings.py
+COMPONENTS = {
+ "extensions": [
+ ColorLoggerExtension,
+ ],
+}
+```
+
+Once installed, in any component, you can define a `ColorLogger` attribute:
+
+```python
+class MyComponent(Component):
+ class ColorLogger:
+ color = "red"
+```
+
+This will log the component name and color when the component is created, deleted, or rendered.
+
+### Utility functions
+
+django-components provides a few utility functions to help with writing extensions:
+
+- [`all_components()`](../../../reference/api#django_components.all_components) - returns a list of all created component classes.
+- [`all_registries()`](../../../reference/api#django_components.all_registries) - returns a list of all created registry instances.
+
+### Access component class
+
+You can access the owner [`Component`](../../../reference/api/#django_components.Component) class (`MyTable`) from within methods
+of the extension class (`MyExtension`) by using
+the [`component_cls`](../../../reference/api/#django_components.ExtensionComponentConfig.component_cls) attribute:
+
+```py
+class MyTable(Component):
+ class MyExtension:
+ def some_method(self):
+ print(self.component_cls)
+```
+
+Here is how the `component_cls` attribute may be used with our `ColorLogger`
+extension shown above:
+
+```python
+class ColorLoggerComponentConfig(ExtensionComponentConfig):
+ color: str
+
+ def log(self, msg: str) -> None:
+ print(f"{self.component_cls.__name__}: {msg}")
+
+
+class ColorLoggerExtension(ComponentExtension):
+ name = "color_logger"
+
+ # All `Component.ColorLogger` classes will inherit from this class.
+ ComponentConfig = ColorLoggerComponentConfig
+```
+
+### Pass slot metadata
+
+When a slot is passed to a component, it is copied, so that the original slot is not modified
+with rendering metadata.
+
+Therefore, don't use slot's identity to associate metadata with the slot:
+
+```py
+# ❌ Don't do this:
+slots_cache = {}
+
+class LoggerExtension(ComponentExtension):
+ name = "logger"
+
+ def on_component_input(self, ctx: OnComponentInputContext):
+ for slot in ctx.component.slots.values():
+ slots_cache[id(slot)] = {"some": "metadata"}
+```
+
+Instead, use the [`Slot.extra`](../../../reference/api#django_components.Slot.extra) attribute,
+which is copied from the original slot:
+
+```python
+# ✅ Do this:
+class LoggerExtension(ComponentExtension):
+ name = "logger"
+
+ # Save component-level logger settings for each slot when a component is rendered.
+ def on_component_input(self, ctx: OnComponentInputContext):
+ for slot in ctx.component.slots.values():
+ slot.extra["logger"] = ctx.component.logger
+
+ # Then, when a fill is rendered with `{% slot %}`, we can access the logger settings
+ # from the slot's metadata.
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext):
+ logger = ctx.slot.extra["logger"]
+ logger.log(...)
+```
+
+## Extension commands
+
+Extensions in django-components can define custom commands that can be executed via the Django management command interface. This allows for powerful automation and customization capabilities.
+
+For example, if you have an extension that defines a command that prints "Hello world", you can run the command with:
+
+```bash
+python manage.py components ext run my_ext hello
+```
+
+Where:
+
+- `python manage.py components` - is the Django entrypoint
+- `ext run` - is the subcommand to run extension commands
+- `my_ext` - is the extension name
+- `hello` - is the command name
+
+### Define commands
+
+To define a command, subclass from [`ComponentCommand`](../../../reference/extension_commands#django_components.ComponentCommand).
+This subclass should define:
+
+- `name` - the command's name
+- `help` - the command's help text
+- `handle` - the logic to execute when the command is run
+
+```python
+from django_components import ComponentCommand, ComponentExtension
+
+class HelloCommand(ComponentCommand):
+ name = "hello"
+ help = "Say hello"
+
+ def handle(self, *args, **kwargs):
+ print("Hello, world!")
+
+class MyExt(ComponentExtension):
+ name = "my_ext"
+ commands = [HelloCommand]
+```
+
+### Define arguments and options
+
+Commands can accept positional arguments and options (e.g. `--foo`), which are defined using the
+[`arguments`](../../../reference/extension_commands#django_components.ComponentCommand.arguments)
+attribute of the [`ComponentCommand`](../../../reference/extension_commands#django_components.ComponentCommand) class.
+
+The arguments are parsed with [`argparse`](https://docs.python.org/3/library/argparse.html)
+into a dictionary of arguments and options. These are then available
+as keyword arguments to the [`handle`](../../../reference/extension_commands#django_components.ComponentCommand.handle)
+method of the command.
+
+```python
+from django_components import CommandArg, ComponentCommand, ComponentExtension
+
+class HelloCommand(ComponentCommand):
+ name = "hello"
+ help = "Say hello"
+
+ arguments = [
+ # Positional argument
+ CommandArg(
+ name_or_flags="name",
+ help="The name to say hello to",
+ ),
+ # Optional argument
+ CommandArg(
+ name_or_flags=["--shout", "-s"],
+ action="store_true",
+ help="Shout the hello",
+ ),
+ ]
+
+ def handle(self, name: str, *args, **kwargs):
+ shout = kwargs.get("shout", False)
+ msg = f"Hello, {name}!"
+ if shout:
+ msg = msg.upper()
+ print(msg)
+```
+
+You can run the command with arguments and options:
+
+```bash
+python manage.py components ext run my_ext hello John --shout
+>>> HELLO, JOHN!
+```
+
+!!! note
+
+ Command definitions are parsed with `argparse`, so you can use all the features of `argparse` to define your arguments and options.
+
+ See the [argparse documentation](https://docs.python.org/3/library/argparse.html) for more information.
+
+ django-components defines types as
+ [`CommandArg`](../../../reference/extension_commands#django_components.CommandArg),
+ [`CommandArgGroup`](../../../reference/extension_commands#django_components.CommandArgGroup),
+ [`CommandSubcommand`](../../../reference/extension_commands#django_components.CommandSubcommand),
+ and [`CommandParserInput`](../../../reference/extension_commands#django_components.CommandParserInput)
+ to help with type checking.
+
+!!! note
+
+ If a command doesn't have the [`handle`](../../../reference/extension_commands#django_components.ComponentCommand.handle)
+ method defined, the command will print a help message and exit.
+
+### Argument groups
+
+Arguments can be grouped using [`CommandArgGroup`](../../../reference/extension_commands#django_components.CommandArgGroup)
+to provide better organization and help messages.
+
+Read more on [argparse argument groups](https://docs.python.org/3/library/argparse.html#argument-groups).
+
+```python
+from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
+
+class HelloCommand(ComponentCommand):
+ name = "hello"
+ help = "Say hello"
+
+ # Argument parsing is managed by `argparse`.
+ arguments = [
+ # Positional argument
+ CommandArg(
+ name_or_flags="name",
+ help="The name to say hello to",
+ ),
+ # Optional argument
+ CommandArg(
+ name_or_flags=["--shout", "-s"],
+ action="store_true",
+ help="Shout the hello",
+ ),
+ # When printing the command help message, `--bar` and `--baz`
+ # will be grouped under "group bar".
+ CommandArgGroup(
+ title="group bar",
+ description="Group description.",
+ arguments=[
+ CommandArg(
+ name_or_flags="--bar",
+ help="Bar description.",
+ ),
+ CommandArg(
+ name_or_flags="--baz",
+ help="Baz description.",
+ ),
+ ],
+ ),
+ ]
+
+ def handle(self, name: str, *args, **kwargs):
+ shout = kwargs.get("shout", False)
+ msg = f"Hello, {name}!"
+ if shout:
+ msg = msg.upper()
+ print(msg)
+```
+
+### Subcommands
+
+Extensions can define subcommands, allowing for more complex command structures.
+
+Subcommands are defined similarly to root commands, as subclasses of
+[`ComponentCommand`](../../../reference/extension_commands#django_components.ComponentCommand) class.
+
+However, instead of defining the subcommands in the
+[`commands`](../../../reference/extension_commands#django_components.ComponentExtension.commands)
+attribute of the extension, you define them in the
+[`subcommands`](../../../reference/extension_commands#django_components.ComponentCommand.subcommands)
+attribute of the parent command:
+
+```python
+from django_components import CommandArg, CommandArgGroup, ComponentCommand, ComponentExtension
+
+class ChildCommand(ComponentCommand):
+ name = "child"
+ help = "Child command"
+
+ def handle(self, *args, **kwargs):
+ print("Child command")
+
+class ParentCommand(ComponentCommand):
+ name = "parent"
+ help = "Parent command"
+ subcommands = [
+ ChildCommand,
+ ]
+
+ def handle(self, *args, **kwargs):
+ print("Parent command")
+```
+
+In this example, we can run two commands.
+
+Either the parent command:
+
+```bash
+python manage.py components ext run parent
+>>> Parent command
+```
+
+Or the child command:
+
+```bash
+python manage.py components ext run parent child
+>>> Child command
+```
+
+!!! warning
+
+ Subcommands are independent of the parent command. When a subcommand runs, the parent command is NOT executed.
+
+ As such, if you want to pass arguments to both the parent and child commands, e.g.:
+
+ ```bash
+ python manage.py components ext run parent --foo child --bar
+ ```
+
+ You should instead pass all the arguments to the subcommand:
+
+ ```bash
+ python manage.py components ext run parent child --foo --bar
+ ```
+
+### Help message
+
+By default, all commands will print their help message when run with the `--help` / `-h` flag.
+
+```bash
+python manage.py components ext run my_ext --help
+```
+
+The help message prints out all the arguments and options available for the command, as well as any subcommands.
+
+### Testing commands
+
+Commands can be tested using Django's [`call_command()`](https://docs.djangoproject.com/en/5.2/ref/django-admin/#running-management-commands-from-your-code)
+function, which allows you to simulate running the command in tests.
+
+```python
+from django.core.management import call_command
+
+call_command('components', 'ext', 'run', 'my_ext', 'hello', '--name', 'John')
+```
+
+To capture the output of the command, you can use the [`StringIO`](https://docs.python.org/3/library/io.html#io.StringIO)
+module to redirect the output to a string:
+
+```python
+from io import StringIO
+
+out = StringIO()
+with patch("sys.stdout", new=out):
+ call_command('components', 'ext', 'run', 'my_ext', 'hello', '--name', 'John')
+output = out.getvalue()
+```
+
+And to temporarily set the extensions, you can use the [`@djc_test`](../../../reference/testing_api#djc_test) decorator.
+
+Thus, a full test example can then look like this:
+
+```python
+from io import StringIO
+from unittest.mock import patch
+
+from django.core.management import call_command
+from django_components.testing import djc_test
+
+@djc_test(
+ components_settings={
+ "extensions": [
+ "my_app.extensions.MyExtension",
+ ],
+ },
+)
+def test_hello_command(self):
+ out = StringIO()
+ with patch("sys.stdout", new=out):
+ call_command('components', 'ext', 'run', 'my_ext', 'hello', '--name', 'John')
+ output = out.getvalue()
+ assert output == "Hello, John!\n"
+```
+
+## Extension URLs
+
+Extensions can define custom views and endpoints that can be accessed through the Django application.
+
+To define URLs for an extension, set them in the [`urls`](../../../reference/api#django_components.ComponentExtension.urls) attribute of your [`ComponentExtension`](../../../reference/api#django_components.ComponentExtension) class. Each URL is defined using the [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) class, which specifies the path, handler, and optional name for the route.
+
+Here's an example of how to define URLs within an extension:
+
+```python
+from django_components.extension import ComponentExtension, URLRoute
+from django.http import HttpResponse
+
+def my_view(request):
+ return HttpResponse("Hello from my extension!")
+
+class MyExtension(ComponentExtension):
+ name = "my_extension"
+
+ urls = [
+ URLRoute(path="my-view/", handler=my_view, name="my_view"),
+ URLRoute(path="another-view//", handler=my_view, name="another_view"),
+ ]
+```
+
+!!! warning
+
+ The [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) objects
+ are different from objects created with Django's
+ [`django.urls.path()`](https://docs.djangoproject.com/en/5.2/ref/urls/#path).
+ Do NOT use `URLRoute` objects in Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/#example)
+ and vice versa!
+
+ django-components uses a custom [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) class to define framework-agnostic routing rules.
+
+ As of v0.131, `URLRoute` objects are directly converted to Django's `URLPattern` and `URLResolver` objects.
+
+### URL paths
+
+The URLs defined in an extension are available under the path
+
+```
+/components/ext//
+```
+
+For example, if you have defined a URL with the path `my-view//` in an extension named `my_extension`, it can be accessed at:
+
+```
+/components/ext/my_extension/my-view/john/
+```
+
+### Nested URLs
+
+Extensions can also define nested URLs to allow for more complex routing structures.
+
+To define nested URLs, set the [`children`](../../../reference/extension_urls#django_components.URLRoute.children)
+attribute of the [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) object to
+a list of child [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) objects:
+
+```python
+class MyExtension(ComponentExtension):
+ name = "my_extension"
+
+ urls = [
+ URLRoute(
+ path="parent/",
+ name="parent_view",
+ children=[
+ URLRoute(path="child//", handler=my_view, name="child_view"),
+ ],
+ ),
+ ]
+```
+
+In this example, the URL
+
+```
+/components/ext/my_extension/parent/child/john/
+```
+
+would call the `my_view` handler with the parameter `name` set to `"John"`.
+
+### Extra URL data
+
+The [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) class is framework-agnostic,
+so that extensions could be used with non-Django frameworks in the future.
+
+However, that means that there may be some extra fields that Django's
+[`django.urls.path()`](https://docs.djangoproject.com/en/5.2/ref/urls/#path)
+accepts, but which are not defined on the `URLRoute` object.
+
+To address this, the [`URLRoute`](../../../reference/extension_urls#django_components.URLRoute) object has
+an [`extra`](../../../reference/extension_urls#django_components.URLRoute.extra) attribute,
+which is a dictionary that can be used to pass any extra kwargs to `django.urls.path()`:
+
+```python
+URLRoute(
+ path="my-view//",
+ handler=my_view,
+ name="my_view",
+ extra={"kwargs": {"foo": "bar"} },
+)
+```
+
+Is the same as:
+
+```python
+django.urls.path(
+ "my-view//",
+ view=my_view,
+ name="my_view",
+ kwargs={"foo": "bar"},
+)
+```
+
+because `URLRoute` is converted to Django's route like so:
+
+```python
+django.urls.path(
+ route.path,
+ view=route.handler,
+ name=route.name,
+ **route.extra,
+)
+```
diff --git a/docs/concepts/advanced/hooks.md b/docs/concepts/advanced/hooks.md
index 13574763..269134cb 100644
--- a/docs/concepts/advanced/hooks.md
+++ b/docs/concepts/advanced/hooks.md
@@ -1,70 +1,340 @@
----
-title: Lifecycle hooks
-weight: 4
----
-
_New in version 0.96_
-Component hooks are functions that allow you to intercept the rendering process at specific positions.
+Intercept the rendering lifecycle with Component hooks.
+
+Unlike the [extension hooks](../../../reference/extension_hooks/), these are defined directly
+on the [`Component`](../../../reference/api#django_components.Component) class.
## Available hooks
-- `on_render_before`
+### `on_render_before`
- ```py
- def on_render_before(
- self: Component,
- context: Context,
- template: Template
- ) -> None:
- ```
+```py
+def on_render_before(
+ self: Component,
+ context: Context,
+ template: Optional[Template],
+) -> None:
+```
- Hook that runs just before the component's template is rendered.
+[`Component.on_render_before`](../../../reference/api#django_components.Component.on_render_before) runs just before the component's template is rendered.
- You can use this hook to access or modify the context or the template:
+It is called for every component, including nested ones, as part of
+the component render lifecycle.
- ```py
- def on_render_before(self, context, template) -> None:
- # Insert value into the Context
- context["from_on_before"] = ":)"
+It receives the [Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
+and the [Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
+as arguments.
- # Append text into the Template
- template.nodelist.append(TextNode("FROM_ON_BEFORE"))
- ```
+The `template` argument is `None` if the component has no template.
-- `on_render_after`
+**Example:**
- ```py
- def on_render_after(
- self: Component,
- context: Context,
- template: Template,
- content: str
- ) -> None | str | SafeString:
- ```
+You can use this hook to access the context or the template:
- Hook that runs just after the component's template was rendered.
- It receives the rendered output as the last argument.
+```py
+from django.template import Context, Template
+from django_components import Component
- You can use this hook to access the context or the template, but modifying
- them won't have any effect.
+class MyTable(Component):
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ # Insert value into the Context
+ context["from_on_before"] = ":)"
- To override the content that gets rendered, you can return a string or SafeString from this hook:
+ assert isinstance(template, Template)
+```
- ```py
- def on_render_after(self, context, template, content):
- # Prepend text to the rendered content
- return "Chocolate cookie recipe: " + content
- ```
+!!! warning
-## Component hooks example
+ If you want to pass data to the template, prefer using
+ [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
+ instead of this hook.
+
+!!! warning
+
+ Do NOT modify the template in this hook. The template is reused across renders.
+
+ Since this hook is called for every component, this means that the template would be modified
+ every time a component is rendered.
+
+### `on_render`
+
+_New in version 0.140_
+
+```py
+def on_render(
+ self: Component,
+ context: Context,
+ template: Optional[Template],
+) -> Union[str, SafeString, OnRenderGenerator, None]:
+```
+
+[`Component.on_render`](../../../reference/api#django_components.Component.on_render) does the actual rendering.
+
+You can override this method to:
+
+- Change what template gets rendered
+- Modify the context
+- Modify the rendered output after it has been rendered
+- Handle errors
+
+The default implementation renders the component's
+[Template](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
+with the given
+[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context).
+
+```py
+class MyTable(Component):
+ def on_render(self, context, template):
+ if template is None:
+ return None
+ else:
+ return template.render(context)
+```
+
+The `template` argument is `None` if the component has no template.
+
+#### Modifying rendered template
+
+To change what gets rendered, you can:
+
+- Render a different template
+- Render a component
+- Return a different string or SafeString
+
+```py
+class MyTable(Component):
+ def on_render(self, context, template):
+ return "Hello"
+```
+
+You can also use [`on_render()`](../../../reference/api#django_components.Component.on_render) as a router,
+rendering other components based on the parent component's arguments:
+
+```py
+class MyTable(Component):
+ def on_render(self, context, template):
+ # Select different component based on `feature_new_table` kwarg
+ if self.kwargs.get("feature_new_table"):
+ comp_cls = NewTable
+ else:
+ comp_cls = OldTable
+
+ # Render the selected component
+ return comp_cls.render(
+ args=self.args,
+ kwargs=self.kwargs,
+ slots=self.slots,
+ context=context,
+ )
+```
+
+#### Post-processing rendered template
+
+When you render the original template in [`on_render()`](../../../reference/api#django_components.Component.on_render) as:
+
+```py
+template.render(context)
+```
+
+The result is NOT the final output, but an intermediate result. Nested components
+are not rendered yet.
+
+Instead, django-components needs to take this result and process it
+to actually render the child components.
+
+To access the final output, you can `yield` the result instead of returning it.
+
+This will return a tuple of (rendered HTML, error). The error is `None` if the rendering succeeded.
+
+```py
+class MyTable(Component):
+ def on_render(self, context, template):
+ html, error = yield template.render(context)
+
+ if error is None:
+ # The rendering succeeded
+ return html
+ else:
+ # The rendering failed
+ print(f"Error: {error}")
+```
+
+At this point you can do 3 things:
+
+1. Return a new HTML
+
+ The new HTML will be used as the final output.
+
+ If the original template raised an error, it will be ignored.
+
+ ```py
+ class MyTable(Component):
+ def on_render(self, context, template):
+ html, error = yield template.render(context)
+
+ return "NEW HTML"
+ ```
+
+2. Raise a new exception
+
+ The new exception is what will bubble up from the component.
+
+ The original HTML and original error will be ignored.
+
+ ```py
+ class MyTable(Component):
+ def on_render(self, context, template):
+ html, error = yield template.render(context)
+
+ raise Exception("Error message")
+ ```
+
+3. Return nothing (or `None`) to handle the result as usual
+
+ If you don't raise an exception, and neither return a new HTML,
+ then original HTML / error will be used:
+
+ - If rendering succeeded, the original HTML will be used as the final output.
+ - If rendering failed, the original error will be propagated.
+
+ ```py
+ class MyTable(Component):
+ def on_render(self, context, template):
+ html, error = yield template.render(context)
+
+ if error is not None:
+ # The rendering failed
+ print(f"Error: {error}")
+ ```
+
+#### Example: ErrorBoundary
+
+[`on_render()`](../../../reference/api#django_components.Component.on_render) can be used to
+implement React's [ErrorBoundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary).
+
+That is, a component that catches errors in nested components and displays a fallback UI instead:
+
+```django
+{% component "error_boundary" %}
+ {% fill "content" %}
+ {% component "nested_component" %}
+ {% endfill %}
+ {% fill "fallback" %}
+ Sorry, something went wrong.
+ {% endfill %}
+{% endcomponent %}
+```
+
+To implement this, we render the fallback slot in [`on_render()`](../../../reference/api#django_components.Component.on_render)
+and return it if an error occured:
+
+```djc_py
+class ErrorFallback(Component):
+ template = """
+ {% slot "content" default / %}
+ """
+
+ def on_render(self, context, template):
+ fallback = self.slots.fallback
+
+ if fallback is None:
+ raise ValueError("fallback slot is required")
+
+ html, error = yield template.render(context)
+
+ if error is not None:
+ return fallback()
+ else:
+ return html
+```
+
+### `on_render_after`
+
+```py
+def on_render_after(
+ self: Component,
+ context: Context,
+ template: Optional[Template],
+ result: Optional[str | SafeString],
+ error: Optional[Exception],
+) -> Union[str, SafeString, None]:
+```
+
+[`on_render_after()`](../../../reference/api#django_components.Component.on_render_after) runs when the component was fully rendered,
+including all its children.
+
+It receives the same arguments as [`on_render_before()`](#on_render_before),
+plus the outcome of the rendering:
+
+- `result`: The rendered output of the component. `None` if the rendering failed.
+- `error`: The error that occurred during the rendering, or `None` if the rendering succeeded.
+
+[`on_render_after()`](../../../reference/api#django_components.Component.on_render_after) behaves the same way
+as the second part of [`on_render()`](#on_render) (after the `yield`).
+
+```py
+class MyTable(Component):
+ def on_render_after(self, context, template, result, error):
+ if error is None:
+ # The rendering succeeded
+ return result
+ else:
+ # The rendering failed
+ print(f"Error: {error}")
+```
+
+Same as [`on_render()`](#on_render),
+you can return a new HTML, raise a new exception, or return nothing:
+
+1. Return a new HTML
+
+ The new HTML will be used as the final output.
+
+ If the original template raised an error, it will be ignored.
+
+ ```py
+ class MyTable(Component):
+ def on_render_after(self, context, template, result, error):
+ return "NEW HTML"
+ ```
+
+2. Raise a new exception
+
+ The new exception is what will bubble up from the component.
+
+ The original HTML and original error will be ignored.
+
+ ```py
+ class MyTable(Component):
+ def on_render_after(self, context, template, result, error):
+ raise Exception("Error message")
+ ```
+
+3. Return nothing (or `None`) to handle the result as usual
+
+ If you don't raise an exception, and neither return a new HTML,
+ then original HTML / error will be used:
+
+ - If rendering succeeded, the original HTML will be used as the final output.
+ - If rendering failed, the original error will be propagated.
+
+ ```py
+ class MyTable(Component):
+ def on_render_after(self, context, template, result, error):
+ if error is not None:
+ # The rendering failed
+ print(f"Error: {error}")
+ ```
+
+## Example
You can use hooks together with [provide / inject](#how-to-use-provide--inject) to create components
that accept a list of items via a slot.
In the example below, each `tab_item` component will be rendered on a separate tab page, but they are all defined in the default slot of the `tabs` component.
-[See here for how it was done](https://github.com/EmilStenstrom/django-components/discussions/540)
+[See here for how it was done](https://github.com/django-components/django-components/discussions/540)
```django
{% component "tabs" %}
diff --git a/docs/concepts/advanced/html_tragments.md b/docs/concepts/advanced/html_fragments.md
similarity index 72%
rename from docs/concepts/advanced/html_tragments.md
rename to docs/concepts/advanced/html_fragments.md
index 1b93c167..3ac8ea4b 100644
--- a/docs/concepts/advanced/html_tragments.md
+++ b/docs/concepts/advanced/html_fragments.md
@@ -1,13 +1,7 @@
----
-title: HTML fragments
-weight: 2
----
+Django-components provides a seamless integration with HTML fragments with AJAX ([HTML over the wire](https://hotwired.dev/)),
+whether you're using jQuery, HTMX, AlpineJS, vanilla JavaScript, or other.
-Django-components provides a seamless integration with HTML fragments ([HTML over the wire](https://hotwired.dev/)),
-whether you're using HTMX, AlpineJS, or vanilla JavaScript.
-
-When you define a component that has extra JS or CSS, and you use django-components
-to render the fragment, django-components will:
+If the fragment component has any JS or CSS, django-components will:
- Automatically load the associated JS and CSS
- Ensure that JS is loaded and executed only once even if the fragment is inserted multiple times
@@ -27,19 +21,23 @@ to render the fragment, django-components will:
4. A library like HTMX, AlpineJS, or custom function inserts the new HTML into
the correct place.
-## Document and fragment types
+## Document and fragment strategies
-Components support two modes of rendering - As a "document" or as a "fragment".
+Components support different ["strategies"](../../advanced/rendering_js_css#dependencies-strategies)
+for rendering JS and CSS.
+
+Two of them are used to enable HTML fragments - ["document"](../../advanced/rendering_js_css#document)
+and ["fragment"](../../advanced/rendering_js_css#fragment).
What's the difference?
-### Document mode
+### Document strategy
-Document mode assumes that the rendered components will be embedded into the HTML
+Document strategy assumes that the rendered components will be embedded into the HTML
of the initial page load. This means that:
- The JS and CSS is embedded into the HTML as `
+
+ ```
+
+- Components' secondary JS and CSS scripts
+ ([`Component.Media`](../../../reference/api/#django_components.Component.Media)) - inserted as links:
+
+ ```html
+
+
+ ```
+
+- A JS script is injected to manage component dependencies, enabling lazy loading of JS and CSS
+ for HTML fragments.
+
+!!! info
+
+ This strategy is required for fragments to work properly, as it sets up the dependency manager that fragments rely on.
+
+!!! note "How the dependency manager works"
+
+ The dependency manager is a JS script that keeps track of all the JS and CSS dependencies that have already been loaded.
+
+ When a fragment is inserted into the page, it will also insert a JSON `
+
+ ```
+
+- Components' secondary JS and CSS scripts
+ ([`Component.Media`](../../../reference/api/#django_components.Component.Media)) - inserted as links:
+
+ ```html
+
+
+ ```
+
+- No extra scripts are inserted.
+
+### `prepend`
+
+This is the same as [`"simple"`](#simple), but placeholders like [`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies) and HTML tags `` and `` are all ignored. The JS and CSS are **always** inserted **before** the rendered content.
+
+```python
+html = MyComponent.render(deps_strategy="prepend")
+```
+
+**Location:**
+
+JS and CSS is **always** inserted before the rendered content.
+
+**Included scripts:**
+
+Same as for the [`"simple"`](#simple) strategy.
+
+### `append`
+
+This is the same as [`"simple"`](#simple), but placeholders like [`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies) and HTML tags `` and `` are all ignored. The JS and CSS are **always** inserted **after** the rendered content.
+
+```python
+html = MyComponent.render(deps_strategy="append")
+```
+
+**Location:**
+
+JS and CSS is **always** inserted after the rendered content.
+
+**Included scripts:**
+
+Same as for the [`"simple"`](#simple) strategy.
+
+### `ignore`
+
+`deps_strategy="ignore"` is used when you do NOT want to process JS and CSS of the rendered HTML.
+
+```python
+html = MyComponent.render(deps_strategy="ignore")
+```
+
+The rendered HTML is left as-is. You can still process it with a different strategy later with `render_dependencies()`.
+
+This is useful when you want to insert rendered HTML into another component.
+
+```python
+html = MyComponent.render(deps_strategy="ignore")
+html = AnotherComponent.render(slots={"content": html})
+```
+
+## Manually rendering JS / CSS
+
+When rendering templates or components, django-components covers all the traditional ways how components
+or templates can be rendered:
+
+- [`Component.render()`](../../../reference/api/#django_components.Component.render)
+- [`Component.render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
+- [`Template.render()`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template.render)
+- [`django.shortcuts.render()`](https://docs.djangoproject.com/en/5.2/topics/http/shortcuts/#render)
+
+This way you don't need to manually handle rendering of JS / CSS.
+
+However, for advanced or low-level use cases, you may need to control when to render JS / CSS.
+
+In such case you can directly pass rendered HTML to [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies).
+
+This function will extract all used components in the HTML string, and insert the components' JS and CSS
+based on given strategy.
+
+!!! info
+
+ The truth is that all the methods listed above call [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies)
+ internally.
+
+**Example:**
+
+To see how [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies) works,
+let's render a template with a component.
+
+We will render it twice:
+
+- First time, we let `template.render()` handle the rendering.
+- Second time, we prevent `template.render()` from inserting the component's JS and CSS with `deps_strategy="ignore"`.
+
+ Instead, we pass the "unprocessed" HTML to `render_dependencies()` ourselves to insert the component's JS and CSS.
```python
from django.template.base import Template
from django.template.context import Context
-from django_component import render_dependencies
+from django_components import render_dependencies
template = Template("""
{% load component_tags %}
@@ -143,85 +402,30 @@ template = Template("""
""")
-rendered = template.render(Context())
-rendered = render_dependencies(rendered)
+rendered = template.render(Context({}))
+
+rendered2_raw = template.render(Context({"DJC_DEPS_STRATEGY": "ignore"}))
+rendered2 = render_dependencies(rendered2_raw)
+
+assert rendered == rendered2
```
-Same applies if you render a template using Django's [`django.shortcuts.render`](https://docs.djangoproject.com/en/5.1/topics/http/shortcuts/#render):
+Same applies to other strategies and other methods of rendering:
```python
-from django.shortcuts import render
+raw_html = MyComponent.render(deps_strategy="ignore")
+html = render_dependencies(raw_html, deps_strategy="document")
-def my_view(request):
- rendered = render(request, "pages/home.html")
- rendered = render_dependencies(rendered)
- return rendered
+html2 = MyComponent.render(deps_strategy="document")
+
+assert html == html2
```
-Alternatively, when you render HTML with [`Component.render()`](#TODO)
-or [`Component.render_to_response()`](#TODO),
-these, by default, call [`render_dependencies()`](#TODO) for you, so you don't have to:
+## HTML fragments
-```python
-from django_components import Component
+Django-components provides a seamless integration with HTML fragments with AJAX ([HTML over the wire](https://hotwired.dev/)),
+whether you're using jQuery, HTMX, AlpineJS, vanilla JavaScript, or other.
-class MyButton(Component):
- ...
+This is achieved by the combination of the [`"document"`](#document) and [`"fragment"`](#fragment) strategies.
-# 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()`](#TODO) does NOT need to be called
-when you render a component via [`Component.render()`](#TODO).
-
-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`](#TODO) to the nested components:
-
-```python
-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?
-
-This is a technical limitation of the current implementation.
-
-As mentioned earlier, each time we call [`Component.render()`](#TODO),
-we also call [`render_dependencies()`](#TODO).
-
-However, there is a problem here - When we call [`render_dependencies()`](#TODO)
-inside [`CardActions.render()`](#TODO),
-we extract and REMOVE the info on components' JS and CSS from the HTML. But the template
-of `CardActions` contains no `{% component_depedencies %}` tags, and nor `` nor `` 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`](#TODO) when rendering pieces of HTML
-with [`Component.render()`](#TODO) and inserting them into larger structures.
-
-#### Summary
-
-1. Every time you render HTML that contained components, you have to call [`render_dependencies()`](#TODO)
- on the rendered output.
-2. There are several ways to call [`render_dependencies()`](#TODO):
- - Using the [`ComponentDependencyMiddleware`](#TODO) middleware
- - Rendering the HTML by calling [`Component.render()`](#TODO) with `render_dependencies=True` (default)
- - Rendering the HTML by calling [`Component.render_to_response()`](#TODO) (always renders dependencies)
- - Directly passing rendered HTML to [`render_dependencies()`](#TODO)
-3. If you pre-render one component to pass it into another, the pre-rendered component must be rendered with
- [`render_dependencies=False`](#TODO).
+Read more about [HTML fragments](../../advanced/html_fragments).
diff --git a/docs/concepts/advanced/tag_formatter.md b/docs/concepts/advanced/tag_formatters.md
similarity index 99%
rename from docs/concepts/advanced/tag_formatter.md
rename to docs/concepts/advanced/tag_formatters.md
index a5af24d6..c0d363f5 100644
--- a/docs/concepts/advanced/tag_formatter.md
+++ b/docs/concepts/advanced/tag_formatters.md
@@ -1,8 +1,3 @@
----
-title: Tag formatters
-weight: 7
----
-
## Customizing component tags with TagFormatter
_New in version 0.89_
diff --git a/docs/concepts/advanced/template_tags.md b/docs/concepts/advanced/template_tags.md
new file mode 100644
index 00000000..ff40181c
--- /dev/null
+++ b/docs/concepts/advanced/template_tags.md
@@ -0,0 +1,196 @@
+Template tags introduced by django-components, such as `{% component %}` and `{% slot %}`,
+offer additional features over the default Django template tags:
+
+
+- [Self-closing tags `{% mytag / %}`](../../fundamentals/template_tag_syntax#self-closing-tags)
+- [Allowing the use of `:`, `-` (and more) in keys](../../fundamentals/template_tag_syntax#special-characters)
+- [Spread operator `...`](../../fundamentals/template_tag_syntax#spread-operator)
+- [Using template tags as inputs to other template tags](../../fundamentals/template_tag_syntax#use-template-tags-inside-component-inputs)
+- [Flat definition of dictionaries `attr:key=val`](../../fundamentals/template_tag_syntax#pass-dictonary-by-its-key-value-pairs)
+- Function-like input validation
+
+You too can easily create custom template tags that use the above features.
+
+## Defining template tags with `@template_tag`
+
+The simplest way to create a custom template tag is using
+the [`template_tag`](../../../reference/api#django_components.template_tag) decorator.
+This decorator allows you to define a template tag by just writing a function that returns the rendered content.
+
+```python
+from django.template import Context, Library
+from django_components import BaseNode, template_tag
+
+library = Library()
+
+@template_tag(
+ library,
+ tag="mytag",
+ end_tag="endmytag",
+ allowed_flags=["required"]
+)
+def mytag(node: BaseNode, context: Context, name: str, **kwargs) -> str:
+ return f"Hello, {name}!"
+```
+
+This will allow you to use the tag in your templates like this:
+
+```django
+{% mytag name="John" %}
+{% endmytag %}
+
+{# or with self-closing syntax #}
+{% mytag name="John" / %}
+
+{# or with flags #}
+{% mytag name="John" required %}
+{% endmytag %}
+```
+
+### Parameters
+
+The [`@template_tag`](../../../reference/api#django_components.template_tag) decorator accepts the following parameters:
+
+- `library`: The Django template library to register the tag with
+- `tag`: The name of the template tag (e.g. `"mytag"` for `{% mytag %}`)
+- `end_tag`: Optional. The name of the end tag (e.g. `"endmytag"` for `{% endmytag %}`)
+- `allowed_flags`: Optional. List of flags that can be used with the tag (e.g. `["required"]` for `{% mytag required %}`)
+
+### Function signature
+
+The function decorated with [`@template_tag`](../../../reference/api#django_components.template_tag)
+must accept at least two arguments:
+
+1. `node`: The node instance (we'll explain this in detail in the next section)
+2. `context`: The Django template context
+
+Any additional parameters in your function's signature define what inputs your template tag accepts. For example:
+
+```python
+@template_tag(library, tag="greet")
+def greet(
+ node: BaseNode,
+ context: Context,
+ name: str, # required positional argument
+ count: int = 1, # optional positional argument
+ *, # keyword-only arguments marker
+ msg: str, # required keyword argument
+ mode: str = "default", # optional keyword argument
+) -> str:
+ return f"{msg}, {name}!" * count
+```
+
+This allows the tag to be used like:
+
+```django
+{# All parameters #}
+{% greet "John" count=2 msg="Hello" mode="custom" %}
+
+{# Only required parameters #}
+{% greet "John" msg="Hello" %}
+
+{# Missing required parameter - will raise error #}
+{% greet "John" %} {# Error: missing 'msg' #}
+```
+
+When you pass input to a template tag, it behaves the same way as if you passed the input to a function:
+
+- If required parameters are missing, an error is raised
+- If unexpected parameters are passed, an error is raised
+
+To accept keys that are not valid Python identifiers (e.g. `data-id`), or would conflict with Python keywords (e.g. `is`), you can use the `**kwargs` syntax:
+
+```python
+@template_tag(library, tag="greet")
+def greet(
+ node: BaseNode,
+ context: Context,
+ **kwargs,
+) -> str:
+ attrs = kwargs.copy()
+ is_var = attrs.pop("is", None)
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
+
+ return mark_safe(f"""
+
+ Hello, {is_var}!
+
+ """)
+```
+
+This allows you to use the tag like this:
+
+```django
+{% greet is="John" data-id="123" %}
+```
+
+## Defining template tags with `BaseNode`
+
+For more control over your template tag, you can subclass [`BaseNode`](../../../reference/api#django_components.BaseNode) directly instead of using the decorator. This gives you access to additional features like the node's internal state and parsing details.
+
+```python
+from django_components import BaseNode
+
+class GreetNode(BaseNode):
+ tag = "greet"
+ end_tag = "endgreet"
+ allowed_flags = ["required"]
+
+ def render(self, context: Context, name: str, **kwargs) -> str:
+ # Access node properties
+ if self.flags["required"]:
+ return f"Required greeting: Hello, {name}!"
+ return f"Hello, {name}!"
+
+# Register the node
+GreetNode.register(library)
+```
+
+### Node properties
+
+When using [`BaseNode`](../../../reference/api#django_components.BaseNode), you have access to several useful properties:
+
+- [`node_id`](../../../reference/api#django_components.BaseNode.node_id): A unique identifier for this node instance
+- [`flags`](../../../reference/api#django_components.BaseNode.flags): Dictionary of flag values (e.g. `{"required": True}`)
+- [`params`](../../../reference/api#django_components.BaseNode.params): List of raw parameters passed to the tag
+- [`nodelist`](../../../reference/api#django_components.BaseNode.nodelist): The template nodes between the start and end tags
+- [`contents`](../../../reference/api#django_components.BaseNode.contents): The raw contents between the start and end tags
+- [`active_flags`](../../../reference/api#django_components.BaseNode.active_flags): List of flags that are currently set to True
+- [`template_name`](../../../reference/api#django_components.BaseNode.template_name): The name of the `Template` instance inside which the node was defined
+- [`template_component`](../../../reference/api#django_components.BaseNode.template_component): The component class that the `Template` belongs to
+
+This is what the `node` parameter in the [`@template_tag`](../../../reference/api#django_components.template_tag) decorator gives you access to - it's the instance of the node class that was automatically created for your template tag.
+
+### Rendering content between tags
+
+When your tag has an end tag, you can access and render the content between the tags using `nodelist`:
+
+```python
+class WrapNode(BaseNode):
+ tag = "wrap"
+ end_tag = "endwrap"
+
+ def render(self, context: Context, tag: str = "div", **attrs) -> str:
+ # Render the content between tags
+ inner = self.nodelist.render(context)
+ attrs_str = " ".join(f'{k}="{v}"' for k, v in attrs.items())
+ return f"<{tag} {attrs_str}>{inner}{tag}>"
+
+# Usage:
+{% wrap tag="section" class="content" %}
+ Hello, world!
+{% endwrap %}
+```
+
+### Unregistering nodes
+
+You can unregister a node from a library using the `unregister` method:
+
+```python
+GreetNode.unregister(library)
+```
+
+This is particularly useful in testing when you want to clean up after registering temporary tags.
diff --git a/docs/concepts/advanced/testing.md b/docs/concepts/advanced/testing.md
new file mode 100644
index 00000000..5fc15665
--- /dev/null
+++ b/docs/concepts/advanced/testing.md
@@ -0,0 +1,115 @@
+_New in version 0.131_
+
+The [`@djc_test`](../../../reference/testing_api#djc_test) decorator is a powerful tool for testing components created with `django-components`. It ensures that each test is properly isolated, preventing components registered in one test from affecting others.
+
+## Usage
+
+The [`@djc_test`](../../../reference/testing_api#djc_test) decorator can be applied to functions, methods, or classes.
+
+When applied to a class, it decorates all methods starting with `test_`, and all nested classes starting with `Test`,
+recursively.
+
+### Applying to a Function
+
+To apply [`djc_test`](../../../reference/testing_api#djc_test) to a function,
+simply decorate the function as shown below:
+
+```python
+import django
+from django_components.testing import djc_test
+
+@djc_test
+def test_my_component():
+ @register("my_component")
+ class MyComponent(Component):
+ template = "..."
+ ...
+```
+
+### Applying to a Class
+
+When applied to a class, `djc_test` decorates each `test_` method, as well as all nested classes starting with `Test`.
+
+```python
+import django
+from django_components.testing import djc_test
+
+@djc_test
+class TestMyComponent:
+ def test_something(self):
+ ...
+
+ class TestNested:
+ def test_something_else(self):
+ ...
+```
+
+This is equivalent to applying the decorator to both of the methods individually:
+
+```python
+import django
+from django_components.testing import djc_test
+
+class TestMyComponent:
+ @djc_test
+ def test_something(self):
+ ...
+
+ class TestNested:
+ @djc_test
+ def test_something_else(self):
+ ...
+```
+
+### Arguments
+
+See the API reference for [`@djc_test`](../../../reference/testing_api#djc_test) for more details.
+
+### Setting Up Django
+
+If you want to define a common Django settings that would be the baseline for all tests,
+you can call [`django.setup()`](https://docs.djangoproject.com/en/5.2/ref/applications/#django.setup)
+before the `@djc_test` decorator:
+
+```python
+import django
+from django_components.testing import djc_test
+
+django.setup(...)
+
+@djc_test
+def test_my_component():
+ ...
+```
+
+!!! info
+
+ If you omit [`django.setup()`](https://docs.djangoproject.com/en/5.2/ref/applications/#django.setup)
+ in the example above, `@djc_test` will call it for you, so you don't need to do it manually.
+
+## Example: Parametrizing Context Behavior
+
+You can parametrize the [context behavior](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior) using [`djc_test`](../../../reference/testing_api#djc_test):
+
+```python
+from django_components.testing import djc_test
+
+@djc_test(
+ # Settings applied to all cases
+ components_settings={
+ "app_dirs": ["custom_dir"],
+ },
+ # Parametrized settings
+ parametrize=(
+ ["components_settings"],
+ [
+ [{"context_behavior": "django"}],
+ [{"context_behavior": "isolated"}],
+ ],
+ ["django", "isolated"],
+ )
+)
+def test_context_behavior(components_settings):
+ rendered = MyComponent.render()
+ ...
+```
diff --git a/docs/concepts/advanced/typing_and_validation.md b/docs/concepts/advanced/typing_and_validation.md
deleted file mode 100644
index e6c0842b..00000000
--- a/docs/concepts/advanced/typing_and_validation.md
+++ /dev/null
@@ -1,178 +0,0 @@
----
-title: Typing and validation
-weight: 6
----
-
-## Adding type hints with Generics
-
-_New in version 0.92_
-
-The `Component` class optionally accepts type parameters
-that allow you to specify the types of args, kwargs, slots, and
-data:
-
-```py
-class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
- ...
-```
-
-- `Args` - Must be a `Tuple` or `Any`
-- `Kwargs` - Must be a `TypedDict` or `Any`
-- `Data` - Must be a `TypedDict` or `Any`
-- `Slots` - Must be a `TypedDict` or `Any`
-
-Here's a full example:
-
-```py
-from typing import NotRequired, Tuple, TypedDict, SlotContent, SlotFunc
-
-# Positional inputs
-Args = Tuple[int, str]
-
-# Kwargs inputs
-class Kwargs(TypedDict):
- variable: str
- another: int
- maybe_var: NotRequired[int] # May be ommited
-
-# Data returned from `get_context_data`
-class Data(TypedDict):
- variable: str
-
-# The data available to the `my_slot` scoped slot
-class MySlotData(TypedDict):
- value: int
-
-# Slots
-class Slots(TypedDict):
- # Use SlotFunc for slot functions.
- # The generic specifies the `data` dictionary
- my_slot: NotRequired[SlotFunc[MySlotData]]
- # SlotContent == Union[str, SafeString]
- another_slot: SlotContent
-
-class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
- def get_context_data(self, variable, another):
- return {
- "variable": variable,
- }
-```
-
-When you then call `Component.render` or `Component.render_to_response`, you will get type hints:
-
-```py
-Button.render(
- # Error: First arg must be `int`, got `float`
- args=(1.25, "abc"),
- # Error: Key "another" is missing
- kwargs={
- "variable": "text",
- },
-)
-```
-
-### Usage for Python <3.11
-
-On Python 3.8-3.10, use `typing_extensions`
-
-```py
-from typing_extensions import TypedDict, NotRequired
-```
-
-Additionally on Python 3.8-3.9, also import `annotations`:
-
-```py
-from __future__ import annotations
-```
-
-Moreover, on 3.10 and less, you may not be able to use `NotRequired`, and instead you will need to mark either all keys are required, or all keys as optional, using TypeDict's `total` kwarg.
-
-[See PEP-655](https://peps.python.org/pep-0655) for more info.
-
-## Passing additional args or kwargs
-
-You may have a function that supports any number of args or kwargs:
-
-```py
-def get_context_data(self, *args, **kwargs):
- ...
-```
-
-This is not supported with the typed components.
-
-As a workaround:
-
-- For `*args`, set a positional argument that accepts a list of values:
-
- ```py
- # Tuple of one member of list of strings
- Args = Tuple[List[str]]
- ```
-
-- For `*kwargs`, set a keyword argument that accepts a dictionary of values:
-
- ```py
- class Kwargs(TypedDict):
- variable: str
- another: int
- # Pass any extra keys under `extra`
- extra: Dict[str, any]
- ```
-
-## Handling no args or no kwargs
-
-To declare that a component accepts no Args, Kwargs, etc, you can use `EmptyTuple` and `EmptyDict` types:
-
-```py
-from django_components import Component, EmptyDict, EmptyTuple
-
-Args = EmptyTuple
-Kwargs = Data = Slots = EmptyDict
-
-class Button(Component[Args, Kwargs, Slots, Data, JsData, CssData]):
- ...
-```
-
-## Runtime input validation with types
-
-_New in version 0.96_
-
-> NOTE: Kwargs, slots, and data validation is supported only for Python >=3.11
-
-In Python 3.11 and later, when you specify the component types, you will get also runtime validation of the inputs you pass to `Component.render` or `Component.render_to_response`.
-
-So, using the example from before, if you ignored the type errors and still ran the following code:
-
-```py
-Button.render(
- # Error: First arg must be `int`, got `float`
- args=(1.25, "abc"),
- # Error: Key "another" is missing
- kwargs={
- "variable": "text",
- },
-)
-```
-
-This would raise a `TypeError`:
-
-```txt
-Component 'Button' expected positional argument at index 0 to be , got 1.25 of type
-```
-
-In case you need to skip these errors, you can either set the faulty member to `Any`, e.g.:
-
-```py
-# Changed `int` to `Any`
-Args = Tuple[Any, str]
-```
-
-Or you can replace `Args` with `Any` altogether, to skip the validation of args:
-
-```py
-# Replaced `Args` with `Any`
-class Button(Component[Any, Kwargs, Slots, Data, JsData, CssData]):
- ...
-```
-
-Same applies to kwargs, data, and slots.
diff --git a/docs/concepts/fundamentals/.nav.yml b/docs/concepts/fundamentals/.nav.yml
new file mode 100644
index 00000000..d88500a4
--- /dev/null
+++ b/docs/concepts/fundamentals/.nav.yml
@@ -0,0 +1,17 @@
+# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
+nav:
+ - Single-file components: single_file_components.md
+ - HTML / JS / CSS files: html_js_css_files.md
+ - HTML / JS / CSS variables: html_js_css_variables.md
+ - Secondary JS / CSS files: secondary_js_css_files.md
+ - Component defaults: component_defaults.md
+ - Render API: render_api.md
+ - Rendering components: rendering_components.md
+ - Slots: slots.md
+ - Template tag syntax: template_tag_syntax.md
+ - HTML attributes: html_attributes.md
+ - Component views and URLs: component_views_urls.md
+ - HTTP Request: http_request.md
+ - Typing and validation: typing_and_validation.md
+ - Subclassing components: subclassing_components.md
+ - Autodiscovery: autodiscovery.md
diff --git a/docs/concepts/fundamentals/access_component_input.md b/docs/concepts/fundamentals/access_component_input.md
deleted file mode 100644
index 6bba6117..00000000
--- a/docs/concepts/fundamentals/access_component_input.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-title: Accessing component inputs
-weight: 3
----
-
-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`.
-
-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.
-
-`self.input` has the same fields as the input to `Component.render`:
-
-```python
-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 isinstance(self.input.context, Context)
-
- return {
- "variable": variable,
- }
-
-rendered = TestComponent.render(
- kwargs={"variable": "test", "another": 1},
- args=(123, "str"),
- slots={"my_slot": "MY_SLOT"},
-)
-```
-
-NOTE: The slots in `self.input.slots` are normalized to slot functions.
diff --git a/docs/concepts/fundamentals/autodiscovery.md b/docs/concepts/fundamentals/autodiscovery.md
index 8ab8a188..452db11a 100644
--- a/docs/concepts/fundamentals/autodiscovery.md
+++ b/docs/concepts/fundamentals/autodiscovery.md
@@ -1,11 +1,15 @@
----
-title: Autodiscovery
-weight: 9
----
+django-components automatically searches for files containing components in the
+[`COMPONENTS.dirs`](../../../reference/settings#django_components.app_settings.ComponentsSettings.dirs) and
+[`COMPONENTS.app_dirs`](../../../reference/settings#django_components.app_settings.ComponentsSettings.app_dirs)
+directories.
-Every component that you want to use in the template with the [`{% component %}`](django_components.templateags.component_tags)
-tag needs to be registered with the [`ComponentRegistry`](django_components.component_registry.ComponentRegistry).
-Normally, we use the [`@register`](django_components.component_registry.register) decorator for that:
+### Manually register components
+
+Every component that you want to use in the template with the
+[`{% component %}`](../../../reference/template_tags#component)
+tag needs to be registered with the [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry).
+
+We use the [`@register`](../../../reference/api#django_components.register) decorator for that:
```python
from django_components import Component, register
@@ -15,7 +19,9 @@ class Calendar(Component):
...
```
-But for the component to be registered, the code needs to be executed - the file needs to be imported as a module.
+But for the component to be registered, the code needs to be executed - and for that, the file needs to be imported as a module.
+
+This is the "discovery" part of the process.
One way to do that is by importing all your components in `apps.py`:
@@ -35,22 +41,28 @@ class MyAppConfig(AppConfig):
However, there's a simpler way!
-By default, the Python files in the [`COMPONENTS.dirs`](django_components.app_settings.ComponentsSettings.dirs) directories (and app-level [`[app]/components/`](django_components.app_settings.ComponentsSettings.app_dirs)) are auto-imported in order to auto-register the components.
+### Autodiscovery
-Autodiscovery occurs when Django is loaded, during the [`AppConfig.ready`](https://docs.djangoproject.com/en/5.1/ref/applications/#django.apps.AppConfig.ready)
+By default, the Python files found in the
+[`COMPONENTS.dirs`](../../../reference/settings#django_components.app_settings.ComponentsSettings.dirs) and
+[`COMPONENTS.app_dirs`](../../../reference/settings#django_components.app_settings.ComponentsSettings.app_dirs)
+are auto-imported in order to execute the code that registers the components.
+
+Autodiscovery occurs when Django is loaded, during the [`AppConfig.ready()`](https://docs.djangoproject.com/en/5.2/ref/applications/#django.apps.AppConfig.ready)
hook of the `apps.py` file.
If you are using autodiscovery, keep a few points in mind:
-- Avoid defining any logic on the module-level inside the `components` dir, that you would not want to run anyway.
-- Components inside the auto-imported files still need to be registered with [`@register`](django_components.component_registry.register)p
+- Avoid defining any logic on the module-level inside the components directories, that you would not want to run anyway.
+- Components inside the auto-imported files still need to be registered with [`@register`](../../../reference/api#django_components.register)
- Auto-imported component files must be valid Python modules, they must use suffix `.py`, and module name should follow [PEP-8](https://peps.python.org/pep-0008/#package-and-module-names).
+- Subdirectories and files starting with an underscore `_` (except `__init__.py`) are ignored.
-Autodiscovery can be disabled in the [settings](django_components.app_settings.ComponentsSettings.autodiscovery).
+Autodiscovery can be disabled in the settings with [`autodiscover=False`](../../../reference/settings#django_components.app_settings.ComponentsSettings.autodiscover).
### Manually trigger autodiscovery
-Autodiscovery can be also triggered manually, using the [`autodiscover`](django_components.autodiscovery.autodiscover) function. This is useful if you want to run autodiscovery at a custom point of the lifecycle:
+Autodiscovery can be also triggered manually, using the [`autodiscover()`](../../../reference/api#django_components.autodiscover) function. This is useful if you want to run autodiscovery at a custom point of the lifecycle:
```python
from django_components import autodiscover
diff --git a/docs/concepts/fundamentals/component_defaults.md b/docs/concepts/fundamentals/component_defaults.md
new file mode 100644
index 00000000..a57daf45
--- /dev/null
+++ b/docs/concepts/fundamentals/component_defaults.md
@@ -0,0 +1,153 @@
+When a component is being rendered, the component inputs are passed to various methods like
+[`get_template_data()`](../../../reference/api#django_components.Component.get_template_data),
+[`get_js_data()`](../../../reference/api#django_components.Component.get_js_data),
+or [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
+
+It can be cumbersome to specify default values for each input in each method.
+
+To make things easier, Components can specify their defaults. Defaults are used when
+no value is provided, or when the value is set to `None` for a particular input.
+
+### Defining defaults
+
+To define defaults for a component, you create a nested [`Defaults`](../../../reference/api#django_components.Component.Defaults)
+class within your [`Component`](../../../reference/api#django_components.Component) class.
+Each attribute in the `Defaults` class represents a default value for a corresponding input.
+
+```py
+from django_components import Component, Default, register
+
+@register("my_table")
+class MyTable(Component):
+
+ class Defaults:
+ position = "left"
+ selected_items = Default(lambda: [1, 2, 3])
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "position": kwargs["position"],
+ "selected_items": kwargs["selected_items"],
+ }
+
+ ...
+```
+
+In this example, `position` is a simple default value, while `selected_items` uses a factory function wrapped in [`Default`](../../../reference/api#django_components.Default) to ensure a new list is created each time the default is used.
+
+Now, when we render the component, the defaults will be applied:
+
+```django
+{% component "my_table" position="right" / %}
+```
+
+In this case:
+
+- `position` input is set to `right`, so no defaults applied
+- `selected_items` is not set, so it will be set to `[1, 2, 3]`.
+
+Same applies to rendering the Component in Python with the
+[`render()`](../../../reference/api#django_components.Component.render) method:
+
+```py
+MyTable.render(
+ kwargs={
+ "position": "right",
+ "selected_items": None,
+ },
+)
+```
+
+Notice that we've set `selected_items` to `None`. `None` values are treated as missing values,
+and so `selected_items` will be set to `[1, 2, 3]`.
+
+!!! warning
+
+ The defaults are aplied only to keyword arguments. They are NOT applied to positional arguments!
+
+!!! warning
+
+ When [typing](../fundamentals/typing_and_validation.md) your components with [`Args`](../../../reference/api/#django_components.Component.Args),
+ [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+ or [`Slots`](../../../reference/api/#django_components.Component.Slots) classes,
+ you may be inclined to define the defaults in the classes.
+
+ ```py
+ class ProfileCard(Component):
+ class Kwargs(NamedTuple):
+ show_details: bool = True
+ ```
+
+ This is **NOT recommended**, because:
+
+ - The defaults will NOT be applied to inputs when using [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs) property.
+ - The defaults will NOT be applied when a field is given but set to `None`.
+
+ Instead, define the defaults in the [`Defaults`](../../../reference/api/#django_components.Component.Defaults) class.
+
+### Default factories
+
+For objects such as lists, dictionaries or other instances, you have to be careful - if you simply set a default value, this instance will be shared across all instances of the component!
+
+```py
+from django_components import Component
+
+class MyTable(Component):
+ class Defaults:
+ # All instances will share the same list!
+ selected_items = [1, 2, 3]
+```
+
+To avoid this, you can use a factory function wrapped in [`Default`](../../../reference/api#django_components.Default).
+
+```py
+from django_components import Component, Default
+
+class MyTable(Component):
+ class Defaults:
+ # A new list is created for each instance
+ selected_items = Default(lambda: [1, 2, 3])
+```
+
+This is similar to how the dataclass fields work.
+
+In fact, you can also use the dataclass's [`field`](https://docs.python.org/3/library/dataclasses.html#dataclasses.field) function to define the factories:
+
+```py
+from dataclasses import field
+from django_components import Component
+
+class MyTable(Component):
+ class Defaults:
+ selected_items = field(default_factory=lambda: [1, 2, 3])
+```
+
+### Accessing defaults
+
+Since the defaults are defined on the component class, you can access the defaults for a component with the [`Component.Defaults`](../../../reference/api#django_components.Component.Defaults) property.
+
+So if we have a component like this:
+
+```py
+from django_components import Component, Default, register
+
+@register("my_table")
+class MyTable(Component):
+
+ class Defaults:
+ position = "left"
+ selected_items = Default(lambda: [1, 2, 3])
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "position": kwargs["position"],
+ "selected_items": kwargs["selected_items"],
+ }
+```
+
+We can access individual defaults like this:
+
+```py
+print(MyTable.Defaults.position)
+print(MyTable.Defaults.selected_items)
+```
diff --git a/docs/concepts/fundamentals/component_views_urls.md b/docs/concepts/fundamentals/component_views_urls.md
new file mode 100644
index 00000000..f8398749
--- /dev/null
+++ b/docs/concepts/fundamentals/component_views_urls.md
@@ -0,0 +1,151 @@
+_New in version 0.34_
+
+_Note: Since 0.92, `Component` is no longer a subclass of Django's `View`. Instead, the nested
+[`Component.View`](../../../reference/api#django_components.Component.View) class is a subclass of Django's `View`._
+
+---
+
+For web applications, it's common to define endpoints that serve HTML content (AKA views).
+
+django-components has a suite of features that help you write and manage views and their URLs:
+
+- For each component, you can define methods for handling HTTP requests (GET, POST, etc.) - `get()`, `post()`, etc.
+
+- Use [`Component.as_view()`](../../../reference/api#django_components.Component.as_view) to be able to use your Components with Django's [`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/). This works the same way as [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view).
+
+- To avoid having to manually define the endpoints for each component, you can set the component to be "public" with [`Component.View.public = True`](../../../reference/api#django_components.ComponentView.public). This will automatically create a URL for the component. To retrieve the component URL, use [`get_component_url()`](../../../reference/api#django_components.get_component_url).
+
+- In addition, [`Component`](../../../reference/api#django_components.Component) has a [`render_to_response()`](../../../reference/api#django_components.Component.render_to_response) method that renders the component template based on the provided input and returns an `HttpResponse` object.
+
+## Define handlers
+
+Here's an example of a calendar component defined as a view. Simply define a `View` class with your custom `get()` method to handle GET requests:
+
+```djc_py title="[project root]/components/calendar.py"
+from django_components import Component, ComponentView, register
+
+@register("calendar")
+class Calendar(Component):
+ template = """
+
+
+ {% slot "header" / %}
+
+
+ Today's date is {{ date }}
+
+
+ """
+
+ class View:
+ # Handle GET requests
+ def get(self, request, *args, **kwargs):
+ # Return HttpResponse with the rendered content
+ return Calendar.render_to_response(
+ request=request,
+ kwargs={
+ "date": request.GET.get("date", "2020-06-06"),
+ },
+ slots={
+ "header": "Calendar header",
+ },
+ )
+```
+
+!!! info
+
+ The View class supports all the same HTTP methods as Django's [`View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View) class. These are:
+
+ `get()`, `post()`, `put()`, `patch()`, `delete()`, `head()`, `options()`, `trace()`
+
+ Each of these receive the [`HttpRequest`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpRequest) object as the first argument.
+
+
+
+
+!!! warning
+
+ **Deprecation warning:**
+
+ Previously, the handler methods such as `get()` and `post()` could be defined directly on the `Component` class:
+
+ ```py
+ class Calendar(Component):
+ def get(self, request, *args, **kwargs):
+ return self.render_to_response(
+ kwargs={
+ "date": request.GET.get("date", "2020-06-06"),
+ }
+ )
+ ```
+
+ This is deprecated from v0.137 onwards, and will be removed in v1.0.
+
+### Acccessing component class
+
+You can access the component class from within the View methods by using the [`View.component_cls`](../../../reference/api#django_components.ComponentView.component_cls) attribute:
+
+```py
+class Calendar(Component):
+ ...
+
+ class View:
+ def get(self, request):
+ return self.component_cls.render_to_response(request=request)
+```
+
+## Register URLs manually
+
+To register the component as a route / endpoint in Django, add an entry to your
+[`urlpatterns`](https://docs.djangoproject.com/en/5.2/topics/http/urls/).
+In place of the view function, create a view object with [`Component.as_view()`](../../../reference/api#django_components.Component.as_view):
+
+```python title="[project root]/urls.py"
+from django.urls import path
+from components.calendar.calendar import Calendar
+
+urlpatterns = [
+ path("calendar/", Calendar.as_view()),
+]
+```
+
+[`Component.as_view()`](../../../reference/api#django_components.Component.as_view)
+internally calls [`View.as_view()`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#django.views.generic.base.View.as_view), passing the component
+instance as one of the arguments.
+
+## Register URLs automatically
+
+If you don't care about the exact URL of the component, you can let django-components manage the URLs for you by setting the [`Component.View.public`](../../../reference/api#django_components.ComponentView.public) attribute to `True`:
+
+```py
+class MyComponent(Component):
+ class View:
+ public = True
+
+ def get(self, request):
+ return self.component_cls.render_to_response(request=request)
+ ...
+```
+
+Then, to get the URL for the component, use [`get_component_url()`](../../../reference/api#django_components.get_component_url):
+
+```py
+from django_components import get_component_url
+
+url = get_component_url(MyComponent)
+```
+
+This way you don't have to mix your app URLs with component URLs.
+
+!!! info
+
+ If you need to pass query parameters or a fragment to the component URL, you can do so by passing the `query` and `fragment` arguments to [`get_component_url()`](../../../reference/api#django_components.get_component_url):
+
+ ```py
+ url = get_component_url(
+ MyComponent,
+ query={"foo": "bar"},
+ fragment="baz",
+ )
+ # /components/ext/view/components/c1ab2c3?foo=bar#baz
+ ```
diff --git a/docs/concepts/fundamentals/components_as_views.md b/docs/concepts/fundamentals/components_as_views.md
deleted file mode 100644
index c950b1db..00000000
--- a/docs/concepts/fundamentals/components_as_views.md
+++ /dev/null
@@ -1,153 +0,0 @@
----
-title: Components as views
-weight: 10
----
-
-_New in version 0.34_
-
-_Note: Since 0.92, Component no longer subclasses View. To configure the View class, set the nested `Component.View` class_
-
-Components can now be used as views:
-
-- Components define the `Component.as_view()` class method that can be used the same as [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view).
-
-- By default, you can define GET, POST or other HTTP handlers directly on the Component, same as you do with [View](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#view). For example, you can override `get` and `post` to handle GET and POST requests, respectively.
-
-- In addition, `Component` now has a [`render_to_response`](#inputs-of-render-and-render_to_response) method that renders the component template based on the provided context and slots' data and returns an `HttpResponse` object.
-
-## Component as view example
-
-Here's an example of a calendar component defined as a view:
-
-```python
-# In a file called [project root]/components/calendar.py
-from django_components import Component, ComponentView, register
-
-@register("calendar")
-class Calendar(Component):
-
- template = """
-
-
- {% slot "header" / %}
-
-
- Today's date is {{ date }}
-
-
- """
-
- # Handle GET requests
- def get(self, request, *args, **kwargs):
- context = {
- "date": request.GET.get("date", "2020-06-06"),
- }
- slots = {
- "header": "Calendar header",
- }
- # Return HttpResponse with the rendered content
- return self.render_to_response(
- context=context,
- slots=slots,
- )
-```
-
-Then, to use this component as a view, you should create a `urls.py` file in your components directory, and add a path to the component's view:
-
-```python
-# In a file called [project root]/components/urls.py
-from django.urls import path
-from components.calendar.calendar import Calendar
-
-urlpatterns = [
- path("calendar/", Calendar.as_view()),
-]
-```
-
-`Component.as_view()` is a shorthand for calling [`View.as_view()`](https://docs.djangoproject.com/en/5.1/ref/class-based-views/base/#django.views.generic.base.View.as_view) and passing the component
-instance as one of the arguments.
-
-Remember to add `__init__.py` to your components directory, so that Django can find the `urls.py` file.
-
-Finally, include the component's urls in your project's `urls.py` file:
-
-```python
-# In a file called [project root]/urls.py
-from django.urls import include, path
-
-urlpatterns = [
- path("components/", include("components.urls")),
-]
-```
-
-Note: Slots content are automatically escaped by default to prevent XSS attacks. To disable escaping, set `escape_slots_content=False` in the `render_to_response` method. If you do so, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
-
-If you're planning on passing an HTML string, check Django's use of [`format_html`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.html.format_html) and [`mark_safe`](https://docs.djangoproject.com/en/5.0/ref/utils/#django.utils.safestring.mark_safe).
-
-## Modifying the View class
-
-The View class that handles the requests is defined on `Component.View`.
-
-When you define a GET or POST handlers on the `Component` class, like so:
-
-```py
-class MyComponent(Component):
- def get(self, request, *args, **kwargs):
- return self.render_to_response(
- context={
- "date": request.GET.get("date", "2020-06-06"),
- },
- )
-
- def post(self, request, *args, **kwargs) -> HttpResponse:
- variable = request.POST.get("variable")
- return self.render_to_response(
- kwargs={"variable": variable}
- )
-```
-
-Then the request is still handled by `Component.View.get()` or `Component.View.post()`
-methods. However, by default, `Component.View.get()` points to `Component.get()`, and so on.
-
-```py
-class ComponentView(View):
- component: Component = None
- ...
-
- def get(self, request, *args, **kwargs):
- return self.component.get(request, *args, **kwargs)
-
- def post(self, request, *args, **kwargs):
- return self.component.post(request, *args, **kwargs)
-
- ...
-```
-
-If you want to define your own `View` class, you need to:
-
-1. Set the class as `Component.View`
-2. Subclass from `ComponentView`, so the View instance has access to the component instance.
-
-In the example below, we added extra logic into `View.setup()`.
-
-Note that the POST handler is still defined at the top. This is because `View` subclasses `ComponentView`, which defines the `post()` method that calls `Component.post()`.
-
-If you were to overwrite the `View.post()` method, then `Component.post()` would be ignored.
-
-```py
-from django_components import Component, ComponentView
-
-class MyComponent(Component):
-
- def post(self, request, *args, **kwargs) -> HttpResponse:
- variable = request.POST.get("variable")
- return self.component.render_to_response(
- kwargs={"variable": variable}
- )
-
- class View(ComponentView):
- def setup(self, request, *args, **kwargs):
- super(request, *args, **kwargs)
-
- do_something_extra(request, *args, **kwargs)
-```
diff --git a/docs/concepts/fundamentals/components_in_python.md b/docs/concepts/fundamentals/components_in_python.md
deleted file mode 100644
index 5afd54d5..00000000
--- a/docs/concepts/fundamentals/components_in_python.md
+++ /dev/null
@@ -1,139 +0,0 @@
----
-title: Components in Python
-weight: 2
----
-
-_New in version 0.81_
-
-Components can be rendered outside of Django templates, calling them as regular functions ("React-style").
-
-The component class defines `render` and `render_to_response` class methods. These methods accept positional args, kwargs, and slots, offering the same flexibility as the `{% component %}` tag:
-
-```py
-class SimpleComponent(Component):
- template = """
- {% load component_tags %}
- hello: {{ hello }}
- foo: {{ foo }}
- kwargs: {{ kwargs|safe }}
- slot_first: {% slot "first" required / %}
- """
-
- def get_context_data(self, arg1, arg2, **kwargs):
- return {
- "hello": arg1,
- "foo": arg2,
- "kwargs": kwargs,
- }
-
-rendered = SimpleComponent.render(
- args=["world", "bar"],
- kwargs={"kw1": "test", "kw2": "ooo"},
- slots={"first": "FIRST_SLOT"},
- context={"from_context": 98},
-)
-```
-
-Renders:
-
-```
-hello: world
-foo: bar
-kwargs: {'kw1': 'test', 'kw2': 'ooo'}
-slot_first: FIRST_SLOT
-```
-
-## Inputs of `render` and `render_to_response`
-
-Both `render` and `render_to_response` accept the same input:
-
-```py
-Component.render(
- context: Mapping | django.template.Context | None = None,
- args: List[Any] | None = None,
- kwargs: Dict[str, Any] | None = None,
- slots: Dict[str, str | SafeString | SlotFunc] | None = None,
- escape_slots_content: bool = True
-) -> str:
-```
-
-- _`args`_ - Positional args for the component. This is the same as calling the component
- as `{% component "my_comp" arg1 arg2 ... %}`
-
-- _`kwargs`_ - Keyword args for the component. This is the same as calling the component
- as `{% component "my_comp" key1=val1 key2=val2 ... %}`
-
-- _`slots`_ - Component slot fills. This is the same as pasing `{% fill %}` tags to the component.
- Accepts a dictionary of `{ slot_name: slot_content }` where `slot_content` can be a string
- or [`SlotFunc`](#slotfunc).
-
-- _`escape_slots_content`_ - Whether the content from `slots` should be escaped. `True` by default to prevent XSS attacks. If you disable escaping, you should make sure that any content you pass to the slots is safe, especially if it comes from user input.
-
-- _`context`_ - A context (dictionary or Django's Context) within which the component
- 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.
-
-- _`request`_ - A Django request object. This is used to enable Django template `context_processors` to run,
- allowing for template tags like `{% csrf_token %}` and variables like `{{ debug }}`.
- - Similar behavior can be achieved with [provide / inject](#how-to-use-provide--inject).
- - This is used internally to convert `context` to a RequestContext. It does nothing if `context` is already
- a `Context` instance.
-
-### `SlotFunc`
-
-When rendering components with slots in `render` or `render_to_response`, you can pass either a string or a function.
-
-The function has following signature:
-
-```py
-def render_func(
- context: Context,
- data: Dict[str, Any],
- slot_ref: SlotRef,
-) -> str | SafeString:
- return nodelist.render(ctx)
-```
-
-- _`context`_ - Django's Context available to the Slot Node.
-- _`data`_ - Data passed to the `{% slot %}` tag. See [Scoped Slots](#scoped-slots).
-- _`slot_ref`_ - The default slot content. See [Accessing original content of slots](#accessing-original-content-of-slots).
- - NOTE: The slot is lazily evaluated. To render the slot, convert it to string with `str(slot_ref)`.
-
-Example:
-
-```py
-def footer_slot(ctx, data, slot_ref):
- return f"""
- SLOT_DATA: {data['abc']}
- ORIGINAL: {slot_ref}
- """
-
-MyComponent.render_to_response(
- slots={
- "footer": footer_slot,
- },
-)
-```
-
-## Response class of `render_to_response`
-
-While `render` method returns a plain string, `render_to_response` wraps the rendered content in a "Response" class. By default, this is `django.http.HttpResponse`.
-
-If you want to use a different Response class in `render_to_response`, set the `Component.response_class` attribute:
-
-```py
-class MyResponse(HttpResponse):
- def __init__(self, *args, **kwargs) -> None:
- super().__init__(*args, **kwargs)
- # Configure response
- self.headers = ...
- self.status = ...
-
-class SimpleComponent(Component):
- response_class = MyResponse
- template: types.django_html = "HELLO"
-
-response = SimpleComponent.render_to_response()
-assert isinstance(response, MyResponse)
-```
diff --git a/docs/concepts/fundamentals/defining_js_css_html_files.md b/docs/concepts/fundamentals/defining_js_css_html_files.md
deleted file mode 100644
index 70cc8720..00000000
--- a/docs/concepts/fundamentals/defining_js_css_html_files.md
+++ /dev/null
@@ -1,203 +0,0 @@
----
-title: Defining HTML / JS / CSS files
-weight: 8
----
-
-django_component's management of files builds on top of [Django's `Media` class](https://docs.djangoproject.com/en/5.0/topics/forms/media/).
-
-To be familiar with how Django handles static files, we recommend reading also:
-
-- [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.0/howto/static-files/)
-
-## Defining file paths relative to component or static dirs
-
-As seen in the [getting started example](#create-your-first-component), to associate HTML/JS/CSS
-files with a component, you set them as `template_name`, `Media.js` and `Media.css` respectively:
-
-```py
-# In a file [project root]/components/calendar/calendar.py
-from django_components import Component, register
-
-@register("calendar")
-class Calendar(Component):
- template_name = "template.html"
-
- class Media:
- css = "style.css"
- js = "script.js"
-```
-
-In the example above, the files are defined relative to the directory where `component.py` is.
-
-Alternatively, you can specify the file paths relative to the directories set in `COMPONENTS.dirs` or `COMPONENTS.app_dirs`.
-
-Assuming that `COMPONENTS.dirs` contains path `[project root]/components`, we can rewrite the example as:
-
-```py
-# In a file [project root]/components/calendar/calendar.py
-from django_components import Component, register
-
-@register("calendar")
-class Calendar(Component):
- template_name = "calendar/template.html"
-
- class Media:
- css = "calendar/style.css"
- js = "calendar/script.js"
-```
-
-NOTE: In case of conflict, the preference goes to resolving the files relative to the component's directory.
-
-## Defining multiple paths
-
-Each component can have only a single template. However, you can define as many JS or CSS files as you want using a list.
-
-```py
-class MyComponent(Component):
- class Media:
- js = ["path/to/script1.js", "path/to/script2.js"]
- css = ["path/to/style1.css", "path/to/style2.css"]
-```
-
-## Configuring CSS Media Types
-
-You can define which stylesheets will be associated with which
-[CSS Media types](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries#targeting_media_types). You do so by defining CSS files as a dictionary.
-
-See the corresponding [Django Documentation](https://docs.djangoproject.com/en/5.0/topics/forms/media/#css).
-
-Again, you can set either a single file or a list of files per media type:
-
-```py
-class MyComponent(Component):
- class Media:
- css = {
- "all": "path/to/style1.css",
- "print": "path/to/style2.css",
- }
-```
-
-```py
-class MyComponent(Component):
- class Media:
- css = {
- "all": ["path/to/style1.css", "path/to/style2.css"],
- "print": ["path/to/style3.css", "path/to/style4.css"],
- }
-```
-
-NOTE: When you define CSS as a string or a list, the `all` media type is implied.
-
-## Supported types for file paths
-
-File paths can be any of:
-
-- `str`
-- `bytes`
-- `PathLike` (`__fspath__` method)
-- `SafeData` (`__html__` method)
-- `Callable` that returns any of the above, evaluated at class creation (`__new__`)
-
-```py
-from pathlib import Path
-
-from django.utils.safestring import mark_safe
-
-class SimpleComponent(Component):
- class Media:
- css = [
- mark_safe(''),
- Path("calendar/style1.css"),
- "calendar/style2.css",
- b"calendar/style3.css",
- lambda: "calendar/style4.css",
- ]
- js = [
- mark_safe(''),
- Path("calendar/script1.js"),
- "calendar/script2.js",
- b"calendar/script3.js",
- lambda: "calendar/script4.js",
- ]
-```
-
-## Path as objects
-
-In the example [above](#supported-types-for-file-paths), you could see that when we used `mark_safe` to mark a string as a `SafeString`, we had to define the full `'
- )
-
-@register("calendar")
-class Calendar(Component):
- template_name = "calendar/template.html"
-
- def get_context_data(self, date):
- return {
- "date": date,
- }
-
- class Media:
- css = "calendar/style.css"
- js = [
- # ',
- self.absolute_path(path)
- )
- return tags
-
-@register("calendar")
-class Calendar(Component):
- template_name = "calendar/template.html"
-
- class Media:
- css = "calendar/style.css"
- js = "calendar/script.js"
-
- # Override the behavior of Media class
- media_class = MyMedia
-```
-
-NOTE: The instance of the `Media` class (or it's subclass) is available under `Component.media` after the class creation (`__new__`).
diff --git a/docs/concepts/fundamentals/html_attributes.md b/docs/concepts/fundamentals/html_attributes.md
index 64e04b23..22b111d3 100644
--- a/docs/concepts/fundamentals/html_attributes.md
+++ b/docs/concepts/fundamentals/html_attributes.md
@@ -1,42 +1,183 @@
----
-title: HTML attributes
-weight: 7
----
-
_New in version 0.74_:
-You can use the `html_attrs` tag to render HTML attributes, given a dictionary
-of values.
+You can use the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag to render various data
+as `key="value"` HTML attributes.
-So if you have a template:
+[`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag is versatile, allowing you to define HTML attributes however you need:
+
+- Define attributes within the HTML template
+- Define attributes in Python code
+- Merge attributes from multiple sources
+- Boolean attributes
+- Append attributes
+- Remove attributes
+- Define default attributes
+
+From v0.135 onwards, [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag also supports merging [`style`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) and [`class`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/class) attributes
+the same way [how Vue does](https://vuejs.org/guide/essentials/class-and-style).
+
+To get started, let's consider a simple example. If you have a template:
```django
```
-You can simplify it with `html_attrs` tag:
+You can rewrite it with the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag:
+
+```django
+
+
+```
+
+The [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag accepts any number of keyword arguments, which will be merged and rendered as HTML attributes:
+
+```django
+
+
+```
+
+Moreover, the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag accepts two positional arguments:
+
+- `attrs` - a dictionary of attributes to be rendered
+- `defaults` - a dictionary of default attributes
+
+You can use this for example to allow users of your component to add extra attributes. We achieve this by capturing the extra attributes and passing them to the [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag as a dictionary:
+
+```djc_py
+@register("my_comp")
+class MyComp(Component):
+ # Pass all kwargs as `attrs`
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "attrs": kwargs,
+ "classes": "text-red",
+ "my_id": 123,
+ }
+
+ template: t.django_html = """
+ {# Pass the extra attributes to `html_attrs` #}
+
+
+ """
+```
+
+This way you can render `MyComp` with extra attributes:
+
+Either via Django template:
+
+```django
+{% component "my_comp"
+ id="example"
+ class="pa-4"
+ style="color: red;"
+%}
+```
+
+Or via Python:
+
+```py
+MyComp.render(
+ kwargs={
+ "id": "example",
+ "class": "pa-4",
+ "style": "color: red;",
+ }
+)
+```
+
+In both cases, the attributes will be merged and rendered as:
+
+```html
+
+```
+
+### Summary
+
+1. The two arguments, `attrs` and `defaults`, can be passed as positional args:
+
+ ```django
+ {% html_attrs attrs defaults key=val %}
+ ```
+
+ or as kwargs:
+
+ ```django
+ {% html_attrs key=val defaults=defaults attrs=attrs %}
+ ```
+
+2. Both `attrs` and `defaults` are optional and can be omitted.
+
+3. Both `attrs` and `defaults` are dictionaries. As such, there's multiple ways to define them:
+
+ - By referencing a variable:
+
+ ```django
+ {% html_attrs attrs=attrs %}
+ ```
+
+ - By defining a literal dictionary:
+
+ ```django
+ {% html_attrs attrs={"key": value} %}
+ ```
+
+ - Or by defining the [dictionary keys](../template_tag_syntax/#pass-dictonary-by-its-key-value-pairs):
+
+ ```django
+ {% html_attrs attrs:key=value %}
+ ```
+
+4. All other kwargs are merged and can be repeated.
+
+ ```django
+ {% html_attrs class="text-red" class="pa-4" %}
+ ```
+
+ Will render:
+
+ ```html
+
+ ```
+
+## Usage
+
+### Boolean attributes
+
+In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not:
+
+```html
+
+
+```
+
+HTML rendering with [`html_attrs`](../../../reference/template_tags#html_attrs) tag or [`format_attributes`](../../../reference/api#django_components.format_attributes) works the same way - an attribute set to `True` is rendered without the value, and an attribute set to `False` is not rendered at all.
+
+So given this input:
+
+```py
+attrs = {
+ "disabled": True,
+ "autofocus": False,
+}
+```
+
+And template:
```django
```
-where `attrs` is:
+Then this renders:
-```py
-attrs = {
- "class": classes,
- "data-id": my_id,
-}
+```html
+
```
-This feature is inspired by [`merge_attrs` tag of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#default--merged-attributes) and
-["fallthrough attributes" feature of Vue](https://vuejs.org/guide/components/attrs).
+### Removing attributes
-## Removing atttributes
-
-Attributes that are set to `None` or `False` are NOT rendered.
+Given how the boolean attributes work, you can "remove" or prevent an attribute from being rendered by setting it to `False` or `None`.
So given this input:
@@ -61,41 +202,9 @@ Then this renders:
```
-## Boolean attributes
+### Default attributes
-In HTML, boolean attributes are usually rendered with no value. Consider the example below where the first button is disabled and the second is not:
-
-```html
-
-```
-
-HTML rendering with `html_attrs` tag or `attributes_to_string` works the same way, where `key=True` is rendered simply as `key`, and `key=False` is not render at all.
-
-So given this input:
-
-```py
-attrs = {
- "disabled": True,
- "autofocus": False,
-}
-```
-
-And template:
-
-```django
-
-
-```
-
-Then this renders:
-
-```html
-
-```
-
-## Default attributes
-
-Sometimes you may want to specify default values for attributes. You can pass a second argument (or kwarg `defaults`) to set the defaults.
+Sometimes you may want to specify default values for attributes. You can pass a second positional argument to set the defaults.
```django
@@ -103,20 +212,30 @@ Sometimes you may want to specify default values for attributes. You can pass a
```
-In the example above, if `attrs` contains e.g. the `class` key, `html_attrs` will render:
+In the example above, if `attrs` contains a certain key, e.g. the `class` key, [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) will render:
-`class="{{ attrs.class }}"`
+```html
+
+ ...
+
+```
-Otherwise, `html_attrs` will render:
+Otherwise, [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) will render:
-`class="{{ defaults.class }}"`
+```html
+
+ ...
+
+```
-## Appending attributes
+### Appending attributes
For the `class` HTML attribute, it's common that we want to _join_ multiple values,
-instead of overriding them. For example, if you're authoring a component, you may
+instead of overriding them.
+
+For example, if you're authoring a component, you may
want to ensure that the component will ALWAYS have a specific class. Yet, you may
-want to allow users of your component to supply their own classes.
+want to allow users of your component to supply their own `class` attribute.
We can achieve this by adding extra kwargs. These values
will be appended, instead of overwriting the previous value.
@@ -129,7 +248,7 @@ attrs = {
}
```
-And on `html_attrs` tag, we set the key `class`:
+And on [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag, we set the key `class`:
```django
@@ -158,23 +277,179 @@ Renders:
>
```
-## Rules for `html_attrs`
+### Merging `class` attributes
-1. Both `attrs` and `defaults` can be passed as positional args
+The `class` attribute can be specified as a string of class names as usual.
- `{% html_attrs attrs defaults key=val %}`
+If you want granular control over individual class names, you can use a dictionary.
- or as kwargs
+- **String**: Used as is.
- `{% html_attrs key=val defaults=defaults attrs=attrs %}`
+ ```django
+ {% html_attrs class="my-class other-class" %}
+ ```
-2. Both `attrs` and `defaults` are optional (can be omitted)
+ Renders:
-3. Both `attrs` and `defaults` are dictionaries, and we can define them the same way [we define dictionaries for the `component` tag](#pass-dictonary-by-its-key-value-pairs). So either as `attrs=attrs` or `attrs:key=value`.
+ ```html
+
+ ```
-4. All other kwargs are appended and can be repeated.
+- **Dictionary**: Keys are the class names, and values are booleans. Only keys with truthy values are rendered.
-## Examples for `html_attrs`
+ ```django
+ {% html_attrs class={
+ "extra-class": True,
+ "other-class": False,
+ } %}
+ ```
+
+ Renders:
+
+ ```html
+
+ ```
+
+If a certain class is specified multiple times, it's the last instance that decides whether the class is rendered or not.
+
+**Example:**
+
+In this example, the `other-class` is specified twice. The last instance is `{"other-class": False}`, so the class is not rendered.
+
+```django
+{% html_attrs
+ class="my-class other-class"
+ class={"extra-class": True, "other-class": False}
+%}
+```
+
+Renders:
+
+```html
+
+```
+
+### Merging `style` attributes
+
+The `style` attribute can be specified as a string of style properties as usual.
+
+If you want granular control over individual style properties, you can use a dictionary.
+
+- **String**: Used as is.
+
+ ```django
+ {% html_attrs style="color: red; background-color: blue;" %}
+ ```
+
+ Renders:
+
+ ```html
+
+ ```
+
+- **Dictionary**: Keys are the style properties, and values are their values.
+
+ ```django
+ {% html_attrs style={
+ "color": "red",
+ "background-color": "blue",
+ } %}
+ ```
+
+ Renders:
+
+ ```html
+
+ ```
+
+If a style property is specified multiple times, the last value is used.
+
+- Properties set to `None` are ignored.
+- If the last non-`None` instance of the property is set to `False`, the property is removed.
+
+**Example:**
+
+In this example, the `width` property is specified twice. The last instance is `{"width": False}`, so the property is removed.
+
+Secondly, the `background-color` property is also set twice. But the second time it's set to `None`, so that instance is ignored, leaving us only with `background-color: blue`.
+
+The `color` property is set to a valid value in both cases, so the latter (`green`) is used.
+
+```django
+{% html_attrs
+ style="color: red; background-color: blue; width: 100px;"
+ style={"color": "green", "background-color": None, "width": False}
+%}
+```
+
+Renders:
+
+```html
+
+```
+
+## Usage outside of templates
+
+In some cases, you want to prepare HTML attributes outside of templates.
+
+To achieve the same behavior as [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag, you can use the [`merge_attributes()`](../../../reference/api#django_components.merge_attributes) and [`format_attributes()`](../../../reference/api#django_components.format_attributes) helper functions.
+
+### Merging attributes
+
+[`merge_attributes()`](../../../reference/api#django_components.merge_attributes) accepts any number of dictionaries and merges them together, using the same merge strategy as [`{% html_attrs %}`](../../../reference/template_tags#html_attrs).
+
+```python
+from django_components import merge_attributes
+
+merge_attributes(
+ {"class": "my-class", "data-id": 123},
+ {"class": "extra-class"},
+ {"class": {"cool-class": True, "uncool-class": False} },
+)
+```
+
+Which will output:
+
+```python
+{
+ "class": "my-class extra-class cool-class",
+ "data-id": 123,
+}
+```
+
+!!! warning
+
+ Unlike [`{% html_attrs %}`](../../../reference/template_tags#html_attrs), where you can pass extra kwargs, [`merge_attributes()`](../../../reference/api#django_components.merge_attributes) requires each argument to be a dictionary.
+
+### Formatting attributes
+
+[`format_attributes()`](../../../reference/api#django_components.format_attributes) serializes attributes the same way as [`{% html_attrs %}`](../../../reference/template_tags#html_attrs) tag does.
+
+```py
+from django_components import format_attributes
+
+format_attributes({
+ "class": "my-class text-red pa-4",
+ "data-id": 123,
+ "required": True,
+ "disabled": False,
+ "ignored-attr": None,
+})
+```
+
+Which will output:
+
+```python
+'class="my-class text-red pa-4" data-id="123" required'
+```
+
+!!! note
+
+ Prior to v0.135, the `format_attributes()` function was named `attributes_to_string()`.
+
+ This function is now deprecated and will be removed in v1.0.
+
+## Cheat sheet
Assuming that:
@@ -194,69 +469,129 @@ defaults = {
Then:
-- Empty tag
- `{% html_attr %}`
+- **Empty tag**
+
+ ```django
+
+ ```
- renders (empty string):
- ` `
+ renders nothing:
-- Only kwargs
- `{% html_attr class="some-class" class=class_from_var data-id="123" %}`
+ ```html
+
+ ```
- renders:
- `class="some-class from-var" data-id="123"`
+- **Only kwargs**
+
+ ```django
+
+ ```
-- Only attrs
- `{% html_attr attrs %}`
+ renders:
- renders:
- `class="from-attrs" type="submit"`
+ ```html
+
+ ```
-- Attrs as kwarg
- `{% html_attr attrs=attrs %}`
+- **Only attrs**
- renders:
- `class="from-attrs" type="submit"`
+ ```django
+
+ ```
-- Only defaults (as kwarg)
- `{% html_attr defaults=defaults %}`
+ renders:
- renders:
- `class="from-defaults" role="button"`
+ ```html
+
+ ```
-- Attrs using the `prefix:key=value` construct
- `{% html_attr attrs:class="from-attrs" attrs:type="submit" %}`
+- **Attrs as kwarg**
- renders:
- `class="from-attrs" type="submit"`
+ ```django
+
+ ```
-- Defaults using the `prefix:key=value` construct
- `{% html_attr defaults:class="from-defaults" %}`
+ renders:
- renders:
- `class="from-defaults" role="button"`
+ ```html
+
+ ```
-- All together (1) - attrs and defaults as positional args:
- `{% html_attrs attrs defaults class="added_class" class=class_from_var data-id=123 %}`
+- **Only defaults (as kwarg)**
- renders:
- `class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
+ ```django
+
+ ```
-- All together (2) - attrs and defaults as kwargs args:
- `{% html_attrs class="added_class" class=class_from_var data-id=123 attrs=attrs defaults=defaults %}`
+ renders:
- renders:
- `class="from-attrs added_class from-var" type="submit" role="button" data-id=123`
+ ```html
+
+ ```
-- All together (3) - mixed:
- `{% html_attrs attrs defaults:class="default-class" class="added_class" class=class_from_var data-id=123 %}`
+- **Attrs using the `prefix:key=value` construct**
- renders:
- `class="from-attrs added_class from-var" type="submit" data-id=123`
+ ```django
+
+ ```
-## Full example for `html_attrs`
+ renders:
-```py
+ ```html
+
+ ```
+
+- **Defaults using the `prefix:key=value` construct**
+
+ ```django
+
+ ```
+
+ renders:
+
+ ```html
+
+ ```
+
+- **All together (1) - attrs and defaults as positional args:**
+
+ ```django
+
+ ```
+
+ renders:
+
+ ```html
+
+ ```
+
+- **All together (2) - attrs and defaults as kwargs args:**
+
+ ```django
+
+ ```
+
+ renders:
+
+ ```html
+
+ ```
+
+- **All together (3) - mixed:**
+
+ ```django
+
+ ```
+
+ renders:
+
+ ```html
+
+ ```
+
+## Full example
+
+```djc_py
@register("my_comp")
class MyComp(Component):
template: t.django_html = """
@@ -272,10 +607,11 @@ class MyComp(Component):
"""
- def get_context_data(self, date: Date, attrs: dict):
+ def get_template_data(self, args, kwargs, slots, context):
+ date = kwargs.pop("date")
return {
"date": date,
- "attrs": attrs,
+ "attrs": kwargs,
"class_from_var": "extra-class"
}
@@ -290,7 +626,7 @@ class Parent(Component):
/ %}
"""
- def get_context_data(self, date: Date):
+ def get_template_data(self, args, kwargs, slots, context):
return {
"date": datetime.now(),
"json_data": json.dumps({"value": 456})
@@ -301,7 +637,9 @@ Note: For readability, we've split the tags across multiple lines.
Inside `MyComp`, we defined a default attribute
-`defaults:class="pa-4 text-red"`
+```
+defaults:class="pa-4 text-red"
+```
So if `attrs` includes key `class`, the default above will be ignored.
@@ -332,7 +670,7 @@ So all kwargs that start with `attrs:` will be collected into an `attrs` dict.
attrs:@click="(e) => onClick(e, 'from_parent')"
```
-And `get_context_data` of `MyComp` will receive `attrs` input with following keys:
+And `get_template_data` of `MyComp` will receive a kwarg named `attrs` with following keys:
```py
attrs = {
@@ -357,22 +695,3 @@ So in the end `MyComp` will render:
...
```
-
-## Rendering HTML attributes outside of templates
-
-If you need to use serialize HTML attributes outside of Django template and the `html_attrs` tag, you can use `attributes_to_string`:
-
-```py
-from django_components.attributes import attributes_to_string
-
-attrs = {
- "class": "my-class text-red pa-4",
- "data-id": 123,
- "required": True,
- "disabled": False,
- "ignored-attr": None,
-}
-
-attributes_to_string(attrs)
-# 'class="my-class text-red pa-4" data-id="123" required'
-```
diff --git a/docs/concepts/fundamentals/html_js_css_files.md b/docs/concepts/fundamentals/html_js_css_files.md
new file mode 100644
index 00000000..296f02e1
--- /dev/null
+++ b/docs/concepts/fundamentals/html_js_css_files.md
@@ -0,0 +1,441 @@
+## Overview
+
+Each component can have single "primary" HTML, CSS and JS file associated with them.
+
+Each of these can be either defined inline, or in a separate file:
+
+- HTML files are defined using [`Component.template`](../../reference/api.md#django_components.Component.template) or [`Component.template_file`](../../reference/api.md#django_components.Component.template_file)
+- CSS files are defined using [`Component.css`](../../reference/api.md#django_components.Component.css) or [`Component.css_file`](../../reference/api.md#django_components.Component.css_file)
+- JS files are defined using [`Component.js`](../../reference/api.md#django_components.Component.js) or [`Component.js_file`](../../reference/api.md#django_components.Component.js_file)
+
+```py
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar.html"
+ css_file = "calendar.css"
+ js_file = "calendar.js"
+```
+
+or
+
+```djc_py
+@register("calendar")
+class Calendar(Component):
+ template = """
+
+ Hi there!
+
+ """
+ css = """
+ .welcome {
+ color: red;
+ }
+ """
+ js = """
+ console.log("Hello, world!");
+ """
+```
+
+These "primary" files will have special behavior. For example, each will receive variables from the component's data methods.
+Read more about each file type below:
+
+- [HTML](#html)
+- [CSS](#css)
+- [JS](#js)
+
+In addition, you can define extra "secondary" CSS / JS files using the nested [`Component.Media`](../../reference/api.md#django_components.Component.Media) class,
+by setting [`Component.Media.js`](../../reference/api.md#django_components.ComponentMediaInput.js) and [`Component.Media.css`](../../reference/api.md#django_components.ComponentMediaInput.css).
+
+Single component can have many secondary files. There is no special behavior for them.
+
+You can use these for third-party libraries, or for shared CSS / JS files.
+
+Read more about [Secondary JS / CSS files](../secondary_js_css_files).
+
+!!! warning
+
+ You **cannot** use both inlined code **and** separate file for a single language type (HTML, CSS, JS).
+
+ However, you can freely mix these for different languages:
+
+ ```djc_py
+ class MyTable(Component):
+ template: types.django_html = """
+
+ Hi there!
+
+ """
+ js_file = "my_table.js"
+ css_file = "my_table.css"
+ ```
+
+## HTML
+
+Components use Django's template system to define their HTML.
+This means that you can use [Django's template syntax](https://docs.djangoproject.com/en/5.2/ref/templates/language/) to define your HTML.
+
+Inside the template, you can access the data returned from the [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data) method.
+
+You can define the HTML directly in your Python code using the [`template`](../../reference/api.md#django_components.Component.template) attribute:
+
+```djc_py
+class Button(Component):
+ template = """
+
+ """
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "text": kwargs.get("text", "Click me"),
+ "icon": kwargs.get("icon", None),
+ }
+```
+
+Or you can define the HTML in a separate file and reference it using [`template_file`](../../reference/api.md#django_components.Component.template_file):
+
+```python
+class Button(Component):
+ template_file = "button.html"
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "text": kwargs.get("text", "Click me"),
+ "icon": kwargs.get("icon", None),
+ }
+```
+
+```django title="button.html"
+
+```
+
+### Dynamic templates
+
+Each component has only a single template associated with it.
+
+However, whether it's for A/B testing or for preserving public API
+when sharing your components, sometimes you may need to render different templates
+based on the input to your component.
+
+You can use [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render)
+to dynamically override what template gets rendered.
+
+By default, the component's template is rendered as-is.
+
+```py
+class Table(Component):
+ def on_render(self, context: Context, template: Optional[Template]):
+ if template is not None:
+ return template.render(context)
+```
+
+If you want to render a different template in its place,
+we recommended you to:
+
+1. Wrap the substitute templates as new Components
+2. Then render those Components inside [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render):
+
+```py
+class TableNew(Component):
+ template_file = "table_new.html"
+
+class TableOld(Component):
+ template_file = "table_old.html"
+
+class Table(Component):
+ def on_render(self, context, template):
+ if self.kwargs.get("feat_table_new_ui"):
+ return TableNew.render(
+ args=self.args,
+ kwargs=self.kwargs,
+ slots=self.slots,
+ )
+ else:
+ return TableOld.render(
+ args=self.args,
+ kwargs=self.kwargs,
+ slots=self.slots,
+ )
+```
+
+!!! warning
+
+ If you do not wrap the templates as Components,
+ there is a risk that some [extensions](../../advanced/extensions) will not work as expected.
+
+ ```py
+ new_template = Template("""
+ {% load django_components %}
+
+ """)
+
+ class Table(Component):
+ def on_render(self, context, template):
+ if self.kwargs.get("feat_table_new_ui"):
+ return new_template.render(context)
+ else:
+ return template.render(context)
+ ```
+
+### Template-less components
+
+Since you can use [`Component.on_render()`](../../reference/api.md#django_components.Component.on_render)
+to render *other* components, there is no need to define a template for the component.
+
+So even an empty component like this is valid:
+
+```py
+class MyComponent(Component):
+ pass
+```
+
+These "template-less" components can be useful as base classes for other components, or as mixins.
+
+### HTML processing
+
+Django Components expects the rendered template to be a valid HTML. This is needed to enable features like [CSS / JS variables](../html_js_css_variables).
+
+Here is how the HTML is post-processed:
+
+1. **Insert component ID**: Each root element in the rendered HTML automatically receives a `data-djc-id-cxxxxxx` attribute containing a unique component instance ID.
+
+ ```html
+
+
+ ...
+
+
+ ...
+
+ ```
+
+2. **Insert CSS ID**: If the component defines CSS variables through [`get_css_data()`](../../../reference/api/#django_components.Component.get_css_data), the root elements also receive a `data-djc-css-xxxxxx` attribute. This attribute links the element to its specific CSS variables.
+
+ ```html
+
+
+
+
+ ```
+
+3. **Insert JS and CSS**: After the HTML is rendered, Django Components handles inserting JS and CSS dependencies into the page based on the [dependencies rendering strategy](../rendering_components/#dependencies-rendering) (document, fragment, or inline).
+
+ For example, if your component contains the
+ [`{% component_js_dependencies %}`](../../reference/template_tags.md#component_js_dependencies)
+ or
+ [`{% component_css_dependencies %}`](../../reference/template_tags.md#component_css_dependencies)
+ tags, or the `` and `` elements, the JS and CSS scripts will be inserted into the HTML.
+
+ For more information on how JS and CSS dependencies are rendered, see [Rendering JS / CSS](../../advanced/rendering_js_css).
+
+## JS
+
+The component's JS script is executed in the browser:
+
+- It is executed AFTER the "secondary" JS files from [`Component.Media.js`](../../reference/api.md#django_components.ComponentMediaInput.js) are loaded.
+- The script is only executed once, even if there are multiple instances of the component on the page.
+- Component JS scripts are executed in the order how they appeared in the template / HTML (top to bottom).
+
+You can define the JS directly in your Python code using the [`js`](../../reference/api.md#django_components.Component.js) attribute:
+
+```djc_py
+class Button(Component):
+ js = """
+ console.log("Hello, world!");
+ """
+
+ def get_js_data(self, args, kwargs, slots, context):
+ return {
+ "text": kwargs.get("text", "Click me"),
+ }
+```
+
+Or you can define the JS in a separate file and reference it using [`js_file`](../../reference/api.md#django_components.Component.js_file):
+
+```python
+class Button(Component):
+ js_file = "button.js"
+
+ def get_js_data(self, args, kwargs, slots, context):
+ return {
+ "text": kwargs.get("text", "Click me"),
+ }
+```
+
+```django title="button.js"
+console.log("Hello, world!");
+```
+
+## CSS
+
+You can define the CSS directly in your Python code using the [`css`](../../reference/api.md#django_components.Component.css) attribute:
+
+```djc_py
+class Button(Component):
+ css = """
+ .btn {
+ width: 100px;
+ color: var(--color);
+ }
+ """
+
+ def get_css_data(self, args, kwargs, slots, context):
+ return {
+ "color": kwargs.get("color", "red"),
+ }
+```
+
+Or you can define the CSS in a separate file and reference it using [`css_file`](../../reference/api.md#django_components.Component.css_file):
+
+```python
+class Button(Component):
+ css_file = "button.css"
+
+ def get_css_data(self, args, kwargs, slots, context):
+ return {
+ "text": kwargs.get("text", "Click me"),
+ }
+```
+
+```django title="button.css"
+.btn {
+ color: red;
+}
+```
+
+## File paths
+
+Compared to the [secondary JS / CSS files](../secondary_js_css_files), the definition of file paths for the main HTML / JS / CSS files is quite simple - just strings, without any lists, objects, or globs.
+
+However, similar to the secondary JS / CSS files, you can specify the file paths [relative to the component's directory](../secondary_js_css_files/#relative-to-component).
+
+So if you have a directory with following files:
+
+```
+[project root]/components/calendar/
+├── calendar.html
+├── calendar.css
+├── calendar.js
+└── calendar.py
+```
+
+You can define the component like this:
+
+```py title="[project root]/components/calendar/calendar.py"
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar.html"
+ css_file = "calendar.css"
+ js_file = "calendar.js"
+```
+
+Assuming that
+[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
+contains path `[project root]/components`, the example above is the same as writing out:
+
+```py title="[project root]/components/calendar/calendar.py"
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar/template.html"
+ css_file = "calendar/style.css"
+ js_file = "calendar/script.js"
+```
+
+If the path cannot be resolved relative to the component, django-components will attempt
+to resolve the path relative to the component directories, as set in
+[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
+or
+[`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs).
+
+Read more about [file path resolution](../secondary_js_css_files/#relative-to-component).
+
+## Access component definition
+
+Component's HTML / CSS / JS is resolved and loaded lazily.
+
+This means that, when you specify any of
+[`template_file`](../../reference/api.md#django_components.Component.template_file),
+[`js_file`](../../reference/api.md#django_components.Component.js_file),
+[`css_file`](../../reference/api.md#django_components.Component.css_file),
+or [`Media.js/css`](../../reference/api.md#django_components.Component.Media),
+these file paths will be resolved only once you either:
+
+1. Access any of the following attributes on the component:
+
+ - [`media`](../../reference/api.md#django_components.Component.media),
+ [`template`](../../reference/api.md#django_components.Component.template),
+ [`template_file`](../../reference/api.md#django_components.Component.template_file),
+ [`js`](../../reference/api.md#django_components.Component.js),
+ [`js_file`](../../reference/api.md#django_components.Component.js_file),
+ [`css`](../../reference/api.md#django_components.Component.css),
+ [`css_file`](../../reference/api.md#django_components.Component.css_file)
+
+2. Render the component.
+
+Once the component's media files have been loaded once, they will remain in-memory
+on the Component class:
+
+- HTML from [`Component.template_file`](../../reference/api.md#django_components.Component.template_file)
+ will be available under [`Component.template`](../../reference/api.md#django_components.Component.template)
+- CSS from [`Component.css_file`](../../reference/api.md#django_components.Component.css_file)
+ will be available under [`Component.css`](../../reference/api.md#django_components.Component.css)
+- JS from [`Component.js_file`](../../reference/api.md#django_components.Component.js_file)
+ will be available under [`Component.js`](../../reference/api.md#django_components.Component.js)
+
+Thus, whether you define HTML via
+[`Component.template_file`](../../reference/api.md#django_components.Component.template_file)
+or [`Component.template`](../../reference/api.md#django_components.Component.template),
+you can always access the HTML content under [`Component.template`](../../reference/api.md#django_components.Component.template).
+And the same applies for JS and CSS.
+
+**Example:**
+
+```py
+# When we create Calendar component, the files like `calendar/template.html`
+# are not yet loaded!
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar/template.html"
+ css_file = "calendar/style.css"
+ js_file = "calendar/script.js"
+
+ class Media:
+ css = "calendar/style1.css"
+ js = "calendar/script2.js"
+
+# It's only at this moment that django-components reads the files like `calendar/template.html`
+print(Calendar.css)
+# Output:
+# .calendar {
+# width: 200px;
+# background: pink;
+# }
+```
+
+!!! warning
+
+ **Do NOT modify HTML / CSS / JS after it has been loaded**
+
+ django-components assumes that the component's media files like `js_file` or `Media.js/css` are static.
+
+ If you need to dynamically change these media files, consider instead defining multiple Components.
+
+ Modifying these files AFTER the component has been loaded at best does nothing. However, this is
+ an untested behavior, which may lead to unexpected errors.
diff --git a/docs/concepts/fundamentals/html_js_css_variables.md b/docs/concepts/fundamentals/html_js_css_variables.md
new file mode 100644
index 00000000..b7164e06
--- /dev/null
+++ b/docs/concepts/fundamentals/html_js_css_variables.md
@@ -0,0 +1,496 @@
+When a component recieves input through [`{% component %}`](../../../reference/template_tags/#component) tag,
+or the [`Component.render()`](../../../reference/api/#django_components.Component.render) or [`Component.render_to_response()`](../../../reference/api/#django_components.Component.render_to_response) methods, you can define how the input is handled, and what variables will be available to the template, JavaScript and CSS.
+
+## Overview
+
+Django Components offers three key methods for passing variables to different parts of your component:
+
+- [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data) - Provides variables to your HTML template
+- [`get_js_data()`](../../../reference/api/#django_components.Component.get_js_data) - Provides variables to your JavaScript code
+- [`get_css_data()`](../../../reference/api/#django_components.Component.get_css_data) - Provides variables to your CSS styles
+
+These methods let you pre-process inputs before they're used in rendering.
+
+Each method handles the data independently - you can define different data for the template, JS, and CSS.
+
+```python
+class ProfileCard(Component):
+ class Kwargs(NamedTuple):
+ user_id: int
+ show_details: bool
+
+ class Defaults:
+ show_details = True
+
+ def get_template_data(self, args, kwargs: Kwargs, slots, context):
+ user = User.objects.get(id=kwargs.user_id)
+ return {
+ "user": user,
+ "show_details": kwargs.show_details,
+ }
+
+ def get_js_data(self, args, kwargs: Kwargs, slots, context):
+ return {
+ "user_id": kwargs.user_id,
+ }
+
+ def get_css_data(self, args, kwargs: Kwargs, slots, context):
+ text_color = "red" if kwargs.show_details else "blue"
+ return {
+ "text_color": text_color,
+ }
+```
+
+## Template variables
+
+The [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data) method is the primary way to provide variables to your HTML template. It receives the component inputs and returns a dictionary of data that will be available in the template.
+
+If [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data) returns `None`, an empty dictionary will be used.
+
+```python
+class ProfileCard(Component):
+ template_file = "profile_card.html"
+
+ class Kwargs(NamedTuple):
+ user_id: int
+ show_details: bool
+
+ def get_template_data(self, args, kwargs: Kwargs, slots, context):
+ user = User.objects.get(id=kwargs.user_id)
+
+ # Process and transform inputs
+ return {
+ "user": user,
+ "show_details": kwargs.show_details,
+ "user_joined_days": (timezone.now() - user.date_joined).days,
+ }
+```
+
+In your template, you can then use these variables:
+
+```django
+
+
{{ user.username }}
+
+ {% if show_details %}
+
Member for {{ user_joined_days }} days
+
Email: {{ user.email }}
+ {% endif %}
+
+```
+
+### Legacy `get_context_data()`
+
+The [`get_context_data()`](../../../reference/api/#django_components.Component.get_context_data) method is the legacy way to provide variables to your HTML template. It serves the same purpose as [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data) - it receives the component inputs and returns a dictionary of data that will be available in the template.
+
+However, [`get_context_data()`](../../../reference/api/#django_components.Component.get_context_data) has a few drawbacks:
+
+- It does NOT receive the `slots` and `context` parameters.
+- The `args` and `kwargs` parameters are given as variadic `*args` and `**kwargs` parameters. As such, they cannot be typed.
+
+```python
+class ProfileCard(Component):
+ template_file = "profile_card.html"
+
+ def get_context_data(self, user_id, show_details=False, *args, **kwargs):
+ user = User.objects.get(id=user_id)
+ return {
+ "user": user,
+ "show_details": show_details,
+ }
+```
+
+There is a slight difference between [`get_context_data()`](../../../reference/api/#django_components.Component.get_context_data) and [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data)
+when rendering a component with the [`{% component %}`](../../../reference/template_tags/#component) tag.
+
+For example if you have component that accepts kwarg `date`:
+
+```py
+class MyComponent(Component):
+ def get_context_data(self, date, *args, **kwargs):
+ return {
+ "date": date,
+ }
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "date": kwargs["date"],
+ }
+```
+
+The difference is that:
+
+- With [`get_context_data()`](../../../reference/api/#django_components.Component.get_context_data), you can pass `date` either as arg or kwarg:
+
+ ```django
+ ✅
+ {% component "my_component" date=some_date %}
+ {% component "my_component" some_date %}
+ ```
+
+- But with [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data), `date` MUST be passed as kwarg:
+
+ ```django
+ ✅
+ {% component "my_component" date=some_date %}
+
+ ❌
+ {% component "my_component" some_date %}
+ ```
+
+!!! warning
+
+ [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data)
+ and [`get_context_data()`](../../../reference/api/#django_components.Component.get_context_data)
+ are mutually exclusive.
+
+ If both methods return non-empty dictionaries, an error will be raised.
+
+!!! note
+
+ The `get_context_data()` method will be removed in v2.
+
+## Accessing component inputs
+
+The component inputs are available in 3 ways:
+
+### Function arguments
+
+The data methods receive the inputs as parameters directly.
+
+```python
+class ProfileCard(Component):
+ # Access inputs directly as parameters
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "user_id": args[0],
+ "show_details": kwargs["show_details"],
+ }
+```
+
+!!! info
+
+ By default, the `args` parameter is a list, while `kwargs` and `slots` are dictionaries.
+
+ If you add typing to your component with
+ [`Args`](../../../reference/api/#django_components.Component.Args),
+ [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+ or [`Slots`](../../../reference/api/#django_components.Component.Slots) classes,
+ the respective inputs will be given as instances of these classes.
+
+ Learn more about [Component typing](../../fundamentals/typing_and_validation).
+
+ ```py
+ class ProfileCard(Component):
+ class Args(NamedTuple):
+ user_id: int
+
+ class Kwargs(NamedTuple):
+ show_details: bool
+
+ # Access inputs directly as parameters
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots, context):
+ return {
+ "user_id": args.user_id,
+ "show_details": kwargs.show_details,
+ }
+ ```
+
+### `args`, `kwargs`, `slots` properties
+
+In other methods, you can access the inputs via
+[`self.args`](../../../reference/api/#django_components.Component.args),
+[`self.kwargs`](../../../reference/api/#django_components.Component.kwargs),
+and [`self.slots`](../../../reference/api/#django_components.Component.slots) properties:
+
+```py
+class ProfileCard(Component):
+ def on_render_before(self, context: Context, template: Optional[Template]):
+ # Access inputs via self.args, self.kwargs, self.slots
+ self.args[0]
+ self.kwargs.get("show_details", False)
+ self.slots["footer"]
+```
+
+!!! info
+
+ These properties work the same way as `args`, `kwargs`, and `slots` parameters in the data methods:
+
+ By default, the `args` property is a list, while `kwargs` and `slots` are dictionaries.
+
+ If you add typing to your component with
+ [`Args`](../../../reference/api/#django_components.Component.Args),
+ [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+ or [`Slots`](../../../reference/api/#django_components.Component.Slots) classes,
+ the respective inputs will be given as instances of these classes.
+
+ Learn more about [Component typing](../../fundamentals/typing_and_validation).
+
+ ```py
+ class ProfileCard(Component):
+ class Args(NamedTuple):
+ user_id: int
+
+ class Kwargs(NamedTuple):
+ show_details: bool
+
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots, context):
+ return {
+ "user_id": self.args.user_id,
+ "show_details": self.kwargs.show_details,
+ }
+ ```
+
+
+
+### `input` property (low-level)
+
+!!! warning
+
+ The `input` property is deprecated and will be removed in v1.
+
+ Instead, use properties defined on the
+ [`Component`](../../../reference/api/#django_components.Component) class
+ directly like
+ [`self.context`](../../../reference/api/#django_components.Component.context).
+
+ To access the unmodified inputs, use
+ [`self.raw_args`](../../../reference/api/#django_components.Component.raw_args),
+ [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs),
+ and [`self.raw_slots`](../../../reference/api/#django_components.Component.raw_slots) properties.
+
+The previous two approaches allow you to access only the most important inputs.
+
+There are additional settings that may be passed to components.
+If you need to access these, you can use [`self.input`](../../../reference/api/#django_components.Component.input) property
+for a low-level access to all the inputs.
+
+The `input` property contains all the inputs passed to the component (instance of [`ComponentInput`](../../../reference/api/#django_components.ComponentInput)).
+
+This includes:
+
+- [`input.args`](../../../reference/api/#django_components.ComponentInput.args) - List of positional arguments
+- [`input.kwargs`](../../../reference/api/#django_components.ComponentInput.kwargs) - Dictionary of keyword arguments
+- [`input.slots`](../../../reference/api/#django_components.ComponentInput.slots) - Dictionary of slots. Values are normalized to [`Slot`](../../../reference/api/#django_components.Slot) instances
+- [`input.context`](../../../reference/api/#django_components.ComponentInput.context) - [`Context`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context) object that should be used to render the component
+- [`input.type`](../../../reference/api/#django_components.ComponentInput.type) - The type of the component (document, fragment)
+- [`input.render_dependencies`](../../../reference/api/#django_components.ComponentInput.render_dependencies) - Whether to render dependencies (CSS, JS)
+
+```python
+class ProfileCard(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ # Access positional arguments
+ user_id = self.input.args[0] if self.input.args else None
+
+ # Access keyword arguments
+ show_details = self.input.kwargs.get("show_details", False)
+
+ # Render component differently depending on the type
+ if self.input.type == "fragment":
+ ...
+
+ return {
+ "user_id": user_id,
+ "show_details": show_details,
+ }
+```
+
+!!! info
+
+ Unlike the parameters passed to the data methods, the `args`, `kwargs`, and `slots` in `self.input` property are always lists and dictionaries,
+ regardless of whether you added typing classes to your component (like [`Args`](../../../reference/api/#django_components.Component.Args),
+ [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+ or [`Slots`](../../../reference/api/#django_components.Component.Slots)).
+
+## Default values
+
+You can use [`Defaults`](../../../reference/api/#django_components.Component.Defaults) class to provide default values for your inputs.
+
+These defaults will be applied either when:
+
+- The input is not provided at rendering time
+- The input is provided as `None`
+
+When you then access the inputs in your data methods, the default values will be already applied.
+
+Read more about [Component Defaults](./component_defaults.md).
+
+```py
+from django_components import Component, Default, register
+
+@register("profile_card")
+class ProfileCard(Component):
+ class Kwargs(NamedTuple):
+ show_details: bool
+
+ class Defaults:
+ show_details = True
+
+ # show_details will be set to True if `None` or missing
+ def get_template_data(self, args, kwargs: Kwargs, slots, context):
+ return {
+ "show_details": kwargs.show_details,
+ }
+
+ ...
+```
+
+!!! warning
+
+ When typing your components with [`Args`](../../../reference/api/#django_components.Component.Args),
+ [`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+ or [`Slots`](../../../reference/api/#django_components.Component.Slots) classes,
+ you may be inclined to define the defaults in the classes.
+
+ ```py
+ class ProfileCard(Component):
+ class Kwargs(NamedTuple):
+ show_details: bool = True
+ ```
+
+ This is **NOT recommended**, because:
+
+ - The defaults will NOT be applied to inputs when using [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs) property.
+ - The defaults will NOT be applied when a field is given but set to `None`.
+
+ Instead, define the defaults in the [`Defaults`](../../../reference/api/#django_components.Component.Defaults) class.
+
+## Accessing Render API
+
+All three data methods have access to the Component's [Render API](../render_api), which includes:
+
+- [`self.args`](../render_api/#args) - The positional arguments for the current render call
+- [`self.kwargs`](../render_api/#kwargs) - The keyword arguments for the current render call
+- [`self.slots`](../render_api/#slots) - The slots for the current render call
+- [`self.raw_args`](../render_api/#args) - Unmodified positional arguments for the current render call
+- [`self.raw_kwargs`](../render_api/#kwargs) - Unmodified keyword arguments for the current render call
+- [`self.raw_slots`](../render_api/#slots) - Unmodified slots for the current render call
+- [`self.context`](../render_api/#context) - The context for the current render call
+- [`self.id`](../render_api/#component-id) - The unique ID for the current render call
+- [`self.request`](../render_api/#request-and-context-processors) - The request object
+- [`self.context_processors_data`](../render_api/#request-and-context-processors) - Data from Django's context processors
+- [`self.inject()`](../render_api/#provide-inject) - Inject data into the component
+- [`self.registry`](../render_api/#template-tag-metadata) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance
+- [`self.registered_name`](../render_api/#template-tag-metadata) - The name under which the component was registered
+- [`self.outer_context`](../render_api/#template-tag-metadata) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag
+- `self.deps_strategy` - The strategy for rendering dependencies
+
+## Type hints
+
+### Typing inputs
+
+You can add type hints for the component inputs to ensure that the component logic is correct.
+
+For this, define the [`Args`](../../../reference/api/#django_components.Component.Args),
+[`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+and [`Slots`](../../../reference/api/#django_components.Component.Slots) classes,
+and then add type hints to the data methods.
+
+This will also validate the inputs at runtime, as the type classes will be instantiated with the inputs.
+
+Read more about [Component typing](../../fundamentals/typing_and_validation).
+
+```python
+from typing import NamedTuple, Optional
+from django_components import Component, SlotInput
+
+class Button(Component):
+ class Args(NamedTuple):
+ name: str
+
+ class Kwargs(NamedTuple):
+ surname: str
+ maybe_var: Optional[int] = None # May be omitted
+
+ class Slots(NamedTuple):
+ my_slot: Optional[SlotInput] = None
+ footer: SlotInput
+
+ # Use the above classes to add type hints to the data method
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
+ # The parameters are instances of the classes we defined
+ assert isinstance(args, Button.Args)
+ assert isinstance(kwargs, Button.Kwargs)
+ assert isinstance(slots, Button.Slots)
+```
+
+!!! note
+
+ To access "untyped" inputs, use [`self.raw_args`](../../../reference/api/#django_components.Component.raw_args),
+ [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs),
+ and [`self.raw_slots`](../../../reference/api/#django_components.Component.raw_slots) properties.
+
+ These are plain lists and dictionaries, even when you added typing to your component.
+
+### Typing data
+
+In the same fashion, you can add types and validation for the data that should be RETURNED from each data method.
+
+For this, set the [`TemplateData`](../../../reference/api/#django_components.Component.TemplateData),
+[`JsData`](../../../reference/api/#django_components.Component.JsData),
+and [`CssData`](../../../reference/api/#django_components.Component.CssData) classes on the component class.
+
+For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class.
+
+```python
+from typing import NamedTuple
+from django_components import Component
+
+class Button(Component):
+ class TemplateData(NamedTuple):
+ data1: str
+ data2: int
+
+ class JsData(NamedTuple):
+ js_data1: str
+ js_data2: int
+
+ class CssData(NamedTuple):
+ css_data1: str
+ css_data2: int
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return Button.TemplateData(
+ data1="...",
+ data2=123,
+ )
+
+ def get_js_data(self, args, kwargs, slots, context):
+ return Button.JsData(
+ js_data1="...",
+ js_data2=123,
+ )
+
+ def get_css_data(self, args, kwargs, slots, context):
+ return Button.CssData(
+ css_data1="...",
+ css_data2=123,
+ )
+```
+
+## Pass-through kwargs
+
+It's best practice to explicitly define what args and kwargs a component accepts.
+
+However, if you want a looser setup, you can easily write components that accept any number
+of kwargs, and pass them all to the template
+(similar to [django-cotton](https://github.com/wrabit/django-cotton)).
+
+To do that, simply return the `kwargs` dictionary itself from [`get_template_data()`](../../../reference/api/#django_components.Component.get_template_data):
+
+```py
+class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return kwargs
+```
+
+You can do the same for [`get_js_data()`](../../../reference/api/#django_components.Component.get_js_data) and [`get_css_data()`](../../../reference/api/#django_components.Component.get_css_data), if needed:
+
+```py
+class MyComponent(Component):
+ def get_js_data(self, args, kwargs, slots, context):
+ return kwargs
+
+ def get_css_data(self, args, kwargs, slots, context):
+ return kwargs
+```
diff --git a/docs/concepts/fundamentals/http_request.md b/docs/concepts/fundamentals/http_request.md
new file mode 100644
index 00000000..148fdc7b
--- /dev/null
+++ b/docs/concepts/fundamentals/http_request.md
@@ -0,0 +1,97 @@
+The most common use of django-components is to render HTML when the server receives a request. As such,
+there are a few features that are dependent on the request object.
+
+## Passing the HttpRequest object
+
+In regular Django templates, the request object is available only within the [`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext).
+
+In Components, you can either use [`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext), or pass the `request` object
+explicitly to [`Component.render()`](../../../reference/api#django_components.Component.render) and
+[`Component.render_to_response()`](../../../reference/api#django_components.Component.render_to_response).
+
+So the request object is available to components either when:
+
+- The component is rendered with [`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext) (Regular Django behavior)
+- The component is rendered with a regular [`Context`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context) (or none), but you set the `request` kwarg
+ of [`Component.render()`](../../../reference/api#django_components.Component.render).
+- The component is nested and the parent has access to the request object.
+
+```python
+# ✅ With request
+MyComponent.render(request=request)
+MyComponent.render(context=RequestContext(request, {}))
+
+# ❌ Without request
+MyComponent.render()
+MyComponent.render(context=Context({}))
+```
+
+When a component is rendered within a template with [`{% component %}`](../../../reference/template_tags#component) tag, the request object is available depending on whether the template is rendered with [`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext) or not.
+
+```python
+template = Template("""
+
+ {% component "MyComponent" / %}
+
+""")
+
+# ❌ No request
+rendered = template.render(Context({}))
+
+# ✅ With request
+rendered = template.render(RequestContext(request, {}))
+```
+
+## Accessing the HttpRequest object
+
+When the component has access to the `request` object, the request object will be available in [`Component.request`](../../../reference/api/#django_components.Component.request).
+
+```python
+class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ 'user_id': self.request.GET['user_id'],
+ }
+```
+
+## Context Processors
+
+Components support Django's [context processors](https://docs.djangoproject.com/en/5.2/ref/templates/api/#using-requestcontext).
+
+In regular Django templates, the context processors are applied only when the template is rendered with [`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext).
+
+In Components, the context processors are applied when the component has access to the `request` object.
+
+### Accessing context processors data
+
+The data from context processors is automatically available within the component's template.
+
+```djc_py
+class MyComponent(Component):
+ template = """
+
+ {{ csrf_token }}
+
+ """
+
+MyComponent.render(request=request)
+```
+
+You can also access the context processors data from within [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data) and other methods under [`Component.context_processors_data`](../../../reference/api#django_components.Component.context_processors_data).
+
+```python
+class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ csrf_token = self.context_processors_data['csrf_token']
+ return {
+ 'csrf_token': csrf_token,
+ }
+```
+
+This is a dictionary with the context processors data.
+
+If the request object is not available, then [`self.context_processors_data`](../../../reference/api/#django_components.Component.context_processors_data) will be an empty dictionary.
+
+!!! warning
+
+ The [`self.context_processors_data`](../../../reference/api/#django_components.Component.context_processors_data) object is generated dynamically, so changes to it are not persisted.
diff --git a/docs/concepts/fundamentals/render_api.md b/docs/concepts/fundamentals/render_api.md
new file mode 100644
index 00000000..d41708d4
--- /dev/null
+++ b/docs/concepts/fundamentals/render_api.md
@@ -0,0 +1,376 @@
+When a component is being rendered, whether with [`Component.render()`](../../../reference/api#django_components.Component.render)
+or [`{% component %}`](../../../reference/template_tags#component), a component instance is populated with the current inputs and context. This allows you to access things like component inputs.
+
+We refer to these render-time-only methods and attributes as the "Render API".
+
+Render API is available inside these [`Component`](../../../reference/api#django_components.Component) methods:
+
+- [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data)
+- [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data)
+- [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data)
+- [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
+- [`on_render_before()`](../../../reference/api#django_components.Component.on_render_before)
+- [`on_render()`](../../../reference/api#django_components.Component.on_render)
+- [`on_render_after()`](../../../reference/api#django_components.Component.on_render_after)
+
+Example:
+
+```python
+class Table(Component):
+ def on_render_before(self, context, template):
+ # Access component's ID
+ assert self.id == "c1A2b3c"
+
+ # Access component's inputs, slots and context
+ assert self.args == [123, "str"]
+ assert self.kwargs == {"variable": "test", "another": 1}
+ footer_slot = self.slots["footer"]
+ some_var = self.context["some_var"]
+
+ def get_template_data(self, args, kwargs, slots, context):
+ # Access the request object and Django's context processors, if available
+ assert self.request.GET == {"query": "something"}
+ assert self.context_processors_data['user'].username == "admin"
+
+rendered = Table.render(
+ kwargs={"variable": "test", "another": 1},
+ args=(123, "str"),
+ slots={"footer": "MY_SLOT"},
+)
+```
+
+## Overview
+
+The Render API includes:
+
+- Component inputs:
+ - [`self.args`](../render_api/#args) - The positional arguments for the current render call
+ - [`self.kwargs`](../render_api/#kwargs) - The keyword arguments for the current render call
+ - [`self.slots`](../render_api/#slots) - The slots for the current render call
+ - [`self.raw_args`](../render_api/#args) - Unmodified positional arguments for the current render call
+ - [`self.raw_kwargs`](../render_api/#kwargs) - Unmodified keyword arguments for the current render call
+ - [`self.raw_slots`](../render_api/#slots) - Unmodified slots for the current render call
+ - [`self.context`](../render_api/#context) - The context for the current render call
+ - [`self.deps_strategy`](../../advanced/rendering_js_css#dependencies-strategies) - The strategy for rendering dependencies
+
+- Request-related:
+ - [`self.request`](../render_api/#request-and-context-processors) - The request object (if available)
+ - [`self.context_processors_data`](../render_api/#request-and-context-processors) - Data from Django's context processors
+
+- Provide / inject:
+ - [`self.inject()`](../render_api/#provide-inject) - Inject data into the component
+
+- Template tag metadata:
+ - [`self.node`](../render_api/#template-tag-metadata) - The [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) instance
+ - [`self.registry`](../render_api/#template-tag-metadata) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance
+ - [`self.registered_name`](../render_api/#template-tag-metadata) - The name under which the component was registered
+ - [`self.outer_context`](../render_api/#template-tag-metadata) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag
+
+- Other metadata:
+ - [`self.id`](../render_api/#component-id) - The unique ID for the current render call
+
+## Component inputs
+
+### Args
+
+The `args` argument as passed to
+[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
+
+If you defined the [`Component.Args`](../../../reference/api/#django_components.Component.Args) class,
+then the [`Component.args`](../../../reference/api/#django_components.Component.args) property will return an instance of that class.
+
+Otherwise, `args` will be a plain list.
+
+Use [`self.raw_args`](../../../reference/api/#django_components.Component.raw_args)
+to access the positional arguments as a plain list irrespective of [`Component.Args`](../../../reference/api/#django_components.Component.Args).
+
+**Example:**
+
+With `Args` class:
+
+```python
+from django_components import Component
+
+class Table(Component):
+ class Args(NamedTuple):
+ page: int
+ per_page: int
+
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert self.args.page == 123
+ assert self.args.per_page == 10
+
+rendered = Table.render(
+ args=[123, 10],
+)
+```
+
+Without `Args` class:
+
+```python
+from django_components import Component
+
+class Table(Component):
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert self.args[0] == 123
+ assert self.args[1] == 10
+```
+
+### Kwargs
+
+The `kwargs` argument as passed to
+[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
+
+If you defined the [`Component.Kwargs`](../../../reference/api/#django_components.Component.Kwargs) class,
+then the [`Component.kwargs`](../../../reference/api/#django_components.Component.kwargs) property will return an instance of that class.
+
+Otherwise, `kwargs` will be a plain dictionary.
+
+Use [`self.raw_kwargs`](../../../reference/api/#django_components.Component.raw_kwargs)
+to access the keyword arguments as a plain dictionary irrespective of [`Component.Kwargs`](../../../reference/api/#django_components.Component.Kwargs).
+
+**Example:**
+
+With `Kwargs` class:
+
+```python
+from django_components import Component
+
+class Table(Component):
+ class Kwargs(NamedTuple):
+ page: int
+ per_page: int
+
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert self.kwargs.page == 123
+ assert self.kwargs.per_page == 10
+
+rendered = Table.render(
+ kwargs={"page": 123, "per_page": 10},
+)
+```
+
+Without `Kwargs` class:
+
+```python
+from django_components import Component
+
+class Table(Component):
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert self.kwargs["page"] == 123
+ assert self.kwargs["per_page"] == 10
+```
+
+### Slots
+
+The `slots` argument as passed to
+[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
+
+If you defined the [`Component.Slots`](../../../reference/api/#django_components.Component.Slots) class,
+then the [`Component.slots`](../../../reference/api/#django_components.Component.slots) property will return an instance of that class.
+
+Otherwise, `slots` will be a plain dictionary.
+
+Use [`self.raw_slots`](../../../reference/api/#django_components.Component.raw_slots)
+to access the slots as a plain dictionary irrespective of [`Component.Slots`](../../../reference/api/#django_components.Component.Slots).
+
+**Example:**
+
+With `Slots` class:
+
+```python
+from django_components import Component, Slot, SlotInput
+
+class Table(Component):
+ class Slots(NamedTuple):
+ header: SlotInput
+ footer: SlotInput
+
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert isinstance(self.slots.header, Slot)
+ assert isinstance(self.slots.footer, Slot)
+
+rendered = Table.render(
+ slots={
+ "header": "MY_HEADER",
+ "footer": lambda ctx: "FOOTER: " + ctx.data["user_id"],
+ },
+)
+```
+
+Without `Slots` class:
+
+```python
+from django_components import Component, Slot, SlotInput
+
+class Table(Component):
+ def on_render_before(self, context: Context, template: Optional[Template]) -> None:
+ assert isinstance(self.slots["header"], Slot)
+ assert isinstance(self.slots["footer"], Slot)
+```
+
+### Context
+
+The `context` argument as passed to
+[`Component.get_template_data()`](../../../reference/api/#django_components.Component.get_template_data).
+
+This is Django's [Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
+with which the component template is rendered.
+
+If the root component or template was rendered with
+[`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext)
+then this will be an instance of `RequestContext`.
+
+Whether the context variables defined in `context` are available to the template depends on the
+[context behavior mode](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior):
+
+- In `"django"` context behavior mode, the template will have access to the keys of this context.
+
+- In `"isolated"` context behavior mode, the template will NOT have access to this context,
+ and data MUST be passed via component's args and kwargs.
+
+## Component ID
+
+Component ID (or render ID) is a unique identifier for the current render call.
+
+That means that if you call [`Component.render()`](../../../reference/api#django_components.Component.render)
+multiple times, the ID will be different for each call.
+
+It is available as [`self.id`](../../../reference/api#django_components.Component.id).
+
+The ID is a 7-letter alphanumeric string in the format `cXXXXXX`,
+where `XXXXXX` is a random string of 6 alphanumeric characters (case-sensitive).
+
+E.g. `c1a2b3c`.
+
+A single render ID has a chance of collision 1 in 57 billion. However, due to birthday paradox, the chance of collision increases to 1% when approaching ~33K render IDs.
+
+Thus, there is currently a soft-cap of ~30K components rendered on a single page.
+
+If you need to expand this limit, please open an issue on GitHub.
+
+```python
+class Table(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ # Access component's ID
+ assert self.id == "c1A2b3c"
+```
+
+## Request and context processors
+
+Components have access to the request object and context processors data if the component was:
+
+- Given a [`request`](../../../reference/api/#django_components.Component.render) kwarg directly
+- Rendered with [`RenderContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext)
+- Nested in another component for which any of these conditions is true
+
+Then the request object will be available in [`self.request`](../../../reference/api/#django_components.Component.request).
+
+If the request object is available, you will also be able to access the [`context processors`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#configuring-an-engine) data in [`self.context_processors_data`](../../../reference/api/#django_components.Component.context_processors_data).
+
+This is a dictionary with the context processors data.
+
+If the request object is not available, then [`self.context_processors_data`](../../../reference/api/#django_components.Component.context_processors_data) will be an empty dictionary.
+
+Read more about the request object and context processors in the [HTTP Request](./http_request.md) section.
+
+```python
+from django.http import HttpRequest
+
+class Table(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ # Access the request object and Django's context processors
+ assert self.request.GET == {"query": "something"}
+ assert self.context_processors_data['user'].username == "admin"
+
+rendered = Table.render(
+ request=HttpRequest(),
+)
+```
+
+## Provide / Inject
+
+Components support a provide / inject system as known from Vue or React.
+
+When rendering the component, you can call [`self.inject()`](../../../reference/api/#django_components.Component.inject) with the key of the data you want to inject.
+
+The object returned by [`self.inject()`](../../../reference/api/#django_components.Component.inject)
+
+To provide data to components, use the [`{% provide %}`](../../../reference/template_tags#provide) template tag.
+
+Read more about [Provide / Inject](../advanced/provide_inject.md).
+
+```python
+class Table(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ # Access provided data
+ data = self.inject("some_data")
+ assert data.some_data == "some_data"
+```
+
+## Template tag metadata
+
+If the component is rendered with [`{% component %}`](../../../reference/template_tags#component) template tag,
+the following metadata is available:
+
+- [`self.node`](../../../reference/api/#django_components.Component.node) - The [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) instance
+- [`self.registry`](../../../reference/api/#django_components.Component.registry) - The [`ComponentRegistry`](../../../reference/api/#django_components.ComponentRegistry) instance
+ that was used to render the component
+- [`self.registered_name`](../../../reference/api/#django_components.Component.registered_name) - The name under which the component was registered
+- [`self.outer_context`](../../../reference/api/#django_components.Component.outer_context) - The context outside of the [`{% component %}`](../../../reference/template_tags#component) tag
+
+ ```django
+ {% with abc=123 %}
+ {{ abc }} {# <--- This is in outer context #}
+ {% component "my_component" / %}
+ {% endwith %}
+ ```
+
+You can use these to check whether the component was rendered inside a template with [`{% component %}`](../../../reference/template_tags#component) tag
+or in Python with [`Component.render()`](../../../reference/api/#django_components.Component.render).
+
+```python
+class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ if self.registered_name is None:
+ # Do something for the render() function
+ else:
+ # Do something for the {% component %} template tag
+```
+
+You can access the [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) under [`Component.node`](../../../reference/api/#django_components.Component.node):
+
+```py
+class MyComponent(Component):
+ def get_template_data(self, context, template):
+ if self.node is not None:
+ assert self.node.name == "my_component"
+```
+
+Accessing the [`ComponentNode`](../../../reference/api/#django_components.ComponentNode) is mostly useful for extensions, which can modify their behaviour based on the source of the Component.
+
+For example, if `MyComponent` was used in another component - that is,
+with a `{% component "my_component" %}` tag
+in a template that belongs to another component - then you can use
+[`self.node.template_component`](../../../reference/api/#django_components.ComponentNode.template_component)
+to access the owner [`Component`](../../../reference/api/#django_components.Component) class.
+
+```djc_py
+class Parent(Component):
+ template: types.django_html = """
+
+ {% component "my_component" / %}
+
+ """
+
+@register("my_component")
+class MyComponent(Component):
+ def get_template_data(self, context, template):
+ if self.node is not None:
+ assert self.node.template_component == Parent
+```
+
+!!! info
+
+ `Component.node` is `None` if the component is created by [`Component.render()`](../../../reference/api/#django_components.Component.render)
+ (but you can pass in the `node` kwarg yourself).
diff --git a/docs/concepts/fundamentals/rendering_components.md b/docs/concepts/fundamentals/rendering_components.md
new file mode 100644
index 00000000..38883204
--- /dev/null
+++ b/docs/concepts/fundamentals/rendering_components.md
@@ -0,0 +1,627 @@
+Your components can be rendered either within your Django templates, or directly in Python code.
+
+## Overview
+
+Django Components provides three main methods to render components:
+
+- [`{% component %}` tag](#component-tag) - Renders the component within your Django templates
+- [`Component.render()` method](#render-method) - Renders the component to a string
+- [`Component.render_to_response()` method](#render-to-response-method) - Renders the component and wraps it in an HTTP response
+
+## `{% component %}` tag
+
+Use the [`{% component %}`](../../../reference/template_tags#component) tag to render a component within your Django templates.
+
+The [`{% component %}`](../../../reference/template_tags#component) tag takes:
+
+- Component's registered name as the first positional argument,
+- Followed by any number of positional and keyword arguments.
+
+```django
+{% load component_tags %}
+
+```
+
+To pass in slots content, you can insert [`{% fill %}`](../../../reference/template_tags#fill) tags,
+directly within the [`{% component %}`](../../../reference/template_tags#component) tag to "fill" the slots:
+
+```django
+{% component "my_table" rows=rows headers=headers %}
+ {% fill "pagination" %}
+ < 1 | 2 | 3 >
+ {% endfill %}
+{% endcomponent %}
+```
+
+You can even nest [`{% fill %}`](../../../reference/template_tags#fill) tags within
+[`{% if %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#if),
+[`{% for %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#for)
+and other tags:
+
+```django
+{% component "my_table" rows=rows headers=headers %}
+ {% if rows %}
+ {% fill "pagination" %}
+ < 1 | 2 | 3 >
+ {% endfill %}
+ {% endif %}
+{% endcomponent %}
+```
+
+!!! info "Omitting the `component` keyword"
+
+ If you would like to omit the `component` keyword, and simply refer to your
+ components by their registered names:
+
+ ```django
+ {% button name="John" job="Developer" / %}
+ ```
+
+ You can do so by setting the "shorthand" [Tag formatter](../../advanced/tag_formatters) in the settings:
+
+ ```python
+ # settings.py
+ COMPONENTS = {
+ "tag_formatter": "django_components.component_shorthand_formatter",
+ }
+ ```
+
+!!! info "Extended template tag syntax"
+
+ Unlike regular Django template tags, django-components' tags offer extra features like
+ defining literal lists and dicts, and more. Read more about [Template tag syntax](../template_tag_syntax).
+
+### Registering components
+
+For a component to be renderable with the [`{% component %}`](../../../reference/template_tags#component) tag, it must be first registered with the [`@register()`](../../../reference/api/#django_components.register) decorator.
+
+For example, if you register a component under the name `"button"`:
+
+```python
+from typing import NamedTuple
+from django_components import Component, register
+
+@register("button")
+class Button(Component):
+ template_file = "button.html"
+
+ class Kwargs(NamedTuple):
+ name: str
+ job: str
+
+ def get_template_data(self, args, kwargs, slots, context):
+ ...
+```
+
+Then you can render this component by using its registered name `"button"` in the template:
+
+```django
+{% component "button" name="John" job="Developer" / %}
+```
+
+As you can see above, the args and kwargs passed to the [`{% component %}`](../../../reference/template_tags#component) tag correspond
+to the component's input.
+
+For more details, read [Registering components](../../advanced/component_registry).
+
+!!! note "Why do I need to register components?"
+
+ TL;DR: To be able to share components as libraries, and because components can be registed with multiple registries / libraries.
+
+ Django-components allows to [share components across projects](../../advanced/component_libraries).
+
+ However, different projects may use different settings. For example, one project may prefer the "long" format:
+
+ ```django
+ {% component "button" name="John" job="Developer" / %}
+ ```
+
+ While the other may use the "short" format:
+
+ ```django
+ {% button name="John" job="Developer" / %}
+ ```
+
+ Both approaches are supported simultaneously for backwards compatibility, because django-components
+ started out with only the "long" format.
+
+ To avoid ambiguity, when you use a 3rd party library, it uses the syntax that the author
+ had configured for it.
+
+ So when you are creating a component, django-components need to know which registry the component
+ belongs to, so it knows which syntax to use.
+
+### Rendering templates
+
+If you have embedded the component in a Django template using the
+[`{% component %}`](../../reference/template_tags#component) tag:
+
+```django title="[project root]/templates/my_template.html"
+{% load component_tags %}
+
+ """)
+
+ rendered_template = template.render()
+ ```
+
+### Isolating components
+
+By default, components behave similarly to Django's
+[`{% include %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#include),
+and the template inside the component has access to the variables defined in the outer template.
+
+You can selectively isolate a component, using the `only` flag, so that the inner template
+can access only the data that was explicitly passed to it:
+
+```django
+{% component "name" positional_arg keyword_arg=value ... only / %}
+```
+
+Alternatively, you can set all components to be isolated by default, by setting
+[`context_behavior`](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior)
+to `"isolated"` in your settings:
+
+```python
+# settings.py
+COMPONENTS = {
+ "context_behavior": "isolated",
+}
+```
+
+## `render()` method
+
+The [`Component.render()`](../../../reference/api/#django_components.Component.render) method renders a component to a string.
+
+This is the equivalent of calling the [`{% component %}`](../template_tags#component) tag.
+
+```python
+from typing import NamedTuple, Optional
+from django_components import Component, SlotInput
+
+class Button(Component):
+ template_file = "button.html"
+
+ class Args(NamedTuple):
+ name: str
+
+ class Kwargs(NamedTuple):
+ surname: str
+ age: int
+
+ class Slots(NamedTuple):
+ footer: Optional[SlotInput] = None
+
+ def get_template_data(self, args, kwargs, slots, context):
+ ...
+
+Button.render(
+ args=["John"],
+ kwargs={
+ "surname": "Doe",
+ "age": 30,
+ },
+ slots={
+ "footer": "i AM A SLOT",
+ },
+)
+```
+
+[`Component.render()`](../../../reference/api/#django_components.Component.render) accepts the following arguments:
+
+- `args` - Positional arguments to pass to the component (as a list or tuple)
+- `kwargs` - Keyword arguments to pass to the component (as a dictionary)
+- `slots` - Slot content to pass to the component (as a dictionary)
+- `context` - Django context for rendering (can be a dictionary or a `Context` object)
+- `deps_strategy` - [Dependencies rendering strategy](#dependencies-rendering) (default: `"document"`)
+- `request` - [HTTP request object](../http_request), used for context processors (optional)
+
+All arguments are optional. If not provided, they default to empty values or sensible defaults.
+
+See the API reference for [`Component.render()`](../../../reference/api/#django_components.Component.render)
+for more details on the arguments.
+
+## `render_to_response()` method
+
+The [`Component.render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
+method works just like [`Component.render()`](../../../reference/api/#django_components.Component.render),
+but wraps the result in an HTTP response.
+
+It accepts all the same arguments as [`Component.render()`](../../../reference/api/#django_components.Component.render).
+
+Any extra arguments are passed to the [`HttpResponse`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpResponse)
+constructor.
+
+```python
+from typing import NamedTuple, Optional
+from django_components import Component, SlotInput
+
+class Button(Component):
+ template_file = "button.html"
+
+ class Args(NamedTuple):
+ name: str
+
+ class Kwargs(NamedTuple):
+ surname: str
+ age: int
+
+ class Slots(NamedTuple):
+ footer: Optional[SlotInput] = None
+
+ def get_template_data(self, args, kwargs, slots, context):
+ ...
+
+# Render the component to an HttpResponse
+response = Button.render_to_response(
+ args=["John"],
+ kwargs={
+ "surname": "Doe",
+ "age": 30,
+ },
+ slots={
+ "footer": "i AM A SLOT",
+ },
+ # Additional response arguments
+ status=200,
+ headers={"X-Custom-Header": "Value"},
+)
+```
+
+This method is particularly useful in view functions, as you can return the result of the component directly:
+
+```python
+def profile_view(request, user_id):
+ return Button.render_to_response(
+ kwargs={
+ "surname": "Doe",
+ "age": 30,
+ },
+ request=request,
+ )
+```
+
+### Custom response classes
+
+By default, [`Component.render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
+returns a standard Django [`HttpResponse`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.HttpResponse).
+
+You can customize this by setting the [`response_class`](../../../reference/api/#django_components.Component.response_class)
+attribute on your component:
+
+```python
+from django.http import HttpResponse
+from django_components import Component
+
+class MyHttpResponse(HttpResponse):
+ ...
+
+class MyComponent(Component):
+ response_class = MyHttpResponse
+
+response = MyComponent.render_to_response()
+assert isinstance(response, MyHttpResponse)
+```
+
+## Dependencies rendering
+
+The rendered HTML may be used in different contexts (browser, email, etc), and each may need different handling of JS and CSS scripts.
+
+[`render()`](../../../reference/api/#django_components.Component.render) and [`render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
+accept a `deps_strategy` parameter, which controls where and how the JS / CSS are inserted into the HTML.
+
+The `deps_strategy` parameter is ultimately passed to [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies).
+
+Learn more about [Rendering JS / CSS](../../advanced/rendering_js_css).
+
+There are six dependencies rendering strategies:
+
+- [`document`](../../advanced/rendering_js_css#document) (default)
+ - Smartly inserts JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `` and `` tags.
+ - Inserts extra script to allow `fragment` components to work.
+ - Assumes the HTML will be rendered in a JS-enabled browser.
+- [`fragment`](../../advanced/rendering_js_css#fragment)
+ - A lightweight HTML fragment to be inserted into a document with AJAX.
+ - Assumes the page was already rendered with `"document"` strategy.
+ - No JS / CSS included.
+- [`simple`](../../advanced/rendering_js_css#simple)
+ - Smartly insert JS / CSS into placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) or into `` and `` tags.
+ - No extra script loaded.
+- [`prepend`](../../advanced/rendering_js_css#prepend)
+ - Insert JS / CSS before the rendered HTML.
+ - Ignores the placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) and any ``/`` HTML tags.
+ - No extra script loaded.
+- [`append`](../../advanced/rendering_js_css#append)
+ - Insert JS / CSS after the rendered HTML.
+ - Ignores the placeholders ([`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies)) and any ``/`` HTML tags.
+ - No extra script loaded.
+- [`ignore`](../../advanced/rendering_js_css#ignore)
+ - HTML is left as-is. You can still process it with a different strategy later with
+ [`render_dependencies()`](../../../reference/api/#django_components.render_dependencies).
+ - Used for inserting rendered HTML into other components.
+
+!!! info
+
+ You can use the `"prepend"` and `"append"` strategies to force to output JS / CSS for components
+ that don't have neither the placeholders like [`{% component_js_dependencies %}`](../../../reference/template_tags#component_js_dependencies), nor any ``/`` HTML tags:
+
+ ```py
+ rendered = Calendar.render_to_response(
+ request=request,
+ kwargs={
+ "date": request.GET.get("date", ""),
+ },
+ deps_strategy="append",
+ )
+ ```
+
+ Renders something like this:
+
+ ```html
+
+
+ ...
+
+
+
+
+ ```
+
+## Passing context
+
+The [`render()`](../../../reference/api/#django_components.Component.render) and [`render_to_response()`](../../../reference/api/#django_components.Component.render_to_response) methods accept an optional `context` argument.
+This sets the context within which the component is rendered.
+
+When a component is rendered within a template with the [`{% component %}`](../../../reference/template_tags#component)
+tag, this will be automatically set to the
+[Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
+instance that is used for rendering the template.
+
+When you call [`Component.render()`](../../../reference/api/#django_components.Component.render) directly from Python,
+there is no context object, so you can ignore this input most of the time.
+Instead, use `args`, `kwargs`, and `slots` to pass data to the component.
+
+However, you can pass
+[`RequestContext`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.RequestContext)
+to the `context` argument, so that the component will gain access to the request object and will use
+[context processors](https://docs.djangoproject.com/en/5.2/ref/templates/api/#using-requestcontext).
+Read more on [Working with HTTP requests](../http_request).
+
+```py
+Button.render(
+ context=RequestContext(request),
+)
+```
+
+For advanced use cases, you can use `context` argument to "pre-render" the component in Python, and then
+pass the rendered output as plain string to the template. With this, the inner component is rendered as if
+it was within the template with [`{% component %}`](../../../reference/template_tags#component).
+
+```py
+class Button(Component):
+ def render(self, context, template):
+ # Pass `context` to Icon component so it is rendered
+ # as if nested within Button.
+ icon = Icon.render(
+ context=context,
+ args=["icon-name"],
+ deps_strategy="ignore",
+ )
+ # Update context with icon
+ with context.update({"icon": icon}):
+ return template.render(context)
+```
+
+!!! warning
+
+ Whether the variables defined in `context` are actually available in the template depends on the
+ [context behavior mode](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior):
+
+ - In `"django"` context behavior mode, the template will have access to the keys of this context.
+
+ - In `"isolated"` context behavior mode, the template will NOT have access to this context,
+ and data MUST be passed via component's args and kwargs.
+
+ Therefore, it's **strongly recommended** to not rely on defining variables on the context object,
+ but instead passing them through as `args` and `kwargs`
+
+ ❌ Don't do this:
+
+ ```python
+ html = ProfileCard.render(
+ context={"name": "John"},
+ )
+ ```
+
+ ✅ Do this:
+
+ ```python
+ html = ProfileCard.render(
+ kwargs={"name": "John"},
+ )
+ ```
+
+## Typing render methods
+
+Neither [`Component.render()`](../../../reference/api/#django_components.Component.render)
+nor [`Component.render_to_response()`](../../../reference/api/#django_components.Component.render_to_response)
+are typed, due to limitations of Python's type system.
+
+To add type hints, you can wrap the inputs
+in component's [`Args`](../../../reference/api/#django_components.Component.Args),
+[`Kwargs`](../../../reference/api/#django_components.Component.Kwargs),
+and [`Slots`](../../../reference/api/#django_components.Component.Slots) classes.
+
+Read more on [Typing and validation](../../fundamentals/typing_and_validation).
+
+```python
+from typing import NamedTuple, Optional
+from django_components import Component, Slot, SlotInput
+
+# Define the component with the types
+class Button(Component):
+ class Args(NamedTuple):
+ name: str
+
+ class Kwargs(NamedTuple):
+ surname: str
+ age: int
+
+ class Slots(NamedTuple):
+ my_slot: Optional[SlotInput] = None
+ footer: SlotInput
+
+# Add type hints to the render call
+Button.render(
+ args=Button.Args(
+ name="John",
+ ),
+ kwargs=Button.Kwargs(
+ surname="Doe",
+ age=30,
+ ),
+ slots=Button.Slots(
+ footer=Slot(lambda ctx: "Click me!"),
+ ),
+)
+```
+
+## Components as input
+
+django_components makes it possible to compose components in a "React-like" way,
+where you can render one component and use its output as input to another component:
+
+```python
+from django.utils.safestring import mark_safe
+
+# Render the inner component
+inner_html = InnerComponent.render(
+ kwargs={"some_data": "value"},
+ deps_strategy="ignore", # Important for nesting!
+)
+
+# Use inner component's output in the outer component
+outer_html = OuterComponent.render(
+ kwargs={
+ "content": mark_safe(inner_html), # Mark as safe to prevent escaping
+ },
+)
+```
+
+The key here is setting [`deps_strategy="ignore"`](../../advanced/rendering_js_css#ignore) for the inner component. This prevents duplicate
+rendering of JS / CSS dependencies when the outer component is rendered.
+
+When `deps_strategy="ignore"`:
+
+- No JS or CSS dependencies will be added to the output HTML
+- The component's content is rendered as-is
+- The outer component will take care of including all needed dependencies
+
+Read more about [Rendering JS / CSS](../../advanced/rendering_js_css).
+
+## Dynamic components
+
+Django components defines a special "dynamic" component ([`DynamicComponent`](../../../reference/components#django_components.components.dynamic.DynamicComponent)).
+
+Normally, you have to hard-code the component name in the template:
+
+```django
+{% component "button" / %}
+```
+
+The dynamic component allows you to dynamically render any component based on the `is` kwarg. This is similar
+to [Vue's dynamic components](https://vuejs.org/guide/essentials/component-basics#dynamic-components) (``).
+
+```django
+{% component "dynamic" is=table_comp data=table_data headers=table_headers %}
+ {% fill "pagination" %}
+ {% component "pagination" / %}
+ {% endfill %}
+{% endcomponent %}
+```
+
+The args, kwargs, and slot fills are all passed down to the underlying component.
+
+As with other components, the dynamic component can be rendered from Python:
+
+```py
+from django_components import DynamicComponent
+
+DynamicComponent.render(
+ kwargs={
+ "is": table_comp,
+ "data": table_data,
+ "headers": table_headers,
+ },
+ slots={
+ "pagination": PaginationComponent.render(
+ deps_strategy="ignore",
+ ),
+ },
+)
+```
+
+### Dynamic 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`](../../../reference/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 %}
+```
+
+## HTML fragments
+
+Django-components provides a seamless integration with HTML fragments with AJAX ([HTML over the wire](https://hotwired.dev/)),
+whether you're using jQuery, HTMX, AlpineJS, vanilla JavaScript, or other.
+
+This is achieved by the combination of the [`"document"`](../../advanced/rendering_js_css#document)
+and [`"fragment"`](../../advanced/rendering_js_css#fragment) dependencies rendering strategies.
+
+Read more about [HTML fragments](../../advanced/html_fragments) and [Rendering JS / CSS](../../advanced/rendering_js_css).
diff --git a/docs/concepts/fundamentals/secondary_js_css_files.md b/docs/concepts/fundamentals/secondary_js_css_files.md
new file mode 100644
index 00000000..ad7e5873
--- /dev/null
+++ b/docs/concepts/fundamentals/secondary_js_css_files.md
@@ -0,0 +1,484 @@
+## Overview
+
+Each component can define extra or "secondary" CSS / JS files using the nested [`Component.Media`](../../reference/api.md#django_components.Component.Media) class,
+by setting [`Component.Media.js`](../../reference/api.md#django_components.ComponentMediaInput.js) and [`Component.Media.css`](../../reference/api.md#django_components.ComponentMediaInput.css).
+
+The [main HTML / JS / CSS files](../html_js_css_files) are limited to 1 per component. This is not the case for the secondary files,
+where components can have many of them.
+
+There is also no special behavior or post-processing for these secondary files, they are loaded as is.
+
+You can use these for third-party libraries, or for shared CSS / JS files.
+
+These must be set as paths, URLs, or [custom objects](#paths-as-objects).
+
+```py
+@register("calendar")
+class Calendar(Component):
+ class Media:
+ js = [
+ "https://unpkg.com/alpinejs@3.14.7/dist/cdn.min.js",
+ "calendar/script.js",
+ ]
+ css = [
+ "https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css",
+ "calendar/style.css",
+ ]
+```
+
+!!! note
+
+ django-component's management of files is inspired by [Django's `Media` class](https://docs.djangoproject.com/en/5.2/topics/forms/media/).
+
+ To be familiar with how Django handles static files, we recommend reading also:
+
+ - [How to manage static files (e.g. images, JavaScript, CSS)](https://docs.djangoproject.com/en/5.2/howto/static-files/)
+
+## `Media` class
+
+
+
+Use the `Media` class to define secondary JS / CSS files for a component.
+
+This `Media` class behaves similarly to
+[Django's Media class](https://docs.djangoproject.com/en/5.2/topics/forms/media/#assets-as-a-static-definition):
+
+- **Static paths** - Paths are handled as static file paths, and are resolved to URLs with Django's
+ [`{% static %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#static) template tag.
+- **URLs** - A path that starts with `http`, `https`, or `/` is considered a URL. URLs are NOT resolved with [`{% static %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#static).
+- **HTML tags** - Both static paths and URLs are rendered to `
+#
+```
+
+When working with component media files, it is important to understand the difference:
+
+- `Component.Media`
+
+ - Is the "raw" media definition, or the input, which holds only the component's **own** media definition
+ - This class is NOT instantiated, it merely holds the JS / CSS files.
+
+- `Component.media`
+ - Returns all resolved media files, **including** those inherited from parent components
+ - Is an instance of [`Component.media_class`](../../reference/api.md#django_components.Component.media_class)
+
+```python
+class ParentComponent(Component):
+ class Media:
+ js = ["parent.js"]
+
+class ChildComponent(ParentComponent):
+ class Media:
+ js = ["child.js"]
+
+# Access only this component's media
+print(ChildComponent.Media.js) # ["child.js"]
+
+# Access all inherited media
+print(ChildComponent.media._js) # ["parent.js", "child.js"]
+```
+
+!!! note
+
+ You should **not** manually modify `Component.media` or `Component.Media` after the component has been resolved, as this may lead to unexpected behavior.
+
+If you want to modify the class that is instantiated for [`Component.media`](../../reference/api.md#django_components.Component.media),
+you can configure [`Component.media_class`](../../reference/api.md#django_components.Component.media_class)
+([See example](#rendering-paths)).
+
+## File paths
+
+Unlike the [main HTML / JS / CSS files](../html_js_css_files), the path definition for the secondary files are quite ergonomic.
+
+### Relative to component
+
+As seen in the [getting started example](../../getting_started/your_first_component.md), to associate HTML / JS / CSS
+files with a component, you can set them as
+[`Component.template_file`](../../reference/api.md#django_components.Component.template_file),
+[`Component.js_file`](../../reference/api.md#django_components.Component.js_file)
+and
+[`Component.css_file`](../../reference/api.md#django_components.Component.css_file) respectively:
+
+```py title="[project root]/components/calendar/calendar.py"
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ template_file = "template.html"
+ css_file = "style.css"
+ js_file = "script.js"
+```
+
+In the example above, we defined the files relative to the directory where the component file is defined.
+
+Alternatively, you can specify the file paths relative to the directories set in
+[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
+or
+[`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs).
+
+If you specify the paths relative to component's directory, django-componenents does the conversion automatically
+for you.
+
+Thus, assuming that
+[`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
+contains path `[project root]/components`, the example above is the same as writing:
+
+```py title="[project root]/components/calendar/calendar.py"
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar/template.html"
+ css_file = "calendar/style.css"
+ js_file = "calendar/script.js"
+```
+
+!!! important
+
+ **File path resolution in-depth**
+
+ At component class creation, django-components checks all file paths defined on the component (e.g. `Component.template_file`).
+
+ For each file path, it checks if the file path is relative to the component's directory.
+ And such file exists, the component's file path is re-written to be defined relative to a first matching directory
+ in [`COMPONENTS.dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.dirs)
+ or
+ [`COMPONENTS.app_dirs`](../../reference/settings.md#django_components.app_settings.ComponentsSettings.app_dirs).
+
+ **Example:**
+
+ ```py title="[root]/components/mytable/mytable.py"
+ class MyTable(Component):
+ template_file = "mytable.html"
+ ```
+
+ 1. Component `MyTable` is defined in file `[root]/components/mytable/mytable.py`.
+ 2. The component's directory is thus `[root]/components/mytable/`.
+ 3. Because `MyTable.template_file` is `mytable.html`, django-components tries to
+ resolve it as `[root]/components/mytable/mytable.html`.
+ 4. django-components checks the filesystem. If there's no such file, nothing happens.
+ 5. If there IS such file, django-components tries to rewrite the path.
+ 6. django-components searches `COMPONENTS.dirs` and `COMPONENTS.app_dirs` for a first
+ directory that contains `[root]/components/mytable/mytable.html`.
+ 7. It comes across `[root]/components/`, which DOES contain the path to `mytable.html`.
+ 8. Thus, it rewrites `template_file` from `mytable.html` to `mytable/mytable.html`.
+
+ NOTE: In case of ambiguity, the preference goes to resolving the files relative to the component's directory.
+
+### Globs
+
+Components can have many secondary files. To simplify their declaration, you can use globs.
+
+Globs MUST be relative to the component's directory.
+
+```py title="[project root]/components/calendar/calendar.py"
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ class Media:
+ js = [
+ "path/to/*.js",
+ "another/path/*.js",
+ ]
+ css = "*.css"
+```
+
+How this works is that django-components will detect that the path is a glob, and will try to resolve all files matching the glob pattern relative to the component's directory.
+
+After that, the file paths are handled the same way as if you defined them explicitly.
+
+### Supported types
+
+File paths can be any of:
+
+- `str`
+- `bytes`
+- `PathLike` (`__fspath__` method)
+- `SafeData` (`__html__` method)
+- `Callable` that returns any of the above, evaluated at class creation (`__new__`)
+
+To help with typing the union, use [`ComponentMediaInputPath`](../../../reference/api#django_components.ComponentMediaInputPath).
+
+```py
+from pathlib import Path
+
+from django.utils.safestring import mark_safe
+
+class SimpleComponent(Component):
+ class Media:
+ css = [
+ mark_safe(''),
+ Path("calendar/style1.css"),
+ "calendar/style2.css",
+ b"calendar/style3.css",
+ lambda: "calendar/style4.css",
+ ]
+ js = [
+ mark_safe(''),
+ Path("calendar/script1.js"),
+ "calendar/script2.js",
+ b"calendar/script3.js",
+ lambda: "calendar/script4.js",
+ ]
+```
+
+### Paths as objects
+
+In the example [above](#supported-types), you can see that when we used Django's
+[`mark_safe()`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe)
+to mark a string as a [`SafeString`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.SafeString),
+we had to define the URL / path as an HTML `'
+ )
+
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar/template.html"
+
+ class Media:
+ css = "calendar/style1.css"
+ js = [
+ # ',
+ self.absolute_path(path)
+ )
+ return tags
+
+@register("calendar")
+class Calendar(Component):
+ template_file = "calendar/template.html"
+ css_file = "calendar/style.css"
+ js_file = "calendar/script.js"
+
+ class Media:
+ css = "calendar/style1.css"
+ js = "calendar/script2.js"
+
+ # Override the behavior of Media class
+ media_class = MyMedia
+```
diff --git a/docs/concepts/fundamentals/single_file_components.md b/docs/concepts/fundamentals/single_file_components.md
index e4ab18c3..6f9b0765 100644
--- a/docs/concepts/fundamentals/single_file_components.md
+++ b/docs/concepts/fundamentals/single_file_components.md
@@ -1,28 +1,138 @@
----
-title: Single-file components
-weight: 1
----
+Components can be defined in a single file, inlining the HTML, JS and CSS within the Python code.
-Components can also be defined in a single file, which is useful for small components. To do this, you can use the `template`, `js`, and `css` class attributes instead of the `template_name` and `Media`. For example, here's the calendar component from above, defined in a single file:
+## Writing single file components
-```python title="[project root]/components/calendar.py"
-# In a file called [project root]/components/calendar.py
+To do this, you can use the
+[`template`](../../../reference/api#django_components.Component.template),
+[`js`](../../../reference/api#django_components.Component.js),
+and [`css`](../../../reference/api#django_components.Component.css)
+class attributes instead of the
+[`template_file`](../../../reference/api#django_components.Component.template_file),
+[`js_file`](../../../reference/api#django_components.Component.js_file),
+and [`css_file`](../../../reference/api#django_components.Component.css_file).
+
+For example, here's the calendar component from
+the [Getting started](../../getting_started/your_first_component.md) tutorial:
+
+```py title="calendar.py"
+from django_components import Component
+
+class Calendar(Component):
+ template_file = "calendar.html"
+ js_file = "calendar.js"
+ css_file = "calendar.css"
+```
+
+And here is the same component, rewritten in a single file:
+
+```djc_py title="[project root]/components/calendar.py"
from django_components import Component, register, types
@register("calendar")
class Calendar(Component):
- def get_context_data(self, date):
+ def get_template_data(self, args, kwargs, slots, context):
return {
- "date": date,
+ "date": kwargs["date"],
}
template: types.django_html = """
-
Today's date is {{ date }}
+
+ Today's date is {{ date }}
+
"""
css: types.css = """
- .calendar-component { width: 200px; background: pink; }
- .calendar-component span { font-weight: bold; }
+ .calendar {
+ width: 200px;
+ background: pink;
+ }
+ .calendar span {
+ font-weight: bold;
+ }
+ """
+
+ js: types.js = """
+ (function(){
+ if (document.querySelector(".calendar")) {
+ document.querySelector(".calendar").onclick = () => {
+ alert("Clicked calendar!");
+ };
+ }
+ })()
+ """
+```
+
+You can mix and match, so you can have a component with inlined HTML,
+while the JS and CSS are in separate files:
+
+```djc_py title="[project root]/components/calendar.py"
+from django_components import Component, register, types
+
+@register("calendar")
+class Calendar(Component):
+ js_file = "calendar.js"
+ css_file = "calendar.css"
+
+ template: types.django_html = """
+
+ Today's date is {{ date }}
+
+ """
+```
+
+## Syntax highlighting
+
+If you "inline" the HTML, JS and CSS code into the Python class, you should set up
+syntax highlighting to let your code editor know that the inlined code is HTML, JS and CSS.
+
+In the examples above, we've annotated the
+[`template`](../../../reference/api#django_components.Component.template),
+[`js`](../../../reference/api#django_components.Component.js),
+and [`css`](../../../reference/api#django_components.Component.css)
+attributes with
+the `types.django_html`, `types.js` and `types.css` types. These are used for syntax highlighting in VSCode.
+
+!!! warning
+
+ Autocompletion / intellisense does not work in the inlined code.
+
+ Help us add support for intellisense in the inlined code! Start a conversation in the
+ [GitHub Discussions](https://github.com/django-components/django-components/discussions).
+
+### VSCode
+
+1. First install [Python Inline Source Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=samwillis.python-inline-source) extension, it will give you syntax highlighting for the template, CSS, and JS.
+
+2. Next, in your component, set typings of
+[`Component.template`](../../../reference/api#django_components.Component.template),
+[`Component.js`](../../../reference/api#django_components.Component.js),
+[`Component.css`](../../../reference/api#django_components.Component.css)
+to `types.django_html`, `types.css`, and `types.js` respectively. The extension will recognize these and will activate syntax highlighting.
+
+```djc_py title="[project root]/components/calendar.py"
+from django_components import Component, register, types
+
+@register("calendar")
+class Calendar(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "date": kwargs["date"],
+ }
+
+ template: types.django_html = """
+
+ Today's date is {{ date }}
+
+ """
+
+ css: types.css = """
+ .calendar-component {
+ width: 200px;
+ background: pink;
+ }
+ .calendar-component span {
+ font-weight: bold;
+ }
"""
js: types.js = """
@@ -34,4 +144,112 @@ class Calendar(Component):
"""
```
-This makes it easy to create small components without having to create a separate template, CSS, and JS file.
+### Pycharm (or other Jetbrains IDEs)
+
+With PyCharm (or any other editor from Jetbrains), you don't need to use `types.django_html`, `types.css`, `types.js` since Pycharm uses [language injections](https://www.jetbrains.com/help/pycharm/using-language-injections.html).
+
+You only need to write the comments `# language=` above the variables.
+
+```djc_py
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "date": kwargs["date"],
+ }
+
+ # language=HTML
+ template= """
+
+ Today's date is {{ date }}
+
+ """
+
+ # language=CSS
+ css = """
+ .calendar-component {
+ width: 200px;
+ background: pink;
+ }
+ .calendar-component span {
+ font-weight: bold;
+ }
+ """
+
+ # language=JS
+ js = """
+ (function(){
+ if (document.querySelector(".calendar-component")) {
+ document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
+ }
+ })()
+ """
+```
+
+### Markdown code blocks with Pygments
+
+[Pygments](https://pygments.org/) is a syntax highlighting library written in Python. It's also what's used by this documentation site ([mkdocs-material](https://squidfunk.github.io/mkdocs-material/)) to highlight code blocks.
+
+To write code blocks with syntax highlighting, you need to install the [`pygments-djc`](https://pypi.org/project/pygments-djc/) package.
+
+```bash
+pip install pygments-djc
+```
+
+And then initialize it by importing `pygments_djc` somewhere in your project:
+
+```python
+import pygments_djc
+```
+
+Now you can use the `djc_py` code block to write code blocks with syntax highlighting for components.
+
+```txt
+\```djc_py
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ template = """
+
+ Today's date is {{ date }}
+
+ """
+
+ css = """
+ .calendar-component {
+ width: 200px;
+ background: pink;
+ }
+ .calendar-component span {
+ font-weight: bold;
+ }
+ """
+\```
+```
+
+Will be rendered as below. Notice that the CSS and HTML are highlighted correctly:
+
+```djc_py
+from django_components import Component, register
+
+@register("calendar")
+class Calendar(Component):
+ template= """
+
+ Today's date is {{ date }}
+
+ """
+
+ css = """
+ .calendar-component {
+ width: 200px;
+ background: pink;
+ }
+ .calendar-component span {
+ font-weight: bold;
+ }
+ """
+```
diff --git a/docs/concepts/fundamentals/slots.md b/docs/concepts/fundamentals/slots.md
index f1ba610a..7a65dcc4 100644
--- a/docs/concepts/fundamentals/slots.md
+++ b/docs/concepts/fundamentals/slots.md
@@ -1,51 +1,74 @@
----
-title: Slots
-weight: 6
----
+django-components has the most extensive slot system of all the popular Python templating engines.
-_New in version 0.26_:
+The slot system is based on [Vue](https://vuejs.org/guide/components/slots.html), and works across both Django templates and Python code.
-- The `slot` tag now serves only to declare new slots inside the component template.
- - To override the content of a declared slot, use the newly introduced `fill` tag instead.
-- Whereas unfilled slots used to raise a warning, filling a slot is now optional by default.
- - To indicate that a slot must be filled, the new `required` option should be added at the end of the `slot` tag.
-
----
+## What are slots?
Components support something called 'slots'.
-When a component is used inside another template, slots allow the parent template to override specific parts of the child component by passing in different content.
-This mechanism makes components more reusable and composable.
-This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/slots.html).
-In the example below we introduce two block tags that work hand in hand to make this work. These are...
+When you write a component, you define its template. The template will always be
+the same each time you render the component.
-- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template.
-- `{% fill %}`/`{% endfill %}`: (Used inside a `{% component %}` tag pair.) Fills a declared slot with the specified content.
+However, sometimes you may want to customize the component slightly to change the
+content of the component. This is where slots come in.
-Let's update our calendar component to support more customization. We'll add `slot` tag pairs to its template, _template.html_.
+Slots allow you to insert parts of HTML into the component.
+This makes components more reusable and composable.
-```htmldjango
+```django
- {% slot "body" %}Today's date is {{ date }}{% endslot %}
+ {# This is where the component will insert the content #}
+ {% slot "header" / %}
```
-When using the component, you specify which slots you want to fill and where you want to use the defaults from the template. It looks like this:
+## Slot anatomy
+
+Slots consists of two parts:
+
+1. [`{% slot %}`](../../../reference/template_tags#slot) tag - Inside your component you decide where you want to insert the content.
+2. [`{% fill %}`](../../../reference/template_tags#fill) tag - In the parent template (outside the component) you decide what content to insert into the slot.
+ It "fills" the slot with the specified content.
+
+Let's look at an example:
+
+First, we define the component template. This component contains two slots, `header` and `body`.
+
+```htmldjango
+
+
+ {% slot "body" %}
+ Today's date is {{ date }}
+ {% endslot %}
+
+
+```
+
+Next, when using the component, we can insert our own content into the slots. It looks like this:
```htmldjango
{% component "calendar" date="2020-06-06" %}
{% fill "body" %}
- Can you believe it's already {{ date }}??
+ Can you believe it's already
+ {{ date }}??
{% endfill %}
{% endcomponent %}
```
-Since the 'header' fill is unspecified, it's taken from the base template. If you put this in a template, and pass in `date=2020-06-06`, this is what gets rendered:
+Since the `'header'` fill is unspecified, it's [default value](#default-slot) is used.
+
+When rendered, notice that:
+
+- The body is filled with the content we specified,
+- The header is still the default value we defined in the component template.
```htmldjango
@@ -58,205 +81,825 @@ Since the 'header' fill is unspecified, it's taken from the base template. If yo
```
-### Named slots
+## Slots overview
-As seen in the previouse section, you can use `{% fill slot_name %}` to insert content into a specific
-slot.
+### Slot definition
-You can define fills for multiple slot simply by defining them all within the `{% component %} {% endcomponent %}`
-tags:
+Slots are defined with the [`{% slot %}`](../../../reference/template_tags#slot) tag:
-```htmldjango
-{% component "calendar" date="2020-06-06" %}
- {% fill "header" %}
- Hi this is header!
+```django
+{% slot "name" %}
+ Default content
+{% endslot %}
+```
+
+Single component can have multiple slots:
+
+```django
+{% slot "name" %}
+ Default content
+{% endslot %}
+
+{% slot "other_name" / %}
+```
+
+And you can even define the same slot in multiple places:
+
+```django
+
+```
+
+!!! info
+
+ If you define the same slot in multiple places, you must mark each slot individually
+ when setting `default` or `required` flags, e.g.:
+
+ ```htmldjango
+
+ ```
+
+### Slot filling
+
+Fill can be defined with the [`{% fill %}`](../../../reference/template_tags#fill) tag:
+
+```django
+{% component "calendar" %}
+ {% fill "name" %}
+ Filled content
{% endfill %}
- {% fill "body" %}
- Can you believe it's already {{ date }}??
+ {% fill "other_name" %}
+ Filled content
{% endfill %}
{% endcomponent %}
```
-You can also use `{% for %}`, `{% with %}`, or other non-component tags (even `{% include %}`)
-to construct the `{% fill %}` tags, **as long as these other tags do not leave any text behind!**
+Or in Python with the [`slots`](../../../reference/api#django_components.Component.render) argument:
-```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 %}
+```py
+Calendar.render(
+ slots={
+ "name": "Filled content",
+ "other_name": "Filled content",
+ },
+)
```
### Default slot
-_Added in version 0.28_
+You can make the syntax shorter by marking the slot as [`default`](../../../reference/template_tags#slot):
-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 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.
-
-The template:
-
-```htmldjango
-
- {% slot "body" default %}Today's date is {{ date }}{% endslot %}
-
-
+```django
+{% slot "name" default %}
+ Default content
+{% endslot %}
```
-Including the component (notice how the `fill` tag is omitted):
+This allows you to fill the slot directly in the [`{% component %}`](../../../reference/template_tags#component) tag,
+omitting the `{% fill %}` tag:
-```htmldjango
-{% component "calendar" date="2020-06-06" %}
- Can you believe it's already {{ date }}??
+```django
+{% component "calendar" %}
+ Filled content
{% endcomponent %}
```
-The rendered result (exactly the same as before):
+To target the default slot in Python, you can use the `"default"` slot name:
-```html
-
-
Calendar header
-
Can you believe it's already 2020-06-06??
-
+```py
+Calendar.render(
+ slots={"default": "Filled content"},
+)
```
-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.
+!!! info "Accessing default slot in Python"
-```htmldjango
-{# DON'T DO THIS #}
-{% component "calendar" date="2020-06-06" %}
- {% fill "header" %}Totally new header!{% endfill %}
- Can you believe it's already {{ date }}??
-{% endcomponent %}
-```
+ Since the default slot is stored under the slot name `default`, you can access the default slot
+ in Python under the `"default"` key:
-Instead, you can use a named fill with name `default` to target the default fill:
+ ```py
+ class MyTable(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ default_slot = slots["default"]
+ return {
+ "default_slot": default_slot,
+ }
+ ```
-```htmldjango
-{# THIS WORKS #}
-{% component "calendar" date="2020-06-06" %}
- {% fill "header" %}Totally new header!{% endfill %}
- {% fill "default" %}
+!!! warning
+
+ Only one [`{% slot %}`](../../../reference/template_tags#slot) can be marked as `default`.
+ But you can have multiple slots with the same name all marked as `default`.
+
+ If you define multiple **different** slots as `default`, this will raise an error.
+
+ ❌ Don't do this
+
+ ```django
+ {% slot "name" default %}
+ Default content
+ {% endslot %}
+ {% slot "other_name" default %}
+ Default content
+ {% endslot %}
+ ```
+
+ ✅ Do this instead
+
+ ```django
+ {% slot "name" default %}
+ Default content
+ {% endslot %}
+ {% slot "name" default %}
+ Default content
+ {% endslot %}
+ ```
+
+!!! warning
+
+ Do NOT combine default fills with explicit named [`{% fill %}`](../../../reference/template_tags#fill) tags.
+
+ The following component template will raise an error when rendered:
+
+ ❌ Don't do this
+
+ ```django
+ {% component "calendar" date="2020-06-06" %}
+ {% fill "header" %}Totally new header!{% endfill %}
Can you believe it's already {{ date }}??
+ {% endcomponent %}
+ ```
+
+ ✅ Do this instead
+
+ ```django
+ {% component "calendar" date="2020-06-06" %}
+ {% fill "header" %}Totally new header!{% endfill %}
+ {% fill "default" %}
+ Can you believe it's already {{ date }}??
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+!!! warning
+
+ You cannot double-fill a slot.
+
+ That is, if both `{% fill "default" %}` and `{% fill "header" %}` point to the same slot,
+ this will raise an error when rendered.
+
+### Required slot
+
+You can make the slot required by adding the [`required`](../../../reference/template_tags#slot) keyword:
+
+```django
+{% slot "name" required %}
+ Default content
+{% endslot %}
+```
+
+This will raise an error if the slot is not filled.
+
+### Access fills
+
+You can access the fills with the
+[`{{ component_vars.slots. }}`](../../../reference/template_vars#slots) template variable:
+
+```django
+{% if component_vars.slots.my_slot %}
+
+{% endif %}
+```
+
+And in Python with the [`Component.slots`](../../../reference/api#django_components.Component.slots) property:
+
+```py
+class Calendar(Component):
+ # `get_template_data` receives the `slots` argument directly
+ def get_template_data(self, args, kwargs, slots, context):
+ if "my_slot" in slots:
+ content = "Filled content"
+ else:
+ content = "Default content"
+
+ return {
+ "my_slot": content,
+ }
+
+ # In other methods you can still access the slots with `Component.slots`
+ def on_render_before(self, context, template):
+ if "my_slot" in self.slots:
+ # Do something
+```
+
+### Dynamic fills
+
+The slot and fill names can be set as variables. This way you can fill slots dynamically:
+
+```django
+{% with "body" as slot_name %}
+ {% component "calendar" %}
+ {% fill slot_name %}
+ Filled content
+ {% endfill %}
+ {% endcomponent %}
+{% endwith %}
+```
+
+You can even use [`{% if %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#std-templatetag-if)
+and [`{% for %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#std-templatetag-for)
+tags inside the [`{% component %}`](../../../reference/template_tags#component) tag to fill slots with more control:
+
+```django
+{% component "calendar" %}
+ {% if condition %}
+ {% fill "name" %}
+ Filled content
+ {% endfill %}
+ {% endif %}
+
+ {% for item in items %}
+ {% fill item.name %}
+ Item: {{ item.value }}
+ {% endfill %}
+ {% endfor %}
+{% endcomponent %}
+```
+
+You can also use [`{% with %}`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#std-templatetag-with)
+or even custom tags to generate the fills dynamically:
+
+```django
+{% component "calendar" %}
+ {% with item.name as name %}
+ {% fill name %}
+ Item: {{ item.value }}
+ {% endfill %}
+ {% endwith %}
+{% endcomponent %}
+```
+
+!!! warning
+
+ If you dynamically generate `{% fill %}` tags, be careful to render text only inside the `{% fill %}` tags.
+
+ Any text rendered outside `{% fill %}` tags will be considered a default fill and will raise an error
+ if combined with explicit fills. (See [Default slot](#default-slot))
+
+### Slot data
+
+Sometimes the slots need to access data from the component. Imagine an HTML table component
+which has a slot to configure how to render the rows. Each row has a different data, so you need
+to pass the data to the slot.
+
+Similarly to [Vue's scoped slots](https://vuejs.org/guide/components/slots#scoped-slots),
+you can pass data to the slot, and then access it in the fill.
+
+This consists of two steps:
+
+1. Passing data to [`{% slot %}`](../../../reference/template_tags#slot) tag
+2. Accessing data in [`{% fill %}`](../../../reference/template_tags#fill) tag
+
+The data is passed to the slot as extra keyword arguments. Below we set two extra arguments: `first_name` and `job`.
+
+```django
+{# Pass data to the slot #}
+{% slot "name" first_name="John" job="Developer" %}
+ {# Fallback implementation #}
+ Name: {{ first_name }}
+ Job: {{ job }}
+{% endslot %}
+```
+
+!!! note
+
+ `name` kwarg is already used for slot name, so you cannot pass it as slot data.
+
+To access the slot's data in the fill, use the [`data`](../../../reference/template_tags#fill) keyword. This sets the name
+of the variable that holds the data in the fill:
+
+```django
+{# Access data in the fill #}
+{% component "profile" %}
+ {% fill "name" data="d" %}
+ Hello, my name is
{{ d.first_name }}
+ and I'm a
{{ d.job }}
{% endfill %}
{% endcomponent %}
```
-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.
-
-#### Accessing default slot in Python
-
-Since the default slot is stored under the slot name `default`, you can access the default slot
-like so:
+To access the slot data in Python, use the `data` attribute in [slot functions](#slot-functions).
```py
-class MyTable(Component):
- def get_context_data(self, *args, **kwargs):
- default_slot = self.input.slots["default"]
+def my_slot(ctx):
+ return f"""
+ Hello, my name is
{ctx.data["first_name"]}
+ and I'm a
{ctx.data["job"]}
+ """
+
+Profile.render(
+ slots={
+ "name": my_slot,
+ },
+)
+```
+
+Slot data can be set also when rendering a slot in Python:
+
+```py
+slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
+
+# Render the slot
+html = slot({"name": "John"})
+```
+
+!!! info
+
+ To access slot data on a [default slot](#default-slot), you have to explictly define the `{% fill %}` tags
+ with name `"default"`.
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "default" data="slot_data" %}
+ {{ slot_data.input }}
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+!!! warning
+
+ You cannot set the `data` attribute and
+ [`fallback` attribute](#slot-fallback)
+ to the same name. This raises an error:
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "content" data="slot_var" fallback="slot_var" %}
+ {{ slot_var.input }}
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+### Slot fallback
+
+The content between the `{% slot %}..{% endslot %}` tags is the *fallback* content that
+will be rendered if no fill is given for the slot.
+
+```django
+{% slot "name" %}
+ Hello, my name is {{ name }}
+{% endslot %}
+```
+
+Sometimes you may want to keep the fallback content, but only wrap it in some other content.
+
+To do so, you can access the fallback content via the [`fallback`](../../../reference/template_tags#fill) kwarg.
+This sets the name of the variable that holds the fallback content in the fill:
+
+```django
+{% component "profile" %}
+ {% fill "name" fallback="fb" %}
+ Original content:
+
+ {{ fb }}
+
+ {% endfill %}
+{% endcomponent %}
+```
+
+To access the fallback content in Python, use the [`fallback`](../../../reference/api#django_components.SlotContext.fallback)
+attribute in [slot functions](#slot-functions).
+
+The fallback value is rendered lazily. Coerce the fallback to a string to render it.
+
+```py
+def my_slot(ctx):
+ # Coerce the fallback to a string
+ fallback = str(ctx.fallback)
+ return f"Original content: " + fallback
+
+Profile.render(
+ slots={
+ "name": my_slot,
+ },
+)
+```
+
+Fallback can be set also when rendering a slot in Python:
+
+```py
+slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
+
+# Render the slot
+html = slot({"name": "John"}, fallback="Hello, world!")
+```
+
+!!! info
+
+ To access slot fallback on a [default slot](#default-slot), you have to explictly define the `{% fill %}` tags
+ with name `"default"`.
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "default" fallback="fallback" %}
+ {{ fallback }}
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+!!! warning
+
+ You cannot set the [`data`](#slot-data) attribute and
+ `fallback` attribute
+ to the same name. This raises an error:
+
+ ```django
+ {% component "my_comp" %}
+ {% fill "content" data="slot_var" fallback="slot_var" %}
+ {{ slot_var.input }}
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+### Slot functions
+
+In Python code, slot fills can be defined as strings, functions, or
+[`Slot`](../../../reference/api#django_components.Slot) instances that wrap the two.
+Slot functions have access to slot [`data`](../../../reference/api#django_components.SlotContext.data),
+[`fallback`](../../../reference/api#django_components.SlotContext.fallback),
+and [`context`](../../../reference/api#django_components.SlotContext.context).
+
+```py
+def row_slot(ctx):
+ if ctx.data["disabled"]:
+ return ctx.fallback
+
+ item = ctx.data["item"]
+ if ctx.data["type"] == "table":
+ return f"
+ Django automatically executes functions when it comes across them in templates.
+
+ Because of this you MUST wrap the function in [`Slot`](../../../reference/api#django_components.Slot)
+ instance to prevent it from being called.
+
+ Read more about Django's [`do_not_call_in_templates`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#variables-and-lookups).
+
+## Slot class
+
+The [`Slot`](../../../reference/api#django_components.Slot) class is a wrapper around a function that can be used to fill a slot.
+
+```py
+from django_components import Component, Slot
+
+def footer(ctx):
+ return f"Hello, {ctx.data['name']}!"
+
+Table.render(
+ slots={
+ "footer": Slot(footer),
+ },
+)
```
-#### Default and required slots
+Slot class can be instantiated with a function or a string:
-If you use a slot multiple times, you can still mark the slot as `default` or `required`.
-For that, you must mark each slot individually, e.g.:
-
-```htmldjango
-
+```py
+slot1 = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
+slot2 = Slot("Hello, world!")
```
-Which you can then use as regular default slot:
+!!! warning
-```htmldjango
-{% component "calendar" date="2020-06-06" %}
-
-{% endcomponent %}
+ Passing a [`Slot`](../../../reference/api#django_components.Slot) instance to the `Slot`
+ constructor results in an error:
+
+ ```py
+ slot = Slot("Hello")
+
+ # Raises an error
+ slot2 = Slot(slot)
+ ```
+
+### Rendering slots
+
+**Python**
+
+You can render a [`Slot`](../../../reference/api#django_components.Slot) instance by simply calling it with data:
+
+```py
+slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
+
+# Render the slot with data
+html = slot({"name": "John"})
```
-Since each slot is tagged individually, you can have multiple slots
+Optionally you can pass the [fallback](#slot-fallback) value to the slot. Fallback should be a string.
+
+```py
+html = slot({"name": "John"}, fallback="Hello, world!")
+```
+
+**Template**
+
+Alternatively, you can pass the [`Slot`](../../../reference/api#django_components.Slot) instance to the
+[`{% fill %}`](../../../reference/template_tags#fill) tag:
+
+```django
+{% fill "name" body=slot / %}
+```
+
+### Slot context
+
+If a slot function is rendered by the [`{% slot %}`](../../../reference/template_tags#slot) tag,
+you can access the current [Context](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
+using the `context` attribute.
+
+```py
+class Table(Component):
+ template = """
+ {% with "abc" as my_var %}
+ {% slot "name" %}
+ Hello!
+ {% endslot %}
+ {% endwith %}
+ """
+
+def slot_func(ctx):
+ return f"Hello, {ctx.context['my_var']}!"
+
+slot = Slot(slot_func)
+html = slot()
+```
+
+!!! warning
+
+ While available, try to avoid using the `context` attribute in slot functions.
+
+ Instead, prefer using the `data` and `fallback` attributes.
+
+
+ Access to `context` may be removed in future versions (v2, v3).
+
+### Slot metadata
+
+When accessing slots from within [`Component`](../../../reference/api#django_components.Component) methods,
+the [`Slot`](../../../reference/api#django_components.Slot) instances are populated
+with extra metadata:
+
+- [`component_name`](../../../reference/api#django_components.Slot.component_name)
+- [`slot_name`](../../../reference/api#django_components.Slot.slot_name)
+- [`nodelist`](../../../reference/api#django_components.Slot.nodelist)
+- [`fill_node`](../../../reference/api#django_components.Slot.fill_node)
+- [`extra`](../../../reference/api#django_components.Slot.extra)
+
+These are populated the first time a slot is passed to a component.
+
+So if you pass the same slot through multiple nested components, the metadata will
+still point to the first component that received the slot.
+
+You can use these for debugging, such as printing out the slot's component name and slot name.
+
+**Fill node**
+
+Components or extensions can use [`Slot.fill_node`](../../../reference/api#django_components.Slot.fill_node)
+to handle slots differently based on whether the slot
+was defined in the template with [`{% fill %}`](../../../reference/template_tags#fill) and
+[`{% component %}`](../../../reference/template_tags#component) tags,
+or in the component's Python code.
+
+If the slot was created from a [`{% fill %}`](../../../reference/template_tags#fill) tag,
+this will be the [`FillNode`](../../../reference/api#django_components.FillNode) instance.
+
+If the slot was a default slot created from a [`{% component %}`](../../../reference/template_tags#component) tag,
+this will be the [`ComponentNode`](../../../reference/api#django_components.ComponentNode) instance.
+
+You can use this to find the [`Component`](../../../reference/api#django_components.Component) in whose
+template the [`{% fill %}`](../../../reference/template_tags#fill) tag was defined:
+
+```python
+class MyTable(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ footer_slot = slots.get("footer")
+ if footer_slot is not None and footer_slot.fill_node is not None:
+ owner_component = footer_slot.fill_node.template_component
+ # ...
+```
+
+**Extra**
+
+You can also pass any additional data along with the slot by setting it in [`Slot.extra`](../../../reference/api#django_components.Slot.extra):
+
+```py
+slot = Slot(
+ lambda ctx: f"Hello, {ctx.data['name']}!",
+ extra={"foo": "bar"},
+)
+```
+
+When you create a slot, you can set any of these fields too:
+
+```py
+# Either at slot creation
+slot = Slot(
+ lambda ctx: f"Hello, {ctx.data['name']}!",
+ # Optional
+ component_name="table",
+ slot_name="name",
+ extra={},
+)
+
+# Or later
+slot.component_name = "table"
+slot.slot_name = "name"
+slot.extra["foo"] = "bar"
+```
+
+Read more in [Pass slot metadata](../../advanced/extensions#pass-slot-metadata).
+
+### Slot contents
+
+Whether you create a slot from a function, a string, or from the [`{% fill %}`](../../../reference/template_tags#fill) tags,
+the [`Slot`](../../../reference/api#django_components.Slot) class normalizes its contents to a function.
+
+Use [`Slot.contents`](../../../reference/api#django_components.Slot.contents) to access the original value that was passed to the Slot constructor.
+
+```py
+slot = Slot("Hello!")
+print(slot.contents) # "Hello!"
+```
+
+If the slot was created from a string or from the [`{% fill %}`](../../../reference/template_tags#fill) tags,
+the contents will be accessible also as a Nodelist under [`Slot.nodelist`](../../../reference/api#django_components.Slot.nodelist).
+
+```py
+slot = Slot("Hello!")
+print(slot.nodelist) #
+```
+
+### Escaping slots content
+
+Slots content are automatically escaped by default to prevent XSS attacks.
+
+In other words, it's as if you would be using Django's [`escape()`](https://docs.djangoproject.com/en/5.2/ref/templates/builtins/#std-templatefilter-escape) on the slot contents / result:
+
+```python
+from django.utils.html import escape
+
+class Calendar(Component):
+ template = """
+
+ {% slot "date" default date=date / %}
+
+ """
+
+Calendar.render(
+ slots={
+ "date": escape("Hello"),
+ }
+)
+```
+
+To disable escaping, you can wrap the slot string or slot result in Django's [`mark_safe()`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe):
+
+```py
+Calendar.render(
+ slots={
+ # string
+ "date": mark_safe("Hello"),
+
+ # function
+ "date": lambda ctx: mark_safe("Hello"),
+ }
+)
+```
+
+!!! info
+
+ Read more about Django's
+ [`format_html`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.html.format_html)
+ and [`mark_safe`](https://docs.djangoproject.com/en/5.2/ref/utils/#django.utils.safestring.mark_safe).
+
+## Examples
+
+### 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):
+
+```djc_py
+class MyTable(Component):
+ template = """
+
+ {% component "child" %}
+ {% for slot_name, slot in component_vars.slots.items %}
+ {% fill name=slot_name body=slot / %}
+ {% endfor %}
+ {% endcomponent %}
+
+ """
+```
+
+### Required and default slots
+
+Since each [`{% slot %}`](../../../reference/template_tags#slot) is tagged
+with [`required`](#required-slot) and [`default`](#default-slot) 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 or name initials.
+In this example, we have a component that renders a user avatar - a small circular image with a profile picture or 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.
+the `image` slot is optional.
+
+But if neither of those are provided, you MUST fill the `image` slot.
```htmldjango
+ {# Image given, so slot is optional #}
{% if image_src %}
{% slot "image" default %}
{% endslot %}
+
+ {# Image not given, but we can make image from initials, so slot is optional #}
{% elif name_initials %}
{% slot "image" default %}
```
-### Accessing original content of slots
+### Dynamic slots in table component
-_Added in version 0.26_
+Sometimes you may want to generate slots based on the given input. One example of this is [Vuetify's table component](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column.
-> NOTE: In version 0.77, the syntax was changed from
->
-> ```django
-> {% fill "my_slot" as "alias" %} {{ alias.default }}
-> ```
->
-> to
->
-> ```django
-> {% fill "my_slot" default="slot_default" %} {{ slot_default }}
-> ```
-
-Sometimes you may want to keep the original slot, but only wrap or prepend/append content to it. To do so, you can access the default slot via the `default` kwarg.
-
-Similarly to the `data` attribute, you specify the variable name through which the default slot will be made available.
-
-For instance, let's say you're filling a slot called 'body'. To render the original slot, assign it to a variable using the `'default'` keyword. You then render this variable to insert the default content:
-
-```htmldjango
-{% component "calendar" date="2020-06-06" %}
- {% fill "body" default="body_default" %}
- {{ body_default }}. Have a great day!
- {% endfill %}
-{% endcomponent %}
-```
-
-This produces:
-
-```htmldjango
-
-
- Calendar header
-
-
- Today's date is 2020-06-06. Have a great day!
-
-
-```
-
-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._
-
-> NOTE: In version 0.70, `{% if_filled %}` tags were replaced with `{{ component_vars.is_filled }}` variables. If your slot name contained special characters, see the section [Accessing `is_filled` of slot names with special characters](#accessing-is_filled-of-slot-names-with-special-characters).
-
-In certain circumstances, you may want the behavior of slot filling to depend on
-whether or not a particular slot is filled.
-
-For example, suppose we have the following component template:
-
-```htmldjango
-
-```
-
-By default the slot named 'subtitle' is empty. Yet when the component is used without
-explicit fills, the div containing the slot is still rendered, as shown below:
-
-```html
-
-
Title
-
-
-```
-
-This may not be what you want. What if instead the outer 'subtitle' div should only
-be included when the inner slot is in fact filled?
-
-The answer is to use the `{{ component_vars.is_filled. }}` variable. You can use this together with Django's `{% if/elif/else/endif %}` tags to define a block whose contents will be rendered only if the component slot with
-the corresponding 'name' is filled.
-
-This is what our example looks like with `component_vars.is_filled`.
-
-```htmldjango
-
-```
-
-Sometimes you're not interested in whether a slot is filled, but rather that it _isn't_.
-To negate the meaning of `component_vars.is_filled`, simply treat it as boolean and negate it with `not`:
-
-```htmldjango
-{% if not component_vars.is_filled.subtitle %}
-
- {% slot "subtitle" / %}
-
-{% endif %}
-```
-
-#### Accessing `is_filled` of slot names with special characters
-
-To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`).
-
-However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`.
-
-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.:
+So if you pass columns named `name` and `age` to the table component:
```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
+[
+ {"key": "name", "title": "Name"},
+ {"key": "age", "title": "Age"},
+]
```
-### Conditional fills
-
-Similarly, you can use `{% if %}` and `{% for %}` when defining the `{% fill %}` tags, to conditionally fill the slots when using the componnet:
-
-In the example below, the `{% fill "footer" %}` fill is used only if the condition is true. If falsy, the fill is ignored, and so the `my_table` component will use its default content for the `footer` slot.
-
-```django_html
-{% component "my_table" %}
- {% if editable %}
- {% fill "footer" %}
-
- {% endfill %}
- {% endif %}
-{% endcomponent %}
-```
-
-You can even combine `{% if %}` and `{% for %}`:
-
-```django_html
-{% component "my_table" %}
- {% for header in headers %}
- {% if header != "hyperlink" %}
- {# Generate fill name like `header.my_column` #}
- {% fill name="header."|add:header" %}
- {{ header }}
- {% endfill %}
- {% endif %}
- {% endfor %}
-{% endcomponent %}
-```
-
-### Scoped slots
-
-_Added in version 0.76_:
-
-Consider a component with slot(s). This component may do some processing on the inputs, and then use the processed variable in the slot's default template:
-
-```py
-@register("my_comp")
-class MyComp(Component):
- template = """
-
- """
-
- def get_context_data(self, input):
- processed_input = do_something(input)
- return {"input": processed_input}
-```
-
-You may want to design a component so that users of your component can still access the `input` variable, so they don't have to recompute it.
-
-This behavior is called "scoped slots". This is inspired by [Vue scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots) and [scoped slots of django-web-components](https://github.com/Xzya/django-web-components/tree/master?tab=readme-ov-file#scoped-slots).
-
-Using scoped slots consists of two steps:
-
-1. Passing data to `slot` tag
-2. Accessing data in `fill` tag
-
-#### Passing data to slots
-
-To pass the data to the `slot` tag, simply pass them as keyword attributes (`key=value`):
-
-```py
-@register("my_comp")
-class MyComp(Component):
- template = """
-
- """
-
- def get_context_data(self, input):
- processed_input = do_something(input)
- return {
- "input": processed_input,
- }
-```
-
-#### Accessing slot data in fill
-
-Next, we head over to where we define a fill for this slot. Here, to access the slot data
-we set the `data` attribute to the name of the variable through which we want to access
-the slot data. In the example below, we set it to `data`:
+Then the component will accept fills named `header-name` and `header-age` (among others):
```django
-{% component "my_comp" %}
- {% fill "content" data="slot_data" %}
- {{ slot_data.input }}
- {% endfill %}
-{% endcomponent %}
+{% fill "header-name" data="data" %}
+ {{ data.value }}
+{% endfill %}
+
+{% fill "header-age" data="data" %}
+ {{ data.value }}
+{% endfill %}
```
-To access slot data on a default slot, you have to explictly define the `{% fill %}` tags.
-
-So this works:
-
-```django
-{% component "my_comp" %}
- {% fill "content" data="slot_data" %}
- {{ slot_data.input }}
- {% endfill %}
-{% endcomponent %}
-```
-
-While this does not:
-
-```django
-{% component "my_comp" data="data" %}
- {{ data.input }}
-{% endcomponent %}
-```
-
-Note: You cannot set the `data` attribute and
-[`default` attribute)](#accessing-original-content-of-slots)
-to the same name. This raises an error:
-
-```django
-{% component "my_comp" %}
- {% fill "content" data="slot_var" default="slot_var" %}
- {{ slot_var.input }}
- {% endfill %}
-{% endcomponent %}
-```
-
-#### Slot data of default slots
-
-To access data of a default slot, you can specify `{% fill name="default" %}`:
-
-```htmldjango
-{% component "my_comp" %}
- {% fill "default" data="slot_data" %}
- {{ slot_data.input }}
- {% endfill %}
-{% endcomponent %}
-```
-
-### Dynamic slots and fills
-
-Until now, we were declaring slot and fill names statically, as a string literal, e.g.
-
-```django
-{% slot "content" / %}
-```
-
-However, sometimes you may want to generate slots based on the given input. One example of this is [a table component like that of Vuetify](https://vuetifyjs.com/en/api/v-data-table/), which creates a header and an item slots for each user-defined column.
-
-In django_components you can achieve the same, simply by using a variable (or a [template expression](#use-template-tags-inside-component-inputs)) instead of a string literal:
+In django-components you can achieve the same, simply by using a variable or a [template expression](../template_tag_syntax#template-tags-inside-literal-strings) instead of a string literal:
```django
@@ -604,9 +964,9 @@ When using the component, you can either set the fill explicitly:
```django
{% component "table" headers=headers items=items %}
- {% fill "header-name" data="data" %}
- {{ data.value }}
- {% endfill %}
+ {% fill "header-name" data="data" %}
+ {{ data.value }}
+ {% endfill %}
{% endcomponent %}
```
@@ -614,16 +974,22 @@ Or also use a variable:
```django
{% component "table" headers=headers items=items %}
- {# Make only the active column bold #}
- {% fill "header-{{ active_header_name }}" data="data" %}
- {{ data.value }}
- {% endfill %}
+ {% fill "header-{{ active_header_name }}" data="data" %}
+ {{ data.value }}
+ {% endfill %}
{% endcomponent %}
```
-> NOTE: It's better to use static slot names whenever possible for clarity. The dynamic slot names should be reserved for advanced use only.
+!!! note
-Lastly, in rare cases, you can also pass the slot name via [the spread operator](#spread-operator). This is possible, because the slot name argument is actually a shortcut for a `name` keyword argument.
+ It's better to use literal slot names whenever possible for clarity.
+ The dynamic slot names should be reserved for advanced use only.
+
+### Spread operator
+
+Lastly, you can also pass the slot name through the [spread operator](../template_tag_syntax#spread-operator).
+
+When you define a slot name, it's actually a shortcut for a `name` keyword argument.
So this:
@@ -644,27 +1010,85 @@ 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
+Full example:
-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):
+```djc_py
+class MyTable(Component):
+ template = """
+ {% slot ...slot_props / %}
+ """
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "slot_props": {"name": "content", "extra_field": 123},
+ }
+```
+
+!!! info
+
+ This applies for both [`{% slot %}`](../../../reference/template_tags#slot)
+ and [`{% fill %}`](../../../reference/template_tags#fill) tags.
+
+
+
+## Legacy conditional slots
+
+> Since version 0.70, you could check if a slot was filled with
+>
+> `{{ component_vars.is_filled. }}`
+>
+> Since version 0.140, this has been deprecated and superseded with
+>
+> [`{% component_vars.slots. %}`](../../../reference/template_vars#slots)
+>
+> The `component_vars.is_filled` variable is still available, but will be removed in v1.0.
+>
+> NOTE: `component_vars.slots` no longer escapes special characters in slot names.
+
+You can use `{{ component_vars.is_filled. }}` together with Django's `{% if / elif / else / endif %}` tags
+to define a block whose contents will be rendered only if the component slot with the corresponding 'name' is filled.
+
+This is what our example looks like with `component_vars.is_filled`.
+
+```htmldjango
+
+```
+
+### Accessing `is_filled` of slot names with special characters
+
+To be able to access a slot name via `component_vars.is_filled`, the slot name needs to be composed of only alphanumeric characters and underscores (e.g. `this__isvalid_123`).
+
+However, you can still define slots with other special characters. In such case, the slot name in `component_vars.is_filled` is modified to replace all invalid characters into `_`.
+
+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 get_context_data(self, *args, **kwargs):
- return {
- "slots": self.input.slots,
- }
+ def on_render_before(self, context, template) -> None:
+ # ✅ Works
+ if self.is_filled["my_super_slot___"]:
+ # Do something
- template: """
-
- """
+ # ❌ Does not work
+ if self.is_filled["my super-slot :)"]:
+ # Do something
```
diff --git a/docs/concepts/fundamentals/subclassing_components.md b/docs/concepts/fundamentals/subclassing_components.md
new file mode 100644
index 00000000..3d1f0dcb
--- /dev/null
+++ b/docs/concepts/fundamentals/subclassing_components.md
@@ -0,0 +1,155 @@
+In larger projects, you might need to write multiple components with similar behavior.
+In such cases, you can extract shared behavior into a standalone component class to keep things
+[DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself).
+
+When subclassing a component, there's a couple of things to keep in mind:
+
+## Template, JS, and CSS inheritance
+
+When it comes to the pairs:
+
+- [`Component.template`](../../reference/api.md#django_components.Component.template)/[`Component.template_file`](../../reference/api.md#django_components.Component.template_file)
+- [`Component.js`](../../reference/api.md#django_components.Component.js)/[`Component.js_file`](../../reference/api.md#django_components.Component.js_file)
+- [`Component.css`](../../reference/api.md#django_components.Component.css)/[`Component.css_file`](../../reference/api.md#django_components.Component.css_file)
+
+inheritance follows these rules:
+
+- If a child component class defines either member of a pair (e.g., either [`template`](../../reference/api.md#django_components.Component.template) or [`template_file`](../../reference/api.md#django_components.Component.template_file)), it takes precedence and the parent's definition is ignored completely.
+- For example, if a child component defines [`template_file`](../../reference/api.md#django_components.Component.template_file), the parent's [`template`](../../reference/api.md#django_components.Component.template) or [`template_file`](../../reference/api.md#django_components.Component.template_file) will be ignored.
+- This applies independently to each pair - you can inherit the JS while overriding the template, for instance.
+
+For example:
+
+```djc_py
+class BaseCard(Component):
+ template = """
+
+ """
+
+# This class overrides parent's template and CSS, but inherits JS
+class CustomCard(BaseCard):
+ template_file = "custom_card.html"
+ css = """
+ .card {
+ border: 2px solid gold;
+ }
+ """
+```
+
+## Media inheritance
+
+The [`Component.Media`](../../reference/api.md#django_components.Component.Media) nested class follows Django's media inheritance rules:
+
+- If both parent and child define a `Media` class, the child's media will automatically include both its own and the parent's JS and CSS files.
+- This behavior can be configured using the [`extend`](../../reference/api.md#django_components.Component.Media.extend) attribute in the Media class, similar to Django's forms.
+ Read more on this in [Media inheritance](./secondary_js_css_files/#media-inheritance).
+
+For example:
+
+```python
+class BaseModal(Component):
+ template = "
Modal content
"
+
+ class Media:
+ css = ["base_modal.css"]
+ js = ["base_modal.js"] # Contains core modal functionality
+
+class FancyModal(BaseModal):
+ class Media:
+ # Will include both base_modal.css/js AND fancy_modal.css/js
+ css = ["fancy_modal.css"] # Additional styling
+ js = ["fancy_modal.js"] # Additional animations
+
+class SimpleModal(BaseModal):
+ class Media:
+ extend = False # Don't inherit parent's media
+ css = ["simple_modal.css"] # Only this CSS will be included
+ js = ["simple_modal.js"] # Only this JS will be included
+```
+
+## Opt out of inheritance
+
+For the following media attributes, when you don't want to inherit from the parent,
+but you also don't need to set the template / JS / CSS to any specific value,
+you can set these attributes to `None`.
+
+- [`template`](../../reference/api.md#django_components.Component.template) / [`template_file`](../../reference/api.md#django_components.Component.template_file)
+- [`js`](../../reference/api.md#django_components.Component.js) / [`js_file`](../../reference/api.md#django_components.Component.js_file)
+- [`css`](../../reference/api.md#django_components.Component.css) / [`css_file`](../../reference/api.md#django_components.Component.css_file)
+- [`Media`](../../reference/api.md#django_components.Component.Media) class
+
+For example:
+
+```djc_py
+class BaseForm(Component):
+ template = "..."
+ css = "..."
+ js = "..."
+
+ class Media:
+ js = ["form.js"]
+
+# Use parent's template and CSS, but no JS
+class ContactForm(BaseForm):
+ js = None
+ Media = None
+```
+
+## Regular Python inheritance
+
+All other attributes and methods (including the [`Component.View`](../../reference/api.md#django_components.ComponentView) class and its methods) follow standard Python inheritance rules.
+
+For example:
+
+```djc_py
+class BaseForm(Component):
+ template = """
+
+ """
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "form_content": self.get_form_content(),
+ "submit_text": "Submit"
+ }
+
+ def get_form_content(self):
+ return ""
+
+class ContactForm(BaseForm):
+ # Extend parent's "context"
+ # but override "submit_text"
+ def get_template_data(self, args, kwargs, slots, context):
+ context = super().get_template_data(args, kwargs, slots, context)
+ context["submit_text"] = "Send Message"
+ return context
+
+ # Completely override parent's get_form_content
+ def get_form_content(self):
+ return """
+
+
+
+ """
+```
diff --git a/docs/concepts/fundamentals/template_tag_syntax.md b/docs/concepts/fundamentals/template_tag_syntax.md
index 6d4b1949..54f59697 100644
--- a/docs/concepts/fundamentals/template_tag_syntax.md
+++ b/docs/concepts/fundamentals/template_tag_syntax.md
@@ -1,8 +1,3 @@
----
-title: Template tag syntax
-weight: 5
----
-
All template tags in django_component, like `{% component %}` or `{% slot %}`, and so on,
support extra syntax that makes it possible to write components like in Vue or React (JSX).
@@ -35,14 +30,12 @@ so are still valid:
```
-These can then be accessed inside `get_context_data` so:
+These can then be accessed inside `get_template_data` so:
```py
@register("calendar")
class Calendar(Component):
- # Since # . @ - are not valid identifiers, we have to
- # use `**kwargs` so the method can accept these args.
- def get_context_data(self, **kwargs):
+ def get_template_data(self, args, kwargs, slots, context):
return {
"date": kwargs["my-date"],
"id": kwargs["#some_id"],
@@ -90,24 +83,24 @@ Other than that, you can use spread operators multiple times, and even put keywo
In a case of conflicts, the values added later (right-most) overwrite previous values.
-## Use template tags inside component inputs
+## Template tags inside literal strings
_New in version 0.93_
When passing data around, sometimes you may need to do light transformations, like negating booleans or filtering lists.
Normally, what you would have to do is to define ALL the variables
-inside `get_context_data()`. But this can get messy if your components contain a lot of logic.
+inside `get_template_data()`. But this can get messy if your components contain a lot of logic.
```py
@register("calendar")
class Calendar(Component):
- def get_context_data(self, id: str, editable: bool):
+ def get_template_data(self, args, kwargs, slots, context):
return {
- "editable": editable,
- "readonly": not editable,
- "input_id": f"input-{id}",
- "icon_id": f"icon-{id}",
+ "editable": kwargs["editable"],
+ "readonly": not kwargs["editable"],
+ "input_id": f"input-{kwargs['id']}",
+ "icon_id": f"icon-{kwargs['id']}",
...
}
```
@@ -124,26 +117,22 @@ Instead, template tags in django_components (`{% component %}`, `{% slot %}`, `{
/ %}
```
-In the example above:
+In the example above, the component receives:
-- Component `test` receives a positional argument with value `"As positional arg "`. The comment is omitted.
-- Kwarg `title` is passed as a string, e.g. `John Doe`
-- Kwarg `id` is passed as `int`, e.g. `15`
-- Kwarg `readonly` is passed as `bool`, e.g. `False`
-- Kwarg `author` is passed as a string, e.g. `John Wick ` (Comment omitted)
+- Positional argument `"As positional arg "` (Comment omitted)
+- `title` - passed as `str`, e.g. `John Doe`
+- `id` - passed as `int`, e.g. `15`
+- `readonly` - passed as `bool`, e.g. `False`
+- `author` - passed as `str`, e.g. `John Wick ` (Comment omitted)
This is inspired by [django-cotton](https://github.com/wrabit/django-cotton#template-expressions-in-attributes).
### Passing data as string vs original values
-Sometimes you may want to use the template tags to transform
-or generate the data that is then passed to the component.
+In the example above, the kwarg `id` was passed as an integer, NOT a string.
-The data doesn't necessarily have to be strings. In the example above, the kwarg `id` was passed as an integer, NOT a string.
-
-Although the string literals for components inputs are treated as regular Django templates, there is one special case:
-
-When the string literal contains only a single template tag, with no extra text, then the value is passed as the original type instead of a string.
+When the string literal contains only a single template tag, with no extra text (and no extra whitespace),
+then the value is passed as the original type instead of a string.
Here, `page` is an integer:
@@ -202,17 +191,17 @@ of HTML attributes (usually called `attrs`) to pass to the underlying template.
In such cases, we may want to define some HTML attributes statically, and other dynamically.
But for that, we need to define this dictionary on Python side:
-```py
+```djc_py
@register("my_comp")
class MyComp(Component):
template = """
{% component "other" attrs=attrs / %}
"""
- def get_context_data(self, some_id: str):
+ def get_template_data(self, args, kwargs, slots, context):
attrs = {
"class": "pa-4 flex",
- "data-some-id": some_id,
+ "data-some-id": kwargs["some_id"],
"@click.stop": "onClickHandler",
}
return {"attrs": attrs}
@@ -229,7 +218,7 @@ as component kwargs, so we can keep all the relevant information in the template
we prefix the key with the name of the dict and `:`. So key `class` of input `attrs` becomes
`attrs:class`. And our example becomes:
-```py
+```djc_py
@register("my_comp")
class MyComp(Component):
template = """
@@ -240,8 +229,8 @@ class MyComp(Component):
/ %}
"""
- def get_context_data(self, some_id: str):
- return {"some_id": some_id}
+ def get_template_data(self, args, kwargs, slots, context):
+ return {"some_id": kwargs["some_id"]}
```
Sweet! Now all the relevant HTML is inside the template, and we can move it to a separate file with confidence:
diff --git a/docs/concepts/fundamentals/typing_and_validation.md b/docs/concepts/fundamentals/typing_and_validation.md
new file mode 100644
index 00000000..59c36de0
--- /dev/null
+++ b/docs/concepts/fundamentals/typing_and_validation.md
@@ -0,0 +1,668 @@
+## Typing overview
+
+
+
+!!! warning
+
+ In versions 0.92 to 0.139 (inclusive), the component typing was specified through generics.
+
+ Since v0.140, the types must be specified as class attributes of the Component class - `Args`, `Kwargs`, `Slots`, `TemplateData`, `JsData`, and `CssData`.
+
+ See [Migrating from generics to class attributes](#migrating-from-generics-to-class-attributes) for more info.
+
+!!! warning
+
+ Input validation was NOT part of Django Components between versions 0.136 and 0.139 (inclusive).
+
+The [`Component`](../../../reference/api#django_components.Component) class optionally accepts class attributes
+that allow you to define the types of args, kwargs, slots, as well as the data returned from the data methods.
+
+Use this to add type hints to your components, to validate the inputs at runtime, and to document them.
+
+```py
+from typing import NamedTuple, Optional
+from django.template import Context
+from django_components import Component, SlotInput
+
+class Button(Component):
+ class Args(NamedTuple):
+ size: int
+ text: str
+
+ class Kwargs(NamedTuple):
+ variable: str
+ maybe_var: Optional[int] = None # May be omitted
+
+ class Slots(NamedTuple):
+ my_slot: Optional[SlotInput] = None
+
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
+ ...
+
+ template_file = "button.html"
+```
+
+The class attributes are:
+
+- [`Args`](../../../reference/api#django_components.Component.Args) - Type for positional arguments.
+- [`Kwargs`](../../../reference/api#django_components.Component.Kwargs) - Type for keyword arguments.
+- [`Slots`](../../../reference/api#django_components.Component.Slots) - Type for slots.
+- [`TemplateData`](../../../reference/api#django_components.Component.TemplateData) - Type for data returned from [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data).
+- [`JsData`](../../../reference/api#django_components.Component.JsData) - Type for data returned from [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data).
+- [`CssData`](../../../reference/api#django_components.Component.CssData) - Type for data returned from [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
+
+You can specify as many or as few of these as you want, the rest will default to `None`.
+
+## Typing inputs
+
+You can use [`Component.Args`](../../../reference/api#django_components.Component.Args),
+[`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs),
+and [`Component.Slots`](../../../reference/api#django_components.Component.Slots) to type the component inputs.
+
+When you set these classes, at render time the `args`, `kwargs`, and `slots` parameters of the data methods
+([`get_template_data()`](../../../reference/api#django_components.Component.get_template_data),
+[`get_js_data()`](../../../reference/api#django_components.Component.get_js_data),
+[`get_css_data()`](../../../reference/api#django_components.Component.get_css_data))
+will be instances of these classes.
+
+This way, each component can have runtime validation of the inputs:
+
+- When you use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
+ or [`@dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass),
+ instantiating these classes will check ONLY for the presence of the attributes.
+- When you use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/),
+ instantiating these classes will check for the presence AND type of the attributes.
+
+If you omit the [`Args`](../../../reference/api#django_components.Component.Args),
+[`Kwargs`](../../../reference/api#django_components.Component.Kwargs), or
+[`Slots`](../../../reference/api#django_components.Component.Slots) classes,
+or set them to `None`, the inputs will be passed as plain lists or dictionaries,
+and will not be validated.
+
+```python
+from typing_extensions import NamedTuple, TypedDict
+from django.template import Context
+from django_components import Component, Slot, SlotInput
+
+# The data available to the `footer` scoped slot
+class ButtonFooterSlotData(TypedDict):
+ value: int
+
+# Define the component with the types
+class Button(Component):
+ class Args(NamedTuple):
+ name: str
+
+ class Kwargs(NamedTuple):
+ surname: str
+ age: int
+ maybe_var: Optional[int] = None # May be omitted
+
+ class Slots(NamedTuple):
+ # Use `SlotInput` to allow slots to be given as `Slot` instance,
+ # plain string, or a function that returns a string.
+ my_slot: Optional[SlotInput] = None
+ # Use `Slot` to allow ONLY `Slot` instances.
+ # The generic is optional, and it specifies the data available
+ # to the slot function.
+ footer: Slot[ButtonFooterSlotData]
+
+ # Add type hints to the data method
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
+ # The parameters are instances of the classes we defined
+ assert isinstance(args, Button.Args)
+ assert isinstance(kwargs, Button.Kwargs)
+ assert isinstance(slots, Button.Slots)
+
+ args.name # str
+ kwargs.age # int
+ slots.footer # Slot[ButtonFooterSlotData]
+
+# Add type hints to the render call
+Button.render(
+ args=Button.Args(
+ name="John",
+ ),
+ kwargs=Button.Kwargs(
+ surname="Doe",
+ age=30,
+ ),
+ slots=Button.Slots(
+ footer=Slot(lambda ctx: "Click me!"),
+ ),
+)
+```
+
+If you don't want to validate some parts, set them to `None` or omit them.
+
+The following will validate only the keyword inputs:
+
+```python
+class Button(Component):
+ # We could also omit these
+ Args = None
+ Slots = None
+
+ class Kwargs(NamedTuple):
+ name: str
+ age: int
+
+ # Only `kwargs` is instantiated. `args` and `slots` are not.
+ def get_template_data(self, args, kwargs: Kwargs, slots, context: Context):
+ assert isinstance(args, list)
+ assert isinstance(slots, dict)
+ assert isinstance(kwargs, Button.Kwargs)
+
+ args[0] # str
+ slots["footer"] # Slot[ButtonFooterSlotData]
+ kwargs.age # int
+```
+
+!!! info
+
+ Components can receive slots as strings, functions, or instances of [`Slot`](../../../reference/api#django_components.Slot).
+
+ Internally these are all normalized to instances of [`Slot`](../../../reference/api#django_components.Slot).
+
+ Therefore, the `slots` dictionary available in data methods (like
+ [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data))
+ will always be a dictionary of [`Slot`](../../../reference/api#django_components.Slot) instances.
+
+ To correctly type this dictionary, you should set the fields of `Slots` to
+ [`Slot`](../../../reference/api#django_components.Slot) or [`SlotInput`](../../../reference/api#django_components.SlotInput):
+
+ [`SlotInput`](../../../reference/api#django_components.SlotInput) is a union of `Slot`, string, and function types.
+
+## Typing data
+
+You can use [`Component.TemplateData`](../../../reference/api#django_components.Component.TemplateData),
+[`Component.JsData`](../../../reference/api#django_components.Component.JsData),
+and [`Component.CssData`](../../../reference/api#django_components.Component.CssData) to type the data returned from [`get_template_data()`](../../../reference/api#django_components.Component.get_template_data), [`get_js_data()`](../../../reference/api#django_components.Component.get_js_data), and [`get_css_data()`](../../../reference/api#django_components.Component.get_css_data).
+
+When you set these classes, at render time they will be instantiated with the data returned from these methods.
+
+This way, each component can have runtime validation of the returned data:
+
+- When you use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
+ or [`@dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass),
+ instantiating these classes will check ONLY for the presence of the attributes.
+- When you use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/),
+ instantiating these classes will check for the presence AND type of the attributes.
+
+If you omit the [`TemplateData`](../../../reference/api#django_components.Component.TemplateData),
+[`JsData`](../../../reference/api#django_components.Component.JsData), or
+[`CssData`](../../../reference/api#django_components.Component.CssData) classes,
+or set them to `None`, the validation and instantiation will be skipped.
+
+```python
+from typing import NamedTuple
+from django_components import Component
+
+class Button(Component):
+ class TemplateData(NamedTuple):
+ data1: str
+ data2: int
+
+ class JsData(NamedTuple):
+ js_data1: str
+ js_data2: int
+
+ class CssData(NamedTuple):
+ css_data1: str
+ css_data2: int
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "data1": "...",
+ "data2": 123,
+ }
+
+ def get_js_data(self, args, kwargs, slots, context):
+ return {
+ "js_data1": "...",
+ "js_data2": 123,
+ }
+
+ def get_css_data(self, args, kwargs, slots, context):
+ return {
+ "css_data1": "...",
+ "css_data2": 123,
+ }
+```
+
+For each data method, you can either return a plain dictionary with the data, or an instance of the respective data class directly.
+
+```python
+from typing import NamedTuple
+from django_components import Component
+
+class Button(Component):
+ class TemplateData(NamedTuple):
+ data1: str
+ data2: int
+
+ class JsData(NamedTuple):
+ js_data1: str
+ js_data2: int
+
+ class CssData(NamedTuple):
+ css_data1: str
+ css_data2: int
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return Button.TemplateData(
+ data1="...",
+ data2=123,
+ )
+
+ def get_js_data(self, args, kwargs, slots, context):
+ return Button.JsData(
+ js_data1="...",
+ js_data2=123,
+ )
+
+ def get_css_data(self, args, kwargs, slots, context):
+ return Button.CssData(
+ css_data1="...",
+ css_data2=123,
+ )
+```
+
+## Custom types
+
+We recommend to use [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)
+for the `Args` class, and [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple),
+[dataclasses](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass),
+or [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/)
+for `Kwargs`, `Slots`, `TemplateData`, `JsData`, and `CssData` classes.
+
+However, you can use any class, as long as they meet the conditions below.
+
+For example, here is how you can use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/)
+to validate the inputs at runtime.
+
+```py
+from django_components import Component
+from pydantic import BaseModel
+
+class Table(Component):
+ class Kwargs(BaseModel):
+ name: str
+ age: int
+
+ def get_template_data(self, args, kwargs, slots, context):
+ assert isinstance(kwargs, Table.Kwargs)
+
+Table.render(
+ kwargs=Table.Kwargs(name="John", age=30),
+)
+```
+
+### `Args` class
+
+The [`Args`](../../../reference/api#django_components.Component.Args) class
+represents a list of positional arguments. It must meet two conditions:
+
+1. The constructor for the `Args` class must accept positional arguments.
+
+ ```py
+ Args(*args)
+ ```
+
+2. The `Args` instance must be convertable to a list.
+
+ ```py
+ list(Args(1, 2, 3))
+ ```
+
+To implement the conversion to a list, you can implement the `__iter__()` method:
+
+```py
+class MyClass:
+ def __init__(self):
+ self.x = 1
+ self.y = 2
+
+ def __iter__(self):
+ return iter([('x', self.x), ('y', self.y)])
+```
+
+### Dictionary classes
+
+On the other hand, other types
+([`Kwargs`](../../../reference/api#django_components.Component.Kwargs),
+[`Slots`](../../../reference/api#django_components.Component.Slots),
+[`TemplateData`](../../../reference/api#django_components.Component.TemplateData),
+[`JsData`](../../../reference/api#django_components.Component.JsData),
+and [`CssData`](../../../reference/api#django_components.Component.CssData))
+represent dictionaries. They must meet these two conditions:
+
+1. The constructor must accept keyword arguments.
+
+ ```py
+ Kwargs(**kwargs)
+ Slots(**slots)
+ ```
+
+2. The instance must be convertable to a dictionary.
+
+ ```py
+ dict(Kwargs(a=1, b=2))
+ dict(Slots(a=1, b=2))
+ ```
+
+To implement the conversion to a dictionary, you can implement either:
+
+1. `_asdict()` method
+ ```py
+ class MyClass:
+ def __init__(self):
+ self.x = 1
+ self.y = 2
+
+ def _asdict(self):
+ return {'x': self.x, 'y': self.y}
+ ```
+
+2. Or make the class dict-like with `__iter__()` and `__getitem__()`
+ ```py
+ class MyClass:
+ def __init__(self):
+ self.x = 1
+ self.y = 2
+
+ def __iter__(self):
+ return iter([('x', self.x), ('y', self.y)])
+
+ def __getitem__(self, key):
+ return getattr(self, key)
+ ```
+
+## Passing variadic args and kwargs
+
+You may have a component that accepts any number of args or kwargs.
+
+However, this cannot be described with the current Python's typing system (as of v0.140).
+
+As a workaround:
+
+- For a variable number of positional arguments (`*args`), set a positional argument that accepts a list of values:
+
+ ```py
+ class Table(Component):
+ class Args(NamedTuple):
+ args: List[str]
+
+ Table.render(
+ args=Table.Args(args=["a", "b", "c"]),
+ )
+ ```
+
+- For a variable number of keyword arguments (`**kwargs`), set a keyword argument that accepts a dictionary of values:
+
+ ```py
+ class Table(Component):
+ class Kwargs(NamedTuple):
+ variable: str
+ another: int
+ # Pass any extra keys under `extra`
+ extra: Dict[str, any]
+
+ Table.render(
+ kwargs=Table.Kwargs(
+ variable="a",
+ another=1,
+ extra={"foo": "bar"},
+ ),
+ )
+ ```
+
+## Handling no args or no kwargs
+
+To declare that a component accepts no args, kwargs, etc, define the types with no attributes using the `pass` keyword:
+
+```py
+from typing import NamedTuple
+from django_components import Component
+
+class Button(Component):
+ class Args(NamedTuple):
+ pass
+
+ class Kwargs(NamedTuple):
+ pass
+
+ class Slots(NamedTuple):
+ pass
+```
+
+This can get repetitive, so we added a [`Empty`](../../../reference/api#django_components.Empty) type to make it easier:
+
+```py
+from django_components import Component, Empty
+
+class Button(Component):
+ Args = Empty
+ Kwargs = Empty
+ Slots = Empty
+```
+
+## Subclassing
+
+Subclassing components with types is simple.
+
+Since each type class is a separate class attribute, you can just override them in the Component subclass.
+
+In the example below, `ButtonExtra` inherits `Kwargs` from `Button`, but overrides the `Args` class.
+
+```py
+from django_components import Component, Empty
+
+class Button(Component):
+ class Args(NamedTuple):
+ size: int
+
+ class Kwargs(NamedTuple):
+ color: str
+
+class ButtonExtra(Button):
+ class Args(NamedTuple):
+ name: str
+ size: int
+
+# Stil works the same way!
+ButtonExtra.render(
+ args=ButtonExtra.Args(name="John", size=30),
+ kwargs=ButtonExtra.Kwargs(color="red"),
+)
+```
+
+The only difference is when it comes to type hints to the data methods like
+[`get_template_data()`](../../../reference/api#django_components.Component.get_template_data).
+
+When you define the nested classes like `Args` and `Kwargs` directly on the class, you
+can reference them just by their class name (`Args` and `Kwargs`).
+
+But when you have a Component subclass, and it uses `Args` or `Kwargs` from the parent,
+you will have to reference the type as a [forward reference](https://peps.python.org/pep-0563/#forward-references), including the full name of the component
+(`Button.Args` and `Button.Kwargs`).
+
+Compare the following:
+
+```py
+class Button(Component):
+ class Args(NamedTuple):
+ size: int
+
+ class Kwargs(NamedTuple):
+ color: str
+
+ # Both `Args` and `Kwargs` are defined on the class
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots, context):
+ pass
+
+class ButtonExtra(Button):
+ class Args(NamedTuple):
+ name: str
+ size: int
+
+ # `Args` is defined on the subclass, `Kwargs` is defined on the parent
+ def get_template_data(self, args: Args, kwargs: "ButtonExtra.Kwargs", slots, context):
+ pass
+
+class ButtonSame(Button):
+ # Both `Args` and `Kwargs` are defined on the parent
+ def get_template_data(self, args: "ButtonSame.Args", kwargs: "ButtonSame.Kwargs", slots, context):
+ pass
+```
+
+## Runtime type validation
+
+When you add types to your component, and implement
+them as [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple) or [`dataclass`](https://docs.python.org/3/library/dataclasses.html#dataclasses.dataclass), the validation will check only for the presence of the attributes.
+
+So this will not catch if you pass a string to an `int` attribute.
+
+To enable runtime type validation, you need to use [Pydantic models](https://docs.pydantic.dev/latest/concepts/models/), and install the [`djc-ext-pydantic`](https://github.com/django-components/djc-ext-pydantic) extension.
+
+The `djc-ext-pydantic` extension ensures compatibility between django-components' classes such as `Component`, or `Slot` and Pydantic models.
+
+First install the extension:
+
+```bash
+pip install djc-ext-pydantic
+```
+
+And then add the extension to your project:
+
+```py
+COMPONENTS = {
+ "extensions": [
+ "djc_pydantic.PydanticExtension",
+ ],
+}
+```
+
+
+
+## Migrating from generics to class attributes
+
+In versions 0.92 to 0.139 (inclusive), the component typing was specified through generics.
+
+Since v0.140, the types must be specified as class attributes of the [Component](../../../reference/api#django_components.Component) class -
+[`Args`](../../../reference/api#django_components.Component.Args),
+[`Kwargs`](../../../reference/api#django_components.Component.Kwargs),
+[`Slots`](../../../reference/api#django_components.Component.Slots),
+[`TemplateData`](../../../reference/api#django_components.Component.TemplateData),
+[`JsData`](../../../reference/api#django_components.Component.JsData),
+and [`CssData`](../../../reference/api#django_components.Component.CssData).
+
+This change was necessary to make it possible to subclass components. Subclassing with generics was otherwise too complicated. Read the discussion [here](https://github.com/django-components/django-components/issues/1122).
+
+Because of this change, the [`Component.render()`](../../../reference/api#django_components.Component.render)
+method is no longer typed.
+To type-check the inputs, you should wrap the inputs in [`Component.Args`](../../../reference/api#django_components.Component.Args),
+[`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs),
+[`Component.Slots`](../../../reference/api#django_components.Component.Slots), etc.
+
+For example, if you had a component like this:
+
+```py
+from typing import NotRequired, Tuple, TypedDict
+from django_components import Component, Slot, SlotInput
+
+ButtonArgs = Tuple[int, str]
+
+class ButtonKwargs(TypedDict):
+ variable: str
+ another: int
+ maybe_var: NotRequired[int] # May be omitted
+
+class ButtonSlots(TypedDict):
+ # Use `SlotInput` to allow slots to be given as `Slot` instance,
+ # plain string, or a function that returns a string.
+ my_slot: NotRequired[SlotInput]
+ # Use `Slot` to allow ONLY `Slot` instances.
+ another_slot: Slot
+
+ButtonType = Component[ButtonArgs, ButtonKwargs, ButtonSlots]
+
+class Button(ButtonType):
+ def get_context_data(self, *args, **kwargs):
+ self.input.args[0] # int
+ self.input.kwargs["variable"] # str
+ self.input.slots["my_slot"] # Slot[MySlotData]
+
+Button.render(
+ args=(1, "hello"),
+ kwargs={
+ "variable": "world",
+ "another": 123,
+ },
+ slots={
+ "my_slot": "...",
+ "another_slot": Slot(lambda ctx: ...),
+ },
+)
+```
+
+The steps to migrate are:
+
+1. Convert all the types (`ButtonArgs`, `ButtonKwargs`, `ButtonSlots`) to subclasses
+ of [`NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple).
+2. Move these types inside the Component class (`Button`), and rename them to `Args`, `Kwargs`, and `Slots`.
+3. If you defined typing for the data methods (like [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)), move them inside the Component class, and rename them to `TemplateData`, `JsData`, and `CssData`.
+4. Remove the `Component` generic.
+5. If you accessed the `args`, `kwargs`, or `slots` attributes via
+ [`self.input`](../../../reference/api#django_components.Component.input), you will need to add the type hints yourself, because [`self.input`](../../../reference/api#django_components.Component.input) is no longer typed.
+
+ Otherwise, you may use [`Component.get_template_data()`](../../../reference/api#django_components.Component.get_template_data) instead of [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data), as `get_template_data()` receives `args`, `kwargs`, `slots` and `context` as arguments. You will still need to add the type hints yourself.
+
+6. Lastly, you will need to update the [`Component.render()`](../../../reference/api#django_components.Component.render)
+ calls to wrap the inputs in [`Component.Args`](../../../reference/api#django_components.Component.Args), [`Component.Kwargs`](../../../reference/api#django_components.Component.Kwargs), and [`Component.Slots`](../../../reference/api#django_components.Component.Slots), to manually add type hints.
+
+Thus, the code above will become:
+
+```py
+from typing import NamedTuple, Optional
+from django.template import Context
+from django_components import Component, Slot, SlotInput
+
+# The Component class does not take any generics
+class Button(Component):
+ # Types are now defined inside the component class
+ class Args(NamedTuple):
+ size: int
+ text: str
+
+ class Kwargs(NamedTuple):
+ variable: str
+ another: int
+ maybe_var: Optional[int] = None # May be omitted
+
+ class Slots(NamedTuple):
+ # Use `SlotInput` to allow slots to be given as `Slot` instance,
+ # plain string, or a function that returns a string.
+ my_slot: Optional[SlotInput] = None
+ # Use `Slot` to allow ONLY `Slot` instances.
+ another_slot: Slot
+
+ # The args, kwargs, slots are instances of the component's Args, Kwargs, and Slots classes
+ def get_template_data(self, args: Args, kwargs: Kwargs, slots: Slots, context: Context):
+ args.size # int
+ kwargs.variable # str
+ slots.my_slot # Slot[MySlotData]
+
+Button.render(
+ # Wrap the inputs in the component's Args, Kwargs, and Slots classes
+ args=Button.Args(1, "hello"),
+ kwargs=Button.Kwargs(
+ variable="world",
+ another=123,
+ ),
+ slots=Button.Slots(
+ my_slot="...",
+ another_slot=Slot(lambda ctx: ...),
+ ),
+)
+```
diff --git a/docs/css/style.css b/docs/css/style.css
index d776a117..4fa5b04b 100644
--- a/docs/css/style.css
+++ b/docs/css/style.css
@@ -11,6 +11,19 @@ h6 {
padding-top: 40px;
}
+.md-typeset h3 {
+ /* Original styling */
+ font-size: 1.25em;
+ font-weight: 400;
+ letter-spacing: -.01em;
+ line-height: 1.5;
+ margin: 1.6em 0 0.8em;
+
+ /* Custom */
+ border-top: 0.5px solid var(--md-typeset-color);
+ padding-top: 40px;
+}
+
.md-nav__item--section {
margin-top: 32px;
}
diff --git a/docs/getting_started/.nav.yml b/docs/getting_started/.nav.yml
new file mode 100644
index 00000000..7d19afe1
--- /dev/null
+++ b/docs/getting_started/.nav.yml
@@ -0,0 +1,8 @@
+# `.nav.yml` is provided by https://lukasgeiter.github.io/mkdocs-awesome-nav
+nav:
+ - Create your first component: your_first_component.md
+ - Adding JS and CSS: adding_js_and_css.md
+ - Components in templates: components_in_templates.md
+ - Parametrising components: parametrising_components.md
+ - Adding slots: adding_slots.md
+ - Rendering components: rendering_components.md
diff --git a/docs/concepts/getting_started/adding_js_and_css.md b/docs/getting_started/adding_js_and_css.md
similarity index 50%
rename from docs/concepts/getting_started/adding_js_and_css.md
rename to docs/getting_started/adding_js_and_css.md
index caed4f05..cc3e675a 100644
--- a/docs/concepts/getting_started/adding_js_and_css.md
+++ b/docs/getting_started/adding_js_and_css.md
@@ -1,8 +1,3 @@
----
-title: Adding JS and CSS
-weight: 2
----
-
Next we will add CSS and JavaScript to our template.
!!! info
@@ -49,14 +44,16 @@ Inside `calendar.css`, write:
Be sure to prefix your rules with unique CSS class like `calendar`, so the CSS doesn't clash with other rules.
+
!!! note
Soon, django-components will automatically scope your CSS by default, so you won't have to worry
about CSS class clashes.
This CSS will be inserted into the page as an inlined `
+
+
+ ...
+
+
+
+
+
+
+
+```
+
+---
Now that we have a fully-defined component, [next let's use it in a Django template ➡️](./components_in_templates.md).
diff --git a/docs/concepts/getting_started/adding_slots.md b/docs/getting_started/adding_slots.md
similarity index 75%
rename from docs/concepts/getting_started/adding_slots.md
rename to docs/getting_started/adding_slots.md
index 31001fb8..9081e5c1 100644
--- a/docs/concepts/getting_started/adding_slots.md
+++ b/docs/getting_started/adding_slots.md
@@ -1,14 +1,9 @@
----
-title: Adding slots
-weight: 5
----
-
Our calendar component's looking great! But we just got a new assignment from
our colleague - The calendar date needs to be shown on 3 different pages:
1. On one page, it needs to be shown as is
2. On the second, the date needs to be **bold**
-3. On the third, the date needs to be in *italics*
+3. On the third, the date needs to be in _italics_
As a reminder, this is what the component's template looks like:
@@ -30,7 +25,7 @@ implementation. And for the sake of demonstration, we'll solve this challenge wi
### 1. What are slots
-Components support something called [Slots](../fundamentals/slots.md).
+Components support something called [Slots](../../concepts/fundamentals/slots).
When a component is used inside another template, slots allow the parent template
to override specific parts of the child component by passing in different content.
@@ -42,13 +37,13 @@ This behavior is similar to [slots in Vue](https://vuejs.org/guide/components/sl
In the example below we introduce two tags that work hand in hand to make this work. These are...
- `{% slot %}`/`{% endslot %}`: Declares a new slot in the component template.
-- `{% fill %}`/`{% endfill %}`: (Used inside a [`{% component %}`](../../reference/template_tags.md#component)
- tag pair.) Fills a declared slot with the specified content.
+- `{% fill %}`/`{% endfill %}`: (Used inside a [`{% component %}`](../../reference/template_tags#component)
+ tag pair.) Fills a declared slot with the specified content.
### 2. Add a slot tag
Let's update our calendar component to support more customization. We'll add
-[`{% slot %}`](../../reference/template_tags.md#slot) tag to the template:
+[`{% slot %}`](../../reference/template_tags#slot) tag to the template:
```htmldjango
@@ -63,15 +58,15 @@ Notice that:
1. We named the slot `date` - so we can fill this slot by using `{% fill "date" %}`
-2. We also made it the [default slot](../fundamentals/slots.md#default-slot).
+2. We also made it the [default slot](../../concepts/fundamentals/slots#default-slot).
-3. We placed our original implementation inside the [`{% slot %}`](../../reference/template_tags.md#slot)
+3. We placed our original implementation inside the [`{% slot %}`](../../reference/template_tags#slot)
tag - this is what will be rendered when the slot is NOT overriden.
### 3. Add fill tag
-Now we can use [`{% fill %}`](../../reference/template_tags.md#fill) tags inside the
-[`{% component %}`](../../reference/template_tags.md#component) tags to override the `date` slot
+Now we can use [`{% fill %}`](../../reference/template_tags#fill) tags inside the
+[`{% component %}`](../../reference/template_tags#component) tags to override the `date` slot
to generate the bold and italics variants:
```htmldjango
@@ -122,15 +117,15 @@ Which will render as:
{% endcomponent %}
```
- 2. Implicitly as the [default slot](../fundamentals/slots.md#default-slot) (Omitting the
- [`{% fill %}`](../../reference/template_tags.md#fill) tag)
+ 2. Implicitly as the [default slot](../../concepts/fundamentals/slots#default-slot) (Omitting the
+ [`{% fill %}`](../../reference/template_tags#fill) tag)
```htmldjango
{% component "calendar" date="2024-12-13" %}
2024-12-13
{% endcomponent %}
```
- 3. Explicitly as the [default slot](../fundamentals/slots.md#default-slot) (Setting fill name to `default`)
+ 3. Explicitly as the [default slot](../../concepts/fundamentals/slots#default-slot) (Setting fill name to `default`)
```htmldjango
{% component "calendar" date="2024-12-13" %}
{% fill "default" %}
@@ -139,7 +134,7 @@ Which will render as:
{% endcomponent %}
```
-### 5. Wait, there's a bug
+### 4. Wait, there's a bug
There is a mistake in our code! `2024-12-13` is Friday, so that's fine. But if we updated
the to `2024-12-14`, which is Saturday, our template from previous step would render this:
@@ -163,7 +158,7 @@ the to `2024-12-14`, which is Saturday, our template from previous step would re
The first instance rendered `2024-12-16`, while the rest rendered `2024-12-14`!
-Why? Remember that in the [`get_context_data()`](../../../reference/api#django_components.Component.get_context_data)
+Why? Remember that in the [`get_template_data()`](../../reference/api#django_components.Component.get_template_data)
method of our Calendar component, we pre-process the date. If the date falls on Saturday or Sunday, we shift it to next Monday:
```python title="[project root]/components/calendar/calendar.py"
@@ -177,29 +172,30 @@ def to_workweek_date(d: date):
@register("calendar")
class Calendar(Component):
- template_name = "calendar.html"
+ template_file = "calendar.html"
...
- def get_context_data(self, date: date, extra_class: str | None = None):
- workweek_date = to_workweek_date(date)
+ def get_template_data(self, args, kwargs, slots, context):
+ workweek_date = to_workweek_date(kwargs["date"])
return {
"date": workweek_date,
- "extra_class": extra_class,
+ "extra_class": kwargs.get("extra_class", "text-blue"),
}
```
-And the issue is that in our template, we used the `date` value that we used *as input*,
+And the issue is that in our template, we used the `date` value that we used _as input_,
which is NOT the same as the `date` variable used inside Calendar's template.
### 5. Adding data to slots
We want to use the same `date` variable that's used inside Calendar's template.
-Luckily, django-components allows passing data to the slot, also known as [Scoped slots](../fundamentals/slots.md#scoped-slots).
+Luckily, django-components allows [passing data to slots](../../concepts/fundamentals/slots#slot-data),
+also known as [Scoped slots](https://vuejs.org/guide/components/slots#scoped-slots).
This consists of two steps:
-1. Pass the `date` variable to the [`{% slot %}`](../../reference/template_tags.md#slot) tag
-2. Access the `date` variable in the [`{% fill %}`](../../reference/template_tags.md#fill)
+1. Pass the `date` variable to the [`{% slot %}`](../../reference/template_tags#slot) tag
+2. Access the `date` variable in the [`{% fill %}`](../../reference/template_tags#fill)
tag by using the special `data` kwarg
Let's update the Calendar's template:
@@ -215,7 +211,7 @@ Let's update the Calendar's template:
!!! info
- The [`{% slot %}`](../../reference/template_tags.md#slot) tag has one special kwarg, `name`. When you write
+ The [`{% slot %}`](../../reference/template_tags#slot) tag has one special kwarg, `name`. When you write
```htmldjango
{% slot "date" / %}
@@ -227,7 +223,7 @@ Let's update the Calendar's template:
{% slot name="date" / %}
```
- Other than the `name` kwarg, you can pass any extra kwargs to the [`{% slot %}`](../../reference/template_tags.md#slot) tag,
+ Other than the `name` kwarg, you can pass any extra kwargs to the [`{% slot %}`](../../reference/template_tags#slot) tag,
and these will be exposed as the slot's data.
```htmldjango
@@ -236,10 +232,10 @@ Let's update the Calendar's template:
### 6. Accessing slot data in fills
-Now, on the [`{% fill %}`](../../reference/template_tags.md#fill) tags, we can use the `data` kwarg to specify the variable under which
+Now, on the [`{% fill %}`](../../reference/template_tags#fill) tags, we can use the `data` kwarg to specify the variable under which
the slot data will be available.
-The variable from the `data` kwarg contains all the extra kwargs passed to the [`{% slot %}`](../../reference/template_tags.md#slot) tag.
+The variable from the `data` kwarg contains all the extra kwargs passed to the [`{% slot %}`](../../reference/template_tags#slot) tag.
So if we set `data="slot_data"`, then we can access the date variable under `slot_data.date`:
@@ -289,7 +285,12 @@ each time:
Generally, slots are more flexible - you can access the slot data, even the original slot content.
Thus, slots behave more like functions that render content based on their context.
- On the other hand, variables are static - the variable you pass to a component is what will be used.
+ On the other hand, variables are simpler - the variable you pass to a component is what will be used.
Moreover, slots are treated as part of the template - for example the CSS scoping (work in progress)
is applied to the slot content too.
+
+---
+
+So far we've rendered components using template tag. [Next, let’s explore other ways to render components ➡️]
+(./rendering_components.md)
diff --git a/docs/concepts/getting_started/components_in_templates.md b/docs/getting_started/components_in_templates.md
similarity index 75%
rename from docs/concepts/getting_started/components_in_templates.md
rename to docs/getting_started/components_in_templates.md
index 4d322c9e..ae798a8d 100644
--- a/docs/concepts/getting_started/components_in_templates.md
+++ b/docs/getting_started/components_in_templates.md
@@ -1,8 +1,3 @@
----
-title: Components in templates
-weight: 3
----
-
By the end of this section, we want to be able to use our components in Django templates like so:
```htmldjango
@@ -20,10 +15,10 @@ By the end of this section, we want to be able to use our components in Django t
### 1. Register component
-First, however, we need to register our component class with [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry).
+First, however, we need to register our component class with [`ComponentRegistry`](../../reference/api#django_components.ComponentRegistry).
-To register a component with a [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry),
-we will use the [`@register`](../../../reference/api#django_components.register)
+To register a component with a [`ComponentRegistry`](../../reference/api#django_components.ComponentRegistry),
+we will use the [`@register`](../../reference/api#django_components.register)
decorator, and give it a name under which the component will be accessible from within the template:
```python title="[project root]/components/calendar/calendar.py"
@@ -31,13 +26,11 @@ from django_components import Component, register # <--- new
@register("calendar") # <--- new
class Calendar(Component):
- template_name = "calendar.html"
+ template_file = "calendar.html"
+ js_file = "calendar.js"
+ css_file = "calendar.css"
- class Media:
- js = "calendar.js"
- css = "calendar.css"
-
- def get_context_data(self):
+ def get_template_data(self, args, kwargs, slots, context):
return {
"date": "1970-01-01",
}
@@ -48,25 +41,25 @@ by calling `{% load component_tags %}` inside the template.
!!! info
- Why do we have to register components?
+ **Why do we have to register components?**
We want to use our component as a template tag (`{% ... %}`) in Django template.
In Django, template tags are managed by the `Library` instances. Whenever you include `{% load xxx %}`
in your template, you are loading a `Library` instance into your template.
- [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry) acts like a router
+ [`ComponentRegistry`](../../reference/api#django_components.ComponentRegistry) acts like a router
and connects the registered components with the associated `Library`.
That way, when you include `{% load component_tags %}` in your template, you are able to "call" components
like `{% component "calendar" / %}`.
`ComponentRegistries` also make it possible to group and share components as standalone packages.
- [Learn more here](../advanced/authoring_component_libraries.md).
+ [Learn more here](../../concepts/advanced/component_libraries).
!!! note
- You can create custom [`ComponentRegistry`](../../../reference/api#django_components.ComponentRegistry)
+ You can create custom [`ComponentRegistry`](../../reference/api#django_components.ComponentRegistry)
instances, which will use different `Library` instances.
In that case you will have to load different libraries depending on which components you want to use:
@@ -87,7 +80,7 @@ by calling `{% load component_tags %}` inside the template.
```
Note that, because the tag name `component` is use by the default ComponentRegistry,
- the custom registry was configured to use the tag `my_component` instead. [Read more here](../advanced/component_registry.md)
+ the custom registry was configured to use the tag `my_component` instead. [Read more here](../../concepts/advanced/component_registry)
### 2. Load and use the component in template
@@ -109,7 +102,7 @@ and render the component inside a template:
!!! info
- Component tags should end with `/` if they do not contain any [Slot fills](../fundamentals/slots.md).
+ Component tags should end with `/` if they do not contain any [Slot fills](../../concepts/fundamentals/slots).
But you can also use `{% endcomponent %}` instead:
```htmldjango
@@ -164,19 +157,20 @@ and keeping your CSS and Javascript in the static directory.
!!! info
Remember that you can use
- [`{% component_js_dependencies %}`](../../reference/template_tags.md#component_js_dependencies)
- and [`{% component_css_dependencies %}`](../../reference/template_tags.md#component_css_dependencies)
- to change where the `
-
- ```
+ ```html
+
+
...
+
+
+
+
+
+
+ ```
- But this has a number of issues:
+ 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.
+ - 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
@@ -119,99 +119,101 @@ 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
+ So a template like this
- ```django
- {% load component_tags %}
-
+
+
+ ```
- Each `` comment includes comma-separated data - a unique hash for the component class, e.g. `my_table_10bc2c`, and the component ID, e.g. `c020ad`.
+ Each `` 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 `` comments remain in the rendered string, we will be able to deduce which JS and CSS dependencies the component needs.
+ 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 `` 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 `` comments, and instead inserting the corresponding JS and CSS dependencies.
- If we dealt only with JS, then we could get away with processing the `` 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 `' end tag. "
+ "This is not allowed, as it would break the HTML."
+ )
+ return f""
+
#########################################################
# 2. Modify the HTML to use the same IDs defined in previous
@@ -183,73 +231,89 @@ def cache_inlined_css(comp_cls: Type["Component"], content: str) -> None:
#########################################################
-class Dependencies(NamedTuple):
+def set_component_attrs_for_js_and_css(
+ html_content: Union[str, SafeString],
+ component_id: Optional[str],
+ css_input_hash: Optional[str],
+ root_attributes: Optional[List[str]] = None,
+) -> Tuple[Union[str, SafeString], Dict[str, List[str]]]:
+ # These are the attributes that we want to set on the root element.
+ all_root_attributes = [*root_attributes] if root_attributes else []
+
+ # Component ID is used for executing JS script, e.g. `data-djc-id-ca1b2c3`
+ #
+ # NOTE: We use `data-djc-css-a1b2c3` and `data-djc-id-ca1b2c3` instead of
+ # `data-djc-css="a1b2c3"` and `data-djc-id="a1b2c3"`, to allow
+ # multiple values to be associated with the same element, which may happen if
+ # one component renders another.
+ if component_id:
+ all_root_attributes.append(f"data-djc-id-{component_id}")
+
+ # Attribute by which we bind the CSS variables to the component's CSS,
+ # e.g. `data-djc-css-a1b2c3`
+ if css_input_hash:
+ all_root_attributes.append(f"data-djc-css-{css_input_hash}")
+
+ is_safestring = isinstance(html_content, SafeString)
+ updated_html, child_components = set_html_attributes(
+ html_content,
+ root_attributes=all_root_attributes,
+ all_attributes=[],
+ # Setting this means that set_html_attributes will check for HTML elemetnts with this
+ # attribute, and return a dictionary of {attribute_value: [attributes_set_on_this_tag]}.
+ #
+ # So if HTML contains tag ,
+ # and we set on that tag `data-djc-id-123`, then we will get
+ # {
+ # "123": ["data-djc-id-123"],
+ # }
+ #
+ # This is a minor optimization. Without this, when we're rendering components in
+ # component_post_render(), we'd have to parse each ``
+ # to find the HTML attribute that were set on it.
+ watch_on_attribute="djc-render-id",
+ )
+ updated_html = mark_safe(updated_html) if is_safestring else updated_html
+
+ return updated_html, child_components
+
+
+# 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 ),
+# 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.
+def insert_component_dependencies_comment(
+ content: str,
# 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:
+ component_cls: Type["Component"],
+ component_id: str,
+ js_input_hash: Optional[str],
+ css_input_hash: Optional[str],
+) -> SafeString:
"""
Given some textual content, prepend it with a short string that
- will be used by the ComponentDependencyMiddleware to collect all
+ will be used by the `render_dependencies()` function 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}"
+ data = f"{component_cls.class_id},{component_id},{js_input_hash or ''},{css_input_hash or ''}"
# 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 ),
- # 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)
+ output = mark_safe(COMPONENT_DEPS_COMMENT.format(data=data) + content)
return output
@@ -266,26 +330,39 @@ def postprocess_component_html(
TContent = TypeVar("TContent", bound=Union[bytes, str])
-CSS_DEPENDENCY_PLACEHOLDER = ''
-JS_DEPENDENCY_PLACEHOLDER = ''
-
-CSS_PLACEHOLDER_BYTES = bytes(CSS_DEPENDENCY_PLACEHOLDER, encoding="utf-8")
-JS_PLACEHOLDER_BYTES = bytes(JS_DEPENDENCY_PLACEHOLDER, encoding="utf-8")
+CSS_PLACEHOLDER_NAME = "CSS_PLACEHOLDER"
+CSS_PLACEHOLDER_NAME_B = CSS_PLACEHOLDER_NAME.encode()
+JS_PLACEHOLDER_NAME = "JS_PLACEHOLDER"
+JS_PLACEHOLDER_NAME_B = JS_PLACEHOLDER_NAME.encode()
+CSS_DEPENDENCY_PLACEHOLDER = f''
+JS_DEPENDENCY_PLACEHOLDER = f''
COMPONENT_DEPS_COMMENT = ""
-# E.g. ``
-COMPONENT_COMMENT_REGEX = re.compile(rb"")
-# E.g. `table,123`
-SCRIPT_NAME_REGEX = re.compile(rb"^(?P[\w\-\./]+?),(?P[\w]+?)$")
+
+# E.g. ``
+COMPONENT_COMMENT_REGEX = re.compile(rb"")
+# E.g. `table,123,a92ef298,bd002c3`
+# - comp_cls_id - Cache key of the component class that was rendered
+# - id - Component render ID
+# - js - Cache key for the JS data from `get_js_data()`
+# - css - Cache key for the CSS data from `get_css_data()`
+SCRIPT_NAME_REGEX = re.compile(
+ rb"^(?P[\w\-\./]+?),(?P[\w]+?),(?P[0-9a-f]*?),(?P[0-9a-f]*?)$"
+)
+# E.g. `data-djc-id-ca1b2c3`
+MAYBE_COMP_ID = r'(?: data-djc-id-\w{{{COMP_ID_LENGTH}}}="")?'.format(COMP_ID_LENGTH=COMP_ID_LENGTH)
+# E.g. `data-djc-css-99914b`
+MAYBE_COMP_CSS_ID = r'(?: data-djc-css-\w{6}="")?'
+
PLACEHOLDER_REGEX = re.compile(
r"{css_placeholder}|{js_placeholder}".format(
- css_placeholder=CSS_DEPENDENCY_PLACEHOLDER,
- js_placeholder=JS_DEPENDENCY_PLACEHOLDER,
+ css_placeholder=f'',
+ js_placeholder=f'',
).encode()
)
-def render_dependencies(content: TContent, type: RenderType = "document") -> TContent:
+def render_dependencies(content: TContent, strategy: DependenciesStrategy = "document") -> TContent:
"""
Given a string that contains parts that were rendered by components,
this function inserts all used JS and CSS.
@@ -324,8 +401,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
return HttpResponse(processed_html)
```
"""
- if type not in ("document", "fragment"):
- raise ValueError(f"Invalid type '{type}'")
+ if strategy not in DEPS_STRATEGIES:
+ raise ValueError(f"Invalid strategy '{strategy}'")
+ elif strategy == "ignore":
+ return content
is_safestring = isinstance(content, SafeString)
@@ -334,26 +413,26 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
else:
content_ = cast(bytes, content)
- content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, type)
+ content_, js_dependencies, css_dependencies = _process_dep_declarations(content_, strategy)
# Replace the placeholders with the actual content
- # If type == `document`, we insert the JS and CSS directly into the HTML,
+ # If strategy in (`document`, 'simple'), we insert the JS and CSS directly into the HTML,
# where the placeholders were.
- # If type == `fragment`, we let the client-side manager load the JS and CSS,
+ # If strategy == `fragment`, we let the client-side manager load the JS and CSS,
# and remove the placeholders.
did_find_js_placeholder = False
did_find_css_placeholder = False
- css_replacement = css_dependencies if type == "document" else b""
- js_replacement = js_dependencies if type == "document" else b""
+ css_replacement = css_dependencies if strategy in ("document", "simple") else b""
+ js_replacement = js_dependencies if strategy in ("document", "simple") else b""
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:
+ if CSS_PLACEHOLDER_NAME_B in match[0]:
replacement = css_replacement
did_find_css_placeholder = True
- elif match[0] == JS_PLACEHOLDER_BYTES:
+ elif JS_PLACEHOLDER_NAME_B in match[0]:
replacement = js_replacement
did_find_js_placeholder = True
else:
@@ -365,10 +444,10 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
content_ = PLACEHOLDER_REGEX.sub(on_replace_match, content_)
- # By default, if user didn't specify any `{% component_dependencies %}`,
+ # By default ("document") and for "simple" strategy, if user didn't specify any `{% component_dependencies %}`,
# then try to insert the JS scripts at the end of and CSS sheets at the end
- # of
- if type == "document" and (not did_find_js_placeholder or not did_find_css_placeholder):
+ # of .
+ if strategy in ("document", "simple") and (not did_find_js_placeholder or not did_find_css_placeholder):
maybe_transformed = _insert_js_css_to_default_locations(
content_.decode(),
css_content=None if did_find_css_placeholder else css_dependencies.decode(),
@@ -379,8 +458,13 @@ def render_dependencies(content: TContent, type: RenderType = "document") -> TCo
content_ = maybe_transformed.encode()
# In case of a fragment, we only append the JS (actually JSON) to trigger the call of dependency-manager
- if type == "fragment":
+ elif strategy == "fragment":
content_ += js_dependencies
+ # For prepend / append, we insert the JS and CSS before / after the content
+ elif strategy == "prepend":
+ content_ = js_dependencies + css_dependencies + content_
+ elif strategy == "append":
+ content_ = content_ + js_dependencies + css_dependencies
# Return the same type as we were given
output = content_.decode() if isinstance(content, str) else content_
@@ -413,16 +497,16 @@ _render_dependencies = render_dependencies
# 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]:
+def _process_dep_declarations(content: bytes, strategy: DependenciesStrategy) -> Tuple[bytes, bytes, bytes]:
"""
Process a textual content that may include metadata on rendered components.
The metadata has format like this
- ``
+ ``
E.g.
- ``
+ ``
"""
# Extract all matched instances of `` while also removing them from the text
all_parts: List[bytes] = list()
@@ -433,92 +517,134 @@ def _process_dep_declarations(content: bytes, type: RenderType) -> Tuple[bytes,
content = COMPONENT_COMMENT_REGEX.sub(on_replace_match, content)
- # NOTE: Python's set does NOT preserve order
+ # NOTE: Python's set does NOT preserve order, so both set and list are needed
seen_comp_hashes: Set[str] = set()
comp_hashes: List[str] = []
+ # Used for passing Python vars to JS/CSS
+ variables_data: List[Tuple[str, ScriptType, Optional[str]]] = []
+ comp_data: List[Tuple[str, ScriptType, Optional[str]]] = []
- # Process individual parts. Each part is like a CSV row of `name,id`.
+ # Process individual parts. Each part is like a CSV row of `name,id,js,css`.
# E.g. something like this:
- # `table_10bac31,1234`
+ # `table_10bac31,1234,a92ef298,a92ef298`
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")
- if comp_cls_hash in seen_comp_hashes:
+ comp_cls_id: str = part_match.group("comp_cls_id").decode("utf-8")
+ js_variables_hash: Optional[str] = part_match.group("js").decode("utf-8") or None
+ css_variables_hash: Optional[str] = part_match.group("css").decode("utf-8") or None
+
+ if comp_cls_id in seen_comp_hashes:
continue
- comp_hashes.append(comp_cls_hash)
- seen_comp_hashes.add(comp_cls_hash)
+ comp_hashes.append(comp_cls_id)
+ seen_comp_hashes.add(comp_cls_id)
+
+ # Schedule to load the `' end tag. "
- "This is not allowed, as it would break the HTML."
- )
- return f""
-
+ content = wrap_component_js(comp_cls, content)
elif script_type == "css":
- if "' end tag. "
- "This is not allowed, as it would break the HTML."
- )
+ content = wrap_component_css(comp_cls, content)
+ else:
+ raise ValueError(f"Unexpected script_type '{script_type}'")
- return f""
-
- return script
+ return content
def get_script_url(
script_type: ScriptType,
comp_cls: Type["Component"],
+ input_hash: Optional[str],
) -> str:
- comp_cls_hash = _hash_comp_cls(comp_cls)
-
return reverse(
CACHE_ENDPOINT_NAME,
kwargs={
- "comp_cls_hash": comp_cls_hash,
+ "comp_cls_id": comp_cls.class_id,
"script_type": script_type,
+ **({"input_hash": input_hash} if input_hash is not None else {}),
},
)
@@ -703,10 +857,11 @@ def _gen_exec_script(
loaded_js_urls: List[str],
loaded_css_urls: List[str],
) -> Optional[str]:
- if not to_load_js_tags and not to_load_css_tags and not loaded_css_urls and not loaded_js_urls:
+ # Return None if all lists are empty
+ if not any([to_load_js_tags, to_load_css_tags, loaded_css_urls, loaded_js_urls]):
return None
- def map_to_base64(lst: List[str]) -> List[str]:
+ def map_to_base64(lst: Sequence[str]) -> List[str]:
return [base64.b64encode(tag.encode()).decode() for tag in lst]
# Generate JSON that will tell the JS dependency manager which JS and CSS to load
@@ -732,6 +887,9 @@ def _gen_exec_script(
return exec_script
+head_or_body_end_tag_re = re.compile(r"<\/(?:head|body)\s*>", re.DOTALL)
+
+
def _insert_js_css_to_default_locations(
html_content: str,
js_content: Optional[str],
@@ -741,37 +899,50 @@ def _insert_js_css_to_default_locations(
This function tries to insert the JS and CSS content into the default locations.
JS is inserted at the end of ``, and CSS is inserted at the end of ``.
- """
- elems = SoupNode.from_fragment(html_content)
- if not elems:
+ We find these tags by looking for the first `` and last `` tags.
+ """
+ if css_content is None and js_content is None:
return None
did_modify_html = False
- if css_content is not None:
- for elem in elems:
- if not elem.is_element():
- continue
- head = elem.find_tag("head")
- if head:
- css_elems = SoupNode.from_fragment(css_content)
- head.append_children(css_elems)
- did_modify_html = True
+ first_end_head_tag_index = None
+ last_end_body_tag_index = None
- if js_content is not None:
- for elem in elems:
- if not elem.is_element():
- continue
- body = elem.find_tag("body")
- if body:
- js_elems = SoupNode.from_fragment(js_content)
- body.append_children(js_elems)
- did_modify_html = True
+ # First check the content for the first `` and last `` tags
+ for match in head_or_body_end_tag_re.finditer(html_content):
+ tag_name = match[0][2:6]
+
+ # We target the first ``, thus, after we set it, we skip the rest
+ if tag_name == "head":
+ if css_content is not None and first_end_head_tag_index is None:
+ first_end_head_tag_index = match.start()
+
+ # But for ``, we want the last occurrence, so we insert the content only
+ # after the loop.
+ elif tag_name == "body":
+ if js_content is not None:
+ last_end_body_tag_index = match.start()
+
+ else:
+ raise ValueError(f"Unexpected tag name '{tag_name}'")
+
+ # Then do two string insertions. First the CSS, because we assume that is before .
+ index_offset = 0
+ updated_html = html_content
+ if css_content is not None and first_end_head_tag_index is not None:
+ updated_html = updated_html[:first_end_head_tag_index] + css_content + updated_html[first_end_head_tag_index:]
+ index_offset = len(css_content)
+ did_modify_html = True
+
+ if js_content is not None and last_end_body_tag_index is not None:
+ js_index = last_end_body_tag_index + index_offset
+ updated_html = updated_html[:js_index] + js_content + updated_html[js_index:]
+ did_modify_html = True
if did_modify_html:
- transformed = SoupNode.to_html_multiroot(elems)
- return transformed
+ return updated_html
else:
return None # No changes made
@@ -795,16 +966,21 @@ def _get_content_types(script_type: ScriptType) -> str:
def cached_script_view(
req: HttpRequest,
- comp_cls_hash: str,
+ comp_cls_id: str,
script_type: ScriptType,
+ input_hash: Optional[str] = None,
) -> HttpResponse:
+ from django_components.component import get_component_by_class_id
+
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)
+ try:
+ comp_cls = get_component_by_class_id(comp_cls_id)
+ except KeyError:
+ return HttpResponseNotFound()
+ script = get_script_content(script_type, comp_cls, input_hash)
if script is None:
return HttpResponseNotFound()
@@ -813,49 +989,70 @@ def cached_script_view(
urlpatterns = [
- # E.g. `/components/cache/table.js`
- path("cache/.", cached_script_view, name=CACHE_ENDPOINT_NAME),
+ # E.g. `/components/cache/MyTable_a1b2c3.js` or `/components/cache/MyTable_a1b2c3.0ab2c3.js`
+ path("cache/..", cached_script_view, name=CACHE_ENDPOINT_NAME),
+ path("cache/.", cached_script_view, name=CACHE_ENDPOINT_NAME),
]
#########################################################
-# 5. Middleware that automatically applies the dependency-
-# aggregating logic on all HTML responses.
+# 5. Template tags
#########################################################
-@sync_and_async_middleware
-class ComponentDependencyMiddleware:
+def _component_dependencies(type: Literal["js", "css"]) -> SafeString:
+ """Marks location where CSS link and JS script tags should be rendered."""
+ if type == "css":
+ placeholder = CSS_DEPENDENCY_PLACEHOLDER
+ elif type == "js":
+ placeholder = JS_DEPENDENCY_PLACEHOLDER
+ else:
+ raise TemplateSyntaxError(
+ f"Unknown dependency type in {{% component_dependencies %}}. Must be one of 'css' or 'js', got {type}"
+ )
+
+ return mark_safe(placeholder)
+
+
+class ComponentCssDependenciesNode(BaseNode):
"""
- Middleware that inserts CSS/JS dependencies for all rendered
- components at points marked with template tags.
+ Marks location where CSS link tags should be rendered after the whole HTML has been generated.
+
+ Generally, this should be inserted into the `` tag of the HTML.
+
+ If the generated HTML does NOT contain any `{% component_css_dependencies %}` tags, CSS links
+ are by default inserted into the `` tag of the HTML. (See
+ [Default JS / CSS locations](../../concepts/advanced/rendering_js_css/#default-js-css-locations))
+
+ Note that there should be only one `{% component_css_dependencies %}` for the whole HTML document.
+ If you insert this tag multiple times, ALL CSS links will be duplicately inserted into ALL these places.
"""
- def __init__(self, get_response: "Callable[[HttpRequest], HttpResponse]") -> None:
- self._get_response = get_response
+ tag = "component_css_dependencies"
+ end_tag = None # inline-only
+ allowed_flags = []
- # NOTE: Required to work with async
- if iscoroutinefunction(self._get_response):
- markcoroutinefunction(self)
+ def render(self, context: Context) -> str:
+ return _component_dependencies("css")
- 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
+class ComponentJsDependenciesNode(BaseNode):
+ """
+ Marks location where JS link tags should be rendered after the whole HTML has been generated.
- # 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
+ Generally, this should be inserted at the end of the `` tag of the HTML.
- 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")
+ If the generated HTML does NOT contain any `{% component_js_dependencies %}` tags, JS scripts
+ are by default inserted at the end of the `` tag of the HTML. (See
+ [Default JS / CSS locations](../../concepts/advanced/rendering_js_css/#default-js-css-locations))
- return response
+ Note that there should be only one `{% component_js_dependencies %}` for the whole HTML document.
+ If you insert this tag multiple times, ALL JS scripts will be duplicately inserted into ALL these places.
+ """
+
+ tag = "component_js_dependencies"
+ end_tag = None # inline-only
+ allowed_flags = []
+
+ def render(self, context: Context) -> str:
+ return _component_dependencies("js")
diff --git a/src/django_components/expression.py b/src/django_components/expression.py
index b766b630..9afcbc80 100644
--- a/src/django_components/expression.py
+++ b/src/django_components/expression.py
@@ -1,42 +1,45 @@
import re
-from abc import ABC, abstractmethod
-from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Dict, List
from django.template import Context, Node, NodeList, TemplateSyntaxError
-from django.template.base import FilterExpression, Lexer, Parser, VariableNode
+from django.template.base import Parser, VariableNode
-Expression = Union[FilterExpression, "DynamicFilterExpression"]
-RuntimeKwargsInput = Dict[str, Union[Expression, "Operator"]]
-RuntimeKwargPairsInput = List[Tuple[str, Union[Expression, "Operator"]]]
+from django_components.util.template_parser import parse_template
-
-class Operator(ABC):
- """
- Operator describes something that somehow changes the inputs
- to template tags (the `{% %}`).
-
- For example, a SpreadOperator inserts one or more kwargs at the
- specified location.
- """
-
- @abstractmethod
- def resolve(self, context: Context) -> Any: ... # noqa E704
-
-
-class SpreadOperator(Operator):
- """Operator that inserts one or more kwargs at the specified location."""
-
- def __init__(self, expr: Expression) -> None:
- self.expr = expr
-
- def resolve(self, context: Context) -> Dict[str, Any]:
- data = self.expr.resolve(context)
- if not isinstance(data, dict):
- raise RuntimeError(f"Spread operator expression must resolve to a Dict, got {data}")
- return data
+if TYPE_CHECKING:
+ from django_components.util.template_tag import TagParam
class DynamicFilterExpression:
+ """
+ To make working with Django templates easier, we allow to use (nested) template tags `{% %}`
+ inside of strings that are passed to our template tags, e.g.:
+
+ ```django
+ {% component "my_comp" value_from_tag="{% gen_dict %}" %}
+ ```
+
+ We call this the "dynamic" or "nested" expression.
+
+ A string is marked as a dynamic expression only if it contains any one
+ of `{{ }}`, `{% %}`, or `{# #}`.
+
+ If the expression consists of a single tag, with no extra text, we return the tag's
+ value directly. E.g.:
+
+ ```django
+ {% component "my_comp" value_from_tag="{% gen_dict %}" %}
+ ```
+
+ will pass a dictionary to the component input `value_from_tag`.
+
+ But if the text already contains spaces or more tags, e.g.
+
+ `{% component "my_comp" value_from_tag=" {% gen_dict %} " %}`
+
+ Then we treat it as a regular template and pass it as string.
+ """
+
def __init__(self, parser: Parser, expr_str: str) -> None:
if not is_dynamic_expression(expr_str):
raise TemplateSyntaxError(f"Not a valid dynamic expression: '{expr_str}'")
@@ -47,8 +50,7 @@ class DynamicFilterExpression:
# Copy the Parser, and pass through the tags and filters available
# in the current context. Thus, if user calls `{% load %}` inside
# the expression, it won't spill outside.
- lexer = Lexer(self.expr)
- tokens = lexer.tokenize()
+ tokens = parse_template(self.expr)
expr_parser = Parser(tokens=tokens)
expr_parser.tags = {**parser.tags}
expr_parser.filters = {**parser.filters}
@@ -103,76 +105,17 @@ class StringifiedNode(Node):
return str(result)
-class RuntimeKwargs:
- def __init__(self, kwargs: RuntimeKwargsInput) -> None:
- self.kwargs = kwargs
-
- def resolve(self, context: Context) -> Dict[str, Any]:
- resolved_kwargs = safe_resolve_dict(context, self.kwargs)
- return process_aggregate_kwargs(resolved_kwargs)
-
-
-class RuntimeKwargPairs:
- def __init__(self, kwarg_pairs: RuntimeKwargPairsInput) -> None:
- self.kwarg_pairs = kwarg_pairs
-
- def resolve(self, context: Context) -> List[Tuple[str, Any]]:
- resolved_kwarg_pairs: List[Tuple[str, Any]] = []
- for key, kwarg in self.kwarg_pairs:
- if isinstance(kwarg, SpreadOperator):
- spread_kwargs = kwarg.resolve(context)
- for spread_key, spread_value in spread_kwargs.items():
- resolved_kwarg_pairs.append((spread_key, spread_value))
- else:
- resolved_kwarg_pairs.append((key, kwarg.resolve(context)))
-
- return resolved_kwarg_pairs
-
-
-def is_identifier(value: Any) -> bool:
- if not isinstance(value, str):
- return False
- if not value.isidentifier():
- return False
- return True
-
-
-def safe_resolve_list(context: Context, args: List[Expression]) -> List:
- return [arg.resolve(context) for arg in args]
-
-
-def safe_resolve_dict(
- context: Context,
- kwargs: Dict[str, Union[Expression, "Operator"]],
-) -> Dict[str, Any]:
- result = {}
-
- for key, kwarg in kwargs.items():
- # If we've come across a Spread Operator (...), we insert the kwargs from it here
- if isinstance(kwarg, SpreadOperator):
- spread_dict = kwarg.resolve(context)
- if spread_dict is not None:
- for spreadkey, spreadkwarg in spread_dict.items():
- result[spreadkey] = spreadkwarg
- else:
- result[key] = kwarg.resolve(context)
- return result
-
-
-def resolve_string(
- s: str,
- parser: Optional[Parser] = None,
- context: Optional[Mapping[str, Any]] = None,
-) -> str:
- parser = parser or Parser([])
- context = Context(context or {})
- return parser.compile_filter(s).resolve(context)
-
-
def is_aggregate_key(key: str) -> bool:
+ key = key.strip()
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
- return ":" in key and not key.startswith(":")
+ return (
+ ":" in key
+ # `:` or `:class` is NOT ok
+ and not key.startswith(":")
+ # `attrs:class` is OK, but `attrs:` is NOT ok
+ and bool(key.split(":", maxsplit=1)[1])
+ )
# A string that must start and end with quotes, and somewhere inside includes
@@ -203,14 +146,8 @@ def is_dynamic_expression(value: Any) -> bool:
return True
-def is_spread_operator(value: Any) -> bool:
- if not isinstance(value, str) or not value:
- return False
-
- return value.startswith("...")
-
-
-def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
+# TODO - Move this out into a plugin?
+def process_aggregate_kwargs(params: List["TagParam"]) -> List["TagParam"]:
"""
This function aggregates "prefixed" kwargs into dicts. "Prefixed" kwargs
start with some prefix delimited with `:` (e.g. `attrs:`).
@@ -264,26 +201,65 @@ def process_aggregate_kwargs(kwargs: Mapping[str, Any]) -> Dict[str, Any]:
"fallthrough attributes", and sufficiently easy for component authors to process
that input while still being able to provide their own keys.
"""
- processed_kwargs = {}
+ from django_components.util.template_tag import TagParam
+
+ _check_kwargs_for_agg_conflict(params)
+
+ processed_params = []
+ seen_keys = set()
nested_kwargs: Dict[str, Dict[str, Any]] = {}
- for key, val in kwargs.items():
- if not is_aggregate_key(key):
- processed_kwargs[key] = val
+ for param in params:
+ # Positional args
+ if param.key is None:
+ processed_params.append(param)
continue
- # NOTE: Trim off the prefix from keys
- prefix, sub_key = key.split(":", 1)
- if prefix not in nested_kwargs:
- nested_kwargs[prefix] = {}
- nested_kwargs[prefix][sub_key] = val
+ # Regular kwargs without `:` prefix
+ if not is_aggregate_key(param.key):
+ outer_key = param.key
+ inner_key = None
+ seen_keys.add(outer_key)
+ processed_params.append(param)
+ continue
+
+ # NOTE: Trim off the outer_key from keys
+ outer_key, inner_key = param.key.split(":", 1)
+ if outer_key not in nested_kwargs:
+ nested_kwargs[outer_key] = {}
+ nested_kwargs[outer_key][inner_key] = param.value
# Assign aggregated values into normal input
for key, val in nested_kwargs.items():
- if key in processed_kwargs:
+ if key in seen_keys:
raise TemplateSyntaxError(
f"Received argument '{key}' both as a regular input ({key}=...)"
f" and as an aggregate dict ('{key}:key=...'). Must be only one of the two"
)
- processed_kwargs[key] = val
+ processed_params.append(TagParam(key=key, value=val))
- return processed_kwargs
+ return processed_params
+
+
+def _check_kwargs_for_agg_conflict(params: List["TagParam"]) -> None:
+ seen_regular_kwargs = set()
+ seen_agg_kwargs = set()
+
+ for param in params:
+ # Ignore positional args
+ if param.key is None:
+ continue
+
+ is_agg_kwarg = is_aggregate_key(param.key)
+ if (
+ (is_agg_kwarg and (param.key in seen_regular_kwargs))
+ or (not is_agg_kwarg and (param.key in seen_agg_kwargs))
+ ): # fmt: skip
+ raise TemplateSyntaxError(
+ f"Received argument '{param.key}' both as a regular input ({param.key}=...)"
+ f" and as an aggregate dict ('{param.key}:key=...'). Must be only one of the two"
+ )
+
+ if is_agg_kwarg:
+ seen_agg_kwargs.add(param.key)
+ else:
+ seen_regular_kwargs.add(param.key)
diff --git a/src/django_components/extension.py b/src/django_components/extension.py
new file mode 100644
index 00000000..a95d9755
--- /dev/null
+++ b/src/django_components/extension.py
@@ -0,0 +1,1369 @@
+from functools import wraps
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Callable,
+ ClassVar,
+ Dict,
+ List,
+ NamedTuple,
+ Optional,
+ Set,
+ Tuple,
+ Type,
+ TypeVar,
+ Union,
+)
+
+import django.urls
+from django.template import Context, Origin, Template
+from django.urls import URLPattern, URLResolver, get_resolver, get_urlconf
+
+from django_components.app_settings import app_settings
+from django_components.compat.django import routes_to_django
+from django_components.util.command import ComponentCommand
+from django_components.util.misc import snake_to_pascal
+from django_components.util.routing import URLRoute
+
+if TYPE_CHECKING:
+ from django_components import Component
+ from django_components.component_registry import ComponentRegistry
+ from django_components.perfutil.component import OnComponentRenderedResult
+ from django_components.slots import Slot, SlotNode, SlotResult
+
+
+TCallable = TypeVar("TCallable", bound=Callable)
+TClass = TypeVar("TClass", bound=Type[Any])
+
+
+################################################
+# HOOK TYPES
+#
+# This is the source of truth for what data is available in each hook.
+# NOTE: These types are also used in docs generation, see `docs/scripts/reference.py`.
+################################################
+
+
+# Mark a class as an extension hook context so we can place these in
+# a separate documentation section
+def mark_extension_hook_api(cls: TClass) -> TClass:
+ cls._extension_hook_api = True
+ return cls
+
+
+@mark_extension_hook_api
+class OnComponentClassCreatedContext(NamedTuple):
+ component_cls: Type["Component"]
+ """The created Component class"""
+
+
+@mark_extension_hook_api
+class OnComponentClassDeletedContext(NamedTuple):
+ component_cls: Type["Component"]
+ """The to-be-deleted Component class"""
+
+
+@mark_extension_hook_api
+class OnRegistryCreatedContext(NamedTuple):
+ registry: "ComponentRegistry"
+ """The created ComponentRegistry instance"""
+
+
+@mark_extension_hook_api
+class OnRegistryDeletedContext(NamedTuple):
+ registry: "ComponentRegistry"
+ """The to-be-deleted ComponentRegistry instance"""
+
+
+@mark_extension_hook_api
+class OnComponentRegisteredContext(NamedTuple):
+ registry: "ComponentRegistry"
+ """The registry the component was registered to"""
+ name: str
+ """The name the component was registered under"""
+ component_cls: Type["Component"]
+ """The registered Component class"""
+
+
+@mark_extension_hook_api
+class OnComponentUnregisteredContext(NamedTuple):
+ registry: "ComponentRegistry"
+ """The registry the component was unregistered from"""
+ name: str
+ """The name the component was registered under"""
+ component_cls: Type["Component"]
+ """The unregistered Component class"""
+
+
+@mark_extension_hook_api
+class OnComponentInputContext(NamedTuple):
+ component: "Component"
+ """The Component instance that received the input and is being rendered"""
+ component_cls: Type["Component"]
+ """The Component class"""
+ component_id: str
+ """The unique identifier for this component instance"""
+ args: List
+ """List of positional arguments passed to the component"""
+ kwargs: Dict
+ """Dictionary of keyword arguments passed to the component"""
+ slots: Dict[str, "Slot"]
+ """Dictionary of slot definitions"""
+ context: Context
+ """The Django template Context object"""
+
+
+@mark_extension_hook_api
+class OnComponentDataContext(NamedTuple):
+ component: "Component"
+ """The Component instance that is being rendered"""
+ component_cls: Type["Component"]
+ """The Component class"""
+ component_id: str
+ """The unique identifier for this component instance"""
+ # TODO_V1 - Remove `context_data`
+ context_data: Dict
+ """Deprecated. Use `template_data` instead. Will be removed in v1.0."""
+ template_data: Dict
+ """Dictionary of template data from `Component.get_template_data()`"""
+ js_data: Dict
+ """Dictionary of JavaScript data from `Component.get_js_data()`"""
+ css_data: Dict
+ """Dictionary of CSS data from `Component.get_css_data()`"""
+
+
+@mark_extension_hook_api
+class OnComponentRenderedContext(NamedTuple):
+ component: "Component"
+ """The Component instance that is being rendered"""
+ component_cls: Type["Component"]
+ """The Component class"""
+ component_id: str
+ """The unique identifier for this component instance"""
+ result: Optional[str]
+ """The rendered component, or `None` if rendering failed"""
+ error: Optional[Exception]
+ """The error that occurred during rendering, or `None` if rendering was successful"""
+
+
+@mark_extension_hook_api
+class OnSlotRenderedContext(NamedTuple):
+ component: "Component"
+ """The Component instance that contains the `{% slot %}` tag"""
+ component_cls: Type["Component"]
+ """The Component class that contains the `{% slot %}` tag"""
+ component_id: str
+ """The unique identifier for this component instance"""
+ slot: "Slot"
+ """The Slot instance that was rendered"""
+ slot_name: str
+ """The name of the `{% slot %}` tag"""
+ slot_node: "SlotNode"
+ """The node instance of the `{% slot %}` tag"""
+ slot_is_required: bool
+ """Whether the slot is required"""
+ slot_is_default: bool
+ """Whether the slot is default"""
+ result: "SlotResult"
+ """The rendered result of the slot"""
+
+
+@mark_extension_hook_api
+class OnTemplateLoadedContext(NamedTuple):
+ component_cls: Type["Component"]
+ """The Component class whose template was loaded"""
+ content: str
+ """The template string"""
+ origin: Optional[Origin]
+ """The origin of the template"""
+ name: Optional[str]
+ """The name of the template"""
+
+
+@mark_extension_hook_api
+class OnTemplateCompiledContext(NamedTuple):
+ component_cls: Type["Component"]
+ """The Component class whose template was loaded"""
+ template: Template
+ """The compiled template object"""
+
+
+@mark_extension_hook_api
+class OnCssLoadedContext(NamedTuple):
+ component_cls: Type["Component"]
+ """The Component class whose CSS was loaded"""
+ content: str
+ """The CSS content (string)"""
+
+
+@mark_extension_hook_api
+class OnJsLoadedContext(NamedTuple):
+ component_cls: Type["Component"]
+ """The Component class whose JS was loaded"""
+ content: str
+ """The JS content (string)"""
+
+
+################################################
+# EXTENSIONS CORE
+################################################
+
+
+class ExtensionComponentConfig:
+ """
+ `ExtensionComponentConfig` is the base class for all extension component configs.
+
+ Extensions can define nested classes on the component class,
+ such as [`Component.View`](../api#django_components.Component.View) or
+ [`Component.Cache`](../api#django_components.Component.Cache):
+
+ ```py
+ class MyComp(Component):
+ class View:
+ def get(self, request):
+ ...
+
+ class Cache:
+ ttl = 60
+ ```
+
+ This allows users to configure extension behavior per component.
+
+ Behind the scenes, the nested classes that users define on their components
+ are merged with the extension's "base" class.
+
+ So the example above is the same as:
+
+ ```py
+ class MyComp(Component):
+ class View(ViewExtension.ComponentConfig):
+ def get(self, request):
+ ...
+
+ class Cache(CacheExtension.ComponentConfig):
+ ttl = 60
+ ```
+
+ Where both `ViewExtension.ComponentConfig` and `CacheExtension.ComponentConfig` are
+ subclasses of `ExtensionComponentConfig`.
+ """
+
+ component_cls: Type["Component"]
+ """The [`Component`](../api#django_components.Component) class that this extension is defined on."""
+
+ # TODO_v1 - Remove, superseded by `component_cls`
+ component_class: Type["Component"]
+ """The [`Component`](../api#django_components.Component) class that this extension is defined on."""
+
+ component: "Component"
+ """
+ When a [`Component`](../api#django_components.Component) is instantiated,
+ also the nested extension classes (such as `Component.View`) are instantiated,
+ receiving the component instance as an argument.
+
+ This attribute holds the owner [`Component`](../api#django_components.Component) instance
+ that this extension is defined on.
+ """
+
+ def __init__(self, component: "Component") -> None:
+ self.component = component
+
+
+# TODO_v1 - Delete
+BaseExtensionClass = ExtensionComponentConfig
+"""
+Deprecated. Will be removed in v1.0. Use
+[`ComponentConfig`](../api#django_components.ExtensionComponentConfig) instead.
+"""
+
+
+# TODO_V1 - Delete, meta class was needed only for backwards support for ExtensionClass.
+class ExtensionMeta(type):
+ def __new__(mcs, name: Any, bases: Tuple, attrs: Dict) -> Any:
+ # Rename `ExtensionClass` to `ComponentConfig`
+ if "ExtensionClass" in attrs:
+ attrs["ComponentConfig"] = attrs.pop("ExtensionClass")
+
+ return super().__new__(mcs, name, bases, attrs)
+
+
+# NOTE: This class is used for generating documentation for the extension hooks API.
+# To be recognized, all hooks must start with `on_` prefix.
+class ComponentExtension(metaclass=ExtensionMeta):
+ """
+ Base class for all extensions.
+
+ Read more on [Extensions](../../concepts/advanced/extensions).
+
+ **Example:**
+
+ ```python
+ class ExampleExtension(ComponentExtension):
+ name = "example"
+
+ # Component-level behavior and settings. User will be able to override
+ # the attributes and methods defined here on the component classes.
+ class ComponentConfig(ComponentExtension.ComponentConfig):
+ foo = "1"
+ bar = "2"
+
+ def baz(cls):
+ return "3"
+
+ # URLs
+ urls = [
+ URLRoute(path="dummy-view/", handler=dummy_view, name="dummy"),
+ URLRoute(path="dummy-view-2///", handler=dummy_view_2, name="dummy-2"),
+ ]
+
+ # Commands
+ commands = [
+ HelloWorldCommand,
+ ]
+
+ # Hooks
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ print(ctx.component_cls.__name__)
+
+ def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
+ print(ctx.component_cls.__name__)
+ ```
+
+ Which users then can override on a per-component basis. E.g.:
+
+ ```python
+ class MyComp(Component):
+ class Example:
+ foo = "overridden"
+
+ def baz(self):
+ return "overridden baz"
+ ```
+ """
+
+ ###########################
+ # USER INPUT
+ ###########################
+
+ name: ClassVar[str]
+ """
+ Name of the extension.
+
+ Name must be lowercase, and must be a valid Python identifier (e.g. `"my_extension"`).
+
+ The extension may add new features to the [`Component`](../api#django_components.Component)
+ class by allowing users to define and access a nested class in
+ the [`Component`](../api#django_components.Component) class.
+
+ The extension name determines the name of the nested class in
+ the [`Component`](../api#django_components.Component) class, and the attribute
+ under which the extension will be accessible.
+
+ E.g. if the extension name is `"my_extension"`, then the nested class in
+ the [`Component`](../api#django_components.Component) class will be
+ `MyExtension`, and the extension will be accessible as `MyComp.my_extension`.
+
+ ```python
+ class MyComp(Component):
+ class MyExtension:
+ ...
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "my_extension": self.my_extension.do_something(),
+ }
+ ```
+
+ !!! info
+
+ The extension class name can be customized by setting
+ the [`class_name`](../api#django_components.ComponentExtension.class_name) attribute.
+ """
+
+ class_name: ClassVar[str]
+ """
+ Name of the extension class.
+
+ By default, this is set automatically at class creation. The class name is the same as
+ the [`name`](../api#django_components.ComponentExtension.name) attribute, but with snake_case
+ converted to PascalCase.
+
+ So if the extension name is `"my_extension"`, then the extension class name will be `"MyExtension"`.
+
+ ```python
+ class MyComp(Component):
+ class MyExtension: # <--- This is the extension class
+ ...
+ ```
+
+ To customize the class name, you can manually set the `class_name` attribute.
+
+ The class name must be a valid Python identifier.
+
+ **Example:**
+
+ ```python
+ class MyExt(ComponentExtension):
+ name = "my_extension"
+ class_name = "MyCustomExtension"
+ ```
+
+ This will make the extension class name `"MyCustomExtension"`.
+
+ ```python
+ class MyComp(Component):
+ class MyCustomExtension: # <--- This is the extension class
+ ...
+ ```
+ """
+
+ ComponentConfig: ClassVar[Type[ExtensionComponentConfig]] = ExtensionComponentConfig
+ """
+ Base class that the "component-level" extension config nested within
+ a [`Component`](../api#django_components.Component) class will inherit from.
+
+ This is where you can define new methods and attributes that will be available to the component
+ instance.
+
+ Background:
+
+ The extension may add new features to the [`Component`](../api#django_components.Component) class
+ by allowing users to define and access a nested class in
+ the [`Component`](../api#django_components.Component) class. E.g.:
+
+ ```python
+ class MyComp(Component):
+ class MyExtension:
+ ...
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "my_extension": self.my_extension.do_something(),
+ }
+ ```
+
+ When rendering a component, the nested extension class will be set as a subclass of
+ `ComponentConfig`. So it will be same as if the user had directly inherited from extension's
+ `ComponentConfig`. E.g.:
+
+ ```python
+ class MyComp(Component):
+ class MyExtension(ComponentExtension.ComponentConfig):
+ ...
+ ```
+
+ This setting decides what the extension class will inherit from.
+ """
+
+ commands: ClassVar[List[Type[ComponentCommand]]] = []
+ """
+ List of commands that can be run by the extension.
+
+ These commands will be available to the user as `components ext run `.
+
+ Commands are defined as subclasses of
+ [`ComponentCommand`](../extension_commands#django_components.ComponentCommand).
+
+ **Example:**
+
+ This example defines an extension with a command that prints "Hello world". To run the command,
+ the user would run `components ext run hello_world hello`.
+
+ ```python
+ from django_components import ComponentCommand, ComponentExtension, CommandArg, CommandArgGroup
+
+ class HelloWorldCommand(ComponentCommand):
+ name = "hello"
+ help = "Hello world command."
+
+ # Allow to pass flags `--foo`, `--bar` and `--baz`.
+ # Argument parsing is managed by `argparse`.
+ arguments = [
+ CommandArg(
+ name_or_flags="--foo",
+ help="Foo description.",
+ ),
+ # When printing the command help message, `bar` and `baz`
+ # will be grouped under "group bar".
+ CommandArgGroup(
+ title="group bar",
+ description="Group description.",
+ arguments=[
+ CommandArg(
+ name_or_flags="--bar",
+ help="Bar description.",
+ ),
+ CommandArg(
+ name_or_flags="--baz",
+ help="Baz description.",
+ ),
+ ],
+ ),
+ ]
+
+ # Callback that receives the parsed arguments and options.
+ def handle(self, *args, **kwargs):
+ print(f"HelloWorldCommand.handle: args={args}, kwargs={kwargs}")
+
+ # Associate the command with the extension
+ class HelloWorldExtension(ComponentExtension):
+ name = "hello_world"
+
+ commands = [
+ HelloWorldCommand,
+ ]
+ ```
+ """
+
+ urls: ClassVar[List[URLRoute]] = []
+
+ ###########################
+ # Misc
+ ###########################
+
+ def __init_subclass__(cls) -> None:
+ if not cls.name.isidentifier():
+ raise ValueError(f"Extension name must be a valid Python identifier, got {cls.name}")
+ if not cls.name.islower():
+ raise ValueError(f"Extension name must be lowercase, got {cls.name}")
+
+ if not getattr(cls, "class_name", None):
+ cls.class_name = snake_to_pascal(cls.name)
+
+ ###########################
+ # Component lifecycle hooks
+ ###########################
+
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ """
+ Called when a new [`Component`](../api#django_components.Component) class is created.
+
+ This hook is called after the [`Component`](../api#django_components.Component) class
+ is fully defined but before it's registered.
+
+ Use this hook to perform any initialization or validation of the
+ [`Component`](../api#django_components.Component) class.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnComponentClassCreatedContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ # Add a new attribute to the Component class
+ ctx.component_cls.my_attr = "my_value"
+ ```
+ """
+ pass
+
+ def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
+ """
+ Called when a [`Component`](../api#django_components.Component) class is being deleted.
+
+ This hook is called before the [`Component`](../api#django_components.Component) class
+ is deleted from memory.
+
+ Use this hook to perform any cleanup related to the [`Component`](../api#django_components.Component) class.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnComponentClassDeletedContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
+ # Remove Component class from the extension's cache on deletion
+ self.cache.pop(ctx.component_cls, None)
+ ```
+ """
+ pass
+
+ def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
+ """
+ Called when a new [`ComponentRegistry`](../api#django_components.ComponentRegistry) is created.
+
+ This hook is called after a new
+ [`ComponentRegistry`](../api#django_components.ComponentRegistry) instance is initialized.
+
+ Use this hook to perform any initialization needed for the registry.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnRegistryCreatedContext
+
+ class MyExtension(ComponentExtension):
+ def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
+ # Add a new attribute to the registry
+ ctx.registry.my_attr = "my_value"
+ ```
+ """
+ pass
+
+ def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
+ """
+ Called when a [`ComponentRegistry`](../api#django_components.ComponentRegistry) is being deleted.
+
+ This hook is called before
+ a [`ComponentRegistry`](../api#django_components.ComponentRegistry) instance is deleted.
+
+ Use this hook to perform any cleanup related to the registry.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnRegistryDeletedContext
+
+ class MyExtension(ComponentExtension):
+ def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
+ # Remove registry from the extension's cache on deletion
+ self.cache.pop(ctx.registry, None)
+ ```
+ """
+ pass
+
+ def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
+ """
+ Called when a [`Component`](../api#django_components.Component) class is
+ registered with a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
+
+ This hook is called after a [`Component`](../api#django_components.Component) class
+ is successfully registered.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnComponentRegisteredContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
+ print(f"Component {ctx.component_cls} registered to {ctx.registry} as '{ctx.name}'")
+ ```
+ """
+ pass
+
+ def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
+ """
+ Called when a [`Component`](../api#django_components.Component) class is
+ unregistered from a [`ComponentRegistry`](../api#django_components.ComponentRegistry).
+
+ This hook is called after a [`Component`](../api#django_components.Component) class
+ is removed from the registry.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnComponentUnregisteredContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
+ print(f"Component {ctx.component_cls} unregistered from {ctx.registry} as '{ctx.name}'")
+ ```
+ """
+ pass
+
+ ###########################
+ # Component render hooks
+ ###########################
+
+ def on_component_input(self, ctx: OnComponentInputContext) -> Optional[str]:
+ """
+ Called when a [`Component`](../api#django_components.Component) was triggered to render,
+ but before a component's context and data methods are invoked.
+
+ Use this hook to modify or validate component inputs before they're processed.
+
+ This is the first hook that is called when rendering a component. As such this hook is called before
+ [`Component.get_template_data()`](../api#django_components.Component.get_template_data),
+ [`Component.get_js_data()`](../api#django_components.Component.get_js_data),
+ and [`Component.get_css_data()`](../api#django_components.Component.get_css_data) methods,
+ and the
+ [`on_component_data`](../extension_hooks#django_components.extension.ComponentExtension.on_component_data)
+ hook.
+
+ This hook also allows to skip the rendering of a component altogether. If the hook returns
+ a non-null value, this value will be used instead of rendering the component.
+
+ You can use this to implement a caching mechanism for components, or define components
+ that will be rendered conditionally.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnComponentInputContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_input(self, ctx: OnComponentInputContext) -> None:
+ # Add extra kwarg to all components when they are rendered
+ ctx.kwargs["my_input"] = "my_value"
+ ```
+
+ !!! warning
+
+ In this hook, the components' inputs are still mutable.
+
+ As such, if a component defines [`Args`](../api#django_components.Component.Args),
+ [`Kwargs`](../api#django_components.Component.Kwargs),
+ [`Slots`](../api#django_components.Component.Slots) types, these types are NOT yet instantiated.
+
+ Instead, component fields like [`Component.args`](../api#django_components.Component.args),
+ [`Component.kwargs`](../api#django_components.Component.kwargs),
+ [`Component.slots`](../api#django_components.Component.slots)
+ are plain `list` / `dict` objects.
+ """
+ pass
+
+ def on_component_data(self, ctx: OnComponentDataContext) -> None:
+ """
+ Called when a [`Component`](../api#django_components.Component) was triggered to render,
+ after a component's context and data methods have been processed.
+
+ This hook is called after
+ [`Component.get_template_data()`](../api#django_components.Component.get_template_data),
+ [`Component.get_js_data()`](../api#django_components.Component.get_js_data)
+ and [`Component.get_css_data()`](../api#django_components.Component.get_css_data).
+
+ This hook runs after [`on_component_input`](../api#django_components.ComponentExtension.on_component_input).
+
+ Use this hook to modify or validate the component's data before rendering.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnComponentDataContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_data(self, ctx: OnComponentDataContext) -> None:
+ # Add extra template variable to all components when they are rendered
+ ctx.template_data["my_template_var"] = "my_value"
+ ```
+ """
+ pass
+
+ def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
+ """
+ Called when a [`Component`](../api#django_components.Component) was rendered, including
+ all its child components.
+
+ Use this hook to access or post-process the component's rendered output.
+
+ This hook works similarly to
+ [`Component.on_render_after()`](../api#django_components.Component.on_render_after):
+
+ 1. To modify the output, return a new string from this hook. The original output or error will be ignored.
+
+ 2. To cause this component to return a new error, raise that error. The original output and error
+ will be ignored.
+
+ 3. If you neither raise nor return string, the original output or error will be used.
+
+ **Examples:**
+
+ Change the final output of a component:
+
+ ```python
+ from django_components import ComponentExtension, OnComponentRenderedContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
+ # Append a comment to the component's rendered output
+ return ctx.result + ""
+ ```
+
+ Cause the component to raise a new exception:
+
+ ```python
+ from django_components import ComponentExtension, OnComponentRenderedContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
+ # Raise a new exception
+ raise Exception("Error message")
+ ```
+
+ Return nothing (or `None`) to handle the result as usual:
+
+ ```python
+ from django_components import ComponentExtension, OnComponentRenderedContext
+
+ class MyExtension(ComponentExtension):
+ def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
+ if ctx.error is not None:
+ # The component raised an exception
+ print(f"Error: {ctx.error}")
+ else:
+ # The component rendered successfully
+ print(f"Result: {ctx.result}")
+ ```
+ """
+ pass
+
+ ##########################
+ # Template / JS / CSS hooks
+ ##########################
+
+ def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
+ """
+ Called when a Component's template is loaded as a string.
+
+ This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
+ [`Component.template`](../api#django_components.Component.template) and
+ [`Component.template_file`](../api#django_components.Component.template_file).
+
+ Use this hook to read or modify the template before it's compiled.
+
+ To modify the template, return a new string from this hook.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnTemplateLoadedContext
+
+ class MyExtension(ComponentExtension):
+ def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> Optional[str]:
+ # Modify the template
+ return ctx.content.replace("Hello", "Hi")
+ ```
+ """
+ pass
+
+ def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
+ """
+ Called when a Component's template is compiled
+ into a [`Template`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template) object.
+
+ This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
+ [`Component.template`](../api#django_components.Component.template) and
+ [`Component.template_file`](../api#django_components.Component.template_file).
+
+ Use this hook to read or modify the template (in-place) after it's compiled.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnTemplateCompiledContext
+
+ class MyExtension(ComponentExtension):
+ def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
+ print(f"Template origin: {ctx.template.origin.name}")
+ ```
+ """
+ pass
+
+ def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
+ """
+ Called when a Component's CSS is loaded as a string.
+
+ This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
+ [`Component.css`](../api#django_components.Component.css) and
+ [`Component.css_file`](../api#django_components.Component.css_file).
+
+ Use this hook to read or modify the CSS.
+
+ To modify the CSS, return a new string from this hook.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnCssLoadedContext
+
+ class MyExtension(ComponentExtension):
+ def on_css_loaded(self, ctx: OnCssLoadedContext) -> Optional[str]:
+ # Modify the CSS
+ return ctx.content.replace("Hello", "Hi")
+ ```
+ """
+ pass
+
+ def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
+ """
+ Called when a Component's JS is loaded as a string.
+
+ This hook runs only once per [`Component`](../api#django_components.Component) class and works for both
+ [`Component.js`](../api#django_components.Component.js) and
+ [`Component.js_file`](../api#django_components.Component.js_file).
+
+ Use this hook to read or modify the JS.
+
+ To modify the JS, return a new string from this hook.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnCssLoadedContext
+
+ class MyExtension(ComponentExtension):
+ def on_js_loaded(self, ctx: OnJsLoadedContext) -> Optional[str]:
+ # Modify the JS
+ return ctx.content.replace("Hello", "Hi")
+ ```
+ """
+ pass
+
+ ##########################
+ # Tags lifecycle hooks
+ ##########################
+
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
+ """
+ Called when a [`{% slot %}`](../template_tags#slot) tag was rendered.
+
+ Use this hook to access or post-process the slot's rendered output.
+
+ To modify the output, return a new string from this hook.
+
+ **Example:**
+
+ ```python
+ from django_components import ComponentExtension, OnSlotRenderedContext
+
+ class MyExtension(ComponentExtension):
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
+ # Append a comment to the slot's rendered output
+ return ctx.result + ""
+ ```
+
+ **Access slot metadata:**
+
+ You can access the [`{% slot %}` tag](../template_tags#slot)
+ node ([`SlotNode`](../api#django_components.SlotNode)) and its metadata using `ctx.slot_node`.
+
+ For example, to find the [`Component`](../api#django_components.Component) class to which
+ belongs the template where the [`{% slot %}`](../template_tags#slot) tag is defined, you can use
+ [`ctx.slot_node.template_component`](../api#django_components.SlotNode.template_component):
+
+ ```python
+ from django_components import ComponentExtension, OnSlotRenderedContext
+
+ class MyExtension(ComponentExtension):
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
+ # Access slot metadata
+ slot_node = ctx.slot_node
+ slot_owner = slot_node.template_component
+ print(f"Slot owner: {slot_owner}")
+ ```
+ """
+ pass
+
+
+# Decorator to store events in `ExtensionManager._events` when django_components is not yet initialized.
+def store_events(func: TCallable) -> TCallable:
+ fn_name = func.__name__
+
+ @wraps(func)
+ def wrapper(self: "ExtensionManager", ctx: Any) -> Any:
+ if not self._initialized:
+ self._events.append((fn_name, ctx))
+ return
+
+ return func(self, ctx)
+
+ return wrapper # type: ignore[return-value]
+
+
+# Manage all extensions from a single place
+class ExtensionManager:
+ ###########################
+ # Internal
+ ###########################
+
+ def __init__(self) -> None:
+ self._initialized = False
+ self._events: List[Tuple[str, Any]] = []
+ self._url_resolvers: Dict[str, URLResolver] = {}
+ # Keep track of which URLRoute (framework-agnostic) maps to which URLPattern (Django-specific)
+ self._route_to_url: Dict[URLRoute, Union[URLPattern, URLResolver]] = {}
+
+ @property
+ def extensions(self) -> List[ComponentExtension]:
+ return app_settings.EXTENSIONS
+
+ def _init_component_class(self, component_cls: Type["Component"]) -> None:
+ # If not yet initialized, this class will be initialized later once we run `_init_app`
+ if not self._initialized:
+ return
+
+ for extension in self.extensions:
+ ext_class_name = extension.class_name
+
+ # If a Component class has a nested extension class, e.g.
+ # ```python
+ # class MyComp(Component):
+ # class MyExtension:
+ # ...
+ # ```
+ # then create a dummy class to make `MyComp.MyExtension` extend
+ # the base class `extension.ComponentConfig`.
+ #
+ # So it will be same as if the user had directly inherited from `extension.ComponentConfig`.
+ # ```python
+ # class MyComp(Component):
+ # class MyExtension(MyExtension.ComponentConfig):
+ # ...
+ # ```
+ component_ext_subclass = getattr(component_cls, ext_class_name, None)
+
+ # Add escape hatch, so that user can override the extension class
+ # from within the component class. E.g.:
+ # ```python
+ # class MyExtDifferentButStillSame(MyExtension.ComponentConfig):
+ # ...
+ #
+ # class MyComp(Component):
+ # my_extension_class = MyExtDifferentButStillSame
+ # class MyExtension:
+ # ...
+ # ```
+ #
+ # Will be effectively the same as:
+ # ```python
+ # class MyComp(Component):
+ # class MyExtension(MyExtDifferentButStillSame):
+ # ...
+ # ```
+ ext_class_override_attr = extension.name + "_class" # "my_extension_class"
+ ext_base_class = getattr(component_cls, ext_class_override_attr, extension.ComponentConfig)
+
+ # Extensions have 3 levels of configuration:
+ # 1. Factory defaults - The values that the extension author set on the extension class
+ # 2. User global defaults with `COMPONENTS.extensions_defaults`
+ # 3. User component-level settings - The values that the user set on the component class
+ #
+ # The component-level settings override the global defaults, which in turn override
+ # the factory defaults.
+ #
+ # To apply these defaults, we set them as bases for our new extension class.
+ #
+ # The final class will look like this:
+ # ```
+ # class MyExtension(MyComp.MyExtension, MyExtensionDefaults, MyExtensionBase):
+ # component_cls = MyComp
+ # ...
+ # ```
+ # Where:
+ # - `MyComp.MyExtension` is the extension class that the user defined on the component class.
+ # - `MyExtensionDefaults` is a dummy class that holds the extension defaults from settings.
+ # - `MyExtensionBase` is the base class that the extension class inherits from.
+ bases_list = [ext_base_class]
+
+ all_extensions_defaults = app_settings.EXTENSIONS_DEFAULTS or {}
+ extension_defaults = all_extensions_defaults.get(extension.name, None)
+ if extension_defaults:
+ # Create dummy class that holds the extension defaults
+ defaults_class = type(f"{ext_class_name}Defaults", tuple(), extension_defaults.copy())
+ bases_list.insert(0, defaults_class)
+
+ if component_ext_subclass:
+ bases_list.insert(0, component_ext_subclass)
+
+ bases: tuple[Type, ...] = tuple(bases_list)
+
+ # Allow component-level extension class to access the owner `Component` class that via
+ # `component_cls`.
+ component_ext_subclass = type(
+ ext_class_name,
+ bases,
+ # TODO_v1 - Remove `component_class`, superseded by `component_cls`
+ {"component_cls": component_cls, "component_class": component_cls},
+ )
+
+ # Finally, reassign the new class extension class on the component class.
+ setattr(component_cls, ext_class_name, component_ext_subclass)
+
+ def _init_component_instance(self, component: "Component") -> None:
+ # Each extension has different class defined nested on the Component class:
+ # ```python
+ # class MyComp(Component):
+ # class MyExtension:
+ # ...
+ # class MyOtherExtension:
+ # ...
+ # ```
+ #
+ # We instantiate them all, passing the component instance to each. These are then
+ # available under the extension name on the component instance.
+ # ```python
+ # component.my_extension
+ # component.my_other_extension
+ # ```
+ for extension in self.extensions:
+ # NOTE: `_init_component_class` creates extension-specific nested classes
+ # on the created component classes, e.g.:
+ # ```py
+ # class MyComp(Component):
+ # class MyExtension:
+ # ...
+ # ```
+ # It should NOT happen in production, but in tests it may happen, if some extensions
+ # are test-specific, then the built-in component classes (like DynamicComponent) will
+ # be initialized BEFORE the extension is set in the settings. As such, they will be missing
+ # the nested class. In that case, we retroactively create the extension-specific nested class,
+ # so that we may proceed.
+ if not hasattr(component, extension.class_name):
+ self._init_component_class(component.__class__)
+
+ used_ext_class = getattr(component, extension.class_name)
+ extension_instance = used_ext_class(component)
+ setattr(component, extension.name, extension_instance)
+
+ def _init_app(self) -> None:
+ if self._initialized:
+ return
+
+ self._initialized = True
+
+ # Populate the `urlpatterns` with URLs specified by the extensions
+ # TODO_V3 - Django-specific logic - replace with hook
+ urls: List[URLResolver] = []
+ seen_names: Set[str] = set()
+
+ from django_components import Component
+
+ for extension in self.extensions:
+ # Ensure that the extension name won't conflict with existing Component class API
+ if hasattr(Component, extension.name) or hasattr(Component, extension.class_name):
+ raise ValueError(f"Extension name '{extension.name}' conflicts with existing Component class API")
+
+ if extension.name.lower() in seen_names:
+ raise ValueError(f"Multiple extensions cannot have the same name '{extension.name}'")
+
+ seen_names.add(extension.name.lower())
+
+ # NOTE: The empty list is a placeholder for the URLs that will be added later
+ curr_ext_url_resolver = django.urls.path(f"{extension.name}/", django.urls.include([]))
+ urls.append(curr_ext_url_resolver)
+
+ # Remember which extension the URLResolver belongs to
+ self._url_resolvers[extension.name] = curr_ext_url_resolver
+
+ self.add_extension_urls(extension.name, extension.urls)
+
+ # NOTE: `urlconf_name` is the actual source of truth that holds either a list of URLPatterns
+ # or an import string thereof.
+ # However, Django's `URLResolver` caches the resolved value of `urlconf_name`
+ # under the key `url_patterns`.
+ # So we set both:
+ # - `urlconf_name` to update the source of truth
+ # - `url_patterns` to override the caching
+ extensions_url_resolver.urlconf_name = urls
+ extensions_url_resolver.url_patterns = urls
+
+ # Rebuild URL resolver cache to be able to resolve the new routes by their names.
+ urlconf = get_urlconf()
+ resolver = get_resolver(urlconf)
+ resolver._populate()
+
+ # Flush stored events
+ #
+ # The triggers for following hooks may occur before the `apps.py` `ready()` hook is called.
+ # - on_component_class_created
+ # - on_component_class_deleted
+ # - on_registry_created
+ # - on_registry_deleted
+ # - on_component_registered
+ # - on_component_unregistered
+ #
+ # The problem is that the extensions are set up only at the initialization (`ready()` hook in `apps.py`).
+ #
+ # So in the case that these hooks are triggered before initialization,
+ # we store these "events" in a list, and then "flush" them all when `ready()` is called.
+ #
+ # This way, we can ensure that all extensions are present before any hooks are called.
+ for hook, data in self._events:
+ if hook == "on_component_class_created":
+ on_component_created_data: OnComponentClassCreatedContext = data
+ self._init_component_class(on_component_created_data.component_cls)
+ getattr(self, hook)(data)
+ self._events = []
+
+ def get_extension(self, name: str) -> ComponentExtension:
+ for extension in self.extensions:
+ if extension.name == name:
+ return extension
+ raise ValueError(f"Extension {name} not found")
+
+ def get_extension_command(self, name: str, command_name: str) -> Type[ComponentCommand]:
+ extension = self.get_extension(name)
+ for command in extension.commands:
+ if command.name == command_name:
+ return command
+ raise ValueError(f"Command {command_name} not found in extension {name}")
+
+ def add_extension_urls(self, name: str, urls: List[URLRoute]) -> None:
+ if not self._initialized:
+ raise RuntimeError("Cannot add extension URLs before initialization")
+
+ url_resolver = self._url_resolvers[name]
+ all_urls = url_resolver.url_patterns
+ new_urls = routes_to_django(urls)
+
+ did_add_urls = False
+
+ # Allow to add only those routes that are not yet added
+ for route, urlpattern in zip(urls, new_urls):
+ if route in self._route_to_url:
+ raise ValueError(f"URLRoute {route} already exists")
+ self._route_to_url[route] = urlpattern
+ all_urls.append(urlpattern)
+ did_add_urls = True
+
+ # Force Django's URLResolver to update its lookups, so things like `reverse()` work
+ if did_add_urls:
+ # Django's root URLResolver
+ urlconf = get_urlconf()
+ root_resolver = get_resolver(urlconf)
+ root_resolver._populate()
+
+ def remove_extension_urls(self, name: str, urls: List[URLRoute]) -> None:
+ if not self._initialized:
+ raise RuntimeError("Cannot remove extension URLs before initialization")
+
+ url_resolver = self._url_resolvers[name]
+ urls_to_remove = routes_to_django(urls)
+ all_urls = url_resolver.url_patterns
+
+ # Remove the URLs in reverse order, so that we don't have to deal with index shifting
+ for index in reversed(range(len(all_urls))):
+ if not urls_to_remove:
+ break
+
+ # Instead of simply checking if the URL is in the `urls_to_remove` list, we search for
+ # the index of the URL within the `urls_to_remove` list, so we can remove it from there.
+ # That way, in theory, the iteration should be faster as the list gets smaller.
+ try:
+ found_index = urls_to_remove.index(all_urls[index])
+ except ValueError:
+ found_index = -1
+
+ if found_index != -1:
+ all_urls.pop(index)
+ urls_to_remove.pop(found_index)
+
+ #############################
+ # Component lifecycle hooks
+ #############################
+
+ @store_events
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ for extension in self.extensions:
+ extension.on_component_class_created(ctx)
+
+ @store_events
+ def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
+ for extension in self.extensions:
+ extension.on_component_class_deleted(ctx)
+
+ @store_events
+ def on_registry_created(self, ctx: OnRegistryCreatedContext) -> None:
+ for extension in self.extensions:
+ extension.on_registry_created(ctx)
+
+ @store_events
+ def on_registry_deleted(self, ctx: OnRegistryDeletedContext) -> None:
+ for extension in self.extensions:
+ extension.on_registry_deleted(ctx)
+
+ @store_events
+ def on_component_registered(self, ctx: OnComponentRegisteredContext) -> None:
+ for extension in self.extensions:
+ extension.on_component_registered(ctx)
+
+ @store_events
+ def on_component_unregistered(self, ctx: OnComponentUnregisteredContext) -> None:
+ for extension in self.extensions:
+ extension.on_component_unregistered(ctx)
+
+ ###########################
+ # Component render hooks
+ ###########################
+
+ def on_component_input(self, ctx: OnComponentInputContext) -> Optional[str]:
+ for extension in self.extensions:
+ result = extension.on_component_input(ctx)
+ # The extension short-circuited the rendering process to return this
+ if result is not None:
+ return result
+ return None
+
+ def on_component_data(self, ctx: OnComponentDataContext) -> None:
+ for extension in self.extensions:
+ extension.on_component_data(ctx)
+
+ def on_component_rendered(
+ self,
+ ctx: OnComponentRenderedContext,
+ ) -> Optional["OnComponentRenderedResult"]:
+ for extension in self.extensions:
+ try:
+ result = extension.on_component_rendered(ctx)
+ except Exception as error:
+ # Error from `on_component_rendered()` - clear HTML and set error
+ ctx = ctx._replace(result=None, error=error)
+ else:
+ # No error from `on_component_rendered()` - set HTML and clear error
+ if result is not None:
+ ctx = ctx._replace(result=result, error=None)
+ return ctx.result, ctx.error
+
+ ##########################
+ # Template / JS / CSS hooks
+ ##########################
+
+ def on_template_loaded(self, ctx: OnTemplateLoadedContext) -> str:
+ for extension in self.extensions:
+ content = extension.on_template_loaded(ctx)
+ if content is not None:
+ ctx = ctx._replace(content=content)
+ return ctx.content
+
+ def on_template_compiled(self, ctx: OnTemplateCompiledContext) -> None:
+ for extension in self.extensions:
+ extension.on_template_compiled(ctx)
+
+ def on_css_loaded(self, ctx: OnCssLoadedContext) -> str:
+ for extension in self.extensions:
+ content = extension.on_css_loaded(ctx)
+ if content is not None:
+ ctx = ctx._replace(content=content)
+ return ctx.content
+
+ def on_js_loaded(self, ctx: OnJsLoadedContext) -> str:
+ for extension in self.extensions:
+ content = extension.on_js_loaded(ctx)
+ if content is not None:
+ ctx = ctx._replace(content=content)
+ return ctx.content
+
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
+ for extension in self.extensions:
+ result = extension.on_slot_rendered(ctx)
+ if result is not None:
+ ctx = ctx._replace(result=result)
+ return ctx.result
+
+
+# NOTE: This is a singleton which is takes the extensions from `app_settings.EXTENSIONS`
+extensions = ExtensionManager()
+
+
+################################
+# VIEW
+################################
+
+# Extensions can define their own URLs, which will be added to the `urlpatterns` list.
+# These will be available under the `/components/ext//` path, e.g.:
+# `/components/ext/my_extension/path/to/route///`
+urlpatterns = [
+ django.urls.path("ext/", django.urls.include([])),
+]
+
+# NOTE: Normally we'd pass all the routes introduced by extensions to `django.urls.include()` and
+# `django.urls.path()` to construct the `URLResolver` objects that would take care of the rest.
+#
+# However, Django's `urlpatterns` are constructed BEFORE the `ready()` hook is called,
+# and so before the extensions are ready.
+#
+# As such, we lazily set the extensions' routes to the `URLResolver` object. And we use the `include()
+# and `path()` funtions above to ensure that the `URLResolver` object is created correctly.
+extensions_url_resolver: URLResolver = urlpatterns[0]
diff --git a/src/django_components/extensions/__init__.py b/src/django_components/extensions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/django_components/extensions/cache.py b/src/django_components/extensions/cache.py
new file mode 100644
index 00000000..5f07dede
--- /dev/null
+++ b/src/django_components/extensions/cache.py
@@ -0,0 +1,205 @@
+from hashlib import md5
+from typing import Any, Dict, List, Optional
+
+from django.core.cache import BaseCache, caches
+
+from django_components.extension import (
+ ComponentExtension,
+ ExtensionComponentConfig,
+ OnComponentInputContext,
+ OnComponentRenderedContext,
+)
+from django_components.slots import Slot
+
+# NOTE: We allow users to override cache key generation, but then we internally
+# still prefix their key with our own prefix, so it's clear where it comes from.
+CACHE_KEY_PREFIX = "components:cache:"
+
+
+class ComponentCache(ExtensionComponentConfig):
+ """
+ The interface for `Component.Cache`.
+
+ The fields of this class are used to configure the component caching.
+
+ Read more about [Component caching](../../concepts/advanced/component_caching).
+
+ **Example:**
+
+ ```python
+ from django_components import Component
+
+ class MyComponent(Component):
+ class Cache:
+ enabled = True
+ ttl = 60 * 60 * 24 # 1 day
+ cache_name = "my_cache"
+ ```
+ """
+
+ enabled: bool = False
+ """
+ Whether this Component should be cached. Defaults to `False`.
+ """
+ include_slots: bool = False
+ """
+ Whether the slots should be hashed into the cache key.
+
+ If enabled, the following two cases will be treated as different entries:
+
+ ```django
+ {% component "mycomponent" name="foo" %}
+ FILL ONE
+ {% endcomponent %}
+
+ {% component "mycomponent" name="foo" %}
+ FILL TWO
+ {% endcomponent %}
+ ```
+
+ !!! warning
+
+ Passing slots as functions to cached components with `include_slots=True` will raise an error.
+
+ !!! warning
+
+ Slot caching DOES NOT account for context variables within the `{% fill %}` tag.
+
+ For example, the following two cases will be treated as the same entry:
+
+ ```django
+ {% with my_var="foo" %}
+ {% component "mycomponent" name="foo" %}
+ {{ my_var }}
+ {% endcomponent %}
+ {% endwith %}
+
+ {% with my_var="bar" %}
+ {% component "mycomponent" name="bar" %}
+ {{ my_var }}
+ {% endcomponent %}
+ {% endwith %}
+ ```
+
+ Currently it's impossible to capture used variables. This will be addressed in v2.
+ Read more about it in https://github.com/django-components/django-components/issues/1164.
+ """
+
+ ttl: Optional[int] = None
+ """
+ The time-to-live (TTL) in seconds, i.e. for how long should an entry be valid in the cache.
+
+ - If `> 0`, the entries will be cached for the given number of seconds.
+ - If `-1`, the entries will be cached indefinitely.
+ - If `0`, the entries won't be cached.
+ - If `None`, the default TTL will be used.
+ """
+
+ cache_name: Optional[str] = None
+ """
+ The name of the cache to use. If `None`, the default cache will be used.
+ """
+
+ def get_entry(self, cache_key: str) -> Any:
+ cache = self.get_cache()
+ return cache.get(cache_key)
+
+ def set_entry(self, cache_key: str, value: Any) -> None:
+ cache = self.get_cache()
+ cache.set(cache_key, value, timeout=self.ttl)
+
+ def get_cache(self) -> BaseCache:
+ cache_name = self.cache_name or "default"
+ cache = caches[cache_name]
+ return cache
+
+ def get_cache_key(self, args: List, kwargs: Dict, slots: Dict) -> str:
+ # Allow user to override how the input is hashed into a cache key with `hash()`,
+ # but then still prefix it wih our own prefix, so it's clear where it comes from.
+ cache_key = self.hash(args, kwargs)
+ if self.include_slots:
+ cache_key += ":" + self.hash_slots(slots)
+ cache_key = self.component._class_hash + ":" + cache_key
+ cache_key = CACHE_KEY_PREFIX + md5(cache_key.encode()).hexdigest()
+ return cache_key
+
+ def hash(self, args: List, kwargs: Dict) -> str:
+ """
+ Defines how the input (both args and kwargs) is hashed into a cache key.
+
+ By default, `hash()` serializes the input into a string. As such, the default
+ implementation might NOT be suitable if you need to hash complex objects.
+ """
+ args_hash = ",".join(str(arg) for arg in args)
+ # Sort keys to ensure consistent ordering
+ sorted_items = sorted(kwargs.items())
+ kwargs_hash = ",".join(f"{k}-{v}" for k, v in sorted_items)
+ return f"{args_hash}:{kwargs_hash}"
+
+ def hash_slots(self, slots: Dict[str, Slot]) -> str:
+ sorted_items = sorted(slots.items())
+ hash_parts = []
+ for key, slot in sorted_items:
+ if callable(slot.contents):
+ raise ValueError(
+ f"Cannot hash slot '{key}' of component '{self.component.name}' - Slot functions are unhashable."
+ " Instead define the slot as a string or `{% fill %}` tag, or disable slot caching"
+ " with `Cache.include_slots=False`."
+ )
+ hash_parts.append(f"{key}-{slot.contents}")
+ return ",".join(hash_parts)
+
+
+class CacheExtension(ComponentExtension):
+ """
+ This extension adds a nested `Cache` class to each `Component`.
+
+ This nested `Cache` class is used to configure component caching.
+
+ **Example:**
+
+ ```python
+ from django_components import Component
+
+ class MyComponent(Component):
+ class Cache:
+ enabled = True
+ ttl = 60 * 60 * 24 # 1 day
+ cache_name = "my_cache"
+ ```
+
+ This extension is automatically added to all components.
+ """
+
+ name = "cache"
+
+ ComponentConfig = ComponentCache
+
+ def __init__(self, *args: Any, **kwargs: Any):
+ self.render_id_to_cache_key: dict[str, str] = {}
+
+ def on_component_input(self, ctx: OnComponentInputContext) -> Optional[Any]:
+ cache_instance = ctx.component.cache
+ if not cache_instance.enabled:
+ return None
+
+ cache_key = cache_instance.get_cache_key(ctx.args, ctx.kwargs, ctx.slots)
+ self.render_id_to_cache_key[ctx.component_id] = cache_key
+
+ # If cache entry exists, return it. This will short-circuit the rendering process.
+ cached_result = cache_instance.get_entry(cache_key)
+ if cached_result is not None:
+ return cached_result
+ return None
+
+ # Save the rendered component to cache
+ def on_component_rendered(self, ctx: OnComponentRenderedContext) -> None:
+ cache_instance = ctx.component.cache
+ if not cache_instance.enabled:
+ return None
+
+ if ctx.error is not None:
+ return
+
+ cache_key = self.render_id_to_cache_key[ctx.component_id]
+ cache_instance.set_entry(cache_key, ctx.result)
diff --git a/src/django_components/extensions/debug_highlight.py b/src/django_components/extensions/debug_highlight.py
new file mode 100644
index 00000000..acaf1f5c
--- /dev/null
+++ b/src/django_components/extensions/debug_highlight.py
@@ -0,0 +1,140 @@
+from typing import Any, Literal, NamedTuple, Optional, Type
+
+from django_components.app_settings import app_settings
+from django_components.extension import (
+ ComponentExtension,
+ ExtensionComponentConfig,
+ OnComponentRenderedContext,
+ OnSlotRenderedContext,
+)
+from django_components.util.misc import gen_id
+
+
+class HighlightColor(NamedTuple):
+ text_color: str
+ border_color: str
+
+
+COLORS = {
+ "component": HighlightColor(text_color="#2f14bb", border_color="blue"),
+ "slot": HighlightColor(text_color="#bb1414", border_color="#e40c0c"),
+}
+
+
+def apply_component_highlight(type: Literal["component", "slot"], output: str, name: str) -> str:
+ """
+ Wrap HTML (string) in a div with a border and a highlight color.
+
+ This is part of the component / slot highlighting feature. User can toggle on
+ to see the component / slot boundaries.
+ """
+ color = COLORS[type]
+
+ # Because the component / slot name is set via styling as a `::before` pseudo-element,
+ # we need to generate a unique ID for each component / slot to avoid conflicts.
+ highlight_id = gen_id()
+
+ output = f"""
+
+
+ {output}
+
+ """
+
+ return output
+
+
+class HighlightComponentsDescriptor:
+ def __get__(self, obj: Optional[Any], objtype: Type) -> bool:
+ return app_settings.DEBUG_HIGHLIGHT_COMPONENTS
+
+
+class HighlightSlotsDescriptor:
+ def __get__(self, obj: Optional[Any], objtype: Type) -> bool:
+ return app_settings.DEBUG_HIGHLIGHT_SLOTS
+
+
+class ComponentDebugHighlight(ExtensionComponentConfig):
+ """
+ The interface for `Component.DebugHighlight`.
+
+ The fields of this class are used to configure the component debug highlighting for this component
+ and its direct slots.
+
+ Read more about [Component debug highlighting](../../guides/other/troubleshooting#component-and-slot-highlighting).
+
+ **Example:**
+
+ ```python
+ from django_components import Component
+
+ class MyComponent(Component):
+ class DebugHighlight:
+ highlight_components = True
+ highlight_slots = True
+ ```
+
+ To highlight ALL components and slots, set
+ [extension defaults](../../reference/settings/#django_components.app_settings.ComponentsSettings.extensions_defaults)
+ in your settings:
+
+ ```python
+ from django_components import ComponentsSettings
+
+ COMPONENTS = ComponentsSettings(
+ extensions_defaults={
+ "debug_highlight": {
+ "highlight_components": True,
+ "highlight_slots": True,
+ },
+ },
+ )
+ ```
+ """ # noqa: E501
+
+ # TODO_v1 - Remove `DEBUG_HIGHLIGHT_COMPONENTS` and `DEBUG_HIGHLIGHT_SLOTS`
+ # Instead set this as plain boolean fields.
+ highlight_components = HighlightComponentsDescriptor()
+ """Whether to highlight this component in the rendered output."""
+ highlight_slots = HighlightSlotsDescriptor()
+ """Whether to highlight slots of this component in the rendered output."""
+
+
+# TODO_v1 - Move into standalone extension (own repo?) and ask people to manually add this extension in settings.
+class DebugHighlightExtension(ComponentExtension):
+ """
+ This extension adds the ability to highlight components and slots in the rendered output.
+
+ To highlight slots, set `ComponentsSettings.DEBUG_HIGHLIGHT_SLOTS` to `True` in your settings.
+
+ To highlight components, set `ComponentsSettings.DEBUG_HIGHLIGHT_COMPONENTS` to `True`.
+
+ Highlighting is done by wrapping the content in a `
` with a border and a highlight color.
+
+ This extension is automatically added to all components.
+ """
+
+ name = "debug_highlight"
+ ComponentConfig = ComponentDebugHighlight
+
+ # Apply highlight to the slot's rendered output
+ def on_slot_rendered(self, ctx: OnSlotRenderedContext) -> Optional[str]:
+ debug_cls: Optional[ComponentDebugHighlight] = getattr(ctx.component_cls, "DebugHighlight", None)
+ if not debug_cls or not debug_cls.highlight_slots:
+ return None
+
+ return apply_component_highlight("slot", ctx.result, f"{ctx.component_cls.__name__} - {ctx.slot_name}")
+
+ # Apply highlight to the rendered component
+ def on_component_rendered(self, ctx: OnComponentRenderedContext) -> Optional[str]:
+ debug_cls: Optional[ComponentDebugHighlight] = getattr(ctx.component_cls, "DebugHighlight", None)
+ if not debug_cls or not debug_cls.highlight_components or ctx.result is None:
+ return None
+
+ return apply_component_highlight("component", ctx.result, f"{ctx.component.name} ({ctx.component_id})")
diff --git a/src/django_components/extensions/defaults.py b/src/django_components/extensions/defaults.py
new file mode 100644
index 00000000..a2781d92
--- /dev/null
+++ b/src/django_components/extensions/defaults.py
@@ -0,0 +1,187 @@
+import sys
+from dataclasses import MISSING, Field, dataclass
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Type
+from weakref import WeakKeyDictionary
+
+from django_components.extension import (
+ ComponentExtension,
+ ExtensionComponentConfig,
+ OnComponentClassCreatedContext,
+ OnComponentInputContext,
+)
+
+if TYPE_CHECKING:
+ from django_components.component import Component
+
+
+# NOTE: `WeakKeyDictionary` is NOT a generic pre-3.9
+if sys.version_info >= (3, 9):
+ ComponentDefaultsCache = WeakKeyDictionary[Type["Component"], List["ComponentDefaultField"]]
+else:
+ ComponentDefaultsCache = WeakKeyDictionary
+
+
+defaults_by_component: ComponentDefaultsCache = WeakKeyDictionary()
+
+
+@dataclass
+class Default:
+ """
+ Use this class to mark a field on the `Component.Defaults` class as a factory.
+
+ Read more about [Component defaults](../../concepts/fundamentals/component_defaults).
+
+ **Example:**
+
+ ```py
+ from django_components import Default
+
+ class MyComponent(Component):
+ class Defaults:
+ # Plain value doesn't need a factory
+ position = "left"
+ # Lists and dicts need to be wrapped in `Default`
+ # Otherwise all instances will share the same value
+ selected_items = Default(lambda: [1, 2, 3])
+ ```
+ """
+
+ value: Callable[[], Any]
+
+
+class ComponentDefaultField(NamedTuple):
+ """Internal representation of a field on the `Defaults` class."""
+
+ key: str
+ value: Any
+ is_factory: bool
+
+
+# Figure out which defaults are factories and which are not, at class creation,
+# so that the actual creation of the defaults dictionary is simple.
+def _extract_defaults(defaults: Optional[Type]) -> List[ComponentDefaultField]:
+ defaults_fields: List[ComponentDefaultField] = []
+ if defaults is None:
+ return defaults_fields
+
+ for default_field_key in dir(defaults):
+ # Iterate only over fields set by the user (so non-dunder fields).
+ # Plus ignore `component_class` because that was set by the extension system.
+ # TODO_V1 - Remove `component_class`
+ if default_field_key.startswith("__") or default_field_key in {"component_class", "component_cls"}:
+ continue
+
+ default_field = getattr(defaults, default_field_key)
+
+ # If the field was defined with dataclass.field(), take the default / factory from there.
+ if isinstance(default_field, Field):
+ if default_field.default is not MISSING:
+ field_value = default_field.default
+ is_factory = False
+ elif default_field.default_factory is not MISSING:
+ field_value = default_field.default_factory
+ is_factory = True
+ else:
+ field_value = None
+ is_factory = False
+
+ # If the field was defined with our `Default` class, it defined a factory
+ elif isinstance(default_field, Default):
+ field_value = default_field.value
+ is_factory = True
+
+ # If the field was defined with a simple assignment, assume it's NOT a factory.
+ else:
+ field_value = default_field
+ is_factory = False
+
+ field_data = ComponentDefaultField(
+ key=default_field_key,
+ value=field_value,
+ is_factory=is_factory,
+ )
+ defaults_fields.append(field_data)
+
+ return defaults_fields
+
+
+def _apply_defaults(kwargs: Dict, defaults: List[ComponentDefaultField]) -> None:
+ """
+ Apply the defaults from `Component.Defaults` to the given `kwargs`.
+
+ Defaults are applied only to missing or `None` values.
+ """
+ for default_field in defaults:
+ # Defaults are applied only to missing or `None` values
+ given_value = kwargs.get(default_field.key, None)
+ if given_value is not None:
+ continue
+
+ if default_field.is_factory:
+ default_value = default_field.value()
+ else:
+ default_value = default_field.value
+
+ kwargs[default_field.key] = default_value
+
+
+class ComponentDefaults(ExtensionComponentConfig):
+ """
+ The interface for `Component.Defaults`.
+
+ The fields of this class are used to set default values for the component's kwargs.
+
+ Read more about [Component defaults](../../concepts/fundamentals/component_defaults).
+
+ **Example:**
+
+ ```python
+ from django_components import Component, Default
+
+ class MyComponent(Component):
+ class Defaults:
+ position = "left"
+ selected_items = Default(lambda: [1, 2, 3])
+ ```
+ """
+
+ pass
+
+
+class DefaultsExtension(ComponentExtension):
+ """
+ This extension adds a nested `Defaults` class to each `Component`.
+
+ This nested `Defaults` class is used to set default values for the component's kwargs.
+
+ **Example:**
+
+ ```py
+ from django_components import Component, Default
+
+ class MyComponent(Component):
+ class Defaults:
+ position = "left"
+ # Factory values need to be wrapped in `Default`
+ selected_items = Default(lambda: [1, 2, 3])
+ ```
+
+ This extension is automatically added to all components.
+ """
+
+ name = "defaults"
+ ComponentConfig = ComponentDefaults
+
+ # Preprocess the `Component.Defaults` class, if given, so we don't have to do it
+ # each time a component is rendered.
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ defaults_cls = getattr(ctx.component_cls, "Defaults", None)
+ defaults_by_component[ctx.component_cls] = _extract_defaults(defaults_cls)
+
+ # Apply defaults to missing or `None` values in `kwargs`
+ def on_component_input(self, ctx: OnComponentInputContext) -> None:
+ defaults = defaults_by_component.get(ctx.component_cls, None)
+ if defaults is None:
+ return
+
+ _apply_defaults(ctx.kwargs, defaults)
diff --git a/src/django_components/extensions/dependencies.py b/src/django_components/extensions/dependencies.py
new file mode 100644
index 00000000..20c4d444
--- /dev/null
+++ b/src/django_components/extensions/dependencies.py
@@ -0,0 +1,21 @@
+from django_components.dependencies import cache_component_css, cache_component_js
+from django_components.extension import (
+ ComponentExtension,
+ OnComponentClassCreatedContext,
+)
+
+
+class DependenciesExtension(ComponentExtension):
+ """
+ This extension adds a nested `Dependencies` class to each `Component`.
+
+ This extension is automatically added to all components.
+ """
+
+ name = "dependencies"
+
+ # Cache the component's JS and CSS scripts when the class is created, so that
+ # components' JS/CSS files are accessible even before having to render the component first.
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ cache_component_js(ctx.component_cls, force=True)
+ cache_component_css(ctx.component_cls, force=True)
diff --git a/src/django_components/extensions/view.py b/src/django_components/extensions/view.py
new file mode 100644
index 00000000..68b7d47f
--- /dev/null
+++ b/src/django_components/extensions/view.py
@@ -0,0 +1,300 @@
+import sys
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Protocol, Type, Union, cast
+from weakref import WeakKeyDictionary
+
+import django.urls
+from django.http import HttpRequest, HttpResponse
+from django.views.generic import View
+
+from django_components.extension import (
+ ComponentExtension,
+ ExtensionComponentConfig,
+ OnComponentClassCreatedContext,
+ OnComponentClassDeletedContext,
+ URLRoute,
+ extensions,
+)
+from django_components.util.misc import format_url
+
+if TYPE_CHECKING:
+ from django_components.component import Component
+
+# NOTE: `WeakKeyDictionary` is NOT a generic pre-3.9
+if sys.version_info >= (3, 9):
+ ComponentRouteCache = WeakKeyDictionary[Type["Component"], URLRoute]
+else:
+ ComponentRouteCache = WeakKeyDictionary
+
+
+class ViewFn(Protocol):
+ def __call__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Any: ... # noqa: E704
+
+
+def _get_component_route_name(component: Union[Type["Component"], "Component"]) -> str:
+ return f"__component_url__{component.class_id}"
+
+
+def get_component_url(
+ component: Union[Type["Component"], "Component"],
+ query: Optional[Dict] = None,
+ fragment: Optional[str] = None,
+) -> str:
+ """
+ Get the URL for a [`Component`](../api#django_components.Component).
+
+ Raises `RuntimeError` if the component is not public.
+
+ Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
+
+ `get_component_url()` optionally accepts `query` and `fragment` arguments.
+
+ **Example:**
+
+ ```py
+ from django_components import Component, get_component_url
+
+ class MyComponent(Component):
+ class View:
+ public = True
+
+ # Get the URL for the component
+ url = get_component_url(
+ MyComponent,
+ query={"foo": "bar"},
+ fragment="baz",
+ )
+ # /components/ext/view/components/c1ab2c3?foo=bar#baz
+ ```
+ """
+ view_cls: Optional[Type[ComponentView]] = getattr(component, "View", None)
+ if not _is_view_public(view_cls):
+ raise RuntimeError("Component URL is not available - Component is not public")
+
+ route_name = _get_component_route_name(component)
+ url = django.urls.reverse(route_name)
+ return format_url(url, query=query, fragment=fragment)
+
+
+class ComponentView(ExtensionComponentConfig, View):
+ """
+ The interface for `Component.View`.
+
+ The fields of this class are used to configure the component views and URLs.
+
+ This class is a subclass of
+ [`django.views.View`](https://docs.djangoproject.com/en/5.2/ref/class-based-views/base/#view).
+ The [`Component`](../api#django_components.Component) class is available
+ via `self.component_cls`.
+
+ Override the methods of this class to define the behavior of the component.
+
+ Read more about [Component views and URLs](../../concepts/fundamentals/component_views_urls).
+
+ **Example:**
+
+ ```python
+ class MyComponent(Component):
+ class View:
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return HttpResponse("Hello, world!")
+ ```
+
+ **Component URL:**
+
+ If the `public` attribute is set to `True`, the component will have its own URL
+ that will point to the Component's View.
+
+ ```py
+ from django_components import Component
+
+ class MyComponent(Component):
+ class View:
+ public = True
+
+ def get(self, request, *args, **kwargs):
+ return HttpResponse("Hello, world!")
+ ```
+
+ Will create a URL route like `/components/ext/view/components/a1b2c3/`.
+
+ To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
+
+ ```py
+ url = get_component_url(MyComponent)
+ ```
+ """
+
+ # NOTE: The `component` / `component_cls` attributes are NOT user input, but still must be declared
+ # on this class for Django's `View.as_view()` to allow us to pass `component` kwarg.
+
+ # TODO_v1 - Remove. Superseded by `component_cls` attribute because we don't actually have access to an instance.
+ component = cast("Component", None)
+ """
+ DEPRECATED: Will be removed in v1.0.
+ Use [`component_cls`](../api#django_components.ComponentView.component_cls) instead.
+
+ This is a dummy instance created solely for the View methods.
+
+ It is the same as if you instantiated the component class directly:
+
+ ```py
+ component = Calendar()
+ component.render_to_response(request=request)
+ ```
+ """
+
+ component_cls = cast(Type["Component"], None)
+ """
+ The parent component class.
+
+ **Example:**
+
+ ```py
+ class MyComponent(Component):
+ class View:
+ def get(self, request):
+ return self.component_cls.render_to_response(request=request)
+ ```
+ """
+
+ def __init__(self, component: "Component", **kwargs: Any) -> None:
+ ComponentExtension.ComponentConfig.__init__(self, component)
+ View.__init__(self, **kwargs)
+
+ @property
+ def url(self) -> str:
+ """
+ The URL for the component.
+
+ Raises `RuntimeError` if the component is not public.
+
+ This is the same as calling [`get_component_url()`](../api#django_components.get_component_url)
+ with the parent [`Component`](../api#django_components.Component) class:
+
+ ```py
+ class MyComponent(Component):
+ class View:
+ def get(self, request):
+ assert self.url == get_component_url(self.component_cls)
+ ```
+ """
+ return get_component_url(self.component_cls)
+
+ # #####################################
+ # PUBLIC API (Configurable by users)
+ # #####################################
+
+ public: ClassVar[bool] = False
+ """
+ Whether the component should be available via a URL.
+
+ **Example:**
+
+ ```py
+ from django_components import Component
+
+ class MyComponent(Component):
+ class View:
+ public = True
+ ```
+
+ Will create a URL route like `/components/ext/view/components/a1b2c3/`.
+
+ To get the URL for the component, use [`get_component_url()`](../api#django_components.get_component_url):
+
+ ```py
+ url = get_component_url(MyComponent)
+ ```
+ """
+
+ # NOTE: The methods below are defined to satisfy the `View` class. All supported methods
+ # are defined in `View.http_method_names`.
+ #
+ # Each method actually delegates to the component's method of the same name.
+ # E.g. When `get()` is called, it delegates to `component.get()`.
+
+ # TODO_V1 - In v1 handlers like `get()` should be defined on the Component.View class,
+ # not the Component class directly. This is to align Views with the extensions API
+ # where each extension should keep its methods in the extension class.
+ # Instead, the defaults for these methods should be something like
+ # `return self.component_cls.render_to_response(request, *args, **kwargs)` or similar
+ # or raise NotImplementedError.
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "get")(request, *args, **kwargs)
+
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "post")(request, *args, **kwargs)
+
+ def put(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "put")(request, *args, **kwargs)
+
+ def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "patch")(request, *args, **kwargs)
+
+ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "delete")(request, *args, **kwargs)
+
+ def head(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "head")(request, *args, **kwargs)
+
+ def options(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "options")(request, *args, **kwargs)
+
+ def trace(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
+ return getattr(self.component_cls(), "trace")(request, *args, **kwargs)
+
+
+class ViewExtension(ComponentExtension):
+ """
+ This extension adds a nested `View` class to each `Component`.
+
+ This nested class is a subclass of `django.views.View`, and allows the component
+ to be used as a view by calling `ComponentView.as_view()`.
+
+ This extension also allows the component to be available via a unique URL.
+
+ This extension is automatically added to all components.
+ """
+
+ name = "view"
+
+ ComponentConfig = ComponentView
+
+ def __init__(self) -> None:
+ # Remember which route belongs to which component
+ self.routes_by_component: ComponentRouteCache = WeakKeyDictionary()
+
+ # Create URL route on creation
+ def on_component_class_created(self, ctx: OnComponentClassCreatedContext) -> None:
+ comp_cls = ctx.component_cls
+ view_cls: Optional[Type[ComponentView]] = getattr(comp_cls, "View", None)
+ if not _is_view_public(view_cls):
+ return
+
+ # Create a URL route like `components/MyTable_a1b2c3/`
+ # And since this is within the `view` extension, the full URL path will then be:
+ # `/components/ext/view/components/MyTable_a1b2c3/`
+ route_path = f"components/{comp_cls.class_id}/"
+ route_name = _get_component_route_name(comp_cls)
+ route = URLRoute(
+ path=route_path,
+ handler=comp_cls.as_view(),
+ name=route_name,
+ )
+
+ self.routes_by_component[comp_cls] = route
+ extensions.add_extension_urls(self.name, [route])
+
+ # Remove URL route on deletion
+ def on_component_class_deleted(self, ctx: OnComponentClassDeletedContext) -> None:
+ comp_cls = ctx.component_cls
+ route = self.routes_by_component.pop(comp_cls, None)
+ if route is None:
+ return
+ extensions.remove_extension_urls(self.name, [route])
+
+
+def _is_view_public(view_cls: Optional[Type[ComponentView]]) -> bool:
+ if view_cls is None:
+ return False
+ return getattr(view_cls, "public", False)
diff --git a/src/django_components/finders.py b/src/django_components/finders.py
index 06029459..c91b7382 100644
--- a/src/django_components/finders.py
+++ b/src/django_components/finders.py
@@ -2,6 +2,7 @@ import os
import re
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
+from django import VERSION as DJANGO_VERSION
from django.contrib.staticfiles.finders import BaseFinder
from django.contrib.staticfiles.utils import get_files
from django.core.checks import CheckMessage, Error, Warning
@@ -90,17 +91,32 @@ class ComponentsFileSystemFinder(BaseFinder):
return errors
# NOTE: Same as `FileSystemFinder.find`
- def find(self, path: str, all: bool = False) -> Union[List[str], str]:
+ def find(self, path: str, **kwargs: Any) -> Union[List[str], str]:
"""
Look for files in the extra locations as defined in COMPONENTS.dirs.
"""
+ # Handle deprecated `all` parameter:
+ # - In Django 5.2, the `all` parameter was deprecated in favour of `find_all`.
+ # - Between Django 5.2 (inclusive) and 6.1 (exclusive), the `all` parameter was still
+ # supported, but an error was raised if both were provided.
+ # - In Django 6.1, the `all` parameter was removed.
+ #
+ # See https://github.com/django/django/blob/5.2/django/contrib/staticfiles/finders.py#L58C9-L58C37
+ # And https://github.com/django-components/django-components/issues/1119
+ if DJANGO_VERSION >= (5, 2) and DJANGO_VERSION < (6, 1):
+ find_all = self._check_deprecated_find_param(**kwargs) # type: ignore
+ elif DJANGO_VERSION >= (6, 1):
+ find_all = kwargs.get("find_all", False)
+ else:
+ find_all = kwargs.get("all", False)
+
matches: List[str] = []
for prefix, root in self.locations:
if root not in searched_locations:
searched_locations.append(root)
matched_path = self.find_location(root, path, prefix)
if matched_path:
- if not all:
+ if not find_all:
return matched_path
matches.append(matched_path)
return matches
diff --git a/src/django_components/library.py b/src/django_components/library.py
index 70ae94eb..5cff7d5f 100644
--- a/src/django_components/library.py
+++ b/src/django_components/library.py
@@ -1,24 +1,19 @@
"""Module for interfacing with Django's Library (`django.template.library`)"""
-from typing import TYPE_CHECKING, Callable, List, Optional
+from typing import Callable, List, Optional
from django.template.base import Node, Parser, Token
from django.template.library import Library
-from django_components.tag_formatter import InternalTagFormatter
-
-if TYPE_CHECKING:
- from django_components.component_registry import ComponentRegistry
-
class TagProtectedError(Exception):
"""
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)
+ [un-/registers the template tags](https://docs.djangoproject.com/en/5.2/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).
+ [`Library`](https://docs.djangoproject.com/en/5.2/howto/custom-template-tags/#code-layout).
In other words, if I have registered a component `"table"`, and I use the shorthand
syntax:
@@ -56,26 +51,15 @@ as they would conflict with other tags in the Library.
def register_tag(
- registry: "ComponentRegistry",
+ library: Library,
tag: str,
- tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
+ tag_fn: Callable[[Parser, Token], Node],
) -> None:
# Register inline tag
- if is_tag_protected(registry.library, tag):
+ if is_tag_protected(library, tag):
raise TagProtectedError('Cannot register tag "%s", this tag name is protected' % tag)
else:
- registry.library.tag(tag, lambda parser, token: tag_fn(parser, token, registry, tag))
-
-
-def register_tag_from_formatter(
- registry: "ComponentRegistry",
- tag_fn: Callable[[Parser, Token, "ComponentRegistry", str], Node],
- formatter: InternalTagFormatter,
- component_name: str,
-) -> str:
- tag = formatter.start_tag(component_name)
- register_tag(registry, tag, tag_fn)
- return tag
+ library.tag(tag, tag_fn)
def mark_protected_tags(lib: Library, tags: Optional[List[str]] = None) -> None:
diff --git a/src/django_components/management/commands/components.py b/src/django_components/management/commands/components.py
new file mode 100644
index 00000000..70424d71
--- /dev/null
+++ b/src/django_components/management/commands/components.py
@@ -0,0 +1,5 @@
+from django_components.commands.components import ComponentsRootCommand
+from django_components.compat.django import load_as_django_command
+
+# TODO_V3
+Command = load_as_django_command(ComponentsRootCommand)
diff --git a/src/django_components/management/commands/startcomponent.py b/src/django_components/management/commands/startcomponent.py
index a4a43267..d750ef0f 100644
--- a/src/django_components/management/commands/startcomponent.py
+++ b/src/django_components/management/commands/startcomponent.py
@@ -1,219 +1,5 @@
-import os
-from textwrap import dedent
-from typing import Any
+from django_components.commands.startcomponent import StartComponentCommand
+from django_components.compat.django import load_as_django_command
-from django.conf import settings
-from django.core.management.base import BaseCommand, CommandError, CommandParser
-
-
-class Command(BaseCommand):
- """
- ### Management Command Usage
-
- To use the command, run the following command in your terminal:
-
- ```bash
- python manage.py startcomponent --path --js --css --template --force --verbose --dry-run
- ```
-
- Replace ``, ``, ``, ``, and `` 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. This is a required argument.",
- )
- parser.add_argument(
- "--path",
- type=str,
- 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. 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 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. This is an optional argument. The default value is `template.html`.",
- default="template.html",
- )
- parser.add_argument(
- "--force",
- action="store_true",
- 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=(
- "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=(
- "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:
- name = kwargs["name"]
-
- if name:
- path = kwargs["path"]
- js_filename = kwargs["js"]
- css_filename = kwargs["css"]
- template_filename = kwargs["template"]
- base_dir = getattr(settings, "BASE_DIR", None)
- force = kwargs["force"]
- verbose = kwargs["verbose"]
- dry_run = kwargs["dry_run"]
-
- if path:
- component_path = os.path.join(path, name)
- elif base_dir:
- component_path = os.path.join(base_dir, "components", name)
- else:
- raise CommandError("You must specify a path or set BASE_DIR in your django settings")
-
- if os.path.exists(component_path):
- if force:
- if verbose:
- self.stdout.write(
- self.style.WARNING(
- f'The component "{name}" already exists at {component_path}. Overwriting...'
- )
- )
- else:
- self.stdout.write(self.style.WARNING(f'The component "{name}" already exists. Overwriting...'))
- else:
- raise CommandError(
- f'The component "{name}" already exists at {component_path}. Use --force to overwrite.'
- )
-
- if not dry_run:
- os.makedirs(component_path, exist_ok=force)
-
- with open(os.path.join(component_path, js_filename), "w") as f:
- script_content = dedent(
- f"""
- window.addEventListener('load', (event) => {{
- console.log("{name} component is fully loaded");
- }});
- """
- )
- f.write(script_content.strip())
-
- with open(os.path.join(component_path, css_filename), "w") as f:
- style_content = dedent(
- f"""
- .component-{name} {{
- background: red;
- }}
- """
- )
- f.write(style_content.strip())
-
- with open(os.path.join(component_path, template_filename), "w") as f:
- template_content = dedent(
- f"""
-
- Hello from {name} component!
-
- This is {{ param }} context value.
-
- """
- )
- f.write(template_content.strip())
-
- with open(os.path.join(component_path, f"{name}.py"), "w") as f:
- py_content = dedent(
- f"""
- from django_components import Component, register
-
- @register("{name}")
- class {name.capitalize()}(Component):
- template_name = "{name}/{template_filename}"
-
- def get_context_data(self, value):
- return {{
- "param": "sample value",
- }}
-
- class Media:
- css = "{name}/{css_filename}"
- js = "{name}/{js_filename}"
- """
- )
- f.write(py_content.strip())
-
- if verbose:
- self.stdout.write(self.style.SUCCESS(f"Successfully created {name} component at {component_path}"))
- else:
- self.stdout.write(self.style.SUCCESS(f"Successfully created {name} component"))
- else:
- raise CommandError("You must specify a component name")
+# TODO_V3
+Command = load_as_django_command(StartComponentCommand)
diff --git a/src/django_components/management/commands/upgradecomponent.py b/src/django_components/management/commands/upgradecomponent.py
index 4c439d8a..3c5f5ce7 100644
--- a/src/django_components/management/commands/upgradecomponent.py
+++ b/src/django_components/management/commands/upgradecomponent.py
@@ -1,69 +1,5 @@
-import os
-import re
-from pathlib import Path
-from typing import Any
+from django_components.commands.upgradecomponent import UpgradeComponentCommand
+from django_components.compat.django import load_as_django_command
-from django.conf import settings
-from django.core.management.base import BaseCommand, CommandParser
-from django.template.engine import Engine
-
-from django_components.template_loader import Loader
-
-
-class Command(BaseCommand):
- help = "Updates component and component_block tags to the new syntax"
-
- def add_arguments(self, parser: CommandParser) -> None:
- parser.add_argument("--path", type=str, help="Path to search for components")
-
- def handle(self, *args: Any, **options: Any) -> None:
- current_engine = Engine.get_default()
- loader = Loader(current_engine)
- dirs = loader.get_dirs(include_apps=False)
-
- if settings.BASE_DIR:
- dirs.append(Path(settings.BASE_DIR) / "templates")
-
- if options["path"]:
- dirs = [options["path"]]
-
- for dir_path in dirs:
- self.stdout.write(f"Searching for components in {dir_path}...")
- for root, _, files in os.walk(dir_path):
- for file in files:
- if file.endswith((".html", ".py")):
- file_path = os.path.join(root, file)
- with open(file_path, "r+", encoding="utf-8") as f:
- content = f.read()
- content_with_closed_components, step0_count = re.subn(
- r'({%\s*component\s*"(\w+?)"(.*?)%})(?!.*?{%\s*endcomponent\s*%})',
- r"\1{% endcomponent %}",
- content,
- flags=re.DOTALL,
- )
- updated_content, step1_count_opening = re.subn(
- r'{%\s*component_block\s*"(\w+?)"\s*(.*?)%}',
- r'{% component "\1" \2%}',
- content_with_closed_components,
- flags=re.DOTALL,
- )
- updated_content, step2_count_closing = re.subn(
- r'{%\s*endcomponent_block\s*"(\w+?)"\s*%}',
- r"{% endcomponent %}",
- updated_content,
- flags=re.DOTALL,
- )
- updated_content, step2_count_closing_no_name = re.subn(
- r"{%\s*endcomponent_block\s*%}",
- r"{% endcomponent %}",
- updated_content,
- flags=re.DOTALL,
- )
- total_updates = (
- step0_count + step1_count_opening + step2_count_closing + step2_count_closing_no_name
- )
- if total_updates > 0:
- f.seek(0)
- f.write(updated_content)
- f.truncate()
- self.stdout.write(f"Updated {file_path}: {total_updates} changes made")
+# TODO_V3
+Command = load_as_django_command(UpgradeComponentCommand)
diff --git a/src/django_components/middleware.py b/src/django_components/middleware.py
deleted file mode 100644
index b18e2e41..00000000
--- a/src/django_components/middleware.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# These middlewares are part of public API
-from django_components.dependencies import ComponentDependencyMiddleware
-
-__all__ = ["ComponentDependencyMiddleware"]
diff --git a/src/django_components/node.py b/src/django_components/node.py
index c3f578ec..f458c939 100644
--- a/src/django_components/node.py
+++ b/src/django_components/node.py
@@ -1,22 +1,667 @@
-from typing import List, Optional
+import functools
+import inspect
+import keyword
+from typing import TYPE_CHECKING, Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, cast
-from django.template.base import Node, NodeList
+from django.template import Context, Library
+from django.template.base import Node, NodeList, Parser, Token
-from django_components.expression import Expression, RuntimeKwargs
+from django_components.util.logger import trace_node_msg
from django_components.util.misc import gen_id
+from django_components.util.template_tag import (
+ TagAttr,
+ parse_template_tag,
+ resolve_params,
+ validate_params,
+)
+
+if TYPE_CHECKING:
+ from django_components.component import Component
-class BaseNode(Node):
- """Shared behavior for our subclasses of Django's `Node`"""
+# Normally, when `Node.render()` is called, it receives only a single argument `context`.
+#
+# ```python
+# def render(self, context: Context) -> str:
+# return self.nodelist.render(context)
+# ```
+#
+# In django-components, the input to template tags is treated as function inputs, e.g.
+#
+# `{% component name="John" age=20 %}`
+#
+# And, for convenience, we want to allow the `render()` method to accept these extra parameters.
+# That way, user can define just the `render()` method and have access to all the information:
+#
+# ```python
+# def render(self, context: Context, name: str, **kwargs: Any) -> str:
+# return f"Hello, {name}!"
+# ```
+#
+# So we need to wrap the `render()` method, and for that we need the metaclass.
+#
+# The outer `render()` (our wrapper) will match the `Node.render()` signature (accepting only `context`),
+# while the inner `render()` (the actual implementation) will match the user-defined `render()` method's signature
+# (accepting all the parameters).
+class NodeMeta(type):
+ def __new__(
+ mcs,
+ name: str,
+ bases: Tuple[Type, ...],
+ attrs: Dict[str, Any],
+ ) -> Type["BaseNode"]:
+ cls = cast(Type["BaseNode"], super().__new__(mcs, name, bases, attrs))
+
+ # Ignore the `BaseNode` class itself
+ if attrs.get("__module__", None) == "django_components.node":
+ return cls
+
+ if not hasattr(cls, "tag"):
+ raise ValueError(f"Node {name} must have a 'tag' attribute")
+
+ # Skip if already wrapped
+ orig_render = cls.render
+ if getattr(orig_render, "_djc_wrapped", False):
+ return cls
+
+ signature = inspect.signature(orig_render)
+
+ # A full signature of `BaseNode.render()` may look like this:
+ #
+ # `def render(self, context: Context, name: str, **kwargs) -> str:`
+ #
+ # We need to remove the first two parameters from the signature.
+ # So we end up only with
+ #
+ # `def render(name: str, **kwargs) -> str:`
+ #
+ # And this becomes the signature that defines what params the template tag accepts, e.g.
+ #
+ # `{% component name="John" age=20 %}`
+ if len(signature.parameters) < 2:
+ raise TypeError(f"`render()` method of {name} must have at least two parameters")
+
+ validation_params = list(signature.parameters.values())
+ validation_params = validation_params[2:]
+ validation_signature = signature.replace(parameters=validation_params)
+
+ # NOTE: This is used for creating docs by `_format_tag_signature()` in `docs/scripts/reference.py`
+ cls._signature = validation_signature
+
+ @functools.wraps(orig_render)
+ def wrapper_render(self: "BaseNode", context: Context) -> str:
+ trace_node_msg("RENDER", self.tag, self.node_id)
+
+ resolved_params = resolve_params(self.tag, self.params, context)
+
+ # Template tags may accept kwargs that are not valid Python identifiers, e.g.
+ # `{% component data-id="John" class="pt-4" :href="myVar" %}`
+ #
+ # Passing them in is still useful, as user may want to pass in arbitrary data
+ # to their `{% component %}` tags as HTML attributes. E.g. example below passes
+ # `data-id`, `class` and `:href` as HTML attributes to the `
+ # """
+ # ```
+ #
+ # HOWEVER, these kwargs like `data-id`, `class` and `:href` may not be valid Python identifiers,
+ # or like in case of `class`, may be a reserved keyword. Thus, we cannot pass them in to the `render()`
+ # method as regular kwargs, because that will raise Python's native errors like
+ # `SyntaxError: invalid syntax`. E.g.
+ #
+ # ```python
+ # def render(self, context: Context, data-id: str, class: str, :href: str) -> str:
+ # ```
+ #
+ # So instead, we filter out any invalid kwargs, and pass those in through a dictionary spread.
+ # We can do so, because following is allowed in Python:
+ #
+ # ```python
+ # def x(**kwargs):
+ # print(kwargs)
+ #
+ # d = {"data-id": 1}
+ # x(**d)
+ # # {'data-id': 1}
+ # ```
+ #
+ # See https://github.com/django-components/django-components/discussions/900#discussioncomment-11859970
+ resolved_params_without_invalid_kwargs = []
+ invalid_kwargs = {}
+ did_see_special_kwarg = False
+ for resolved_param in resolved_params:
+ key = resolved_param.key
+ if key is not None:
+ # Case: Special kwargs
+ if not key.isidentifier() or keyword.iskeyword(key):
+ # NOTE: Since these keys are not part of signature validation,
+ # we have to check ourselves if any args follow them.
+ invalid_kwargs[key] = resolved_param.value
+ did_see_special_kwarg = True
+ else:
+ # Case: Regular kwargs
+ resolved_params_without_invalid_kwargs.append(resolved_param)
+ else:
+ # Case: Regular positional args
+ if did_see_special_kwarg:
+ raise SyntaxError("positional argument follows keyword argument")
+ resolved_params_without_invalid_kwargs.append(resolved_param)
+
+ # Validate the params against the signature
+ #
+ # This uses a signature that has been stripped of the `self` and `context` parameters. E.g.
+ #
+ # `def render(name: str, **kwargs: Any) -> None`
+ #
+ # If there are any errors in the input, this will trigger Python's
+ # native error handling (e.g. `TypeError: render() got multiple values for argument 'context'`)
+ #
+ # But because we stripped the two parameters, then these errors will correctly
+ # point to the actual error in the template tag.
+ #
+ # E.g. if we supplied one too many positional args,
+ # `{% mytag "John" 20 %}`
+ #
+ # Then without stripping the two parameters, then the error could be:
+ # `render() takes from 3 positional arguments but 4 were given`
+ #
+ # Which is confusing, because we supplied only two positional args.
+ #
+ # But cause we stripped the two parameters, then the error will be:
+ # `render() takes from 1 positional arguments but 2 were given`
+ args, kwargs = validate_params(
+ orig_render,
+ validation_signature,
+ self.tag,
+ resolved_params_without_invalid_kwargs,
+ invalid_kwargs,
+ )
+
+ output = orig_render(self, context, *args, **kwargs)
+
+ trace_node_msg("RENDER", self.tag, self.node_id, msg="...Done!")
+ return output
+
+ # Wrap cls.render() so we resolve the args and kwargs and pass them to the
+ # actual render method.
+ cls.render = wrapper_render # type: ignore
+ cls.render._djc_wrapped = True # type: ignore
+
+ return cls
+
+
+class BaseNode(Node, metaclass=NodeMeta):
+ """
+ Node class for all django-components custom template tags.
+
+ This class has a dual role:
+
+ 1. It declares how a particular template tag should be parsed - By setting the
+ [`tag`](../api#django_components.BaseNode.tag),
+ [`end_tag`](../api#django_components.BaseNode.end_tag),
+ and [`allowed_flags`](../api#django_components.BaseNode.allowed_flags)
+ attributes:
+
+ ```python
+ class SlotNode(BaseNode):
+ tag = "slot"
+ end_tag = "endslot"
+ allowed_flags = ["required"]
+ ```
+
+ This will allow the template tag `{% slot %}` to be used like this:
+
+ ```django
+ {% slot required %} ... {% endslot %}
+ ```
+
+ 2. The [`render`](../api#django_components.BaseNode.render) method is
+ the actual implementation of the template tag.
+
+ This is where the tag's logic is implemented:
+
+ ```python
+ class MyNode(BaseNode):
+ tag = "mynode"
+
+ def render(self, context: Context, name: str, **kwargs: Any) -> str:
+ return f"Hello, {name}!"
+ ```
+
+ This will allow the template tag `{% mynode %}` to be used like this:
+
+ ```django
+ {% mynode name="John" %}
+ ```
+
+ The template tag accepts parameters as defined on the
+ [`render`](../api#django_components.BaseNode.render) method's signature.
+
+ For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
+ """
+
+ # #####################################
+ # PUBLIC API (Configurable by users)
+ # #####################################
+
+ tag: ClassVar[str]
+ """
+ The tag name.
+
+ E.g. `"component"` or `"slot"` will make this class match
+ template tags `{% component %}` or `{% slot %}`.
+
+ ```python
+ class SlotNode(BaseNode):
+ tag = "slot"
+ end_tag = "endslot"
+ ```
+
+ This will allow the template tag `{% slot %}` to be used like this:
+
+ ```django
+ {% slot %} ... {% endslot %}
+ ```
+ """
+
+ end_tag: ClassVar[Optional[str]] = None
+ """
+ The end tag name.
+
+ E.g. `"endcomponent"` or `"endslot"` will make this class match
+ template tags `{% endcomponent %}` or `{% endslot %}`.
+
+ ```python
+ class SlotNode(BaseNode):
+ tag = "slot"
+ end_tag = "endslot"
+ ```
+
+ This will allow the template tag `{% slot %}` to be used like this:
+
+ ```django
+ {% slot %} ... {% endslot %}
+ ```
+
+ If not set, then this template tag has no end tag.
+
+ So instead of `{% component %} ... {% endcomponent %}`, you'd use only
+ `{% component %}`.
+
+ ```python
+ class MyNode(BaseNode):
+ tag = "mytag"
+ end_tag = None
+ ```
+ """
+
+ allowed_flags: ClassVar[Optional[List[str]]] = None
+ """
+ The list of all *possible* flags for this tag.
+
+ E.g. `["required"]` will allow this tag to be used like `{% slot required %}`.
+
+ ```python
+ class SlotNode(BaseNode):
+ tag = "slot"
+ end_tag = "endslot"
+ allowed_flags = ["required", "default"]
+ ```
+
+ This will allow the template tag `{% slot %}` to be used like this:
+
+ ```django
+ {% slot required %} ... {% endslot %}
+ {% slot default %} ... {% endslot %}
+ {% slot required default %} ... {% endslot %}
+ ```
+ """
+
+ def render(self, context: Context, *args: Any, **kwargs: Any) -> str:
+ """
+ Render the node. This method is meant to be overridden by subclasses.
+
+ The signature of this function decides what input the template tag accepts.
+
+ The `render()` method MUST accept a `context` argument. Any arguments after that
+ will be part of the tag's input parameters.
+
+ So if you define a `render` method like this:
+
+ ```python
+ def render(self, context: Context, name: str, **kwargs: Any) -> str:
+ ```
+
+ Then the tag will require the `name` parameter, and accept any extra keyword arguments:
+
+ ```django
+ {% component name="John" age=20 %}
+ ```
+ """
+ return self.nodelist.render(context)
+
+ # #####################################
+ # Attributes
+ # #####################################
+
+ params: List[TagAttr]
+ """
+ The parameters to the tag in the template.
+
+ A single param represents an arg or kwarg of the template tag.
+
+ E.g. the following tag:
+
+ ```django
+ {% component "my_comp" key=val key2='val2 two' %}
+ ```
+
+ Has 3 params:
+
+ - Posiitonal arg `"my_comp"`
+ - Keyword arg `key=val`
+ - Keyword arg `key2='val2 two'`
+ """
+
+ flags: Dict[str, bool]
+ """
+ Dictionary of all [`allowed_flags`](../api#django_components.BaseNode.allowed_flags)
+ that were set on the tag.
+
+ Flags that were set are `True`, and the rest are `False`.
+
+ E.g. the following tag:
+
+ ```python
+ class SlotNode(BaseNode):
+ tag = "slot"
+ end_tag = "endslot"
+ allowed_flags = ["default", "required"]
+ ```
+
+ ```django
+ {% slot "content" default %}
+ ```
+
+ Has 2 flags, `default` and `required`, but only `default` was set.
+
+ The `flags` dictionary will be:
+
+ ```python
+ {
+ "default": True,
+ "required": False,
+ }
+ ```
+
+ You can check if a flag is set by doing:
+
+ ```python
+ if node.flags["default"]:
+ ...
+ ```
+ """
+
+ nodelist: NodeList
+ """
+ The nodelist of the tag.
+
+ This is the text between the opening and closing tags, e.g.
+
+ ```django
+ {% slot "content" default required %}
+
+ ...
+
+ {% endslot %}
+ ```
+
+ The `nodelist` will contain the `
...
` part.
+
+ Unlike [`contents`](../api#django_components.BaseNode.contents),
+ the `nodelist` contains the actual Nodes, not just the text.
+ """
+
+ contents: Optional[str]
+ """
+ The contents of the tag.
+
+ This is the text between the opening and closing tags, e.g.
+
+ ```django
+ {% slot "content" default required %}
+
+ ...
+
+ {% endslot %}
+ ```
+
+ The `contents` will be `"
...
"`.
+ """
+
+ node_id: str
+ """
+ The unique ID of the node.
+
+ Extensions can use this ID to store additional information.
+ """
+
+ template_name: Optional[str]
+ """
+ The name of the [`Template`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Template)
+ that contains this node.
+
+ The template name is set by Django's
+ [template loaders](https://docs.djangoproject.com/en/5.2/topics/templates/#loaders).
+
+ For example, the filesystem template loader will set this to the absolute path of the template file.
+
+ ```
+ "/home/user/project/templates/my_template.html"
+ ```
+ """
+
+ template_component: Optional[Type["Component"]]
+ """
+ If the template that contains this node belongs to a [`Component`](../api#django_components.Component),
+ then this will be the [`Component`](../api#django_components.Component) class.
+ """
+
+ # #####################################
+ # MISC
+ # #####################################
def __init__(
self,
+ params: List[TagAttr],
+ flags: Optional[Dict[str, bool]] = None,
nodelist: Optional[NodeList] = None,
node_id: Optional[str] = None,
- args: Optional[List[Expression]] = None,
- kwargs: Optional[RuntimeKwargs] = None,
+ contents: Optional[str] = None,
+ template_name: Optional[str] = None,
+ template_component: Optional[Type["Component"]] = None,
):
+ self.params = params
+ self.flags = flags or {flag: False for flag in self.allowed_flags or []}
self.nodelist = nodelist or NodeList()
self.node_id = node_id or gen_id()
- self.args = args or []
- self.kwargs = kwargs or RuntimeKwargs({})
+ self.contents = contents
+ self.template_name = template_name
+ self.template_component = template_component
+
+ def __repr__(self) -> str:
+ return (
+ f"<{self.__class__.__name__}: {self.node_id}. Contents: {repr(self.nodelist)}."
+ f" Flags: {self.active_flags}>"
+ )
+
+ @property
+ def active_flags(self) -> List[str]:
+ """
+ Flags that were set for this specific instance as a list of strings.
+
+ E.g. the following tag:
+
+ ```django
+ {% slot "content" default required / %}
+ ```
+
+ Will have the following flags:
+
+ ```python
+ ["default", "required"]
+ ```
+ """
+ flags = []
+ for flag, value in self.flags.items():
+ if value:
+ flags.append(flag)
+ return flags
+
+ @classmethod
+ def parse(cls, parser: Parser, token: Token, **kwargs: Any) -> "BaseNode":
+ """
+ This function is what is passed to Django's `Library.tag()` when
+ [registering the tag](https://docs.djangoproject.com/en/5.2/howto/custom-template-tags/#registering-the-tag).
+
+ In other words, this method is called by Django's template parser when we encounter
+ a tag that matches this node's tag, e.g. `{% component %}` or `{% slot %}`.
+
+ To register the tag, you can use [`BaseNode.register()`](../api#django_components.BaseNode.register).
+ """
+ # NOTE: Avoids circular import
+ from django_components.template import get_component_from_origin
+
+ tag_id = gen_id()
+ tag = parse_template_tag(cls.tag, cls.end_tag, cls.allowed_flags, parser, token)
+
+ trace_node_msg("PARSE", cls.tag, tag_id)
+
+ body, contents = tag.parse_body()
+ node = cls(
+ nodelist=body,
+ node_id=tag_id,
+ params=tag.params,
+ flags=tag.flags,
+ contents=contents,
+ template_name=parser.origin.name if parser.origin else None,
+ template_component=get_component_from_origin(parser.origin) if parser.origin else None,
+ **kwargs,
+ )
+
+ trace_node_msg("PARSE", cls.tag, tag_id, "...Done!")
+ return node
+
+ @classmethod
+ def register(cls, library: Library) -> None:
+ """
+ A convenience method for registering the tag with the given library.
+
+ ```python
+ class MyNode(BaseNode):
+ tag = "mynode"
+
+ MyNode.register(library)
+ ```
+
+ Allows you to then use the node in templates like so:
+
+ ```django
+ {% load mylibrary %}
+ {% mynode %}
+ ```
+ """
+ library.tag(cls.tag, cls.parse)
+
+ @classmethod
+ def unregister(cls, library: Library) -> None:
+ """Unregisters the node from the given library."""
+ library.tags.pop(cls.tag, None)
+
+
+def template_tag(
+ library: Library,
+ tag: str,
+ end_tag: Optional[str] = None,
+ allowed_flags: Optional[List[str]] = None,
+) -> Callable[[Callable], Callable]:
+ """
+ A simplified version of creating a template tag based on [`BaseNode`](../api#django_components.BaseNode).
+
+ Instead of defining the whole class, you can just define the
+ [`render()`](../api#django_components.BaseNode.render) method.
+
+ ```python
+ from django.template import Context, Library
+ from django_components import BaseNode, template_tag
+
+ library = Library()
+
+ @template_tag(
+ library,
+ tag="mytag",
+ end_tag="endmytag",
+ allowed_flags=["required"],
+ )
+ def mytag(node: BaseNode, context: Context, name: str, **kwargs: Any) -> str:
+ return f"Hello, {name}!"
+ ```
+
+ This will allow the template tag `{% mytag %}` to be used like this:
+
+ ```django
+ {% mytag name="John" %}
+ {% mytag name="John" required %} ... {% endmytag %}
+ ```
+
+ The given function will be wrapped in a class that inherits from [`BaseNode`](../api#django_components.BaseNode).
+
+ And this class will be registered with the given library.
+
+ The function MUST accept at least two positional arguments: `node` and `context`
+
+ - `node` is the [`BaseNode`](../api#django_components.BaseNode) instance.
+ - `context` is the [`Context`](https://docs.djangoproject.com/en/5.2/ref/templates/api/#django.template.Context)
+ of the template.
+
+ Any extra parameters defined on this function will be part of the tag's input parameters.
+
+ For more info, see [`BaseNode.render()`](../api#django_components.BaseNode.render).
+ """
+
+ def decorator(fn: Callable) -> Callable:
+ subcls_name = fn.__name__.title().replace("_", "").replace("-", "") + "Node"
+
+ try:
+ subcls: Type[BaseNode] = type(
+ subcls_name,
+ (BaseNode,),
+ {
+ "tag": tag,
+ "end_tag": end_tag,
+ "allowed_flags": allowed_flags or [],
+ "render": fn,
+ },
+ )
+ except Exception as e:
+ raise e.__class__(f"Failed to create node class in 'template_tag()' for '{fn.__name__}'") from e
+
+ subcls.register(library)
+
+ # Allow to access the node class
+ fn._node = subcls # type: ignore[attr-defined]
+
+ return fn
+
+ return decorator
diff --git a/src/django_components/perfutil/__init__.py b/src/django_components/perfutil/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/django_components/perfutil/component.py b/src/django_components/perfutil/component.py
new file mode 100644
index 00000000..0af91856
--- /dev/null
+++ b/src/django_components/perfutil/component.py
@@ -0,0 +1,503 @@
+import re
+from collections import deque
+from typing import TYPE_CHECKING, Callable, Deque, Dict, List, NamedTuple, Optional, Set, Tuple, Union
+
+from django.utils.safestring import mark_safe
+
+from django_components.constants import COMP_ID_LENGTH
+from django_components.util.exception import component_error_message
+
+if TYPE_CHECKING:
+ from django_components.component import ComponentContext, OnRenderGenerator
+
+OnComponentRenderedResult = Tuple[Optional[str], Optional[Exception]]
+
+# When we're inside a component's template, we need to acccess some component data,
+# as defined by `ComponentContext`. If we have nested components, then
+# each nested component will point to the Context of its parent component
+# via `outer_context`. This make is possible to access the correct data
+# inside `{% fill %}` tags.
+#
+# Previously, `ComponentContext` was stored directly on the `Context` object, but
+# this was problematic:
+# - The need for creating a Context snapshot meant potentially a lot of copying
+# - It was hard to trace and debug. Because if you printed the Context, it included the
+# `ComponentContext` data, including the `outer_context` which contained another
+# `ComponentContext` object, and so on.
+#
+# Thus, similarly to the data stored by `{% provide %}`, we store the actual
+# `ComponentContext` data on a separate dictionary, and what's passed through the Context
+# is only a key to this dictionary.
+component_context_cache: Dict[str, "ComponentContext"] = {}
+
+
+class ComponentPart(NamedTuple):
+ """Queue item where a component is nested in another component."""
+
+ child_id: str
+ parent_id: Optional[str]
+ component_name_path: List[str]
+
+ def __repr__(self) -> str:
+ return (
+ f"ComponentPart(child_id={self.child_id!r}, parent_id={self.parent_id!r}, "
+ f"component_name_path={self.component_name_path!r})"
+ )
+
+
+class TextPart(NamedTuple):
+ """Queue item where a text is between two components."""
+
+ text: str
+ is_last: bool
+ parent_id: str
+
+
+class ErrorPart(NamedTuple):
+ """Queue item where a component has thrown an error."""
+
+ child_id: str
+ error: Exception
+
+
+# Function that accepts a list of extra HTML attributes to be set on the component's root elements
+# and returns the component's HTML content and a dictionary of child components' IDs
+# and their root elements' HTML attributes.
+#
+# In other words, we use this to "delay" the actual rendering of the component's HTML content,
+# until we know what HTML attributes to apply to the root elements.
+ComponentRenderer = Callable[
+ [Optional[List[str]]],
+ Tuple[str, Dict[str, List[str]], Optional["OnRenderGenerator"]],
+]
+
+# Render-time cache for component rendering
+# See component_post_render()
+component_renderer_cache: Dict[str, Tuple[ComponentRenderer, str]] = {}
+child_component_attrs: Dict[str, List[str]] = {}
+
+nested_comp_pattern = re.compile(
+ r']*?djc-render-id="\w{{{COMP_ID_LENGTH}}}"[^>]*?>'.format(COMP_ID_LENGTH=COMP_ID_LENGTH)
+)
+render_id_pattern = re.compile(
+ r'djc-render-id="(?P\w{{{COMP_ID_LENGTH}}})"'.format(COMP_ID_LENGTH=COMP_ID_LENGTH)
+)
+
+
+# When a component is rendered, we want to apply HTML attributes like `data-djc-id-ca1b3cf`
+# to all root elements. However, we have to approach it smartly, to minimize the HTML parsing.
+#
+# If we naively first rendered the child components, and then the parent component, then we would
+# have to parse the child's HTML twice (once for itself, and once as part of the parent).
+# When we have a deeply nested component structure, this can add up to a lot of parsing.
+# See https://github.com/django-components/django-components/issues/14#issuecomment-2596096632.
+#
+# Imagine we first render the child components. Once rendered, child's HTML gets embedded into
+# the HTML of the parent. So by the time we get to the root, we will have to parse the full HTML
+# document, even if the root component is only a small part of the document.
+#
+# So instead, when a nested component is rendered, we put there only a placeholder, and store the
+# actual HTML content in `component_renderer_cache`.
+#
+# ```django
+#
+#
...
+#
+# ...
+#
+#
+# ```
+#
+# The full flow is as follows:
+# 1. When a component is nested in another, the child component is rendered, but it returns
+# only a placeholder like ``.
+# The actual HTML output is stored in `component_renderer_cache`.
+# 2. The parent of the child component is rendered normally.
+# 3. If the placeholder for the child component is at root of the parent component,
+# then the placeholder may be tagged with extra attributes, e.g. `data-djc-id-ca1b3cf`.
+# ``.
+# 4. When the parent is done rendering, we go back to step 1., the parent component
+# either returns the actual HTML, or a placeholder.
+# 5. Only once we get to the root component, that has no further parents, is when we finally
+# start putting it all together.
+# 6. We start at the root component. We search the root component's output HTML for placeholders.
+# Each placeholder has ID `data-djc-render-id` that links to its actual content.
+# 7. For each found placeholder, we replace it with the actual content.
+# But as part of step 7), we also:
+# - If any of the child placeholders had extra attributes, we cache these, so we can access them
+# once we get to rendering the child component.
+# - And if the parent component had any extra attributes set by its parent, we apply these
+# to the root elements.
+# 8. Lastly, we merge all the parts together, and return the final HTML.
+def component_post_render(
+ renderer: ComponentRenderer,
+ render_id: str,
+ component_name: str,
+ parent_id: Optional[str],
+ on_component_rendered_callbacks: Dict[
+ str, Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]
+ ],
+ on_html_rendered: Callable[[str], str],
+) -> str:
+ # Instead of rendering the component's HTML content immediately, we store it,
+ # so we can render the component only once we know if there are any HTML attributes
+ # to be applied to the resulting HTML.
+ component_renderer_cache[render_id] = (renderer, component_name)
+
+ # Case: Nested component
+ # If component is nested, return a placeholder
+ #
+ # How this works is that we have nested components:
+ # ```
+ # ComponentA
+ # ComponentB
+ # ComponentC
+ # ```
+ #
+ # And these components are embedded one in another using the `{% component %}` tag.
+ # ```django
+ #
+ #
+ # {% component "ComponentB" / %}
+ #
+ # ```
+ #
+ # Then the order in which components call `component_post_render()` is:
+ # 1. ComponentB - Triggered by `{% component "ComponentB" / %}` while A's template is being rendered,
+ # returns only a placeholder.
+ # 2. ComponentA - Triggered by the end of A's template. A isn't nested, so it starts full component
+ # tree render. This replaces B's placeholder with actual HTML and introduces C's placeholder.
+ # And so on...
+ # 3. ComponentC - Triggered by `{% component "ComponentC" / %}` while B's template is being rendered
+ # as part of full component tree render. Returns only a placeholder, to be replaced in next
+ # step.
+ if parent_id is not None:
+ return mark_safe(f'')
+
+ # Case: Root component - Construct the final HTML by recursively replacing placeholders
+ #
+ # We first generate the component's HTML content, by calling the renderer.
+ #
+ # Then we process the component's HTML from root-downwards, going depth-first.
+ # So if we have a template:
+ # ```django
+ #
+ #
+ # And put the pairs of (content, placeholder_id) into a queue:
+ # - ("
...
", "a1b3cf")
+ # - ("...", "f3d3cf")
+ # - ("
", None)
+ #
+ # Then we process each part:
+ # 1. Append the content to the output
+ # 2. If the placeholder ID is not None, then we fetch the renderer by its placeholder ID (e.g. "a1b3cf")
+ # 3. If there were any extra attributes set by the parent component, we apply these to the renderer.
+ # 4. We split the content by placeholders, and put the pairs of (content, placeholder_id) into the queue,
+ # repeating this whole process until we've processed all nested components.
+ # 5. If the placeholder ID is None, then we've reached the end of the component's HTML content,
+ # and we can go one level up to continue the process with component's parent.
+ process_queue: Deque[Union[ErrorPart, TextPart, ComponentPart]] = deque()
+
+ process_queue.append(
+ ComponentPart(
+ child_id=render_id,
+ parent_id=None,
+ component_name_path=[],
+ )
+ )
+
+ # By looping over the queue below, we obtain bits of rendered HTML, which we then
+ # must all join together into a single final HTML.
+ #
+ # But instead of joining it all up once at the end, we join the bits on component basis.
+ # So if component has a template like this:
+ # ```django
+ #
+ # Hello
+ # {% component "table" / %}
+ #
+ # ```
+ #
+ # Then we end up with 3 bits - 1. text before, 2. component, and 3. text after
+ #
+ # We know when we've arrived at component's end. We then collect the HTML parts by the component ID,
+ # and when we hit the end, we join all the bits that belong to the same component.
+ #
+ # Once the component's HTML is joined, we can call the callback for the component, and
+ # then add the joined HTML to the cache for the parent component to continue the cycle.
+ html_parts_by_component_id: Dict[str, List[str]] = {}
+ content_parts: List[str] = []
+
+ # Remember which component ID had which parent ID, so we can bubble up errors
+ # to the parent component.
+ child_id_to_parent_id: Dict[str, Optional[str]] = {}
+
+ def get_html_parts(component_id: str) -> List[str]:
+ if component_id not in html_parts_by_component_id:
+ html_parts_by_component_id[component_id] = []
+ return html_parts_by_component_id[component_id]
+
+ def handle_error(component_id: str, error: Exception) -> None:
+ # Cleanup
+ # Remove any HTML parts that were already rendered for this component
+ html_parts_by_component_id.pop(component_id, None)
+ # Mark any remaining parts of this component (that may be still in the queue) as errored
+ ignored_ids.add(component_id)
+ # Also mark as ignored any remaining parts of the PARENT component.
+ # The reason is because due to the error, parent's rendering flow was disrupted.
+ # Even if parent recovers from the error by returning a new HTML, this new HTML
+ # may have nothing in common with the original HTML.
+ parent_id = child_id_to_parent_id[component_id]
+ if parent_id is not None:
+ ignored_ids.add(parent_id)
+
+ # Add error item to the queue so we handle it in next iteration
+ process_queue.appendleft(
+ ErrorPart(
+ child_id=component_id,
+ error=error,
+ )
+ )
+
+ def finalize_component(component_id: str, error: Optional[Exception]) -> None:
+ parent_id = child_id_to_parent_id[component_id]
+
+ component_parts = html_parts_by_component_id.pop(component_id, [])
+ if error is None:
+ component_html = "".join(component_parts)
+ else:
+ component_html = None
+
+ # Allow to optionally override/modify the rendered content from `Component.on_render()`
+ # and by extensions' `on_component_rendered` hooks.
+ on_component_rendered = on_component_rendered_callbacks[component_id]
+ component_html, error = on_component_rendered(component_html, error)
+
+ # If this component had an error, then we ignore this component's HTML, and instead
+ # bubble the error up to the parent component.
+ if error is not None:
+ handle_error(component_id=component_id, error=error)
+ return
+
+ if component_html is None:
+ raise RuntimeError("Unexpected `None` from `Component.on_render()`")
+
+ # At this point we have a component, and we've resolved all its children into strings.
+ # So the component's full HTML is now only strings.
+ #
+ # Hence we can transfer the child component's HTML to parent, treating it as if
+ # the parent component had the rendered HTML in child's place.
+ if parent_id is not None:
+ target_list = get_html_parts(parent_id)
+ target_list.append(component_html)
+ # If there is no parent, then we're at the root component, and we can add the
+ # component's HTML to the final output.
+ else:
+ content_parts.append(component_html)
+
+ # To avoid having to iterate over the queue multiple times to remove from it those
+ # entries that belong to components that have thrown error, we instead keep track of which
+ # components have thrown error, and skip any remaining parts of the component.
+ ignored_ids: Set[str] = set()
+
+ while len(process_queue):
+ curr_item = process_queue.popleft()
+
+ # NOTE: When an error is bubbling up, then the flow goes between `handle_error()`, `finalize_component()`,
+ # and this branch, until we reach the root component, where the error is finally raised.
+ #
+ # Any ancestor component of the one that raised can intercept the error and instead return a new string
+ # (or a new error).
+ if isinstance(curr_item, ErrorPart):
+ parent_id = child_id_to_parent_id[curr_item.child_id]
+
+ # If there is no parent, then we're at the root component, so we simply propagate the error.
+ # This ends the error bubbling.
+ if parent_id is None:
+ raise curr_item.error from None # Re-raise
+
+ # This will make the parent component either handle the error and return a new string instead,
+ # or propagate the error to its parent.
+ finalize_component(component_id=parent_id, error=curr_item.error)
+ continue
+
+ # Skip parts of errored components
+ elif curr_item.parent_id in ignored_ids:
+ continue
+
+ # Process text parts
+ elif isinstance(curr_item, TextPart):
+ parent_html_parts = get_html_parts(curr_item.parent_id)
+ parent_html_parts.append(curr_item.text)
+
+ # In this case we've reached the end of the component's HTML content, and there's
+ # no more subcomponents to process. We can call `finalize_component()` to process
+ # the component's HTML and eventually trigger `on_component_rendered` hook.
+ if curr_item.is_last:
+ finalize_component(component_id=curr_item.parent_id, error=None)
+
+ continue
+
+ # The rest of this branch assumes `curr_item` is a `ComponentPart`
+ component_id = curr_item.child_id
+
+ # Remember which component ID had which parent ID, so we can bubble up errors
+ # to the parent component.
+ child_id_to_parent_id[component_id] = curr_item.parent_id
+
+ # Generate component's content, applying the extra HTML attributes set by the parent component
+ curr_comp_renderer, curr_comp_name = component_renderer_cache.pop(component_id)
+ # NOTE: Attributes passed from parent to current component are `None` for the root component.
+ curr_comp_attrs = child_component_attrs.pop(component_id, None)
+
+ full_path = [*curr_item.component_name_path, curr_comp_name]
+
+ # This is where we actually render the component
+ #
+ # NOTE: [1:] because the root component will be yet again added to the error's
+ # `components` list in `_render_with_error_trace` so we remove the first element from the path.
+ try:
+ with component_error_message(full_path[1:]):
+ comp_content, grandchild_component_attrs, on_render_generator = curr_comp_renderer(curr_comp_attrs)
+ # This error may be triggered when any of following raises:
+ # - `Component.on_render()` (first part - before yielding)
+ # - `Component.on_render_before()`
+ # - Rendering of component's template
+ #
+ # In all cases, we want to mark the component as errored, and let the parent handle it.
+ except Exception as err:
+ handle_error(component_id=component_id, error=err)
+ continue
+
+ # To access the *final* output (with all its children rendered) from within `Component.on_render()`,
+ # users may convert it to a generator by including a `yield` keyword. If they do so, the part of code
+ # AFTER the yield will be called once, when the component's HTML is fully rendered.
+ #
+ # We want to make sure we call the second part of `Component.on_render()` BEFORE
+ # we call `Component.on_render_after()`. The latter will be triggered by calling
+ # corresponding `on_component_rendered`.
+ #
+ # So we want to wrap the `on_component_rendered` callback, so we get to call the generator first.
+ if on_render_generator is not None:
+ unwrapped_on_component_rendered = on_component_rendered_callbacks[component_id]
+ on_component_rendered_callbacks[component_id] = _call_generator_before_callback(
+ on_render_generator,
+ unwrapped_on_component_rendered,
+ )
+
+ child_component_attrs.update(grandchild_component_attrs)
+
+ # Split component's content by placeholders, and put the pairs of
+ # `(text_between_components, placeholder_id)`
+ # into the queue.
+ last_index = 0
+ parts_to_process: List[Union[TextPart, ComponentPart]] = []
+ for match in nested_comp_pattern.finditer(comp_content):
+ part_before_component = comp_content[last_index : match.start()] # noqa: E203
+ last_index = match.end()
+ comp_part = match[0]
+
+ # Extract the placeholder ID from ``
+ grandchild_id_match = render_id_pattern.search(comp_part)
+ if grandchild_id_match is None:
+ raise ValueError(f"No placeholder ID found in {comp_part}")
+ grandchild_id = grandchild_id_match.group("render_id")
+
+ parts_to_process.extend(
+ [
+ TextPart(
+ text=part_before_component,
+ is_last=False,
+ parent_id=component_id,
+ ),
+ ComponentPart(
+ child_id=grandchild_id,
+ parent_id=component_id,
+ component_name_path=full_path,
+ ),
+ ]
+ )
+
+ # Append any remaining text
+ parts_to_process.extend(
+ [
+ TextPart(
+ text=comp_content[last_index:],
+ is_last=True,
+ parent_id=component_id,
+ ),
+ ]
+ )
+
+ process_queue.extendleft(reversed(parts_to_process))
+
+ # Lastly, join up all pieces of the component's HTML content
+ output = "".join(content_parts)
+
+ output = on_html_rendered(output)
+
+ return mark_safe(output)
+
+
+def _call_generator_before_callback(
+ on_render_generator: Optional["OnRenderGenerator"],
+ inner_fn: Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult],
+) -> Callable[[Optional[str], Optional[Exception]], OnComponentRenderedResult]:
+ if on_render_generator is None:
+ return inner_fn
+
+ def on_component_rendered_wrapper(
+ html: Optional[str],
+ error: Optional[Exception],
+ ) -> OnComponentRenderedResult:
+ try:
+ on_render_generator.send((html, error))
+ # `Component.on_render()` should contain only one `yield` statement, so calling `.send()`
+ # should reach `return` statement in `Component.on_render()`, which triggers `StopIteration`.
+ # In that case, the value returned from `Component.on_render()` with the `return` keyword
+ # is the new output (if not `None`).
+ except StopIteration as generator_err:
+ # To override what HTML / error gets returned, user may either:
+ # - Return a new HTML at the end of `Component.on_render()` (after yielding),
+ # - Raise a new error
+ new_output = generator_err.value
+ if new_output is not None:
+ html = new_output
+ error = None
+
+ # Catch if `Component.on_render()` raises an exception, in which case this becomes
+ # the new error.
+ except Exception as new_error:
+ error = new_error
+ html = None
+ # This raises if `StopIteration` was not raised, which may be if `Component.on_render()`
+ # contains more than one `yield` statement.
+ else:
+ raise RuntimeError("`Component.on_render()` must include only one `yield` statement")
+
+ return inner_fn(html, error)
+
+ return on_component_rendered_wrapper
diff --git a/src/django_components/perfutil/provide.py b/src/django_components/perfutil/provide.py
new file mode 100644
index 00000000..be99167c
--- /dev/null
+++ b/src/django_components/perfutil/provide.py
@@ -0,0 +1,155 @@
+"""
+This module contains optimizations for the `{% provide %}` feature.
+"""
+
+from contextlib import contextmanager
+from typing import Dict, Generator, NamedTuple, Set
+
+from django.template import Context
+
+from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
+
+# Originally, when `{% provide %}` was used, the provided data was passed down
+# through the Context object.
+#
+# However, this was hard to debug if the provided data was large (e.g. a long
+# list of items).
+#
+# Instead, similarly to how the component internal data is passed through
+# the Context object, there's now a level of indirection - the Context now stores
+# only a key that points to the provided data.
+#
+# So when we inspect a Context layers, we may see something like this:
+#
+# ```py
+# [
+# {"False": False, "None": None, "True": True}, # All Contexts contain this
+# {"custom_key": "custom_value"}, # Data passed to Context()
+# {"_DJC_INJECT__my_provide": "a1b3c3"}, # Data provided by {% provide %}
+# # containing only the key to "my_provide"
+# ]
+# ```
+#
+# Since the provided data is represented only as a key, we have to store the ACTUAL
+# data somewhere. Thus, we store it in a separate dictionary.
+#
+# So when one calls `Component.inject(key)`, we use the key to look up the actual data
+# in the dictionary and return that.
+#
+# This approach has several benefits:
+# - Debugging: One needs to only follow the IDs to trace the flow of data.
+# - Debugging: All provided data is stored in a separate dictionary, so it's easy to
+# see what data is provided.
+# - Perf: The Context object is copied each time we call `Component.render()`, to have a "snapshot"
+# of the context, in order to defer the rendering. Passing around only the key instead
+# of actual value avoids potentially copying the provided data. This also keeps the source of truth
+# unambiguous.
+# - Tests: It's easier to test the provided data, as we can just modify the dictionary directly.
+#
+# However, there is a price to pay for this:
+# - Manual memory management - Because the data is stored in a separate dictionary, we now need to
+# keep track of when to delete the entries.
+#
+# The challenge with this manual memory management is that:
+# 1. Component rendering is deferred, so components are rendered AFTER we finish `Template.render()`.
+# 2. For the deferred rendering, we copy the Context object.
+#
+# This means that:
+# 1. We can't rely on simply reaching the end of `Template.render()` to delete the provided data.
+# 2. We can't rely on the Context object being deleted to delete the provided data.
+#
+# So we need to manually delete the provided data when we know it's no longer needed.
+#
+# Thus, the strategy is to count references to the provided data:
+# 1. When `{% provide %}` is used, it adds a key to the context.
+# 2. When we come across `{% component %}` that is within the `{% provide %}` tags,
+# the component will see the provide's key and the component will register itself as a "child" of
+# the `{% provide %}` tag at `Component.render()`.
+# 3. Once the component's deferred rendering takes place and finishes, the component makes a call
+# to unregister itself from any "subscribed" provided data.
+# 4. While unsubscribing, if we see that there are no more children subscribed to the provided data,
+# we can finally delete the provided data from the cache.
+#
+# However, this leaves open the edge case of when `{% provide %}` contains NO components.
+# In such case, we check if there are any subscribed components after rendering the contents
+# of `{% provide %}`. If there are NONE, we delete the provided data.
+
+
+# Similarly to ComponentContext instances, we store the actual Provided data
+# outside of the Context object, to make it easier to debug the data flow.
+provide_cache: Dict[str, NamedTuple] = {}
+
+# Keep track of how many components are referencing each provided data.
+provide_references: Dict[str, Set[str]] = {}
+
+# Keep track of all the listeners that are referencing any provided data.
+all_reference_ids: Set[str] = set()
+
+
+@contextmanager
+def managed_provide_cache(provide_id: str) -> Generator[None, None, None]:
+ all_reference_ids_before = all_reference_ids.copy()
+
+ def cache_cleanup() -> None:
+ # Lastly, remove provided data from the cache that was generated during this run,
+ # IF there are no more references to it.
+ if provide_id in provide_references and not provide_references[provide_id]:
+ provide_references.pop(provide_id)
+ provide_cache.pop(provide_id)
+
+ # Case: `{% provide %}` contained no components in its body.
+ # The provided data was not referenced by any components, but it's still in the cache.
+ elif provide_id not in provide_references and provide_id in provide_cache:
+ provide_cache.pop(provide_id)
+
+ try:
+ yield
+ except Exception as e:
+ # In case of an error in `Component.render()`, there may be some
+ # references left hanging, so we remove them.
+ new_reference_ids = all_reference_ids - all_reference_ids_before
+ for reference_id in new_reference_ids:
+ unregister_provide_reference(reference_id)
+
+ # Cleanup
+ cache_cleanup()
+ # Forward the error
+ raise e from None
+
+ # Cleanup
+ cache_cleanup()
+
+
+def register_provide_reference(context: Context, reference_id: str) -> None:
+ # No `{% provide %}` among the ancestors, nothing to register to
+ if not provide_cache:
+ return
+
+ all_reference_ids.add(reference_id)
+
+ for key, provide_id in context.flatten().items():
+ if not key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
+ continue
+
+ if provide_id not in provide_references:
+ provide_references[provide_id] = set()
+ provide_references[provide_id].add(reference_id)
+
+
+def unregister_provide_reference(reference_id: str) -> None:
+ # No registered references, nothing to unregister
+ if reference_id not in all_reference_ids:
+ return
+
+ all_reference_ids.remove(reference_id)
+
+ for provide_id in list(provide_references.keys()):
+ if reference_id not in provide_references[provide_id]:
+ continue
+
+ provide_references[provide_id].remove(reference_id)
+
+ # There are no more references to the provided data, so we can delete it.
+ if not provide_references[provide_id]:
+ provide_cache.pop(provide_id)
+ provide_references.pop(provide_id)
diff --git a/src/django_components/provide.py b/src/django_components/provide.py
index 2c168353..41e2367f 100644
--- a/src/django_components/provide.py
+++ b/src/django_components/provide.py
@@ -1,63 +1,171 @@
-from typing import Dict, Optional, Tuple
+from collections import namedtuple
+from typing import Any, Dict, Optional
-from django.template import Context
-from django.template.base import NodeList
+from django.template import Context, TemplateSyntaxError
from django.utils.safestring import SafeString
-from django_components.context import set_provided_context_var
-from django_components.expression import RuntimeKwargs
+from django_components.context import _INJECT_CONTEXT_KEY_PREFIX
from django_components.node import BaseNode
-from django_components.util.logger import trace_msg
+from django_components.perfutil.provide import managed_provide_cache, provide_cache
from django_components.util.misc import gen_id
-PROVIDE_NAME_KWARG = "name"
-
class ProvideNode(BaseNode):
"""
- Implementation of the `{% provide %}` tag.
- For more info see `Component.inject`.
+ The [`{% provide %}`](../template_tags#provide) tag is part of the "provider" part of
+ the [provide / inject feature](../../concepts/advanced/provide_inject).
+
+ Pass kwargs to this tag to define the provider's data.
+
+ Any components defined within the `{% provide %}..{% endprovide %}` tags will be able to access this data
+ with [`Component.inject()`](../api#django_components.Component.inject).
+
+ This is similar to React's [`ContextProvider`](https://react.dev/learn/passing-data-deeply-with-context),
+ or Vue's [`provide()`](https://vuejs.org/guide/components/provide-inject).
+
+ **Args:**
+
+ - `name` (str, required): Provider name. This is the name you will then use in
+ [`Component.inject()`](../api#django_components.Component.inject).
+ - `**kwargs`: Any extra kwargs will be passed as the provided data.
+
+ **Example:**
+
+ Provide the "user_data" in parent component:
+
+ ```djc_py
+ @register("parent")
+ class Parent(Component):
+ template = \"\"\"
+
+ \"\"\"
+
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "user": kwargs["user"],
+ }
+ ```
+
+ Since the "child" component is used within the `{% provide %} / {% endprovide %}` tags,
+ we can request the "user_data" using `Component.inject("user_data")`:
+
+ ```djc_py
+ @register("child")
+ class Child(Component):
+ template = \"\"\"
+
+ User is: {{ user }}
+
+ \"\"\"
+
+ def get_template_data(self, args, kwargs, slots, context):
+ user = self.inject("user_data").user
+ return {
+ "user": user,
+ }
+ ```
+
+ Notice that the keys defined on the [`{% provide %}`](../template_tags#provide) tag are then accessed as attributes
+ when accessing them with [`Component.inject()`](../api#django_components.Component.inject).
+
+ ✅ Do this
+ ```python
+ user = self.inject("user_data").user
+ ```
+
+ ❌ Don't do this
+ ```python
+ user = self.inject("user_data")["user"]
+ ```
"""
- def __init__(
- self,
- nodelist: NodeList,
- trace_id: str,
- node_id: Optional[str] = None,
- kwargs: Optional[RuntimeKwargs] = None,
- ):
- super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
-
- self.nodelist = nodelist
- self.node_id = node_id or gen_id()
- self.trace_id = trace_id
- self.kwargs = kwargs or RuntimeKwargs({})
-
- def __repr__(self) -> str:
- return f""
-
- def render(self, context: Context) -> SafeString:
- trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id)
-
- name, kwargs = self.resolve_kwargs(context)
+ tag = "provide"
+ end_tag = "endprovide"
+ allowed_flags = []
+ def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
# NOTE: The "provided" kwargs are meant to be shared privately, meaning that components
# have to explicitly opt in by using the `Component.inject()` method. That's why we don't
# add the provided kwargs into the Context.
with context.update({}):
# "Provide" the data to child nodes
- set_provided_context_var(context, name, kwargs)
+ provide_id = set_provided_context_var(context, name, kwargs)
- output = self.nodelist.render(context)
+ # `managed_provide_cache` will remove the cache entry at the end if no components reference it.
+ with managed_provide_cache(provide_id):
+ output = self.nodelist.render(context)
- trace_msg("RENDR", "PROVIDE", self.trace_id, self.node_id, msg="...Done!")
return output
- def resolve_kwargs(self, context: Context) -> Tuple[str, Dict[str, Optional[str]]]:
- kwargs = self.kwargs.resolve(context)
- name = kwargs.pop(PROVIDE_NAME_KWARG, None)
- if not name:
- raise RuntimeError("Provide tag kwarg 'name' is missing")
+def get_injected_context_var(
+ component_name: str,
+ context: Context,
+ key: str,
+ default: Optional[Any] = None,
+) -> Any:
+ """
+ Retrieve a 'provided' field. The field MUST have been previously 'provided'
+ by the component's ancestors using the `{% provide %}` template tag.
+ """
+ # NOTE: For simplicity, we keep the provided values directly on the context.
+ # This plays nicely with Django's Context, which behaves like a stack, so "newer"
+ # values overshadow the "older" ones.
+ internal_key = _INJECT_CONTEXT_KEY_PREFIX + key
- return (name, kwargs)
+ # Return provided value if found
+ if internal_key in context:
+ cache_key = context[internal_key]
+ return provide_cache[cache_key]
+
+ # If a default was given, return that
+ if default is not None:
+ return default
+
+ # Otherwise raise error
+ raise KeyError(
+ f"Component '{component_name}' tried to inject a variable '{key}' before it was provided."
+ f" To fix this, make sure that at least one ancestor of component '{component_name}' has"
+ f" the variable '{key}' in their 'provide' attribute."
+ )
+
+
+def set_provided_context_var(
+ context: Context,
+ key: str,
+ provided_kwargs: Dict[str, Any],
+) -> str:
+ """
+ 'Provide' given data under given key. In other words, this data can be retrieved
+ using `self.inject(key)` inside of `get_template_data()` method of components that
+ are nested inside the `{% provide %}` tag.
+ """
+ # NOTE: We raise TemplateSyntaxError since this func should be called only from
+ # within template.
+ if not key:
+ raise TemplateSyntaxError(
+ "Provide tag received an empty string. Key must be non-empty and a valid identifier."
+ )
+ if not key.isidentifier():
+ raise TemplateSyntaxError(
+ "Provide tag received a non-identifier string. Key must be non-empty and a valid identifier."
+ )
+
+ # We turn the kwargs into a NamedTuple so that the object that's "provided"
+ # is immutable. This ensures that the data returned from `inject` will always
+ # have all the keys that were passed to the `provide` tag.
+ tuple_cls = namedtuple("DepInject", provided_kwargs.keys()) # type: ignore[misc]
+ payload = tuple_cls(**provided_kwargs)
+
+ # Instead of storing the provided data on the Context object, we store it
+ # in a separate dictionary, and we set only the key to the data on the Context.
+ context_key = _INJECT_CONTEXT_KEY_PREFIX + key
+ provide_id = gen_id()
+ context[context_key] = provide_id
+ provide_cache[provide_id] = payload
+
+ return provide_id
diff --git a/src/django_components/slots.py b/src/django_components/slots.py
index 5535d702..69c60b10 100644
--- a/src/django_components/slots.py
+++ b/src/django_components/slots.py
@@ -1,6 +1,7 @@
import difflib
import re
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from dataclasses import replace as dataclass_replace
from typing import (
TYPE_CHECKING,
Any,
@@ -20,164 +21,625 @@ from typing import (
runtime_checkable,
)
-from django.template import Context
+from django.template import Context, Template
from django.template.base import NodeList, TextNode
from django.template.exceptions import TemplateSyntaxError
+from django.utils.html import conditional_escape
from django.utils.safestring import SafeString, mark_safe
from django_components.app_settings import ContextBehavior
-from django_components.context import (
- _COMPONENT_SLOT_CTX_CONTEXT_KEY,
- _INJECT_CONTEXT_KEY_PREFIX,
- _REGISTRY_CONTEXT_KEY,
- _ROOT_CTX_CONTEXT_KEY,
-)
-from django_components.expression import RuntimeKwargs, is_identifier
+from django_components.context import _COMPONENT_CONTEXT_KEY, _INJECT_CONTEXT_KEY_PREFIX, COMPONENT_IS_NESTED_KEY
+from django_components.extension import OnSlotRenderedContext, extensions
from django_components.node import BaseNode
-from django_components.util.logger import trace_msg
-from django_components.util.misc import get_last_index
+from django_components.perfutil.component import component_context_cache
+from django_components.util.exception import add_slot_to_error_message
+from django_components.util.logger import trace_component_msg
+from django_components.util.misc import default, get_index, get_last_index, is_identifier
if TYPE_CHECKING:
- from django_components.component_registry import ComponentRegistry
+ from django_components.component import Component, ComponentNode
-TSlotData = TypeVar("TSlotData", bound=Mapping, contravariant=True)
+TSlotData = TypeVar("TSlotData", bound=Mapping)
DEFAULT_SLOT_KEY = "default"
FILL_GEN_CONTEXT_KEY = "_DJANGO_COMPONENTS_GEN_FILL"
-SLOT_DATA_KWARG = "data"
SLOT_NAME_KWARG = "name"
-SLOT_DEFAULT_KWARG = "default"
-SLOT_REQUIRED_KEYWORD = "required"
-SLOT_DEFAULT_KEYWORD = "default"
+SLOT_REQUIRED_FLAG = "required"
+SLOT_DEFAULT_FLAG = "default"
+FILL_DATA_KWARG = "data"
+FILL_FALLBACK_KWARG = "fallback"
+FILL_BODY_KWARG = "body"
# Public types
SlotResult = Union[str, SafeString]
+"""
+Type representing the result of a slot render function.
+
+**Example:**
+
+```python
+from django_components import SlotContext, SlotResult
+
+def my_slot_fn(ctx: SlotContext) -> SlotResult:
+ return "Hello, world!"
+
+my_slot = Slot(my_slot_fn)
+html = my_slot() # Output: Hello, world!
+```
+
+Read more about [Slot functions](../../concepts/fundamentals/slots#slot-functions).
+"""
+
+
+@dataclass(frozen=True)
+class SlotContext(Generic[TSlotData]):
+ """
+ Metadata available inside slot functions.
+
+ Read more about [Slot functions](../../concepts/fundamentals/slots#slot-class).
+
+ **Example:**
+
+ ```python
+ from django_components import SlotContext, SlotResult
+
+ def my_slot(ctx: SlotContext) -> SlotResult:
+ return f"Hello, {ctx.data['name']}!"
+ ```
+
+ You can pass a type parameter to the `SlotContext` to specify the type of the data passed to the slot:
+
+ ```python
+ class MySlotData(TypedDict):
+ name: str
+
+ def my_slot(ctx: SlotContext[MySlotData]):
+ return f"Hello, {ctx.data['name']}!"
+ ```
+ """
+
+ data: TSlotData
+ """
+ Data passed to the slot.
+
+ Read more about [Slot data](../../concepts/fundamentals/slots#slot-data).
+
+ **Example:**
+
+ ```python
+ def my_slot(ctx: SlotContext):
+ return f"Hello, {ctx.data['name']}!"
+ ```
+ """
+ fallback: Optional[Union[str, "SlotFallback"]] = None
+ """
+ Slot's fallback content. Lazily-rendered - coerce this value to string to force it to render.
+
+ Read more about [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
+
+ **Example:**
+
+ ```python
+ def my_slot(ctx: SlotContext):
+ return f"Hello, {ctx.fallback}!"
+ ```
+
+ May be `None` if you call the slot fill directly, without using [`{% slot %}`](../template_tags#slot) tags.
+ """
+ context: Optional[Context] = None
+ """
+ Django template [`Context`](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.Context)
+ available inside the [`{% fill %}`](../template_tags#fill) tag.
+
+ May be `None` if you call the slot fill directly, without using [`{% slot %}`](../template_tags#slot) tags.
+ """
@runtime_checkable
class SlotFunc(Protocol, Generic[TSlotData]):
- def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult: ... # noqa E704
+ """
+ When rendering components with
+ [`Component.render()`](../api#django_components.Component.render)
+ or
+ [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
+ the slots can be given either as strings or as functions.
+ If a slot is given as a function, it will have the signature of `SlotFunc`.
-SlotContent = Union[SlotResult, SlotFunc[TSlotData], "Slot[TSlotData]"]
+ Read more about [Slot functions](../../concepts/fundamentals/slots#slot-functions).
+
+ Args:
+ ctx (SlotContext): Single named tuple that holds the slot data and metadata.
+
+ Returns:
+ (str | SafeString): The rendered slot content.
+
+ **Example:**
+
+ ```python
+ from django_components import SlotContext, SlotResult
+
+ def header(ctx: SlotContext) -> SlotResult:
+ if ctx.data.get("name"):
+ return f"Hello, {ctx.data['name']}!"
+ else:
+ return ctx.fallback
+
+ html = MyTable.render(
+ slots={
+ "header": header,
+ },
+ )
+ ```
+ """
+
+ def __call__(self, ctx: SlotContext[TSlotData]) -> SlotResult: ... # noqa E704
@dataclass
class Slot(Generic[TSlotData]):
- """This class holds the slot content function along with related metadata."""
+ """
+ This class is the main way for defining and handling slots.
- content_func: SlotFunc[TSlotData]
+ It holds the slot content function along with related metadata.
+
+ Read more about [Slot class](../../concepts/fundamentals/slots#slot-class).
+
+ **Example:**
+
+ Passing slots to components:
+
+ ```python
+ from django_components import Slot
+
+ slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
+
+ MyComponent.render(
+ slots={
+ "my_slot": slot,
+ },
+ )
+ ```
+
+ Accessing slots inside the components:
+
+ ```python
+ from django_components import Component
+
+ class MyComponent(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ my_slot = slots["my_slot"]
+ return {
+ "my_slot": my_slot,
+ }
+ ```
+
+ Rendering slots:
+
+ ```python
+ from django_components import Slot
+
+ slot = Slot(lambda ctx: f"Hello, {ctx.data['name']}!")
+ html = slot({"name": "John"}) # Output: Hello, John!
+ ```
+ """
+
+ contents: Any
+ """
+ The original value that was passed to the `Slot` constructor.
+
+ - If Slot was created from [`{% fill %}`](../template_tags#fill) tag, `Slot.contents` will contain
+ the body (string) of that `{% fill %}` tag.
+ - If Slot was created from string as `Slot("...")`, `Slot.contents` will contain that string.
+ - If Slot was created from a function, `Slot.contents` will contain that function.
+
+ Read more about [Slot contents](../../concepts/fundamentals/slots#slot-contents).
+ """
+ content_func: SlotFunc[TSlotData] = cast(SlotFunc[TSlotData], None)
+ """
+ The actual slot function.
+
+ Do NOT call this function directly, instead call the `Slot` instance as a function.
+
+ Read more about [Rendering slot functions](../../concepts/fundamentals/slots#rendering-slots).
+ """
+
+ # Following fields are only for debugging
+ component_name: Optional[str] = None
+ """
+ Name of the component that originally received this slot fill.
+
+ See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
+ """
+ slot_name: Optional[str] = None
+ """
+ Slot name to which this Slot was initially assigned.
+
+ See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
+ """
+ nodelist: Optional[NodeList] = None
+ """
+ If the slot was defined with [`{% fill %}`](../template_tags#fill) tag,
+ this will be the Nodelist of the fill's content.
+
+ See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
+ """
+ fill_node: Optional[Union["FillNode", "ComponentNode"]] = None
+ """
+ If the slot was created from a [`{% fill %}`](../template_tags#fill) tag,
+ this will be the [`FillNode`](../api/#django_components.FillNode) instance.
+
+ If the slot was a default slot created from a [`{% component %}`](../template_tags#component) tag,
+ this will be the [`ComponentNode`](../api/#django_components.ComponentNode) instance.
+
+ Otherwise, this will be `None`.
+
+ Extensions can use this info to handle slots differently based on their source.
+
+ See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
+
+ **Example:**
+
+ You can use this to find the [`Component`](../api/#django_components.Component) in whose
+ template the [`{% fill %}`](../template_tags#fill) tag was defined:
+
+ ```python
+ class MyTable(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ footer_slot = slots.get("footer")
+ if footer_slot is not None and footer_slot.fill_node is not None:
+ owner_component = footer_slot.fill_node.template_component
+ # ...
+ ```
+ """
+ extra: Dict[str, Any] = field(default_factory=dict)
+ """
+ Dictionary that can be used to store arbitrary metadata about the slot.
+
+ See [Slot metadata](../../concepts/fundamentals/slots#slot-metadata).
+
+ See [Pass slot metadata](../../concepts/advanced/extensions#pass-slot-metadata)
+ for usage for extensions.
+
+ **Example:**
+
+ ```python
+ # Either at slot creation
+ slot = Slot(lambda ctx: "Hello, world!", extra={"foo": "bar"})
+
+ # Or later
+ slot.extra["baz"] = "qux"
+ ```
+ """
def __post_init__(self) -> None:
- if not callable(self.content_func):
- raise ValueError(f"Slot content must be a callable, got: {self.content_func}")
+ # Raise if Slot received another Slot instance as `contents`,
+ # because this leads to ambiguity about how to handle the metadata.
+ if isinstance(self.contents, Slot):
+ raise ValueError("Slot received another Slot instance as `contents`")
- # Allow passing Slot instances as content functions
- if isinstance(self.content_func, Slot):
- inner_slot = self.content_func
- self.content_func = inner_slot.content_func
+ if self.content_func is None:
+ self.contents, new_nodelist, self.content_func = self._resolve_contents(self.contents)
+ if self.nodelist is None:
+ self.nodelist = new_nodelist
+
+ if not callable(self.content_func):
+ raise ValueError(f"Slot 'content_func' must be a callable, got: {self.content_func}")
# Allow to treat the instances as functions
- def __call__(self, ctx: Context, slot_data: TSlotData, slot_ref: "SlotRef") -> SlotResult:
- return self.content_func(ctx, slot_data, slot_ref)
+ def __call__(
+ self,
+ data: Optional[TSlotData] = None,
+ fallback: Optional[Union[str, "SlotFallback"]] = None,
+ context: Optional[Context] = None,
+ ) -> SlotResult:
+ slot_ctx: SlotContext = SlotContext(context=context, data=data or {}, fallback=fallback)
+ result = self.content_func(slot_ctx)
+ return conditional_escape(result)
# Make Django pass the instances of this class within the templates without calling
# the instances as a function.
@property
def do_not_call_in_templates(self) -> bool:
+ """
+ Django special property to prevent calling the instance as a function
+ inside Django templates.
+ """
return True
+ def __repr__(self) -> str:
+ comp_name = f"'{self.component_name}'" if self.component_name else None
+ slot_name = f"'{self.slot_name}'" if self.slot_name else None
+ return f"<{self.__class__.__name__} component_name={comp_name} slot_name={slot_name}>"
+
+ def _resolve_contents(self, contents: Any) -> Tuple[Any, NodeList, SlotFunc[TSlotData]]:
+ # Case: Content is a string / scalar, so we can use `TextNode` to render it.
+ if not callable(contents):
+ contents = str(contents) if not isinstance(contents, (str, SafeString)) else contents
+ contents = conditional_escape(contents)
+ slot = _nodelist_to_slot(
+ component_name=self.component_name or "",
+ slot_name=self.slot_name,
+ nodelist=NodeList([TextNode(contents)]),
+ contents=contents,
+ data_var=None,
+ fallback_var=None,
+ )
+ return slot.contents, slot.nodelist, slot.content_func
+
+ # Otherwise, we're dealing with a function.
+ return contents, None, contents
+
+
+# NOTE: This must be defined here, so we don't have any forward references
+# otherwise Pydantic has problem resolving the types.
+SlotInput = Union[SlotResult, SlotFunc[TSlotData], Slot[TSlotData]]
+"""
+Type representing all forms in which slot content can be passed to a component.
+
+When rendering a component with [`Component.render()`](../api#django_components.Component.render)
+or [`Component.render_to_response()`](../api#django_components.Component.render_to_response),
+the slots may be given a strings, functions, or [`Slot`](../api#django_components.Slot) instances.
+This type describes that union.
+
+Use this type when typing the slots in your component.
+
+`SlotInput` accepts an optional type parameter to specify the data dictionary that will be passed to the
+slot content function.
+
+**Example:**
+
+```python
+from typing import NamedTuple
+from typing_extensions import TypedDict
+from django_components import Component, SlotInput
+
+class TableFooterSlotData(TypedDict):
+ page_number: int
+
+class Table(Component):
+ class Slots(NamedTuple):
+ header: SlotInput
+ footer: SlotInput[TableFooterSlotData]
+
+ template = "
{% slot 'footer' %}
"
+
+html = Table.render(
+ slots={
+ # As a string
+ "header": "Hello, World!",
+
+ # Safe string
+ "header": mark_safe(""),
+
+ # Function
+ "footer": lambda ctx: f"Page: {ctx.data['page_number']}!",
+
+ # Slot instance
+ "footer": Slot(lambda ctx: f"Page: {ctx.data['page_number']}!"),
+
+ # None (Same as no slot)
+ "header": None,
+ },
+)
+```
+"""
+# TODO_V1 - REMOVE, superseded by SlotInput
+SlotContent = SlotInput[TSlotData]
+"""
+DEPRECATED: Use [`SlotInput`](../api#django_components.SlotInput) instead. Will be removed in v1.
+"""
+
# Internal type aliases
SlotName = str
-@dataclass(frozen=True)
-class SlotFill(Generic[TSlotData]):
+class SlotFallback:
"""
- SlotFill describes what WILL be rendered.
+ The content between the `{% slot %}..{% endslot %}` tags is the *fallback* content that
+ will be rendered if no fill is given for the slot.
- The fill may be provided by the user from the outside (`is_filled=True`),
- or it may be the default content of the slot (`is_filled=False`).
- """
+ ```django
+ {% slot "name" %}
+ Hello, my name is {{ name }}
+ {% endslot %}
+ ```
- name: str
- """Name of the slot."""
- is_filled: bool
- slot: Slot[TSlotData]
+ Because the fallback is defined as a piece of the template
+ ([`NodeList`](https://github.com/django/django/blob/ddb85294159185c5bd5cae34c9ef735ff8409bfe/django/template/base.py#L1017)),
+ we want to lazily render it only when needed.
+ `SlotFallback` type allows to pass around the slot fallback as a variable.
-class SlotRef:
- """
- SlotRef allows to treat a slot as a variable. The slot is rendered only once
- the instance is coerced to string.
+ To force the fallback to render, coerce it to string to trigger the `__str__()` method.
- This is used to access slots as variables inside the templates. When a SlotRef
- is rendered in the template with `{{ my_lazy_slot }}`, it will output the contents
- of the slot.
- """
+ **Example:**
+
+ ```py
+ def slot_function(self, ctx: SlotContext):
+ return f"Hello, {ctx.fallback}!"
+ ```
+ """ # noqa: E501
def __init__(self, slot: "SlotNode", context: Context):
self._slot = slot
self._context = context
- # Render the slot when the template coerces SlotRef to string
+ # Render the slot when the template coerces SlotFallback to string
def __str__(self) -> str:
return mark_safe(self._slot.nodelist.render(self._context))
+# TODO_v1 - REMOVE - superseded by SlotFallback
+SlotRef = SlotFallback
+"""
+DEPRECATED: Use [`SlotFallback`](../api#django_components.SlotFallback) instead. Will be removed in v1.
+"""
+
+
+name_escape_re = re.compile(r"[^\w]")
+
+
+# TODO_v1 - Remove, superseded by `Component.slots` and `component_vars.slots`
class SlotIsFilled(dict):
"""
Dictionary that returns `True` if the slot is filled (key is found), `False` otherwise.
"""
def __init__(self, fills: Dict, *args: Any, **kwargs: Any) -> None:
- escaped_fill_names = {_escape_slot_name(fill_name): True for fill_name in fills.keys()}
+ escaped_fill_names = {self._escape_slot_name(fill_name): True for fill_name in fills.keys()}
super().__init__(escaped_fill_names, *args, **kwargs)
def __missing__(self, key: Any) -> bool:
return False
+ def _escape_slot_name(self, name: str) -> str:
+ """
+ Users may define slots with names which are invalid identifiers like 'my slot'.
+ But these cannot be used as keys in the template context, e.g. `{{ component_vars.is_filled.'my slot' }}`.
+ So as workaround, we instead use these escaped names which are valid identifiers.
-@dataclass
-class ComponentSlotContext:
- component_name: str
- template_name: str
- is_dynamic_component: bool
- default_slot: Optional[str]
- fills: Dict[SlotName, Slot]
+ So e.g. `my slot` should be escaped as `my_slot`.
+ """
+ # NOTE: Do a simple substitution where we replace all non-identifier characters with `_`.
+ # Identifiers consist of alphanum (a-zA-Z0-9) and underscores.
+ # We don't check if these escaped names conflict with other existing slots in the template,
+ # we leave this obligation to the user.
+ escaped_name = name_escape_re.sub("_", name)
+ return escaped_name
class SlotNode(BaseNode):
- """Node corresponding to `{% slot %}`"""
+ """
+ [`{% slot %}`](../template_tags#slot) tag marks a place inside a component where content can be inserted
+ from outside.
- def __init__(
- self,
- nodelist: NodeList,
- trace_id: str,
- node_id: Optional[str] = None,
- kwargs: Optional[RuntimeKwargs] = None,
- is_required: bool = False,
- is_default: bool = False,
- ):
- super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
+ [Learn more](../../concepts/fundamentals/slots) about using slots.
- self.is_required = is_required
- self.is_default = is_default
- self.trace_id = trace_id
+ This is similar to slots as seen in
+ [Web components](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot),
+ [Vue](https://vuejs.org/guide/components/slots.html)
+ or [React's `children`](https://react.dev/learn/passing-props-to-a-component#passing-jsx-as-children).
- @property
- def active_flags(self) -> List[str]:
- flags = []
- if self.is_required:
- flags.append("required")
- if self.is_default:
- flags.append("default")
- return flags
+ **Args:**
- def __repr__(self) -> str:
- return f""
+ - `name` (str, required): Registered name of the component to render
+ - `default`: Optional flag. If there is a default slot, you can pass the component slot content
+ without using the [`{% fill %}`](../template_tags#fill) tag. See
+ [Default slot](../../concepts/fundamentals/slots#default-slot)
+ - `required`: Optional flag. Will raise an error if a slot is required but not given.
+ - `**kwargs`: Any extra kwargs will be passed as the slot data.
+
+ **Example:**
+
+ ```djc_py
+ @register("child")
+ class Child(Component):
+ template = \"\"\"
+
+ {% slot "content" default %}
+ This is shown if not overriden!
+ {% endslot %}
+
+ \"\"\"
+ ```
+
+ ### Slot data
+
+ Any extra kwargs will be considered as slot data, and will be accessible
+ in the [`{% fill %}`](../template_tags#fill) tag via fill's `data` kwarg:
+
+ Read more about [Slot data](../../concepts/fundamentals/slots#slot-data).
+
+ ```djc_py
+ @register("child")
+ class Child(Component):
+ template = \"\"\"
+
+ {# Passing data to the slot #}
+ {% slot "content" user=user %}
+ This is shown if not overriden!
+ {% endslot %}
+
+ \"\"\"
+ ```
+
+ ```djc_py
+ @register("parent")
+ class Parent(Component):
+ template = \"\"\"
+ {# Parent can access the slot data #}
+ {% component "child" %}
+ {% fill "content" data="data" %}
+
+ {{ data.user }}
+
+ {% endfill %}
+ {% endcomponent %}
+ \"\"\"
+ ```
+
+ ### Slot fallback
+
+ The content between the `{% slot %}..{% endslot %}` tags is the fallback content that
+ will be rendered if no fill is given for the slot.
+
+ This fallback content can then be accessed from within the [`{% fill %}`](../template_tags#fill) tag
+ using the fill's `fallback` kwarg.
+ This is useful if you need to wrap / prepend / append the original slot's content.
+
+ ```djc_py
+ @register("child")
+ class Child(Component):
+ template = \"\"\"
+
+ {% slot "content" %}
+ This is fallback content!
+ {% endslot %}
+
+ \"\"\"
+ ```
+
+ ```djc_py
+ @register("parent")
+ class Parent(Component):
+ template = \"\"\"
+ {# Parent can access the slot's fallback content #}
+ {% component "child" %}
+ {% fill "content" fallback="fallback" %}
+ {{ fallback }}
+ {% endfill %}
+ {% endcomponent %}
+ \"\"\"
+ ```
+ """
+
+ tag = "slot"
+ end_tag = "endslot"
+ allowed_flags = [SLOT_DEFAULT_FLAG, SLOT_REQUIRED_FLAG]
# NOTE:
# In the current implementation, the slots are resolved only at the render time.
@@ -200,27 +662,49 @@ class SlotNode(BaseNode):
# for unfilled slots (rendered slots WILL raise an error if the fill is missing).
# 2. User may provide extra fills, but these may belong to slots we haven't
# encountered in this render run. So we CANNOT say which ones are extra.
- def render(self, context: Context) -> SafeString:
- trace_msg("RENDR", "SLOT", self.trace_id, self.node_id)
-
+ def render(self, context: Context, name: str, **kwargs: Any) -> SafeString:
# Do not render `{% slot %}` tags within the `{% component %} .. {% endcomponent %}` tags
# at the fill discovery stage (when we render the component's body to determine if the body
# is a default slot, or contains named slots).
if _is_extracting_fill(context):
return ""
- if _COMPONENT_SLOT_CTX_CONTEXT_KEY not in context or not context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]:
+ if _COMPONENT_CONTEXT_KEY not in context or not context[_COMPONENT_CONTEXT_KEY]:
raise TemplateSyntaxError(
- "Encountered a SlotNode outside of a ComponentNode context. "
+ "Encountered a SlotNode outside of a Component context. "
"Make sure that all {% slot %} tags are nested within {% component %} tags.\n"
f"SlotNode: {self.__repr__()}"
)
- component_ctx: ComponentSlotContext = context[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
- slot_name, kwargs = self.resolve_kwargs(context, component_ctx.component_name)
+ # Component info
+ component_id: str = context[_COMPONENT_CONTEXT_KEY]
+ component_ctx = component_context_cache[component_id]
+ component = component_ctx.component
+ component_name = component.name
+ component_path = component_ctx.component_path
+ is_dynamic_component = getattr(component, "_is_dynamic_component", False)
+ # NOTE: Use `ComponentContext.outer_context`, and NOT `Component.outer_context`.
+ # The first is a SNAPSHOT of the outer context.
+ outer_context = component_ctx.outer_context
+
+ # Slot info
+ slot_fills = component.raw_slots
+ slot_name = name
+ is_default = self.flags[SLOT_DEFAULT_FLAG]
+ is_required = self.flags[SLOT_REQUIRED_FLAG]
+
+ trace_component_msg(
+ "RENDER_SLOT_START",
+ component_name=component_name,
+ component_id=component_id,
+ slot_name=slot_name,
+ component_path=component_path,
+ slot_fills=slot_fills,
+ extra=f"Available fills: {slot_fills}",
+ )
# Check for errors
- if self.is_default and not component_ctx.is_dynamic_component:
+ if is_default and not is_dynamic_component:
# Allow one slot to be marked as 'default', or multiple slots but with
# the same name. If there is multiple 'default' slots with different names, raise.
default_slot_name = component_ctx.default_slot
@@ -229,7 +713,7 @@ class SlotNode(BaseNode):
"Only one component slot may be marked as 'default', "
f"found '{default_slot_name}' and '{slot_name}'. "
f"To fix, check template '{component_ctx.template_name}' "
- f"of component '{component_ctx.component_name}'."
+ f"of component '{component_name}'."
)
if default_slot_name is None:
@@ -239,40 +723,134 @@ class SlotNode(BaseNode):
# by specifying the fill both by explicit slot name and implicitly as 'default'.
if (
slot_name != DEFAULT_SLOT_KEY
- and component_ctx.fills.get(slot_name, False)
- and component_ctx.fills.get(DEFAULT_SLOT_KEY, False)
+ and slot_fills.get(slot_name, False)
+ and slot_fills.get(DEFAULT_SLOT_KEY, False)
):
raise TemplateSyntaxError(
- f"Slot '{slot_name}' of component '{component_ctx.component_name}' was filled twice: "
+ f"Slot '{slot_name}' of component '{component_name}' was filled twice: "
"once explicitly and once implicitly as 'default'."
)
# If slot is marked as 'default', we use the name 'default' for the fill,
# IF SUCH FILL EXISTS. Otherwise, we use the slot's name.
- if self.is_default and DEFAULT_SLOT_KEY in component_ctx.fills:
+ if is_default and DEFAULT_SLOT_KEY in slot_fills:
fill_name = DEFAULT_SLOT_KEY
else:
fill_name = slot_name
- if fill_name in component_ctx.fills:
- slot_fill_fn = component_ctx.fills[fill_name]
- slot_fill = SlotFill(
- name=slot_name,
- is_filled=True,
- slot=slot_fill_fn,
+ # NOTE: TBH not sure why this happens. But there's an edge case when:
+ # 1. Using the "django" context behavior
+ # 2. AND the slot fill is defined in the root template
+ #
+ # Then `ctx_with_fills.component.raw_slots` does NOT contain any fills (`{% fill %}`). So in this case,
+ # we need to use a different strategy to find the fills Context layer that contains the fills.
+ #
+ # ------------------------------------------------------------------------------------------
+ #
+ # Context:
+ # When we render slot fills, we want to use the context as was OUTSIDE of the component.
+ # E.g. In this example, we want to render `{{ item.name }}` inside the `{% fill %}` tag:
+ #
+ # ```django
+ # {% for item in items %}
+ # {% component "my_component" %}
+ # {% fill "my_slot" %}
+ # {{ item.name }}
+ # {% endfill %}
+ # {% endcomponent %}
+ # {% endfor %}
+ # ```
+ #
+ # In this case, we need to find the context that was used to render the component,
+ # and use the fills from that context.
+ if (
+ component.registry.settings.context_behavior == ContextBehavior.DJANGO
+ and outer_context is None
+ and (slot_name not in slot_fills)
+ ):
+ # When we have nested components with fills, the context layers are added in
+ # the following order:
+ # Page -> SubComponent -> NestedComponent -> ChildComponent
+ #
+ # Then, if ChildComponent defines a `{% slot %}` tag, its `{% fill %}` will be defined
+ # within the context of its parent, NestedComponent. The context is updated as follows:
+ # Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent
+ #
+ # And if, WITHIN THAT `{% fill %}`, there is another `{% slot %}` tag, its `{% fill %}`
+ # will be defined within the context of its parent, SubComponent. The context becomes:
+ # Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent -> SubComponent
+ #
+ # If that top-level `{% fill %}` defines a `{% component %}`, and the component accepts a `{% fill %}`,
+ # we'd go one down inside the component, and then one up outside of it inside the `{% fill %}`.
+ # Page -> SubComponent -> NestedComponent -> ChildComponent -> NestedComponent -> SubComponent ->
+ # -> CompA -> SubComponent
+ #
+ # So, given a context of nested components like this, we need to find which component was parent
+ # of the current component, and use the fills from that component.
+ #
+ # In the Context, the components are identified by their ID, NOT by their name, as in the example above.
+ # So the path is more like this:
+ # a1b2c3 -> ax3c89 -> hui3q2 -> kok92a -> a1b2c3 -> kok92a -> hui3q2 -> d4e5f6 -> hui3q2
+ #
+ # We're at the right-most `hui3q2` (index 8), and we want to find `ax3c89` (index 1).
+ # To achieve that, we first find the left-most `hui3q2` (index 2), and then find the `ax3c89`
+ # in the list of dicts before it (index 1).
+ curr_index = get_index(
+ context.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d and d[_COMPONENT_CONTEXT_KEY] == component_id
)
- else:
- # No fill was supplied, render the slot's default content
- slot_fill = SlotFill(
- name=slot_name,
- is_filled=False,
- slot=_nodelist_to_slot_render_func(
- slot_name=slot_name,
- nodelist=self.nodelist,
- data_var=None,
- default_var=None,
+ parent_index = get_last_index(context.dicts[:curr_index], lambda d: _COMPONENT_CONTEXT_KEY in d)
+
+ # NOTE: There's an edge case when our component `hui3q2` appears at the start of the stack:
+ # hui3q2 -> ax3c89 -> ... -> hui3q2
+ #
+ # Looking left finds nothing. In this case, look for the first component layer to the right.
+ if parent_index is None and curr_index + 1 < len(context.dicts):
+ parent_index = get_index(
+ context.dicts[curr_index + 1 :], lambda d: _COMPONENT_CONTEXT_KEY in d # noqa: E203
+ )
+ if parent_index is not None:
+ parent_index = parent_index + curr_index + 1
+
+ trace_component_msg(
+ "SLOT_PARENT_INDEX",
+ component_name=component_name,
+ component_id=component_id,
+ slot_name=name,
+ component_path=component_path,
+ extra=(
+ f"Parent index: {parent_index}, Current index: {curr_index}, "
+ f"Context stack: {[d.get(_COMPONENT_CONTEXT_KEY) for d in context.dicts]}"
),
)
+ if parent_index is not None:
+ ctx_id_with_fills = context.dicts[parent_index][_COMPONENT_CONTEXT_KEY]
+ ctx_with_fills = component_context_cache[ctx_id_with_fills]
+ slot_fills = ctx_with_fills.component.raw_slots
+
+ # Add trace message when slot_fills are overwritten
+ trace_component_msg(
+ "SLOT_FILLS_OVERWRITTEN",
+ component_name=component_name,
+ component_id=component_id,
+ slot_name=slot_name,
+ component_path=component_path,
+ extra=f"Slot fills overwritten in django mode. New fills: {slot_fills}",
+ )
+
+ if fill_name in slot_fills:
+ slot_is_filled = True
+ slot = slot_fills[fill_name]
+ else:
+ # No fill was supplied, render the slot's fallback content
+ slot_is_filled = False
+ slot = _nodelist_to_slot(
+ component_name=component_name,
+ slot_name=slot_name,
+ nodelist=self.nodelist,
+ contents=self.contents,
+ data_var=None,
+ fallback_var=None,
+ )
# Check: If a slot is marked as 'required', it must be filled.
#
@@ -284,12 +862,12 @@ class SlotNode(BaseNode):
# Note: Finding a good `cutoff` value may require further trial-and-error.
# Higher values make matching stricter. This is probably preferable, as it
# reduces false positives.
- if self.is_required and not slot_fill.is_filled and not component_ctx.is_dynamic_component:
+ if is_required and not slot_is_filled and not is_dynamic_component:
msg = (
f"Slot '{slot_name}' is marked as 'required' (i.e. non-optional), "
f"yet no fill is provided. Check template.'"
)
- fill_names = list(component_ctx.fills.keys())
+ fill_names = list(slot_fills.keys())
if fill_names:
fuzzy_fill_name_matches = difflib.get_close_matches(fill_name, fill_names, n=1, cutoff=0.7)
if fuzzy_fill_name_matches:
@@ -308,8 +886,8 @@ class SlotNode(BaseNode):
# then we will enter an endless loop. E.g.:
# ```django
# {% component "mycomponent" %}
- # {% slot "content" %}
- # {% fill "content" %}
+ # {% slot "content" %} <--,
+ # {% fill "content" %} ---'
# ...
# {% endfill %}
# {% endslot %}
@@ -317,14 +895,18 @@ class SlotNode(BaseNode):
# ```
#
# Hence, even in the "django" mode, we MUST use slots of the context of the parent component.
- all_ctxs = [d for d in context.dicts if _COMPONENT_SLOT_CTX_CONTEXT_KEY in d]
- if len(all_ctxs) > 1:
- second_to_last_ctx = all_ctxs[-2]
- extra_context[_COMPONENT_SLOT_CTX_CONTEXT_KEY] = second_to_last_ctx[_COMPONENT_SLOT_CTX_CONTEXT_KEY]
+ if (
+ component.registry.settings.context_behavior == ContextBehavior.DJANGO
+ and outer_context is not None
+ and _COMPONENT_CONTEXT_KEY in outer_context
+ ):
+ extra_context[_COMPONENT_CONTEXT_KEY] = outer_context[_COMPONENT_CONTEXT_KEY]
+ # This ensures that the ComponentVars API (e.g. `{{ component_vars.is_filled }}`) is accessible in the fill
+ extra_context["component_vars"] = outer_context["component_vars"]
# Irrespective of which context we use ("root" context or the one passed to this
# render function), pass down the keys used by inject/provide feature. This makes it
- # possible to pass the provided values down the slots, e.g.:
+ # possible to pass the provided values down through slots, e.g.:
# {% provide "abc" val=123 %}
# {% slot "content" %}{% endslot %}
# {% endprovide %}
@@ -332,132 +914,306 @@ class SlotNode(BaseNode):
if key.startswith(_INJECT_CONTEXT_KEY_PREFIX):
extra_context[key] = value
- slot_ref = SlotRef(self, context)
+ fallback = SlotFallback(self, context)
# For the user-provided slot fill, we want to use the context of where the slot
# came from (or current context if configured so)
- used_ctx = self._resolve_slot_context(context, slot_fill)
+ used_ctx = self._resolve_slot_context(context, slot_is_filled, component, outer_context)
with used_ctx.update(extra_context):
# Required for compatibility with Django's {% extends %} tag
# This makes sure that the render context used outside of a component
# is the same as the one used inside the slot.
- # See https://github.com/EmilStenstrom/django-components/pull/859
- render_ctx_layer = used_ctx.render_context.dicts[-2] if len(used_ctx.render_context.dicts) > 1 else {}
- with used_ctx.render_context.push(render_ctx_layer):
- # Render slot as a function
- # NOTE: While `{% fill %}` tag has to opt in for the `default` and `data` variables,
- # the render function ALWAYS receives them.
- output = slot_fill.slot(used_ctx, kwargs, slot_ref)
+ # See https://github.com/django-components/django-components/pull/859
+ if len(used_ctx.render_context.dicts) > 1 and "block_context" in used_ctx.render_context.dicts[-2]:
+ render_ctx_layer = used_ctx.render_context.dicts[-2]
+ else:
+ # Otherwise we simply re-use the last layer, so that following logic uses `with` in either case
+ render_ctx_layer = used_ctx.render_context.dicts[-1]
+
+ with used_ctx.render_context.push(render_ctx_layer):
+ with add_slot_to_error_message(component_name, slot_name):
+ # Render slot as a function
+ # NOTE: While `{% fill %}` tag has to opt in for the `fallback` and `data` variables,
+ # the render function ALWAYS receives them.
+ output = slot(data=kwargs, fallback=fallback, context=used_ctx)
+
+ # Allow plugins to post-process the slot's rendered output
+ output = extensions.on_slot_rendered(
+ OnSlotRenderedContext(
+ component=component,
+ component_cls=component.__class__,
+ component_id=component_id,
+ slot=slot,
+ slot_name=slot_name,
+ slot_node=self,
+ slot_is_required=is_required,
+ slot_is_default=is_default,
+ result=output,
+ ),
+ )
+
+ trace_component_msg(
+ "RENDER_SLOT_END",
+ component_name=component_name,
+ component_id=component_id,
+ slot_name=slot_name,
+ component_path=component_path,
+ slot_fills=slot_fills,
+ )
- trace_msg("RENDR", "SLOT", self.trace_id, self.node_id, msg="...Done!")
return output
- def _resolve_slot_context(self, context: Context, slot_fill: "SlotFill") -> Context:
- """Prepare the context used in a slot fill based on the settings."""
- # If slot is NOT filled, we use the slot's default AKA content between
- # the `{% slot %}` tags. These should be evaluated as if the `{% slot %}`
- # tags weren't even there, which means that we use the current context.
- if not slot_fill.is_filled:
- return context
-
- registry: "ComponentRegistry" = context[_REGISTRY_CONTEXT_KEY]
- if registry.settings.context_behavior == ContextBehavior.DJANGO:
- return context
- elif registry.settings.context_behavior == ContextBehavior.ISOLATED:
- return context[_ROOT_CTX_CONTEXT_KEY]
- else:
- raise ValueError(f"Unknown value for context_behavior: '{registry.settings.context_behavior}'")
-
- def resolve_kwargs(
+ def _resolve_slot_context(
self,
context: Context,
- component_name: Optional[str] = None,
- ) -> Tuple[str, Dict[str, Optional[str]]]:
- kwargs = self.kwargs.resolve(context)
- name = kwargs.pop(SLOT_NAME_KWARG, None)
+ slot_is_filled: bool,
+ component: "Component",
+ outer_context: Optional[Context],
+ ) -> Context:
+ """Prepare the context used in a slot fill based on the settings."""
+ # If slot is NOT filled, we use the slot's fallback AKA content between
+ # the `{% slot %}` tags. These should be evaluated as if the `{% slot %}`
+ # tags weren't even there, which means that we use the current context.
+ if not slot_is_filled:
+ return context
- if not name:
- raise RuntimeError(f"Slot tag kwarg 'name' is missing in component {component_name}")
-
- return (name, kwargs)
+ registry_settings = component.registry.settings
+ if registry_settings.context_behavior == ContextBehavior.DJANGO:
+ return context
+ elif registry_settings.context_behavior == ContextBehavior.ISOLATED:
+ return outer_context if outer_context is not None else Context()
+ else:
+ raise ValueError(f"Unknown value for context_behavior: '{registry_settings.context_behavior}'")
class FillNode(BaseNode):
- """Node corresponding to `{% fill %}`"""
+ """
+ Use [`{% fill %}`](../template_tags#fill) tag to insert content into component's
+ [slots](../../concepts/fundamentals/slots).
- def __init__(
+ [`{% fill %}`](../template_tags#fill) tag may be used only within a `{% component %}..{% endcomponent %}` block,
+ and raises a `TemplateSyntaxError` if used outside of a component.
+
+ **Args:**
+
+ - `name` (str, required): Name of the slot to insert this content into. Use `"default"` for
+ the [default slot](../../concepts/fundamentals/slots#default-slot).
+ - `data` (str, optional): This argument allows you to access the data passed to the slot
+ under the specified variable name. See [Slot data](../../concepts/fundamentals/slots#slot-data).
+ - `fallback` (str, optional): This argument allows you to access the original content of the slot
+ under the specified variable name. See [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
+
+ **Example:**
+
+ ```django
+ {% component "my_table" %}
+ {% fill "pagination" %}
+ < 1 | 2 | 3 >
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ ### Access slot fallback
+
+ Use the `fallback` kwarg to access the original content of the slot.
+
+ The `fallback` kwarg defines the name of the variable that will contain the slot's fallback content.
+
+ Read more about [Slot fallback](../../concepts/fundamentals/slots#slot-fallback).
+
+ Component template:
+
+ ```django
+ {# my_table.html #}
+
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ ### Access slot data
+
+ Use the `data` kwarg to access the data passed to the slot.
+
+ The `data` kwarg defines the name of the variable that will contain the slot's data.
+
+ Read more about [Slot data](../../concepts/fundamentals/slots#slot-data).
+
+ Component template:
+
+ ```django
+ {# my_table.html #}
+
+ ```
+
+ Fill:
+
+ ```django
+ {% component "my_table" %}
+ {% fill "pagination" data="slot_data" %}
+ {% for page in slot_data.pages %}
+
+ {{ page.index }}
+
+ {% endfor %}
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ ### Using default slot
+
+ To access slot data and the fallback slot content on the default slot,
+ use [`{% fill %}`](../template_tags#fill) with `name` set to `"default"`:
+
+ ```django
+ {% component "button" %}
+ {% fill name="default" data="slot_data" fallback="slot_fallback" %}
+ You clicked me {{ slot_data.count }} times!
+ {{ slot_fallback }}
+ {% endfill %}
+ {% endcomponent %}
+ ```
+
+ ### Slot fills from Python
+
+ You can pass a slot fill from Python to a component by setting the `body` kwarg
+ on the [`{% fill %}`](../template_tags#fill) tag.
+
+ First pass a [`Slot`](../api#django_components.Slot) instance to the template
+ with the [`get_template_data()`](../api#django_components.Component.get_template_data)
+ method:
+
+ ```python
+ from django_components import component, Slot
+
+ class Table(Component):
+ def get_template_data(self, args, kwargs, slots, context):
+ return {
+ "my_slot": Slot(lambda ctx: "Hello, world!"),
+ }
+ ```
+
+ Then pass the slot to the [`{% fill %}`](../template_tags#fill) tag:
+
+ ```django
+ {% component "table" %}
+ {% fill "pagination" body=my_slot / %}
+ {% endcomponent %}
+ ```
+
+ !!! warning
+
+ If you define both the `body` kwarg and the [`{% fill %}`](../template_tags#fill) tag's body,
+ an error will be raised.
+
+ ```django
+ {% component "table" %}
+ {% fill "pagination" body=my_slot %}
+ ...
+ {% endfill %}
+ {% endcomponent %}
+ ```
+ """
+
+ tag = "fill"
+ end_tag = "endfill"
+ allowed_flags = []
+
+ def render(
self,
- nodelist: NodeList,
- kwargs: RuntimeKwargs,
- trace_id: str,
- node_id: Optional[str] = None,
- ):
- super().__init__(nodelist=nodelist, args=None, kwargs=kwargs, node_id=node_id)
+ context: Context,
+ name: str,
+ *,
+ data: Optional[str] = None,
+ fallback: Optional[str] = None,
+ body: Optional[SlotInput] = None,
+ # TODO_V1: Use `fallback` kwarg instead of `default`
+ default: Optional[str] = None,
+ ) -> str:
+ # TODO_V1: Use `fallback` kwarg instead of `default`
+ if fallback is not None and default is not None:
+ raise TemplateSyntaxError(
+ f"Fill tag received both 'default' and '{FILL_FALLBACK_KWARG}' kwargs. "
+ f"Use '{FILL_FALLBACK_KWARG}' instead."
+ )
+ elif fallback is None and default is not None:
+ fallback = default
- self.trace_id = trace_id
-
- def render(self, context: Context) -> str:
- if _is_extracting_fill(context):
- self._extract_fill(context)
- return ""
-
- raise TemplateSyntaxError(
- "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
- "Make sure that the {% fill %} tags are nested within {% component %} tags."
- )
-
- def __repr__(self) -> str:
- return f"<{self.__class__.__name__} ID: {self.node_id}. Contents: {repr(self.nodelist)}.>"
-
- def resolve_kwargs(self, context: Context) -> "FillWithData":
- kwargs = self.kwargs.resolve(context)
-
- name = self._process_kwarg(kwargs, SLOT_NAME_KWARG, identifier=False)
- default_var = self._process_kwarg(kwargs, SLOT_DEFAULT_KWARG)
- data_var = self._process_kwarg(kwargs, SLOT_DATA_KWARG)
+ if not _is_extracting_fill(context):
+ raise TemplateSyntaxError(
+ "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
+ "Make sure that the {% fill %} tags are nested within {% component %} tags."
+ )
+ # Validate inputs
if not isinstance(name, str):
raise TemplateSyntaxError(f"Fill tag '{SLOT_NAME_KWARG}' kwarg must resolve to a string, got {name}")
- if data_var is not None and not isinstance(data_var, str):
- raise TemplateSyntaxError(f"Fill tag '{SLOT_DATA_KWARG}' kwarg must resolve to a string, got {data_var}")
+ if data is not None:
+ if not isinstance(data, str):
+ raise TemplateSyntaxError(f"Fill tag '{FILL_DATA_KWARG}' kwarg must resolve to a string, got {data}")
+ if not is_identifier(data):
+ raise RuntimeError(
+ f"Fill tag kwarg '{FILL_DATA_KWARG}' does not resolve to a valid Python identifier, got '{data}'"
+ )
- if default_var is not None and not isinstance(default_var, str):
- raise TemplateSyntaxError(
- f"Fill tag '{SLOT_DEFAULT_KWARG}' kwarg must resolve to a string, got {default_var}"
- )
+ if fallback is not None:
+ if not isinstance(fallback, str):
+ raise TemplateSyntaxError(
+ f"Fill tag '{FILL_FALLBACK_KWARG}' kwarg must resolve to a string, got {fallback}"
+ )
+ if not is_identifier(fallback):
+ raise RuntimeError(
+ f"Fill tag kwarg '{FILL_FALLBACK_KWARG}' does not resolve to a valid Python identifier,"
+ f" got '{fallback}'"
+ )
- # data and default cannot be bound to the same variable
- if data_var and default_var and data_var == default_var:
+ # data and fallback cannot be bound to the same variable
+ if data and fallback and data == fallback:
raise RuntimeError(
- f"Fill '{name}' received the same string for slot default ({SLOT_DEFAULT_KWARG}=...)"
- f" and slot data ({SLOT_DATA_KWARG}=...)"
+ f"Fill '{name}' received the same string for slot fallback ({FILL_FALLBACK_KWARG}=...)"
+ f" and slot data ({FILL_DATA_KWARG}=...)"
)
- return FillWithData(
+ if body is not None and self.contents:
+ raise TemplateSyntaxError(
+ f"Fill '{name}' received content both through '{FILL_BODY_KWARG}' kwarg and '{{% fill %}}' body. "
+ f"Use only one method."
+ )
+
+ fill_data = FillWithData(
fill=self,
name=name,
- default_var=default_var,
- data_var=data_var,
+ fallback_var=fallback,
+ data_var=data,
extra_context={},
+ body=body,
)
- def _process_kwarg(
- self,
- kwargs: Dict[str, Any],
- key: str,
- identifier: bool = True,
- ) -> Optional[Any]:
- if key not in kwargs:
- return None
+ self._extract_fill(context, fill_data)
- value = kwargs[key]
- if identifier and not is_identifier(value):
- raise RuntimeError(f"Fill tag kwarg '{key}' does not resolve to a valid Python identifier, got '{value}'")
+ return ""
- return value
-
- def _extract_fill(self, context: Context) -> None:
+ def _extract_fill(self, context: Context, data: "FillWithData") -> None:
# `FILL_GEN_CONTEXT_KEY` is only ever set when we are rendering content between the
# `{% component %}...{% endcomponent %}` tags. This is done in order to collect all fill tags.
# E.g.
@@ -466,14 +1222,13 @@ class FillNode(BaseNode):
# ...
# {% endfill %}
# {% endfor %}
- collected_fills: List[FillWithData] = context.get(FILL_GEN_CONTEXT_KEY, None)
+ captured_fills: Optional[List[FillWithData]] = context.get(FILL_GEN_CONTEXT_KEY, None)
- if collected_fills is None:
- return
-
- # NOTE: It's important that we use the context given to the fill tag, so it accounts
- # for any variables set via e.g. for-loops.
- data = self.resolve_kwargs(context)
+ if captured_fills is None:
+ raise RuntimeError(
+ "FillNode.render() (AKA {% fill ... %} block) cannot be rendered outside of a Component context. "
+ "Make sure that the {% fill %} tags are nested within {% component %} tags."
+ )
# To allow using variables which were defined within the template and to which
# the `{% fill %}` tag has access, we need to capture those variables too.
@@ -493,7 +1248,8 @@ class FillNode(BaseNode):
# `{% component %} ... {% endcomponent %}`. Hence we search for the last
# index of `FILL_GEN_CONTEXT_KEY`.
index_of_new_layers = get_last_index(context.dicts, lambda d: FILL_GEN_CONTEXT_KEY in d)
- for dict_layer in context.dicts[index_of_new_layers:]:
+ context_dicts: List[Dict[str, Any]] = context.dicts
+ for dict_layer in context_dicts[index_of_new_layers:]:
for key, value in dict_layer.items():
if not key.startswith("_"):
data.extra_context[key] = value
@@ -528,25 +1284,61 @@ class FillNode(BaseNode):
layer["forloop"] = layer["forloop"].copy()
data.extra_context.update(layer)
- collected_fills.append(data)
+ captured_fills.append(data)
#######################################
# EXTRACTING {% fill %} FROM TEMPLATES
+# (internal)
#######################################
class FillWithData(NamedTuple):
fill: FillNode
name: str
- default_var: Optional[str]
+ """Name of the slot to be filled, as set on the `{% fill %}` tag."""
+ body: Optional[SlotInput]
+ """
+ Slot fill as set by the `body` kwarg on the `{% fill %}` tag.
+
+ E.g.
+ ```django
+ {% component "mycomponent" %}
+ {% fill "footer" body=my_slot / %}
+ {% endcomponent %}
+ ```
+ """
+ fallback_var: Optional[str]
+ """Name of the FALLBACK variable, as set on the `{% fill %}` tag."""
data_var: Optional[str]
+ """Name of the DATA variable, as set on the `{% fill %}` tag."""
extra_context: Dict[str, Any]
+ """
+ Extra context variables that will be available inside the `{% fill %}` tag.
+
+ For example, if the `{% fill %}` tags are nested within `{% with %}` or `{% for %}` tags,
+ then the variables defined within those tags will be available inside the `{% fill %}` tags:
+
+ ```django
+ {% component "mycomponent" %}
+ {% with extra_var="extra_value" %}
+ {% fill "my_fill" %}
+ {{ extra_var }}
+ {% endfill %}
+ {% endwith %}
+ {% for item in items %}
+ {% fill "my_fill" %}
+ {{ item }}
+ {% endfill %}
+ {% endfor %}
+ {% endcomponent %}
+ ```
+ """
def resolve_fills(
context: Context,
- nodelist: NodeList,
+ component_node: "ComponentNode",
component_name: str,
) -> Dict[SlotName, Slot]:
"""
@@ -597,6 +1389,9 @@ def resolve_fills(
"""
slots: Dict[SlotName, Slot] = {}
+ nodelist = component_node.nodelist
+ contents = component_node.contents
+
if not nodelist:
return slots
@@ -614,11 +1409,14 @@ def resolve_fills(
)
if not nodelist_is_empty:
- slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot_render_func(
- DEFAULT_SLOT_KEY,
- nodelist,
+ slots[DEFAULT_SLOT_KEY] = _nodelist_to_slot(
+ component_name=component_name,
+ slot_name=None, # Will be populated later
+ nodelist=nodelist,
+ contents=contents,
data_var=None,
- default_var=None,
+ fallback_var=None,
+ fill_node=component_node,
)
# The content has fills
@@ -626,13 +1424,30 @@ def resolve_fills(
# NOTE: If slot fills are explicitly defined, we use them even if they are empty (or only whitespace).
# This is different from the default slot, where we ignore empty content.
for fill in maybe_fills:
- slots[fill.name] = _nodelist_to_slot_render_func(
- slot_name=fill.name,
- nodelist=fill.fill.nodelist,
- data_var=fill.data_var,
- default_var=fill.default_var,
- extra_context=fill.extra_context,
- )
+ # Case: Slot fill was explicitly defined as `{% fill body=... / %}`
+ if fill.body is not None:
+ # Set `Slot.fill_node` so the slot fill behaves the same as if it was defined inside
+ # a `{% fill %}` tag.
+ # This for example allows CSS scoping to work even on slots that are defined
+ # as `{% fill ... body=... / %}`
+ if isinstance(fill.body, Slot):
+ # Make a copy of the Slot instance and set its `fill_node`.
+ slot_fill = dataclass_replace(fill.body, fill_node=fill.fill)
+ else:
+ slot_fill = Slot(fill.body, fill_node=fill.fill)
+ # Case: Slot fill was defined as the body of `{% fill / %}...{% endfill %}`
+ else:
+ slot_fill = _nodelist_to_slot(
+ component_name=component_name,
+ slot_name=fill.name,
+ nodelist=fill.fill.nodelist,
+ contents=fill.fill.contents,
+ data_var=fill.data_var,
+ fallback_var=fill.fallback_var,
+ extra_context=fill.extra_context,
+ fill_node=fill.fill,
+ )
+ slots[fill.name] = slot_fill
return slots
@@ -678,31 +1493,81 @@ def _extract_fill_content(
#######################################
-name_escape_re = re.compile(r"[^\w]")
+def normalize_slot_fills(
+ fills: Mapping[SlotName, SlotInput],
+ component_name: Optional[str] = None,
+) -> Dict[SlotName, Slot]:
+ norm_fills = {}
+
+ # NOTE: `copy_slot` 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 copy_slot(content: Union[SlotFunc, Slot], slot_name: str) -> Slot:
+ # Case: Already Slot and names assigned, so nothing to do.
+ if isinstance(content, Slot) and content.slot_name and content.component_name:
+ return content
+
+ # Otherwise, we create a new instance of Slot, whether we've been given Slot or not,
+ # so we can assign metadata to our internal copies without affecting the original.
+ if not isinstance(content, Slot):
+ content_func = content
+ else:
+ content_func = content.content_func
+
+ # Populate potentially missing fields so we can trace the component and slot
+ if isinstance(content, Slot):
+ used_component_name = content.component_name or component_name
+ used_slot_name = content.slot_name or slot_name
+ used_nodelist = content.nodelist
+ used_contents = content.contents if content.contents is not None else content_func
+ used_fill_node = content.fill_node
+ used_extra = content.extra.copy()
+ else:
+ used_component_name = component_name
+ used_slot_name = slot_name
+ used_nodelist = None
+ used_contents = content_func
+ used_fill_node = None
+ used_extra = {}
+
+ slot = Slot(
+ contents=used_contents,
+ content_func=content_func,
+ component_name=used_component_name,
+ slot_name=used_slot_name,
+ nodelist=used_nodelist,
+ fill_node=used_fill_node,
+ extra=used_extra,
+ )
+
+ return slot
+
+ for slot_name, content in fills.items():
+ # Case: No content, so nothing to do.
+ if content is None:
+ continue
+ # Case: Content is a string / non-slot / non-callable
+ elif not callable(content):
+ # NOTE: `Slot.content_func` and `Slot.nodelist` will be set in `Slot.__init__()`
+ slot: Slot = Slot(contents=content, component_name=component_name, slot_name=slot_name)
+ # Case: Content is a callable, so either a plain function or a `Slot` instance.
+ else:
+ slot = copy_slot(content, slot_name)
+
+ norm_fills[slot_name] = slot
+
+ return norm_fills
-def _escape_slot_name(name: str) -> str:
- """
- Users may define slots with names which are invalid identifiers like 'my slot'.
- But these cannot be used as keys in the template context, e.g. `{{ component_vars.is_filled.'my slot' }}`.
- So as workaround, we instead use these escaped names which are valid identifiers.
-
- So e.g. `my slot` should be escaped as `my_slot`.
- """
- # NOTE: Do a simple substitution where we replace all non-identifier characters with `_`.
- # Identifiers consist of alphanum (a-zA-Z0-9) and underscores.
- # We don't check if these escaped names conflict with other existing slots in the template,
- # we leave this obligation to the user.
- escaped_name = name_escape_re.sub("_", name)
- return escaped_name
-
-
-def _nodelist_to_slot_render_func(
- slot_name: str,
+def _nodelist_to_slot(
+ component_name: str,
+ slot_name: Optional[str],
nodelist: NodeList,
+ contents: Optional[str] = None,
data_var: Optional[str] = None,
- default_var: Optional[str] = None,
+ fallback_var: Optional[str] = None,
extra_context: Optional[Dict[str, Any]] = None,
+ fill_node: Optional[Union[FillNode, "ComponentNode"]] = None,
+ extra: Optional[Dict[str, Any]] = None,
) -> Slot:
if data_var:
if not data_var.isidentifier():
@@ -710,23 +1575,30 @@ def _nodelist_to_slot_render_func(
f"Slot data alias in fill '{slot_name}' must be a valid identifier. Got '{data_var}'"
)
- if default_var:
- if not default_var.isidentifier():
+ if fallback_var:
+ if not fallback_var.isidentifier():
raise TemplateSyntaxError(
- f"Slot default alias in fill '{slot_name}' must be a valid identifier. Got '{default_var}'"
+ f"Slot fallback alias in fill '{slot_name}' must be a valid identifier. Got '{fallback_var}'"
)
- def render_func(ctx: Context, slot_data: Dict[str, Any], slot_ref: SlotRef) -> SlotResult:
+ # We use Template.render() to render the nodelist, so that Django correctly sets up
+ # and binds the context.
+ template = Template("")
+ template.nodelist = nodelist
+
+ def render_func(ctx: SlotContext) -> SlotResult:
+ context = ctx.context or Context()
+
# Expose the kwargs that were passed to the `{% slot %}` tag. These kwargs
# are made available through a variable name that was set on the `{% fill %}`
# tag.
if data_var:
- ctx[data_var] = slot_data
+ context[data_var] = ctx.data
- # If slot fill is using `{% fill "myslot" default="abc" %}`, then set the "abc" to
- # the context, so users can refer to the default slot from within the fill content.
- if default_var:
- ctx[default_var] = slot_ref
+ # If slot fill is using `{% fill "myslot" fallback="abc" %}`, then set the "abc" to
+ # the context, so users can refer to the fallback slot from within the fill content.
+ if fallback_var:
+ context[fallback_var] = ctx.fallback
# NOTE: If a `{% fill %}` tag inside a `{% component %}` tag is inside a forloop,
# the `extra_context` contains the forloop variables. We want to make these available
@@ -742,34 +1614,55 @@ def _nodelist_to_slot_render_func(
#
# Thus, when we get here and `extra_context` is not None, it means that the component
# is being rendered from within the template. And so we know that we're inside `Component._render()`.
- # And that means that the context MUST contain our internal context keys like `_ROOT_CTX_CONTEXT_KEY`.
+ # And that means that the context MUST contain our internal context keys like `_COMPONENT_CONTEXT_KEY`.
#
- # And so we want to put the `extra_context` into the same layer that contains `_ROOT_CTX_CONTEXT_KEY`.
+ # And so we want to put the `extra_context` into the same layer that contains `_COMPONENT_CONTEXT_KEY`.
#
- # HOWEVER, the layer with `_ROOT_CTX_CONTEXT_KEY` also contains user-defined data from `get_context_data()`.
- # Data from `get_context_data()` should take precedence over `extra_context`. So we have to insert
+ # HOWEVER, the layer with `_COMPONENT_CONTEXT_KEY` also contains user-defined data from `get_template_data()`.
+ # Data from `get_template_data()` should take precedence over `extra_context`. So we have to insert
# the forloop variables BEFORE that.
- index_of_last_component_layer = get_last_index(ctx.dicts, lambda d: _ROOT_CTX_CONTEXT_KEY in d)
+ index_of_last_component_layer = get_last_index(context.dicts, lambda d: _COMPONENT_CONTEXT_KEY in d)
if index_of_last_component_layer is None:
index_of_last_component_layer = 0
- # TODO: Currently there's one more layer before the `_ROOT_CTX_CONTEXT_KEY` layer, which is
+ # TODO_V1: Currently there's one more layer before the `_COMPONENT_CONTEXT_KEY` layer, which is
# pushed in `_prepare_template()` in `component.py`.
# That layer should be removed when `Component.get_template()` is removed, after which
# the following line can be removed.
index_of_last_component_layer -= 1
- # Insert the `extra_context` into the correct layer of the context stack
- ctx.dicts.insert(index_of_last_component_layer, extra_context or {})
+ # Insert the `extra_context` layer BEFORE the layer that defines the variables from get_template_data.
+ # Thus, get_template_data will overshadow these on conflict.
+ context.dicts.insert(index_of_last_component_layer, extra_context or {})
- rendered = nodelist.render(ctx)
+ trace_component_msg("RENDER_NODELIST", component_name, component_id=None, slot_name=slot_name)
+
+ # NOTE 1: We wrap the slot nodelist in Template. However, we also override Django's `Template.render()`
+ # to call `render_dependencies()` on the results. So we need to set the strategy to `ignore`
+ # so that the dependencies are processed only once the whole component tree is rendered.
+ # NOTE 2: We also set `_DJC_COMPONENT_IS_NESTED` to `True` so that the template can access
+ # current RenderContext layer.
+ with context.push({"DJC_DEPS_STRATEGY": "ignore", COMPONENT_IS_NESTED_KEY: True}):
+ rendered = template.render(context)
# After the rendering is done, remove the `extra_context` from the context stack
- ctx.dicts.pop(index_of_last_component_layer)
+ context.dicts.pop(index_of_last_component_layer)
return rendered
- return Slot(content_func=cast(SlotFunc, render_func))
+ return Slot(
+ content_func=cast(SlotFunc, render_func),
+ component_name=component_name,
+ slot_name=slot_name,
+ nodelist=nodelist,
+ # The `contents` param passed to this function may be `None`, because it's taken from
+ # `BaseNode.contents` which is `None` for self-closing tags like `{% fill "footer" / %}`.
+ # But `Slot(contents=None)` would result in `Slot.contents` being the render function.
+ # So we need to special-case this.
+ contents=default(contents, ""),
+ fill_node=default(fill_node, None),
+ extra=default(extra, {}),
+ )
def _is_extracting_fill(context: Context) -> bool:
diff --git a/src/django_components/static/django_components/django_components.min.js b/src/django_components/static/django_components/django_components.min.js
index 2b944672..89b7f3cf 100644
--- a/src/django_components/static/django_components/django_components.min.js
+++ b/src/django_components/static/django_components/django_components.min.js
@@ -1 +1 @@
-(()=>{var x=o=>new DOMParser().parseFromString(o,"text/html").documentElement.textContent,E=Array.isArray,m=o=>typeof o=="function",H=o=>o!==null&&typeof o=="object",S=o=>(H(o)||m(o))&&m(o.then)&&m(o.catch);function N(o,i){try{return i?o.apply(null,i):o()}catch(s){L(s)}}function g(o,i){if(m(o)){let s=N(o,i);return s&&S(s)&&s.catch(c=>{L(c)}),[s]}if(E(o)){let s=[];for(let c=0;c{let i=new MutationObserver(s=>{for(let c of s)c.type==="childList"&&c.addedNodes.forEach(p=>{p.nodeName==="SCRIPT"&&p.hasAttribute("data-djc")&&o(p)})});return i.observe(document,{childList:!0,subtree:!0}),i};var y=()=>{let o=new Set,i=new Set,s={},c={},p=t=>{let e=new DOMParser().parseFromString(t,"text/html").querySelector("script");if(!e)throw Error("[Components] Failed to extract
+
+
+
+
+
""", # noqa: E501
)
- self.assertNotIn("override-me", rendered)
+ assert "override-me" not in rendered
def test_tag_aggregate_args(self):
@register("test")
@@ -230,23 +285,29 @@ class HtmlAttrsTests(BaseTestCase):
""" # noqa: E501
- def get_context_data(self, *args, attrs):
- return {"attrs": attrs}
+ def get_template_data(self, args, kwargs, slots, context):
+ return {"attrs": kwargs["attrs"]}
template = Template(self.template_str)
rendered = template.render(Context({"class_var": "padding-top-8"}))
# NOTE: The attrs from self.template_str should be ignored because they are not used.
- self.assertHTMLEqual(
+ assertHTMLEqual(
rendered,
"""
-
+
content
""", # noqa: E501
)
- self.assertNotIn("override-me", rendered)
+ assert "override-me" not in rendered
+ # Note: Because there's both `attrs:class` and `defaults:class`, the `attrs`,
+ # it's as if the template tag call was (ignoring the `class` and `data-id` attrs):
+ #
+ # `{% html_attrs attrs={"class": ...} defaults={"class": ...} attrs %}>content
`
+ #
+ # Which raises, because `attrs` is passed both as positional and as keyword argument.
def test_tag_raises_on_aggregate_and_positional_args_for_attrs(self):
@register("test")
class AttrsComponent(Component):
@@ -257,12 +318,15 @@ class HtmlAttrsTests(BaseTestCase):
""" # noqa: E501
- def get_context_data(self, *args, attrs):
- return {"attrs": attrs}
+ def get_template_data(self, args, kwargs, slots, context):
+ return {"attrs": kwargs["attrs"]}
template = Template(self.template_str)
- with self.assertRaisesMessage(TemplateSyntaxError, "Received argument 'attrs' both as a regular input"):
+ with pytest.raises(
+ TypeError,
+ match=re.escape("Invalid parameters for tag 'html_attrs': got multiple values for argument 'attrs'"),
+ ):
template.render(Context({"class_var": "padding-top-8"}))
def test_tag_raises_on_aggregate_and_positional_args_for_defaults(self):
@@ -270,19 +334,26 @@ class HtmlAttrsTests(BaseTestCase):
class AttrsComponent(Component):
template: types.django_html = """
{% load component_tags %}
-
+
content
""" # noqa: E501
- def get_context_data(self, *args, attrs):
- return {"attrs": attrs}
+ def get_template_data(self, args, kwargs, slots, context):
+ return {"attrs": kwargs["attrs"]}
template = Template(self.template_str)
- with self.assertRaisesMessage(
+ with pytest.raises(
TemplateSyntaxError,
- "Received argument 'defaults' both as a regular input",
+ match=re.escape("Received argument 'defaults' both as a regular input"),
):
template.render(Context({"class_var": "padding-top-8"}))
@@ -296,15 +367,15 @@ class HtmlAttrsTests(BaseTestCase):
+""" # noqa: E501
+
+
+class NavbarData(NamedTuple):
+ attrs: Optional[dict] = None
+
+
+@registry.library.simple_tag(takes_context=True)
+def navbar(context: Context, data: NavbarData):
+ sidebar_toggle_icon_data = IconData(
+ name="bars-3",
+ variant="outline",
+ )
+
+ with context.push(
+ {
+ "sidebar_toggle_icon_data": sidebar_toggle_icon_data,
+ "attrs": data.attrs,
+ }
+ ):
+ return lazy_load_template(navbar_template_str).render(context)
+
+
+#####################################
+# DIALOG
+#####################################
+
+
+def construct_btn_onclick(model: str, btn_on_click: Optional[str]):
+ """
+ We want to allow the component users to define Alpine.js `@click` actions.
+ However, we also need to use `@click` to close the dialog after clicking
+ one of the buttons.
+
+ Hence, this function constructs the '@click' attribute, such that we can do both.
+
+ NOTE: `model` is the name of the Alpine variable used by the dialog.
+ """
+ on_click_cb = f"{model} = false;"
+ if btn_on_click:
+ on_click_cb = f"{btn_on_click}; {on_click_cb}"
+ return mark_safe(on_click_cb)
+
+
+dialog_template_str: types.django_html = """
+ {# Based on https://tailwindui.com/components/application-ui/overlays/modals #}
+
+ {% comment %}
+ NOTE: {{ model }} is the Alpine variable used for opening/closing. The variable name
+ is set dynamically, hence we use Django's double curly braces to refer to it.
+ {% endcomment %}
+
+ {% if slot_activator %}
+ {# This is what opens the modal #}
+
+ {{ slot_activator }}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ slot_prepend }}
+
+
+ {% if slot_title %}
+
+ {{ slot_title }}
+
+ {% endif %}
+
+ {{ slot_content }}
+
+
+ {{ slot_append }}
+
+
+
+ {% if not confirm_hide %}
+ {% button confirm_button_data %}
+ {% endif %}
+
+ {% if not cancel_hide %}
+ {% button cancel_button_data %}
+ {% endif %}
+
+"""
+
+
+class BookmarkData(NamedTuple):
+ bookmark: BookmarkItem
+ js: Optional[dict] = None
+
+
+@registry.library.simple_tag(takes_context=True)
+def bookmark(context: Context, data: BookmarkData):
+ bookmark_icon_data = IconData(
+ name="ellipsis-vertical",
+ variant="outline",
+ color=theme.sidebar_link,
+ svg_attrs={
+ "class": "inline",
+ },
+ text_attrs={
+ "class": "p-0",
+ },
+ attrs={
+ "class": "self-center cursor-pointer",
+ "x-ref": "bookmark_menu",
+ "@click": "onMenuToggle",
+ },
+ )
+
+ with context.push(
+ {
+ "theme": theme,
+ "bookmark": data.bookmark._asdict(),
+ "js": data.js,
+ "bookmark_icon_data": bookmark_icon_data,
+ }
+ ):
+ return lazy_load_template(bookmark_template_str).render(context)
+
+
+#####################################
+# LIST
+#####################################
+
+
+@dataclass(frozen=True)
+class ListItem:
+ """
+ Single menu item used with the `menu` components.
+
+ Menu items can be divided by a horizontal line to indicate that the items
+ belong together. In code, we specify this by wrapping the item(s) as an array.
+ """
+
+ value: Any
+ """Value of the menu item to render."""
+
+ link: Optional[str] = None
+ """
+ If set, the list item will be wrapped in an `` tag pointing to this link.
+ """
+
+ attrs: Optional[dict] = None
+ """Any additional attributes to apply to the list item."""
+
+ meta: Optional[dict] = None
+ """Any additional data to pass along the list item."""
+
+
+list_template_str: types.django_html = """
+
+""" # noqa: E501
+
+
+class TabsImplData(NamedTuple):
+ tabs: List[TabEntry]
+ # Unique name to identify this tabs instance, so we can open/close the tabs
+ # based on the query params.
+ name: Optional[str] = None
+ attrs: Optional[dict] = None
+ header_attrs: Optional[dict] = None
+ content_attrs: Optional[dict] = None
+
+
+@registry.library.simple_tag(takes_context=True)
+def tabs_impl(context: Context, data: TabsImplData):
+ with context.push(
+ {
+ "attrs": data.attrs,
+ "tabs": data.tabs,
+ "header_attrs": data.header_attrs,
+ "content_attrs": data.content_attrs,
+ "tabs_data": {"name": data.name},
+ "theme": theme,
+ }
+ ):
+ return lazy_load_template(tabs_impl_template_str).render(context)
+
+
+class TabsData(NamedTuple):
+ # Unique name to identify this tabs instance, so we can open/close the tabs
+ # based on the query params.
+ name: Optional[str] = None
+ attrs: Optional[dict] = None
+ header_attrs: Optional[dict] = None
+ content_attrs: Optional[dict] = None
+ slot_content: Optional[CallableSlot] = None
+
+
+# This is an "API" component, meaning that it's designed to process
+# user input provided as nested components. But after the input is
+# processed, it delegates to an internal "implementation" component
+# that actually renders the content.
+@registry.library.simple_tag(takes_context=True)
+def tabs(context: Context, data: TabsData):
+ if not data.slot_content:
+ return ""
+
+ ProvidedData = NamedTuple("ProvidedData", [("tabs", List[TabEntry]), ("enabled", bool)])
+ collected_tabs: List[TabEntry] = []
+ provided_data = ProvidedData(tabs=collected_tabs, enabled=True)
+
+ with context.push({"_tabs": provided_data}):
+ data.slot_content.render(context)
+
+ # By the time we get here, all child TabItem components should have been
+ # rendered, and they should've populated the tabs list.
+ return tabs_impl(
+ context,
+ TabsImplData(
+ tabs=collected_tabs,
+ name=data.name,
+ attrs=data.attrs,
+ header_attrs=data.header_attrs,
+ content_attrs=data.content_attrs,
+ ),
+ )
+
+
+class TabItemData(NamedTuple):
+ header: str
+ disabled: bool = False
+ slot_content: Optional[str] = None
+
+
+# Use this component to define individual tabs inside the default slot
+# inside the `tab` component.
+@registry.library.simple_tag(takes_context=True)
+def tab_item(context, data: TabItemData):
+ # Access the list of tabs registered for parent Tabs component
+ # This raises if we're not nested inside the Tabs component.
+ tab_ctx = context["_tabs"]
+
+ # We accessed the _tabs context, but we're inside ANOTHER TabItem
+ if not tab_ctx.enabled:
+ raise RuntimeError(
+ "Component 'tab_item' was called with no parent Tabs component. "
+ "Either wrap 'tab_item' in Tabs component, or check if the component "
+ "is not a descendant of another instance of 'tab_item'"
+ )
+ parent_tabs = tab_ctx.tabs
+
+ parent_tabs.append(
+ {
+ "header": data.header,
+ "disabled": data.disabled,
+ "content": mark_safe(data.slot_content or "").strip(),
+ }
+ )
+ return ""
+
+
+tabs_static_template_str: types.django_html = """
+
+
+ {% for tab, styling in tabs_data %}
+ {% if not tab.disabled %}
+
+ {% if not has_attachments and editable %}
+ This output does not have any attachments, create one below:
+ {% elif not has_attachments and not editable %}
+ This output does not have any attachments.
+ {% elif has_attachments and not editable %}
+ Attachments:
+ {% else %} {# NOTE: Else branch required by django-shouty #}
+ {% endif %}
+
+ {% for group in groups %}
+ {% component "ExpansionPanel"
+ open=group.has_outputs
+ header_attrs:class="flex gap-x-2 prose"
+ %}
+ {% fill "header" %}
+
+ """
+
+ js: types.js = """
+ // Define component similarly to defining Vue components
+ const ProjectOutputDependency = AlpineComposition.defineComponent({
+ name: 'project_output_dependency',
+
+ props: {
+ initAttachments: { type: String, required: true },
+ },
+
+ // Instead of Alpine's init(), use setup()
+ // Props are passed down as reactive props, same as in Vue
+ // Second argument is the Alpine component instance.
+ setup(props, vm, { ref }) {
+ const attachments = ref([]);
+
+ // Set the initial state from HTML
+ if (props.initAttachments) {
+ attachments.value = JSON.parse(props.initAttachments).map(({ url, text, tags }) => ({
+ url,
+ text,
+ tags,
+ isPreview: true,
+ }));
+ }
+
+ // Only those variables exposed by returning can be accessed from within HTML
+ return {
+ attachments,
+ };
+ },
+ });
+
+ document.addEventListener('alpine:init', () => {
+ AlpineComposition.registerComponent(Alpine, ProjectOutputDependency);
+ });
+ """
+
+#####################################
+# PROJECT_OUTPUT_ATTACHMENTS
+#####################################
+
+class ProjectOutputAttachmentsJsProps(TypedDict):
+ attachments: str
+
+
+@register("ProjectOutputAttachments")
+class ProjectOutputAttachments(Component):
+ def get_context_data(
+ self,
+ /,
+ *,
+ has_attachments: bool,
+ js_props: ProjectOutputAttachmentsJsProps,
+ editable: bool,
+ attrs: Optional[dict] = None,
+ ):
+ return {
+ "has_attachments": has_attachments,
+ "editable": editable,
+ "attrs": attrs,
+ "js_props": js_props,
+ "text_max_len": FORM_SHORT_TEXT_MAX_LEN,
+ "tag_type": "project_output_attachment",
+ }
+
+ template: types.django_html = """
+
+
+ {% if not has_attachments and editable %}
+ This output does not have any attachments, create one below:
+ {% elif not has_attachments and not editable %}
+ This output does not have any attachments.
+ {% elif has_attachments and not editable %}
+ Attachments:
+ {% else %} {# NOTE: Else branch required by django-shouty #}
+ {% endif %}
+
"
+ component_name = "TestComponent"
+ result = apply_component_highlight("component", test_html, component_name)
+
+ # Check that the output contains the component name
+ assert component_name in result
+ # Check that the output contains the original HTML
+ assert test_html in result
+ # Check that the component colors are used
+ assert COLORS["component"].text_color in result
+ assert COLORS["component"].border_color in result
+
+ def test_slot_highlight_fn(self):
+ # Test slot highlighting
+ test_html = "Slot content"
+ slot_name = "content-slot"
+ result = apply_component_highlight("slot", test_html, slot_name)
+
+ # Check that the output contains the slot name
+ assert slot_name in result
+ # Check that the output contains the original HTML
+ assert test_html in result
+ # Check that the slot colors are used
+ assert COLORS["slot"].text_color in result
+ assert COLORS["slot"].border_color in result
+
+ @djc_test(
+ components_settings={
+ "extensions_defaults": {
+ "debug_highlight": {"highlight_components": True},
+ },
+ }
+ )
+ def test_component_highlight_extension(self):
+ template = _prepare_template()
+ rendered = template.render(Context({"items": [1, 2]}))
+
+ expected = """
+
",
- )
-
- def test_inlined_js_and_css(self):
+# "Main media" refer to the HTML, JS, and CSS set on the Component class itself
+# (as opposed via the `Media` class). These have special handling in the Component.
+@djc_test
+class TestMainMedia:
+ def test_html_js_css_inlined(self):
class TestComponent(Component):
- template = """
+ template = dedent(
+ """
{% load component_tags %}
{% component_js_dependencies %}
{% component_css_dependencies %}
",
+ # Check that the HTML / JS / CSS can be accessed on the component class
+ assert TestComponent.template == dedent(
+ """
+ {% load component_tags %}
+ {% component_js_dependencies %}
+ {% component_css_dependencies %}
+
+ Variable3: foo
+ """,
+ )
+
+ # Test that, if multiple components share the same root HTML elements,
+ # then those elemens will have the `data-djc-id-` attribute added for each component.
+ def test_adds_component_id_html_attr_nested(self):
+ class SimpleMultiroot(SimpleComponent):
+ template: types.django_html = """
+ Variable: {{ variable }}
+ Variable2:
+ """,
+ )
+
+ # `data-djc-id-` attribute should be added on each instance in the RESULTING HTML.
+ # So if in a loop, each iteration creates a new component, and each of those should
+ # have a unique `data-djc-id-` attribute.
+ def test_adds_component_id_html_attr_loops(self):
+ class SimpleMultiroot(SimpleComponent):
+ template: types.django_html = """
+ Variable: {{ variable }}
+ Variable2:
'
+ assert data_before["fragHtml"] is None
# Clicking button should load and insert the fragment
await page.locator("button").click()
@@ -341,16 +376,18 @@ class E2eDependencyRenderingTests(BaseTestCase):
data = await page.evaluate(test_js)
- self.assertEqual(data["targetHtml"], None)
- self.assertHTMLEqual('
'
+ assert data_before["fragHtml"] is None
# Clicking button should load and insert the fragment
await page.locator("button").click()
@@ -389,16 +426,18 @@ class E2eDependencyRenderingTests(BaseTestCase):
data = await page.evaluate(test_js)
- self.assertEqual(data["targetHtml"], None)
- self.assertHTMLEqual('
'
+ assert data_before["fragHtml"] is None
# Clicking button should load and insert the fragment
await page.locator("button").click()
@@ -441,19 +480,17 @@ class E2eDependencyRenderingTests(BaseTestCase):
# NOTE: Unlike the vanilla JS tests, for the Alpine test we don't remove the targetHtml,
# but only change its contents.
- self.assertInHTML(
- '