From cf3e1d1ca74b1284641e18444296aa943aeebb72 Mon Sep 17 00:00:00 2001 From: Puru Vijay Date: Mon, 6 Jul 2026 23:08:14 +0530 Subject: [PATCH 1/5] feat: add sanitize-html -> neosanitize replacement Add sanitize-html to the preferred manifest with neosanitize/legacy as a drop-in replacement and the neosanitize root module (fast WHATWG parser, deny-by-default) as the recommended alternative. Includes docs page. Co-Authored-By: Claude Opus 4.8 --- docs/modules/sanitize-html.md | 56 +++++++++++++++++++++++++++++++++++ manifests/preferred.json | 16 ++++++++++ 2 files changed, 72 insertions(+) create mode 100644 docs/modules/sanitize-html.md diff --git a/docs/modules/sanitize-html.md b/docs/modules/sanitize-html.md new file mode 100644 index 00000000..1dcb4b54 --- /dev/null +++ b/docs/modules/sanitize-html.md @@ -0,0 +1,56 @@ +--- +description: Replacing sanitize-html with neosanitize, a zero-dependency, isomorphic HTML sanitizer with a drop-in legacy engine and a faster WHATWG-based engine +--- + +# Replacements for `sanitize-html` + +[`sanitize-html`](https://github.com/apostrophecms/sanitize-html) is a widely used HTML sanitizer, but it pulls in a large dependency tree (including `htmlparser2`, `parse-srcset`, `postcss`, and others) and only runs in Node-like environments. + +[`neosanitize`](https://github.com/PuruVJ/neosanitize) is a zero-dependency, isomorphic (works in Node, browsers, and edge runtimes) HTML sanitizer written in TypeScript. It ships two engines: + +- A **legacy** engine (`neosanitize/legacy`) that is a byte-identical, drop-in port of `sanitize-html` 2.x — same API, same options, same output. +- A **root** engine (`neosanitize`) built on a fast, browser-faithful WHATWG parser with a deny-by-default policy, recommended for new code and for the best performance and security. + +## `neosanitize/legacy` (drop-in replacement) + +The legacy engine mirrors the `sanitize-html` API and output exactly, so migrating is usually just a matter of changing the import: + +```ts +import sanitizeHtml from 'sanitize-html' // [!code --] +import sanitizeHtml from 'neosanitize/legacy' // [!code ++] + +const clean = sanitizeHtml(dirty, { + allowedTags: ['b', 'i', 'em', 'strong', 'a'], + allowedAttributes: { + a: ['href'] + } +}) +``` + +All `sanitize-html` options (`allowedTags`, `allowedAttributes`, `allowedSchemes`, `transformTags`, `exclusiveFilter`, etc.) are supported with identical behaviour. + +## `neosanitize` (recommended) + +For new code, the root module is recommended. It is built on a fast, browser-faithful WHATWG parser (100% html5lib tokenizer conformance) and follows a deny-by-default policy, so only what you explicitly allow gets through: + +```ts +import sanitizeHtml from 'sanitize-html' // [!code --] +import { sanitize } from 'neosanitize' // [!code ++] + +const clean = sanitize(dirty, { + allowedTags: ['b', 'i', 'em', 'strong', 'a'], + allowedAttributes: { + a: ['href'] + } +}) +``` + +Because it has no dependencies and runs anywhere, it works unchanged in browsers and edge runtimes: + +```ts +import { sanitize } from 'neosanitize/browser' + +const clean = sanitize(userInput) +``` + +Preset configurations are available under `neosanitize/presets`, and the underlying parser can be used directly via `neosanitize/whatwg-parser`. diff --git a/manifests/preferred.json b/manifests/preferred.json index bd638b5b..c423c9a5 100644 --- a/manifests/preferred.json +++ b/manifests/preferred.json @@ -2540,6 +2540,12 @@ "replacements": ["crypto.timingSafeEqual"], "url": {"type": "e18e", "id": "buffer-equal-constant-time"} }, + "sanitize-html": { + "type": "module", + "moduleName": "sanitize-html", + "replacements": ["neosanitize/legacy", "neosanitize"], + "url": {"type": "e18e", "id": "sanitize-html"} + }, "scmp": { "type": "module", "moduleName": "scmp", @@ -3364,6 +3370,16 @@ "type": "documented", "replacementModule": "neoqs" }, + "neosanitize": { + "id": "neosanitize", + "type": "documented", + "replacementModule": "neosanitize" + }, + "neosanitize/legacy": { + "id": "neosanitize/legacy", + "type": "documented", + "replacementModule": "neosanitize/legacy" + }, "neotraverse": { "id": "neotraverse", "type": "documented", From b4212ecd033abeca1d5ed8ea221ff87b7828b46d Mon Sep 17 00:00:00 2001 From: Puru Vijay Date: Mon, 6 Jul 2026 23:28:40 +0530 Subject: [PATCH 2/5] docs: fix neosanitize root example to use builder pattern The root module uses Sanitizer.builder(...).build(), not a sanitize(dirty, options) call. Correct the docs example. Co-Authored-By: Claude Opus 4.8 --- docs/modules/sanitize-html.md | 44 +++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/modules/sanitize-html.md b/docs/modules/sanitize-html.md index 1dcb4b54..6bcea3d7 100644 --- a/docs/modules/sanitize-html.md +++ b/docs/modules/sanitize-html.md @@ -31,26 +31,40 @@ All `sanitize-html` options (`allowedTags`, `allowedAttributes`, `allowedSchemes ## `neosanitize` (recommended) -For new code, the root module is recommended. It is built on a fast, browser-faithful WHATWG parser (100% html5lib tokenizer conformance) and follows a deny-by-default policy, so only what you explicitly allow gets through: +For new code, the root module is recommended. It is built on a fast, browser-faithful WHATWG parser (100% html5lib tokenizer conformance) and follows a deny-by-default policy, so only what you explicitly allow gets through. -```ts -import sanitizeHtml from 'sanitize-html' // [!code --] -import { sanitize } from 'neosanitize' // [!code ++] +Unlike `sanitize-html`, the root engine uses a builder pattern: you compile a policy once with `Sanitizer.builder(...)`, then reuse the resulting sanitizer everywhere. -const clean = sanitize(dirty, { - allowedTags: ['b', 'i', 'em', 'strong', 'a'], - allowedAttributes: { - a: ['href'] - } -}) +```ts +import { Sanitizer } from 'neosanitize' +import * as presets from 'neosanitize/presets' + +// Build once (compiles the policy), reuse everywhere. +const sanitizer = Sanitizer.builder(presets.ugc) + .allow('img', ['src', 'alt']) + .build() + +sanitizer.sanitize( + '

