text-sanctifier 1.3.2 โ†’ 1.3.3

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,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
- */
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
+ */
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
+ }