purifai 2.0.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,447 +1,305 @@
1
- # 🛡️ Purifai
2
-
3
- [![npm version](https://badge.fury.io/js/purifai.svg)](https://badge.fury.io/js/purifai)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
- [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
6
- [![Zero Dependencies](https://img.shields.io/badge/dependencies-zero-green.svg)](https://www.npmjs.com/package/purifai)
7
-
8
- > **Zero-dependency strip-to-text sanitizer with contextual output encoding**
9
-
10
- Purifai is a lightweight, zero-dependency sanitizer for the **strip-to-text** case:
11
- untrusted content that must be displayed as text, never as markup. It removes all
12
- HTML rather than allow-listing safe tags. The result contains no retained markup
13
- for an HTML parser to mutate on re-parse.
14
-
15
- **Reach for Purifai when** you want untrusted input rendered as plain text, with no
16
- dependencies, no DOM, and a ~4.5 KB gzipped package build that runs in Node, browsers,
17
- and edge runtimes alike.
18
-
19
- **Reach for DOMPurify or sanitize-html when** you need to *keep* safe formatting
20
- such as `<b>` and `<a href>`. That is a harder problem and they solve it well.
21
-
22
- ## Benchmark snapshot
23
-
24
- Produced by `pnpm benchmark`. Each tool's output is inserted into a real DOM
25
- (jsdom), then serialized and re-parsed — the round trip where mutation XSS lives.
26
- A vector counts as having no executable output only if *neither* parse yields a
27
- script node, an `on*` handler, or a dangerous-protocol URL. Exact-text fidelity
28
- and raw-container removal are separate measurements so deletion cannot masquerade
29
- as quality.
30
-
31
- | Library | Category | No executable output* | Exact text | Markup kept | Raw body removed | Median ops/sec† | p95 µs/op† |
32
- |---------|----------|----------------------|------------|-------------|------------------|----------------|------------|
33
- | **Purifai.sanitize** | strip-to-text | **100%** | **100%** | 0% *(by design)* | **100%** | **526,709** | **2.050** |
34
- | Purifai.escape | encode-as-text | 100% | 40% | 0% | 0% | 596,421 | 3.627 |
35
- | striptags | strip-to-text | 100% | 100% | 0% | 20% | 633,697 | 1.836 |
36
- | DOMPurify (jsdom) | preserve-safe-html | 100% | 100% | 100% | 60% | 1,686 | 698.853 |
37
- | sanitize-html | preserve-safe-html | 100% | 100% | 100% | 60% | 88,492 | 14.731 |
38
- | xss | preserve-safe-html | 100% | 100% | 100% | 0% | 335,448 | 4.079 |
39
- | rehype-sanitize | preserve-safe-html | 100% | 100% | 100% | 40% | 24,522 | 44.439 |
40
- | escape-html | encode-as-text | 100% | 40% | 0% | 0% | 2,238,804 | 0.452 |
41
- | validator.escape | encode-as-text | 100% | 40% | 0% | 0% | 735,565 | 1.494 |
42
- | entities.escapeUTF8 | encode-as-text | 100% | 40% | 0% | 0% | 1,208,824 | 1.453 |
43
- | html-entities | encode-as-text | 100% | 40% | 0% | 0% | 869,440 | 1.203 |
44
- | he.escape | encode-as-text | 100% | 40% | 0% | 0% | 952,948 | 1.242 |
45
-
46
- 84 attack vectors (OWASP, PortSwigger, cure53 corpora) · 15 benign documents.
47
-
48
- \* This is an observed corpus result, not a security guarantee. † Throughput is
49
- the median of seven samples after warm-up; p95 is the slowest of those seven
50
- local samples. Snapshot captured on 2026-08-02
51
- with Node 26.3.0 on Apple Silicon. It varies by hardware and runtime and should
52
- only be compared within the same category. The command above is the source of
53
- truth. HTML encoders safely display the original markup as text; unlike Purifai
54
- and striptags, they do not turn markup-bearing input into clean reader text.
55
-
56
- Purifai is not a drop-in replacement for a safe-HTML-preserving sanitizer. Its
57
- measured advantages are a bounded scanner, zero runtime dependencies, and no DOM
58
- requirement for the narrower strip-to-text job.
59
-
60
- Within the strip-to-text rows, striptags is smaller and faster in this snapshot;
61
- Purifai's differentiation is exact benign text together with full raw-container
62
- body removal and bounded malformed-input scaling.
63
-
64
- ## 🚀 Why Purifai?
65
-
66
- ### ✅ Security boundaries
67
- - Produces no executable output in the current 84-vector DOM re-parse corpus
68
- - Uses a forward-only scanner with measured near-linear adversarial scaling
69
- - Removes scriptable/raw-text containers together with their bodies
70
- - Rejects invalid numeric entity scalars instead of emitting control characters
71
- - Contextual encoders for HTML body, attribute, and URL contexts
72
-
73
- ### ⚡ High Performance
74
- - **Fast processing** - optimized algorithms for high-throughput applications
75
- - **Zero dependencies** - minimal bundle size and attack surface
76
- - **TypeScript native** with full type definitions
77
- - **Node.js and Browser** compatible
78
-
79
- ### 🎯 Developer Friendly
80
- - Simple API with intelligent defaults
81
- - Detailed threat analysis and reporting
82
- - Batch processing support
83
- - Comprehensive documentation
84
-
85
- ## Test coverage
86
-
87
- The repository exercises 64 classic and 20 modern mutation/namespace vectors,
88
- seeded fuzzing, real-browser parsing, malformed raw-text containers, URL context
89
- validation, idempotence, and adversarial inputs from 2–128 KiB. A passing corpus
90
- is regression evidence, not proof against every future browser or payload.
91
-
92
- ## 🛠️ Installation
93
-
94
- ```bash
95
- npm install purifai
96
- # or
97
- yarn add purifai
98
- # or
99
- pnpm add purifai
1
+ > Stream untrusted HTML into clean, readable text with bounded working memory.
2
+ > No DOM. No document tree. No runtime dependencies.
3
+
4
+ # Purifai
5
+
6
+ [![npm version](https://img.shields.io/npm/v/purifai.svg)](https://www.npmjs.com/package/purifai)
7
+ [![CI](https://github.com/moji2002/purifai/actions/workflows/ci.yml/badge.svg)](https://github.com/moji2002/purifai/actions/workflows/ci.yml)
8
+ [![gzip: 23.7 KiB](https://img.shields.io/badge/gzip-23.7_KiB-2f855a)](docs/benchmarks/v3.md)
9
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
+
11
+ **Readable text from hostile HTML—without a DOM.**
12
+
13
+ Purifai is a fixed-policy HTML-to-text converter for servers, browsers, and
14
+ edge runtimes. It keeps useful document structure, drops non-reader bodies, and
15
+ enforces input, output, nesting, and retained-token limits while scanning.
16
+
17
+ ```ts
18
+ import { toText } from 'purifai';
19
+
20
+ const text = toText(
21
+ '<script>alert(1)</script><h2>Release</h2><ul><li>Fast</li></ul>',
22
+ );
23
+
24
+ console.log(text);
25
+ // Release
26
+ //
27
+ // - Fast
100
28
  ```
101
29
 
102
- ## 📖 Usage
30
+ A flat tag remover can leak `alert(1)` from the script body and collapse the
31
+ remaining text. Purifai drops that body and formats the reader content.
103
32
 
104
- ### Basic Usage
33
+ ## Choose Purifai when
105
34
 
106
- ```typescript
107
- import { Purifai } from 'purifai';
35
+ - HTML may be large, malformed, or hostile.
36
+ - You want readable plain text—not preserved markup or a browser DOM.
37
+ - Conversion must have deterministic resource limits.
38
+ - The same implementation must run in Node, Bun, Deno, Workers, and browsers.
39
+ - Streaming should produce the same result regardless of chunk boundaries.
108
40
 
109
- // Simple sanitization
110
- const clean = Purifai.sanitize('<script>alert("xss")</script>Hello World');
111
- console.log(clean); // "Hello World"
41
+ If you need selector-driven formatting, complex table layout, or allow-listed
42
+ safe HTML, jump to [Which tool should you choose?](#which-tool-should-you-choose).
112
43
 
113
- // With options
114
- const safe = Purifai.sanitize(userInput, {
115
- maxLength: 10000
116
- });
44
+ ## Install
45
+
46
+ ```sh
47
+ npm install purifai
117
48
  ```
118
49
 
119
- ### Advanced Analysis
50
+ Purifai v3 requires Node.js 22 or newer when used in Node. It ships ESM and
51
+ CommonJS exports and has zero runtime dependencies.
52
+
53
+ ## Quick start
120
54
 
121
- ```typescript
122
- import { analyze } from 'purifai';
55
+ ```ts
56
+ import { toText } from 'purifai';
123
57
 
124
- const result = analyze('<script>alert("hack")</script>User content');
58
+ const text = toText('<h1>Guide</h1><p>Start <strong>here</strong>.</p>', {
59
+ layout: 'readable',
60
+ links: 'label',
61
+ images: 'alt',
62
+ });
125
63
 
126
- console.log(result.content); // "User content"
127
- console.log(result.hadThreats); // true
128
- console.log(result.threatLevel); // "critical"
129
- console.log(result.processingTime); // 0.023 (ms)
64
+ // Guide
65
+ //
66
+ // Start here.
130
67
  ```
131
68
 
132
- ### Batch Processing
69
+ `toText` returns a JavaScript string. It does not return safe HTML.
133
70
 
134
- ```typescript
135
- import { sanitizeBatch } from 'purifai';
71
+ ## Safe output
136
72
 
137
- const userInputs = [
138
- '<script>alert(1)</script>Hello',
139
- '<img src=x onerror=alert(1)>World',
140
- 'Safe content'
141
- ];
73
+ Prefer a text sink:
142
74
 
143
- const cleanData = sanitizeBatch(userInputs);
144
- // Result: ["Hello", "World", "Safe content"]
75
+ ```ts
76
+ element.textContent = toText(untrustedHtml);
145
77
  ```
146
78
 
147
- ### Threat Detection
79
+ If the only available sink is an HTML text node, escape the text explicitly:
148
80
 
149
- ```typescript
150
- import { isDangerous } from 'purifai';
81
+ ```ts
82
+ import { escapeHtmlText, toText } from 'purifai';
151
83
 
152
- if (isDangerous(userInput)) {
153
- // Optional telemetry only. Do not use this advisory signal as an
154
- // authorization, authentication, or request-blocking decision.
155
- console.warn('Potentially dangerous markup observed');
156
- }
84
+ element.innerHTML = escapeHtmlText(toText(untrustedHtml));
157
85
  ```
158
86
 
159
- ### Contextual Escaping
87
+ `escapeHtmlText` is only for an HTML text context. It does not make a value safe
88
+ for an attribute, URL, JavaScript, CSS, or template source. A displayed URL is
89
+ also still text; moving it into `href` requires a separate URL-policy decision.
160
90
 
161
- `sanitize()` treats its input as HTML and removes markup. When the input is
162
- **plain text**, escaping is the better tool — it is lossless, and no guessing is
163
- involved. These follow OWASP's context-specific output encoding guidance.
91
+ ## Why Purifai
164
92
 
165
- ```typescript
166
- import { escape, escapeAttribute, escapeUrl } from 'purifai';
93
+ Most HTML-to-text tools optimize for either minimal tag removal or broad
94
+ formatting control. Purifai targets a narrower intersection:
167
95
 
168
- // HTML body context lossless, nothing is removed
169
- escape('if (a<b && c>d) return;');
170
- // "if (a&lt;b &amp;&amp; c&gt;d) return;"
96
+ | Requirement | Purifai behavior |
97
+ | --- | --- |
98
+ | Reader-friendly output | Preserves headings, paragraphs, lists, quotes, code, simple tables, links, and image alternatives |
99
+ | Non-reader content | Drops bodies such as `script`, `style`, `template`, `iframe`, `svg`, and `math` |
100
+ | Hostile-input bounds | Enforces input, output, depth, and aggregate retained-token limits during scanning |
101
+ | Streaming | Uses a native Web `TransformStream` with chunk-invariant output |
102
+ | Portability | Uses no DOM, document tree, Node built-in, or runtime dependency |
103
+ | Predictability | Fixed policy, validated options, explicit overflow behavior, and frozen reports |
171
104
 
172
- // Attribute context safe even unquoted, since a bare space or
173
- // backtick could otherwise close the value and start onerror=
174
- `<div title="${escapeAttribute(userInput)}">`;
105
+ That fixed scope is the reason to choose Purifai. It deliberately does not
106
+ preserve markup, reconstruct CSS layout, expose custom formatters, or classify a
107
+ user's intent.
175
108
 
176
- // URL context — returns '' when the protocol is not allow-listed
177
- escapeUrl('https://example.com'); // escaped, safe for href
178
- escapeUrl('javascript:alert(1)'); // ""
179
- escapeUrl('java\tscript:alert(1)'); // "" (whitespace-split protocols too)
180
- ```
109
+ ## Streaming
110
+
111
+ `createTextTransform` converts incrementally using the same state machine as
112
+ `toText`. Joining its output produces exactly the same text for every possible
113
+ input chunking.
114
+
115
+ ```ts
116
+ import { createTextTransform } from 'purifai';
117
+
118
+ const response = await fetch('https://example.test/article');
119
+ if (response.body === null) throw new Error('Response has no body');
181
120
 
182
- Why both exist: `sanitize()` uses a conservative HTML-like scanner and returns
183
- clean reader text, while `escape()` is lossless for text that must be displayed
184
- exactly. Only the caller knows the destination context.
185
-
186
- ## ⚙️ Configuration Options
187
-
188
- ```typescript
189
- interface PurifaiOptions {
190
- /** Maximum input length (default: 1MB) */
191
- maxLength?: number;
192
-
193
- /** Subset of built-in safe protocols: http, https, mailto */
194
- allowedProtocols?: string[];
195
-
196
- /** @deprecated Retained for compatibility; strip-to-text is always used. */
197
- aggressiveMode?: boolean;
121
+ const transform = createTextTransform({ links: 'label-and-url' });
122
+ const readable = response.body
123
+ .pipeThrough(new TextDecoderStream())
124
+ .pipeThrough(transform);
125
+
126
+ for await (const chunk of readable) {
127
+ consumeText(chunk);
198
128
  }
129
+
130
+ const report = await transform.result;
199
131
  ```
200
132
 
201
- `allowedProtocols` can narrow the built-in set but cannot add executable
202
- schemes. Protocol-relative URLs are rejected.
133
+ Stream conversion always throws when a limit is breached. Some output may
134
+ already have been enqueued when `readable` and `transform.result` reject, so
135
+ discard partial output unless your application explicitly accepts it.
203
136
 
204
- ## Test Purifai in your project
137
+ ## Bounded conversion
205
138
 
206
- The example below imports only the public package API and runs with Node's
207
- built-in test runner:
139
+ `toText` throws a `PurifaiLimitError` when any configured limit is exceeded.
140
+ Use `convert` only when a bounded prefix is an acceptable result:
208
141
 
209
- ```javascript
210
- import test from 'node:test';
211
- import assert from 'node:assert/strict';
212
- import { sanitize, escape, escapeUrl } from 'purifai';
142
+ ```ts
143
+ import { convert } from 'purifai';
213
144
 
214
- test('renders untrusted markup as plain text', () => {
215
- assert.equal(sanitize('<script>bad()</script><b>Hello</b>'), 'Hello');
216
- assert.equal(escape('a<b && c>d'), 'a&lt;b &amp;&amp; c&gt;d');
217
- assert.equal(escapeUrl('javascript:alert(1)'), '');
145
+ const result = convert(largeHtml, {
146
+ limits: { input: 1_000_000, output: 20_000, depth: 64, token: 65_536 },
147
+ overflow: 'truncate',
218
148
  });
149
+
150
+ result.text;
151
+ result.truncatedBy; // 'input', 'output', 'depth', 'token', or null
152
+ result.scanComplete; // false after truncation
153
+ result.consumedInputCodeUnits;
154
+ result.outputCodeUnits;
155
+ result.droppedContainers; // e.g. { script: 2, style: 1 }
219
156
  ```
220
157
 
221
- Save it as `purifai.test.mjs`, then run:
158
+ Truncation is explicit and deterministic, and never emits half of a UTF-16
159
+ surrogate pair. `toText` and `createTextTransform` never truncate silently.
222
160
 
223
- ```bash
224
- node --test purifai.test.mjs
225
- ```
161
+ ## Options
226
162
 
227
- This is a regression example, not proof that an application is secure. Keep
228
- authorization separate and use the encoder for the actual output context. The
229
- repository-owned copy runs with `pnpm test:example`.
230
-
231
- ## 🧪 Testing Methodology
232
-
233
- Our comprehensive test suite evaluates sanitizers against:
234
-
235
- - **84 attack vectors** from OWASP, PortSwigger, cure53, and regression research
236
- - **Advanced polyglot attacks** that combine multiple bypass techniques
237
- - **Encoding variations** (Unicode, HTML entities, URL encoding)
238
- - **Context-breaking attacks** for different HTML contexts
239
- - **Modern browser vectors** including HTML5 and SVG attacks
240
- - **Template injection** patterns from popular frameworks
241
-
242
- ### Test Categories:
243
- 1. **Basic XSS** - Standard script injection attempts
244
- 2. **Event Handlers** - Various HTML event attributes
245
- 3. **Protocol Variations** - javascript:, vbscript:, data: URIs
246
- 4. **CSS Expressions** - Style-based code execution
247
- 5. **Template Injection** - Framework-specific patterns
248
- 6. **Polyglot Attacks** - Multi-context bypass attempts
249
- 7. **Encoding Bypasses** - Obfuscation techniques
250
- 8. **Modern Vectors** - HTML5, SVG, and browser-specific attacks
251
-
252
- ## 📊 Detailed Comparison
253
-
254
- ### Security Comparison by Attack Type
255
-
256
- Superseded by the two-axis benchmark above. The per-category percentages that
257
- used to sit here came from a scoring rule that counted "output is empty" as a
258
- win, so it rewarded deletion rather than safety and marked correct competitor
259
- behaviour as failure. Run `pnpm test:fair` for numbers that survive scrutiny.
260
-
261
- ### Bundle Size Comparison
262
-
263
- | Library | Category | Target | Minified | Gzip | Direct runtime deps |
264
- |---------|----------|--------|----------|------|---------------------|
265
- | **Purifai** | strip-text | browser | **3.5 KB** | **1.6 KB** | **0** |
266
- | striptags | strip-text | browser | 2.1 KB | 1.1 KB | 0 |
267
- | DOMPurify | preserve-html | browser | 28.0 KB | 10.6 KB | 0 |
268
- | sanitize-html | preserve-html | Node | 192.2 KB | 70.4 KB | 7 |
269
- | xss | preserve-html | browser | 18.4 KB | 6.2 KB | 2 |
270
- | rehype-sanitize | preserve-html | browser | 244.5 KB | 70.7 KB | 2 |
271
- | escape-html | escape-html | browser | 1.2 KB | 0.7 KB | 0 |
272
- | validator.escape | escape-html | browser | 0.4 KB | 0.2 KB | 0 |
273
- | entities.escapeUTF8 | escape-html | browser | 0.7 KB | 0.4 KB | 0 |
274
- | html-entities | escape-html | browser | 34.8 KB | 13.1 KB | 0 |
275
- | he.escape | escape-html | browser | 85.7 KB | 30.2 KB | 0 |
276
-
277
- Measured by `pnpm test:size` with esbuild 0.27.7: smallest supported ESM
278
- import, bundled and minified for ES2020, then gzipped. The lockfile pins the
279
- exact library versions. DOMPurify is measured against the native browser API;
280
- sanitize-html is a Node bundle; the rehype row includes the parser, sanitizer,
281
- and serializer pipeline. Direct dependency counts come from each named package's
282
- manifest. Compare sizes within a category and target—the tools do different jobs.
283
-
284
- ## 🌟 Use Cases
285
-
286
- ### Web Applications
287
- ```typescript
288
- // Sanitize user-generated content
289
- app.post('/comments', (req, res) => {
290
- const safeComment = Purifai.sanitize(req.body.comment);
291
- // Store safeComment in database
292
- });
293
- ```
163
+ Unknown keys and invalid values throw `TypeError`; Purifai does not guess around
164
+ configuration mistakes.
294
165
 
295
- ### Content Management Systems
296
- ```typescript
297
- // Reduce rich text editor content to safe plain text.
298
- // Note: Purifai strips ALL markup. If you need to KEEP <b>/<a href>,
299
- // use DOMPurify or sanitize-html instead - that is a different job.
300
- const cleanText = Purifai.sanitize(editorContent, {
301
- maxLength: 50000
302
- });
303
- ```
166
+ | Option | Type | Default | Meaning |
167
+ | --- | --- | --- | --- |
168
+ | `layout` | `'readable' \| 'compact'` | `'readable'` | Structural boundaries, or normalized single-space text |
169
+ | `links` | `'label' \| 'label-and-url' \| 'drop'` | `'label'` | Keep the label, append an accepted display URL, or drop the link body |
170
+ | `images` | `'alt' \| 'drop'` | `'alt'` | Emit decoded non-empty `alt` text, or omit images |
171
+ | `baseUrl` | `string \| URL` | none | Resolve relative display URLs against a credential-free HTTP(S) base |
172
+ | `limits.input` | non-negative safe integer | `1_000_000` | Maximum input UTF-16 code units consumed |
173
+ | `limits.output` | non-negative safe integer | `250_000` | Maximum output UTF-16 code units emitted |
174
+ | `limits.depth` | non-negative safe integer | `64` | Maximum live structural nesting |
175
+ | `limits.token` | non-negative safe integer | `65_536` | Maximum aggregate retained token and attribute code units |
176
+ | `overflow` | `'throw' \| 'truncate'` | `'throw'` | `convert` only; other APIs always throw |
304
177
 
305
- ### Real-time Chat
306
- ```typescript
307
- // Clean messages before broadcasting
308
- socket.on('message', (data) => {
309
- const result = analyze(data.message);
310
- // hadThreats/threatLevel are advisory telemetry, not an auth gate.
311
- broadcast(result.content);
312
- });
313
- ```
178
+ All four limits are enforced before unbounded caller-controlled state can
179
+ accumulate. Values measure JavaScript UTF-16 code units, not encoded bytes.
314
180
 
315
- ## 🚦 Migration Guide
181
+ ### Display URL policy
316
182
 
317
- ### From DOMPurify
318
- ```typescript
319
- // Before
320
- import DOMPurify from 'dompurify';
321
- const clean = DOMPurify.sanitize(dirty);
183
+ `label-and-url` emits destinations as display text, never as active links. It
184
+ accepts absolute `http:`, `https:`, and `mailto:` URLs. Relative URLs require a
185
+ validated HTTP(S) `baseUrl`. Credentials, controls, ambiguous schemes,
186
+ protocol-relative inputs, leading backslashes, unsupported schemes, and invalid
187
+ URLs are omitted while their visible label remains.
322
188
 
323
- // After
324
- import { sanitize } from 'purifai';
325
- const clean = sanitize(dirty);
326
- // Only migrate when dropping every tag is intended. Otherwise keep DOMPurify.
327
- ```
189
+ ## Extraction policy
328
190
 
329
- ### From sanitize-html
330
- ```typescript
331
- // Before
332
- import sanitizeHtml from 'sanitize-html';
333
- const clean = sanitizeHtml(dirty, options);
334
-
335
- // After
336
- import { sanitize } from 'purifai';
337
- const clean = sanitize(dirty);
338
- // Purifai removes all tags. If the migration needs to KEEP safe HTML,
339
- // stay on sanitize-html - Purifai targets the strip-to-text case.
340
- ```
191
+ Purifai removes source and non-reader bodies including `script`, `style`,
192
+ `template`, `iframe`, `noscript`, `noembed`, `noframes`, `svg`, and `math`. It
193
+ preserves selected fallback and form text, decodes the complete pinned WHATWG
194
+ character-reference set, preserves literal `xmp`, and treats `plaintext` as text
195
+ through end of input.
341
196
 
342
- ### From xss
343
- ```typescript
344
- // Before
345
- import xss from 'xss';
346
- const clean = xss(dirty);
197
+ This is a bounded extraction grammar, not browser tree construction. It does not
198
+ recreate CSS layout, browser `innerText`, complex `rowspan`/`colspan` tables,
199
+ SVG/MathML semantics, selector rules, custom formatters, or browser-equivalent
200
+ malformed-markup recovery.
347
201
 
348
- // After
349
- import { sanitize } from 'purifai';
350
- const clean = sanitize(dirty);
351
- // Only migrate when dropping every tag is intended. Otherwise keep xss.
352
- ```
202
+ ## Benchmarks
353
203
 
354
- ### From validator.js
355
- ```typescript
356
- // Before
357
- import validator from 'validator';
358
- const clean = validator.escape(dirty);
204
+ The checked category benchmark pins `striptags@3.2.0` and
205
+ `html-to-text@10.0.0`. It measures reviewed readability and body-removal
206
+ fixtures, isolated warm median and p95 latency, and fresh-process peak RSS.
359
207
 
360
- // After: preserve the original encode-as-text behavior
361
- import { escape } from 'purifai';
362
- const clean = escape(dirty);
363
- ```
208
+ On the recorded Apple M1 / Node 24 run, Purifai passed all 11 category gates:
364
209
 
365
- ### From node-sanitize
366
- ```typescript
367
- // Before
368
- import sanitize from 'node-sanitize';
369
- const clean = sanitize(dirty);
210
+ - 8/8 readability fixtures and all 5 non-reader-body fixtures;
211
+ - lower hostile-input p95 than `html-to-text` on four hostile corpora; and
212
+ - lower streaming peak RSS than `html-to-text` on all five memory corpora.
370
213
 
371
- // After
372
- import { sanitize } from 'purifai';
373
- const clean = sanitize(dirty);
374
- ```
214
+ `striptags` remains faster on some flat-strip cases. That is not Purifai's
215
+ claim. Results are machine-, runtime-, and corpus-specific.
375
216
 
376
- ## 🔐 Security Features
377
-
378
- ### Defensive design
379
- - **Forward-only scanning** with bounded adversarial scaling checks
380
- - **Fail-closed raw-text removal** for unclosed scriptable containers
381
- - **Context-specific encoders** instead of one output reused everywhere
382
- - **No executable output observed** across the current 84-vector corpus
383
-
384
- ### Encoded Attack Detection
385
- ```typescript
386
- // Markup variants are decoded before the strip-to-text scan:
387
- '<script>alert(1)</script>' // Direct
388
- '&#60;script&#62;alert(1)&#60;/script&#62;' // HTML entities
389
- '%3Cscript%3Ealert(1)%3C/script%3E' // URL encoded
390
- '\\u003cscript\\u003ealert(1)\\u003c/script\\u003e' // Unicode
391
- ```
217
+ See the [complete methodology, raw results, and tables](docs/benchmarks/v3.md).
218
+ Reproduce measurements with `pnpm run bench`; check the recorded release gates
219
+ with `pnpm run bench:check`.
392
220
 
393
- ## 📈 Performance Optimization
221
+ ## Size, portability, and release proof
394
222
 
395
- `pnpm test:perf` measures two malformed-tag shapes from 2–128 KiB. In the
396
- 2026-08-02 Node 26.3.0 run, 64× larger inputs took 67.55× and 59.88× longer;
397
- normalized cost stayed at 1.06× and 0.94×. These local results support the
398
- scanner's near-linear design but are not a universal runtime guarantee.
223
+ The complete minified ESM runtime—including all 2,231 pinned WHATWG entity
224
+ names—is 23,689 bytes with deterministic `gzip -9`. The release gate also checks
225
+ packed exports, zero runtime dependencies, cold import time, and retained import
226
+ heap.
399
227
 
400
- ## 🤝 Contributing
228
+ The same packed artifact is tested in:
401
229
 
402
- We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
230
+ | Runtime | Release coverage |
231
+ | --- | --- |
232
+ | Node.js | 22, 24, and 26; ESM and CommonJS |
233
+ | Bun | ESM and CommonJS |
234
+ | Deno | ESM |
235
+ | Cloudflare Workers | Real `workerd`, without Node compatibility |
236
+ | Browsers | Chromium, Firefox, and WebKit |
403
237
 
404
- ### Development Setup
405
- ```bash
406
- git clone https://github.com/moji2002/purifai.git
407
- cd purifai
408
- pnpm install
409
- pnpm build
410
- pnpm test
411
- ```
238
+ Release qualification also includes 10,000 seeded malformed-input cases,
239
+ adversarial scaling checks, safe-sink tests with a positive control, package
240
+ smoke tests, and npm OIDC provenance bound to the tagged GitHub source commit.
412
241
 
413
- ### Running Benchmarks
414
- ```bash
415
- pnpm benchmark
416
- ```
242
+ ## API reference
417
243
 
418
- This builds the published artifact, runs the two-axis competitor benchmark, and
419
- then measures Purifai's throughput, critical-attack checks, and bundle size. The
420
- competitor set and exact versions are pinned in `package.json` and
421
- `pnpm-lock.yaml` so results are reproducible.
244
+ ### `toText(html, options?) string`
422
245
 
423
- ## 📄 License
246
+ Converts one HTML string into readable text. Throws `TypeError` for invalid
247
+ input or options and `PurifaiLimitError` for a breached limit.
424
248
 
425
- MIT License - see [LICENSE](LICENSE) file for details.
249
+ ### `convert(html, options?) ConversionResult`
426
250
 
427
- ## 🙏 Acknowledgments
251
+ Returns text plus a frozen report containing completion, truncation, consumed
252
+ input, output length, and dropped-container counts. It is the only API that can
253
+ return a deliberately truncated prefix.
428
254
 
429
- - Security research from OWASP, PortSwigger, and the security community
430
- - Inspiration from existing sanitization libraries
431
- - Comprehensive testing methodologies from security experts
255
+ ### `createTextTransform(options?) TextTransform`
432
256
 
433
- ## 📚 Related Resources
257
+ Returns a native `TransformStream<string, string>` with a `result` promise for
258
+ the frozen conversion report. Limit failures reject both the stream and the
259
+ promise with the same error object.
434
260
 
435
- - [OWASP XSS Prevention](https://owasp.org/www-community/xss-filter-evasion-cheatsheet)
436
- - [PortSwigger XSS Labs](https://portswigger.net/web-security/cross-site-scripting)
437
- - [MDN Web Security](https://developer.mozilla.org/en-US/docs/Web/Security)
261
+ ### `escapeHtmlText(text) string`
438
262
 
439
- ---
263
+ Losslessly encodes `&`, `<`, `>`, `"`, and `'` for an HTML text-node context.
440
264
 
441
- **⚡ Need untrusted markup reduced to plain text? Install Purifai.**
265
+ ### `PurifaiLimitError`
442
266
 
443
- ```bash
444
- npm install purifai
267
+ Extends `RangeError` and exposes `kind`, `limit`, and `observed`.
268
+
269
+ ```ts
270
+ import { PurifaiLimitError, toText } from 'purifai';
271
+
272
+ try {
273
+ toText(html, { limits: { input: 10_000 } });
274
+ } catch (error) {
275
+ if (error instanceof PurifaiLimitError) {
276
+ console.error(error.kind, error.limit, error.observed);
277
+ }
278
+ }
445
279
  ```
446
280
 
447
- *Purifai strip untrusted markup, keep the text.*
281
+ ## Which tool should you choose?
282
+
283
+ | Need | Choice |
284
+ | --- | --- |
285
+ | Fixed-policy readable text, hostile-input bounds, and portable Web streaming | Choose Purifai |
286
+ | Selectors, custom formatters, advanced tables, wrapping, and broad formatting control | Choose `html-to-text` |
287
+ | The smallest flat tag-removal operation | Choose stable `striptags` |
288
+ | Preserve an allow-listed safe HTML fragment | Choose DOMPurify or `sanitize-html` |
289
+
290
+ These categories are not interchangeable. DOMPurify and `sanitize-html` are
291
+ the correct category when safe markup must survive.
292
+
293
+ ## Migration and development
294
+
295
+ V3 is a clean break. See the [v3 migration guide](docs/migration-v3.md) for every
296
+ removed export and option.
297
+
298
+ - [Runnable examples](examples)
299
+ - [Contributor guide](CONTRIBUTING.md)
300
+ - [Project notes](https://worksonmy.dev/projects/purifai)
301
+ - [Issues](https://github.com/moji2002/purifai/issues)
302
+
303
+ ## License
304
+
305
+ [MIT](LICENSE)