Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-django-js-asset for 
openSUSE:Factory checked in at 2026-08-22 21:35:11
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-django-js-asset (Old)
 and      /work/SRC/openSUSE:Factory/.python-django-js-asset.new.1258 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-django-js-asset"

Sat Aug 22 21:35:11 2026 rev:10 rq:1372942 version:4.0.2

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-django-js-asset/python-django-js-asset.changes
    2026-06-22 17:29:45.010395344 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-django-js-asset.new.1258/python-django-js-asset.changes
  2026-08-22 21:37:20.721797582 +0200
@@ -1,0 +2,19 @@
+Fri Aug 21 20:53:01 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 4.0.2:
+  * Fixed js_asset.Media treating html-safe strings -- e.g.
+    mark_safe('<script defer src="..."></script>'), a
+    long-documented Django idiom for embedding a complete asset tag
+    -- as asset paths, so they were run through static() and
+    percent-encoded instead of being rendered verbatim. Anything
+    providing __html__() now takes the verbatim path, matching
+    django.forms.Media (see Django's ticket #37262).
+  * js_asset.Media additionally renders html-safe strings
+    correctly on Django 6.1, whose own forms.Media mangles them
+    (fixed in Django for 6.1.1). Only media built through
+    js_asset.Media benefits; assets adopted from a plain
+    forms.Media -- including widget Media declarations, which
+    Django always builds with forms.Media -- are already normalized
+    before we see them.
+
+-------------------------------------------------------------------

Old:
----
  4.0.1.tar.gz

New:
----
  4.0.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-django-js-asset.spec ++++++
--- /var/tmp/diff_new_pack.DMqEA3/_old  2026-08-22 21:37:21.526826358 +0200
+++ /var/tmp/diff_new_pack.DMqEA3/_new  2026-08-22 21:37:21.528826429 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-django-js-asset
-Version:        4.0.1
+Version:        4.0.2
 Release:        0
 Summary:        Script tag with additional attributes for django.formsMedia
 License:        BSD-3-Clause

++++++ 4.0.1.tar.gz -> 4.0.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/.github/workflows/publish.yml 
new/django-js-asset-4.0.2/.github/workflows/publish.yml
--- old/django-js-asset-4.0.1/.github/workflows/publish.yml     2026-06-11 
18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/.github/workflows/publish.yml     2026-08-18 
08:50:13.000000000 +0200
@@ -11,8 +11,8 @@
     runs-on: ubuntu-latest
     if: github.event.repository.fork == false
     steps:
-    - uses: actions/checkout@v6
-    - uses: actions/setup-python@v6
+    - uses: actions/checkout@v7
+    - uses: actions/setup-python@v7
       with:
         python-version: 3.x
     - name: Build
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/.github/workflows/tests.yml 
new/django-js-asset-4.0.2/.github/workflows/tests.yml
--- old/django-js-asset-4.0.1/.github/workflows/tests.yml       2026-06-11 
18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/.github/workflows/tests.yml       2026-08-18 
08:50:13.000000000 +0200
@@ -23,9 +23,9 @@
         - '3.14'
 
     steps:
-    - uses: actions/checkout@v6
+    - uses: actions/checkout@v7
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v6
+      uses: actions/setup-python@v7
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/.pre-commit-config.yaml 
new/django-js-asset-4.0.2/.pre-commit-config.yaml
--- old/django-js-asset-4.0.1/.pre-commit-config.yaml   2026-06-11 
18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/.pre-commit-config.yaml   2026-08-18 
08:50:13.000000000 +0200
@@ -14,17 +14,17 @@
       - id: mixed-line-ending
       - id: trailing-whitespace
   - repo: https://github.com/adamchainz/django-upgrade
-    rev: 1.30.0
+    rev: 1.31.1
     hooks:
       - id: django-upgrade
         args: [--target-version, "3.2"]
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.15.16"
+    rev: "v0.16.3"
     hooks:
       - id: ruff
       - id: ruff-format
   - repo: https://github.com/tox-dev/pyproject-fmt
-    rev: v2.23.0
+    rev: v2.28.0
     hooks:
       - id: pyproject-fmt
   - repo: https://github.com/abravalheri/validate-pyproject
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/.readthedocs.yaml 
new/django-js-asset-4.0.2/.readthedocs.yaml
--- old/django-js-asset-4.0.1/.readthedocs.yaml 1970-01-01 01:00:00.000000000 
+0100
+++ new/django-js-asset-4.0.2/.readthedocs.yaml 2026-08-18 08:50:13.000000000 
+0200
@@ -0,0 +1,12 @@
+# 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.13"
+
+sphinx:
+  configuration: docs/conf.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/AGENTS.md 
new/django-js-asset-4.0.2/AGENTS.md
--- old/django-js-asset-4.0.1/AGENTS.md 2026-06-11 18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/AGENTS.md 2026-08-18 08:50:13.000000000 +0200
@@ -32,6 +32,30 @@
   `Script`/`Stylesheet` in `Media._js`/`._css`; older Django keeps raw strings.
   `media.py:_render_{js,css}` wrap any leftover strings via `JS()`/`CSS()`, so
   `_render_asset` always sees a `MediaAsset` (or `JSON`/`ImportMap`).
+- **Never test an asset with `isinstance(item, str)`.** `SafeString` is a `str`
+  subclass, so `mark_safe('<script src=...></script>')` — a complete tag that
+  must render verbatim — would be resolved through `static()` and
+  percent-encoded. The predicate is `hasattr(item, "__html__")`; only bare
+  paths get wrapped. Django made exactly this mistake in 6.1 (ticket #37262,
+  fixed on its 6.1.x branch for 6.1.1), and we had it independently in
+  `_render_{js,css}`.
+- Because Django 6.1 mangles such strings in `forms.Media.__init__` itself,
+  `media.py` also overrides `_normalize_{js,css}` with the `__html__`
+  predicate — those hooks are called through `self`, so a subclass can fix
+  them, and `_compat` gives us `Script`/`Stylesheet` on every supported Django
+  so the wrapped output is unchanged. This covers media built *through* our
+  class; assets adopted from a foreign `forms.Media` (`from_media`, `__add__`,
+  and Django's widget `media_property`, which always instantiates
+  `forms.Media`) arrive already normalized and cannot be recovered on 6.1.
+  `test_html_safe_strings_adopted_from_foreign_media` pins that boundary via
+  the `DJANGO_KEEPS_HTML_SAFE_STRINGS` probe.
+- `tests/testapp/test_media.py` carries `JS_ASSETS`/`CSS_ASSETS`: one row per
+  asset kind, each with its exact rendering with and without a nonce. **Add a
+  row whenever a new asset kind appears** — that table is the guard against a
+  rendering branch quietly mishandling one of them.
+  `test_matches_django_rendering` compares us against stock `forms.Media` for
+  path assets; it is what catches Django changing its normalization or tag
+  format under our overrides.
 
 ## Layout
 
@@ -47,7 +71,8 @@
   Equality is Django's, so dedup is attribute-aware on 4.2-5.1 + 6.2+ and
   path-only on 5.2-6.1 (`test_set` derives its expectation from this).
 - `js_asset/media.py` — `Media(forms.Media)` subclass: merges embedded
-  `ImportMap`s into one tag and applies a nonce. Implements `__add__` **and**
+  `ImportMap`s into one tag, applies a nonce, and normalizes js/css entries by
+  the `__html__` predicate (see the html-safe-string note above). Implements 
`__add__` **and**
   `__radd__` so it keeps its type (and nonce) when combined with plain
   `forms.Media` from either side. The nonce lives on the instance (constructor
   `nonce=` or `with_nonce()` returning a copy); `render()` reads it, since
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/CHANGELOG.rst 
new/django-js-asset-4.0.2/CHANGELOG.rst
--- old/django-js-asset-4.0.1/CHANGELOG.rst     2026-06-11 18:03:46.000000000 
+0200
+++ new/django-js-asset-4.0.2/CHANGELOG.rst     2026-08-18 08:50:13.000000000 
+0200
@@ -13,6 +13,20 @@
   fall back to ``__html__()`` exactly like ``django.forms.Media`` does; the
   nonce cannot be threaded into an opaque ``__html__`` asset, same as with 
stock
   Django.
+- Fixed ``js_asset.Media`` treating html-safe strings -- e.g.
+  ``mark_safe('<script defer src="..."></script>')``, a long-documented Django
+  idiom for embedding a complete asset tag -- as asset paths, so they were run
+  through ``static()`` and percent-encoded instead of being rendered verbatim.
+  Anything providing ``__html__()`` now takes the verbatim path, matching
+  ``django.forms.Media`` (see Django's ticket #37262).
+- ``js_asset.Media`` additionally renders html-safe strings correctly on Django
+  6.1, whose own ``forms.Media`` mangles them (fixed in Django for 6.1.1). Only
+  media built through ``js_asset.Media`` benefits; assets adopted from a plain
+  ``forms.Media`` -- including widget ``Media`` declarations, which Django
+  always builds with ``forms.Media`` -- are already normalized before we see
+  them.
+- Corrected the docs around Django 6.1 support. Thanks James Bligh!
+- Finally set up a documentation site at Read the Docs for django-js-asset.
 
 
 4.0 (2026-06-11)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/README.rst 
new/django-js-asset-4.0.2/README.rst
--- old/django-js-asset-4.0.1/README.rst        2026-06-11 18:03:46.000000000 
+0200
+++ new/django-js-asset-4.0.2/README.rst        2026-08-18 08:50:13.000000000 
+0200
@@ -269,14 +269,15 @@
 applied when the media is rendered, not when it is constructed.
 ``js_asset.Media`` stores an optional nonce and applies it to every script and
 stylesheet it renders (a ``JSON`` block is data, not executable, and
-deliberately gets none). There are three ways to get the nonce in, depending on
+deliberately gets none). There are a few ways to get the nonce in, depending on
 your Django version.
 
-Django 6.2 and newer (built-in CSP)
+Django 6.1 and newer (built-in CSP)
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Django 6.2 ships CSP support, and ``js_asset.Media`` plugs straight into it --
-no extra wiring. Configure CSP as usual:
+Django 6.1 added the ``csp_nonce_attr`` template tag with ``forms.Media``
+support, and ``js_asset.Media`` plugs straight into it -- no extra wiring.
+Configure CSP as usual:
 
 .. code-block:: python
 
@@ -314,10 +315,47 @@
 That single tag emits the merged import map and every script/stylesheet, each
 carrying the per-request nonce.
 
-Django 4.2 to 6.1 (with ``django-csp``)
+Django 6.0 (built-in CSP, no ``csp_nonce_attr``)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Django 6.0 has the built-in CSP middleware, the ``SECURE_CSP`` setting and the
+``csp`` context processor, but not the ``csp_nonce_attr`` tag (that arrived in
+6.1). Configure CSP exactly as for `Django 6.1 and newer (built-in CSP)`_
+above; the only difference is that you apply the nonce yourself.
+
+Drop in a small tag. The built-in ``{{ csp_nonce }}`` value is lazy and reads 
as
+falsy until first used, so the tag wraps it in ``str()``. It also copes with a
+plain ``forms.Media`` -- ``Media(form.media)`` does **not** work, because
+``forms.Media`` copies assets from a media *definition*, not an *instance*, so
+use ``from_media``:
+
+.. code-block:: python
+
+    # yourapp/templatetags/js_asset_csp.py
+    from django import template
+    from js_asset import Media
+
+    register = template.Library()
+
+    @register.simple_tag(takes_context=True)
+    def media_with_nonce(context, media):
+        nonce = context.get("csp_nonce", "")
+        if not isinstance(media, Media):
+            media = Media.from_media(media)
+        return media.with_nonce(str(nonce)).render()
+
+.. code-block:: html
+
+    {% load js_asset_csp %}
+    {% media_with_nonce form.media %}
+
+``with_nonce()`` returns a *copy*, so a shared/cached widget ``media`` object 
is
+never mutated and one request's nonce can never leak into another.
+
+Django 4.2 to 5.x (with ``django-csp``)
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Older Django has no built-in nonce, so use the third-party `django-csp
+Older Django has no built-in CSP, so use the third-party `django-csp
 <https://django-csp.readthedocs.io/>`__ package. Install it, add its
 middleware, and make sure the nonce is part of the relevant directives.
 
@@ -372,32 +410,14 @@
 
     {{ form_media }}
 
-``with_nonce()`` returns a *copy*, so a shared/cached widget ``media`` object 
is
-never mutated and one request's nonce can never leak into another. If you would
-rather stay in the template, drop in a small tag (the ``request`` context
-processor must be enabled). It also copes with a plain ``forms.Media`` --
-``Media(form.media)`` does **not** work, because ``forms.Media`` copies assets
-from a media *definition*, not an *instance*, so use ``from_media``:
+If you would rather stay in the template, the ``media_with_nonce()`` approach
+is exactly the same as for `Django 6.0 (built-in CSP, no csp_nonce_attr)`_ 
above,
+only its *source* changes. The template tag's nonce line can read
+``request.csp_nonce`` (the ``request`` context processor must be enabled):
 
 .. code-block:: python
 
-    # yourapp/templatetags/js_asset_csp.py
-    from django import template
-    from js_asset import Media
-
-    register = template.Library()
-
-    @register.simple_tag(takes_context=True)
-    def media_with_nonce(context, media):
-        nonce = getattr(context.get("request"), "csp_nonce", "")
-        if not isinstance(media, Media):
-            media = Media.from_media(media)
-        return media.with_nonce(nonce).render()
-
-.. code-block:: html
-
-    {% load js_asset_csp %}
-    {% media_with_nonce form.media %}
+    nonce = getattr(context.get("request"), "csp_nonce", "")
 
 Anywhere: set the nonce explicitly
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/docs/.gitignore 
new/django-js-asset-4.0.2/docs/.gitignore
--- old/django-js-asset-4.0.1/docs/.gitignore   1970-01-01 01:00:00.000000000 
+0100
+++ new/django-js-asset-4.0.2/docs/.gitignore   2026-08-18 08:50:13.000000000 
+0200
@@ -0,0 +1 @@
+build
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/docs/Makefile 
new/django-js-asset-4.0.2/docs/Makefile
--- old/django-js-asset-4.0.1/docs/Makefile     1970-01-01 01:00:00.000000000 
+0100
+++ new/django-js-asset-4.0.2/docs/Makefile     2026-08-18 08:50:13.000000000 
+0200
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx 
installed, then set the SPHINXBUILD environment variable to point to the full 
path of the '$(SPHINXBUILD)' executable. Alternatively you can add the 
directory with the executable to your PATH. If you don't have Sphinx installed, 
grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp 
epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+       @echo "Please use \`make <target>' where <target> is one of"
+       @echo "  html       to make standalone HTML files"
+       @echo "  dirhtml    to make HTML files named index.html in directories"
+       @echo "  singlehtml to make a single large HTML file"
+       @echo "  pickle     to make pickle files"
+       @echo "  json       to make JSON files"
+       @echo "  htmlhelp   to make HTML files and a HTML help project"
+       @echo "  qthelp     to make HTML files and a qthelp project"
+       @echo "  devhelp    to make HTML files and a Devhelp project"
+       @echo "  epub       to make an epub"
+       @echo "  latex      to make LaTeX files, you can set PAPER=a4 or 
PAPER=letter"
+       @echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+       @echo "  latexpdfja to make LaTeX files and run them through 
platex/dvipdfmx"
+       @echo "  text       to make text files"
+       @echo "  man        to make manual pages"
+       @echo "  texinfo    to make Texinfo files"
+       @echo "  info       to make Texinfo files and run them through makeinfo"
+       @echo "  gettext    to make PO message catalogs"
+       @echo "  changes    to make an overview of all changed/added/deprecated 
items"
+       @echo "  xml        to make Docutils-native XML files"
+       @echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
+       @echo "  linkcheck  to check all external links for integrity"
+       @echo "  doctest    to run all doctests embedded in the documentation 
(if enabled)"
+
+clean:
+       rm -rf $(BUILDDIR)/*
+
+html:
+       $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+       @echo
+       @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+       $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+       @echo
+       @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+       $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+       @echo
+       @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+       $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+       @echo
+       @echo "Build finished; now you can process the pickle files."
+
+json:
+       $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+       @echo
+       @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+       $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+       @echo
+       @echo "Build finished; now you can run HTML Help Workshop with the" \
+             ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+       $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+       @echo
+       @echo "Build finished; now you can run "qcollectiongenerator" with the" 
\
+             ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+       @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoCTEForest.qhcp"
+       @echo "To view the help file:"
+       @echo "# assistant -collectionFile 
$(BUILDDIR)/qthelp/DjangoCTEForest.qhc"
+
+devhelp:
+       $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+       @echo
+       @echo "Build finished."
+       @echo "To view the help file:"
+       @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoCTEForest"
+       @echo "# ln -s $(BUILDDIR)/devhelp 
$$HOME/.local/share/devhelp/DjangoCTEForest"
+       @echo "# devhelp"
+
+epub:
+       $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+       @echo
+       @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+       $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+       @echo
+       @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+       @echo "Run \`make' in that directory to run these through (pdf)latex" \
+             "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+       $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+       @echo "Running LaTeX files through pdflatex..."
+       $(MAKE) -C $(BUILDDIR)/latex all-pdf
+       @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+       $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+       @echo "Running LaTeX files through platex and dvipdfmx..."
+       $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+       @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+       $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+       @echo
+       @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+       $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+       @echo
+       @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+       $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+       @echo
+       @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+       @echo "Run \`make' in that directory to run these through makeinfo" \
+             "(use \`make info' here to do that automatically)."
+
+info:
+       $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+       @echo "Running Texinfo files through makeinfo..."
+       make -C $(BUILDDIR)/texinfo info
+       @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+       $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+       @echo
+       @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+       $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+       @echo
+       @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+       $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+       @echo
+       @echo "Link check complete; look for any errors in the above output " \
+             "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+       $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+       @echo "Testing of doctests in the sources finished, look at the " \
+             "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+       $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+       @echo
+       @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+       $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+       @echo
+       @echo "Build finished. The pseudo-XML files are in 
$(BUILDDIR)/pseudoxml."
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/docs/conf.py 
new/django-js-asset-4.0.2/docs/conf.py
--- old/django-js-asset-4.0.1/docs/conf.py      1970-01-01 01:00:00.000000000 
+0100
+++ new/django-js-asset-4.0.2/docs/conf.py      2026-08-18 08:50:13.000000000 
+0200
@@ -0,0 +1,59 @@
+import os
+import sys
+
+
+sys.path.append(os.path.abspath(".."))
+
+extensions = []
+
+templates_path = ["_templates"]
+
+source_suffix = ".rst"
+
+master_doc = "index"
+
+project = "django-js-asset"
+copyright = "2017 - {dt.date.today().year} Feinheit AG"
+
+version = __import__("js_asset").__version__
+release = version
+
+pygments_style = "sphinx"
+
+html_theme = "alabaster"
+
+html_static_path = ["_static"]
+
+htmlhelp_basename = "django-js-assetdoc"
+
+latex_documents = [
+    (
+        "index",
+        "django-js-asset.tex",
+        "form-designer Documentation",
+        "Feinheit AG",
+        "manual",
+    )
+]
+
+man_pages = [
+    (
+        "index",
+        "django-js-asset",
+        "form-designer Documentation",
+        ["Feinheit AG"],
+        1,
+    )
+]
+
+texinfo_documents = [
+    (
+        "index",
+        "django-js-asset",
+        "form-designer Documentation",
+        "Feinheit AG",
+        "django-js-asset",
+        "A simple form designer for Django",
+        "Miscellaneous",
+    )
+]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/docs/index.rst 
new/django-js-asset-4.0.2/docs/index.rst
--- old/django-js-asset-4.0.1/docs/index.rst    1970-01-01 01:00:00.000000000 
+0100
+++ new/django-js-asset-4.0.2/docs/index.rst    2026-08-18 08:50:13.000000000 
+0200
@@ -0,0 +1,2 @@
+.. include:: ../README.rst
+.. include:: ../CHANGELOG.rst
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/docs/make.bat 
new/django-js-asset-4.0.2/docs/make.bat
--- old/django-js-asset-4.0.1/docs/make.bat     1970-01-01 01:00:00.000000000 
+0100
+++ new/django-js-asset-4.0.2/docs/make.bat     2026-08-18 08:50:13.000000000 
+0200
@@ -0,0 +1,242 @@
+@ECHO OFF
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+       set SPHINXBUILD=sphinx-build
+)
+set BUILDDIR=build
+set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
+set I18NSPHINXOPTS=%SPHINXOPTS% .
+if NOT "%PAPER%" == "" (
+       set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
+       set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
+)
+
+if "%1" == "" goto help
+
+if "%1" == "help" (
+       :help
+       echo.Please use `make ^<target^>` where ^<target^> is one of
+       echo.  html       to make standalone HTML files
+       echo.  dirhtml    to make HTML files named index.html in directories
+       echo.  singlehtml to make a single large HTML file
+       echo.  pickle     to make pickle files
+       echo.  json       to make JSON files
+       echo.  htmlhelp   to make HTML files and a HTML help project
+       echo.  qthelp     to make HTML files and a qthelp project
+       echo.  devhelp    to make HTML files and a Devhelp project
+       echo.  epub       to make an epub
+       echo.  latex      to make LaTeX files, you can set PAPER=a4 or 
PAPER=letter
+       echo.  text       to make text files
+       echo.  man        to make manual pages
+       echo.  texinfo    to make Texinfo files
+       echo.  gettext    to make PO message catalogs
+       echo.  changes    to make an overview over all changed/added/deprecated 
items
+       echo.  xml        to make Docutils-native XML files
+       echo.  pseudoxml  to make pseudoxml-XML files for display purposes
+       echo.  linkcheck  to check all external links for integrity
+       echo.  doctest    to run all doctests embedded in the documentation if 
enabled
+       goto end
+)
+
+if "%1" == "clean" (
+       for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
+       del /q /s %BUILDDIR%\*
+       goto end
+)
+
+
+%SPHINXBUILD% 2> nul
+if errorlevel 9009 (
+       echo.
+       echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+       echo.installed, then set the SPHINXBUILD environment variable to point
+       echo.to the full path of the 'sphinx-build' executable. Alternatively 
you
+       echo.may add the Sphinx directory to PATH.
+       echo.
+       echo.If you don't have Sphinx installed, grab it from
+       echo.http://sphinx-doc.org/
+       exit /b 1
+)
+
+if "%1" == "html" (
+       %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The HTML pages are in %BUILDDIR%/html.
+       goto end
+)
+
+if "%1" == "dirhtml" (
+       %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
+       goto end
+)
+
+if "%1" == "singlehtml" (
+       %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
+       goto end
+)
+
+if "%1" == "pickle" (
+       %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can process the pickle files.
+       goto end
+)
+
+if "%1" == "json" (
+       %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can process the JSON files.
+       goto end
+)
+
+if "%1" == "htmlhelp" (
+       %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can run HTML Help Workshop with the ^
+.hhp project file in %BUILDDIR%/htmlhelp.
+       goto end
+)
+
+if "%1" == "qthelp" (
+       %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; now you can run "qcollectiongenerator" with the ^
+.qhcp project file in %BUILDDIR%/qthelp, like this:
+       echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DjangoCTEForest.qhcp
+       echo.To view the help file:
+       echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DjangoCTEForest.ghc
+       goto end
+)
+
+if "%1" == "devhelp" (
+       %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished.
+       goto end
+)
+
+if "%1" == "epub" (
+       %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The epub file is in %BUILDDIR%/epub.
+       goto end
+)
+
+if "%1" == "latex" (
+       %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
+       goto end
+)
+
+if "%1" == "latexpdf" (
+       %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+       cd %BUILDDIR%/latex
+       make all-pdf
+       cd %BUILDDIR%/..
+       echo.
+       echo.Build finished; the PDF files are in %BUILDDIR%/latex.
+       goto end
+)
+
+if "%1" == "latexpdfja" (
+       %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+       cd %BUILDDIR%/latex
+       make all-pdf-ja
+       cd %BUILDDIR%/..
+       echo.
+       echo.Build finished; the PDF files are in %BUILDDIR%/latex.
+       goto end
+)
+
+if "%1" == "text" (
+       %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The text files are in %BUILDDIR%/text.
+       goto end
+)
+
+if "%1" == "man" (
+       %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The manual pages are in %BUILDDIR%/man.
+       goto end
+)
+
+if "%1" == "texinfo" (
+       %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
+       goto end
+)
+
+if "%1" == "gettext" (
+       %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
+       goto end
+)
+
+if "%1" == "changes" (
+       %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.The overview file is in %BUILDDIR%/changes.
+       goto end
+)
+
+if "%1" == "linkcheck" (
+       %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Link check complete; look for any errors in the above output ^
+or in %BUILDDIR%/linkcheck/output.txt.
+       goto end
+)
+
+if "%1" == "doctest" (
+       %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Testing of doctests in the sources finished, look at the ^
+results in %BUILDDIR%/doctest/output.txt.
+       goto end
+)
+
+if "%1" == "xml" (
+       %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The XML files are in %BUILDDIR%/xml.
+       goto end
+)
+
+if "%1" == "pseudoxml" (
+       %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
+       if errorlevel 1 exit /b 1
+       echo.
+       echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
+       goto end
+)
+
+:end
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/js_asset/__init__.py 
new/django-js-asset-4.0.2/js_asset/__init__.py
--- old/django-js-asset-4.0.1/js_asset/__init__.py      2026-06-11 
18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/js_asset/__init__.py      2026-08-18 
08:50:13.000000000 +0200
@@ -1,4 +1,4 @@
-__version__ = "4.0.1"
+__version__ = "4.0.2"
 
 import contextlib
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/js_asset/js.py 
new/django-js-asset-4.0.2/js_asset/js.py
--- old/django-js-asset-4.0.1/js_asset/js.py    2026-06-11 18:03:46.000000000 
+0200
+++ new/django-js-asset-4.0.2/js_asset/js.py    2026-08-18 08:50:13.000000000 
+0200
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import json
 from dataclasses import dataclass, field
 from typing import Any
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/js_asset/media.py 
new/django-js-asset-4.0.2/js_asset/media.py
--- old/django-js-asset-4.0.1/js_asset/media.py 2026-06-11 18:03:46.000000000 
+0200
+++ new/django-js-asset-4.0.2/js_asset/media.py 2026-08-18 08:50:13.000000000 
+0200
@@ -5,7 +5,7 @@
 from django.forms.utils import flatatt
 from django.utils.html import format_html, mark_safe
 
-from js_asset._compat import MediaAsset
+from js_asset._compat import MediaAsset, Script, Stylesheet
 from js_asset.js import CSS, JS, JSON, ImportMap
 
 
@@ -34,6 +34,41 @@
         self.nonce = nonce
         super().__init__(media=media, css=css, js=js)
 
+    # -- Normalization ----------------------------------------------------
+
+    # ``forms.Media.__init__`` calls these through ``self``, so a subclass can
+    # override them. They only exist -- and are only reached -- on Django >=
+    # 6.1; older versions keep js/css entries as given and ``_render_{js,css}``
+    # wraps them at render time instead.
+    #
+    # Django 6.1 normalizes with ``isinstance(path, str)``, which swallows
+    # html-safe strings: a ``SafeString`` such as
+    # ``mark_safe('<script defer src="..."></script>')`` is a complete tag, but
+    # being a ``str`` subclass it got turned into a ``Script`` and resolved
+    # through ``static()`` (ticket #37262, fixed for 6.1.1). Normalizing with
+    # the ``__html__`` predicate instead keeps such entries verbatim on 6.1 
too.
+    # ``Script``/``Stylesheet`` come from ``_compat``, so the wrapped output is
+    # the same on every supported Django.
+    #
+    # This can only help media built *through* this class. Assets adopted from
+    # a foreign ``forms.Media`` (``from_media``, ``__add__``, and Django's
+    # widget ``media_property``, which always instantiates ``forms.Media``) are
+    # already normalized by the time we see them.
+
+    @staticmethod
+    def _normalize_js(js):
+        return [path if hasattr(path, "__html__") else Script(path) for path 
in js]
+
+    @staticmethod
+    def _normalize_css(css):
+        return {
+            medium: [
+                path if hasattr(path, "__html__") else Stylesheet(path, 
media=medium)
+                for path in paths
+            ]
+            for medium, paths in css.items()
+        }
+
     @classmethod
     def from_media(cls, media, *, nonce=""):
         """
@@ -108,7 +143,13 @@
         for item in self._js:
             if isinstance(item, ImportMap):
                 continue
-            asset = JS(item) if isinstance(item, str) else item
+            # ``hasattr(item, "__html__")`` -- not ``isinstance(item, str)``:
+            # ``SafeString`` is a ``str`` subclass, and html-safe strings such
+            # as ``mark_safe("<script defer src=...></script>")`` are complete
+            # tags which must render verbatim instead of being resolved through
+            # ``static()``. Only bare paths are wrapped. (Django hit the same
+            # trap in 6.1, fixed for 6.1.1 -- ticket #37262.)
+            asset = item if hasattr(item, "__html__") else JS(item)
             rendered.append(self._render_asset(asset, nonce))
         return rendered
 
@@ -116,7 +157,8 @@
         rendered = []
         for medium in sorted(self._css):
             for item in self._css[medium]:
-                asset = CSS(item, media=medium) if isinstance(item, str) else 
item
+                # See ``_render_js`` on the ``__html__`` check.
+                asset = item if hasattr(item, "__html__") else CSS(item, 
media=medium)
                 rendered.append(self._render_asset(asset, nonce))
         return rendered
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/tests/testapp/test_media.py 
new/django-js-asset-4.0.2/tests/testapp/test_media.py
--- old/django-js-asset-4.0.1/tests/testapp/test_media.py       2026-06-11 
18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/tests/testapp/test_media.py       2026-08-18 
08:50:13.000000000 +0200
@@ -1,8 +1,9 @@
 from django.forms import Media as DjangoMedia
 from django.test import TestCase
 from django.utils.html import html_safe
+from django.utils.safestring import mark_safe
 
-from js_asset import CSS, JS, JSON, ImportMap, Media
+from js_asset import CSS, JS, JSON, ImportMap, Media, Script, Stylesheet
 
 
 @html_safe
@@ -20,6 +21,161 @@
         return self._markup
 
 
+# Django 6.1 normalizes *every* js/css string -- html-safe ones included --
+# into ``Script``/``Stylesheet`` inside ``forms.Media.__init__`` (ticket 
#37262,
+# fixed on the 6.1.x branch for 6.1.1). Our ``Media`` overrides the normalizers
+# so media built through *our* class is unaffected, but assets adopted from a
+# foreign ``forms.Media`` arrive already mangled. This probe marks that window.
+_PROBE = mark_safe('<script src="/probe.js"></script>')
+DJANGO_KEEPS_HTML_SAFE_STRINGS = str(DjangoMedia(js=[_PROBE])) == _PROBE
+
+
+HTML_SAFE_JS = mark_safe('<script defer 
src="https://example.org/asset.js";></script>')
+HTML_SAFE_CSS = mark_safe(
+    '<link href="https://example.org/asset.css"; rel="stylesheet">'
+)
+
+# (label, asset, rendering without a nonce, rendering with nonce="n0nce").
+#
+# One row per kind of asset a ``Media`` can carry. Add a row whenever a new
+# asset kind appears -- this is the guard against a rendering branch (such as
+# the "is it a bare path?" test in ``Media._render_js``/``._render_css``)
+# quietly mishandling one of them.
+JS_ASSETS = [
+    (
+        "bare path string",
+        "app.js",
+        '<script src="/static/app.js"></script>',
+        '<script src="/static/app.js" nonce="n0nce"></script>',
+    ),
+    (
+        "JS factory",
+        JS("app.js"),
+        '<script src="/static/app.js"></script>',
+        '<script src="/static/app.js" nonce="n0nce"></script>',
+    ),
+    (
+        "JS factory with attributes",
+        JS("app.js", {"type": "module"}),
+        '<script src="/static/app.js" type="module"></script>',
+        '<script src="/static/app.js" nonce="n0nce" type="module"></script>',
+    ),
+    (
+        "Script object",
+        Script("app.js"),
+        '<script src="/static/app.js"></script>',
+        '<script src="/static/app.js" nonce="n0nce"></script>',
+    ),
+    (
+        "JSON block",
+        JSON({"a": 1}, id="cfg"),
+        # Data, not executed script: no nonce either way.
+        '<script id="cfg" type="application/json">{"a": 1}</script>',
+        '<script id="cfg" type="application/json">{"a": 1}</script>',
+    ),
+    (
+        "ImportMap",
+        ImportMap({"imports": {"a": "/static/a.js"}}),
+        '<script type="importmap">{"imports": {"a": "/static/a.js"}}</script>',
+        (
+            '<script type="importmap" nonce="n0nce">'
+            '{"imports": {"a": "/static/a.js"}}</script>'
+        ),
+    ),
+    (
+        "object with only __html__",
+        HTMLOnlyAsset('<script src="/bundle.js"></script>'),
+        # Opaque markup: rendered verbatim, and the nonce cannot be threaded in
+        # (same as stock ``forms.Media``).
+        '<script src="/bundle.js"></script>',
+        '<script src="/bundle.js"></script>',
+    ),
+    (
+        "html-safe string",
+        HTML_SAFE_JS,
+        # A ``SafeString`` is a ``str``, but it is a complete tag rather than a
+        # path: it must never be resolved through ``static()``.
+        HTML_SAFE_JS,
+        HTML_SAFE_JS,
+    ),
+]
+
+CSS_ASSETS = [
+    (
+        "bare path string",
+        "app.css",
+        '<link href="/static/app.css" media="all" rel="stylesheet">',
+        '<link href="/static/app.css" media="all" nonce="n0nce" 
rel="stylesheet">',
+    ),
+    (
+        "CSS factory",
+        CSS("app.css"),
+        '<link href="/static/app.css" media="all" rel="stylesheet">',
+        '<link href="/static/app.css" media="all" nonce="n0nce" 
rel="stylesheet">',
+    ),
+    (
+        # Django's ``Stylesheet`` has no implicit ``media``; unlike the ``CSS``
+        # factory it only emits the attribute when one is passed. The dict key
+        # it is filed under does not add one.
+        "Stylesheet object",
+        Stylesheet("app.css"),
+        '<link href="/static/app.css" rel="stylesheet">',
+        '<link href="/static/app.css" nonce="n0nce" rel="stylesheet">',
+    ),
+    (
+        "inline CSS",
+        CSS("body{color:red}", inline=True),
+        '<style media="all">body{color:red}</style>',
+        '<style media="all" nonce="n0nce">body{color:red}</style>',
+    ),
+    (
+        "object with only __html__",
+        HTMLOnlyAsset('<link href="/bundle.css" rel="stylesheet">'),
+        '<link href="/bundle.css" rel="stylesheet">',
+        '<link href="/bundle.css" rel="stylesheet">',
+    ),
+    (
+        "html-safe string",
+        HTML_SAFE_CSS,
+        HTML_SAFE_CSS,
+        HTML_SAFE_CSS,
+    ),
+]
+
+
+class AssetRenderingTest(TestCase):
+    """
+    Exhaustive per-asset-kind rendering, with and without a CSP nonce.
+    """
+
+    def _check(self, rows, kwargs_for):
+        for label, asset, plain, with_nonce in rows:
+            with self.subTest(asset=label):
+                self.assertEqual(Media(**kwargs_for(asset)).render(), plain)
+                self.assertEqual(
+                    Media(nonce="n0nce", **kwargs_for(asset)).render(), 
with_nonce
+                )
+
+    def test_js_assets(self):
+        self._check(JS_ASSETS, lambda asset: {"js": [asset]})
+
+    def test_css_assets(self):
+        self._check(CSS_ASSETS, lambda asset: {"css": {"all": [asset]}})
+
+    def test_matches_django_rendering(self):
+        # Our rendering must not drift from stock ``forms.Media`` for the asset
+        # kinds Django itself understands -- this is what catches a future
+        # change to Django's normalization or tag format that our overridden
+        # ``_normalize_{js,css}`` would otherwise silently skip. (Excludes
+        # ``ImportMap``, which we deliberately merge and hoist, and html-safe
+        # strings, which Django 6.1 itself gets wrong.)
+        kwargs = {
+            "css": {"all": ["a.css", CSS("b.css"), Stylesheet("c.css")]},
+            "js": ["a.js", JS("b.js"), Script("c.js"), JS("d.js", {"defer": 
True})],
+        }
+        self.assertEqual(str(Media(**kwargs)), str(DjangoMedia(**kwargs)))
+
+
 class MediaTest(TestCase):
     def test_nonce_applied_to_assets(self):
         media = Media(
@@ -181,3 +337,47 @@
             '<script src="/static/app.js" nonce="xyz"></script>',
             request_media.render(),
         )
+
+    def test_html_safe_strings_dedupe_and_merge(self):
+        # Mirrors Django's own #37262 tests: html-safe strings survive
+        # deduplication and media merging alongside regular path assets.
+        first = Media(
+            css={"all": [HTML_SAFE_CSS, "a.css"]},
+            js=["a.js", HTML_SAFE_JS],
+        )
+        second = Media(
+            nonce="n0nce",
+            css={"all": [HTML_SAFE_CSS]},
+            js=[HTML_SAFE_JS, JS("b.js")],
+        )
+        merged = first + second
+
+        self.assertEqual(merged.nonce, "n0nce")
+        self.assertEqual(
+            merged.render(),
+            f"{HTML_SAFE_CSS}\n"
+            '<link href="/static/a.css" media="all" nonce="n0nce" 
rel="stylesheet">\n'
+            '<script src="/static/a.js" nonce="n0nce"></script>\n'
+            f"{HTML_SAFE_JS}\n"
+            '<script src="/static/b.js" nonce="n0nce"></script>',
+        )
+
+    def test_html_safe_strings_normalized_through_our_class(self):
+        # We override ``_normalize_{js,css}`` so html-safe strings survive
+        # construction even on Django 6.1, whose own normalizers mangle them.
+        self.assertEqual(Media(js=[HTML_SAFE_JS]).render(), HTML_SAFE_JS)
+        self.assertEqual(
+            Media(media=type("Def", (), {"js": [HTML_SAFE_JS]})).render(),
+            HTML_SAFE_JS,
+        )
+
+    def test_html_safe_strings_adopted_from_foreign_media(self):
+        # ``from_media`` adopts assets a plain ``forms.Media`` already
+        # normalized, so on Django 6.1 the damage is done before we see them.
+        # Nothing to fix from here -- just pin which side the boundary is on.
+        adopted = Media.from_media(DjangoMedia(js=[HTML_SAFE_JS]))
+        if DJANGO_KEEPS_HTML_SAFE_STRINGS:
+            self.assertEqual(adopted.render(), HTML_SAFE_JS)
+        else:
+            self.assertNotEqual(adopted.render(), HTML_SAFE_JS)
+            self.assertEqual(adopted.render(), 
str(DjangoMedia(js=[HTML_SAFE_JS])))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/django-js-asset-4.0.1/tox.ini 
new/django-js-asset-4.0.2/tox.ini
--- old/django-js-asset-4.0.1/tox.ini   2026-06-11 18:03:46.000000000 +0200
+++ new/django-js-asset-4.0.2/tox.ini   2026-08-18 08:50:13.000000000 +0200
@@ -19,3 +19,11 @@
     dj60: Django>=6.0,<6.1
     dj61: Django>=6.1a1,<6.2
     djmain: https://github.com/django/django/archive/main.tar.gz
+
+[testenv:docs]
+deps =
+    Sphinx
+changedir = docs
+commands = make html
+skip_install = true
+allowlist_externals = make

Reply via email to