├── tests ├── __init__.py ├── templatetags │ ├── __init__.py │ └── test_django_htmx.py ├── settings.py ├── test_docs.py ├── test_jinja.py ├── test_middleware.py └── test_http.py ├── example ├── example │ ├── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ └── example_tags.py │ ├── static │ │ ├── app.js │ │ ├── ext │ │ │ └── event-header.js │ │ └── mvp.css │ ├── forms.py │ ├── templates │ │ ├── csrf-demo-checker.html │ │ ├── index.html │ │ ├── csrf-demo.html │ │ ├── error-demo.html │ │ ├── _base.html │ │ ├── middleware-tester.html │ │ ├── middleware-tester-table.html │ │ └── partial-rendering.html │ ├── context_processors.py │ ├── urls.py │ ├── settings.py │ └── views.py ├── .gitignore ├── requirements.in ├── requirements.txt ├── README.rst ├── manage.py └── download_htmx_extensions.py ├── src └── django_htmx │ ├── __init__.py │ ├── py.typed │ ├── templatetags │ ├── __init__.py │ └── django_htmx.py │ ├── static │ └── django_htmx │ │ ├── django-htmx.js │ │ └── htmx.min.js │ ├── jinja.py │ ├── middleware.py │ └── http.py ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.yml │ └── issue.yml ├── SECURITY.md ├── CODE_OF_CONDUCT.md ├── dependabot.yml └── workflows │ └── main.yml ├── HISTORY.rst ├── MANIFEST.in ├── .gitignore ├── .editorconfig ├── .typos.toml ├── docs ├── example_project.rst ├── Makefile ├── index.rst ├── installation.rst ├── conf.py ├── middleware.rst ├── template_tags.rst ├── tips.rst ├── _static │ └── logo.svg ├── http.rst └── changelog.rst ├── .readthedocs.yaml ├── tox.ini ├── LICENSE ├── download_htmx.py ├── README.rst ├── .pre-commit-config.yaml └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_htmx/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_htmx/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | -------------------------------------------------------------------------------- /tests/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/django_htmx/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /example/requirements.in: -------------------------------------------------------------------------------- 1 | django 2 | django-template-partials 3 | faker 4 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | See https://django-htmx.readthedocs.io/en/latest/changelog.html 2 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | Please report security issues directly over email to me@adamj.eu 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/). 2 | -------------------------------------------------------------------------------- /example/example/static/app.js: -------------------------------------------------------------------------------- 1 | // Log all htmx events to the console. 2 | // https://htmx.org/api/#logAll 3 | htmx.logAll(); 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include pyproject.toml 3 | include README.rst 4 | include src/*/py.typed 5 | recursive-include src *.js 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.pyc 3 | /.coverage 4 | /.coverage.* 5 | /.tox 6 | /build/ 7 | /dist/ 8 | /docs/_build/ 9 | /example/.venv/ 10 | -------------------------------------------------------------------------------- /example/example/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django import forms 4 | 5 | 6 | class OddNumberForm(forms.Form): 7 | number = forms.IntegerField() 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | groups: 6 | "GitHub Actions": 7 | patterns: 8 | - "*" 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /example/example/templates/csrf-demo-checker.html: -------------------------------------------------------------------------------- 1 | {% if not form.is_valid %} 2 | Please enter a number 3 | {% elif number_is_odd %} 4 | {{ form.number.value }} is odd! 5 | {% else %} 6 | {{ form.number.value }} is not odd. 7 | {% endif %} 8 | -------------------------------------------------------------------------------- /example/example/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block main %} 4 |
5 | Welcome to the example app. 6 | Use one of the links in the navigation to explore! 7 |
8 | {% endblock main %} 9 | -------------------------------------------------------------------------------- /example/example/context_processors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | from django.http import HttpRequest 5 | 6 | 7 | def debug(request: HttpRequest) -> dict[str, str]: 8 | return {"DEBUG": settings.DEBUG} 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.py] 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | # Configuration file for 'typos' tool 2 | # https://github.com/crate-ci/typos 3 | 4 | [default] 5 | extend-ignore-re = [ 6 | # Single line ignore comments 7 | "(?Rm)^.*(#|//)\\s*typos: ignore$", 8 | # Multi-line ignore comments 9 | "(?s)(#|//)\\s*typos: off.*?\\n\\s*(#|//)\\s*typos: on" 10 | ] 11 | -------------------------------------------------------------------------------- /docs/example_project.rst: -------------------------------------------------------------------------------- 1 | Example Project 2 | =============== 3 | 4 | The django-htmx repository contains an `example project `__, demonstrating use of django-htmx. 5 | Run it locally and check out its source code to learn about using htmx with Django, and how django-htmx can help. 6 | -------------------------------------------------------------------------------- /example/example/templatetags/example_tags.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any 5 | 6 | from django import template 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.filter 12 | def json_dumps(value: Any) -> str: 13 | return json.dumps(value, indent=2, sort_keys=True) 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request an enhancement or new feature. 3 | body: 4 | - type: textarea 5 | id: description 6 | attributes: 7 | label: Description 8 | description: Please describe your feature request with appropriate detail. 9 | validations: 10 | required: true 11 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --universal requirements.in -o requirements.txt 3 | asgiref==3.8.1 4 | # via django 5 | django==5.2 6 | # via 7 | # -r requirements.in 8 | # django-template-partials 9 | django-template-partials==24.4 10 | # via -r requirements.in 11 | faker==37.1.0 12 | # via -r requirements.in 13 | sqlparse==0.5.3 14 | # via django 15 | tzdata==2025.2 16 | # via 17 | # django 18 | # faker 19 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | SECRET_KEY = "NOTASECRET" 6 | 7 | ALLOWED_HOSTS: list[str] = [] 8 | 9 | DATABASES: dict[str, dict[str, Any]] = {} 10 | 11 | INSTALLED_APPS = [ 12 | "django_htmx", 13 | ] 14 | 15 | MIDDLEWARE: list[str] = [] 16 | 17 | TEMPLATES = [ 18 | { 19 | "BACKEND": "django.template.backends.django.DjangoTemplates", 20 | "DIRS": [], 21 | "OPTIONS": {"context_processors": []}, 22 | } 23 | ] 24 | 25 | USE_TZ = True 26 | -------------------------------------------------------------------------------- /src/django_htmx/templatetags/django_htmx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.template import Context, Library 4 | 5 | from django_htmx.jinja import django_htmx_script as base_django_htmx_script 6 | from django_htmx.jinja import htmx_script as base_htmx_script 7 | 8 | register = Library() 9 | 10 | 11 | @register.simple_tag(takes_context=True) 12 | def htmx_script(context: Context, minified: bool = True) -> str: 13 | return base_htmx_script(minified=minified, nonce=context.get("csp_nonce")) 14 | 15 | 16 | @register.simple_tag(takes_context=True) 17 | def django_htmx_script(context: Context) -> str: 18 | return base_django_htmx_script(nonce=context.get("csp_nonce")) 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-24.04 9 | tools: 10 | python: "3.14" 11 | jobs: 12 | pre_create_environment: 13 | - asdf plugin add uv 14 | - asdf install uv latest 15 | - asdf global uv latest 16 | create_environment: 17 | - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" 18 | install: 19 | - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs 20 | 21 | sphinx: 22 | configuration: docs/conf.py 23 | fail_on_warning: true 24 | 25 | formats: all 26 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= "-W" 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /example/README.rst: -------------------------------------------------------------------------------- 1 | Example Application 2 | =================== 3 | 4 | Use Python 3.13 to set up and run with these commands: 5 | 6 | .. code-block:: sh 7 | 8 | python -m venv .venv 9 | source .venv/bin/activate 10 | python -m pip install -e .. -r requirements.txt 11 | python manage.py runserver 12 | 13 | Open it at http://127.0.0.1:8000/ . 14 | 15 | Browse the individual examples, and take them apart! 16 | 17 | In your browser’s devtools, you can read the htmx `debug log `__ in your browser’s console, and see the requests made in the network tab. 18 | In the source code, check out the HTML comments via “view source” or templates, and the view code in ``example/views.py``. 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | py314-django{60, 52} 6 | py313-django{60, 52, 51} 7 | py312-django{60, 52, 51, 50, 42} 8 | py311-django{52, 51, 50, 42} 9 | py310-django{52, 51, 50, 42} 10 | 11 | [testenv] 12 | runner = uv-venv-lock-runner 13 | package = wheel 14 | wheel_build_env = .pkg 15 | set_env = 16 | PYTHONDEVMODE = 1 17 | commands = 18 | python \ 19 | -W error::ResourceWarning \ 20 | -W error::DeprecationWarning \ 21 | -W error::PendingDeprecationWarning \ 22 | -m coverage run \ 23 | -m pytest {posargs:tests} 24 | dependency_groups = 25 | test 26 | django42: django42 27 | django50: django50 28 | django51: django51 29 | django52: django52 30 | django60: django60 31 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | from __future__ import annotations 5 | 6 | import os 7 | import sys 8 | 9 | 10 | def main() -> None: 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.urls import path 4 | 5 | from example import views 6 | 7 | urlpatterns = [ 8 | path("", views.index), 9 | path("favicon.ico", views.favicon), 10 | path("csrf-demo/", views.csrf_demo), 11 | path("csrf-demo/checker/", views.csrf_demo_checker), 12 | path("error-demo/", views.error_demo), 13 | path("error-demo/400/", views.error_demo_400), 14 | path("error-demo/403/", views.error_demo_403), 15 | path("error-demo/500/", views.error_demo_500), 16 | path("error-demo/500-custom/", views.error_demo_500_custom), 17 | path("middleware-tester/", views.middleware_tester), 18 | path("middleware-tester/table/", views.middleware_tester_table), 19 | path("partial-rendering/", views.partial_rendering), 20 | ] 21 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-htmx documentation 2 | ========================= 3 | 4 | *Extensions for using Django with* |htmx|__\ *.* 5 | 6 | .. |htmx| replace:: *htmx* 7 | __ https://htmx.org/ 8 | 9 | ---- 10 | 11 | **Improve your Django and Git skills** with `my books `__. 12 | 13 | ---- 14 | 15 | Welcome to the documentation for django-htmx. 16 | This package provides an easy way to include htmx in your Django projects and tools to interact with htmx’s HTTP extensions. 17 | 18 | .. toctree:: 19 | :maxdepth: 1 20 | :caption: Contents: 21 | 22 | installation 23 | middleware 24 | template_tags 25 | http 26 | example_project 27 | tips 28 | changelog 29 | 30 | 31 | Indices and tables 32 | ================== 33 | 34 | * :ref:`genindex` 35 | * :ref:`modindex` 36 | * :ref:`search` 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.yml: -------------------------------------------------------------------------------- 1 | name: Issue 2 | description: File an issue 3 | body: 4 | - type: input 5 | id: python_version 6 | attributes: 7 | label: Python Version 8 | description: Which version of Python were you using? 9 | placeholder: 3.14.0 10 | validations: 11 | required: false 12 | - type: input 13 | id: django_version 14 | attributes: 15 | label: Django Version 16 | description: Which version of Django were you using? 17 | placeholder: 3.2.0 18 | validations: 19 | required: false 20 | - type: input 21 | id: package_version 22 | attributes: 23 | label: Package Version 24 | description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved. 25 | placeholder: 1.0.0 26 | validations: 27 | required: false 28 | - type: textarea 29 | id: description 30 | attributes: 31 | label: Description 32 | description: Please describe your issue. 33 | validations: 34 | required: true 35 | -------------------------------------------------------------------------------- /src/django_htmx/static/django_htmx/django-htmx.js: -------------------------------------------------------------------------------- 1 | { 2 | const data = document.currentScript.dataset; 3 | const isDebug = data.debug === "True"; 4 | 5 | if (isDebug) { 6 | document.addEventListener("htmx:beforeOnLoad", function (event) { 7 | const xhr = event.detail.xhr; 8 | if (xhr.status == 400 || xhr.status == 403 || xhr.status == 404 || xhr.status == 500 ) { 9 | // Tell htmx to stop processing this response 10 | event.stopPropagation(); 11 | 12 | document.children[0].innerHTML = xhr.response; 13 | 14 | // Run inline scripts, which Django’s error pages use 15 | for (const script of document.scripts) { 16 | // (1, eval) wtf - see https://stackoverflow.com/questions/9107240/1-evalthis-vs-evalthis-in-javascript 17 | (1, eval)(script.innerText); 18 | } 19 | 20 | // Run window.onload function if defined, which Django’s error pages use 21 | if (typeof window.onload === "function") { 22 | window.onload(); 23 | } 24 | } 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/download_htmx_extensions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Download the htmx version and the extensions we're using. 4 | 5 | This is only intended for maintaining the example app. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import subprocess 11 | from pathlib import Path 12 | 13 | ext_dir = Path(__file__).parent.resolve() / "example/static/ext" 14 | 15 | 16 | def main() -> int: 17 | # Per: https://github.com/bigskysoftware/htmx-extensions/tree/main/src/event-header 18 | download_file( 19 | "https://unpkg.com/htmx-ext-event-header/event-header.js", 20 | ext_dir / "event-header.js", 21 | ) 22 | 23 | print("✅") 24 | return 0 25 | 26 | 27 | def download_file(url: str, destination: Path) -> None: 28 | print(f"{destination.name}...") 29 | subprocess.run( 30 | [ 31 | "curl", 32 | "--fail", 33 | "--location", 34 | url, 35 | "-o", 36 | str(destination), 37 | ], 38 | ) 39 | 40 | 41 | if __name__ == "__main__": 42 | raise SystemExit(main()) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/example/static/ext/event-header.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | function stringifyEvent(event) { 3 | var obj = {} 4 | for (var key in event) { 5 | obj[key] = event[key] 6 | } 7 | return JSON.stringify(obj, function(key, value) { 8 | if (value instanceof Node) { 9 | var nodeRep = value.tagName 10 | if (nodeRep) { 11 | nodeRep = nodeRep.toLowerCase() 12 | if (value.id) { 13 | nodeRep += '#' + value.id 14 | } 15 | if (value.classList && value.classList.length) { 16 | nodeRep += '.' + value.classList.toString().replace(' ', '.') 17 | } 18 | return nodeRep 19 | } else { 20 | return 'Node' 21 | } 22 | } 23 | if (value instanceof Window) return 'Window' 24 | return value 25 | }) 26 | } 27 | 28 | htmx.defineExtension('event-header', { 29 | onEvent: function(name, evt) { 30 | if (name === 'htmx:configRequest') { 31 | if (evt.detail.triggeringEvent) { 32 | evt.detail.headers['Triggering-Event'] = stringifyEvent(evt.detail.triggeringEvent) 33 | } 34 | } 35 | } 36 | }) 37 | })() 38 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from pathlib import Path 5 | 6 | from django.test import SimpleTestCase 7 | 8 | 9 | class TemplateTagsTests(SimpleTestCase): 10 | def test_htmx_versions_match(self): 11 | base_dir = Path(__file__).resolve().parent.parent 12 | htmx_js_path = base_dir / "src/django_htmx/static/django_htmx/htmx.js" 13 | htmx_min_js_path = base_dir / "src/django_htmx/static/django_htmx/htmx.min.js" 14 | scripts_rst_path = base_dir / "docs/template_tags.rst" 15 | 16 | htmx_js_version = read_version( 17 | htmx_js_path, 18 | r"version: '(\d+\.\d+\.\d+)'", 19 | ) 20 | htmx_min_js_version = read_version( 21 | htmx_min_js_path, r'version:"(\d+\.\d+\.\d+)"' 22 | ) 23 | scripts_rst_version = read_version( 24 | scripts_rst_path, 25 | r"The current vendored version of htmx is (\d+\.\d+\.\d+)\.", 26 | ) 27 | 28 | assert htmx_js_version == htmx_min_js_version 29 | assert htmx_js_version == scripts_rst_version 30 | 31 | 32 | def read_version(path: Path, regex: str) -> str: 33 | content = path.read_text() 34 | match = re.search(regex, content) 35 | assert match 36 | return match[1] 37 | -------------------------------------------------------------------------------- /download_htmx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Download htmx to django_htmx/static/htmx.min.js. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import argparse 9 | import subprocess 10 | from pathlib import Path 11 | 12 | static_dir = Path(__file__).parent.resolve() / "src/django_htmx/static/django_htmx/" 13 | 14 | 15 | def main() -> int: 16 | parser = argparse.ArgumentParser() 17 | parser.add_argument("version", help="The version of htmx to download, e.g. 2.0.4") 18 | args = parser.parse_args() 19 | # Per: https://htmx.org/docs/#installing 20 | download_file( 21 | f"https://unpkg.com/htmx.org@{args.version}/dist/htmx.js", 22 | static_dir / "htmx.js", 23 | ) 24 | download_file( 25 | f"https://unpkg.com/htmx.org@{args.version}/dist/htmx.min.js", 26 | static_dir / "htmx.min.js", 27 | ) 28 | print("✅") 29 | return 0 30 | 31 | 32 | def download_file(url: str, destination: Path) -> None: 33 | print(f"{destination.name}...") 34 | subprocess.run( 35 | [ 36 | "curl", 37 | "--fail", 38 | "--location", 39 | url, 40 | "-o", 41 | str(destination), 42 | ], 43 | check=True, 44 | ) 45 | 46 | 47 | if __name__ == "__main__": 48 | raise SystemExit(main()) 49 | -------------------------------------------------------------------------------- /example/example/templates/csrf-demo.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block main %} 4 |
5 |

