punctilio 2.3.0 → 3.0.1

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,4 +1,4 @@
1
- import { niceQuotes } from "../quotes.js"
1
+ import { niceQuotes, classifyApostrophes } from "../quotes.js"
2
2
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
3
3
 
4
4
  const {
@@ -11,9 +11,6 @@ const {
11
11
  ELLIPSIS,
12
12
  } = UNICODE_SYMBOLS
13
13
 
14
- /** Enable MLA output for tests that assert the apostrophe codepoint. */
15
- const MLA = { useModifierLetterApostrophe: true } as const
16
-
17
14
  describe("niceQuotes", () => {
18
15
  describe("double quotes", () => {
19
16
  it.each([
@@ -52,7 +49,7 @@ describe("niceQuotes", () => {
52
49
  // End-of-line quote becomes RIGHT quote
53
50
  ['not confident in that plan - "', `not confident in that plan - ${RIGHT_DOUBLE_QUOTE}`],
54
51
  ])('should convert double quotes in "%s"', (input, expected) => {
55
- expect(niceQuotes(input, MLA)).toBe(expected)
52
+ expect(classifyApostrophes(input)).toBe(expected)
56
53
  })
57
54
  })
58
55
 
@@ -117,7 +114,7 @@ describe("niceQuotes", () => {
117
114
  // Closing RSQ then opening LDQ
118
115
  [`'hello' "world"`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`],
119
116
  ])('should handle single quotes/apostrophes in "%s"', (input, expected) => {
120
- expect(niceQuotes(input, MLA)).toBe(expected)
117
+ expect(classifyApostrophes(input)).toBe(expected)
121
118
  })
122
119
  })
123
120
 
@@ -139,7 +136,7 @@ describe("niceQuotes", () => {
139
136
  ["'dogs'", `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE}`],
140
137
  ["'he tells' stories", `${LEFT_SINGLE_QUOTE}he tells${RIGHT_SINGLE_QUOTE} stories`],
141
138
  ])('handles plural possessive: "%s"', (input, expected) => {
142
- expect(niceQuotes(input, MLA)).toBe(expected)
139
+ expect(classifyApostrophes(input)).toBe(expected)
143
140
  })
144
141
 
145
142
  it.each([
@@ -147,8 +144,8 @@ describe("niceQuotes", () => {
147
144
  `Bayes${MODIFIER_LETTER_APOSTROPHE} rule`,
148
145
  `dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE}`,
149
146
  ])('plural possessive is idempotent: "%s"', (input) => {
150
- expect(niceQuotes(input, MLA)).toBe(input)
151
- expect(niceQuotes(niceQuotes(input, MLA), MLA)).toBe(input)
147
+ expect(classifyApostrophes(input)).toBe(input)
148
+ expect(classifyApostrophes(classifyApostrophes(input))).toBe(input)
152
149
  })
153
150
 
154
151
  it("stress: many possessives interleaved with quotes", () => {
@@ -157,7 +154,7 @@ describe("niceQuotes", () => {
157
154
  i % 2 === 0 ? `dogs'` : `'yes'`
158
155
  )
159
156
  const input = pairs.join(" ")
160
- const result = niceQuotes(input, MLA)
157
+ const result = classifyApostrophes(input)
161
158
  // Every dogs' → MLA, every 'yes' → LSQ+yes+RSQ
162
159
  const expectedParts = Array.from({ length: 50 }, (_, i) =>
163
160
  i % 2 === 0
@@ -165,19 +162,19 @@ describe("niceQuotes", () => {
165
162
  : `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`
166
163
  )
167
164
  expect(result).toBe(expectedParts.join(" "))
168
- expect(niceQuotes(result, MLA)).toBe(result)
165
+ expect(classifyApostrophes(result)).toBe(result)
169
166
  })
170
167
 
171
168
  it("stress: 200 consecutive plural possessives", () => {
172
169
  const words = ["dogs'", "cats'", "Bayes'", "Thomas'", "PLAYERS'"]
173
170
  const input = Array.from({ length: 200 }, (_, i) => words[i % words.length]).join(" ")
174
171
  const start = performance.now()
175
- const result = niceQuotes(input, MLA)
172
+ const result = classifyApostrophes(input)
176
173
  expect(performance.now() - start).toBeLessThan(1000)
177
174
  // All should be MLA (no LSQ to pair with)
178
175
  expect(result).not.toContain(RIGHT_SINGLE_QUOTE)
179
176
  expect(result).not.toContain("'")
180
- expect(niceQuotes(result, MLA)).toBe(result)
177
+ expect(classifyApostrophes(result)).toBe(result)
181
178
  })
182
179
 
183
180
  it("stress: deeply nested alternating pattern", () => {
@@ -186,7 +183,7 @@ describe("niceQuotes", () => {
186
183
  const input = Array.from({ length: 30 }, (_, i) =>
187
184
  i % 2 === 0 ? `'word${i}'` : `${possessives[i % possessives.length]}'`
188
185
  ).join(" ")
189
- const result = niceQuotes(input, MLA)
186
+ const result = classifyApostrophes(input)
190
187
  // Quoted words get LSQ/RSQ, possessives get MLA
191
188
  for (let i = 0; i < 30; i++) {
192
189
  if (i % 2 === 0) {
@@ -195,7 +192,7 @@ describe("niceQuotes", () => {
195
192
  expect(result).toContain(`${possessives[i % possessives.length]}${MODIFIER_LETTER_APOSTROPHE}`)
196
193
  }
197
194
  }
198
- expect(niceQuotes(result, MLA)).toBe(result)
195
+ expect(classifyApostrophes(result)).toBe(result)
199
196
  })
200
197
  })
201
198
 
@@ -214,11 +211,11 @@ describe("niceQuotes", () => {
214
211
  ["'twas the night, 'twas indeed", `${MODIFIER_LETTER_APOSTROPHE}twas the night, ${MODIFIER_LETTER_APOSTROPHE}twas indeed`],
215
212
  [`"'twas the night"`, `${LEFT_DOUBLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}twas the night${RIGHT_DOUBLE_QUOTE}`],
216
213
  ])('handles leading apostrophe in "%s"', (input, expected) => {
217
- expect(niceQuotes(input, MLA)).toBe(expected)
214
+ expect(classifyApostrophes(input)).toBe(expected)
218
215
  })
219
216
 
220
217
  it("multiple decades in one sentence", () => {
221
- expect(niceQuotes("The '60s, '70s, and '80s were different.", MLA)).toBe(
218
+ expect(classifyApostrophes("The '60s, '70s, and '80s were different.")).toBe(
222
219
  `The ${MODIFIER_LETTER_APOSTROPHE}60s, ${MODIFIER_LETTER_APOSTROPHE}70s, and ${MODIFIER_LETTER_APOSTROPHE}80s were different.`
223
220
  )
224
221
  })
@@ -231,7 +228,7 @@ describe("niceQuotes", () => {
231
228
  ["digit neighbors (\\w includes digits)", "1 'n' 2", `1 ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} 2`],
232
229
  ["known false positive: quoted letter", "the letter 'n' is common", `the letter ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} is common`],
233
230
  ])('%s', (_label, input, expected) => {
234
- expect(niceQuotes(input, MLA)).toBe(expected)
231
+ expect(classifyApostrophes(input)).toBe(expected)
235
232
  })
236
233
 
237
234
  it.each([
@@ -240,7 +237,7 @@ describe("niceQuotes", () => {
240
237
  ["punctuation precedes first apostrophe", "said, 'n' is good", `said, ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} is good`],
241
238
  ["newline replaces space", "Rock 'n'\nRoll", `Rock ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE}\nRoll`],
242
239
  ])('does not produce MLA-n-MLA: %s', (_label, input, expected) => {
243
- expect(niceQuotes(input, MLA)).toBe(expected)
240
+ expect(classifyApostrophes(input)).toBe(expected)
244
241
  })
245
242
  })
246
243
 
@@ -277,7 +274,7 @@ describe("niceQuotes", () => {
277
274
  [`O'Brien's idea`, `O${MODIFIER_LETTER_APOSTROPHE}Brien${MODIFIER_LETTER_APOSTROPHE}s idea`],
278
275
  [`The O'Connors`, `The O${MODIFIER_LETTER_APOSTROPHE}Connors`],
279
276
  ])('handles Irish/Scottish name apostrophes: "%s"', (input, expected) => {
280
- expect(niceQuotes(input, MLA)).toBe(expected)
277
+ expect(classifyApostrophes(input)).toBe(expected)
281
278
  })
282
279
 
283
280
  // From smartquotes.js: complex nested quotes
@@ -311,7 +308,7 @@ describe("niceQuotes", () => {
311
308
  // Quote with slash separator
312
309
  ['"option1"/"option2"', `${LEFT_DOUBLE_QUOTE}option1${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}option2${RIGHT_DOUBLE_QUOTE}`],
313
310
  ])('handles complex pattern: "%s"', (input, expected) => {
314
- expect(niceQuotes(input, MLA)).toBe(expected)
311
+ expect(classifyApostrophes(input)).toBe(expected)
315
312
  })
316
313
 
317
314
  it("handles multi-line dialogue", () => {
@@ -337,8 +334,8 @@ describe("niceQuotes", () => {
337
334
  // Mixed quote types
338
335
  `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`,
339
336
  ])('is idempotent for: "%s"', (input) => {
340
- expect(niceQuotes(input, MLA)).toBe(input)
341
- expect(niceQuotes(niceQuotes(input, MLA), MLA)).toBe(input)
337
+ expect(classifyApostrophes(input)).toBe(input)
338
+ expect(classifyApostrophes(classifyApostrophes(input))).toBe(input)
342
339
  })
343
340
  })
344
341
 
@@ -357,21 +354,21 @@ describe("niceQuotes", () => {
357
354
  // Closing after separator
358
355
  [`'hello${sep}'`, `${LEFT_SINGLE_QUOTE}hello${sep}${RIGHT_SINGLE_QUOTE}`, "closing quote after separator"],
359
356
  ])("%s → %s (%s)", (input, expected) => {
360
- expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
357
+ expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
361
358
  })
362
359
 
363
360
  it("plural possessive across separator", () => {
364
361
  const input = `dogs${sep}' food`
365
362
  const expected = `dogs${sep}${MODIFIER_LETTER_APOSTROPHE} food`
366
- expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
367
- expect(niceQuotes(expected, { separator: sep, ...MLA })).toBe(expected)
363
+ expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
364
+ expect(classifyApostrophes(expected, { separator: sep })).toBe(expected)
368
365
  })
369
366
 
370
367
  it("'n' with separators around word boundaries", () => {
371
368
  const input = `Rock${sep} 'n' ${sep}Roll`
372
369
  const expected = `Rock${sep} ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} ${sep}Roll`
373
- expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
374
- expect(niceQuotes(expected, { separator: sep, ...MLA })).toBe(expected)
370
+ expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
371
+ expect(classifyApostrophes(expected, { separator: sep })).toBe(expected)
375
372
  })
376
373
  })
377
374
 
@@ -387,7 +384,7 @@ describe("niceQuotes", () => {
387
384
  ["'a", `${MODIFIER_LETTER_APOSTROPHE}a`],
388
385
  ["a'", `a${RIGHT_SINGLE_QUOTE}`],
389
386
  ])('handles edge quote pattern: "%s"', (input, expected) => {
390
- expect(niceQuotes(input, MLA)).toBe(expected)
387
+ expect(classifyApostrophes(input)).toBe(expected)
391
388
  })
392
389
  })
393
390
 
@@ -411,7 +408,7 @@ describe("niceQuotes", () => {
411
408
  ["wouldn't've", `wouldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
412
409
  ["couldn't've", `couldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
413
410
  ])('handles double contraction: "%s"', (input, expected) => {
414
- expect(niceQuotes(input, MLA)).toBe(expected)
411
+ expect(classifyApostrophes(input)).toBe(expected)
415
412
  })
416
413
  })
417
414
 
@@ -424,7 +421,7 @@ describe("niceQuotes", () => {
424
421
  // Newline stops lookahead → MLA (not LSQ) since no closing quote visible
425
422
  ["'hello\nworld'", `${MODIFIER_LETTER_APOSTROPHE}hello\nworld${RIGHT_SINGLE_QUOTE}`],
426
423
  ])('handles multiline: "%s"', (input, expected) => {
427
- expect(niceQuotes(input, MLA)).toBe(expected)
424
+ expect(classifyApostrophes(input)).toBe(expected)
428
425
  })
429
426
  })
430
427
 
@@ -490,7 +487,7 @@ describe("niceQuotes", () => {
490
487
  // British: RSQ period moves outside (contrast)
491
488
  [`${LEFT_SINGLE_QUOTE}hello.${RIGHT_SINGLE_QUOTE}`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}.`, "british"],
492
489
  ])('%s → %s (%s)', (input, expected, style) => {
493
- expect(niceQuotes(input, { punctuationStyle: style as "american" | "british", ...MLA })).toBe(expected)
490
+ expect(classifyApostrophes(input, { punctuationStyle: style as "american" | "british" })).toBe(expected)
494
491
  })
495
492
  })
496
493
 
@@ -518,7 +515,7 @@ describe("niceQuotes", () => {
518
515
  })
519
516
  })
520
517
 
521
- describe("useModifierLetterApostrophe option", () => {
518
+ describe("niceQuotes vs classifyApostrophes", () => {
522
519
  const RSQ = RIGHT_SINGLE_QUOTE
523
520
 
524
521
  it.each([
@@ -533,8 +530,8 @@ describe("niceQuotes", () => {
533
530
  expect(niceQuotes(input)).toBe(expected)
534
531
  })
535
532
 
536
- it("uses MLA when option is true", () => {
537
- expect(niceQuotes("don't", MLA)).toBe(`don${MODIFIER_LETTER_APOSTROPHE}t`)
533
+ it("classifyApostrophes uses MLA for apostrophes", () => {
534
+ expect(classifyApostrophes("don't")).toBe(`don${MODIFIER_LETTER_APOSTROPHE}t`)
538
535
  })
539
536
 
540
537
  it("RSQ output is idempotent", () => {
@@ -545,11 +542,11 @@ describe("niceQuotes", () => {
545
542
  }
546
543
  })
547
544
 
548
- it("MLA output is idempotent", () => {
545
+ it("classifyApostrophes output is idempotent", () => {
549
546
  const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
550
547
  for (const input of inputs) {
551
- const first = niceQuotes(input, MLA)
552
- expect(niceQuotes(first, MLA)).toBe(first)
548
+ const first = classifyApostrophes(input)
549
+ expect(classifyApostrophes(first)).toBe(first)
553
550
  }
554
551
  })
555
552
 
@@ -563,12 +560,12 @@ describe("niceQuotes", () => {
563
560
  // Mixed MLA + RSQ in same text
564
561
  [`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`],
565
562
  ])('normalizes RSQ contraction "%s"', (input, expected) => {
566
- expect(niceQuotes(input, MLA)).toBe(expected)
563
+ expect(classifyApostrophes(input)).toBe(expected)
567
564
  })
568
565
 
569
566
  it("does NOT normalize RSQ when it is a closing quote", () => {
570
567
  const input = `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} world`
571
- expect(niceQuotes(input, MLA)).toBe(input)
568
+ expect(classifyApostrophes(input)).toBe(input)
572
569
  })
573
570
 
574
571
  it.each([
@@ -578,20 +575,20 @@ describe("niceQuotes", () => {
578
575
  [`${LEFT_SINGLE_QUOTE}Stop!${RIGHT_SINGLE_QUOTE}`, "exclamation"],
579
576
  [`${LEFT_SINGLE_QUOTE}test,${RIGHT_SINGLE_QUOTE} more`, "comma"],
580
577
  ])("preserves closing RSQ preceded by non-word char (%s)", (input) => {
581
- expect(niceQuotes(input, MLA)).toBe(input)
578
+ expect(classifyApostrophes(input)).toBe(input)
582
579
  })
583
580
 
584
581
  it("normalizes RSQ possessive with digit prefix", () => {
585
- expect(niceQuotes(`GPT-2${RIGHT_SINGLE_QUOTE}s`, MLA)).toBe(`GPT-2${MODIFIER_LETTER_APOSTROPHE}s`)
582
+ expect(classifyApostrophes(`GPT-2${RIGHT_SINGLE_QUOTE}s`)).toBe(`GPT-2${MODIFIER_LETTER_APOSTROPHE}s`)
586
583
  })
587
584
 
588
585
  it("normalizes RSQ plural possessive to MLA", () => {
589
- expect(niceQuotes(`the dogs${RIGHT_SINGLE_QUOTE} owner`, MLA)).toBe(`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`)
586
+ expect(classifyApostrophes(`the dogs${RIGHT_SINGLE_QUOTE} owner`)).toBe(`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`)
590
587
  })
591
588
 
592
589
  it("preserves RSQ plural possessive when matched with LSQ", () => {
593
590
  const input = `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE} and more`
594
- expect(niceQuotes(input, MLA)).toBe(input)
591
+ expect(classifyApostrophes(input)).toBe(input)
595
592
  })
596
593
  })
597
594
  })
@@ -600,24 +597,24 @@ describe("niceQuotes", () => {
600
597
  it("handles 1500-char input without closing quote", () => {
601
598
  const input = `'${"a".repeat(1500)}`
602
599
  const start = performance.now()
603
- const result = niceQuotes(input, MLA)
600
+ const result = classifyApostrophes(input)
604
601
  expect(performance.now() - start).toBeLessThan(1000)
605
- expect(niceQuotes(result, MLA)).toBe(result)
602
+ expect(classifyApostrophes(result)).toBe(result)
606
603
  })
