diff --git a/rollup.config.js b/rollup.config.js index 43d743c..3148274 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -46,10 +46,14 @@ function create_configuration({ plugin_name, input, format, optimize }) { return { input, + external: ["dompurify"], treeshake: "smallest", output: { file: `dist/${plugin_name}/alpinegear-${plugin_name}${ext_format}${ext_min}.js`, format: format, + globals: { + dompurify: "DOMPurify" + }, plugins: optimize && [terser({ output: { comments: false diff --git a/src/plugins/safehtml/README.md b/src/plugins/safehtml/README.md index 636fe1d..2d37801 100644 --- a/src/plugins/safehtml/README.md +++ b/src/plugins/safehtml/README.md @@ -3,21 +3,26 @@ `@ramstack/alpinegear-safehtml` provides the `x-safehtml` Alpine.js directive. It renders reactive HTML after sanitizing it with DOMPurify. +DOMPurify is a peer dependency and is not bundled with the plugin. + ## Installation ### Using CDN -Include the plugin before Alpine.js: +Include DOMPurify before the plugin, and the plugin before Alpine.js: ```html + ``` +If DOMPurify is already loaded on the page, reuse it instead of adding another script. + ### Using NPM ```bash -npm install --save @ramstack/alpinegear-safehtml +npm install --save @ramstack/alpinegear-safehtml dompurify@3 ``` ```js @@ -46,6 +51,11 @@ or a custom `mapAttributes` shorthand, `x-ignore` keeps it inert. ## Configuration +The plugin creates a private DOMPurify instance when it is registered with Alpine. +Calls to `DOMPurify.setConfig()` or `DOMPurify.addHook()` on the application's instance do not affect the plugin, +and the plugin does not change the application's configuration or hooks. +The library code is shared; only the instance state is separate. + Global DOMPurify options can be declared in a `meta` element: ```html diff --git a/src/plugins/safehtml/index.js b/src/plugins/safehtml/index.js index 05ee148..9b2e175 100644 --- a/src/plugins/safehtml/index.js +++ b/src/plugins/safehtml/index.js @@ -1,4 +1,4 @@ -import DOMPurify from "dompurify"; +import create_dompurify from "dompurify"; import { parse_options } from "@/utilities/options"; const directive_name = "safehtml"; @@ -6,6 +6,7 @@ const meta_options_selectors = "meta[name='alpinegear-safehtml-options']"; const data_options_attribute = "data-safehtml-options"; function plugin({ bind, directive, mutateDom: mutate_dom, prefixed }) { + const purifier = create_dompurify(window); let global_options; directive(directive_name, (el, { expression }, { effect, evaluateLater: evaluate_later }) => { @@ -24,7 +25,7 @@ function plugin({ bind, directive, mutateDom: mutate_dom, prefixed }) { }; effect(() => evaluate(value => { - const html = DOMPurify.sanitize(value, options); + const html = purifier.sanitize(value, options); mutate_dom(() => { el.innerHTML = html; diff --git a/src/plugins/safehtml/package.json b/src/plugins/safehtml/package.json index 3f37118..5ee4233 100644 --- a/src/plugins/safehtml/package.json +++ b/src/plugins/safehtml/package.json @@ -18,6 +18,9 @@ "alpinejs-directive", "alpinejs-plugin" ], + "peerDependencies": { + "dompurify": "^3.4.15" + }, "exports": { ".": { "import": { diff --git a/tests/playwright/assets/page.html b/tests/playwright/assets/page.html index c7e44f5..b7bbe68 100644 --- a/tests/playwright/assets/page.html +++ b/tests/playwright/assets/page.html @@ -16,6 +16,7 @@ + diff --git a/tests/playwright/x-safehtml.spec.js b/tests/playwright/x-safehtml.spec.js index fc7461b..04f31e5 100644 --- a/tests/playwright/x-safehtml.spec.js +++ b/tests/playwright/x-safehtml.spec.js @@ -54,6 +54,40 @@ test("x-safehtml merges global and local options", async ({ page }) => { await expect(content.locator("em")).toHaveText("Local"); }); +test("x-safehtml isolates DOMPurify configuration and hooks", async ({ page }) => { + await page.addInitScript(() => { + document.addEventListener("alpine:init", () => { + DOMPurify.setConfig({ ALLOWED_TAGS: ["strong"], RETURN_DOM: true }); + DOMPurify.addHook("afterSanitizeAttributes", node => { + if (node.nodeName === "STRONG") { + node.setAttribute("data-shared", "yes"); + } + }); + }); + }); + + await set_html(page, ` +
+
+
`); + + const content = page.locator("#content"); + + await expect(content.locator("p")).toHaveText("SharedLocal"); + await expect(content.locator("p")).not.toHaveAttribute("title"); + await expect(content.locator("strong")).toHaveText("Shared"); + await expect(content.locator("strong")).not.toHaveAttribute("data-shared"); + await expect(content.locator("em")).toHaveText("Local"); + + expect(await page.evaluate(() => { + const result = DOMPurify.sanitize("

SharedLocal

"); + return { nodeType: result.nodeType, html: result.innerHTML }; + })).toEqual({ + nodeType: 1, + html: 'SharedLocal' + }); +}); + test("x-safehtml ignores invalid local options", async ({ page }) => { const warnings = []; page.on("console", message => message.type() === "warning" && warnings.push(message.text()));