hi

' +) +// → '

hi

' ``` -Because it has no dependencies and runs anywhere, it works unchanged in browsers and edge runtimes: +You can also build a policy from scratch instead of starting from a preset, and chain `.allow()` / `.deny()` to refine it: ```ts -import { sanitize } from 'neosanitize/browser' - -const clean = sanitize(userInput) +const sanitizer = Sanitizer.builder({ + tags: ['a', 'b', 'p'], + attrs: { a: ['href'] } +}) + .allow('img', ['src', 'alt']) + .deny('span') + .build() + +sanitizer.sanitize( + '

see docs

' +) +// → '

see docs

' ``` -Preset configurations are available under `neosanitize/presets`, and the underlying parser can be used directly via `neosanitize/whatwg-parser`. +Because it has no dependencies, the same code runs in browsers and edge runtimes unchanged (bundlers resolve `neosanitize` to the browser build automatically). The underlying parser can also be used directly via `neosanitize/whatwg-parser`. From fc211a9b1d2132f21d0fb9688b3adddb8fdb0992 Mon Sep 17 00:00:00 2001 From: Puru Vijay Date: Mon, 6 Jul 2026 23:31:00 +0530 Subject: [PATCH 3/5] docs: document pluggable parse5/htmlparser2 parser adapters Show how to swap the neosanitize parser via .parser() on the builder, with parse5 (spec conformance) and htmlparser2 (speed) adapters. Co-Authored-By: Claude Opus 4.8 --- docs/modules/sanitize-html.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/modules/sanitize-html.md b/docs/modules/sanitize-html.md index 6bcea3d7..0ed342cd 100644 --- a/docs/modules/sanitize-html.md +++ b/docs/modules/sanitize-html.md @@ -68,3 +68,22 @@ sanitizer.sanitize( ``` Because it has no dependencies, the same code runs in browsers and edge runtimes unchanged (bundlers resolve `neosanitize` to the browser build automatically). The underlying parser can also be used directly via `neosanitize/whatwg-parser`. + +### Swapping the parser + +The default parser is the fastest option and is browser-faithful with 100% html5lib tokenizer conformance. If you need different trade-offs, the parser is pluggable via `.parser(...)` on the builder — the policy engine and sanitization logic stay identical, only the HTML-to-tree step changes. Adapters for [`parse5`](https://github.com/inikulin/parse5) and [`htmlparser2`](https://github.com/fb55/htmlparser2) ship as separate exports (their libraries are peer dependencies, loaded only when imported): + +```ts +import { Sanitizer } from 'neosanitize' +import { parse5Adapter } from 'neosanitize/parse5' +import { htmlparser2Adapter } from 'neosanitize/htmlparser2' +import * as presets from 'neosanitize/presets' + +// Full WHATWG spec conformance on adversarial markup (~50% throughput) +const strict = Sanitizer.builder(presets.ugc).parser(parse5Adapter).build() + +// Fast, lenient parsing (no full WHATWG tree builder — e.g. no foster-parenting) +const lenient = Sanitizer.builder(presets.ugc) + .parser(htmlparser2Adapter) + .build() +``` From ced9b32331b4b9c772553e946a874e2276733443 Mon Sep 17 00:00:00 2001 From: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:55:48 +0530 Subject: [PATCH 4/5] Remove legacy replacement from sanitize-html --- manifests/preferred.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifests/preferred.json b/manifests/preferred.json index c423c9a5..59ccea58 100644 --- a/manifests/preferred.json +++ b/manifests/preferred.json @@ -2543,7 +2543,7 @@ "sanitize-html": { "type": "module", "moduleName": "sanitize-html", - "replacements": ["neosanitize/legacy", "neosanitize"], + "replacements": ["neosanitize"], "url": {"type": "e18e", "id": "sanitize-html"} }, "scmp": { From 7a9de09fd3b3235561adff65512a4a78b45614e5 Mon Sep 17 00:00:00 2001 From: Puru Vijay <47742487+PuruVJ@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:07:27 +0530 Subject: [PATCH 5/5] Remove neosanitize/legacy module entry Removed legacy neosanitize module from preferred.json --- manifests/preferred.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/manifests/preferred.json b/manifests/preferred.json index 59ccea58..01248bf3 100644 --- a/manifests/preferred.json +++ b/manifests/preferred.json @@ -3375,11 +3375,6 @@ "type": "documented", "replacementModule": "neosanitize" }, - "neosanitize/legacy": { - "id": "neosanitize/legacy", - "type": "documented", - "replacementModule": "neosanitize/legacy" - }, "neotraverse": { "id": "neotraverse", "type": "documented",