text-sanctifier 1.3.10 → 1.3.12

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
@@ -314,7 +317,8 @@ src/auth.js:40:3 U+202E bidi_control
314
317
  2 findings in 2 of 31 files.
315
318
  ```
316
319
 
317
- - `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped, notUtf8 }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
320
+ - `--json` (or `--format json`): one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped, excluded, notUtf8, errors }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed. Usage errors also produce JSON on stdout.
321
+ - `--exclude GLOB`: exclude matching paths; repeatable. See [Inputs](#inputs). Not available with `--ai-config`.
318
322
  - `--format sarif`: a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log for code-scanning viewers and editors. See [SARIF output](#sarif-output).
319
323
  - `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
320
324
  - `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
@@ -351,7 +355,7 @@ skills/review/SKILL.md:2:3 U+FE00 run of 3 variation selectors
351
355
  - Each location has the file (relative to `%SRCROOT%`, the current directory), `startLine`, `startColumn` and `endColumn`. Columns count UTF-16 code units, as in `inspectTextDetailed` (`columnKind: "utf16CodeUnits"`).
352
356
  - Each result has a `partialFingerprints` hash based on its rule, relative path, line content and occurrence on that line. Adding unrelated lines above does not change it.
353
357
  - With `--ai-config`, the message of the first result in a run describes the run and its decoded text.
354
- - `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out) and `skipped`.
358
+ - `--max-findings` caps results per file as usual. `runs[0].properties` holds `total`, `filesScanned`, `filesWithFindings`, `truncated` (files with findings left out), `skipped` and `excluded`.
355
359
 
356
360
  ### pre-commit
357
361
 
@@ -361,13 +365,13 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
361
365
  # .pre-commit-config.yaml
362
366
  repos:
363
367
  - repo: https://github.com/iWhatty/text-sanctifier
364
- rev: v1.3.10 # a release tag
368
+ rev: v1.3.12 # a release tag
365
369
  hooks:
366
370
  - id: text-sanctifier # every staged text file
367
371
  - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
368
372
  ```
369
373
 
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.
374
+ - `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
375
  - `text-sanctifier-ai-config` runs `text-sanctifier check --ai-config` on staged files that match the `--ai-config` file list.
372
376
  - A hook fails the commit when it finds something (exit code 1). Neither hook changes files; run `text-sanctifier fix` yourself.
373
377
 
@@ -383,7 +387,8 @@ Rewrites files in place and reports what it replaced and removed.
383
387
  - With none of these, `fix` uses `--normalize-spaces --purge-invisible --nuke-controls`: look-alike spaces become ordinary spaces, and everything else `check` reports is removed.
384
388
  - `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
385
389
  - `--dry-run`: write nothing; report what would change.
386
- - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`.
390
+ - `--exclude GLOB`: exclude matching paths; repeatable. See [Inputs](#inputs).
391
+ - `--json` (or `--format json`): `{ files, dryRun, filesScanned, filesChanged, skipped, excluded, errors }`, where each file is `{ path, spacesReplaced, removed, removedTotal, newlinesNormalized }`. Usage errors also produce JSON on stdout.
387
392
 
388
393
  Apart from look-alike spaces, `fix` deletes characters. By default, emoji lose their zero width joiners and variation selectors, and so does Mongolian text. Use `--keep-benign` to preserve recognized legitimate sequences, or run `check` or `fix --dry-run` first on prose. To make hidden characters visible without changing a file, use `reveal`.
389
394
 
@@ -399,10 +404,14 @@ git diff | npx text-sanctifier reveal
399
404
  ### Inputs
400
405
 
401
406
  - Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
402
- - 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`.
407
+ - 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, unless `--exclude` matches it.
408
+ - `--exclude GLOB` matches paths relative to the current directory, or any parent directory. Leading `./` and trailing `/` are ignored. `*` does not cross `/`: `*.tar` matches only the top level, while `**/*.tar` matches any depth. Excluded paths are listed in JSON and SARIF and counted in text summaries. Stdin is never excluded. There is no config file, ignore file or inline comment form; scanned text cannot silence the scanner.
409
+ - Binary files are skipped, counted in the summary and listed under `skipped` in JSON. A file counts as binary when its first 8000 bytes hold a NUL and more than 5% control bytes, and it is either invalid UTF-8 or entirely ASCII with at least 8 control bytes. Any non-ASCII byte in valid UTF-8 keeps the file checked, even beyond the sample. A stray NUL or bad byte does not hide a text file. 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.
410
+ - UTF-16 files with a BOM are decoded and checked. `fix` never rewrites them and lists them under `skipped`.
404
411
  - 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.
412
+ - `-` 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.
413
+ - 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.
414
+ - JSON `errors` entries have `path` (or null), `code` and `message`; read and write failures may also include a system `cause`. Codes are `usage`, `not_found`, `no_match`, `read_failed`, `decode_failed`, `write_failed`, `refused` and `nothing_checked`. Exit 2 means `errors` is nonempty.
406
415
 
407
416
  ### Exit codes
408
417
 
@@ -410,7 +419,7 @@ git diff | npx text-sanctifier reveal
410
419
  | --- | --- | --- | --- |
411
420
  | 0 | nothing found | done (or nothing to do) | done |
412
421
  | 1 | findings | `--dry-run` with changes pending | not used |
413
- | 2 | usage error, missing path or unreadable file | same | same |
422
+ | 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
423
 
415
424
  ---
416
425
 
@@ -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';
@@ -22,6 +22,10 @@ import { scanHiddenSpans } from '../src/hiddenScan.js';
22
22
  import { inspectTextInContext } from '../src/context.js';
23
23
  import { classifyCodeUnit, classifyAstral } from '../src/categories.generated.js';
24
24
 
25
+ for (const stream of [process.stdout, process.stderr]) {
26
+ stream.on('error', (e) => { if (e.code !== 'EPIPE') throw e; });
27
+ }
28
+
25
29
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
26
30
 
27
31
  const USAGE = `text-sanctifier ${VERSION}
@@ -32,12 +36,17 @@ Usage:
32
36
  text-sanctifier reveal [paths...]
33
37
 
34
38
  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.
39
+ alone). "-" reads stdin. With no paths, check reads piped input, or scans the current
40
+ directory when stdin is a terminal, the null device or empty. Fix and reveal read
41
+ piped input. Directories and globs skip files ignored by git (inside
42
+ a git work tree). Binary files are skipped; files that are text apart from a stray
43
+ NUL or bad byte are not, and UTF-16 files with a BOM are decoded. A path or glob that
44
+ matches no files, or a run that checks nothing, exits 2.
37
45
 
38
46
  check options:
39
47
  --format F text (default), json or sarif (SARIF 2.1.0)
40
48
  --json same as --format json
49
+ --exclude GLOB exclude matching paths (repeatable)
41
50
  --max-findings N findings listed per file (default 100, "all" for no cap)
42
51
  --strip-bom also report a BOM at the start of a file
43
52
  --ai-config check agent instruction files (CLAUDE.md, AGENTS.md,
@@ -54,6 +63,7 @@ fix options:
54
63
  --normalize-newlines convert CRLF and CR to LF
55
64
  --strip-bom also remove a BOM at the start of a file (kept by default)
56
65
  --dry-run report what would change; write nothing
66
+ --exclude GLOB exclude matching paths (repeatable)
57
67
  --format F text (default) or json
58
68
  --json same as --format json
59
69
  With no cleaning flag, fix uses --normalize-spaces --purge-invisible --nuke-controls.
@@ -77,6 +87,8 @@ const BOM = 0xFEFF;
77
87
  const STDIN = '-';
78
88
  const STDIN_LABEL = '<stdin>';
79
89
  const BINARY_SNIFF_BYTES = 8000;
90
+ const BINARY_CONTROL_SHARE = 0.05;
91
+ const BINARY_MIN_ASCII_CONTROLS = 8;
80
92
  const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
81
93
  const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
82
94
 
@@ -106,6 +118,7 @@ function parseArgs(argv) {
106
118
  const opts = {
107
119
  command: null,
108
120
  paths: [],
121
+ excludes: [],
109
122
  format: null,
110
123
  aiConfig: false,
111
124
  stripBom: false,
@@ -134,6 +147,14 @@ function parseArgs(argv) {
134
147
  if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
135
148
 
136
149
  const [name, inline] = arg.split(/=(.*)/s, 2);
150
+ if (name === '--exclude') {
151
+ const value = inline !== undefined ? inline : argv[++i];
152
+ if (!value || value.startsWith('-')) throw new UsageError('--exclude expects a non-empty glob.');
153
+ try { excludeRegExp(value); }
154
+ catch (e) { if (e instanceof SyntaxError) throw new UsageError(`Invalid --exclude pattern: ${value}`); throw e; }
155
+ opts.excludes.push(value);
156
+ continue;
157
+ }
137
158
  if (name === '--max-findings') {
138
159
  const value = inline !== undefined ? inline : argv[++i];
139
160
  opts.maxFindings = parseMaxFindings(value);
@@ -160,6 +181,7 @@ function parseArgs(argv) {
160
181
  throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
161
182
  }
162
183
  if (opts.command === 'reveal') {
184
+ if (opts.excludes.length) throw new UsageError('--exclude does not apply to reveal.');
163
185
  for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
164
186
  if (opts[key]) throw new UsageError(`${flag} does not apply to reveal.`);
165
187
  }
@@ -171,6 +193,7 @@ function parseArgs(argv) {
171
193
  if (opts.command === 'check' && FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
172
194
  if (opts.command === 'fix' && CHECK_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to check.`);
173
195
  }
