text-sanctifier 1.3.9 → 1.3.10

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
@@ -7,28 +7,13 @@
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. 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.
10
+ Find and remove invisible Unicode in web input, model-bound text and repositories. The prebuilt browser file stays under <!-- gen:budget:browser -->3 KiB<!-- /gen --> gzipped; bundlers include only the imports you use.
11
11
 
12
12
  ## Features
13
13
 
14
- - Purges zero-width Unicode garbage
15
- - Purges bidi controls, including the isolates (U+2066–U+2069) and the Arabic letter mark (U+061C) used in Trojan Source attacks
16
- - Purges Unicode tag characters (U+E0000–U+E007F), the "ASCII smuggling" channel for hiding instructions from humans in text an LLM reads, plus variation selectors, Hangul fillers, soft hyphens and invisible operators
17
- - Locates invisible and control characters by offset, line and column (`inspectTextDetailed`)
18
- - Shows hidden characters as visible markers such as `<U+200B>` (`revealInvisible`, `text-sanctifier reveal`)
19
- - Cleans pasted text and model output without gluing words together: look-alike spaces such as the narrow no-break space (U+202F) become ordinary spaces (`summonSanctifier.pasted`)
20
- - `text-sanctifier check` / `fix` command line for CI and coding agents, with JSON or SARIF output, exit codes and pre-commit hooks
21
- - `check --ai-config` finds and decodes hidden text in agent instruction files (`CLAUDE.md`, `AGENTS.md`, Cursor rules, `SKILL.md`, MCP configuration)
22
- - Normalizes line endings (CRLF, CR, LF) to LF
23
- - Collapses unwanted spaces and paragraphs
24
- - Nukes control characters (if enabled)
25
- - Smart normalization of typographic junk (quotes, dashes, bullets, full-width punctuation)
26
- - Keyboard-only filtering (retain printable ASCII + full emoji sequences)
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
- - Configurable via fine-grained flags or ready-made presets
30
- - Includes strict, loose, and keyboard-only modes
31
- - Deterministic RegExp usage (no global `lastIndex` state leaks)
14
+ - **Clean:** presets and options for invisible and control characters, including the bidi controls behind Trojan Source attacks and the tag characters behind "ASCII smuggling" to LLMs, plus look-alike spaces, emoji and newlines.
15
+ - **Inspect:** exact offsets, lines, columns and categories; visible markers; context-aware findings and decoded hidden tag text.
16
+ - **CLI:** repository and AI configuration checks, fixes, JSON or SARIF output, exit codes and local pre-commit hooks.
32
17
 
33
18
  ---
34
19
 
@@ -36,65 +21,48 @@ Brutal text normalizer and invisible Unicode scrubber for modern web projects. T
36
21
 
37
22
  ```sh
38
23
  pnpm add text-sanctifier
24
+ # or
25
+ npm i text-sanctifier
39
26
  ```
40
27
 
41
- ---
42
-
43
- ## Quick start
28
+ ## Which import do I use?
44
29
 
45
- **Custom config:**
46
-
47
- ```js
48
- import { summonSanctifier } from 'text-sanctifier';
30
+ | Job | Import or command |
31
+ | --- | --- |
32
+ | Sanitize text with presets | `import { sanctifyPasted, sanctifyStrict } from 'text-sanctifier'` |
33
+ | Find and locate characters | `import { inspectTextDetailed } from 'text-sanctifier'` |
34
+ | Emoji-aware findings | `import { inspectTextInContext } from 'text-sanctifier/context'` |
35
+ | Text headed to a model | `import { findHiddenRuns, decodeTagText } from 'text-sanctifier/hidden'` |
36
+ | No build step | `import { sanctifyPasted } from 'text-sanctifier/browser'` (CDN example below) |
37
+ | Repository checks | `npx text-sanctifier check` |
49
38
 
50
- const clean = summonSanctifier({
51
- purgeInvisibleChars: true,
52
- purgeEmojis: true,
53
- collapseSpaces: true,
54
- collapseNewLines: true,
55
- preserveParagraphs: true,
56
- finalTrim: true,
57
- });
58
-
59
- const output = clean(rawText);
60
- ```
61
-
62
- **Strict preset:**
63
-
64
- ```js
65
- const output = summonSanctifier.strict(rawText);
66
- ```
67
-
68
- **Loose preset:**
69
-
70
- ```js
71
- const output = summonSanctifier.loose(rawText);
72
- ```
73
-
74
- **Keyboard-only (no emojis):**
75
-
76
- ```js
77
- const output = summonSanctifier.keyboardOnly(userInput);
78
- ```
39
+ ## Quick start
79
40
 
80
- **Keyboard-only (with emojis):**
41
+ ### Web
81
42
 
82
43
  ```js
83
- const output = summonSanctifier.keyboardOnlyEmoji(commentText);
44
+ import { sanctifyPasted } from 'text-sanctifier';
45
+ const input = 'Hello\u202Fworld\u200B';
46
+ const clean = sanctifyPasted(input); // 'Hello world'
47
+ document.querySelector('#preview').textContent = clean;
84
48
  ```
85
49
 
86
- **Pasted text and model output:**
50
+ Without a build step, use the browser file from a CDN:
87
51
 
88
- ```js
89
- const output = summonSanctifier.pasted(clipboardText);
52
+ ```html
53
+ <div id="preview"></div>
54
+ <script type="module">
55
+ import { sanctifyPasted } from 'https://cdn.jsdelivr.net/npm/text-sanctifier/dist/text-sanctifier.min.js';
56
+ document.querySelector('#preview').textContent = sanctifyPasted('Hello\u200B world');
57
+ </script>
90
58
  ```
91
59
 
92
- **Show what is hidden:**
93
-
94
- ```js
95
- import { revealInvisible } from 'text-sanctifier';
60
+ ### Agents/CI
96
61
 
97
- revealInvisible(text); // 'Hello<U+200B>world'
62
+ ```sh
63
+ npx text-sanctifier check src docs
64
+ npx text-sanctifier check --ai-config
65
+ # check exits: 0 clean, 1 findings, 2 usage or I/O error
98
66
  ```
99
67
 
100
68
  ---
