text-sanctifier 1.3.7 → 1.3.8

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
@@ -1,433 +1,437 @@
1
- # text-sanctifier
2
-
3
- [![npm](https://badgen.net/npm/v/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
4
- [![downloads](https://badgen.net/npm/dm/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
5
- [![bundle size](https://badgen.net/static/gzip/under%203%20kB/green)](https://bundlejs.com/?q=text-sanctifier)
6
- [![license](https://badgen.net/static/license/AGPL-3.0%20%2B%20terms/blue)](https://github.com/iWhatty/text-sanctifier/blob/main/LICENSE)
7
- [![stars](https://badgen.net/github/stars/iWhatty/text-sanctifier)](https://github.com/iWhatty/text-sanctifier)
8
- [![types](https://badgen.net/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
-
10
- Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.87 KB gzipped.
11
-
12
- ## Features
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 (👨‍👩‍👧‍👦)
28
- - Preserves VS16 emoji presentation variants (✌️, ‼️)
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)
32
-
33
- ---
34
-
35
- ## Install
36
-
37
- ```sh
38
- pnpm add text-sanctifier
39
- ```
40
-
41
- ---
42
-
43
- ## Quick start
44
-
45
- **Custom config:**
46
-
47
- ```js
48
- import { summonSanctifier } from 'text-sanctifier';
49
-
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
- ```
79
-
80
- **Keyboard-only (with emojis):**
81
-
82
- ```js
83
- const output = summonSanctifier.keyboardOnlyEmoji(commentText);
84
- ```
85
-
86
- **Pasted text and model output:**
87
-
88
- ```js
89
- const output = summonSanctifier.pasted(clipboardText);
90
- ```
91
-
92
- **Show what is hidden:**
93
-
94
- ```js
95
- import { revealInvisible } from 'text-sanctifier';
96
-
97
- revealInvisible(text); // 'Hello<U+200B>world'
98
- ```
99
-
100
- ---
101
-
102
- ## Security notes
103
-
104
- - **Not an HTML/XSS sanitizer.** This library normalizes and filters plain text.
105
- - If you need to render **untrusted content**, render it as text (e.g. `textContent`), not HTML (`innerHTML`).
106
- - 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.
108
-
109
- ---
110
-
111
- ## API
112
-
113
- ### `summonSanctifier(options?: SanctifyOptions): (text: string) => string`
114
-
115
- Creates a reusable sanitizer from an option object.
116
-
117
- Two options handle spaces and zero-width characters gently, and both run before the others:
118
-
119
- - `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
- - `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
-
122
- ### Presets as standalone imports
123
-
124
- Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about 1.1 KB gzipped instead of 1.45 KB).
125
-
126
- ```js
127
- import { sanctifyPasted } from 'text-sanctifier';
128
-
129
- const output = sanctifyPasted(clipboardText);
130
- ```
131
-
132
- ### `summonSanctifier.strict`
133
-
134
- Aggressively purges: emojis, control characters, extra spacing, and newlines.
135
-
136
- ### `summonSanctifier.loose`
137
-
138
- Gently normalizes spacing and newlines while preserving emojis and paragraphs.
139
-
140
- ### `summonSanctifier.keyboardOnly`
141
-
142
- Restricts to printable ASCII only (removes emojis).
143
-
144
- ### `summonSanctifier.keyboardOnlyEmoji`
145
-
146
- Restricts to printable ASCII + full emoji sequences. Preserves ZWJ emoji clusters and emoji presentation variants.
147
-
148
- ### `summonSanctifier.pasted`
149
-
150
- For text pasted from documents, chat apps and PDFs, and for model output. Some models put narrow no-break spaces (U+202F) and other look-alike spaces into their answers. This preset uses `normalizeSpaces`, `purgeZeroWidthSpaces` and `normalizeNewlines`: look-alike spaces become ordinary spaces, zero-width spaces and word joiners are removed, and CRLF and CR become LF. It keeps emoji joiners, variation selectors, bidi marks, indentation, blank lines and leading and trailing whitespace, so it is safe on code and Markdown.
151
-
152
- ### `revealInvisible(text: string, options?: RevealOptions): string`
153
-
154
- Replaces every character `inspectTextDetailed` would report with a visible marker and leaves everything else alone, including tabs, newlines and emoji. Use it to show hidden text in logs, diffs, review tools and agent tool output. Also in the browser build.
155
-
156
- ```js
157
- import { revealInvisible } from 'text-sanctifier';
158
-
159
- revealInvisible('a\u200Bb\u{E0041}'); // 'a<U+200B>b<U+E0041>'
160
- revealInvisible('a\u202Eb', { format: (codePoint, category) => `[${category}]` }); // 'a[bidi_control]b'
161
- ```
162
-
163
- The default marker is `<U+XXXX>` with at least four uppercase hex digits. Every hidden character is replaced, with no cap like `maxFindings`. A literal `<U+200B>` already in the text looks the same as a marker; pass `format` to choose markers that can't occur in your input.
164
-
165
- ### `inspectText(text: string): UnicodeTrashReport`
166
-
167
- Returns a structural report of control codes, invisible chars, newline styles, and more.
168
-
169
- ```js
170
- import { inspectText } from 'text-sanctifier';
171
-
172
- const report = inspectText(input);
173
-
174
- /*
175
- {
176
- hasControlChars: true,
177
- hasInvisibleChars: true,
178
- hasMixedNewlines: false,
179
- newlineStyle: 'LF',
180
- hasEmojis: true,
181
- hasNonKeyboardChars: false,
182
- summary: [
183
- 'Control characters detected.',
184
- 'Invisible Unicode characters detected.',
185
- 'Emojis detected.',
186
- 'Consistent newline style: LF'
187
- ]
188
- }
189
- */
190
- ```
191
-
192
- Use `inspectText` to preflight text content before rendering, storing, or linting. It's a diagnostic tool to help inform sanitization needs. Pass the report to `getRecommendedSanctifierOptions(report)` to auto-generate config flags for `summonSanctifier()`.
193
-
194
- ### `inspectTextDetailed(text: string, options?: { maxFindings?: number }): DetailedTrashReport`
195
-
196
- Returns each invisible or control character with its offset, line, column, code point and category, plus per-category counts and a `truncated` flag. Also exported from the browser build (`text-sanctifier/browser`). `inspectText`'s output is unchanged.
197
-
198
- ```js
199
- import { inspectTextDetailed } from 'text-sanctifier';
200
-
201
- const { findings, counts, total, truncated } = inspectTextDetailed(input, { maxFindings: 100 });
202
-
203
- /*
204
- findings: [
205
- { offset: 0, line: 1, column: 1, codePoint: 0xFEFF, category: 'bom', atStart: true },
206
- { offset: 42, line: 3, column: 7, codePoint: 0x2067, category: 'bidi_control' },
207
- ]
208
- counts: { bidi_control: 1, zero_width: 0, nonbreaking_space: 0, bom: 1, nul: 0, c0_control: 0, c1_control: 0,
209
- invisible_format: 0, hangul_filler: 0, variation_selector: 0, tag: 0 }
210
- total: 2
211
- truncated: false
212
- */
213
- ```
214
-
215
- <!-- categories:start (generated from scripts/categories.js by `npm run gen`; do not edit) -->
216
- | Category | Code points | Removed by |
217
- | --- | --- | --- |
218
- | `bidi_control` | U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 | `nukeControls`, `purgeInvisibleChars` |
219
- | `zero_width` | U+200B–U+200D, U+2060 | `purgeInvisibleChars` |
220
- | `nonbreaking_space` | U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000 (non-breaking and typographic spaces) | `purgeInvisibleChars` |
221
- | `bom` | U+FEFF (`atStart` is true at offset 0, where a BOM is legitimate) | `purgeInvisibleChars` |
222
- | `nul` | U+0000 | `nukeControls` |
223
- | `c0_control` | U+0001–U+0008, U+000B, U+000C, U+000E–U+001F, U+007F (tab, LF and CR are whitespace, not trash) | `nukeControls` |
224
- | `c1_control` | U+0080–U+009F | `nukeControls` |
225
- | `invisible_format` | U+00AD (soft hyphen), U+034F (combining grapheme joiner), U+17B4, U+17B5 (Khmer inherent vowels), U+180E (Mongolian vowel separator), U+2061–U+2064 (invisible operators), U+2065 (reserved), U+206A–U+206F (deprecated format characters), U+FFF0–U+FFF8 (reserved), U+1BCA0–U+1BCA3 (shorthand format controls), U+1D173–U+1D17A (musical formatting), U+E0080–U+E00FF (reserved), U+E01F0–U+E0FFF (reserved) (every other Default_Ignorable_Code_Point, which renderers draw as nothing) | `purgeInvisibleChars` |
226
- | `hangul_filler` | U+115F, U+1160, U+3164, U+FFA0 (letters that render blank) | `purgeInvisibleChars` |
227
- | `variation_selector` | U+180B–U+180D (Mongolian FVS1-FVS3), U+180F (Mongolian FVS4), U+FE00–U+FE0F (VS1-VS16, including VS16 in emoji), U+E0100–U+E01EF (VS17-VS256) | `purgeInvisibleChars` |
228
- | `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) | `purgeInvisibleChars` |
229
- <!-- categories:end -->
230
-
231
- `codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
232
-
233
- **Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about 80 ms and uses 0.2 MB. With `Infinity`, expect roughly 100–200 bytes per finding: about 1.5 GB for 8 million. On untrusted input, keep a cap. Use `maxFindings: 0` for a counts-only report: it builds no finding objects, and `counts`, `total` and `truncated` stay exact.
234
-
235
- ### `getNewlineStats(text: string): NewlineStats`
236
-
237
- Counts each newline style in one pass, with no regex and no copy: `{ crlf, cr, lf, types, mixed }`. `cr` counts CRs not followed by LF, `lf` counts LFs not preceded by CR, `types` lists the styles present in the order `'CRLF'`, `'CR'`, `'LF'`, and `mixed` is true when there is more than one. Also exported from the browser build.
238
-
239
- ```js
240
- import { getNewlineStats } from 'text-sanctifier';
241
-
242
- getNewlineStats('a\r\nb\n'); // { crlf: 1, cr: 0, lf: 1, types: ['CRLF', 'LF'], mixed: true }
243
- ```
244
-
245
- `zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨‍👩‍👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16. The Mongolian free variation selectors (U+180B–U+180D, U+180F) are also `variation_selector`: they are legitimate in Mongolian text, which `purgeInvisibleChars` can change, so don't purge Mongolian text with it.
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
- ### `text-sanctifier/hidden`: `findHiddenRuns(text, options?)` and `decodeTagText(text)`
250
-
251
- 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.
252
-
253
- ```js
254
- import { findHiddenRuns, decodeTagText } from 'text-sanctifier/hidden';
255
-
256
- const { runs, benign, counts, total, truncated } = findHiddenRuns(text, { maxRuns: 100, maxDecodedLength: 1000 });
257
- // runs: [{ kind: 'tag', offset, end, line, column, count, decoded: 'Ignore the user', decodedTruncated: false }, ...]
258
- // benign: [[offset, end], ...] England/Scotland/Wales flags, a lone U+FE0E/U+FE0F after a character
259
- // counts: { tag, variation_selector, invisible_operator, reserved_tag_plane }, every run listed or not; total is their sum
260
- ```
261
-
262
- - `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
- - `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.
265
- - `decodeTagText` turns U+E0020–U+E007E into the ASCII they shadow and writes any other character as `<U+XXXX>`.
266
- - Zero-width, bidi and other invisible characters are not runs; use `inspectTextDetailed` for those.
267
- - `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`.
268
- - `VERSION` is exported here and from the package root: the installed version, as in `package.json`.
269
-
270
- ### `text-sanctifier/context`: `inspectTextInContext(text, options?)`
271
-
272
- `inspectTextDetailed` with context. Some invisible characters are part of ordinary text: the joiners in a family emoji, the selector in ✌ followed by VS16, an ideographic variation selector after a CJK character. This entry point reports the same findings and `counts`, marks each listed finding `benign: true` or `false`, and adds `benignCounts`, exact whether or not a finding is listed. The categories are the same.
273
-
274
- ```js
275
- import { inspectTextInContext } from 'text-sanctifier/context';
276
-
277
- const report = inspectTextInContext(text, { maxFindings: 20 });
278
- const suspicious = report.counts.zero_width - report.benignCounts.zero_width; // U+200B, U+2060, stray U+200D
279
- ```
280
-
281
- Benign, and nothing else:
282
-
283
- - U+200D between two emoji (`Extended_Pictographic`), allowing a skin-tone modifier or VS16 after the first
284
- - U+FE0E or U+FE0F directly after an emoji, or in a keycap (0-9, # or *, the selector, U+20E3)
285
- - one of U+E0100–U+E01EF directly after an ideograph
286
- - U+180B–U+180D or U+180F directly after a Mongolian letter
287
- - the tags of the England, Scotland and Wales flags
288
-
289
- U+200B, U+2060, U+FEFF, bidi controls and all other tags are never benign. Joiners in scripts that need them (ZWNJ in Persian, ZWJ in Indic scripts) are not benign yet. It needs Unicode property escapes (Node 10+, current browsers), is not in the browser file, and is about 1.7 KB gzipped on its own.
290
-
291
- ---
292
-
293
- ## Command line
294
-
295
- 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.
296
-
297
- ```sh
298
- npx text-sanctifier check src docs "**/*.md"
299
- npx text-sanctifier fix --dry-run src
300
- npx text-sanctifier reveal notes.md
301
- ```
302
-
303
- ### `check [paths...]`
304
-
305
- Reports every invisible or control character as `path:line:column U+XXXX category`, using the categories of `inspectTextDetailed`:
306
-
307
- ```
308
- docs/intro.md:12:7 U+200B zero_width
309
- src/auth.js:40:3 U+202E bidi_control
310
- 2 findings in 2 of 31 files.
311
- ```
312
-
313
- - `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
314
- - `--format sarif`: a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for code-scanning viewers and editors. See [SARIF output](#sarif-output).
315
- - `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
316
- - `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
317
- - `--ai-config`: check agent instruction files for hidden text. See [AI configuration files](#ai-configuration-files).
318
-
319
- ### AI configuration files
320
-
321
- `check --ai-config` looks for hidden instructions in the files coding agents read: rules files, skill files and MCP configuration. Tag characters, variation selectors and invisible operators can carry text that people reviewing the file cannot see but a model reads.
322
-
323
- ```sh
324
- npx text-sanctifier check --ai-config
325
- ```
326
-
327
- ```
328
- skills/review/SKILL.md:1:17 U+E0049 run of 15 tag characters spelling "Ignore the user"
329
- skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
330
- .cursor/rules/style.mdc:4:9 U+200B zero_width
331
- 19 findings (2 hidden runs) in 2 of 6 AI configuration files.
332
- ```
333
-
334
- - With no paths it searches the current directory; directories you name are searched the same way, and files you name are checked whatever they are called. In a git work tree, files `.gitignore` excludes are skipped.
335
- - Files it looks for, matched case-insensitively: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `AGENT.md`, `GEMINI.md` and `SKILL.md` at any depth; and at the top of the directory, `.claude/**`, `.cursorrules`, `.cursor/rules/**`, `.cursor/mcp.json`, `.windsurfrules`, `.windsurf/rules/**`, `.clinerules` (file or directory), `.roorules`, `.roo/rules/**`, `.github/copilot-instructions.md`, `.github/{instructions,prompts,chatmodes,agents}/**`, `.mcp.json`, `mcp.json` and `.vscode/mcp.json`.
336
- - Consecutive tag characters, variation selectors or invisible operators are reported once, as a run, at the first character. Tag runs are decoded: U+E0020–U+E007E become the ASCII they shadow, and other tags are shown as `<U+XXXX>`.
337
- - Every other invisible or control character is reported as in a plain `check`.
338
- - Two legitimate sequences are left out: the flags of England, Scotland and Wales (U+1F3F4, tag letters, U+E007F), and a single U+FE0E or U+FE0F after another character (text or emoji presentation). A plain `check` still reports both.
339
- - `--json` adds `hiddenRuns` (`{ kind, offset, line, column, codePoint, count, decoded?, decodedTruncated? }`, at most `--max-findings` per file) and `hiddenRunCount` to each file, and `hiddenRunCount` to the summary. `kind` is `tag`, `variation_selector`, `invisible_operator` or `reserved_tag_plane`; `decoded` is cut at 1000 characters. `total` and `counts` leave out the legitimate sequences.
340
-
341
- ### SARIF output
342
-
343
- `check --format sarif` (with or without `--ai-config`) prints one SARIF 2.1.0 log with one result per listed finding:
344
-
345
- - `ruleId` is the category, and `tool.driver.rules` lists every category.
346
- - `level`: `error` for `bidi_control` and `tag`, `note` for `nonbreaking_space`, `warning` for the rest.
347
- - 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"`).
348
- - With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
349
- - `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out) and `skipped`.
350
-
351
- ### pre-commit
352
-
353
- The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, so the check runs locally on the files being committed:
354
-
355
- ```yaml
356
- # .pre-commit-config.yaml
357
- repos:
358
- - repo: https://github.com/iWhatty/text-sanctifier
359
- rev: v1.3.7 # a release tag
360
- hooks:
361
- - id: text-sanctifier # every staged text file
362
- - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
363
- ```
364
-
365
- - `text-sanctifier` runs `text-sanctifier check` on staged text files. Use `exclude:` for files that legitimately contain these characters, or `args: [--strip-bom]` and the other `check` options.
366
- - `text-sanctifier-ai-config` runs `text-sanctifier check --ai-config` on staged files that match the `--ai-config` file list.
367
- - A hook fails the commit when it finds something (exit code 1). Neither hook changes files; run `text-sanctifier fix` yourself.
368
-
369
- ### `fix [paths...]`
370
-
371
- Rewrites files in place and reports what it replaced and removed.
372
-
373
- - `--normalize-spaces`: `normalizeSpaces` (look-alike spaces become ordinary spaces)
374
- - `--purge-invisible`: `purgeInvisibleChars`
375
- - `--nuke-controls`: `nukeControls`
376
- - `--normalize-newlines`: `normalizeNewlines` (CRLF and CR to LF)
377
- - 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.
378
- - `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
379
- - `--dry-run`: write nothing; report what would change.
380
- - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`.
381
-
382
- 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`.
383
-
384
- ### `reveal [paths...]`
385
-
386
- Prints each input with every character `check` would report replaced by a marker such as `<U+200B>` (see `revealInvisible`). Everything else, including newlines, is printed as it is, and files are never changed. Several inputs are printed one after another under `==> path <==` headers. `reveal` takes no options and exits 0 unless an input can't be read.
387
-
388
- ```sh
389
- npx text-sanctifier reveal AGENTS.md
390
- git diff | npx text-sanctifier reveal
391
- ```
392
-
393
- ### Inputs
394
-
395
- - Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
396
- - Inside a git work tree, directories and globs list files with `git ls-files`, so anything `.gitignore` excludes is skipped. Outside git, `.git` and `node_modules` are skipped. A file named explicitly is always read.
397
- - Binary files (a NUL byte in the first 8000 bytes) and files that are not valid UTF-8 are skipped, and `fix` never writes them. In JSON they appear under `skipped`.
398
- - `-`, or no paths with piped input, reads stdin. `fix` writes the cleaned text to stdout and the report to stderr.
399
-
400
- ### Exit codes
401
-
402
- | Code | `check` | `fix` | `reveal` |
403
- | --- | --- | --- | --- |
404
- | 0 | nothing found | done (or nothing to do) | done |
405
- | 1 | findings | `--dry-run` with changes pending | not used |
406
- | 2 | usage error, missing path or unreadable file | same | same |
407
-
408
- ---
409
-
410
- ## Notes
411
-
412
- ### Runtime requirements
413
-
414
- Requires a modern JavaScript runtime with ES2020+ support:
415
-
416
- - Node.js 18+ (`engines.node >=18`)
417
- - Modern evergreen browsers
418
-
419
- ### Package and build info
420
-
421
- - **Source (`src/`):** ES2020+ ESM modules with JSDoc
422
- - **ESM only:** no CommonJS build; `require('text-sanctifier')` works only on Node versions with `require(esm)` support (20.19+, 22.12+)
423
- - **Types:** TypeScript declarations ship in `dist/types`
424
- - **Browser build (`dist/`):** one minified ESM file with every export, for pages with no build step (`<script type="module">` from a CDN). It is imported as `text-sanctifier/browser`. With a bundler, import `text-sanctifier` instead: the source tree-shakes, and the prebuilt file does not
425
- - **Tree-shake friendly:** ships `sideEffects: false`; `npm run ci` checks the bundled size of each export on its own (for example `inspectTextDetailed` is under 1 KB gzipped)
426
- - **Zero transpilation:** no built-in polyfills or runtime overhead
427
- - **Bundler ready:** works with Vite, Rollup, Webpack, Parcel, esbuild
428
-
429
- ---
430
-
431
- ## License
432
-
433
- Licensed under AGPL-3.0 with WATT3D Additional Terms. See [LICENSE](./LICENSE) and [ADDITIONAL_TERMS.md](./ADDITIONAL_TERMS.md). Commercial AI/model-training use requires compliance with those terms or a separate WATT3D license. © WATT3D.
1
+ # text-sanctifier
2
+
3
+ [![npm](https://badgen.net/npm/v/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
4
+ [![downloads](https://badgen.net/npm/dm/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
5
+ [![bundle size](https://badgen.net/static/gzip/under%203%20KiB/green)](https://bundlejs.com/?q=text-sanctifier)
6
+ [![license](https://badgen.net/static/license/AGPL-3.0%20%2B%20terms/blue)](https://github.com/iWhatty/text-sanctifier/blob/main/LICENSE)
7
+ [![stars](https://badgen.net/github/stars/iWhatty/text-sanctifier)](https://github.com/iWhatty/text-sanctifier)
8
+ [![types](https://badgen.net/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
+
10
+ Brutal text normalizer and invisible Unicode scrubber for modern web projects. The prebuilt browser file stays under 3 KiB gzipped, a budget `npm run ci` enforces; with a bundler you pay only for what you import.
11
+
12
+ ## Features
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 (👨‍👩‍👧‍👦)
28
+ - Preserves VS16 emoji presentation variants (✌️, ‼️)
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)
32
+
33
+ ---
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ pnpm add text-sanctifier
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Quick start
44
+
45
+ **Custom config:**
46
+
47
+ ```js
48
+ import { summonSanctifier } from 'text-sanctifier';
49
+
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
+ ```
79
+
80
+ **Keyboard-only (with emojis):**
81
+
82
+ ```js
83
+ const output = summonSanctifier.keyboardOnlyEmoji(commentText);
84
+ ```
85
+
86
+ **Pasted text and model output:**
87
+
88
+ ```js
89
+ const output = summonSanctifier.pasted(clipboardText);
90
+ ```
91
+
92
+ **Show what is hidden:**
93
+
94
+ ```js
95
+ import { revealInvisible } from 'text-sanctifier';
96
+
97
+ revealInvisible(text); // 'Hello<U+200B>world'
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Security notes
103
+
104
+ - **Not an HTML/XSS sanitizer.** This library normalizes and filters plain text.
105
+ - If you need to render **untrusted content**, render it as text (e.g. `textContent`), not HTML (`innerHTML`).
106
+ - 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.
108
+
109
+ ---
110
+
111
+ ## API
112
+
113
+ ### `summonSanctifier(options?: SanctifyOptions): (text: string) => string`
114
+
115
+ Creates a reusable sanitizer from an option object.
116
+
117
+ Two options handle spaces and zero-width characters gently, and both run before the others:
118
+
119
+ - `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
+ - `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
+
122
+ ### Presets as standalone imports
123
+
124
+ Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about 1.1 KB gzipped instead of 1.45 KB).
125
+
126
+ ```js
127
+ import { sanctifyPasted } from 'text-sanctifier';
128
+
129
+ const output = sanctifyPasted(clipboardText);
130
+ ```
131
+
132
+ ### `summonSanctifier.strict`
133
+
134
+ Aggressively purges: emojis, control characters, extra spacing, and newlines.
135
+
136
+ ### `summonSanctifier.loose`
137
+
138
+ Gently normalizes spacing and newlines while preserving emojis and paragraphs.
139
+
140
+ ### `summonSanctifier.keyboardOnly`
141
+
142
+ Restricts to printable ASCII only (removes emojis).
143
+
144
+ ### `summonSanctifier.keyboardOnlyEmoji`
145
+
146
+ Restricts to printable ASCII + full emoji sequences. Preserves ZWJ emoji clusters and emoji presentation variants.
147
+
148
+ ### `summonSanctifier.pasted`
149
+
150
+ For text pasted from documents, chat apps and PDFs, and for model output. Some models put narrow no-break spaces (U+202F) and other look-alike spaces into their answers. This preset uses `normalizeSpaces`, `purgeZeroWidthSpaces` and `normalizeNewlines`: look-alike spaces become ordinary spaces, zero-width spaces and word joiners are removed, and CRLF and CR become LF. It keeps emoji joiners, variation selectors, bidi marks, indentation, blank lines and leading and trailing whitespace, so it is safe on code and Markdown.
151
+
152
+ ### `revealInvisible(text: string, options?: RevealOptions): string`
153
+
154
+ Replaces every character `inspectTextDetailed` would report with a visible marker and leaves everything else alone, including tabs, newlines and emoji. Use it to show hidden text in logs, diffs, review tools and agent tool output. Also in the browser build.
155
+
156
+ ```js
157
+ import { revealInvisible } from 'text-sanctifier';
158
+
159
+ revealInvisible('a\u200Bb\u{E0041}'); // 'a<U+200B>b<U+E0041>'
160
+ revealInvisible('a\u202Eb', { format: (codePoint, category) => `[${category}]` }); // 'a[bidi_control]b'
161
+ ```
162
+
163
+ The default marker is `<U+XXXX>` with at least four uppercase hex digits. Every hidden character is replaced, with no cap like `maxFindings`. A literal `<U+200B>` already in the text looks the same as a marker; pass `format` to choose markers that can't occur in your input.
164
+
165
+ ### `inspectText(text: string): UnicodeTrashReport`
166
+
167
+ Returns a structural report of control codes, invisible chars, newline styles, and more.
168
+
169
+ ```js
170
+ import { inspectText } from 'text-sanctifier';
171
+
172
+ const report = inspectText(input);
173
+
174
+ /*
175
+ {
176
+ hasControlChars: true,
177
+ hasInvisibleChars: true,
178
+ hasMixedNewlines: false,
179
+ newlineStyle: 'LF',
180
+ hasEmojis: true,
181
+ hasNonKeyboardChars: false,
182
+ summary: [
183
+ 'Control characters detected.',
184
+ 'Invisible Unicode characters detected.',
185
+ 'Emojis detected.',
186
+ 'Consistent newline style: LF'
187
+ ]
188
+ }
189
+ */
190
+ ```
191
+
192
+ Use `inspectText` to preflight text content before rendering, storing, or linting. It's a diagnostic tool to help inform sanitization needs. Pass the report to `getRecommendedSanctifierOptions(report)` to auto-generate config flags for `summonSanctifier()`.
193
+
194
+ ### `inspectTextDetailed(text: string, options?: { maxFindings?: number }): DetailedTrashReport`
195
+
196
+ Returns each invisible or control character with its offset, line, column, code point and category, plus per-category counts and a `truncated` flag. Also exported from the browser build (`text-sanctifier/browser`). `inspectText`'s output is unchanged.
197
+
198
+ ```js
199
+ import { inspectTextDetailed } from 'text-sanctifier';
200
+
201
+ const { findings, counts, total, truncated } = inspectTextDetailed(input, { maxFindings: 100 });
202
+
203
+ /*
204
+ findings: [
205
+ { offset: 0, line: 1, column: 1, codePoint: 0xFEFF, category: 'bom', atStart: true },
206
+ { offset: 42, line: 3, column: 7, codePoint: 0x2067, category: 'bidi_control' },
207
+ ]
208
+ counts: { bidi_control: 1, zero_width: 0, nonbreaking_space: 0, bom: 1, nul: 0, c0_control: 0, c1_control: 0,
209
+ invisible_format: 0, hangul_filler: 0, variation_selector: 0, tag: 0 }
210
+ total: 2
211
+ truncated: false
212
+ */
213
+ ```
214
+
215
+ <!-- categories:start (generated from scripts/categories.js by `npm run gen`; do not edit) -->
216
+ | Category | Code points | Removed by |
217
+ | --- | --- | --- |
218
+ | `bidi_control` | U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 | `nukeControls`, `purgeInvisibleChars` |
219
+ | `zero_width` | U+200B–U+200D, U+2060 | `purgeInvisibleChars` |
220
+ | `nonbreaking_space` | U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000 (non-breaking and typographic spaces) | `purgeInvisibleChars` |
221
+ | `bom` | U+FEFF (`atStart` is true at offset 0, where a BOM is legitimate) | `purgeInvisibleChars` |
222
+ | `nul` | U+0000 | `nukeControls` |
223
+ | `c0_control` | U+0001–U+0008, U+000B, U+000C, U+000E–U+001F, U+007F (tab, LF and CR are whitespace, not trash) | `nukeControls` |
224
+ | `c1_control` | U+0080–U+009F | `nukeControls` |
225
+ | `invisible_format` | U+00AD (soft hyphen), U+034F (combining grapheme joiner), U+17B4, U+17B5 (Khmer inherent vowels), U+180E (Mongolian vowel separator), U+2061–U+2064 (invisible operators), U+2065 (reserved), U+206A–U+206F (deprecated format characters), U+FFF0–U+FFF8 (reserved), U+1BCA0–U+1BCA3 (shorthand format controls), U+1D173–U+1D17A (musical formatting), U+E0080–U+E00FF (reserved), U+E01F0–U+E0FFF (reserved) (every other Default_Ignorable_Code_Point, which renderers draw as nothing) | `purgeInvisibleChars` |
226
+ | `hangul_filler` | U+115F, U+1160, U+3164, U+FFA0 (letters that render blank) | `purgeInvisibleChars` |
227
+ | `variation_selector` | U+180B–U+180D (Mongolian FVS1-FVS3), U+180F (Mongolian FVS4), U+FE00–U+FE0F (VS1-VS16, including VS16 in emoji), U+E0100–U+E01EF (VS17-VS256) | `purgeInvisibleChars` |
228
+ | `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) | `purgeInvisibleChars` |
229
+ <!-- categories:end -->
230
+
231
+ `codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
232
+
233
+ **Choosing `maxFindings`:** the scan itself uses almost no memory, but every listed finding is an object, so the cost of `maxFindings` grows with the number of findings, not the size of the input. 16 MB of non-breaking spaces is 16.7 million findings. With a cap of 20, it scans in about 80 ms and uses 0.2 MB. With `Infinity`, expect roughly 100–200 bytes per finding: about 1.5 GB for 8 million. On untrusted input, keep a cap. Use `maxFindings: 0` for a counts-only report: it builds no finding objects, and `counts`, `total` and `truncated` stay exact.
234
+
235
+ ### `getNewlineStats(text: string): NewlineStats`
236
+
237
+ Counts each newline style in one pass, with no regex and no copy: `{ crlf, cr, lf, types, mixed }`. `cr` counts CRs not followed by LF, `lf` counts LFs not preceded by CR, `types` lists the styles present in the order `'CRLF'`, `'CR'`, `'LF'`, and `mixed` is true when there is more than one. Also exported from the browser build.
238
+
239
+ ```js
240
+ import { getNewlineStats } from 'text-sanctifier';
241
+
242
+ getNewlineStats('a\r\nb\n'); // { crlf: 1, cr: 0, lf: 1, types: ['CRLF', 'LF'], mixed: true }
243
+ ```
244
+
245
+ `zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨‍👩‍👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16. The Mongolian free variation selectors (U+180B–U+180D, U+180F) are also `variation_selector`: they are legitimate in Mongolian text, which `purgeInvisibleChars` can change, so don't purge Mongolian text with it.
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
+ ### `text-sanctifier/hidden`: `findHiddenRuns(text, options?)` and `decodeTagText(text)`
250
+
251
+ 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.
252
+
253
+ ```js
254
+ import { findHiddenRuns, decodeTagText } from 'text-sanctifier/hidden';
255
+
256
+ const { runs, benign, counts, total, truncated } = findHiddenRuns(text, { maxRuns: 100, maxDecodedLength: 1000 });
257
+ // runs: [{ kind: 'tag', offset, end, line, column, count, decoded: 'Ignore the user', decodedTruncated: false }, ...]
258
+ // benign: [[offset, end], ...] England/Scotland/Wales flags, a lone U+FE0E/U+FE0F after a character
259
+ // counts: { tag, variation_selector, invisible_operator, reserved_tag_plane }, every run listed or not; total is their sum
260
+ ```
261
+
262
+ - `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
+ - `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.
265
+ - `decodeTagText` turns U+E0020–U+E007E into the ASCII they shadow and writes any other character as `<U+XXXX>`.
266
+ - Zero-width, bidi and other invisible characters are not runs; use `inspectTextDetailed` for those.
267
+ - `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`.
268
+ - `VERSION` is exported here and from the package root: the installed version, as in `package.json`.
269
+
270
+ ### `text-sanctifier/context`: `inspectTextInContext(text, options?)`
271
+
272
+ `inspectTextDetailed` with context. Some invisible characters are part of ordinary text: the joiners in a family emoji, the selector in ✌ followed by VS16, an ideographic variation selector after a CJK character. This entry point reports the same findings and `counts`, marks each listed finding `benign: true` or `false`, and adds `benignCounts`, exact whether or not a finding is listed. The categories are the same.
273
+
274
+ ```js
275
+ import { inspectTextInContext } from 'text-sanctifier/context';
276
+
277
+ const report = inspectTextInContext(text, { maxFindings: 20 });
278
+ const suspicious = report.counts.zero_width - report.benignCounts.zero_width; // U+200B, U+2060, stray U+200D
279
+ ```
280
+
281
+ Benign, and nothing else:
282
+
283
+ - U+200D between two emoji (`Extended_Pictographic`), allowing a skin-tone modifier or VS16 after the first
284
+ - U+FE0E or U+FE0F directly after an emoji, or in a keycap (0-9, # or *, the selector, U+20E3)
285
+ - one of U+E0100–U+E01EF directly after an ideograph
286
+ - U+180B–U+180D or U+180F directly after a Mongolian letter
287
+ - the tags of the England, Scotland and Wales flags
288
+ - U+200C or U+200D directly after a virama (Devanagari, Bengali, Tamil, Malayalam, Sinhala, Myanmar and the other scripts with one), as IDNA ContextJ (RFC 5892) allows
289
+ - U+200C between two Arabic-script letters, skipping marks, as in Persian. This approximates the RFC's joining-type rule: it also accepts a ZWNJ after a letter that does not join to the left, and none in other joining scripts such as Syriac
290
+
291
+ U+200B, U+2060, U+FEFF, bidi controls and all other tags are never benign, and neither is U+200D between Arabic letters. It needs Unicode property escapes (Node 10+, current browsers), is not in the browser file, and is about 2.1 KB gzipped on its own.
292
+
293
+ `benignSupplementarySelectorCount` counts the benign ideographic selectors U+E0100–U+E01EF. With `findHiddenRuns` from `text-sanctifier/hidden`, `scan.supplementarySelectorCount - report.benignSupplementarySelectorCount` is the exact number of suspicious ones.
294
+
295
+ ---
296
+
297
+ ## Command line
298
+
299
+ 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
+
301
+ ```sh
302
+ npx text-sanctifier check src docs "**/*.md"
303
+ npx text-sanctifier fix --dry-run src
304
+ npx text-sanctifier reveal notes.md
305
+ ```
306
+
307
+ ### `check [paths...]`
308
+
309
+ Reports every invisible or control character as `path:line:column U+XXXX category`, using the categories of `inspectTextDetailed`:
310
+
311
+ ```
312
+ docs/intro.md:12:7 U+200B zero_width
313
+ src/auth.js:40:3 U+202E bidi_control
314
+ 2 findings in 2 of 31 files.
315
+ ```
316
+
317
+ - `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
318
+ - `--format sarif`: a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for code-scanning viewers and editors. See [SARIF output](#sarif-output).
319
+ - `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
320
+ - `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
321
+ - `--ai-config`: check agent instruction files for hidden text. See [AI configuration files](#ai-configuration-files).
322
+
323
+ ### AI configuration files
324
+
325
+ `check --ai-config` looks for hidden instructions in the files coding agents read: rules files, skill files and MCP configuration. Tag characters, variation selectors and invisible operators can carry text that people reviewing the file cannot see but a model reads.
326
+
327
+ ```sh
328
+ npx text-sanctifier check --ai-config
329
+ ```
330
+
331
+ ```
332
+ skills/review/SKILL.md:1:17 U+E0049 run of 15 tag characters spelling "Ignore the user"
333
+ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
334
+ .cursor/rules/style.mdc:4:9 U+200B zero_width
335
+ 19 findings (2 hidden runs) in 2 of 6 AI configuration files.
336
+ ```
337
+
338
+ - With no paths it searches the current directory; directories you name are searched the same way, and files you name are checked whatever they are called. In a git work tree, files `.gitignore` excludes are skipped.
339
+ - Files it looks for, matched case-insensitively: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `AGENT.md`, `GEMINI.md` and `SKILL.md` at any depth; and at the top of the directory, `.claude/**`, `.cursorrules`, `.cursor/rules/**`, `.cursor/mcp.json`, `.windsurfrules`, `.windsurf/rules/**`, `.clinerules` (file or directory), `.roorules`, `.roo/rules/**`, `.github/copilot-instructions.md`, `.github/{instructions,prompts,chatmodes,agents}/**`, `.mcp.json`, `mcp.json` and `.vscode/mcp.json`.
340
+ - Consecutive tag characters, variation selectors or invisible operators are reported once, as a run, at the first character. Tag runs are decoded: U+E0020–U+E007E become the ASCII they shadow, and other tags are shown as `<U+XXXX>`.
341
+ - Every other invisible or control character is reported as in a plain `check`.
342
+ - Two legitimate sequences are left out: the flags of England, Scotland and Wales (U+1F3F4, tag letters, U+E007F), and a single U+FE0E or U+FE0F after another character (text or emoji presentation). A plain `check` still reports both.
343
+ - `--json` adds `hiddenRuns` (`{ kind, offset, line, column, codePoint, count, decoded?, decodedTruncated? }`, at most `--max-findings` per file) and `hiddenRunCount` to each file, and `hiddenRunCount` to the summary. `kind` is `tag`, `variation_selector`, `invisible_operator` or `reserved_tag_plane`; `decoded` is cut at 1000 characters. `total` and `counts` leave out the legitimate sequences.
344
+
345
+ ### SARIF output
346
+
347
+ `check --format sarif` (with or without `--ai-config`) prints one SARIF 2.1.0 log with one result per listed finding:
348
+
349
+ - `ruleId` is the category, and `tool.driver.rules` lists every category.
350
+ - `level`: `error` for `bidi_control` and `tag`, `note` for `nonbreaking_space`, `warning` for the rest.
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
+ - With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
353
+ - `--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
+ ### pre-commit
356
+
357
+ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, so the check runs locally on the files being committed:
358
+
359
+ ```yaml
360
+ # .pre-commit-config.yaml
361
+ repos:
362
+ - repo: https://github.com/iWhatty/text-sanctifier
363
+ rev: v1.3.8 # a release tag
364
+ hooks:
365
+ - id: text-sanctifier # every staged text file
366
+ - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
367
+ ```
368
+
369
+ - `text-sanctifier` runs `text-sanctifier check` on staged text files. Use `exclude:` for files that legitimately contain these characters, or `args: [--strip-bom]` and the other `check` options.
370
+ - `text-sanctifier-ai-config` runs `text-sanctifier check --ai-config` on staged files that match the `--ai-config` file list.
371
+ - A hook fails the commit when it finds something (exit code 1). Neither hook changes files; run `text-sanctifier fix` yourself.
372
+
373
+ ### `fix [paths...]`
374
+
375
+ Rewrites files in place and reports what it replaced and removed.
376
+
377
+ - `--normalize-spaces`: `normalizeSpaces` (look-alike spaces become ordinary spaces)
378
+ - `--purge-invisible`: `purgeInvisibleChars`
379
+ - `--nuke-controls`: `nukeControls`
380
+ - `--normalize-newlines`: `normalizeNewlines` (CRLF and CR to LF)
381
+ - 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.
382
+ - `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
383
+ - `--dry-run`: write nothing; report what would change.
384
+ - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`.
385
+
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`.
387
+
388
+ ### `reveal [paths...]`
389
+
390
+ Prints each input with every character `check` would report replaced by a marker such as `<U+200B>` (see `revealInvisible`). Everything else, including newlines, is printed as it is, and files are never changed. Several inputs are printed one after another under `==> path <==` headers. `reveal` takes no options and exits 0 unless an input can't be read.
391
+
392
+ ```sh
393
+ npx text-sanctifier reveal AGENTS.md
394
+ git diff | npx text-sanctifier reveal
395
+ ```
396
+
397
+ ### Inputs
398
+
399
+ - Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
400
+ - Inside a git work tree, directories and globs list files with `git ls-files`, so anything `.gitignore` excludes is skipped. Outside git, `.git` and `node_modules` are skipped. A file named explicitly is always read.
401
+ - Binary files (a NUL byte in the first 8000 bytes) and files that are not valid UTF-8 are skipped, and `fix` never writes them. In JSON they appear under `skipped`.
402
+ - `-`, or no paths with piped input, reads stdin. `fix` writes the cleaned text to stdout and the report to stderr.
403
+
404
+ ### Exit codes
405
+
406
+ | Code | `check` | `fix` | `reveal` |
407
+ | --- | --- | --- | --- |
408
+ | 0 | nothing found | done (or nothing to do) | done |
409
+ | 1 | findings | `--dry-run` with changes pending | not used |
410
+ | 2 | usage error, missing path or unreadable file | same | same |
411
+
412
+ ---
413
+
414
+ ## Notes
415
+
416
+ ### Runtime requirements
417
+
418
+ Requires a modern JavaScript runtime with ES2020+ support:
419
+
420
+ - Node.js 18+ (`engines.node >=18`)
421
+ - Modern evergreen browsers
422
+
423
+ ### Package and build info
424
+
425
+ - **Source (`src/`):** ES2020+ ESM modules with JSDoc
426
+ - **ESM only:** no CommonJS build; `require('text-sanctifier')` works only on Node versions with `require(esm)` support (20.19+, 22.12+)
427
+ - **Types:** TypeScript declarations ship in `dist/types`
428
+ - **Browser build (`dist/`):** one minified ESM file with every export, for pages with no build step (`<script type="module">` from a CDN). It is imported as `text-sanctifier/browser`. With a bundler, import `text-sanctifier` instead: the source tree-shakes, and the prebuilt file does not
429
+ - **Tree-shake friendly:** ships `sideEffects: false`; `npm run ci` checks the bundled size of each export on its own (for example `inspectTextDetailed` is under 1 KB gzipped)
430
+ - **Zero transpilation:** no built-in polyfills or runtime overhead
431
+ - **Bundler ready:** works with Vite, Rollup, Webpack, Parcel, esbuild
432
+
433
+ ---
434
+
435
+ ## License
436
+
437
+ Licensed under AGPL-3.0 with WATT3D Additional Terms. See [LICENSE](./LICENSE) and [ADDITIONAL_TERMS.md](./ADDITIONAL_TERMS.md). Commercial AI/model-training use requires compliance with those terms or a separate WATT3D license. © WATT3D.
@@ -3,4 +3,4 @@ var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u2
3
3
  `){return e.replace(Q,r)}var re=/[ \t]*(\n+)[ \t]*/g;function ne(e){return e.replace(re,"$1")}var te=/\n{2,}/g,ie=/\n{3,}/g;function oe(e,r){return r?e.replace(ie,`
4
4
 
5
5
  `):e.replace(te,`
6
- `)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.7";export{fe as VERSION,b as getNewlineStats,K as getRecommendedSanctifierOptions,j as inspectText,C as inspectTextDetailed,ae as revealInvisible,T as sanctifyKeyboardOnly,N as sanctifyKeyboardOnlyEmoji,w as sanctifyLoose,L as sanctifyPasted,R as sanctifyStrict,W as summonSanctifier};
6
+ `)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.8";export{fe as VERSION,b as getNewlineStats,K as getRecommendedSanctifierOptions,j as inspectText,C as inspectTextDetailed,ae as revealInvisible,T as sanctifyKeyboardOnly,N as sanctifyKeyboardOnlyEmoji,w as sanctifyLoose,L as sanctifyPasted,R as sanctifyStrict,W as summonSanctifier};
@@ -3,6 +3,10 @@
3
3
  * the ones that belong to legitimate sequences as benign:
4
4
  * - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
5
5
  * VS16 after the first: an emoji ZWJ sequence
6
+ * - U+200C or U+200D right after a virama (Devanagari, Bengali, Malayalam, Sinhala and
7
+ * the other scripts with a combining class 9 mark), as IDNA ContextJ allows
8
+ * - U+200C between two Arabic-script letters, nonspacing marks skipped (Persian and
9
+ * others); an approximation of IDNA's joining-type rule
6
10
  * - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
7
11
  * - one of U+E0100–U+E01EF directly after an ideograph: an ideographic variation sequence
8
12
  * - U+180B–U+180D or U+180F directly after a Mongolian letter
@@ -53,4 +57,10 @@ export type ContextReport = {
53
57
  * - Sum of `benignCounts`.
54
58
  */
55
59
  benignTotal: number;
60
+ /**
61
+ * - Benign U+E0100–U+E01EF (VS17–VS256),
62
+ * part of `benignCounts.variation_selector`. `supplementarySelectorCount` from findHiddenRuns
63
+ * minus this is the number of suspicious ones.
64
+ */
65
+ benignSupplementarySelectorCount: number;
56
66
  };
@@ -1,2 +1,2 @@
1
1
  /** The installed version of text-sanctifier, as in its package.json. */
2
- export const VERSION: "1.3.7";
2
+ export const VERSION: "1.3.8";
@@ -0,0 +1 @@
1
+ export const VIRAMA_AT: RegExp;
package/package.json CHANGED
@@ -1,83 +1,83 @@
1
- {
2
- "name": "text-sanctifier",
3
- "version": "1.3.7",
4
- "type": "module",
5
- "description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
6
- "engines": {
7
- "node": ">=18"
8
- },
9
- "main": "./src/index.js",
10
- "module": "./src/index.js",
11
- "browser": "./dist/text-sanctifier.min.js",
12
- "bin": {
13
- "text-sanctifier": "bin/text-sanctifier.js"
14
- },
15
- "files": [
16
- "src",
17
- "bin",
18
- "dist/text-sanctifier.min.js",
19
- "dist/types",
20
- "LICENSE",
21
- "README.md",
22
- "ADDITIONAL_TERMS.md",
23
- "robots.txt"
24
- ],
25
- "exports": {
26
- ".": {
27
- "types": "./dist/types/index.d.ts",
28
- "default": "./src/index.js"
29
- },
30
- "./hidden": {
31
- "types": "./dist/types/hiddenText.d.ts",
32
- "default": "./src/hiddenText.js"
33
- },
34
- "./context": {
35
- "types": "./dist/types/context.d.ts",
36
- "default": "./src/context.js"
37
- },
38
- "./browser": {
39
- "types": "./dist/types/index.d.ts",
40
- "default": "./dist/text-sanctifier.min.js"
41
- }
42
- },
43
- "types": "./dist/types/index.d.ts",
44
- "sideEffects": false,
45
- "scripts": {
46
- "gen": "node scripts/gen-categories.js && node scripts/gen-version.js",
47
- "gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check",
48
- "build": "node scripts/build.js",
49
- "build:types": "tsc -p tsconfig.types.json",
50
- "test:types": "tsc -p tests/types/tsconfig.json",
51
- "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js",
52
- "test-min": "node tests/sanctifyText.test.min.js",
53
- "ci": "npm run gen:check && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
54
- "size": "node scripts/check-size.js"
55
- },
56
- "keywords": [
57
- "text",
58
- "sanitize",
59
- "normalize",
60
- "invisible",
61
- "scrubber",
62
- "unicode",
63
- "clean",
64
- "sanctify"
65
- ],
66
- "author": "WATT3D <WATT3D@protonmail.com>",
67
- "license": "SEE LICENSE IN LICENSE",
68
- "repository": {
69
- "type": "git",
70
- "url": "git+https://github.com/iWhatty/text-sanctifier.git"
71
- },
72
- "bugs": {
73
- "url": "https://github.com/iWhatty/text-sanctifier/issues"
74
- },
75
- "homepage": "https://github.com/iWhatty/text-sanctifier#readme",
76
- "directories": {
77
- "test": "tests"
78
- },
79
- "devDependencies": {
80
- "esbuild": "^0.25.3",
81
- "typescript": "^5.9.3"
82
- }
83
- }
1
+ {
2
+ "name": "text-sanctifier",
3
+ "version": "1.3.8",
4
+ "type": "module",
5
+ "description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "main": "./src/index.js",
10
+ "module": "./src/index.js",
11
+ "browser": "./dist/text-sanctifier.min.js",
12
+ "bin": {
13
+ "text-sanctifier": "bin/text-sanctifier.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin",
18
+ "dist/text-sanctifier.min.js",
19
+ "dist/types",
20
+ "LICENSE",
21
+ "README.md",
22
+ "ADDITIONAL_TERMS.md",
23
+ "robots.txt"
24
+ ],
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/types/index.d.ts",
28
+ "default": "./src/index.js"
29
+ },
30
+ "./hidden": {
31
+ "types": "./dist/types/hiddenText.d.ts",
32
+ "default": "./src/hiddenText.js"
33
+ },
34
+ "./context": {
35
+ "types": "./dist/types/context.d.ts",
36
+ "default": "./src/context.js"
37
+ },
38
+ "./browser": {
39
+ "types": "./dist/types/index.d.ts",
40
+ "default": "./dist/text-sanctifier.min.js"
41
+ }
42
+ },
43
+ "types": "./dist/types/index.d.ts",
44
+ "sideEffects": false,
45
+ "scripts": {
46
+ "gen": "node scripts/gen-categories.js && node scripts/gen-version.js && node scripts/gen-virama.js",
47
+ "gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check && node scripts/gen-virama.js --check",
48
+ "build": "node scripts/build.js",
49
+ "build:types": "tsc -p tsconfig.types.json",
50
+ "test:types": "tsc -p tests/types/tsconfig.json",
51
+ "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js",
52
+ "test-min": "node tests/sanctifyText.test.min.js",
53
+ "ci": "npm run gen:check && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
54
+ "size": "node scripts/check-size.js"
55
+ },
56
+ "keywords": [
57
+ "text",
58
+ "sanitize",
59
+ "normalize",
60
+ "invisible",
61
+ "scrubber",
62
+ "unicode",
63
+ "clean",
64
+ "sanctify"
65
+ ],
66
+ "author": "WATT3D <WATT3D@protonmail.com>",
67
+ "license": "SEE LICENSE IN LICENSE",
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "git+https://github.com/iWhatty/text-sanctifier.git"
71
+ },
72
+ "bugs": {
73
+ "url": "https://github.com/iWhatty/text-sanctifier/issues"
74
+ },
75
+ "homepage": "https://github.com/iWhatty/text-sanctifier#readme",
76
+ "directories": {
77
+ "test": "tests"
78
+ },
79
+ "devDependencies": {
80
+ "esbuild": "^0.25.3",
81
+ "typescript": "^5.9.3"
82
+ }
83
+ }
package/src/context.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // src/context.js
2
2
  //
3
3
  // inspectTextInContext: inspectTextDetailed plus which findings belong to legitimate
4
- // sequences (emoji joiners and selectors, ideographic variation sequences, Mongolian
5
- // selectors, subdivision flags), counted exactly per category. The categories are
4
+ // sequences (emoji joiners and selectors, joiners in Indic and Arabic-script text,
5
+ // ideographic variation sequences, Mongolian selectors, subdivision flags), counted
6
+ // exactly per category. The categories are
6
7
  // unchanged; see "Proposal: context-aware findings" in DESIGN.md.
7
8
  //
8
9
  // Exported as `text-sanctifier/context`. It needs Unicode property escapes in regexes,
9
10
  // so it lives apart from the core and the browser file and has its own size budget.
10
11
 
11
12
  import { inspectTextDetailed } from './inspectText.js';
13
+ import { VIRAMA_AT } from './virama.generated.js';
12
14
 
13
15
  /** @typedef {import('./inspectText.js').TrashFinding} TrashFinding */
14
16
  /** @typedef {import('./inspectText.js').TrashCounts} TrashCounts */
@@ -28,11 +30,16 @@ import { inspectTextDetailed } from './inspectText.js';
28
30
  * @property {TrashCounts} benignCounts - Benign occurrences per category, listed or not.
29
31
  * `counts[c] - benignCounts[c]` is the number of suspicious characters in category `c`.
30
32
  * @property {number} benignTotal - Sum of `benignCounts`.
33
+ * @property {number} benignSupplementarySelectorCount - Benign U+E0100–U+E01EF (VS17–VS256),
34
+ * part of `benignCounts.variation_selector`. `supplementarySelectorCount` from findHiddenRuns
35
+ * minus this is the number of suspicious ones.
31
36
  */
32
37
 
33
38
  // Tested at an index of the text itself, so nothing is allocated per character.
34
39
  const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
35
40
  const IDEOGRAPHIC_AT = /\p{Ideographic}/yu;
41
+ const ARABIC_LETTER_AT = /(?=\p{Script=Arabic})\p{L}/yu;
42
+ const MARK_AT = /\p{Mn}/yu;
36
43
 
37
44
  // RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
38
45
  const SUBDIVISION_FLAGS = ['gbeng', 'gbsct', 'gbwls'];
@@ -80,6 +87,29 @@ function isEmojiJoiner(text, i) {
80
87
  return p >= 0 && matchesAt(PICTOGRAPHIC_AT, text, p) && matchesAt(PICTOGRAPHIC_AT, text, i + 1);
81
88
  }
82
89
 
90
+ /**
91
+ * IDNA ContextJ (RFC 5892, appendix A.1 and A.2): a U+200C or U+200D is benign right
92
+ * after a virama, and a U+200C also between two Arabic-script letters, skipping
93
+ * nonspacing marks on either side. The second rule approximates the RFC's joining types
94
+ * (L or D before, R or D after), which JavaScript regexes can't express: it also allows
95
+ * a ZWNJ after a right-joining or non-joining Arabic letter, and none in other joining
96
+ * scripts such as Syriac or N'Ko.
97
+ * @param {string} text
98
+ * @param {number} i - index of the U+200C or U+200D
99
+ * @param {boolean} zwnj - true for U+200C
100
+ */
101
+ function isScriptJoiner(text, i, zwnj) {
102
+ let p = previousStart(text, i);
103
+ if (p < 0) return false;
104
+ if (matchesAt(VIRAMA_AT, text, p)) return true;
105
+ if (!zwnj) return false;
106
+ while (p >= 0 && matchesAt(MARK_AT, text, p)) p = previousStart(text, p);
107
+ if (p < 0 || !matchesAt(ARABIC_LETTER_AT, text, p)) return false;
108
+ let q = i + 1;
109
+ while (matchesAt(MARK_AT, text, q)) q = MARK_AT.lastIndex;
110
+ return matchesAt(ARABIC_LETTER_AT, text, q);
111
+ }
112
+
83
113
  /**
84
114
  * Length in UTF-16 units of the subdivision-flag tags starting at `i` (just after a
85
115
  * U+1F3F4), or 0 when they are not exactly one of the RGI flags, as findHiddenRuns decides.
@@ -106,6 +136,10 @@ function flagTagsAt(text, i) {
106
136
  * the ones that belong to legitimate sequences as benign:
107
137
  * - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
108
138
  * VS16 after the first: an emoji ZWJ sequence
139
+ * - U+200C or U+200D right after a virama (Devanagari, Bengali, Malayalam, Sinhala and
140
+ * the other scripts with a combining class 9 mark), as IDNA ContextJ allows
141
+ * - U+200C between two Arabic-script letters, nonspacing marks skipped (Persian and
142
+ * others); an approximation of IDNA's joining-type rule
109
143
  * - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
110
144
  * - one of U+E0100–U+E01EF directly after an ideograph: an ideographic variation sequence
111
145
  * - U+180B–U+180D or U+180F directly after a Mongolian letter
@@ -132,6 +166,7 @@ export function inspectTextInContext(text, options = {}) {
132
166
  const benignCounts = /** @type {TrashCounts} */ ({});
133
167
  for (const key of Object.keys(report.counts)) benignCounts[/** @type {keyof TrashCounts} */ (key)] = 0;
134
168
  let benignTotal = 0;
169
+ let benignSupplementarySelectorCount = 0;
135
170
  let next = 0; // first listed finding not yet passed
136
171
 
137
172
  /**
@@ -150,7 +185,9 @@ export function inspectTextInContext(text, options = {}) {
150
185
  if (c < 0x180B) continue; // the common case: nothing below can be benign
151
186
 
152
187
  if (c === 0x200D) {
153
- if (isEmojiJoiner(text, i)) markBenign(i, 'zero_width');
188
+ if (isEmojiJoiner(text, i) || isScriptJoiner(text, i, false)) markBenign(i, 'zero_width');
189
+ } else if (c === 0x200C) {
190
+ if (isScriptJoiner(text, i, true)) markBenign(i, 'zero_width');
154
191
  } else if (c === 0xFE0E || c === 0xFE0F) {
155
192
  const p = previousStart(text, i);
156
193
  const before = p < 0 ? -1 : text.charCodeAt(p);
@@ -167,11 +204,14 @@ export function inspectTextInContext(text, options = {}) {
167
204
  const low = text.charCodeAt(i + 1);
168
205
  if (low >= 0xDD00 && low <= 0xDDEF) { // U+E0100-U+E01EF
169
206
  const p = previousStart(text, i);
170
- if (p >= 0 && matchesAt(IDEOGRAPHIC_AT, text, p)) markBenign(i, 'variation_selector');
207
+ if (p >= 0 && matchesAt(IDEOGRAPHIC_AT, text, p)) {
208
+ markBenign(i, 'variation_selector');
209
+ benignSupplementarySelectorCount++;
210
+ }
171
211
  }
172
212
  if (low >= 0xDC00 && low <= 0xDFFF) i++;
173
213
  }
174
214
  }
175
215
 
176
- return { findings, counts: report.counts, total: report.total, truncated: report.truncated, benignCounts, benignTotal };
216
+ return { findings, counts: report.counts, total: report.total, truncated: report.truncated, benignCounts, benignTotal, benignSupplementarySelectorCount };
177
217
  }
@@ -1,5 +1,5 @@
1
- // src/version.generated.js
2
- // Generated from package.json by scripts/gen-version.js (`npm run gen`); do not edit.
3
-
4
- /** The installed version of text-sanctifier, as in its package.json. */
5
- export const VERSION = '1.3.7';
1
+ // src/version.generated.js
2
+ // Generated from package.json by scripts/gen-version.js (`npm run gen`); do not edit.
3
+
4
+ /** The installed version of text-sanctifier, as in its package.json. */
5
+ export const VERSION = '1.3.8';
@@ -0,0 +1,5 @@
1
+ // src/virama.generated.js
2
+ // Generated from Node's normalization data by scripts/gen-virama.js (`npm run gen`); do not edit.
3
+
4
+ // The 69 code points with Canonical_Combining_Class 9 (Virama). Sticky: tested at an index.
5
+ export const VIRAMA_AT = /[\u094D\u09CD\u0A4D\u0ACD\u0B4D\u0BCD\u0C4D\u0CCD\u0D3B\u0D3C\u0D4D\u0DCA\u0E3A\u0EBA\u0F84\u1039\u103A\u1714\u1715\u1734\u17D2\u1A60\u1B44\u1BAA\u1BAB\u1BF2\u1BF3\u2D7F\uA806\uA82C\uA8C4\uA953\uA9C0\uAAF6\uABED\u{10A3F}\u{11046}\u{11070}\u{1107F}\u{110B9}\u{11133}\u{11134}\u{111C0}\u{11235}\u{112EA}\u{1134D}\u{113CE}-\u{113D0}\u{11442}\u{114C2}\u{115BF}\u{1163F}\u{116B6}\u{1172B}\u{11839}\u{1193D}\u{1193E}\u{119E0}\u{11A34}\u{11A47}\u{11A99}\u{11C3F}\u{11D44}\u{11D45}\u{11D97}\u{11F41}\u{11F42}\u{1612F}]/yu;