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/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 and invisible operators. Tag runs are decoded
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
- /** @typedef {'tag' | 'variation_selector' | 'invisible_operator'} HiddenRunKind */
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 {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText)
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
- for (const ch of text) {
62
- const cp = /** @type {number} */ (ch.codePointAt(0));
63
- out += cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
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
- * Finds runs of tag characters, variation selectors and invisible operators.
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) { i += cp > 0xFFFF ? 2 : 1; continue; }
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
- if (kind === 'tag') {
103
- const body = text.slice(offset, end);
104
- const afterFlag = offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG;
105
- if (afterFlag && last === CANCEL_TAG && SUBDIVISION_FLAGS.has(decodeTagText(body).slice(0, -'<U+E007F>'.length))) {
106
- benign.push([offset, end]);
107
- } else {
108
- runs.push({ kind, offset, end, count, decoded: decodeTagText(body) });
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
- if (kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
113
- offset > 0 && !/\s/.test(text[offset - 1])) {
114
- benign.push([offset, end]);
115
- continue;
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
- export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier };
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
@@ -1,13 +1,7 @@
1
1
  // ./src/inspectText.js
2
2
 
3
- import {
4
- CONTROL_CHARS_REGEX,
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
+ }