text-sanctifier 1.3.10 → 1.3.11

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
@@ -106,20 +106,22 @@ Used by `summonSanctifier(options)`. All boolean options default to `false`. Pro
106
106
  | `collapseSpaces` | Collapse runs of ordinary spaces to one; keep tabs and newlines. |
107
107
  | `finalTrim` | Apply JavaScript `.trim()` to the result. |
108
108
  | `maxLength` | Input cap in UTF-16 code units, checked before processing. Default `Infinity`; negative, non-finite or non-number values mean no cap. |
109
- | `onMaxLength` | Default `'throw'`: throw `RangeError` above the cap. `'truncate'`: slice input to the cap before processing (may split a surrogate pair). `'noop'`: return original input unchanged. Invalid values use `'throw'`. |
109
+ | `onMaxLength` | Default `'throw'`: throw `RangeError` above the cap. `'truncate'`: slice input to at most the cap before processing, never through a surrogate pair. `'noop'`: return original input unchanged. Invalid values use `'throw'`. |
110
110
 
111
111
  ```js
112
112
  import { summonSanctifier } from 'text-sanctifier';
113
113
  const clean = summonSanctifier({ purgeInvisibleChars: true, maxLength: 10000, onMaxLength: 'throw' });
114
114
  ```
115
115
 
116
+ Every sanitizer replaces unpaired surrogates with U+FFFD before anything else, so removing a character between two halves cannot join them into a new one.
117
+
116
118
  ### Errors
117
119
 
118
120
  All text-processing exports throw `TypeError` on non-string input, including the preset functions, the sanitizer returned by `summonSanctifier`, `findHiddenRuns` and `decodeTagText`. Inputs are not coerced to strings. Sanitizers also throw `RangeError` when input exceeds `maxLength` with `onMaxLength: 'throw'`.
119
121
 
120
122
  ### Presets as standalone imports
121
123
 
122
- Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about <!-- gen:size:sanctifyPasted -->1.1 KiB<!-- /gen --> gzipped instead of <!-- gen:size:summonSanctifier.pasted -->1.4 KiB<!-- /gen -->).
124
+ Each preset is also its own export: `sanctifyStrict`, `sanctifyLoose`, `sanctifyKeyboardOnly`, `sanctifyKeyboardOnlyEmoji` and `sanctifyPasted`. They are the same functions as `summonSanctifier.strict` and the rest. Import one directly and a bundler leaves out `summonSanctifier` and the other presets (about <!-- gen:size:sanctifyPasted -->1.1 KiB<!-- /gen --> gzipped instead of <!-- gen:size:summonSanctifier.pasted -->1.5 KiB<!-- /gen -->).
123
125
 
124
126
  ```js
125
127
  import { sanctifyPasted } from 'text-sanctifier';
@@ -187,7 +189,7 @@ const report = inspectText(input);
187
189
  */
188
190
  ```
189
191
 
190
- Use `inspectText` to preflight text content before rendering, storing, or linting. It's a diagnostic tool to help inform sanitization needs. Pass the report to `getRecommendedSanctifierOptions(report)` to auto-generate config flags for `summonSanctifier()`.
192
+ Use `inspectText` to preflight text content before rendering, storing, or linting. It's a diagnostic tool to help inform sanitization needs. Pass the report to `getRecommendedSanctifierOptions(report)` to auto-generate config flags for `summonSanctifier()`. It turns on what removes trash without losing meaning; `purgeEmojis` and `keyboardOnlyFilter` delete content, so you opt in to those yourself.
191
193
 
192
194
  ### `inspectTextDetailed(text: string, options?: { maxFindings?: number }): DetailedTrashReport`
193
195
 
@@ -299,6 +301,7 @@ The package ships a `text-sanctifier` command for CI and coding agents, which ca
299
301
  ```sh
300
302
  npx text-sanctifier --help
301
303
  npx text-sanctifier --version
304
+ npx text-sanctifier check
302
305
  npx text-sanctifier check src docs "**/*.md"
303
306
  npx text-sanctifier fix --dry-run src
304
307
  npx text-sanctifier reveal notes.md
@@ -361,13 +364,13 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
361
364
  # .pre-commit-config.yaml
362
365
  repos:
363
366
  - repo: https://github.com/iWhatty/text-sanctifier
364
- rev: v1.3.10 # a release tag
367
+ rev: v1.3.11 # a release tag
365
368
  hooks:
366
369
  - id: text-sanctifier # every staged text file
367
370
  - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
368
371
  ```
369
372
 
370
- - `text-sanctifier` runs `text-sanctifier check` on staged text files. Use `exclude:` for files that legitimately contain these characters, or `args: [--strip-bom]` and the other `check` options.
373
+ - `text-sanctifier` runs `text-sanctifier check` on staged text files, as pre-commit defines them: it can drop a file with control bytes near its start before the hook sees it, so scan the repository with `check` as well if that matters to you. Use `exclude:` for files that legitimately contain these characters, or `args: [--strip-bom]` and the other `check` options.
371
374
  - `text-sanctifier-ai-config` runs `text-sanctifier check --ai-config` on staged files that match the `--ai-config` file list.
372
375
  - A hook fails the commit when it finds something (exit code 1). Neither hook changes files; run `text-sanctifier fix` yourself.
373
376
 
@@ -400,9 +403,11 @@ git diff | npx text-sanctifier reveal
400
403
 
401
404
  - Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
402
405
  - Inside a git work tree, directories and globs list files with `git ls-files`, so anything `.gitignore` excludes is skipped. Outside git, `.git` and `node_modules` are skipped. A file named explicitly is always read.