6 | This form shows you how to implement CSRF with htmx, using the hx-headers attribute. 7 |

8 |
9 |
10 |

11 | View the source to see how it works! 12 |

13 |
14 |
15 | 24 |
27 | 30 | 34 | 37 |
38 |
39 |
40 |

Awaiting interaction...

41 |
42 | {% endblock main %} 43 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | django-htmx 3 | =========== 4 | 5 | .. image:: https://img.shields.io/readthedocs/django-htmx?style=for-the-badge 6 | :target: https://django-htmx.readthedocs.io/en/latest/ 7 | 8 | .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/django-htmx/main.yml.svg?branch=main&style=for-the-badge 9 | :target: https://github.com/adamchainz/django-htmx/actions?workflow=CI 10 | 11 | .. image:: https://img.shields.io/badge/Coverage-100%25-success?style=for-the-badge 12 | :target: https://github.com/adamchainz/django-htmx/actions?workflow=CI 13 | 14 | .. image:: https://img.shields.io/pypi/v/django-htmx.svg?style=for-the-badge 15 | :target: https://pypi.org/project/django-htmx/ 16 | 17 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge 18 | :target: https://github.com/psf/black 19 | 20 | .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge 21 | :target: https://github.com/pre-commit/pre-commit 22 | :alt: pre-commit 23 | 24 | ---- 25 | 26 | .. figure:: https://raw.githubusercontent.com/adamchainz/django-htmx/main/docs/_static/logo.svg 27 | :alt: django-htmx logo 28 | :align: center 29 | 30 | Extensions for using Django with `htmx `__. 31 | 32 | Documentation 33 | ------------- 34 | 35 | Please see https://django-htmx.readthedocs.io/. 36 | -------------------------------------------------------------------------------- /example/example/templates/error-demo.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block main %} 4 |
5 |

6 | This page shows you the django-htmx extension script error handler in action. 7 |

8 |
9 |
10 |

11 | See more in the docs. 12 |

13 |
14 |
15 |

16 | {% if DEBUG %} 17 | The error handler will work, since DEBUG = True. 18 | {% else %} 19 | The error handler will not work, since DEBUG = False. 20 | {% endif %} 21 |

22 |
23 |
24 | 27 |
28 |
29 | 32 |
33 |
34 | 37 |
38 |
39 | 42 |
43 |
44 | 47 |
48 | {% endblock main %} 49 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | # Hide development server warning 8 | # https://docs.djangoproject.com/en/stable/ref/django-admin/#envvar-DJANGO_RUNSERVER_HIDE_WARNING 9 | os.environ["DJANGO_RUNSERVER_HIDE_WARNING"] = "true" 10 | 11 | BASE_DIR = Path(__file__).parent 12 | 13 | DEBUG = True 14 | 15 | SECRET_KEY = ")w%-67b9lurhzs*o2ow(e=n_^(n2!0_f*2+g+1*9tcn6_k58(f" 16 | 17 | # Dangerous: disable host header validation 18 | ALLOWED_HOSTS = ["*"] 19 | 20 | INSTALLED_APPS = [ 21 | "example", 22 | "django_htmx", 23 | "template_partials", 24 | "django.contrib.staticfiles", 25 | ] 26 | 27 | MIDDLEWARE = [ 28 | "django.middleware.csrf.CsrfViewMiddleware", 29 | "django_htmx.middleware.HtmxMiddleware", 30 | ] 31 | 32 | ROOT_URLCONF = "example.urls" 33 | 34 | DATABASES: dict[str, dict[str, Any]] = {} 35 | 36 | TEMPLATES = [ 37 | { 38 | "BACKEND": "django.template.backends.django.DjangoTemplates", 39 | "DIRS": [BASE_DIR / "templates"], 40 | "APP_DIRS": True, 41 | "OPTIONS": { 42 | "context_processors": [ 43 | "django.template.context_processors.request", 44 | "example.context_processors.debug", 45 | ] 46 | }, 47 | } 48 | ] 49 | 50 | USE_TZ = True 51 | 52 | 53 | STATIC_URL = "/static/" 54 | STATICFILES_DIRS = [BASE_DIR / "static"] 55 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Requirements 5 | ------------ 6 | 7 | Python 3.10 to 3.14 supported. 8 | 9 | Django 4.2 to 6.0 supported. 10 | 11 | Installation 12 | ------------ 13 | 14 | 1. Install with **pip**: 15 | 16 | .. code-block:: sh 17 | 18 | python -m pip install django-htmx 19 | 20 | 2. Add django-htmx to your ``INSTALLED_APPS``: 21 | 22 | .. code-block:: python 23 | 24 | INSTALLED_APPS = [ 25 | ..., 26 | "django_htmx", 27 | ..., 28 | ] 29 | 30 | 3. (Optional) Add the middleware: 31 | 32 | .. code-block:: python 33 | 34 | MIDDLEWARE = [ 35 | ..., 36 | "django_htmx.middleware.HtmxMiddleware", 37 | ..., 38 | ] 39 | 40 | The middleware adds ``request.htmx``, as described in :doc:`middleware`. 41 | 42 | 4. (Optional) Update your base template to: 43 | 44 | 1. Add htmx and the django-htmx extension script to your pages with a :doc:`template tag `, available for Django templates and Jinja2. 45 | 2. Add Django’s CSRF token to all htmx requests, so POST requests work, per :ref:`this tip `. 46 | 47 | In the typical case, with Django templates: 48 | 49 | .. code-block:: django 50 | :emphasize-lines: 1,6,8 51 | 52 | {% load django_htmx %} 53 | 54 | 55 | 56 | ... 57 | {% htmx_script %} 58 | 59 | 60 | ... 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/django_htmx/jinja.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | from django.templatetags.static import static 5 | from django.utils.html import format_html 6 | from django.utils.safestring import SafeString, mark_safe 7 | 8 | 9 | def htmx_script(*, minified: bool = True, nonce: str | None = None) -> SafeString: 10 | path = f"django_htmx/htmx{'.min' if minified else ''}.js" 11 | if nonce is not None: 12 | result = format_html( 13 | '', 14 | static(path), 15 | nonce, 16 | ) 17 | else: 18 | result = format_html( 19 | '', 20 | static(path), 21 | ) 22 | if settings.DEBUG: 23 | result += django_htmx_script(nonce=nonce) 24 | return result 25 | 26 | 27 | def django_htmx_script(*, nonce: str | None = None) -> SafeString: 28 | # Optimization: whilst the script has no behaviour outside of debug mode, 29 | # don't include it. 30 | if not settings.DEBUG: 31 | return mark_safe("") 32 | if nonce is not None: 33 | return format_html( 34 | '', 35 | static("django_htmx/django-htmx.js"), 36 | str(bool(settings.DEBUG)), 37 | nonce, 38 | ) 39 | else: 40 | return format_html( 41 | '', 42 | static("django_htmx/django-htmx.js"), 43 | str(bool(settings.DEBUG)), 44 | ) 45 | -------------------------------------------------------------------------------- /example/example/templates/_base.html: -------------------------------------------------------------------------------- 1 | {% load django_htmx static %} 2 | 3 | 4 | 5 | 6 | 7 | django-htmx example app 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% htmx_script %} 15 | 16 | 17 | 18 | 19 | 23 | 24 |
25 | 46 |
47 |
48 | {% block main %}{% endblock %} 49 |
50 | 56 | {% django_htmx_script %} 57 | 58 | 59 | -------------------------------------------------------------------------------- /example/example/templates/middleware-tester.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | 3 | {% block main %} 4 |
5 | 11 |
15 |

16 | The below form controls implement different patterns with HTMX. 17 | Interact with them to trigger requests that will render a table showing the Django request attributes added and changed by HtmxMiddleware. 18 |

19 |

20 | 24 |

25 |

26 | 31 |

32 |

33 | 40 |

41 |
42 |
43 |
44 |
45 |

Awaiting interaction...

