diff --git a/.gitignore b/.gitignore
index e675e09a..58252718 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ src/reactpy_django/static/reactpy_django/index.js
src/reactpy_django/static/reactpy_django/index.js.map
src/reactpy_django/static/reactpy_django/pyscript
src/reactpy_django/static/reactpy_django/morphdom
+tmp/*
# Django #
logs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8526d721..c07a5fa2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ Don't forget to remove deprecated code on each major release!
### Added
- Automatically serve ReactPy wheel from Django's static directory when using PyScript.
+- Jinja2 template support via `reactpy_django.templatetags.jinja.ReactPyExtension`.
### Changed
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index bde5ac08..5537356c 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -2,7 +2,9 @@
nav:
- Home: index.md
- Get Started:
- - Add ReactPy to a Django Project: learn/add-reactpy-to-a-django-project.md
+ - Add ReactPy to your...:
+ - Django Project: learn/add-reactpy-to-a-django-project.md
+ - Django Project (with Jinja): learn/add-reactpy-to-a-django-project-with-jinja.md
- Your First Component: learn/your-first-component.md
- Reference:
- Components: reference/components.md
diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt
index d2ff722d..889bd62d 100644
--- a/docs/src/dictionary.txt
+++ b/docs/src/dictionary.txt
@@ -49,3 +49,8 @@ linters
linting
formatters
bootstrap_form
+env
+jinja
+jinja2
+myproject
+vs
diff --git a/docs/src/learn/add-reactpy-to-a-django-project-with-jinja.md b/docs/src/learn/add-reactpy-to-a-django-project-with-jinja.md
new file mode 100644
index 00000000..0464c1c4
--- /dev/null
+++ b/docs/src/learn/add-reactpy-to-a-django-project-with-jinja.md
@@ -0,0 +1,216 @@
+## Overview
+
+
+
+If you use **Jinja2 templates** in your Django project and want to add ReactPy interactivity, this guide walks you through the additional configuration needed. First complete the [standard setup](./add-reactpy-to-a-django-project.md), then follow the steps below.
+
+
+
+!!! abstract "Note"
+
+ These docs assume you have already completed the [standard ReactPy-Django setup](./add-reactpy-to-a-django-project.md) and have a working **Django project** with Jinja2 configured.
+
+---
+
+## Step 1: Install from PyPI
+
+Run the following command to install [`reactpy-django`](https://pypi.org/project/reactpy-django/) and [`jinja2`](https://pypi.org/project/Jinja2/) in your Python environment.
+
+```bash linenums="0"
+pip install reactpy-django jinja2
+```
+
+## Step 2: Configure `settings.py`
+
+Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-INSTALLED_APPS) in your [`settings.py`](https://docs.djangoproject.com/en/stable/topics/settings/) file.
+
+=== "settings.py"
+
+ ```python
+ {% include "../../examples/python/configure_installed_apps.py" %}
+ ```
+
+??? warning "Enable ASGI and Django Channels (Required)"
+
+ ReactPy-Django requires Django ASGI and [Django Channels](https://github.com/django/channels) WebSockets.
+
+ If you have not enabled ASGI on your **Django project** yet, here is a summary of the [`django`](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) and [`channels`](https://channels.readthedocs.io/en/stable/installation.html) installation docs:
+
+ 1. Install `channels[daphne]`
+ 2. Add `#!python "daphne"` to `#!python INSTALLED_APPS`.
+
+ ```python linenums="0"
+ {% include "../../examples/python/configure_channels_installed_app.py" %}
+ ```
+
+ 3. Set your `#!python ASGI_APPLICATION` variable.
+
+ ```python linenums="0"
+ {% include "../../examples/python/configure_channels_asgi_app.py" %}
+ ```
+
+??? info "Configure ReactPy settings (Optional)"
+
+ ReactPy's has additional configuration available to fit a variety of use cases.
+
+ See the [ReactPy settings](../reference/settings.md) documentation to learn more.
+
+Also add a Jinja2 backend entry to your `TEMPLATES` list:
+
+The backend must include:
+
+- `"BACKEND": "django.template.backends.jinja2.Jinja2"`
+- An `"environment"` option pointing to a function that registers the `ReactPyExtension`
+
+=== "settings.py"
+
+ ```python
+ import os
+
+ TEMPLATES = [
+ ... ,
+ {
+ "BACKEND": "django.template.backends.jinja2.Jinja2",
+ "DIRS": [os.path.join(BASE_DIR, "templates")],
+ "OPTIONS": {
+ "environment": "myproject.jinja_env.environment",
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+ ]
+ ```
+
+## Step 3: Configure `urls.py`
+
+Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https://docs.djangoproject.com/en/stable/topics/http/urls/) file.
+
+=== "urls.py"
+
+ ```python
+ {% include "../../examples/python/configure_urls.py" %}
+ ```
+
+## Step 4: Configure `asgi.py`
+
+Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`asgi.py`](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) file.
+
+=== "asgi.py"
+
+ ```python
+ {% include "../../examples/python/configure_asgi.py" %}
+ ```
+
+??? info "Add `#!python AuthMiddlewareStack` (Optional)"
+
+ There are many situations where you need to access the Django `#!python User` or `#!python Session` objects within ReactPy components. For example, if you want to:
+
+ 1. Access the `#!python User` that is currently logged in
+ 3. Access Django's `#!python Session` object
+ 2. Login or logout the current `#!python User`
+
+ In these situations will need to ensure you are using `#!python AuthMiddlewareStack`.
+
+ {% include "../../includes/auth-middleware-stack.md" %}
+
+??? question "Where is my `asgi.py`?"
+
+ If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html).
+
+## Step 5: Register the Jinja2 Extension
+
+Create a `jinja_env.py` module that registers the `ReactPyExtension`:
+
+=== "myproject/jinja_env.py"
+
+ ```python
+ from jinja2 import Environment
+ from reactpy_django.templatetags.jinja import ReactPyExtension
+
+
+ def environment(**options):
+ env = Environment(**options)
+ env.add_extension(ReactPyExtension)
+ return env
+ ```
+
+## Step 6: Run database migrations
+
+Run Django's [`migrate` command](https://docs.djangoproject.com/en/stable/topics/migrations/) to initialize ReactPy-Django's database table.
+
+```bash linenums="0"
+python manage.py migrate
+```
+
+## Step 7: Check your configuration
+
+Run Django's [`check` command](https://docs.djangoproject.com/en/stable/ref/django-admin/#check) to verify everything is set up correctly.
+
+```bash linenums="0"
+python manage.py check
+```
+
+## Step 8: Create your first component
+
+The [next page](./your-first-component.md) will show you how to create your first ReactPy component.
+
+Prefer a quick summary? Read the **At a Glance** section below.
+
+!!! info "At a Glance"
+
+ With the extension registered, you can call ReactPy functions directly inside any Jinja2 template:
+
+ === "templates/example.jinja"
+
+ ```jinja
+
+
+
+ ReactPy + Jinja2
+
+
+ Server-side component
+ {{ component("my_app.components.hello_world", recipient="World") }}
+
+ Client-side PyScript component
+ {{ pyscript_component("my_app/components/my_app.py") }}
+
+ {{ pyscript_setup() }}
+
+
+ ```
+
+ ---
+
+ **`my_app/components.py`**
+
+ {% include-markdown "../../../README.md" start="" end="" %}
+
+ ---
+
+ **`my_app/templates/my_template.jinja`**
+
+ In your Jinja2 template, call the `component` function directly with the dotted path to your component:
+
+ ```jinja
+
+
+
+ {{ component("my_app.components.hello_world", recipient="World") }}
+
+
+ ```
+
+ ??? info "Template tag vs Jinja2 function syntax"
+
+ Unlike Django templates which require `{% load reactpy %}` and `{% component "..." %}`, Jinja2 allows you to call component functions directly using the `{{ component(...) }}` syntax. The registered functions are:
+
+ | Function | Description |
+ |---|---|
+ | `{{ component(dotted_path, *args, **kwargs) }}` | Render a server-side ReactPy component. Equivalent to `{% component ... %}`. |
+ | `{{ pyscript_component(*file_paths, initial, root) }}` | Render a client-side PyScript component. Equivalent to `{% pyscript_component ... %}`. |
+ | `{{ pyscript_setup(*extra_py, extra_js, config) }}` | Render PyScript setup configuration. Equivalent to `{% pyscript_setup ... %}`. |
diff --git a/pyproject.toml b/pyproject.toml
index 42e1b1f0..abdc6be5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -97,6 +97,7 @@ extra-dependencies = [
"django-bootstrap5",
"decorator",
"uvicorn[standard]",
+ "jinja2",
]
matrix-name-format = "{variable}-{value}"
@@ -197,7 +198,7 @@ deploy_develop = ["cd docs && mike deploy --push develop"]
################################
[tool.hatch.envs.python]
-extra-dependencies = ["django-stubs", "channels-redis", "pyright"]
+extra-dependencies = ["django-stubs", "channels-redis", "pyright", "jinja2"]
[tool.hatch.envs.python.scripts]
type_check = ["pyright src"]
diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py
index d778e531..cef8ec37 100644
--- a/src/reactpy_django/checks.py
+++ b/src/reactpy_django/checks.py
@@ -83,9 +83,19 @@ def reactpy_warnings(app_configs, **kwargs):
)
# Check if the reactpy/component.html template exists
- try:
+ template_found = False
+ with contextlib.suppress(Exception):
loader.get_template("reactpy/component.html")
- except Exception:
+ template_found = True
+ if not template_found:
+ from django.template import engines as _engines
+
+ for _engine in _engines.all():
+ with contextlib.suppress(Exception):
+ _engine.get_template("reactpy/component.html")
+ template_found = True
+ break
+ if not template_found:
warnings.append(
checks.Warning(
"ReactPy HTML templates could not be found!",
@@ -251,6 +261,21 @@ def reactpy_warnings(app_configs, **kwargs):
)
)
+ # Check if Jinja2 template backends are missing the request context processor
+ for _tmpl in getattr(settings, "TEMPLATES", []):
+ if "jinja2" in _tmpl.get("BACKEND", "").lower():
+ _options = _tmpl.get("OPTIONS", {})
+ _context_processors = _options.get("context_processors", [])
+ if "django.template.context_processors.request" not in _context_processors:
+ warnings.append(
+ checks.Warning(
+ "A Jinja2 template backend is missing the request context processor.",
+ hint="Add 'django.template.context_processors.request' to the context_processors "
+ "list within the OPTIONS of your Jinja2 template backend in settings.py:TEMPLATES.",
+ id="reactpy_django.W022",
+ )
+ )
+
return warnings
diff --git a/src/reactpy_django/templatetags/jinja.py b/src/reactpy_django/templatetags/jinja.py
new file mode 100644
index 00000000..b97bd55f
--- /dev/null
+++ b/src/reactpy_django/templatetags/jinja.py
@@ -0,0 +1,222 @@
+"""
+Jinja2 template support for ReactPy-Django.
+
+Provides Jinja2 global functions that mirror the functionality of Django's
+``{% component %}``, ``{% pyscript_component %}``, and ``{% pyscript_setup %}``
+template tags. These are registered as environment globals so they can be
+called directly from Jinja2 templates via ``{{ component(...) }}`` syntax.
+
+To enable, add the extension to your Jinja2 environment configuration:
+
+.. code-block:: python
+
+ TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.jinja2.Jinja2",
+ "DIRS": [...],
+ "OPTIONS": {
+ "environment": "myproject.jinja_env.environment",
+ },
+ },
+ ]
+
+Then in ``myproject/jinja_env.py``:
+
+.. code-block:: python
+
+ from jinja2 import Environment
+ from reactpy_django.templatetags.jinja import ReactPyExtension
+
+
+ def environment(**options):
+ env = Environment(**options)
+ env.add_extension(ReactPyExtension)
+ return env
+"""
+
+from __future__ import annotations
+
+try:
+ import jinja2 # noqa: F401
+except ModuleNotFoundError as e:
+ raise ModuleNotFoundError(
+ "The `jinja2` package is required to use ReactPy-Django's Jinja2 template support. "
+ "Install it with: pip install jinja2"
+ ) from e
+
+from logging import getLogger
+from typing import TYPE_CHECKING
+
+from django.template import RequestContext, loader
+from jinja2 import pass_context
+from jinja2.ext import Extension
+
+from reactpy_django.templatetags.reactpy import (
+ COMPONENT_TEMPLATE,
+ PYSCRIPT_COMPONENT_TEMPLATE,
+ PYSCRIPT_SETUP_TEMPLATE,
+)
+from reactpy_django.templatetags.reactpy import (
+ component as django_component_tag,
+)
+from reactpy_django.templatetags.reactpy import (
+ pyscript_component as django_pyscript_component_tag,
+)
+from reactpy_django.templatetags.reactpy import (
+ pyscript_setup as django_pyscript_setup_tag,
+)
+
+if TYPE_CHECKING:
+ from jinja2.runtime import Context
+ from reactpy.types import Component, VdomDict
+
+_logger = getLogger(__name__)
+
+
+class ReactPyExtension(Extension):
+ """A Jinja2 extension that adds ReactPy component rendering functions.
+
+ This extension registers the following globals into the Jinja2 environment:
+
+ * ``component`` - Renders a server-side ReactPy component.
+ * ``pyscript_component`` - Renders a client-side PyScript component.
+ * ``pyscript_setup`` - Renders PyScript setup configuration.
+
+ Unlike Django's template tags, which require ``{% load reactpy %}`` and use
+ ``{% component %}`` syntax, Jinja2 uses ``{{ component(...) }}`` function calls.
+ This is because Jinja2 has more expressive power and can directly handle
+ function expansions.
+ """
+
+ def __init__(self, environment):
+ super().__init__(environment)
+ environment.globals["component"] = self._component
+ environment.globals["pyscript_component"] = self._pyscript_component
+ environment.globals["pyscript_setup"] = self._pyscript_setup
+
+ @pass_context
+ def _component(
+ self,
+ jinja_context: Context,
+ dotted_path: str,
+ *args,
+ host: str | None = None,
+ prerender: str = "",
+ offline: str = "",
+ **kwargs,
+ ) -> str:
+ """Render a server-side ReactPy component.
+
+ This is the Jinja2 equivalent of ``{% component "path.to.Component" %}``.
+
+ Args:
+ dotted_path: The dotted path to the component to render.
+ *args: Positional arguments to pass to the component.
+ host: The host to use for ReactPy connections.
+ prerender: If ``"true"`` the component will be pre-rendered server-side.
+ offline: Dotted path to an offline fallback component.
+ **kwargs: Keyword arguments to pass to the component.
+
+ Returns:
+ Rendered HTML string.
+ """
+ from reactpy_django.config import REACTPY_PRERENDER as _REACTPY_PRERENDER
+
+ if not prerender:
+ prerender = str(_REACTPY_PRERENDER)
+ request = jinja_context.parent.get("request")
+ if request is None:
+ _logger.error(
+ "Cannot render a ReactPy component in a Jinja2 template without a "
+ "request object. Ensure the 'django.template.context_processors.request' "
+ "context processor is enabled for your Jinja2 backend."
+ )
+ return ""
+
+ django_context = RequestContext(
+ request,
+ autoescape=jinja_context.eval_ctx.autoescape,
+ )
+ template_context = django_component_tag(
+ django_context,
+ dotted_path,
+ *args,
+ host=host,
+ prerender=prerender,
+ offline=offline,
+ **kwargs,
+ )
+ return loader.render_to_string(
+ COMPONENT_TEMPLATE,
+ template_context,
+ request,
+ )
+
+ @pass_context
+ def _pyscript_component(
+ self,
+ jinja_context: Context,
+ *file_paths: str,
+ initial: str | VdomDict | Component = "",
+ root: str = "root",
+ ) -> str:
+ """Render a client-side PyScript component.
+
+ This is the Jinja2 equivalent of ``{% pyscript_component "path/to/file.py" %}``.
+
+ Args:
+ file_paths: File paths to client-side component Python files.
+ initial: Initial HTML displayed before the PyScript component loads.
+ root: The name of the root component function.
+
+ Returns:
+ Rendered HTML string.
+ """
+ request = jinja_context.parent.get("request")
+ if request is None:
+ _logger.error("Cannot render a PyScript component in a Jinja2 template without a request object.")
+ return ""
+
+ django_context = RequestContext(
+ request,
+ autoescape=jinja_context.eval_ctx.autoescape,
+ )
+ template_context = django_pyscript_component_tag(
+ django_context,
+ *file_paths,
+ initial=initial,
+ root=root,
+ )
+ return loader.render_to_string(
+ PYSCRIPT_COMPONENT_TEMPLATE,
+ template_context,
+ request,
+ )
+
+ def _pyscript_setup(
+ self,
+ *extra_py: str,
+ extra_js: str | dict = "",
+ config: str | dict = "",
+ ) -> str:
+ """Render PyScript setup configuration.
+
+ This is the Jinja2 equivalent of ``{% pyscript_setup %}``.
+
+ Args:
+ extra_py: Additional Python dependencies.
+ extra_js: Additional JavaScript modules.
+ config: PyScript configuration overrides.
+
+ Returns:
+ Rendered HTML string.
+ """
+ template_context = django_pyscript_setup_tag(
+ *extra_py,
+ extra_js=extra_js,
+ config=config,
+ )
+ return loader.render_to_string(
+ PYSCRIPT_SETUP_TEMPLATE,
+ template_context,
+ )
diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py
index 77a07955..c6cd67ab 100644
--- a/src/reactpy_django/templatetags/reactpy.py
+++ b/src/reactpy_django/templatetags/reactpy.py
@@ -45,8 +45,12 @@
RESOLVED_WEB_MODULES_PATH = ""
_logger.exception("Could not resolve the 'web_modules' URL path!")
+COMPONENT_TEMPLATE = "reactpy/component.html"
+PYSCRIPT_COMPONENT_TEMPLATE = "reactpy/pyscript_component.html"
+PYSCRIPT_SETUP_TEMPLATE = "reactpy/pyscript_setup.html"
-@register.inclusion_tag("reactpy/component.html", takes_context=True)
+
+@register.inclusion_tag(COMPONENT_TEMPLATE, takes_context=True)
def component(
context: template.RequestContext,
dotted_path: str,
@@ -190,7 +194,7 @@ def component(
}
-@register.inclusion_tag("reactpy/pyscript_component.html", takes_context=True)
+@register.inclusion_tag(PYSCRIPT_COMPONENT_TEMPLATE, takes_context=True)
def pyscript_component(
context: template.RequestContext,
*file_paths: str,
@@ -224,7 +228,7 @@ def pyscript_component(
}
-@register.inclusion_tag("reactpy/pyscript_setup.html")
+@register.inclusion_tag(PYSCRIPT_SETUP_TEMPLATE)
def pyscript_setup(
*extra_py: str,
extra_js: str | dict = "",
diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py
index 38b7fa66..ba637e6a 100644
--- a/src/reactpy_django/utils.py
+++ b/src/reactpy_django/utils.py
@@ -62,6 +62,15 @@
+ rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?"
+ r"\s*%}"
)
+JINJA_COMPONENT_REGEX = re.compile(
+ r"\{\{\s*"
+ + _TAG_PATTERN
+ + r"\s*\("
+ + r"\s*"
+ + _PATH_PATTERN
+ + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?"
+ + r"\s*\)\s*\}\}"
+)
FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator")
SYNC_LAYOUT_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-SyncLayout")
@@ -179,11 +188,19 @@ def get_paths(self) -> set[str]:
if get_template_sources is None:
get_template_sources = loader.get_template_sources
paths.update(smart_str(origin) for origin in get_template_sources(""))
+ # Collect directories from template engines that don't expose
+ # Django-style loaders (e.g., Jinja2), by reading template_dirs directly.
+ from django.template.backends.base import BaseEngine
+
+ for e in engines.all():
+ if not hasattr(e, "engine") and isinstance(e, BaseEngine):
+ for directory in e.template_dirs:
+ paths.add(smart_str(directory))
return paths
def get_templates(self, paths: set[str]) -> set[str]:
- """Obtains a set of all HTML template paths."""
- extensions = [".html"]
+ """Obtains a set of all HTML and Jinja2 template paths."""
+ extensions = [".html", ".jinja"]
templates: set[str] = set()
for path in paths:
for root, _, files in os.walk(path, followlinks=False):
@@ -201,7 +218,8 @@ def get_components(self, templates: set[str]) -> set[str]:
for template in templates:
with contextlib.suppress(Exception), open(template, encoding="utf-8") as template_file:
clean_template = COMMENT_REGEX.sub("", template_file.read())
- regex_iterable = COMPONENT_REGEX.finditer(clean_template)
+ regex_iterable = list(COMPONENT_REGEX.finditer(clean_template))
+ regex_iterable.extend(JINJA_COMPONENT_REGEX.finditer(clean_template))
new_components: list[str] = []
for match in regex_iterable:
new_components.append(match.group("path").replace('"', "").replace("'", ""))
diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py
index df44ad6c..cccc67a5 100644
--- a/tests/test_app/__init__.py
+++ b/tests/test_app/__init__.py
@@ -5,15 +5,20 @@
# Make sure the JS is always re-built before running the tests
js_dir = Path(__file__).parent.parent.parent / "src" / "js"
static_dir = Path(__file__).parent.parent.parent / "src" / "reactpy_django" / "static" / "reactpy_django"
-assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0
-assert (
+
+# Check if bun is available; if so, rebuild the JS. If not, assume artifacts exist.
+_bun_available = shutil.which("bun") is not None
+if _bun_available:
+ subprocess.run(["bun", "install"], cwd=str(js_dir), check=True)
subprocess.run(
["bun", "build", "./src/index.ts", f"--outdir={static_dir}", "--sourcemap=linked"],
cwd=str(js_dir),
check=True,
- ).returncode
- == 0
-)
+ )
+# Verify that JS artifacts already exist so we don't silently skip a needed build
+elif not (static_dir / "index.js").exists():
+ msg = f"bun is not installed and JS artifacts are missing. Run 'bun install && bun build' in {js_dir} first."
+ raise RuntimeError(msg)
# Make sure the test environment is always using the latest JS
diff --git a/tests/test_app/jinja2_templates/base.html b/tests/test_app/jinja2_templates/base.html
new file mode 100644
index 00000000..90aa4068
--- /dev/null
+++ b/tests/test_app/jinja2_templates/base.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+
+ ReactPy Test Page (Jinja2)
+
+ {{ component("test_app.components.hello_world", class="hello-world") }}
+
+ {{ component("test_app.components.button", class="button") }}
+
+ {{ component("test_app.components.parameterized_component", class="parametarized-component", x=123, y=456) }}
+
+ {{ component("test_app.components.object_in_templatetag", my_object) }}
+
+ {{ component("test_app.components.button_from_js_module") }}
+
+ {{ component("test_app.components.use_connection") }}
+
+ {{ component("test_app.components.use_scope") }}
+
+ {{ component("test_app.components.use_location") }}
+
+ {{ component("test_app.components.use_origin") }}
+
+ {{ component("test_app.components.django_css") }}
+
+ {{ component("test_app.components.django_js") }}
+
+ {{ component("test_app.components.unauthorized_user") }}
+
+ {{ component("test_app.components.authorized_user") }}
+
+ {{ component("test_app.components.relational_query") }}
+
+ {{ component("test_app.components.async_relational_query") }}
+
+ {{ component("test_app.components.todo_list") }}
+
+ {{ component("test_app.components.async_todo_list") }}
+
+ {{ component("test_app.components.view_to_component_sync_func") }}
+
+ {{ component("test_app.components.view_to_component_async_func") }}
+
+ {{ component("test_app.components.view_to_component_sync_class") }}
+
+ {{ component("test_app.components.view_to_component_async_class") }}
+
+ {{ component("test_app.components.view_to_component_template_view_class") }}
+
+ {{ component("test_app.components.view_to_component_script") }}
+
+ {{ component("test_app.components.view_to_component_request") }}
+
+ {{ component("test_app.components.view_to_component_args") }}
+
+ {{ component("test_app.components.view_to_component_kwargs") }}
+
+ {{ component("test_app.components.view_to_iframe_sync_func") }}
+
+ {{ component("test_app.components.view_to_iframe_async_func") }}
+
+ {{ component("test_app.components.view_to_iframe_sync_class") }}
+
+ {{ component("test_app.components.view_to_iframe_async_class") }}
+
+ {{ component("test_app.components.view_to_iframe_template_view_class") }}
+
+ {{ component("test_app.components.view_to_iframe_args") }}
+
+ {{ component("test_app.components.use_user_data") }}
+
+ {{ component("test_app.components.use_user_data_with_default") }}
+
+ {{ component("test_app.components.use_auth") }}
+
+ {{ component("test_app.components.use_auth_no_rerender") }}
+
+ {{ component("test_app.components.use_rerender") }}
+
+
+
+
diff --git a/tests/test_app/jinja2_templates/errors.html b/tests/test_app/jinja2_templates/errors.html
new file mode 100644
index 00000000..f9e5d17c
--- /dev/null
+++ b/tests/test_app/jinja2_templates/errors.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+ ReactPy Errors Test Page (Jinja2)
+
+ {{ component("test_app.components.does_not_exist") }}
+
+ {{ component("test_app.components.hello_world", invalid_param="random_value") }}
+
+ {{ component("test_app.components.hello_world", host="https://example.com/") }}
+
+
+ {{ component("test_app.components.broken_postprocessor_query") }}
+
+
+
+ {{ component("test_app.components.view_to_iframe_not_registered") }}
+
+
+
+ {{ component("test_app.components.incorrect_user_passes_test_decorator") }}
+
+
+
+
diff --git a/tests/test_app/jinja2_templates/host_port.html b/tests/test_app/jinja2_templates/host_port.html
new file mode 100644
index 00000000..bbd83e48
--- /dev/null
+++ b/tests/test_app/jinja2_templates/host_port.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+ ReactPy Test Page
+
+ Custom Host ({{ new_host }}):
+ {{ component("test_app.components.custom_host", host=new_host, number=0) }}
+
+
+
+
diff --git a/tests/test_app/jinja2_templates/host_port_roundrobin.html b/tests/test_app/jinja2_templates/host_port_roundrobin.html
new file mode 100644
index 00000000..47f5229a
--- /dev/null
+++ b/tests/test_app/jinja2_templates/host_port_roundrobin.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+ ReactPy Test Page
+
+ {% for count_val in count %}
+ Round-Robin Host:
+ {{ component("test_app.components.custom_host", number=count_val) }}
+
+ {% endfor %}
+
+
+
diff --git a/tests/test_app/jinja2_templates/offline.html b/tests/test_app/jinja2_templates/offline.html
new file mode 100644
index 00000000..545895f8
--- /dev/null
+++ b/tests/test_app/jinja2_templates/offline.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+ ReactPy Offline Test Page
+
+ {{ component("test_app.offline.components.online", offline="test_app.offline.components.offline") }}
+
+
+
+
diff --git a/tests/test_app/jinja2_templates/prerender.html b/tests/test_app/jinja2_templates/prerender.html
new file mode 100644
index 00000000..cfea84b3
--- /dev/null
+++ b/tests/test_app/jinja2_templates/prerender.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ ReactPy
+
+
+
+ ReactPy Prerender Test Page
+
+
+ {{ component("test_app.prerender.components.prerender_string", class="prerender-string", prerender="true") }}
+
+
+
+ {{ component("test_app.prerender.components.prerender_vdom", class="prerender-vdom", prerender="true") }}
+
+
+
+ {{ component("test_app.prerender.components.prerender_component", class="prerender-component", prerender="true") }}
+
+
+ {{ component("test_app.prerender.components.use_user", prerender="true") }}
+
+ {{ component("test_app.prerender.components.use_root_id", prerender="true") }}
+
+
+
+
diff --git a/tests/test_app/jinja_env.py b/tests/test_app/jinja_env.py
new file mode 100644
index 00000000..c162ec1d
--- /dev/null
+++ b/tests/test_app/jinja_env.py
@@ -0,0 +1,12 @@
+"""Jinja2 environment configuration for the test app."""
+
+from jinja2 import Environment
+
+
+def environment(**options):
+ """Create a Jinja2 environment with the ReactPy extension loaded."""
+ from reactpy_django.templatetags.jinja import ReactPyExtension
+
+ env = Environment(**options)
+ env.add_extension(ReactPyExtension)
+ return env
diff --git a/tests/test_app/jinja_urls.py b/tests/test_app/jinja_urls.py
new file mode 100644
index 00000000..009b0d71
--- /dev/null
+++ b/tests/test_app/jinja_urls.py
@@ -0,0 +1,10 @@
+"""URL patterns for Jinja2 template views."""
+
+from django.urls import path
+
+from . import jinja_views
+
+urlpatterns = [
+ path("", jinja_views.jinja_base_template, name="jinja_base_template"),
+ path("errors/", jinja_views.jinja_errors_template, name="jinja_errors_template"),
+]
diff --git a/tests/test_app/jinja_views.py b/tests/test_app/jinja_views.py
new file mode 100644
index 00000000..e21520ca
--- /dev/null
+++ b/tests/test_app/jinja_views.py
@@ -0,0 +1,15 @@
+"""Jinja2 template views for the test app."""
+
+from django.shortcuts import render
+
+from .types import TestObject
+
+
+def jinja_base_template(request):
+ """Render the Jinja2 version of the base template."""
+ return render(request, "base.html", {"my_object": TestObject(1)}, using="jinja2")
+
+
+def jinja_errors_template(request):
+ """Render the Jinja2 version of the errors template."""
+ return render(request, "errors.html", {}, using="jinja2")
diff --git a/tests/test_app/settings_jinja.py b/tests/test_app/settings_jinja.py
new file mode 100644
index 00000000..e83b0f79
--- /dev/null
+++ b/tests/test_app/settings_jinja.py
@@ -0,0 +1,146 @@
+import os
+import sys
+from pathlib import Path
+
+# Build paths inside the project like this: BASE_DIR / 'subdir'.
+BASE_DIR = Path(__file__).resolve().parent.parent
+SRC_DIR = BASE_DIR.parent / "src"
+
+# Quick-start development settings - unsuitable for production
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c"
+
+# Run in production mode when using a real web server
+DEBUG = not any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn", "daphne"])
+ALLOWED_HOSTS = ["*"]
+
+# Application definition
+INSTALLED_APPS = [
+ "servestatic",
+ "daphne", # Overrides `runserver` command with an ASGI server
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "reactpy_django", # Django compatiblity layer for ReactPy
+ "test_app", # This test application
+ "django_bootstrap5",
+]
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "servestatic.middleware.ServeStaticMiddleware",
+ "test_app.middleware.AutoCreateAdminMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+]
+ROOT_URLCONF = "test_app.urls"
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [os.path.join(BASE_DIR, "test_app", "templates")],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+ {
+ "BACKEND": "django.template.backends.jinja2.Jinja2",
+ "DIRS": [os.path.join(BASE_DIR, "test_app", "jinja2_templates")],
+ "OPTIONS": {
+ "environment": "test_app.jinja_env.environment",
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+ASGI_APPLICATION = "test_app.asgi.application"
+sys.path.append(str(SRC_DIR))
+
+# Database
+# WARNING: There are overrides in `test_components.py` that require no in-memory
+# databases are used for testing. Make sure all SQLite databases are on disk.
+DB_NAME = "single_db"
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"),
+ "TEST": {
+ "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"),
+ "OPTIONS": {"timeout": 20},
+ "DEPENDENCIES": [],
+ },
+ "OPTIONS": {"timeout": 20},
+ },
+}
+
+# Cache
+CACHES = {
+ "default": {
+ "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
+ "LOCATION": os.path.join(BASE_DIR, "cache"),
+ }
+}
+
+# Password validation
+AUTH_PASSWORD_VALIDATORS = [
+ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
+]
+
+# Internationalization
+LANGUAGE_CODE = "en-us"
+TIME_ZONE = "UTC"
+USE_I18N = True
+USE_TZ = True
+
+# Default primary key field type
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+STATIC_ROOT = os.path.join(BASE_DIR, "static-deploy")
+
+# Static Files (CSS, JavaScript, Images)
+STATIC_URL = "/static/"
+STATICFILES_DIRS = [
+ os.path.join(BASE_DIR, "test_app", "static"),
+]
+STATICFILES_FINDERS = [
+ "django.contrib.staticfiles.finders.FileSystemFinder",
+ "django.contrib.staticfiles.finders.AppDirectoriesFinder",
+]
+
+# Logging
+LOG_LEVEL = "DEBUG"
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "handlers": {
+ "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL},
+ },
+}
+
+# Django Channels Settings
+CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
+
+# ReactPy-Django Settings
+REACTPY_BACKHAUL_THREAD = any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"])
+
+# ServeStatic Settings
+SERVESTATIC_USE_FINDERS = True
+SERVESTATIC_AUTOREFRESH = True
diff --git a/tests/test_app/tests/test_jinja.py b/tests/test_app/tests/test_jinja.py
new file mode 100644
index 00000000..5945a9f6
--- /dev/null
+++ b/tests/test_app/tests/test_jinja.py
@@ -0,0 +1,105 @@
+"""Tests for Jinja2 template rendering with ReactPy components."""
+
+from django.http import HttpRequest
+from django.test import TestCase, override_settings
+
+# Jinja2 template backend configuration to add alongside the default Django templates
+JINJA2_TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+ {
+ "BACKEND": "django.template.backends.jinja2.Jinja2",
+ "DIRS": [],
+ "OPTIONS": {
+ "environment": "test_app.jinja_env.environment",
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+
+class Jinja2ComponentTests(TestCase):
+ """Verify that ReactPy components render correctly in Jinja2 templates."""
+
+ @override_settings(TEMPLATES=JINJA2_TEMPLATES)
+ def test_component_function_available(self):
+ """The component function should be available in Jinja2 templates."""
+ from jinja2 import Environment
+
+ from reactpy_django.templatetags.jinja import ReactPyExtension
+
+ env = Environment()
+ env.add_extension(ReactPyExtension)
+
+ assert "component" in env.globals
+ assert callable(env.globals["component"])
+
+ @override_settings(TEMPLATES=JINJA2_TEMPLATES)
+ def test_pyscript_component_function_available(self):
+ """The pyscript_component function should be available in Jinja2 templates."""
+ from jinja2 import Environment
+
+ from reactpy_django.templatetags.jinja import ReactPyExtension
+
+ env = Environment()
+ env.add_extension(ReactPyExtension)
+
+ assert "pyscript_component" in env.globals
+ assert callable(env.globals["pyscript_component"])
+
+ @override_settings(TEMPLATES=JINJA2_TEMPLATES)
+ def test_pyscript_setup_function_available(self):
+ """The pyscript_setup function should be available in Jinja2 templates."""
+ from jinja2 import Environment
+
+ from reactpy_django.templatetags.jinja import ReactPyExtension
+
+ env = Environment()
+ env.add_extension(ReactPyExtension)
+
+ assert "pyscript_setup" in env.globals
+ assert callable(env.globals["pyscript_setup"])
+
+ @override_settings(TEMPLATES=JINJA2_TEMPLATES)
+ def test_jinja_component_renders_without_error(self):
+ """A Jinja2 template containing a component tag should render without error."""
+ from django.template import engines
+
+ request = HttpRequest()
+ request.method = "GET"
+
+ jinja2_engine = engines["jinja2"]
+ template = jinja2_engine.from_string("{{ component('test_app.components.hello_world') }}")
+ rendered = template.render({"request": request})
+ assert isinstance(rendered, str)
+ assert "mountComponent" in rendered
+ assert "test_app.components.hello_world" in rendered
+
+ @override_settings(TEMPLATES=JINJA2_TEMPLATES)
+ def test_jinja_extension_configuration(self):
+ """The ReactPyExtension should be configurable via the environment function."""
+ from test_app.jinja_env import environment
+
+ env = environment()
+ assert "component" in env.globals
+ assert callable(env.globals["component"])
+ assert "pyscript_component" in env.globals
+ assert callable(env.globals["pyscript_component"])
+ assert "pyscript_setup" in env.globals
+ assert callable(env.globals["pyscript_setup"])
diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py
index 65672cdc..99be197b 100644
--- a/tests/test_app/urls.py
+++ b/tests/test_app/urls.py
@@ -36,5 +36,6 @@
path("", include("test_app.channel_layers.urls")),
path("", include("test_app.forms.urls")),
path("reactpy/", include("reactpy_django.http.urls")),
+ path("jinja/", include("test_app.jinja_urls")),
path("admin/", admin.site.urls),
]