text-sanctifier 1.3.7 โ 1.3.9
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/ADDITIONAL_TERMS.md +122 -122
- package/LICENSE +694 -694
- package/README.md +21 -16
- package/bin/text-sanctifier.js +31 -8
- package/dist/text-sanctifier.min.js +1 -1
- package/dist/types/context.d.ts +10 -0
- package/dist/types/hiddenText.d.ts +4 -1
- package/dist/types/sanctifyText.d.ts +1 -1
- package/dist/types/version.generated.d.ts +1 -1
- package/dist/types/virama.generated.d.ts +1 -0
- package/package.json +12 -6
- package/robots.txt +79 -79
- package/src/context.js +45 -5
- package/src/hiddenText.js +243 -233
- package/src/index.d.ts +22 -22
- package/src/index.js +29 -29
- package/src/sanctifyText.js +4 -4
- package/src/version.generated.js +1 -1
- package/src/virama.generated.js +5 -0
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
4
4
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
5
|
-
[](https://bundlejs.com/?q=text-sanctifier)
|
|
6
6
|
[](https://github.com/iWhatty/text-sanctifier/blob/main/LICENSE)
|
|
7
7
|
[](https://github.com/iWhatty/text-sanctifier)
|
|
8
8
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
9
9
|
|
|
10
|
-
Brutal text normalizer and invisible Unicode scrubber for modern web projects.
|
|
10
|
+
Brutal text normalizer and invisible Unicode scrubber for modern web projects. The prebuilt browser file stays under <!-- gen:budget:browser -->3 KiB<!-- /gen --> gzipped, a budget `npm run ci` enforces; with a bundler you pay only for what you import.
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
@@ -24,8 +24,8 @@ Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~
|
|
|
24
24
|
- Nukes control characters (if enabled)
|
|
25
25
|
- Smart normalization of typographic junk (quotes, dashes, bullets, full-width punctuation)
|
|
26
26
|
- Keyboard-only filtering (retain printable ASCII + full emoji sequences)
|
|
27
|
-
- Preserves ZWJ emoji clusters (
|
|
28
|
-
- Preserves VS16 emoji presentation variants (
|
|
27
|
+
- Preserves ZWJ emoji clusters (the family emoji, U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466)
|
|
28
|
+
- Preserves VS16 emoji presentation variants (U+270C U+FE0F, U+203C U+FE0F)
|
|
29
29
|
- Configurable via fine-grained flags or ready-made presets
|
|
30
30
|
- Includes strict, loose, and keyboard-only modes
|
|
31
31
|
- Deterministic RegExp usage (no global `lastIndex` state leaks)
|
|
@@ -121,7 +121,7 @@ Two options handle spaces and zero-width characters gently, and both run before
|
|
|
121
121
|
|
|
122
122
|
### Presets as standalone imports
|
|
123
123
|
|
|
124
|
-
Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about 1.1
|
|
124
|
+
Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about <!-- gen:size:sanctifyPasted -->1.1 KiB<!-- /gen --> gzipped instead of <!-- gen:size:summonSanctifier.pasted -->1.4 KiB<!-- /gen -->).
|
|
125
125
|
|
|
126
126
|
```js
|
|
127
127
|
import { sanctifyPasted } from 'text-sanctifier';
|
|
@@ -230,7 +230,7 @@ truncated: false
|
|
|
230
230
|
|
|
231
231
|
`codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
|
|
232
232
|
|
|
233
|
-
**Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about 80 ms and uses 0.2 MB. With `Infinity`, expect roughly 100โ200 bytes per finding: about 1.5 GB for 8 million. On untrusted input, keep a cap. Use `maxFindings: 0` for a counts-only report: it builds no finding objects, and `counts`, `total` and `truncated` stay exact.
|
|
233
|
+
**Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about 80 ms and uses 0.2 MB (`npm run bench` measures it on your machine). With `Infinity`, expect roughly 100โ200 bytes per finding: about 1.5 GB for 8 million. On untrusted input, keep a cap. Use `maxFindings: 0` for a counts-only report: it builds no finding objects, and `counts`, `total` and `truncated` stay exact.
|
|
234
234
|
|
|
235
235
|
### `getNewlineStats(text: string): NewlineStats`
|
|
236
236
|
|
|
@@ -242,7 +242,7 @@ import { getNewlineStats } from 'text-sanctifier';
|
|
|
242
242
|
getNewlineStats('a\r\nb\n'); // { crlf: 1, cr: 0, lf: 1, types: ['CRLF', 'LF'], mixed: true }
|
|
243
243
|
```
|
|
244
244
|
|
|
245
|
-
`zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as
|
|
245
|
+
`zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as U+1F468 U+200D U+1F469 U+200D U+1F467, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as U+270C U+FE0F and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16. The Mongolian free variation selectors (U+180BโU+180D, U+180F) are also `variation_selector`: they are legitimate in Mongolian text, which `purgeInvisibleChars` can change, so don't purge Mongolian text with it.
|
|
246
246
|
|
|
247
247
|
The categories cover exactly what `nukeControls` and `purgeInvisibleChars` strip. Every code point with the Unicode property Default_Ignorable_Code_Point (characters renderers must draw as nothing) is in a category, and a test checks this against the running Node's Unicode data.
|
|
248
248
|
|
|
@@ -255,7 +255,7 @@ import { findHiddenRuns, decodeTagText } from 'text-sanctifier/hidden';
|
|
|
255
255
|
|
|
256
256
|
const { runs, benign, counts, total, truncated } = findHiddenRuns(text, { maxRuns: 100, maxDecodedLength: 1000 });
|
|
257
257
|
// runs: [{ kind: 'tag', offset, end, line, column, count, decoded: 'Ignore the user', decodedTruncated: false }, ...]
|
|
258
|
-
// benign: [[offset, end], ...] England/Scotland/Wales flags, a lone U+FE0E/U+FE0F after a
|
|
258
|
+
// benign: [[offset, end], ...] England/Scotland/Wales flags, a lone U+FE0E/U+FE0F after an emoji or in a keycap
|
|
259
259
|
// counts: { tag, variation_selector, invisible_operator, reserved_tag_plane }, every run listed or not; total is their sum
|
|
260
260
|
```
|
|
261
261
|
|
|
@@ -285,8 +285,12 @@ Benign, and nothing else:
|
|
|
285
285
|
- one of U+E0100โU+E01EF directly after an ideograph
|
|
286
286
|
- U+180BโU+180D or U+180F directly after a Mongolian letter
|
|
287
287
|
- the tags of the England, Scotland and Wales flags
|
|
288
|
+
- U+200C or U+200D directly after a virama (Devanagari, Bengali, Tamil, Malayalam, Sinhala, Myanmar and the other scripts with one), as IDNA ContextJ (RFC 5892) allows
|
|
289
|
+
- U+200C between two Arabic-script letters, skipping marks, as in Persian. This approximates the RFC's joining-type rule: it also accepts a ZWNJ after a letter that does not join to the left, and none in other joining scripts such as Syriac
|
|
288
290
|
|
|
289
|
-
U+200B, U+2060, U+FEFF, bidi controls and all other tags are never benign
|
|
291
|
+
U+200B, U+2060, U+FEFF, bidi controls and all other tags are never benign, and neither is U+200D between Arabic letters. It needs Unicode property escapes (Node 10+, current browsers), is not in the browser file, and is about <!-- gen:size:text-sanctifier/context -->2.0 KiB<!-- /gen --> gzipped on its own.
|
|
292
|
+
|
|
293
|
+
`benignSupplementarySelectorCount` counts the benign ideographic selectors U+E0100โU+E01EF. With `findHiddenRuns` from `text-sanctifier/hidden`, `scan.supplementarySelectorCount - report.benignSupplementarySelectorCount` is the exact number of suspicious ones.
|
|
290
294
|
|
|
291
295
|
---
|
|
292
296
|
|
|
@@ -310,7 +314,7 @@ src/auth.js:40:3 U+202E bidi_control
|
|
|
310
314
|
2 findings in 2 of 31 files.
|
|
311
315
|
```
|
|
312
316
|
|
|
313
|
-
- `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
|
|
317
|
+
- `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped, notUtf8 }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
|
|
314
318
|
- `--format sarif`: a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for code-scanning viewers and editors. See [SARIF output](#sarif-output).
|
|
315
319
|
- `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
|
|
316
320
|
- `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
|
|
@@ -331,11 +335,11 @@ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
|
|
|
331
335
|
19 findings (2 hidden runs) in 2 of 6 AI configuration files.
|
|
332
336
|
```
|
|
333
337
|
|
|
334
|
-
- With no paths it searches the current directory; directories you name are searched the same way, and files you name are checked whatever they are called. In a git work tree, files `.gitignore` excludes are skipped.
|
|
338
|
+
- With no paths it searches the current directory, even when input is piped (so a CI job without stdin still checks the repository); pass `-` to check stdin; directories you name are searched the same way, and files you name are checked whatever they are called. In a git work tree, files `.gitignore` excludes are skipped.
|
|
335
339
|
- Files it looks for, matched case-insensitively: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `AGENT.md`, `GEMINI.md` and `SKILL.md` at any depth; and at the top of the directory, `.claude/**`, `.cursorrules`, `.cursor/rules/**`, `.cursor/mcp.json`, `.windsurfrules`, `.windsurf/rules/**`, `.clinerules` (file or directory), `.roorules`, `.roo/rules/**`, `.github/copilot-instructions.md`, `.github/{instructions,prompts,chatmodes,agents}/**`, `.mcp.json`, `mcp.json` and `.vscode/mcp.json`.
|
|
336
340
|
- Consecutive tag characters, variation selectors or invisible operators are reported once, as a run, at the first character. Tag runs are decoded: U+E0020โU+E007E become the ASCII they shadow, and other tags are shown as `<U+XXXX>`.
|
|
337
341
|
- Every other invisible or control character is reported as in a plain `check`.
|
|
338
|
-
- Two legitimate sequences are left out: the flags of England, Scotland and Wales (U+1F3F4, tag letters, U+E007F), and a single U+FE0E or U+FE0F after
|
|
342
|
+
- Two legitimate sequences are left out: the flags of England, Scotland and Wales (U+1F3F4, tag letters, U+E007F), and a single U+FE0E or U+FE0F (text or emoji presentation) after an emoji (`Extended_Pictographic`) or in a keycap such as 1, U+FE0F, U+20E3. After a letter it is reported: one selector per letter can carry a bit of hidden data. A plain `check` still reports both.
|
|
339
343
|
- `--json` adds `hiddenRuns` (`{ kind, offset, line, column, codePoint, count, decoded?, decodedTruncated? }`, at most `--max-findings` per file) and `hiddenRunCount` to each file, and `hiddenRunCount` to the summary. `kind` is `tag`, `variation_selector`, `invisible_operator` or `reserved_tag_plane`; `decoded` is cut at 1000 characters. `total` and `counts` leave out the legitimate sequences.
|
|
340
344
|
|
|
341
345
|
### SARIF output
|
|
@@ -356,7 +360,7 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
|
|
|
356
360
|
# .pre-commit-config.yaml
|
|
357
361
|
repos:
|
|
358
362
|
- repo: https://github.com/iWhatty/text-sanctifier
|
|
359
|
-
rev: v1.3.
|
|
363
|
+
rev: v1.3.9 # a release tag
|
|
360
364
|
hooks:
|
|
361
365
|
- id: text-sanctifier # every staged text file
|
|
362
366
|
- id: text-sanctifier-ai-config # agent instruction files, with --ai-config
|
|
@@ -394,8 +398,9 @@ git diff | npx text-sanctifier reveal
|
|
|
394
398
|
|
|
395
399
|
- Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
|
|
396
400
|
- Inside a git work tree, directories and globs list files with `git ls-files`, so anything `.gitignore` excludes is skipped. Outside git, `.git` and `node_modules` are skipped. A file named explicitly is always read.
|
|
397
|
-
- Binary files (a NUL byte in the first 8000 bytes)
|
|
398
|
-
-
|
|
401
|
+
- Binary files (a NUL byte in the first 8000 bytes) are skipped and listed under `skipped` in JSON. Stdin and AI configuration files are never treated as binary: a NUL in them is reported as `nul`.
|
|
402
|
+
- Files that are not valid UTF-8 are still checked, with each invalid byte read as U+FFFD, so one bad byte cannot hide the rest of the file. `check` notes them on stderr and lists them under `notUtf8` in JSON; `fix` never writes them and lists them under `skipped`.
|
|
403
|
+
- `-`, or no paths with piped input, reads stdin (with `--ai-config`, only `-` does). `fix` writes the cleaned text to stdout and the report to stderr.
|
|
399
404
|
|
|
400
405
|
### Exit codes
|
|
401
406
|
|
|
@@ -422,7 +427,7 @@ Requires a modern JavaScript runtime with ES2020+ support:
|
|
|
422
427
|
- **ESM only:** no CommonJS build; `require('text-sanctifier')` works only on Node versions with `require(esm)` support (20.19+, 22.12+)
|
|
423
428
|
- **Types:** TypeScript declarations ship in `dist/types`
|
|
424
429
|
- **Browser build (`dist/`):** one minified ESM file with every export, for pages with no build step (`<script type="module">` from a CDN). It is imported as `text-sanctifier/browser`. With a bundler, import `text-sanctifier` instead: the source tree-shakes, and the prebuilt file does not
|
|
425
|
-
- **Tree-shake friendly:** ships `sideEffects: false`; `npm run ci` checks the bundled size of each export on its own (for example `inspectTextDetailed` is
|
|
430
|
+
- **Tree-shake friendly:** ships `sideEffects: false`; `npm run ci` checks the bundled size of each export on its own (for example `inspectTextDetailed` is about <!-- gen:size:inspectTextDetailed -->0.9 KiB<!-- /gen --> gzipped)
|
|
426
431
|
- **Zero transpilation:** no built-in polyfills or runtime overhead
|
|
427
432
|
- **Bundler ready:** works with Vite, Rollup, Webpack, Parcel, esbuild
|
|
428
433
|
|
package/bin/text-sanctifier.js
CHANGED
|
@@ -378,18 +378,22 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
|
|
|
378
378
|
// --- Reading ---
|
|
379
379
|
|
|
380
380
|
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
381
|
+
const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
381
382
|
|
|
382
383
|
/**
|
|
383
|
-
* Decodes UTF-8, keeping a leading BOM.
|
|
384
|
+
* Decodes UTF-8, keeping a leading BOM. With `sniff`, data with a NUL in its first
|
|
385
|
+
* BINARY_SNIFF_BYTES is binary and not decoded. Bytes that are not UTF-8 become U+FFFD
|
|
386
|
+
* and set `lossy`, so one bad byte cannot hide the rest of a file from `check`.
|
|
384
387
|
* @param {Uint8Array} bytes
|
|
385
|
-
* @
|
|
388
|
+
* @param {boolean} sniff
|
|
389
|
+
* @returns {{ text: string|null, reason?: string, lossy?: boolean }}
|
|
386
390
|
*/
|
|
387
|
-
function decode(bytes) {
|
|
388
|
-
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
|
|
391
|
+
function decode(bytes, sniff) {
|
|
392
|
+
if (sniff && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
|
|
389
393
|
try {
|
|
390
394
|
return { text: utf8.decode(bytes) };
|
|
391
395
|
} catch {
|
|
392
|
-
return { text:
|
|
396
|
+
return { text: utf8Lenient.decode(bytes), lossy: true };
|
|
393
397
|
}
|
|
394
398
|
}
|
|
395
399
|
|
|
@@ -612,6 +616,8 @@ async function main(argv) {
|
|
|
612
616
|
|
|
613
617
|
const cwd = process.cwd();
|
|
614
618
|
let paths = opts.paths;
|
|
619
|
+
// --ai-config with no paths searches the current directory even when stdin is not a
|
|
620
|
+
// terminal, so a CI job with no stdin still scans the repository; `-` reads stdin.
|
|
615
621
|
if (paths.length === 0 && opts.aiConfig) {
|
|
616
622
|
paths = ['.'];
|
|
617
623
|
} else if (paths.length === 0) {
|
|
@@ -627,6 +633,8 @@ async function main(argv) {
|
|
|
627
633
|
let errors = pathErrors;
|
|
628
634
|
/** @type {Array<{ path: string, reason: string }>} */
|
|
629
635
|
const skipped = [];
|
|
636
|
+
/** @type {string[]} */
|
|
637
|
+
const notUtf8 = [];
|
|
630
638
|
|
|
631
639
|
/**
|
|
632
640
|
* Reads every input in order, calling `visit(label, text, input)` for each decodable one.
|
|
@@ -643,12 +651,24 @@ async function main(argv) {
|
|
|
643
651
|
errors++;
|
|
644
652
|
continue;
|
|
645
653
|
}
|
|
646
|
-
|
|
654
|
+
// Files that look binary are skipped. Stdin and AI configuration files are text
|
|
655
|
+
// by intent, so a NUL in one is a finding, not a reason to skip it.
|
|
656
|
+
const { text, reason, lossy } = decode(bytes, input.path !== STDIN && !opts.aiConfig);
|
|
647
657
|
if (text === null) {
|
|
648
658
|
skipped.push({ path: label, reason });
|
|
649
659
|
if (input.explicit) warn(`${label}: skipped (${reason})`);
|
|
650
660
|
continue;
|
|
651
661
|
}
|
|
662
|
+
if (lossy) {
|
|
663
|
+
// fix never rewrites bytes it could not decode
|
|
664
|
+
if (opts.command === 'fix') {
|
|
665
|
+
skipped.push({ path: label, reason: 'not UTF-8' });
|
|
666
|
+
warn(`${label}: skipped (not UTF-8)`);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
notUtf8.push(label);
|
|
670
|
+
warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
|
|
671
|
+
}
|
|
652
672
|
visit(label, text, input);
|
|
653
673
|
}
|
|
654
674
|
};
|
|
@@ -691,14 +711,17 @@ async function main(argv) {
|
|
|
691
711
|
|
|
692
712
|
const kind = opts.aiConfig ? 'AI configuration file' : 'file';
|
|
693
713
|
if (opts.format === 'json') {
|
|
694
|
-
const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped };
|
|
714
|
+
const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped, notUtf8 };
|
|
695
715
|
if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
|
|
696
716
|
out(JSON.stringify(summary, null, 2));
|
|
697
717
|
} else if (opts.format === 'sarif') {
|
|
698
718
|
out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
|
|
699
719
|
} else {
|
|
700
720
|
for (const line of lines) out(line);
|
|
701
|
-
if (opts.aiConfig && scanned === 0 && skipped.length === 0)
|
|
721
|
+
if (opts.aiConfig && scanned === 0 && skipped.length === 0) {
|
|
722
|
+
out('No AI configuration files found.');
|
|
723
|
+
if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
|
|
724
|
+
}
|
|
702
725
|
else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
|
|
703
726
|
else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}.`);
|
|
704
727
|
}
|
|
@@ -3,4 +3,4 @@ var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u2
|
|
|
3
3
|
`){return e.replace(Q,r)}var re=/[ \t]*(\n+)[ \t]*/g;function ne(e){return e.replace(re,"$1")}var te=/\n{2,}/g,ie=/\n{3,}/g;function oe(e,r){return r?e.replace(ie,`
|
|
4
4
|
|
|
5
5
|
`):e.replace(te,`
|
|
6
|
-
`)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.
|
|
6
|
+
`)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.9";export{fe as VERSION,b as getNewlineStats,K as getRecommendedSanctifierOptions,j as inspectText,C as inspectTextDetailed,ae as revealInvisible,T as sanctifyKeyboardOnly,N as sanctifyKeyboardOnlyEmoji,w as sanctifyLoose,L as sanctifyPasted,R as sanctifyStrict,W as summonSanctifier};
|
package/dist/types/context.d.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* the ones that belong to legitimate sequences as benign:
|
|
4
4
|
* - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
|
|
5
5
|
* VS16 after the first: an emoji ZWJ sequence
|
|
6
|
+
* - U+200C or U+200D right after a virama (Devanagari, Bengali, Malayalam, Sinhala and
|
|
7
|
+
* the other scripts with a combining class 9 mark), as IDNA ContextJ allows
|
|
8
|
+
* - U+200C between two Arabic-script letters, nonspacing marks skipped (Persian and
|
|
9
|
+
* others); an approximation of IDNA's joining-type rule
|
|
6
10
|
* - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
|
|
7
11
|
* - one of U+E0100โU+E01EF directly after an ideograph: an ideographic variation sequence
|
|
8
12
|
* - U+180BโU+180D or U+180F directly after a Mongolian letter
|
|
@@ -53,4 +57,10 @@ export type ContextReport = {
|
|
|
53
57
|
* - Sum of `benignCounts`.
|
|
54
58
|
*/
|
|
55
59
|
benignTotal: number;
|
|
60
|
+
/**
|
|
61
|
+
* - Benign U+E0100โU+E01EF (VS17โVS256),
|
|
62
|
+
* part of `benignCounts.variation_selector`. `supplementarySelectorCount` from findHiddenRuns
|
|
63
|
+
* minus this is the number of suspicious ones.
|
|
64
|
+
*/
|
|
65
|
+
benignSupplementarySelectorCount: number;
|
|
56
66
|
};
|
|
@@ -13,7 +13,10 @@ export function decodeTagText(text: string): string;
|
|
|
13
13
|
* legitimate and listed under `benign` instead of `runs`:
|
|
14
14
|
* - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
|
|
15
15
|
* tags for "gbeng", "gbsct" or "gbwls" and U+E007F
|
|
16
|
-
* - a single U+FE0E or U+FE0F (text or emoji presentation) after
|
|
16
|
+
* - a single U+FE0E or U+FE0F (text or emoji presentation) after an
|
|
17
|
+
* Extended_Pictographic character, or between #, * or a digit and U+20E3 (a keycap),
|
|
18
|
+
* the same rule as text-sanctifier/context. After a letter it is a run: one selector
|
|
19
|
+
* per letter can carry a bit each.
|
|
17
20
|
*
|
|
18
21
|
* `line` and `column` are counted as in inspectTextDetailed. Every run and benign
|
|
19
22
|
* span is counted; only the first `maxRuns` of each are listed. With the caps set,
|
|
@@ -52,7 +52,7 @@ export function sanctifyPasted(text: string): string;
|
|
|
52
52
|
* Brutal text normalizer and invisible trash scrubber,
|
|
53
53
|
* configurable to kill whatever ghosts you want dead.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
55
|
+
* Note: This is plain-text normalization/filtering โ not an HTML/XSS sanitizer.
|
|
56
56
|
*
|
|
57
57
|
* @param {string} text
|
|
58
58
|
* @param {boolean} [purgeInvisibleChars=false] - Remove ZWSP, NBSP, bidi, tags, variation selectors, etc.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The installed version of text-sanctifier, as in its package.json. */
|
|
2
|
-
export const VERSION: "1.3.
|
|
2
|
+
export const VERSION: "1.3.9";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VIRAMA_AT: RegExp;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "text-sanctifier",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
|
|
6
6
|
"engines": {
|
|
@@ -43,15 +43,21 @@
|
|
|
43
43
|
"types": "./dist/types/index.d.ts",
|
|
44
44
|
"sideEffects": false,
|
|
45
45
|
"scripts": {
|
|
46
|
-
"gen": "node scripts/gen-categories.js && node scripts/gen-version.js",
|
|
47
|
-
"gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check",
|
|
46
|
+
"gen": "node scripts/gen-categories.js && node scripts/gen-version.js && node scripts/gen-virama.js && node scripts/gen-docs.js",
|
|
47
|
+
"gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check && node scripts/gen-virama.js --check && node scripts/gen-docs.js --check",
|
|
48
48
|
"build": "node scripts/build.js",
|
|
49
49
|
"build:types": "tsc -p tsconfig.types.json",
|
|
50
50
|
"test:types": "tsc -p tests/types/tsconfig.json",
|
|
51
|
-
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js",
|
|
51
|
+
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js && node tests/release.test.js",
|
|
52
52
|
"test-min": "node tests/sanctifyText.test.min.js",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
53
|
+
"check:self": "node bin/text-sanctifier.js check .",
|
|
54
|
+
"check:names": "node scripts/check-names.js",
|
|
55
|
+
"ci": "npm run gen:check && npm run check:names && npm run check:self && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
|
|
56
|
+
"size": "node scripts/check-size.js",
|
|
57
|
+
"bench": "node --expose-gc scripts/bench.js",
|
|
58
|
+
"release:prep": "node scripts/release.js prep",
|
|
59
|
+
"release:verify": "node scripts/release.js verify",
|
|
60
|
+
"hooks:install": "git config core.hooksPath scripts/hooks"
|
|
55
61
|
},
|
|
56
62
|
"keywords": [
|
|
57
63
|
"text",
|
package/robots.txt
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
# WATT3D โ AI scraping policy
|
|
2
|
-
#
|
|
3
|
-
# This file enumerates known AI / LLM training and inference crawlers
|
|
4
|
-
# that the WATT3D AI Training Rider does not consent to. See
|
|
5
|
-
# ./ADDITIONAL_TERMS.md for the legal scope and definitions.
|
|
6
|
-
#
|
|
7
|
-
# Honoring robots.txt is voluntary. The rider applies whether or not
|
|
8
|
-
# a bot respects this file. The enumeration is non-exhaustive โ bots
|
|
9
|
-
# not listed here are still bound by the rider if they perform
|
|
10
|
-
# Training Use as defined in ADDITIONAL_TERMS.md ยง1.
|
|
11
|
-
|
|
12
|
-
User-agent: GPTBot
|
|
13
|
-
Disallow: /
|
|
14
|
-
|
|
15
|
-
User-agent: ChatGPT-User
|
|
16
|
-
Disallow: /
|
|
17
|
-
|
|
18
|
-
User-agent: OAI-SearchBot
|
|
19
|
-
Disallow: /
|
|
20
|
-
|
|
21
|
-
User-agent: anthropic-ai
|
|
22
|
-
Disallow: /
|
|
23
|
-
|
|
24
|
-
User-agent: ClaudeBot
|
|
25
|
-
Disallow: /
|
|
26
|
-
|
|
27
|
-
User-agent: Claude-Web
|
|
28
|
-
Disallow: /
|
|
29
|
-
|
|
30
|
-
User-agent: Claude-SearchBot
|
|
31
|
-
Disallow: /
|
|
32
|
-
|
|
33
|
-
User-agent: Google-Extended
|
|
34
|
-
Disallow: /
|
|
35
|
-
|
|
36
|
-
User-agent: Applebot-Extended
|
|
37
|
-
Disallow: /
|
|
38
|
-
|
|
39
|
-
User-agent: Meta-ExternalAgent
|
|
40
|
-
Disallow: /
|
|
41
|
-
|
|
42
|
-
User-agent: Meta-ExternalFetcher
|
|
43
|
-
Disallow: /
|
|
44
|
-
|
|
45
|
-
User-agent: FacebookBot
|
|
46
|
-
Disallow: /
|
|
47
|
-
|
|
48
|
-
User-agent: CCBot
|
|
49
|
-
Disallow: /
|
|
50
|
-
|
|
51
|
-
User-agent: PerplexityBot
|
|
52
|
-
Disallow: /
|
|
53
|
-
|
|
54
|
-
User-agent: Perplexity-User
|
|
55
|
-
Disallow: /
|
|
56
|
-
|
|
57
|
-
User-agent: cohere-ai
|
|
58
|
-
Disallow: /
|
|
59
|
-
|
|
60
|
-
User-agent: Bytespider
|
|
61
|
-
Disallow: /
|
|
62
|
-
|
|
63
|
-
User-agent: Amazonbot
|
|
64
|
-
Disallow: /
|
|
65
|
-
|
|
66
|
-
User-agent: Omgilibot
|
|
67
|
-
Disallow: /
|
|
68
|
-
|
|
69
|
-
User-agent: AI2Bot
|
|
70
|
-
Disallow: /
|
|
71
|
-
|
|
72
|
-
User-agent: Diffbot
|
|
73
|
-
Disallow: /
|
|
74
|
-
|
|
75
|
-
# Everyone else: full access. Search engines and human-driven crawlers
|
|
76
|
-
# remain welcome.
|
|
77
|
-
|
|
78
|
-
User-agent: *
|
|
79
|
-
Allow: /
|
|
1
|
+
# WATT3D โ AI scraping policy
|
|
2
|
+
#
|
|
3
|
+
# This file enumerates known AI / LLM training and inference crawlers
|
|
4
|
+
# that the WATT3D AI Training Rider does not consent to. See
|
|
5
|
+
# ./ADDITIONAL_TERMS.md for the legal scope and definitions.
|
|
6
|
+
#
|
|
7
|
+
# Honoring robots.txt is voluntary. The rider applies whether or not
|
|
8
|
+
# a bot respects this file. The enumeration is non-exhaustive โ bots
|
|
9
|
+
# not listed here are still bound by the rider if they perform
|
|
10
|
+
# Training Use as defined in ADDITIONAL_TERMS.md ยง1.
|
|
11
|
+
|
|
12
|
+
User-agent: GPTBot
|
|
13
|
+
Disallow: /
|
|
14
|
+
|
|
15
|
+
User-agent: ChatGPT-User
|
|
16
|
+
Disallow: /
|
|
17
|
+
|
|
18
|
+
User-agent: OAI-SearchBot
|
|
19
|
+
Disallow: /
|
|
20
|
+
|
|
21
|
+
User-agent: anthropic-ai
|
|
22
|
+
Disallow: /
|
|
23
|
+
|
|
24
|
+
User-agent: ClaudeBot
|
|
25
|
+
Disallow: /
|
|
26
|
+
|
|
27
|
+
User-agent: Claude-Web
|
|
28
|
+
Disallow: /
|
|
29
|
+
|
|
30
|
+
User-agent: Claude-SearchBot
|
|
31
|
+
Disallow: /
|
|
32
|
+
|
|
33
|
+
User-agent: Google-Extended
|
|
34
|
+
Disallow: /
|
|
35
|
+
|
|
36
|
+
User-agent: Applebot-Extended
|
|
37
|
+
Disallow: /
|
|
38
|
+
|
|
39
|
+
User-agent: Meta-ExternalAgent
|
|
40
|
+
Disallow: /
|
|
41
|
+
|
|
42
|
+
User-agent: Meta-ExternalFetcher
|
|
43
|
+
Disallow: /
|
|
44
|
+
|
|
45
|
+
User-agent: FacebookBot
|
|
46
|
+
Disallow: /
|
|
47
|
+
|
|
48
|
+
User-agent: CCBot
|
|
49
|
+
Disallow: /
|
|
50
|
+
|
|
51
|
+
User-agent: PerplexityBot
|
|
52
|
+
Disallow: /
|
|
53
|
+
|
|
54
|
+
User-agent: Perplexity-User
|
|
55
|
+
Disallow: /
|
|
56
|
+
|
|
57
|
+
User-agent: cohere-ai
|
|
58
|
+
Disallow: /
|
|
59
|
+
|
|
60
|
+
User-agent: Bytespider
|
|
61
|
+
Disallow: /
|
|
62
|
+
|
|
63
|
+
User-agent: Amazonbot
|
|
64
|
+
Disallow: /
|
|
65
|
+
|
|
66
|
+
User-agent: Omgilibot
|
|
67
|
+
Disallow: /
|
|
68
|
+
|
|
69
|
+
User-agent: AI2Bot
|
|
70
|
+
Disallow: /
|
|
71
|
+
|
|
72
|
+
User-agent: Diffbot
|
|
73
|
+
Disallow: /
|
|
74
|
+
|
|
75
|
+
# Everyone else: full access. Search engines and human-driven crawlers
|
|
76
|
+
# remain welcome.
|
|
77
|
+
|
|
78
|
+
User-agent: *
|
|
79
|
+
Allow: /
|
package/src/context.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// src/context.js
|
|
2
2
|
//
|
|
3
3
|
// inspectTextInContext: inspectTextDetailed plus which findings belong to legitimate
|
|
4
|
-
// sequences (emoji joiners and selectors,
|
|
5
|
-
// selectors, subdivision flags), counted
|
|
4
|
+
// sequences (emoji joiners and selectors, joiners in Indic and Arabic-script text,
|
|
5
|
+
// ideographic variation sequences, Mongolian selectors, subdivision flags), counted
|
|
6
|
+
// exactly per category. The categories are
|
|
6
7
|
// unchanged; see "Proposal: context-aware findings" in DESIGN.md.
|
|
7
8
|
//
|
|
8
9
|
// Exported as `text-sanctifier/context`. It needs Unicode property escapes in regexes,
|
|
9
10
|
// so it lives apart from the core and the browser file and has its own size budget.
|
|
10
11
|
|
|
11
12
|
import { inspectTextDetailed } from './inspectText.js';
|
|
13
|
+
import { VIRAMA_AT } from './virama.generated.js';
|
|
12
14
|
|
|
13
15
|
/** @typedef {import('./inspectText.js').TrashFinding} TrashFinding */
|
|
14
16
|
/** @typedef {import('./inspectText.js').TrashCounts} TrashCounts */
|
|
@@ -28,11 +30,16 @@ import { inspectTextDetailed } from './inspectText.js';
|
|
|
28
30
|
* @property {TrashCounts} benignCounts - Benign occurrences per category, listed or not.
|
|
29
31
|
* `counts[c] - benignCounts[c]` is the number of suspicious characters in category `c`.
|
|
30
32
|
* @property {number} benignTotal - Sum of `benignCounts`.
|
|
33
|
+
* @property {number} benignSupplementarySelectorCount - Benign U+E0100โU+E01EF (VS17โVS256),
|
|
34
|
+
* part of `benignCounts.variation_selector`. `supplementarySelectorCount` from findHiddenRuns
|
|
35
|
+
* minus this is the number of suspicious ones.
|
|
31
36
|
*/
|
|
32
37
|
|
|
33
38
|
// Tested at an index of the text itself, so nothing is allocated per character.
|
|
34
39
|
const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
|
|
35
40
|
const IDEOGRAPHIC_AT = /\p{Ideographic}/yu;
|
|
41
|
+
const ARABIC_LETTER_AT = /(?=\p{Script=Arabic})\p{L}/yu;
|
|
42
|
+
const MARK_AT = /\p{Mn}/yu;
|
|
36
43
|
|
|
37
44
|
// RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
|
|
38
45
|
const SUBDIVISION_FLAGS = ['gbeng', 'gbsct', 'gbwls'];
|
|
@@ -80,6 +87,29 @@ function isEmojiJoiner(text, i) {
|
|
|
80
87
|
return p >= 0 && matchesAt(PICTOGRAPHIC_AT, text, p) && matchesAt(PICTOGRAPHIC_AT, text, i + 1);
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* IDNA ContextJ (RFC 5892, appendix A.1 and A.2): a U+200C or U+200D is benign right
|
|
92
|
+
* after a virama, and a U+200C also between two Arabic-script letters, skipping
|
|
93
|
+
* nonspacing marks on either side. The second rule approximates the RFC's joining types
|
|
94
|
+
* (L or D before, R or D after), which JavaScript regexes can't express: it also allows
|
|
95
|
+
* a ZWNJ after a right-joining or non-joining Arabic letter, and none in other joining
|
|
96
|
+
* scripts such as Syriac or N'Ko.
|
|
97
|
+
* @param {string} text
|
|
98
|
+
* @param {number} i - index of the U+200C or U+200D
|
|
99
|
+
* @param {boolean} zwnj - true for U+200C
|
|
100
|
+
*/
|
|
101
|
+
function isScriptJoiner(text, i, zwnj) {
|
|
102
|
+
let p = previousStart(text, i);
|
|
103
|
+
if (p < 0) return false;
|
|
104
|
+
if (matchesAt(VIRAMA_AT, text, p)) return true;
|
|
105
|
+
if (!zwnj) return false;
|
|
106
|
+
while (p >= 0 && matchesAt(MARK_AT, text, p)) p = previousStart(text, p);
|
|
107
|
+
if (p < 0 || !matchesAt(ARABIC_LETTER_AT, text, p)) return false;
|
|
108
|
+
let q = i + 1;
|
|
109
|
+
while (matchesAt(MARK_AT, text, q)) q = MARK_AT.lastIndex;
|
|
110
|
+
return matchesAt(ARABIC_LETTER_AT, text, q);
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
/**
|
|
84
114
|
* Length in UTF-16 units of the subdivision-flag tags starting at `i` (just after a
|
|
85
115
|
* U+1F3F4), or 0 when they are not exactly one of the RGI flags, as findHiddenRuns decides.
|
|
@@ -106,6 +136,10 @@ function flagTagsAt(text, i) {
|
|
|
106
136
|
* the ones that belong to legitimate sequences as benign:
|
|
107
137
|
* - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
|
|
108
138
|
* VS16 after the first: an emoji ZWJ sequence
|
|
139
|
+
* - U+200C or U+200D right after a virama (Devanagari, Bengali, Malayalam, Sinhala and
|
|
140
|
+
* the other scripts with a combining class 9 mark), as IDNA ContextJ allows
|
|
141
|
+
* - U+200C between two Arabic-script letters, nonspacing marks skipped (Persian and
|
|
142
|
+
* others); an approximation of IDNA's joining-type rule
|
|
109
143
|
* - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
|
|
110
144
|
* - one of U+E0100โU+E01EF directly after an ideograph: an ideographic variation sequence
|
|
111
145
|
* - U+180BโU+180D or U+180F directly after a Mongolian letter
|
|
@@ -132,6 +166,7 @@ export function inspectTextInContext(text, options = {}) {
|
|
|
132
166
|
const benignCounts = /** @type {TrashCounts} */ ({});
|
|
133
167
|
for (const key of Object.keys(report.counts)) benignCounts[/** @type {keyof TrashCounts} */ (key)] = 0;
|
|
134
168
|
let benignTotal = 0;
|
|
169
|
+
let benignSupplementarySelectorCount = 0;
|
|
135
170
|
let next = 0; // first listed finding not yet passed
|
|
136
171
|
|
|
137
172
|
/**
|
|
@@ -150,7 +185,9 @@ export function inspectTextInContext(text, options = {}) {
|
|
|
150
185
|
if (c < 0x180B) continue; // the common case: nothing below can be benign
|
|
151
186
|
|
|
152
187
|
if (c === 0x200D) {
|
|
153
|
-
if (isEmojiJoiner(text, i)) markBenign(i, 'zero_width');
|
|
188
|
+
if (isEmojiJoiner(text, i) || isScriptJoiner(text, i, false)) markBenign(i, 'zero_width');
|
|
189
|
+
} else if (c === 0x200C) {
|
|
190
|
+
if (isScriptJoiner(text, i, true)) markBenign(i, 'zero_width');
|
|
154
191
|
} else if (c === 0xFE0E || c === 0xFE0F) {
|
|
155
192
|
const p = previousStart(text, i);
|
|
156
193
|
const before = p < 0 ? -1 : text.charCodeAt(p);
|
|
@@ -167,11 +204,14 @@ export function inspectTextInContext(text, options = {}) {
|
|
|
167
204
|
const low = text.charCodeAt(i + 1);
|
|
168
205
|
if (low >= 0xDD00 && low <= 0xDDEF) { // U+E0100-U+E01EF
|
|
169
206
|
const p = previousStart(text, i);
|
|
170
|
-
if (p >= 0 && matchesAt(IDEOGRAPHIC_AT, text, p))
|
|
207
|
+
if (p >= 0 && matchesAt(IDEOGRAPHIC_AT, text, p)) {
|
|
208
|
+
markBenign(i, 'variation_selector');
|
|
209
|
+
benignSupplementarySelectorCount++;
|
|
210
|
+
}
|
|
171
211
|
}
|
|
172
212
|
if (low >= 0xDC00 && low <= 0xDFFF) i++;
|
|
173
213
|
}
|
|
174
214
|
}
|
|
175
215
|
|
|
176
|
-
return { findings, counts: report.counts, total: report.total, truncated: report.truncated, benignCounts, benignTotal };
|
|
216
|
+
return { findings, counts: report.counts, total: report.total, truncated: report.truncated, benignCounts, benignTotal, benignSupplementarySelectorCount };
|
|
177
217
|
}
|