pax_global_header00006660000000000000000000000064152347450120014515gustar00rootroot0000000000000052 comment=a27556eee0ffa92829427e95c7d8e2bf997a4bb0 django-htmx-1.29.0/000077500000000000000000000000001523474501200140265ustar00rootroot00000000000000django-htmx-1.29.0/.editorconfig000066400000000000000000000003451523474501200165050ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.py] indent_size = 4 [Makefile] indent_style = tab django-htmx-1.29.0/.github/000077500000000000000000000000001523474501200153665ustar00rootroot00000000000000django-htmx-1.29.0/.github/CODE_OF_CONDUCT.md000066400000000000000000000001311523474501200201600ustar00rootroot00000000000000This project follows [Django's Code of Conduct](https://www.djangoproject.com/conduct/). django-htmx-1.29.0/.github/FUNDING.yml000066400000000000000000000001221523474501200171760ustar00rootroot00000000000000github: adamchainz tidelift: pypi/django-htmx custom: - "https://adamj.eu/books/" django-htmx-1.29.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001523474501200175515ustar00rootroot00000000000000django-htmx-1.29.0/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000000341523474501200215360ustar00rootroot00000000000000blank_issues_enabled: false django-htmx-1.29.0/.github/ISSUE_TEMPLATE/feature-request.yml000066400000000000000000000004111523474501200234110ustar00rootroot00000000000000name: Feature Request description: Request an enhancement or new feature. body: - type: textarea id: description attributes: label: Description description: Please describe your feature request with appropriate detail. validations: required: true django-htmx-1.29.0/.github/ISSUE_TEMPLATE/issue.yml000066400000000000000000000015271523474501200214310ustar00rootroot00000000000000name: Issue description: File an issue body: - type: input id: python_version attributes: label: Python Version description: Which version of Python were you using? placeholder: 3.14.0 validations: required: false - type: input id: django_version attributes: label: Django Version description: Which version of Django were you using? placeholder: 3.2.0 validations: required: false - type: input id: package_version attributes: label: Package Version description: Which version of this package were you using? If not the latest version, please check this issue has not since been resolved. placeholder: 1.0.0 validations: required: false - type: textarea id: description attributes: label: Description description: Please describe your issue. validations: required: true django-htmx-1.29.0/.github/SECURITY.md000066400000000000000000000001011523474501200171470ustar00rootroot00000000000000Please report security issues directly over email to me@adamj.eu django-htmx-1.29.0/.github/workflows/000077500000000000000000000000001523474501200174235ustar00rootroot00000000000000django-htmx-1.29.0/.github/workflows/main.yml000066400000000000000000000063671523474501200211060ustar00rootroot00000000000000name: CI on: push: branches: - main tags: - '**' pull_request: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: tests: name: Python ${{ matrix.python-version }} runs-on: ubuntu-24.04 strategy: matrix: python-version: - '3.10' - '3.11' - '3.12' - '3.13' - '3.14' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - name: Run tox targets for ${{ matrix.python-version }} run: uvx --with tox-uv tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage data uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-data-${{ matrix.python-version }} path: '${{ github.workspace }}/.coverage' include-hidden-files: true if-no-files-found: error coverage: name: Coverage runs-on: ubuntu-24.04 needs: tests if: always() steps: - name: Check all test jobs passed if: needs.tests.result != 'success' run: exit 1 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Install dependencies run: uv pip install --system coverage[toml] - name: Download data uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: .coverage pattern: coverage-data-* merge-multiple: true - name: Combine coverage and fail if it's <100% run: | python -m coverage combine python -m coverage html --skip-covered --skip-empty python -m coverage report --fail-under=100 echo "## Coverage summary" >> $GITHUB_STEP_SUMMARY python -m coverage report --format=markdown >> $GITHUB_STEP_SUMMARY - name: Upload HTML report if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: html-report path: htmlcov release: needs: [coverage] if: success() && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-24.04 environment: release permissions: contents: read id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: false - name: Build run: uv build - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 django-htmx-1.29.0/.gitignore000066400000000000000000000001351523474501200160150ustar00rootroot00000000000000*.egg-info/ *.pyc /.coverage /.coverage.* /.tox /build/ /dist/ /docs/_build/ /example/.venv/ django-htmx-1.29.0/.pre-commit-config.yaml000066400000000000000000000052411523474501200203110ustar00rootroot00000000000000ci: autoupdate_schedule: monthly default_language_version: python: python3.14 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-json - id: check-merge-conflict - id: check-symlinks - id: check-toml - id: end-of-file-fixer exclude: | (?x)^( example/example/static/ext/debug\.js |src/django_htmx/static/django_htmx/(htmx|htmax)-[24](\.min)?\.js |src/django_htmx/static/django_htmx/ext/[a-z0-9-]+-[24](\.min)?\.js )$ - id: trailing-whitespace exclude: | (?x)^( src/django_htmx/static/django_htmx/(htmx|htmax)-[24](\.min)?\.js |src/django_htmx/static/django_htmx/ext/[a-z0-9-]+-[24](\.min)?\.js )$ - repo: https://github.com/crate-ci/typos rev: 12ffd4a04b4893ab75db66a59aaad2d996c3e982 # frozen: v1 hooks: - id: typos exclude: | (?x)^( .*\.svg |src/django_htmx/static/django_htmx/(htmx|htmax)-[24](\.min)?\.js |src/django_htmx/static/django_htmx/ext/[a-z0-9-]+-[24](\.min)?\.js )$ - repo: https://github.com/tox-dev/pyproject-fmt rev: d600c142bb19f521ae6a6a345f94a2efd7937759 # frozen: v2.26.0 hooks: - id: pyproject-fmt - repo: https://github.com/tox-dev/tox-ini-fmt rev: 69e8f0ead1b1164c731b4a702896e3218feecf0b # frozen: 1.8.1 hooks: - id: tox-ini-fmt - repo: https://github.com/rstcheck/rstcheck rev: 27a8027d6c6df787891a01b76e8298bac493fb58 # frozen: v6.3.0 hooks: - id: rstcheck additional_dependencies: - sphinx==8.1.3 - tomli==2.2.1 - repo: https://github.com/sphinx-contrib/sphinx-lint rev: c883505f64b59c3c5c9375191e4ad9f98e727ccd # frozen: v1.0.2 hooks: - id: sphinx-lint - repo: https://github.com/adamchainz/django-upgrade rev: bdcc9646c249f00ec9781751791e7b75b48b3722 # frozen: 1.31.1 hooks: - id: django-upgrade - repo: https://github.com/adamchainz/blacken-docs rev: dda8db18cfc68df532abf33b185ecd12d5b7b326 # frozen: 1.20.0 hooks: - id: blacken-docs additional_dependencies: - black==25.1.0 - repo: https://github.com/astral-sh/ruff-pre-commit rev: 39d9ac5938dadb73df0564a45f163e25ff9fa6e2 # frozen: v0.16.1 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0 hooks: - id: mypy additional_dependencies: - django-stubs==6.0.5 - types-python-dateutil - repo: https://github.com/adamchainz/djade-pre-commit rev: 52a7ce253113456c2a1113186a4a19b15c8b68af # frozen: 1.9.0 hooks: - id: djade django-htmx-1.29.0/.readthedocs.yaml000066400000000000000000000005501523474501200172550ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-24.04 tools: python: "3.14" python: install: - method: uv command: sync groups: - docs sphinx: configuration: docs/conf.py fail_on_warning: true formats: all django-htmx-1.29.0/.typos.toml000066400000000000000000000004131523474501200161550ustar00rootroot00000000000000# Configuration file for 'typos' tool # https://github.com/crate-ci/typos [default] extend-ignore-re = [ # Single line ignore comments "(?Rm)^.*(#|//)\\s*typos: ignore$", # Multi-line ignore comments "(?s)(#|//)\\s*typos: off.*?\\n\\s*(#|//)\\s*typos: on" ] django-htmx-1.29.0/HISTORY.rst000066400000000000000000000001001523474501200157100ustar00rootroot00000000000000See https://django-htmx.readthedocs.io/en/latest/changelog.html django-htmx-1.29.0/LICENSE000066400000000000000000000020551523474501200150350ustar00rootroot00000000000000MIT License Copyright (c) 2020 Adam Johnson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-htmx-1.29.0/README.rst000066400000000000000000000024051523474501200155160ustar00rootroot00000000000000=========== django-htmx =========== .. image:: https://img.shields.io/readthedocs/django-htmx?style=for-the-badge :target: https://django-htmx.readthedocs.io/en/latest/ .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/django-htmx/main.yml.svg?branch=main&style=for-the-badge :target: https://github.com/adamchainz/django-htmx/actions?workflow=CI .. image:: https://img.shields.io/badge/Coverage-100%25-success?style=for-the-badge :target: https://github.com/adamchainz/django-htmx/actions?workflow=CI .. image:: https://img.shields.io/pypi/v/django-htmx.svg?style=for-the-badge :target: https://pypi.org/project/django-htmx/ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge :target: https://github.com/psf/black .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge :target: https://github.com/pre-commit/pre-commit :alt: pre-commit ---- .. figure:: https://raw.githubusercontent.com/adamchainz/django-htmx/main/docs/_static/logo.svg :alt: django-htmx logo :align: center Extensions for using Django with `htmx `__. Documentation ------------- Please see https://django-htmx.readthedocs.io/. django-htmx-1.29.0/docs/000077500000000000000000000000001523474501200147565ustar00rootroot00000000000000django-htmx-1.29.0/docs/Makefile000066400000000000000000000011771523474501200164240ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= "-W" SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) django-htmx-1.29.0/docs/_static/000077500000000000000000000000001523474501200164045ustar00rootroot00000000000000django-htmx-1.29.0/docs/_static/logo.svg000066400000000000000000000263731523474501200201000ustar00rootroot00000000000000 django-htmx-1.29.0/docs/changelog.rst000066400000000000000000000352331523474501200174450ustar00rootroot00000000000000========= Changelog ========= 1.29.0 (2026-08-06) ------------------- * Vendor some stable htmx extensions, named per their htmx 4 versions: ``htmx-2-compat``, ``hx-browser-indicator``, ``hx-download``, ``hx-head``, ``hx-optimistic``, ``hx-preload``, ``hx-prompt``, ``hx-ptag``, ``hx-sse``, ``hx-targets``, ``hx-upsert``, and ``hx-ws``. Render their script tags with the new ``extensions`` argument of the ``htmx_script`` :doc:`template tags `, which selects the extension files matching the htmx version in use: .. code-block:: django {% htmx_script version=4 extensions="hx-sse,hx-ws" %} ``hx-head``, ``hx-preload``, ``hx-sse``, and ``hx-ws`` are available for both htmx versions 2 and 4; the rest are htmx 4 only. The special name ``htmax`` renders htmx 4’s htmax bundle of htmx plus its most popular extensions, in place of the plain htmx script: .. code-block:: django {% htmx_script version=4 extensions="htmax" %} `PR #614 `__. Thanks to Rex Zhang for initial work in `PR #575 `__. * Upgrade the vendored htmx 2 to `version 2.0.10 `__. `PR #594 `__. * Upgrade the vendored htmx 4 to `version 4.0.0-beta6 `__. `PR #610 `__. * Add support for the polling tags protocol of the htmx 4 |hx-ptag extension|__: * :attr:`HtmxDetails.ptag ` reads the ``HX-PTag`` request header directly. * The :func:`django_htmx.http.ptag` view decorator implements the protocol, with an API mirroring Django’s |etag decorator|__. It computes the current tag with the given function and, when the ``HX-PTag`` request header matches, returns a 304 (Not Modified) response without calling the view, making htmx skip the swap. Otherwise, it calls the view and sets the ``HX-PTag`` response header. .. |hx-ptag extension| replace:: ``hx-ptag`` extension __ https://four.htmx.org/extensions/hx-ptag .. |etag decorator| replace:: ``etag`` decorator __ https://docs.djangoproject.com/en/stable/topics/conditional-view-processing/ `PR #613 `__. * Switch package build backend from setuptools to `uv_build `__. This makes builds with uv about nine times faster, since uv runs the backend natively, without creating a build environment or spawning a Python process. Additionally, source distributions no longer include test files, which setuptools previously included incompletely, missing the files needed to actually run them. 1.28.0 (2026-07-12) ------------------- * Support `htmx version 4 `__ (beta), available by adding ``version=4`` to the :doc:`template tags `. The default htmx version remains 2, since htmx 4 is in beta. When htmx 4 is released, the default will be updated to 4 in a future major release of django-htmx. For example, with the Django templates ``htmx_script`` tag: .. code-block:: django {% htmx_script version=4 %} The :doc:`example project ` now uses htmx 4, to demonstrate its usage. See `the htmx 4 migration guide `__ for guidance on adopting version 4. `PR #606 `__. * Extend the ``request.htmx`` object with two new attributes based on new htmx-4-only attributes: * :attr:`HtmxDetails.request_type `, based on the |HX-Request-Type header|__ that indicates if the request is for full or partial content. * :attr:`HtmxDetails.source `, based on the |HX-Source request header|__ that htmx 4 sends instead of ``HX-Trigger``/``HX-Trigger-Name``. .. |HX-Source request header| replace:: ``HX-Source`` request header __ https://four.htmx.org/reference/headers/HX-Source .. |HX-Request-Type header| replace:: ``HX-Request-Type`` request header __ https://four.htmx.org/reference/headers/HX-Request-Type `PR #606 `__. * Add Django 6.1 support. * Drop Django 4.2 to 5.1 support. 1.27.0 (2025-11-28) ------------------- * Drop Python 3.9 support. * Fix CSP nonce support in the template tags when they’re the first use of ``csp_nonce``. `PR #572 `__. 1.26.0 (2025-09-22) ------------------- * 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. This change can help you debug `Issue #521 `__. * Add :func:`.reselect` to set the ``HX-Reselect`` header. `Issue #559 `__. * Improve typing of :func:`.reswap` to only accept valid HTMX swap methods. Thanks to Thibaut Decombe in `PR #555 `__. * Prevent :class:`.HttpResponseClientRedirect` from being called with ``preserve_request=True``, which was added to `redirect responses `__ in Django 5.2. 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. `Issue #517 `__. 1.25.0 (2025-09-18) ------------------- * Support Django 6.0. * Add Content Security Policy (CSP) nonce support to the template tags. Thanks to waifudegen for the report in `Issue #542 `__. 1.24.1 (2025-09-11) ------------------- * Upgrade the vendored htmx to `version 2.0.7 `__. 1.24.0 (2025-09-10) ------------------- * Support Python 3.14. * Fix crashes in the extension script for custom error pages. Thanks to S Foster for the report in `Issue #546 `__. 1.23.2 (2025-06-27) ------------------- * Upgrade the vendored htmx to `version 2.0.6 `__. 1.23.1 (2025-06-21) ------------------- * Upgrade the vendored htmx to `version 2.0.5 `__. 1.23.0 (2025-03-14) ------------------- * Vendor htmx. You can now render an htmx script tag in your templates with: .. code-block:: django {% load django_htmx %} {% htmx_script %} No need to include htmx in your project separately. See :doc:`template_tags` for more information. 1.22.0 (2025-02-06) ------------------- * Support Django 5.2. 1.21.0 (2024-10-27) ------------------- * Drop Django 3.2 to 4.1 support. 1.20.0 (2024-10-25) ------------------- * Drop Python 3.8 support. * Support Python 3.13. * Updated :ref:`the partial rendering tip ` to cover using django-template-partials. Thanks to Carlton Gibson in `PR #413 `__. 1.19.0 (2024-08-05) ------------------- * Add :func:`django_htmx.http.replace_url` for setting the ``HX-Replace-URL`` header. Thanks to Bogumil Schube in `PR #396 `__. * Add ``select`` parameter to :class:`.HttpResponseLocation`. Thanks to Nikola Anović in `PR #462 `__. * Add documentation notes under :class:`.HtmxMiddleware`, covering setting the ``Vary`` header for caching and type hinting ``request.htmx``. 1.18.0 (2024-06-19) ------------------- * Support Django 5.1. 1.17.3 (2024-03-01) ------------------- * Change ``reswap()`` type hint for ``method`` to ``str``. Thanks to Dan Jacob for the report in `Issue #421 `__ and fix in `PR #422 `__. 1.17.2 (2023-11-16) ------------------- * Fix asgiref dependency declaration. 1.17.1 (2023-11-14) ------------------- * Fix ASGI compatibility on Python 3.12. Thanks to Grigory Vydrin for the report in `Issue #381 `__. 1.17.0 (2023-10-11) ------------------- * Support Django 5.0. 1.16.0 (2023-07-10) ------------------- * Drop Python 3.7 support. * Remove the unnecessary ``type`` attribute on the `` {% django_htmx_script %} ... On Django 6.0+, the `` {{ django_htmx_script() }} ... To use a CSP nonce, pass it to the function as ``nonce``: .. code-block:: jinja {{ django_htmx_script(nonce=csp_nonce) }} .. _django-htmx-extension-script: django-htmx extension script ---------------------------- This script, rendered by either of the above template tags when ``settings.DEBUG`` is ``True``, extends htmx with an error handler. htmx’s default behaviour when encountering an HTTP error is to discard the response content, which can make it hard to debug errors. This script adds an error handler that detects responses with 400, 403, 404, and 500 status codes and replaces the page with their content. This change exposes Django’s default error responses, allowing you to debug as you would for a non-htmx request. See the script in action in the “Error Demo” section of the :doc:`example project `. See its source `on GitHub `__. django-htmx-1.29.0/docs/tips.rst000066400000000000000000000160321523474501200164710ustar00rootroot00000000000000Tips ==== This page contains some tips for using htmx with Django. .. _tips-csrf-token: Make htmx pass Django’s CSRF token ---------------------------------- 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 `__. 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). .. |CSRF_HEADER_NAME setting| replace:: ``CSRF_HEADER_NAME`` setting __ https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_HEADER_NAME You can make htmx pass the header with its |hx-headers attribute|__. It’s most convenient to place ``hx-headers`` on your ```` tag, as then all elements will inherit it. For example: .. |hx-headers attribute| replace:: ``hx-headers`` attribute __ https://htmx.org/attributes/hx-headers/ .. code-block:: django ... Note this uses ``{{ csrf_token }}``, the variable, as opposed to ``{% csrf_token %}``, the tag that renders a hidden ````. This snippet should work with both Django templates and Jinja. For an example of this in action, see the “CSRF Demo” page of the :doc:`example project `. .. _partial-rendering: Partial Rendering ----------------- For requests made with htmx, you may want to reduce the page content you render, since only part of the page gets updated. This is a small optimization compared to correctly setting up compression, caching, etc. Using django-template-partials ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The `django-template-partials package `__ extends the Django Template Language with reusable sections called “partials”. It then allows you to render just one partial from a template. Install ``django-template-partials`` and add its ``{% partialdef %}`` tag around a template section: .. code-block:: django {% extends "_base.html" %} {% load partials %} {% block main %}

Countries

... {% partialdef country-table inline %} ... {% for country in countries %} ... {% endfor %}
{% endpartialdef %} ... {% endblock main %} The above template defines a partial named ``country-table``, which renders some table of country data. The ``inline`` argument makes the partial render when the full page renders. In the view, you can select to render the partial for htmx requests. This is done by adding ``#`` and the partial name to the template name: .. code-block:: python from django.shortcuts import render from example.models import Country def country_listing(request): template_name = "countries.html" if request.htmx: template_name += "#country-table" countries = Country.objects.all() return render( request, template_name, { "countries": countries, }, ) htmx requests will render only the partial, whilst full page requests will render the full page. This allows refreshing of the table without an extra view or separating the template contents from its context. For a working example, see the “Partial Rendering” page of the :doc:`example project `. It’s also possible to use a partial from within a separate view. This may be preferable if other customizations are required for htmx requests. For more information on django-template-partials, see `its documentation `__. Swapping the base template ~~~~~~~~~~~~~~~~~~~~~~~~~~ Another technique is to swap the base template in your view. This is a little more manual but good to have on-hand in case you need it, You can use Django’s template inheritance to limit rendered content to only the affected section. In your view, set up a context variable for your base template like so: .. code-block:: python from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.views.decorators.http import require_GET @require_GET def partial_rendering(request: HttpRequest) -> HttpResponse: if request.htmx: base_template = "_partial.html" else: base_template = "_base.html" ... return render( request, "page.html", { "base_template": base_template, # ... }, ) Then in the template (``page.html``), use that variable in ``{% extends %}``: .. code-block:: django {% extends base_template %} {% block main %} ... {% endblock %} Here, ``_base.html`` would be the main site base: .. code-block:: django ...
{% block main %}{% endblock %}
…whilst ``_partial.html`` would contain only the minimum element to update: .. code-block:: django
{% block main %}{% endblock %}
.. _htmx-extensions: Install htmx extensions ----------------------- django-htmx vendors htmx and can render it with the ``{% htmx_script %}`` :doc:`template tag `. However, it does not include any of `the many htmx extensions `__, so it’s up to you to add such extensions to your project. Avoid using JavaScript CDNs like unpkg.com to include extensions, or any other resources. They reduce privacy, performance, and security - see `this blog post `__. Instead, download extension scripts into your project’s static files and serve them directly. Include their script tags after your htmx `` ... 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``). django-htmx-1.29.0/download_htmx.py000077500000000000000000000063211523474501200172540ustar00rootroot00000000000000#!/usr/bin/env uv run --script --no-project """ Download htmx to django_htmx/static/django_htmx/htmx-.js and htmx-.min.js, plus the vendored extensions for that major version to django_htmx/static/django_htmx/ext/-.js and -.min.js. For htmx 4, the htmax bundle of htmx plus popular extensions is also downloaded, to django_htmx/static/django_htmx/htmax-4.js and htmax-4.min.js. """ from __future__ import annotations import argparse import subprocess from pathlib import Path # Extensions are keyed by their htmx 4 names, as bundled in the htmx.org # package and downloaded with the same version as htmx itself. # Values give the standalone htmx 2 package name and version, for those # extensions that also have htmx 2 versions: # https://github.com/bigskysoftware/htmx-extensions EXTENSIONS: dict[str, tuple[str, str] | None] = { "htmx-2-compat": None, "hx-browser-indicator": None, "hx-download": None, "hx-head": ("head-support", "2.0.5"), "hx-optimistic": None, "hx-preload": ("preload", "2.1.2"), "hx-prompt": None, "hx-ptag": None, "hx-sse": ("sse", "2.2.4"), "hx-targets": None, "hx-upsert": None, "hx-ws": ("ws", "2.0.4"), } static_dir = Path(__file__).parent.resolve() / "src/django_htmx/static/django_htmx/" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("version", help="The version of htmx to download, e.g. 2.0.4") args = parser.parse_args() major = args.version.split(".")[0] if major not in ("2", "4"): parser.error(f"Unsupported htmx major version: {major}") # Per: https://htmx.org/docs/#installing download_file( f"https://unpkg.com/htmx.org@{args.version}/dist/htmx.js", static_dir / f"htmx-{major}.js", ) download_file( f"https://unpkg.com/htmx.org@{args.version}/dist/htmx.min.js", static_dir / f"htmx-{major}.min.js", ) if major == "2": for name, htmx_2_source in EXTENSIONS.items(): if htmx_2_source is None: continue htmx_2_name, htmx_2_version = htmx_2_source for suffix in ("", ".min"): download_file( f"https://unpkg.com/htmx-ext-{htmx_2_name}@{htmx_2_version}/dist/{htmx_2_name}{suffix}.js", static_dir / f"ext/{name}-2{suffix}.js", ) else: for suffix in ("", ".min"): download_file( f"https://unpkg.com/htmx.org@{args.version}/dist/htmax{suffix}.js", static_dir / f"htmax-4{suffix}.js", ) for name in EXTENSIONS: for suffix in ("", ".min"): download_file( f"https://unpkg.com/htmx.org@{args.version}/dist/ext/{name}{suffix}.js", static_dir / f"ext/{name}-4{suffix}.js", ) print("✅") return 0 def download_file(url: str, destination: Path) -> None: print(f"{destination.name}...") subprocess.run( [ "curl", "--fail", "--location", url, "-o", str(destination), ], check=True, ) if __name__ == "__main__": raise SystemExit(main()) django-htmx-1.29.0/example/000077500000000000000000000000001523474501200154615ustar00rootroot00000000000000django-htmx-1.29.0/example/.gitignore000066400000000000000000000000071523474501200174460ustar00rootroot00000000000000/venv/ django-htmx-1.29.0/example/README.rst000066400000000000000000000010601523474501200171450ustar00rootroot00000000000000Example Application =================== Run with: .. code-block:: sh uv run --group example manage.py runserver Open it at http://127.0.0.1:8000/ . Browse the individual examples, and take them apart! 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. In the source code, check out the HTML comments via “view source” or templates, and the view code in ``example/views.py``. django-htmx-1.29.0/example/example/000077500000000000000000000000001523474501200171145ustar00rootroot00000000000000django-htmx-1.29.0/example/example/__init__.py000066400000000000000000000000001523474501200212130ustar00rootroot00000000000000django-htmx-1.29.0/example/example/context_processors.py000066400000000000000000000003031523474501200234300ustar00rootroot00000000000000from __future__ import annotations from django.conf import settings from django.http import HttpRequest def debug(request: HttpRequest) -> dict[str, str]: return {"DEBUG": settings.DEBUG} django-htmx-1.29.0/example/example/forms.py000066400000000000000000000002021523474501200206060ustar00rootroot00000000000000from __future__ import annotations from django import forms class OddNumberForm(forms.Form): number = forms.IntegerField() django-htmx-1.29.0/example/example/settings.py000066400000000000000000000022701523474501200213270ustar00rootroot00000000000000from __future__ import annotations import os from pathlib import Path from typing import Any # Hide development server warning # https://docs.djangoproject.com/en/stable/ref/django-admin/#envvar-DJANGO_RUNSERVER_HIDE_WARNING os.environ["DJANGO_RUNSERVER_HIDE_WARNING"] = "true" BASE_DIR = Path(__file__).parent DEBUG = True SECRET_KEY = ")w%-67b9lurhzs*o2ow(e=n_^(n2!0_f*2+g+1*9tcn6_k58(f" # Dangerous: disable host header validation ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ "example", "django_htmx", "template_partials", "django.contrib.staticfiles", ] MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django_htmx.middleware.HtmxMiddleware", ] ROOT_URLCONF = "example.urls" DATABASES: dict[str, dict[str, Any]] = {} TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "example.context_processors.debug", ] }, } ] USE_TZ = True STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"] django-htmx-1.29.0/example/example/static/000077500000000000000000000000001523474501200204035ustar00rootroot00000000000000django-htmx-1.29.0/example/example/static/app.js000066400000000000000000000001431523474501200215170ustar00rootroot00000000000000// Log all htmx events to the console. // https://htmx.org/docs/#config htmx.config.logAll = true; django-htmx-1.29.0/example/example/static/ext/000077500000000000000000000000001523474501200212035ustar00rootroot00000000000000django-htmx-1.29.0/example/example/static/ext/event-header.js000066400000000000000000000022051523474501200241070ustar00rootroot00000000000000(function() { function stringifyEvent(event) { var obj = {} for (var key in event) { obj[key] = event[key] } return JSON.stringify(obj, function(key, value) { if (value instanceof Node) { var nodeRep = value.tagName if (nodeRep) { nodeRep = nodeRep.toLowerCase() if (value.id) { nodeRep += '#' + value.id } if (value.classList && value.classList.length) { nodeRep += '.' + value.classList.toString().replace(' ', '.') } return nodeRep } else { return 'Node' } } if (value instanceof Window) return 'Window' return value }) } // Ported from https://github.com/bigskysoftware/htmx-extensions/blob/main/src/event-header/event-header.js // for htmx 4's extension API, which has no upstream release yet. htmx.registerExtension('event-header', { htmx_config_request: function(elt, detail) { var sourceEvent = detail.ctx.sourceEvent if (sourceEvent) { detail.ctx.request.headers['Triggering-Event'] = stringifyEvent(sourceEvent) } } }) })() django-htmx-1.29.0/example/example/static/mvp.css000066400000000000000000000174701523474501200217300ustar00rootroot00000000000000/* MVP.css v1.6.2 - https://github.com/andybrewer/mvp */ :root { --border-radius: 5px; --box-shadow: 2px 2px 10px; --color: #118bee; --color-accent: #118bee15; --color-bg: #fff; --color-bg-secondary: #e9e9e9; --color-secondary: #920de9; --color-secondary-accent: #920de90b; --color-shadow: #f4f4f4; --color-text: #000; --color-text-secondary: #999; --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; --hover-brightness: 1.2; --justify-important: center; --justify-normal: left; --line-height: 1.5; --width-card: 285px; --width-card-medium: 460px; --width-card-wide: 800px; --width-content: 1080px; } /* @media (prefers-color-scheme: dark) { :root { --color: #0097fc; --color-accent: #0097fc4f; --color-bg: #333; --color-bg-secondary: #555; --color-secondary: #e20de9; --color-secondary-accent: #e20de94f; --color-shadow: #bbbbbb20; --color-text: #f7f7f7; --color-text-secondary: #aaa; } } */ /* Layout */ article aside { background: var(--color-secondary-accent); border-left: 4px solid var(--color-secondary); padding: 0.01rem 0.8rem; } body { background: var(--color-bg); color: var(--color-text); font-family: var(--font-family); line-height: var(--line-height); margin: 0; overflow-x: hidden; padding: 1rem 0; } footer, header, main { margin: 0 auto; max-width: var(--width-content); padding: 2rem 1rem; } hr { background-color: var(--color-bg-secondary); border: none; height: 1px; margin: 4rem 0; } section { display: flex; flex-wrap: wrap; justify-content: var(--justify-important); } section aside { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); box-shadow: var(--box-shadow) var(--color-shadow); margin: 1rem; padding: 1.25rem; width: var(--width-card); } section aside:hover { box-shadow: var(--box-shadow) var(--color-bg-secondary); } section aside img { max-width: 100%; } [hidden] { display: none; } /* Headers */ article header, div header, main header { padding-top: 0; } header { text-align: var(--justify-important); } header a b, header a em, header a i, header a strong { margin-left: 0.5rem; margin-right: 0.5rem; } header nav img { margin: 1rem 0; } section header { padding-top: 0; width: 100%; } /* Nav */ nav { align-items: center; display: flex; font-weight: bold; justify-content: space-between; margin-bottom: 7rem; } nav ul { list-style: none; padding: 0; } nav ul li { display: inline-block; margin: 0 0.5rem; position: relative; text-align: left; } /* Nav Dropdown */ nav ul li:hover ul { display: block; } nav ul li ul { background: var(--color-bg); border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); box-shadow: var(--box-shadow) var(--color-shadow); display: none; height: auto; left: -2px; padding: .5rem 1rem; position: absolute; top: 1.7rem; white-space: nowrap; width: auto; } nav ul li ul li, nav ul li ul li a { display: block; } /* Typography */ code, samp { background-color: var(--color-accent); border-radius: var(--border-radius); color: var(--color-text); display: inline-block; margin: 0 0.1rem; padding: 0 0.5rem; } details { margin: 1.3rem 0; } details summary { font-weight: bold; cursor: pointer; } h1, h2, h3, h4, h5, h6 { line-height: var(--line-height); } mark { padding: 0.1rem; } ol li, ul li { padding: 0.2rem 0; } p { margin: 0.75rem 0; padding: 0; } pre { margin: 1rem 0; max-width: var(--width-card-wide); padding: 1rem 0; } pre code, pre samp { display: block; max-width: var(--width-card-wide); padding: 0.5rem 2rem; white-space: pre-wrap; } small { color: var(--color-text-secondary); } sup { background-color: var(--color-secondary); border-radius: var(--border-radius); color: var(--color-bg); font-size: xx-small; font-weight: bold; margin: 0.2rem; padding: 0.2rem 0.3rem; position: relative; top: -2px; } /* Links */ a { color: var(--color-secondary); display: inline-block; font-weight: bold; text-decoration: none; } a:hover { filter: brightness(var(--hover-brightness)); text-decoration: underline; } a b, a em, a i, a strong, button { border-radius: var(--border-radius); display: inline-block; font-size: medium; font-weight: bold; line-height: var(--line-height); margin: 0.5rem 0; padding: 1rem 2rem; } button { font-family: var(--font-family); } button:hover { cursor: pointer; filter: brightness(var(--hover-brightness)); } a b, a strong, button { background-color: var(--color); border: 2px solid var(--color); color: var(--color-bg); } a em, a i { border: 2px solid var(--color); border-radius: var(--border-radius); color: var(--color); display: inline-block; padding: 1rem 2rem; } /* Images */ figure { margin: 0; padding: 0; } figure img { max-width: 100%; } figure figcaption { color: var(--color-text-secondary); } /* Forms */ button:disabled, input:disabled { background: var(--color-bg-secondary); border-color: var(--color-bg-secondary); color: var(--color-text-secondary); cursor: not-allowed; } button[disabled]:hover { filter: none; } form { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); box-shadow: var(--box-shadow) var(--color-shadow); display: block; max-width: var(--width-card-wide); min-width: var(--width-card); padding: 1.5rem; text-align: var(--justify-normal); } form header { margin: 1.5rem 0; padding: 1.5rem 0; } input, label, select, textarea { display: block; font-size: inherit; max-width: var(--width-card-wide); } input[type="checkbox"], input[type="radio"] { display: inline-block; } input[type="checkbox"]+label, input[type="radio"]+label { display: inline-block; font-weight: normal; position: relative; top: 1px; } input, select, textarea { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); margin-bottom: 1rem; padding: 0.4rem 0.8rem; } input[readonly], textarea[readonly] { background-color: var(--color-bg-secondary); } label { font-weight: bold; margin-bottom: 0.2rem; } /* Tables */ table { border: 1px solid var(--color-bg-secondary); border-radius: var(--border-radius); border-spacing: 0; display: inline-block; max-width: 100%; overflow-x: auto; padding: 0; white-space: nowrap; } table td, table th, table tr { padding: 0.4rem 0.8rem; text-align: var(--justify-important); } table thead { background-color: var(--color); border-collapse: collapse; border-radius: var(--border-radius); color: var(--color-bg); margin: 0; padding: 0; } table thead th:first-child { border-top-left-radius: var(--border-radius); } table thead th:last-child { border-top-right-radius: var(--border-radius); } table thead th:first-child, table tr td:first-child { text-align: var(--justify-normal); } table tr:nth-child(even) { background-color: var(--color-accent); } /* Quotes */ blockquote { display: block; font-size: x-large; line-height: var(--line-height); margin: 1rem auto; max-width: var(--width-card-medium); padding: 1.5rem 1rem; text-align: var(--justify-important); } blockquote footer { color: var(--color-text-secondary); display: block; font-size: small; line-height: var(--line-height); padding: 1.5rem 0; } django-htmx-1.29.0/example/example/templates/000077500000000000000000000000001523474501200211125ustar00rootroot00000000000000django-htmx-1.29.0/example/example/templates/_base.html000066400000000000000000000022151523474501200230510ustar00rootroot00000000000000{% load django_htmx static %} django-htmx example app {% htmx_script version=4 extensions="hx-prompt" %}
{% block main %}{% endblock %}
{% django_htmx_script %} django-htmx-1.29.0/example/example/templates/csrf-demo-checker.html000066400000000000000000000002531523474501200252610ustar00rootroot00000000000000{% if not form.is_valid %} Please enter a number {% elif number_is_odd %} {{ form.number.value }} is odd! {% else %} {{ form.number.value }} is not odd. {% endif %} django-htmx-1.29.0/example/example/templates/csrf-demo.html000066400000000000000000000022001523474501200236510ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}

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

