Skip to content

Getting started

Terminal window
npm install @johnhenry/safe-fragment

Latest on npm: @johnhenry/[email protected].

Provenance. This is a new package: never published under any other name, and 0.0.0 is the unreleased development version, so there is no earlier name or version to migrate from.

dompurify (pinned to an exact version) is a normal dependency, installed automatically. It is loaded lazily, only on browsers without Element.setHTML (Safari today) or when you force the fallback. The shipped ESM and CJS output targets evergreen browsers; Node 26 or newer is only the toolchain (devEngines).

From git. Until there is a release, a commit can be installed directly: npm install git+https://github.com/johnhenry/safe-fragment.git#<sha>. dist/ is not committed, so the package has a prepare script that builds it for git installs and npm ci (after installing its devDependencies; it skips with a warning instead of failing when devDependencies are omitted). Before that script (safe-fragment#10, 1817d79) a git install was an empty package. A registry tarball is never built this way. The built dist/index.js still reaches the fallback with a bare import("dompurify"), so a page that imports it from node_modules needs the import map too.

import { registerSafeFragment } from "@johnhenry/safe-fragment";
// Call once, from browser-executed code. It never happens as an import side effect.
registerSafeFragment();

registerSafeFragment() is idempotent for a tag name, and throws UNSUPPORTED_ENVIRONMENT where there is no DOM. Its options (fetch, maxInputLength, loadDOMPurify, tagName) are in the API reference.

Set the .html property from script:

<safe-fragment profile="article-v1" id="post"></safe-fragment>
<script type="module">
document.getElementById("post").html = await fetch("/api/posts/42").then((r) => r.text());
</script>

Or declaratively, through a <template> child. <template> content is inert until explicitly read, so the browser never eagerly parses it as live markup:

<safe-fragment profile="article-v1">
<template>
<p>Hello <strong>world</strong>. <img src="x" onerror="alert(1)" /></p>
</template>
</safe-fragment>

The onerror attribute is removed and nothing executes. The profile attribute is required: there is no default profile, and a missing or unknown one rejects with UNKNOWN_PROFILE. See the element for the source precedence and profiles for choosing one.

To see what a render changed, listen for safe-fragment:render, whose detail.report is a SanitizationReport:

post.addEventListener("safe-fragment:render", (e) => console.log(e.detail.report.removedAttributes));

The fallback engine does import("dompurify"), a bare specifier. With a bundler it just resolves. With none (a static page, <script type="module">), map it, pinning the exact version this release pins:

<script type="importmap">
{
"imports": {
"dompurify": "https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.es.mjs"
}
}
</script>

or hand safe-fragment the factory yourself:

registerSafeFragment({
loadDOMPurify: () => import("https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.es.mjs").then((m) => m.default),
});

If neither is in place and the browser needs the fallback, rendering rejects with SANITIZER_UNAVAILABLE, whose message says exactly this. Call await preloadSanitizer() at startup to pay the load once and surface the problem early. It resolves "native" without loading anything where setHTML exists, and it is what makes the synchronous API usable on Safari. See the sanitize API.

With mport: on raw-file CDNs (jsDelivr, unpkg) the generated import map only contains the entry points you ask for, so list dompurify explicitly, or let mport add it from this package’s dependencies with --dependencies (build(specs, { dependencies: true }) from code):

Terminal window
npx @johnhenry/mport build @johnhenry/safe-fragment@0 [email protected]

Use the exact version this release pins. Every page in the repository’s examples carries an import map for it.