purifai 2.0.2 → 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 +204 -368
- package/benchmark/results/v3.json +2767 -0
- package/dist/index.cjs +14 -406
- package/dist/index.d.cts +2 -191
- package/dist/index.d.ts +2 -191
- package/dist/index.js +14 -374
- package/dist/src/api.d.ts +7 -0
- package/dist/src/config.d.ts +18 -0
- package/dist/src/contracts.d.ts +40 -0
- package/dist/src/entities.d.ts +15 -0
- package/dist/src/formatter.d.ts +36 -0
- package/dist/src/generated/entities.d.ts +3 -0
- package/dist/src/policy.d.ts +15 -0
- package/dist/src/scanner.d.ts +71 -0
- package/dist/src/session.d.ts +27 -0
- package/docs/benchmarks/v3.md +92 -0
- package/docs/migration-v3.md +95 -0
- package/package.json +42 -38
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ConversionResult, type ConvertOptions, type PurifaiTextTransform, type ToTextOptions } from './contracts.js';
|
|
2
|
+
export { PurifaiLimitError } from './contracts.js';
|
|
3
|
+
export type { ConversionLimits, ConversionReport, ConversionResult, ConvertOptions, ImageMode, LayoutMode, LimitKind, LinkMode, OverflowMode, PurifaiTextTransform, ToTextOptions, } from './contracts.js';
|
|
4
|
+
export declare function toText(html: string, options?: ToTextOptions): string;
|
|
5
|
+
export declare function convert(html: string, options?: ConvertOptions): ConversionResult;
|
|
6
|
+
export declare function createTextTransform(options?: ToTextOptions): PurifaiTextTransform;
|
|
7
|
+
export declare function escapeHtmlText(text: string): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ConvertOptions, ImageMode, LayoutMode, LinkMode, OverflowMode, ToTextOptions } from './contracts.js';
|
|
2
|
+
export interface ValidatedLimits {
|
|
3
|
+
readonly input: number;
|
|
4
|
+
readonly output: number;
|
|
5
|
+
readonly depth: number;
|
|
6
|
+
readonly token: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ValidatedConfig {
|
|
9
|
+
readonly layout: LayoutMode;
|
|
10
|
+
readonly links: LinkMode;
|
|
11
|
+
readonly images: ImageMode;
|
|
12
|
+
readonly baseUrl: URL | null;
|
|
13
|
+
readonly limits: ValidatedLimits;
|
|
14
|
+
readonly overflow: OverflowMode;
|
|
15
|
+
}
|
|
16
|
+
export declare function validateToTextOptions(options: ToTextOptions | undefined): ValidatedConfig;
|
|
17
|
+
export declare function validateConvertOptions(options: ConvertOptions | undefined): ValidatedConfig;
|
|
18
|
+
export declare function displayUrl(raw: string, baseUrl: URL | null): string | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type LayoutMode = 'readable' | 'compact';
|
|
2
|
+
export type LinkMode = 'label' | 'label-and-url' | 'drop';
|
|
3
|
+
export type ImageMode = 'alt' | 'drop';
|
|
4
|
+
export type OverflowMode = 'throw' | 'truncate';
|
|
5
|
+
export type LimitKind = 'input' | 'output' | 'depth' | 'token';
|
|
6
|
+
export interface ConversionLimits {
|
|
7
|
+
input?: number;
|
|
8
|
+
output?: number;
|
|
9
|
+
depth?: number;
|
|
10
|
+
token?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ToTextOptions {
|
|
13
|
+
layout?: LayoutMode;
|
|
14
|
+
links?: LinkMode;
|
|
15
|
+
images?: ImageMode;
|
|
16
|
+
baseUrl?: string | URL;
|
|
17
|
+
limits?: ConversionLimits;
|
|
18
|
+
}
|
|
19
|
+
export interface ConvertOptions extends ToTextOptions {
|
|
20
|
+
overflow?: OverflowMode;
|
|
21
|
+
}
|
|
22
|
+
export interface ConversionReport {
|
|
23
|
+
truncatedBy: LimitKind | null;
|
|
24
|
+
scanComplete: boolean;
|
|
25
|
+
consumedInputCodeUnits: number;
|
|
26
|
+
outputCodeUnits: number;
|
|
27
|
+
droppedContainers: Readonly<Record<string, number>>;
|
|
28
|
+
}
|
|
29
|
+
export interface ConversionResult extends ConversionReport {
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
export interface PurifaiTextTransform extends TransformStream<string, string> {
|
|
33
|
+
readonly result: Promise<ConversionReport>;
|
|
34
|
+
}
|
|
35
|
+
export declare class PurifaiLimitError extends RangeError {
|
|
36
|
+
readonly kind: LimitKind;
|
|
37
|
+
readonly limit: number;
|
|
38
|
+
readonly observed: number;
|
|
39
|
+
constructor(kind: LimitKind, limit: number, observed: number);
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type ReferenceContext = 'data' | 'attribute';
|
|
2
|
+
export type ReferenceDecision = {
|
|
3
|
+
readonly kind: 'match';
|
|
4
|
+
readonly consumed: number;
|
|
5
|
+
readonly examined: number;
|
|
6
|
+
readonly value: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: 'literal';
|
|
9
|
+
readonly consumed: 1;
|
|
10
|
+
readonly examined: number;
|
|
11
|
+
readonly value: '&';
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: 'need-more';
|
|
14
|
+
};
|
|
15
|
+
export declare function decodeReference(source: string, ampersandOffset: number, context: ReferenceContext, final: boolean): ReferenceDecision;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ValidatedConfig } from './config.js';
|
|
2
|
+
import { type SelectedAttributes, type SemanticTag } from './policy.js';
|
|
3
|
+
export declare function isHtmlWhitespace(value: string): boolean;
|
|
4
|
+
export declare function splitOutputChunk(value: string, maximum: number): [string, string];
|
|
5
|
+
export declare class ReaderFormatter {
|
|
6
|
+
private readonly config;
|
|
7
|
+
private readonly writeOutput;
|
|
8
|
+
private readonly frames;
|
|
9
|
+
private readonly lists;
|
|
10
|
+
private readonly tables;
|
|
11
|
+
private readonly preInitialLineFeeds;
|
|
12
|
+
private pendingWhitespace;
|
|
13
|
+
private pendingBreaks;
|
|
14
|
+
private pendingTab;
|
|
15
|
+
private pendingListPrefix;
|
|
16
|
+
private quoteDepth;
|
|
17
|
+
private suppressedLinkDepth;
|
|
18
|
+
private hasOutput;
|
|
19
|
+
private atLineStart;
|
|
20
|
+
private trailingNewlines;
|
|
21
|
+
private serial;
|
|
22
|
+
constructor(config: ValidatedConfig, writeOutput: (value: string, splittable: boolean) => void);
|
|
23
|
+
text(value: string, mode: 'normal' | 'pre'): void;
|
|
24
|
+
start(tag: SemanticTag, attributes: SelectedAttributes): void;
|
|
25
|
+
end(tag: SemanticTag): void;
|
|
26
|
+
finish(): void;
|
|
27
|
+
private listPrefix;
|
|
28
|
+
private image;
|
|
29
|
+
private appendUrl;
|
|
30
|
+
private boundary;
|
|
31
|
+
private takeSeparator;
|
|
32
|
+
private takePrefixes;
|
|
33
|
+
private emitNormal;
|
|
34
|
+
private emitPre;
|
|
35
|
+
private write;
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TagKind = 'void' | 'inline' | 'block' | 'list' | 'table' | 'pre' | 'raw-preserve' | 'raw-drop' | 'ordinary-drop' | 'head' | 'foreign-drop';
|
|
2
|
+
export type SemanticTag = 'a' | 'abbr' | 'address' | 'article' | 'aside' | 'audio' | 'b' | 'bdi' | 'bdo' | 'blockquote' | 'body' | 'br' | 'button' | 'canvas' | 'caption' | 'cite' | 'code' | 'col' | 'colgroup' | 'data' | 'dd' | 'del' | 'details' | 'dfn' | 'dialog' | 'div' | 'dl' | 'dt' | 'em' | 'fieldset' | 'figcaption' | 'figure' | 'footer' | 'form' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'head' | 'header' | 'hgroup' | 'hr' | 'html' | 'i' | 'img' | 'input' | 'ins' | 'kbd' | 'label' | 'legend' | 'li' | 'main' | 'mark' | 'menu' | 'nav' | 'object' | 'ol' | 'option' | 'p' | 'pre' | 'q' | 'rp' | 'rt' | 'ruby' | 's' | 'samp' | 'section' | 'select' | 'slot' | 'small' | 'span' | 'strong' | 'sub' | 'summary' | 'sup' | 'table' | 'tbody' | 'td' | 'textarea' | 'tfoot' | 'th' | 'thead' | 'time' | 'tr' | 'u' | 'ul' | 'var' | 'video' | 'wbr' | 'xmp' | 'plaintext' | DroppedTag;
|
|
3
|
+
export type DroppedTag = 'applet' | 'base' | 'embed' | 'frameset' | 'iframe' | 'link' | 'math' | 'meta' | 'noembed' | 'noframes' | 'noscript' | 'param' | 'script' | 'source' | 'style' | 'svg' | 'template' | 'title' | 'track';
|
|
4
|
+
export interface SelectedAttributes {
|
|
5
|
+
href?: string;
|
|
6
|
+
alt?: string;
|
|
7
|
+
start?: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface TagPolicy {
|
|
11
|
+
readonly name: SemanticTag;
|
|
12
|
+
readonly kind: TagKind;
|
|
13
|
+
}
|
|
14
|
+
export declare function getTagPolicy(name: string): TagPolicy | null;
|
|
15
|
+
export declare function createsBlockBoundary(tag: SemanticTag): boolean;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type DroppedTag, type SelectedAttributes, type SemanticTag } from './policy.js';
|
|
2
|
+
export interface ScannerHost {
|
|
3
|
+
retainToken(units: number): void;
|
|
4
|
+
releaseToken(units: number): void;
|
|
5
|
+
probeToken(units: number): void;
|
|
6
|
+
pushDepth(): void;
|
|
7
|
+
popDepth(count: number): void;
|
|
8
|
+
text(value: string, mode: 'normal' | 'pre'): void;
|
|
9
|
+
start(tag: SemanticTag, attributes: SelectedAttributes): void;
|
|
10
|
+
end(tag: SemanticTag): void;
|
|
11
|
+
dropped(tag: DroppedTag): void;
|
|
12
|
+
}
|
|
13
|
+
export declare class HtmlScanner {
|
|
14
|
+
private readonly host;
|
|
15
|
+
private readonly frames;
|
|
16
|
+
private readonly topByName;
|
|
17
|
+
private readonly referenceCache;
|
|
18
|
+
private state;
|
|
19
|
+
private token;
|
|
20
|
+
private quote;
|
|
21
|
+
private commentTail;
|
|
22
|
+
private referenceBuffer;
|
|
23
|
+
private pendingCarriageReturn;
|
|
24
|
+
private preDepth;
|
|
25
|
+
private raw;
|
|
26
|
+
private ordinaryDrop;
|
|
27
|
+
private specialReference;
|
|
28
|
+
private inHead;
|
|
29
|
+
private finished;
|
|
30
|
+
constructor(host: ScannerHost);
|
|
31
|
+
write(chunk: string): void;
|
|
32
|
+
finish(): void;
|
|
33
|
+
private consume;
|
|
34
|
+
private consumeData;
|
|
35
|
+
private consumeTagOpen;
|
|
36
|
+
private consumeEndTagOpen;
|
|
37
|
+
private consumeTag;
|
|
38
|
+
private consumeMarkupProbe;
|
|
39
|
+
private consumeComment;
|
|
40
|
+
private consumeCommentSpan;
|
|
41
|
+
private consumeDataSpan;
|
|
42
|
+
private consumeDroppedRawSpan;
|
|
43
|
+
private consumeReferenceSpan;
|
|
44
|
+
private consumeTagSpan;
|
|
45
|
+
private startToken;
|
|
46
|
+
private appendToken;
|
|
47
|
+
private appendTokenValue;
|
|
48
|
+
private releaseTokenBuffer;
|
|
49
|
+
private retainReference;
|
|
50
|
+
private resolveReference;
|
|
51
|
+
private consumeResolvedRemainder;
|
|
52
|
+
private emitToken;
|
|
53
|
+
private emitParsedTag;
|
|
54
|
+
private openTag;
|
|
55
|
+
private openVisibleTag;
|
|
56
|
+
private closeTag;
|
|
57
|
+
private closeFramesTo;
|
|
58
|
+
private emitText;
|
|
59
|
+
private handleHeadTag;
|
|
60
|
+
private exitHead;
|
|
61
|
+
private consumeRaw;
|
|
62
|
+
private consumeRawCandidate;
|
|
63
|
+
private failRawCandidate;
|
|
64
|
+
private closeRawState;
|
|
65
|
+
private retainSpecialReference;
|
|
66
|
+
private resolveSpecialReference;
|
|
67
|
+
private consumeOrdinaryDrop;
|
|
68
|
+
private finishSpecialState;
|
|
69
|
+
private emitRawLiteral;
|
|
70
|
+
private decodeReference;
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ValidatedConfig } from './config.js';
|
|
2
|
+
import { type ConversionReport } from './contracts.js';
|
|
3
|
+
export declare class ConversionSession {
|
|
4
|
+
private readonly config;
|
|
5
|
+
private readonly emit;
|
|
6
|
+
private readonly droppedContainers;
|
|
7
|
+
private readonly scanner;
|
|
8
|
+
private readonly formatter;
|
|
9
|
+
private consumedInput;
|
|
10
|
+
private outputLength;
|
|
11
|
+
private retainedToken;
|
|
12
|
+
private depth;
|
|
13
|
+
private outputBuffer;
|
|
14
|
+
private stoppedBy;
|
|
15
|
+
private finished;
|
|
16
|
+
constructor(config: ValidatedConfig, emit: (chunk: string) => void);
|
|
17
|
+
write(chunk: string): void;
|
|
18
|
+
finish(): ConversionReport;
|
|
19
|
+
private appendOutput;
|
|
20
|
+
private retainToken;
|
|
21
|
+
private probeToken;
|
|
22
|
+
private pushDepth;
|
|
23
|
+
private recordDropped;
|
|
24
|
+
private exceed;
|
|
25
|
+
private flushFullChunks;
|
|
26
|
+
private flushAll;
|
|
27
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Purifai v3 category benchmark
|
|
2
|
+
|
|
3
|
+
Purifai is benchmarked for its deliberately narrow category: bounded streaming conversion of untrusted HTML into readable plain text. This report does not claim that Purifai is the fastest HTML tool for every job.
|
|
4
|
+
|
|
5
|
+
Raw measurements: [benchmark/results/v3.json](../../benchmark/results/v3.json)
|
|
6
|
+
|
|
7
|
+
## Reproduce
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm run bench
|
|
11
|
+
pnpm run bench:check
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- Run date: 2026-08-02T17:49:35.986Z
|
|
15
|
+
- Runtime: v24.18.0
|
|
16
|
+
- Platform: darwin arm64
|
|
17
|
+
- CPU: Apple M1 (8 logical cores)
|
|
18
|
+
- Memory: 8192.00 MiB
|
|
19
|
+
- Packages: purifai 3.0.0, striptags 3.2.0, html-to-text 10.0.0
|
|
20
|
+
- Corpus SHA-256: `fe131490fda5dfb290654d3ff7f6f126471362e6debe47e2f0aced0dbad7b4d5`
|
|
21
|
+
|
|
22
|
+
The throughput path uses already-materialized strings for every implementation. Each worker calibrates a batch to at least 100 ms, performs 10 warmup batches, then records 40 batches with `process.hrtime.bigint()`. Median and p95 are nearest-rank values per conversion. Each package/corpus pair runs in a fresh process.
|
|
23
|
+
|
|
24
|
+
The memory path is different by design: Purifai streaming receives lazily generated chunks of at most 16,384 code units, while one-shot tools receive the same logical document after materialization. Each mode runs alone under `node --expose-gc` in 7 fresh processes. The table gates the nearest-rank median of those process peak-RSS values and retains every raw sample in JSON. Purifai one-shot memory is reported separately and is not presented as streaming memory.
|
|
25
|
+
|
|
26
|
+
Artifact: 23,689 bytes for the complete minified ESM runtime at gzip level 9; 68,592 bytes for the npm tarball.
|
|
27
|
+
|
|
28
|
+
## Release gates
|
|
29
|
+
|
|
30
|
+
| Gate | Result | Evidence |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| reviewed readability fixtures | PASS | Purifai 8/8; striptags 0/8 |
|
|
33
|
+
| non-reader body removal | PASS | Purifai 5/5; striptags script calibration leak observed |
|
|
34
|
+
| hostile-tags hostile p95 | PASS | Purifai 2.651 ms; html-to-text 3.052 ms |
|
|
35
|
+
| hostile-comments hostile p95 | PASS | Purifai 0.987 ms; html-to-text 1.524 ms |
|
|
36
|
+
| hostile-entities hostile p95 | PASS | Purifai 2.362 ms; html-to-text 2.876 ms |
|
|
37
|
+
| hostile-raw hostile p95 | PASS | Purifai 2.022 ms; html-to-text 2.163 ms |
|
|
38
|
+
| readable-large streaming RSS | PASS | Purifai 66.80 MiB; html-to-text 82.47 MiB |
|
|
39
|
+
| hostile-tags streaming RSS | PASS | Purifai 61.09 MiB; html-to-text 62.59 MiB |
|
|
40
|
+
| hostile-comments streaming RSS | PASS | Purifai 58.34 MiB; html-to-text 63.56 MiB |
|
|
41
|
+
| hostile-entities streaming RSS | PASS | Purifai 60.05 MiB; html-to-text 66.64 MiB |
|
|
42
|
+
| hostile-raw streaming RSS | PASS | Purifai 60.47 MiB; html-to-text 62.88 MiB |
|
|
43
|
+
|
|
44
|
+
## Throughput
|
|
45
|
+
|
|
46
|
+
| Corpus | Package | Median ms | p95 ms | Conversions/sample |
|
|
47
|
+
| --- | --- | ---: | ---: | ---: |
|
|
48
|
+
| readable-small | purifai | 0.019 | 0.020 | 16384–16384 |
|
|
49
|
+
| readable-small | html-to-text | 0.185 | 0.188 | 2048–2048 |
|
|
50
|
+
| readable-small | striptags | 0.009 | 0.009 | 32768–32768 |
|
|
51
|
+
| readable-large | purifai | 8.736 | 8.886 | 32–32 |
|
|
52
|
+
| readable-large | html-to-text | 13.164 | 13.537 | 16–16 |
|
|
53
|
+
| readable-large | striptags | 4.166 | 4.249 | 64–64 |
|
|
54
|
+
| hostile-tags | purifai | 2.630 | 2.651 | 128–128 |
|
|
55
|
+
| hostile-tags | html-to-text | 2.999 | 3.052 | 64–64 |
|
|
56
|
+
| hostile-tags | striptags | 2.275 | 2.308 | 128–128 |
|
|
57
|
+
| hostile-comments | purifai | 0.965 | 0.987 | 256–256 |
|
|
58
|
+
| hostile-comments | html-to-text | 1.489 | 1.524 | 256–256 |
|
|
59
|
+
| hostile-comments | striptags | 2.269 | 2.329 | 128–128 |
|
|
60
|
+
| hostile-entities | purifai | 2.320 | 2.362 | 128–128 |
|
|
61
|
+
| hostile-entities | html-to-text | 2.824 | 2.876 | 128–128 |
|
|
62
|
+
| hostile-entities | striptags | 2.440 | 2.479 | 128–128 |
|
|
63
|
+
| hostile-raw | purifai | 1.996 | 2.022 | 128–128 |
|
|
64
|
+
| hostile-raw | html-to-text | 2.120 | 2.163 | 128–128 |
|
|
65
|
+
| hostile-raw | striptags | 1.722 | 1.743 | 128–128 |
|
|
66
|
+
|
|
67
|
+
## Peak memory
|
|
68
|
+
|
|
69
|
+
| Corpus | Mode | Max RSS MiB | Retained heap MiB |
|
|
70
|
+
| --- | --- | ---: | ---: |
|
|
71
|
+
| readable-large | purifai-stream | 66.80 | 1.04 |
|
|
72
|
+
| readable-large | purifai-one-shot | 67.55 | 1.86 |
|
|
73
|
+
| readable-large | html-to-text | 82.47 | 2.48 |
|
|
74
|
+
| hostile-tags | purifai-stream | 61.09 | 0.92 |
|
|
75
|
+
| hostile-tags | purifai-one-shot | 60.84 | 0.87 |
|
|
76
|
+
| hostile-tags | html-to-text | 62.59 | 2.21 |
|
|
77
|
+
| hostile-comments | purifai-stream | 58.34 | 0.90 |
|
|
78
|
+
| hostile-comments | purifai-one-shot | 58.81 | 0.99 |
|
|
79
|
+
| hostile-comments | html-to-text | 63.56 | 2.23 |
|
|
80
|
+
| hostile-entities | purifai-stream | 60.05 | 0.95 |
|
|
81
|
+
| hostile-entities | purifai-one-shot | 61.41 | 1.99 |
|
|
82
|
+
| hostile-entities | html-to-text | 66.64 | 2.68 |
|
|
83
|
+
| hostile-raw | purifai-stream | 60.47 | 0.94 |
|
|
84
|
+
| hostile-raw | purifai-one-shot | 61.00 | 0.91 |
|
|
85
|
+
| hostile-raw | html-to-text | 62.88 | 2.21 |
|
|
86
|
+
|
|
87
|
+
## Interpretation and limits
|
|
88
|
+
|
|
89
|
+
- `striptags` is a minimal tag remover. Its inclusion calibrates readability and dropped-body behavior; it is not the hostile-throughput gate.
|
|
90
|
+
- `html-to-text` offers a broader formatting feature set. Purifai intentionally does less, which is why hostile one-shot latency and streaming memory are compared directly while advanced table fidelity is not claimed.
|
|
91
|
+
- Purifai preserves simple rows and cells, but it does not attempt rowspan/colspan layout, visual CSS reconstruction, or browser-equivalent error recovery.
|
|
92
|
+
- Results are machine-, runtime-, and corpus-specific. The checked claim is the set of release gates above, not an uncategorized “fastest” claim.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Migrating to Purifai v3
|
|
2
|
+
|
|
3
|
+
Purifai v3 is a clean-break HTML-to-readable-text converter. There is no compatibility layer.
|
|
4
|
+
The v2 class, threat-scoring helpers, batch helpers, and context encoders are not
|
|
5
|
+
carried forward. The smaller API makes conversion, resource limits, and output
|
|
6
|
+
sink responsibilities explicit.
|
|
7
|
+
|
|
8
|
+
## Export and option mapping
|
|
9
|
+
|
|
10
|
+
| V2 surface | V3 decision |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `Purifai.sanitize / sanitize` | Purifai.sanitize / sanitize → toText |
|
|
13
|
+
| `escape` | escape → escapeHtmlText (HTML text context only) |
|
|
14
|
+
| `analyze` | analyze → removed; use convert for conversion metadata |
|
|
15
|
+
| `sanitizeBatch` | sanitizeBatch → removed; map toText explicitly |
|
|
16
|
+
| `isDangerous` | isDangerous → removed; Purifai does not classify intent |
|
|
17
|
+
| `escapeAttribute / escapeUrl` | escapeAttribute / escapeUrl → removed; use a context-specific library or platform API |
|
|
18
|
+
| `getVersion / getStats` | getVersion / getStats → removed; use package metadata and published benchmarks |
|
|
19
|
+
| `aggressiveMode / allowedProtocols` | aggressiveMode / allowedProtocols → removed; fixed policy |
|
|
20
|
+
| `maxLength` | maxLength → limits.input; overflow is explicit |
|
|
21
|
+
|
|
22
|
+
## One-shot conversion
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
// v2
|
|
26
|
+
const text = sanitize(html, { maxLength: 10_000 });
|
|
27
|
+
|
|
28
|
+
// v3: throws PurifaiLimitError when the input is too large
|
|
29
|
+
const text = toText(html, { limits: { input: 10_000 } });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If a bounded prefix is a valid product result, opt in through `convert`:
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
const result = convert(html, {
|
|
36
|
+
limits: { input: 10_000, output: 2_000 },
|
|
37
|
+
overflow: 'truncate',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (result.truncatedBy !== null) {
|
|
41
|
+
console.log(`Stopped at the ${result.truncatedBy} limit`);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`toText` and `createTextTransform` never truncate silently.
|
|
46
|
+
|
|
47
|
+
## Batch conversion
|
|
48
|
+
|
|
49
|
+
Batching is ordinary application control flow in v3, so scheduling and error
|
|
50
|
+
handling remain visible:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const texts = htmlItems.map((html) => toText(html));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Conversion metadata
|
|
57
|
+
|
|
58
|
+
`convert` reports mechanical conversion facts: completion, input consumed,
|
|
59
|
+
output produced, truncation, and dropped-container counts. It does not assign a
|
|
60
|
+
threat score or infer whether content or a user is malicious.
|
|
61
|
+
|
|
62
|
+
## Output contexts
|
|
63
|
+
|
|
64
|
+
V3 only provides `escapeHtmlText`, for placing plain text in an HTML text-node
|
|
65
|
+
context. Prefer assigning `textContent` directly. Attribute, URL, JavaScript,
|
|
66
|
+
CSS, and template contexts need policies and encoders designed for those exact
|
|
67
|
+
sinks.
|
|
68
|
+
|
|
69
|
+
## Fixed policy changes
|
|
70
|
+
|
|
71
|
+
V3 deliberately has no mutable protocol allow-list or aggressive mode. Display
|
|
72
|
+
URLs accept absolute HTTP(S) and `mailto`, and resolve relative URLs only when an
|
|
73
|
+
HTTP(S) `baseUrl` is supplied. A displayed URL is text, not authorization to use
|
|
74
|
+
it as an active destination.
|
|
75
|
+
|
|
76
|
+
Markup is never preserved. Source and non-reader bodies are removed; visible
|
|
77
|
+
reader structure is converted to text. Applications that need safe HTML should
|
|
78
|
+
remain on a markup-preserving sanitizer.
|
|
79
|
+
|
|
80
|
+
## Streaming
|
|
81
|
+
|
|
82
|
+
Node stream adapters were not carried forward. Use the Web Streams API available
|
|
83
|
+
in modern Node, Bun, Deno, Workers, and browsers:
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
const transform = createTextTransform();
|
|
87
|
+
const textStream = byteStream
|
|
88
|
+
.pipeThrough(new TextDecoderStream())
|
|
89
|
+
.pipeThrough(transform);
|
|
90
|
+
const report = await transform.result;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
When a stream limit fails, some output may already have reached the reader. Treat
|
|
94
|
+
that output as partial and discard it unless your application explicitly accepts
|
|
95
|
+
partial records.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "purifai",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Bounded streaming conversion from untrusted HTML to readable plain text, with zero dependencies and no DOM.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -14,46 +14,59 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"benchmark/results/v3.json",
|
|
18
|
+
"docs/benchmarks/v3.md",
|
|
19
|
+
"docs/migration-v3.md",
|
|
17
20
|
"README.md",
|
|
18
21
|
"LICENSE"
|
|
19
22
|
],
|
|
20
23
|
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"packageManager": "pnpm@11.4.0",
|
|
21
26
|
"scripts": {
|
|
22
|
-
"build": "tsup index.ts --format cjs,esm --clean && pnpm run build:dts",
|
|
27
|
+
"build": "tsup index.ts --format cjs,esm --minify --clean && pnpm run build:dts",
|
|
23
28
|
"build:dts": "tsc -p tsconfig.dts.json && node -e \"require('fs').copyFileSync('dist/index.d.ts','dist/index.d.cts')\"",
|
|
24
|
-
"test": "pnpm run
|
|
25
|
-
"test:
|
|
26
|
-
"test:
|
|
27
|
-
"test:
|
|
28
|
-
"test:
|
|
29
|
-
"test:
|
|
30
|
-
"test:
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
29
|
+
"test": "pnpm run test:core",
|
|
30
|
+
"test:unit": "pnpm run build && node --test test/v3/api.test.js test/v3/entities.test.js test/v3/entities-exhaustive.test.js test/v3/scanner.test.js test/v3/readability.test.js test/v3/special-elements.test.js test/v3/links-images.test.js test/v3/limits.test.js test/v3/stream.test.js",
|
|
31
|
+
"test:core": "pnpm run entities:check && pnpm run typecheck && pnpm run test:unit && pnpm run test:fuzz && pnpm run test:sinks && node test/package-smoke.mjs",
|
|
32
|
+
"test:fuzz": "pnpm run build && node --test test/v3/fuzz.test.js",
|
|
33
|
+
"test:sinks": "pnpm run build && node --test test/v3/sinks.test.js",
|
|
34
|
+
"test:package": "node test/package-smoke.mjs",
|
|
35
|
+
"test:runtimes": "pnpm run build && node scripts/test-runtimes.mjs",
|
|
36
|
+
"test:cloudflare": "pnpm run build && node scripts/test-cloudflare.mjs",
|
|
37
|
+
"test:browsers": "pnpm run build && node test/browser/run.mjs",
|
|
38
|
+
"bench": "pnpm run build && node benchmark/run.mjs",
|
|
39
|
+
"bench:check": "pnpm run build && node benchmark/run.mjs --check",
|
|
40
|
+
"test:size": "pnpm run build && node scripts/verify-size.mjs",
|
|
41
|
+
"test:scaling": "pnpm run build && node scripts/verify-scaling.mjs",
|
|
42
|
+
"test:docs": "pnpm run build && node test/docs.test.mjs",
|
|
43
|
+
"test:release": "node scripts/verify-release.mjs",
|
|
44
|
+
"prepublishOnly": "pnpm run test:release",
|
|
45
|
+
"dev": "tsup index.ts --format cjs,esm --minify --watch",
|
|
34
46
|
"typecheck": "tsc --noEmit",
|
|
35
|
-
"
|
|
47
|
+
"entities:update": "node scripts/update-entities.mjs && node scripts/generate-entities.mjs",
|
|
48
|
+
"entities:generate": "node scripts/generate-entities.mjs",
|
|
49
|
+
"entities:check": "node scripts/generate-entities.mjs --check"
|
|
36
50
|
},
|
|
37
51
|
"keywords": [
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
52
|
+
"html-to-text",
|
|
53
|
+
"streaming",
|
|
54
|
+
"plain-text",
|
|
41
55
|
"html",
|
|
42
|
-
"
|
|
56
|
+
"reader-text",
|
|
43
57
|
"polyglot",
|
|
44
58
|
"typescript",
|
|
45
59
|
"zero-dependencies",
|
|
46
60
|
"lightweight",
|
|
47
61
|
"fast",
|
|
48
62
|
"secure",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"xss-protection",
|
|
52
|
-
"sanitization",
|
|
63
|
+
"transform-stream",
|
|
64
|
+
"bounded-memory",
|
|
53
65
|
"frontend",
|
|
54
66
|
"backend",
|
|
55
67
|
"nodejs",
|
|
56
|
-
"browser"
|
|
68
|
+
"browser",
|
|
69
|
+
"edge-runtime"
|
|
57
70
|
],
|
|
58
71
|
"author": "Purifai Team <it@worksonmy.dev>",
|
|
59
72
|
"license": "MIT",
|
|
@@ -66,26 +79,17 @@
|
|
|
66
79
|
"url": "https://github.com/moji2002/purifai/issues"
|
|
67
80
|
},
|
|
68
81
|
"engines": {
|
|
69
|
-
"node": ">=
|
|
82
|
+
"node": ">=22.0.0"
|
|
70
83
|
},
|
|
71
84
|
"devDependencies": {
|
|
85
|
+
"@cloudflare/vitest-pool-workers": "^0.20.1",
|
|
72
86
|
"@types/node": "^26.0.0",
|
|
73
|
-
"
|
|
74
|
-
"cli-table3": "^0.6.5",
|
|
75
|
-
"entities": "^8.0.0",
|
|
76
|
-
"escape-html": "^1.0.3",
|
|
77
|
-
"he": "^1.2.0",
|
|
78
|
-
"html-entities": "^2.6.0",
|
|
79
|
-
"isomorphic-dompurify": "^3.19.0",
|
|
87
|
+
"html-to-text": "10.0.0",
|
|
80
88
|
"jsdom": "^29.1.1",
|
|
81
|
-
"playwright
|
|
82
|
-
"
|
|
83
|
-
"rehype-sanitize": "^6.0.0",
|
|
84
|
-
"sanitize-html": "^2.17.6",
|
|
85
|
-
"striptags": "^3.2.0",
|
|
89
|
+
"playwright": "^1.62.1",
|
|
90
|
+
"striptags": "3.2.0",
|
|
86
91
|
"tsup": "^8.5.1",
|
|
87
92
|
"typescript": "^7.0.2",
|
|
88
|
-
"
|
|
89
|
-
"xss": "^1.0.15"
|
|
93
|
+
"vitest": "^4.1.10"
|
|
90
94
|
}
|
|
91
95
|
}
|