@@ -104,7 +72,7 @@ revealInvisible(text); // 'Hello<U+200B>world'
104
72
  - **Not an HTML/XSS sanitizer.** This library normalizes and filters plain text.
105
73
  - If you need to render **untrusted content**, render it as text (e.g. `textContent`), not HTML (`innerHTML`).
106
74
  - If you need to sanitize **HTML**, use a dedicated HTML sanitizer (e.g. DOMPurify / sanitize-html).
107
- - Like any text-processing library, extremely large untrusted inputs can be used for CPU/DoS pressure; consider input size limits in high-risk environments.
75
+ - Like any text-processing library, extremely large untrusted inputs can be used for CPU/DoS pressure; set input size limits with [`maxLength` and `onMaxLength`](#sanctifyoptions) in high-risk environments. Inspection caps limit output, not input length or scan time.
108
76
 
109
77
  ---
110
78
 
@@ -119,6 +87,36 @@ Two options handle spaces and zero-width characters gently, and both run before
119
87
  - `normalizeSpaces`: replace look-alike spaces with an ordinary space. These are U+00A0, U+2000–U+200A, U+202F and U+205F: the `nonbreaking_space` category except U+3000, the ordinary space of CJK text. With `purgeInvisibleChars` alone, a non-breaking space between two words is deleted and the words run together; with `normalizeSpaces` as well, they stay apart.
120
88
  - `purgeZeroWidthSpaces`: remove the zero width space U+200B, the word joiner U+2060 and U+FEFF, and nothing else. Emoji joiners, ZWNJ, variation selectors and bidi marks are kept.
121
89
 
90
+ ### `SanctifyOptions`
91
+
92
+ Used by `summonSanctifier(options)`. All boolean options default to `false`. Processing follows table order after the input-length check.
93
+
94
+ | Option | Behavior |
95
+ | --- | --- |
96
+ | `normalizeSpaces` | Replace U+00A0, U+2000-U+200A, U+202F and U+205F with ordinary spaces; keep U+3000. |
97
+ | `purgeZeroWidthSpaces` | Remove only U+200B, U+2060 and U+FEFF. |
98
+ | `purgeInvisibleChars` | Remove characters marked `purgeInvisibleChars` in the category table, including emoji joiners and selectors. |
99
+ | `purgeEmojis` | Remove matched emoji sequences. |
100
+ | `nukeControls` | Remove NUL, non-whitespace C0 controls, DEL, C1 controls and bidi controls; keep tab, LF and CR. |
101
+ | `keyboardOnlyFilter` | Normalize typographic punctuation, then keep printable ASCII, CR/LF and matched emoji sequences; with `purgeEmojis`, keep no emoji. Deletes non-ASCII letters. |
102
+ | `normalizeNewlines` | Convert CRLF and CR to LF. |
103
+ | `trimSpacesAroundNewlines` | Remove spaces and tabs immediately around LF runs. |
104
+ | `collapseNewLines` | Collapse runs of two or more LFs to one, or three or more to two with `preserveParagraphs`. |
105
+ | `preserveParagraphs` | Keep double LF breaks when `collapseNewLines` is enabled; otherwise no effect. |
106
+ | `collapseSpaces` | Collapse runs of ordinary spaces to one; keep tabs and newlines. |
107
+ | `finalTrim` | Apply JavaScript `.trim()` to the result. |
108
+ | `maxLength` | Input cap in UTF-16 code units, checked before processing. Default `Infinity`; negative, non-finite or non-number values mean no cap. |
109
+ | `onMaxLength` | Default `'throw'`: throw `RangeError` above the cap. `'truncate'`: slice input to the cap before processing (may split a surrogate pair). `'noop'`: return original input unchanged. Invalid values use `'throw'`. |
110
+
111
+ ```js
112
+ import { summonSanctifier } from 'text-sanctifier';
113
+ const clean = summonSanctifier({ purgeInvisibleChars: true, maxLength: 10000, onMaxLength: 'throw' });
114
+ ```
115
+
116
+ ### Errors
117
+
118
+ All text-processing exports throw `TypeError` on non-string input, including the preset functions, the sanitizer returned by `summonSanctifier`, `findHiddenRuns` and `decodeTagText`. Inputs are not coerced to strings. Sanitizers also throw `RangeError` when input exceeds `maxLength` with `onMaxLength: 'throw'`.
119
+
122
120
  ### Presets as standalone imports
123
121
 
124
122
  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 -->).
@@ -131,19 +129,19 @@ const output = sanctifyPasted(clipboardText);
131
129
 
132
130
  ### `summonSanctifier.strict`
133
131
 
134
- Aggressively purges: emojis, control characters, extra spacing, and newlines.
132
+ Purges invisible characters, emoji and non-whitespace controls. Normalizes CRLF and CR to LF, removes spaces/tabs around LF, collapses blank lines to a single LF, collapses ordinary spaces and trims the ends. Single line breaks remain.
135
133
 
136
134
  ### `summonSanctifier.loose`
137
135
 
138
- Gently normalizes spacing and newlines while preserving emojis and paragraphs.
136
+ Normalizes spacing and newlines while preserving emoji and paragraph breaks (LF runs collapse to at most two). Keeps zero-width characters such as U+200B, U+200C, U+200D and U+2060; it does not enable `purgeInvisibleChars`.
139
137
 
140
138
  ### `summonSanctifier.keyboardOnly`
141
139
 
142
- Restricts to printable ASCII only (removes emojis).
140
+ Normalizes typographic punctuation, then keeps printable ASCII and line breaks; removes emoji and non-ASCII letters.
143
141
 
144
142
  ### `summonSanctifier.keyboardOnlyEmoji`
145
143
 
146
- Restricts to printable ASCII + full emoji sequences. Preserves ZWJ emoji clusters and emoji presentation variants.
144
+ Normalizes typographic punctuation, then keeps printable ASCII, line breaks and matched emoji sequences, including ZWJ clusters and emoji presentation variants. Both keyboard-only presets delete non-ASCII letters: `sanctifyKeyboardOnlyEmoji('caf\u00E9')` returns `'caf'`, not an ASCII transliteration.
147
145
 
148
146
  ### `summonSanctifier.pasted`
149
147
 
