text-sanctifier 1.3.4 → 1.3.6
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 +81 -19
- package/bin/text-sanctifier.js +48 -7
- package/dist/text-sanctifier.min.js +6 -15
- package/dist/types/hiddenText.d.ts +62 -6
- package/dist/types/index.d.ts +9 -1
- package/dist/types/reveal.d.ts +26 -0
- package/dist/types/sanctifyText.d.ts +156 -91
- package/dist/types/textPatterns.d.ts +15 -0
- package/dist/types/version.generated.d.ts +2 -0
- package/package.json +79 -80
- package/src/hiddenText.js +129 -26
- package/src/index.d.ts +9 -1
- package/src/index.js +9 -2
- package/src/inspectText.js +2 -8
- package/src/reveal.js +40 -0
- package/src/sanctifyText.js +179 -125
- package/src/textPatterns.js +78 -0
- package/src/version.generated.js +5 -0
package/src/hiddenText.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// src/hiddenText.js
|
|
2
2
|
//
|
|
3
3
|
// Finds runs of the characters used to hide instructions in text a model reads:
|
|
4
|
-
// tag characters, variation selectors
|
|
4
|
+
// tag characters, variation selectors, invisible operators and the reserved code points
|
|
5
|
+
// of the tag plane. Tag runs are decoded
|
|
5
6
|
// to the ASCII they spell. Used by `text-sanctifier check --ai-config`, and exported
|
|
6
7
|
// as `text-sanctifier/hidden`.
|
|
7
8
|
//
|
|
8
9
|
// Plain JavaScript with no Node APIs, so it also runs in a browser. src/index.js does
|
|
9
10
|
// not import this file, so it stays out of the browser bundle and its size budget.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
export { VERSION } from './version.generated.js';
|
|
13
|
+
|
|
14
|
+
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* @typedef {Object} HiddenRun
|
|
@@ -16,14 +19,38 @@
|
|
|
16
19
|
* @property {number} offset - UTF-16 index of the first character
|
|
17
20
|
* @property {number} end - UTF-16 index just past the last character
|
|
18
21
|
* @property {number} count - number of code points in the run
|
|
19
|
-
* @property {
|
|
22
|
+
* @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
|
|
23
|
+
* @property {number} column - 1-based column of the first character, in UTF-16 units
|
|
24
|
+
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
|
|
25
|
+
* at most `maxDecodedLength` UTF-16 units
|
|
26
|
+
* @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} HiddenRunCounts
|
|
31
|
+
* @property {number} tag
|
|
32
|
+
* @property {number} variation_selector
|
|
33
|
+
* @property {number} invisible_operator
|
|
34
|
+
* @property {number} reserved_tag_plane
|
|
20
35
|
*/
|
|
21
36
|
|
|
22
37
|
/**
|
|
23
38
|
* @typedef {Object} HiddenTextScan
|
|
24
|
-
* @property {HiddenRun[]} runs - runs that may hide text, in order
|
|
39
|
+
* @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
|
|
25
40
|
* @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
|
|
26
|
-
* sequences (subdivision flags, a single emoji or text presentation selector)
|
|
41
|
+
* sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
|
|
42
|
+
* @property {HiddenRunCounts} counts - every run per kind, listed or not
|
|
43
|
+
* @property {number} total - sum of `counts`
|
|
44
|
+
* @property {number} benignTotal - every benign span, listed or not
|
|
45
|
+
* @property {boolean} truncated - true when runs or benign spans were left out of the lists
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} HiddenScanOptions
|
|
50
|
+
* @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
|
|
51
|
+
* (non-negative integer or Infinity). Counts stay exact.
|
|
52
|
+
* @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
|
|
53
|
+
* For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
|
|
27
54
|
*/
|
|
28
55
|
|
|
29
56
|
const TAG_FIRST = 0xE0000;
|
|
@@ -43,6 +70,8 @@ function kindOf(cp) {
|
|
|
43
70
|
if ((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF) ||
|
|
44
71
|
(cp >= 0x180B && cp <= 0x180D) || cp === 0x180F) return 'variation_selector';
|
|
45
72
|
if (cp >= 0x2061 && cp <= 0x2064) return 'invisible_operator';
|
|
73
|
+
// Unassigned and default ignorable: U+E0080-U+E00FF and U+E01F0-U+E0FFF
|
|
74
|
+
if (cp >= 0xE0080 && cp <= 0xE0FFF) return 'reserved_tag_plane';
|
|
46
75
|
return null;
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -57,35 +86,101 @@ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
|
|
|
57
86
|
* @returns {string}
|
|
58
87
|
*/
|
|
59
88
|
export function decodeTagText(text) {
|
|
89
|
+
return decodeRange(text, 0, text.length, Infinity).decoded;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Decodes text[start, end) as decodeTagText does, stopping before the output would
|
|
94
|
+
* pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
|
|
95
|
+
* @param {string} text
|
|
96
|
+
* @param {number} start
|
|
97
|
+
* @param {number} end
|
|
98
|
+
* @param {number} limit
|
|
99
|
+
* @returns {{ decoded: string, truncated: boolean }}
|
|
100
|
+
*/
|
|
101
|
+
function decodeRange(text, start, end, limit) {
|
|
60
102
|
let out = '';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
103
|
+
let i = start;
|
|
104
|
+
while (i < end) {
|
|
105
|
+
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
106
|
+
const piece = cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
|
|
107
|
+
if (out.length + piece.length > limit) return { decoded: out, truncated: true };
|
|
108
|
+
out += piece;
|
|
109
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
64
110
|
}
|
|
65
|
-
return out;
|
|
111
|
+
return { decoded: out, truncated: false };
|
|
66
112
|
}
|
|
67
113
|
|
|
114
|
+
// Whitespace as JavaScript's \s defines it, tested at an index of the text itself,
|
|
115
|
+
// so the check allocates nothing.
|
|
116
|
+
const SPACE_AT = /\s/y;
|
|
117
|
+
|
|
68
118
|
/**
|
|
69
|
-
*
|
|
119
|
+
* @param {string} text
|
|
120
|
+
* @param {number} index
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
function isSpaceAt(text, index) {
|
|
124
|
+
SPACE_AT.lastIndex = index;
|
|
125
|
+
return SPACE_AT.test(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A cap option: a non-negative integer or Infinity; anything else means no cap.
|
|
130
|
+
* @param {unknown} value
|
|
131
|
+
* @returns {number}
|
|
132
|
+
*/
|
|
133
|
+
function cap(value) {
|
|
134
|
+
return typeof value === 'number' && (value === Infinity || (Number.isInteger(value) && value >= 0))
|
|
135
|
+
? value
|
|
136
|
+
: Infinity;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Finds runs of tag characters, variation selectors, invisible operators and reserved
|
|
141
|
+
* tag-plane code points (U+E0080–U+E00FF, U+E01F0–U+E0FFF).
|
|
70
142
|
* Consecutive characters of the same kind form one run. Two kinds of sequence are
|
|
71
143
|
* legitimate and listed under `benign` instead of `runs`:
|
|
72
144
|
* - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
|
|
73
145
|
* tags for "gbeng", "gbsct" or "gbwls" and U+E007F
|
|
74
146
|
* - a single U+FE0E or U+FE0F (text or emoji presentation) after another character
|
|
147
|
+
*
|
|
148
|
+
* `line` and `column` are counted as in inspectTextDetailed. Every run and benign
|
|
149
|
+
* span is counted; only the first `maxRuns` of each are listed. With the caps set,
|
|
150
|
+
* memory stays flat however large the input is. One pass over the string, no copy.
|
|
151
|
+
* Set both caps for untrusted input: without them, results grow with the input.
|
|
152
|
+
*
|
|
75
153
|
* @param {string} text
|
|
154
|
+
* @param {HiddenScanOptions} [options]
|
|
76
155
|
* @returns {HiddenTextScan}
|
|
77
156
|
*/
|
|
78
|
-
export function findHiddenRuns(text) {
|
|
157
|
+
export function findHiddenRuns(text, options = {}) {
|
|
158
|
+
const maxRuns = cap(options && options.maxRuns);
|
|
159
|
+
const maxDecodedLength = cap(options && options.maxDecodedLength);
|
|
160
|
+
|
|
79
161
|
/** @type {HiddenRun[]} */
|
|
80
162
|
const runs = [];
|
|
81
163
|
/** @type {Array<[number, number]>} */
|
|
82
164
|
const benign = [];
|
|
165
|
+
/** @type {HiddenRunCounts} */
|
|
166
|
+
const counts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
|
|
167
|
+
let total = 0;
|
|
168
|
+
let benignTotal = 0;
|
|
169
|
+
let line = 1;
|
|
170
|
+
let lineStart = 0;
|
|
83
171
|
|
|
84
172
|
let i = 0;
|
|
85
173
|
while (i < text.length) {
|
|
86
174
|
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
87
175
|
const kind = kindOf(cp);
|
|
88
|
-
if (kind === null) {
|
|
176
|
+
if (kind === null) {
|
|
177
|
+
if (cp === 0x0A || (cp === 0x0D && text.charCodeAt(i + 1) !== 0x0A)) { // CRLF ends at its LF
|
|
178
|
+
line++;
|
|
179
|
+
lineStart = i + 1;
|
|
180
|
+
}
|
|
181
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
89
184
|
|
|
90
185
|
const offset = i;
|
|
91
186
|
let count = 0;
|
|
@@ -99,22 +194,30 @@ export function findHiddenRuns(text) {
|
|
|
99
194
|
}
|
|
100
195
|
const end = i;
|
|
101
196
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
197
|
+
// A subdivision flag is exactly five tag letters and CANCEL TAG after U+1F3F4.
|
|
198
|
+
const isBenign = kind === 'tag'
|
|
199
|
+
? count === 6 && last === CANCEL_TAG && offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG &&
|
|
200
|
+
SUBDIVISION_FLAGS.has(decodeRange(text, offset, end - 2, Infinity).decoded)
|
|
201
|
+
: kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
|
|
202
|
+
offset > 0 && !isSpaceAt(text, offset - 1);
|
|
203
|
+
if (isBenign) {
|
|
204
|
+
benignTotal++;
|
|
205
|
+
if (benign.length < maxRuns) benign.push([offset, end]);
|
|
110
206
|
continue;
|
|
111
207
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
208
|
+
|
|
209
|
+
counts[kind]++;
|
|
210
|
+
total++;
|
|
211
|
+
if (runs.length < maxRuns) {
|
|
212
|
+
/** @type {HiddenRun} */
|
|
213
|
+
const run = { kind, offset, end, line, column: offset - lineStart + 1, count };
|
|
214
|
+
if (kind === 'tag') {
|
|
215
|
+
const { decoded, truncated } = decodeRange(text, offset, end, maxDecodedLength);
|
|
216
|
+
run.decoded = decoded;
|
|
217
|
+
run.decodedTruncated = truncated;
|
|
218
|
+
}
|
|
219
|
+
runs.push(run);
|
|
116
220
|
}
|
|
117
|
-
runs.push({ kind, offset, end, count });
|
|
118
221
|
}
|
|
119
|
-
return { runs, benign };
|
|
222
|
+
return { runs, benign, counts, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
|
|
120
223
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -3,7 +3,14 @@ import { inspectTextDetailed } from './inspectText.js';
|
|
|
3
3
|
import { getNewlineStats } from './inspectText.js';
|
|
4
4
|
import { getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
5
|
import { summonSanctifier } from './sanctifyText.js';
|
|
6
|
-
|
|
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 };
|
|
7
14
|
export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
|
|
8
15
|
export type TrashCategory = import('./inspectText.js').TrashCategory;
|
|
9
16
|
export type TrashFinding = import('./inspectText.js').TrashFinding;
|
|
@@ -12,3 +19,4 @@ export type DetailedTrashReport = import('./inspectText.js').DetailedTrashReport
|
|
|
12
19
|
export type InspectDetailedOptions = import('./inspectText.js').InspectDetailedOptions;
|
|
13
20
|
export type NewlineStats = import('./inspectText.js').NewlineStats;
|
|
14
21
|
export type SanctifyOptions = import('./sanctifyText.js').SanctifyOptions;
|
|
22
|
+
export type RevealOptions = import('./reveal.js').RevealOptions;
|
package/src/index.js
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
import { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
5
|
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions };
|
|
6
6
|
|
|
7
|
-
import { summonSanctifier } from './sanctifyText.js';
|
|
8
|
-
export { summonSanctifier };
|
|
7
|
+
import { summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted } from './sanctifyText.js';
|
|
8
|
+
export { summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted };
|
|
9
|
+
|
|
10
|
+
import { revealInvisible } from './reveal.js';
|
|
11
|
+
export { revealInvisible };
|
|
12
|
+
|
|
13
|
+
import { VERSION } from './version.generated.js';
|
|
14
|
+
export { VERSION };
|
|
9
15
|
|
|
10
16
|
// Type-only re-exports so TypeScript consumers can name these from the package root.
|
|
11
17
|
/** @typedef {import('./inspectText.js').UnicodeTrashReport} UnicodeTrashReport */
|
|
@@ -16,6 +22,7 @@ export { summonSanctifier };
|
|
|
16
22
|
/** @typedef {import('./inspectText.js').InspectDetailedOptions} InspectDetailedOptions */
|
|
17
23
|
/** @typedef {import('./inspectText.js').NewlineStats} NewlineStats */
|
|
18
24
|
/** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
|
|
25
|
+
/** @typedef {import('./reveal.js').RevealOptions} RevealOptions */
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
// In Future: export helpers for power users
|
package/src/inspectText.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
// ./src/inspectText.js
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
INVISIBLE_TRASH_REGEX,
|
|
6
|
-
EMOJI_REGEX,
|
|
7
|
-
ASCII_KEYBOARD_SAFE_REGEX,
|
|
8
|
-
normalizeTypographicJank,
|
|
9
|
-
} from './sanctifyText.js';
|
|
10
|
-
import { classifyCodeUnit, classifyAstral } from './categories.generated.js';
|
|
3
|
+
import { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX, classifyCodeUnit, classifyAstral } from './categories.generated.js';
|
|
4
|
+
import { EMOJI_REGEX, ASCII_KEYBOARD_SAFE_REGEX, normalizeTypographicJank } from './textPatterns.js';
|
|
11
5
|
|
|
12
6
|
/** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
|
|
13
7
|
|
package/src/reveal.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ./src/reveal.js
|
|
2
|
+
|
|
3
|
+
import { inspectTextDetailed } from './inspectText.js';
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('./inspectText.js').TrashCategory} TrashCategory */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} RevealOptions
|
|
9
|
+
* @property {(codePoint: number, category: TrashCategory) => string} [format] - Text shown in place of
|
|
10
|
+
* each hidden character. Default: `<U+XXXX>` (at least four uppercase hex digits).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Makes hidden characters visible: every character `inspectTextDetailed` would report is
|
|
15
|
+
* replaced by a marker such as `<U+200B>`, and everything else is kept as it is,
|
|
16
|
+
* including tabs, newlines and emoji. Use it to show hidden text in logs, diffs, review
|
|
17
|
+
* tools and agent tool output. A literal `<U+200B>` already in the text looks the same as
|
|
18
|
+
* a marker; pass `format` to choose markers that can't occur in your input.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} text
|
|
21
|
+
* @param {RevealOptions} [options]
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function revealInvisible(text, options = {}) {
|
|
25
|
+
if (typeof text !== 'string') {
|
|
26
|
+
throw new TypeError('revealInvisible expects a string input.');
|
|
27
|
+
}
|
|
28
|
+
const format = options && typeof options.format === 'function'
|
|
29
|
+
? options.format
|
|
30
|
+
: (codePoint) => '<U+' + codePoint.toString(16).toUpperCase().padStart(4, '0') + '>';
|
|
31
|
+
|
|
32
|
+
// One finding per hidden character, in offset order; code points above U+FFFF span two units
|
|
33
|
+
let out = '';
|
|
34
|
+
let last = 0;
|
|
35
|
+
for (const f of inspectTextDetailed(text, { maxFindings: Infinity }).findings) {
|
|
36
|
+
out += text.slice(last, f.offset) + format(f.codePoint, f.category);
|
|
37
|
+
last = f.offset + (f.codePoint > 0xFFFF ? 2 : 1);
|
|
38
|
+
}
|
|
39
|
+
return out + text.slice(last);
|
|
40
|
+
}
|