607
604
 
608
605
  it("handles 16 rapid apostrophes", () => {
609
606
  const input = "a'b'c'd'e'f'g'h'i'j'k'l'm'n'o'p"
610
607
  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`
611
608
  const start = performance.now()
612
- const result = niceQuotes(input, MLA)
609
+ const result = classifyApostrophes(input)
613
610
  expect(performance.now() - start).toBeLessThan(1000)
614
611
  expect(result).toBe(expected)
615
- expect(niceQuotes(result, MLA)).toBe(result)
612
+ expect(classifyApostrophes(result)).toBe(result)
616
613
  })
617
614
 
618
615
  it("handles 500-char word + possessive", () => {
619
616
  const input = `${"a".repeat(500)}'s thing`
620
- expect(niceQuotes(input, MLA)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
617
+ expect(classifyApostrophes(input)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
621
618
  })
622
619
  })
623
620
 
@@ -69,7 +69,7 @@ describe("rehypePunctilio", () => {
69
69
  ["multi-segment number preserved", "<p>1-<em>2</em>-3</p>", "<p>1-<em>2</em>-3</p>"],
70
70
  ["model name preserved", "<p><em>GPT</em>-3</p>", "<p><em>GPT</em>-3</p>"],
71
71
  ["simple range still converts", "<p>pages <em>1</em>-5</p>", `<p>pages <em>1</em>${EN_DASH}5</p>`],
72
- ["genuine negative at element start", "<p><em>-5</em> degrees</p>", "<p><em>\u22125</em> degrees</p>"],
72
+ ["genuine negative at element start", "<p><em>-5</em> degrees</p>", `<p><em>${UNICODE_SYMBOLS.MINUS}5</em> degrees</p>`],
73
73
  ])("%s", async (_name, html, expected) => {
74
74
  expect(await processHtml(html, { nbsp: false })).toEqual(expected)
75
75
  })