46 |
47 | {% endblock main %} 48 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | 4 | default_language_version: 5 | python: python3.13 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 10 | hooks: 11 | - id: check-added-large-files 12 | - id: check-case-conflict 13 | - id: check-json 14 | - id: check-merge-conflict 15 | - id: check-symlinks 16 | - id: check-toml 17 | - id: end-of-file-fixer 18 | exclude: | 19 | (?x)^( 20 | example/example/static/ext/debug\.js 21 | |src/django_htmx/static/django_htmx/htmx\.min\.js 22 | )$ 23 | - id: trailing-whitespace 24 | - repo: https://github.com/crate-ci/typos 25 | rev: 802d5794ff9cf7b15610c47eca99cd1ab757d8d4 # frozen: v1 26 | hooks: 27 | - id: typos 28 | exclude: | 29 | (?x)^( 30 | .*\.min\.js 31 | |.*\.svg 32 | )$ 33 | - repo: https://github.com/tox-dev/pyproject-fmt 34 | rev: d252a2a7678b47d1f2eea2f6b846ddfdcd012759 # frozen: v2.11.1 35 | hooks: 36 | - id: pyproject-fmt 37 | - repo: https://github.com/tox-dev/tox-ini-fmt 38 | rev: be26ee0d710a48f7c1acc1291d84082036207bd3 # frozen: 1.7.0 39 | hooks: 40 | - id: tox-ini-fmt 41 | - repo: https://github.com/rstcheck/rstcheck 42 | rev: 27258fde1ee7d3b1e6a7bbc58f4c7b1dd0e719e5 # frozen: v6.2.5 43 | hooks: 44 | - id: rstcheck 45 | additional_dependencies: 46 | - sphinx==8.1.3 47 | - tomli==2.2.1 48 | - repo: https://github.com/sphinx-contrib/sphinx-lint 49 | rev: c883505f64b59c3c5c9375191e4ad9f98e727ccd # frozen: v1.0.2 50 | hooks: 51 | - id: sphinx-lint 52 | - repo: https://github.com/adamchainz/django-upgrade 53 | rev: 553731fe59437e0bd2cf18b10144116422bed259 # frozen: 1.29.1 54 | hooks: 55 | - id: django-upgrade 56 | - repo: https://github.com/adamchainz/blacken-docs 57 | rev: dda8db18cfc68df532abf33b185ecd12d5b7b326 # frozen: 1.20.0 58 | hooks: 59 | - id: blacken-docs 60 | additional_dependencies: 61 | - black==25.1.0 62 | - repo: https://github.com/astral-sh/ruff-pre-commit 63 | rev: 36243b70e5ce219623c3503f5afba0f8c96fda55 # frozen: v0.14.7 64 | hooks: 65 | - id: ruff-check 66 | args: [ --fix ] 67 | - id: ruff-format 68 | - repo: https://github.com/pre-commit/mirrors-mypy 69 | rev: c2738302f5cf2bfb559c1f210950badb133613ea # frozen: v1.19.0 70 | hooks: 71 | - id: mypy 72 | additional_dependencies: 73 | - django-stubs==5.1.2 74 | - types-python-dateutil 75 | - repo: https://github.com/adamchainz/djade-pre-commit 76 | rev: 47481957f135f6af9121b2af9c415155d260cc8e # frozen: 1.6.0 77 | hooks: 78 | - id: djade 79 | -------------------------------------------------------------------------------- /example/example/templates/middleware-tester-table.html: -------------------------------------------------------------------------------- 1 | {% load example_tags %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 62 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
AttributeValue
Timestamp{{ timestamp }}
request.method{{ request.method|stringformat:'r' }}
bool(request.htmx) 22 | {% if request.htmx %} 23 | True 24 | {% else %} 25 | For 26 | {% endif %} 27 |
request.htmx.boosted{{ request.htmx.boosted|stringformat:'r' }}
request.htmx.current_url{{ request.htmx.current_url|stringformat:'r' }}
request.htmx.current_url_abs_path{{ request.htmx.current_url_abs_path|stringformat:'r' }}
request.htmx.prompt{{ request.htmx.prompt|stringformat:'r' }}
request.htmx.target{{ request.htmx.target|stringformat:'r' }}
request.htmx.trigger{{ request.htmx.trigger|stringformat:'r' }}
request.htmx.trigger_name{{ request.htmx.trigger_name|stringformat:'r' }}
59 | request.htmx.triggering_event
60 | (via event-header extension) 61 |
63 | {% if request.htmx.triggering_event %} 64 |
65 | JSON 66 |
{{ request.htmx.triggering_event|json_dumps }}
67 |
68 | {% else %} 69 | {{ request.htmx.triggering_event|stringformat:'r' }} 70 | {% endif %} 71 |
request.POST.get('keyup_input'){{ request.POST.keyup_input|stringformat:'r' }}
79 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: 10 | 11 | concurrency: 12 | group: ${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | tests: 17 | name: Python ${{ matrix.python-version }} 18 | runs-on: ubuntu-24.04 19 | 20 | strategy: 21 | matrix: 22 | python-version: 23 | - '3.10' 24 | - '3.11' 25 | - '3.12' 26 | - '3.13' 27 | - '3.14' 28 | 29 | steps: 30 | - uses: actions/checkout@v6 31 | 32 | - uses: actions/setup-python@v6 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | allow-prereleases: true 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v7 39 | with: 40 | enable-cache: true 41 | 42 | - name: Run tox targets for ${{ matrix.python-version }} 43 | run: uvx --with tox-uv tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) 44 | 45 | - name: Upload coverage data 46 | uses: actions/upload-artifact@v5 47 | with: 48 | name: coverage-data-${{ matrix.python-version }} 49 | path: '${{ github.workspace }}/.coverage.*' 50 | include-hidden-files: true 51 | if-no-files-found: error 52 | 53 | coverage: 54 | name: Coverage 55 | runs-on: ubuntu-24.04 56 | needs: tests 57 | steps: 58 | - uses: actions/checkout@v6 59 | 60 | - uses: actions/setup-python@v6 61 | with: 62 | python-version: '3.13' 63 | 64 | - name: Install uv 65 | uses: astral-sh/setup-uv@v7 66 | 67 | - name: Install dependencies 68 | run: uv pip install --system coverage[toml] 69 | 70 | - name: Download data 71 | uses: actions/download-artifact@v6 72 | with: 73 | path: ${{ github.workspace }} 74 | pattern: coverage-data-* 75 | merge-multiple: true 76 | 77 | - name: Combine coverage and fail if it's <100% 78 | run: | 79 | python -m coverage combine 80 | python -m coverage html --skip-covered --skip-empty 81 | python -m coverage report --fail-under=100 82 | echo "## Coverage summary" >> $GITHUB_STEP_SUMMARY 83 | python -m coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 84 | 85 | - name: Upload HTML report 86 | if: ${{ failure() }} 87 | uses: actions/upload-artifact@v5 88 | with: 89 | name: html-report 90 | path: htmlcov 91 | 92 | release: 93 | needs: [coverage] 94 | if: success() && startsWith(github.ref, 'refs/tags/') 95 | runs-on: ubuntu-24.04 96 | environment: release 97 | 98 | permissions: 99 | contents: read 100 | id-token: write 101 | 102 | steps: 103 | - uses: actions/checkout@v6 104 | 105 | - uses: astral-sh/setup-uv@v7 106 | 107 | - name: Build 108 | run: uv build 109 | 110 | - uses: pypa/gh-action-pypi-publish@release/v1 111 | -------------------------------------------------------------------------------- /tests/test_jinja.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import secrets 4 | 5 | import django 6 | import pytest 7 | from django.test import SimpleTestCase, override_settings 8 | 9 | from django_htmx.jinja import django_htmx_script, htmx_script 10 | 11 | 12 | class HtmxScriptTests(SimpleTestCase): 13 | def test_default(self): 14 | result = htmx_script() 15 | 16 | assert result == '' 17 | 18 | @pytest.mark.skipif(django.VERSION < (6, 0), reason="Django 6.0+") 19 | def test_default_nonce(self): 20 | from django.utils.csp import LazyNonce 21 | 22 | nonce = LazyNonce() 23 | 24 | result = htmx_script(nonce=nonce) 25 | 26 | assert ( 27 | result 28 | == f'' 29 | ) 30 | 31 | def test_debug(self): 32 | with override_settings(DEBUG=True): 33 | result = htmx_script() 34 | 35 | assert result == ( 36 | '' 37 | + '' 38 | ) 39 | 40 | @pytest.mark.skipif(django.VERSION < (6, 0), reason="Django 6.0+") 41 | def test_debug_nonce(self): 42 | from django.utils.csp import LazyNonce 43 | 44 | nonce = LazyNonce() 45 | 46 | with override_settings(DEBUG=True): 47 | result = htmx_script(nonce=nonce) 48 | 49 | assert result == ( 50 | f'' 51 | + f'' 52 | ) 53 | 54 | def test_unminified(self): 55 | result = htmx_script(minified=False) 56 | 57 | assert result == '' 58 | 59 | def test_unminified_nonce(self): 60 | nonce = secrets.token_urlsafe(16) 61 | 62 | result = htmx_script(minified=False, nonce=nonce) 63 | 64 | assert ( 65 | result 66 | == f'' 67 | ) 68 | 69 | 70 | class DjangoHtmxScriptTests(SimpleTestCase): 71 | def test_non_debug_empty(self): 72 | result = django_htmx_script() 73 | 74 | assert result == "" 75 | 76 | def test_debug(self): 77 | with override_settings(DEBUG=True): 78 | result = django_htmx_script() 79 | 80 | assert result == ( 81 | '' 82 | ) 83 | 84 | @pytest.mark.skipif(django.VERSION < (6, 0), reason="Django 6.0+") 85 | def test_debug_nonce(self): 86 | from django.utils.csp import LazyNonce 87 | 88 | nonce = LazyNonce() 89 | 90 | with override_settings(DEBUG=True): 91 | result = django_htmx_script(nonce=nonce) 92 | 93 | assert result == ( 94 | f'' 95 | ) 96 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | from __future__ import annotations 7 | 8 | import os 9 | import sys 10 | from pathlib import Path 11 | 12 | import tomllib 13 | 14 | # -- Path setup -------------------------------------------------------------- 15 | 16 | here = Path(__file__).parent.resolve() 17 | sys.path.insert(0, str(here / ".." / "src")) 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | with (here / ".." / "pyproject.toml").open("rb") as fp: 22 | pyproject_toml_data = tomllib.load(fp) 23 | 24 | project = pyproject_toml_data["project"]["name"] 25 | copyright = "2020 Adam Johnson" 26 | author = "Adam Johnson" 27 | 28 | # The version info for the project you're documenting, acts as replacement 29 | # for |version| and |release|, also used in various other places throughout 30 | # the built documents. 31 | 32 | version = pyproject_toml_data["project"]["version"] 33 | release = version 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.intersphinx", 43 | "sphinx.ext.viewcode", 44 | "sphinx_copybutton", 45 | ] 46 | if os.environ.get("READTHEDOCS") == "True": 47 | extensions.append("sphinx_build_compatibility.extension") 48 | 49 | # List of patterns, relative to source directory, that match files and 50 | # directories to ignore when looking for source files. 51 | exclude_patterns = [ 52 | ".venv", 53 | "_build", 54 | ] 55 | 56 | autodoc_typehints = "description" 57 | 58 | # -- Options for HTML output ------------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_logo = "_static/logo.svg" 64 | html_theme = "furo" 65 | html_theme_options = { 66 | "dark_css_variables": { 67 | "admonition-font-size": "100%", 68 | "admonition-title-font-size": "100%", 69 | }, 70 | "light_css_variables": { 71 | "admonition-font-size": "100%", 72 | "admonition-title-font-size": "100%", 73 | }, 74 | } 75 | 76 | # -- Options for LaTeX output ------------------------------------------ 77 | 78 | # Grouping the document tree into LaTeX files. List of tuples 79 | # (source start file, target name, title, author, documentclass 80 | # [howto/manual]). 81 | latex_documents = [ 82 | ( 83 | "index", 84 | "django-htmx.tex", 85 | "django-htmx Documentation", 86 | "Adam Johnson", 87 | "manual", 88 | ), 89 | ] 90 | 91 | # -- Options for Intersphinx ------------------------------------------- 92 | 93 | intersphinx_mapping = { 94 | "django": ( 95 | "https://docs.djangoproject.com/en/stable/", 96 | "https://docs.djangoproject.com/en/stable/_objects/", 97 | ), 98 | } 99 | -------------------------------------------------------------------------------- /example/example/templates/partial-rendering.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load partials %} 3 | 4 | {% block main %} 5 |
6 |

7 | This example shows you how you can do partial rendering for htmx requests using django-template-partials. 8 | The view renders only the content of the table section partial for requests made with htmx, saving time and bandwidth. 9 | Paginate through the below list of randomly generated people to see this in action, and study the view and template. 10 |

11 |

12 | See more in the docs. 13 |

