text-sanctifier 1.3.4 → 1.3.5
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 +62 -13
- package/bin/text-sanctifier.js +47 -6
- package/dist/text-sanctifier.min.js +6 -15
- package/dist/types/index.d.ts +3 -1
- package/dist/types/reveal.d.ts +26 -0
- package/dist/types/sanctifyText.d.ts +35 -1
- package/package.json +79 -80
- package/src/index.d.ts +3 -1
- package/src/index.js +4 -0
- package/src/reveal.js +40 -0
- package/src/sanctifyText.js +59 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://github.com/iWhatty/text-sanctifier)
|
|
8
8
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
9
9
|
|
|
10
|
-
Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.
|
|
10
|
+
Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.87 KB gzipped.
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
@@ -15,6 +15,8 @@ Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~
|
|
|
15
15
|
- Purges bidi controls, including the isolates (U+2066–U+2069) and the Arabic letter mark (U+061C) used in Trojan Source attacks
|
|
16
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
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`)
|
|
18
20
|
- `text-sanctifier check` / `fix` command line for CI and coding agents, with JSON or SARIF output, exit codes and pre-commit hooks
|
|
19
21
|
- `check --ai-config` finds and decodes hidden text in agent instruction files (`CLAUDE.md`, `AGENTS.md`, Cursor rules, `SKILL.md`, MCP configuration)
|
|
20
22
|
- Normalizes line endings (CRLF, CR, LF) to LF
|
|
@@ -81,6 +83,20 @@ const output = summonSanctifier.keyboardOnly(userInput);
|
|
|
81
83
|
const output = summonSanctifier.keyboardOnlyEmoji(commentText);
|
|
82
84
|
```
|
|
83
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
|
+
|
|
84
100
|
---
|
|
85
101
|
|
|
86
102
|
## Security notes
|
|
@@ -98,6 +114,11 @@ const output = summonSanctifier.keyboardOnlyEmoji(commentText);
|
|
|
98
114
|
|
|
99
115
|
Creates a reusable sanitizer from an option object.
|
|
100
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
|
+
|
|
101
122
|
### `summonSanctifier.strict`
|
|
102
123
|
|
|
103
124
|
Aggressively purges: emojis, control characters, extra spacing, and newlines.
|
|
@@ -114,6 +135,23 @@ Restricts to printable ASCII only (removes emojis).
|
|
|
114
135
|
|
|
115
136
|
Restricts to printable ASCII + full emoji sequences. Preserves ZWJ emoji clusters and emoji presentation variants.
|
|
116
137
|
|
|
138
|
+
### `summonSanctifier.pasted`
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
### `revealInvisible(text: string, options?: RevealOptions): string`
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
import { revealInvisible } from 'text-sanctifier';
|
|
148
|
+
|
|
149
|
+
revealInvisible('a\u200Bb\u{E0041}'); // 'a<U+200B>b<U+E0041>'
|
|
150
|
+
revealInvisible('a\u202Eb', { format: (codePoint, category) => `[${category}]` }); // 'a[bidi_control]b'
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
117
155
|
### `inspectText(text: string): UnicodeTrashReport`
|
|
118
156
|
|
|
119
157
|
Returns a structural report of control codes, invisible chars, newline styles, and more.
|
|
@@ -224,6 +262,7 @@ The package ships a `text-sanctifier` command for CI and coding agents, which ca
|
|
|
224
262
|
```sh
|
|
225
263
|
npx text-sanctifier check src docs "**/*.md"
|
|
226
264
|
npx text-sanctifier fix --dry-run src
|
|
265
|
+
npx text-sanctifier reveal notes.md
|
|
227
266
|
```
|
|
228
267
|
|
|
229
268
|
### `check [paths...]`
|
|
@@ -282,7 +321,7 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
|
|
|
282
321
|
# .pre-commit-config.yaml
|
|
283
322
|
repos:
|
|
284
323
|
- repo: https://github.com/iWhatty/text-sanctifier
|
|
285
|
-
rev: v1.3.
|
|
324
|
+
rev: v1.3.5 # a release tag
|
|
286
325
|
hooks:
|
|
287
326
|
- id: text-sanctifier # every staged text file
|
|
288
327
|
- id: text-sanctifier-ai-config # agent instruction files, with --ai-config
|
|
@@ -294,17 +333,27 @@ repos:
|
|
|
294
333
|
|
|
295
334
|
### `fix [paths...]`
|
|
296
335
|
|
|
297
|
-
Rewrites files in place and reports what it removed.
|
|
336
|
+
Rewrites files in place and reports what it replaced and removed.
|
|
298
337
|
|
|
338
|
+
- `--normalize-spaces`: `normalizeSpaces` (look-alike spaces become ordinary spaces)
|
|
299
339
|
- `--purge-invisible`: `purgeInvisibleChars`
|
|
300
340
|
- `--nuke-controls`: `nukeControls`
|
|
301
341
|
- `--normalize-newlines`: `normalizeNewlines` (CRLF and CR to LF)
|
|
302
|
-
- With none of
|
|
342
|
+
- 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.
|
|
303
343
|
- `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
|
|
304
344
|
- `--dry-run`: write nothing; report what would change.
|
|
305
|
-
- `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, removed, removedTotal, newlinesNormalized }`.
|
|
345
|
+
- `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`.
|
|
306
346
|
|
|
307
|
-
`fix` deletes characters
|
|
347
|
+
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`.
|
|
348
|
+
|
|
349
|
+
### `reveal [paths...]`
|
|
350
|
+
|
|
351
|
+
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.
|
|
352
|
+
|
|
353
|
+
```sh
|
|
354
|
+
npx text-sanctifier reveal AGENTS.md
|
|
355
|
+
git diff | npx text-sanctifier reveal
|
|
356
|
+
```
|
|
308
357
|
|
|
309
358
|
### Inputs
|
|
310
359
|
|
|
@@ -315,11 +364,11 @@ Rewrites files in place and reports what it removed.
|
|
|
315
364
|
|
|
316
365
|
### Exit codes
|
|
317
366
|
|
|
318
|
-
| Code | `check` | `fix` |
|
|
319
|
-
| --- | --- | --- |
|
|
320
|
-
| 0 | nothing found | done (or nothing to do) |
|
|
321
|
-
| 1 | findings | `--dry-run` with changes pending |
|
|
322
|
-
| 2 | usage error, missing path or unreadable file | same |
|
|
367
|
+
| Code | `check` | `fix` | `reveal` |
|
|
368
|
+
| --- | --- | --- | --- |
|
|
369
|
+
| 0 | nothing found | done (or nothing to do) | done |
|
|
370
|
+
| 1 | findings | `--dry-run` with changes pending | not used |
|
|
371
|
+
| 2 | usage error, missing path or unreadable file | same | same |
|
|
323
372
|
|
|
324
373
|
---
|
|
325
374
|
|
|
@@ -337,8 +386,8 @@ Requires a modern JavaScript runtime with ES2020+ support:
|
|
|
337
386
|
- **Source (`src/`):** ES2020+ ESM modules with JSDoc
|
|
338
387
|
- **ESM only:** no CommonJS build; `require('text-sanctifier')` works only on Node versions with `require(esm)` support (20.19+, 22.12+)
|
|
339
388
|
- **Types:** TypeScript declarations ship in `dist/types`
|
|
340
|
-
- **Browser build (`dist/`):**
|
|
341
|
-
- **Tree-shake friendly:** ships `sideEffects: false`
|
|
389
|
+
- **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
|
|
390
|
+
- **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)
|
|
342
391
|
- **Zero transpilation:** no built-in polyfills or runtime overhead
|
|
343
392
|
- **Bundler ready:** works with Vite, Rollup, Webpack, Parcel, esbuild
|
|
344
393
|
|
package/bin/text-sanctifier.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// text-sanctifier check [paths...] report invisible and control characters
|
|
6
6
|
// text-sanctifier check --ai-config report hidden text in agent instruction files
|
|
7
7
|
// text-sanctifier fix [paths...] remove them in place
|
|
8
|
+
// text-sanctifier reveal [paths...] print the text with hidden characters shown as <U+XXXX>
|
|
8
9
|
//
|
|
9
10
|
// Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
|
|
10
11
|
// or fix --dry-run with changes pending), 2 usage or I/O error.
|
|
@@ -13,8 +14,9 @@ import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from '
|
|
|
13
14
|
import { resolve, join, sep, relative } from 'node:path';
|
|
14
15
|
import { spawnSync } from 'node:child_process';
|
|
15
16
|
import { pathToFileURL } from 'node:url';
|
|
16
|
-
import { inspectTextDetailed, summonSanctifier } from '../src/index.js';
|
|
17
|
+
import { inspectTextDetailed, summonSanctifier, revealInvisible } from '../src/index.js';
|
|
17
18
|
import { getNewlineStats } from '../src/inspectText.js';
|
|
19
|
+
import { SPACE_LOOKALIKES_REGEX } from '../src/sanctifyText.js';
|
|
18
20
|
import { findHiddenRuns } from '../src/hiddenText.js';
|
|
19
21
|
|
|
20
22
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
@@ -24,6 +26,7 @@ const USAGE = `text-sanctifier ${VERSION}
|
|
|
24
26
|
Usage:
|
|
25
27
|
text-sanctifier check [options] [paths...]
|
|
26
28
|
text-sanctifier fix [options] [paths...]
|
|
29
|
+
text-sanctifier reveal [paths...]
|
|
27
30
|
|
|
28
31
|
Paths may be files, directories or globs (quote globs so the shell leaves them
|
|
29
32
|
alone). "-", or no paths with piped input, reads stdin. Directories and globs
|
|
@@ -40,6 +43,8 @@ check options:
|
|
|
40
43
|
for those files, and tag text is decoded
|
|
41
44
|
|
|
42
45
|
fix options:
|
|
46
|
+
--normalize-spaces replace look-alike spaces (NBSP, narrow NBSP, typographic
|
|
47
|
+
spaces) with an ordinary space instead of removing them
|
|
43
48
|
--purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
|
|
44
49
|
--nuke-controls remove C0/C1 control characters and bidi controls
|
|
45
50
|
--normalize-newlines convert CRLF and CR to LF
|
|
@@ -47,9 +52,15 @@ fix options:
|
|
|
47
52
|
--dry-run report what would change; write nothing
|
|
48
53
|
--format F text (default) or json
|
|
49
54
|
--json same as --format json
|
|
50
|
-
With no cleaning flag, fix uses --purge-invisible --nuke-controls.
|
|
55
|
+
With no cleaning flag, fix uses --normalize-spaces --purge-invisible --nuke-controls.
|
|
51
56
|
stdin is cleaned to stdout; the report then goes to stderr.
|
|
52
57
|
|
|
58
|
+
reveal:
|
|
59
|
+
Prints each input with every character check would report replaced by a
|
|
60
|
+
marker such as <U+200B>; everything else, including newlines, is unchanged.
|
|
61
|
+
Several inputs are printed one after another under "==> path <==" headers.
|
|
62
|
+
Takes no options. Exit code 0 unless an input can't be read.
|
|
63
|
+
|
|
53
64
|
Other:
|
|
54
65
|
-h, --help show this help
|
|
55
66
|
-v, --version show the version
|
|
@@ -73,12 +84,13 @@ class UsageError extends Error {}
|
|
|
73
84
|
const BOOLEAN_FLAGS = {
|
|
74
85
|
'--ai-config': 'aiConfig',
|
|
75
86
|
'--strip-bom': 'stripBom',
|
|
87
|
+
'--normalize-spaces': 'normalizeSpaces',
|
|
76
88
|
'--purge-invisible': 'purgeInvisible',
|
|
77
89
|
'--nuke-controls': 'nukeControls',
|
|
78
90
|
'--normalize-newlines': 'normalizeNewlines',
|
|
79
91
|
'--dry-run': 'dryRun',
|
|
80
92
|
};
|
|
81
|
-
const FIX_ONLY_FLAGS = new Set(['purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
|
|
93
|
+
const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
|
|
82
94
|
const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
|
|
83
95
|
const FORMATS = new Set(['text', 'json', 'sarif']);
|
|
84
96
|
|
|
@@ -92,6 +104,7 @@ function parseArgs(argv) {
|
|
|
92
104
|
format: null,
|
|
93
105
|
aiConfig: false,
|
|
94
106
|
stripBom: false,
|
|
107
|
+
normalizeSpaces: false,
|
|
95
108
|
purgeInvisible: false,
|
|
96
109
|
nukeControls: false,
|
|
97
110
|
normalizeNewlines: false,
|
|
@@ -137,16 +150,25 @@ function parseArgs(argv) {
|
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
if (opts.help || opts.version) return opts;
|
|
140
|
-
if (opts.command !== 'check' && opts.command !== 'fix') {
|
|
153
|
+
if (opts.command !== 'check' && opts.command !== 'fix' && opts.command !== 'reveal') {
|
|
141
154
|
throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
|
|
142
155
|
}
|
|
156
|
+
if (opts.command === 'reveal') {
|
|
157
|
+
for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
|
|
158
|
+
if (opts[key]) throw new UsageError(`${flag} does not apply to reveal.`);
|
|
159
|
+
}
|
|
160
|
+
if (opts.format !== null) throw new UsageError('--format does not apply to reveal.');
|
|
161
|
+
if (opts.maxFindings !== 100) throw new UsageError('--max-findings does not apply to reveal.');
|
|
162
|
+
return opts;
|
|
163
|
+
}
|
|
143
164
|
for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
|
|
144
165
|
if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
|
|
145
166
|
if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
|
|
146
167
|
}
|
|
147
168
|
if (opts.format === null) opts.format = 'text';
|
|
148
169
|
if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
|
|
149
|
-
if (opts.command === 'fix' && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
|
|
170
|
+
if (opts.command === 'fix' && !opts.normalizeSpaces && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
|
|
171
|
+
opts.normalizeSpaces = true;
|
|
150
172
|
opts.purgeInvisible = true;
|
|
151
173
|
opts.nukeControls = true;
|
|
152
174
|
}
|
|
@@ -683,8 +705,22 @@ async function main(argv) {
|
|
|
683
705
|
return errors > 0 ? 2 : total > 0 ? 1 : 0;
|
|
684
706
|
}
|
|
685
707
|
|
|
708
|
+
if (opts.command === 'reveal') {
|
|
709
|
+
const many = inputs.length > 1;
|
|
710
|
+
let first = true;
|
|
711
|
+
await forEachText((label, text) => {
|
|
712
|
+
if (many) {
|
|
713
|
+
process.stdout.write(`${first ? '' : '\n'}==> ${label} <==\n`);
|
|
714
|
+
first = false;
|
|
715
|
+
}
|
|
716
|
+
process.stdout.write(revealInvisible(text));
|
|
717
|
+
});
|
|
718
|
+
return errors > 0 ? 2 : 0;
|
|
719
|
+
}
|
|
720
|
+
|
|
686
721
|
// fix
|
|
687
722
|
const clean = summonSanctifier({
|
|
723
|
+
normalizeSpaces: opts.normalizeSpaces,
|
|
688
724
|
purgeInvisibleChars: opts.purgeInvisible,
|
|
689
725
|
nukeControls: opts.nukeControls,
|
|
690
726
|
normalizeNewlines: opts.normalizeNewlines,
|
|
@@ -706,6 +742,10 @@ async function main(argv) {
|
|
|
706
742
|
|
|
707
743
|
const before = inspectTextDetailed(text, { maxFindings: 0 }).counts;
|
|
708
744
|
const after = inspectTextDetailed(cleaned, { maxFindings: 0 }).counts;
|
|
745
|
+
// Look-alike spaces that became ordinary spaces were replaced, not removed
|
|
746
|
+
SPACE_LOOKALIKES_REGEX.lastIndex = 0;
|
|
747
|
+
const spacesReplaced = opts.normalizeSpaces ? (text.match(SPACE_LOOKALIKES_REGEX) || []).length : 0;
|
|
748
|
+
before.nonbreaking_space -= spacesReplaced;
|
|
709
749
|
const removed = {};
|
|
710
750
|
let removedTotal = 0;
|
|
711
751
|
for (const k of Object.keys(before)) {
|
|
@@ -724,7 +764,7 @@ async function main(argv) {
|
|
|
724
764
|
return;
|
|
725
765
|
}
|
|
726
766
|
}
|
|
727
|
-
files.push({ path: label, removed, removedTotal, newlinesNormalized });
|
|
767
|
+
files.push({ path: label, spacesReplaced, removed, removedTotal, newlinesNormalized });
|
|
728
768
|
});
|
|
729
769
|
|
|
730
770
|
if (opts.format === 'json') {
|
|
@@ -733,6 +773,7 @@ async function main(argv) {
|
|
|
733
773
|
const verb = opts.dryRun ? 'would fix' : 'fixed';
|
|
734
774
|
for (const f of files) {
|
|
735
775
|
const parts = [];
|
|
776
|
+
if (f.spacesReplaced > 0) parts.push(`replaced ${plural(f.spacesReplaced, 'look-alike space')}`);
|
|
736
777
|
if (f.removedTotal > 0) parts.push(`removed ${f.removedTotal} (${formatCounts(f.removed)})`);
|
|
737
778
|
if (f.newlinesNormalized) parts.push('normalized newlines');
|
|
738
779
|
report(`${verb} ${f.path}: ${parts.join('; ')}`);
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
var
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,z=/\n{3,}/g,C=/ {2,}/g;function L(a,c){a.lastIndex=0;return a.test(c)}
|
|
8
|
-
function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],d={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(n,a)&&(d.hasControlChars=!0,c.push("Control characters detected."));L(q,a)&&(d.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(v,a)&&(d.hasEmojis=!0,c.push("Emojis detected."));const {mixed:f,types:h}=N(a);d.hasMixedNewlines=f;d.newlineStyle=
|
|
9
|
-
f?"Mixed":h[0]||null;d.newlineStyle&&c.push(f?"Mixed newline styles detected.":`Consistent newline style: ${d.newlineStyle}`);a:{a=E(a);if(L(D,a))for(g of a)if(!("\n"===g||"\r"===g||" "<=g&&"~">=g||L(v,g))){var g=!0;break a}g=!1}g&&(d.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return d}
|
|
10
|
-
function O(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const d={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},f=[];let h=0,g=1,l=0;for(let k=0,r=a.length;k<r;k++){var b=a.charCodeAt(k);if(32<=b&&127>b)continue;if(10===b){g++;l=k+1;continue}if(13===b){10!==a.charCodeAt(k+1)&&(g++,
|
|
11
|
-
l=k+1);continue}const m=k;var e=b;if(55296<=b&&56319>=b){e=a.charCodeAt(k+1);if(!(56320<=e&&57343>=e))continue;e=65536+(b-55296<<10)+(e-56320);b=917504>e?113828>e?113824>e?null:"invisible_format":119155>e?null:119163>e?"invisible_format":null:917760>e?917632>e?"tag":"invisible_format":918E3>e?"variation_selector":921600>e?"invisible_format":null;k++}else b=8192>b?174>b?32>b?11>b?1>b?"nul":9>b?"c0_control":null:13>b?"c0_control":14>b?null:"c0_control":160>b?127>b?null:128>b?"c0_control":"c1_control":
|
|
12
|
-
161>b?"nonbreaking_space":173>b?null:"invisible_format":4449>b?1564>b?847>b?null:848>b?"invisible_format":null:1565>b?"bidi_control":4447>b?null:"hangul_filler":6155>b?6068>b?null:6070>b?"invisible_format":null:6159>b?6158>b?"variation_selector":"invisible_format":6160>b?"variation_selector":null:8304>b?8240>b?8208>b?8203>b?"nonbreaking_space":8206>b?"zero_width":"bidi_control":8234>b?null:8239>b?"bidi_control":"nonbreaking_space":8289>b?8287>b?null:8288>b?"nonbreaking_space":"zero_width":8294>b?
|
|
13
|
-
"invisible_format":8298>b?"bidi_control":"invisible_format":65040>b?12644>b?12288>b?null:12289>b?"nonbreaking_space":null:12645>b?"hangul_filler":65024>b?null:"variation_selector":65440>b?65279>b?null:65280>b?"bom":null:65520>b?65441>b?"hangul_filler":null:65529>b?"invisible_format":null;null!==b&&(d[b]++,h++,f.length<c&&(e={offset:m,line:g,column:m-l+1,codePoint:e,category:b},"bom"===b&&(e.atStart=0===m),f.push(e)))}return{findings:f,counts:d,total:h,truncated:h>f.length}}
|
|
14
|
-
function N(a){if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var c=0;let d=0,f=0;for(let h=0,g=a.length;h<g;h++){const l=a.charCodeAt(h);13===l?10===a.charCodeAt(h+1)?(c++,h++):d++:10===l&&f++}a={crlf:c,cr:d,lf:f};c=[];0<a.crlf&&c.push("CRLF");0<a.cr&&c.push("CR");0<a.lf&&c.push("LF");return{...a,types:c,mixed:1<c.length}}
|
|
15
|
-
function P(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { t as summonSanctifier, M as inspectText, P as getRecommendedSanctifierOptions, O as inspectTextDetailed, N as getNewlineStats };
|
|
1
|
+
var d=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,C=/[\u00A0\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180F\u2000-\u200F\u202A-\u202F\u205F-\u206F\u3000\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF0-\uFFF8\u{1BCA0}-\u{1BCA3}\u{1D173}-\u{1D17A}\u{E0000}-\u{E0FFF}]+/gu;function I(e){return e<8192?R(e):N(e)}function R(e){return e<174?e<32?e<11?e<1?"nul":e<9?"c0_control":null:e<13?"c0_control":e<14?null:"c0_control":e<160?e<127?null:e<128?"c0_control":"c1_control":e<161?"nonbreaking_space":e<173?null:"invisible_format":e<4449?e<1564?e<847?null:e<848?"invisible_format":null:e<1565?"bidi_control":e<4447?null:"hangul_filler":e<6155?e<6068?null:e<6070?"invisible_format":null:e<6159?e<6158?"variation_selector":"invisible_format":e<6160?"variation_selector":null}function N(e){return e<8304?e<8240?e<8208?e<8203?"nonbreaking_space":e<8206?"zero_width":"bidi_control":e<8234?null:e<8239?"bidi_control":"nonbreaking_space":e<8289?e<8287?null:e<8288?"nonbreaking_space":"zero_width":e<8294?"invisible_format":e<8298?"bidi_control":"invisible_format":e<65040?e<12644?e<12288?null:e<12289?"nonbreaking_space":null:e<12645?"hangul_filler":e<65024?null:"variation_selector":e<65440?e<65279?null:e<65280?"bom":null:e<65520?e<65441?"hangul_filler":null:e<65529?"invisible_format":null}function b(e){return e<917504?e<113828?e<113824?null:"invisible_format":e<119155?null:e<119163?"invisible_format":null:e<917760?e<917632?"tag":"invisible_format":e<918e3?"variation_selector":e<921600?"invisible_format":null}function h(e={}){let r=!!e.normalizeSpaces,n=!!e.purgeZeroWidthSpaces,o=!!e.purgeInvisibleChars,u=!!e.purgeEmojis,i=!!e.nukeControls,l=!!e.keyboardOnlyFilter,c=!!e.normalizeNewlines,f=!!e.trimSpacesAroundNewlines,a=!!e.collapseNewLines,g=!!e.preserveParagraphs,s=!!e.collapseSpaces,E=!!e.finalTrim,F=Number.isFinite(e.maxLength)&&e.maxLength>=0?e.maxLength:1/0,x=e.onMaxLength==="truncate"||e.onMaxLength==="noop"||e.onMaxLength==="throw"?e.onMaxLength:"throw";return t=>_(t,o,u,i,l,c,f,a,g,s,E,F,x,r,n)}h.strict=e=>_(e,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);h.loose=e=>_(e,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);h.keyboardOnlyEmoji=e=>_(e,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);h.keyboardOnly=e=>_(e,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);h.pasted=e=>_(e,!1,!1,!1,!1,!0,!1,!1,!1,!1,!1,1/0,"throw",!0,!0);function _(e,r=!1,n=!1,o=!1,u=!1,i=!1,l=!1,c=!1,f=!1,a=!1,g=!1,s=1/0,E="throw",F=!1,x=!1){if(typeof e!="string")throw new TypeError("sanctifyText expects a string input.");if(E!=="truncate"&&E!=="noop"&&E!=="throw"&&(E="throw"),e.length>s)switch(E){case"truncate":e=e.slice(0,s);break;case"noop":return e;case"throw":default:throw new RangeError(`sanctifyText input length ${e.length} exceeds maxLength ${s}.`)}let t=e;return F&&(t=t.replace(T," ")),x&&(t=t.replace(L,"")),r&&(t=v(t)),n&&(t=z(t)),o&&(t=q(t)),u&&(t=B(t,n)),i&&(t=H(t)),l&&(t=O(t)),c&&(t=Z(t,f)),a&&(t=V(t)),g?t.trim():t}var T=/[\u00A0\u2000-\u200A\u202F\u205F]/g,L=/[\u200B\u2060\uFEFF]/g;function v(e){return e.replace(C,"")}function D(e){if(p.lastIndex=0,!p.test(e))return"";p.lastIndex=0;let r="";for(let n of e.matchAll(p))r+=n[0];return r}var S=/[^\x20-\x7E\r\n]+/gu;function B(e,r=!1){let n=A(e);return r?n.replace(S,""):n.replace(S,o=>D(o))}var G=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,X=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,k=/[\u2012\u2013\u2014\u2015\u2212]/g,U=/\u2026/g,M=/[\u2022\u00B7]/g,P=/[\uFF01-\uFF5E]/g;function A(e){return e.replace(G,"'").replace(X,'"').replace(k,"-").replace(U,"...").replace(M,"*").replace(P,r=>String.fromCharCode(r.charCodeAt(0)-65248))}var p;try{p=new RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{p=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}function z(e){return e.replace(p,"")}var j=/\r\n|\r|\n/g;function H(e,r=`
|
|
2
|
+
`){return e.replace(j,r)}var K=/[ \t]*(\n+)[ \t]*/g;function O(e){return e.replace(K,"$1")}var W=/\n{2,}/g,J=/\n{3,}/g;function Z(e,r){return r?e.replace(J,`
|
|
3
|
+
|
|
4
|
+
`):e.replace(W,`
|
|
5
|
+
`)}var $=/ {2,}/g;function V(e){return e.replace($," ")}function q(e){return e.replace(d,"")}function m(e,r){return e.lastIndex=0,e.test(r)}function Q(e){let r=A(e);if(!m(S,r))return!1;for(let n of r)if(!(n===`
|
|
6
|
+
`||n==="\r"||n>=" "&&n<="~")&&!m(p,n))return!0;return!1}function Y(e){if(typeof e!="string")throw new TypeError("inspectText expects a string input.");let r=[],n={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:r},o=(l,c,f)=>{l&&(n[c]=!0,r.push(f))};o(m(d,e),"hasControlChars","Control characters detected."),o(m(C,e),"hasInvisibleChars","Invisible Unicode characters detected."),o(m(p,e),"hasEmojis","Emojis detected.");let{mixed:u,types:i}=w(e);return n.hasMixedNewlines=u,n.newlineStyle=u?"Mixed":i[0]||null,n.newlineStyle&&r.push(u?"Mixed newline styles detected.":`Consistent newline style: ${n.newlineStyle}`),o(Q(e),"hasNonKeyboardChars","Non-keyboard characters detected."),n}var ee=100;function y(e,r={}){if(typeof e!="string")throw new TypeError("inspectTextDetailed expects a string input.");let n=r&&r.maxFindings,o=n===1/0||Number.isInteger(n)&&n>=0?n:ee,u={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},i=[],l=0,c=1,f=0;for(let a=0,g=e.length;a<g;a++){let s=e.charCodeAt(a);if(s>=32&&s<127)continue;if(s===10){c++,f=a+1;continue}if(s===13){e.charCodeAt(a+1)!==10&&(c++,f=a+1);continue}let E=a,F=s,x;if(s>=55296&&s<=56319){let t=e.charCodeAt(a+1);if(!(t>=56320&&t<=57343))continue;F=65536+(s-55296<<10)+(t-56320),x=b(F),a++}else x=I(s);if(x!==null&&(u[x]++,l++,i.length<o)){let t={offset:E,line:c,column:E-f+1,codePoint:F,category:x};x==="bom"&&(t.atStart=E===0),i.push(t)}}return{findings:i,counts:u,total:l,truncated:l>i.length}}function w(e){if(typeof e!="string")throw new TypeError("getNewlineStats expects a string input.");let r=0,n=0,o=0;for(let l=0,c=e.length;l<c;l++){let f=e.charCodeAt(l);f===13?e.charCodeAt(l+1)===10?(r++,l++):n++:f===10&&o++}let u={crlf:r,cr:n,lf:o},i=[];return u.crlf>0&&i.push("CRLF"),u.cr>0&&i.push("CR"),u.lf>0&&i.push("LF"),{...u,types:i,mixed:i.length>1}}function re(e){return{purgeInvisibleChars:e.hasInvisibleChars,purgeEmojis:e.hasEmojis,nukeControls:e.hasControlChars,keyboardOnlyFilter:e.hasNonKeyboardChars,normalizeNewlines:e.hasMixedNewlines||e.newlineStyle==="CRLF"||e.newlineStyle==="CR"}}function ne(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")+">",o="",u=0;for(let i of y(e,{maxFindings:1/0}).findings)o+=e.slice(u,i.offset)+n(i.codePoint,i.category),u=i.offset+(i.codePoint>65535?2:1);return o+e.slice(u)}export{w as getNewlineStats,re as getRecommendedSanctifierOptions,Y as inspectText,y as inspectTextDetailed,ne as revealInvisible,h as summonSanctifier};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -6,9 +6,11 @@ export type DetailedTrashReport = import("./inspectText.js").DetailedTrashReport
|
|
|
6
6
|
export type InspectDetailedOptions = import("./inspectText.js").InspectDetailedOptions;
|
|
7
7
|
export type NewlineStats = import("./inspectText.js").NewlineStats;
|
|
8
8
|
export type SanctifyOptions = import("./sanctifyText.js").SanctifyOptions;
|
|
9
|
+
export type RevealOptions = import("./reveal.js").RevealOptions;
|
|
9
10
|
import { inspectText } from './inspectText.js';
|
|
10
11
|
import { inspectTextDetailed } from './inspectText.js';
|
|
11
12
|
import { getNewlineStats } from './inspectText.js';
|
|
12
13
|
import { getRecommendedSanctifierOptions } from './inspectText.js';
|
|
13
14
|
import { summonSanctifier } from './sanctifyText.js';
|
|
14
|
-
|
|
15
|
+
import { revealInvisible } from './reveal.js';
|
|
16
|
+
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier, revealInvisible };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** @typedef {import('./inspectText.js').TrashCategory} TrashCategory */
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {Object} RevealOptions
|
|
4
|
+
* @property {(codePoint: number, category: TrashCategory) => string} [format] - Text shown in place of
|
|
5
|
+
* each hidden character. Default: `<U+XXXX>` (at least four uppercase hex digits).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Makes hidden characters visible: every character `inspectTextDetailed` would report is
|
|
9
|
+
* replaced by a marker such as `<U+200B>`, and everything else is kept as it is,
|
|
10
|
+
* including tabs, newlines and emoji. Use it to show hidden text in logs, diffs, review
|
|
11
|
+
* tools and agent tool output. A literal `<U+200B>` already in the text looks the same as
|
|
12
|
+
* a marker; pass `format` to choose markers that can't occur in your input.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @param {RevealOptions} [options]
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function revealInvisible(text: string, options?: RevealOptions): string;
|
|
19
|
+
export type TrashCategory = import("./inspectText.js").TrashCategory;
|
|
20
|
+
export type RevealOptions = {
|
|
21
|
+
/**
|
|
22
|
+
* - Text shown in place of
|
|
23
|
+
* each hidden character. Default: `<U+XXXX>` (at least four uppercase hex digits).
|
|
24
|
+
*/
|
|
25
|
+
format?: (codePoint: number, category: TrashCategory) => string;
|
|
26
|
+
};
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* @property {boolean} [finalTrim]
|
|
13
13
|
* @property {number} [maxLength]
|
|
14
14
|
* @property {'throw'|'truncate'|'noop'} [onMaxLength]
|
|
15
|
+
* @property {boolean} [normalizeSpaces] - Replace look-alike spaces with an ordinary space (runs first).
|
|
16
|
+
* @property {boolean} [purgeZeroWidthSpaces] - Remove U+200B, U+2060 and U+FEFF only, keeping joiners and selectors.
|
|
15
17
|
*/
|
|
16
18
|
/**
|
|
17
19
|
* Summons a customized sanctifier function with pre-bound booleans.
|
|
@@ -31,6 +33,8 @@
|
|
|
31
33
|
* @param {boolean} [defaultOptions.finalTrim]
|
|
32
34
|
* @param {number} [defaultOptions.maxLength=Infinity] - Hard input length cap (UTF-16 code units).
|
|
33
35
|
* @param {'throw' | 'truncate' | 'noop'} [defaultOptions.onMaxLength='throw'] - Behavior when input exceeds maxLength.
|
|
36
|
+
* @param {boolean} [defaultOptions.normalizeSpaces]
|
|
37
|
+
* @param {boolean} [defaultOptions.purgeZeroWidthSpaces]
|
|
34
38
|
* @returns {(text: string) => string}
|
|
35
39
|
*/
|
|
36
40
|
export function summonSanctifier(defaultOptions?: {
|
|
@@ -46,6 +50,8 @@ export function summonSanctifier(defaultOptions?: {
|
|
|
46
50
|
finalTrim?: boolean;
|
|
47
51
|
maxLength?: number;
|
|
48
52
|
onMaxLength?: "throw" | "truncate" | "noop";
|
|
53
|
+
normalizeSpaces?: boolean;
|
|
54
|
+
purgeZeroWidthSpaces?: boolean;
|
|
49
55
|
}): (text: string) => string;
|
|
50
56
|
export namespace summonSanctifier {
|
|
51
57
|
/**
|
|
@@ -81,6 +87,16 @@ export namespace summonSanctifier {
|
|
|
81
87
|
*/
|
|
82
88
|
/** @param {string} text @returns {string} */
|
|
83
89
|
function keyboardOnly(text: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* Pasted text and model output:
|
|
92
|
+
* - Look-alike spaces (NBSP, narrow NBSP, typographic spaces) become ordinary spaces,
|
|
93
|
+
* so words never run together
|
|
94
|
+
* - Zero-width spaces, word joiners and BOMs are removed
|
|
95
|
+
* - Newlines are normalized to `\n`
|
|
96
|
+
* Keeps emoji joiners, variation selectors, bidi marks, indentation and line breaks.
|
|
97
|
+
*/
|
|
98
|
+
/** @param {string} text @returns {string} */
|
|
99
|
+
function pasted(text: string): string;
|
|
84
100
|
}
|
|
85
101
|
/**
|
|
86
102
|
* Text Sanctifier
|
|
@@ -106,17 +122,27 @@ export namespace summonSanctifier {
|
|
|
106
122
|
* - `'throw'`: throw a `RangeError`
|
|
107
123
|
* - `'truncate'`: slice to `maxLength` before processing
|
|
108
124
|
* - `'noop'`: return the original input unchanged (skips processing)
|
|
125
|
+
* @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
|
|
126
|
+
* so `purgeInvisibleChars` no longer joins the words around them.
|
|
127
|
+
* @param {boolean} [purgeZeroWidthSpaces=false] - Remove zero-width spaces, word joiners and BOMs only.
|
|
109
128
|
* @returns {string}
|
|
110
129
|
* @throws {TypeError} If `text` is not a string.
|
|
111
130
|
* @throws {RangeError} If input exceeds `maxLength` and `onMaxLength` is `'throw'`.
|
|
112
131
|
*/
|
|
113
|
-
export function sanctifyText(text: string, purgeInvisibleChars?: boolean, purgeEmojis?: boolean, nukeControls?: boolean, keyboardOnlyFilter?: boolean, normalizeNewlines?: boolean, trimSpacesAroundNewlines?: boolean, collapseNewLines?: boolean, preserveParagraphs?: boolean, collapseSpaces?: boolean, finalTrim?: boolean, maxLength?: number, onMaxLength?: "throw" | "truncate" | "noop"): string;
|
|
132
|
+
export function sanctifyText(text: string, purgeInvisibleChars?: boolean, purgeEmojis?: boolean, nukeControls?: boolean, keyboardOnlyFilter?: boolean, normalizeNewlines?: boolean, trimSpacesAroundNewlines?: boolean, collapseNewLines?: boolean, preserveParagraphs?: boolean, collapseSpaces?: boolean, finalTrim?: boolean, maxLength?: number, onMaxLength?: "throw" | "truncate" | "noop", normalizeSpaces?: boolean, purgeZeroWidthSpaces?: boolean): string;
|
|
114
133
|
/**
|
|
115
134
|
* Normalizes typographic Unicode punctuation into ASCII equivalents.
|
|
116
135
|
* @param {string} text
|
|
117
136
|
* @returns {string}
|
|
118
137
|
*/
|
|
119
138
|
export function normalizeTypographicJank(text: string): string;
|
|
139
|
+
/**
|
|
140
|
+
* Look-alike spaces: the `nonbreaking_space` category except U+3000, the ordinary
|
|
141
|
+
* space of CJK text. A test checks this against the category table.
|
|
142
|
+
*/
|
|
143
|
+
export const SPACE_LOOKALIKES_REGEX: RegExp;
|
|
144
|
+
/** Zero-width space, word joiner and BOM: invisible and never needed in plain text. */
|
|
145
|
+
export const ZERO_WIDTH_SPACES_REGEX: RegExp;
|
|
120
146
|
/**
|
|
121
147
|
* Matches any character that is NOT:
|
|
122
148
|
* - Printable ASCII (U+0020–U+007E)
|
|
@@ -139,6 +165,14 @@ export type SanctifyOptions = {
|
|
|
139
165
|
finalTrim?: boolean;
|
|
140
166
|
maxLength?: number;
|
|
141
167
|
onMaxLength?: "throw" | "truncate" | "noop";
|
|
168
|
+
/**
|
|
169
|
+
* - Replace look-alike spaces with an ordinary space (runs first).
|
|
170
|
+
*/
|
|
171
|
+
normalizeSpaces?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* - Remove U+200B, U+2060 and U+FEFF only, keeping joiners and selectors.
|
|
174
|
+
*/
|
|
175
|
+
purgeZeroWidthSpaces?: boolean;
|
|
142
176
|
};
|
|
143
177
|
import { CONTROL_CHARS_REGEX } from './categories.generated.js';
|
|
144
178
|
import { INVISIBLE_TRASH_REGEX } from './categories.generated.js';
|
package/package.json
CHANGED
|
@@ -1,80 +1,79 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "text-sanctifier",
|
|
3
|
-
"version": "1.3.
|
|
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
|
-
"./browser": {
|
|
35
|
-
"types": "./dist/types/index.d.ts",
|
|
36
|
-
"default": "./dist/text-sanctifier.min.js"
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
"types": "./dist/types/index.d.ts",
|
|
40
|
-
"sideEffects": false,
|
|
41
|
-
"scripts": {
|
|
42
|
-
"gen": "node scripts/gen-categories.js",
|
|
43
|
-
"gen:check": "node scripts/gen-categories.js --check",
|
|
44
|
-
"build": "node scripts/
|
|
45
|
-
"build:types": "tsc -p tsconfig.types.json",
|
|
46
|
-
"test:types": "tsc -p tests/types/tsconfig.json",
|
|
47
|
-
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node tests/cli.test.js",
|
|
48
|
-
"test-min": "node tests/sanctifyText.test.min.js",
|
|
49
|
-
"ci": "npm run gen:check && npm run test && npm run build && npm run build:types && npm run test:types && npm run test-min"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "text-sanctifier",
|
|
3
|
+
"version": "1.3.5",
|
|
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
|
+
"./browser": {
|
|
35
|
+
"types": "./dist/types/index.d.ts",
|
|
36
|
+
"default": "./dist/text-sanctifier.min.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"types": "./dist/types/index.d.ts",
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"scripts": {
|
|
42
|
+
"gen": "node scripts/gen-categories.js",
|
|
43
|
+
"gen:check": "node scripts/gen-categories.js --check",
|
|
44
|
+
"build": "node scripts/build.js",
|
|
45
|
+
"build:types": "tsc -p tsconfig.types.json",
|
|
46
|
+
"test:types": "tsc -p tests/types/tsconfig.json",
|
|
47
|
+
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node tests/cli.test.js",
|
|
48
|
+
"test-min": "node tests/sanctifyText.test.min.js",
|
|
49
|
+
"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",
|
|
50
|
+
"size": "node scripts/check-size.js"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"text",
|
|
54
|
+
"sanitize",
|
|
55
|
+
"normalize",
|
|
56
|
+
"invisible",
|
|
57
|
+
"scrubber",
|
|
58
|
+
"unicode",
|
|
59
|
+
"clean",
|
|
60
|
+
"sanctify"
|
|
61
|
+
],
|
|
62
|
+
"author": "WATT3D <WATT3D@protonmail.com>",
|
|
63
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/iWhatty/text-sanctifier.git"
|
|
67
|
+
},
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/iWhatty/text-sanctifier/issues"
|
|
70
|
+
},
|
|
71
|
+
"homepage": "https://github.com/iWhatty/text-sanctifier#readme",
|
|
72
|
+
"directories": {
|
|
73
|
+
"test": "tests"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"esbuild": "^0.25.3",
|
|
77
|
+
"typescript": "^5.9.3"
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { inspectTextDetailed } from './inspectText.js';
|
|
|
3
3
|
import { getNewlineStats } from './inspectText.js';
|
|
4
4
|
import { getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
5
|
import { summonSanctifier } from './sanctifyText.js';
|
|
6
|
-
|
|
6
|
+
import { revealInvisible } from './reveal.js';
|
|
7
|
+
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier, revealInvisible };
|
|
7
8
|
export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
|
|
8
9
|
export type TrashCategory = import('./inspectText.js').TrashCategory;
|
|
9
10
|
export type TrashFinding = import('./inspectText.js').TrashFinding;
|
|
@@ -12,3 +13,4 @@ export type DetailedTrashReport = import('./inspectText.js').DetailedTrashReport
|
|
|
12
13
|
export type InspectDetailedOptions = import('./inspectText.js').InspectDetailedOptions;
|
|
13
14
|
export type NewlineStats = import('./inspectText.js').NewlineStats;
|
|
14
15
|
export type SanctifyOptions = import('./sanctifyText.js').SanctifyOptions;
|
|
16
|
+
export type RevealOptions = import('./reveal.js').RevealOptions;
|
package/src/index.js
CHANGED
|
@@ -7,6 +7,9 @@ export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSancti
|
|
|
7
7
|
import { summonSanctifier } from './sanctifyText.js';
|
|
8
8
|
export { summonSanctifier };
|
|
9
9
|
|
|
10
|
+
import { revealInvisible } from './reveal.js';
|
|
11
|
+
export { revealInvisible };
|
|
12
|
+
|
|
10
13
|
// Type-only re-exports so TypeScript consumers can name these from the package root.
|
|
11
14
|
/** @typedef {import('./inspectText.js').UnicodeTrashReport} UnicodeTrashReport */
|
|
12
15
|
/** @typedef {import('./inspectText.js').TrashCategory} TrashCategory */
|
|
@@ -16,6 +19,7 @@ export { summonSanctifier };
|
|
|
16
19
|
/** @typedef {import('./inspectText.js').InspectDetailedOptions} InspectDetailedOptions */
|
|
17
20
|
/** @typedef {import('./inspectText.js').NewlineStats} NewlineStats */
|
|
18
21
|
/** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
|
|
22
|
+
/** @typedef {import('./reveal.js').RevealOptions} RevealOptions */
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
// In Future: export helpers for power users
|
package/src/reveal.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ./src/reveal.js
|
|
2
|
+
|
|
3
|
+
import { inspectTextDetailed } from './inspectText.js';
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('./inspectText.js').TrashCategory} TrashCategory */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} RevealOptions
|
|
9
|
+
* @property {(codePoint: number, category: TrashCategory) => string} [format] - Text shown in place of
|
|
10
|
+
* each hidden character. Default: `<U+XXXX>` (at least four uppercase hex digits).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Makes hidden characters visible: every character `inspectTextDetailed` would report is
|
|
15
|
+
* replaced by a marker such as `<U+200B>`, and everything else is kept as it is,
|
|
16
|
+
* including tabs, newlines and emoji. Use it to show hidden text in logs, diffs, review
|
|
17
|
+
* tools and agent tool output. A literal `<U+200B>` already in the text looks the same as
|
|
18
|
+
* a marker; pass `format` to choose markers that can't occur in your input.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} text
|
|
21
|
+
* @param {RevealOptions} [options]
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function revealInvisible(text, options = {}) {
|
|
25
|
+
if (typeof text !== 'string') {
|
|
26
|
+
throw new TypeError('revealInvisible expects a string input.');
|
|
27
|
+
}
|
|
28
|
+
const format = options && typeof options.format === 'function'
|
|
29
|
+
? options.format
|
|
30
|
+
: (codePoint) => '<U+' + codePoint.toString(16).toUpperCase().padStart(4, '0') + '>';
|
|
31
|
+
|
|
32
|
+
// One finding per hidden character, in offset order; code points above U+FFFF span two units
|
|
33
|
+
let out = '';
|
|
34
|
+
let last = 0;
|
|
35
|
+
for (const f of inspectTextDetailed(text, { maxFindings: Infinity }).findings) {
|
|
36
|
+
out += text.slice(last, f.offset) + format(f.codePoint, f.category);
|
|
37
|
+
last = f.offset + (f.codePoint > 0xFFFF ? 2 : 1);
|
|
38
|
+
}
|
|
39
|
+
return out + text.slice(last);
|
|
40
|
+
}
|
package/src/sanctifyText.js
CHANGED
|
@@ -18,6 +18,8 @@ export { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX };
|
|
|
18
18
|
* @property {boolean} [finalTrim]
|
|
19
19
|
* @property {number} [maxLength]
|
|
20
20
|
* @property {'throw'|'truncate'|'noop'} [onMaxLength]
|
|
21
|
+
* @property {boolean} [normalizeSpaces] - Replace look-alike spaces with an ordinary space (runs first).
|
|
22
|
+
* @property {boolean} [purgeZeroWidthSpaces] - Remove U+200B, U+2060 and U+FEFF only, keeping joiners and selectors.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
|
|
@@ -39,9 +41,13 @@ export { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX };
|
|
|
39
41
|
* @param {boolean} [defaultOptions.finalTrim]
|
|
40
42
|
* @param {number} [defaultOptions.maxLength=Infinity] - Hard input length cap (UTF-16 code units).
|
|
41
43
|
* @param {'throw' | 'truncate' | 'noop'} [defaultOptions.onMaxLength='throw'] - Behavior when input exceeds maxLength.
|
|
44
|
+
* @param {boolean} [defaultOptions.normalizeSpaces]
|
|
45
|
+
* @param {boolean} [defaultOptions.purgeZeroWidthSpaces]
|
|
42
46
|
* @returns {(text: string) => string}
|
|
43
47
|
*/
|
|
44
48
|
export function summonSanctifier(defaultOptions = {}) {
|
|
49
|
+
const normalizeSpaces = !!defaultOptions.normalizeSpaces;
|
|
50
|
+
const purgeZeroWidthSpaces = !!defaultOptions.purgeZeroWidthSpaces;
|
|
45
51
|
const purgeInvisibleChars = !!defaultOptions.purgeInvisibleChars;
|
|
46
52
|
const purgeEmojis = !!defaultOptions.purgeEmojis;
|
|
47
53
|
const nukeControls = !!defaultOptions.nukeControls;
|
|
@@ -79,7 +85,9 @@ export function summonSanctifier(defaultOptions = {}) {
|
|
|
79
85
|
collapseSpaces,
|
|
80
86
|
finalTrim,
|
|
81
87
|
maxLength,
|
|
82
|
-
onMaxLength
|
|
88
|
+
onMaxLength,
|
|
89
|
+
normalizeSpaces,
|
|
90
|
+
purgeZeroWidthSpaces
|
|
83
91
|
);
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -174,6 +182,34 @@ summonSanctifier.keyboardOnly = text => sanctifyText(
|
|
|
174
182
|
);
|
|
175
183
|
|
|
176
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Pasted text and model output:
|
|
187
|
+
* - Look-alike spaces (NBSP, narrow NBSP, typographic spaces) become ordinary spaces,
|
|
188
|
+
* so words never run together
|
|
189
|
+
* - Zero-width spaces, word joiners and BOMs are removed
|
|
190
|
+
* - Newlines are normalized to `\n`
|
|
191
|
+
* Keeps emoji joiners, variation selectors, bidi marks, indentation and line breaks.
|
|
192
|
+
*/
|
|
193
|
+
/** @param {string} text @returns {string} */
|
|
194
|
+
summonSanctifier.pasted = text => sanctifyText(
|
|
195
|
+
text,
|
|
196
|
+
false, // purgeInvisibleChars
|
|
197
|
+
false, // purgeEmojis
|
|
198
|
+
false, // nukeControls
|
|
199
|
+
false, // keyboardOnlyFilter
|
|
200
|
+
true, // normalizeNewlines
|
|
201
|
+
false, // trimSpacesAroundNewlines
|
|
202
|
+
false, // collapseNewLines
|
|
203
|
+
false, // preserveParagraphs
|
|
204
|
+
false, // collapseSpaces
|
|
205
|
+
false, // finalTrim
|
|
206
|
+
Infinity, // maxLength
|
|
207
|
+
'throw', // onMaxLength
|
|
208
|
+
true, // normalizeSpaces
|
|
209
|
+
true // purgeZeroWidthSpaces
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
|
|
177
213
|
/**
|
|
178
214
|
* Text Sanctifier
|
|
179
215
|
*
|
|
@@ -198,6 +234,9 @@ summonSanctifier.keyboardOnly = text => sanctifyText(
|
|
|
198
234
|
* - `'throw'`: throw a `RangeError`
|
|
199
235
|
* - `'truncate'`: slice to `maxLength` before processing
|
|
200
236
|
* - `'noop'`: return the original input unchanged (skips processing)
|
|
237
|
+
* @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
|
|
238
|
+
* so `purgeInvisibleChars` no longer joins the words around them.
|
|
239
|
+
* @param {boolean} [purgeZeroWidthSpaces=false] - Remove zero-width spaces, word joiners and BOMs only.
|
|
201
240
|
* @returns {string}
|
|
202
241
|
* @throws {TypeError} If `text` is not a string.
|
|
203
242
|
* @throws {RangeError} If input exceeds `maxLength` and `onMaxLength` is `'throw'`.
|
|
@@ -215,7 +254,9 @@ export function sanctifyText(
|
|
|
215
254
|
collapseSpaces = false,
|
|
216
255
|
finalTrim = false,
|
|
217
256
|
maxLength = Infinity,
|
|
218
|
-
onMaxLength = 'throw'
|
|
257
|
+
onMaxLength = 'throw',
|
|
258
|
+
normalizeSpaces = false,
|
|
259
|
+
purgeZeroWidthSpaces = false
|
|
219
260
|
) {
|
|
220
261
|
if (typeof text !== 'string') {
|
|
221
262
|
throw new TypeError('sanctifyText expects a string input.');
|
|
@@ -242,6 +283,12 @@ export function sanctifyText(
|
|
|
242
283
|
|
|
243
284
|
let cleaned = text;
|
|
244
285
|
|
|
286
|
+
// Look-alike spaces become ordinary spaces, before anything can delete them
|
|
287
|
+
if (normalizeSpaces) cleaned = cleaned.replace(SPACE_LOOKALIKES_REGEX, ' ');
|
|
288
|
+
|
|
289
|
+
// Zero-width spaces, word joiners and BOMs (not joiners or selectors)
|
|
290
|
+
if (purgeZeroWidthSpaces) cleaned = cleaned.replace(ZERO_WIDTH_SPACES_REGEX, '');
|
|
291
|
+
|
|
245
292
|
// Purge invisible Unicode trash (zero-width, non-breaking, bidi junk, etc.)
|
|
246
293
|
if (purgeInvisibleChars) cleaned = purgeInvisibleTrash(cleaned);
|
|
247
294
|
|
|
@@ -274,6 +321,16 @@ export function sanctifyText(
|
|
|
274
321
|
// --- Micro helpers ---
|
|
275
322
|
|
|
276
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Look-alike spaces: the `nonbreaking_space` category except U+3000, the ordinary
|
|
326
|
+
* space of CJK text. A test checks this against the category table.
|
|
327
|
+
*/
|
|
328
|
+
export const SPACE_LOOKALIKES_REGEX = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
|
|
329
|
+
|
|
330
|
+
/** Zero-width space, word joiner and BOM: invisible and never needed in plain text. */
|
|
331
|
+
export const ZERO_WIDTH_SPACES_REGEX = /[\u200B\u2060\uFEFF]/g;
|
|
332
|
+
|
|
333
|
+
|
|
277
334
|
/**
|
|
278
335
|
* Removes the invisible Unicode characters in INVISIBLE_TRASH_REGEX: non-breaking and
|
|
279
336
|
* typographic spaces, zero-width characters, bidi controls, invisible format characters,
|