Vendoring third-party JS
Declare the third-party JS/CSS your app or component package serves, with Subresource Integrity and a startup check for every version.
On this page
Any Django app that serves someone else's JavaScript or CSS to a browser —
djust itself, a component package, or your own project — declares it in a
djust_assets.json manifest. djust renders it with Subresource Integrity
(SRI), checks it at startup, and can list it in a CycloneDX SBOM (see
Scanning a djust app). This is ADR-040.
Who it's for
- Component packages that bundle a JS library (a chart widget, a rich text editor, a terminal) and want it declared once, checked at every consumer's startup, and visible to a scanner.
- Apps that vendor their own third-party files, or that need to
override a library djust already bundles — for example shipping a
security fix for
highlight.jsbefore djust releases one.
If you never write <script src="..."> or <link href="..."> by hand for
third-party code, you don't need this guide — djust's own components already
declare their assets, and {% code_block %} and the ttyd terminal already
use them.
The schema
A manifest is <app>/djust_assets.json, next to the app's apps.py:
{
"schema": 1,
"assets": {
"markdown-visual": {
"files": [
{
"path": "djust_components/markdown-visual.js",
"integrity": "sha384-…"
}
],
"license_file": "djust_components/markdown-visual.LICENSE.txt",
"packages": [
{"purl": "pkg:npm/%40tiptap/core@3.31.3", "license": "MIT"}
]
},
"chart.js": {
"files": [
{
"url": "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js",
"integrity": "sha384-…"
}
],
"packages": [{"purl": "pkg:npm/chart.js@4.4.0", "license": "MIT"}]
}
}
}
Rules:
"schema"must be1.- Each entry in
"files"has exactly one of"path"(a staticfiles path — vendored) or"url"(external), plus"integrity"in SRI form (sha256-,sha384-orsha512-). A file may also carry"variant"(for example a highlight.js theme) and"type"(script,moduleorstyle);"type"is inferred from the extension when omitted (.js→script,.mjs→module,.css→style). "packages"must be non-empty. Each"purl"includes an exact@version— it's the only record of name and version djust keeps. Non-npm sources usepkg:github/<owner>/<repo>@<tag>orpkg:generic/<name>@<version>?download_url=<url>."license"is an SPDX expression."license_file"is optional, for a bundle whose build generates one.
An invalid asset is dropped, and its neighbours in the same manifest still
load — startup checks report each problem (djust.B001–B002) rather than
crashing.
Where manifests live
- App-owned:
<app>/djust_assets.json, discovered automatically from every installed app viaapps.get_app_configs(). This is how djust's owncomponentsandadmin_extapps declare their bundles, and how a component package ships one. - Project-owned: list extra manifest paths in
DJUST_ASSET_MANIFESTS(default[]), for files under yourSTATICFILES_DIRSrather than an installed app.
# settings.py
DJUST_ASSET_MANIFESTS = [
BASE_DIR / "static" / "djust_assets.json",
]
Names are global. When two manifests declare the same asset name, project
manifests win over apps (in DJUST_ASSET_MANIFESTS list order), and apps
earlier in INSTALLED_APPS win over later ones — the same order Django
already uses for templates and static files.
How to compute an integrity
openssl dgst -sha384 -binary path/to/file.js | openssl base64 -A
Prefix the result with sha384- (or use -sha256/-sha512 and prefix
accordingly) for the manifest's "integrity" value.
Using assets
{% load djust_assets %}, then:
{% djust_asset "highlight.js" variant="github-dark" %}
{% djust_asset_url "xterm" file_type="module" %}
{% djust_asset "name" [variant="…"] %}renders every tag the asset needs —<script integrity="…">,<link rel="stylesheet" integrity="…">or<link rel="modulepreload" integrity="…">— computed from the stored static file (so a post-collectstaticrewrite, likeManifestStaticFilesStorage's hashed CSSurl()s, doesn't invalidate the hash).{% djust_asset_url "name" [variant="…"] [file_type="module"] %}returns just the URL, for JavaScript that needs toimport()a module itself —import()can't carry SRI, so the module is rendered as amodulepreloadlink and imported by its URL from adata-attribute instead.- A component declares what it needs and djust checks it exists:
from djust.components.base import Component
class ChartWidget(Component):
requires_assets = ["chart.js"]
An undeclared name in requires_assets fails startup with djust.B007.
Rendering a component's assets
requires_assets only declares and checks; it renders nothing by itself.
Something has to output the tags, and you choose where.
In the page template, through the variable holding the component
instance — for a component the view exposes as chart:
{{ chart.asset_tags }}
In the component's own template (or _render_custom), from Python
with self.asset_tags():
class ChartWidget(Component):
requires_assets = ["chart.js"]
template = "<div>{{ asset_tags }}<canvas></canvas></div>"
def get_context_data(self):
return {"asset_tags": self.asset_tags()}
asset_tags is per instance: every instance that renders it emits its own
tags. When several instances on one page share an asset, render the tags
once in a page-level template ({% djust_asset "chart.js" %} in the base
template, or one instance's asset_tags) instead of in each instance.
LiveComponent does not inherit from Component, so it has neither the
asset_tags helper nor the djust.B007 check for requires_assets. Put
{% load djust_assets %}{% djust_asset "chart.js" %} in its template
instead — an unknown name there raises ImproperlyConfigured when it
renders.
Overriding a djust-bundled library for a security fix
Because resolution follows INSTALLED_APPS/DJUST_ASSET_MANIFESTS order
and the first declaration of a name wins, you can ship a fixed version of a
library djust bundles — for example highlight.js — before djust cuts a
release with the fix: add your own djust_assets.json entry for the same
asset name in a manifest that resolves before djust's.
An override replaces djust's declaration whole — djust does not merge the
two. Overriding highlight.js in particular means your entry must
re-declare all thirteen theme files as "variant" entries (see
Supported highlight.js themes) alongside
the script, or {% code_block theme="…" %} raises ImproperlyConfigured
for every theme you left out.
Keep the override under its own path, not djust's: a copy of a djust
static file at djust's own path (for example an old
djust_components/vendor/highlight/highlight.js in STATICFILES_DIRS)
shadows the vendored file, no longer matches djust's manifest, and fails
startup with djust.B004. Delete such a copy.
The override is never silent: djust.B009 (a warning, not an error) names
both sources and both versions, so the fact that something is shadowing
djust's own copy stays visible in manage.py check output. Silence it
deliberately with DJUST_CONFIG = {"suppress_checks": ["B009"]} once
you've confirmed it's intentional.
External assets
Vendoring is the default; loading from another origin needs an explicit opt-in:
# settings.py
DJUST_ALLOW_EXTERNAL_ASSETS = True
Without it, any manifest entry using "url" fails startup with
djust.B005. With it, every external file still needs "integrity" —
djust.B006 catches one that doesn't.
If your STATIC_URL is absolute (serving your own vendored files from
S3, a CDN, or any host other than the app itself), djust adds
crossorigin="anonymous" to the rendered tag automatically — the browser
requires it before it will apply an SRI check to a cross-origin resource.
Your static host must send an Access-Control-Allow-Origin header back for
that fetch, or the browser blocks the file outright rather than silently
skipping the integrity check. Check that against your CDN/S3 CORS
configuration before you switch STATIC_URL to an absolute host.
Origins that can't be pinned
Some third-party SDKs must load from their vendor's own origin and change
without notice, so they can be neither vendored nor given an integrity:
Stripe.js (js.stripe.com), Cloudflare Turnstile
(challenges.cloudflare.com), Google Tag Manager
(www.googletagmanager.com), the Google Fonts CSS API
(fonts.googleapis.com). An external asset declaration needs an
integrity (djust.B006), which these can't have, and leaving them
undeclared makes djust.B010 warn about every template that loads them.
List those hosts explicitly instead:
# settings.py
DJUST_ALLOWED_EXTERNAL_ORIGINS = [
"js.stripe.com",
"challenges.cloudflare.com",
]
djust.B010 then accepts templates that load from exactly those hosts.
The list is empty by default, and it matches whole hosts only:
js.stripe.com does not cover m.stripe.com, and there are no wildcards.
Each entry is a bare host (js.stripe.com), optionally written as an
origin (https://js.stripe.com, with or without a trailing /). The scheme
is ignored, so http:// and https:// entries mean the same host, and case
doesn't matter. An entry with an empty host, a port, userinfo, a path or a
query is ignored, and B010 names it; so is the whole setting when it isn't
a list of strings. Hosts are compared as written, with no normalisation:
write an internationalised domain and a host with a trailing dot exactly as
they appear in the template's URL (for example the xn-- form if that is
what the template uses).
This is an exception list for a security review to read, not a way to
switch the check off: every entry is an origin whose code your pages run
unverified and that no SBOM lists. It affects only djust.B010's template
scan. It does not declare an asset, {% djust_asset %} still renders only
declared assets, and it does not change your Content-Security-Policy.
Supported highlight.js themes
{% code_block %} renders one of a curated set of vendored highlight.js
themes, not the full ~250 upstream themes:
githubgithub-darkatom-one-darkatom-one-lightmonokaivs2015norddefaultdarka11y-darka11y-lightstackoverflow-lightstackoverflow-dark
{% code_block theme="dracula" %} (or any other name outside this list)
raises ImproperlyConfigured naming the thirteen available themes — never
a silently unhighlighted block.
See also
- Scanning a djust app — SBOM setup and scanner commands.
- Error Codes — every
djust.B0xxcheck. - ADR-040 — the design.