View the source to see how it works!

Awaiting interaction...

{% endblock main %} django-htmx-1.29.0/example/example/templates/error-demo.html000066400000000000000000000023001523474501200240460ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}

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

See more in the docs.

{% if DEBUG %} The error handler will work, since DEBUG = True. {% else %} The error handler will not work, since DEBUG = False. {% endif %}

{% endblock main %} django-htmx-1.29.0/example/example/templates/index.html000066400000000000000000000002611523474501200231060ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}
Welcome to the example app. Use one of the links in the navigation to explore!
{% endblock main %} django-htmx-1.29.0/example/example/templates/middleware-tester-table.html000066400000000000000000000045131523474501200265110ustar00rootroot00000000000000{% load example_tags %}
Attribute Value
Timestamp {{ timestamp }}
request.method {{ request.method|stringformat:'r' }}
bool(request.htmx) {% if request.htmx %} True {% else %} For {% endif %}
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.request_type {{ request.htmx.request_type|stringformat:'r' }}
request.htmx.source {{ request.htmx.source|stringformat:'r' }}
request.htmx.target {{ request.htmx.target|stringformat:'r' }}
request.htmx.triggering_event
(via event-header extension)
{% if request.htmx.triggering_event %}
JSON
{{ request.htmx.triggering_event|json_dumps }}
{% else %} {{ request.htmx.triggering_event|stringformat:'r' }} {% endif %}
request.POST.get('keyup_input') {{ request.POST.keyup_input|stringformat:'r' }}
django-htmx-1.29.0/example/example/templates/middleware-tester.html000066400000000000000000000031521523474501200254220ustar00rootroot00000000000000{% extends "_base.html" %} {% block main %}

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


