text-sanctifier 1.3.3 → 1.3.5

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.
@@ -1,506 +1,563 @@
1
- // src/sanctifyText.js
2
-
3
- import { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX } from './categories.generated.js';
4
- export { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX };
5
-
6
-
7
- /**
8
- * @typedef {Object} SanctifyOptions
9
- * @property {boolean} [purgeInvisibleChars]
10
- * @property {boolean} [purgeEmojis]
11
- * @property {boolean} [nukeControls]
12
- * @property {boolean} [keyboardOnlyFilter]
13
- * @property {boolean} [normalizeNewlines]
14
- * @property {boolean} [trimSpacesAroundNewlines]
15
- * @property {boolean} [collapseNewLines]
16
- * @property {boolean} [preserveParagraphs]
17
- * @property {boolean} [collapseSpaces]
18
- * @property {boolean} [finalTrim]
19
- * @property {number} [maxLength]
20
- * @property {'throw'|'truncate'|'noop'} [onMaxLength]
21
- */
22
-
23
-
24
- /**
25
- * Summons a customized sanctifier function with pre-bound booleans.
26
- *
27
- * Accepts full flag names and returns a text-cleaning function.
28
- *
29
- * @param {Object} [defaultOptions={}]
30
- * @param {boolean} [defaultOptions.purgeInvisibleChars]
31
- * @param {boolean} [defaultOptions.purgeEmojis]
32
- * @param {boolean} [defaultOptions.nukeControls]
33
- * @param {boolean} [defaultOptions.keyboardOnlyFilter]
34
- * @param {boolean} [defaultOptions.normalizeNewlines]
35
- * @param {boolean} [defaultOptions.trimSpacesAroundNewlines]
36
- * @param {boolean} [defaultOptions.collapseNewLines]
37
- * @param {boolean} [defaultOptions.preserveParagraphs]
38
- * @param {boolean} [defaultOptions.collapseSpaces]
39
- * @param {boolean} [defaultOptions.finalTrim]
40
- * @param {number} [defaultOptions.maxLength=Infinity] - Hard input length cap (UTF-16 code units).
41
- * @param {'throw' | 'truncate' | 'noop'} [defaultOptions.onMaxLength='throw'] - Behavior when input exceeds maxLength.
42
- * @returns {(text: string) => string}
43
- */
44
- export function summonSanctifier(defaultOptions = {}) {
45
- const purgeInvisibleChars = !!defaultOptions.purgeInvisibleChars;
46
- const purgeEmojis = !!defaultOptions.purgeEmojis;
47
- const nukeControls = !!defaultOptions.nukeControls;
48
- const keyboardOnlyFilter = !!defaultOptions.keyboardOnlyFilter;
49
- const normalizeNewlines = !!defaultOptions.normalizeNewlines;
50
- const trimSpacesAroundNewlines = !!defaultOptions.trimSpacesAroundNewlines;
51
- const collapseNewLines = !!defaultOptions.collapseNewLines;
52
- const preserveParagraphs = !!defaultOptions.preserveParagraphs;
53
- const collapseSpaces = !!defaultOptions.collapseSpaces;
54
- const finalTrim = !!defaultOptions.finalTrim;
55
-
56
- const maxLength =
57
- Number.isFinite(defaultOptions.maxLength) && defaultOptions.maxLength >= 0
58
- ? defaultOptions.maxLength
59
- : Infinity;
60
-
61
- const onMaxLength =
62
- defaultOptions.onMaxLength === 'truncate' ||
63
- defaultOptions.onMaxLength === 'noop' ||
64
- defaultOptions.onMaxLength === 'throw'
65
- ? defaultOptions.onMaxLength
66
- : 'throw';
67
-
68
- return (text) =>
69
- sanctifyText(
70
- text,
71
- purgeInvisibleChars,
72
- purgeEmojis,
73
- nukeControls,
74
- keyboardOnlyFilter,
75
- normalizeNewlines,
76
- trimSpacesAroundNewlines,
77
- collapseNewLines,
78
- preserveParagraphs,
79
- collapseSpaces,
80
- finalTrim,
81
- maxLength,
82
- onMaxLength
83
- );
84
- }
85
-
86
- // --- Added Presets ---
87
-
88
- /**
89
- * Strict sanitizer:
90
- * - Purge emojis
91
- * - Collapse all newlines
92
- * - Collapse spaces
93
- * - Nuke control characters
94
- */
95
- /** @param {string} text @returns {string} */
96
- summonSanctifier.strict = text => sanctifyText(
97
- text,
98
- true, // purgeInvisibleChars
99
- true, // purgeEmojis
100
- true, // nukeControls
101
- false, // keyboardOnlyFilter
102
- true, // normalizeNewlines
103
- true, // trimSpacesAroundNewlines
104
- true, // collapseNewLines
105
- false, // preserveParagraphs
106
- true, // collapseSpaces
107
- true // finalTrim
108
- );
109
-
110
-
111
- /**
112
- * Loose sanitizer:
113
- * - Collapse spaces
114
- * - Preserve paragraphs
115
- * - Normalize newlines
116
- */
117
- /** @param {string} text @returns {string} */
118
- summonSanctifier.loose = text => sanctifyText(
119
- text,
120
- false, // purgeInvisibleChars
121
- false, // purgeEmojis
122
- false, // nukeControls
123
- false, // keyboardOnlyFilter
124
- true, // normalizeNewlines
125
- true, // trimSpacesAroundNewlines
126
- true, // collapseNewLines
127
- true, // preserveParagraphs
128
- true, // collapseSpaces
129
- true // finalTrim
130
- );
131
-
132
-
133
- /**
134
- * Keyboard-only (with emojis):
135
- * - Keeps emojis and printable ASCII
136
- * - Strips non-standard characters
137
- * - Normalizes typographic trash
138
- */
139
- /** @param {string} text @returns {string} */
140
- summonSanctifier.keyboardOnlyEmoji = text => sanctifyText(
141
- text,
142
- false, // purgeInvisibleChars
143
- false, // purgeEmojis
144
- false, // nukeControls
145
- true, // keyboardOnlyFilter
146
- true, // normalizeNewlines
147
- true, // trimSpacesAroundNewlines
148
- false, // collapseNewLines
149
- false, // preserveParagraphs
150
- false, // collapseSpaces
151
- true // finalTrim
152
- );
153
-
154
-
155
- /**
156
- * Keyboard-only (strict):
157
- * - Removes emojis
158
- * - Collapses all whitespace
159
- * - Restricts to printable ASCII only
160
- */
161
- /** @param {string} text @returns {string} */
162
- summonSanctifier.keyboardOnly = text => sanctifyText(
163
- text,
164
- true, // purgeInvisibleChars
165
- true, // purgeEmojis
166
- true, // nukeControls
167
- true, // keyboardOnlyFilter
168
- true, // normalizeNewlines
169
- true, // trimSpacesAroundNewlines
170
- true, // collapseNewLines
171
- false, // preserveParagraphs
172
- true, // collapseSpaces
173
- true // finalTrim
174
- );
175
-
176
-
177
- /**
178
- * Text Sanctifier
179
- *
180
- * Brutal text normalizer and invisible trash scrubber,
181
- * configurable to kill whatever ghosts you want dead.
182
- *
183
- * ⚠️ Note: This is plain-text normalization/filtering — not an HTML/XSS sanitizer.
184
- *
185
- * @param {string} text
186
- * @param {boolean} [purgeInvisibleChars=false] - Remove ZWSP, NBSP, bidi, tags, variation selectors, etc.
187
- * @param {boolean} [purgeEmojis=false] - Remove emoji characters entirely.
188
- * @param {boolean} [nukeControls=false] - Remove non-whitespace control characters.
189
- * @param {boolean} [keyboardOnlyFilter=false] - Keep printable ASCII + full emoji sequences only (drops other Unicode).
190
- * @param {boolean} [normalizeNewlines=false] - Convert all newlines to `\n`.
191
- * @param {boolean} [trimSpacesAroundNewlines=false] - Remove spaces/tabs around newlines.
192
- * @param {boolean} [collapseNewLines=false] - Collapse `\n` runs (optionally preserve paragraphs).
193
- * @param {boolean} [preserveParagraphs=false] - Preserve paragraph breaks when collapsing newlines.
194
- * @param {boolean} [collapseSpaces=false] - Collapse multiple spaces into one.
195
- * @param {boolean} [finalTrim=false] - `.trim()` the final output (head/tail).
196
- * @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
197
- * @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
198
- * - `'throw'`: throw a `RangeError`
199
- * - `'truncate'`: slice to `maxLength` before processing
200
- * - `'noop'`: return the original input unchanged (skips processing)
201
- * @returns {string}
202
- * @throws {TypeError} If `text` is not a string.
203
- * @throws {RangeError} If input exceeds `maxLength` and `onMaxLength` is `'throw'`.
204
- */
205
- export function sanctifyText(
206
- text,
207
- purgeInvisibleChars = false,
208
- purgeEmojis = false,
209
- nukeControls = false,
210
- keyboardOnlyFilter = false,
211
- normalizeNewlines = false,
212
- trimSpacesAroundNewlines = false,
213
- collapseNewLines = false,
214
- preserveParagraphs = false,
215
- collapseSpaces = false,
216
- finalTrim = false,
217
- maxLength = Infinity,
218
- onMaxLength = 'throw'
219
- ) {
220
- if (typeof text !== 'string') {
221
- throw new TypeError('sanctifyText expects a string input.');
222
- }
223
-
224
- if (onMaxLength !== 'truncate' && onMaxLength !== 'noop' && onMaxLength !== 'throw') {
225
- onMaxLength = 'throw';
226
- }
227
-
228
- if (text.length > maxLength) {
229
- switch (onMaxLength) {
230
- case 'truncate':
231
- text = text.slice(0, maxLength);
232
- break;
233
- case 'noop':
234
- return text;
235
- case 'throw':
236
- default:
237
- throw new RangeError(
238
- `sanctifyText input length ${text.length} exceeds maxLength ${maxLength}.`
239
- );
240
- }
241
- }
242
-
243
- let cleaned = text;
244
-
245
- // Purge invisible Unicode trash (zero-width, non-breaking, bidi junk, etc.)
246
- if (purgeInvisibleChars) cleaned = purgeInvisibleTrash(cleaned);
247
-
248
- // Remove emojis
249
- if (purgeEmojis) cleaned = purgeEmojiCharacters(cleaned);
250
-
251
- // Nuke control characters (excluding whitespace)
252
- if (nukeControls) cleaned = purgeControlCharacters(cleaned);
253
-
254
- // Keep only ASCII/emojis
255
- if (keyboardOnlyFilter) cleaned = purgeNonKeyboardChars(cleaned, purgeEmojis);
256
-
257
- // Normalize line endings to Unix style (\n)
258
- if (normalizeNewlines) cleaned = normalizeNewlineChars(cleaned);
259
-
260
- // Remove spaces/tabs around newlines
261
- if (trimSpacesAroundNewlines) cleaned = trimSpacesAroundNewlineChars(cleaned);
262
-
263
- // Collapse excessive newlines, Optionally preserve Paragraphs
264
- if (collapseNewLines) cleaned = collapseMultipleNewLines(cleaned, preserveParagraphs);
265
-
266
- // Collapse multiple spaces into a single space
267
- if (collapseSpaces) cleaned = collapseExtraSpaces(cleaned);
268
-
269
- // Final trim, return Sanctified Text
270
- return finalTrim ? cleaned.trim() : cleaned;
271
- }
272
-
273
-
274
- // --- Micro helpers ---
275
-
276
-
277
- /**
278
- * Removes the invisible Unicode characters in INVISIBLE_TRASH_REGEX: non-breaking and
279
- * typographic spaces, zero-width characters, bidi controls, invisible format characters,
280
- * Hangul fillers, variation selectors (including VS16 in emoji), the BOM and tag characters.
281
- * The code points come from the table in scripts/categories.js.
282
- *
283
- * @param {string} text
284
- * @returns {string}
285
- */
286
- function purgeInvisibleTrash(text) {
287
- return text.replace(INVISIBLE_TRASH_REGEX, '');
288
- }
289
-
290
-
291
- /**
292
- * Extracts and preserves complete emoji sequences from a string.
293
- *
294
- * Uses the global `EMOJI_REGEX` to match full emoji grapheme sequences,
295
- * including:
296
- * - Extended pictographic characters
297
- * - Zero-width joiner (ZWJ) sequences (e.g. 👨‍👩‍👧‍👦)
298
- * - Variation Selector-16 (VS16) emoji presentation forms (e.g. ✌️, ‼️)
299
- *
300
- * Any non-emoji characters are ignored.
301
- *
302
- * Important:
303
- * `EMOJI_REGEX` is global (`/g`), so `lastIndex` is reset before matching
304
- * to ensure deterministic behavior across repeated calls.
305
- *
306
- * @param {string} text - Input string potentially containing emoji sequences.
307
- * @returns {string} A string containing only the matched emoji sequences,
308
- * concatenated in original order.
309
- */
310
- function extractEmojiSequences(text) {
311
- EMOJI_REGEX.lastIndex = 0;
312
- if (!EMOJI_REGEX.test(text)) return ''; // quick reject
313
- EMOJI_REGEX.lastIndex = 0;
314
-
315
- let out = '';
316
- for (const match of text.matchAll(EMOJI_REGEX)) out += match[0];
317
- return out;
318
- }
319
-
320
- /**
321
- * Matches any character that is NOT:
322
- * - Printable ASCII (U+0020–U+007E)
323
- * - Newline characters: \n (LF), \r (CR)
324
- * This allows for \n, \r, and \r\n line endings.
325
- */
326
- export const ASCII_KEYBOARD_SAFE_REGEX = /[^\x20-\x7E\r\n]+/gu;
327
-
328
- /**
329
- * Removes all non-keyboard characters, preserving:
330
- * - Printable ASCII (0x20–0x7E)
331
- * - Emojis (Unicode Emoji_Presentation + FE0F variants)
332
- */
333
- function purgeNonKeyboardChars(text, purgeEmojis = false) {
334
- const normalized = normalizeTypographicJank(text);
335
-
336
- // If emojis are being purged, keyboard-only becomes "ASCII + CR/LF only"
337
- if (purgeEmojis) {
338
- return normalized.replace(ASCII_KEYBOARD_SAFE_REGEX, '');
339
- }
340
-
341
- // Replace each non-ASCII run with ONLY the emoji sequences inside it.
342
- // This preserves ZWJ sequences and VS16 variants (e.g. 👨‍👩‍👧‍👦, ✌️, ‼️),
343
- // while dropping non-emoji non-ASCII (e.g. 𝌆).
344
- return normalized.replace(ASCII_KEYBOARD_SAFE_REGEX, (run) => extractEmojiSequences(run));
345
- }
346
-
347
-
348
- // Smart apostrophes (single quote variations)
349
- const SMART_SINGLE_QUOTES_REGEX = /[\u2018\u2019\u201A\u201B\u2032\u2035]/g;
350
-
351
- // Smart double quotes (including primes and guillemets)
352
- const SMART_DOUBLE_QUOTES_REGEX = /[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g;
353
-
354
- // Dash variants: en dash, em dash, horizontal bar, figure dash, minus
355
- const UNICODE_DASHES_REGEX = /[\u2012\u2013\u2014\u2015\u2212]/g;
356
-
357
- // Ellipsis character
358
- const ELLIPSIS_REGEX = /\u2026/g;
359
-
360
- // Bullets and middle dot
361
- const BULLETS_REGEX = /[\u2022\u00B7]/g;
362
-
363
- // Full-width ASCII punctuation: U+FF01 - U+FF5E
364
- const FULLWIDTH_PUNCTUATION_REGEX = /[\uFF01-\uFF5E]/g;
365
-
366
- /**
367
- * Normalizes typographic Unicode punctuation into ASCII equivalents.
368
- * @param {string} text
369
- * @returns {string}
370
- */
371
- export function normalizeTypographicJank(text) {
372
- return text
373
- .replace(SMART_SINGLE_QUOTES_REGEX, "'")
374
- .replace(SMART_DOUBLE_QUOTES_REGEX, '"')
375
- .replace(UNICODE_DASHES_REGEX, '-')
376
- .replace(ELLIPSIS_REGEX, '...')
377
- .replace(BULLETS_REGEX, '*')
378
- .replace(FULLWIDTH_PUNCTUATION_REGEX, m =>
379
- String.fromCharCode(m.charCodeAt(0) - 0xFEE0)
380
- );
381
- }
382
-
383
-
384
- /** @type {RegExp} */
385
- export let EMOJI_REGEX;
386
-
387
- /**
388
- * Try Unicode property escape regex (preferred).
389
- * Fallback to basic emoji ranges if unsupported.
390
- */
391
- try {
392
- EMOJI_REGEX = new RegExp(
393
- '(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)',
394
- 'gu'
395
- );
396
- } catch {
397
- // Fallback: wide-range emoji component match (flags, tones, symbols)
398
- // * Covers:
399
- // * - Emoji base chars (1F300–1FAFF)
400
- // * - Dingbats (2700–27BF) like ❌, ✅, ☑️
401
- // * - Skin tones (1F3FB–1F3FF)
402
- // * - ZWJ (200D), variation selectors (FE0F)
403
- // * - Regional indicators (1F1E6–1F1FF)
404
- EMOJI_REGEX = /[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu;
405
- }
406
-
407
- /**
408
- * Removes all emoji characters using Unicode property escapes.
409
- * Supports modern environments (Unicode v13+) with fallback.
410
- *
411
- * @param {string} text
412
- * @returns {string}
413
- */
414
- function purgeEmojiCharacters(text) {
415
- return text.replace(EMOJI_REGEX, '');
416
- }
417
-
418
-
419
- /**
420
- * Normalizes all line endings to a consistent format.
421
- *
422
- * Converts:
423
- * - Windows ("\r\n"), Old Mac ("\r"), Unix ("\n")
424
- * Into the specified newline format (default: Unix "\n").
425
- *
426
- * @param {string} text - Input string to normalize.
427
- * @param {string} [normalized='\n'] - Target newline style (e.g. '\n', '\r\n').
428
- * @returns {string}
429
- */
430
- const NORMALIZE_NEWLINES_REGEX = /\r\n|\r|\n/g;
431
- function normalizeNewlineChars(text, normalized = '\n') {
432
- return text.replace(NORMALIZE_NEWLINES_REGEX, normalized);
433
- }
434
-
435
-
436
- /**
437
- * Trims spaces and tabs immediately before and after newlines.
438
- *
439
- * Example: "hello \n world" → "hello\nworld"
440
- *
441
- * Preserves the newline itself, just removes whitespace around it.
442
- *
443
- * @param {string} text
444
- * @returns {string}
445
- */
446
- const TRIM_SPACES_AROUND_NEWLINES_REGEX = /[ \t]*(\n+)[ \t]*/g;
447
- function trimSpacesAroundNewlineChars(text) {
448
- return text.replace(TRIM_SPACES_AROUND_NEWLINES_REGEX, '$1');
449
- }
450
-
451
-
452
- /**
453
- * Collapses excessive newlines based on paragraph preservation settings.
454
- *
455
- * - If preserveParagraphs = true:
456
- * Collapses 3 or more consecutive newlines → exactly two ("\n\n")
457
- * - If preserveParagraphs = false:
458
- * Collapses 2 or more consecutive newlines → exactly one ("\n")
459
- *
460
- * Example (preserveParagraphs = true):
461
- * "Line1\n\n\n\nLine2" → "Line1\n\nLine2"
462
- *
463
- * @param {string} text
464
- * @param {boolean} preserveParagraphs - Whether to preserve paragraph breaks (double newlines).
465
- * @returns {string}
466
- */
467
- const MULTIPLE_NEWLINES_REGEX = /\n{2,}/g;
468
- const TRIPLE_NEWLINES_REGEX = /\n{3,}/g;
469
-
470
- function collapseMultipleNewLines(text, preserveParagraphs) {
471
- return preserveParagraphs
472
- ? text.replace(TRIPLE_NEWLINES_REGEX, '\n\n')
473
- : text.replace(MULTIPLE_NEWLINES_REGEX, '\n');
474
- }
475
-
476
-
477
- /**
478
- * Collapses multiple consecutive spaces into a single space.
479
- * (Does not touch tabs or newlines.)
480
- *
481
- * Example: "hello world" → "hello world"
482
- *
483
- * @param {string} text
484
- * @returns {string}
485
- */
486
- const MULTIPLE_SPACES_REGEX = / {2,}/g;
487
- function collapseExtraSpaces(text) {
488
- return text.replace(MULTIPLE_SPACES_REGEX, ' ');
489
- }
490
-
491
-
492
- /**
493
- * Nukes hidden control characters that are invisible and often dangerous.
494
- * (Excludes necessary whitespace like \n and \t.)
495
- *
496
- * Control characters nuked (the code points come from the table in scripts/categories.js):
497
- * - NUL and the C0 controls except tab, LF and CR; DEL (U+007F)
498
- * - C1 controls (U+0080-U+009F)
499
- * - Bidi controls
500
- *
501
- * @param {string} text
502
- * @returns {string}
503
- */
504
- function purgeControlCharacters(text) {
505
- return text.replace(CONTROL_CHARS_REGEX, '');
506
- }
1
+ // src/sanctifyText.js
2
+
3
+ import { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX } from './categories.generated.js';
4
+ export { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX };
5
+
6
+
7
+ /**
8
+ * @typedef {Object} SanctifyOptions
9
+ * @property {boolean} [purgeInvisibleChars]
10
+ * @property {boolean} [purgeEmojis]
11
+ * @property {boolean} [nukeControls]
12
+ * @property {boolean} [keyboardOnlyFilter]
13
+ * @property {boolean} [normalizeNewlines]
14
+ * @property {boolean} [trimSpacesAroundNewlines]
15
+ * @property {boolean} [collapseNewLines]
16
+ * @property {boolean} [preserveParagraphs]
17
+ * @property {boolean} [collapseSpaces]
18
+ * @property {boolean} [finalTrim]
19
+ * @property {number} [maxLength]
20
+ * @property {'throw'|'truncate'|'noop'} [onMaxLength]
21
+ * @property {boolean} [normalizeSpaces] - Replace look-alike spaces with an ordinary space (runs first).
22
+ * @property {boolean} [purgeZeroWidthSpaces] - Remove U+200B, U+2060 and U+FEFF only, keeping joiners and selectors.
23
+ */
24
+
25
+
26
+ /**
27
+ * Summons a customized sanctifier function with pre-bound booleans.
28
+ *
29
+ * Accepts full flag names and returns a text-cleaning function.
30
+ *
31
+ * @param {Object} [defaultOptions={}]
32
+ * @param {boolean} [defaultOptions.purgeInvisibleChars]
33
+ * @param {boolean} [defaultOptions.purgeEmojis]
34
+ * @param {boolean} [defaultOptions.nukeControls]
35
+ * @param {boolean} [defaultOptions.keyboardOnlyFilter]
36
+ * @param {boolean} [defaultOptions.normalizeNewlines]
37
+ * @param {boolean} [defaultOptions.trimSpacesAroundNewlines]
38
+ * @param {boolean} [defaultOptions.collapseNewLines]
39
+ * @param {boolean} [defaultOptions.preserveParagraphs]
40
+ * @param {boolean} [defaultOptions.collapseSpaces]
41
+ * @param {boolean} [defaultOptions.finalTrim]
42
+ * @param {number} [defaultOptions.maxLength=Infinity] - Hard input length cap (UTF-16 code units).
43
+ * @param {'throw' | 'truncate' | 'noop'} [defaultOptions.onMaxLength='throw'] - Behavior when input exceeds maxLength.
44
+ * @param {boolean} [defaultOptions.normalizeSpaces]
45
+ * @param {boolean} [defaultOptions.purgeZeroWidthSpaces]
46
+ * @returns {(text: string) => string}
47
+ */
48
+ export function summonSanctifier(defaultOptions = {}) {
49
+ const normalizeSpaces = !!defaultOptions.normalizeSpaces;
50
+ const purgeZeroWidthSpaces = !!defaultOptions.purgeZeroWidthSpaces;
51
+ const purgeInvisibleChars = !!defaultOptions.purgeInvisibleChars;
52
+ const purgeEmojis = !!defaultOptions.purgeEmojis;
53
+ const nukeControls = !!defaultOptions.nukeControls;
54
+ const keyboardOnlyFilter = !!defaultOptions.keyboardOnlyFilter;
55
+ const normalizeNewlines = !!defaultOptions.normalizeNewlines;
56
+ const trimSpacesAroundNewlines = !!defaultOptions.trimSpacesAroundNewlines;
57
+ const collapseNewLines = !!defaultOptions.collapseNewLines;
58
+ const preserveParagraphs = !!defaultOptions.preserveParagraphs;
59
+ const collapseSpaces = !!defaultOptions.collapseSpaces;
60
+ const finalTrim = !!defaultOptions.finalTrim;
61
+
62
+ const maxLength =
63
+ Number.isFinite(defaultOptions.maxLength) && defaultOptions.maxLength >= 0
64
+ ? defaultOptions.maxLength
65
+ : Infinity;
66
+
67
+ const onMaxLength =
68
+ defaultOptions.onMaxLength === 'truncate' ||
69
+ defaultOptions.onMaxLength === 'noop' ||
70
+ defaultOptions.onMaxLength === 'throw'
71
+ ? defaultOptions.onMaxLength
72
+ : 'throw';
73
+
74
+ return (text) =>
75
+ sanctifyText(
76
+ text,
77
+ purgeInvisibleChars,
78
+ purgeEmojis,
79
+ nukeControls,
80
+ keyboardOnlyFilter,
81
+ normalizeNewlines,
82
+ trimSpacesAroundNewlines,
83
+ collapseNewLines,
84
+ preserveParagraphs,
85
+ collapseSpaces,
86
+ finalTrim,
87
+ maxLength,
88
+ onMaxLength,
89
+ normalizeSpaces,
90
+ purgeZeroWidthSpaces
91
+ );
92
+ }
93
+
94
+ // --- Added Presets ---
95
+
96
+ /**
97
+ * Strict sanitizer:
98
+ * - Purge emojis
99
+ * - Collapse all newlines
100
+ * - Collapse spaces
101
+ * - Nuke control characters
102
+ */
103
+ /** @param {string} text @returns {string} */
104
+ summonSanctifier.strict = text => sanctifyText(
105
+ text,
106
+ true, // purgeInvisibleChars
107
+ true, // purgeEmojis
108
+ true, // nukeControls
109
+ false, // keyboardOnlyFilter
110
+ true, // normalizeNewlines
111
+ true, // trimSpacesAroundNewlines
112
+ true, // collapseNewLines
113
+ false, // preserveParagraphs
114
+ true, // collapseSpaces
115
+ true // finalTrim
116
+ );
117
+
118
+
119
+ /**
120
+ * Loose sanitizer:
121
+ * - Collapse spaces
122
+ * - Preserve paragraphs
123
+ * - Normalize newlines
124
+ */
125
+ /** @param {string} text @returns {string} */
126
+ summonSanctifier.loose = text => sanctifyText(
127
+ text,
128
+ false, // purgeInvisibleChars
129
+ false, // purgeEmojis
130
+ false, // nukeControls
131
+ false, // keyboardOnlyFilter
132
+ true, // normalizeNewlines
133
+ true, // trimSpacesAroundNewlines
134
+ true, // collapseNewLines
135
+ true, // preserveParagraphs
136
+ true, // collapseSpaces
137
+ true // finalTrim
138
+ );
139
+
140
+
141
+ /**
142
+ * Keyboard-only (with emojis):
143
+ * - Keeps emojis and printable ASCII
144
+ * - Strips non-standard characters
145
+ * - Normalizes typographic trash
146
+ */
147
+ /** @param {string} text @returns {string} */
148
+ summonSanctifier.keyboardOnlyEmoji = text => sanctifyText(
149
+ text,
150
+ false, // purgeInvisibleChars
151
+ false, // purgeEmojis
152
+ false, // nukeControls
153
+ true, // keyboardOnlyFilter
154
+ true, // normalizeNewlines
155
+ true, // trimSpacesAroundNewlines
156
+ false, // collapseNewLines
157
+ false, // preserveParagraphs
158
+ false, // collapseSpaces
159
+ true // finalTrim
160
+ );
161
+
162
+
163
+ /**
164
+ * Keyboard-only (strict):
165
+ * - Removes emojis
166
+ * - Collapses all whitespace
167
+ * - Restricts to printable ASCII only
168
+ */
169
+ /** @param {string} text @returns {string} */
170
+ summonSanctifier.keyboardOnly = text => sanctifyText(
171
+ text,
172
+ true, // purgeInvisibleChars
173
+ true, // purgeEmojis
174
+ true, // nukeControls
175
+ true, // keyboardOnlyFilter
176
+ true, // normalizeNewlines
177
+ true, // trimSpacesAroundNewlines
178
+ true, // collapseNewLines
179
+ false, // preserveParagraphs
180
+ true, // collapseSpaces
181
+ true // finalTrim
182
+ );
183
+
184
+
185
+ /**
186
+ * Pasted text and model output:
187
+ * - Look-alike spaces (NBSP, narrow NBSP, typographic spaces) become ordinary spaces,
188
+ * so words never run together
189
+ * - Zero-width spaces, word joiners and BOMs are removed
190
+ * - Newlines are normalized to `\n`
191
+ * Keeps emoji joiners, variation selectors, bidi marks, indentation and line breaks.
192
+ */
193
+ /** @param {string} text @returns {string} */
194
+ summonSanctifier.pasted = text => sanctifyText(
195
+ text,
196
+ false, // purgeInvisibleChars
197
+ false, // purgeEmojis
198
+ false, // nukeControls
199
+ false, // keyboardOnlyFilter
200
+ true, // normalizeNewlines
201
+ false, // trimSpacesAroundNewlines
202
+ false, // collapseNewLines
203
+ false, // preserveParagraphs
204
+ false, // collapseSpaces
205
+ false, // finalTrim
206
+ Infinity, // maxLength
207
+ 'throw', // onMaxLength
208
+ true, // normalizeSpaces
209
+ true // purgeZeroWidthSpaces
210
+ );
211
+
212
+
213
+ /**
214
+ * Text Sanctifier
215
+ *
216
+ * Brutal text normalizer and invisible trash scrubber,
217
+ * configurable to kill whatever ghosts you want dead.
218
+ *
219
+ * ⚠️ Note: This is plain-text normalization/filtering — not an HTML/XSS sanitizer.
220
+ *
221
+ * @param {string} text
222
+ * @param {boolean} [purgeInvisibleChars=false] - Remove ZWSP, NBSP, bidi, tags, variation selectors, etc.
223
+ * @param {boolean} [purgeEmojis=false] - Remove emoji characters entirely.
224
+ * @param {boolean} [nukeControls=false] - Remove non-whitespace control characters.
225
+ * @param {boolean} [keyboardOnlyFilter=false] - Keep printable ASCII + full emoji sequences only (drops other Unicode).
226
+ * @param {boolean} [normalizeNewlines=false] - Convert all newlines to `\n`.
227
+ * @param {boolean} [trimSpacesAroundNewlines=false] - Remove spaces/tabs around newlines.
228
+ * @param {boolean} [collapseNewLines=false] - Collapse `\n` runs (optionally preserve paragraphs).
229
+ * @param {boolean} [preserveParagraphs=false] - Preserve paragraph breaks when collapsing newlines.
230
+ * @param {boolean} [collapseSpaces=false] - Collapse multiple spaces into one.
231
+ * @param {boolean} [finalTrim=false] - `.trim()` the final output (head/tail).
232
+ * @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
233
+ * @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
234
+ * - `'throw'`: throw a `RangeError`
235
+ * - `'truncate'`: slice to `maxLength` before processing
236
+ * - `'noop'`: return the original input unchanged (skips processing)
237
+ * @param {boolean} [normalizeSpaces=false] - Replace look-alike spaces with an ordinary space. Runs first,
238
+ * so `purgeInvisibleChars` no longer joins the words around them.
239
+ * @param {boolean} [purgeZeroWidthSpaces=false] - Remove zero-width spaces, word joiners and BOMs only.
240
+ * @returns {string}
241
+ * @throws {TypeError} If `text` is not a string.
242
+ * @throws {RangeError} If input exceeds `maxLength` and `onMaxLength` is `'throw'`.
243
+ */
244
+ export function sanctifyText(
245
+ text,
246
+ purgeInvisibleChars = false,
247
+ purgeEmojis = false,
248
+ nukeControls = false,
249
+ keyboardOnlyFilter = false,
250
+ normalizeNewlines = false,
251
+ trimSpacesAroundNewlines = false,
252
+ collapseNewLines = false,
253
+ preserveParagraphs = false,
254
+ collapseSpaces = false,
255
+ finalTrim = false,
256
+ maxLength = Infinity,
257
+ onMaxLength = 'throw',
258
+ normalizeSpaces = false,
259
+ purgeZeroWidthSpaces = false
260
+ ) {
261
+ if (typeof text !== 'string') {
262
+ throw new TypeError('sanctifyText expects a string input.');
263
+ }
264
+
265
+ if (onMaxLength !== 'truncate' && onMaxLength !== 'noop' && onMaxLength !== 'throw') {
266
+ onMaxLength = 'throw';
267
+ }
268
+
269
+ if (text.length > maxLength) {
270
+ switch (onMaxLength) {
271
+ case 'truncate':
272
+ text = text.slice(0, maxLength);
273
+ break;
274
+ case 'noop':
275
+ return text;
276
+ case 'throw':
277
+ default:
278
+ throw new RangeError(
279
+ `sanctifyText input length ${text.length} exceeds maxLength ${maxLength}.`
280
+ );
281
+ }
282
+ }
283
+
284
+ let cleaned = text;
285
+
286
+ // Look-alike spaces become ordinary spaces, before anything can delete them
287
+ if (normalizeSpaces) cleaned = cleaned.replace(SPACE_LOOKALIKES_REGEX, ' ');
288
+
289
+ // Zero-width spaces, word joiners and BOMs (not joiners or selectors)
290
+ if (purgeZeroWidthSpaces) cleaned = cleaned.replace(ZERO_WIDTH_SPACES_REGEX, '');
291
+
292
+ // Purge invisible Unicode trash (zero-width, non-breaking, bidi junk, etc.)
293
+ if (purgeInvisibleChars) cleaned = purgeInvisibleTrash(cleaned);
294
+
295
+ // Remove emojis
296
+ if (purgeEmojis) cleaned = purgeEmojiCharacters(cleaned);
297
+
298
+ // Nuke control characters (excluding whitespace)
299
+ if (nukeControls) cleaned = purgeControlCharacters(cleaned);
300
+
301
+ // Keep only ASCII/emojis
302
+ if (keyboardOnlyFilter) cleaned = purgeNonKeyboardChars(cleaned, purgeEmojis);
303
+
304
+ // Normalize line endings to Unix style (\n)
305
+ if (normalizeNewlines) cleaned = normalizeNewlineChars(cleaned);
306
+
307
+ // Remove spaces/tabs around newlines
308
+ if (trimSpacesAroundNewlines) cleaned = trimSpacesAroundNewlineChars(cleaned);
309
+
310
+ // Collapse excessive newlines, Optionally preserve Paragraphs
311
+ if (collapseNewLines) cleaned = collapseMultipleNewLines(cleaned, preserveParagraphs);
312
+
313
+ // Collapse multiple spaces into a single space
314
+ if (collapseSpaces) cleaned = collapseExtraSpaces(cleaned);
315
+
316
+ // Final trim, return Sanctified Text
317
+ return finalTrim ? cleaned.trim() : cleaned;
318
+ }
319
+
320
+
321
+ // --- Micro helpers ---
322
+
323
+
324
+ /**
325
+ * Look-alike spaces: the `nonbreaking_space` category except U+3000, the ordinary
326
+ * space of CJK text. A test checks this against the category table.
327
+ */
328
+ export const SPACE_LOOKALIKES_REGEX = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
329
+
330
+ /** Zero-width space, word joiner and BOM: invisible and never needed in plain text. */
331
+ export const ZERO_WIDTH_SPACES_REGEX = /[\u200B\u2060\uFEFF]/g;
332
+
333
+
334
+ /**
335
+ * Removes the invisible Unicode characters in INVISIBLE_TRASH_REGEX: non-breaking and
336
+ * typographic spaces, zero-width characters, bidi controls, invisible format characters,
337
+ * Hangul fillers, variation selectors (including VS16 in emoji), the BOM and tag characters.
338
+ * The code points come from the table in scripts/categories.js.
339
+ *
340
+ * @param {string} text
341
+ * @returns {string}
342
+ */
343
+ function purgeInvisibleTrash(text) {
344
+ return text.replace(INVISIBLE_TRASH_REGEX, '');
345
+ }
346
+
347
+
348
+ /**
349
+ * Extracts and preserves complete emoji sequences from a string.
350
+ *
351
+ * Uses the global `EMOJI_REGEX` to match full emoji grapheme sequences,
352
+ * including:
353
+ * - Extended pictographic characters
354
+ * - Zero-width joiner (ZWJ) sequences (e.g. 👨‍👩‍👧‍👦)
355
+ * - Variation Selector-16 (VS16) emoji presentation forms (e.g. ✌️, ‼️)
356
+ *
357
+ * Any non-emoji characters are ignored.
358
+ *
359
+ * Important:
360
+ * `EMOJI_REGEX` is global (`/g`), so `lastIndex` is reset before matching
361
+ * to ensure deterministic behavior across repeated calls.
362
+ *
363
+ * @param {string} text - Input string potentially containing emoji sequences.
364
+ * @returns {string} A string containing only the matched emoji sequences,
365
+ * concatenated in original order.
366
+ */
367
+ function extractEmojiSequences(text) {
368
+ EMOJI_REGEX.lastIndex = 0;
369
+ if (!EMOJI_REGEX.test(text)) return ''; // quick reject
370
+ EMOJI_REGEX.lastIndex = 0;
371
+
372
+ let out = '';
373
+ for (const match of text.matchAll(EMOJI_REGEX)) out += match[0];
374
+ return out;
375
+ }
376
+
377
+ /**
378
+ * Matches any character that is NOT:
379
+ * - Printable ASCII (U+0020–U+007E)
380
+ * - Newline characters: \n (LF), \r (CR)
381
+ * This allows for \n, \r, and \r\n line endings.
382
+ */
383
+ export const ASCII_KEYBOARD_SAFE_REGEX = /[^\x20-\x7E\r\n]+/gu;
384
+
385
+ /**
386
+ * Removes all non-keyboard characters, preserving:
387
+ * - Printable ASCII (0x20–0x7E)
388
+ * - Emojis (Unicode Emoji_Presentation + FE0F variants)
389
+ */
390
+ function purgeNonKeyboardChars(text, purgeEmojis = false) {
391
+ const normalized = normalizeTypographicJank(text);
392
+
393
+ // If emojis are being purged, keyboard-only becomes "ASCII + CR/LF only"
394
+ if (purgeEmojis) {
395
+ return normalized.replace(ASCII_KEYBOARD_SAFE_REGEX, '');
396
+ }
397
+
398
+ // Replace each non-ASCII run with ONLY the emoji sequences inside it.
399
+ // This preserves ZWJ sequences and VS16 variants (e.g. 👨‍👩‍👧‍👦, ✌️, ‼️),
400
+ // while dropping non-emoji non-ASCII (e.g. 𝌆).
401
+ return normalized.replace(ASCII_KEYBOARD_SAFE_REGEX, (run) => extractEmojiSequences(run));
402
+ }
403
+
404
+
405
+ // Smart apostrophes (single quote variations)
406
+ const SMART_SINGLE_QUOTES_REGEX = /[\u2018\u2019\u201A\u201B\u2032\u2035]/g;
407
+
408
+ // Smart double quotes (including primes and guillemets)
409
+ const SMART_DOUBLE_QUOTES_REGEX = /[\u201C\u201D\u201E\u201F\u2033\u2036\u00AB\u00BB]/g;
410
+
411
+ // Dash variants: en dash, em dash, horizontal bar, figure dash, minus
412
+ const UNICODE_DASHES_REGEX = /[\u2012\u2013\u2014\u2015\u2212]/g;
413
+
414
+ // Ellipsis character
415
+ const ELLIPSIS_REGEX = /\u2026/g;
416
+
417
+ // Bullets and middle dot
418
+ const BULLETS_REGEX = /[\u2022\u00B7]/g;
419
+
420
+ // Full-width ASCII punctuation: U+FF01 - U+FF5E
421
+ const FULLWIDTH_PUNCTUATION_REGEX = /[\uFF01-\uFF5E]/g;
422
+
423
+ /**
424
+ * Normalizes typographic Unicode punctuation into ASCII equivalents.
425
+ * @param {string} text
426
+ * @returns {string}
427
+ */
428
+ export function normalizeTypographicJank(text) {
429
+ return text
430
+ .replace(SMART_SINGLE_QUOTES_REGEX, "'")
431
+ .replace(SMART_DOUBLE_QUOTES_REGEX, '"')
432
+ .replace(UNICODE_DASHES_REGEX, '-')
433
+ .replace(ELLIPSIS_REGEX, '...')
434
+ .replace(BULLETS_REGEX, '*')
435
+ .replace(FULLWIDTH_PUNCTUATION_REGEX, m =>
436
+ String.fromCharCode(m.charCodeAt(0) - 0xFEE0)
437
+ );
438
+ }
439
+
440
+
441
+ /** @type {RegExp} */
442
+ export let EMOJI_REGEX;
443
+
444
+ /**
445
+ * Try Unicode property escape regex (preferred).
446
+ * Fallback to basic emoji ranges if unsupported.
447
+ */
448
+ try {
449
+ EMOJI_REGEX = new RegExp(
450
+ '(?:\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D(?:\\p{Extended_Pictographic}|\\w)+)*)',
451
+ 'gu'
452
+ );
453
+ } catch {
454
+ // Fallback: wide-range emoji component match (flags, tones, symbols)
455
+ // * Covers:
456
+ // * - Emoji base chars (1F300–1FAFF)
457
+ // * - Dingbats (2700–27BF) like ❌, ✅, ☑️
458
+ // * - Skin tones (1F3FB–1F3FF)
459
+ // * - ZWJ (200D), variation selectors (FE0F)
460
+ // * - Regional indicators (1F1E6–1F1FF)
461
+ EMOJI_REGEX = /[\u{2700}-\u{27BF}\u{1F300}-\u{1FAFF}\u{1F3FB}-\u{1F3FF}\u200D\uFE0F\u{1F1E6}-\u{1F1FF}]/gu;
462
+ }
463
+
464
+ /**
465
+ * Removes all emoji characters using Unicode property escapes.
466
+ * Supports modern environments (Unicode v13+) with fallback.
467
+ *
468
+ * @param {string} text
469
+ * @returns {string}
470
+ */
471
+ function purgeEmojiCharacters(text) {
472
+ return text.replace(EMOJI_REGEX, '');
473
+ }
474
+
475
+
476
+ /**
477
+ * Normalizes all line endings to a consistent format.
478
+ *
479
+ * Converts:
480
+ * - Windows ("\r\n"), Old Mac ("\r"), Unix ("\n")
481
+ * Into the specified newline format (default: Unix "\n").
482
+ *
483
+ * @param {string} text - Input string to normalize.
484
+ * @param {string} [normalized='\n'] - Target newline style (e.g. '\n', '\r\n').
485
+ * @returns {string}
486
+ */
487
+ const NORMALIZE_NEWLINES_REGEX = /\r\n|\r|\n/g;
488
+ function normalizeNewlineChars(text, normalized = '\n') {
489
+ return text.replace(NORMALIZE_NEWLINES_REGEX, normalized);
490
+ }
491
+
492
+
493
+ /**
494
+ * Trims spaces and tabs immediately before and after newlines.
495
+ *
496
+ * Example: "hello \n world" → "hello\nworld"
497
+ *
498
+ * Preserves the newline itself, just removes whitespace around it.
499
+ *
500
+ * @param {string} text
501
+ * @returns {string}
502
+ */
503
+ const TRIM_SPACES_AROUND_NEWLINES_REGEX = /[ \t]*(\n+)[ \t]*/g;
504
+ function trimSpacesAroundNewlineChars(text) {
505
+ return text.replace(TRIM_SPACES_AROUND_NEWLINES_REGEX, '$1');
506
+ }
507
+
508
+
509
+ /**
510
+ * Collapses excessive newlines based on paragraph preservation settings.
511
+ *
512
+ * - If preserveParagraphs = true:
513
+ * Collapses 3 or more consecutive newlines → exactly two ("\n\n")
514
+ * - If preserveParagraphs = false:
515
+ * Collapses 2 or more consecutive newlines → exactly one ("\n")
516
+ *
517
+ * Example (preserveParagraphs = true):
518
+ * "Line1\n\n\n\nLine2" → "Line1\n\nLine2"
519
+ *
520
+ * @param {string} text
521
+ * @param {boolean} preserveParagraphs - Whether to preserve paragraph breaks (double newlines).
522
+ * @returns {string}
523
+ */
524
+ const MULTIPLE_NEWLINES_REGEX = /\n{2,}/g;
525
+ const TRIPLE_NEWLINES_REGEX = /\n{3,}/g;
526
+
527
+ function collapseMultipleNewLines(text, preserveParagraphs) {
528
+ return preserveParagraphs
529
+ ? text.replace(TRIPLE_NEWLINES_REGEX, '\n\n')
530
+ : text.replace(MULTIPLE_NEWLINES_REGEX, '\n');
531
+ }
532
+
533
+
534
+ /**
535
+ * Collapses multiple consecutive spaces into a single space.
536
+ * (Does not touch tabs or newlines.)
537
+ *
538
+ * Example: "hello world" → "hello world"
539
+ *
540
+ * @param {string} text
541
+ * @returns {string}
542
+ */
543
+ const MULTIPLE_SPACES_REGEX = / {2,}/g;
544
+ function collapseExtraSpaces(text) {
545
+ return text.replace(MULTIPLE_SPACES_REGEX, ' ');
546
+ }
547
+
548
+
549
+ /**
550
+ * Nukes hidden control characters that are invisible and often dangerous.
551
+ * (Excludes necessary whitespace like \n and \t.)
552
+ *
553
+ * Control characters nuked (the code points come from the table in scripts/categories.js):
554
+ * - NUL and the C0 controls except tab, LF and CR; DEL (U+007F)
555
+ * - C1 controls (U+0080-U+009F)
556
+ * - Bidi controls
557
+ *
558
+ * @param {string} text
559
+ * @returns {string}
560
+ */
561
+ function purgeControlCharacters(text) {
562
+ return text.replace(CONTROL_CHARS_REGEX, '');
563
+ }