403
- - Binary files (a NUL byte in the first 8000 bytes) are skipped and listed under `skipped` in JSON. Stdin and AI configuration files are never treated as binary: a NUL in them is reported as `nul`.
406
+ - Binary files are skipped, counted in the summary and listed under `skipped` in JSON. A file counts as binary when it is not valid UTF-8 and its first 8000 bytes hold a NUL and more than 5% control bytes, so a stray NUL or bad byte does not hide a text file: the NUL is reported as `nul`. Stdin and AI configuration files are never treated as binary. `fix` is more careful, because it rewrites files: in a directory or glob it also skips any file with a NUL in those bytes, and it cleans such a file only if you name it.
407
+ - UTF-16 files with a BOM are decoded and checked. `fix` never rewrites them and lists them under `skipped`.
404
408
  - Files that are not valid UTF-8 are still checked, with each invalid byte read as U+FFFD, so one bad byte cannot hide the rest of the file. `check` notes them on stderr and lists them under `notUtf8` in JSON; `fix` never writes them and lists them under `skipped`.
405
- - `-`, or no paths with piped input, reads stdin (with `--ai-config`, only `-` does). `fix` writes the cleaned text to stdout and the report to stderr.
409
+ - `-` reads stdin. With no paths, `check` reads piped input, and scans the current directory when stdin is a terminal, the null device or empty. `check --ai-config` always searches the current directory with no paths. `fix` and `reveal` read piped input. `fix` writes the cleaned text to stdout and the report to stderr.
410
+ - A path or glob that matches no files, or a run that checks nothing, exits 2 instead of reporting a clean result. So does `fix` when it refuses stdin or a file you named. Files it skips in a directory or glob don't fail the run.
406
411
 
407
412
  ### Exit codes
408
413
 
@@ -410,7 +415,7 @@ git diff | npx text-sanctifier reveal
410
415
  | --- | --- | --- | --- |
411
416
  | 0 | nothing found | done (or nothing to do) | done |
412
417
  | 1 | findings | `--dry-run` with changes pending | not used |
413
- | 2 | usage error, missing path or unreadable file | same | same |
418
+ | 2 | usage error, missing or unreadable input, a path matching no files, or nothing checked | same, or an input you named that it refused to rewrite | same as `check` |
414
419
 
415
420
  ---
416
421
 
@@ -10,7 +10,7 @@
10
10
  // Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
11
11
  // or fix --dry-run with changes pending), 2 usage or I/O error.
12
12
 
13
- import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
13
+ import { readFileSync, writeFileSync, statSync, fstatSync, readdirSync } from 'node:fs';
14
14
  import { resolve, join, sep, relative } from 'node:path';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { pathToFileURL } from 'node:url';
@@ -32,8 +32,12 @@ Usage:
32
32
  text-sanctifier reveal [paths...]
33
33
 
34
34
  Paths may be files, directories or globs (quote globs so the shell leaves them
35
- alone). "-", or no paths with piped input, reads stdin. Directories and globs
36
- skip files ignored by git (inside a git work tree) and skip binary files.
35
+ alone). "-" reads stdin. With no paths, check reads piped input, or scans the current
36
+ directory when stdin is a terminal, the null device or empty. Fix and reveal read
37
+ piped input. Directories and globs skip files ignored by git (inside
38
+ a git work tree). Binary files are skipped; files that are text apart from a stray
39
+ NUL or bad byte are not, and UTF-16 files with a BOM are decoded. A path or glob that
40
+ matches no files, or a run that checks nothing, exits 2.
37
41
 
38
42
  check options:
39
43
  --format F text (default), json or sarif (SARIF 2.1.0)
@@ -77,6 +81,7 @@ const BOM = 0xFEFF;
77
81
  const STDIN = '-';
78
82
  const STDIN_LABEL = '<stdin>';
79
83
  const BINARY_SNIFF_BYTES = 8000;
84
+ const BINARY_CONTROL_SHARE = 0.05;
80
85
  const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
81
86
  const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
82
87
 
@@ -206,21 +211,36 @@ function isGitWorkTree(cwd) {
206
211
  }
207
212
 
208
213
  /**
209
- * Lists tracked and untracked-but-not-ignored files under one pathspec.
214
+ * Lists tracked and untracked-but-not-ignored files under the given pathspecs.
210
215
  * Returns null when git cannot answer (for example a path outside the work tree).
211
216
  * @param {string} cwd
212
- * @param {string} pathspec
217
+ * @param {string[]} pathspecs
213
218
  * @returns {string[]|null}
214
219
  */
