punctilio 3.7.3 → 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,783 +0,0 @@
1
- import { niceQuotes, classifyApostrophes } from "../quotes.js"
2
- import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, TERMINAL_PUNCTUATION } from "../constants.js"
3
- import { assertLinearScaling } from "./test-helpers.js"
4
-
5
- const {
6
- LEFT_DOUBLE_QUOTE,
7
- RIGHT_DOUBLE_QUOTE,
8
- LEFT_SINGLE_QUOTE,
9
- RIGHT_SINGLE_QUOTE,
10
- MODIFIER_LETTER_APOSTROPHE,
11
- EM_DASH,
12
- ELLIPSIS,
13
- DOUBLE_LOW_9_QUOTE,
14
- SINGLE_LOW_9_QUOTE,
15
- LEFT_GUILLEMET,
16
- RIGHT_GUILLEMET,
17
- NNBSP,
18
- } = UNICODE_SYMBOLS
19
-
20
- describe("niceQuotes", () => {
21
- describe("double quotes", () => {
22
- it.each([
23
- ['"This is a quote", she said.', `${LEFT_DOUBLE_QUOTE}This is a quote,${RIGHT_DOUBLE_QUOTE} she said.`],
24
- ['"This is a quote," she said.', `${LEFT_DOUBLE_QUOTE}This is a quote,${RIGHT_DOUBLE_QUOTE} she said.`],
25
- ['"This is a quote!".', `${LEFT_DOUBLE_QUOTE}This is a quote!${RIGHT_DOUBLE_QUOTE}.`],
26
- ['"This is a quote..." he trailed off.', `${LEFT_DOUBLE_QUOTE}This is a quote...${RIGHT_DOUBLE_QUOTE} he trailed off.`],
27
- ['She said, "This is a quote."', `She said, ${LEFT_DOUBLE_QUOTE}This is a quote.${RIGHT_DOUBLE_QUOTE}`],
28
- ['"Hello." Mary', `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} Mary`],
29
- ['"Hello." (Mary)', `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} (Mary)`],
30
- [
31
- '"I am" so "tired" of "these" "quotes".',
32
- `${LEFT_DOUBLE_QUOTE}I am${RIGHT_DOUBLE_QUOTE} so ${LEFT_DOUBLE_QUOTE}tired${RIGHT_DOUBLE_QUOTE} of ${LEFT_DOUBLE_QUOTE}these${RIGHT_DOUBLE_QUOTE} ${LEFT_DOUBLE_QUOTE}quotes.${RIGHT_DOUBLE_QUOTE}`,
33
- ],
34
- ['"world model";', `${LEFT_DOUBLE_QUOTE}world model${RIGHT_DOUBLE_QUOTE};`],
35
- ['"party"/"wedding."', `${LEFT_DOUBLE_QUOTE}party${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}wedding.${RIGHT_DOUBLE_QUOTE}`],
36
- ['"Hi \'Trout!"', `${LEFT_DOUBLE_QUOTE}Hi ${MODIFIER_LETTER_APOSTROPHE}Trout!${RIGHT_DOUBLE_QUOTE}`],
37
- [`${LEFT_DOUBLE_QUOTE}scope insensitivity${RIGHT_DOUBLE_QUOTE}`, `${LEFT_DOUBLE_QUOTE}scope insensitivity${RIGHT_DOUBLE_QUOTE}`],
38
- [
39
- '"how many ways can this function be implemented?".',
40
- `${LEFT_DOUBLE_QUOTE}how many ways can this function be implemented?${RIGHT_DOUBLE_QUOTE}.`,
41
- ],
42
- ['SSL.")', `SSL.${RIGHT_DOUBLE_QUOTE})`],
43
- ["can't multiply\"?", `can${MODIFIER_LETTER_APOSTROPHE}t multiply${RIGHT_DOUBLE_QUOTE}?`],
44
- ['with "scope insensitivity":', `with ${LEFT_DOUBLE_QUOTE}scope insensitivity${RIGHT_DOUBLE_QUOTE}:`],
45
- ['("the best")', `(${LEFT_DOUBLE_QUOTE}the best${RIGHT_DOUBLE_QUOTE})`],
46
- ['"This is a quote"...', `${LEFT_DOUBLE_QUOTE}This is a quote${RIGHT_DOUBLE_QUOTE}...`],
47
- ['He said, "This is a quote"...', `He said, ${LEFT_DOUBLE_QUOTE}This is a quote${RIGHT_DOUBLE_QUOTE}...`],
48
- ['"... What is this?"', `${LEFT_DOUBLE_QUOTE}... What is this?${RIGHT_DOUBLE_QUOTE}`],
49
- ['"/"', `${LEFT_DOUBLE_QUOTE}/${RIGHT_DOUBLE_QUOTE}`],
50
- ['"Game"/"Life"', `${LEFT_DOUBLE_QUOTE}Game${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}Life${RIGHT_DOUBLE_QUOTE}`],
51
- ['"Test:".', `${LEFT_DOUBLE_QUOTE}Test:${RIGHT_DOUBLE_QUOTE}.`],
52
- ['"Test...".', `${LEFT_DOUBLE_QUOTE}Test...${RIGHT_DOUBLE_QUOTE}.`],
53
- [`"To maximize reward${ELLIPSIS}".`, `${LEFT_DOUBLE_QUOTE}To maximize reward${ELLIPSIS}${RIGHT_DOUBLE_QUOTE}.`],
54
- ['"Test"s', `${LEFT_DOUBLE_QUOTE}Test${RIGHT_DOUBLE_QUOTE}s`],
55
- // End-of-line quote becomes RIGHT quote
56
- ['not confident in that plan - "', `not confident in that plan - ${RIGHT_DOUBLE_QUOTE}`],
57
- ])('should convert double quotes in "%s"', (input, expected) => {
58
- expect(classifyApostrophes(input)).toBe(expected)
59
- })
60
- })
61
-
62
- describe("quoted punctuation (short strings of punctuation inside quotes)", () => {
63
- it.each([
64
- ['"?"', `${LEFT_DOUBLE_QUOTE}?${RIGHT_DOUBLE_QUOTE}`],
65
- ['"!"', `${LEFT_DOUBLE_QUOTE}!${RIGHT_DOUBLE_QUOTE}`],
66
- ['"."', `${LEFT_DOUBLE_QUOTE}.${RIGHT_DOUBLE_QUOTE}`],
67
- ['","', `${LEFT_DOUBLE_QUOTE},${RIGHT_DOUBLE_QUOTE}`],
68
- ['";"', `${LEFT_DOUBLE_QUOTE};${RIGHT_DOUBLE_QUOTE}`],
69
- ['"?!"', `${LEFT_DOUBLE_QUOTE}?!${RIGHT_DOUBLE_QUOTE}`],
70
- ['why not "?"', `why not ${LEFT_DOUBLE_QUOTE}?${RIGHT_DOUBLE_QUOTE}`],
71
- ['She asked "?" and left.', `She asked ${LEFT_DOUBLE_QUOTE}?${RIGHT_DOUBLE_QUOTE} and left.`],
72
- ['("?")', `(${LEFT_DOUBLE_QUOTE}?${RIGHT_DOUBLE_QUOTE})`],
73
- ])('handles quoted punctuation: "%s"', (input, expected) => {
74
- expect(classifyApostrophes(input)).toBe(expected)
75
- })
76
- })
77
-
78
- describe("single quotes and apostrophes", () => {
79
- it.each([
80
- ["He said, 'Hi'", `He said, ${LEFT_SINGLE_QUOTE}Hi${RIGHT_SINGLE_QUOTE}`],
81
- ["He wanted 'power.'", `He wanted ${LEFT_SINGLE_QUOTE}power.${RIGHT_SINGLE_QUOTE}`],
82
- ["I'd", `I${MODIFIER_LETTER_APOSTROPHE}d`],
83
- ["I don't'nt want to go", `I don${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}nt want to go`],
84
- ['"\'sup"', `${LEFT_DOUBLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}sup${RIGHT_DOUBLE_QUOTE}`],
85
- ["'SUP", `${MODIFIER_LETTER_APOSTROPHE}SUP`],
86
- ["Rock 'n' Roll", `Rock ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} Roll`],
87
- ["I was born in '99", `I was born in ${MODIFIER_LETTER_APOSTROPHE}99`],
88
- ["'99 tigers weren't a match", `${MODIFIER_LETTER_APOSTROPHE}99 tigers weren${MODIFIER_LETTER_APOSTROPHE}t a match`],
89
- [
90
- "I'm not the best, haven't you heard?",
91
- `I${MODIFIER_LETTER_APOSTROPHE}m not the best, haven${MODIFIER_LETTER_APOSTROPHE}t you heard?`,
92
- ],
93
- // Skipped: Complex edge case with 'sup and quoted phrase
94
- // ["Hey, 'sup 'this is a single quote'", `Hey, ${RIGHT_SINGLE_QUOTE}sup ${LEFT_SINGLE_QUOTE}this is a single quote${RIGHT_SINGLE_QUOTE}`],
95
- ["'the best',", `${LEFT_SINGLE_QUOTE}the best,${RIGHT_SINGLE_QUOTE}`],
96
- ["'I lost the game.'", `${LEFT_SINGLE_QUOTE}I lost the game.${RIGHT_SINGLE_QUOTE}`],
97
- ["I hate you.'\"", `I hate you.${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`],
98
- ["The 'function space')", `The ${LEFT_SINGLE_QUOTE}function space${RIGHT_SINGLE_QUOTE})`],
99
- [`The 'function space'${EM_DASH}`, `The ${LEFT_SINGLE_QUOTE}function space${RIGHT_SINGLE_QUOTE}${EM_DASH}`],
100
- ["What do you think?']", `What do you think?${RIGHT_SINGLE_QUOTE}]`],
101
- ["('survival incentive')", `(${LEFT_SINGLE_QUOTE}survival incentive${RIGHT_SINGLE_QUOTE})`],
102
- [
103
- "strategy s's return is good, even as d's return is bad",
104
- `strategy s${MODIFIER_LETTER_APOSTROPHE}s return is good, even as d${MODIFIER_LETTER_APOSTROPHE}s return is bad`,
105
- ],
106
- // Chained possessives
107
- ["the dog's owner's car", `the dog${MODIFIER_LETTER_APOSTROPHE}s owner${MODIFIER_LETTER_APOSTROPHE}s car`],
108
- // Hyphenated possessive
109
- ["mother-in-law's house", `mother-in-law${MODIFIER_LETTER_APOSTROPHE}s house`],
110
- // Accented character before possessive
111
- ["café's menu", `café${MODIFIER_LETTER_APOSTROPHE}s menu`],
112
- // Digit before possessive
113
- ["the 747's engine", `the 747${MODIFIER_LETTER_APOSTROPHE}s engine`],
114
- // Compound contraction + possessive
115
- ["shouldn't's", `shouldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}s`],
116
- // Contraction before em-dash
117
- [`don't${EM_DASH}stop`, `don${MODIFIER_LETTER_APOSTROPHE}t${EM_DASH}stop`],
118
- // Contraction + digit context
119
- ["it's 5 o'clock", `it${MODIFIER_LETTER_APOSTROPHE}s 5 o${MODIFIER_LETTER_APOSTROPHE}clock`],
120
- // French contraction (single-letter prefix + accented Latin)
121
- ["l'homme", `l${MODIFIER_LETTER_APOSTROPHE}homme`],
122
- // Uppercase contraction/name
123
- ["O'NEILL", `O${MODIFIER_LETTER_APOSTROPHE}NEILL`],
124
- // Possessive in parenthetical
125
- ["(Brien's)", `(Brien${MODIFIER_LETTER_APOSTROPHE}s)`],
126
- // Possessive inside single-quoted phrase
127
- ["'the dog's bowl'", `${LEFT_SINGLE_QUOTE}the dog${MODIFIER_LETTER_APOSTROPHE}s bowl${RIGHT_SINGLE_QUOTE}`],
128
- // Straight apostrophe inside pre-converted double quotes
129
- [`${LEFT_DOUBLE_QUOTE}the dog's${RIGHT_DOUBLE_QUOTE}`, `${LEFT_DOUBLE_QUOTE}the dog${MODIFIER_LETTER_APOSTROPHE}s${RIGHT_DOUBLE_QUOTE}`],
130
- // Decade in double quotes
131
- [`"the '90s"`, `${LEFT_DOUBLE_QUOTE}the ${MODIFIER_LETTER_APOSTROPHE}90s${RIGHT_DOUBLE_QUOTE}`],
132
- // 'n' + possessive combination
133
- ["Rock 'n' Roll's greatest hits", `Rock ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} Roll${MODIFIER_LETTER_APOSTROPHE}s greatest hits`],
134
- // Non-Latin character before possessive
135
- ["Привет's", `Привет${MODIFIER_LETTER_APOSTROPHE}s`],
136
- // Closing RSQ then opening LDQ
137
- [`'hello' "world"`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`],
138
- // Latin Extended Additional: Vietnamese contraction-like patterns
139
- ["l'\u1EA3nh", `l${MODIFIER_LETTER_APOSTROPHE}\u1EA3nh`],
140
- // Latin Extended Additional: Welsh w-acute (U+1E83, in U+1E00-1EFF range)
141
- ["l'\u1E83r", `l${MODIFIER_LETTER_APOSTROPHE}\u1E83r`],
142
- ])('should handle single quotes/apostrophes in "%s"', (input, expected) => {
143
- expect(classifyApostrophes(input)).toBe(expected)
144
- })
145
- })
146
-
147
- describe("plural possessive apostrophes", () => {
148
- it.each([
149
- // Unmatched trailing s' → MLA
150
- ["the models' behavior", `the models${MODIFIER_LETTER_APOSTROPHE} behavior`],
151
- ["Bayes' rule", `Bayes${MODIFIER_LETTER_APOSTROPHE} rule`],
152
- ["Thomas' gaze", `Thomas${MODIFIER_LETTER_APOSTROPHE} gaze`],
153
- ["dogs' and cats' toys", `dogs${MODIFIER_LETTER_APOSTROPHE} and cats${MODIFIER_LETTER_APOSTROPHE} toys`],
154
- ["belongs to the dogs'", `belongs to the dogs${MODIFIER_LETTER_APOSTROPHE}`],
155
- ["the DOGS' owner", `the DOGS${MODIFIER_LETTER_APOSTROPHE} owner`],
156
- ["Prisoners' Dilemma", `Prisoners${MODIFIER_LETTER_APOSTROPHE} Dilemma`],
157
- // Mixed possessive + quoted phrase
158
- ["dogs' and 'hello'", `dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}`],
159
- ["'cats' and dogs'", `${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE} and dogs${MODIFIER_LETTER_APOSTROPHE}`],
160
- // Matched closing quote after s stays RSQ
161
- ["'yes'", `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`],
162
- ["'dogs'", `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE}`],
163
- ["'he tells' stories", `${LEFT_SINGLE_QUOTE}he tells${RIGHT_SINGLE_QUOTE} stories`],
164
- ])('handles plural possessive: "%s"', (input, expected) => {
165
- expect(classifyApostrophes(input)).toBe(expected)
166
- })
167
-
168
- it.each([
169
- `the dogs${MODIFIER_LETTER_APOSTROPHE} owner`,
170
- `Bayes${MODIFIER_LETTER_APOSTROPHE} rule`,
171
- `dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE}`,
172
- ])('plural possessive is idempotent: "%s"', (input) => {
173
- expect(classifyApostrophes(input)).toBe(input)
174
- expect(classifyApostrophes(classifyApostrophes(input))).toBe(input)
175
- })
176
-
177
- it("stress: many possessives interleaved with quotes", () => {
178
- // 50 alternating possessives and quoted phrases
179
- const pairs = Array.from({ length: 50 }, (_, i) =>
180
- i % 2 === 0 ? `dogs'` : `'yes'`
181
- )
182
- const input = pairs.join(" ")
183
- const result = classifyApostrophes(input)
184
- // Every dogs' → MLA, every 'yes' → LSQ+yes+RSQ
185
- const expectedParts = Array.from({ length: 50 }, (_, i) =>
186
- i % 2 === 0
187
- ? `dogs${MODIFIER_LETTER_APOSTROPHE}`
188
- : `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`
189
- )
190
- expect(result).toBe(expectedParts.join(" "))
191
- expect(classifyApostrophes(result)).toBe(result)
192
- })
193
-
194
- it("stress: 200 consecutive plural possessives scales linearly", () => {
195
- const words = ["dogs'", "cats'", "Bayes'", "Thomas'", "PLAYERS'"]
196
- const buildInput = (n: number) => Array.from({ length: n }, (_, i) => words[i % words.length]).join(" ")
197
-
198
- assertLinearScaling(classifyApostrophes, buildInput)
199
- // All should be MLA (no LSQ to pair with)
200
- const result = classifyApostrophes(buildInput(200))
201
- expect(result).not.toContain(RIGHT_SINGLE_QUOTE)
202
- expect(result).not.toContain("'")
203
- expect(classifyApostrophes(result)).toBe(result)
204
- })
205
-
206
- it("stress: deeply nested alternating pattern", () => {
207
- // Pattern: 'a' dogs' 'b' cats' 'c' ...
208
- const possessives = ["dogs", "cats", "items", "players", "Bayes"]
209
- const input = Array.from({ length: 30 }, (_, i) =>
210
- i % 2 === 0 ? `'word${i}'` : `${possessives[i % possessives.length]}'`
211
- ).join(" ")
212
- const result = classifyApostrophes(input)
213
- // Quoted words get LSQ/RSQ, possessives get MLA
214
- for (let i = 0; i < 30; i++) {
215
- if (i % 2 === 0) {
216
- expect(result).toContain(`${LEFT_SINGLE_QUOTE}word${i}${RIGHT_SINGLE_QUOTE}`)
217
- } else {
218
- expect(result).toContain(`${possessives[i % possessives.length]}${MODIFIER_LETTER_APOSTROPHE}`)
219
- }
220
- }
221
- expect(classifyApostrophes(result)).toBe(result)
222
- })
223
- })
224
-
225
- describe("leading apostrophe contractions", () => {
226
- it.each([
227
- ["'twas the night", `${MODIFIER_LETTER_APOSTROPHE}twas the night`],
228
- ["'tis the season", `${MODIFIER_LETTER_APOSTROPHE}tis the season`],
229
- ["'Twas brillig", `${MODIFIER_LETTER_APOSTROPHE}Twas brillig`],
230
- ["'Tis but a scratch", `${MODIFIER_LETTER_APOSTROPHE}Tis but a scratch`],
231
- // Decade abbreviations
232
- ["the '90s", `the ${MODIFIER_LETTER_APOSTROPHE}90s`],
233
- ["in '99", `in ${MODIFIER_LETTER_APOSTROPHE}99`],
234
- ["the '00s", `the ${MODIFIER_LETTER_APOSTROPHE}00s`],
235
- // Additional leading contractions
236
- ["'cause", `${MODIFIER_LETTER_APOSTROPHE}cause`],
237
- ["'twas the night, 'twas indeed", `${MODIFIER_LETTER_APOSTROPHE}twas the night, ${MODIFIER_LETTER_APOSTROPHE}twas indeed`],
238
- [`"'twas the night"`, `${LEFT_DOUBLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}twas the night${RIGHT_DOUBLE_QUOTE}`],
239
- ])('handles leading apostrophe in "%s"', (input, expected) => {
240
- expect(classifyApostrophes(input)).toBe(expected)
241
- })
242
-
243
- it("multiple decades in one sentence", () => {
244
- expect(classifyApostrophes("The '60s, '70s, and '80s were different.")).toBe(
245
- `The ${MODIFIER_LETTER_APOSTROPHE}60s, ${MODIFIER_LETTER_APOSTROPHE}70s, and ${MODIFIER_LETTER_APOSTROPHE}80s were different.`
246
- )
247
- })
248
- })
249
-
250
- describe("'n' abbreviation edge cases", () => {
251
- it.each([
252
- ["uppercase 'N' is not abbreviated", "Rock 'N' Roll", `Rock ${LEFT_SINGLE_QUOTE}N${RIGHT_SINGLE_QUOTE} Roll`],
253
- ["multiple 'n' abbreviations", "Rock 'n' Roll and fish 'n' chips", `Rock ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} Roll and fish ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} chips`],
254
- ["digit neighbors (\\w includes digits)", "1 'n' 2", `1 ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} 2`],
255
- ["known false positive: quoted letter", "the letter 'n' is common", `the letter ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} is common`],
256
- ])('%s', (_label, input, expected) => {
257
- expect(classifyApostrophes(input)).toBe(expected)
258
- })
259
-
260
- it.each([
261
- ["start of string (no preceding word)", "'n' Roll", `${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} Roll`],
262
- ["end of string (no following word)", "Rock 'n'", `Rock ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE}`],
263
- ["punctuation precedes first apostrophe", "said, 'n' is good", `said, ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} is good`],
264
- ["newline replaces space", "Rock 'n'\nRoll", `Rock ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE}\nRoll`],
265
- ])('does not produce MLA-n-MLA: %s', (_label, input, expected) => {
266
- expect(classifyApostrophes(input)).toBe(expected)
267
- })
268
- })
269
-
270
- // Tests derived from competitor libraries and typography guidelines
271
- describe("competitor-derived edge cases", () => {
272
- // From smartquotes.js: Unicode text within quotes
273
- it.each([
274
- ['"Águila"', `${LEFT_DOUBLE_QUOTE}Águila${RIGHT_DOUBLE_QUOTE}`],
275
- ['"café"', `${LEFT_DOUBLE_QUOTE}café${RIGHT_DOUBLE_QUOTE}`],
276
- ['"naïve"', `${LEFT_DOUBLE_QUOTE}naïve${RIGHT_DOUBLE_QUOTE}`],
277
- ['"日本語"', `${LEFT_DOUBLE_QUOTE}日本語${RIGHT_DOUBLE_QUOTE}`],
278
- ['"שלום"', `${LEFT_DOUBLE_QUOTE}שלום${RIGHT_DOUBLE_QUOTE}`],
279
- ])('handles Unicode text in quotes: "%s"', (input, expected) => {
280
- expect(niceQuotes(input)).toBe(expected)
281
- })
282
-
283
- // From retext-smartypants: quotes after em dashes
284
- it.each([
285
- [`He said${EM_DASH}"Hello"`, `He said${EM_DASH}${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`],
286
- [`word${EM_DASH}"quoted"${EM_DASH}word`, `word${EM_DASH}${LEFT_DOUBLE_QUOTE}quoted${RIGHT_DOUBLE_QUOTE}${EM_DASH}word`],
287
- ])('handles quotes adjacent to em dashes: "%s"', (input, expected) => {
288
- expect(niceQuotes(input)).toBe(expected)
289
- })
290
-
291
- it("handles single quote after em-dash", () => {
292
- const input = `${EM_DASH}'Hi'${EM_DASH}`
293
- const result = niceQuotes(input)
294
- expect(result).toBe(`${EM_DASH}${LEFT_SINGLE_QUOTE}Hi${RIGHT_SINGLE_QUOTE}${EM_DASH}`)
295
- })
296
-
297
- // From Standard Ebooks: M'Donald-style names (archaic patterns)
298
- it.each([
299
- [`"M'Lord"`, `${LEFT_DOUBLE_QUOTE}M${MODIFIER_LETTER_APOSTROPHE}Lord${RIGHT_DOUBLE_QUOTE}`],
300
- [`O'Brien's idea`, `O${MODIFIER_LETTER_APOSTROPHE}Brien${MODIFIER_LETTER_APOSTROPHE}s idea`],
301
- [`The O'Connors`, `The O${MODIFIER_LETTER_APOSTROPHE}Connors`],
302
- ])('handles Irish/Scottish name apostrophes: "%s"', (input, expected) => {
303
- expect(classifyApostrophes(input)).toBe(expected)
304
- })
305
-
306
- // From smartquotes.js: complex nested quotes
307
- it.each([
308
- ['"Alfred \'bertrand\' cees"', `${LEFT_DOUBLE_QUOTE}Alfred ${LEFT_SINGLE_QUOTE}bertrand${RIGHT_SINGLE_QUOTE} cees${RIGHT_DOUBLE_QUOTE}`],
309
- ["'Alfred \"bertrand\" cees'", `${LEFT_SINGLE_QUOTE}Alfred ${LEFT_DOUBLE_QUOTE}bertrand${RIGHT_DOUBLE_QUOTE} cees${RIGHT_SINGLE_QUOTE}`],
310
- ])('handles complex nested quotes: "%s"', (input, expected) => {
311
- expect(niceQuotes(input)).toBe(expected)
312
- })
313
-
314
- // From retext-smartypants: quotes with ellipsis
315
- it.each([
316
- [`"${ELLIPSIS}"`, `${LEFT_DOUBLE_QUOTE}${ELLIPSIS}${RIGHT_DOUBLE_QUOTE}`],
317
- [`"Wait${ELLIPSIS}"`, `${LEFT_DOUBLE_QUOTE}Wait${ELLIPSIS}${RIGHT_DOUBLE_QUOTE}`],
318
- [`'${ELLIPSIS}'`, `${LEFT_SINGLE_QUOTE}${ELLIPSIS}${RIGHT_SINGLE_QUOTE}`],
319
- ])('handles quotes with ellipsis: "%s"', (input, expected) => {
320
- expect(niceQuotes(input)).toBe(expected)
321
- })
322
- })
323
-
324
- describe("complex real-world patterns", () => {
325
- it.each([
326
- // Dialogue with attribution
327
- ['"I can\'t," she said.', `${LEFT_DOUBLE_QUOTE}I can${MODIFIER_LETTER_APOSTROPHE}t,${RIGHT_DOUBLE_QUOTE} she said.`],
328
- ['"Why not?" he asked.', `${LEFT_DOUBLE_QUOTE}Why not?${RIGHT_DOUBLE_QUOTE} he asked.`],
329
- // Multiple contractions
330
- ["I'm can't won't don't", `I${MODIFIER_LETTER_APOSTROPHE}m can${MODIFIER_LETTER_APOSTROPHE}t won${MODIFIER_LETTER_APOSTROPHE}t don${MODIFIER_LETTER_APOSTROPHE}t`],
331
- // Quote within parentheses
332
- ['("test")', `(${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE})`],
333
- ["('test')", `(${LEFT_SINGLE_QUOTE}test${RIGHT_SINGLE_QUOTE})`],
334
- // Quote with slash separator
335
- ['"option1"/"option2"', `${LEFT_DOUBLE_QUOTE}option1${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}option2${RIGHT_DOUBLE_QUOTE}`],
336
- ])('handles complex pattern: "%s"', (input, expected) => {
337
- expect(classifyApostrophes(input)).toBe(expected)
338
- })
339
-
340
- it("handles multi-line dialogue", () => {
341
- const input = '"Hello,"\n"World"'
342
- const expected = `${LEFT_DOUBLE_QUOTE}Hello,${RIGHT_DOUBLE_QUOTE}\n${LEFT_DOUBLE_QUOTE}World${RIGHT_DOUBLE_QUOTE}`
343
- expect(niceQuotes(input)).toBe(expected)
344
- })
345
- })
346
-
347
- describe("idempotency", () => {
348
- it.each([
349
- `${LEFT_DOUBLE_QUOTE}already curly${RIGHT_DOUBLE_QUOTE}`,
350
- `${LEFT_SINGLE_QUOTE}single curly${RIGHT_SINGLE_QUOTE}`,
351
- `I${MODIFIER_LETTER_APOSTROPHE}m already converted`,
352
- `${LEFT_DOUBLE_QUOTE}nested ${LEFT_SINGLE_QUOTE}quotes${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`,
353
- // French contractions
354
- `l${MODIFIER_LETTER_APOSTROPHE}homme d${MODIFIER_LETTER_APOSTROPHE}accord`,
355
- // Uppercase
356
- `O${MODIFIER_LETTER_APOSTROPHE}NEILL`,
357
- `CAN${MODIFIER_LETTER_APOSTROPHE}T`,
358
- // Plural possessive
359
- `the dogs${MODIFIER_LETTER_APOSTROPHE} owner`,
360
- // Mixed quote types
361
- `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`,
362
- ])('is idempotent for: "%s"', (input) => {
363
- expect(classifyApostrophes(input)).toBe(input)
364
- expect(classifyApostrophes(classifyApostrophes(input))).toBe(input)
365
- })
366
- })
367
-
368
- describe("with separator character", () => {
369
- const sep = DEFAULT_SEPARATOR
370
- it.each([
371
- [`"Hello${sep} world"`, `${LEFT_DOUBLE_QUOTE}Hello${sep} world${RIGHT_DOUBLE_QUOTE}`, "preserves separator positions"],
372
- [`don${sep}'t`, `don${sep}${MODIFIER_LETTER_APOSTROPHE}t`, "contractions across separator"],
373
- [`"test${sep}"`, `${LEFT_DOUBLE_QUOTE}test${sep}${RIGHT_DOUBLE_QUOTE}`, "quotes at separator boundaries"],
374
- // Possessive across separator
375
- [`dog${sep}'s bone`, `dog${sep}${MODIFIER_LETTER_APOSTROPHE}s bone`, "possessive across separator"],
376
- // Separator between apostrophe and s
377
- [`the dog'${sep}s bone`, `the dog${MODIFIER_LETTER_APOSTROPHE}${sep}s bone`, "separator between apostrophe and s"],
378
- // Opening after separator
379
- [`${sep}'hello'`, `${sep}${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}`, "opening quote after separator"],
380
- // Closing after separator
381
- [`'hello${sep}'`, `${LEFT_SINGLE_QUOTE}hello${sep}${RIGHT_SINGLE_QUOTE}`, "closing quote after separator"],
382
- ])("%s → %s (%s)", (input, expected) => {
383
- expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
384
- })
385
-
386
- it("plural possessive across separator", () => {
387
- const input = `dogs${sep}' food`
388
- const expected = `dogs${sep}${MODIFIER_LETTER_APOSTROPHE} food`
389
- expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
390
- expect(classifyApostrophes(expected, { separator: sep })).toBe(expected)
391
- })
392
-
393
- it("'n' with separators around word boundaries", () => {
394
- const input = `Rock${sep} 'n' ${sep}Roll`
395
- const expected = `Rock${sep} ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} ${sep}Roll`
396
- expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
397
- expect(classifyApostrophes(expected, { separator: sep })).toBe(expected)
398
- })
399
- })
400
-
401
- describe("unbalanced and edge quotes", () => {
402
- it.each([
403
- ['"unclosed', `${LEFT_DOUBLE_QUOTE}unclosed`],
404
- ["unclosed'", `unclosed${RIGHT_SINGLE_QUOTE}`],
405
- ['""', `${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`],
406
- // Minimal inputs
407
- ["", ""],
408
- [" ", " "],
409
- ["'", `${MODIFIER_LETTER_APOSTROPHE}`],
410
- ["'a", `${MODIFIER_LETTER_APOSTROPHE}a`],
411
- ["a'", `a${RIGHT_SINGLE_QUOTE}`],
412
- ])('handles edge quote pattern: "%s"', (input, expected) => {
413
- expect(classifyApostrophes(input)).toBe(expected)
414
- })
415
- })
416
-
417
- describe("quotes after various punctuation", () => {
418
- it.each([
419
- ['text; "quote"', `text; ${LEFT_DOUBLE_QUOTE}quote${RIGHT_DOUBLE_QUOTE}`],
420
- ['title: "quote"', `title: ${LEFT_DOUBLE_QUOTE}quote${RIGHT_DOUBLE_QUOTE}`],
421
- ['what? "quote"', `what? ${LEFT_DOUBLE_QUOTE}quote${RIGHT_DOUBLE_QUOTE}`],
422
- ['wow! "quote"', `wow! ${LEFT_DOUBLE_QUOTE}quote${RIGHT_DOUBLE_QUOTE}`],
423
- ['[note] "quote"', `[note] ${LEFT_DOUBLE_QUOTE}quote${RIGHT_DOUBLE_QUOTE}`],
424
- ['{code} "quote"', `{code} ${LEFT_DOUBLE_QUOTE}quote${RIGHT_DOUBLE_QUOTE}`],
425
- ])('handles quotes after punctuation: "%s"', (input, expected) => {
426
- expect(niceQuotes(input)).toBe(expected)
427
- })
428
- })
429
-
430
- describe("consecutive contractions", () => {
431
- it.each([
432
- ["I'd've", `I${MODIFIER_LETTER_APOSTROPHE}d${MODIFIER_LETTER_APOSTROPHE}ve`],
433
- ["y'all'd've", `y${MODIFIER_LETTER_APOSTROPHE}all${MODIFIER_LETTER_APOSTROPHE}d${MODIFIER_LETTER_APOSTROPHE}ve`],
434
- ["wouldn't've", `wouldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
435
- ["couldn't've", `couldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
436
- ])('handles double contraction: "%s"', (input, expected) => {
437
- expect(classifyApostrophes(input)).toBe(expected)
438
- })
439
- })
440
-
441
- describe("multiline quotes", () => {
442
- it.each([
443
- ['"Hello\nWorld"', `${LEFT_DOUBLE_QUOTE}Hello\nWorld${RIGHT_DOUBLE_QUOTE}`],
444
- ['"A"\n"B"', `${LEFT_DOUBLE_QUOTE}A${RIGHT_DOUBLE_QUOTE}\n${LEFT_DOUBLE_QUOTE}B${RIGHT_DOUBLE_QUOTE}`],
445
- // Contractions across lines
446
- ["I can't\nbelieve it's\nnot butter", `I can${MODIFIER_LETTER_APOSTROPHE}t\nbelieve it${MODIFIER_LETTER_APOSTROPHE}s\nnot butter`],
447
- // Newline stops lookahead → MLA (not LSQ) since no closing quote visible
448
- ["'hello\nworld'", `${MODIFIER_LETTER_APOSTROPHE}hello\nworld${RIGHT_SINGLE_QUOTE}`],
449
- ])('handles multiline: "%s"', (input, expected) => {
450
- expect(classifyApostrophes(input)).toBe(expected)
451
- })
452
- })
453
-
454
- describe("split dialogue", () => {
455
- it.each([
456
- [
457
- '"Yes," he said, "absolutely."',
458
- `${LEFT_DOUBLE_QUOTE}Yes,${RIGHT_DOUBLE_QUOTE} he said, ${LEFT_DOUBLE_QUOTE}absolutely.${RIGHT_DOUBLE_QUOTE}`,
459
- ],
460
- [
461
- '"No," she replied, "I disagree."',
462
- `${LEFT_DOUBLE_QUOTE}No,${RIGHT_DOUBLE_QUOTE} she replied, ${LEFT_DOUBLE_QUOTE}I disagree.${RIGHT_DOUBLE_QUOTE}`,
463
- ],
464
- ])('handles split dialogue: "%s"', (input, expected) => {
465
- expect(niceQuotes(input)).toBe(expected)
466
- })
467
- })
468
-
469
- describe("punctuationStyle option", () => {
470
- // Reusable test strings
471
- const periodOutsideDouble = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}.`
472
- const periodInsideDouble = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}`
473
- const periodOutsideSingle = `${LEFT_SINGLE_QUOTE}Hello${RIGHT_SINGLE_QUOTE}.`
474
- const periodInsideSingle = `${LEFT_SINGLE_QUOTE}Hello.${RIGHT_SINGLE_QUOTE}`
475
- const commaOutsideDouble = `${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE},`
476
- const commaInsideDouble = `${LEFT_DOUBLE_QUOTE}test,${RIGHT_DOUBLE_QUOTE}`
477
-
478
- describe('when "american" (default)', () => {
479
- it.each([
480
- [periodOutsideDouble, periodInsideDouble, "period outside double quotes"],
481
- [periodOutsideSingle, periodInsideSingle, "period outside single quotes"],
482
- [commaOutsideDouble, commaInsideDouble, "comma outside double quotes"],
483
- ])("moves punctuation inside: %s", (input, expected) => {
484
- expect(niceQuotes(input, { punctuationStyle: "american" })).toBe(expected)
485
- })
486
-
487
- it("is the default behavior", () => {
488
- expect(niceQuotes(periodOutsideDouble)).toBe(periodInsideDouble)
489
- })
490
- })
491
-
492
- describe('when "british"', () => {
493
- it.each([
494
- [periodInsideDouble, periodOutsideDouble, "period inside double quotes"],
495
- [periodInsideSingle, periodOutsideSingle, "period inside single quotes"],
496
- [commaInsideDouble, commaOutsideDouble, "comma inside double quotes"],
497
- ])("moves punctuation outside: %s", (input, expected) => {
498
- expect(niceQuotes(input, { punctuationStyle: "british" })).toBe(expected)
499
- })
500
-
501
- it("still converts straight quotes to smart quotes", () => {
502
- expect(niceQuotes('"Hello."', { punctuationStyle: "british" })).toBe(periodOutsideDouble)
503
- })
504
- })
505
-
506
- describe("terminal punctuation blocks movement", () => {
507
- // American: comma outside stays outside for every terminal mark
508
- it.each(TERMINAL_PUNCTUATION.map((mark) => [`${LEFT_DOUBLE_QUOTE}Test${mark}${RIGHT_DOUBLE_QUOTE},`]))(
509
- "american comma stays outside: %s", (input) => {
510
- expect(niceQuotes(input, { punctuationStyle: "american" })).toBe(input)
511
- })
512
-
513
- // American: period outside stays outside for every terminal mark
514
- it.each(TERMINAL_PUNCTUATION.map((mark) => [`${LEFT_DOUBLE_QUOTE}Test${mark}${RIGHT_DOUBLE_QUOTE}.`]))(
515
- "american period stays outside: %s", (input) => {
516
- expect(niceQuotes(input, { punctuationStyle: "american" })).toBe(input)
517
- })
518
-
519
- // American: single quotes work the same as double
520
- it("american comma stays outside for single quotes", () => {
521
- expect(niceQuotes(`${LEFT_SINGLE_QUOTE}Stop!${RIGHT_SINGLE_QUOTE},`, { punctuationStyle: "american" }))
522
- .toBe(`${LEFT_SINGLE_QUOTE}Stop!${RIGHT_SINGLE_QUOTE},`)
523
- })
524
-
525
- // British: comma inside always moves outside (even after terminal punctuation)
526
- // Exclude "." — the period regex also fires, creating a cascading double-movement
527
- it.each(TERMINAL_PUNCTUATION.filter((m) => m !== ".").map((mark) => [
528
- `${LEFT_DOUBLE_QUOTE}Test${mark},${RIGHT_DOUBLE_QUOTE}`,
529
- `${LEFT_DOUBLE_QUOTE}Test${mark}${RIGHT_DOUBLE_QUOTE},`,
530
- ]))("british comma moves outside: %s → %s", (input, expected) => {
531
- expect(niceQuotes(input, { punctuationStyle: "british" })).toBe(expected)
532
- })
533
-
534
- // British: period inside always moves outside (even after terminal punctuation)
535
- // Exclude "," — the comma regex also fires, creating a cascading double-movement
536
- it.each(TERMINAL_PUNCTUATION.filter((m) => m !== ",").map((mark) => [
537
- `${LEFT_DOUBLE_QUOTE}Test${mark}.${RIGHT_DOUBLE_QUOTE}`,
538
- `${LEFT_DOUBLE_QUOTE}Test${mark}${RIGHT_DOUBLE_QUOTE}.`,
539
- ]))("british period moves outside: %s → %s", (input, expected) => {
540
- expect(niceQuotes(input, { punctuationStyle: "british" })).toBe(expected)
541
- })
542
- })
543
-
544
- describe("MLA excluded from punctuation movement", () => {
545
- it.each([
546
- // American: MLA period/comma stay put (MLA is not a quote character)
547
- [`the dog${MODIFIER_LETTER_APOSTROPHE}s.`, `the dog${MODIFIER_LETTER_APOSTROPHE}s.`, "american"],
548
- [`Brien${MODIFIER_LETTER_APOSTROPHE}s,`, `Brien${MODIFIER_LETTER_APOSTROPHE}s,`, "american"],
549
- // American: RSQ period/comma move inside (contrast)
550
- [`${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}.`, `${LEFT_SINGLE_QUOTE}hello.${RIGHT_SINGLE_QUOTE}`, "american"],
551
- // British: RSQ period moves outside (contrast)
552
- [`${LEFT_SINGLE_QUOTE}hello.${RIGHT_SINGLE_QUOTE}`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}.`, "british"],
553
- ])('%s → %s (%s)', (input, expected, style) => {
554
- expect(classifyApostrophes(input, { punctuationStyle: style as "american" | "british" })).toBe(expected)
555
- })
556
- })
557
-
558
- describe('when "none"', () => {
559
- it.each([
560
- [periodOutsideDouble],
561
- [periodInsideDouble],
562
- [commaOutsideDouble],
563
- ['"Hello".'],
564
- ["It's a test"],
565
- ])("skips all quote transforms: %s", (input) => {
566
- expect(niceQuotes(input, { punctuationStyle: "none" })).toBe(input)
567
- })
568
- })
569
- })
570
-
571
- describe("empty and whitespace quotes", () => {
572
- it.each([
573
- ["''", `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`, "empty single"],
574
- ['""', `${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`, "empty double"],
575
- ["' '", `${LEFT_SINGLE_QUOTE} ${RIGHT_SINGLE_QUOTE}`, "whitespace single"],
576
- ['" "', `${LEFT_DOUBLE_QUOTE} ${RIGHT_DOUBLE_QUOTE}`, "whitespace double"],
577
- ])("converts %s → %s (%s)", (input, expected) => {
578
- expect(niceQuotes(input)).toBe(expected)
579
- })
580
- })
581
-
582
- describe("niceQuotes vs classifyApostrophes", () => {
583
- const RSQ = RIGHT_SINGLE_QUOTE
584
-
585
- it.each([
586
- ["contractions", "don't", `don${RSQ}t`],
587
- ["possessives", "the dog's bone", `the dog${RSQ}s bone`],
588
- ["plural possessives", "the dogs' owner", `the dogs${RSQ} owner`],
589
- ["O'Brien-style names", "O'Brien's idea", `O${RSQ}Brien${RSQ}s idea`],
590
- ["'n' abbreviation", "Rock 'n' Roll", `Rock ${RSQ}n${RSQ} Roll`],
591
- ["decades", "the '90s", `the ${RSQ}90s`],
592
- ["leading apostrophes", "'twas", `${RSQ}twas`],
593
- ])('defaults to RSQ for %s', (_label, input, expected) => {
594
- expect(niceQuotes(input)).toBe(expected)
595
- })
596
-
597
- it("classifyApostrophes uses MLA for apostrophes", () => {
598
- expect(classifyApostrophes("don't")).toBe(`don${MODIFIER_LETTER_APOSTROPHE}t`)
599
- })
600
-
601
- it("RSQ output is idempotent", () => {
602
- const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
603
- for (const input of inputs) {
604
- const first = niceQuotes(input)
605
- expect(niceQuotes(first)).toBe(first)
606
- }
607
- })
608
-
609
- it("classifyApostrophes output is idempotent", () => {
610
- const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
611
- for (const input of inputs) {
612
- const first = classifyApostrophes(input)
613
- expect(classifyApostrophes(first)).toBe(first)
614
- }
615
- })
616
-
617
- describe("RSQ→MLA normalization (external system input)", () => {
618
- it.each([
619
- [`don${RIGHT_SINGLE_QUOTE}t`, `don${MODIFIER_LETTER_APOSTROPHE}t`],
620
- [`I${RIGHT_SINGLE_QUOTE}m`, `I${MODIFIER_LETTER_APOSTROPHE}m`],
621
- [`they${RIGHT_SINGLE_QUOTE}re`, `they${MODIFIER_LETTER_APOSTROPHE}re`],
622
- [`O${RIGHT_SINGLE_QUOTE}Brien`, `O${MODIFIER_LETTER_APOSTROPHE}Brien`],
623
- [`the dog${RIGHT_SINGLE_QUOTE}s bone`, `the dog${MODIFIER_LETTER_APOSTROPHE}s bone`],
624
- // Mixed MLA + RSQ in same text
625
- [`I${MODIFIER_LETTER_APOSTROPHE}m fine and you${RIGHT_SINGLE_QUOTE}re great`, `I${MODIFIER_LETTER_APOSTROPHE}m fine and you${MODIFIER_LETTER_APOSTROPHE}re great`],
626
- ])('normalizes RSQ contraction "%s"', (input, expected) => {
627
- expect(classifyApostrophes(input)).toBe(expected)
628
- })
629
-
630
- it("does NOT normalize RSQ when it is a closing quote", () => {
631
- const input = `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} world`
632
- expect(classifyApostrophes(input)).toBe(input)
633
- })
634
-
635
- it.each([
636
- [`[test]${RIGHT_SINGLE_QUOTE}`, "bracket"],
637
- [`${LEFT_SINGLE_QUOTE}Hello.${RIGHT_SINGLE_QUOTE}`, "period"],
638
- [`${LEFT_SINGLE_QUOTE}Really?${RIGHT_SINGLE_QUOTE}`, "question mark"],
639
- [`${LEFT_SINGLE_QUOTE}Stop!${RIGHT_SINGLE_QUOTE}`, "exclamation"],
640
- [`${LEFT_SINGLE_QUOTE}test,${RIGHT_SINGLE_QUOTE} more`, "comma"],
641
- ])("preserves closing RSQ preceded by non-word char (%s)", (input) => {
642
- expect(classifyApostrophes(input)).toBe(input)
643
- })
644
-
645
- it("normalizes RSQ possessive with digit prefix", () => {
646
- expect(classifyApostrophes(`GPT-2${RIGHT_SINGLE_QUOTE}s`)).toBe(`GPT-2${MODIFIER_LETTER_APOSTROPHE}s`)
647
- })
648
-
649
- it("normalizes RSQ plural possessive to MLA", () => {
650
- expect(classifyApostrophes(`the dogs${RIGHT_SINGLE_QUOTE} owner`)).toBe(`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`)
651
- })
652
-
653
- it("preserves RSQ plural possessive when matched with LSQ", () => {
654
- const input = `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE} and more`
655
- expect(classifyApostrophes(input)).toBe(input)
656
- })
657
- })
658
- })
659
-
660
- describe("locale quotes", () => {
661
- describe.each([
662
- // German double quotes
663
- ['"Guten Tag"', `${DOUBLE_LOW_9_QUOTE}Guten Tag${LEFT_DOUBLE_QUOTE}`, { punctuationStyle: "german" as const }],
664
- ['"Hello," she said.', `${DOUBLE_LOW_9_QUOTE}Hello${LEFT_DOUBLE_QUOTE}, she said.`, { punctuationStyle: "german" as const }],
665
- // German single quotes
666
- ["'Hallo'", `${SINGLE_LOW_9_QUOTE}Hallo${LEFT_SINGLE_QUOTE}`, { punctuationStyle: "german" as const }],
667
- // German apostrophes stay as RSQ (after MLA→RSQ conversion in niceQuotes)
668
- ["it's", `it${RIGHT_SINGLE_QUOTE}s`, { punctuationStyle: "german" as const }],
669
- // French double quotes (NNBSP per Imprimerie nationale / Unicode CLDR)
670
- ['"Bonjour"', `${LEFT_GUILLEMET}${NNBSP}Bonjour${NNBSP}${RIGHT_GUILLEMET}`, { punctuationStyle: "french" as const }],
671
- // French apostrophes stay as RSQ
672
- ["l'homme", `l${RIGHT_SINGLE_QUOTE}homme`, { punctuationStyle: "french" as const }],
673
- // Nested quotes in German
674
- [`"She said 'hello'"`, `${DOUBLE_LOW_9_QUOTE}She said ${SINGLE_LOW_9_QUOTE}hello${LEFT_SINGLE_QUOTE}${LEFT_DOUBLE_QUOTE}`, { punctuationStyle: "german" as const }],
675
- // French punctuation placement
676
- ['"Bonjour," dit-il.', `${LEFT_GUILLEMET}${NNBSP}Bonjour${NNBSP}${RIGHT_GUILLEMET}, dit-il.`, { punctuationStyle: "french" as const }],
677
- // German leading apostrophe ('Twas)
678
- ["'Twas the night", `${RIGHT_SINGLE_QUOTE}Twas the night`, { punctuationStyle: "german" as const }],
679
- ])("locale quotes: %s → %s", (input, expected, options) => {
680
- it("transforms correctly", () => {
681
- expect(niceQuotes(input, options)).toBe(expected)
682
- })
683
- })
684
-
685
- it.each([
686
- [`${DOUBLE_LOW_9_QUOTE}Guten Tag${LEFT_DOUBLE_QUOTE}`, { punctuationStyle: "german" as const }],
687
- [`${SINGLE_LOW_9_QUOTE}Hallo${LEFT_SINGLE_QUOTE}`, { punctuationStyle: "german" as const }],
688
- [`${LEFT_GUILLEMET}${NNBSP}Bonjour${NNBSP}${RIGHT_GUILLEMET}`, { punctuationStyle: "french" as const }],
689
- // German apostrophe idempotency — RSQ must not flip to LSQ on re-processing
690
- [`${RIGHT_SINGLE_QUOTE}Twas the night`, { punctuationStyle: "german" as const }],
691
- ])("locale output is idempotent: %s", (input, options) => {
692
- expect(niceQuotes(input, options)).toBe(input)
693
- })
694
-
695
- it("german normalizer re-classifies pre-existing RDQ", () => {
696
- // RDQ is not used in German — should become German closing double
697
- const input = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`
698
- expect(niceQuotes(input, { punctuationStyle: "german" })).toBe(`${DOUBLE_LOW_9_QUOTE}Hello${LEFT_DOUBLE_QUOTE}`)
699
- })
700
- })
701
-
702
- describe("nested quote punctuation placement", () => {
703
- it.each([
704
- // Period moves past all closing quotes in one pass
705
- [
706
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello.${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`,
707
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}.`,
708
- ],
709
- // Comma moves past all closing quotes in one pass
710
- [
711
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello,${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE} she replied.`,
712
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}, she replied.`,
713
- ],
714
- ])("british moves past all closing quotes: %s → %s", (input, expected) => {
715
- expect(niceQuotes(input, { punctuationStyle: "british" })).toBe(expected)
716
- })
717
-
718
- it.each(["british", "german", "french"] as const)("%s nested quote period is idempotent", (style) => {
719
- const first = niceQuotes(`"He said, 'Hello.'"`, { punctuationStyle: style })
720
- expect(niceQuotes(first, { punctuationStyle: style })).toBe(first)
721
- })
722
-
723
- it.each([
724
- // Period moves past all closing quotes in one pass
725
- [
726
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}.`,
727
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello.${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`,
728
- ],
729
- // Comma moves past all closing quotes in one pass
730
- [
731
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}, she replied.`,
732
- `${LEFT_DOUBLE_QUOTE}He said, ${LEFT_SINGLE_QUOTE}Hello,${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE} she replied.`,
733
- ],
734
- ])("american moves past all closing quotes: %s → %s", (input, expected) => {
735
- const result = niceQuotes(input, { punctuationStyle: "american" })
736
- expect(result).toBe(expected)
737
- expect(niceQuotes(result, { punctuationStyle: "american" })).toBe(result)
738
- })
739
- })
740
-
741
- describe("pathological inputs", () => {
742
- it("scales linearly for input without closing quote", () => {
743
- assertLinearScaling(classifyApostrophes, (n) => `'${"a".repeat(n)}`)
744
- const input = `'${"a".repeat(1500)}`
745
- expect(classifyApostrophes(classifyApostrophes(input))).toBe(classifyApostrophes(input))
746
- })
747
-
748
- it("handles 16 rapid apostrophes", () => {
749
- const input = "a'b'c'd'e'f'g'h'i'j'k'l'm'n'o'p"
750
- const expected = `a${MODIFIER_LETTER_APOSTROPHE}b${MODIFIER_LETTER_APOSTROPHE}c${MODIFIER_LETTER_APOSTROPHE}d${MODIFIER_LETTER_APOSTROPHE}e${MODIFIER_LETTER_APOSTROPHE}f${MODIFIER_LETTER_APOSTROPHE}g${MODIFIER_LETTER_APOSTROPHE}h${MODIFIER_LETTER_APOSTROPHE}i${MODIFIER_LETTER_APOSTROPHE}j${MODIFIER_LETTER_APOSTROPHE}k${MODIFIER_LETTER_APOSTROPHE}l${MODIFIER_LETTER_APOSTROPHE}m${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}o${MODIFIER_LETTER_APOSTROPHE}p`
751
- const result = classifyApostrophes(input)
752
- expect(result).toBe(expected)
753
- expect(classifyApostrophes(result)).toBe(result)
754
- })
755
-
756
- it("handles 500-char word + possessive", () => {
757
- const input = `${"a".repeat(500)}'s thing`
758
- expect(classifyApostrophes(input)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
759
- })
760
- })
761
-
762
- describe("mixed-locale stress", () => {
763
- it.each([
764
- ["german", `Er sagte, "es ist gro\u00DFartig" und sie sagte, "h\u00F6r nicht auf"`, DOUBLE_LOW_9_QUOTE],
765
- ["french", `"C'est magnifique," dit-elle. "N'arr\u00EAte pas."`, LEFT_GUILLEMET],
766
- ["american", `He said, "She said, 'They said, "No."'"`, LEFT_DOUBLE_QUOTE],
767
- ["british", `He said, "She said, 'They said, "No."'"`, LEFT_DOUBLE_QUOTE],
768
- ["german", `Er fragte, "Hat sie 'Nein' gesagt?"`, DOUBLE_LOW_9_QUOTE],
769
- ["french", `"Qui a dit 'bonjour'?" demanda-t-il.`, LEFT_GUILLEMET],
770
- ] as const)("%s style produces locale-appropriate quotes and is idempotent", (style, input, expectedChar) => {
771
- const opts = { punctuationStyle: style }
772
- const first = niceQuotes(input, opts)
773
- expect(first).toContain(expectedChar)
774
- expect(niceQuotes(first, opts)).toBe(first)
775
- })
776
- })
777
-
778
- describe("apostrophe regex scaling", () => {
779
- it("scales linearly for repeated apostrophe patterns", () => {
780
- assertLinearScaling(classifyApostrophes, (n) => "'a".repeat(n))
781
- })
782
- })
783
- })