196
+ if (opts.aiConfig && opts.excludes.length) throw new UsageError('--exclude does not apply to --ai-config.');
174
197
  if (opts.format === null) opts.format = 'text';
175
198
  if (opts.command === 'fix' && opts.format === 'sarif') throw new UsageError('--format sarif only applies to check.');
176
199
  if (opts.command === 'fix' && !opts.normalizeSpaces && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
@@ -206,21 +229,36 @@ function isGitWorkTree(cwd) {
206
229
  }
207
230
 
208
231
  /**
209
- * Lists tracked and untracked-but-not-ignored files under one pathspec.
232
+ * Lists tracked and untracked-but-not-ignored files under the given pathspecs.
210
233
  * Returns null when git cannot answer (for example a path outside the work tree).
211
234
  * @param {string} cwd
212
- * @param {string} pathspec
235
+ * @param {string[]} pathspecs
213
236
  * @returns {string[]|null}
214
237
  */
215
- function gitListFiles(cwd, pathspec) {
238
+ function gitListFiles(cwd, pathspecs) {
216
239
  const r = spawnSync(
217
240
  'git',
218
- ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
241
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...pathspecs],
219
242
  { cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
220
243
  );
221
244
  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)));
245
+ // --cached also lists tracked files deleted from the work tree, submodules and
246
+ // symlinks to directories; keep only what can be read as a file
247
+ return r.stdout.split('\0').filter((f) => {
248
+ if (!f) return false;
249
+ try { return statSync(resolve(cwd, f)).isFile(); } catch { return false; }
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Expands `{a,b}` into one glob per alternative: git pathspecs have no brace syntax.
255
+ * @param {string} glob
256
+ * @returns {string[]}
257
+ */
258
+ function expandBraces(glob) {
259
+ const m = /\{([^{}]*)\}/.exec(glob);
260
+ if (!m) return [glob];
261
+ return m[1].split(',').flatMap((alt) => expandBraces(glob.slice(0, m.index) + alt + glob.slice(m.index + m[0].length)));
224
262
  }
225
263
 
226
264
  /**
@@ -257,7 +295,7 @@ function walk(cwd, dir) {
257
295
  * Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
258
296
  * @param {string} glob
259
297
  */
260
- function globToRegExp(glob) {
298
+ function globToRegExp(glob, flags = '') {
261
299
  let re = '';
262
300
  for (let i = 0; i < glob.length; i++) {
263
301
  const c = glob[i];
@@ -288,9 +326,12 @@ function globToRegExp(glob) {
288
326
  re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
289
327
  }
290
328
  }
291
- return new RegExp(`^${re}$`);
329
+ return new RegExp(`^${re}$`, flags);
292
330
  }
293
331
 
332
+ /** @param {string} glob - an --exclude pattern */
333
+ const excludeRegExp = (glob) => globToRegExp(toSlash(glob).replace(/^\.\//, '').replace(/\/$/, ''), CASE_INSENSITIVE_FS ? 'i' : '');
334
+
294
335
  /**
295
336
  * @param {string} cwd
296
337
  * @param {string} glob
@@ -331,14 +372,13 @@ const isAiConfigPath = (rel) => AI_CONFIG_RES.some((re) => re.test(rel));
331
372
  * directories contribute only agent instruction files (AI_CONFIG_GLOBS).
332
373
  * @param {string[]} args
333
374
  * @param {string} cwd
334
- * @param {(msg: string) => void} warn
375
+ * @param {(path: string|null, code: string, message: string) => void} addError
335
376
  * @param {boolean} [aiConfig]
336
- * @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
377
+ * @returns {Array<{ path: string, explicit: boolean }>}
337
378
  */
338
- function collectInputs(args, cwd, warn, aiConfig = false) {
379
+ function collectInputs(args, cwd, addError, aiConfig = false) {
339
380
  const inputs = [];
340
381
  const seen = new Set();
341
- let errors = 0;
342
382
  let inGit;
343
383
 
344
384
  const add = (path, explicit) => {
@@ -359,25 +399,24 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
359
399
 
360
400
  const isGlob = !stat && GLOB_CHARS.test(arg);
361
401
  if (!stat && !isGlob) {
362
- warn(`${arg}: no such file or directory`);
363
- errors++;
402
+ addError(arg, 'not_found', `${arg}: no such file or directory`);
364
403
  continue;
365
404
  }
366
405
 
367
406
  if (inGit === undefined) inGit = isGitWorkTree(cwd);
368
407
  let listed =
369
- (inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
408
+ (inGit && gitListFiles(cwd, isGlob ? expandBraces(toSlash(arg)).map((g) => `:(glob)${g}`) : [toSlash(arg)])) ||
370
409
  (isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
371
410
 
372
411
  if (aiConfig && !isGlob) {
373
412
  const base = resolve(cwd, arg);
374
413
  listed = listed.filter((f) => isAiConfigPath(toSlash(relative(base, resolve(cwd, f)))));
375
414
  } else if (listed.length === 0) {
376
- warn(`${arg}: no files matched`);
415
+ addError(arg, 'no_match', `${arg}: no files matched`);
377
416
  }
378
417
  for (const f of listed) add(f, false);
379
418
  }
380
- return { inputs, errors };
419
+ return inputs;
381
420
  }
382
421
 
383
422
 
@@ -385,24 +424,63 @@ function collectInputs(args, cwd, warn, aiConfig = false) {
385
424
 
386
425
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
387
426
  const utf8Lenient = new TextDecoder('utf-8', { ignoreBOM: true });
427
+ const utf16 = new TextDecoder('utf-16le', { ignoreBOM: true });
388
428
 
389
429
  /**
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`.
430
+ * A NUL plus a share of control bytes that text does not have, in the first
431
+ * BINARY_SNIFF_BYTES. One stray NUL is not enough, or it would hide a file from `check`.
393
432
  * @param {Uint8Array} bytes
433
+ * @param {number} [minControls] - fewest control bytes that count
434
+ */
435
+ function looksBinary(bytes, minControls = 0) {
436
+ const sample = bytes.subarray(0, BINARY_SNIFF_BYTES);
437
+ if (!sample.includes(0)) return false;
438
+ let controls = 0;
439
+ for (const b of sample) if (b < 9 || (b > 13 && b < 32)) controls++;
440
+ return controls >= minControls && controls / sample.length > BINARY_CONTROL_SHARE;
441
+ }
442
+
443
+ /**
444
+ * Decodes UTF-8, or UTF-16 with a BOM (setting `wide`), keeping a leading BOM. With
445
+ * `sniff`, data that looks binary is not decoded if it is not UTF-8, or if it is entirely
446
+ * ASCII with at least BINARY_MIN_ASCII_CONTROLS control bytes, so a short file with a stray
447
+ * NUL is still read. Invisible Unicode needs non-ASCII bytes in UTF-8, so ASCII-only data has
448
+ * only controls to hide. Other bytes that are not UTF-8 become U+FFFD and set `lossy`, so
449
+ * one bad byte cannot hide the rest of a file.
450
+ * @param {Buffer} bytes
394
451
  * @param {boolean} sniff
395
- * @returns {{ text: string|null, reason?: string, lossy?: boolean }}
452
+ * @returns {{ text: string|null, reason?: string, lossy?: boolean, wide?: boolean }}
396
453
  */
397
454
  function decode(bytes, sniff) {
398
- if (sniff && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
455
+ if (bytes[0] === 0xFF && bytes[1] === 0xFE) return { text: utf16.decode(bytes), wide: true };
456
+ if (bytes[0] === 0xFE && bytes[1] === 0xFF) {
457
+ return { text: utf16.decode(Buffer.from(bytes.subarray(0, bytes.length & ~1)).swap16()), wide: true };
458
+ }
399
459
  try {
400
- return { text: utf8.decode(bytes) };
401
- } catch {
460
+ const text = utf8.decode(bytes);
461
+ if (sniff && looksBinary(bytes, BINARY_MIN_ASCII_CONTROLS) && bytes.every((b) => b < 0x80)) return { text: null, reason: 'binary' };
462
+ return { text };
463
+ } catch (e) {
464
+ if (!(e instanceof TypeError)) throw e; // too large for a string, not bad UTF-8
465
+ if (sniff && looksBinary(bytes)) return { text: null, reason: 'binary' };
402
466
  return { text: utf8Lenient.decode(bytes), lossy: true };
403
467
  }
404
468
  }
405
469
 
470
+ /**
471
+ * True when stdin may hold input for check with no paths: anything but a terminal,
472
+ * the null device or an empty file. Pipes must be read to detect empty input;
473
+ * Windows pipes do not report isFIFO().
474
+ */
475
+ function stdinHasInput() {
476
+ try {
477
+ const stat = fstatSync(0);
478
+ return !stat.isCharacterDevice() && !(stat.isFile() && stat.size === 0);
479
+ } catch {
480
+ return false;
481
+ }
482
+ }
483
+
406
484
  /** @returns {Promise<Buffer>} */
407
485
  function readStdin() {
408
486
  return new Promise((res, rej) => {
@@ -581,9 +659,9 @@ function sarifArtifact(label) {
581
659
  /**
582
660
  * Builds a SARIF 2.1.0 log with one result per listed finding; the rule is the category.
583
661
  * @param {Array<{ path: string, findings: Array<{ offset: number, line: number, column: number, codePoint: number, category: string }>, hiddenRuns?: Array<{ offset: number, kind: string, count: number, decoded?: string }>, truncated: boolean }>} files
584
- * @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, succeeded: boolean }} info
662
+ * @param {{ cwd: string, total: number, filesScanned: number, skipped: Array<{ path: string, reason: string }>, excluded: string[], succeeded: boolean }} info
585
663
  */
586
- function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
664
+ function sarifLog(files, { cwd, total, filesScanned, skipped, excluded, succeeded }) {
587
665
  const ruleIds = Object.keys(inspectTextDetailed('', { maxFindings: 0 }).counts);
588
666
  const rules = ruleIds.map((id) => ({
589
667
  id,
@@ -628,6 +706,7 @@ function sarifLog(files, { cwd, total, filesScanned, skipped, succeeded }) {
628
706
  filesWithFindings: files.length,
629
707
  truncated: files.filter((f) => f.truncated).map((f) => f.path),
630
708
  skipped,
709
+ excluded,
631
710
  },
632
711
  }],
633
712
  };
@@ -650,28 +729,61 @@ async function main(argv) {
650
729
  } catch (e) {
651
730
  if (!(e instanceof UsageError)) throw e;
652
731
  err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
732
+ if (argv.some((arg, i) => arg === '--json' || arg === '--format=json' || (arg === '--format' && argv[i + 1] === 'json'))) {
733
+ out(JSON.stringify({ errors: [{ path: null, code: 'usage', message: e.message }] }));
734
+ }
653
735
  return 2;
654
736
  }
655
737
  if (opts.help) { out(USAGE); return 0; }
656
738
  if (opts.version) { out(VERSION); return 0; }
657
739
 
658
740
  const cwd = process.cwd();
741
+ const warn = (msg) => err(`text-sanctifier: ${msg}`);
742
+ const errors = [];
743
+ const addError = (path, code, message, cause, emit = true) => {
744
+ const entry = { path, code, message };
745
+ if (cause) entry.cause = cause;
746
+ errors.push(entry);
747
+ if (emit) warn(message);
748
+ };
659
749
  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) {
750
+ /** @type {Buffer | undefined} */
751
+ let stdinBytes;
752
+ // check with no paths reads nonempty stdin once, otherwise scans the current
753
+ // directory. AI configuration discovery always defaults to the directory.
754
+ if (paths.length === 0 && opts.command === 'check') {
663
755
  paths = ['.'];
756
+ if (!opts.aiConfig && stdinHasInput()) {
757
+ try {
758
+ stdinBytes = await readStdin();
759
+ } catch (e) {
760
+ addError(STDIN_LABEL, 'read_failed', `${STDIN_LABEL}: ${e.code || e.message}`, e.code);
761
+ if (opts.format === 'json') out(JSON.stringify({ errors }));
762
+ return 2;
763
+ }
764
+ if (stdinBytes.length > 0) paths = [STDIN];
765
+ }
664
766
  } else if (paths.length === 0) {
665
767
  if (process.stdin.isTTY) {
768
+ addError(null, 'usage', 'no paths given and nothing piped on stdin.', undefined, false);
666
769
  err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
770
+ if (opts.format === 'json') out(JSON.stringify({ errors }));
667
771
  return 2;
668
772
  }
669
773
  paths = [STDIN];
670
774
  }
671
775
 
672
- const warn = (msg) => err(`text-sanctifier: ${msg}`);
673
- const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn, opts.aiConfig);
674
- let errors = pathErrors;
776
+ const collected = collectInputs(paths, cwd, addError, opts.aiConfig);
777
+ const excluded = [];
778
+ const excludePatterns = opts.excludes.map(excludeRegExp);
779
+ const inputs = collected.filter((input) => {
780
+ if (input.path === STDIN) return true;
781
+ const path = toSlash(relative(cwd, resolve(cwd, input.path)));
782
+ const parts = path.split('/');
783
+ const matches = parts.some((_, i) => excludePatterns.some((re) => re.test(parts.slice(0, i + 1).join('/'))));
784
+ if (matches) excluded.push(input.path);
785
+ return !matches;
786
+ });
675
787
  /** @type {Array<{ path: string, reason: string }>} */
676
788
  const skipped = [];
677
789
  /** @type {string[]} */
@@ -682,38 +794,56 @@ async function main(argv) {
682
794
  * @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
683
795
  */
684
796
  const forEachText = async (visit) => {
797
+ let visited = 0;
685
798
  for (const input of inputs) {
686
799
  const label = input.path === STDIN ? STDIN_LABEL : input.path;
687
800
  let bytes;
688
801
  try {
689
- bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
802
+ bytes = input.path === STDIN ? (stdinBytes ?? await readStdin()) : readFileSync(resolve(cwd, input.path));
690
803
  } catch (e) {
691
- warn(`${label}: ${e.code || e.message}`);
692
- errors++;
804
+ addError(label, 'read_failed', `${label}: ${e.code || e.message}`, e.code);
693
805
  continue;
694
806
  }
695
807
  // Files that look binary are skipped. Stdin and AI configuration files are text
696
808
  // 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) {
809
+ let decoded;
810
+ try {
811
+ decoded = decode(bytes, input.path !== STDIN && !opts.aiConfig);
812
+ } catch (e) {
813
+ addError(label, 'decode_failed', `${label}: ${e.message}`);
814
+ continue;
815
+ }
816
+ const { text, lossy, wide } = decoded;
817
+ let { reason } = decoded;
818
+ // fix writes UTF-8 in place, so it never rewrites what it could not decode or
819
+ // what was not UTF-8. A directory scan also leaves any file with a NUL alone;
820
+ // a file you name is cleaned if it is otherwise UTF-8.
821
+ if (opts.command === 'fix' && !reason) {
822
+ if (lossy) reason = 'not UTF-8';
823
+ else if (wide) reason = 'UTF-16';
824
+ else if (!input.explicit && bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) reason = 'binary';
825
+ }
826
+ if (reason) {
699
827
  skipped.push({ path: label, reason });
700
- if (input.explicit) warn(`${label}: skipped (${reason})`);
828
+ if (input.explicit || reason !== 'binary') warn(`${label}: skipped (${reason})`);
829
+ // Asked to clean this input and did not: that must not look like success
830
+ if (opts.command === 'fix' && input.explicit) addError(label, 'refused', `${label}: skipped (${reason})`, undefined, false);
701
831
  continue;
702
832
  }
703
833
  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
834
  notUtf8.push(label);
711
835
  warn(`${label}: not valid UTF-8; checked with invalid bytes read as U+FFFD`);
712
836
  }
837
+ visited++;
713
838
  visit(label, text, input);
714
839
  }
840
+ // A clean result must mean something was read. Finding no AI configuration files is
841
+ // an answer; --ai-config reports that itself.
842
+ if (visited === 0 && errors.length === 0 && !opts.aiConfig) addError(null, 'nothing_checked', 'Nothing checked.', undefined, false);
715
843
  };
716
844
 
845
+ const skippedNote = () => (skipped.length > 0 ? `; ${plural(skipped.length, 'file')} skipped` : '') + (excluded.length > 0 ? `; ${plural(excluded.length, 'file')} excluded` : '');
846
+
717
847
  if (opts.command === 'check') {
718
848
  const files = [];
719
849
  let scanned = 0;
@@ -754,21 +884,22 @@ async function main(argv) {
754
884
 
755
885
  const kind = opts.aiConfig ? 'AI configuration file' : 'file';
756
886
  if (opts.format === 'json') {
757
- const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped, notUtf8 };
887
+ const summary = { files, total, filesScanned: scanned, filesWithFindings: files.length, skipped, excluded, notUtf8, errors };
758
888
  if (opts.aiConfig) summary.hiddenRunCount = hiddenRunCount;
759
889
  out(JSON.stringify(summary, null, 2));
760
890
  } else if (opts.format === 'sarif') {
761
- out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, succeeded: errors === 0 }), null, 2));
891
+ out(JSON.stringify(sarifLog(files, { cwd, total, filesScanned: scanned, skipped, excluded, succeeded: errors.length === 0 }), null, 2));
762
892
  } else {
763
893
  for (const line of lines) out(line);
764
- if (opts.aiConfig && scanned === 0 && skipped.length === 0) {
894
+ if (opts.aiConfig && scanned === 0 && skipped.length === 0 && errors.length === 0) {
765
895
  out('No AI configuration files found.');
766
896
  if (opts.paths.length === 0 && !process.stdin.isTTY) out('To check piped text, pass "-": text-sanctifier check --ai-config -');
767
897
  }
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)}.`);
898
+ else if (scanned === 0) out(`Nothing checked${skippedNote()}.`);
899
+ else if (total === 0) out(`No ${opts.aiConfig ? 'hidden text, ' : ''}invisible or control characters in ${plural(scanned, kind)}${skippedNote()}.`);
900
+ else out(`${plural(total, 'finding')}${opts.aiConfig ? ` (${plural(hiddenRunCount, 'hidden run')})` : ''} in ${files.length} of ${plural(scanned, kind)}${skippedNote()}.`);
770
901
  }
771
- return errors > 0 ? 2 : total > 0 ? 1 : 0;
902
+ return errors.length > 0 ? 2 : total > 0 ? 1 : 0;
772
903
  }
773
904
 
774
905
  if (opts.command === 'reveal') {
@@ -781,7 +912,7 @@ async function main(argv) {
781
912
  }
782
913
  process.stdout.write(revealInvisible(text));
783
914
  });
784
- return errors > 0 ? 2 : 0;
915
+ return errors.length > 0 ? 2 : 0;
785
916
  }
786
917
 
787
918
  // fix
@@ -839,8 +970,7 @@ async function main(argv) {
839
970
  try {
840
971
  writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
841
972
  } catch (e) {
842
- warn(`${label}: ${e.code || e.message}`);
843
- errors++;
973
+ addError(label, 'write_failed', `${label}: ${e.code || e.message}`, e.code);
844
974
  return;
845
975
  }
846
976
  }
@@ -848,7 +978,7 @@ async function main(argv) {
848
978
  });
849
979
 
850
980
  if (opts.format === 'json') {
851
- report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
981
+ report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped, excluded, errors }, null, 2));
852
982
  } else {
853
983
  const verb = opts.dryRun ? 'would fix' : 'fixed';
854
984
  for (const f of files) {
@@ -858,11 +988,13 @@ async function main(argv) {
858
988
  if (f.newlinesNormalized) parts.push('normalized newlines');
859
989
  report(`${verb} ${f.path}: ${parts.join('; ')}`);
860
990
  }
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'}.`);
991
+ report(scanned === 0
992
+ ? `Nothing checked${skippedNote()}.`
993
+ : files.length === 0
994
+ ? `Nothing to fix in ${plural(scanned, 'file')}${skippedNote()}.`
995
+ : `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${plural(scanned, 'file')}${skippedNote()}.`);
864
996
  }
865
- return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
997
+ return errors.length > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
866
998
  }
867
999
 
868
1000
  main(process.argv.slice(2)).then(
@@ -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.12";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.12";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "text-sanctifier",
3
- "version": "1.3.10",
3
+ "version": "1.3.12",
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.12';
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;