215
- function gitListFiles(cwd, pathspec) {
220
+ function gitListFiles(cwd, pathspecs) {
216
221
  const r = spawnSync(
217
222
  'git',
218
- ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
223
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs],
219
224
  { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
220
225
  );
221
226
  if (r.error || r.status !== 0) return null;
222
- // --cached lists tracked files deleted from the work tree; drop them
223
- return r.stdout.split('\0').filter((f) => f && existsSync(resolve(cwd, f)));
227
+ // --cached also lists tracked files deleted from the work tree, submodules and
228
+ // symlinks to directories; keep only what can be read as a file
229
+ return r.stdout.split('\0').filter((f) => {
230
+ if (!f) return false;
231
+ try { return statSync(resolve(cwd, f)).isFile(); } catch { return false; }
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Expands `{a,b}` into one glob per alternative: git pathspecs have no brace syntax.
237
+ * @param {string} glob
238
+ * @returns {string[]}
239
+ */
240
+ function expandBraces(glob) {
241
+ const m = /\{([^{}]*)\}/.exec(glob);
242
+ if (!m) return [glob];
243
+ return m[1].split(',').flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
224
244
  }
225
245
 
226
246
  /**
@@ -366,7 +386,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
366
386
 
367
387
  if (inGit === undefined) inGit = isGitWorkTree(cwd);
368
388
  let listed =
369
- (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
389
+ (inGit && gitListFiles(cwd, isGlob ? expandBraces(toSlash(arg)).map((g) => `:(glob)${g}`) : [toSlash(arg)])) ||
370
390
  (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
371
391
 
372
392
  if (aiConfig && !isGlob) {
@@ -374,6 +394,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
374
394
  listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
375
395
  } else if (listed.length === 0) {
376
396
  warn(`${arg}: no files matched`);
397
+ errors++;
377
398
  }
378
399
  for (const f of listed) add(f, false);
379
400
  }
@@ -385,24 +406,57 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
385
406
 
386
407
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
387
408
  const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
409
+ const utf16 = new TextDecoder('utf-16le', { ignoreBOM: true });
388
410
 
389
411
  /**
390
- * Decodes UTF-8, keeping a leading BOM. With `sniff`, data with a NUL in its first
391
- * BINARY_SNIFF_BYTES is binary and not decoded. Bytes that are not UTF-8 become U+FFFD
392
- * and set `lossy`, so one bad byte cannot hide the rest of a file from `check`.
412
+ * A NUL plus a share of control bytes that text does not have, in the first
413
+ * BINARY_SNIFF_BYTES. One stray NUL is not enough, or it would hide a file from `check`.
393
414
  * @param {Uint8Array} bytes
415
+ */
416
+ function looksBinary(bytes) {
417
+ const sample = bytes.subarray(0, BINARY_SNIFF_BYTES);
418
+ if (!sample.includes(0)) return false;
419
+ let controls = 0;
420
+ for (const b of sample) if (b < 9 || (b > 13 && b < 32)) controls++;
421
+ return controls / sample.length > BINARY_CONTROL_SHARE;
422
+ }
423
+
424
+ /**
425
+ * Decodes UTF-8, or UTF-16 with a BOM (setting `wide`), keeping a leading BOM. With
426
+ * `sniff`, data that is not UTF-8 and looks binary is not decoded. Other bytes that are
427
+ * not UTF-8 become U+FFFD and set `lossy`, so one bad byte cannot hide the rest of a file.
428
+ * @param {Buffer} bytes
394
429
  * @param {boolean} sniff
395
- * @returns {{ text: string|null, reason?: string, lossy?: boolean }}
430
+ * @returns {{ text: string|null, reason?: string, lossy?: boolean, wide?: boolean }}
396
431
  */
397
432
  function decode(bytes, sniff) {
398
- if (sniff && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
433
+ if (bytes[0] === 0xFF && bytes[1] === 0xFE) return { text: utf16.decode(bytes), wide: true };
434
+ if (bytes[0] === 0xFE && bytes[1] === 0xFF) {
435
+ return { text: utf16.decode(Buffer.from(bytes.subarray(0, bytes.length & ~1)).swap16()), wide: true };
436
+ }
399
437
  try {
400
438
  return { text: utf8.decode(bytes) };
401
- } catch {
439
+ } catch (e) {
440
+ if (!(e instanceof TypeError)) throw e; // too large for a string, not bad UTF-8
441
+ if (sniff && looksBinary(bytes)) return { text: null, reason: 'binary' };
402
442
  return { text: utf8Lenient.decode(bytes), lossy: true };
403
443
  }
404
444
  }
405
445
 
446
+ /**
447
+ * True when stdin may hold input for check with no paths: anything but a terminal,
448
+ * the null device or an empty file. Pipes must be read to detect empty input;
449
+ * Windows pipes do not report isFIFO().
450
+ */
451
+ function stdinHasInput() {
452
+ try {
453
+ const stat = fstatSync(0);
454
+ return !stat.isCharacterDevice() && !(stat.isFile() && stat.size === 0);
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+
406
460
  /** @returns {Promise<Buffer>} */
407
461
  function readStdin() {
408
462
  return new Promise((res, rej) => {
@@ -656,11 +710,23 @@ async function main(argv) {
656
710
  if (opts.version) { out(VERSION); return 0; }
657
711
 
658
712
  const cwd = process.cwd();
713
+ const warn = (msg) => err(`text-sanctifier: ${msg}`);
659
714
  let paths = opts.paths;
660
- // --ai-config with no paths searches the current directory even when stdin is not a
661
- // terminal, so a CI job with no stdin still scans the repository; `-` reads stdin.
662
- if (paths.length === 0 && opts.aiConfig) {
715
+ /** @type {Buffer | undefined} */
716
+ let stdinBytes;
717
+ // check with no paths reads nonempty stdin once, otherwise scans the current
718
+ // directory. AI configuration discovery always defaults to the directory.
719
+ if (paths.length === 0 && opts.command === 'check') {
663
720
  paths = ['.'];
721
+ if (!opts.aiConfig && stdinHasInput()) {
722
+ try {
723
+ stdinBytes = await readStdin();
724
+ } catch (e) {
725
+ warn(`${STDIN_LABEL}: ${e.code || e.message}`);
726
+ return 2;
727
+ }
728
+ if (stdinBytes.length > 0) paths = [STDIN];
729
+ }
664
730
  } else if (paths.length === 0) {
665
731
  if (process.stdin.isTTY) {
666
732
  err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
@@ -669,7 +735,6 @@ async function main(argv) {
669
735
  paths = [STDIN];
670
736
  }
671
737
 
672
- const warn = (msg) => err(`text-sanctifier: ${msg}`);
673
738
  const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
674
739
  let errors = pathErrors;
675
740
  /** @type {Array<{ path: string, reason: string }>} */
@@ -682,11 +747,12 @@ async function main(argv) {
682
747
  * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
683
748
  */
684
749
  const forEachText = async (visit) => {
750
+ let visited = 0;
685
751
  for (const input of inputs) {
686
752
  const label = input.path === STDIN ? STDIN_LABEL : input.path;
687
753
  let bytes;
688
754
  try {
689
- bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
755
+ bytes = input.path === STDIN ? (stdinBytes ?? await readStdin()) : readFileSync(resolve(cwd, input.path));
690
756
  } catch (e) {
691
757
  warn(`${label}: ${e.code || e.message}`);
692
758
  errors++;
@@ -694,26 +760,45 @@ async function main(argv) {
694
760
  }
695
761
  // Files that look binary are skipped. Stdin and AI configuration files are text
696
762
  // by intent, so a NUL in one is a finding, not a reason to skip it.
697
- const { text, reason, lossy } = decode(bytes, input.path !== STDIN && !opts.aiConfig);
698
- if (text === null) {
763
+ let decoded;
764
+ try {
765
+ decoded = decode(bytes, input.path !== STDIN && !opts.aiConfig);
766
+ } catch (e) {
767
+ warn(`${label}: ${e.message}`);
768
+ errors++;
769
+ continue;
770
+ }
771
+ const { text, lossy, wide } = decoded;
772
+ let { reason } = decoded;
773
+ // fix writes UTF-8 in place, so it never rewrites what it could not decode or
774
+ // what was not UTF-8. A directory scan also leaves any file with a NUL alone;
775
+ // a file you name is cleaned if it is otherwise UTF-8.
776
+ if (opts.command === 'fix' && !reason) {
777
+ if (lossy) reason = 'not UTF-8';
778
+ else if (wide) reason = 'UTF-16';
779
+ else if (!input.explicit && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) reason = 'binary';
780
+ }
781
+ if (reason) {
699
782
  skipped.push({ path: label, reason });
700
- if (input.explicit) warn(`${label}: skipped (${reason})`);
783
+ if (input.explicit || reason !== 'binary') warn(`${label}: skipped (${reason})`);
784
+ // Asked to clean this input and did not: that must not look like success
785
+ if (opts.command === 'fix' && input.explicit) errors++;
701
786
  continue;
702
787
  }
703
788
  if (lossy) {
704
- // fix never rewrites bytes it could not decode
705
- if (opts.command === 'fix') {
706
- skipped.push({ path: label, reason: 'not UTF-8' });
707
- warn(`${label}: skipped (not UTF-8)`);
708
- continue;
709
- }
710
789
  notUtf8.push(label);
711
790
  warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
712
791
  }
792
+ visited++;
713
793
  visit(label, text, input);
714
794
  }
795
+ // A clean result must mean something was read. Finding no AI configuration files is
796
+ // an answer; --ai-config reports that itself.
797
+ if (visited === 0 && errors === 0 && !opts.aiConfig) errors++;
715
798
  };
716
799
 
800
+ const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '');
801
+
717
802
  if (opts.command === 'check') {
718
803
  const files = [];
719
804
  let scanned = 0;
@@ -765,8 +850,9 @@ async function main(argv) {
765
850
  out('No AI configuration files found.');
766
851
  if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
767
852
  }
768
- else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
769
- else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}.`);
853
+ else if (scanned === 0) out(`Nothing checked${skippedNote()}.`);
854
+ else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}${skippedNote()}.`);
855
+ else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}${skippedNote()}.`);
770
856
  }
771
857
  return errors > 0 ? 2 : total > 0 ? 1 : 0;
772
858
  }
@@ -858,9 +944,11 @@ async function main(argv) {
858
944
  if (f.newlinesNormalized) parts.push('normalized newlines');
859
945
  report(`${verb} ${f.path}: ${parts.join('; ')}`);
860
946
  }
861
- report(files.length === 0
862
- ? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
863
- : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
947
+ report(scanned === 0
948
+ ? `Nothing checked${skippedNote()}.`
949
+ : files.length === 0
950
+ ? `Nothing to fix in ${plural(scanned, 'file')}${skippedNote()}.`
951
+ : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${plural(scanned, 'file')}${skippedNote()}.`);
864
952
  }
865
953
  return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
866
954
  }
@@ -1,6 +1,6 @@
1
- var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,d=/[\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 A(e){return e<8192?D(e):v(e)}function D(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 v(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 I(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}var g=/[^\x20-\x7E\r\n]+/gu,B=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,X=/[\u2012\u2013\u2014\u2015\u2212]/g,k=/\u2026/g,O=/[\u2022\u00B7]/g,P=/[\uFF01-\uFF5E]/g;function S(e){return e.replace(B,"'").replace(G,'"').replace(X,"-").replace(k,"...").replace(O,"*").replace(P,r=>String.fromCharCode(r.charCodeAt(0)-65248))}function M(){try{return new RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{return/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}}var x=M();function m(e,r){return e.lastIndex=0,e.test(r)}function U(e){let r=S(e);if(!m(g,r))return!1;for(let n of r)if(!(n===`
2
- `||n==="\r"||n>=" "&&n<="~")&&!m(x,n))return!0;return!1}function j(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},u=(s,c,f)=>{s&&(n[c]=!0,r.push(f))};u(m(y,e),"hasControlChars","Control characters detected."),u(m(d,e),"hasInvisibleChars","Invisible Unicode characters detected."),u(m(x,e),"hasEmojis","Emojis detected.");let{mixed:o,types:i}=b(e);return n.hasMixedNewlines=o,n.newlineStyle=o?"Mixed":i[0]||null,n.newlineStyle&&r.push(o?"Mixed newline styles detected.":`Consistent newline style: ${n.newlineStyle}`),u(U(e),"hasNonKeyboardChars","Non-keyboard characters detected."),n}var z=100;function C(e,r={}){if(typeof e!="string")throw new TypeError("inspectTextDetailed expects a string input.");let n=r&&r.maxFindings,u=n===1/0||Number.isInteger(n)&&n>=0?n:z,o={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=[],s=0,c=1,f=0;for(let a=0,h=e.length;a<h;a++){let l=e.charCodeAt(a);if(l>=32&&l<127)continue;if(l===10){c++,f=a+1;continue}if(l===13){e.charCodeAt(a+1)!==10&&(c++,f=a+1);continue}let E=a,F=l,p;if(l>=55296&&l<=56319){let t=e.charCodeAt(a+1);if(!(t>=56320&&t<=57343))continue;F=65536+(l-55296<<10)+(t-56320),p=I(F),a++}else p=A(l);if(p!==null&&(o[p]++,s++,i.length<u)){let t={offset:E,line:c,column:E-f+1,codePoint:F,category:p};p==="bom"&&(t.atStart=E===0),i.push(t)}}return{findings:i,counts:o,total:s,truncated:s>i.length}}function b(e){if(typeof e!="string")throw new TypeError("getNewlineStats expects a string input.");let r=0,n=0,u=0;for(let s=0,c=e.length;s<c;s++){let f=e.charCodeAt(s);f===13?e.charCodeAt(s+1)===10?(r++,s++):n++:f===10&&u++}let o={crlf:r,cr:n,lf:u},i=[];return o.crlf>0&&i.push("CRLF"),o.cr>0&&i.push("CR"),o.lf>0&&i.push("LF"),{...o,types:i,mixed:i.length>1}}function K(e){return{purgeInvisibleChars:e.hasInvisibleChars,purgeEmojis:e.hasEmojis,nukeControls:e.hasControlChars,keyboardOnlyFilter:e.hasNonKeyboardChars,normalizeNewlines:e.hasMixedNewlines||e.newlineStyle==="CRLF"||e.newlineStyle==="CR"}}function H(e={}){let r=!!e.normalizeSpaces,n=!!e.purgeZeroWidthSpaces,u=!!e.purgeInvisibleChars,o=!!e.purgeEmojis,i=!!e.nukeControls,s=!!e.keyboardOnlyFilter,c=!!e.normalizeNewlines,f=!!e.trimSpacesAroundNewlines,a=!!e.collapseNewLines,h=!!e.preserveParagraphs,l=!!e.collapseSpaces,E=!!e.finalTrim,F=Number.isFinite(e.maxLength)&&e.maxLength>=0?e.maxLength:1/0,p=e.onMaxLength==="truncate"||e.onMaxLength==="noop"||e.onMaxLength==="throw"?e.onMaxLength:"throw";return t=>_(t,u,o,i,s,c,f,a,h,l,E,F,p,r,n)}function R(e){return _(e,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0)}function w(e){return _(e,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0)}function N(e){return _(e,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0)}function T(e){return _(e,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0)}function L(e){return _(e,!1,!1,!1,!1,!0,!1,!1,!1,!1,!1,1/0,"throw",!0,!0)}var W=Object.assign(H,{strict:R,loose:w,keyboardOnlyEmoji:N,keyboardOnly:T,pasted:L});function _(e,r=!1,n=!1,u=!1,o=!1,i=!1,s=!1,c=!1,f=!1,a=!1,h=!1,l=1/0,E="throw",F=!1,p=!1){if(typeof e!="string")throw new TypeError("sanctifyText expects a string input.");if(E!=="truncate"&&E!=="noop"&&E!=="throw"&&(E="throw"),e.length>l)switch(E){case"truncate":e=e.slice(0,l);break;case"noop":return e;case"throw":default:throw new RangeError(`sanctifyText input length ${e.length} exceeds maxLength ${l}.`)}let t=e;return F&&(t=t.replace(J," ")),p&&(t=t.replace(V,"")),r&&(t=Z(t)),n&&(t=q(t)),u&&(t=le(t)),o&&(t=Y(t,n)),i&&(t=ee(t)),s&&(t=ne(t)),c&&(t=oe(t,f)),a&&(t=se(t)),h?t.trim():t}var J=/[\u00A0\u2000-\u200A\u202F\u205F]/g,V=/[\u200B\u2060\uFEFF]/g;function Z(e){return e.replace(d,"")}function $(e){if(x.lastIndex=0,!x.test(e))return"";x.lastIndex=0;let r="";for(let n of e.matchAll(x))r+=n[0];return r}function Y(e,r=!1){let n=S(e);return r?n.replace(g,""):n.replace(g,u=>$(u))}function q(e){return e.replace(x,"")}var Q=/\r\n|\r|\n/g;function ee(e,r=`
3
- `){return e.replace(Q,r)}var re=/[ \t]*(\n+)[ \t]*/g;function ne(e){return e.replace(re,"$1")}var te=/\n{2,}/g,ie=/\n{3,}/g;function oe(e,r){return r?e.replace(ie,`
1
+ var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g,S=/[\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?v(e):B(e)}function v(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 B(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}var g=/[^\x20-\x7E\r\n]+/gu,G=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,X=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,M=/[\u2012\u2013\u2014\u2015\u2212]/g,O=/\u2026/g,k=/[\u2022\u00B7]/g,P=/[\uFF01-\uFF5E]/g;function d(e){return e.replace(G,"'").replace(X,'"').replace(M,"-").replace(O,"...").replace(k,"*").replace(P,r=>String.fromCharCode(r.charCodeAt(0)-65248))}function U(){try{return new RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{return/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}}var p=U();function m(e,r){return e.lastIndex=0,e.test(r)}function z(e){let r=d(e);if(!m(g,r))return!1;for(let n of r)if(!(n===`
2
+ `||n==="\r"||n>=" "&&n<="~")&&!m(p,n))return!0;return!1}function j(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},u=(l,c,f)=>{l&&(n[c]=!0,r.push(f))};u(m(y,e),"hasControlChars","Control characters detected."),u(m(S,e),"hasInvisibleChars","Invisible Unicode characters detected."),u(m(p,e),"hasEmojis","Emojis detected.");let{mixed:o,types:i}=R(e);return n.hasMixedNewlines=o,n.newlineStyle=o?"Mixed":i[0]||null,n.newlineStyle&&r.push(o?"Mixed newline styles detected.":`Consistent newline style: ${n.newlineStyle}`),u(z(e),"hasNonKeyboardChars","Non-keyboard characters detected."),n}var K=100;function A(e,r={}){if(typeof e!="string")throw new TypeError("inspectTextDetailed expects a string input.");let n=r&&r.maxFindings,u=n===1/0||Number.isInteger(n)&&n>=0?n:K,o={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,h=e.length;a<h;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 x=a,F=s,E;if(s>=55296&&s<=56319){let t=e.charCodeAt(a+1);if(!(t>=56320&&t<=57343))continue;F=65536+(s-55296<<10)+(t-56320),E=b(F),a++}else E=I(s);if(E!==null&&(o[E]++,l++,i.length<u)){let t={offset:x,line:c,column:x-f+1,codePoint:F,category:E};E==="bom"&&(t.atStart=x===0),i.push(t)}}return{findings:i,counts:o,total:l,truncated:l>i.length}}function R(e){if(typeof e!="string")throw new TypeError("getNewlineStats expects a string input.");let r=0,n=0,u=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&&u++}let o={crlf:r,cr:n,lf:u},i=[];return o.crlf>0&&i.push("CRLF"),o.cr>0&&i.push("CR"),o.lf>0&&i.push("LF"),{...o,types:i,mixed:i.length>1}}function H(e){return{normalizeSpaces:e.hasInvisibleChars,purgeInvisibleChars:e.hasInvisibleChars,purgeEmojis:!1,nukeControls:e.hasControlChars,keyboardOnlyFilter:!1,normalizeNewlines:e.hasMixedNewlines||e.newlineStyle==="CRLF"||e.newlineStyle==="CR"}}function W(e={}){let r=!!e.normalizeSpaces,n=!!e.purgeZeroWidthSpaces,u=!!e.purgeInvisibleChars,o=!!e.purgeEmojis,i=!!e.nukeControls,l=!!e.keyboardOnlyFilter,c=!!e.normalizeNewlines,f=!!e.trimSpacesAroundNewlines,a=!!e.collapseNewLines,h=!!e.preserveParagraphs,s=!!e.collapseSpaces,x=!!e.finalTrim,F=Number.isFinite(e.maxLength)&&e.maxLength>=0?e.maxLength:1/0,E=e.onMaxLength==="truncate"||e.onMaxLength==="noop"||e.onMaxLength==="throw"?e.onMaxLength:"throw";return t=>_(t,u,o,i,l,c,f,a,h,s,x,F,E,r,n)}function w(e){return _(e,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0)}function N(e){return _(e,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0)}function T(e){return _(e,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0)}function L(e){return _(e,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0)}function D(e){return _(e,!1,!1,!1,!1,!0,!1,!1,!1,!1,!1,1/0,"throw",!0,!0)}var J=Object.assign(W,{strict:w,loose:N,keyboardOnlyEmoji:T,keyboardOnly:L,pasted:D});function _(e,r=!1,n=!1,u=!1,o=!1,i=!1,l=!1,c=!1,f=!1,a=!1,h=!1,s=1/0,x="throw",F=!1,E=!1){if(typeof e!="string")throw new TypeError("sanctifyText expects a string input.");if(e.length>s)switch(x){case"truncate":{let C=e.charCodeAt(s-1);e=e.slice(0,C>=55296&&C<=56319?s-1:s)}break;case"noop":return e;default:throw new RangeError(`sanctifyText input length ${e.length} exceeds maxLength ${s}.`)}let t=e.replace($,"\uFFFD");return F&&(t=t.replace(V," ")),E&&(t=t.replace(Z,"")),r&&(t=Y(t)),n&&(t=ee(t)),u&&(t=fe(t)),o&&(t=Q(t,n)),i&&(t=ne(t)),l&&(t=ie(t)),c&&(t=se(t,f)),a&&(t=ae(t)),h?t.trim():t}var V=/[\u00A0\u2000-\u200A\u202F\u205F]/g,$=/[\uD800-\uDFFF]/gu,Z=/[\u200B\u2060\uFEFF]/g;function Y(e){return e.replace(S,"")}function q(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}function Q(e,r=!1){let n=d(e);return r?n.replace(g,""):n.replace(g,u=>q(u))}function ee(e){return e.replace(p,"")}var re=/\r\n|\r|\n/g;function ne(e,r=`
3
+ `){return e.replace(re,r)}var te=/[ \t]*(\n+)[ \t]*|([ \t]+)/g;function ie(e){return e.replace(te,"$1$2")}var oe=/\n{2,}/g,ue=/\n{3,}/g;function se(e,r){return r?e.replace(ue,`
4
4
 
5
- `):e.replace(te,`
6
- `)}var ue=/ {2,}/g;function se(e){return e.replace(ue," ")}function le(e){return e.replace(y,"")}function ae(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of C(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var fe="1.3.10";export{fe as VERSION,b as getNewlineStats,K as getRecommendedSanctifierOptions,j as inspectText,C as inspectTextDetailed,ae as revealInvisible,T as sanctifyKeyboardOnly,N as sanctifyKeyboardOnlyEmoji,w as sanctifyLoose,L as sanctifyPasted,R as sanctifyStrict,W as summonSanctifier};
5
+ `):e.replace(oe,`
6
+ `)}var le=/ {2,}/g;function ae(e){return e.replace(le," ")}function fe(e){return e.replace(y,"")}function ce(e,r={}){if(typeof e!="string")throw new TypeError("revealInvisible expects a string input.");let n=r&&typeof r.format=="function"?r.format:i=>"<U+"+i.toString(16).toUpperCase().padStart(4,"0")+">",u="",o=0;for(let i of A(e,{maxFindings:1/0}).findings)u+=e.slice(o,i.offset)+n(i.codePoint,i.category),o=i.offset+(i.codePoint>65535?2:1);return u+e.slice(o)}var Ee="1.3.11";export{Ee as VERSION,R as getNewlineStats,H as getRecommendedSanctifierOptions,j as inspectText,A as inspectTextDetailed,ce as revealInvisible,L as sanctifyKeyboardOnly,T as sanctifyKeyboardOnlyEmoji,N as sanctifyLoose,D as sanctifyPasted,w as sanctifyStrict,J as summonSanctifier};
@@ -43,7 +43,10 @@ export function inspectTextDetailed(text: string, options?: InspectDetailedOptio
43
43
  */
44
44
  export function getNewlineStats(text: string): NewlineStats;
45
45
  /**
46
- * Creates defaultOptions for summonSanctifier based on inspectText result
46
+ * Creates defaultOptions for summonSanctifier based on inspectText result.
47
+ * Recommends only what removes trash without losing meaning: non-ASCII text and emoji are
48
+ * content, so `purgeEmojis` and `keyboardOnlyFilter` stay opt-in, and look-alike spaces
49
+ * become ordinary spaces so that words do not run together.
47
50
  * @param {!UnicodeTrashReport} report
48
51
  * @return {!SanctifyOptions}
49
52
  */
@@ -68,7 +68,7 @@ export function sanctifyPasted(text: string): string;
68
68
  * @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
69
69
  * @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
70
70
  * - `'throw'`: throw a `RangeError`
71
- * - `'truncate'`: slice to `maxLength` before processing
71
+ * - `'truncate'`: slice to at most `maxLength` before processing, never through a surrogate pair
72
72
  * - `'noop'`: return the original input unchanged (skips processing)
73
73
  * @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
74
74
  * so `purgeInvisibleChars` no longer joins the words around them.
@@ -1,2 +1,2 @@
1
1
  /** The installed version of text-sanctifier, as in its package.json. */
2
- export const VERSION: "1.3.10";
2
+ export const VERSION: "1.3.11";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "text-sanctifier",
3
- "version": "1.3.10",
3
+ "version": "1.3.11",
4
4
  "type": "module",
5
5
  "description": "Find and remove invisible Unicode in web input, model-bound text and repositories.",
6
6
  "engines": {
@@ -48,15 +48,17 @@
48
48
  "build": "node scripts/build.js",
49
49
  "build:types": "tsc -p tsconfig.types.json",
50
50
  "test:types": "tsc -p tests/types/tsconfig.json",
51
- "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js && node tests/release.test.js",
51
+ "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js && node tests/release.test.js && node tests/names.test.js && node tests/docs.test.js && node tests/repo.test.js",
52
52
  "test-min": "node tests/sanctifyText.test.min.js",
53
53
  "check:self": "node bin/text-sanctifier.js check .",
54
54
  "check:names": "node scripts/check-names.js",
55
- "ci": "npm run gen:check && npm run check:names && npm run check:self && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
55
+ "check:repo": "node scripts/check-repo.js",
56
+ "ci": "npm run gen:check && npm run check:names && npm run check:repo && npm run check:self && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
56
57
  "size": "node scripts/check-size.js",
57
58
  "bench": "node --expose-gc scripts/bench.js",
58
59
  "release:prep": "node scripts/release.js prep",
59
60
  "release:verify": "node scripts/release.js verify",
61
+ "prepublishOnly": "node scripts/release.js check-publish",
60
62
  "hooks:install": "git config core.hooksPath scripts/hooks"
61
63
  },
62
64
  "keywords": [
@@ -83,7 +85,7 @@
83
85
  "test": "tests"
84
86
  },
85
87
  "devDependencies": {
86
- "esbuild": "^0.25.3",
87
- "typescript": "^5.9.3"
88
+ "esbuild": "0.25.12",
89
+ "typescript": "5.9.3"
88
90
  }
89
91
  }
@@ -322,16 +322,20 @@ export function getNewlineStats(text) {
322
322
  }
323
323
 
324
324
  /**
325
- * Creates defaultOptions for summonSanctifier based on inspectText result
325
+ * Creates defaultOptions for summonSanctifier based on inspectText result.
326
+ * Recommends only what removes trash without losing meaning: non-ASCII text and emoji are
327
+ * content, so `purgeEmojis` and `keyboardOnlyFilter` stay opt-in, and look-alike spaces
328
+ * become ordinary spaces so that words do not run together.
326
329
  * @param {!UnicodeTrashReport} report
327
330
  * @return {!SanctifyOptions}
328
331
  */
329
332
  export function getRecommendedSanctifierOptions(report) {
330
333
  return {
334
+ normalizeSpaces: report.hasInvisibleChars,
331
335
  purgeInvisibleChars: report.hasInvisibleChars,
332
- purgeEmojis: report.hasEmojis,
336
+ purgeEmojis: false,
333
337
  nukeControls: report.hasControlChars,
334
- keyboardOnlyFilter: report.hasNonKeyboardChars,
338
+ keyboardOnlyFilter: false,
335
339
  normalizeNewlines:
336
340
  report.hasMixedNewlines || report.newlineStyle === 'CRLF' || report.newlineStyle === 'CR',
337
341
  };
@@ -294,7 +294,7 @@ export const summonSanctifier = /* @__PURE__ */ Object.assign(createSanctifier,
294
294
  * @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
295
295
  * @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
296
296
  * - `'throw'`: throw a `RangeError`
297
- * - `'truncate'`: slice to `maxLength` before processing
297
+ * - `'truncate'`: slice to at most `maxLength` before processing, never through a surrogate pair
298
298
  * - `'noop'`: return the original input unchanged (skips processing)
299
299
  * @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
300
300
  * so `purgeInvisibleChars` no longer joins the words around them.
@@ -324,26 +324,27 @@ export function sanctifyText(
324
324
  throw new TypeError('sanctifyText expects a string input.');
325
325
  }
326
326
 
327
- if (onMaxLength !== 'truncate' && onMaxLength !== 'noop' && onMaxLength !== 'throw') {
328
- onMaxLength = 'throw';
329
- }
330
-
331
327
  if (text.length > maxLength) {
332
328
  switch (onMaxLength) {
333
329
  case 'truncate':
334
- text = text.slice(0, maxLength);
330
+ {
331
+ // Cut before a surrogate pair, not through it
332
+ const last = text.charCodeAt(maxLength - 1);
333
+ text = text.slice(0, last >= 0xD800 && last <= 0xDBFF ? maxLength - 1 : maxLength);
334
+ }
335
335
  break;
336
336
  case 'noop':
337
337
  return text;
338
- case 'throw':
339
- default:
338
+ default: // 'throw', and any invalid value
340
339
  throw new RangeError(
341
340
  `sanctifyText input length ${text.length} exceeds maxLength ${maxLength}.`
342
341
  );
343
342
  }
344
343
  }
345
344
 
346
- let cleaned = text;
345
+ // Unpaired surrogates become U+FFFD first, so removing a character between two
346
+ // halves cannot join them into a new character, such as a hidden tag.
347
+ let cleaned = text.replace(LONE_SURROGATES_REGEX, '\uFFFD');
347
348
 
348
349
  // Look-alike spaces become ordinary spaces, before anything can delete them
349
350
  if (normalizeSpaces) cleaned = cleaned.replace(SPACE_LOOKALIKES_REGEX, ' ');
@@ -389,6 +390,9 @@ export function sanctifyText(
389
390
  */
390
391
  export const SPACE_LOOKALIKES_REGEX = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
391
392
 
393
+ /** With the `u` flag a surrogate pair is one character, so this matches only unpaired halves. */
394
+ const LONE_SURROGATES_REGEX = /[\uD800-\uDFFF]/gu;
395
+
392
396
  /** Zero-width space, word joiner and BOM: invisible and never needed in plain text. */
393
397
  export const ZERO_WIDTH_SPACES_REGEX = /[\u200B\u2060\uFEFF]/g;
394
398
 
@@ -497,9 +501,11 @@ function normalizeNewlineChars(text, normalized = '\n') {
497
501
  * @param {string} text
498
502
  * @returns {string}
499
503
  */
500
- const TRIM_SPACES_AROUND_NEWLINES_REGEX = /[ \t]*(\n+)[ \t]*/g;
504
+ // The second branch consumes a run with no newline after it, so matching resumes past the
505
+ // run instead of retrying from each of its characters, which took quadratic time.
506
+ const TRIM_SPACES_AROUND_NEWLINES_REGEX = /[ \t]*(\n+)[ \t]*|([ \t]+)/g;
501
507
  function trimSpacesAroundNewlineChars(text) {
502
- return text.replace(TRIM_SPACES_AROUND_NEWLINES_REGEX, '$1');
508
+ return text.replace(TRIM_SPACES_AROUND_NEWLINES_REGEX, '$1$2');
503
509
  }
504
510
 
505
511
 
@@ -2,4 +2,4 @@
2
2
  // Generated from package.json by scripts/gen-version.js (`npm run gen`); do not edit.
3
3
 
4
4
  /** The installed version of text-sanctifier, as in its package.json. */
5
- export const VERSION = '1.3.10';
5
+ export const VERSION = '1.3.11';
package/src/index.d.ts DELETED
@@ -1,22 +0,0 @@
1
- import { inspectText } from './inspectText.js';
2
- import { inspectTextDetailed } from './inspectText.js';
3
- import { getNewlineStats } from './inspectText.js';
4
- import { getRecommendedSanctifierOptions } from './inspectText.js';
5
- import { summonSanctifier } from './sanctifyText.js';
6
- import { sanctifyStrict } from './sanctifyText.js';
7
- import { sanctifyLoose } from './sanctifyText.js';
8
- import { sanctifyKeyboardOnlyEmoji } from './sanctifyText.js';
9
- import { sanctifyKeyboardOnly } from './sanctifyText.js';
10
- import { sanctifyPasted } from './sanctifyText.js';
11
- import { revealInvisible } from './reveal.js';
12
- import { VERSION } from './version.generated.js';
13
- export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted, revealInvisible, VERSION };
14
- export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
15
- export type TrashCategory = import('./inspectText.js').TrashCategory;
16
- export type TrashFinding = import('./inspectText.js').TrashFinding;
17
- export type TrashCounts = import('./inspectText.js').TrashCounts;
18
- export type DetailedTrashReport = import('./inspectText.js').DetailedTrashReport;
19
- export type InspectDetailedOptions = import('./inspectText.js').InspectDetailedOptions;
20
- export type NewlineStats = import('./inspectText.js').NewlineStats;
21
- export type SanctifyOptions = import('./sanctifyText.js').SanctifyOptions;
22
- export type RevealOptions = import('./reveal.js').RevealOptions;