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 CHANGED
@@ -7,7 +7,7 @@
7
7
  [![stars](https://badgen.net/github/stars/iWhatty/text-sanctifier)](https://github.com/iWhatty/text-sanctifier)
8
8
  [![types](https://badgen.net/npm/types/text-sanctifier)](https://www.npmjs.com/package/text-sanctifier)
9
9
 
10
- Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.57 KB gzipped.
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.4 # a release tag
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 the three, `fix` uses `--purge-invisible --nuke-controls`, which removes exactly what `check` reports.
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; it does not replace them. A non-breaking space between two words joins them, and emoji lose their zero width joiners and variation selectors. Run `check` or `fix --dry-run` first on prose.
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/`):** Minified ESM bundle for `<script type="module">`
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
 
@@ -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 n=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,q=/[\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;
2
- function t(a={}){const c=!!a.purgeInvisibleChars,d=!!a.purgeEmojis,f=!!a.nukeControls,h=!!a.keyboardOnlyFilter,g=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,e=!!a.preserveParagraphs,k=!!a.collapseSpaces,r=!!a.finalTrim,m=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,p="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return A=>u(A,c,d,f,h,g,l,b,e,k,r,m,p)}t.strict=a=>u(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
3
- t.loose=a=>u(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);t.keyboardOnlyEmoji=a=>u(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);t.keyboardOnly=a=>u(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
4
- function u(a,c=!1,d=!1,f=!1,h=!1,g=!1,l=!1,b=!1,e=!1,k=!1,r=!1,m=Infinity,p="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==p&&"noop"!==p&&"throw"!==p&&(p="throw");if(a.length>m)switch(p){case "truncate":a=a.slice(0,m);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${m}.`);}c&&(a=a.replace(q,""));d&&(a=a.replace(v,""));f&&(a=a.replace(n,""));h&&(a=w(a,d));g&&(a=a.replace(x,"\n"));
5
- l&&(a=a.replace(y,"$1"));b&&(c=a,a=e?c.replace(z,"\n\n"):c.replace(B,"\n"));k&&(a=a.replace(C," "));return r?a.trim():a}var D=/[^\x20-\x7E\r\n]+/gu;function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,d=>{v.lastIndex=0;if(v.test(d)){v.lastIndex=0;var f="";for(const h of d.matchAll(v))f+=h[0];d=f}else d="";return d})}
6
- var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var v;try{v=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{v=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}
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};
@@ -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
- export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier };
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",
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/buildc.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 build:types && npm run test:types && npm run test-min"
50
- },
51
- "keywords": [
52
- "text",
53
- "sanitize",
54
- "normalize",
55
- "invisible",
56
- "scrubber",
57
- "unicode",
58
- "clean",
59
- "sanctify"
60
- ],
61
- "author": "WATT3D <WATT3D@protonmail.com>",
62
- "license": "SEE LICENSE IN LICENSE",
63
- "repository": {
64
- "type": "git",
65
- "url": "git+https://github.com/iWhatty/text-sanctifier.git"
66
- },
67
- "bugs": {
68
- "url": "https://github.com/iWhatty/text-sanctifier/issues"
69
- },
70
- "homepage": "https://github.com/iWhatty/text-sanctifier#readme",
71
- "directories": {
72
- "test": "tests"
73
- },
74
- "devDependencies": {
75
- "esbuild": "^0.25.3",
76
- "google-closure-compiler": "^20240317.0.0",
77
- "terser": "^5.39.0",
78
- "typescript": "^5.9.3"
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
- export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier };
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
+ }
@@ -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,