Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ extra-dependencies = [
"django-bootstrap5",
"decorator",
"uvicorn[standard]",
"jinja2",
]
matrix-name-format = "{variable}-{value}"

Expand Down Expand Up @@ -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"]
Expand Down
210 changes: 210 additions & 0 deletions src/reactpy_django/templatetags/jinja.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""
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

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.
"""
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,
)
10 changes: 7 additions & 3 deletions src/reactpy_django/templatetags/reactpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = "",
Expand Down
Loading
Loading