purifai 2.0.3 → 3.0.0

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,268 @@
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](https://www.npmjs.com/package/purifai) ·
7
+ [Project notes](https://worksonmy.dev/projects/purifai) ·
8
+ [Runnable examples](https://github.com/moji2002/purifai/tree/main/examples) ·
9
+ [Issues](https://github.com/moji2002/purifai/issues)
10
+
11
+ Purifai is a fixed-policy HTML-to-readable-text converter for servers, browsers,
12
+ and edge runtimes. It incrementally removes non-reader bodies, decodes the full
13
+ WHATWG character-reference set, and preserves useful structure such as headings,
14
+ paragraphs, lists, links, image alternatives, code, and simple tables.
15
+
16
+ ```ts
17
+ import { toText } from 'purifai';
18
+
19
+ const text = toText(
20
+ '<script>alert(1)</script><h2>Release</h2><ul><li>Fast</li></ul>',
21
+ );
22
+ // Release
23
+ //
24
+ // - Fast
100
25
  ```
101
26
 
102
- ## 📖 Usage
103
-
104
- ### Basic Usage
27
+ A flat tag remover can leak `alert(1)` from the script body and collapse the
28
+ remaining text. Purifai drops that body and formats the reader content.
105
29
 
106
- ```typescript
107
- import { Purifai } from 'purifai';
30
+ The output is a JavaScript string, not safe HTML. Use one of these supported
31
+ sinks:
108
32
 
109
- // Simple sanitization
110
- const clean = Purifai.sanitize('<script>alert("xss")</script>Hello World');
111
- console.log(clean); // "Hello World"
33
+ ```ts
34
+ import { escapeHtmlText, toText } from 'purifai';
112
35
 
113
- // With options
114
- const safe = Purifai.sanitize(userInput, {
115
- maxLength: 10000
116
- });
36
+ element.textContent = toText(untrustedHtml);
37
+ element.innerHTML = escapeHtmlText(toText(untrustedHtml));
117
38
  ```
118
39
 
119
- ### Advanced Analysis
40
+ Prefer `textContent`. `escapeHtmlText` exists for an HTML text context only; it
41
+ does not make a value safe for an attribute, URL, JavaScript, CSS, or template
42
+ source.
120
43
 
121
- ```typescript
122
- import { analyze } from 'purifai';
44
+ ## Why it exists
123
45
 
124
- const result = analyze('<script>alert("hack")</script>User content');
125
-
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)
130
- ```
46
+ Purifai targets one narrow intersection:
131
47
 
132
- ### Batch Processing
48
+ - readable extraction instead of flat deletion;
49
+ - deterministic input, output, nesting, and retained-token limits;
50
+ - chunk-invariant Web `TransformStream` conversion;
51
+ - no DOM, tree, Node built-in, or runtime dependency; and
52
+ - one side-effect-free artifact across server, browser, and edge runtimes.
133
53
 
134
- ```typescript
135
- import { sanitizeBatch } from 'purifai';
54
+ It does not preserve markup and does not classify a user's intent. If either is
55
+ your requirement, use a tool designed for that different job.
136
56
 
137
- const userInputs = [
138
- '<script>alert(1)</script>Hello',
139
- '<img src=x onerror=alert(1)>World',
140
- 'Safe content'
141
- ];
57
+ ## Install
142
58
 
143
- const cleanData = sanitizeBatch(userInputs);
144
- // Result: ["Hello", "World", "Safe content"]
59
+ ```sh
60
+ npm install purifai
145
61
  ```
146
62
 
147
- ### Threat Detection
63
+ Purifai v3 requires Node.js 22 or newer when used in Node.
148
64
 
149
- ```typescript
150
- import { isDangerous } from 'purifai';
65
+ ## API
151
66
 
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
- }
157
- ```
67
+ ### `toText(html, options?)`
68
+
69
+ Converts one string and returns readable text. A breached limit throws a
70
+ `PurifaiLimitError`.
158
71
 
159
- ### Contextual Escaping
72
+ ```ts
73
+ import { toText } from 'purifai';
160
74
 
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.
75
+ const text = toText('<h1>Guide</h1><p>Start here.</p>', {
76
+ layout: 'readable',
77
+ links: 'label-and-url',
78
+ images: 'alt',
79
+ baseUrl: 'https://docs.example/',
80
+ limits: { input: 1_000_000, output: 250_000, depth: 64, token: 65_536 },
81
+ });
82
+ ```
164
83
 
165
- ```typescript
166
- import { escape, escapeAttribute, escapeUrl } from 'purifai';
84
+ ### `convert(html, options?)`
167
85
 
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;"
86
+ Returns the text plus a frozen conversion report. It is the only entry point
87
+ that can deliberately return a bounded prefix instead of throwing.
171
88
 
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)}">`;
89
+ ```ts
90
+ import { convert } from 'purifai';
175
91
 
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
- ```
92
+ const result = convert(largeHtml, {
93
+ limits: { output: 20_000 },
94
+ overflow: 'truncate',
95
+ });
181
96
 
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;
198
- }
97
+ result.text;
98
+ result.truncatedBy; // 'output' or null
99
+ result.scanComplete; // false after truncation
100
+ result.consumedInputCodeUnits;
101
+ result.outputCodeUnits;
102
+ result.droppedContainers; // e.g. { script: 2, style: 1 }
199
103
  ```
200
104
 
201
- `allowedProtocols` can narrow the built-in set but cannot add executable
202
- schemes. Protocol-relative URLs are rejected.
105
+ Truncation is explicit, deterministic, and never emits half of a UTF-16
106
+ surrogate pair. When multiple limits meet at the same point, the first observed
107
+ limit is reported.
203
108
 
204
- ## Test Purifai in your project
109
+ ### `createTextTransform(options?)`
205
110
 
206
- The example below imports only the public package API and runs with Node's
207
- built-in test runner:
111
+ Returns a native `TransformStream<string, string>` with a `result` promise. The
112
+ stream uses the same state machine and produces exactly the same joined text as
113
+ `toText`, regardless of chunk boundaries.
208
114
 
209
- ```javascript
210
- import test from 'node:test';
211
- import assert from 'node:assert/strict';
212
- import { sanitize, escape, escapeUrl } from 'purifai';
115
+ ```ts
116
+ import { createTextTransform } from 'purifai';
213
117
 
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)'), '');
218
- });
219
- ```
118
+ const response = await fetch('https://example.test/article');
119
+ if (response.body === null) throw new Error('Response has no body');
220
120
 
221
- Save it as `purifai.test.mjs`, then run:
222
-
223
- ```bash
224
- node --test purifai.test.mjs
225
- ```
121
+ const transform = createTextTransform({ links: 'label-and-url' });
122
+ const readable = response.body
123
+ .pipeThrough(new TextDecoderStream())
124
+ .pipeThrough(transform);
125
+ const reader = readable.getReader();
226
126
 
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
- ```
127
+ for (;;) {
128
+ const { done, value } = await reader.read();
129
+ if (done) break;
130
+ consumeText(value);
131
+ }
294
132
 
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
- });
133
+ const report = await transform.result;
303
134
  ```
304
135
 
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
- ```
136
+ Stream conversion always throws on a breached limit. Output may already have
137
+ been enqueued when `readable` and `transform.result` reject, so discard partial
138
+ output unless your application has deliberately defined it as useful. Purifai
139
+ does not buffer the whole result to make an error transactional.
314
140
 
315
- ## 🚦 Migration Guide
141
+ ### `escapeHtmlText(text)`
316
142
 
317
- ### From DOMPurify
318
- ```typescript
319
- // Before
320
- import DOMPurify from 'dompurify';
321
- const clean = DOMPurify.sanitize(dirty);
143
+ Encodes `&`, `<`, `>`, `"`, and `'` for an HTML text node. It is lossless and is
144
+ for plain text—including `toText` output—when the only available sink is
145
+ `innerHTML`.
322
146
 
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
- ```
147
+ ### `PurifaiLimitError`
328
148
 
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
- ```
149
+ Extends `RangeError` and exposes `kind`, `limit`, and `observed`.
341
150
 
342
- ### From xss
343
- ```typescript
344
- // Before
345
- import xss from 'xss';
346
- const clean = xss(dirty);
151
+ ```ts
152
+ import { PurifaiLimitError, toText } from 'purifai';
347
153
 
348
- // After
349
- import { sanitize } from 'purifai';
350
- const clean = sanitize(dirty);
351
- // Only migrate when dropping every tag is intended. Otherwise keep xss.
154
+ try {
155
+ toText(html, { limits: { input: 10_000 } });
156
+ } catch (error) {
157
+ if (error instanceof PurifaiLimitError) {
158
+ console.error(error.kind, error.limit, error.observed);
159
+ }
160
+ }
352
161
  ```
353
162
 
354
- ### From validator.js
355
- ```typescript
356
- // Before
357
- import validator from 'validator';
358
- const clean = validator.escape(dirty);
163
+ ## Options and defaults
164
+
165
+ Unknown keys and invalid values throw `TypeError`; Purifai does not silently
166
+ guess around configuration mistakes.
359
167
 
360
- // After: preserve the original encode-as-text behavior
361
- import { escape } from 'purifai';
362
- const clean = escape(dirty);
363
- ```
168
+ | Option | Type | Default | Meaning |
169
+ | --- | --- | --- | --- |
170
+ | `layout` | `'readable' \| 'compact'` | `'readable'` | Structural newlines/lists/tables, or normalized single-space text |
171
+ | `links` | `'label' \| 'label-and-url' \| 'drop'` | `'label'` | Keep label, append an accepted display URL, or drop the link body |
172
+ | `images` | `'alt' \| 'drop'` | `'alt'` | Emit decoded non-empty `alt` text, or omit images |
173
+ | `baseUrl` | `string \| URL` | none | Resolve relative display URLs against a credential-free HTTP(S) base |
174
+ | `limits.input` | non-negative safe integer | `1_000_000` | Maximum input UTF-16 code units consumed |
175
+ | `limits.output` | non-negative safe integer | `250_000` | Maximum output UTF-16 code units emitted |
176
+ | `limits.depth` | non-negative safe integer | `64` | Maximum live structural nesting |
177
+ | `limits.token` | non-negative safe integer | `65_536` | Maximum aggregate retained token/attribute code units |
178
+ | `overflow` | `'throw' \| 'truncate'` | `'throw'` | `convert` only; other APIs always throw |
364
179
 
365
- ### From node-sanitize
366
- ```typescript
367
- // Before
368
- import sanitize from 'node-sanitize';
369
- const clean = sanitize(dirty);
180
+ All four limits are enforced while scanning, before unbounded caller-controlled
181
+ state can accumulate. Values measure JavaScript UTF-16 code units, not encoded
182
+ bytes.
370
183
 
371
- // After
372
- import { sanitize } from 'purifai';
373
- const clean = sanitize(dirty);
374
- ```
184
+ ## Link policy
375
185
 
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
- ```
186
+ `label-and-url` emits a destination as display text, never as an active link. It
187
+ accepts absolute `http:`, `https:`, and `mailto:` URLs. Relative URLs require a
188
+ validated HTTP(S) `baseUrl`. Control characters, whitespace-split schemes,
189
+ protocol-relative inputs, leading backslashes, credentials, unsupported schemes,
190
+ and invalid URLs are omitted while their visible label remains.
392
191
 
393
- ## 📈 Performance Optimization
192
+ The returned URL string is still only text. Do not move it into `href` without a
193
+ separate URL-policy decision at that sink.
394
194
 
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.
195
+ ## Extraction fidelity
399
196
 
400
- ## 🤝 Contributing
197
+ Purifai intentionally removes source and non-reader bodies including `script`,
198
+ `style`, `template`, `iframe`, `noscript`, `noembed`, `noframes`, `svg`, and
199
+ `math`. It preserves selected fallback/form text, decodes `textarea`, preserves
200
+ literal `xmp`, and treats `plaintext` as text through end of input.
401
201
 
402
- We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
202
+ This is a bounded extraction grammar, not browser tree construction. It does not
203
+ recreate CSS layout, browser `innerText`, complex `rowspan`/`colspan` tables,
204
+ SVG/MathML semantics, selector rules, custom formatters, or browser-equivalent
205
+ malformed-markup recovery. Simple rows and cells are represented with tabs and
206
+ line boundaries.
403
207
 
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
- ```
208
+ ## Which tool should you choose?
412
209
 
413
- ### Running Benchmarks
414
- ```bash
415
- pnpm benchmark
416
- ```
210
+ | Need | Choice |
211
+ | --- | --- |
212
+ | Fixed-policy readable text, hostile-input bounds, and portable Web streaming | Choose Purifai |
213
+ | Selectors, custom formatters, advanced tables, wrapping, and broader formatting control | Choose `html-to-text` |
214
+ | The smallest flat tag-removal operation | Choose stable `striptags` |
215
+ | Preserve an allow-listed safe HTML fragment | Choose DOMPurify or `sanitize-html` |
417
216
 
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.
217
+ These tools are not interchangeable. In particular, DOMPurify and
218
+ `sanitize-html` are the right category when safe markup must survive.
422
219
 
423
- ## 📄 License
220
+ ## Benchmarks
424
221
 
425
- MIT License - see [LICENSE](LICENSE) file for details.
222
+ The checked category benchmark pins `striptags@3.2.0` and
223
+ `html-to-text@10.0.0`. It measures exact readability/body-removal fixtures,
224
+ isolated warm median and p95 one-shot latency, and fresh-process peak RSS. The
225
+ throughput path gives every package the same materialized string; the memory path
226
+ lets Purifai consume lazy 16,384-code-unit chunks because streaming ingestion is
227
+ the product claim.
426
228
 
427
- ## 🙏 Acknowledgments
229
+ On the recorded Apple M1 / Node 24 run, Purifai passed all 11 category gates: all
230
+ readability and body-removal fixtures, lower hostile-input p95 than
231
+ `html-to-text` on four hostile corpora, and lower streaming peak RSS on all five
232
+ memory corpora. `striptags` remains faster on some flat-strip cases, which is not
233
+ Purifai's claim.
428
234
 
429
- - Security research from OWASP, PortSwigger, and the security community
430
- - Inspiration from existing sanitization libraries
431
- - Comprehensive testing methodologies from security experts
235
+ See the [complete methodology, raw-result link, and tables](docs/benchmarks/v3.md).
236
+ Reproduce it with `pnpm run bench`; re-check the saved gates with
237
+ `pnpm run bench:check`.
432
238
 
433
- ## 📚 Related Resources
239
+ ## Size and runtime matrix
434
240
 
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)
241
+ The complete minified ESM runtime—including the full 2,231-name WHATWG entity
242
+ data—is gated at 25 KiB using deterministic `gzip -9`. The recorded artifact is
243
+ 23,689 bytes. `pnpm run test:size` also checks the packed exports, zero runtime
244
+ dependencies, cold import time, and retained import heap.
438
245
 
439
- ---
246
+ The release matrix exercises the same packed ESM artifact:
440
247
 
441
- **⚡ Need untrusted markup reduced to plain text? Install Purifai.**
248
+ | Runtime | Required release coverage |
249
+ | --- | --- |
250
+ | Node.js | 22, 24, and 26; ESM and CommonJS |
251
+ | Bun | ESM and CommonJS consumers |
252
+ | Deno | ESM consumer |
253
+ | Cloudflare Workers | real `workerd`, without Node compatibility |
254
+ | Browsers | Chromium, Firefox, and WebKit |
442
255
 
443
- ```bash
444
- npm install purifai
445
- ```
256
+ Browser qualification also reparses escaped output in a real DOM with a working
257
+ positive control. This validates the documented sinks; it is not a universal
258
+ claim about every output context.
259
+
260
+ ## Migration and development
446
261
 
447
- *Purifai strip untrusted markup, keep the text.*
262
+ V3 is a clean break. See the [v3 migration guide](docs/migration-v3.md) for every
263
+ removed export and option. Contributor setup and the full verification commands
264
+ are in [CONTRIBUTING.md](CONTRIBUTING.md).
265
+
266
+ ## License
267
+
268
+ MIT