Awaiting interaction...

{% endblock main %} django-htmx-1.29.0/example/example/templates/partial-rendering.html000066400000000000000000000064761523474501200254240ustar00rootroot00000000000000{% extends "_base.html" %} {% load partials %} {% block main %}

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

See more in the docs.

{% partialdef table-section inline %} {% endpartialdef table-section %} {% endblock main %} django-htmx-1.29.0/example/example/templatetags/000077500000000000000000000000001523474501200216065ustar00rootroot00000000000000django-htmx-1.29.0/example/example/templatetags/__init__.py000066400000000000000000000000001523474501200237050ustar00rootroot00000000000000django-htmx-1.29.0/example/example/templatetags/example_tags.py000066400000000000000000000003601523474501200246300ustar00rootroot00000000000000from __future__ import annotations import json from typing import Any from django import template register = template.Library() @register.filter def json_dumps(value: Any) -> str: return json.dumps(value, indent=2, sort_keys=True) django-htmx-1.29.0/example/example/urls.py000066400000000000000000000013201523474501200204470ustar00rootroot00000000000000from __future__ import annotations from django.urls import path from example import views urlpatterns = [ path("", views.index), path("favicon.ico", views.favicon), path("csrf-demo/", views.csrf_demo), path("csrf-demo/checker/", views.csrf_demo_checker), path("error-demo/", views.error_demo), path("error-demo/400/", views.error_demo_400), path("error-demo/403/", views.error_demo_403), path("error-demo/500/", views.error_demo_500), path("error-demo/500-custom/", views.error_demo_500_custom), path("middleware-tester/", views.middleware_tester), path("middleware-tester/table/", views.middleware_tester_table), path("partial-rendering/", views.partial_rendering), ] django-htmx-1.29.0/example/example/views.py000066400000000000000000000073731523474501200206350ustar00rootroot00000000000000from __future__ import annotations import time from dataclasses import dataclass from django.core.exceptions import PermissionDenied, SuspiciousOperation from django.core.paginator import Paginator from django.http import HttpRequest, HttpResponse, HttpResponseServerError from django.shortcuts import render from django.views.decorators.http import require_GET, require_http_methods, require_POST from faker import Faker from django_htmx.middleware import HtmxDetails from example.forms import OddNumberForm # Typing pattern recommended by django-stubs: # https://github.com/typeddjango/django-stubs#how-can-i-create-a-httprequest-thats-guaranteed-to-have-an-authenticated-user class HtmxHttpRequest(HttpRequest): htmx: HtmxDetails @require_GET def index(request: HtmxHttpRequest) -> HttpResponse: return render(request, "index.html") @require_GET def favicon(request: HtmxHttpRequest) -> HttpResponse: return HttpResponse( ( '' + '🦊' + "" ), content_type="image/svg+xml", ) # CSRF Demo @require_GET def csrf_demo(request: HtmxHttpRequest) -> HttpResponse: return render(request, "csrf-demo.html") @require_POST def csrf_demo_checker(request: HtmxHttpRequest) -> HttpResponse: form = OddNumberForm(request.POST) if form.is_valid(): number = form.cleaned_data["number"] number_is_odd = number % 2 == 1 else: number_is_odd = False return render( request, "csrf-demo-checker.html", {"form": form, "number_is_odd": number_is_odd}, ) # Error demo @require_GET def error_demo(request: HtmxHttpRequest) -> HttpResponse: return render(request, "error-demo.html") @require_GET def error_demo_400(request: HtmxHttpRequest) -> HttpResponse: raise SuspiciousOperation("What are you doing??") @require_GET def error_demo_403(request: HtmxHttpRequest) -> HttpResponse: raise PermissionDenied("Access denied!") @require_GET def error_demo_500(request: HtmxHttpRequest) -> HttpResponse: _ = 1 / 0 return render(request, "error-demo.html") # unreachable @require_GET def error_demo_500_custom(request: HtmxHttpRequest) -> HttpResponse: return HttpResponseServerError( "