@@ -228,7 +226,11 @@ truncated: false
228
226
  | `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) | `purgeInvisibleChars` |
229
227
  <!-- categories:end -->
230
228
 
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.
229
+ `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. Use [`text-sanctifier/context`](#text-sanctifiercontext-inspecttextincontexttext-options) to mark supported legitimate sequences as benign. (`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.
230
+
231
+ 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.
232
+
233
+ `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; invalid values such as negative numbers, fractions, strings, `NaN` or `null` fall back to 100) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
232
234
 
233
235
  **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
236
 
@@ -242,10 +244,6 @@ import { getNewlineStats } from 'text-sanctifier';
242
244
  getNewlineStats('a\r\nb\n'); // { crlf: 1, cr: 0, lf: 1, types: ['CRLF', 'LF'], mixed: true }
243
245
  ```
244
246
 
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
-
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
-
249
247
  ### `text-sanctifier/hidden`: `findHiddenRuns(text, options?)` and `decodeTagText(text)`
250
248
 
251
249
  The run finder behind [`check --ai-config`](#ai-configuration-files), for tools that read text on its way to a model. It has no Node APIs, so it works in a browser too, but it is a separate entry point and not part of the `text-sanctifier/browser` bundle.
@@ -261,7 +259,7 @@ const { runs, benign, counts, total, truncated } = findHiddenRuns(text, { maxRun
261
259
 
262
260
  - `kind` is `tag` (U+E0000–U+E007F), `variation_selector` (U+FE00–U+FE0F, U+E0100–U+E01EF, U+180B–U+180D, U+180F) `invisible_operator` (U+2061–U+2064) or `reserved_tag_plane` (U+E0080–U+E00FF, U+E01F0–U+E0FFF: unassigned, default ignorable, so they render as nothing and can carry data). Consecutive characters of one kind form one run.
263
261
  - `offset` and `end` are UTF-16 indexes, `[offset, end)`; `count` is in code points. `line` and `column` are counted exactly as in `inspectTextDetailed` (1-based, columns in UTF-16 units, CRLF is one line break). Only tag runs have `decoded` and `decodedTruncated`.
264
- - `maxRuns` caps the `runs` list and, separately, the `benign` list; `counts`, `total` and `benignTotal` count everything, and `truncated` is true when either list was cut. `maxDecodedLength` caps each `decoded` string in UTF-16 units and never cuts a `<U+XXXX>` marker. Both default to no cap, and an invalid value (negative, fractional, a string) also means no cap, so set them explicitly for untrusted input. With both set, memory stays flat on any input: on 16 M characters (32 MB) of hidden text the heap grows by under 1 MB, against hundreds of MB with no caps.
262
+ - `maxRuns` caps the `runs` list and, separately, the `benign` list; `counts`, `total` and `benignTotal` count everything, and `truncated` is true when either list was cut. `maxDecodedLength` caps each `decoded` string in UTF-16 units and never cuts a `<U+XXXX>` marker. Both default to no cap. Only non-negative integers and `Infinity` are valid; invalid values (negative, fractional, a string, `NaN` or `null`) also mean no cap, so set them explicitly for untrusted input. With both set, memory stays flat on any input: on 16 M characters (32 MB) of hidden text the heap grows by under 1 MB, against hundreds of MB with no caps.
265
263
  - `decodeTagText` turns U+E0020–U+E007E into the ASCII they shadow and writes any other character as `<U+XXXX>`.
266
264
  - Zero-width, bidi and other invisible characters are not runs; use `inspectTextDetailed` for those.
267
265
  - `codePointCounts` has the same keys as `counts` and counts the code points in those runs, so long runs weigh more than short ones; `supplementarySelectorCount` counts U+E0100–U+E01EF on their own, apart from the emoji selectors U+FE00–U+FE0F. Both leave out benign spans and stay exact under `maxRuns`.
@@ -299,6 +297,8 @@ U+200B, U+2060, U+FEFF, bidi controls and all other tags are never benign, and n
299
297
  The package ships a `text-sanctifier` command for CI and coding agents, which cannot see invisible characters in their own tool output. It runs on Node 18+ and has no dependencies.
300
298
 
301
299
  ```sh
300
+ npx text-sanctifier --help
301
+ npx text-sanctifier --version
302
302
  npx text-sanctifier check src docs "**/*.md"
303
303
  npx text-sanctifier fix --dry-run src
304
304
  npx text-sanctifier reveal notes.md
@@ -349,6 +349,7 @@ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
349
349
  - `ruleId` is the category, and `tool.driver.rules` lists every category.
350
350
  - `level`: `error` for `bidi_control` and `tag`, `note` for `nonbreaking_space`, `warning` for the rest.
351
351
  - Each location has the file (relative to `%SRCROOT%`, the current directory), `startLine`, `startColumn` and `endColumn`. Columns count UTF-16 code units, as in `inspectTextDetailed` (`columnKind: "utf16CodeUnits"`).
352
+ - Each result has a `partialFingerprints` hash based on its rule, relative path, line content and occurrence on that line. Adding unrelated lines above does not change it.
352
353
  - With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
353
354
  - `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out) and `skipped`.
354
355
 
@@ -360,7 +361,7 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
360
361
  # .pre-commit-config.yaml
361
362
  repos:
362
363
  - repo: https://github.com/iWhatty/text-sanctifier
363
- rev: v1.3.9 # a release tag
364
+ rev: v1.3.10 # a release tag
364
365
  hooks:
365
366
  - id: text-sanctifier # every staged text file
366
367
  - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
@@ -376,6 +377,7 @@ Rewrites files in place and reports what it replaced and removed.
376
377
 
377
378
  - `--normalize-spaces`: `normalizeSpaces` (look-alike spaces become ordinary spaces)
378
379
  - `--purge-invisible`: `purgeInvisibleChars`
380
+ - `--keep-benign`: preserve contextually legitimate joiners, selectors and flag tags when purging invisible characters. Works with the default cleaning flags too.
379
381
  - `--nuke-controls`: `nukeControls`
380
382
  - `--normalize-newlines`: `normalizeNewlines` (CRLF and CR to LF)
381
383
  - With none of these, `fix` uses `--normalize-spaces --purge-invisible --nuke-controls`: look-alike spaces become ordinary spaces, and everything else `check` reports is removed.
@@ -383,7 +385,7 @@ Rewrites files in place and reports what it replaced and removed.
383
385
  - `--dry-run`: write nothing; report what would change.
384
386
  - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`.
385
387
 
386
- Apart from look-alike spaces, `fix` deletes characters. Emoji lose their zero width joiners and variation selectors, and so does Mongolian text. Run `check` or `fix --dry-run` first on prose. To make hidden characters visible without changing a file, use `reveal`.
388
+ Apart from look-alike spaces, `fix` deletes characters. By default, emoji lose their zero width joiners and variation selectors, and so does Mongolian text. Use `--keep-benign` to preserve recognized legitimate sequences, or run `check` or `fix --dry-run` first on prose. To make hidden characters visible without changing a file, use `reveal`.
387
389
 
388
390
  ### `reveal [paths...]`
389
391
 
@@ -14,10 +14,13 @@ import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from '
14
14
  import { resolve, join, sep, relative } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { pathToFileURL } from 'node:url';
17
+ import { createHash } from 'node:crypto';
17
18
  import { inspectTextDetailed, summonSanctifier, revealInvisible } from '../src/index.js';
18
19
  import { getNewlineStats } from '../src/inspectText.js';
19
20
  import { SPACE_LOOKALIKES_REGEX } from '../src/sanctifyText.js';
20
- import { findHiddenRuns } from '../src/hiddenText.js';
21
+ import { scanHiddenSpans } from '../src/hiddenScan.js';
22
+ import { inspectTextInContext } from '../src/context.js';
23
+ import { classifyCodeUnit, classifyAstral } from '../src/categories.generated.js';
21
24
 
22
25
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
26
 
@@ -46,6 +49,7 @@ fix options:
46
49
  --normalize-spaces replace look-alike spaces (NBSP, narrow NBSP, typographic
47
50
  spaces) with an ordinary space instead of removing them
48
51
  --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
52
+ --keep-benign keep contextually legitimate joiners and selectors when purging
49
53
  --nuke-controls remove C0/C1 control characters and bidi controls
50
54
  --normalize-newlines convert CRLF and CR to LF
51
55
  --strip-bom also remove a BOM at the start of a file (kept by default)
@@ -86,11 +90,12 @@ const BOOLEAN_FLAGS = {
86
90
  '--strip-bom': 'stripBom',
87
91
  '--normalize-spaces': 'normalizeSpaces',
88
92
  '--purge-invisible': 'purgeInvisible',
93
+ '--keep-benign': 'keepBenign',
89
94
  '--nuke-controls': 'nukeControls',
90
95
  '--normalize-newlines': 'normalizeNewlines',
91
96
  '--dry-run': 'dryRun',
92
97
  };
93
- const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
98
+ const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'keepBenign', 'nukeControls', 'normalizeNewlines', 'dryRun']);
94
99
  const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
95
100
  const FORMATS = new Set(['text', 'json', 'sarif']);
96
101
 
@@ -106,6 +111,7 @@ function parseArgs(argv) {
106
111
  stripBom: false,
107
112
  normalizeSpaces: false,
108
113
  purgeInvisible: false,
114
+ keepBenign: false,
109
115
  nukeControls: false,
110
116
  normalizeNewlines: false,
111
117
  dryRun: false,
@@ -458,46 +464,56 @@ const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selecto
458
464
  * @param {boolean} stripBom
459
465
  */
460
466
  function inspectAiConfig(text, maxFindings, stripBom) {
461
- const full = inspect(text, Infinity, stripBom);
462
- const { runs, benign } = findHiddenRuns(text);
463
-
464
- const kept = [];
465
- let b = 0;
466
- for (const f of full.findings) {
467
- while (b < benign.length && benign[b][1] <= f.offset) b++;
468
- if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
469
- kept.push(f);
470
- }
471
-
472
- // Every run character is a finding, so each run starts at a kept finding
473
- const hiddenRuns = [];
467
+ const full = inspect(text, 0, stripBom);
468
+ const findings = [];
474
469
  const loose = [];
475
- let r = 0;
476
- for (const f of kept) {
477
- while (r < runs.length && runs[r].end <= f.offset) r++;
478
- const run = runs[r];
479
- if (!run || f.offset < run.offset) { loose.push(f); continue; }
480
- if (f.offset !== run.offset) continue;
481
- const entry = { kind: run.kind, offset: run.offset, line: f.line, column: f.column, codePoint: f.codePoint, count: run.count };
470
+ let looseCount = 0;
471
+ let position = 0, line = 1, lineStart = 0;
472
+ const scan = (end, inRun, benign) => {
473
+ while (position < end) {
474
+ const offset = position;
475
+ const codePoint = text.codePointAt(position);
476
+ position += codePoint > 0xFFFF ? 2 : 1;
477
+ if (codePoint === 10 || (codePoint === 13 && text.charCodeAt(position) !== 10)) {
478
+ line++; lineStart = position; continue;
479
+ }
480
+ const category = codePoint > 0xFFFF ? classifyAstral(codePoint) : classifyCodeUnit(codePoint);
481
+ if (!category || (offset === 0 && codePoint === BOM && !stripBom)) continue;
482
+ if (benign) { full.counts[category]--; full.total--; continue; }
483
+ if (findings.length < maxFindings || (!inRun && loose.length < maxFindings)) {
484
+ const f = { offset, line, column: offset - lineStart + 1, codePoint, category };
485
+ if (category === 'bom') f.atStart = offset === 0;
486
+ if (findings.length < maxFindings) findings.push(f);
487
+ if (!inRun && loose.length < maxFindings) loose.push(f);
488
+ }
489
+ if (!inRun) looseCount++;
490
+ }
491
+ };
492
+ // Decode beyond the cut point by one marker so slicing matches the original output.
493
+ const hidden = scanHiddenSpans(text, { maxRuns: maxFindings, maxDecodedLength: MAX_DECODED + 10 }, (span) => {
494
+ scan(span.offset, false, false);
495
+ scan(span.end, !span.benign, span.benign);
496
+ });
497
+ scan(text.length, false, false);
498
+ const hiddenRuns = hidden.runs.map((run) => {
499
+ const entry = { kind: run.kind, offset: run.offset, line: run.line, column: run.column, codePoint: text.codePointAt(run.offset), count: run.count };
482
500
  if (run.decoded !== undefined) {
483
501
  entry.decoded = run.decoded.slice(0, MAX_DECODED);
484
- entry.decodedTruncated = run.decoded.length > MAX_DECODED;
502
+ entry.decodedTruncated = run.decodedTruncated || run.decoded.length > MAX_DECODED;
485
503
  }
486
- hiddenRuns.push(entry);
487
- }
488
-
489
- const findings = kept.slice(0, maxFindings);
504
+ return entry;
505
+ });
490
506
  return {
491
507
  report: {
492
508
  ...full,
493
509
  findings,
494
- total: kept.length,
495
- truncated: kept.length > findings.length,
496
- hiddenRuns: hiddenRuns.slice(0, maxFindings),
497
- hiddenRunCount: hiddenRuns.length,
510
+ truncated: full.total > findings.length,
511
+ hiddenRuns,
512
+ hiddenRunCount: hidden.total,
498
513
  },
499
514
  hiddenRuns,
500
515
  loose,
516
+ entryCount: hidden.total + looseCount,
501
517
  };
502
518
  }
503
519
 
@@ -530,6 +546,30 @@ const CATEGORY_RULES = {
530
546
  const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
531
547
  const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
532
548
  const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
549
+ const fingerprints = new WeakMap();
550
+
551
+ function fingerprintFindings(label, text, findings, cwd) {
552
+ const path = label === STDIN_LABEL ? 'stdin' : toSlash(relative(cwd, resolve(cwd, label)));
553
+ let previousLine = 0, start = 0, end = 0;
554
+ let occurrences = new Map();
555
+ const hashOccurrences = new Map();
556
+ for (const finding of findings) {
557
+ if (finding.line !== previousLine) {
558
+ start = finding.offset - finding.column + 1;
559
+ end = start;
560
+ while (end < text.length && text.charCodeAt(end) !== 10 && text.charCodeAt(end) !== 13) end++;
561
+ occurrences = new Map();
562
+ previousLine = finding.line;
563
+ }
564
+ const index = occurrences.get(finding.category) || 0;
565
+ occurrences.set(finding.category, index + 1);
566
+ const hash = createHash('sha256').update(JSON.stringify([finding.category, path, text.slice(start, end), index])).digest('hex');
567
+ const count = (hashOccurrences.get(hash) || 0) + 1;
568
+ hashOccurrences.set(hash, count);
569
+ fingerprints.set(finding, count === 1 ? hash :
570
+ createHash('sha256').update(JSON.stringify([hash, count])).digest('hex'));
571
+ }
572
+ }
533
573
 
534
574
  /** @param {string} label */
535
575
  function sarifArtifact(label) {
@@ -559,6 +599,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
559
599
  const run = runAt.get(x.offset);
560
600
  results.push({
561
601
  ruleId: x.category,
602
+ partialFingerprints: { 'textSanctifier/v1': fingerprints.get(x) },
562
603
  ruleIndex: ruleIds.indexOf(x.category),
563
604
  level: CATEGORY_RULES[x.category][0],
564
605
  message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
@@ -689,16 +730,18 @@ async function main(argv) {
689
730
  const report = inspect(text, opts.maxFindings, opts.stripBom);
690
731
  total += report.total;
691
732
  if (report.total === 0) return;
733
+ if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
692
734
  files.push({ path: label, ...report });
693
735
  for (const x of report.findings) lines.push(findingLine(label, x));
694
736
  if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
695
737
  return;
696
738
  }
697
739
 
698
- const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
740
+ const { report, hiddenRuns, loose, entryCount } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
699
741
  total += report.total;
700
742
  hiddenRunCount += report.hiddenRunCount;
701
743
  if (report.total === 0) return;
744
+ if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
702
745
  files.push({ path: label, ...report });
703
746
  // Text output lists each run once, in place of its characters
704
747
  const entries = [
@@ -706,7 +749,7 @@ async function main(argv) {
706
749
  ...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
707
750
  ].sort((a, b) => a.offset - b.offset);
708
751
  for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
709
- if (entries.length > opts.maxFindings) lines.push(`${label}: ${entries.length - opts.maxFindings} more not listed (--max-findings)`);
752
+ if (entryCount > opts.maxFindings) lines.push(`${label}: ${entryCount - opts.maxFindings} more not listed (--max-findings)`);
710
753
  });
711
754
 
712
755
  const kind = opts.aiConfig ? 'AI configuration file' : 'file';
@@ -757,7 +800,21 @@ async function main(argv) {
757
800
  scanned++;
758
801
  // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
759
802
  const leadingBom = text.charCodeAt(0) === BOM;
760
- let cleaned = clean(leadingBom ? text.slice(1) : text);
803
+ const body = leadingBom ? text.slice(1) : text;
804
+ let cleaned;
805
+ if (opts.keepBenign && opts.purgeInvisible) {
806
+ const benign = inspectTextInContext(body, { maxFindings: Infinity }).findings.filter((f) => f.benign);
807
+ let start = 0;
808
+ const parts = [];
809
+ for (const f of benign) {
810
+ parts.push(clean(body.slice(start, f.offset)));
811
+ const end = f.offset + (f.codePoint > 0xFFFF ? 2 : 1);
812
+ parts.push(body.slice(f.offset, end));
813
+ start = end;
814
+ }
815
+ parts.push(clean(body.slice(start)));
816
+ cleaned = parts.join('');
817
+ } else cleaned = clean(body);
761
818
  if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
762
819
 
763
820
  if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
@@ -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.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};
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.10";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};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Decodes text[start, end) as decodeTagText does, stopping before the output would
3
+ * pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
4
+ * @param {string} text
5
+ * @param {number} start
6
+ * @param {number} end
7
+ * @param {number} limit
8
+ * @returns {{ decoded: string, truncated: boolean }}
9
+ */
10
+ export function decodeRange(text: string, start: number, end: number, limit: number): {
11
+ decoded: string;
12
+ truncated: boolean;
13
+ };
14
+ /**
15
+ * @param {string} text
16
+ * @param {HiddenScanOptions} [options]
17
+ * @param {(span: {offset: number, end: number, benign: boolean, kind: HiddenRunKind}) => void} [onSpan] - internal streaming visitor
18
+ * @returns {HiddenTextScan}
19
+ */
20
+ export function scanHiddenSpans(text: string, options?: HiddenScanOptions, onSpan?: (span: {
21
+ offset: number;
22
+ end: number;
23
+ benign: boolean;
24
+ kind: HiddenRunKind;
25
+ }) => void): HiddenTextScan;
26
+ export type HiddenRunKind = import("./hiddenText.js").HiddenRunKind;
27
+ export type HiddenRun = import("./hiddenText.js").HiddenRun;
28
+ export type HiddenRunCounts = import("./hiddenText.js").HiddenRunCounts;
29
+ export type HiddenTextScan = import("./hiddenText.js").HiddenTextScan;
30
+ export type HiddenScanOptions = import("./hiddenText.js").HiddenScanOptions;
@@ -1,3 +1,43 @@
1
+ /** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
2
+ /**
3
+ * @typedef {Object} HiddenRun
4
+ * @property {HiddenRunKind} kind
5
+ * @property {number} offset - UTF-16 index of the first character
6
+ * @property {number} end - UTF-16 index just past the last character
7
+ * @property {number} count - number of code points in the run
8
+ * @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
9
+ * @property {number} column - 1-based column of the first character, in UTF-16 units
10
+ * @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
11
+ * at most `maxDecodedLength` UTF-16 units
12
+ * @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
13
+ */
14
+ /**
15
+ * @typedef {Object} HiddenRunCounts
16
+ * @property {number} tag
17
+ * @property {number} variation_selector
18
+ * @property {number} invisible_operator
19
+ * @property {number} reserved_tag_plane
20
+ */
21
+ /**
22
+ * @typedef {Object} HiddenTextScan
23
+ * @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
24
+ * @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
25
+ * sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
26
+ * @property {HiddenRunCounts} counts - every run per kind, listed or not
27
+ * @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
28
+ * @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
29
+ * (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
30
+ * @property {number} total - sum of `counts`
31
+ * @property {number} benignTotal - every benign span, listed or not
32
+ * @property {boolean} truncated - true when runs or benign spans were left out of the lists
33
+ */
34
+ /**
35
+ * @typedef {Object} HiddenScanOptions
36
+ * @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
37
+ * (non-negative integer or Infinity). Counts stay exact.
38
+ * @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
39
+ * For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
40
+ */
1
41
  /**
2
42
  * Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
3
43
  * U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
@@ -1,2 +1,2 @@
1
1
  /** The installed version of text-sanctifier, as in its package.json. */
2
- export const VERSION: "1.3.9";
2
+ export const VERSION: "1.3.10";
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "text-sanctifier",
3
- "version": "1.3.9",
3
+ "version": "1.3.10",
4
4
  "type": "module",
5
- "description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
5
+ "description": "Find and remove invisible Unicode in web input, model-bound text and repositories.",
6
6
  "engines": {
7
7
  "node": ">=18"
8
8
  },
@@ -0,0 +1,171 @@
1
+ // Internal hidden-span scanner; not a package entry point.
2
+
3
+ /**
4
+ * @typedef {import('./hiddenText.js').HiddenRunKind} HiddenRunKind
5
+ * @typedef {import('./hiddenText.js').HiddenRun} HiddenRun
6
+ * @typedef {import('./hiddenText.js').HiddenRunCounts} HiddenRunCounts
7
+ * @typedef {import('./hiddenText.js').HiddenTextScan} HiddenTextScan
8
+ * @typedef {import('./hiddenText.js').HiddenScanOptions} HiddenScanOptions
9
+ */
10
+
11
+ const TAG_FIRST = 0xE0000;
12
+ const TAG_LAST = 0xE007F;
13
+ const CANCEL_TAG = 0xE007F;
14
+ const BLACK_FLAG = 0x1F3F4;
15
+
16
+ // RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
17
+ const SUBDIVISION_FLAGS = new Set(['gbeng', 'gbsct', 'gbwls']);
18
+
19
+ /**
20
+ * @param {number} cp
21
+ * @returns {HiddenRunKind | null}
22
+ */
23
+ function kindOf(cp) {
24
+ if (cp >= TAG_FIRST && cp <= TAG_LAST) return 'tag';
25
+ if ((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF) ||
26
+ (cp >= 0x180B && cp <= 0x180D) || cp === 0x180F) return 'variation_selector';
27
+ if (cp >= 0x2061 && cp <= 0x2064) return 'invisible_operator';
28
+ // Unassigned and default ignorable: U+E0080-U+E00FF and U+E01F0-U+E0FFF
29
+ if (cp >= 0xE0080 && cp <= 0xE0FFF) return 'reserved_tag_plane';
30
+ return null;
31
+ }
32
+
33
+ /** @param {number} cp */
34
+ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
35
+
36
+ /**
37
+ * Decodes text[start, end) as decodeTagText does, stopping before the output would
38
+ * pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
39
+ * @param {string} text
40
+ * @param {number} start
41
+ * @param {number} end
42
+ * @param {number} limit
43
+ * @returns {{ decoded: string, truncated: boolean }}
44
+ */
45
+ export function decodeRange(text, start, end, limit) {
46
+ let out = '';
47
+ let i = start;
48
+ while (i < end) {
49
+ const cp = /** @type {number} */ (text.codePointAt(i));
50
+ const piece = cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
51
+ if (out.length + piece.length > limit) return { decoded: out, truncated: true };
52
+ out += piece;
53
+ i += cp > 0xFFFF ? 2 : 1;
54
+ }
55
+ return { decoded: out, truncated: false };
56
+ }
57
+
58
+ // Tested at an index of the text itself, so the check allocates nothing.
59
+ const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
60
+
61
+ /**
62
+ * True when a presentation selector at `index` follows an emoji-capable character:
63
+ * an Extended_Pictographic code point, or a keycap base (#, * or a digit) with U+20E3 after.
64
+ * @param {string} text
65
+ * @param {number} index
66
+ * @returns {boolean}
67
+ */
68
+ function isPresentationBase(text, index) {
69
+ if (index === 0) return false;
70
+ const low = text.charCodeAt(index - 1);
71
+ const p = low >= 0xDC00 && low <= 0xDFFF && index >= 2 &&
72
+ (text.charCodeAt(index - 2) & 0xFC00) === 0xD800 ? index - 2 : index - 1;
73
+ const before = text.charCodeAt(p);
74
+ if ((before === 0x23 || before === 0x2A || (before >= 0x30 && before <= 0x39)) && text.charCodeAt(index + 1) === 0x20E3) return true;
75
+ PICTOGRAPHIC_AT.lastIndex = p;
76
+ return PICTOGRAPHIC_AT.test(text);
77
+ }
78
+
79
+ /**
80
+ * A cap option: a non-negative integer or Infinity; anything else means no cap.
81
+ * @param {unknown} value
82
+ * @returns {number}
83
+ */
84
+ function cap(value) {
85
+ return typeof value === 'number' && (value === Infinity || (Number.isInteger(value) && value >= 0))
86
+ ? value
87
+ : Infinity;
88
+ }
89
+
90
+ /**
91
+ * @param {string} text
92
+ * @param {HiddenScanOptions} [options]
93
+ * @param {(span: {offset: number, end: number, benign: boolean, kind: HiddenRunKind}) => void} [onSpan] - internal streaming visitor
94
+ * @returns {HiddenTextScan}
95
+ */
96
+ export function scanHiddenSpans(text, options = {}, onSpan) {
97
+ if (typeof text !== 'string') throw new TypeError('findHiddenRuns expects a string input.');
98
+ const maxRuns = cap(options && options.maxRuns);
99
+ const maxDecodedLength = cap(options && options.maxDecodedLength);
100
+
101
+ /** @type {HiddenRun[]} */
102
+ const runs = [];
103
+ /** @type {Array<[number, number]>} */
104
+ const benign = [];
105
+ /** @type {HiddenRunCounts} */
106
+ const counts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
107
+ /** @type {HiddenRunCounts} */
108
+ const codePointCounts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
109
+ let supplementarySelectorCount = 0;
110
+ let total = 0;
111
+ let benignTotal = 0;
112
+ let line = 1;
113
+ let lineStart = 0;
114
+
115
+ let i = 0;
116
+ while (i < text.length) {
117
+ const cp = /** @type {number} */ (text.codePointAt(i));
118
+ const kind = kindOf(cp);
119
+ if (kind === null) {
120
+ if (cp === 0x0A || (cp === 0x0D && text.charCodeAt(i + 1) !== 0x0A)) { // CRLF ends at its LF
121
+ line++;
122
+ lineStart = i + 1;
123
+ }
124
+ i += cp > 0xFFFF ? 2 : 1;
125
+ continue;
126
+ }
127
+
128
+ const offset = i;
129
+ let count = 0;
130
+ let supplementary = 0;
131
+ let last = cp;
132
+ while (i < text.length) {
133
+ const c = /** @type {number} */ (text.codePointAt(i));
134
+ if (kindOf(c) !== kind) break;
135
+ last = c;
136
+ count++;
137
+ if (c >= 0xE0100 && c <= 0xE01EF) supplementary++;
138
+ i += c > 0xFFFF ? 2 : 1;
139
+ }
140
+ const end = i;
141
+
142
+ // A subdivision flag is exactly five tag letters and CANCEL TAG after U+1F3F4.
143
+ const isBenign = kind === 'tag'
144
+ ? count === 6 && last === CANCEL_TAG && offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG &&
145
+ SUBDIVISION_FLAGS.has(decodeRange(text, offset, end - 2, Infinity).decoded)
146
+ : kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
147
+ isPresentationBase(text, offset);
148
+ if (onSpan) onSpan({ offset, end, benign: isBenign, kind });
149
+ if (isBenign) {
150
+ benignTotal++;
151
+ if (benign.length < maxRuns) benign.push([offset, end]);
152
+ continue;
153
+ }
154
+
155
+ counts[kind]++;
156
+ codePointCounts[kind] += count;
157
+ supplementarySelectorCount += supplementary;
158
+ total++;
159
+ if (runs.length < maxRuns) {
160
+ /** @type {HiddenRun} */
161
+ const run = { kind, offset, end, line, column: offset - lineStart + 1, count };
162
+ if (kind === 'tag') {
163
+ const { decoded, truncated } = decodeRange(text, offset, end, maxDecodedLength);
164
+ run.decoded = decoded;
165
+ run.decodedTruncated = truncated;
166
+ }
167
+ runs.push(run);
168
+ }
169
+ }
170
+ return { runs, benign, counts, codePointCounts, supplementarySelectorCount, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
171
+ }
package/src/hiddenText.js CHANGED
@@ -9,6 +9,8 @@
9
9
  // Plain JavaScript with no Node APIs, so it also runs in a browser. src/index.js does
10
10
  // not import this file, so it stays out of the browser bundle and its size budget.
11
11
 
12
+ import { scanHiddenSpans, decodeRange } from './hiddenScan.js';
13
+
12
14
  export { VERSION } from './version.generated.js';
13
15
 
14
16
  /** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
@@ -56,31 +58,6 @@ export { VERSION } from './version.generated.js';
56
58
  * For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
57
59
  */
58
60
 
59
- const TAG_FIRST = 0xE0000;
60
- const TAG_LAST = 0xE007F;
61
- const CANCEL_TAG = 0xE007F;
62
- const BLACK_FLAG = 0x1F3F4;
63
-
64
- // RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
65
- const SUBDIVISION_FLAGS = new Set(['gbeng', 'gbsct', 'gbwls']);
66
-
67
- /**
68
- * @param {number} cp
69
- * @returns {HiddenRunKind | null}
70
- */
71
- function kindOf(cp) {
72
- if (cp >= TAG_FIRST && cp <= TAG_LAST) return 'tag';
73
- if ((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF) ||
74
- (cp >= 0x180B && cp <= 0x180D) || cp === 0x180F) return 'variation_selector';
75
- if (cp >= 0x2061 && cp <= 0x2064) return 'invisible_operator';
76
- // Unassigned and default ignorable: U+E0080-U+E00FF and U+E01F0-U+E0FFF
77
- if (cp >= 0xE0080 && cp <= 0xE0FFF) return 'reserved_tag_plane';
78
- return null;
79
- }
80
-
81
- /** @param {number} cp */
82
- const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
83
-
84
61
  /**
85
62
  * Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
86
63
  * U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
@@ -89,63 +66,10 @@ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
89
66
  * @returns {string}
90
67
  */
91
68
  export function decodeTagText(text) {
69
+ if (typeof text !== 'string') throw new TypeError('decodeTagText expects a string input.');
92
70
  return decodeRange(text, 0, text.length, Infinity).decoded;
93
71
  }
94
72
 
95
- /**
96
- * Decodes text[start, end) as decodeTagText does, stopping before the output would
97
- * pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
98
- * @param {string} text
99
- * @param {number} start
100
- * @param {number} end
101
- * @param {number} limit
102
- * @returns {{ decoded: string, truncated: boolean }}
103
- */
104
- function decodeRange(text, start, end, limit) {
105
- let out = '';
106
- let i = start;
107
- while (i < end) {
108
- const cp = /** @type {number} */ (text.codePointAt(i));
109
- const piece = cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
110
- if (out.length + piece.length > limit) return { decoded: out, truncated: true };
111
- out += piece;
112
- i += cp > 0xFFFF ? 2 : 1;
113
- }
114
- return { decoded: out, truncated: false };
115
- }
116
-
117
- // Tested at an index of the text itself, so the check allocates nothing.
118
- const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
119
-
120
- /**
121
- * True when a presentation selector at `index` follows an emoji-capable character:
122
- * an Extended_Pictographic code point, or a keycap base (#, * or a digit) with U+20E3 after.
123
- * @param {string} text
124
- * @param {number} index
125
- * @returns {boolean}
126
- */
127
- function isPresentationBase(text, index) {
128
- if (index === 0) return false;
129
- const low = text.charCodeAt(index - 1);
130
- const p = low >= 0xDC00 && low <= 0xDFFF && index >= 2 &&
131
- (text.charCodeAt(index - 2) & 0xFC00) === 0xD800 ? index - 2 : index - 1;
132
- const before = text.charCodeAt(p);
133
- if ((before === 0x23 || before === 0x2A || (before >= 0x30 && before <= 0x39)) && text.charCodeAt(index + 1) === 0x20E3) return true;
134
- PICTOGRAPHIC_AT.lastIndex = p;
135
- return PICTOGRAPHIC_AT.test(text);
136
- }
137
-
138
- /**
139
- * A cap option: a non-negative integer or Infinity; anything else means no cap.
140
- * @param {unknown} value
141
- * @returns {number}
142
- */
143
- function cap(value) {
144
- return typeof value === 'number' && (value === Infinity || (Number.isInteger(value) && value >= 0))
145
- ? value
146
- : Infinity;
147
- }
148
-
149
73
  /**
150
74
  * Finds runs of tag characters, variation selectors, invisible operators and reserved
151
75
  * tag-plane code points (U+E0080–U+E00FF, U+E01F0–U+E0FFF).
@@ -168,76 +92,5 @@ function cap(value) {
168
92
  * @returns {HiddenTextScan}
169
93
  */
170
94
  export function findHiddenRuns(text, options = {}) {
171
- const maxRuns = cap(options && options.maxRuns);
172
- const maxDecodedLength = cap(options && options.maxDecodedLength);
173
-
174
- /** @type {HiddenRun[]} */
175
- const runs = [];
176
- /** @type {Array<[number, number]>} */
177
- const benign = [];
178
- /** @type {HiddenRunCounts} */
179
- const counts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
180
- /** @type {HiddenRunCounts} */
181
- const codePointCounts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
182
- let supplementarySelectorCount = 0;
183
- let total = 0;
184
- let benignTotal = 0;
185
- let line = 1;
186
- let lineStart = 0;
187
-
188
- let i = 0;
189
- while (i < text.length) {
190
- const cp = /** @type {number} */ (text.codePointAt(i));
191
- const kind = kindOf(cp);
192
- if (kind === null) {
193
- if (cp === 0x0A || (cp === 0x0D && text.charCodeAt(i + 1) !== 0x0A)) { // CRLF ends at its LF
194
- line++;
195
- lineStart = i + 1;
196
- }
197
- i += cp > 0xFFFF ? 2 : 1;
198
- continue;
199
- }
200
-
201
- const offset = i;
202
- let count = 0;
203
- let supplementary = 0;
204
- let last = cp;
205
- while (i < text.length) {
206
- const c = /** @type {number} */ (text.codePointAt(i));
207
- if (kindOf(c) !== kind) break;
208
- last = c;
209
- count++;
210
- if (c >= 0xE0100 && c <= 0xE01EF) supplementary++;
211
- i += c > 0xFFFF ? 2 : 1;
212
- }
213
- const end = i;
214
-
215
- // A subdivision flag is exactly five tag letters and CANCEL TAG after U+1F3F4.
216
- const isBenign = kind === 'tag'
217
- ? count === 6 && last === CANCEL_TAG && offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG &&
218
- SUBDIVISION_FLAGS.has(decodeRange(text, offset, end - 2, Infinity).decoded)
219
- : kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
220
- isPresentationBase(text, offset);
221
- if (isBenign) {
222
- benignTotal++;
223
- if (benign.length < maxRuns) benign.push([offset, end]);
224
- continue;
225
- }
226
-
227
- counts[kind]++;
228
- codePointCounts[kind] += count;
229
- supplementarySelectorCount += supplementary;
230
- total++;
231
- if (runs.length < maxRuns) {
232
- /** @type {HiddenRun} */
233
- const run = { kind, offset, end, line, column: offset - lineStart + 1, count };
234
- if (kind === 'tag') {
235
- const { decoded, truncated } = decodeRange(text, offset, end, maxDecodedLength);
236
- run.decoded = decoded;
237
- run.decodedTruncated = truncated;
238
- }
239
- runs.push(run);
240
- }
241
- }
242
- return { runs, benign, counts, codePointCounts, supplementarySelectorCount, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
95
+ return scanHiddenSpans(text, options);
243
96
  }
@@ -2,4 +2,4 @@
2
2
  // Generated from package.json by scripts/gen-version.js (`npm run gen`); do not edit.
3
3
 
4
4
  /** The installed version of text-sanctifier, as in its package.json. */
5
- export const VERSION = '1.3.9';
5
+ export const VERSION = '1.3.10';