14 |
15 | 16 | {% partialdef table-section inline %} 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for person in page.object_list %} 28 | 29 | 30 | 31 | 32 | {% empty %} 33 | 34 | 37 | 38 | {% endfor %} 39 | 40 |
idname
{{ person.id }}{{ person.name }}
35 | No people on this page. 36 |
41 |
42 | 43 |
44 | 52 | 93 |
94 |
95 | {% endpartialdef %} 96 | 97 | {% endblock main %} 98 | -------------------------------------------------------------------------------- /tests/templatetags/test_django_htmx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import secrets 4 | 5 | import django 6 | import pytest 7 | from django.template import Context, Template 8 | from django.test import SimpleTestCase, override_settings 9 | 10 | 11 | class HtmxScriptTests(SimpleTestCase): 12 | def test_default(self): 13 | result = Template("{% load django_htmx %}{% htmx_script %}").render(Context()) 14 | 15 | assert result == '' 16 | 17 | @pytest.mark.skipif(django.VERSION < (6, 0), reason="Django 6.0+") 18 | def test_default_nonce(self): 19 | from django.utils.csp import LazyNonce 20 | 21 | nonce = LazyNonce() 22 | result = Template("{% load django_htmx %}{% htmx_script %}").render( 23 | Context({"csp_nonce": nonce}) 24 | ) 25 | 26 | assert ( 27 | result 28 | == f'' 29 | ) 30 | 31 | def test_debug(self): 32 | with override_settings(DEBUG=True): 33 | result = Template("{% load django_htmx %}{% htmx_script %}").render( 34 | Context() 35 | ) 36 | 37 | assert result == ( 38 | '' 39 | + '' 40 | ) 41 | 42 | def test_debug_nonce(self): 43 | nonce = secrets.token_urlsafe(16) 44 | with override_settings(DEBUG=True): 45 | result = Template("{% load django_htmx %}{% htmx_script %}").render( 46 | Context({"csp_nonce": nonce}) 47 | ) 48 | 49 | assert result == ( 50 | f'' 51 | + f'' 52 | ) 53 | 54 | def test_unminified(self): 55 | result = Template( 56 | "{% load django_htmx %}{% htmx_script minified=False %}" 57 | ).render(Context()) 58 | 59 | assert result == '' 60 | 61 | def test_unminified_nonce(self): 62 | nonce = secrets.token_urlsafe(16) 63 | result = Template( 64 | "{% load django_htmx %}{% htmx_script minified=False %}" 65 | ).render(Context({"csp_nonce": nonce})) 66 | 67 | assert ( 68 | result 69 | == f'' 70 | ) 71 | 72 | 73 | class DjangoHtmxScriptTests(SimpleTestCase): 74 | def test_non_debug_empty(self): 75 | result = Template("{% load django_htmx %}{% django_htmx_script %}").render( 76 | Context() 77 | ) 78 | 79 | assert result == "" 80 | 81 | def test_debug(self): 82 | with override_settings(DEBUG=True): 83 | result = Template("{% load django_htmx %}{% django_htmx_script %}").render( 84 | Context() 85 | ) 86 | 87 | assert result == ( 88 | '' 89 | ) 90 | 91 | @pytest.mark.skipif(django.VERSION < (6, 0), reason="Django 6.0+") 92 | def test_debug_nonce(self): 93 | from django.utils.csp import LazyNonce 94 | 95 | nonce = LazyNonce() 96 | with override_settings(DEBUG=True): 97 | result = Template("{% load django_htmx %}{% django_htmx_script %}").render( 98 | Context({"csp_nonce": nonce}) 99 | ) 100 | 101 | assert result == ( 102 | f'' 103 | ) 104 | -------------------------------------------------------------------------------- /src/django_htmx/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from collections.abc import Awaitable, Callable 5 | from typing import Any 6 | from urllib.parse import unquote, urlsplit, urlunsplit 7 | 8 | from asgiref.sync import iscoroutinefunction, markcoroutinefunction 9 | from django.http import HttpRequest 10 | from django.http.response import HttpResponseBase 11 | from django.utils.functional import cached_property 12 | 13 | 14 | class HtmxMiddleware: 15 | sync_capable = True 16 | async_capable = True 17 | 18 | def __init__( 19 | self, 20 | get_response: ( 21 | Callable[[HttpRequest], HttpResponseBase] 22 | | Callable[[HttpRequest], Awaitable[HttpResponseBase]] 23 | ), 24 | ) -> None: 25 | self.get_response = get_response 26 | self.async_mode = iscoroutinefunction(self.get_response) 27 | 28 | if self.async_mode: 29 | # Mark the class as async-capable, but do the actual switch 30 | # inside __call__ to avoid swapping out dunder methods 31 | markcoroutinefunction(self) 32 | 33 | def __call__( 34 | self, request: HttpRequest 35 | ) -> HttpResponseBase | Awaitable[HttpResponseBase]: 36 | if self.async_mode: 37 | return self.__acall__(request) 38 | request.htmx = HtmxDetails(request) # type: ignore [attr-defined] 39 | return self.get_response(request) 40 | 41 | async def __acall__(self, request: HttpRequest) -> HttpResponseBase: 42 | request.htmx = HtmxDetails(request) # type: ignore [attr-defined] 43 | return await self.get_response(request) # type: ignore [no-any-return, misc] 44 | 45 | 46 | class HtmxDetails: 47 | def __init__(self, request: HttpRequest) -> None: 48 | self.request = request 49 | 50 | def _get_header_value(self, name: str) -> str | None: 51 | value = self.request.headers.get(name) or None 52 | if value and self.request.headers.get(f"{name}-URI-AutoEncoded") == "true": 53 | value = unquote(value) 54 | return value 55 | 56 | def __bool__(self) -> bool: 57 | return self._get_header_value("HX-Request") == "true" 58 | 59 | @cached_property 60 | def boosted(self) -> bool: 61 | return self._get_header_value("HX-Boosted") == "true" 62 | 63 | @cached_property 64 | def current_url(self) -> str | None: 65 | return self._get_header_value("HX-Current-URL") 66 | 67 | @cached_property 68 | def current_url_abs_path(self) -> str | None: 69 | url = self.current_url 70 | if url is not None: 71 | split = urlsplit(url) 72 | if ( 73 | split.scheme == self.request.scheme 74 | and split.netloc == self.request.get_host() 75 | ): 76 | url = urlunsplit(split._replace(scheme="", netloc="")) 77 | else: 78 | url = None 79 | return url 80 | 81 | @cached_property 82 | def history_restore_request(self) -> bool: 83 | return self._get_header_value("HX-History-Restore-Request") == "true" 84 | 85 | @cached_property 86 | def prompt(self) -> str | None: 87 | return self._get_header_value("HX-Prompt") 88 | 89 | @cached_property 90 | def target(self) -> str | None: 91 | return self._get_header_value("HX-Target") 92 | 93 | @cached_property 94 | def trigger(self) -> str | None: 95 | return self._get_header_value("HX-Trigger") 96 | 97 | @cached_property 98 | def trigger_name(self) -> str | None: 99 | return self._get_header_value("HX-Trigger-Name") 100 | 101 | @cached_property 102 | def triggering_event(self) -> Any: 103 | value = self._get_header_value("Triggering-Event") 104 | if value is not None: 105 | try: 106 | value = json.loads(value) 107 | except json.JSONDecodeError: 108 | value = None 109 | return value 110 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools>=77", 5 | ] 6 | 7 | [project] 8 | name = "django-htmx" 9 | version = "1.27.0" 10 | description = "Extensions for using Django with htmx." 11 | readme = "README.rst" 12 | keywords = [ 13 | "Django", 14 | ] 15 | license = "MIT" 16 | license-files = [ "LICENSE" ] 17 | authors = [ 18 | { name = "Adam Johnson", email = "me@adamj.eu" }, 19 | ] 20 | requires-python = ">=3.10" 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Framework :: Django :: 4.2", 24 | "Framework :: Django :: 5.0", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | "Framework :: Django :: 6.0", 28 | "Intended Audience :: Developers", 29 | "Natural Language :: English", 30 | "Operating System :: OS Independent", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13", 36 | "Programming Language :: Python :: 3.14", 37 | "Programming Language :: Python :: Implementation :: CPython", 38 | "Typing :: Typed", 39 | ] 40 | dependencies = [ 41 | "asgiref>=3.6", 42 | "django>=4.2", 43 | ] 44 | urls.Changelog = "https://django-htmx.readthedocs.io/en/latest/changelog.html" 45 | urls.Documentation = "https://django-htmx.readthedocs.io/" 46 | urls.Funding = "https://adamj.eu/books/" 47 | urls.Repository = "https://github.com/adamchainz/django-htmx" 48 | 49 | [dependency-groups] 50 | test = [ 51 | "coverage[toml]", 52 | "pytest", 53 | "pytest-django", 54 | "pytest-randomly", 55 | ] 56 | docs = [ 57 | "furo>=2024.8.6", 58 | "sphinx>=7.4.7", 59 | "sphinx-build-compatibility", 60 | "sphinx-copybutton>=0.5.2", 61 | ] 62 | 63 | django42 = [ "django>=4.2a1,<5; python_version>='3.8'" ] 64 | django50 = [ "django>=5a1,<5.1; python_version>='3.10'" ] 65 | django51 = [ "django>=5.1a1,<5.2; python_version>='3.10'" ] 66 | django52 = [ "django>=5.2a1,<6; python_version>='3.10'" ] 67 | django60 = [ "django>=6a1,<6.1; python_version>='3.12'" ] 68 | 69 | [tool.uv] 70 | conflicts = [ 71 | [ 72 | { group = "django42" }, 73 | { group = "django50" }, 74 | { group = "django51" }, 75 | { group = "django52" }, 76 | { group = "django60" }, 77 | ], 78 | ] 79 | 80 | [tool.uv.sources] 81 | sphinx-build-compatibility = { git = "https://github.com/readthedocs/sphinx-build-compatibility", rev = "4f304bd4562cdc96316f4fec82b264ca379d23e0" } 82 | 83 | [tool.ruff] 84 | lint.select = [ 85 | # flake8-bugbear 86 | "B", 87 | # flake8-comprehensions 88 | "C4", 89 | # pycodestyle 90 | "E", 91 | # Pyflakes errors 92 | "F", 93 | # isort 94 | "I", 95 | # flake8-simplify 96 | "SIM", 97 | # flake8-tidy-imports 98 | "TID", 99 | # pyupgrade 100 | "UP", 101 | # Pyflakes warnings 102 | "W", 103 | ] 104 | lint.ignore = [ 105 | # flake8-bugbear opinionated rules 106 | "B9", 107 | # line-too-long 108 | "E501", 109 | # suppressible-exception 110 | "SIM105", 111 | # if-else-block-instead-of-if-exp 112 | "SIM108", 113 | ] 114 | lint.extend-safe-fixes = [ 115 | # non-pep585-annotation 116 | "UP006", 117 | ] 118 | lint.isort.required-imports = [ "from __future__ import annotations" ] 119 | 120 | [tool.pyproject-fmt] 121 | max_supported_python = "3.14" 122 | 123 | [tool.pytest.ini_options] 124 | addopts = """\ 125 | --strict-config 126 | --strict-markers 127 | --ds=tests.settings 128 | """ 129 | django_find_project = false 130 | xfail_strict = true 131 | 132 | [tool.coverage.run] 133 | branch = true 134 | parallel = true 135 | source = [ 136 | "django_htmx", 137 | "tests", 138 | ] 139 | 140 | [tool.coverage.paths] 141 | source = [ 142 | "src", 143 | ".tox/**/site-packages", 144 | ] 145 | 146 | [tool.coverage.report] 147 | show_missing = true 148 | 149 | [tool.mypy] 150 | enable_error_code = [ 151 | "ignore-without-code", 152 | "redundant-expr", 153 | "truthy-bool", 154 | ] 155 | mypy_path = "src/" 156 | namespace_packages = false 157 | strict = true 158 | warn_unreachable = true 159 | 160 | [[tool.mypy.overrides]] 161 | module = "tests.*" 162 | allow_untyped_defs = true 163 | 164 | [tool.rstcheck] 165 | ignore_directives = [ 166 | "autoclass", 167 | "autofunction", 168 | ] 169 | report_level = "ERROR" 170 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from dataclasses import dataclass 5 | 6 | from django.core.exceptions import PermissionDenied, SuspiciousOperation 7 | from django.core.paginator import Paginator 8 | from django.http import HttpRequest, HttpResponse, HttpResponseServerError 9 | from django.shortcuts import render 10 | from django.views.decorators.http import require_GET, require_http_methods, require_POST 11 | from faker import Faker 12 | 13 | from django_htmx.middleware import HtmxDetails 14 | from example.forms import OddNumberForm 15 | 16 | 17 | # Typing pattern recommended by django-stubs: 18 | # https://github.com/typeddjango/django-stubs#how-can-i-create-a-httprequest-thats-guaranteed-to-have-an-authenticated-user 19 | class HtmxHttpRequest(HttpRequest): 20 | htmx: HtmxDetails 21 | 22 | 23 | @require_GET 24 | def index(request: HtmxHttpRequest) -> HttpResponse: 25 | return render(request, "index.html") 26 | 27 | 28 | @require_GET 29 | def favicon(request: HtmxHttpRequest) -> HttpResponse: 30 | return HttpResponse( 31 | ( 32 | '' 33 | + '🦊' 34 | + "" 35 | ), 36 | content_type="image/svg+xml", 37 | ) 38 | 39 | 40 | # CSRF Demo 41 | 42 | 43 | @require_GET 44 | def csrf_demo(request: HtmxHttpRequest) -> HttpResponse: 45 | return render(request, "csrf-demo.html") 46 | 47 | 48 | @require_POST 49 | def csrf_demo_checker(request: HtmxHttpRequest) -> HttpResponse: 50 | form = OddNumberForm(request.POST) 51 | if form.is_valid(): 52 | number = form.cleaned_data["number"] 53 | number_is_odd = number % 2 == 1 54 | else: 55 | number_is_odd = False 56 | return render( 57 | request, 58 | "csrf-demo-checker.html", 59 | {"form": form, "number_is_odd": number_is_odd}, 60 | ) 61 | 62 | 63 | # Error demo 64 | 65 | 66 | @require_GET 67 | def error_demo(request: HtmxHttpRequest) -> HttpResponse: 68 | return render(request, "error-demo.html") 69 | 70 | 71 | @require_GET 72 | def error_demo_400(request: HtmxHttpRequest) -> HttpResponse: 73 | raise SuspiciousOperation("What are you doing??") 74 | 75 | 76 | @require_GET 77 | def error_demo_403(request: HtmxHttpRequest) -> HttpResponse: 78 | raise PermissionDenied("Access denied!") 79 | 80 | 81 | @require_GET 82 | def error_demo_500(request: HtmxHttpRequest) -> HttpResponse: 83 | _ = 1 / 0 84 | return render(request, "error-demo.html") # unreachable 85 | 86 | 87 | @require_GET 88 | def error_demo_500_custom(request: HtmxHttpRequest) -> HttpResponse: 89 | return HttpResponseServerError( 90 | "

😱 Woops

This is our fancy custom 500 page.

