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/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  [![npm](https://badgen.net/npm/v/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
4
4
  [![downloads](https://badgen.net/npm/dm/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
5
- [![bundle size](https://badgen.net/static/gzip/under%203%20kB/green)](https://bundlejs.com/?q=text-sanctifier)
5
+ [![bundle size](https://badgen.net/static/gzip/under%203%20KiB/green)](https://bundlejs.com/?q=text-sanctifier)
6
6
  [![license](https://badgen.net/static/license/AGPL-3.0%20%2B%20terms/blue)](https://github.com/iWhatty/text-sanctifier/blob/main/LICENSE)
7
7
  [![stars](https://badgen.net/github/stars/iWhatty/text-sanctifier)](https://github.com/iWhatty/text-sanctifier)
8
8
  [![types](https://badgen.net/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
9
 
10
- Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.87 KB gzipped.
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 KB gzipped instead of 1.45 KB).
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 ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง, 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 โœŒ๏ธ 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.
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 character
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. Joiners in scripts that need them (ZWNJ in Persian, ZWJ in Indic scripts) are not benign yet. It needs Unicode property escapes (Node 10+, current browsers), is not in the browser file, and is about 1.7 KB gzipped on its own.
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 another character (text or emoji presentation). A plain `check` still reports both.
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.7 # a release tag
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) and files that are not valid UTF-8 are skipped, and `fix` never writes them. In JSON they appear under `skipped`.
398
- - `-`, or no paths with piped input, reads stdin. `fix` writes the cleaned text to stdout and the report to stderr.
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 under 1 KB gzipped)
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
 
@@ -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. Returns null for binary or non-UTF-8 data.
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
- * @returns {{ text: string|null, reason?: string }}
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: null, reason: 'not UTF-8' };
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
- const { text, reason } = decode(bytes);
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) out('No AI configuration files found.');
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.7";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};
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};
@@ -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 another character
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
- * โš ๏ธ Note: This is plain-text normalization/filtering โ€” not an HTML/XSS sanitizer.
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.7";
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.7",
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
- "ci": "npm run gen:check && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
54
- "size": "node scripts/check-size.js"
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, ideographic variation sequences, Mongolian
5
- // selectors, subdivision flags), counted exactly per category. The categories are
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)) markBenign(i, 'variation_selector');
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
  }