😱 Woops

This is our fancy custom 500 page.

" ) # Middleware tester # This uses two views - one to render the form, and the second to render the # table of attributes. @require_GET def middleware_tester(request: HtmxHttpRequest) -> HttpResponse: return render(request, "middleware-tester.html") @require_http_methods(["DELETE", "POST", "PUT"]) def middleware_tester_table(request: HtmxHttpRequest) -> HttpResponse: return render( request, "middleware-tester-table.html", {"timestamp": time.time()}, ) # Partial rendering example # This dataclass acts as a stand-in for a database model - the example app # avoids having a database for simplicity. @dataclass class Person: id: int name: str faker = Faker() people = [Person(id=i, name=faker.name()) for i in range(1, 235)] @require_GET def partial_rendering(request: HtmxHttpRequest) -> HttpResponse: # Standard Django pagination page_num = request.GET.get("page", "1") page = Paginator(object_list=people, per_page=10).get_page(page_num) # The htmx magic - render just the `#table-section` partial for htmx # requests, allowing us to skip rendering the unchanging parts of the # template. template_name = "partial-rendering.html" if request.htmx: template_name += "#table-section" return render( request, template_name, { "page": page, }, ) django-htmx-1.29.0/example/manage.py000077500000000000000000000012401523474501200172630ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" from __future__ import annotations import os import sys def main() -> None: os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() django-htmx-1.29.0/pyproject.toml000066400000000000000000000061651523474501200167520ustar00rootroot00000000000000[build-system] build-backend = "uv_build" requires = [ "uv-build>=0.12.1,<0.13", ] [project] name = "django-htmx" version = "1.29.0" description = "Extensions for using Django with htmx." readme = "README.rst" keywords = [ "Django", ] license = "MIT" license-files = [ "LICENSE" ] authors = [ { name = "Adam Johnson", email = "me@adamj.eu" }, ] requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django :: 5.2", "Framework :: Django :: 6.0", "Framework :: Django :: 6.1", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Typing :: Typed", ] dependencies = [ "asgiref>=3.6", "django>=5.2", ] urls.Changelog = "https://django-htmx.readthedocs.io/en/latest/changelog.html" urls.Documentation = "https://django-htmx.readthedocs.io/" urls.Funding = "https://adamj.eu/books/" urls.Repository = "https://github.com/adamchainz/django-htmx" [dependency-groups] test = [ "coverage[toml]", "pytest>=9", "pytest-django", "pytest-randomly", ] docs = [ "furo>=2024.8.6", "sphinx>=7.4.7", "sphinx-copybutton>=0.5.2", ] django52 = [ "django>=5.2a1,<6; python_version>='3.10'" ] django60 = [ "django>=6a1,<6.1; python_version>='3.12'" ] django61 = [ "django>=6.1a1,<6.2; python_version>='3.12'" ] example = [ "django-template-partials", "faker", ] [tool.uv] conflicts = [ [ { group = "django52" }, { group = "django60" }, { group = "django61" }, ], ] [tool.ruff] lint.select = [ # flake8-bugbear "B", # flake8-comprehensions "C4", # pycodestyle "E", # Pyflakes errors "F", # isort "I", # flake8-simplify "SIM", # flake8-tidy-imports "TID", # pyupgrade "UP", # Pyflakes warnings "W", ] lint.ignore = [ # flake8-bugbear opinionated rules "B9", # line-too-long "E501", # suppressible-exception "SIM105", # if-else-block-instead-of-if-exp "SIM108", ] lint.extend-safe-fixes = [ # non-pep585-annotation "UP006", ] lint.isort.required-imports = [ "from __future__ import annotations" ] [tool.pyproject-fmt] max_supported_python = "3.14" [tool.mypy] mypy_path = "src/" namespace_packages = false warn_unreachable = true enable_error_code = [ "ignore-without-code", "redundant-expr", "truthy-bool", ] strict = true overrides = [ { module = "tests.*", allow_untyped_defs = true } ] [tool.pytest] django_find_project = false DJANGO_SETTINGS_MODULE = "tests.settings" strict = true [tool.coverage] run.branch = true run.data_file = ".coverage/cov" run.parallel = true run.source = [ "django_htmx", "tests", ] paths.source = [ "src", ".tox/**/site-packages", ] report.show_missing = true report.skip_covered = true report.skip_empty = true [tool.rstcheck] ignore_directives = [ "autoclass", "autofunction", ] report_level = "ERROR" django-htmx-1.29.0/src/000077500000000000000000000000001523474501200146155ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/000077500000000000000000000000001523474501200171175ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/__init__.py000066400000000000000000000000001523474501200212160ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/http.py000066400000000000000000000150601523474501200204520ustar00rootroot00000000000000from __future__ import annotations import json from collections.abc import Callable from functools import wraps from typing import Any, Literal, TypeVar, cast from asgiref.sync import iscoroutinefunction from django.core.serializers.json import DjangoJSONEncoder from django.http import HttpRequest, HttpResponse, HttpResponseNotModified from django.http.response import HttpResponseBase, HttpResponseRedirectBase HTMX_STOP_POLLING = 286 SwapMethod = Literal[ "innerHTML", "outerHTML", "beforebegin", "afterbegin", "beforeend", "afterend", "delete", "none", ] class HttpResponseStopPolling(HttpResponse): status_code = HTMX_STOP_POLLING def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._reason_phrase = "Stop Polling" class HttpResponseClientRedirect(HttpResponseRedirectBase): status_code = 200 def __init__(self, redirect_to: str, *args: Any, **kwargs: Any) -> None: if kwargs.get("preserve_request"): raise ValueError( "The 'preserve_request' argument is not supported for " "HttpResponseClientRedirect.", ) super().__init__(redirect_to, *args, **kwargs) self["HX-Redirect"] = self["Location"] del self["Location"] @property def url(self) -> str: return self["HX-Redirect"] class HttpResponseClientRefresh(HttpResponse): def __init__(self) -> None: super().__init__() self["HX-Refresh"] = "true" class HttpResponseLocation(HttpResponseRedirectBase): status_code = 200 def __init__( self, redirect_to: str, *args: Any, source: str | None = None, event: str | None = None, target: str | None = None, swap: SwapMethod | None = None, select: str | None = None, values: dict[str, str] | None = None, headers: dict[str, str] | None = None, **kwargs: Any, ) -> None: super().__init__(redirect_to, *args, **kwargs) spec: dict[str, str | dict[str, str]] = { "path": self["Location"], } del self["Location"] if source is not None: spec["source"] = source if event is not None: spec["event"] = event if target is not None: spec["target"] = target if swap is not None: spec["swap"] = swap if select is not None: spec["select"] = select if headers is not None: spec["headers"] = headers if values is not None: spec["values"] = values self["HX-Location"] = json.dumps(spec) _HttpResponse = TypeVar("_HttpResponse", bound=HttpResponseBase) _View = TypeVar("_View", bound=Callable[..., Any]) def ptag(ptag_func: Callable[..., str | None]) -> Callable[[_View], _View]: def decorator(func: _View) -> _View: def _pre_process_request( request: HttpRequest, *args: Any, **kwargs: Any ) -> tuple[HttpResponseBase | None, str | None]: # Compute the polling tag (if any) for the requested content. res_ptag = ptag_func(request, *args, **kwargs) response: HttpResponseBase | None = None if ( request.method in ("GET", "HEAD") and res_ptag is not None and request.headers.get("HX-PTag") == res_ptag ): response = HttpResponseNotModified() return response, res_ptag def _post_process_request( request: HttpRequest, response: HttpResponseBase, res_ptag: str | None ) -> None: # Set the header on the response if it doesn't already exist and # if the request method is safe. if request.method in ("GET", "HEAD") and res_ptag is not None: response.headers.setdefault("HX-PTag", res_ptag) if iscoroutinefunction(func): @wraps(func) async def ainner( request: HttpRequest, *args: Any, **kwargs: Any ) -> HttpResponseBase: response, res_ptag = _pre_process_request(request, *args, **kwargs) if response is None: response = await func(request, *args, **kwargs) _post_process_request(request, response, res_ptag) return response return cast(_View, ainner) else: @wraps(func) def inner( request: HttpRequest, *args: Any, **kwargs: Any ) -> HttpResponseBase: response, res_ptag = _pre_process_request(request, *args, **kwargs) if response is None: response = func(request, *args, **kwargs) _post_process_request(request, response, res_ptag) return response return cast(_View, inner) return decorator def push_url(response: _HttpResponse, url: str | Literal[False]) -> _HttpResponse: response["HX-Push-Url"] = "false" if url is False else url return response def replace_url(response: _HttpResponse, url: str | Literal[False]) -> _HttpResponse: response["HX-Replace-Url"] = "false" if url is False else url return response def reswap(response: _HttpResponse, method: SwapMethod) -> _HttpResponse: response["HX-Reswap"] = method return response def retarget(response: _HttpResponse, target: str) -> _HttpResponse: response["HX-Retarget"] = target return response def reselect(response: _HttpResponse, selector: str) -> _HttpResponse: response["HX-Reselect"] = selector return response def trigger_client_event( response: _HttpResponse, name: str, params: dict[str, Any] | None = None, *, after: Literal["receive", "settle", "swap"] = "receive", encoder: type[json.JSONEncoder] = DjangoJSONEncoder, ) -> _HttpResponse: params = params or {} if after == "receive": header = "HX-Trigger" elif after == "settle": header = "HX-Trigger-After-Settle" elif after == "swap": header = "HX-Trigger-After-Swap" else: raise ValueError( "Value for 'after' must be one of: 'receive', 'settle', or 'swap'." ) if header in response: value = response[header] try: data = json.loads(value) except json.JSONDecodeError as exc: raise ValueError(f"{header!r} value should be valid JSON.") from exc data[name] = params else: data = {name: params} response[header] = json.dumps(data, cls=encoder) return response django-htmx-1.29.0/src/django_htmx/jinja.py000066400000000000000000000072151523474501200205710ustar00rootroot00000000000000from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING import django from django.conf import settings from django.templatetags.static import static from django.utils.html import format_html from django.utils.safestring import SafeString, mark_safe if TYPE_CHECKING or django.VERSION >= (6, 0): from django.utils.csp import LazyNonce else: LazyNonce = None # Extension names mapped to the htmx versions they’re available for. EXTENSIONS = { "htmx-2-compat": frozenset({4}), "hx-browser-indicator": frozenset({4}), "hx-download": frozenset({4}), "hx-head": frozenset({2, 4}), "hx-optimistic": frozenset({4}), "hx-preload": frozenset({2, 4}), "hx-prompt": frozenset({4}), "hx-ptag": frozenset({4}), "hx-sse": frozenset({2, 4}), "hx-targets": frozenset({4}), "hx-upsert": frozenset({4}), "hx-ws": frozenset({2, 4}), } def htmx_script( *, version: int = 2, minified: bool = True, extensions: str | Sequence[str] = (), nonce: LazyNonce | str | None = None, ) -> SafeString: if version not in (2, 4): raise ValueError(f"Unsupported htmx version {version!r}, must be one of: 2, 4") if isinstance(extensions, str): extension_names = [e.strip() for e in extensions.split(",") if e.strip()] else: extension_names = list(extensions) htmax = "htmax" in extension_names if htmax: if version != 4: raise ValueError("htmax is only available for htmx version 4") if len(extension_names) > 1: raise ValueError( "htmax already bundles extensions, so it cannot be combined " + "with other extension names." ) else: for name in extension_names: if name not in EXTENSIONS: raise ValueError( f"Unknown htmx extension {name!r}, must be one of: " + ", ".join(sorted([*EXTENSIONS, "htmax"])) ) if version not in EXTENSIONS[name]: raise ValueError( f"htmx extension {name!r} is not available for htmx " + f"version {version}" ) suffix = ".min" if minified else "" if htmax: result = _script_tag(f"django_htmx/htmax-4{suffix}.js", nonce) else: result = _script_tag(f"django_htmx/htmx-{version}{suffix}.js", nonce) for name in extension_names: result += _script_tag(f"django_htmx/ext/{name}-{version}{suffix}.js", nonce) if settings.DEBUG: result += django_htmx_script(nonce=nonce) return result def _script_tag(path: str, nonce: LazyNonce | str | None) -> SafeString: if nonce is not None: return format_html( '', static(path), nonce, ) else: return format_html( '', static(path), ) def django_htmx_script(*, nonce: LazyNonce | str | None = None) -> SafeString: # Optimization: whilst the script has no behaviour outside of debug mode, # don't include it. if not settings.DEBUG: return mark_safe("") if nonce is not None: return format_html( '', static("django_htmx/django-htmx.js"), str(bool(settings.DEBUG)), nonce, ) else: return format_html( '', static("django_htmx/django-htmx.js"), str(bool(settings.DEBUG)), ) django-htmx-1.29.0/src/django_htmx/middleware.py000066400000000000000000000076511523474501200216170ustar00rootroot00000000000000from __future__ import annotations import json from collections.abc import Awaitable, Callable from typing import Any from urllib.parse import unquote, urlsplit, urlunsplit from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.http import HttpRequest from django.http.response import HttpResponseBase from django.utils.functional import cached_property class HtmxMiddleware: sync_capable = True async_capable = True def __init__( self, get_response: ( Callable[[HttpRequest], HttpResponseBase] | Callable[[HttpRequest], Awaitable[HttpResponseBase]] ), ) -> None: self.get_response = get_response self.async_mode = iscoroutinefunction(self.get_response) if self.async_mode: # Mark the class as async-capable, but do the actual switch # inside __call__ to avoid swapping out dunder methods markcoroutinefunction(self) def __call__( self, request: HttpRequest ) -> HttpResponseBase | Awaitable[HttpResponseBase]: if self.async_mode: return self.__acall__(request) request.htmx = HtmxDetails(request) # type: ignore [attr-defined] return self.get_response(request) async def __acall__(self, request: HttpRequest) -> HttpResponseBase: request.htmx = HtmxDetails(request) # type: ignore [attr-defined] return await self.get_response(request) # type: ignore [no-any-return, misc] class HtmxDetails: def __init__(self, request: HttpRequest) -> None: self.request = request def _get_header_value(self, name: str) -> str | None: value = self.request.headers.get(name) or None if value and self.request.headers.get(f"{name}-URI-AutoEncoded") == "true": value = unquote(value) return value def __bool__(self) -> bool: return self._get_header_value("HX-Request") == "true" @cached_property def boosted(self) -> bool: return self._get_header_value("HX-Boosted") == "true" @cached_property def current_url(self) -> str | None: return self._get_header_value("HX-Current-URL") @cached_property def current_url_abs_path(self) -> str | None: url = self.current_url if url is not None: split = urlsplit(url) if ( split.scheme == self.request.scheme and split.netloc == self.request.get_host() ): url = urlunsplit(split._replace(scheme="", netloc="")) else: url = None return url @cached_property def history_restore_request(self) -> bool: return self._get_header_value("HX-History-Restore-Request") == "true" @cached_property def prompt(self) -> str | None: return self._get_header_value("HX-Prompt") @cached_property def ptag(self) -> str | None: # htmx 4 only, with the hx-ptag extension return self._get_header_value("HX-PTag") @cached_property def request_type(self) -> str | None: # htmx 4 only return self._get_header_value("HX-Request-Type") @cached_property def source(self) -> str | None: # htmx 4 only return self._get_header_value("HX-Source") @cached_property def target(self) -> str | None: return self._get_header_value("HX-Target") @cached_property def trigger(self) -> str | None: # htmx 2 only return self._get_header_value("HX-Trigger") @cached_property def trigger_name(self) -> str | None: # htmx 2 only return self._get_header_value("HX-Trigger-Name") @cached_property def triggering_event(self) -> Any: value = self._get_header_value("Triggering-Event") if value is not None: try: value = json.loads(value) except json.JSONDecodeError: value = None return value django-htmx-1.29.0/src/django_htmx/py.typed000066400000000000000000000000001523474501200206040ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/static/000077500000000000000000000000001523474501200204065ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/static/django_htmx/000077500000000000000000000000001523474501200227105ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/static/django_htmx/django-htmx.js000066400000000000000000000031101523474501200254610ustar00rootroot00000000000000{ const data = document.currentScript.dataset; const isDebug = data.debug === "True"; if (isDebug) { function showErrorResponse(html) { document.children[0].innerHTML = html; // Run inline scripts, which Django’s error pages use for (const script of document.scripts) { // (1, eval) wtf - see https://stackoverflow.com/questions/9107240/1-evalthis-vs-evalthis-in-javascript (1, eval)(script.innerText); } // Run window.onload function if defined, which Django’s error pages use if (typeof window.onload === "function") { window.onload(); } } const isHtmx4 = window.htmx && window.htmx.version && window.htmx.version.startsWith("4."); if (isHtmx4) { // htmx 4 swaps error responses into their request's target by default, // so opt back out of that and handle them ourselves instead. htmx.config.noSwap.push("4xx", "5xx"); document.addEventListener("htmx:response:error", function (event) { const status = event.detail.ctx.response.status; if (status == 400 || status == 403 || status == 404 || status == 500) { showErrorResponse(event.detail.ctx.text); } }); } else { document.addEventListener("htmx:beforeOnLoad", function (event) { const xhr = event.detail.xhr; if (xhr.status == 400 || xhr.status == 403 || xhr.status == 404 || xhr.status == 500 ) { // Tell htmx to stop processing this response event.stopPropagation(); showErrorResponse(xhr.response); } }); } } } django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/000077500000000000000000000000001523474501200235105ustar00rootroot00000000000000django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/htmx-2-compat-4.js000066400000000000000000000074001523474501200266100ustar00rootroot00000000000000(()=>{ //======================================================== // htmx 2.0 compatibility extension //======================================================== let api function maybeRetriggerEvent(elt, evtName, detail) { if (!htmx.config.compat?.doNotTriggerOldEvents) { htmx.trigger(elt, evtName, detail); } } htmx.registerExtension('compat', { init: (internalAPI) => { api = internalAPI; // revert inheritance if (!htmx.config.compat?.useExplicitInheritace) { htmx.config.implicitInheritance = true; } // do not swap 4xx and 5xx responses if (!htmx.config.compat?.swapErrorResponseCodes) { htmx.config.noSwap.push("4xx", "5xx"); } }, // Re-delegate new events to old event names for backwards compatibility htmx_after_implicitInheritance: function (elt, detail) { if (!htmx.config.compat?.suppressInheritanceLogs) { console.log("IMPLICIT INHERITANCE DETECTED, attribute: " + detail.name + ", elt: ", elt, ", inherited from: ", detail.parent) let evt = new CustomEvent("htmxImplicitInheritace", { detail, cancelable: true, bubbles : true, composed: true, }); elt.dispatchEvent(evt) } }, htmx_after_init: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:afterOnLoad", detail); maybeRetriggerEvent(elt, "htmx:afterProcessNode", detail); maybeRetriggerEvent(elt, "htmx:load", detail); }, htmx_after_request: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:afterRequest", detail); }, htmx_after_swap: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:afterSettle", detail); maybeRetriggerEvent(elt, "htmx:afterSwap", detail); }, htmx_before_cleanup: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeCleanupElement", detail); }, htmx_before_history_update: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeHistoryUpdate", detail); maybeRetriggerEvent(elt, "htmx:beforeHistorySave", detail); }, htmx_before_init: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeOnLoad", detail); }, htmx_before_process: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeProcessNode", detail); }, htmx_before_request: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeRequest", detail); maybeRetriggerEvent(elt, "htmx:beforeSend", detail); }, htmx_before_swap: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeSwap", detail); }, htmx_before_viewTransition: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:beforeTransition", detail); }, htmx_config_request: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:configRequest", detail); }, htmx_before_history_restore: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:historyRestore", detail); }, htmx_after_history_push: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:pushedIntoHistory", detail); }, htmx_after_history_replace: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:replacedInHistory", detail); }, htmx_error: function (elt, detail) { maybeRetriggerEvent(elt, "htmx:targetError", detail); }, }); })(); django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/htmx-2-compat-4.min.js000066400000000000000000000033001523474501200273650ustar00rootroot00000000000000(()=>{let t;function e(t,e,o){htmx.config.compat?.doNotTriggerOldEvents||htmx.trigger(t,e,o)}htmx.registerExtension("compat",{init:e=>{t=e,htmx.config.compat?.useExplicitInheritace||(htmx.config.implicitInheritance=!0),htmx.config.compat?.swapErrorResponseCodes||htmx.config.noSwap.push("4xx","5xx")},htmx_after_implicitInheritance:function(t,e){if(!htmx.config.compat?.suppressInheritanceLogs){console.log("IMPLICIT INHERITANCE DETECTED, attribute: "+e.name+", elt: ",t,", inherited from: ",e.parent);let o=new CustomEvent("htmxImplicitInheritace",{detail:e,cancelable:!0,bubbles:!0,composed:!0});t.dispatchEvent(o)}},htmx_after_init:function(t,o){e(t,"htmx:afterOnLoad",o),e(t,"htmx:afterProcessNode",o),e(t,"htmx:load",o)},htmx_after_request:function(t,o){e(t,"htmx:afterRequest",o)},htmx_after_swap:function(t,o){e(t,"htmx:afterSettle",o),e(t,"htmx:afterSwap",o)},htmx_before_cleanup:function(t,o){e(t,"htmx:beforeCleanupElement",o)},htmx_before_history_update:function(t,o){e(t,"htmx:beforeHistoryUpdate",o),e(t,"htmx:beforeHistorySave",o)},htmx_before_init:function(t,o){e(t,"htmx:beforeOnLoad",o)},htmx_before_process:function(t,o){e(t,"htmx:beforeProcessNode",o)},htmx_before_request:function(t,o){e(t,"htmx:beforeRequest",o),e(t,"htmx:beforeSend",o)},htmx_before_swap:function(t,o){e(t,"htmx:beforeSwap",o)},htmx_before_viewTransition:function(t,o){e(t,"htmx:beforeTransition",o)},htmx_config_request:function(t,o){e(t,"htmx:configRequest",o)},htmx_before_history_restore:function(t,o){e(t,"htmx:historyRestore",o)},htmx_after_history_push:function(t,o){e(t,"htmx:pushedIntoHistory",o)},htmx_after_history_replace:function(t,o){e(t,"htmx:replacedInHistory",o)},htmx_error:function(t,o){e(t,"htmx:targetError",o)}})})();django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-browser-indicator-4.js000066400000000000000000000056131523474501200302660ustar00rootroot00000000000000(() => { if (typeof navigation === 'undefined') return; let api; let activeCount = 0; let activeAborts = new Set(); let cleanupNavigation = null; function shouldShowIndicator(elt) { let val = api.attributeValue(elt, 'hx-browser-indicator'); if (val != null && val !== 'false') return true; if (htmx.config.boostBrowserIndicator && elt._htmx?.boosted) return true; return false; } function listenForNavigate() { navigation.addEventListener('navigate', (event) => { if (!event.canIntercept) return; // save state before intercept — navigation.navigate() with {history:'replace'} wipes it let savedState = history.state; let hideBrowserIndicator; event.intercept({ handler: () => new Promise(r => { hideBrowserIndicator = r }), scroll: 'manual', focusReset: 'manual' }); event.signal.addEventListener('abort', () => { if (activeCount > 0) { activeAborts.forEach(abort => abort()); activeAborts.clear(); activeCount = 0; } cleanupNavigation = null; }); cleanupNavigation = () => { hideBrowserIndicator(); // restore after resolving — replaceState during a pending intercept aborts the signal early history.replaceState(savedState, ''); }; }, {once: true}); } function startIndicator() { listenForNavigate(); navigation.navigate(location.href, { history: 'replace' }); } function stopIndicator() { if (cleanupNavigation) { cleanupNavigation(); cleanupNavigation = null; } } htmx.registerExtension('browser-indicator', { init: (internalAPI) => { api = internalAPI; }, htmx_before_history_update: () => { // stop indicator before htmx pushState fires a navigate event that would abort it stopIndicator(); }, htmx_before_request: (elt, detail) => { if (!shouldShowIndicator(elt)) return; detail.ctx._browserIndicator = true; activeCount++; if (activeCount === 1) startIndicator(); // add abort after startIndicator() so it isn't present when navigate fires during navigation.navigate() if (detail.ctx.request?.abort) activeAborts.add(detail.ctx.request.abort); }, htmx_finally_request: (elt, detail) => { if (!detail.ctx._browserIndicator) return; if (detail.ctx.request?.abort) activeAborts.delete(detail.ctx.request.abort); if (activeCount === 0) return; activeCount--; if (activeCount === 0) stopIndicator(); } }); })(); django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-browser-indicator-4.min.js000066400000000000000000000017431523474501200310500ustar00rootroot00000000000000(()=>{if("undefined"==typeof navigation)return;let t,e=0,r=new Set,n=null;function a(){navigation.addEventListener("navigate",t=>{if(!t.canIntercept)return;let a,o=history.state;t.intercept({handler:()=>new Promise(t=>{a=t}),scroll:"manual",focusReset:"manual"}),t.signal.addEventListener("abort",()=>{e>0&&(r.forEach(t=>t()),r.clear(),e=0),n=null}),n=()=>{a(),history.replaceState(o,"")}},{once:!0}),navigation.navigate(location.href,{history:"replace"})}function o(){n&&(n(),n=null)}htmx.registerExtension("browser-indicator",{init:e=>{t=e},htmx_before_history_update:()=>{o()},htmx_before_request:(n,o)=>{(function(e){let r=t.attributeValue(e,"hx-browser-indicator");return null!=r&&"false"!==r||!(!htmx.config.boostBrowserIndicator||!e._htmx?.boosted)})(n)&&(o.ctx._browserIndicator=!0,e++,1===e&&a(),o.ctx.request?.abort&&r.add(o.ctx.request.abort))},htmx_finally_request:(t,n)=>{n.ctx._browserIndicator&&(n.ctx.request?.abort&&r.delete(n.ctx.request.abort),0!==e&&(e--,0===e&&o()))}})})();django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-download-4.js000066400000000000000000000061331523474501200264360ustar00rootroot00000000000000//========================================================== // hx-download.js // // An extension that triggers a file download instead of a // DOM swap, with streaming progress events for progress bars. // // Activates when: // - hx-swap="download" is set on the element // - server responds with Content-Disposition: attachment // - server responds with HX-Download: (fetches that // url as the download, useful when the backend cannot // stream the file directly as the htmx response) // // Usage: // // // Events: // htmx:download:start {total} // htmx:download:progress {loaded, total, percent} // htmx:download:complete {filename, size} //========================================================== (() => { let api; htmx.registerExtension('download', { init: (internalAPI) => { api = internalAPI; }, htmx_before_response: (elt, {ctx}) => { let downloadUrl = ctx.response.headers.get('HX-Download'); if (downloadUrl) { (async () => streamDownload(ctx.sourceElement, await fetch(downloadUrl), downloadUrl))(); return; } let cd = ctx.response.headers.get('Content-Disposition'); if (ctx.swap !== 'download' && !cd?.includes('attachment')) return; streamDownload(ctx.sourceElement, ctx.response.raw, ctx.request.action); return false; } }); function streamDownload(sourceElement, response, url) { (async () => { let total = +response.headers.get('Content-Length') || null; api.triggerHtmxEvent(sourceElement, 'htmx:download:start', {total}); let reader = response.body.getReader(); let chunks = [], loaded = 0; while (true) { let {done, value} = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; api.triggerHtmxEvent(sourceElement, 'htmx:download:progress', { loaded, total, percent: total ? Math.round(loaded / total * 100) : null }); } let blob = new Blob(chunks, { type: response.headers.get('Content-Type') || 'application/octet-stream' }); let filename = parseFilename(response.headers, url); let blobUrl = URL.createObjectURL(blob); Object.assign(document.createElement('a'), {href: blobUrl, download: filename}).click(); URL.revokeObjectURL(blobUrl); api.triggerHtmxEvent(sourceElement, 'htmx:download:complete', {filename, size: blob.size}); })(); } function parseFilename(headers, url) { let cd = headers.get('Content-Disposition'); if (cd) { let match = cd.match(/filename\*?=['"]?(?:UTF-8'')?([^'";]+)/i); if (match) return decodeURIComponent(match[1]); } return url.split('/').pop().split('?')[0] || 'download'; } })(); django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-download-4.min.js000066400000000000000000000023001523474501200272100ustar00rootroot00000000000000(()=>{let e;function t(t,n,o){(async()=>{let r=+n.headers.get("Content-Length")||null;e.triggerHtmxEvent(t,"htmx:download:start",{total:r});let a=n.body.getReader(),l=[],s=0;for(;;){let{done:n,value:o}=await a.read();if(n)break;l.push(o),s+=o.length,e.triggerHtmxEvent(t,"htmx:download:progress",{loaded:s,total:r,percent:r?Math.round(s/r*100):null})}let i=new Blob(l,{type:n.headers.get("Content-Type")||"application/octet-stream"}),d=function(e,t){let n=e.get("Content-Disposition");if(n){let e=n.match(/filename\*?=['"]?(?:UTF-8'')?([^'";]+)/i);if(e)return decodeURIComponent(e[1])}return t.split("/").pop().split("?")[0]||"download"}(n.headers,o),c=URL.createObjectURL(i);Object.assign(document.createElement("a"),{href:c,download:d}).click(),URL.revokeObjectURL(c),e.triggerHtmxEvent(t,"htmx:download:complete",{filename:d,size:i.size})})()}htmx.registerExtension("download",{init:t=>{e=t},htmx_before_response:(e,{ctx:n})=>{let o=n.response.headers.get("HX-Download");if(o)return void(async()=>{t(n.sourceElement,await fetch(o),o)})();let r=n.response.headers.get("Content-Disposition");return"download"===n.swap||r?.includes("attachment")?(t(n.sourceElement,n.response.raw,n.request.action),!1):void 0}})})();django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-head-2.js000066400000000000000000000142151523474501200255260ustar00rootroot00000000000000//========================================================== // head-support.js // // An extension to add head tag merging. //========================================================== (function(){ var api = null; function log() { //console.log(arguments); } function mergeHead(newContent, defaultMergeStrategy) { if (newContent && newContent.indexOf(' -1) { const htmlDoc = document.createElement("html"); // remove svgs to avoid conflicts var contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); // extract head tag var headTag = contentWithSvgsRemoved.match(/(]*>|>)([\s\S]*?)<\/head>)/im); // if the head tag exists... if (headTag) { var added = [] var removed = [] var preserved = [] var nodesToAppend = [] htmlDoc.innerHTML = headTag; var newHeadTag = htmlDoc.querySelector("head"); var currentHead = document.head; if (newHeadTag == null) { return; } else { // put all new head elements into a Map, by their outerHTML var srcToNewHeadNodes = new Map(); for (const newHeadChild of newHeadTag.children) { srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); } } // determine merge strategy var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy; // get the current head for (const currentHeadElt of currentHead.children) { // If the current head element is in the map var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval"; var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true"; if (inNewContent || isPreserved) { if (isReAppended) { // remove the current version and let the new version replace it and re-execute removed.push(currentHeadElt); } else { // this element already exists and should not be re-appended, so remove it from // the new content map, preserving it in the DOM srcToNewHeadNodes.delete(currentHeadElt.outerHTML); preserved.push(currentHeadElt); } } else { if (mergeStrategy === "append") { // we are appending and this existing element is not new content // so if and only if it is marked for re-append do we do anything if (isReAppended) { removed.push(currentHeadElt); nodesToAppend.push(currentHeadElt); } } else { // if this is a merge, we remove this content since it is not in the new head if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) { removed.push(currentHeadElt); } } } } // Push the tremaining new head elements in the Map into the // nodes to append to the head tag nodesToAppend.push(...srcToNewHeadNodes.values()); log("to append: ", nodesToAppend); for (const newNode of nodesToAppend) { log("adding: ", newNode); var newElt = document.createRange().createContextualFragment(newNode.outerHTML); log(newElt); if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) { currentHead.appendChild(newElt); added.push(newElt); } } // remove all removed elements, after we have appended the new elements to avoid // additional network requests for things like style sheets for (const removedElement of removed) { if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) { currentHead.removeChild(removedElement); } } api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed}); } } } htmx.defineExtension("head-support", { init: function(apiRef) { // store a reference to the internal API. api = apiRef; htmx.on('htmx:afterSwap', function(evt){ let xhr = evt.detail.xhr; if (xhr) { var serverResponse = xhr.response; if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) { mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append"); } } }) htmx.on('htmx:historyRestore', function(evt){ if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) { if (evt.detail.cacheMiss) { mergeHead(evt.detail.serverResponse, "merge"); } else { mergeHead(evt.detail.item.head, "merge"); } } }) htmx.on('htmx:historyItemCreated', function(evt){ var historyItem = evt.detail.item; historyItem.head = document.head.outerHTML; }) } }); })() django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-head-2.min.js000066400000000000000000000034741523474501200263150ustar00rootroot00000000000000(function(){var H=null;function M(){}function a(e,t){if(e&&e.indexOf("-1){const g=document.createElement("html");var r=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var a=r.match(/(]*>|>)([\s\S]*?)<\/head>)/im);if(a){var n=[];var d=[];var o=[];var i=[];g.innerHTML=a;var h=g.querySelector("head");var m=document.head;if(h==null){return}else{var s=new Map;for(const p of h.children){s.set(p.outerHTML,p)}}var u=H.getAttributeValue(h,"hx-head")||t;for(const x of m.children){var l=s.has(x.outerHTML);var f=x.getAttribute("hx-head")==="re-eval";var v=H.getAttributeValue(x,"hx-preserve")==="true";if(l||v){if(f){d.push(x)}else{s.delete(x.outerHTML);o.push(x)}}else{if(u==="append"){if(f){d.push(x);i.push(x)}}else{if(H.triggerEvent(document.body,"htmx:removingHeadElement",{headElement:x})!==false){d.push(x)}}}}i.push(...s.values());M("to append: ",i);for(const E of i){M("adding: ",E);var c=document.createRange().createContextualFragment(E.outerHTML);M(c);if(H.triggerEvent(document.body,"htmx:addingHeadElement",{headElement:c})!==false){m.appendChild(c);n.push(c)}}for(const b of d){if(H.triggerEvent(document.body,"htmx:removingHeadElement",{headElement:b})!==false){m.removeChild(b)}}H.triggerEvent(document.body,"htmx:afterHeadMerge",{added:n,kept:o,removed:d})}}}htmx.defineExtension("head-support",{init:function(e){H=e;htmx.on("htmx:afterSwap",function(e){let t=e.detail.xhr;if(t){var r=t.response;if(H.triggerEvent(document.body,"htmx:beforeHeadMerge",e.detail)){a(r,e.detail.boosted?"merge":"append")}}});htmx.on("htmx:historyRestore",function(e){if(H.triggerEvent(document.body,"htmx:beforeHeadMerge",e.detail)){if(e.detail.cacheMiss){a(e.detail.serverResponse,"merge")}else{a(e.detail.item.head,"merge")}}});htmx.on("htmx:historyItemCreated",function(e){var t=e.detail.item;t.head=document.head.outerHTML})}})})();django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-head-4.js000066400000000000000000000206631523474501200255340ustar00rootroot00000000000000//========================================================== // hx-head.js // // An extension to add head tag merging. //========================================================== (function () { let api // Appends a new head node, returning a promise for render-critical resources // (blocking scripts, stylesheets) or null for fire-and-forget resources. function appendNode(newNode) { let newElt = document.createElement(newNode.tagName) for (const attr of newNode.attributes) newElt.setAttribute(attr.name, attr.value) newElt.textContent = newNode.textContent // stylesheet — await CSSOM or content will flash unstyled if (newNode.tagName === "LINK" && newNode.rel === "stylesheet") { return new Promise(resolve => { newElt.onload = resolve newElt.onerror = resolve // failed stylesheet shouldn't block swap document.head.appendChild(newElt) }) } // blocking external script (no async/defer) — must init before swap if (newNode.tagName === "SCRIPT" && newNode.src && !newNode.async && !newNode.defer) { return new Promise((resolve, reject) => { newElt.onload = resolve newElt.onerror = reject document.head.appendChild(newElt) }) } // meta, title, base, preload/prefetch/icon links, async scripts — fire-and-forget document.head.appendChild(newElt) if (newNode._preloadHint) newElt.addEventListener("load", () => newNode._preloadHint.remove(), {once: true}) return null } async function mergeHead(newContent, defaultMergeStrategy) { if (newContent && newContent.indexOf(' -1) { const htmlDoc = document.createElement("html") // remove svgs to avoid conflicts let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, '') // extract head tag let headTag = contentWithSvgsRemoved.match(/(]*>|>)([\s\S]*?)<\/head>)/im) // if the head tag exists... if (headTag) { let added = [] let removed = [] let preserved = [] let nodesToAppend = [] let deferred = [] htmlDoc.innerHTML = headTag let newHeadTag = htmlDoc.querySelector("head") let currentHead = document.head if (newHeadTag == null) { return [] } // put all new head elements into a Map, by their outerHTML let srcToNewHeadNodes = new Map() for (const newHeadChild of newHeadTag.children) { srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild) } // determine merge strategy let mergeStrategy = api.attributeValue(newHeadTag, "hx-head") || defaultMergeStrategy // get the current head for (const currentHeadElt of currentHead.children) { // If the current head element is in the map let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML) let isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval" let isPreserved = api.attributeValue(currentHeadElt, "hx-preserve") === "true" if (inNewContent || isPreserved) { if (isReAppended) { // remove the current version and let the new version replace it and re-execute removed.push(currentHeadElt) } else { // this element already exists and should not be re-appended, so remove it from // the new content map, preserving it in the DOM srcToNewHeadNodes.delete(currentHeadElt.outerHTML) preserved.push(currentHeadElt) } } else { if (mergeStrategy === "append") { // we are appending and this existing element is not new content // so if and only if it is marked for re-append do we do anything if (isReAppended) { removed.push(currentHeadElt) nodesToAppend.push(currentHeadElt) } } else { // if this is a merge, we remove this content since it is not in the new head if (htmx.trigger(document.body, "htmx:before:head:remove", {headElement: currentHeadElt}) !== false) { removed.push(currentHeadElt) } } } } // Push the remaining new head elements in the Map into the // nodes to append to the head tag nodesToAppend.push(...srcToNewHeadNodes.values()) // defer scripts need the swapped DOM to exist — split them out for (const newNode of nodesToAppend) { if (newNode.tagName === "SCRIPT" && newNode.defer) { deferred.push(newNode) if (newNode.src) { let hint = document.createElement("link") hint.rel = newNode.type === "module" ? "modulepreload" : "preload" hint.as = "script" hint.href = newNode.src document.head.appendChild(hint) newNode._preloadHint = hint } } else { if (htmx.trigger(document.body, "htmx:before:head:add", {headElement: newNode}) !== false) { await appendNode(newNode) added.push(newNode) } } } // remove all removed elements, after we have appended the new elements to avoid // additional network requests for things like style sheets for (const removedElement of removed) { if (htmx.trigger(document.body, "htmx:before:head:remove", {headElement: removedElement}) !== false) { currentHead.removeChild(removedElement) } } htmx.trigger(document.body, "htmx:after:head:merge", { added: added, kept: preserved, removed: removed }) return deferred } } return [] } htmx.registerExtension("hx-head", { init: (internalAPI) => { api = internalAPI; }, htmx_before_response: (elt, detail) => { let ctx = detail.ctx let target = ctx.target // TODO - is there a better way to handle this? it used to be based on if the element was boosted let defaultMergeStrategy = target === document.body ? "merge" : "append"; if (htmx.trigger(document.body, "htmx:before:head:merge", detail)) { let realText = ctx.response.raw.text.bind(ctx.response.raw) ctx.response.raw.text = async () => { let text = await realText() ctx._deferredHeadScripts = await mergeHead(text, defaultMergeStrategy) return text } } }, htmx_after_swap: (elt, detail) => { for (const node of detail.ctx._deferredHeadScripts || []) appendNode(node) }, htmx_history_cache_before_restore: (elt, detail) => { if (detail.head) { // mergeHead awaits stylesheets/blocking scripts, returns deferred scripts. // Set detail.ready so history-cache awaits before swapping body. // Stash deferred scripts on detail — history-cache copies them onto the swap ctx // so htmx_after_swap picks them up. detail.ready = mergeHead(detail.head, 'merge').then(deferred => { detail._deferredHeadScripts = deferred; }); } } }) })(); django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-head-4.min.js000066400000000000000000000044331523474501200263130ustar00rootroot00000000000000!function(){let e;function t(e){let t=document.createElement(e.tagName);for(const r of e.attributes)t.setAttribute(r.name,r.value);return t.textContent=e.textContent,"LINK"===e.tagName&&"stylesheet"===e.rel?new Promise(e=>{t.onload=e,t.onerror=e,document.head.appendChild(t)}):"SCRIPT"!==e.tagName||!e.src||e.async||e.defer?(document.head.appendChild(t),e._preloadHint&&t.addEventListener("load",()=>e._preloadHint.remove(),{once:!0}),null):new Promise((e,r)=>{t.onload=e,t.onerror=r,document.head.appendChild(t)})}async function r(r,d){if(r&&r.indexOf("-1){const a=document.createElement("html");let o=r.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"").match(/(]*>|>)([\s\S]*?)<\/head>)/im);if(o){let r=[],n=[],h=[],s=[],m=[];a.innerHTML=o;let l=a.querySelector("head"),i=document.head;if(null==l)return[];let u=new Map;for(const e of l.children)u.set(e.outerHTML,e);let c=e.attributeValue(l,"hx-head")||d;for(const t of i.children){let r=u.has(t.outerHTML),d="re-eval"===t.getAttribute("hx-head"),a="true"===e.attributeValue(t,"hx-preserve");r||a?d?n.push(t):(u.delete(t.outerHTML),h.push(t)):"append"===c?d&&(n.push(t),s.push(t)):!1!==htmx.trigger(document.body,"htmx:before:head:remove",{headElement:t})&&n.push(t)}s.push(...u.values());for(const e of s)if("SCRIPT"===e.tagName&&e.defer){if(m.push(e),e.src){let t=document.createElement("link");t.rel="module"===e.type?"modulepreload":"preload",t.as="script",t.href=e.src,document.head.appendChild(t),e._preloadHint=t}}else!1!==htmx.trigger(document.body,"htmx:before:head:add",{headElement:e})&&(await t(e),r.push(e));for(const e of n)!1!==htmx.trigger(document.body,"htmx:before:head:remove",{headElement:e})&&i.removeChild(e);return htmx.trigger(document.body,"htmx:after:head:merge",{added:r,kept:h,removed:n}),m}}return[]}htmx.registerExtension("hx-head",{init:t=>{e=t},htmx_before_response:(e,t)=>{let d=t.ctx,a=d.target===document.body?"merge":"append";if(htmx.trigger(document.body,"htmx:before:head:merge",t)){let e=d.response.raw.text.bind(d.response.raw);d.response.raw.text=async()=>{let t=await e();return d._deferredHeadScripts=await r(t,a),t}}},htmx_after_swap:(e,r)=>{for(const e of r.ctx._deferredHeadScripts||[])t(e)},htmx_history_cache_before_restore:(e,t)=>{t.head&&(t.ready=r(t.head,"merge").then(e=>{t._deferredHeadScripts=e}))}})}();django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-optimistic-4.js000066400000000000000000000070261523474501200270150ustar00rootroot00000000000000(() =>{ function normalizeSwapStyle(style) { return style === 'before' ? 'beforebegin' : style === 'after' ? 'afterend' : style === 'prepend' ? 'afterbegin' : style === 'append' ? 'beforeend' : style; } let api; function insertOptimisticContent(ctx) { ctx.optimistic = api.attributeValue(ctx.sourceElement, "hx-optimistic"); if (!ctx.optimistic) { return } let sourceElt = document.querySelector(ctx.optimistic); if (!sourceElt) return; let target = ctx.target; if (typeof target === 'string') { target = document.querySelector(target); } if (!target) return; // Create optimistic div with reset styling let optimisticDiv = document.createElement('div'); optimisticDiv.style.cssText = 'all: initial'; optimisticDiv.classList.add('hx-optimistic'); let sourceNodes = sourceElt instanceof HTMLTemplateElement ? sourceElt.content.childNodes : sourceElt.childNodes; for (let child of sourceNodes) optimisticDiv.appendChild(child.cloneNode(true)); // Set data-* for each request param if (ctx.optimisticBody) { let keys = new Set(ctx.optimisticBody.keys()); for (let k of keys) { let values = ctx.optimisticBody.getAll(k).filter(v => typeof v === 'string'); if (!values.length) continue; let val = values.length === 1 ? values[0] : JSON.stringify(values); try { optimisticDiv.dataset[k] = val; } catch (e) { try { optimisticDiv.setAttribute('data-' + k, val); } catch (e2) { /* truly invalid name, skip */ } } } } let swapStyle = normalizeSwapStyle(ctx.swap); ctx.optHidden = []; if (swapStyle === 'innerHTML') { // Hide children of target for (let child of target.children) { child.style.display = 'none'; ctx.optHidden.push(child); } target.appendChild(optimisticDiv); } else if (['beforebegin', 'afterbegin', 'beforeend', 'afterend'].includes(swapStyle)) { target.insertAdjacentElement(swapStyle, optimisticDiv); } else { // Assume outerHTML-like behavior, Hide target and insert div after it target.style.display = 'none'; ctx.optHidden.push(target); target.after(optimisticDiv); } ctx.optimisticDiv = optimisticDiv; htmx.process(optimisticDiv); } function removeOptimisticContent(ctx) { if (!ctx.optimisticDiv) return; // Remove optimistic div ctx.optimisticDiv.remove(); // Unhide any hidden elements for (let elt of ctx.optHidden) { elt.style.display = ''; } } htmx.registerExtension('hx-optimistic', { init: (internalAPI) => { api = internalAPI; }, htmx_config_request: (elt, detail) => { let body = detail.ctx.request.body; if (body?.entries) detail.ctx.optimisticBody = body; }, htmx_before_request: (elt, detail) => { insertOptimisticContent(detail.ctx); }, htmx_error : (elt, detail) => { removeOptimisticContent(detail.ctx) }, htmx_before_swap : (elt, detail) => { removeOptimisticContent(detail.ctx) } }); })(); django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-optimistic-4.min.js000066400000000000000000000027701523474501200276000ustar00rootroot00000000000000(()=>{let e;function t(e){if(e.optimisticDiv){e.optimisticDiv.remove();for(let t of e.optHidden)t.style.display=""}}htmx.registerExtension("hx-optimistic",{init:t=>{e=t},htmx_config_request:(e,t)=>{let i=t.ctx.request.body;i?.entries&&(t.ctx.optimisticBody=i)},htmx_before_request:(t,i)=>{!function(t){if(t.optimistic=e.attributeValue(t.sourceElement,"hx-optimistic"),!t.optimistic)return;let i=document.querySelector(t.optimistic);if(!i)return;let o=t.target;if("string"==typeof o&&(o=document.querySelector(o)),!o)return;let n=document.createElement("div");n.style.cssText="all: initial",n.classList.add("hx-optimistic");let r=i instanceof HTMLTemplateElement?i.content.childNodes:i.childNodes;for(let e of r)n.appendChild(e.cloneNode(!0));if(t.optimisticBody){let e=new Set(t.optimisticBody.keys());for(let i of e){let e=t.optimisticBody.getAll(i).filter(e=>"string"==typeof e);if(!e.length)continue;let o=1===e.length?e[0]:JSON.stringify(e);try{n.dataset[i]=o}catch(e){try{n.setAttribute("data-"+i,o)}catch(e){}}}}let s="before"===(l=t.swap)?"beforebegin":"after"===l?"afterend":"prepend"===l?"afterbegin":"append"===l?"beforeend":l;var l;if(t.optHidden=[],"innerHTML"===s){for(let e of o.children)e.style.display="none",t.optHidden.push(e);o.appendChild(n)}else["beforebegin","afterbegin","beforeend","afterend"].includes(s)?o.insertAdjacentElement(s,n):(o.style.display="none",t.optHidden.push(o),o.after(n));t.optimisticDiv=n,htmx.process(n)}(i.ctx)},htmx_error:(e,i)=>{t(i.ctx)},htmx_before_swap:(e,i)=>{t(i.ctx)}})})();django-htmx-1.29.0/src/django_htmx/static/django_htmx/ext/hx-preload-2.js000066400000000000000000000334231523474501200262550ustar00rootroot00000000000000(function() { /** * This adds the "preload" extension to htmx. The extension will * preload the targets of elements with "preload" attribute if: * - they also have `href`, `hx-get` or `data-hx-get` attributes * - they are radio buttons, checkboxes, select elements and submit * buttons of forms with `method="get"` or `hx-get` attributes * The extension relies on browser cache and for it to work * server response must include `Cache-Control` header * e.g. `Cache-Control: private, max-age=60`. * For more details @see https://htmx.org/extensions/preload/ */ htmx.defineExtension('preload', { onEvent: function(name, event) { // Process preload attributes on `htmx:afterProcessNode` if (name === 'htmx:afterProcessNode') { // Initialize all nodes with `preload` attribute const parent = event.target || event.detail.elt; const preloadNodes = [ ...parent.hasAttribute("preload") ? [parent] : [], ...parent.querySelectorAll("[preload]")] preloadNodes.forEach(function(node) { // Initialize the node with the `preload` attribute init(node) // Initialize all child elements which has // `href`, `hx-get` or `data-hx-get` attributes node.querySelectorAll('[href],[hx-get],[data-hx-get]').forEach(init) }) return } // Intercept HTMX preload requests on `htmx:beforeRequest` and // send them as XHR requests instead to avoid side-effects, // such as showing loading indicators while preloading data. if (name === 'htmx:beforeRequest') { const requestHeaders = event.detail.requestConfig.headers if (!("HX-Preloaded" in requestHeaders && requestHeaders["HX-Preloaded"] === "true")) { return } event.preventDefault() // Reuse XHR created by HTMX with replaced callbacks const xhr = event.detail.xhr xhr.onload = function() { processResponse(event.detail.elt, xhr.responseText) } xhr.onerror = null xhr.onabort = null xhr.ontimeout = null xhr.send() } } }) /** * Initialize `node`, set up event handlers based on own or inherited * `preload` attributes and set `node.preloadState` to `READY`. * * `node.preloadState` can have these values: * - `READY` - event handlers have been set up and node is ready to preload * - `TIMEOUT` - a triggering event has been fired, but `node` is not * yet being loaded because some time need to pass first e.g. user * has to keep hovering over an element for 100ms for preload to start * - `LOADING` means that `node` is in the process of being preloaded * - `DONE` means that the preloading process is complete and `node` * doesn't need a repeated preload (indicated by preload="always") * @param {Node} node */ function init(node) { // Guarantee that each node is initialized only once if (node.preloadState !== undefined) { return } if (!isValidNodeForPreloading(node)) { return } // Initialize form element preloading if (node instanceof HTMLFormElement) { const form = node // Only initialize forms with `method="get"` or `hx-get` attributes if (!((form.hasAttribute('method') && form.method === 'get') || form.hasAttribute('hx-get') || form.hasAttribute('hx-data-get'))) { return } for (let i = 0; i < form.elements.length; i++) { const element = form.elements.item(i); init(element); if ("labels" in element) { element.labels.forEach(init); } } return } // Process node configuration from preload attribute let preloadAttr = getClosestAttribute(node, 'preload'); node.preloadAlways = preloadAttr && preloadAttr.includes('always'); if (node.preloadAlways) { preloadAttr = preloadAttr.replace('always', '').trim(); } let triggerEventName = preloadAttr || 'mousedown'; // Set up event handlers listening for triggering events const needsTimeout = triggerEventName === 'mouseover' node.addEventListener(triggerEventName, getEventHandler(node, needsTimeout), {passive: true}) // Add `touchstart` listener for touchscreen support // if `mousedown` or `mouseover` is used if (triggerEventName === 'mousedown' || triggerEventName === 'mouseover') { node.addEventListener('touchstart', getEventHandler(node), {passive: true}) } // If `mouseover` is used, set up `mouseout` listener, // which will abort preloading if user moves mouse outside // the element in less than 100ms after hovering over it if (triggerEventName === 'mouseover') { node.addEventListener('mouseout', function(evt) { if ((evt.target === node) && (node.preloadState === 'TIMEOUT')) { node.preloadState = 'READY' } }, {passive: true}) } // Mark the node as ready to be preloaded node.preloadState = 'READY' // This event can be used to load content immediately htmx.trigger(node, 'preload:init') } /** * Return event handler which can be called by event listener to start * the preloading process of `node` with or without a timeout * @param {Node} node * @param {boolean=} needsTimeout * @returns {function(): void} */ function getEventHandler(node, needsTimeout = false) { return function() { // Do not preload uninitialized nodes, nodes which are in process // of being preloaded or have been preloaded and don't need repeat if (node.preloadState !== 'READY') { return } if (needsTimeout) { node.preloadState = 'TIMEOUT' const timeoutMs = 100 window.setTimeout(function() { if (node.preloadState === 'TIMEOUT') { node.preloadState = 'READY' load(node) } }, timeoutMs) return } load(node) } } /** * Preload the target of node, which can be: * - hx-get or data-hx-get attribute * - href or form action attribute * @param {Node} node */ function load(node) { // Do not preload uninitialized nodes, nodes which are in process // of being preloaded or have been preloaded and don't need repeat if (node.preloadState !== 'READY') { return } node.preloadState = 'LOADING' // Load nodes with `hx-get` or `data-hx-get` attribute // Forms don't reach this because only their elements are initialized const hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get') if (hxGet) { sendHxGetRequest(hxGet, node); return } // Load nodes with `href` attribute const hxBoost = getClosestAttribute(node, "hx-boost") === "true" if (node.hasAttribute('href')) { const url = node.getAttribute('href'); if (hxBoost) { sendHxGetRequest(url, node); } else { sendXmlGetRequest(url, node); } return } // Load form elements if (isPreloadableFormElement(node)) { const url = node.form.getAttribute('action') || node.form.getAttribute('hx-get') || node.form.getAttribute('data-hx-get'); const formData = htmx.values(node.form); const isStandardForm = !(node.form.getAttribute('hx-get') || node.form.getAttribute('data-hx-get') || hxBoost); const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest // submit button if (node.type === 'submit') { sendGetRequest(url, node.form, formData) return } // select const inputName = node.name || node.control.name; if (node.tagName === 'SELECT') { Array.from(node.options).forEach(option => { if (option.selected) return; formData.set(inputName, option.value); const formDataOrdered = forceFormDataInOrder(node.form, formData); sendGetRequest(url, node.form, formDataOrdered) }); return } // radio and checkbox const inputType = node.getAttribute("type") || node.control.getAttribute("type"); const nodeValue = node.value || node.control?.value; if (inputType === 'radio') { formData.set(inputName, nodeValue); } else if (inputType === 'checkbox'){ const inputValues = formData.getAll(inputName); if (inputValues.includes(nodeValue)) { formData[inputName] = inputValues.filter(value => value !== nodeValue); } else { formData.append(inputName, nodeValue); } } const formDataOrdered = forceFormDataInOrder(node.form, formData); sendGetRequest(url, node.form, formDataOrdered) return } } /** * Force formData values to be in the order of form elements. * This is useful to apply after alternating formData values * and before passing them to a HTTP request because cache is * sensitive to GET parameter order e.g., cached `/link?a=1&b=2` * will not be used for `/link?b=2&a=1`. * @param {HTMLFormElement} form * @param {FormData} formData * @returns {FormData} */ function forceFormDataInOrder(form, formData) { const formElements = form.elements; const orderedFormData = new FormData(); for(let i = 0; i < formElements.length; i++) { const element = formElements.item(i); if (formData.has(element.name) && element.tagName === 'SELECT') { orderedFormData.append( element.name, formData.get(element.name)); continue; } if (formData.has(element.name) && formData.getAll(element.name) .includes(element.value)) { orderedFormData.append(element.name, element.value); } } return orderedFormData; } /** * Send GET request with `hx-request` headers as if `sourceNode` * target was loaded. Send alternated values if `formData` is set. * * Note that this request is intercepted and sent as XMLHttpRequest. * It is necessary to use `htmx.ajax` to acquire correct headers which * HTMX and extensions add based on `sourceNode`. But it cannot be used * to perform the request due to side-effects e.g. loading indicators. * @param {string} url * @param {Node} sourceNode * @param {FormData=} formData */ function sendHxGetRequest(url, sourceNode, formData = undefined) { htmx.ajax('GET', url, { source: sourceNode, values: formData, headers: {"HX-Preloaded": "true"} }); } /** * Send XML GET request to `url`. Send `formData` as URL params if set. * @param {string} url * @param {Node} sourceNode * @param {FormData=} formData */ function sendXmlGetRequest(url, sourceNode, formData = undefined) { const xhr = new XMLHttpRequest() if (formData) { url += '?' + new URLSearchParams(formData.entries()).toString() } xhr.open('GET', url); xhr.setRequestHeader("HX-Preloaded", "true") xhr.onload = function() { processResponse(sourceNode, xhr.responseText) } xhr.send() } /** * Process request response by marking node `DONE` to prevent repeated * requests, except if preload attribute contains `always`, * and load linked resources (e.g. images) returned in the response * if `preload-images` attribute is `true` * @param {Node} node * @param {string} responseText */ function processResponse(node, responseText) { node.preloadState = node.preloadAlways ? 'READY' : 'DONE' if (getClosestAttribute(node, 'preload-images') === 'true') { // Load linked resources document.createElement('div').innerHTML = responseText } } /** * Gets attribute value from node or one of its parents * @param {Node} node * @param {string} attribute * @returns { string | undefined } */ function getClosestAttribute(node, attribute) { if (node == undefined) { return undefined } return node.getAttribute(attribute) || node.getAttribute('data-' + attribute) || getClosestAttribute(node.parentElement, attribute) } /** * Determines if node is valid for preloading and should be * initialized by setting up event listeners and handlers * @param {Node} node * @returns {boolean} */ function isValidNodeForPreloading(node) { // Add listeners only to nodes which include "GET" transactions // or preloadable "GET" form elements const getReqAttrs = ['href', 'hx-get', 'data-hx-get']; const includesGetRequest = node => getReqAttrs.some(a => node.hasAttribute(a)) || node.method === 'get'; const isPreloadableGetFormElement = node.form instanceof HTMLFormElement && includesGetRequest(node.form) && isPreloadableFormElement(node) if (!includesGetRequest(node) && !isPreloadableGetFormElement) { return false } // Don't preload elements contained in