" 91 | ) 92 | 93 | 94 | # Middleware tester 95 | 96 | # This uses two views - one to render the form, and the second to render the 97 | # table of attributes. 98 | 99 | 100 | @require_GET 101 | def middleware_tester(request: HtmxHttpRequest) -> HttpResponse: 102 | return render(request, "middleware-tester.html") 103 | 104 | 105 | @require_http_methods(["DELETE", "POST", "PUT"]) 106 | def middleware_tester_table(request: HtmxHttpRequest) -> HttpResponse: 107 | return render( 108 | request, 109 | "middleware-tester-table.html", 110 | {"timestamp": time.time()}, 111 | ) 112 | 113 | 114 | # Partial rendering example 115 | 116 | 117 | # This dataclass acts as a stand-in for a database model - the example app 118 | # avoids having a database for simplicity. 119 | 120 | 121 | @dataclass 122 | class Person: 123 | id: int 124 | name: str 125 | 126 | 127 | faker = Faker() 128 | people = [Person(id=i, name=faker.name()) for i in range(1, 235)] 129 | 130 | 131 | @require_GET 132 | def partial_rendering(request: HtmxHttpRequest) -> HttpResponse: 133 | # Standard Django pagination 134 | page_num = request.GET.get("page", "1") 135 | page = Paginator(object_list=people, per_page=10).get_page(page_num) 136 | 137 | # The htmx magic - render just the `#table-section` partial for htmx 138 | # requests, allowing us to skip rendering the unchanging parts of the 139 | # template. 140 | template_name = "partial-rendering.html" 141 | if request.htmx: 142 | template_name += "#table-section" 143 | 144 | return render( 145 | request, 146 | template_name, 147 | { 148 | "page": page, 149 | }, 150 | ) 151 | -------------------------------------------------------------------------------- /src/django_htmx/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from typing import Any, Literal, TypeVar 5 | 6 | from django.core.serializers.json import DjangoJSONEncoder 7 | from django.http import HttpResponse 8 | from django.http.response import HttpResponseBase, HttpResponseRedirectBase 9 | 10 | HTMX_STOP_POLLING = 286 11 | 12 | SwapMethod = Literal[ 13 | "innerHTML", 14 | "outerHTML", 15 | "beforebegin", 16 | "afterbegin", 17 | "beforeend", 18 | "afterend", 19 | "delete", 20 | "none", 21 | ] 22 | 23 | 24 | class HttpResponseStopPolling(HttpResponse): 25 | status_code = HTMX_STOP_POLLING 26 | 27 | def __init__(self, *args: Any, **kwargs: Any) -> None: 28 | super().__init__(*args, **kwargs) 29 | self._reason_phrase = "Stop Polling" 30 | 31 | 32 | class HttpResponseClientRedirect(HttpResponseRedirectBase): 33 | status_code = 200 34 | 35 | def __init__(self, redirect_to: str, *args: Any, **kwargs: Any) -> None: 36 | if kwargs.get("preserve_request"): 37 | raise ValueError( 38 | "The 'preserve_request' argument is not supported for " 39 | "HttpResponseClientRedirect.", 40 | ) 41 | super().__init__(redirect_to, *args, **kwargs) 42 | self["HX-Redirect"] = self["Location"] 43 | del self["Location"] 44 | 45 | @property 46 | def url(self) -> str: 47 | return self["HX-Redirect"] 48 | 49 | 50 | class HttpResponseClientRefresh(HttpResponse): 51 | def __init__(self) -> None: 52 | super().__init__() 53 | self["HX-Refresh"] = "true" 54 | 55 | 56 | class HttpResponseLocation(HttpResponseRedirectBase): 57 | status_code = 200 58 | 59 | def __init__( 60 | self, 61 | redirect_to: str, 62 | *args: Any, 63 | source: str | None = None, 64 | event: str | None = None, 65 | target: str | None = None, 66 | swap: SwapMethod | None = None, 67 | select: str | None = None, 68 | values: dict[str, str] | None = None, 69 | headers: dict[str, str] | None = None, 70 | **kwargs: Any, 71 | ) -> None: 72 | super().__init__(redirect_to, *args, **kwargs) 73 | spec: dict[str, str | dict[str, str]] = { 74 | "path": self["Location"], 75 | } 76 | del self["Location"] 77 | if source is not None: 78 | spec["source"] = source 79 | if event is not None: 80 | spec["event"] = event 81 | if target is not None: 82 | spec["target"] = target 83 | if swap is not None: 84 | spec["swap"] = swap 85 | if select is not None: 86 | spec["select"] = select 87 | if headers is not None: 88 | spec["headers"] = headers 89 | if values is not None: 90 | spec["values"] = values 91 | self["HX-Location"] = json.dumps(spec) 92 | 93 | 94 | _HttpResponse = TypeVar("_HttpResponse", bound=HttpResponseBase) 95 | 96 | 97 | def push_url(response: _HttpResponse, url: str | Literal[False]) -> _HttpResponse: 98 | response["HX-Push-Url"] = "false" if url is False else url 99 | return response 100 | 101 | 102 | def replace_url(response: _HttpResponse, url: str | Literal[False]) -> _HttpResponse: 103 | response["HX-Replace-Url"] = "false" if url is False else url 104 | return response 105 | 106 | 107 | def reswap(response: _HttpResponse, method: SwapMethod) -> _HttpResponse: 108 | response["HX-Reswap"] = method 109 | return response 110 | 111 | 112 | def retarget(response: _HttpResponse, target: str) -> _HttpResponse: 113 | response["HX-Retarget"] = target 114 | return response 115 | 116 | 117 | def reselect(response: _HttpResponse, selector: str) -> _HttpResponse: 118 | response["HX-Reselect"] = selector 119 | return response 120 | 121 | 122 | def trigger_client_event( 123 | response: _HttpResponse, 124 | name: str, 125 | params: dict[str, Any] | None = None, 126 | *, 127 | after: Literal["receive", "settle", "swap"] = "receive", 128 | encoder: type[json.JSONEncoder] = DjangoJSONEncoder, 129 | ) -> _HttpResponse: 130 | params = params or {} 131 | 132 | if after == "receive": 133 | header = "HX-Trigger" 134 | elif after == "settle": 135 | header = "HX-Trigger-After-Settle" 136 | elif after == "swap": 137 | header = "HX-Trigger-After-Swap" 138 | else: 139 | raise ValueError( 140 | "Value for 'after' must be one of: 'receive', 'settle', or 'swap'." 141 | ) 142 | 143 | if header in response: 144 | value = response[header] 145 | try: 146 | data = json.loads(value) 147 | except json.JSONDecodeError as exc: 148 | raise ValueError(f"{header!r} value should be valid JSON.") from exc 149 | data[name] = params 150 | else: 151 | data = {name: params} 152 | 153 | response[header] = json.dumps(data, cls=encoder) 154 | 155 | return response 156 | -------------------------------------------------------------------------------- /docs/middleware.rst: -------------------------------------------------------------------------------- 1 | Middleware 2 | ========== 3 | 4 | .. currentmodule:: django_htmx.middleware 5 | 6 | .. class:: HtmxMiddleware 7 | 8 | This middleware attaches ``request.htmx``, an instance of :obj:`HtmxDetails` (below). 9 | Your views, and any following middleware, can use ``request.htmx`` to switch behaviour for requests from htmx. 10 | The middleware supports both sync and async modes. 11 | 12 | See it action in the “Middleware Tester” section of the :doc:`example project `. 13 | 14 | .. admonition:: Set the ``Vary`` header for cacheable responses 15 | 16 | If you set HTTP caching headers, ensure any views that switch content with ``request.htmx`` attributes add the appropriate htmx headers to the ``Vary`` header, per Django’s documentation section |Using Vary headers|__. 17 | For example: 18 | 19 | .. |Using Vary headers| replace:: Using ``Vary`` headers 20 | __ https://docs.djangoproject.com/en/stable/topics/cache/#using-vary-headers 21 | 22 | .. code-block:: python 23 | 24 | from django.shortcuts import render 25 | from django.views.decorators.cache import cache_control 26 | from django.views.decorators.vary import vary_on_headers 27 | 28 | 29 | @cache_control(max_age=300) 30 | @vary_on_headers("HX-Request") 31 | def my_view(request): 32 | if request.htmx: 33 | template_name = "partial.html" 34 | else: 35 | template_name = "complete.html" 36 | return render(request, template_name, ...) 37 | 38 | .. hint:: 39 | 40 | If you are type-checking your Django project, declare ``request.htmx`` as below in any custom ``HttpRequest`` classes, per `the pattern in django-stubs `__. 41 | 42 | .. code-block:: python 43 | 44 | from django.http import HttpRequest as HttpRequestBase 45 | from django_htmx.middleware import HtmxDetails 46 | 47 | 48 | class HttpRequest(HttpRequestBase): 49 | htmx: HtmxDetails 50 | 51 | .. class:: HtmxDetails 52 | 53 | This class provides shortcuts for reading the htmx-specific `request headers `__. 54 | 55 | .. automethod:: __bool__ 56 | 57 | ``True`` if the request was made with htmx, otherwise ``False``. 58 | Detected by checking if the ``HX-Request`` header equals ``true``. 59 | 60 | This method allows you to change content for requests made with htmx: 61 | 62 | .. code-block:: python 63 | 64 | from django.shortcuts import render 65 | 66 | 67 | def my_view(request): 68 | if request.htmx: 69 | template_name = "partial.html" 70 | else: 71 | template_name = "complete.html" 72 | return render(request, template_name, ...) 73 | 74 | .. attribute:: boosted 75 | :type: bool 76 | 77 | ``True`` if the request came from an element with the ``hx-boost`` attribute. 78 | Detected by checking if the ``HX-Boosted`` header equals ``true``. 79 | 80 | You can use this attribute to change behaviour for boosted requests: 81 | 82 | .. code-block:: python 83 | 84 | def my_view(request): 85 | if request.htmx.boosted: 86 | # do something special 87 | ... 88 | return render(...) 89 | 90 | .. attribute:: current_url 91 | :type: str | None 92 | 93 | The current URL in the browser that htmx made this request from, or ``None`` for non-htmx requests. 94 | Based on the ``HX-Current-URL`` header. 95 | 96 | .. attribute:: current_url_abs_path 97 | :type: str | None 98 | 99 | The absolute-path form of ``current_url``, that is the URL without scheme or netloc, or ``None`` for non-htmx requests. 100 | 101 | This value will also be ``None`` if the scheme and netloc do not match the request. 102 | This could happen if the request is cross-origin, or if Django is not configured correctly. 103 | 104 | For example: 105 | 106 | .. code-block:: pycon 107 | 108 | >>> request.htmx.current_url 109 | 'https://example.com/dashboard/?year=2022' 110 | >>> # assuming request.scheme and request.get_host() match: 111 | >>> request.htmx.current_url_abs_path 112 | '/dashboard/?year=2022' 113 | 114 | This is useful for redirects: 115 | 116 | .. code-block:: python 117 | 118 | if not sudo_mode_active(request): 119 | next_url = request.htmx.current_url_abs_path or "" 120 | return HttpResponseClientRedirect(f"/activate-sudo/?next={next_url}") 121 | 122 | .. attribute:: history_restore_request 123 | :type: bool 124 | 125 | ``True`` if the request is for history restoration after a miss in the local history cache. 126 | Detected by checking if the ``HX-History-Restore-Request`` header equals ``true``. 127 | 128 | .. attribute:: prompt 129 | :type: str | None 130 | 131 | The user response to `hx-prompt `__ if it was used, or ``None``. 132 | 133 | .. attribute:: target 134 | :type: str | None 135 | 136 | The ``id`` of the target element if it exists, or ``None``. 137 | Based on the ``HX-Target`` header. 138 | 139 | .. attribute:: trigger 140 | :type: str | None 141 | 142 | The ``id`` of the triggered element if it exists, or ``None``. 143 | Based on the ``HX-Trigger`` header. 144 | 145 | .. attribute:: trigger_name 146 | :type: str | None 147 | 148 | The ``name`` of the triggered element if it exists, or ``None``. 149 | Based on the ``HX-Trigger-Name`` header. 150 | 151 | .. attribute:: triggering_event 152 | :type: Any | None 153 | 154 | The deserialized JSON representation of the event that triggered the request if it exists, or ``None``. 155 | This header is set by the `event-header htmx extension `__, and contains details of the DOM event that triggered the request. 156 | -------------------------------------------------------------------------------- /docs/template_tags.rst: -------------------------------------------------------------------------------- 1 | Template tags 2 | ============= 3 | 4 | django-htmx comes with two template tags for rendering `` 137 | {% django_htmx_script %} 138 | 139 | 140 | ... 141 | 142 | 143 | 144 | On Django 6.0+, the `` 180 | {{ django_htmx_script() }} 181 | 182 | 183 | ... 184 | 185 | 186 | 187 | To use a CSP nonce, pass it to the function as ``nonce``: 188 | 189 | .. code-block:: jinja 190 | 191 | {{ django_htmx_script(nonce=csp_nonce) }} 192 | 193 | .. _django-htmx-extension-script: 194 | 195 | django-htmx extension script 196 | ---------------------------- 197 | 198 | This script, rendered by either of the above template tags when ``settings.DEBUG`` is ``True``, extends htmx with an error handler. 199 | htmx’s default behaviour when encountering an HTTP error is to discard the response content, which can make it hard to debug errors. 200 | 201 | This script adds an error handler that detects responses with 400, 403, 404, and 500 status codes and replaces the page with their content. 202 | This change exposes Django’s default error responses, allowing you to debug as you would for a non-htmx request. 203 | 204 | See the script in action in the “Error Demo” section of the :doc:`example project `. 205 | 206 | See its source `on GitHub `__. 207 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, cast 4 | 5 | from django.core.handlers.wsgi import WSGIRequest 6 | from django.http import HttpResponse 7 | from django.http.response import HttpResponseBase 8 | from django.test import RequestFactory as BaseRequestFactory 9 | from django.test import SimpleTestCase 10 | 11 | from django_htmx.middleware import HtmxDetails, HtmxMiddleware 12 | 13 | 14 | class HtmxWSGIRequest(WSGIRequest): 15 | htmx: HtmxDetails 16 | 17 | 18 | class RequestFactory(BaseRequestFactory): 19 | def get( 20 | self, path: str, data: Any = None, secure: bool = False, **extra: Any 21 | ) -> HtmxWSGIRequest: 22 | return cast(HtmxWSGIRequest, super().get(path, data, secure, **extra)) 23 | 24 | 25 | def dummy_view(request): 26 | return HttpResponse("Hello!") 27 | 28 | 29 | class HtmxMiddlewareTests(SimpleTestCase): 30 | request_factory = RequestFactory() 31 | middleware = HtmxMiddleware(dummy_view) 32 | 33 | def test_bool_default(self): 34 | request = self.request_factory.get("/") 35 | self.middleware(request) 36 | assert bool(request.htmx) is False 37 | 38 | def test_bool_false(self): 39 | request = self.request_factory.get("/", HTTP_HX_REQUEST="false") 40 | self.middleware(request) 41 | assert bool(request.htmx) is False 42 | 43 | def test_bool_true(self): 44 | request = self.request_factory.get("/", HTTP_HX_REQUEST="true") 45 | self.middleware(request) 46 | assert bool(request.htmx) is True 47 | 48 | def test_boosted_default(self): 49 | request = self.request_factory.get("/") 50 | self.middleware(request) 51 | assert not request.htmx.boosted 52 | 53 | def test_boosted_set(self): 54 | request = self.request_factory.get("/", HTTP_HX_BOOSTED="true") 55 | self.middleware(request) 56 | assert request.htmx.boosted 57 | 58 | def test_current_url_default(self): 59 | request = self.request_factory.get("/") 60 | self.middleware(request) 61 | assert request.htmx.current_url is None 62 | 63 | def test_current_url_set(self): 64 | request = self.request_factory.get( 65 | "/", HTTP_HX_CURRENT_URL="https://example.com" 66 | ) 67 | self.middleware(request) 68 | assert request.htmx.current_url == "https://example.com" 69 | 70 | def test_current_url_set_url_encoded(self): 71 | request = self.request_factory.get( 72 | "/", 73 | HTTP_HX_CURRENT_URL="https%3A%2F%2Fexample.com%2F%3F", 74 | HTTP_HX_CURRENT_URL_URI_AUTOENCODED="true", 75 | ) 76 | self.middleware(request) 77 | assert request.htmx.current_url == "https://example.com/?" 78 | 79 | def test_current_url_abs_path_default(self): 80 | request = self.request_factory.get("/") 81 | self.middleware(request) 82 | assert request.htmx.current_url_abs_path is None 83 | 84 | def test_current_url_abs_path_set_same_domain(self): 85 | request = self.request_factory.get( 86 | "/", HTTP_HX_CURRENT_URL="http://testserver/duck/?quack=true#h2" 87 | ) 88 | self.middleware(request) 89 | assert request.htmx.current_url_abs_path == "/duck/?quack=true#h2" 90 | 91 | def test_current_url_abs_path_set_different_domain(self): 92 | request = self.request_factory.get( 93 | "/", HTTP_HX_CURRENT_URL="https://example.com/duck/?quack=true#h2" 94 | ) 95 | self.middleware(request) 96 | assert request.htmx.current_url_abs_path is None 97 | 98 | def test_history_restore_request_false(self): 99 | request = self.request_factory.get("/", HTTP_HX_HISTORY_RESTORE_REQUEST="false") 100 | self.middleware(request) 101 | assert request.htmx.history_restore_request is False 102 | 103 | def test_history_restore_request_true(self): 104 | request = self.request_factory.get("/", HTTP_HX_HISTORY_RESTORE_REQUEST="true") 105 | self.middleware(request) 106 | assert request.htmx.history_restore_request is True 107 | 108 | def test_prompt_default(self): 109 | request = self.request_factory.get("/") 110 | self.middleware(request) 111 | assert request.htmx.prompt is None 112 | 113 | def test_prompt_set(self): 114 | request = self.request_factory.get("/", HTTP_HX_PROMPT="yes please") 115 | self.middleware(request) 116 | assert request.htmx.prompt == "yes please" 117 | 118 | def test_target_default(self): 119 | request = self.request_factory.get("/") 120 | self.middleware(request) 121 | assert request.htmx.target is None 122 | 123 | def test_target_set(self): 124 | request = self.request_factory.get("/", HTTP_HX_TARGET="some-element") 125 | self.middleware(request) 126 | assert request.htmx.target == "some-element" 127 | 128 | def test_trigger_default(self): 129 | request = self.request_factory.get("/") 130 | self.middleware(request) 131 | assert request.htmx.trigger is None 132 | 133 | def test_trigger_set(self): 134 | request = self.request_factory.get("/", HTTP_HX_TRIGGER="some-element") 135 | self.middleware(request) 136 | assert request.htmx.trigger == "some-element" 137 | 138 | def test_trigger_name_default(self): 139 | request = self.request_factory.get("/") 140 | self.middleware(request) 141 | assert request.htmx.trigger_name is None 142 | 143 | def test_trigger_name_set(self): 144 | request = self.request_factory.get("/", HTTP_HX_TRIGGER_NAME="some-name") 145 | self.middleware(request) 146 | assert request.htmx.trigger_name == "some-name" 147 | 148 | def test_triggering_event_none(self): 149 | request = self.request_factory.get("/") 150 | self.middleware(request) 151 | assert request.htmx.triggering_event is None 152 | 153 | def test_triggering_event_bad_json(self): 154 | request = self.request_factory.get("/", HTTP_TRIGGERING_EVENT="{") 155 | self.middleware(request) 156 | assert request.htmx.triggering_event is None 157 | 158 | def test_triggering_event_good_json(self): 159 | request = self.request_factory.get( 160 | "/", 161 | HTTP_TRIGGERING_EVENT="%7B%22target%22%3A%20null%7D", 162 | HTTP_TRIGGERING_EVENT_URI_AUTOENCODED="true", 163 | ) 164 | self.middleware(request) 165 | assert request.htmx.triggering_event == {"target": None} 166 | 167 | async def test_async(self): 168 | async def dummy_async_view(request): 169 | return HttpResponse("Hello!") 170 | 171 | middleware = HtmxMiddleware(dummy_async_view) 172 | request = self.request_factory.get("/", HTTP_HX_REQUEST="true") 173 | 174 | result = middleware(request) 175 | assert not isinstance(result, HttpResponseBase) # type narrow 176 | response = await result 177 | 178 | assert isinstance(response, HttpResponse) 179 | assert bool(request.htmx) is True 180 | -------------------------------------------------------------------------------- /docs/tips.rst: -------------------------------------------------------------------------------- 1 | Tips 2 | ==== 3 | 4 | This page contains some tips for using htmx with Django. 5 | 6 | .. _tips-csrf-token: 7 | 8 | Make htmx pass Django’s CSRF token 9 | ---------------------------------- 10 | 11 | If you use htmx to make requests with “unsafe” methods, such as POST via `hx-post `__, you will need to make htmx cooperate with Django’s `Cross Site Request Forgery (CSRF) protection `__. 12 | Django can accept the CSRF token in a header, normally ``x-csrftoken`` (configurable with the |CSRF_HEADER_NAME setting|__, but there’s rarely a reason to change it). 13 | 14 | .. |CSRF_HEADER_NAME setting| replace:: ``CSRF_HEADER_NAME`` setting 15 | __ https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_HEADER_NAME 16 | 17 | You can make htmx pass the header with its |hx-headers attribute|__. 18 | It’s most convenient to place ``hx-headers`` on your ```` tag, as then all elements will inherit it. 19 | For example: 20 | 21 | .. |hx-headers attribute| replace:: ``hx-headers`` attribute 22 | __ https://htmx.org/attributes/hx-headers/ 23 | 24 | .. code-block:: django 25 | 26 | 27 | ... 28 | 29 | 30 | Note this uses ``{{ csrf_token }}``, the variable, as opposed to ``{% csrf_token %}``, the tag that renders a hidden ````. 31 | 32 | This snippet should work with both Django templates and Jinja. 33 | 34 | For an example of this in action, see the “CSRF Demo” page of the :doc:`example project `. 35 | 36 | .. _partial-rendering: 37 | 38 | Partial Rendering 39 | ----------------- 40 | 41 | For requests made with htmx, you may want to reduce the page content you render, since only part of the page gets updated. 42 | This is a small optimization compared to correctly setting up compression, caching, etc. 43 | 44 | Using django-template-partials 45 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 46 | 47 | The `django-template-partials package `__ extends the Django Template Language with reusable sections called “partials”. 48 | It then allows you to render just one partial from a template. 49 | 50 | Install ``django-template-partials`` and add its ``{% partialdef %}`` tag around a template section: 51 | 52 | .. code-block:: django 53 | 54 | {% extends "_base.html" %} 55 | 56 | {% load partials %} 57 | 58 | {% block main %} 59 | 60 |

