text-sanctifier 1.3.9 → 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.
@@ -10,14 +10,17 @@
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';
17
+ import { createHash } from 'node:crypto';
17
18
  import { inspectTextDetailed, summonSanctifier, revealInvisible } from '../src/index.js';
18
19
  import { getNewlineStats } from '../src/inspectText.js';
19
20
  import { SPACE_LOOKALIKES_REGEX } from '../src/sanctifyText.js';
20
- import { findHiddenRuns } from '../src/hiddenText.js';
21
+ import { scanHiddenSpans } from '../src/hiddenScan.js';
22
+ import { inspectTextInContext } from '../src/context.js';
23
+ import { classifyCodeUnit, classifyAstral } from '../src/categories.generated.js';
21
24
 
22
25
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
23
26
 
@@ -29,8 +32,12 @@ Usage:
29
32
  text-sanctifier reveal [paths...]
30
33
 
31
34
  Paths may be files, directories or globs (quote globs so the shell leaves them
32
- alone). "-", or no paths with piped input, reads stdin. Directories and globs
33
- 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.
34
41
 
35
42
  check options:
36
43
  --format F text (default), json or sarif (SARIF 2.1.0)
@@ -46,6 +53,7 @@ fix options:
46
53
  --normalize-spaces replace look-alike spaces (NBSP, narrow NBSP, typographic
47
54
  spaces) with an ordinary space instead of removing them
48
55
  --purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
56
+ --keep-benign keep contextually legitimate joiners and selectors when purging
49
57
  --nuke-controls remove C0/C1 control characters and bidi controls
50
58
  --normalize-newlines convert CRLF and CR to LF
51
59
  --strip-bom also remove a BOM at the start of a file (kept by default)
@@ -73,6 +81,7 @@ const BOM = 0xFEFF;
73
81
  const STDIN = '-';
74
82
  const STDIN_LABEL = '<stdin>';
75
83
  const BINARY_SNIFF_BYTES = 8000;
84
+ const BINARY_CONTROL_SHARE = 0.05;
76
85
  const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
77
86
  const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
78
87
 
@@ -86,11 +95,12 @@ const BOOLEAN_FLAGS = {
86
95
  '--strip-bom': 'stripBom',
87
96
  '--normalize-spaces': 'normalizeSpaces',
88
97
  '--purge-invisible': 'purgeInvisible',
98
+ '--keep-benign': 'keepBenign',
89
99
  '--nuke-controls': 'nukeControls',
90
100
  '--normalize-newlines': 'normalizeNewlines',
91
101
  '--dry-run': 'dryRun',
92
102
  };
93
- const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
103
+ const FIX_ONLY_FLAGS = new Set(['normalizeSpaces', 'purgeInvisible', 'keepBenign', 'nukeControls', 'normalizeNewlines', 'dryRun']);
94
104
  const CHECK_ONLY_FLAGS = new Set(['aiConfig']);
95
105
  const FORMATS = new Set(['text', 'json', 'sarif']);
96
106
 
@@ -106,6 +116,7 @@ function parseArgs(argv) {
106
116
  stripBom: false,
107
117
  normalizeSpaces: false,
108
118
  purgeInvisible: false,
119
+ keepBenign: false,
109
120
  nukeControls: false,
110
121
  normalizeNewlines: false,
111
122
  dryRun: false,
@@ -200,21 +211,36 @@ function isGitWorkTree(cwd) {
200
211
  }
201
212
 
202
213
  /**
203
- * Lists tracked and untracked-but-not-ignored files under one pathspec.
214
+ * Lists tracked and untracked-but-not-ignored files under the given pathspecs.
204
215
  * Returns null when git cannot answer (for example a path outside the work tree).
205
216
  * @param {string} cwd
206
- * @param {string} pathspec
217
+ * @param {string[]} pathspecs
207
218
  * @returns {string[]|null}
208
219
  */
209
- function gitListFiles(cwd, pathspec) {
220
+ function gitListFiles(cwd, pathspecs) {
210
221
  const r = spawnSync(
211
222
  'git',
212
- ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
223
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs],
213
224
  { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
214
225
  );
215
226
  if (r.error || r.status !== 0) return null;
216
- // --cached lists tracked files deleted from the work tree; drop them
217
- 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)));
218
244
  }
219
245
 
