text-sanctifier 1.0.19 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,507 +1,507 @@
1
- // src/sanctifyText.js
2
-
3
-
4
- /**
5
- * @typedef {Object} SanctifyOptions
6
- * @property {boolean} [purgeInvisibleChars]
7
- * @property {boolean} [purgeEmojis]
8
- * @property {boolean} [nukeControls]
9
- * @property {boolean} [keyboardOnlyFilter]
10
- * @property {boolean} [normalizeNewlines]
11
- * @property {boolean} [trimSpacesAroundNewlines]
12
- * @property {boolean} [collapseNewLines]
13
- * @property {boolean} [preserveParagraphs]
14
- * @property {boolean} [collapseSpaces]
15
- * @property {boolean} [finalTrim]
16
- * @property {number} [maxLength]
17
- * @property {'throw'|'truncate'|'noop'} [onMaxLength]
18
- */
19
-
20
-
21
- /**
22
- * Summons a customized sanctifier function with pre-bound booleans.
23
- *
24
- * Accepts full flag names and returns a text-cleaning function.
25
- *
26
- * @param {Object} [defaultOptions={}]
27
- * @param {boolean} [defaultOptions.purgeInvisibleChars]
28
- * @param {boolean} [defaultOptions.purgeEmojis]
29
- * @param {boolean} [defaultOptions.nukeControls]
30
- * @param {boolean} [defaultOptions.keyboardOnlyFilter]
31
- * @param {boolean} [defaultOptions.normalizeNewlines]
32
- * @param {boolean} [defaultOptions.trimSpacesAroundNewlines]
33
- * @param {boolean} [defaultOptions.collapseNewLines]
34
- * @param {boolean} [defaultOptions.preserveParagraphs]
35
- * @param {boolean} [defaultOptions.collapseSpaces]
36
- * @param {boolean} [defaultOptions.finalTrim]
37
- * @param {number} [defaultOptions.maxLength=Infinity] - Hard input length cap (UTF-16 code units).
38
- * @param {'throw' | 'truncate' | 'noop'} [defaultOptions.onMaxLength='throw'] - Behavior when input exceeds maxLength.
39
- * @returns {(text: string) => string}
40
- */
41
- export function summonSanctifier(defaultOptions = {}) {
42
- const purgeInvisibleChars = !!defaultOptions.purgeInvisibleChars;
43
- const purgeEmojis = !!defaultOptions.purgeEmojis;
44
- const nukeControls = !!defaultOptions.nukeControls;
45
- const keyboardOnlyFilter = !!defaultOptions.keyboardOnlyFilter;
46
- const normalizeNewlines = !!defaultOptions.normalizeNewlines;
47
- const trimSpacesAroundNewlines = !!defaultOptions.trimSpacesAroundNewlines;
48
- const collapseNewLines = !!defaultOptions.collapseNewLines;
49
- const preserveParagraphs = !!defaultOptions.preserveParagraphs;
50
- const collapseSpaces = !!defaultOptions.collapseSpaces;
51
- const finalTrim = !!defaultOptions.finalTrim;
52
-
53
- const maxLength =
54
- Number.isFinite(defaultOptions.maxLength) && defaultOptions.maxLength >= 0
55
- ? defaultOptions.maxLength
56
- : Infinity;
57
-
58
- const onMaxLength =
59
- defaultOptions.onMaxLength === 'truncate' ||
60
- defaultOptions.onMaxLength === 'noop' ||
61
- defaultOptions.onMaxLength === 'throw'
62
- ? defaultOptions.onMaxLength
63
- : 'throw';
64
-
65
- return (text) =>
66
- sanctifyText(
67
- text,
68
- purgeInvisibleChars,
69
- purgeEmojis,
70
- nukeControls,
71
- keyboardOnlyFilter,
72
- normalizeNewlines,
73
- trimSpacesAroundNewlines,
74
- collapseNewLines,
75
- preserveParagraphs,
76
- collapseSpaces,
77
- finalTrim,
78
- maxLength,
79
- onMaxLength
80
- );
81
- }
82
-
83
- // --- Added Presets ---
84
-
85
- /**
86
- * Strict sanitizer:
87
- * - Purge emojis
88
- * - Collapse all newlines
89
- * - Collapse spaces
90
- * - Nuke control characters
91
- */
92
- /** @param {string} text @returns {string} */
93
- summonSanctifier.strict = text => sanctifyText(
94
- text,
95
- true, // purgeInvisibleChars
96
- true, // purgeEmojis
97
- true, // nukeControls
98
- false, // keyboardOnlyFilter
99
- true, // normalizeNewlines
100
- true, // trimSpacesAroundNewlines
101
- true, // collapseNewLines
102
- false, // preserveParagraphs
103
- true, // collapseSpaces
104
- true // finalTrim
105
- );
106
-
107
-
108
- /**
109
- * Loose sanitizer:
110
- * - Collapse spaces
111
- * - Preserve paragraphs
112
- * - Normalize newlines
113
- */
114
- /** @param {string} text @returns {string} */
115
- summonSanctifier.loose = text => sanctifyText(
116
- text,
117
- false, // purgeInvisibleChars
118
- false, // purgeEmojis
119
- false, // nukeControls
120
- false, // keyboardOnlyFilter
121
- true, // normalizeNewlines
122
- true, // trimSpacesAroundNewlines
123
- true, // collapseNewLines
124
- true, // preserveParagraphs
125
- true, // collapseSpaces
126
- true // finalTrim
127
- );
128
-
129
-
130
- /**
131
- * Keyboard-only (with emojis):
132
- * - Keeps emojis and printable ASCII
133
- * - Strips non-standard characters
134
- * - Normalizes typographic trash
135
- */
136
- /** @param {string} text @returns {string} */
137
- summonSanctifier.keyboardOnlyEmoji = text => sanctifyText(
138
- text,
139
- false, // purgeInvisibleChars
140
- false, // purgeEmojis
141
- false, // nukeControls
142
- true, // keyboardOnlyFilter
143
- true, // normalizeNewlines
144
- true, // trimSpacesAroundNewlines
145
- false, // collapseNewLines
146
- false, // preserveParagraphs
147
- false, // collapseSpaces
148
- true // finalTrim
149
- );
150
-
151
-
152
- /**
153
- * Keyboard-only (strict):
154
- * - Removes emojis
155
- * - Collapses all whitespace
156
- * - Restricts to printable ASCII only
157
- */
158
- /** @param {string} text @returns {string} */
159
- summonSanctifier.keyboardOnly = text => sanctifyText(
160
- text,
161
- true, // purgeInvisibleChars
162
- true, // purgeEmojis
163
- true, // nukeControls
164
- true, // keyboardOnlyFilter
165
- true, // normalizeNewlines
166
- true, // trimSpacesAroundNewlines
167
- true, // collapseNewLines
168
- false, // preserveParagraphs
169
- true, // collapseSpaces
170
- true // finalTrim
171
- );
172
-
173
-
174
- /**
175
- * Text Sanctifier
176
- *
177
- * Brutal text normalizer and invisible trash scrubber,
178
- * configurable to kill whatever ghosts you want dead.
179
- *
180
- * ⚠️ Note: This is plain-text normalization/filtering — not an HTML/XSS sanitizer.
181
- *
182
- * @param {string} text
183
- * @param {boolean} [purgeInvisibleChars=false] - Remove ZWSP, NBSP, bidi, etc.
184
- * @param {boolean} [purgeEmojis=false] - Remove emoji characters entirely.
185
- * @param {boolean} [nukeControls=false] - Remove non-whitespace control characters.
186
- * @param {boolean} [keyboardOnlyFilter=false] - Keep printable ASCII + full emoji sequences only (drops other Unicode).
187
- * @param {boolean} [normalizeNewlines=false] - Convert all newlines to `\n`.
188
- * @param {boolean} [trimSpacesAroundNewlines=false] - Remove spaces/tabs around newlines.
189
- * @param {boolean} [collapseNewLines=false] - Collapse `\n` runs (optionally preserve paragraphs).
190
- * @param {boolean} [preserveParagraphs=false] - Preserve paragraph breaks when collapsing newlines.
191
- * @param {boolean} [collapseSpaces=false] - Collapse multiple spaces into one.
192
- * @param {boolean} [finalTrim=false] - `.trim()` the final output (head/tail).
193
- * @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
194
- * @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
195
- * - `'throw'`: throw a `RangeError`
196
- * - `'truncate'`: slice to `maxLength` before processing
197
- * - `'noop'`: return the original input unchanged (skips processing)
198
- * @returns {string}
199
- * @throws {TypeError} If `text` is not a string.
200
- * @throws {RangeError} If input exceeds `maxLength` and `onMaxLength` is `'throw'`.
201
- */
202
- export function sanctifyText(
203
- text,
204
- purgeInvisibleChars = false,
205
- purgeEmojis = false,
206
- nukeControls = false,
207
- keyboardOnlyFilter = false,
208
- normalizeNewlines = false,
209
- trimSpacesAroundNewlines = false,
210
- collapseNewLines = false,
211
- preserveParagraphs = false,
212
- collapseSpaces = false,
213
- finalTrim = false,
214
- maxLength = Infinity,
215
- onMaxLength = 'throw'
216
- ) {
217
- if (typeof text !== 'string') {
218
- throw new TypeError('sanctifyText expects a string input.');
219
- }
220
-
221
- if (onMaxLength !== 'truncate' && onMaxLength !== 'noop' && onMaxLength !== 'throw') {
222
- onMaxLength = 'throw';
223
- }
224
-
225
- if (text.length > maxLength) {
226
- switch (onMaxLength) {
227
- case 'truncate':
228
- text = text.slice(0, maxLength);
229
- break;
230
- case 'noop':
231
- return text;
232
- case 'throw':
233
- default:
234
- throw new RangeError(
235
- `sanctifyText input length ${text.length} exceeds maxLength ${maxLength}.`
236
- );
237
- }
238
- }
239
-
240
- let cleaned = text;
241
-
242
- // Purge invisible Unicode trash (zero-width, non-breaking, bidi junk, etc.)
243
- if (purgeInvisibleChars) cleaned = purgeInvisibleTrash(cleaned);
244
-
245
- // Remove emojis
246
- if (purgeEmojis) cleaned = purgeEmojiCharacters(cleaned);
247
-
248
- // Nuke control characters (excluding whitespace)
249
- if (nukeControls) cleaned = purgeControlCharacters(cleaned);
250
-
251
- // Keep only ASCII/emojis
252
- if (keyboardOnlyFilter) cleaned = purgeNonKeyboardChars(cleaned, purgeEmojis);
253
-
254
- // Normalize line endings to Unix style (\n)
255
- if (normalizeNewlines) cleaned = normalizeNewlineChars(cleaned);
256
-
257
- // Remove spaces/tabs around newlines
258
- if (trimSpacesAroundNewlines) cleaned = trimSpacesAroundNewlineChars(cleaned);
259
-
260
- // Collapse excessive newlines, Optionally preserve Paragraphs
261
- if (collapseNewLines) cleaned = collapseMultipleNewLines(cleaned, preserveParagraphs);
262
-
263
- // Collapse multiple spaces into a single space
264
- if (collapseSpaces) cleaned = collapseExtraSpaces(cleaned);
265
-
266
- // Final trim, return Sanctified Text
267
- return finalTrim ? cleaned.trim() : cleaned;
268
- }
269
-
270
-
271
- // --- Micro helpers ---
272
-
273
-
274
- /**
275
- * Purges invisible Unicode "trash" characters and removes them.
276
- *
277
- * Targets:
278
- * - Non-breaking spaces (\u00A0)
279
- * - Zero-width spaces and miscellaneous Unicode spaces (\u2000–\u200D, \u202F, \u2060, \u3000, \uFEFF)
280
- * - Left-to-right/right-to-left markers and overrides (\u200E, \u200F, \u202A–\u202E)
281
- *
282
- * @param {string} text
283
- * @returns {string}
284
- */
285
- export const INVISIBLE_TRASH_REGEX = /[\u00A0\u2000-\u200D\u202F\u2060\u3000\uFEFF\u200E\u200F\u202A-\u202E]+/g;
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:
497
- * - ASCII control range (0x00-0x1F, 0x7F)
498
- * - Unicode control range (0x80-0x9F)
499
- * - RTL/LTR markers (U+200E, U+200F, U+202A–U+202E)
500
- *
501
- * @param {string} text
502
- * @returns {string}
503
- */
504
- export const CONTROL_CHARS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u200E\u200F\u202A-\u202E]+/g;
505
- function purgeControlCharacters(text) {
506
- return text.replace(CONTROL_CHARS_REGEX, '');
507
- }
1
+ // src/sanctifyText.js
2
+
3
+
4
+ /**
5
+ * @typedef {Object} SanctifyOptions
6
+ * @property {boolean} [purgeInvisibleChars]
7
+ * @property {boolean} [purgeEmojis]
8
+ * @property {boolean} [nukeControls]
9
+ * @property {boolean} [keyboardOnlyFilter]
10
+ * @property {boolean} [normalizeNewlines]
11
+ * @property {boolean} [trimSpacesAroundNewlines]
12
+ * @property {boolean} [collapseNewLines]
13
+ * @property {boolean} [preserveParagraphs]
14
+ * @property {boolean} [collapseSpaces]
15
+ * @property {boolean} [finalTrim]
16
+ * @property {number} [maxLength]
17
+ * @property {'throw'|'truncate'|'noop'} [onMaxLength]
18
+ */
19
+
20
+
21
+ /**
22
+ * Summons a customized sanctifier function with pre-bound booleans.
23
+ *
24
+ * Accepts full flag names and returns a text-cleaning function.
25
+ *
26
+ * @param {Object} [defaultOptions={}]
27
+ * @param {boolean} [defaultOptions.purgeInvisibleChars]
28
+ * @param {boolean} [defaultOptions.purgeEmojis]
29
+ * @param {boolean} [defaultOptions.nukeControls]
30
+ * @param {boolean} [defaultOptions.keyboardOnlyFilter]
31
+ * @param {boolean} [defaultOptions.normalizeNewlines]
32
+ * @param {boolean} [defaultOptions.trimSpacesAroundNewlines]
33
+ * @param {boolean} [defaultOptions.collapseNewLines]
34
+ * @param {boolean} [defaultOptions.preserveParagraphs]
35
+ * @param {boolean} [defaultOptions.collapseSpaces]
36
+ * @param {boolean} [defaultOptions.finalTrim]
37
+ * @param {number} [defaultOptions.maxLength=Infinity] - Hard input length cap (UTF-16 code units).
38
+ * @param {'throw' | 'truncate' | 'noop'} [defaultOptions.onMaxLength='throw'] - Behavior when input exceeds maxLength.
39
+ * @returns {(text: string) => string}
40
+ */
41
+ export function summonSanctifier(defaultOptions = {}) {
42
+ const purgeInvisibleChars = !!defaultOptions.purgeInvisibleChars;
43
+ const purgeEmojis = !!defaultOptions.purgeEmojis;
44
+ const nukeControls = !!defaultOptions.nukeControls;
45
+ const keyboardOnlyFilter = !!defaultOptions.keyboardOnlyFilter;
46
+ const normalizeNewlines = !!defaultOptions.normalizeNewlines;
47
+ const trimSpacesAroundNewlines = !!defaultOptions.trimSpacesAroundNewlines;
48
+ const collapseNewLines = !!defaultOptions.collapseNewLines;
49
+ const preserveParagraphs = !!defaultOptions.preserveParagraphs;
50
+ const collapseSpaces = !!defaultOptions.collapseSpaces;
51
+ const finalTrim = !!defaultOptions.finalTrim;
52
+
53
+ const maxLength =
54
+ Number.isFinite(defaultOptions.maxLength) && defaultOptions.maxLength >= 0
55
+ ? defaultOptions.maxLength
56
+ : Infinity;
57
+
58
+ const onMaxLength =
59
+ defaultOptions.onMaxLength === 'truncate' ||
60
+ defaultOptions.onMaxLength === 'noop' ||
61
+ defaultOptions.onMaxLength === 'throw'
62
+ ? defaultOptions.onMaxLength
63
+ : 'throw';
64
+
65
+ return (text) =>
66
+ sanctifyText(
67
+ text,
68
+ purgeInvisibleChars,
69
+ purgeEmojis,
70
+ nukeControls,
71
+ keyboardOnlyFilter,
72
+ normalizeNewlines,
73
+ trimSpacesAroundNewlines,
74
+ collapseNewLines,
75
+ preserveParagraphs,
76
+ collapseSpaces,
77
+ finalTrim,
78
+ maxLength,
79
+ onMaxLength
80
+ );
81
+ }
82
+
83
+ // --- Added Presets ---
84
+
85
+ /**
86
+ * Strict sanitizer:
87
+ * - Purge emojis
88
+ * - Collapse all newlines
89
+ * - Collapse spaces
90
+ * - Nuke control characters
91
+ */
92
+ /** @param {string} text @returns {string} */
93
+ summonSanctifier.strict = text => sanctifyText(
94
+ text,
95
+ true, // purgeInvisibleChars
96
+ true, // purgeEmojis
97
+ true, // nukeControls
98
+ false, // keyboardOnlyFilter
99
+ true, // normalizeNewlines
100
+ true, // trimSpacesAroundNewlines
101
+ true, // collapseNewLines
102
+ false, // preserveParagraphs
103
+ true, // collapseSpaces
104
+ true // finalTrim
105
+ );
106
+
107
+
108
+ /**
109
+ * Loose sanitizer:
110
+ * - Collapse spaces
111
+ * - Preserve paragraphs
112
+ * - Normalize newlines
113
+ */
114
+ /** @param {string} text @returns {string} */
115
+ summonSanctifier.loose = text => sanctifyText(
116
+ text,
117
+ false, // purgeInvisibleChars
118
+ false, // purgeEmojis
119
+ false, // nukeControls
120
+ false, // keyboardOnlyFilter
121
+ true, // normalizeNewlines
122
+ true, // trimSpacesAroundNewlines
123
+ true, // collapseNewLines
124
+ true, // preserveParagraphs
125
+ true, // collapseSpaces
126
+ true // finalTrim
127
+ );
128
+
129
+
130
+ /**
131
+ * Keyboard-only (with emojis):
132
+ * - Keeps emojis and printable ASCII
133
+ * - Strips non-standard characters
134
+ * - Normalizes typographic trash
135
+ */
136
+ /** @param {string} text @returns {string} */
137
+ summonSanctifier.keyboardOnlyEmoji = text => sanctifyText(
138
+ text,
139
+ false, // purgeInvisibleChars
140
+ false, // purgeEmojis
141
+ false, // nukeControls
142
+ true, // keyboardOnlyFilter
143
+ true, // normalizeNewlines
144
+ true, // trimSpacesAroundNewlines
145
+ false, // collapseNewLines
146
+ false, // preserveParagraphs
147
+ false, // collapseSpaces
148
+ true // finalTrim
149
+ );
150
+
151
+
152
+ /**
153
+ * Keyboard-only (strict):
154
+ * - Removes emojis
155
+ * - Collapses all whitespace
156
+ * - Restricts to printable ASCII only
157
+ */
158
+ /** @param {string} text @returns {string} */
159
+ summonSanctifier.keyboardOnly = text => sanctifyText(
160
+ text,
161
+ true, // purgeInvisibleChars
162
+ true, // purgeEmojis
163
+ true, // nukeControls
164
+ true, // keyboardOnlyFilter
165
+ true, // normalizeNewlines
166
+ true, // trimSpacesAroundNewlines
167
+ true, // collapseNewLines
168
+ false, // preserveParagraphs
169
+ true, // collapseSpaces
170
+ true // finalTrim
171
+ );
172
+
173
+
174
+ /**
175
+ * Text Sanctifier
176
+ *
177
+ * Brutal text normalizer and invisible trash scrubber,
178
+ * configurable to kill whatever ghosts you want dead.
179
+ *
180
+ * ⚠️ Note: This is plain-text normalization/filtering — not an HTML/XSS sanitizer.
181
+ *
182
+ * @param {string} text
183
+ * @param {boolean} [purgeInvisibleChars=false] - Remove ZWSP, NBSP, bidi, etc.
184
+ * @param {boolean} [purgeEmojis=false] - Remove emoji characters entirely.
185
+ * @param {boolean} [nukeControls=false] - Remove non-whitespace control characters.
186
+ * @param {boolean} [keyboardOnlyFilter=false] - Keep printable ASCII + full emoji sequences only (drops other Unicode).
187
+ * @param {boolean} [normalizeNewlines=false] - Convert all newlines to `\n`.
188
+ * @param {boolean} [trimSpacesAroundNewlines=false] - Remove spaces/tabs around newlines.
189
+ * @param {boolean} [collapseNewLines=false] - Collapse `\n` runs (optionally preserve paragraphs).
190
+ * @param {boolean} [preserveParagraphs=false] - Preserve paragraph breaks when collapsing newlines.
191
+ * @param {boolean} [collapseSpaces=false] - Collapse multiple spaces into one.
192
+ * @param {boolean} [finalTrim=false] - `.trim()` the final output (head/tail).
193
+ * @param {number} [maxLength=Infinity] - Hard input length cap (UTF-16 code units via `text.length`).
194
+ * @param {'throw'|'truncate'|'noop'} [onMaxLength='throw'] - Behavior when `text.length` exceeds `maxLength`:
195
+ * - `'throw'`: throw a `RangeError`
196
+ * - `'truncate'`: slice to `maxLength` before processing
197
+ * - `'noop'`: return the original input unchanged (skips processing)
198
+ * @returns {string}
199
+ * @throws {TypeError} If `text` is not a string.
200
+ * @throws {RangeError} If input exceeds `maxLength` and `onMaxLength` is `'throw'`.
201
+ */
202
+ export function sanctifyText(
203
+ text,
204
+ purgeInvisibleChars = false,
205
+ purgeEmojis = false,
206
+ nukeControls = false,
207
+ keyboardOnlyFilter = false,
208
+ normalizeNewlines = false,
209
+ trimSpacesAroundNewlines = false,
210
+ collapseNewLines = false,
211
+ preserveParagraphs = false,
212
+ collapseSpaces = false,
213
+ finalTrim = false,
214
+ maxLength = Infinity,
215
+ onMaxLength = 'throw'
216
+ ) {
217
+ if (typeof text !== 'string') {
218
+ throw new TypeError('sanctifyText expects a string input.');
219
+ }
220
+
221
+ if (onMaxLength !== 'truncate' && onMaxLength !== 'noop' && onMaxLength !== 'throw') {
222
+ onMaxLength = 'throw';
223
+ }
224
+
225
+ if (text.length > maxLength) {
226
+ switch (onMaxLength) {
227
+ case 'truncate':
228
+ text = text.slice(0, maxLength);
229
+ break;
230
+ case 'noop':
231
+ return text;
232
+ case 'throw':
233
+ default:
234
+ throw new RangeError(
235
+ `sanctifyText input length ${text.length} exceeds maxLength ${maxLength}.`
236
+ );
237
+ }
238
+ }
239
+
240
+ let cleaned = text;
241
+
242
+ // Purge invisible Unicode trash (zero-width, non-breaking, bidi junk, etc.)
243
+ if (purgeInvisibleChars) cleaned = purgeInvisibleTrash(cleaned);
244
+
245
+ // Remove emojis
246
+ if (purgeEmojis) cleaned = purgeEmojiCharacters(cleaned);
247
+
248
+ // Nuke control characters (excluding whitespace)
249
+ if (nukeControls) cleaned = purgeControlCharacters(cleaned);
250
+
251
+ // Keep only ASCII/emojis
252
+ if (keyboardOnlyFilter) cleaned = purgeNonKeyboardChars(cleaned, purgeEmojis);
253
+
254
+ // Normalize line endings to Unix style (\n)
255
+ if (normalizeNewlines) cleaned = normalizeNewlineChars(cleaned);
256
+
257
+ // Remove spaces/tabs around newlines
258
+ if (trimSpacesAroundNewlines) cleaned = trimSpacesAroundNewlineChars(cleaned);
259
+
260
+ // Collapse excessive newlines, Optionally preserve Paragraphs
261
+ if (collapseNewLines) cleaned = collapseMultipleNewLines(cleaned, preserveParagraphs);
262
+
263
+ // Collapse multiple spaces into a single space
264
+ if (collapseSpaces) cleaned = collapseExtraSpaces(cleaned);
265
+
266
+ // Final trim, return Sanctified Text
267
+ return finalTrim ? cleaned.trim() : cleaned;
268
+ }
269
+
270
+
271
+ // --- Micro helpers ---
272
+
273
+
274
+ /**
275
+ * Purges invisible Unicode "trash" characters and removes them.
276
+ *
277
+ * Targets:
278
+ * - Non-breaking spaces (\u00A0)
279
+ * - Zero-width spaces and miscellaneous Unicode spaces (\u2000–\u200D, \u202F, \u2060, \u3000, \uFEFF)
280
+ * - Bidi controls: marks, embeddings/overrides and isolates (\u061C, \u200E, \u200F, \u202A–\u202E, \u2066–\u2069)
281
+ *
282
+ * @param {string} text
283
+ * @returns {string}
284
+ */
285
+ export const INVISIBLE_TRASH_REGEX = /[\u00A0\u2000-\u200D\u202F\u2060\u3000\uFEFF\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
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:
497
+ * - ASCII control range (0x00-0x1F, 0x7F)
498
+ * - Unicode control range (0x80-0x9F)
499
+ * - Bidi controls (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069)
500
+ *
501
+ * @param {string} text
502
+ * @returns {string}
503
+ */
504
+ export const CONTROL_CHARS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
505
+ function purgeControlCharacters(text) {
506
+ return text.replace(CONTROL_CHARS_REGEX, '');
507
+ }