text-sanctifier 1.1.1 → 1.3.0
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 +65 -4
- package/bin/text-sanctifier.js +519 -0
- package/dist/text-sanctifier.min.js +13 -11
- package/dist/types/inspectText.d.ts +12 -2
- package/dist/types/sanctifyText.d.ts +8 -4
- package/package.json +6 -2
- package/src/inspectText.js +386 -341
- package/src/sanctifyText.js +511 -507
package/README.md
CHANGED
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
[](https://github.com/iWhatty/text-sanctifier)
|
|
8
8
|
[](https://www.npmjs.com/package/text-sanctifier)
|
|
9
9
|
|
|
10
|
-
Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.
|
|
10
|
+
Brutal text normalizer and invisible Unicode scrubber for modern web projects. ~2.40 KB gzipped.
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
14
14
|
- Purges zero-width Unicode garbage
|
|
15
15
|
- Purges bidi controls, including the isolates (U+2066–U+2069) and the Arabic letter mark (U+061C) used in Trojan Source attacks
|
|
16
|
+
- Purges Unicode tag characters (U+E0000–U+E007F), the "ASCII smuggling" channel for hiding instructions from humans in text an LLM reads, plus variation selectors, Hangul fillers, soft hyphens and invisible operators
|
|
16
17
|
- Locates invisible and control characters by offset, line and column (`inspectTextDetailed`)
|
|
18
|
+
- `text-sanctifier check` / `fix` command line for CI and coding agents, with JSON output and exit codes
|
|
17
19
|
- Normalizes line endings (CRLF, CR, LF) to LF
|
|
18
20
|
- Collapses unwanted spaces and paragraphs
|
|
19
21
|
- Nukes control characters (if enabled)
|
|
@@ -154,7 +156,8 @@ findings: [
|
|
|
154
156
|
{ offset: 0, line: 1, column: 1, codePoint: 0xFEFF, category: 'bom', atStart: true },
|
|
155
157
|
{ offset: 42, line: 3, column: 7, codePoint: 0x2067, category: 'bidi_control' },
|
|
156
158
|
]
|
|
157
|
-
counts: { bidi_control: 1, zero_width: 0, nonbreaking_space: 0, bom: 1, nul: 0, c0_control: 0, c1_control: 0
|
|
159
|
+
counts: { bidi_control: 1, zero_width: 0, nonbreaking_space: 0, bom: 1, nul: 0, c0_control: 0, c1_control: 0,
|
|
160
|
+
invisible_format: 0, hangul_filler: 0, variation_selector: 0, tag: 0 }
|
|
158
161
|
total: 2
|
|
159
162
|
truncated: false
|
|
160
163
|
*/
|
|
@@ -169,15 +172,73 @@ truncated: false
|
|
|
169
172
|
| `nul` | U+0000 |
|
|
170
173
|
| `c0_control` | U+0001–U+001F except tab, LF and CR; U+007F |
|
|
171
174
|
| `c1_control` | U+0080–U+009F |
|
|
175
|
+
| `invisible_format` | U+00AD (soft hyphen), U+034F, U+180E, U+2061–U+2064 (invisible operators) |
|
|
176
|
+
| `hangul_filler` | U+115F, U+1160, U+3164, U+FFA0 (letters that render blank) |
|
|
177
|
+
| `variation_selector` | U+FE00–U+FE0F, U+E0100–U+E01EF |
|
|
178
|
+
| `tag` | U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions) |
|
|
172
179
|
|
|
173
|
-
`offset` is a 0-based UTF-16 index; `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
|
|
180
|
+
`codePoint` is the full code point, so a tag character reports `0xE0041`, not a surrogate. `offset` is a 0-based UTF-16 index (for a code point above U+FFFF, that of its high surrogate); `line` and `column` are 1-based, with columns in UTF-16 code units and lines ending at LF, CR or CRLF. Every occurrence is counted; only the first `maxFindings` (default 100, `Infinity` for all) are listed, and `truncated` says when some were not. The scan is one pass with no regex and no copy of the input.
|
|
174
181
|
|
|
175
|
-
`zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨👩👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.)
|
|
182
|
+
`zero_width` also flags U+200D (zero width joiner) inside emoji ZWJ sequences such as 👨👩👧, so a finding there is not necessarily hidden text. Filter by neighbouring code points if you need to tell them apart. (`purgeInvisibleChars` removes those joiners too, splitting the emoji; `keyboardOnlyEmoji` keeps the sequences whole.) The same holds for `variation_selector`, which flags VS16 (U+FE0F) in emoji such as ✌️ and the ideographic variation selectors in CJK names, and for `tag`, which flags the tag characters inside subdivision flags such as the England flag (U+1F3F4 followed by tags). `purgeInvisibleChars` strips them all; `keyboardOnlyEmoji` keeps VS16.
|
|
176
183
|
|
|
177
184
|
The categories cover exactly what `nukeControls` and `purgeInvisibleChars` strip.
|
|
178
185
|
|
|
179
186
|
---
|
|
180
187
|
|
|
188
|
+
## Command line
|
|
189
|
+
|
|
190
|
+
The package ships a `text-sanctifier` command for CI and coding agents, which cannot see invisible characters in their own tool output. It runs on Node 18+ and has no dependencies.
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
npx text-sanctifier check src docs "**/*.md"
|
|
194
|
+
npx text-sanctifier fix --dry-run src
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### `check [paths...]`
|
|
198
|
+
|
|
199
|
+
Reports every invisible or control character as `path:line:column U+XXXX category`, using the categories of `inspectTextDetailed`:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
docs/intro.md:12:7 U+200B zero_width
|
|
203
|
+
src/auth.js:40:3 U+202E bidi_control
|
|
204
|
+
2 findings in 2 of 31 files.
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- `--json`: one JSON object on stdout, `{ files, total, filesScanned, filesWithFindings, skipped }`. Each entry in `files` is `{ path, ...inspectTextDetailed(text) }`; only files with findings are listed.
|
|
208
|
+
- `--max-findings N`: findings listed per file (default 100; `all` for no cap). Counts always cover every occurrence.
|
|
209
|
+
- `--strip-bom`: also report a BOM at offset 0, which is skipped by default.
|
|
210
|
+
|
|
211
|
+
### `fix [paths...]`
|
|
212
|
+
|
|
213
|
+
Rewrites files in place and reports what it removed.
|
|
214
|
+
|
|
215
|
+
- `--purge-invisible`: `purgeInvisibleChars`
|
|
216
|
+
- `--nuke-controls`: `nukeControls`
|
|
217
|
+
- `--normalize-newlines`: `normalizeNewlines` (CRLF and CR to LF)
|
|
218
|
+
- With none of the three, `fix` uses `--purge-invisible --nuke-controls`, which removes exactly what `check` reports.
|
|
219
|
+
- `--strip-bom`: also remove a BOM at offset 0. It is kept by default.
|
|
220
|
+
- `--dry-run`: write nothing; report what would change.
|
|
221
|
+
- `--json`: `{ files, dryRun, filesScanned, filesChanged, skipped }`, where each file is `{ path, removed, removedTotal, newlinesNormalized }`.
|
|
222
|
+
|
|
223
|
+
`fix` deletes characters; it does not replace them. A non-breaking space between two words joins them, and emoji lose their zero width joiners and variation selectors. Run `check` or `fix --dry-run` first on prose.
|
|
224
|
+
|
|
225
|
+
### Inputs
|
|
226
|
+
|
|
227
|
+
- Paths can be files, directories or globs (`*`, `**`, `?`, `[...]`, `{a,b}`). Quote globs so the shell passes them through.
|
|
228
|
+
- 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.
|
|
229
|
+
- Binary files (a NUL byte in the first 8000 bytes) and files that are not valid UTF-8 are skipped, and `fix` never writes them. In JSON they appear under `skipped`.
|
|
230
|
+
- `-`, or no paths with piped input, reads stdin. `fix` writes the cleaned text to stdout and the report to stderr.
|
|
231
|
+
|
|
232
|
+
### Exit codes
|
|
233
|
+
|
|
234
|
+
| Code | `check` | `fix` |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| 0 | nothing found | done (or nothing to do) |
|
|
237
|
+
| 1 | findings | `--dry-run` with changes pending |
|
|
238
|
+
| 2 | usage error, missing path or unreadable file | same |
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
181
242
|
## Notes
|
|
182
243
|
|
|
183
244
|
### Runtime requirements
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/text-sanctifier.js
|
|
3
|
+
//
|
|
4
|
+
// Command-line front end for coding agents and CI:
|
|
5
|
+
// text-sanctifier check [paths...] report invisible and control characters
|
|
6
|
+
// text-sanctifier fix [paths...] remove them in place
|
|
7
|
+
//
|
|
8
|
+
// Plain Node (>=18), no dependencies. Exit codes: 0 clean, 1 findings (check,
|
|
9
|
+
// or fix --dry-run with changes pending), 2 usage or I/O error.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from 'node:fs';
|
|
12
|
+
import { resolve, join, sep } from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { inspectTextDetailed, summonSanctifier } from '../src/index.js';
|
|
15
|
+
import { getNewlineStats } from '../src/inspectText.js';
|
|
16
|
+
|
|
17
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
18
|
+
|
|
19
|
+
const USAGE = `text-sanctifier ${VERSION}
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
text-sanctifier check [options] [paths...]
|
|
23
|
+
text-sanctifier fix [options] [paths...]
|
|
24
|
+
|
|
25
|
+
Paths may be files, directories or globs (quote globs so the shell leaves them
|
|
26
|
+
alone). "-", or no paths with piped input, reads stdin. Directories and globs
|
|
27
|
+
skip files ignored by git (inside a git work tree) and skip binary files.
|
|
28
|
+
|
|
29
|
+
check options:
|
|
30
|
+
--json JSON report on stdout
|
|
31
|
+
--max-findings N findings listed per file (default 100, "all" for no cap)
|
|
32
|
+
--strip-bom also report a BOM at the start of a file
|
|
33
|
+
|
|
34
|
+
fix options:
|
|
35
|
+
--purge-invisible remove zero-width, non-breaking space, bidi and BOM characters
|
|
36
|
+
--nuke-controls remove C0/C1 control characters and bidi controls
|
|
37
|
+
--normalize-newlines convert CRLF and CR to LF
|
|
38
|
+
--strip-bom also remove a BOM at the start of a file (kept by default)
|
|
39
|
+
--dry-run report what would change; write nothing
|
|
40
|
+
--json JSON report
|
|
41
|
+
With no cleaning flag, fix uses --purge-invisible --nuke-controls.
|
|
42
|
+
stdin is cleaned to stdout; the report then goes to stderr.
|
|
43
|
+
|
|
44
|
+
Other:
|
|
45
|
+
-h, --help show this help
|
|
46
|
+
-v, --version show the version
|
|
47
|
+
|
|
48
|
+
Exit codes: 0 clean, 1 findings (check) or changes pending (fix --dry-run),
|
|
49
|
+
2 usage or I/O error.
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const BOM = 0xFEFF;
|
|
53
|
+
const STDIN = '-';
|
|
54
|
+
const STDIN_LABEL = '<stdin>';
|
|
55
|
+
const BINARY_SNIFF_BYTES = 8000;
|
|
56
|
+
const WALK_SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
57
|
+
const CASE_INSENSITIVE_FS = process.platform === 'win32' || process.platform === 'darwin';
|
|
58
|
+
|
|
59
|
+
class UsageError extends Error {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
// --- Arguments ---
|
|
63
|
+
|
|
64
|
+
const BOOLEAN_FLAGS = {
|
|
65
|
+
'--json': 'json',
|
|
66
|
+
'--strip-bom': 'stripBom',
|
|
67
|
+
'--purge-invisible': 'purgeInvisible',
|
|
68
|
+
'--nuke-controls': 'nukeControls',
|
|
69
|
+
'--normalize-newlines': 'normalizeNewlines',
|
|
70
|
+
'--dry-run': 'dryRun',
|
|
71
|
+
};
|
|
72
|
+
const FIX_ONLY_FLAGS = new Set(['purgeInvisible', 'nukeControls', 'normalizeNewlines', 'dryRun']);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string[]} argv
|
|
76
|
+
*/
|
|
77
|
+
function parseArgs(argv) {
|
|
78
|
+
const opts = {
|
|
79
|
+
command: null,
|
|
80
|
+
paths: [],
|
|
81
|
+
json: false,
|
|
82
|
+
stripBom: false,
|
|
83
|
+
purgeInvisible: false,
|
|
84
|
+
nukeControls: false,
|
|
85
|
+
normalizeNewlines: false,
|
|
86
|
+
dryRun: false,
|
|
87
|
+
maxFindings: 100,
|
|
88
|
+
help: false,
|
|
89
|
+
version: false,
|
|
90
|
+
};
|
|
91
|
+
let endOfOptions = false;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < argv.length; i++) {
|
|
94
|
+
const arg = argv[i];
|
|
95
|
+
|
|
96
|
+
if (endOfOptions || arg === STDIN || !arg.startsWith('-')) {
|
|
97
|
+
if (opts.command === null && !endOfOptions) opts.command = arg;
|
|
98
|
+
else opts.paths.push(arg);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (arg === '--') { endOfOptions = true; continue; }
|
|
102
|
+
if (arg === '-h' || arg === '--help') { opts.help = true; continue; }
|
|
103
|
+
if (arg === '-v' || arg === '--version') { opts.version = true; continue; }
|
|
104
|
+
|
|
105
|
+
const [name, inline] = arg.split(/=(.*)/s, 2);
|
|
106
|
+
if (name === '--max-findings') {
|
|
107
|
+
const value = inline !== undefined ? inline : argv[++i];
|
|
108
|
+
opts.maxFindings = parseMaxFindings(value);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (name in BOOLEAN_FLAGS && inline === undefined) {
|
|
112
|
+
opts[BOOLEAN_FLAGS[name]] = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw new UsageError(`Unknown option: ${arg}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (opts.help || opts.version) return opts;
|
|
119
|
+
if (opts.command !== 'check' && opts.command !== 'fix') {
|
|
120
|
+
throw new UsageError(opts.command === null ? 'Missing command.' : `Unknown command: ${opts.command}`);
|
|
121
|
+
}
|
|
122
|
+
if (opts.command === 'check') {
|
|
123
|
+
for (const [flag, key] of Object.entries(BOOLEAN_FLAGS)) {
|
|
124
|
+
if (FIX_ONLY_FLAGS.has(key) && opts[key]) throw new UsageError(`${flag} only applies to fix.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (opts.command === 'fix' && !opts.purgeInvisible && !opts.nukeControls && !opts.normalizeNewlines) {
|
|
128
|
+
opts.purgeInvisible = true;
|
|
129
|
+
opts.nukeControls = true;
|
|
130
|
+
}
|
|
131
|
+
return opts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @param {string|undefined} value */
|
|
135
|
+
function parseMaxFindings(value) {
|
|
136
|
+
if (value === 'all') return Infinity;
|
|
137
|
+
const n = Number(value);
|
|
138
|
+
if (value === undefined || value === '' || !Number.isInteger(n) || n < 0) {
|
|
139
|
+
throw new UsageError(`--max-findings expects a non-negative integer or "all", got ${value === undefined ? 'nothing' : JSON.stringify(value)}.`);
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
// --- File collection ---
|
|
146
|
+
|
|
147
|
+
const GLOB_CHARS = /[*?[{]/;
|
|
148
|
+
|
|
149
|
+
/** @param {string} p */
|
|
150
|
+
const toSlash = (p) => p.split(sep).join('/');
|
|
151
|
+
|
|
152
|
+
/** @param {string} cwd */
|
|
153
|
+
function isGitWorkTree(cwd) {
|
|
154
|
+
const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf8' });
|
|
155
|
+
return !r.error && r.status === 0 && r.stdout.trim() === 'true';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lists tracked and untracked-but-not-ignored files under one pathspec.
|
|
160
|
+
* Returns null when git cannot answer (for example a path outside the work tree).
|
|
161
|
+
* @param {string} cwd
|
|
162
|
+
* @param {string} pathspec
|
|
163
|
+
* @returns {string[]|null}
|
|
164
|
+
*/
|
|
165
|
+
function gitListFiles(cwd, pathspec) {
|
|
166
|
+
const r = spawnSync(
|
|
167
|
+
'git',
|
|
168
|
+
['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', pathspec],
|
|
169
|
+
{ cwd, encoding: 'utf8', maxBuffer: 1 << 28 }
|
|
170
|
+
);
|
|
171
|
+
if (r.error || r.status !== 0) return null;
|
|
172
|
+
// --cached lists tracked files deleted from the work tree; drop them
|
|
173
|
+
return r.stdout.split('\0').filter((f) => f && existsSync(resolve(cwd, f)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Recursive file walk used outside git. Skips .git and node_modules.
|
|
178
|
+
* @param {string} cwd
|
|
179
|
+
* @param {string} dir - relative or absolute, as the user typed it
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
function walk(cwd, dir) {
|
|
183
|
+
const out = [];
|
|
184
|
+
const stack = [dir];
|
|
185
|
+
while (stack.length) {
|
|
186
|
+
const current = stack.pop();
|
|
187
|
+
let entries;
|
|
188
|
+
try {
|
|
189
|
+
entries = readdirSync(resolve(cwd, current), { withFileTypes: true });
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
entries.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
194
|
+
for (const e of entries) {
|
|
195
|
+
const p = current === '.' ? e.name : join(current, e.name);
|
|
196
|
+
if (e.isDirectory()) {
|
|
197
|
+
if (!WALK_SKIP_DIRS.has(e.name)) stack.push(p);
|
|
198
|
+
} else if (e.isFile()) {
|
|
199
|
+
out.push(toSlash(p));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out.sort();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Glob to RegExp for the non-git fallback: `**`, `*`, `?`, `[...]` and `{a,b}`.
|
|
208
|
+
* @param {string} glob
|
|
209
|
+
*/
|
|
210
|
+
function globToRegExp(glob) {
|
|
211
|
+
let re = '';
|
|
212
|
+
for (let i = 0; i < glob.length; i++) {
|
|
213
|
+
const c = glob[i];
|
|
214
|
+
if (c === '*') {
|
|
215
|
+
if (glob[i + 1] === '*') {
|
|
216
|
+
const slashAfter = glob[i + 2] === '/';
|
|
217
|
+
re += slashAfter ? '(?:.*/)?' : '.*';
|
|
218
|
+
i += slashAfter ? 2 : 1;
|
|
219
|
+
} else {
|
|
220
|
+
re += '[^/]*';
|
|
221
|
+
}
|
|
222
|
+
} else if (c === '?') {
|
|
223
|
+
re += '[^/]';
|
|
224
|
+
} else if (c === '[') {
|
|
225
|
+
const close = glob.indexOf(']', i + 1);
|
|
226
|
+
if (close === -1) { re += '\\['; continue; }
|
|
227
|
+
let body = glob.slice(i + 1, close).replace(/\\/g, '\\\\');
|
|
228
|
+
if (body[0] === '!') body = '^' + body.slice(1);
|
|
229
|
+
re += `[${body}]`;
|
|
230
|
+
i = close;
|
|
231
|
+
} else if (c === '{') {
|
|
232
|
+
const close = glob.indexOf('}', i + 1);
|
|
233
|
+
if (close === -1) { re += '\\{'; continue; }
|
|
234
|
+
const alts = glob.slice(i + 1, close).split(',').map((a) => a.replace(/[.+^$()|\\]/g, '\\$&'));
|
|
235
|
+
re += `(?:${alts.join('|')})`;
|
|
236
|
+
i = close;
|
|
237
|
+
} else {
|
|
238
|
+
re += c.replace(/[.+^$()|\\\]}]/g, '\\$&');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return new RegExp(`^${re}$`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {string} cwd
|
|
246
|
+
* @param {string} glob
|
|
247
|
+
*/
|
|
248
|
+
function walkGlob(cwd, glob) {
|
|
249
|
+
const pattern = toSlash(glob).replace(/^\.\//, '');
|
|
250
|
+
const segments = pattern.split('/');
|
|
251
|
+
const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
|
|
252
|
+
const base = segments.slice(0, firstGlob).join('/') || '.';
|
|
253
|
+
const re = globToRegExp(pattern);
|
|
254
|
+
return walk(cwd, base).filter((f) => re.test(f));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Expands the path arguments into a de-duplicated input list.
|
|
259
|
+
* @param {string[]} args
|
|
260
|
+
* @param {string} cwd
|
|
261
|
+
* @param {(msg: string) => void} warn
|
|
262
|
+
* @returns {{ inputs: Array<{ path: string, explicit: boolean }>, errors: number }}
|
|
263
|
+
*/
|
|
264
|
+
function collectInputs(args, cwd, warn) {
|
|
265
|
+
const inputs = [];
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
let errors = 0;
|
|
268
|
+
let inGit;
|
|
269
|
+
|
|
270
|
+
const add = (path, explicit) => {
|
|
271
|
+
const abs = resolve(cwd, path);
|
|
272
|
+
const key = path === STDIN ? STDIN : CASE_INSENSITIVE_FS ? abs.toLowerCase() : abs;
|
|
273
|
+
if (seen.has(key)) return;
|
|
274
|
+
seen.add(key);
|
|
275
|
+
inputs.push({ path, explicit });
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
for (const arg of args) {
|
|
279
|
+
if (arg === STDIN) { add(STDIN, true); continue; }
|
|
280
|
+
|
|
281
|
+
let stat = null;
|
|
282
|
+
try { stat = statSync(resolve(cwd, arg)); } catch { /* not a plain path */ }
|
|
283
|
+
|
|
284
|
+
if (stat && stat.isFile()) { add(arg, true); continue; }
|
|
285
|
+
|
|
286
|
+
const isGlob = !stat && GLOB_CHARS.test(arg);
|
|
287
|
+
if (!stat && !isGlob) {
|
|
288
|
+
warn(`${arg}: no such file or directory`);
|
|
289
|
+
errors++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (inGit === undefined) inGit = isGitWorkTree(cwd);
|
|
294
|
+
const listed =
|
|
295
|
+
(inGit && gitListFiles(cwd, isGlob ? `:(glob)${toSlash(arg)}` : toSlash(arg))) ||
|
|
296
|
+
(isGlob ? walkGlob(cwd, arg) : walk(cwd, arg));
|
|
297
|
+
|
|
298
|
+
if (listed.length === 0) warn(`${arg}: no files matched`);
|
|
299
|
+
for (const f of listed) add(f, false);
|
|
300
|
+
}
|
|
301
|
+
return { inputs, errors };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
// --- Reading ---
|
|
306
|
+
|
|
307
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Decodes UTF-8, keeping a leading BOM. Returns null for binary or non-UTF-8 data.
|
|
311
|
+
* @param {Uint8Array} bytes
|
|
312
|
+
* @returns {{ text: string|null, reason?: string }}
|
|
313
|
+
*/
|
|
314
|
+
function decode(bytes) {
|
|
315
|
+
if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return { text: null, reason: 'binary' };
|
|
316
|
+
try {
|
|
317
|
+
return { text: utf8.decode(bytes) };
|
|
318
|
+
} catch {
|
|
319
|
+
return { text: null, reason: 'not UTF-8' };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @returns {Promise<Buffer>} */
|
|
324
|
+
function readStdin() {
|
|
325
|
+
return new Promise((res, rej) => {
|
|
326
|
+
const chunks = [];
|
|
327
|
+
process.stdin.on('data', (c) => chunks.push(c));
|
|
328
|
+
process.stdin.on('end', () => res(Buffer.concat(chunks)));
|
|
329
|
+
process.stdin.on('error', rej);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
// --- check ---
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* inspectTextDetailed, minus a BOM at offset 0 unless stripBom.
|
|
338
|
+
* @param {string} text
|
|
339
|
+
* @param {number} maxFindings
|
|
340
|
+
* @param {boolean} stripBom
|
|
341
|
+
*/
|
|
342
|
+
function inspect(text, maxFindings, stripBom) {
|
|
343
|
+
const leadingBom = !stripBom && text.charCodeAt(0) === BOM;
|
|
344
|
+
const report = inspectTextDetailed(text, { maxFindings: leadingBom ? maxFindings + 1 : maxFindings });
|
|
345
|
+
if (leadingBom) {
|
|
346
|
+
report.findings.shift();
|
|
347
|
+
report.counts.bom--;
|
|
348
|
+
report.total--;
|
|
349
|
+
report.truncated = report.total > report.findings.length;
|
|
350
|
+
}
|
|
351
|
+
return report;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @param {number} cp */
|
|
355
|
+
const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {Record<string, number>} counts
|
|
359
|
+
*/
|
|
360
|
+
const formatCounts = (counts) =>
|
|
361
|
+
Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${k} ${n}`).join(', ');
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
// --- Main ---
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {string[]} argv
|
|
368
|
+
* @returns {Promise<number>} exit code
|
|
369
|
+
*/
|
|
370
|
+
async function main(argv) {
|
|
371
|
+
const out = (s) => process.stdout.write(s + '\n');
|
|
372
|
+
const err = (s) => process.stderr.write(s + '\n');
|
|
373
|
+
|
|
374
|
+
let opts;
|
|
375
|
+
try {
|
|
376
|
+
opts = parseArgs(argv);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
if (!(e instanceof UsageError)) throw e;
|
|
379
|
+
err(`text-sanctifier: ${e.message}\nRun "text-sanctifier --help" for usage.`);
|
|
380
|
+
return 2;
|
|
381
|
+
}
|
|
382
|
+
if (opts.help) { out(USAGE); return 0; }
|
|
383
|
+
if (opts.version) { out(VERSION); return 0; }
|
|
384
|
+
|
|
385
|
+
const cwd = process.cwd();
|
|
386
|
+
let paths = opts.paths;
|
|
387
|
+
if (paths.length === 0) {
|
|
388
|
+
if (process.stdin.isTTY) {
|
|
389
|
+
err('text-sanctifier: no paths given and nothing piped on stdin.\nRun "text-sanctifier --help" for usage.');
|
|
390
|
+
return 2;
|
|
391
|
+
}
|
|
392
|
+
paths = [STDIN];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const warn = (msg) => err(`text-sanctifier: ${msg}`);
|
|
396
|
+
const { inputs, errors: pathErrors } = collectInputs(paths, cwd, warn);
|
|
397
|
+
let errors = pathErrors;
|
|
398
|
+
/** @type {Array<{ path: string, reason: string }>} */
|
|
399
|
+
const skipped = [];
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Reads every input in order, calling `visit(label, text, input)` for each decodable one.
|
|
403
|
+
* @param {(label: string, text: string, input: { path: string, explicit: boolean }) => void} visit
|
|
404
|
+
*/
|
|
405
|
+
const forEachText = async (visit) => {
|
|
406
|
+
for (const input of inputs) {
|
|
407
|
+
const label = input.path === STDIN ? STDIN_LABEL : input.path;
|
|
408
|
+
let bytes;
|
|
409
|
+
try {
|
|
410
|
+
bytes = input.path === STDIN ? await readStdin() : readFileSync(resolve(cwd, input.path));
|
|
411
|
+
} catch (e) {
|
|
412
|
+
warn(`${label}: ${e.code || e.message}`);
|
|
413
|
+
errors++;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const { text, reason } = decode(bytes);
|
|
417
|
+
if (text === null) {
|
|
418
|
+
skipped.push({ path: label, reason });
|
|
419
|
+
if (input.explicit) warn(`${label}: skipped (${reason})`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
visit(label, text, input);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
if (opts.command === 'check') {
|
|
427
|
+
const files = [];
|
|
428
|
+
let scanned = 0;
|
|
429
|
+
let total = 0;
|
|
430
|
+
|
|
431
|
+
await forEachText((label, text) => {
|
|
432
|
+
scanned++;
|
|
433
|
+
const report = inspect(text, opts.maxFindings, opts.stripBom);
|
|
434
|
+
total += report.total;
|
|
435
|
+
if (report.total > 0) files.push({ path: label, ...report });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (opts.json) {
|
|
439
|
+
out(JSON.stringify({ files, total, filesScanned: scanned, filesWithFindings: files.length, skipped }, null, 2));
|
|
440
|
+
} else {
|
|
441
|
+
for (const f of files) {
|
|
442
|
+
for (const x of f.findings) out(`${f.path}:${x.line}:${x.column} ${hex(x.codePoint)} ${x.category}`);
|
|
443
|
+
if (f.truncated) out(`${f.path}: ${f.total - f.findings.length} more not listed (--max-findings)`);
|
|
444
|
+
}
|
|
445
|
+
out(total === 0
|
|
446
|
+
? `No invisible or control characters in ${scanned} file${scanned === 1 ? '' : 's'}.`
|
|
447
|
+
: `${total} finding${total === 1 ? '' : 's'} in ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
|
|
448
|
+
}
|
|
449
|
+
return errors > 0 ? 2 : total > 0 ? 1 : 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// fix
|
|
453
|
+
const clean = summonSanctifier({
|
|
454
|
+
purgeInvisibleChars: opts.purgeInvisible,
|
|
455
|
+
nukeControls: opts.nukeControls,
|
|
456
|
+
normalizeNewlines: opts.normalizeNewlines,
|
|
457
|
+
});
|
|
458
|
+
const writesStdout = !opts.dryRun && inputs.some((i) => i.path === STDIN);
|
|
459
|
+
const report = writesStdout ? err : out;
|
|
460
|
+
const files = [];
|
|
461
|
+
let scanned = 0;
|
|
462
|
+
|
|
463
|
+
await forEachText((label, text, input) => {
|
|
464
|
+
scanned++;
|
|
465
|
+
// The BOM at offset 0 is set aside so purgeInvisibleChars cannot take it
|
|
466
|
+
const leadingBom = text.charCodeAt(0) === BOM;
|
|
467
|
+
let cleaned = clean(leadingBom ? text.slice(1) : text);
|
|
468
|
+
if (leadingBom && !opts.stripBom) cleaned = String.fromCharCode(BOM) + cleaned;
|
|
469
|
+
|
|
470
|
+
if (input.path === STDIN && !opts.dryRun) process.stdout.write(cleaned);
|
|
471
|
+
if (cleaned === text) return;
|
|
472
|
+
|
|
473
|
+
const before = inspectTextDetailed(text, { maxFindings: 0 }).counts;
|
|
474
|
+
const after = inspectTextDetailed(cleaned, { maxFindings: 0 }).counts;
|
|
475
|
+
const removed = {};
|
|
476
|
+
let removedTotal = 0;
|
|
477
|
+
for (const k of Object.keys(before)) {
|
|
478
|
+
const n = before[k] - (after[k] || 0);
|
|
479
|
+
if (n > 0) { removed[k] = n; removedTotal += n; }
|
|
480
|
+
}
|
|
481
|
+
const nl = getNewlineStats(text);
|
|
482
|
+
const newlinesNormalized = opts.normalizeNewlines && nl.crlf + nl.cr > 0;
|
|
483
|
+
|
|
484
|
+
if (input.path !== STDIN && !opts.dryRun) {
|
|
485
|
+
try {
|
|
486
|
+
writeFileSync(resolve(cwd, input.path), cleaned, 'utf8');
|
|
487
|
+
} catch (e) {
|
|
488
|
+
warn(`${label}: ${e.code || e.message}`);
|
|
489
|
+
errors++;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
files.push({ path: label, removed, removedTotal, newlinesNormalized });
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
if (opts.json) {
|
|
497
|
+
report(JSON.stringify({ files, dryRun: opts.dryRun, filesScanned: scanned, filesChanged: files.length, skipped }, null, 2));
|
|
498
|
+
} else {
|
|
499
|
+
const verb = opts.dryRun ? 'would fix' : 'fixed';
|
|
500
|
+
for (const f of files) {
|
|
501
|
+
const parts = [];
|
|
502
|
+
if (f.removedTotal > 0) parts.push(`removed ${f.removedTotal} (${formatCounts(f.removed)})`);
|
|
503
|
+
if (f.newlinesNormalized) parts.push('normalized newlines');
|
|
504
|
+
report(`${verb} ${f.path}: ${parts.join('; ')}`);
|
|
505
|
+
}
|
|
506
|
+
report(files.length === 0
|
|
507
|
+
? `Nothing to fix in ${scanned} file${scanned === 1 ? '' : 's'}.`
|
|
508
|
+
: `${opts.dryRun ? 'Would change' : 'Changed'} ${files.length} of ${scanned} file${scanned === 1 ? '' : 's'}.`);
|
|
509
|
+
}
|
|
510
|
+
return errors > 0 ? 2 : opts.dryRun && files.length > 0 ? 1 : 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
main(process.argv.slice(2)).then(
|
|
514
|
+
(code) => { process.exitCode = code; },
|
|
515
|
+
(e) => {
|
|
516
|
+
process.stderr.write(`text-sanctifier: ${e && e.stack ? e.stack : e}\n`);
|
|
517
|
+
process.exitCode = 2;
|
|
518
|
+
}
|
|
519
|
+
);
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
function n(a={}){const c=!!a.purgeInvisibleChars,
|
|
1
|
+
function n(a={}){const c=!!a.purgeInvisibleChars,e=!!a.purgeEmojis,f=!!a.nukeControls,h=!!a.keyboardOnlyFilter,m=!!a.normalizeNewlines,l=!!a.trimSpacesAroundNewlines,b=!!a.collapseNewLines,d=!!a.preserveParagraphs,g=!!a.collapseSpaces,p=!!a.finalTrim,k=Number.isFinite(a.maxLength)&&0<=a.maxLength?a.maxLength:Infinity,q="truncate"===a.g||"noop"===a.g||"throw"===a.g?a.g:"throw";return z=>r(z,c,e,f,h,m,l,b,d,g,p,k,q)}n.strict=a=>r(a,!0,!0,!0,!1,!0,!0,!0,!1,!0,!0);
|
|
2
2
|
n.loose=a=>r(a,!1,!1,!1,!1,!0,!0,!0,!0,!0,!0);n.keyboardOnlyEmoji=a=>r(a,!1,!1,!1,!0,!0,!0,!1,!1,!1,!0);n.keyboardOnly=a=>r(a,!0,!0,!0,!0,!0,!0,!0,!1,!0,!0);
|
|
3
|
-
function r(a,c=!1,
|
|
4
|
-
|
|
5
|
-
var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;
|
|
6
|
-
var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,A=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
function r(a,c=!1,e=!1,f=!1,h=!1,m=!1,l=!1,b=!1,d=!1,g=!1,p=!1,k=Infinity,q="throw"){if("string"!==typeof a)throw new TypeError("sanctifyText expects a string input.");"truncate"!==q&&"noop"!==q&&"throw"!==q&&(q="throw");if(a.length>k)switch(q){case "truncate":a=a.slice(0,k);break;case "noop":return a;default:throw new RangeError(`sanctifyText input length ${a.length} exceeds maxLength ${k}.`);}c&&(a=a.replace(t,""));e&&(a=a.replace(u,""));f&&(a=a.replace(v,""));h&&(a=w(a,e));m&&(a=a.replace(x,"\n"));
|
|
4
|
+
l&&(a=a.replace(y,"$1"));b&&(c=a,a=d?c.replace(A,"\n\n"):c.replace(B,"\n"));g&&(a=a.replace(C," "));return p?a.trim():a}var t=/[\u00a0\u00ad\u034f\u061c\u115f\u1160\u180e\u2000-\u200f\u202a-\u202f\u2060-\u2064\u2066-\u2069\u3000\u3164\ufe00-\ufe0f\ufeff\uffa0\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu,D=/[^\x20-\x7E\r\n]+/gu;
|
|
5
|
+
function w(a,c=!1){a=E(a);return c?a.replace(D,""):a.replace(D,e=>{u.lastIndex=0;if(u.test(e)){u.lastIndex=0;var f="";for(const h of e.matchAll(u))f+=h[0];e=f}else e="";return e})}var F=/[\u2018\u2019\u201A\u201B\u2032\u2035]/g,G=/[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g,H=/[\u2012\u2013\u2014\u2015\u2212]/g,I=/\u2026/g,J=/[\u2022\u00B7]/g,K=/[\uFF01-\uFF5E]/g;
|
|
6
|
+
function E(a){return a.replace(F,"'").replace(G,'"').replace(H,"-").replace(I,"...").replace(J,"*").replace(K,c=>String.fromCharCode(c.charCodeAt(0)-65248))}var u;try{u=RegExp("(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)","gu")}catch{u=/[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu}var x=/\r\n|\r|\n/g,y=/[ \t]*(\n+)[ \t]*/g,B=/\n{2,}/g,A=/\n{3,}/g,C=/ {2,}/g,v=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
|
|
7
|
+
function L(a,c){a.lastIndex=0;return a.test(c)}
|
|
8
|
+
function M(a){if("string"!==typeof a)throw new TypeError("inspectText expects a string input.");const c=[],e={hasControlChars:!1,hasInvisibleChars:!1,hasMixedNewlines:!1,newlineStyle:null,hasEmojis:!1,hasNonKeyboardChars:!1,summary:c};L(v,a)&&(e.hasControlChars=!0,c.push("Control characters detected."));L(t,a)&&(e.hasInvisibleChars=!0,c.push("Invisible Unicode characters detected."));L(u,a)&&(e.hasEmojis=!0,c.push("Emojis detected."));if("string"!==typeof a)throw new TypeError("getNewlineStats expects a string input.");var f=
|
|
9
|
+
0,h=0;let m=0;for(let g=0,p=a.length;g<p;g++){const k=a.charCodeAt(g);13===k?10===a.charCodeAt(g+1)?(f++,g++):h++:10===k&&m++}f={i:f,h,j:m};h=[];0<f.i&&h.push("CRLF");0<f.h&&h.push("CR");0<f.j&&h.push("LF");const {l,types:b}={...f,types:h,l:1<h.length};e.hasMixedNewlines=l;e.newlineStyle=l?"Mixed":b[0]||null;e.newlineStyle&&c.push(l?"Mixed newline styles detected.":`Consistent newline style: ${e.newlineStyle}`);a:{a=E(a);if(L(D,a))for(d of a)if(!("\n"===d||"\r"===d||" "<=d&&"~">=d||L(u,d))){var d=
|
|
10
|
+
!0;break a}d=!1}d&&(e.hasNonKeyboardChars=!0,c.push("Non-keyboard characters detected."));return e}
|
|
11
|
+
function N(a,c={}){if("string"!==typeof a)throw new TypeError("inspectTextDetailed expects a string input.");c=c&&c.maxFindings;c=Infinity===c||Number.isInteger(c)&&0<=c?c:100;const e={bidi_control:0,zero_width:0,nonbreaking_space:0,bom:0,nul:0,c0_control:0,c1_control:0,invisible_format:0,hangul_filler:0,variation_selector:0,tag:0},f=[];let h=0,m=1,l=0;for(let g=0,p=a.length;g<p;g++){var b=a.charCodeAt(g);if(32<=b&&127>b)continue;if(10===b){m++;l=g+1;continue}if(13===b){10!==a.charCodeAt(g+1)&&(m++,
|
|
12
|
+
l=g+1);continue}const k=g;var d=b;if(56128===b){d=a.charCodeAt(g+1);if(!(56320<=d&&57343>=d))continue;d=65536+(b-55296<<10)+(d-56320);b=917504<=d&&917631>=d?"tag":917760<=d&&917999>=d?"variation_selector":null;g++}else b=128>b?0===b?"nul":32>b?9===b||10===b||13===b?null:"c0_control":127===b?"c0_control":null:159>=b?"c1_control":160===b?"nonbreaking_space":173===b||847===b||6158===b?"invisible_format":1564===b?"bidi_control":4447===b||4448===b?"hangul_filler":8192>b?null:8202>=b?"nonbreaking_space":
|
|
13
|
+
8205>=b?"zero_width":8207>=b||8234<=b&&8238>=b?"bidi_control":8239===b?"nonbreaking_space":8288===b?"zero_width":8289<=b&&8292>=b?"invisible_format":8294<=b&&8297>=b?"bidi_control":12288===b?"nonbreaking_space":12644===b||65440===b?"hangul_filler":65024<=b&&65039>=b?"variation_selector":65279===b?"bom":null;null!==b&&(e[b]++,h++,f.length<c&&(d={offset:k,line:m,column:k-l+1,codePoint:d,category:b},"bom"===b&&(d.atStart=0===k),f.push(d)))}return{findings:f,counts:e,total:h,truncated:h>f.length}}
|
|
14
|
+
function O(a){return{purgeInvisibleChars:a.hasInvisibleChars,purgeEmojis:a.hasEmojis,nukeControls:a.hasControlChars,keyboardOnlyFilter:a.hasNonKeyboardChars,normalizeNewlines:a.hasMixedNewlines||"CRLF"===a.newlineStyle||"CR"===a.newlineStyle}}export { n as summonSanctifier, M as inspectText, O as getRecommendedSanctifierOptions, N as inspectTextDetailed };
|