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 +80 -78
- package/bin/text-sanctifier.js +91 -34
- package/dist/text-sanctifier.min.js +1 -1
- package/dist/types/hiddenScan.d.ts +30 -0
- package/dist/types/hiddenText.d.ts +40 -0
- package/dist/types/version.generated.d.ts +1 -1
- package/package.json +2 -2
- package/src/hiddenScan.js +171 -0
- package/src/hiddenText.js +4 -151
- package/src/version.generated.js +1 -1
package/README.md
CHANGED
|
@@ -7,28 +7,13 @@
|
|
|
7
7
|
[](https://github.com/iWhatty/text-sanctifier)
|
|
8
8
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
### Web
|
|
81
42
|
|
|
82
43
|
```js
|
|
83
|
-
|
|
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
|
-
|
|
50
|
+
Without a build step, use the browser file from a CDN:
|
|
87
51
|
|
|
88
|
-
```
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
```js
|
|
95
|
-
import { revealInvisible } from 'text-sanctifier';
|
|
60
|
+
### Agents/CI
|
|
96
61
|
|
|
97
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
package/bin/text-sanctifier.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
462
|
-
const
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "1.3.10",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
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
|
-
|
|
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
|
}
|
package/src/version.generated.js
CHANGED