220
246
  /**
@@ -360,7 +386,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
360
386
 
361
387
  if (inGit === undefined) inGit = isGitWorkTree(cwd);
362
388
  let listed =
363
- (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
389
+ (inGit && gitListFiles(cwd, isGlob ? expandBraces(toSlash(arg)).map((g) => `:(glob)${g}`) : [toSlash(arg)])) ||
364
390
  (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
365
391
 
366
392
  if (aiConfig && !isGlob) {
@@ -368,6 +394,7 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
368
394
  listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
369
395
  } else if (listed.length === 0) {
370
396
  warn(`${arg}: no files matched`);
397
+ errors++;
371
398
  }
372
399
  for (const f of listed) add(f, false);
373
400
  }
@@ -379,24 +406,57 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
379
406
 
380
407
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
381
408
  const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
409
+ const utf16 = new TextDecoder('utf-16le', { ignoreBOM: true });
382
410
 
383
411
  /**
384
- * Decodes UTF-8, keeping a leading BOM. With `sniff`, data with a NUL in its first
385
- * BINARY_SNIFF_BYTES is binary and not decoded. Bytes that are not UTF-8 become U+FFFD
386
- * 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`.
387
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
388
429
  * @param {boolean} sniff
389
- * @returns {{ text: string|null, reason?: string, lossy?: boolean }}
430
+ * @returns {{ text: string|null, reason?: string, lossy?: boolean, wide?: boolean }}
390
431
  */
391
432
  function decode(bytes, sniff) {
392
- 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
+ }
393
437
  try {
394
438
  return { text: utf8.decode(bytes) };
395
- } 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' };
396
442
  return { text: utf8Lenient.decode(bytes), lossy: true };
397
443
  }
398
444
  }
399
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
+
400
460
  /** @returns {Promise<Buffer>} */
