punctilio 3.7.2 → 3.7.4

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,713 +0,0 @@
1
- import { transform, DEFAULT_SEPARATOR, countSeparators } from "../index.js"
2
- import { ellipsis } from "../symbols.js"
3
- import { UNICODE_SYMBOLS, REGEX_SPECIAL_CHARS, MAX_REGEX_CACHE_SIZE } from "../constants.js"
4
- import { assertLinearScaling, buildMixedContent } from "./test-helpers.js"
5
-
6
- const {
7
- LEFT_DOUBLE_QUOTE,
8
- RIGHT_DOUBLE_QUOTE,
9
- RIGHT_SINGLE_QUOTE,
10
- EM_DASH,
11
- EN_DASH,
12
- ELLIPSIS,
13
- MULTIPLICATION,
14
- NOT_EQUAL,
15
- PLUS_MINUS,
16
- LESS_EQUAL,
17
- GREATER_EQUAL,
18
- COPYRIGHT,
19
- NBSP,
20
- PRIME,
21
- DOUBLE_PRIME,
22
- TRADEMARK,
23
- REGISTERED,
24
- DEGREE,
25
- MINUS,
26
- FRACTION_1_2,
27
- SUPERSCRIPT_ST,
28
- } = UNICODE_SYMBOLS
29
-
30
- describe("transform", () => {
31
- it("applies both quote and dash transformations", () => {
32
- const input = '"Hello," she said - "it\'s pages 1-5."'
33
- const expected = `${LEFT_DOUBLE_QUOTE}Hello,${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1${EN_DASH}5.${RIGHT_DOUBLE_QUOTE}`
34
- expect(transform(input, { nbsp: false })).toBe(expected)
35
- })
36
-
37
- it("handles complex mixed content", () => {
38
- const input = 'I was born in \'99 - "the best year" - and pages 10-20 are my favorite.'
39
- const expected = `I was born in ${RIGHT_SINGLE_QUOTE}99${EM_DASH}${LEFT_DOUBLE_QUOTE}the best year${RIGHT_DOUBLE_QUOTE}${EM_DASH}and pages 10${EN_DASH}20 are my favorite.`
40
- expect(transform(input, { nbsp: false })).toBe(expected)
41
- })
42
-
43
- it("preserves separator character", () => {
44
- const sep = DEFAULT_SEPARATOR
45
- const input = `"Hello${sep}" - test`
46
- expect(transform(input, { separator: sep, nbsp: false })).toBe(`${LEFT_DOUBLE_QUOTE}Hello${sep}${RIGHT_DOUBLE_QUOTE}${EM_DASH}test`)
47
- })
48
-
49
- describe("symbol transforms", () => {
50
- it("applies symbol transforms by default", () => {
51
- expect(transform('Wait... 5x5 != 25 (c) 2024', { nbsp: false })).toBe(`Wait${ELLIPSIS} 5${MULTIPLICATION}5 ${NOT_EQUAL} 25 ${COPYRIGHT} 2024`)
52
- })
53
-
54
- it("can disable symbol transforms", () => {
55
- expect(transform('Wait... 5x5 != 25', { symbols: false, nbsp: false })).toBe('Wait... 5x5 != 25')
56
- })
57
- })
58
-
59
- describe("optional transforms", () => {
60
- it.each([
61
- ["fractions disabled", "1/2", { nbsp: false }, "1/2"],
62
- ["fractions enabled", "1/2", { fractions: true, nbsp: false }, UNICODE_SYMBOLS.FRACTION_1_2],
63
- ["degrees disabled", "20 C", { nbsp: false }, "20 C"],
64
- ["degrees enabled", "20 C", { degrees: true, nbsp: false }, `20 ${UNICODE_SYMBOLS.DEGREE}C`],
65
- ["superscript disabled", "1st", { nbsp: false }, "1st"],
66
- ["superscript enabled", "1st", { superscript: true, nbsp: false }, `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
67
- ["ligatures disabled", "??", { nbsp: false }, "??"],
68
- ["ligatures enabled", "??", { ligatures: true, nbsp: false }, UNICODE_SYMBOLS.DOUBLE_QUESTION],
69
- ["nbsp enabled (default)", "Dr. Smith", {}, `Dr.${NBSP}Smith`],
70
- ["nbsp disabled", "Dr. Smith", { nbsp: false }, "Dr. Smith"],
71
- ] as const)("%s: %s → %s", (_desc, input, options, expected) => {
72
- expect(transform(input, options)).toBe(expected)
73
- })
74
- })
75
-
76
- describe("punctuationStyle option", () => {
77
- const periodOutside = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}.`
78
- const periodInside = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}`
79
-
80
- it.each([
81
- ['"Hello".', undefined, periodInside, "american (default)"],
82
- ['"Hello."', "british", periodOutside, "british"],
83
- ['"Hello".', "none", '"Hello".', "none (skips all quote transforms)"],
84
- ] as const)("handles %s with %s style", (input, style, expected) => {
85
- expect(transform(input, { ...(style ? { punctuationStyle: style } : {}), nbsp: false })).toBe(expected)
86
- })
87
-
88
- it("none skips prime marks too", () => {
89
- expect(transform('5\'10"', { punctuationStyle: "none", nbsp: false })).toBe('5\'10"')
90
- })
91
-
92
- it("none skips all quote transforms while other transforms still apply", () => {
93
- expect(transform('"Wait..." she said', { punctuationStyle: "none", nbsp: false })).toBe(`"Wait${ELLIPSIS}" she said`)
94
- })
95
- })
96
-
97
- describe("german and french locale styles", () => {
98
- it.each([
99
- ['"Guten Tag"', `${UNICODE_SYMBOLS.DOUBLE_LOW_9_QUOTE}Guten Tag${LEFT_DOUBLE_QUOTE}`, { punctuationStyle: "german" as const, nbsp: false }],
100
- ['"Bonjour"', `${UNICODE_SYMBOLS.LEFT_GUILLEMET}${UNICODE_SYMBOLS.NNBSP}Bonjour${UNICODE_SYMBOLS.NNBSP}${UNICODE_SYMBOLS.RIGHT_GUILLEMET}`, { punctuationStyle: "french" as const, nbsp: false }],
101
- ["it's", `it${RIGHT_SINGLE_QUOTE}s`, { punctuationStyle: "german" as const, nbsp: false }],
102
- ["l'homme", `l${RIGHT_SINGLE_QUOTE}homme`, { punctuationStyle: "french" as const, nbsp: false }],
103
- ])('transforms "%s" with locale style', (input, expected, options) => {
104
- expect(transform(input, options)).toBe(expected)
105
- })
106
-
107
- it("german style is idempotent", () => {
108
- const input = '"Guten Tag"'
109
- const first = transform(input, { punctuationStyle: "german", nbsp: false })
110
- const second = transform(first, { punctuationStyle: "german", nbsp: false })
111
- expect(second).toBe(first)
112
- })
113
-
114
- it("french style is idempotent", () => {
115
- const input = '"Bonjour"'
116
- const first = transform(input, { punctuationStyle: "french", nbsp: false })
117
- const second = transform(first, { punctuationStyle: "french", nbsp: false })
118
- expect(second).toBe(first)
119
- })
120
- })
121
-
122
- describe("collapseSpaces option", () => {
123
- it.each([
124
- ["hello world", "hello world", "multiple spaces"],
125
- [`foo${NBSP}${NBSP}bar`, `foo${NBSP}bar`, "multiple nbsp"],
126
- [`a ${NBSP}b`, `a${NBSP}b`, "mixed spaces prefer nbsp"],
127
- [`a${NBSP} b`, `a${NBSP}b`, "mixed spaces prefer nbsp"],
128
- ["hello\t\tworld", "hello world", "multiple tabs"],
129
- ["hello\t world", "hello world", "mixed tab and space"],
130
- ])("collapses %s by default", (input, expected) => {
131
- expect(transform(input, { nbsp: false })).toBe(expected)
132
- })
133
-
134
- it.each([
135
- ["hello world", "hello world", "multiple spaces"],
136
- [`foo${NBSP}${NBSP}bar`, `foo${NBSP}${NBSP}bar`, "multiple nbsp"],
137
- ["hello\t\tworld", "hello\t\tworld", "multiple tabs"],
138
- ])("preserves %s when disabled", (input, expected) => {
139
- expect(transform(input, { collapseSpaces: false, nbsp: false })).toBe(expected)
140
- })
141
- })
142
-
143
- describe("nbsp option", () => {
144
- it("inserts nbsp in typographically appropriate places", () => {
145
- expect(transform("Dr. Smith wrote Fig. 1 on p. 42", { nbsp: true }))
146
- .toBe(`Dr.${NBSP}Smith wrote Fig.${NBSP}1 on${NBSP}p.${NBSP}42`)
147
- })
148
-
149
- it("collapseSpaces cleans up after nbsp", () => {
150
- expect(transform("Prof. Wilson arrived", { nbsp: true }))
151
- .toBe(`Prof.${NBSP}Wilson${NBSP}arrived`)
152
- })
153
-
154
- it("is idempotent with nbsp enabled", () => {
155
- const input = "Dr. Smith has 5 kg of items in § 3"
156
- const first = transform(input, { nbsp: true })
157
- const second = transform(first, { nbsp: true })
158
- expect(second).toBe(first)
159
- })
160
-
161
- it("preserves separator count with nbsp enabled", () => {
162
- const sep = DEFAULT_SEPARATOR
163
- const input = `Dr.${sep} Smith has${sep} 5 kg`
164
- expect(() => transform(input, { nbsp: true, separator: sep })).not.toThrow()
165
- })
166
- })
167
-
168
- describe("separator preservation", () => {
169
- it.each([
170
- [`Wait.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}. for it`, 2],
171
- [`"Hello${DEFAULT_SEPARATOR}" - ${DEFAULT_SEPARATOR}she${DEFAULT_SEPARATOR} said`, 3],
172
- [`.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`, 2],
173
- ])('preserves %i separators in "%s"', (input, expectedCount) => {
174
- expect(() => transform(input, { separator: DEFAULT_SEPARATOR, nbsp: false })).not.toThrow()
175
- expect(countSeparators(transform(input, { separator: DEFAULT_SEPARATOR, nbsp: false }), DEFAULT_SEPARATOR)).toBe(expectedCount)
176
- })
177
-
178
- it("preserves separator in ellipsis", () => {
179
- const input = `.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`
180
- const result = ellipsis(input, { separator: DEFAULT_SEPARATOR })
181
- expect(result.split(DEFAULT_SEPARATOR).length - 1).toBe(2)
182
- })
183
-
184
- it("preserves consecutive separators", () => {
185
- const input = `a${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}b`
186
- expect(transform(input, { nbsp: false })).toBe(input)
187
- })
188
- })
189
-
190
- describe("Unicode edge cases", () => {
191
- it.each([
192
- '"café"',
193
- '"Hello 👋 world"',
194
- '"日本語"',
195
- '"שלום"',
196
- "a\u200Bb",
197
- ])('preserves content in "%s"', (input) => {
198
- expect(transform(input, { nbsp: false })).toBe(transform(input, { nbsp: false })) // idempotent
199
- })
200
- })
201
-
202
- // Tests derived from competitor libraries and benchmark cases
203
- describe("competitor-derived comprehensive tests", () => {
204
- // These tests document where punctilio excels over competitors
205
- describe("punctilio advantages (benchmark cases)", () => {
206
- it.each([
207
- // Leading apostrophes - smartypants/tipograph fail these
208
- ["'SUP", `${RIGHT_SINGLE_QUOTE}SUP`],
209
- ["Rock 'n' Roll", `Rock ${RIGHT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} Roll`],
210
- ["I was born in '99", `I was born in ${RIGHT_SINGLE_QUOTE}99`],
211
- // Em dashes from surrounded hyphens - smartquotes fails
212
- ["This is a - hyphen.", `This is a${EM_DASH}hyphen.`],
213
- // Number ranges - smartypants/smartquotes/typograf fail
214
- ["Pages 1-5", `Pages 1${EN_DASH}5`],
215
- ["2000-2020", `2000${EN_DASH}2020`],
216
- // Date ranges - all competitors fail
217
- ["January-March", `January${EN_DASH}March`],
218
- // Minus signs - most competitors fail
219
- ["-5", `${MINUS}5`],
220
- ["(-5)", `(${MINUS}5)`],
221
- // Prime marks - smartypants/typograf fail
222
- ['5\'10"', `5${PRIME}10${DOUBLE_PRIME}`],
223
- ['He is 6\'2" tall', `He is 6${PRIME}2${DOUBLE_PRIME} tall`],
224
- ])('correctly transforms: "%s"', (input, expected) => {
225
- expect(transform(input, { symbols: true, degrees: true, nbsp: false })).toBe(expected)
226
- })
227
- })
228
-
229
- // Compound word preservation (all libraries should preserve these)
230
- describe("compound word preservation", () => {
231
- it.each([
232
- "well-known",
233
- "a browser- or OS-specific fashion",
234
- "re-read",
235
- "self-aware",
236
- "high-quality",
237
- ])('preserves compound word: "%s"', (input) => {
238
- expect(transform(input, { nbsp: false })).toBe(input)
239
- })
240
- })
241
-
242
- // Model name preservation (punctilio's unique strength)
243
- describe("model name preservation", () => {
244
- it.each([
245
- "Llama-2-7B",
246
- "Llama-3-8B-Instruct",
247
- "GPT-4",
248
- "Claude-3-Opus",
249
- "Qwen1.5-1.8B",
250
- ])('preserves model name: "%s"', (input) => {
251
- expect(transform(input, { nbsp: false })).toBe(input)
252
- })
253
- })
254
- })
255
-
256
- describe("complex real-world text", () => {
257
- it("handles dialogue with dashes and quotes", () => {
258
- const input = '"Wait," she said -- "I don\'t think that\'s right."'
259
- const expected = `${LEFT_DOUBLE_QUOTE}Wait,${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}I don${RIGHT_SINGLE_QUOTE}t think that${RIGHT_SINGLE_QUOTE}s right.${RIGHT_DOUBLE_QUOTE}`
260
- expect(transform(input, { nbsp: false })).toEqual(expected)
261
- })
262
-
263
- it("handles technical documentation", () => {
264
- const input = 'The API returns x != y when a <= b and c >= d. Error tolerance: +-5%.'
265
- const expected = `The API returns x ${NOT_EQUAL} y when a ${LESS_EQUAL} b and c ${GREATER_EQUAL} d. Error tolerance: ${PLUS_MINUS}5%.`
266
- expect(transform(input, { nbsp: false })).toBe(expected)
267
- })
268
-
269
-
270
- it("handles measurement text", () => {
271
- const input = 'He is 6\'2" tall.'
272
- const expected = `He is 6${PRIME}2${DOUBLE_PRIME} tall.`
273
- expect(transform(input, { symbols: true, nbsp: false })).toEqual(expected)
274
- })
275
-
276
- it("handles copyright notices", () => {
277
- const input = '(c) 2024 Company(tm). All rights reserved(r).'
278
- const expected = `${COPYRIGHT} 2024 Company${TRADEMARK}. All rights reserved${REGISTERED}.`
279
- expect(transform(input, { nbsp: false })).toEqual(expected)
280
- })
281
- })
282
-
283
- describe("known limitations", () => {
284
- // These document current behavior for edge cases
285
- it("documents prime mark vs closing quote ambiguity", () => {
286
- // When there's an open quote before a number, the quote after should close
287
- // not become a prime mark
288
- const input = '"Number 5"'
289
- const result = transform(input, { nbsp: false })
290
- // The 5" should be a closing quote, not a prime
291
- expect(result).toBe(`${LEFT_DOUBLE_QUOTE}Number 5${RIGHT_DOUBLE_QUOTE}`)
292
- })
293
- })
294
-
295
- describe("complex combined transformations", () => {
296
- it.each([
297
- [
298
- '"Wait..." she said - "it\'s pages 1-5."',
299
- `${LEFT_DOUBLE_QUOTE}Wait${ELLIPSIS}${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1${EN_DASH}5.${RIGHT_DOUBLE_QUOTE}`,
300
- ],
301
- [
302
- "(c) 2024 - Room is 10x12, set to 72 F",
303
- `${COPYRIGHT} 2024${EM_DASH}Room is 10${MULTIPLICATION}12, set to 72 ${DEGREE}F`,
304
- ],
305
- ])('handles complex transform: "%s"', (input, expected) => {
306
- expect(transform(input, { degrees: true, nbsp: false })).toBe(expected)
307
- })
308
- })
309
-
310
- describe("empty and whitespace inputs", () => {
311
- it.each([
312
- ["", ""],
313
- [" ", " "],
314
- ["\n", "\n"],
315
- ["\t", "\t"],
316
- [" ", " "],
317
- ])('handles empty/whitespace: "%s"', (input, expected) => {
318
- expect(transform(input, { nbsp: false })).toBe(expected)
319
- })
320
- })
321
-
322
- describe("very long inputs", () => {
323
- it("handles long repeated patterns", () => {
324
- const input = '"Hello" '.repeat(100)
325
- const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE} `.repeat(100)
326
- expect(transform(input, { nbsp: false })).toEqual(expected)
327
- })
328
-
329
- it("handles long continuous text", () => {
330
- const input = "word ".repeat(1000)
331
- const result = transform(input, { nbsp: false })
332
- expect(result).toBe(input.trim() + " ")
333
- })
334
-
335
- it("converts ALL quote pairs in repeated pattern", () => {
336
- const count = 50
337
- const input = '"Hello" '.repeat(count)
338
- const result = transform(input, { nbsp: false })
339
- const leftCount = (result.match(new RegExp(LEFT_DOUBLE_QUOTE, "g")) || []).length
340
- const rightCount = (result.match(new RegExp(RIGHT_DOUBLE_QUOTE, "g")) || []).length
341
- expect(leftCount).toBe(count)
342
- expect(rightCount).toBe(count)
343
- })
344
-
345
- it("converts ALL dashes in repeated pattern", () => {
346
- const count = 50
347
- const input = "pages 1-5 ".repeat(count)
348
- const result = transform(input, { nbsp: false })
349
- const enDashCount = (result.match(new RegExp(EN_DASH, "g")) || []).length
350
- expect(enDashCount).toBe(count)
351
- })
352
-
353
- it("converts ALL ellipses in repeated pattern", () => {
354
- const count = 50
355
- const input = "Wait... ".repeat(count)
356
- const result = transform(input, { nbsp: false })
357
- const ellipsisCount = (result.match(new RegExp(ELLIPSIS, "g")) || []).length
358
- expect(ellipsisCount).toBe(count)
359
- })
360
- })
361
-
362
- describe("Unicode control characters", () => {
363
- it.each([
364
- ['"test\u200Bword"', `${LEFT_DOUBLE_QUOTE}test\u200Bword${RIGHT_DOUBLE_QUOTE}`],
365
- ['"test\u200Fword"', `${LEFT_DOUBLE_QUOTE}test\u200Fword${RIGHT_DOUBLE_QUOTE}`],
366
- ['"caf\u0065\u0301"', `${LEFT_DOUBLE_QUOTE}caf\u0065\u0301${RIGHT_DOUBLE_QUOTE}`],
367
- ])('handles Unicode edge case', (input, expected) => {
368
- expect(transform(input, { nbsp: false })).toBe(expected)
369
- })
370
- })
371
-
372
- describe("option combinations", () => {
373
- it("applies all optional transforms together", () => {
374
- const input = "1st place: 1/2 at 72 F - wow!!"
375
- const expected = `1${SUPERSCRIPT_ST} place: ${FRACTION_1_2} at 72 ${DEGREE}F${EM_DASH}wow!`
376
- expect(transform(input, {
377
- fractions: true,
378
- degrees: true,
379
- superscript: true,
380
- ligatures: true,
381
- nbsp: false,
382
- })).toEqual(expected)
383
- })
384
-
385
- it("respects disabled transforms", () => {
386
- const input = "1/2 at 72 F"
387
- const expected = `${FRACTION_1_2} at 72 F`
388
- expect(transform(input, {
389
- fractions: true,
390
- degrees: false,
391
- nbsp: false,
392
- })).toEqual(expected)
393
- })
394
- })
395
-
396
- describe("separator robustness", () => {
397
- const sep = DEFAULT_SEPARATOR
398
-
399
- it.each([
400
- [`${sep}"test"${sep}`, `${sep}${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE}${sep}`],
401
- [`"test${sep}word"`, `${LEFT_DOUBLE_QUOTE}test${sep}word${RIGHT_DOUBLE_QUOTE}`],
402
- [`${sep}${sep}"test"${sep}${sep}`, `${sep}${sep}${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE}${sep}${sep}`],
403
- ])('handles separator in quotes', (input, expected) => {
404
- expect(transform(input, { separator: sep, nbsp: false })).toBe(expected)
405
- })
406
-
407
- it.each([
408
- [`word${sep} - ${sep}word`, `word${sep}${EM_DASH}${sep}word`],
409
- [`1${sep}-${sep}5`, `1${sep}${EN_DASH}${sep}5`],
410
- ])('handles separator in dashes', (input, expected) => {
411
- expect(transform(input, { separator: sep, nbsp: false })).toBe(expected)
412
- })
413
-
414
- it("preserves exact separator count through transform", () => {
415
- const input = `${sep}text${sep}more${sep}text${sep}`
416
- const result = transform(input, { separator: sep, nbsp: false })
417
- const inputCount = (input.match(new RegExp(sep, "g")) || []).length
418
- const resultCount = (result.match(new RegExp(sep, "g")) || []).length
419
- expect(resultCount).toBe(inputCount)
420
- })
421
-
422
- it("handles input containing separator character", () => {
423
- const input = `Text with ${sep} in it`
424
- expect(() => transform(input, { separator: sep, nbsp: false })).not.toThrow()
425
- })
426
-
427
- it("handles separator in fractions", () => {
428
- const input = `1${sep}/2`
429
- expect(transform(input, { separator: sep, fractions: true, nbsp: false })).toBe(`${sep}${FRACTION_1_2}`)
430
- })
431
-
432
- it("handles separator in degrees", () => {
433
- const input = `72${sep} F`
434
- expect(transform(input, { separator: sep, degrees: true, nbsp: false })).toBe(`72${sep} ${DEGREE}F`)
435
- })
436
- })
437
-
438
- describe("additional idempotency", () => {
439
- it.each([
440
- '"Hello" - she said, "it\'s pages 1-5..."',
441
- 'Product(tm) 5x5 at 72 F (c) 2024',
442
- "Add 1/2 cup - that's about 3/4 done",
443
- '1st place winner said "congrats!!"',
444
- ])('is idempotent: "%s"', (input) => {
445
- const first = transform(input, { fractions: true, degrees: true, superscript: true, ligatures: true })
446
- const second = transform(first, { fractions: true, degrees: true, superscript: true, ligatures: true })
447
- expect(second).toBe(first)
448
- })
449
-
450
- it.each([
451
- // Nested quotes with period — tests the multi-quote-jump fix
452
- [`"She said, 'Hello.'"`, "british" as const],
453
- [`"She said, 'Hello.'"`, "german" as const],
454
- [`"She said, 'Hello.'"`, "french" as const],
455
- // Leading apostrophe — tests the German RSQ normalization fix
456
- [`'Twas the night before Christmas.`, "german" as const],
457
- ])('is idempotent across styles: "%s" [%s]', (input, style) => {
458
- const first = transform(input, { punctuationStyle: style })
459
- const second = transform(first, { punctuationStyle: style })
460
- expect(second).toBe(first)
461
- })
462
-
463
- it.each([
464
- `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`,
465
- `word${EM_DASH}word`,
466
- `1${EN_DASH}5`,
467
- `Wait${ELLIPSIS}`,
468
- `5${MULTIPLICATION}5`,
469
- `20${NBSP}${DEGREE}C`,
470
- `${COPYRIGHT}${NBSP}2024`,
471
- ])('stable for: "%s"', (input) => {
472
- expect(transform(input)).toBe(input)
473
- })
474
- })
475
-
476
- describe("style combinations", () => {
477
- it("applies American conventions throughout", () => {
478
- const input = '"Hello." - word - "World."'
479
- const expected = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}${EM_DASH}word${EM_DASH}${LEFT_DOUBLE_QUOTE}World.${RIGHT_DOUBLE_QUOTE}`
480
- expect(transform(input, { punctuationStyle: "american", dashStyle: "american", nbsp: false })).toEqual(expected)
481
- })
482
-
483
- it("applies British conventions throughout", () => {
484
- const input = '"Hello." - word - "World."'
485
- const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}. ${EN_DASH} word ${EN_DASH} ${LEFT_DOUBLE_QUOTE}World${RIGHT_DOUBLE_QUOTE}.`
486
- expect(transform(input, { punctuationStyle: "british", dashStyle: "british", nbsp: false })).toEqual(expected)
487
- })
488
-
489
- it("applies American punctuation with British dashes", () => {
490
- const input = '"Hello." - word'
491
- const expected = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} ${EN_DASH} word`
492
- expect(transform(input, { punctuationStyle: "american", dashStyle: "british", nbsp: false })).toEqual(expected)
493
- })
494
-
495
- it("skips all dash transforms with dashStyle none", () => {
496
- const input = '"Hello" - pages 1-5, -3'
497
- const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE} - pages 1-5, -3`
498
- expect(transform(input, { dashStyle: "none", nbsp: false })).toEqual(expected)
499
- })
500
-
501
- it("skips everything with both set to none", () => {
502
- const input = '"Hello" - pages 1-5'
503
- expect(transform(input, { punctuationStyle: "none", dashStyle: "none", nbsp: false })).toBe(input)
504
- })
505
-
506
- it("skips quotes but applies dashes with mixed none", () => {
507
- const input = '"Hello" - pages 1-5'
508
- const expected = `"Hello"${EM_DASH}pages 1${EN_DASH}5`
509
- expect(transform(input, { punctuationStyle: "none", dashStyle: "american", nbsp: false })).toEqual(expected)
510
- })
511
- })
512
-
513
- describe("regex performance", () => {
514
- it("scales linearly for pathological quote patterns", () => {
515
- assertLinearScaling(transform, (n) => '"a"'.repeat(n) + "'")
516
- })
517
-
518
- it("scales linearly for pathological dash patterns", () => {
519
- assertLinearScaling(transform, (n) => "1" + "-1".repeat(n))
520
- })
521
-
522
- it("scales linearly for unbalanced single quotes", () => {
523
- assertLinearScaling(transform, (n) => "'".repeat(n) + "a")
524
- })
525
- })
526
-
527
- describe("special regex characters in input", () => {
528
- it.each([
529
- ['"test [bracket]"', `${LEFT_DOUBLE_QUOTE}test [bracket]${RIGHT_DOUBLE_QUOTE}`],
530
- ['"test (paren)"', `${LEFT_DOUBLE_QUOTE}test (paren)${RIGHT_DOUBLE_QUOTE}`],
531
- ['"test {brace}"', `${LEFT_DOUBLE_QUOTE}test {brace}${RIGHT_DOUBLE_QUOTE}`],
532
- ['"test $dollar"', `${LEFT_DOUBLE_QUOTE}test $dollar${RIGHT_DOUBLE_QUOTE}`],
533
- ['"test ^caret"', `${LEFT_DOUBLE_QUOTE}test ^caret${RIGHT_DOUBLE_QUOTE}`],
534
- ['"test .dot"', `${LEFT_DOUBLE_QUOTE}test .dot${RIGHT_DOUBLE_QUOTE}`],
535
- ['"test |pipe"', `${LEFT_DOUBLE_QUOTE}test |pipe${RIGHT_DOUBLE_QUOTE}`],
536
- ])('handles regex special chars: "%s"', (input, expected) => {
537
- expect(transform(input, { nbsp: false })).toBe(expected)
538
- })
539
- })
540
-
541
- describe("boundary conditions", () => {
542
- it.each([
543
- ['"start', `${LEFT_DOUBLE_QUOTE}start`],
544
- ["'start", `${RIGHT_SINGLE_QUOTE}start`],
545
- ["-5 start", `${MINUS}5 start`],
546
- ["...start", `${ELLIPSIS} start`],
547
- ['end"', `end${RIGHT_DOUBLE_QUOTE}`],
548
- ["end'", `end${RIGHT_SINGLE_QUOTE}`],
549
- ["end...", `end${ELLIPSIS}`],
550
- ["5x", `5${MULTIPLICATION}`],
551
- ])('handles boundary: "%s"', (input, expected) => {
552
- expect(transform(input, { nbsp: false })).toBe(expected)
553
- })
554
- })
555
-
556
- describe("separator validation", () => {
557
- const invalidSeparators = [
558
- ["🎉", "emoji (non-BMP)"],
559
- ["", "empty string"],
560
- ] as const
561
-
562
- it.each(invalidSeparators)("rejects %s separator (%s)", (sep) => {
563
- expect(() => transform('"Hello"', { separator: sep, nbsp: false })).toThrow(
564
- /Invalid separator/
565
- )
566
- })
567
-
568
- const validSeparators = ["\uE000\uE001", "\uE000", "|", "\u2603", "ab"] // Default, single PUA, pipe, snowman, multi-char
569
-
570
- it.each(validSeparators)("accepts '%s' as separator", (sep) => {
571
- expect(() => transform('"Hello"', { separator: sep, nbsp: false })).not.toThrow()
572
- })
573
- })
574
-
575
- describe("regex-special separator characters", () => {
576
- it.each(REGEX_SPECIAL_CHARS)("handles '%s' as separator", (sep) => {
577
- expect(transform('"Hello"', { separator: sep, nbsp: false })).toEqual(
578
- `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`
579
- )
580
- })
581
- })
582
-
583
- describe("multi-character separator behavior", () => {
584
- const sep = "\uE000\uE001"
585
-
586
- it.each([
587
- ["quotes with separator at boundary", `${sep}"Hello"`, `${sep}${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`],
588
- ["em-dash with adjacent separators", `word${sep} - ${sep}word`, `word${sep}${EM_DASH}${sep}word`],
589
- ["ellipsis with separator between dots", `a.${sep}.${sep}.`, `a\u2026${sep}${sep}`],
590
- ["nbsp before last word with trailing separator", `Hello world${sep}`, `Hello${NBSP}world${sep}`],
591
- ])("%s", (_desc, input, expected) => {
592
- expect(transform(input, { separator: sep, nbsp: true })).toBe(expected)
593
- })
594
-
595
- it("preserves separator count through transform", () => {
596
- const input = `${sep}"Hello"${sep} -- ${sep}world${sep}`
597
- const result = transform(input, { separator: sep })
598
- expect(countSeparators(result, sep)).toBe(countSeparators(input, sep))
599
- })
600
- })
601
-
602
- describe("Unicode preservation", () => {
603
- it.each([
604
- ["emoji", '"Hello 👋 World"', `${LEFT_DOUBLE_QUOTE}Hello 👋 World${RIGHT_DOUBLE_QUOTE}`],
605
- ["RTL text", '"שלום"', `${LEFT_DOUBLE_QUOTE}שלום${RIGHT_DOUBLE_QUOTE}`],
606
- ["zero-width space", '"Hello\u200BWorld"', `${LEFT_DOUBLE_QUOTE}Hello\u200BWorld${RIGHT_DOUBLE_QUOTE}`],
607
- ] as const)("preserves %s in input", (_, input, expected) => {
608
- expect(transform(input, { nbsp: false })).toEqual(expected)
609
- })
610
- })
611
-
612
- describe("large input handling", () => {
613
- it("scales linearly on seeded mixed content", () => {
614
- assertLinearScaling(transform, (n) => buildMixedContent(n * 100), 50)
615
- })
616
-
617
- it("scales linearly with all features enabled", () => {
618
- const allFeatures = { fractions: true, degrees: true, superscript: true, ligatures: true }
619
- assertLinearScaling((input) => transform(input, allFeatures), (n) => buildMixedContent(n * 100), 50)
620
- })
621
- })
622
-
623
- describe("idempotency across punctuation styles", () => {
624
- const mixedInput = `"Hello," she said -- "it's pages 1-5." Wait... 5x5 != 25. He's 5'10" tall.`
625
-
626
- it.each([
627
- ["american"],
628
- ["british"],
629
- ["german"],
630
- ["french"],
631
- ] as const)("idempotent with %s style", (style) => {
632
- const opts = { punctuationStyle: style, checkIdempotency: false, nbsp: false } as const
633
- const first = transform(mixedInput, opts)
634
- const second = transform(first, opts)
635
- expect(second).toBe(first)
636
- })
637
- })
638
-
639
- describe("edge case inputs", () => {
640
- it.each([
641
- ["empty string", "", ""],
642
- ["single space", " ", " "],
643
- ["newlines only", "\n\n\n", "\n\n\n"],
644
- ["tab only", "\t", "\t"],
645
- ["nbsp only", NBSP, NBSP],
646
- ])("handles %s", (_desc, input, expected) => {
647
- expect(transform(input)).toBe(expected)
648
- })
649
- })
650
-
651
- describe("separator collision", () => {
652
- it("preserves default separator in plain transform", () => {
653
- // transform() itself uses the separator internally but tolerates it in input
654
- // The assertSeparatorAbsent check is in the rehype/remark plugins
655
- const input = `text${DEFAULT_SEPARATOR}more`
656
- const result = transform(input)
657
- expect(countSeparators(result, DEFAULT_SEPARATOR)).toBe(1)
658
- })
659
-
660
- it("does not throw for single char of multi-char default separator", () => {
661
- expect(() => transform(`text${DEFAULT_SEPARATOR[0]}more`)).not.toThrow()
662
- })
663
- })
664
-
665
- describe("regex cache thrashing", () => {
666
- it("produces correct results after exceeding cache capacity", () => {
667
- const input = `"Hello," she said.`
668
- const separatorCount = MAX_REGEX_CACHE_SIZE + 200
669
- for (let i = 0; i < separatorCount; i++) {
670
- const sep = String.fromCharCode(0xE100 + i)
671
- const result = transform(input, { separator: sep, nbsp: false })
672
- expect(result).toBe(`${LEFT_DOUBLE_QUOTE}Hello,${RIGHT_DOUBLE_QUOTE} she said.`)
673
- }
674
- })
675
- })
676
-
677
- describe("ReDoS resistance", () => {
678
- it.each([
679
- ["single quotes", (n: number) => "'".repeat(n)],
680
- ["double quotes", (n: number) => '"'.repeat(n)],
681
- ["unbalanced quotes", (n: number) => '"Hello '.repeat(n)],
682
- ["hyphens", (n: number) => "-".repeat(n)],
683
- ["alternating digits/hyphens", (n: number) => "1-2-3-4-5-6-7-8-9-0".repeat(n / 10)],
684
- ["dots", (n: number) => ".".repeat(n)],
685
- ["fraction-like patterns", (n: number) => "1/2 ".repeat(n)],
686
- ["short words (nbsp)", (n: number) => "a b c d e f ".repeat(n)],
687
- ["math symbol patterns", (n: number) => "!= ".repeat(n)],
688
- ] as const)("scales linearly for %s", (_, buildInput) => {
689
- assertLinearScaling(transform, buildInput)
690
- })
691
-
692
- it("scales linearly on seeded mixed content", () => {
693
- assertLinearScaling(transform, (n) => buildMixedContent(n * 10))
694
- })
695
- })
696
-
697
- describe("edge cases", () => {
698
- it("handles empty string input", () => {
699
- expect(transform("")).toBe("")
700
- expect(transform("", { nbsp: false })).toBe("")
701
- })
702
-
703
- it("treats undefined option values as absent (uses defaults)", () => {
704
- const input = '"Hello," she said.'
705
- const withDefault = transform(input)
706
- expect(transform(input, { nbsp: undefined })).toBe(withDefault)
707
- })
708
-
709
- it("scales linearly for long non-transformable content", () => {
710
- assertLinearScaling(transform, (n) => "abcdefghij ".repeat(n))
711
- })
712
- })
713
- })