text-sanctifier 1.3.6 → 1.3.7

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
@@ -264,8 +264,30 @@ const { runs, benign, counts, total, truncated } = findHiddenRuns(text, { maxRun
264
264
  - `maxRuns` caps the `runs` list and, separately, the `benign` list; `counts`, `total` and `benignTotal` count everything, and `truncated` is true when either list was cut. `maxDecodedLength` caps each `decoded` string in UTF-16 units and never cuts a `<U+XXXX>` marker. Both default to no cap, and an invalid value (negative, fractional, a string) also means no cap, so set them explicitly for untrusted input. With both set, memory stays flat on any input: on 16 M characters (32 MB) of hidden text the heap grows by under 1 MB, against hundreds of MB with no caps.
265
265
  - `decodeTagText` turns U+E0020–U+E007E into the ASCII they shadow and writes any other character as `<U+XXXX>`.
266
266
  - Zero-width, bidi and other invisible characters are not runs; use `inspectTextDetailed` for those.
267
+ - `codePointCounts` has the same keys as `counts` and counts the code points in those runs, so long runs weigh more than short ones; `supplementarySelectorCount` counts U+E0100–U+E01EF on their own, apart from the emoji selectors U+FE00–U+FE0F. Both leave out benign spans and stay exact under `maxRuns`.
267
268
  - `VERSION` is exported here and from the package root: the installed version, as in `package.json`.
268
269
 
270
+ ### `text-sanctifier/context`: `inspectTextInContext(text, options?)`
271
+
272
+ `inspectTextDetailed` with context. Some invisible characters are part of ordinary text: the joiners in a family emoji, the selector in ✌ followed by VS16, an ideographic variation selector after a CJK character. This entry point reports the same findings and `counts`, marks each listed finding `benign: true` or `false`, and adds `benignCounts`, exact whether or not a finding is listed. The categories are the same.
273
+
274
+ ```js
275
+ import { inspectTextInContext } from 'text-sanctifier/context';
276
+
277
+ const report = inspectTextInContext(text, { maxFindings: 20 });
278
+ const suspicious = report.counts.zero_width - report.benignCounts.zero_width; // U+200B, U+2060, stray U+200D
279
+ ```
280
+
281
+ Benign, and nothing else:
282
+
283
+ - U+200D between two emoji (`Extended_Pictographic`), allowing a skin-tone modifier or VS16 after the first
284
+ - U+FE0E or U+FE0F directly after an emoji, or in a keycap (0-9, # or *, the selector, U+20E3)
285
+ - one of U+E0100–U+E01EF directly after an ideograph
286
+ - U+180B–U+180D or U+180F directly after a Mongolian letter
287
+ - the tags of the England, Scotland and Wales flags
288
+
289
+ U+200B, U+2060, U+FEFF, bidi controls and all other tags are never benign. Joiners in scripts that need them (ZWNJ in Persian, ZWJ in Indic scripts) are not benign yet. It needs Unicode property escapes (Node 10+, current browsers), is not in the browser file, and is about 1.7 KB gzipped on its own.
290
+
269
291
  ---
270
292
 
271
293
  ## Command line
@@ -334,7 +356,7 @@ The repository publishes a [pre-commit](https://pre-commit.com) hook manifest, s
334
356
  # .pre-commit-config.yaml
335
357
  repos:
336
358
  - repo: https://github.com/iWhatty/text-sanctifier
337
- rev: v1.3.6 # a release tag
359
+ rev: v1.3.7 # a release tag
338
360
  hooks:
339
361
  - id: text-sanctifier # every staged text file
340
362
  - id: text-sanctifier-ai-config # agent instruction files, with --ai-config
@@ -3,4 +3,4 @@ var y=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u061C\u200E\u200F\u2
3
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,`
4
4
 
5
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.6";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};
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.7";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};
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Locates invisible and control characters as inspectTextDetailed does, and marks
3
+ * the ones that belong to legitimate sequences as benign:
4
+ * - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
5
+ * VS16 after the first: an emoji ZWJ sequence
6
+ * - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
7
+ * - one of U+E0100–U+E01EF directly after an ideograph: an ideographic variation sequence
8
+ * - U+180B–U+180D or U+180F directly after a Mongolian letter
9
+ * - the tags of the England, Scotland and Wales flags after U+1F3F4
10
+ * Nothing else is benign: not U+200B, U+2060, U+FEFF, bidi controls or other tags.
11
+ *
12
+ * `counts`, `total` and `truncated` are exactly those of inspectTextDetailed;
13
+ * `benignCounts` counts every benign character, listed or not. Two passes over the
14
+ * string, no copy; memory is flat with `maxFindings` set.
15
+ *
16
+ * @param {string} text
17
+ * @param {InspectDetailedOptions} [options]
18
+ * @returns {ContextReport}
19
+ */
20
+ export function inspectTextInContext(text: string, options?: InspectDetailedOptions): ContextReport;
21
+ export type TrashFinding = import("./inspectText.js").TrashFinding;
22
+ export type TrashCounts = import("./inspectText.js").TrashCounts;
23
+ export type InspectDetailedOptions = import("./inspectText.js").InspectDetailedOptions;
24
+ /**
25
+ * A finding with `benign`: true when the character is part of a legitimate sequence.
26
+ */
27
+ export type ContextFinding = TrashFinding & {
28
+ benign: boolean;
29
+ };
30
+ export type ContextReport = {
31
+ /**
32
+ * - As in inspectTextDetailed, each with `benign`.
33
+ */
34
+ findings: ContextFinding[];
35
+ /**
36
+ * - As in inspectTextDetailed: every occurrence, benign or not.
37
+ */
38
+ counts: TrashCounts;
39
+ /**
40
+ * - Sum of `counts`.
41
+ */
42
+ total: number;
43
+ /**
44
+ * - True when `total` exceeds `findings.length`.
45
+ */
46
+ truncated: boolean;
47
+ /**
48
+ * - Benign occurrences per category, listed or not.
49
+ * `counts[c] - benignCounts[c]` is the number of suspicious characters in category `c`.
50
+ */
51
+ benignCounts: TrashCounts;
52
+ /**
53
+ * - Sum of `benignCounts`.
54
+ */
55
+ benignTotal: number;
56
+ };
@@ -79,6 +79,15 @@ export type HiddenTextScan = {
79
79
  * - every run per kind, listed or not
80
80
  */
81
81
  counts: HiddenRunCounts;
82
+ /**
83
+ * - code points in those runs per kind, listed or not
84
+ */
85
+ codePointCounts: HiddenRunCounts;
86
+ /**
87
+ * - code points U+E0100–U+E01EF in those runs, which
88
+ * (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
89
+ */
90
+ supplementarySelectorCount: number;
82
91
  /**
83
92
  * - sum of `counts`
84
93
  */
@@ -1,2 +1,2 @@
1
1
  /** The installed version of text-sanctifier, as in its package.json. */
2
- export const VERSION: "1.3.6";
2
+ export const VERSION: "1.3.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "text-sanctifier",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "type": "module",
5
5
  "description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
6
6
  "engines": {
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/types/hiddenText.d.ts",
32
32
  "default": "./src/hiddenText.js"
33
33
  },
34
+ "./context": {
35
+ "types": "./dist/types/context.d.ts",
36
+ "default": "./src/context.js"
37
+ },
34
38
  "./browser": {
35
39
  "types": "./dist/types/index.d.ts",
36
40
  "default": "./dist/text-sanctifier.min.js"
@@ -44,7 +48,7 @@
44
48
  "build": "node scripts/build.js",
45
49
  "build:types": "tsc -p tsconfig.types.json",
46
50
  "test:types": "tsc -p tests/types/tsconfig.json",
47
- "test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node tests/cli.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",
48
52
  "test-min": "node tests/sanctifyText.test.min.js",
49
53
  "ci": "npm run gen:check && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
50
54
  "size": "node scripts/check-size.js"
package/src/context.js ADDED
@@ -0,0 +1,177 @@
1
+ // src/context.js
2
+ //
3
+ // inspectTextInContext: inspectTextDetailed plus which findings belong to legitimate
4
+ // sequences (emoji joiners and selectors, ideographic variation sequences, Mongolian
5
+ // selectors, subdivision flags), counted exactly per category. The categories are
6
+ // unchanged; see "Proposal: context-aware findings" in DESIGN.md.
7
+ //
8
+ // Exported as `text-sanctifier/context`. It needs Unicode property escapes in regexes,
9
+ // so it lives apart from the core and the browser file and has its own size budget.
10
+
11
+ import { inspectTextDetailed } from './inspectText.js';
12
+
13
+ /** @typedef {import('./inspectText.js').TrashFinding} TrashFinding */
14
+ /** @typedef {import('./inspectText.js').TrashCounts} TrashCounts */
15
+ /** @typedef {import('./inspectText.js').InspectDetailedOptions} InspectDetailedOptions */
16
+
17
+ /**
18
+ * @typedef {TrashFinding & { benign: boolean }} ContextFinding
19
+ * A finding with `benign`: true when the character is part of a legitimate sequence.
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} ContextReport
24
+ * @property {ContextFinding[]} findings - As in inspectTextDetailed, each with `benign`.
25
+ * @property {TrashCounts} counts - As in inspectTextDetailed: every occurrence, benign or not.
26
+ * @property {number} total - Sum of `counts`.
27
+ * @property {boolean} truncated - True when `total` exceeds `findings.length`.
28
+ * @property {TrashCounts} benignCounts - Benign occurrences per category, listed or not.
29
+ * `counts[c] - benignCounts[c]` is the number of suspicious characters in category `c`.
30
+ * @property {number} benignTotal - Sum of `benignCounts`.
31
+ */
32
+
33
+ // Tested at an index of the text itself, so nothing is allocated per character.
34
+ const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
35
+ const IDEOGRAPHIC_AT = /\p{Ideographic}/yu;
36
+
37
+ // RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
38
+ const SUBDIVISION_FLAGS = ['gbeng', 'gbsct', 'gbwls'];
39
+
40
+ /**
41
+ * @param {RegExp} re - sticky
42
+ * @param {string} text
43
+ * @param {number} index
44
+ */
45
+ function matchesAt(re, text, index) {
46
+ re.lastIndex = index;
47
+ return re.test(text);
48
+ }
49
+
50
+ /**
51
+ * Index where the code point that ends just before `index` starts, or -1.
52
+ * @param {string} text
53
+ * @param {number} index
54
+ */
55
+ function previousStart(text, index) {
56
+ if (index <= 0) return -1;
57
+ const low = text.charCodeAt(index - 1);
58
+ if (low >= 0xDC00 && low <= 0xDFFF && index >= 2) {
59
+ const high = text.charCodeAt(index - 2);
60
+ if (high >= 0xD800 && high <= 0xDBFF) return index - 2;
61
+ }
62
+ return index - 1;
63
+ }
64
+
65
+ /** @param {number} c */
66
+ const isMongolianLetter = (c) =>
67
+ (c >= 0x1820 && c <= 0x1878) || (c >= 0x1880 && c <= 0x1884) || (c >= 0x1887 && c <= 0x18A8) || c === 0x18AA;
68
+
69
+ /**
70
+ * A U+200D is benign between two Extended_Pictographic code points, allowing a
71
+ * skin-tone modifier or VS16 after the first.
72
+ * @param {string} text
73
+ * @param {number} i - index of the U+200D
74
+ */
75
+ function isEmojiJoiner(text, i) {
76
+ let p = previousStart(text, i);
77
+ if (p < 0) return false;
78
+ const before = /** @type {number} */ (text.codePointAt(p));
79
+ if (before === 0xFE0F || (before >= 0x1F3FB && before <= 0x1F3FF)) p = previousStart(text, p);
80
+ return p >= 0 && matchesAt(PICTOGRAPHIC_AT, text, p) && matchesAt(PICTOGRAPHIC_AT, text, i + 1);
81
+ }
82
+
83
+ /**
84
+ * Length in UTF-16 units of the subdivision-flag tags starting at `i` (just after a
85
+ * U+1F3F4), or 0 when they are not exactly one of the RGI flags, as findHiddenRuns decides.
86
+ * @param {string} text
87
+ * @param {number} i
88
+ */
89
+ function flagTagsAt(text, i) {
90
+ // five tag letters and CANCEL TAG, each a surrogate pair
91
+ if (text.charCodeAt(i + 10) !== 0xDB40 || text.charCodeAt(i + 11) !== 0xDC7F) return 0;
92
+ // more tags after CANCEL TAG make it one longer tag run, which is not a flag
93
+ const after = text.charCodeAt(i + 13);
94
+ if (text.charCodeAt(i + 12) === 0xDB40 && after >= 0xDC00 && after <= 0xDC7F) return 0;
95
+ let letters = '';
96
+ for (let k = 0; k < 10; k += 2) {
97
+ const low = text.charCodeAt(i + k + 1);
98
+ if (text.charCodeAt(i + k) !== 0xDB40 || low < 0xDC61 || low > 0xDC7A) return 0; // tag a-z
99
+ letters += String.fromCharCode(low - 0xDC00);
100
+ }
101
+ return SUBDIVISION_FLAGS.includes(letters) ? 12 : 0;
102
+ }
103
+
104
+ /**
105
+ * Locates invisible and control characters as inspectTextDetailed does, and marks
106
+ * the ones that belong to legitimate sequences as benign:
107
+ * - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
108
+ * VS16 after the first: an emoji ZWJ sequence
109
+ * - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
110
+ * - one of U+E0100–U+E01EF directly after an ideograph: an ideographic variation sequence
111
+ * - U+180B–U+180D or U+180F directly after a Mongolian letter
112
+ * - the tags of the England, Scotland and Wales flags after U+1F3F4
113
+ * Nothing else is benign: not U+200B, U+2060, U+FEFF, bidi controls or other tags.
114
+ *
115
+ * `counts`, `total` and `truncated` are exactly those of inspectTextDetailed;
116
+ * `benignCounts` counts every benign character, listed or not. Two passes over the
117
+ * string, no copy; memory is flat with `maxFindings` set.
118
+ *
119
+ * @param {string} text
120
+ * @param {InspectDetailedOptions} [options]
121
+ * @returns {ContextReport}
122
+ */
123
+ export function inspectTextInContext(text, options = {}) {
124
+ if (typeof text !== 'string') {
125
+ throw new TypeError('inspectTextInContext expects a string input.');
126
+ }
127
+ const report = inspectTextDetailed(text, options);
128
+ const findings = /** @type {ContextFinding[]} */ (report.findings);
129
+ for (const f of findings) f.benign = false;
130
+
131
+ /** @type {TrashCounts} */
132
+ const benignCounts = /** @type {TrashCounts} */ ({});
133
+ for (const key of Object.keys(report.counts)) benignCounts[/** @type {keyof TrashCounts} */ (key)] = 0;
134
+ let benignTotal = 0;
135
+ let next = 0; // first listed finding not yet passed
136
+
137
+ /**
138
+ * @param {number} offset
139
+ * @param {keyof TrashCounts} category
140
+ */
141
+ const markBenign = (offset, category) => {
142
+ benignCounts[category]++;
143
+ benignTotal++;
144
+ while (next < findings.length && findings[next].offset < offset) next++;
145
+ if (next < findings.length && findings[next].offset === offset) findings[next].benign = true;
146
+ };
147
+
148
+ for (let i = 0, n = text.length; i < n; i++) {
149
+ const c = text.charCodeAt(i);
150
+ if (c < 0x180B) continue; // the common case: nothing below can be benign
151
+
152
+ if (c === 0x200D) {
153
+ if (isEmojiJoiner(text, i)) markBenign(i, 'zero_width');
154
+ } else if (c === 0xFE0E || c === 0xFE0F) {
155
+ const p = previousStart(text, i);
156
+ const before = p < 0 ? -1 : text.charCodeAt(p);
157
+ const keycap = (before === 0x23 || before === 0x2A || (before >= 0x30 && before <= 0x39)) &&
158
+ text.charCodeAt(i + 1) === 0x20E3;
159
+ if (keycap || (p >= 0 && matchesAt(PICTOGRAPHIC_AT, text, p))) markBenign(i, 'variation_selector');
160
+ } else if ((c >= 0x180B && c <= 0x180D) || c === 0x180F) {
161
+ if (isMongolianLetter(text.charCodeAt(i - 1))) markBenign(i, 'variation_selector');
162
+ } else if (c === 0xD83C && text.charCodeAt(i + 1) === 0xDFF4) { // U+1F3F4
163
+ const length = flagTagsAt(text, i + 2);
164
+ for (let k = 0; k < length; k += 2) markBenign(i + 2 + k, 'tag');
165
+ i += 1 + length;
166
+ } else if (c === 0xDB40) { // plane 14: U+E0000-U+E03FF
167
+ const low = text.charCodeAt(i + 1);
168
+ if (low >= 0xDD00 && low <= 0xDDEF) { // U+E0100-U+E01EF
169
+ const p = previousStart(text, i);
170
+ if (p >= 0 && matchesAt(IDEOGRAPHIC_AT, text, p)) markBenign(i, 'variation_selector');
171
+ }
172
+ if (low >= 0xDC00 && low <= 0xDFFF) i++;
173
+ }
174
+ }
175
+
176
+ return { findings, counts: report.counts, total: report.total, truncated: report.truncated, benignCounts, benignTotal };
177
+ }
package/src/hiddenText.js CHANGED
@@ -40,6 +40,9 @@ export { VERSION } from './version.generated.js';
40
40
  * @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
41
41
  * sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
42
42
  * @property {HiddenRunCounts} counts - every run per kind, listed or not
43
+ * @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
44
+ * @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
45
+ * (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
43
46
  * @property {number} total - sum of `counts`
44
47
  * @property {number} benignTotal - every benign span, listed or not
45
48
  * @property {boolean} truncated - true when runs or benign spans were left out of the lists
@@ -164,6 +167,9 @@ export function findHiddenRuns(text, options = {}) {
164
167
  const benign = [];
165
168
  /** @type {HiddenRunCounts} */
166
169
  const counts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
170
+ /** @type {HiddenRunCounts} */
171
+ const codePointCounts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
172
+ let supplementarySelectorCount = 0;
167
173
  let total = 0;
168
174
  let benignTotal = 0;
169
175
  let line = 1;
@@ -184,12 +190,14 @@ export function findHiddenRuns(text, options = {}) {
184
190
 
185
191
  const offset = i;
186
192
  let count = 0;
193
+ let supplementary = 0;
187
194
  let last = cp;
188
195
  while (i < text.length) {
189
196
  const c = /** @type {number} */ (text.codePointAt(i));
190
197
  if (kindOf(c) !== kind) break;
191
198
  last = c;
192
199
  count++;
200
+ if (c >= 0xE0100 && c <= 0xE01EF) supplementary++;
193
201
  i += c > 0xFFFF ? 2 : 1;
194
202
  }
195
203
  const end = i;
@@ -207,6 +215,8 @@ export function findHiddenRuns(text, options = {}) {
207
215
  }
208
216
 
209
217
  counts[kind]++;
218
+ codePointCounts[kind] += count;
219
+ supplementarySelectorCount += supplementary;
210
220
  total++;
211
221
  if (runs.length < maxRuns) {
212
222
  /** @type {HiddenRun} */
@@ -219,5 +229,5 @@ export function findHiddenRuns(text, options = {}) {
219
229
  runs.push(run);
220
230
  }
221
231
  }
222
- return { runs, benign, counts, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
232
+ return { runs, benign, counts, codePointCounts, supplementarySelectorCount, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
223
233
  }
@@ -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.6';
5
+ export const VERSION = '1.3.7';