Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/plugins/safehtml/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@ramstack/alpinegear-safehtml@1/alpinegear-safehtml.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
```

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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/plugins/safehtml/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import DOMPurify from "dompurify";
import create_dompurify from "dompurify";
import { parse_options } from "@/utilities/options";

const directive_name = "safehtml";
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 }) => {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/safehtml/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"alpinejs-directive",
"alpinejs-plugin"
],
"peerDependencies": {
"dompurify": "^3.4.15"
},
"exports": {
".": {
"import": {
Expand Down
1 change: 1 addition & 0 deletions tests/playwright/assets/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<script src="../../../dist/main/alpinegear-main.js"></script>
<script src="../../../dist/destroy/alpinegear-destroy.js"></script>
<script src="../../../dist/hotkey/alpinegear-hotkey.js"></script>
<script src="../../../node_modules/dompurify/dist/purify.min.js"></script>
<script src="../../../dist/safehtml/alpinegear-safehtml.js"></script>
<script src="../../../dist/markdown/alpinegear-markdown.js"></script>
<script src="../../../dist/typegrab/alpinegear-typegrab.js"></script>
Expand Down
34 changes: 34 additions & 0 deletions tests/playwright/x-safehtml.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, `
<div x-data="{ content: '<p title=removed><strong>Shared</strong><em>Local</em></p>' }">
<div id="content" x-safehtml="content" data-safehtml-options='{ "FORBID_ATTR": ["title"] }'></div>
</div>`);

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("<p><strong>Shared</strong><em>Local</em></p>");
return { nodeType: result.nodeType, html: result.innerHTML };
})).toEqual({
nodeType: 1,
html: '<strong data-shared="yes">Shared</strong>Local'
});
});

test("x-safehtml ignores invalid local options", async ({ page }) => {
const warnings = [];
page.on("console", message => message.type() === "warning" && warnings.push(message.text()));
Expand Down
Loading