@@ -259,8 +259,8 @@ describe("rehypePunctilio", () => {
259
259
 
260
260
  it("applies custom transforms", () => {
261
261
  const element = h("p", "pages 1-5") as Element
262
- transformElement(element, (s) => s.replace(/-/g, "–"), ignoreNone, DEFAULT_SEPARATOR)
263
- expect((element.children[0] as Text).value).toBe("pages 1–5")
262
+ transformElement(element, (s) => s.replace(/-/g, EN_DASH), ignoreNone, DEFAULT_SEPARATOR)
263
+ expect((element.children[0] as Text).value).toBe(`pages 1${EN_DASH}5`)
264
264
  })
265
265
 
266
266
  it("handles missing children gracefully", () => {
@@ -351,8 +351,8 @@ describe("rehypePunctilio", () => {
351
351
 
352
352
  describe("assertSmartQuotesMatch", () => {
353
353
  it.each([
354
- ["matched double quotes", "\u201CHello\u201D"],
355
- ["nested quotes", "\u201COuter \u201Cinner\u201D outer\u201D"],
354
+ ["matched double quotes", `${LDQ}Hello${RDQ}`],
355
+ ["nested quotes", `${LDQ}Outer ${LDQ}inner${RDQ} outer${RDQ}`],
356
356
  ["empty string", ""],
357
357
  ["no quotes", "Hello, world!"],
358
358
  ])("passes for %s", (_name, input) => {
@@ -360,9 +360,10 @@ describe("rehypePunctilio", () => {
360
360
  })
361
361
 
362
362
  it.each([
363
- ["unmatched opening", "\u201CHello"],
364
- ["unmatched closing", "Hello\u201D"],
365
- ["extra opening", "\u201C\u201CHello\u201D"],
363
+ ["unmatched opening", `${LDQ}Hello`],
364
+ ["unmatched closing", `Hello${RDQ}`],
365
+ ["extra opening", `${LDQ}${LDQ}Hello${RDQ}`],
366
+ ["reversed quotes", `${RDQ}Hello${LDQ}`],
366
367
  ])("throws for %s", (_name, input) => {
367
368
  expect(() => assertSmartQuotesMatch(input)).toThrow("Mismatched quotes")
368
369
  })
@@ -48,7 +48,7 @@ describe("remarkPunctilio", () => {
48
48
  describe("basic transformations (nbsp enabled)", () => {
49
49
  it.each([
50
50
  ["quotes", '"Hello," she said.', `${LDQ}Hello,${RDQ} she${NBSP}said.`],
51
- ["apostrophes", "It's a test.", `It${RSQ}s a${NBSP}test.`],
51
+ ["apostrophes", "It's a test.", `It${RSQ}s${NBSP}a${NBSP}test.`],
52
52
  ["em dashes", "Wait -- here it comes.", `Wait${EM_DASH}here it${NBSP}comes.`],
53
53
  ["en dashes (number range)", "Pages 1-5", `Pages${NBSP}1${EN_DASH}5`],
54
54
  ["ellipses", "Wait...", `Wait${ELLIPSIS}`],
@@ -129,7 +129,7 @@ describe("legalSymbols", () => {
129
129
  // (c) preceded by "copyright" → copyright
130
130
  ["Copyright (c) Company", `Copyright ${UNICODE_SYMBOLS.COPYRIGHT} Company`],
131
131
  ["copyright (C) by Author", `copyright ${UNICODE_SYMBOLS.COPYRIGHT} by Author`],
132
- // (r) and (tm) always convert
132
+ // (r) converts in trademark context
133
133
  ["Brand(r)", `Brand${UNICODE_SYMBOLS.REGISTERED}`],
134
134
  ["Product (R)", `Product ${UNICODE_SYMBOLS.REGISTERED}`],
135
135
  ["Name(tm)", `Name${UNICODE_SYMBOLS.TRADEMARK}`],
@@ -154,6 +154,16 @@ describe("legalSymbols", () => {
154
154
  ])('preserves (c) in non-copyright context "%s"', (input, expected) => {
155
155
  expect(legalSymbols(input)).toBe(expected)
156
156
  })
157
+
158
+ it.each([
159
+ // (r) in enumerations
160
+ ["(p), (q), (r), (s)", "(p), (q), (r), (s)"],
161
+ ["(Q); (R); (S)", "(Q); (R); (S)"],
162
+ // (r) in legal citations
163
+ ["See (r)(1)(A)", "See (r)(1)(A)"],
164
+ ])('preserves (r) in non-trademark context "%s"', (input, expected) => {
165
+ expect(legalSymbols(input)).toBe(expected)
166
+ })
157
167
  })
158
168
 
159
169
  describe("arrows", () => {
@@ -227,14 +237,14 @@ describe("primeMarks", () => {
227
237
  ['He is 6\'2" tall', `He is 6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME} tall`],
228
238
  ["The board is 8' long", `The board is 8${UNICODE_SYMBOLS.PRIME} long`],
229
239
  ['12"', `12${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
230
- ["Location: 45° 30' 15\"", `Location: 45° 30${UNICODE_SYMBOLS.PRIME} 15${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
240
+ [`Location: 45${UNICODE_SYMBOLS.DEGREE} 30' 15"`, `Location: 45${UNICODE_SYMBOLS.DEGREE} 30${UNICODE_SYMBOLS.PRIME} 15${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
231
241
  ["don't", "don't"],
232
242
  ["it's", "it's"],
233
243
  ["'Twas the night", "'Twas the night"],
234
244
  ["'hello'", "'hello'"],
235
245
  ['"test"', '"test"'],
236
246
  ['The ceiling is 9\'6" high', `The ceiling is 9${UNICODE_SYMBOLS.PRIME}6${UNICODE_SYMBOLS.DOUBLE_PRIME} high`],
237
- ["40° 44' 54\" N", `40° 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
247
+ [`40${UNICODE_SYMBOLS.DEGREE} 44' 54" N`, `40${UNICODE_SYMBOLS.DEGREE} 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
238
248
  ['The board is 12" wide', `The board is 12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
239
249
  ['12" long', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} long`],
240
250
  // Multiple primes in one string
@@ -534,9 +544,9 @@ describe("competitor-derived edge cases", () => {
534
544
  // From Standard Ebooks: coordinates
535
545
  describe("coordinate notation", () => {
536
546
  it.each([
537
- ["40° 44' 54\" N", `40° 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
538
- ["74° 0' 21\" W", `74° 0${UNICODE_SYMBOLS.PRIME} 21${UNICODE_SYMBOLS.DOUBLE_PRIME} W`],
539
- ["51° 30' N", `51° 30${UNICODE_SYMBOLS.PRIME} N`],
547
+ [`40${UNICODE_SYMBOLS.DEGREE} 44' 54" N`, `40${UNICODE_SYMBOLS.DEGREE} 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
548
+ [`74${UNICODE_SYMBOLS.DEGREE} 0' 21" W`, `74${UNICODE_SYMBOLS.DEGREE} 0${UNICODE_SYMBOLS.PRIME} 21${UNICODE_SYMBOLS.DOUBLE_PRIME} W`],
549
+ [`51${UNICODE_SYMBOLS.DEGREE} 30' N`, `51${UNICODE_SYMBOLS.DEGREE} 30${UNICODE_SYMBOLS.PRIME} N`],
540
550
  ])('converts coordinates: "%s"', (input, expected) => {
541
551
  expect(primeMarks(input)).toBe(expected)
542
552
  })