401
461
  function readStdin() {
402
462
  return new Promise((res, rej) => {
@@ -458,46 +518,56 @@ const RUN_NOUNS = { tag: 'tag character', variation_selector: 'variation selecto
458
518
  * @param {boolean} stripBom
459
519
  */
460
520
  function inspectAiConfig(text, maxFindings, stripBom) {
461
- const full = inspect(text, Infinity, stripBom);
462
- const { runs, benign } = findHiddenRuns(text);
463
-
464
- const kept = [];
465
- let b = 0;
466
- for (const f of full.findings) {
467
- while (b < benign.length && benign[b][1] <= f.offset) b++;
468
- if (b < benign.length && benign[b][0] <= f.offset) { full.counts[f.category]--; continue; }
469
- kept.push(f);
470
- }
471
-
472
- // Every run character is a finding, so each run starts at a kept finding
473
- const hiddenRuns = [];
521
+ const full = inspect(text, 0, stripBom);
522
+ const findings = [];
474
523
  const loose = [];
475
- let r = 0;
476
- for (const f of kept) {
477
- while (r < runs.length && runs[r].end <= f.offset) r++;
478
- const run = runs[r];
479
- if (!run || f.offset < run.offset) { loose.push(f); continue; }
480
- if (f.offset !== run.offset) continue;
481
- const entry = { kind: run.kind, offset: run.offset, line: f.line, column: f.column, codePoint: f.codePoint, count: run.count };
524
+ let looseCount = 0;
525
+ let position = 0, line = 1, lineStart = 0;
526
+ const scan = (end, inRun, benign) => {
527
+ while (position < end) {
528
+ const offset = position;
529
+ const codePoint = text.codePointAt(position);
530
+ position += codePoint > 0xFFFF ? 2 : 1;
531
+ if (codePoint === 10 || (codePoint === 13 && text.charCodeAt(position) !== 10)) {
532
+ line++; lineStart = position; continue;
533
+ }
534
+ const category = codePoint > 0xFFFF ? classifyAstral(codePoint) : classifyCodeUnit(codePoint);
535
+ if (!category || (offset === 0 && codePoint === BOM && !stripBom)) continue;
536
+ if (benign) { full.counts[category]--; full.total--; continue; }
537
+ if (findings.length < maxFindings || (!inRun && loose.length < maxFindings)) {
538
+ const f = { offset, line, column: offset - lineStart + 1, codePoint, category };
539
+ if (category === 'bom') f.atStart = offset === 0;
540
+ if (findings.length < maxFindings) findings.push(f);
541
+ if (!inRun && loose.length < maxFindings) loose.push(f);
542
+ }
543
+ if (!inRun) looseCount++;
544
+ }
545
+ };
546
+ // Decode beyond the cut point by one marker so slicing matches the original output.
547
+ const hidden = scanHiddenSpans(text, { maxRuns: maxFindings, maxDecodedLength: MAX_DECODED + 10 }, (span) => {
548
+ scan(span.offset, false, false);
549
+ scan(span.end, !span.benign, span.benign);
550
+ });
551
+ scan(text.length, false, false);
552
+ const hiddenRuns = hidden.runs.map((run) => {
553
+ const entry = { kind: run.kind, offset: run.offset, line: run.line, column: run.column, codePoint: text.codePointAt(run.offset), count: run.count };
482
554
  if (run.decoded !== undefined) {
483
555
  entry.decoded = run.decoded.slice(0, MAX_DECODED);
484
- entry.decodedTruncated = run.decoded.length > MAX_DECODED;
556
+ entry.decodedTruncated = run.decodedTruncated || run.decoded.length > MAX_DECODED;
485
557
  }
486
- hiddenRuns.push(entry);
487
- }
488
-
489
- const findings = kept.slice(0, maxFindings);
558
+ return entry;
559
+ });
490
560
  return {
491
561
  report: {
492
562
  ...full,
493
563
  findings,
494
- total: kept.length,
495
- truncated: kept.length > findings.length,
496
- hiddenRuns: hiddenRuns.slice(0, maxFindings),
497
- hiddenRunCount: hiddenRuns.length,
564
+ truncated: full.total > findings.length,
565
+ hiddenRuns,
566
+ hiddenRunCount: hidden.total,
498
567
  },
499
568
  hiddenRuns,
500
569
  loose,
570
+ entryCount: hidden.total + looseCount,
501
571
  };
502
572
  }
503
573
 
@@ -530,6 +600,30 @@ const CATEGORY_RULES = {
530
600
  const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
531
601
  const INFORMATION_URI = 'https://github.com/iWhatty/text-sanctifier';
532
602
  const ABSOLUTE_PATH = /^(?:[a-zA-Z]:)?[\\/]/;
603
+ const fingerprints = new WeakMap();
604
+
605
+ function fingerprintFindings(label, text, findings, cwd) {
606
+ const path = label === STDIN_LABEL ? 'stdin' : toSlash(relative(cwd, resolve(cwd, label)));
607
+ let previousLine = 0, start = 0, end = 0;
608
+ let occurrences = new Map();
609
+ const hashOccurrences = new Map();
610
+ for (const finding of findings) {
611
+ if (finding.line !== previousLine) {
612
+ start = finding.offset - finding.column + 1;
613
+ end = start;
614
+ while (end < text.length && text.charCodeAt(end) !== 10 && text.charCodeAt(end) !== 13) end++;
615
+ occurrences = new Map();
616
+ previousLine = finding.line;
617
+ }
618
+ const index = occurrences.get(finding.category) || 0;
619
+ occurrences.set(finding.category, index + 1);
620
+ const hash = createHash('sha256').update(JSON.stringify([finding.category, path, text.slice(start, end), index])).digest('hex');
621
+ const count = (hashOccurrences.get(hash) || 0) + 1;
622
+ hashOccurrences.set(hash, count);
623
+ fingerprints.set(finding, count === 1 ? hash :
624
+ createHash('sha256').update(JSON.stringify([hash, count])).digest('hex'));
625
+ }
626
+ }
533
627
 
534
628
  /** @param {string} label */
535
629
  function sarifArtifact(label) {
@@ -559,6 +653,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
559
653
  const run = runAt.get(x.offset);
560
654
  results.push({
561
655
  ruleId: x.category,
656
+ partialFingerprints: { 'textSanctifier/v1': fingerprints.get(x) },
562
657
  ruleIndex: ruleIds.indexOf(x.category),
563
658
  level: CATEGORY_RULES[x.category][0],
564
659
  message: { text: `${hex(x.codePoint)} ${x.category}${run ? `, start of a ${describeRun(run)}` : ''}` },
@@ -615,11 +710,23 @@ async function main(argv) {
615
710
  if (opts.version) { out(VERSION); return 0; }
616
711
 
617
712
  const cwd = process.cwd();
713
+ const warn = (msg) => err(`text-sanctifier: ${msg}`);
618
714
  let paths = opts.paths;
619
- // --ai-config with no paths searches the current directory even when stdin is not a
620
- // terminal, so a CI job with no stdin still scans the repository; `-` reads stdin.
621
- 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') {
622
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
+ }
623
730
  } else if (paths.length === 0) {
624
731
  if (process.stdin.isTTY) {
625
732
  err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
@@ -628,7 +735,6 @@ async function main(argv) {
628
735
  paths = [STDIN];
629
736
  }
630
737
 
631
- const warn = (msg) => err(`text-sanctifier: ${msg}`);
632
738
  const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
633
739
  let errors = pathErrors;
634
740
  /** @type {Array<{ path: string, reason: string }>} */
@@ -641,11 +747,12 @@ async function main(argv) {
641
747
  * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
642
748
  */
643
749
  const forEachText = async (visit) => {
750
+ let visited = 0;
644
751
  for (const input of inputs) {
645
752
  const label = input.path === STDIN ? STDIN_LABEL : input.path;
646
753
  let bytes;
647
754
  try {
648
- bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
755
+ bytes = input.path === STDIN ? (stdinBytes ?? await readStdin()) : readFileSync(resolve(cwd, input.path));
649
756
  } catch (e) {
650
757
  warn(`${label}: ${e.code || e.message}`);
651
758
  errors++;
@@ -653,26 +760,45 @@ async function main(argv) {
653
760
  }
654
761
  // Files that look binary are skipped. Stdin and AI configuration files are text
655
762
  // by intent, so a NUL in one is a finding, not a reason to skip it.
656
- const { text, reason, lossy } = decode(bytes, input.path !== STDIN && !opts.aiConfig);
657
- 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) {
658
782
  skipped.push({ path: label, reason });
659
- 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++;
660
786
  continue;
661
787
  }
662
788
  if (lossy) {
663
- // fix never rewrites bytes it could not decode
664
- if (opts.command === 'fix') {
665
- skipped.push({ path: label, reason: 'not UTF-8' });
666
- warn(`${label}: skipped (not UTF-8)`);
667
- continue;
668
- }
669
789
  notUtf8.push(label);
670
790
  warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
671
791
  }
792
+ visited++;
672
793
  visit(label, text, input);
673
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++;
674
798
  };
675
799
 
800
+ const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '');
801
+
676
802
  if (opts.command === 'check') {
677
803
  const files = [];
678
804
  let scanned = 0;
@@ -689,16 +815,18 @@ async function main(argv) {
689
815
  const report = inspect(text, opts.maxFindings, opts.stripBom);
690
816
  total += report.total;
691
817
  if (report.total === 0) return;
818
+ if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
692
819
  files.push({ path: label, ...report });
693
820
  for (const x of report.findings) lines.push(findingLine(label, x));
694
821
  if (report.truncated) lines.push(`${label}: ${report.total - report.findings.length} more not listed (--max-findings)`);
695
822
  return;
696
823
  }
697
824
 
698
- const { report, hiddenRuns, loose } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
825
+ const { report, hiddenRuns, loose, entryCount } = inspectAiConfig(text, opts.maxFindings, opts.stripBom);
699
826
  total += report.total;
700
827
  hiddenRunCount += report.hiddenRunCount;
701
828
  if (report.total === 0) return;
829
+ if (opts.format === 'sarif') fingerprintFindings(label, text, report.findings, cwd);
702
830
  files.push({ path: label, ...report });
703
831
  // Text output lists each run once, in place of its characters
704
832
  const entries = [
@@ -706,7 +834,7 @@ async function main(argv) {
706
834
  ...loose.map((x) => ({ offset: x.offset, text: findingLine(label, x) })),
707
835
  ].sort((a, b) => a.offset - b.offset);
708
836
  for (const e of entries.slice(0, opts.maxFindings)) lines.push(e.text);
709
- if (entries.length > opts.maxFindings) lines.push(`${label}: ${entries.length - opts.maxFindings} more not listed (--max-findings)`);
837
+ if (entryCount > opts.maxFindings) lines.push(`${label}: ${entryCount - opts.maxFindings} more not listed (--max-findings)`);
710
838
  });
711
839
 
712
840
  const kind = opts.aiConfig ? 'AI configuration file' : 'file';
@@ -722,8 +850,9 @@ async function main(argv) {
722
850
  out('No AI configuration files found.');
723
851
  if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
724
852
  }
725
- else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}.`);
726
- 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()}.`);
727
856
  }
728
857
  return errors > 0 ? 2 : total > 0 ? 1 : 0;
729
858
  }
@@ -757,7 +886,21 @@ async function main(argv) {
757
886
  scanned++;
758
887
  // The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
759
888
  const leadingBom = text.charCodeAt(0) === BOM;
760
- let cleaned = clean(leadingBom ? text.slice(1) : text);
889
+ const body = leadingBom ? text.slice(1) : text;
890
+ let cleaned;
891
+ if (opts.keepBenign && opts.purgeInvisible) {
892
+ const benign = inspectTextInContext(body, { maxFindings: Infinity }).findings.filter((f) => f.benign);
893
+ let start = 0;
894
+ const parts = [];
895
+ for (const f of benign) {
896
+ parts.push(clean(body.slice(start, f.offset)));
897
+ const end = f.offset + (f.codePoint > 0xFFFF ? 2 : 1);
898
+ parts.push(body.slice(f.offset, end));
899
+ start = end;
900
+ }
901
+ parts.push(clean(body.slice(start)));
902
+ cleaned = parts.join('');
903
+ } else cleaned = clean(body);
761
904
  if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
762
905
 
763
906
  if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
@@ -801,9 +944,11 @@ async function main(argv) {
801
944
  if (f.newlinesNormalized) parts.push('normalized newlines');
802
945
  report(`${verb} ${f.path}: ${parts.join('; ')}`);
803
946
  }
804
- report(files.length === 0
805
- ? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
806
- : `${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()}.`);
807
952
  }
808
953
  return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
809
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.9";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};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Decodes text[start, end) as decodeTagText does, stopping before the output would
3
+ * pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
4
+ * @param {string} text
5
+ * @param {number} start
6
+ * @param {number} end
7
+ * @param {number} limit
8
+ * @returns {{ decoded: string, truncated: boolean }}
9
+ */
10
+ export function decodeRange(text: string, start: number, end: number, limit: number): {
11
+ decoded: string;
12
+ truncated: boolean;
13
+ };
14
+ /**
15
+ * @param {string} text
16
+ * @param {HiddenScanOptions} [options]
17
+ * @param {(span: {offset: number, end: number, benign: boolean, kind: HiddenRunKind}) => void} [onSpan] - internal streaming visitor
18
+ * @returns {HiddenTextScan}
19
+ */
20
+ export function scanHiddenSpans(text: string, options?: HiddenScanOptions, onSpan?: (span: {
21
+ offset: number;
22
+ end: number;
23
+ benign: boolean;
24
+ kind: HiddenRunKind;
25
+ }) => void): HiddenTextScan;
26
+ export type HiddenRunKind = import("./hiddenText.js").HiddenRunKind;
27
+ export type HiddenRun = import("./hiddenText.js").HiddenRun;
28
+ export type HiddenRunCounts = import("./hiddenText.js").HiddenRunCounts;
29
+ export type HiddenTextScan = import("./hiddenText.js").HiddenTextScan;
30
+ export type HiddenScanOptions = import("./hiddenText.js").HiddenScanOptions;
@@ -1,3 +1,43 @@
1
+ /** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
2
+ /**
3
+ * @typedef {Object} HiddenRun
4
+ * @property {HiddenRunKind} kind
5
+ * @property {number} offset - UTF-16 index of the first character
6
+ * @property {number} end - UTF-16 index just past the last character
7
+ * @property {number} count - number of code points in the run
8
+ * @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
9
+ * @property {number} column - 1-based column of the first character, in UTF-16 units
10
+ * @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
11
+ * at most `maxDecodedLength` UTF-16 units
12
+ * @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
13
+ */
14
+ /**
15
+ * @typedef {Object} HiddenRunCounts
16
+ * @property {number} tag
17
+ * @property {number} variation_selector
18
+ * @property {number} invisible_operator
19
+ * @property {number} reserved_tag_plane
20
+ */
21
+ /**
22
+ * @typedef {Object} HiddenTextScan
23
+ * @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
24
+ * @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
25
+ * sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
26
+ * @property {HiddenRunCounts} counts - every run per kind, listed or not
27
+ * @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
28
+ * @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
29
+ * (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
30
+ * @property {number} total - sum of `counts`
31
+ * @property {number} benignTotal - every benign span, listed or not
32
+ * @property {boolean} truncated - true when runs or benign spans were left out of the lists
33
+ */
34
+ /**
35
+ * @typedef {Object} HiddenScanOptions
36
+ * @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
37
+ * (non-negative integer or Infinity). Counts stay exact.
38
+ * @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
39
+ * For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
40
+ */
1
41
  /**
2
42
  * Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
3
43
  * U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
@@ -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
  */