Skip to main content

script tag with additional attributes for django.forms.Media

Project description

https://github.com/feincms/django-js-asset/workflows/Tests/badge.svg

Note! Django 5.2 adds its own support for JavaScript objects. This library has a slightly different API and also supports much older versions of Django, and it also supports CSS and JSON tags. As of the next version JS and CSS actually produce Django’s own Script and Stylesheet objects (backported on Django versions that lack them), so js_asset assets, plain path strings and Django’s native assets share the same de-duplication buckets in forms.Media – see Deduplication below.

Usage

Use this to insert a script tag via forms.Media containing additional attributes (such as id and data-* for CSP-compatible data injection.):

from js_asset import JS

forms.Media(js=[
    JS("asset.js", {
        "id": "asset-script",
        "data-answer": "42",
    }),
])

The rendered media tag (via {{ media.js }} or {{ media }} will now contain a script tag as follows, without line breaks:

<script type="text/javascript" src="/static/asset.js"
    data-answer="42" id="asset-script"></script>

The attributes are automatically escaped. The data attributes may now be accessed inside asset.js:

let answer = document.querySelector("#asset-script").dataset.answer;

Also, because the implementation of static differs between supported Django versions (older do not take the presence of django.contrib.staticfiles in INSTALLED_APPS into account), a js_asset.static function is provided which does the right thing automatically.

CSS and JSON support

Since 3.0 django-js-asset also ships a CSS and JSON media object which can be used to ship stylesheets, inline styles and JSON blobs to the frontend. It’s recommended to pass those through forms.Media(js=[]) as well since js is a simple list while css uses a dictionary keyed with the media to use for the stylesheet.

So, you can add everything at once:

from js_asset import CSS, JS, JSON

forms.Media(js=[
    JSON({"configuration": 42}, id="widget-configuration"),
    CSS("widget/style.css"),
    CSS("p{color:red;}", inline=True),
    JS("widget/script.js", {"type": "module"}),
])

This produces:

<script id="widget-configuration" type="application/json">{"configuration": 42}</script>
<link href="/static/widget/style.css" media="all" rel="stylesheet">
<style media="all">p{color:red;}</style>
<script src="/static/widget/script.js" type="module"></script>

Compatibility

At the time of writing this app is compatible with Django 4.2 and better (up to and including the Django main branch), but have a look at the tox configuration for definitive answers.

Deduplication

JS and CSS produce Django’s own Script and Stylesheet objects (backported on Django versions that lack them), so they de-duplicate against each other, against plain path strings, and against Django’s native assets when forms.Media merges the media of several forms and widgets. The rules for what counts as a duplicate are Django’s, and Django changed them between releases:

  • Django 4.2 - 5.1 and 6.2+: two assets are equal when both the path and the attributes match, so JS("a.js", {"id": "x"}) and JS("a.js", {"id": "y"}) are distinct and both render.

  • Django 5.2 - 6.1: Django compares the path only, so those same two assets de-duplicate to one (whichever wins the merge) – exactly as Django’s own Script/Stylesheet behave on those versions.

A given file referenced both as a bare string and via JS/CSS always de-duplicates, and nothing is ever rendered twice for the same path. The per-version difference only affects the unusual case of the same path carried with different attributes in a single merge.

Import maps

django-js-asset can ship import maps. They let your modules import short names (import { Stuff } from "my-library") and have the browser resolve them to the real, possibly hashed, URLs produced by Django’s ManifestStaticFilesStorage – without rewriting the imports in your JavaScript.

Browsers do not reliably support more than one import map per page, so all of them have to be merged into one. js_asset.Media does this for you: drop ImportMap objects into your media wherever they are relevant – typically next to the module that needs them – and they are combined into a single <script type="importmap"> rendered before every other script, no matter how many media objects were added together to get there:

from js_asset import ImportMap, JS, Media, static_lazy

media = Media(js=[
    ImportMap({"imports": {"my-library": static_lazy("my-library.js")}}),
    JS("code.js", {"type": "module"}),
])

See CSP nonces below for per-request nonces.

Rendering media in views and the admin

js_asset.Media renders exactly like forms.Media: {{ media }} (i.e. str(media)) in a template emits the merged import map followed by every stylesheet and script – the import map first, so the modules can rely on it. What changes from one situation to the next is only how you obtain the right Media object.

In a front-facing view

When you own the template, build or collect the media in the view and put it in the context:

from django.shortcuts import render
from js_asset import ImportMap, JS, Media, static

def dashboard(request):
    media = Media(js=[
        ImportMap({"imports": {"chart": static("chart/index.js")}}),
        JS("dashboard.js", {"type": "module"}),
    ])
    return render(request, "dashboard.html", {"media": media})

form.media is already a js_asset.Media as soon as one of its widgets returns one, so you can render it directly. Combine your own assets with a form’s by simply adding them – as long as one side is a js_asset.Media the result stays one (regardless of order) and merges import maps. If neither side is, wrap one with Media.from_media first (Media(form.media) does not work, because forms.Media copies from a media definition, not an instance):

def edit(request):
    form = MyForm()
    page = Media(js=[
        ImportMap({"imports": {"editor": static("editor/index.js")}}),
        JS("editor/init.js", {"type": "module"}),
    ])
    media = page + form.media            # or: Media.from_media(form.media)
    return render(request, "edit.html", {"form": form, "media": media})
{# edit.html #}
<head>{{ media }}</head>
...
{{ form }}

Attach a per-request CSP nonce with media.with_nonce(request.csp_nonce) – see CSP nonces below.

In the Django admin

In the admin you write no view code: the admin collects ModelAdmin.media together with every form’s and widget’s media itself and renders it in the change form. All you have to do is make sure the widget that needs the import map returns a js_asset.Media:

from django.contrib import admin
from django import forms
from js_asset import ImportMap, JS, Media, static

class EditorWidget(forms.Textarea):
    @property
    def media(self):
        return Media(js=[
            ImportMap({"imports": {"editor": static("editor/index.js")}}),
            JS("editor/init.js", {"type": "module"}),
        ])

class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = "__all__"
        widgets = {"body": EditorWidget}

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    form = ArticleForm

Because js_asset.Media keeps its type through the additions Django performs while combining ModelAdmin.media with the form media, the object the admin finally renders is a js_asset.Media – so the import maps your widgets contribute are merged into the single tag automatically, ahead of the admin’s own scripts. The same widget works unchanged outside the admin.

CSP nonces

A CSP nonce is request-scoped – it must change on every response, while widget media is usually built once at class-definition time – so the nonce is 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 your Django version.

Django 6.2 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:

# settings.py
from django.utils.csp import CSP

MIDDLEWARE = [
    # ...
    "django.middleware.csp.ContentSecurityPolicyMiddleware",
]

SECURE_CSP = {
    "default-src": [CSP.SELF],
    "script-src": [CSP.SELF, CSP.NONCE],
    "style-src": [CSP.SELF, CSP.NONCE],
}

TEMPLATES = [{
    # ...
    "OPTIONS": {
        "context_processors": [
            # ...
            "django.template.context_processors.csp",
        ],
    },
}]

Then render the media with the built-in {% csp_nonce_attr %} tag, which calls media.render(attrs={"nonce": ...}) for you:

{% csp_nonce_attr form.media %}

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)

Older Django has no built-in nonce, so use the third-party django-csp package. Install it, add its middleware, and make sure the nonce is part of the relevant directives.

With django-csp 4.x the policy is a single setting and the nonce is a sentinel from csp.constants:

# settings.py (django-csp 4.x)
from csp.constants import NONCE, SELF

MIDDLEWARE = [
    # ...
    "csp.middleware.CSPMiddleware",
]

CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": [SELF],
        "script-src": [SELF, NONCE],
        "style-src": [SELF, NONCE],
    },
}