Countries

61 | 62 | ... 63 | 64 | {% partialdef country-table inline %} 65 | 66 | ... 67 | 68 | {% for country in countries %} 69 | ... 70 | {% endfor %} 71 | 72 |
73 | {% endpartialdef %} 74 | 75 | ... 76 | 77 | {% endblock main %} 78 | 79 | The above template defines a partial named ``country-table``, which renders some table of country data. 80 | The ``inline`` argument makes the partial render when the full page renders. 81 | 82 | In the view, you can select to render the partial for htmx requests. 83 | This is done by adding ``#`` and the partial name to the template name: 84 | 85 | .. code-block:: python 86 | 87 | from django.shortcuts import render 88 | 89 | from example.models import Country 90 | 91 | 92 | def country_listing(request): 93 | template_name = "countries.html" 94 | if request.htmx: 95 | template_name += "#country-table" 96 | 97 | countries = Country.objects.all() 98 | 99 | return render( 100 | request, 101 | template_name, 102 | { 103 | "countries": countries, 104 | }, 105 | ) 106 | 107 | htmx requests will render only the partial, whilst full page requests will render the full page. 108 | This allows refreshing of the table without an extra view or separating the template contents from its context. 109 | For a working example, see the “Partial Rendering” page of the :doc:`example project `. 110 | 111 | It’s also possible to use a partial from within a separate view. 112 | This may be preferable if other customizations are required for htmx requests. 113 | 114 | For more information on django-template-partials, see `its documentation `__. 115 | 116 | Swapping the base template 117 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 118 | 119 | Another technique is to swap the base template in your view. 120 | This is a little more manual but good to have on-hand in case you need it, 121 | 122 | You can use Django’s template inheritance to limit rendered content to only the affected section. 123 | In your view, set up a context variable for your base template like so: 124 | 125 | .. code-block:: python 126 | 127 | from django.http import HttpRequest, HttpResponse 128 | from django.shortcuts import render 129 | from django.views.decorators.http import require_GET 130 | 131 | 132 | @require_GET 133 | def partial_rendering(request: HttpRequest) -> HttpResponse: 134 | if request.htmx: 135 | base_template = "_partial.html" 136 | else: 137 | base_template = "_base.html" 138 | 139 | ... 140 | 141 | return render( 142 | request, 143 | "page.html", 144 | { 145 | "base_template": base_template, 146 | # ... 147 | }, 148 | ) 149 | 150 | Then in the template (``page.html``), use that variable in ``{% extends %}``: 151 | 152 | .. code-block:: django 153 | 154 | {% extends base_template %} 155 | 156 | {% block main %} 157 | ... 158 | {% endblock %} 159 | 160 | Here, ``_base.html`` would be the main site base: 161 | 162 | .. code-block:: django 163 | 164 | 165 | 166 | 167 | ... 168 | 169 | 170 |
171 | 174 |
175 |
176 | {% block main %}{% endblock %} 177 |
178 | 179 | 180 | …whilst ``_partial.html`` would contain only the minimum element to update: 181 | 182 | .. code-block:: django 183 | 184 |
185 | {% block main %}{% endblock %} 186 |
187 | 188 | .. _htmx-extensions: 189 | 190 | Install htmx extensions 191 | ----------------------- 192 | 193 | django-htmx vendors htmx and can render it with the ``{% htmx_script %}`` :doc:`template tag `. 194 | However, it does not include any of `the many htmx extensions `__, so it’s up to you to add such extensions to your project. 195 | 196 | Avoid using JavaScript CDNs like unpkg.com to include extensions, or any other resources. 197 | They reduce privacy, performance, and security - see `this blog post `__. 198 | 199 | Instead, download extension scripts into your project’s static files and serve them directly. 200 | Include their script tags after your htmx `` 221 | 222 | 223 | ... 224 | 225 | 226 | 227 | For another example, see the :doc:`example project `, which includes two extensions and a Python script to download their latest versions (``download_htmx_extensions.py``). 228 | -------------------------------------------------------------------------------- /example/example/static/mvp.css: -------------------------------------------------------------------------------- 1 | /* MVP.css v1.6.2 - https://github.com/andybrewer/mvp */ 2 | 3 | :root { 4 | --border-radius: 5px; 5 | --box-shadow: 2px 2px 10px; 6 | --color: #118bee; 7 | --color-accent: #118bee15; 8 | --color-bg: #fff; 9 | --color-bg-secondary: #e9e9e9; 10 | --color-secondary: #920de9; 11 | --color-secondary-accent: #920de90b; 12 | --color-shadow: #f4f4f4; 13 | --color-text: #000; 14 | --color-text-secondary: #999; 15 | --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 16 | --hover-brightness: 1.2; 17 | --justify-important: center; 18 | --justify-normal: left; 19 | --line-height: 1.5; 20 | --width-card: 285px; 21 | --width-card-medium: 460px; 22 | --width-card-wide: 800px; 23 | --width-content: 1080px; 24 | } 25 | 26 | /* 27 | @media (prefers-color-scheme: dark) { 28 | :root { 29 | --color: #0097fc; 30 | --color-accent: #0097fc4f; 31 | --color-bg: #333; 32 | --color-bg-secondary: #555; 33 | --color-secondary: #e20de9; 34 | --color-secondary-accent: #e20de94f; 35 | --color-shadow: #bbbbbb20; 36 | --color-text: #f7f7f7; 37 | --color-text-secondary: #aaa; 38 | } 39 | } 40 | */ 41 | 42 | /* Layout */ 43 | article aside { 44 | background: var(--color-secondary-accent); 45 | border-left: 4px solid var(--color-secondary); 46 | padding: 0.01rem 0.8rem; 47 | } 48 | 49 | body { 50 | background: var(--color-bg); 51 | color: var(--color-text); 52 | font-family: var(--font-family); 53 | line-height: var(--line-height); 54 | margin: 0; 55 | overflow-x: hidden; 56 | padding: 1rem 0; 57 | } 58 | 59 | footer, 60 | header, 61 | main { 62 | margin: 0 auto; 63 | max-width: var(--width-content); 64 | padding: 2rem 1rem; 65 | } 66 | 67 | hr { 68 | background-color: var(--color-bg-secondary); 69 | border: none; 70 | height: 1px; 71 | margin: 4rem 0; 72 | } 73 | 74 | section { 75 | display: flex; 76 | flex-wrap: wrap; 77 | justify-content: var(--justify-important); 78 | } 79 | 80 | section aside { 81 | border: 1px solid var(--color-bg-secondary); 82 | border-radius: var(--border-radius); 83 | box-shadow: var(--box-shadow) var(--color-shadow); 84 | margin: 1rem; 85 | padding: 1.25rem; 86 | width: var(--width-card); 87 | } 88 | 89 | section aside:hover { 90 | box-shadow: var(--box-shadow) var(--color-bg-secondary); 91 | } 92 | 93 | section aside img { 94 | max-width: 100%; 95 | } 96 | 97 | [hidden] { 98 | display: none; 99 | } 100 | 101 | /* Headers */ 102 | article header, 103 | div header, 104 | main header { 105 | padding-top: 0; 106 | } 107 | 108 | header { 109 | text-align: var(--justify-important); 110 | } 111 | 112 | header a b, 113 | header a em, 114 | header a i, 115 | header a strong { 116 | margin-left: 0.5rem; 117 | margin-right: 0.5rem; 118 | } 119 | 120 | header nav img { 121 | margin: 1rem 0; 122 | } 123 | 124 | section header { 125 | padding-top: 0; 126 | width: 100%; 127 | } 128 | 129 | /* Nav */ 130 | nav { 131 | align-items: center; 132 | display: flex; 133 | font-weight: bold; 134 | justify-content: space-between; 135 | margin-bottom: 7rem; 136 | } 137 | 138 | nav ul { 139 | list-style: none; 140 | padding: 0; 141 | } 142 | 143 | nav ul li { 144 | display: inline-block; 145 | margin: 0 0.5rem; 146 | position: relative; 147 | text-align: left; 148 | } 149 | 150 | /* Nav Dropdown */ 151 | nav ul li:hover ul { 152 | display: block; 153 | } 154 | 155 | nav ul li ul { 156 | background: var(--color-bg); 157 | border: 1px solid var(--color-bg-secondary); 158 | border-radius: var(--border-radius); 159 | box-shadow: var(--box-shadow) var(--color-shadow); 160 | display: none; 161 | height: auto; 162 | left: -2px; 163 | padding: .5rem 1rem; 164 | position: absolute; 165 | top: 1.7rem; 166 | white-space: nowrap; 167 | width: auto; 168 | } 169 | 170 | nav ul li ul li, 171 | nav ul li ul li a { 172 | display: block; 173 | } 174 | 175 | /* Typography */ 176 | code, 177 | samp { 178 | background-color: var(--color-accent); 179 | border-radius: var(--border-radius); 180 | color: var(--color-text); 181 | display: inline-block; 182 | margin: 0 0.1rem; 183 | padding: 0 0.5rem; 184 | } 185 | 186 | details { 187 | margin: 1.3rem 0; 188 | } 189 | 190 | details summary { 191 | font-weight: bold; 192 | cursor: pointer; 193 | } 194 | 195 | h1, 196 | h2, 197 | h3, 198 | h4, 199 | h5, 200 | h6 { 201 | line-height: var(--line-height); 202 | } 203 | 204 | mark { 205 | padding: 0.1rem; 206 | } 207 | 208 | ol li, 209 | ul li { 210 | padding: 0.2rem 0; 211 | } 212 | 213 | p { 214 | margin: 0.75rem 0; 215 | padding: 0; 216 | } 217 | 218 | pre { 219 | margin: 1rem 0; 220 | max-width: var(--width-card-wide); 221 | padding: 1rem 0; 222 | } 223 | 224 | pre code, 225 | pre samp { 226 | display: block; 227 | max-width: var(--width-card-wide); 228 | padding: 0.5rem 2rem; 229 | white-space: pre-wrap; 230 | } 231 | 232 | small { 233 | color: var(--color-text-secondary); 234 | } 235 | 236 | sup { 237 | background-color: var(--color-secondary); 238 | border-radius: var(--border-radius); 239 | color: var(--color-bg); 240 | font-size: xx-small; 241 | font-weight: bold; 242 | margin: 0.2rem; 243 | padding: 0.2rem 0.3rem; 244 | position: relative; 245 | top: -2px; 246 | } 247 | 248 | /* Links */ 249 | a { 250 | color: var(--color-secondary); 251 | display: inline-block; 252 | font-weight: bold; 253 | text-decoration: none; 254 | } 255 | 256 | a:hover { 257 | filter: brightness(var(--hover-brightness)); 258 | text-decoration: underline; 259 | } 260 | 261 | a b, 262 | a em, 263 | a i, 264 | a strong, 265 | button { 266 | border-radius: var(--border-radius); 267 | display: inline-block; 268 | font-size: medium; 269 | font-weight: bold; 270 | line-height: var(--line-height); 271 | margin: 0.5rem 0; 272 | padding: 1rem 2rem; 273 | } 274 | 275 | button { 276 | font-family: var(--font-family); 277 | } 278 | 279 | button:hover { 280 | cursor: pointer; 281 | filter: brightness(var(--hover-brightness)); 282 | } 283 | 284 | a b, 285 | a strong, 286 | button { 287 | background-color: var(--color); 288 | border: 2px solid var(--color); 289 | color: var(--color-bg); 290 | } 291 | 292 | a em, 293 | a i { 294 | border: 2px solid var(--color); 295 | border-radius: var(--border-radius); 296 | color: var(--color); 297 | display: inline-block; 298 | padding: 1rem 2rem; 299 | } 300 | 301 | /* Images */ 302 | figure { 303 | margin: 0; 304 | padding: 0; 305 | } 306 | 307 | figure img { 308 | max-width: 100%; 309 | } 310 | 311 | figure figcaption { 312 | color: var(--color-text-secondary); 313 | } 314 | 315 | /* Forms */ 316 | 317 | button:disabled, 318 | input:disabled { 319 | background: var(--color-bg-secondary); 320 | border-color: var(--color-bg-secondary); 321 | color: var(--color-text-secondary); 322 | cursor: not-allowed; 323 | } 324 | 325 | button[disabled]:hover { 326 | filter: none; 327 | } 328 | 329 | form { 330 | border: 1px solid var(--color-bg-secondary); 331 | border-radius: var(--border-radius); 332 | box-shadow: var(--box-shadow) var(--color-shadow); 333 | display: block; 334 | max-width: var(--width-card-wide); 335 | min-width: var(--width-card); 336 | padding: 1.5rem; 337 | text-align: var(--justify-normal); 338 | } 339 | 340 | form header { 341 | margin: 1.5rem 0; 342 | padding: 1.5rem 0; 343 | } 344 | 345 | input, 346 | label, 347 | select, 348 | textarea { 349 | display: block; 350 | font-size: inherit; 351 | max-width: var(--width-card-wide); 352 | } 353 | 354 | input[type="checkbox"], 355 | input[type="radio"] { 356 | display: inline-block; 357 | } 358 | 359 | input[type="checkbox"]+label, 360 | input[type="radio"]+label { 361 | display: inline-block; 362 | font-weight: normal; 363 | position: relative; 364 | top: 1px; 365 | } 366 | 367 | input, 368 | select, 369 | textarea { 370 | border: 1px solid var(--color-bg-secondary); 371 | border-radius: var(--border-radius); 372 | margin-bottom: 1rem; 373 | padding: 0.4rem 0.8rem; 374 | } 375 | 376 | input[readonly], 377 | textarea[readonly] { 378 | background-color: var(--color-bg-secondary); 379 | } 380 | 381 | label { 382 | font-weight: bold; 383 | margin-bottom: 0.2rem; 384 | } 385 | 386 | /* Tables */ 387 | table { 388 | border: 1px solid var(--color-bg-secondary); 389 | border-radius: var(--border-radius); 390 | border-spacing: 0; 391 | display: inline-block; 392 | max-width: 100%; 393 | overflow-x: auto; 394 | padding: 0; 395 | white-space: nowrap; 396 | } 397 | 398 | table td, 399 | table th, 400 | table tr { 401 | padding: 0.4rem 0.8rem; 402 | text-align: var(--justify-important); 403 | } 404 | 405 | table thead { 406 | background-color: var(--color); 407 | border-collapse: collapse; 408 | border-radius: var(--border-radius); 409 | color: var(--color-bg); 410 | margin: 0; 411 | padding: 0; 412 | } 413 | 414 | table thead th:first-child { 415 | border-top-left-radius: var(--border-radius); 416 | } 417 | 418 | table thead th:last-child { 419 | border-top-right-radius: var(--border-radius); 420 | } 421 | 422 | table thead th:first-child, 423 | table tr td:first-child { 424 | text-align: var(--justify-normal); 425 | } 426 | 427 | table tr:nth-child(even) { 428 | background-color: var(--color-accent); 429 | } 430 | 431 | /* Quotes */ 432 | blockquote { 433 | display: block; 434 | font-size: x-large; 435 | line-height: var(--line-height); 436 | margin: 1rem auto; 437 | max-width: var(--width-card-medium); 438 | padding: 1.5rem 1rem; 439 | text-align: var(--justify-important); 440 | } 441 | 442 | blockquote footer { 443 | color: var(--color-text-secondary); 444 | display: block; 445 | font-size: small; 446 | line-height: var(--line-height); 447 | padding: 1.5rem 0; 448 | } 449 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from uuid import UUID 5 | 6 | import django 7 | import pytest 8 | from django.core.serializers.json import DjangoJSONEncoder 9 | from django.http import HttpResponse, StreamingHttpResponse 10 | from django.test import SimpleTestCase 11 | 12 | from django_htmx.http import ( 13 | HttpResponseClientRedirect, 14 | HttpResponseClientRefresh, 15 | HttpResponseLocation, 16 | HttpResponseStopPolling, 17 | push_url, 18 | replace_url, 19 | reselect, 20 | reswap, 21 | retarget, 22 | trigger_client_event, 23 | ) 24 | 25 | 26 | class HttpResponseStopPollingTests(SimpleTestCase): 27 | def test_success(self): 28 | response = HttpResponseStopPolling() 29 | 30 | assert response.status_code == 286 31 | assert response.reason_phrase == "Stop Polling" 32 | 33 | 34 | class HttpResponseClientRedirectTests(SimpleTestCase): 35 | def test_success(self): 36 | response = HttpResponseClientRedirect("https://example.com") 37 | 38 | assert response.status_code == 200 39 | assert response["HX-Redirect"] == "https://example.com" 40 | assert "Location" not in response 41 | 42 | @pytest.mark.skipif( 43 | django.VERSION < (5, 2), reason="Django 5.2 introduced preserve_request" 44 | ) 45 | def test_fail_preserve_request(self): 46 | with pytest.raises(ValueError) as exinfo: 47 | HttpResponseClientRedirect("https://example.com", preserve_request=True) 48 | assert exinfo.value.args == ( 49 | "The 'preserve_request' argument is not supported for " 50 | "HttpResponseClientRedirect.", 51 | ) 52 | 53 | def test_repr(self): 54 | response = HttpResponseClientRedirect("https://example.com") 55 | 56 | assert repr(response) == ( 57 | '' 59 | ) 60 | 61 | 62 | class HttpResponseLocationTests(SimpleTestCase): 63 | def test_success(self): 64 | response = HttpResponseLocation("/home/") 65 | 66 | assert response.status_code == 200 67 | assert "Location" not in response 68 | spec = json.loads(response["HX-Location"]) 69 | assert spec == {"path": "/home/"} 70 | 71 | def test_success_complete(self): 72 | response = HttpResponseLocation( 73 | "/home/", 74 | source="#button", 75 | event="doubleclick", 76 | target="#main", 77 | swap="innerHTML", 78 | select="#content", 79 | headers={"year": "2022"}, 80 | values={"banner": "true"}, 81 | ) 82 | 83 | assert response.status_code == 200 84 | assert "Location" not in response 85 | spec = json.loads(response["HX-Location"]) 86 | assert spec == { 87 | "path": "/home/", 88 | "source": "#button", 89 | "event": "doubleclick", 90 | "target": "#main", 91 | "swap": "innerHTML", 92 | "select": "#content", 93 | "headers": {"year": "2022"}, 94 | "values": {"banner": "true"}, 95 | } 96 | 97 | 98 | class HttpResponseClientRefreshTests(SimpleTestCase): 99 | def test_success(self): 100 | response = HttpResponseClientRefresh() 101 | 102 | assert response.status_code == 200 103 | assert response["Content-Type"] == "text/html; charset=utf-8" 104 | assert response["HX-Refresh"] == "true" 105 | 106 | 107 | class PushUrlTests(SimpleTestCase): 108 | def test_success(self): 109 | response = HttpResponse() 110 | 111 | response2 = push_url(response, "/index.html") 112 | 113 | assert response2 is response 114 | assert response["HX-Push-Url"] == "/index.html" 115 | 116 | def test_success_false(self): 117 | response = HttpResponse() 118 | 119 | response2 = push_url(response, False) 120 | 121 | assert response2 is response 122 | assert response["HX-Push-Url"] == "false" 123 | 124 | 125 | class ReplaceUrlTests(SimpleTestCase): 126 | def test_success(self): 127 | response = HttpResponse() 128 | 129 | response2 = replace_url(response, "/index.html") 130 | 131 | assert response2 is response 132 | assert response["HX-Replace-Url"] == "/index.html" 133 | 134 | def test_success_false(self): 135 | response = HttpResponse() 136 | 137 | response2 = replace_url(response, False) 138 | 139 | assert response2 is response 140 | assert response["HX-Replace-Url"] == "false" 141 | 142 | 143 | class ReswapTests(SimpleTestCase): 144 | def test_success(self): 145 | response = HttpResponse() 146 | 147 | response2 = reswap(response, "outerHTML") 148 | 149 | assert response2 is response 150 | assert response["HX-Reswap"] == "outerHTML" 151 | 152 | 153 | class RetargetTests(SimpleTestCase): 154 | def test_success(self): 155 | response = HttpResponse() 156 | 157 | response2 = retarget(response, "#heading") 158 | 159 | assert response2 is response 160 | assert response["HX-Retarget"] == "#heading" 161 | 162 | 163 | class ReselectTests(SimpleTestCase): 164 | def test_success(self): 165 | response = HttpResponse() 166 | 167 | response2 = reselect(response, "#list") 168 | 169 | assert response2 is response 170 | assert response["HX-Reselect"] == "#list" 171 | 172 | 173 | class TriggerClientEventTests(SimpleTestCase): 174 | def test_fail_bad_after_value(self): 175 | response = HttpResponse() 176 | 177 | with pytest.raises(ValueError) as exinfo: 178 | trigger_client_event( 179 | response, 180 | "custom-event", 181 | {}, 182 | after="bad-value", # type: ignore [arg-type] 183 | ) 184 | 185 | assert exinfo.value.args == ( 186 | "Value for 'after' must be one of: 'receive', 'settle', or 'swap'.", 187 | ) 188 | 189 | def test_fail_header_there_not_json(self): 190 | response = HttpResponse() 191 | response["HX-Trigger"] = "broken{" 192 | 193 | with pytest.raises(ValueError) as exinfo: 194 | trigger_client_event(response, "custom-event", {}) 195 | 196 | assert exinfo.value.args == ("'HX-Trigger' value should be valid JSON.",) 197 | 198 | def test_success(self): 199 | response = HttpResponse() 200 | 201 | result = trigger_client_event( 202 | response, "showConfetti", {"colours": ["purple", "red"]} 203 | ) 204 | 205 | assert result is response 206 | assert ( 207 | response["HX-Trigger"] == '{"showConfetti": {"colours": ["purple", "red"]}}' 208 | ) 209 | 210 | def test_success_no_params(self): 211 | response = HttpResponse() 212 | 213 | result = trigger_client_event(response, "showConfetti") 214 | 215 | assert result is response 216 | assert response["HX-Trigger"] == '{"showConfetti": {}}' 217 | 218 | def test_success_streaming(self): 219 | response = StreamingHttpResponse(iter((b"hello",))) 220 | 221 | result = trigger_client_event( 222 | response, "showConfetti", {"colours": ["purple", "red"]} 223 | ) 224 | 225 | assert result is response 226 | assert ( 227 | response["HX-Trigger"] == '{"showConfetti": {"colours": ["purple", "red"]}}' 228 | ) 229 | 230 | def test_success_multiple_events(self): 231 | response = HttpResponse() 232 | 233 | result1 = trigger_client_event( 234 | response, "showConfetti", {"colours": ["purple"]} 235 | ) 236 | result2 = trigger_client_event(response, "showMessage", {"value": "Well done!"}) 237 | 238 | assert result1 is response 239 | assert result2 is response 240 | assert response["HX-Trigger"] == ( 241 | '{"showConfetti": {"colours": ["purple"]},' 242 | + ' "showMessage": {"value": "Well done!"}}' 243 | ) 244 | 245 | def test_success_override(self): 246 | response = HttpResponse() 247 | 248 | trigger_client_event(response, "showMessage", {"value": "That was okay."}) 249 | trigger_client_event(response, "showMessage", {"value": "Well done!"}) 250 | 251 | assert response["HX-Trigger"] == '{"showMessage": {"value": "Well done!"}}' 252 | 253 | def test_success_after_settle(self): 254 | response = HttpResponse() 255 | 256 | trigger_client_event( 257 | response, "showMessage", {"value": "Great!"}, after="settle" 258 | ) 259 | 260 | assert ( 261 | response["HX-Trigger-After-Settle"] 262 | == '{"showMessage": {"value": "Great!"}}' 263 | ) 264 | 265 | def test_success_after_swap(self): 266 | response = HttpResponse() 267 | 268 | trigger_client_event(response, "showMessage", {"value": "Great!"}, after="swap") 269 | 270 | assert ( 271 | response["HX-Trigger-After-Swap"] == '{"showMessage": {"value": "Great!"}}' 272 | ) 273 | 274 | def test_django_json_encoder(self): 275 | response = HttpResponse() 276 | uuid_value = UUID("{12345678-1234-5678-1234-567812345678}") 277 | 278 | trigger_client_event(response, "showMessage", {"uuid": uuid_value}) 279 | 280 | assert ( 281 | response["HX-Trigger"] 282 | == '{"showMessage": {"uuid": "12345678-1234-5678-1234-567812345678"}}' 283 | ) 284 | 285 | def test_custom_json_encoder(self): 286 | class Bean: 287 | pass 288 | 289 | class BeanEncoder(DjangoJSONEncoder): 290 | def default(self, o): 291 | if isinstance(o, Bean): 292 | return "bean" 293 | 294 | return super().default(o) 295 | 296 | response = HttpResponse() 297 | 298 | trigger_client_event( 299 | response, 300 | "showMessage", 301 | { 302 | "a": UUID("{12345678-1234-5678-1234-567812345678}"), 303 | "b": Bean(), 304 | }, 305 | encoder=BeanEncoder, 306 | ) 307 | 308 | assert response["HX-Trigger"] == ( 309 | '{"showMessage": {' 310 | + '"a": "12345678-1234-5678-1234-567812345678",' 311 | + ' "b": "bean"' 312 | + "}}" 313 | ) 314 | -------------------------------------------------------------------------------- /docs/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 107 | 108 | 132 | -------------------------------------------------------------------------------- /docs/http.rst: -------------------------------------------------------------------------------- 1 | HTTP tools 2 | ========== 3 | 4 | .. currentmodule:: django_htmx.http 5 | 6 | Response classes 7 | ---------------- 8 | 9 | .. autoclass:: HttpResponseClientRedirect 10 | 11 | htmx can trigger a client-side redirect when it receives a response with the |HX-Redirect header|__. 12 | ``HttpResponseClientRedirect`` is a subclass of `HttpResponseRedirect `__ for triggering such redirects. 13 | 14 | .. |HX-Redirect header| replace:: ``HX-Redirect`` header 15 | __ https://htmx.org/reference/#response_headers 16 | 17 | :param redirect_to: 18 | The path to redirect to, as per ``HttpResponseRedirect``. 19 | 20 | :param args: 21 | Other ``HTTPResponse`` parameters. 22 | 23 | :param kwargs: 24 | Other ``HTTPResponse`` parameters. 25 | 26 | For example: 27 | 28 | .. code-block:: python 29 | 30 | from django_htmx.http import HttpResponseClientRedirect 31 | 32 | 33 | def sensitive_view(request): 34 | if not sudo_mode.active(request): 35 | return HttpResponseClientRedirect("/activate-sudo-mode/") 36 | ... 37 | 38 | .. autoclass:: HttpResponseClientRefresh 39 | 40 | htmx will trigger a page reload when it receives a response with the |HX-Refresh header|__. 41 | ``HttpResponseClientRefresh`` is a `custom response class `__ that allows you to send such a response. 42 | It takes no arguments, since htmx ignores any content. 43 | 44 | .. |HX-Refresh header| replace:: ``HX-Refresh`` header 45 | __ https://htmx.org/reference/#response_headers 46 | 47 | For example: 48 | 49 | .. code-block:: python 50 | 51 | from django_htmx.http import HttpResponseClientRefresh 52 | 53 | 54 | def partial_table_view(request): 55 | if page_outdated(request): 56 | return HttpResponseClientRefresh() 57 | ... 58 | 59 | .. autoclass:: HttpResponseLocation 60 | 61 | An HTTP response class for sending the |HX-Location header|__. 62 | This header makes htmx make a client-side “boosted” request, acting like a client side redirect with a page reload. 63 | 64 | .. |HX-Location header| replace:: ``HX-Location`` header 65 | __ https://htmx.org/headers/hx-location/ 66 | 67 | :param redirect_to: 68 | The path to redirect to, as per |HttpResponseRedirect|__. 69 | 70 | .. |HttpResponseRedirect| replace:: ``HttpResponseRedirect`` 71 | __ https://docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpResponseRedirect 72 | 73 | :param source: 74 | The source element of the request. 75 | 76 | :param event: 77 | The event that “triggered” the request. 78 | 79 | :param target: 80 | CSS selector to target. 81 | 82 | :param swap: 83 | How the response will be swapped into the target. 84 | 85 | :param select: 86 | Select the content that will be swapped from a response. 87 | 88 | :param values: 89 | values to submit with the request. 90 | 91 | :param headers: 92 | headers to submit with the request. 93 | 94 | :param args: 95 | Other ``HTTPResponse`` parameters. 96 | 97 | :param kwargs: 98 | Other ``HTTPResponse`` parameters. 99 | 100 | For example: 101 | 102 | .. code-block:: python 103 | 104 | from django_htmx.http import HttpResponseLocation 105 | 106 | 107 | def wait_for_completion(request, action_id): 108 | ... 109 | if action.completed: 110 | return HttpResponseLocation(f"/action/{action.id}/completed/") 111 | ... 112 | 113 | .. autoclass:: HttpResponseStopPolling 114 | 115 | When using a `polling trigger `__, htmx will stop polling when it encounters a response with the special HTTP status code 286. 116 | ``HttpResponseStopPolling`` is a `custom response class `__ with that status code. 117 | 118 | :param args: 119 | Other ``HTTPResponse`` parameters. 120 | 121 | :param kwargs: 122 | Other ``HTTPResponse`` parameters. 123 | 124 | For example: 125 | 126 | .. code-block:: python 127 | 128 | from django_htmx.http import HttpResponseStopPolling 129 | 130 | 131 | def my_pollable_view(request): 132 | if event_finished(): 133 | return HttpResponseStopPolling() 134 | ... 135 | 136 | .. data:: HTMX_STOP_POLLING 137 | :type: int 138 | :value: 286 139 | 140 | A constant for the HTTP status code 286. 141 | You can use this instead of ``HttpResponseStopPolling`` to stop htmx from polling. 142 | 143 | For example, with Django’s `render shortcut `__: 144 | 145 | .. code-block:: python 146 | 147 | from django.shortcuts import render 148 | from django_htmx.http import HTMX_STOP_POLLING 149 | 150 | 151 | def my_pollable_view(request): 152 | if event_finished(): 153 | return render(request, "event-finished.html", status=HTMX_STOP_POLLING) 154 | ... 155 | 156 | Response modifying functions 157 | ---------------------------- 158 | 159 | .. autofunction:: push_url 160 | 161 | Set the |HX-Push-Url header|__ of ``response`` and return it. 162 | This header makes htmx push the given URL into the browser location history. 163 | 164 | .. |HX-Push-Url header| replace:: ``HX-Push-Url`` header 165 | __ https://htmx.org/headers/hx-push-url/ 166 | 167 | 168 | :param response: 169 | The response to modify and return. 170 | 171 | :param url: 172 | The (relative) URL to push, or ``False`` to prevent the location history from being updated. 173 | 174 | For example: 175 | 176 | .. code-block:: python 177 | 178 | from django_htmx.http import push_url 179 | 180 | 181 | def leaf(request, leaf_id): 182 | ... 183 | if leaf is None: 184 | # Directly render branch view 185 | response = branch(request, branch=leaf.branch) 186 | return push_url(response, f"/branch/{leaf.branch.id}") 187 | ... 188 | 189 | .. autofunction:: replace_url 190 | 191 | Set the |HX-Replace-Url header|__ of ``response`` and return it. 192 | This header causes htmx to replace the current URL in the browser location history. 193 | 194 | .. |HX-Replace-Url header| replace:: ``HX-Replace-Url`` header 195 | __ https://htmx.org/headers/hx-replace-url/ 196 | 197 | :param response: 198 | The response to modify and return. 199 | 200 | :param url: 201 | The (relative) URL to replace, or ``False`` to prevent the location history from being updated. 202 | 203 | For example: 204 | 205 | .. code-block:: python 206 | 207 | from django_htmx.http import replace_url 208 | 209 | 210 | def dashboard(request): 211 | ... 212 | response = render(request, "dashboard.html", ...) 213 | # Pretend the user was always on the dashboard, rather than wherever 214 | # they were on before. 215 | return replace_url(response, "/dashboard/") 216 | 217 | .. autofunction:: reswap 218 | 219 | Set the |HX-Reswap header|__ of ``response`` and return it. 220 | This header overrides the `swap method `__ that htmx will use. 221 | 222 | .. |HX-Reswap header| replace:: ``HX-Reswap`` header 223 | __ https://htmx.org/docs/#response-headers:~:text=HX%2DReswap 224 | 225 | :param response: 226 | The response to modify and return. 227 | 228 | :param method: 229 | The swap method. 230 | 231 | For example: 232 | 233 | .. code-block:: python 234 | 235 | from django.shortcuts import render 236 | from django_htmx.http import reswap 237 | 238 | 239 | def employee_table_row(request): 240 | ... 241 | response = render(...) 242 | if employee.is_boss: 243 | reswap(response, "afterbegin") 244 | return response 245 | 246 | .. autofunction:: retarget 247 | 248 | Set the |HX-Retarget header|__ of ``response`` and return it. 249 | This header overrides the element that htmx will swap content into. 250 | 251 | .. |HX-Retarget header| replace:: ``HX-Retarget`` header 252 | __ https://htmx.org/docs/#response-headers:~:text=HX%2DRetarget 253 | 254 | :param response: 255 | The response to modify and return. 256 | 257 | :param target: 258 | CSS selector to target. 259 | 260 | For example: 261 | 262 | .. code-block:: python 263 | 264 | from django.shortcuts import render 265 | from django.views.decorators.http import require_POST 266 | from django_htmx.http import retarget 267 | 268 | 269 | @require_POST 270 | def add_widget(request): 271 | ... 272 | 273 | if form.is_valid(): 274 | # Rerender the whole table on success 275 | response = render(request, "widget-table.html", ...) 276 | return retarget(response, "#widgets") 277 | 278 | # Render just inline table row on failure 279 | return render(request, "widget-table-row.html", ...) 280 | 281 | .. autofunction:: reselect 282 | 283 | Set the |HX-Reselect header|__ of ``response`` and return it. 284 | This header overrides the selection of the response that htmx will swap into the target. 285 | 286 | .. |HX-Reselect header| replace:: ``HX-Reselect`` header 287 | __ https://htmx.org/docs/#response-headers:~:text=HX%2DReselect 288 | 289 | :param response: 290 | The response to modify and return. 291 | 292 | :param selectori: 293 | CSS selector of what to select. 294 | 295 | .. autofunction:: trigger_client_event 296 | 297 | Modify one of the |HX-Trigger headers|__ of ``response`` and return it. 298 | These headers make htmx trigger client-side events. 299 | 300 | Calling ``trigger_client_event`` multiple times for the same ``response`` and ``after`` will update the appropriate header, preserving existing event specifications. 301 | 302 | .. |HX-Trigger headers| replace:: ``HX-Trigger`` headers 303 | __ https://htmx.org/headers/hx-trigger/ 304 | 305 | :param response: 306 | The response to modify and return. 307 | 308 | :param name: 309 | The name of the event to trigger. 310 | 311 | :param params: 312 | Optional JSON-compatible parameters for the event. 313 | 314 | :param after: 315 | Which ``HX-Trigger`` header to modify: 316 | 317 | * ``"receive"``, the default, maps to ``HX-Trigger`` 318 | * ``"settle"`` maps to ``HX-Trigger-After-Settle`` 319 | * ``"swap"`` maps to ``HX-Trigger-After-Swap`` 320 | 321 | :param encoder: 322 | 323 | The |JSONEncoder|__ class used to generate the JSON. 324 | Defaults to |DjangoJSONEncoder|__ for its extended data type support. 325 | 326 | .. |JSONEncoder| replace:: ``JSONEncoder`` 327 | __ https://docs.python.org/3/library/json.html#json.JSONEncoder 328 | 329 | .. |DjangoJSONEncoder| replace:: ``DjangoJSONEncoder`` 330 | __ https://docs.djangoproject.com/en/stable/topics/serialization/#django.core.serializers.json.DjangoJSONEncoder 331 | 332 | For example: 333 | 334 | .. code-block:: python 335 | 336 | from django.shortcuts import render 337 | from django_htmx.http import trigger_client_event 338 | 339 | 340 | def end_of_long_process(request): 341 | response = render(request, "end-of-long-process.html") 342 | return trigger_client_event( 343 | response, 344 | "showConfetti", 345 | {"colours": ["purple", "red", "pink"]}, 346 | after="swap", 347 | ) 348 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 1.27.0 (2025-11-28) 6 | ------------------- 7 | 8 | * Drop Python 3.9 support. 9 | 10 | * Fix CSP nonce support in the template tags when they’re the first use of ``csp_nonce``. 11 | 12 | `PR #572 `__. 13 | 14 | 1.26.0 (2025-09-22) 15 | ------------------- 16 | 17 | * The :ref:`django-htmx-extension-script` now displays responses with status codes 400 (bad request) and 403 (forbidden), like the existing support for codes 404 and 500. 18 | This change can help you debug 19 | 20 | `Issue #521 `__. 21 | 22 | * Add :func:`.reselect` to set the ``HX-Reselect`` header. 23 | 24 | `Issue #559 `__. 25 | 26 | * Improve typing of :func:`.reswap` to only accept valid HTMX swap methods. 27 | 28 | Thanks to Thibaut Decombe in `PR #555 `__. 29 | 30 | * Prevent :class:`.HttpResponseClientRedirect` from being called with ``preserve_request=True``, which was added to `redirect responses `__ in Django 5.2. 31 | It doesn’t make sense in the context of a client-side redirect, which always returns a status code of 200, and would crash anyway. 32 | 33 | `Issue #517 `__. 34 | 35 | 1.25.0 (2025-09-18) 36 | ------------------- 37 | 38 | * Support Django 6.0. 39 | 40 | * Add Content Security Policy (CSP) nonce support to the template tags. 41 | 42 | Thanks to waifudegen for the report in `Issue #542 `__. 43 | 44 | 1.24.1 (2025-09-11) 45 | ------------------- 46 | 47 | * Upgrade the vendored htmx to `version 2.0.7 `__. 48 | 49 | 1.24.0 (2025-09-10) 50 | ------------------- 51 | 52 | * Support Python 3.14. 53 | 54 | * Fix crashes in the extension script for custom error pages. 55 | 56 | Thanks to S Foster for the report in `Issue #546 `__. 57 | 58 | 1.23.2 (2025-06-27) 59 | ------------------- 60 | 61 | * Upgrade the vendored htmx to `version 2.0.6 `__. 62 | 63 | 1.23.1 (2025-06-21) 64 | ------------------- 65 | 66 | * Upgrade the vendored htmx to `version 2.0.5 `__. 67 | 68 | 1.23.0 (2025-03-14) 69 | ------------------- 70 | 71 | * Vendor htmx. 72 | 73 | You can now render an htmx script tag in your templates with: 74 | 75 | .. code-block:: django 76 | 77 | {% load django_htmx %} 78 | {% htmx_script %} 79 | 80 | No need to include htmx in your project separately. 81 | 82 | See :doc:`template_tags` for more information. 83 | 84 | 1.22.0 (2025-02-06) 85 | ------------------- 86 | 87 | * Support Django 5.2. 88 | 89 | 1.21.0 (2024-10-27) 90 | ------------------- 91 | 92 | * Drop Django 3.2 to 4.1 support. 93 | 94 | 1.20.0 (2024-10-25) 95 | ------------------- 96 | 97 | * Drop Python 3.8 support. 98 | 99 | * Support Python 3.13. 100 | 101 | * Updated :ref:`the partial rendering tip ` to cover using django-template-partials. 102 | 103 | Thanks to Carlton Gibson in `PR #413 `__. 104 | 105 | 1.19.0 (2024-08-05) 106 | ------------------- 107 | 108 | * Add :func:`django_htmx.http.replace_url` for setting the ``HX-Replace-URL`` header. 109 | 110 | Thanks to Bogumil Schube in `PR #396 `__. 111 | 112 | * Add ``select`` parameter to :class:`.HttpResponseLocation`. 113 | 114 | Thanks to Nikola Anović in `PR #462 `__. 115 | 116 | * Add documentation notes under :class:`.HtmxMiddleware`, covering setting the ``Vary`` header for caching and type hinting ``request.htmx``. 117 | 118 | 1.18.0 (2024-06-19) 119 | ------------------- 120 | 121 | * Support Django 5.1. 122 | 123 | 1.17.3 (2024-03-01) 124 | ------------------- 125 | 126 | * Change ``reswap()`` type hint for ``method`` to ``str``. 127 | 128 | Thanks to Dan Jacob for the report in `Issue #421 `__ and fix in `PR #422 `__. 129 | 130 | 1.17.2 (2023-11-16) 131 | ------------------- 132 | 133 | * Fix asgiref dependency declaration. 134 | 135 | 1.17.1 (2023-11-14) 136 | ------------------- 137 | 138 | * Fix ASGI compatibility on Python 3.12. 139 | 140 | Thanks to Grigory Vydrin for the report in `Issue #381 `__. 141 | 142 | 1.17.0 (2023-10-11) 143 | ------------------- 144 | 145 | * Support Django 5.0. 146 | 147 | 1.16.0 (2023-07-10) 148 | ------------------- 149 | 150 | * Drop Python 3.7 support. 151 | 152 | * Remove the unnecessary ``type`` attribute on the ``