Stream untrusted HTML into clean, readable text with bounded working memory. No DOM. No document tree. No runtime dependencies.
Readable text from hostile HTML—without a DOM.
Purifai is a fixed-policy HTML-to-text converter for servers, browsers, and edge runtimes. It keeps useful document structure, drops non-reader bodies, and enforces input, output, nesting, and retained-token limits while scanning.
import { toText } from 'purifai';
const text = toText(
'<script>alert(1)</script><h2>Release</h2><ul><li>Fast</li></ul>',
);
console.log(text);
// Release
//
// - FastA flat tag remover can leak alert(1) from the script body and collapse the
remaining text. Purifai drops that body and formats the reader content.
- HTML may be large, malformed, or hostile.
- You want readable plain text—not preserved markup or a browser DOM.
- Conversion must have deterministic resource limits.
- The same implementation must run in Node, Bun, Deno, Workers, and browsers.
- Streaming should produce the same result regardless of chunk boundaries.
If you need selector-driven formatting, complex table layout, or allow-listed safe HTML, jump to Which tool should you choose?.
npm install purifaiPurifai v3 requires Node.js 22 or newer when used in Node. It ships ESM and CommonJS exports and has zero runtime dependencies.
import { toText } from 'purifai';
const text = toText('<h1>Guide</h1><p>Start <strong>here</strong>.</p>', {
layout: 'readable',
links: 'label',
images: 'alt',
});
// Guide
//
// Start here.toText returns a JavaScript string. It does not return safe HTML.
Prefer a text sink:
element.textContent = toText(untrustedHtml);If the only available sink is an HTML text node, escape the text explicitly:
import { escapeHtmlText, toText } from 'purifai';
element.innerHTML = escapeHtmlText(toText(untrustedHtml));escapeHtmlText is only for an HTML text context. It does not make a value safe
for an attribute, URL, JavaScript, CSS, or template source. A displayed URL is
also still text; moving it into href requires a separate URL-policy decision.
Most HTML-to-text tools optimize for either minimal tag removal or broad formatting control. Purifai targets a narrower intersection:
| Requirement | Purifai behavior |
|---|---|
| Reader-friendly output | Preserves headings, paragraphs, lists, quotes, code, simple tables, links, and image alternatives |
| Non-reader content | Drops bodies such as script, style, template, iframe, svg, and math |
| Hostile-input bounds | Enforces input, output, depth, and aggregate retained-token limits during scanning |
| Streaming | Uses a native Web TransformStream with chunk-invariant output |
| Portability | Uses no DOM, document tree, Node built-in, or runtime dependency |
| Predictability | Fixed policy, validated options, explicit overflow behavior, and frozen reports |
That fixed scope is the reason to choose Purifai. It deliberately does not preserve markup, reconstruct CSS layout, expose custom formatters, or classify a user's intent.
createTextTransform converts incrementally using the same state machine as
toText. Joining its output produces exactly the same text for every possible
input chunking.
import { createTextTransform } from 'purifai';
const response = await fetch('https://example.test/article');
if (response.body === null) throw new Error('Response has no body');
const transform = createTextTransform({ links: 'label-and-url' });
const readable = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(transform);
for await (const chunk of readable) {
consumeText(chunk);
}
const report = await transform.result;Stream conversion always throws when a limit is breached. Some output may
already have been enqueued when readable and transform.result reject, so
discard partial output unless your application explicitly accepts it.
toText throws a PurifaiLimitError when any configured limit is exceeded.
Use convert only when a bounded prefix is an acceptable result:
import { convert } from 'purifai';
const result = convert(largeHtml, {
limits: { input: 1_000_000, output: 20_000, depth: 64, token: 65_536 },
overflow: 'truncate',
});
result.text;
result.truncatedBy; // 'input', 'output', 'depth', 'token', or null
result.scanComplete; // false after truncation
result.consumedInputCodeUnits;
result.outputCodeUnits;
result.droppedContainers; // e.g. { script: 2, style: 1 }Truncation is explicit and deterministic, and never emits half of a UTF-16
surrogate pair. toText and createTextTransform never truncate silently.
Unknown keys and invalid values throw TypeError; Purifai does not guess around
configuration mistakes.
| Option | Type | Default | Meaning |
|---|---|---|---|
layout |
'readable' | 'compact' |
'readable' |
Structural boundaries, or normalized single-space text |
links |
'label' | 'label-and-url' | 'drop' |
'label' |
Keep the label, append an accepted display URL, or drop the link body |
images |
'alt' | 'drop' |
'alt' |
Emit decoded non-empty alt text, or omit images |
baseUrl |
string | URL |
none | Resolve relative display URLs against a credential-free HTTP(S) base |
limits.input |
non-negative safe integer | 1_000_000 |
Maximum input UTF-16 code units consumed |
limits.output |
non-negative safe integer | 250_000 |
Maximum output UTF-16 code units emitted |
limits.depth |
non-negative safe integer | 64 |
Maximum live structural nesting |
limits.token |
non-negative safe integer | 65_536 |
Maximum aggregate retained token and attribute code units |
overflow |
'throw' | 'truncate' |
'throw' |
convert only; other APIs always throw |
All four limits are enforced before unbounded caller-controlled state can accumulate. Values measure JavaScript UTF-16 code units, not encoded bytes.
label-and-url emits destinations as display text, never as active links. It
accepts absolute http:, https:, and mailto: URLs. Relative URLs require a
validated HTTP(S) baseUrl. Credentials, controls, ambiguous schemes,
protocol-relative inputs, leading backslashes, unsupported schemes, and invalid
URLs are omitted while their visible label remains.
Purifai removes source and non-reader bodies including script, style,
template, iframe, noscript, noembed, noframes, svg, and math. It
preserves selected fallback and form text, decodes the complete pinned WHATWG
character-reference set, preserves literal xmp, and treats plaintext as text
through end of input.
This is a bounded extraction grammar, not browser tree construction. It does not
recreate CSS layout, browser innerText, complex rowspan/colspan tables,
SVG/MathML semantics, selector rules, custom formatters, or browser-equivalent
malformed-markup recovery.
The checked category benchmark pins striptags@3.2.0 and
html-to-text@10.0.0. It measures reviewed readability and body-removal
fixtures, isolated warm median and p95 latency, and fresh-process peak RSS.
On the recorded Apple M1 / Node 24 run, Purifai passed all 11 category gates:
- 8/8 readability fixtures and all 5 non-reader-body fixtures;
- lower hostile-input p95 than
html-to-texton four hostile corpora; and - lower streaming peak RSS than
html-to-texton all five memory corpora.
striptags remains faster on some flat-strip cases. That is not Purifai's
claim. Results are machine-, runtime-, and corpus-specific.
See the complete methodology, raw results, and tables.
Reproduce measurements with pnpm run bench; check the recorded release gates
with pnpm run bench:check.
The complete minified ESM runtime—including all 2,231 pinned WHATWG entity
names—is 23,689 bytes with deterministic gzip -9. The release gate also checks
packed exports, zero runtime dependencies, cold import time, and retained import
heap.
The same packed artifact is tested in:
| Runtime | Release coverage |
|---|---|
| Node.js | 22, 24, and 26; ESM and CommonJS |
| Bun | ESM and CommonJS |
| Deno | ESM |
| Cloudflare Workers | Real workerd, without Node compatibility |
| Browsers | Chromium, Firefox, and WebKit |
Release qualification also includes 10,000 seeded malformed-input cases, adversarial scaling checks, safe-sink tests with a positive control, package smoke tests, and npm OIDC provenance bound to the tagged GitHub source commit.
Converts one HTML string into readable text. Throws TypeError for invalid
input or options and PurifaiLimitError for a breached limit.
Returns text plus a frozen report containing completion, truncation, consumed input, output length, and dropped-container counts. It is the only API that can return a deliberately truncated prefix.
Returns a native TransformStream<string, string> with a result promise for
the frozen conversion report. Limit failures reject both the stream and the
promise with the same error object.
Losslessly encodes &, <, >, ", and ' for an HTML text-node context.
Extends RangeError and exposes kind, limit, and observed.
import { PurifaiLimitError, toText } from 'purifai';
try {
toText(html, { limits: { input: 10_000 } });
} catch (error) {
if (error instanceof PurifaiLimitError) {
console.error(error.kind, error.limit, error.observed);
}
}| Need | Choice |
|---|---|
| Fixed-policy readable text, hostile-input bounds, and portable Web streaming | Choose Purifai |
| Selectors, custom formatters, advanced tables, wrapping, and broad formatting control | Choose html-to-text |
| The smallest flat tag-removal operation | Choose stable striptags |
| Preserve an allow-listed safe HTML fragment | Choose DOMPurify or sanitize-html |
These categories are not interchangeable. DOMPurify and sanitize-html are
the correct category when safe markup must survive.
V3 is a clean break. See the v3 migration guide for every removed export and option.