django-csp 3.x uses individual settings instead, and you opt the nonce into directives with CSP_INCLUDE_NONCE_IN:

# settings.py (django-csp 3.x)
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'",)
CSP_INCLUDE_NONCE_IN = ("script-src", "style-src")

Either way the middleware exposes the per-request nonce as request.csp_nonce. It is lazy: django-csp only adds the nonce to the response header once the value has actually been used. Rendering the media with it counts as using it, so you do not have to do anything special – just make sure you render through js_asset. Attach the nonce in the view and render the copy:

def my_view(request):
    form = MyForm()
    return render(request, "page.html", {
        "form_media": form.media.with_nonce(request.csp_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. 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.MediaMedia(form.media) does not work, because forms.Media copies assets from a media definition, not an instance, so use from_media:

# 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()
{% load js_asset_csp %}
{% media_with_nonce form.media %}

Anywhere: set the nonce explicitly

You can always set the nonce yourself, either on construction or per request:

Media(nonce=the_nonce, js=[...])      # at construction
some_media.with_nonce(the_nonce)      # copy with a nonce
some_media.render(nonce=the_nonce)    # one-off render

Notes

  • The merged import map is always rendered first, so a module added in the same media can rely on it.

  • Browsers honour only the first import map on a page; make sure everything that needs the same map ends up in one merged Media – a second <script type="importmap"> reaching the page some other way is silently ignored.

  • Import maps are subject to script-src; make sure CSP.NONCE is present there (and in style-src if you render stylesheets).

  • A stylesheet placed in js=[...] is only de-duplicated against that list. forms.Media keeps the css={...} dictionary in a separate slot, so the same file listed in both renders twice – pick one.

  • JSON blocks are data and deliberately get no nonce.

  • Browser support for import maps is still uneven; merging into a single map is currently the only portable way to use them in production.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_js_asset-4.0.0.tar.gz (26.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_js_asset-4.0.0-py3-none-any.whl (13.3 kB view details)

Uploaded Python 3

File details

Details for the file django_js_asset-4.0.0.tar.gz.

File metadata

  • Download URL: django_js_asset-4.0.0.tar.gz
  • Upload date:
  • Size: 26.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for django_js_asset-4.0.0.tar.gz
Algorithm Hash digest
SHA256 c349cf5a08ba8eabad47689546de4f25ea927968f8b929b1d3cb683b6f424653
MD5 ba6c0c3a729bf972026641da1e44faa2
BLAKE2b-256 fe304f3999a9dcc4ddeabac4b40fa98ba8cc2085e396c0b33fcd7b840cb0373e

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_js_asset-4.0.0.tar.gz:

Publisher: publish.yml on feincms/django-js-asset

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_js_asset-4.0.0-py3-none-any.whl.

File metadata

  • Download URL: django_js_asset-4.0.0-py3-none-any.whl
  • Upload date:
  • Size: 13.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for django_js_asset-4.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ad3b32778a918daa67d9bff0759da2959514866c45b30203f2769e3ec01b3e67
MD5 9ed7307592e8a74d893ba4ebe36eb6e8
BLAKE2b-256 1697790afa149580543d397dbc3ea925214e544fcd2053248089310303fd6500

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_js_asset-4.0.0-py3-none-any.whl:

Publisher: publish.yml on feincms/django-js-asset

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page