punctilio 3.2.3 → 3.3.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.
package/src/symbols.ts CHANGED
@@ -293,43 +293,58 @@ export function primeMarks(text: string, options: SymbolOptions = {}): string {
293
293
  return text
294
294
  }
295
295
 
296
- const FRACTION_MAP: Record<string, string> = {
297
- "1/4": UNICODE_SYMBOLS.FRACTION_1_4,
298
- "1/2": UNICODE_SYMBOLS.FRACTION_1_2,
299
- "3/4": UNICODE_SYMBOLS.FRACTION_3_4,
300
- "1/3": UNICODE_SYMBOLS.FRACTION_1_3,
301
- "2/3": UNICODE_SYMBOLS.FRACTION_2_3,
302
- "1/5": UNICODE_SYMBOLS.FRACTION_1_5,
303
- "2/5": UNICODE_SYMBOLS.FRACTION_2_5,
304
- "3/5": UNICODE_SYMBOLS.FRACTION_3_5,
305
- "4/5": UNICODE_SYMBOLS.FRACTION_4_5,
306
- "1/6": UNICODE_SYMBOLS.FRACTION_1_6,
307
- "5/6": UNICODE_SYMBOLS.FRACTION_5_6,
308
- "1/8": UNICODE_SYMBOLS.FRACTION_1_8,
309
- "3/8": UNICODE_SYMBOLS.FRACTION_3_8,
310
- "5/8": UNICODE_SYMBOLS.FRACTION_5_8,
311
- "7/8": UNICODE_SYMBOLS.FRACTION_7_8,
312
- }
296
+ /**
297
+ * Supported Unicode fractions: [numerator, denominator, unicode character].
298
+ * The regex alternation and lookup map are both derived from this list.
299
+ */
300
+ const FRACTION_TUPLES: [string, string, string][] = [
301
+ ["1", "4", UNICODE_SYMBOLS.FRACTION_1_4],
302
+ ["1", "2", UNICODE_SYMBOLS.FRACTION_1_2],
303
+ ["3", "4", UNICODE_SYMBOLS.FRACTION_3_4],
304
+ ["1", "3", UNICODE_SYMBOLS.FRACTION_1_3],
305
+ ["2", "3", UNICODE_SYMBOLS.FRACTION_2_3],
306
+ ["1", "5", UNICODE_SYMBOLS.FRACTION_1_5],
307
+ ["2", "5", UNICODE_SYMBOLS.FRACTION_2_5],
308
+ ["3", "5", UNICODE_SYMBOLS.FRACTION_3_5],
309
+ ["4", "5", UNICODE_SYMBOLS.FRACTION_4_5],
310
+ ["1", "6", UNICODE_SYMBOLS.FRACTION_1_6],
311
+ ["5", "6", UNICODE_SYMBOLS.FRACTION_5_6],
312
+ ["1", "8", UNICODE_SYMBOLS.FRACTION_1_8],
313
+ ["3", "8", UNICODE_SYMBOLS.FRACTION_3_8],
314
+ ["5", "8", UNICODE_SYMBOLS.FRACTION_5_8],
315
+ ["7", "8", UNICODE_SYMBOLS.FRACTION_7_8],
316
+ ]
313
317
 
314
- /** Convert 1/2, 1/4, etc. to ½, ¼, etc. */
318
+ const FRACTION_MAP = Object.fromEntries(FRACTION_TUPLES.map(([n, d, u]) => [`${n}/${d}`, u]))
319
+
320
+ /** Convert 1/2, 1/4, etc. to ½, ¼, etc. Single-pass using alternation. */
315
321
  export function fractions(text: string, options: SymbolOptions = {}): string {
316
322
  const chr = getEscapedSeparator(options)
317
323
 
318
- for (const [ascii, unicode] of Object.entries(FRACTION_MAP)) {
319
- // Negative lookbehind/lookahead: ensures fraction is not part of a larger number or path
320
- // - (?<![/.\d]) prevents matching after slashes, dots, or digits
321
- // - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
322
- // Named captures preserve separators before and after the slash
323
- const [numerator, denominator] = ascii.split("/")
324
- const pattern = cachedRegExp(
325
- `(?<![/\\.\\d])${numerator}(?<sepBefore>${chr}?)/(?<sepAfter>${chr}?)${denominator}(?![/\\d]|\\.\\d)`,
326
- "g"
327
- )
328
- // Preserve separators around the fraction Unicode character
329
- text = text.replace(pattern, `$<sepBefore>${unicode}$<sepAfter>`)
330
- }
324
+ // Build alternation of exact valid pairs: `1${sep}?/${sep}?4|1${sep}?/${sep}?2|...`
325
+ // Only exact pairs from FRACTION_TUPLES match — no cross-product.
326
+ const pairAlternation = FRACTION_TUPLES.map(([n, d]) =>
327
+ `${n}${chr}?/${chr}?${d}`
328
+ ).join("|")
331
329
 
332
- return text
330
+ // - (?<![/.\d]) prevents matching after slashes, dots, or digits
331
+ // - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
332
+ const pattern = cachedRegExp(
333
+ `(?<![/\\.\\d])(?:${pairAlternation})(?![/\\d]|\\.\\d)`,
334
+ "g"
335
+ )
336
+
337
+ const sepPattern = cachedRegExp(chr, "g")
338
+
339
+ return text.replace(pattern, (match) => {
340
+ // Split on separator to isolate the raw fraction and preserve markers.
341
+ // e.g. "1\uE000/\uE0002" → parts ["1", "/", "2"], separators ["\uE000", "\uE000"]
342
+ const parts = match.split(sepPattern)
343
+ const raw = parts.join("")
344
+ const separators = match.match(sepPattern) ?? []
345
+ // Distribute separators: first goes before the unicode char, second after
346
+ return `${separators[0] ?? ""}${FRACTION_MAP[raw]}${separators[1] ?? ""}`
347
+ })
333
348
  }
334
349
 
335
350
  const ORDINAL_MAP: Record<string, string> = {
@@ -1,5 +1,6 @@
1
1
  import { hyphenReplace, enDashNumberRange, enDashDateRange, minusReplace, numberRangeDisallowedPrefixes } from "../dashes.js"
2
2
  import { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "../constants.js"
3
+ import { assertLinearScaling } from "./test-helpers.js"
3
4
 
4
5
  const {
5
6
  LEFT_DOUBLE_QUOTE,
@@ -709,3 +710,13 @@ describe("hyphenReplace preserves multi-segment numbers across separators", () =
709
710
  expect(hyphenReplace(input, { separator: sep })).toBe(input)
710
711
  })
711
712
  })
713
+
714
+ describe("dash stress tests", () => {
715
+ it("scales linearly for parenthetical dashes", () => {
716
+ assertLinearScaling(hyphenReplace, (n) => "word - word ".repeat(n))
717
+ })
718
+
719
+ it("scales linearly for number ranges", () => {
720
+ assertLinearScaling(hyphenReplace, (n) => "pages 1-2 ".repeat(n))
721
+ })
722
+ })
@@ -1,6 +1,7 @@
1
1
  import { transform, DEFAULT_SEPARATOR, countSeparators } from "../index.js"
2
2
  import { ellipsis } from "../symbols.js"
3
- import { UNICODE_SYMBOLS, REGEX_SPECIAL_CHARS } from "../constants.js"
3
+ import { UNICODE_SYMBOLS, REGEX_SPECIAL_CHARS, MAX_REGEX_CACHE_SIZE } from "../constants.js"
4
+ import { assertLinearScaling, buildMixedContent } from "./test-helpers.js"
4
5
 
5
6
  const {
6
7
  LEFT_DOUBLE_QUOTE,
@@ -507,36 +508,16 @@ describe("transform", () => {
507
508
  })
508
509
 
509
510
  describe("regex performance", () => {
510
- it("handles pathological quote patterns efficiently", () => {
511
- const input = '"a"'.repeat(50) + "'"
512
- const start = Date.now()
513
- transform(input)
514
- const duration = Date.now() - start
515
- expect(duration).toBeLessThan(1000)
511
+ it("scales linearly for pathological quote patterns", () => {
512
+ assertLinearScaling(transform, (n) => '"a"'.repeat(n) + "'")
516
513
  })
517
514
 
518
- it("handles pathological dash patterns efficiently", () => {
519
- const input = "1-2-3-4-5-6-7-8-9-10-11-12-13-14-15"
520
- const start = Date.now()
521
- transform(input)
522
- const duration = Date.now() - start
523
- expect(duration).toBeLessThan(1000)
515
+ it("scales linearly for pathological dash patterns", () => {
516
+ assertLinearScaling(transform, (n) => "1" + "-1".repeat(n))
524
517
  })
525
518
 
526
- it("handles 1000 unbalanced single quotes efficiently", () => {
527
- const input = "'".repeat(1000) + "a"
528
- const start = performance.now()
529
- transform(input)
530
- const duration = performance.now() - start
531
- expect(duration).toBeLessThan(500)
532
- })
533
-
534
- it("handles 500 chained number patterns efficiently", () => {
535
- const input = "1" + "-1".repeat(500)
536
- const start = performance.now()
537
- transform(input)
538
- const duration = performance.now() - start
539
- expect(duration).toBeLessThan(500)
519
+ it("scales linearly for unbalanced single quotes", () => {
520
+ assertLinearScaling(transform, (n) => "'".repeat(n) + "a")
540
521
  })
541
522
  })
542
523
 
@@ -626,28 +607,110 @@ describe("transform", () => {
626
607
  })
627
608
 
628
609
  describe("large input handling", () => {
629
- it("handles 1MB of text", () => {
630
- const input = "Hello, world! ".repeat(70000)
631
- const start = performance.now()
632
- transform(input)
633
- expect(performance.now() - start).toBeLessThan(2000)
610
+ it("scales linearly on seeded mixed content", () => {
611
+ assertLinearScaling(transform, (n) => buildMixedContent(n * 100), 50)
612
+ })
613
+
614
+ it("scales linearly with all features enabled", () => {
615
+ const allFeatures = { fractions: true, degrees: true, superscript: true, ligatures: true }
616
+ assertLinearScaling((input) => transform(input, allFeatures), (n) => buildMixedContent(n * 100), 50)
634
617
  })
635
618
  })
636
619
 
637
- describe("ReDoS resistance", () => {
638
- const MAX_TIMEOUT_MS = 100
620
+ describe("idempotency across punctuation styles", () => {
621
+ const mixedInput = `"Hello," she said -- "it's pages 1-5." Wait... 5x5 != 25. He's 5'10" tall.`
639
622
 
640
623
  it.each([
641
- ["10000 single quotes", "'".repeat(10000)],
642
- ["10000 double quotes", '"'.repeat(10000)],
643
- ["1000 unbalanced quotes", '"Hello '.repeat(1000)],
644
- ["10000 hyphens", "-".repeat(10000)],
645
- ["alternating digits/hyphens", "1-2-3-4-5-6-7-8-9-0".repeat(500)],
646
- ["10000 dots", ".".repeat(10000)],
647
- ] as const)("handles %s", (_, input) => {
624
+ ["american"],
625
+ ["british"],
626
+ ["german"],
627
+ ["french"],
628
+ ] as const)("idempotent with %s style", (style) => {
629
+ const opts = { punctuationStyle: style, checkIdempotency: false, nbsp: false } as const
630
+ const first = transform(mixedInput, opts)
631
+ const second = transform(first, opts)
632
+ expect(second).toBe(first)
633
+ })
634
+ })
635
+
636
+ describe("edge case inputs", () => {
637
+ it.each([
638
+ ["empty string", "", ""],
639
+ ["single space", " ", " "],
640
+ ["newlines only", "\n\n\n", "\n\n\n"],
641
+ ["tab only", "\t", "\t"],
642
+ ["nbsp only", NBSP, NBSP],
643
+ ])("handles %s", (_desc, input, expected) => {
644
+ expect(transform(input)).toBe(expected)
645
+ })
646
+ })
647
+
648
+ describe("separator collision", () => {
649
+ it("preserves default separator in plain transform", () => {
650
+ // transform() itself uses the separator internally but tolerates it in input
651
+ // The assertSeparatorAbsent check is in the rehype/remark plugins
652
+ const input = `text${DEFAULT_SEPARATOR}more`
653
+ const result = transform(input)
654
+ expect(countSeparators(result, DEFAULT_SEPARATOR)).toBe(1)
655
+ })
656
+
657
+ it("does not throw for single char of multi-char default separator", () => {
658
+ expect(() => transform(`text${DEFAULT_SEPARATOR[0]}more`)).not.toThrow()
659
+ })
660
+ })
661
+
662
+ describe("regex cache thrashing", () => {
663
+ it("produces correct results after exceeding cache capacity", () => {
664
+ const input = `"Hello," she said.`
665
+ const separatorCount = MAX_REGEX_CACHE_SIZE + 200
666
+ for (let i = 0; i < separatorCount; i++) {
667
+ const sep = String.fromCharCode(0xE100 + i)
668
+ const result = transform(input, { separator: sep, nbsp: false })
669
+ expect(result).toBe(`${LEFT_DOUBLE_QUOTE}Hello,${RIGHT_DOUBLE_QUOTE} she said.`)
670
+ }
671
+ })
672
+ })
673
+
674
+ describe("ReDoS resistance", () => {
675
+ it.each([
676
+ ["single quotes", (n: number) => "'".repeat(n)],
677
+ ["double quotes", (n: number) => '"'.repeat(n)],
678
+ ["unbalanced quotes", (n: number) => '"Hello '.repeat(n)],
679
+ ["hyphens", (n: number) => "-".repeat(n)],
680
+ ["alternating digits/hyphens", (n: number) => "1-2-3-4-5-6-7-8-9-0".repeat(n / 10)],
681
+ ["dots", (n: number) => ".".repeat(n)],
682
+ ["fraction-like patterns", (n: number) => "1/2 ".repeat(n)],
683
+ ["short words (nbsp)", (n: number) => "a b c d e f ".repeat(n)],
684
+ ["math symbol patterns", (n: number) => "!= ".repeat(n)],
685
+ ] as const)("scales linearly for %s", (_, buildInput) => {
686
+ assertLinearScaling(transform, buildInput)
687
+ })
688
+
689
+ it("scales linearly on seeded mixed content", () => {
690
+ assertLinearScaling(transform, (n) => buildMixedContent(n * 10))
691
+ })
692
+ })
693
+
694
+ describe("edge cases", () => {
695
+ it("handles empty string input", () => {
696
+ expect(transform("")).toBe("")
697
+ expect(transform("", { nbsp: false })).toBe("")
698
+ })
699
+
700
+ it("handles undefined option values", () => {
701
+ const input = '"Hello," she said.'
702
+ // `nbsp: undefined` overrides the default (true) via spread, so nbsp is skipped
703
+ const expected = transform(input, { nbsp: false })
704
+ expect(transform(input, { nbsp: undefined })).toBe(expected)
705
+ })
706
+
707
+ it("handles long string without transformable content", () => {
708
+ const input = "abcdefghij ".repeat(1000)
648
709
  const start = performance.now()
649
- transform(input)
650
- expect(performance.now() - start).toBeLessThan(MAX_TIMEOUT_MS)
710
+ const result = transform(input)
711
+ const elapsed = performance.now() - start
712
+ expect(result).toBe(input)
713
+ expect(elapsed).toBeLessThan(2000)
651
714
  })
652
715
  })
653
716
  })
@@ -1,6 +1,12 @@
1
+ import { readFileSync } from "fs"
2
+ import { resolve } from "path"
3
+ import { fileURLToPath } from "url"
1
4
  import { transformMarkdown } from "../markdown.js"
5
+ import { transform } from "../index.js"
2
6
  import { UNICODE_SYMBOLS } from "../constants.js"
3
7
 
8
+ const __dirname = fileURLToPath(new URL(".", import.meta.url))
9
+
4
10
  const {
5
11
  LEFT_DOUBLE_QUOTE: LDQ,
6
12
  RIGHT_DOUBLE_QUOTE: RDQ,
@@ -75,4 +81,22 @@ describe("transformMarkdown", () => {
75
81
  })
76
82
  expect(result.trimEnd()).toEqual("See references \\[1\\]\\[4\\]\\[5\\]\\[6\\] for details.")
77
83
  })
84
+
85
+ it("README.md prose is already typographically correct", () => {
86
+ const readme = readFileSync(resolve(__dirname, "../../README.md"), "utf-8")
87
+ const lines = readme.split("\n")
88
+ let inCodeBlock = false
89
+
90
+ for (const line of lines) {
91
+ if (line.startsWith("```")) { inCodeBlock = !inCodeBlock; continue }
92
+ if (inCodeBlock) continue
93
+ // Skip non-prose: tables, badges, HTML, footnotes, headings, list items (including indented)
94
+ if (/^[|#>\d]|^\s*-\s|^\s*\d+\.\s|^\[[\^!]|<span/.test(line)) continue
95
+ if (line.trim() === "") continue
96
+
97
+ // collapseSpaces: false because Markdown uses double-space for formatting
98
+ const transformed = transform(line, { nbsp: false, collapseSpaces: false, checkIdempotency: false })
99
+ expect(transformed).toBe(line)
100
+ }
101
+ })
78
102
  })
@@ -14,6 +14,7 @@ import {
14
14
  type NbspOptions,
15
15
  } from "../nbsp.js"
16
16
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
17
+ import { assertLinearScaling } from "./test-helpers.js"
17
18
 
18
19
  const { NBSP, COPYRIGHT, REGISTERED, TRADEMARK } = UNICODE_SYMBOLS
19
20
  const SEP = DEFAULT_SEPARATOR
@@ -252,4 +253,15 @@ describe("nbspTransform", () => {
252
253
  expect(nbspTransform("St. Anne had 5 kg")).toBe(`St.${NBSP}Anne had 5${NBSP}kg`)
253
254
  })
254
255
  })
256
+
257
+ describe("stress tests", () => {
258
+ it("scales linearly for short words", () => {
259
+ assertLinearScaling(nbspTransform, (n) => "a b c d e f ".repeat(n))
260
+ })
261
+
262
+ it("early-exits on text with no spaces", () => {
263
+ const result = nbspTransform("nospaces".repeat(1000))
264
+ expect(result).toBe("nospaces".repeat(1000))
265
+ })
266
+ })
255
267
  })
@@ -1,5 +1,6 @@
1
1
  import { niceQuotes, classifyApostrophes } from "../quotes.js"
2
2
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, TERMINAL_PUNCTUATION } from "../constants.js"
3
+ import { assertLinearScaling } from "./test-helpers.js"
3
4
 
4
5
  const {
5
6
  LEFT_DOUBLE_QUOTE,
@@ -170,13 +171,13 @@ describe("niceQuotes", () => {
170
171
  expect(classifyApostrophes(result)).toBe(result)
171
172
  })
172
173
 
173
- it("stress: 200 consecutive plural possessives", () => {
174
+ it("stress: 200 consecutive plural possessives scales linearly", () => {
174
175
  const words = ["dogs'", "cats'", "Bayes'", "Thomas'", "PLAYERS'"]
175
- const input = Array.from({ length: 200 }, (_, i) => words[i % words.length]).join(" ")
176
- const start = performance.now()
177
- const result = classifyApostrophes(input)
178
- expect(performance.now() - start).toBeLessThan(1000)
176
+ const buildInput = (n: number) => Array.from({ length: n }, (_, i) => words[i % words.length]).join(" ")
177
+
178
+ assertLinearScaling(classifyApostrophes, buildInput)
179
179
  // All should be MLA (no LSQ to pair with)
180
+ const result = classifyApostrophes(buildInput(200))
180
181
  expect(result).not.toContain(RIGHT_SINGLE_QUOTE)
181
182
  expect(result).not.toContain("'")
182
183
  expect(classifyApostrophes(result)).toBe(result)
@@ -701,20 +702,16 @@ describe("niceQuotes", () => {
701
702
  })
702
703
 
703
704
  describe("pathological inputs", () => {
704
- it("handles 1500-char input without closing quote", () => {
705
+ it("scales linearly for input without closing quote", () => {
706
+ assertLinearScaling(classifyApostrophes, (n) => `'${"a".repeat(n)}`)
705
707
  const input = `'${"a".repeat(1500)}`
706
- const start = performance.now()
707
- const result = classifyApostrophes(input)
708
- expect(performance.now() - start).toBeLessThan(1000)
709
- expect(classifyApostrophes(result)).toBe(result)
708
+ expect(classifyApostrophes(classifyApostrophes(input))).toBe(classifyApostrophes(input))
710
709
  })
711
710
 
712
711
  it("handles 16 rapid apostrophes", () => {
713
712
  const input = "a'b'c'd'e'f'g'h'i'j'k'l'm'n'o'p"
714
713
  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`
715
- const start = performance.now()
716
714
  const result = classifyApostrophes(input)
717
- expect(performance.now() - start).toBeLessThan(1000)
718
715
  expect(result).toBe(expected)
719
716
  expect(classifyApostrophes(result)).toBe(result)
720
717
  })
@@ -725,4 +722,25 @@ describe("niceQuotes", () => {
725
722
  })
726
723
  })
727
724
 
725
+ describe("mixed-locale stress", () => {
726
+ it.each([
727
+ ["german", `Er sagte, "es ist gro\u00DFartig" und sie sagte, "h\u00F6r nicht auf"`, DOUBLE_LOW_9_QUOTE],
728
+ ["french", `"C'est magnifique," dit-elle. "N'arr\u00EAte pas."`, LEFT_GUILLEMET],
729
+ ["american", `He said, "She said, 'They said, "No."'"`, LEFT_DOUBLE_QUOTE],
730
+ ["british", `He said, "She said, 'They said, "No."'"`, LEFT_DOUBLE_QUOTE],
731
+ ["german", `Er fragte, "Hat sie 'Nein' gesagt?"`, DOUBLE_LOW_9_QUOTE],
732
+ ["french", `"Qui a dit 'bonjour'?" demanda-t-il.`, LEFT_GUILLEMET],
733
+ ] as const)("%s style produces locale-appropriate quotes and is idempotent", (style, input, expectedChar) => {
734
+ const opts = { punctuationStyle: style }
735
+ const first = niceQuotes(input, opts)
736
+ expect(first).toContain(expectedChar)
737
+ expect(niceQuotes(first, opts)).toBe(first)
738
+ })
739
+ })
740
+
741
+ describe("apostrophe regex scaling", () => {
742
+ it("scales linearly for repeated apostrophe patterns", () => {
743
+ assertLinearScaling(classifyApostrophes, (n) => "'a".repeat(n))
744
+ })
745
+ })
728
746
  })
@@ -414,14 +414,12 @@ describe("rehypePunctilio", () => {
414
414
 
415
415
  it("inserts nbsp when option enabled", async () => {
416
416
  const result = await processHtml('<p>Dr. Smith has 5 kg of items.</p>', { nbsp: true })
417
- expect(result).toContain(`Dr.${NBSP}Smith`)
418
- expect(result).toContain(`5${NBSP}kg`)
417
+ expect(result).toBe(`<p>Dr.${NBSP}Smith has 5${NBSP}kg${NBSP}of${NBSP}items.</p>`)
419
418
  })
420
419
 
421
420
  it("inserts nbsp by default", async () => {
422
421
  const result = await processHtml('<p>Dr. Smith has 5 kg of items.</p>')
423
- expect(result).toContain(`Dr.${NBSP}Smith`)
424
- expect(result).toContain(`5${NBSP}kg`)
422
+ expect(result).toBe(`<p>Dr.${NBSP}Smith has 5${NBSP}kg${NBSP}of${NBSP}items.</p>`)
425
423
  })
426
424
 
427
425
  it("does not insert nbsp when option disabled", async () => {
@@ -431,9 +429,7 @@ describe("rehypePunctilio", () => {
431
429
 
432
430
  it("works across element boundaries", async () => {
433
431
  const result = await processHtml('<p>See <em>Fig.</em> 1</p>', { nbsp: true })
434
- expect(result).toContain(`Fig.`)
435
- // The nbsp should appear between Fig. and 1
436
- expect(result).toContain(NBSP)
432
+ expect(result).toBe(`<p>See <em>Fig.</em>${NBSP}1</p>`)
437
433
  })
438
434
  })
439
435
 
@@ -461,4 +457,55 @@ describe("rehypePunctilio", () => {
461
457
  )
462
458
  })
463
459
  })
460
+
461
+ describe("stress tests", () => {
462
+ it("handles many sibling paragraphs", async () => {
463
+ // 100 siblings: enough to exercise the visitor/transformed-set logic
464
+ // without spending seconds on HTML parsing
465
+ const count = 100
466
+ const html = '<div>' + '<p>"Hello," she said.</p>'.repeat(count) + '</div>'
467
+ const result = await processHtml(html, { nbsp: false })
468
+ const matchCount = (result.match(new RegExp(LDQ, "g")) ?? []).length
469
+ expect(matchCount).toBe(count)
470
+ })
471
+
472
+ it("handles paragraph with many inline elements", async () => {
473
+ const count = 100
474
+ const inlineElements = Array.from({ length: count }, (_, i) =>
475
+ `<em>"word${i}"</em>`
476
+ ).join(" ")
477
+ const html = `<p>${inlineElements}</p>`
478
+ const result = await processHtml(html, { nbsp: false })
479
+ const matchCount = (result.match(new RegExp(LDQ, "g")) ?? []).length
480
+ expect(matchCount).toBe(count)
481
+ })
482
+
483
+ it("transforms text at 100-level deep nesting", async () => {
484
+ let html = '"Hello"'
485
+ for (let i = 0; i < 100; i++) {
486
+ html = `<span>${html}</span>`
487
+ }
488
+ html = `<p>${html}</p>`
489
+ const result = await processHtml(html, { nbsp: false })
490
+ let expected = `${LDQ}Hello${RDQ}`
491
+ for (let i = 0; i < 100; i++) expected = `<span>${expected}</span>`
492
+ expected = `<p>${expected}</p>`
493
+ expect(result).toBe(expected)
494
+ })
495
+
496
+ it("does not crash beyond MAX_RECURSION_DEPTH", async () => {
497
+ let html = '"Hello"'
498
+ for (let i = 0; i < 1050; i++) {
499
+ html = `<span>${html}</span>`
500
+ }
501
+ html = `<p>${html}</p>`
502
+ // Should not throw — deep nesting is silently skipped
503
+ const result = await processHtml(html, { nbsp: false })
504
+ // Build expected: same structure with smart quotes applied
505
+ let expected = `${LDQ}Hello${RDQ}`
506
+ for (let i = 0; i < 1050; i++) expected = `<span>${expected}</span>`
507
+ expected = `<p>${expected}</p>`
508
+ expect(result).toBe(expected)
509
+ })
510
+ })
464
511
  })
@@ -170,4 +170,31 @@ describe("remarkPunctilio", () => {
170
170
  const first = await processMarkdown('"Hello," she said -- "it\'s pages 1-5."', { nbsp: false })
171
171
  expect(await processMarkdown(first, { nbsp: false })).toEqual(first)
172
172
  })
173
+
174
+ describe("stress tests", () => {
175
+ it("handles many paragraphs", async () => {
176
+ const count = 100
177
+ const paragraphs = Array.from({ length: count }, () => '"Hello," she said.').join("\n\n")
178
+ const result = await processMarkdown(paragraphs, { nbsp: false })
179
+ const matchCount = (result.match(new RegExp(LDQ, "g")) ?? []).length
180
+ expect(matchCount).toBe(count)
181
+ })
182
+
183
+ it("handles paragraph with many inline elements", async () => {
184
+ const count = 50
185
+ const inlines = Array.from({ length: count }, (_, i) => `*"word${i}"*`).join(" ")
186
+ const result = await processMarkdown(inlines, { nbsp: false })
187
+ const matchCount = (result.match(new RegExp(LDQ, "g")) ?? []).length
188
+ expect(matchCount).toBe(count)
189
+ })
190
+
191
+ it("handles quotes spanning nested inline elements", async () => {
192
+ // Exercises text node flattening across multiple nesting levels:
193
+ // emphasis inside strong inside link text
194
+ const md = `[**"Hello,** *world"*](url)`
195
+ const result = await processMarkdown(md, { nbsp: false })
196
+ expect(result).toContain(LDQ)
197
+ expect(result).toContain(RDQ)
198
+ })
199
+ })
173
200
  })
@@ -13,6 +13,7 @@ import {
13
13
  symbolTransform,
14
14
  } from "../symbols.js"
15
15
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
16
+ import { assertLinearScaling } from "./test-helpers.js"
16
17
 
17
18
  describe("ellipsis", () => {
18
19
  it.each([
@@ -683,11 +684,9 @@ describe("symbolTransform", () => {
683
684
  })
684
685
 
685
686
  describe("multiplication ReDoS regression", () => {
686
- it("handles 50k digits without quadratic slowdown", () => {
687
- const start = performance.now()
688
- multiplication("1".repeat(50_000))
689
- // Before fix: ~4400ms (O(n²)). After fix: <50ms (O(n)).
690
- expect(performance.now() - start).toBeLessThan(500)
687
+ it("scales linearly for long digit strings", () => {
688
+ // Before fix: quadratic (~100x for 10x input). After fix: linear.
689
+ assertLinearScaling(multiplication, (n) => "1".repeat(n), 5_000)
691
690
  })
692
691
  })
693
692
 
@@ -784,6 +783,8 @@ describe("fractions edge cases", () => {
784
783
  ["(1/2)", `(${UNICODE_SYMBOLS.FRACTION_1_2})`],
785
784
  ["5/7", "5/7"],
786
785
  ["11/12", "11/12"],
786
+ ["2/2", "2/2"],
787
+ ["4/3", "4/3"],
787
788
  ])('handles fraction edge: "%s"', (input, expected) => {
788
789
  expect(fractions(input)).toBe(expected)
789
790
  })
@@ -802,3 +803,21 @@ describe("chained multiplications", () => {
802
803
  })
803
804
  })
804
805
 
806
+ describe("symbol stress tests", () => {
807
+ it("scales linearly for ellipsis patterns", () => {
808
+ assertLinearScaling(ellipsis, (n) => "wait... ".repeat(n))
809
+ })
810
+
811
+ it("scales linearly for fraction patterns", () => {
812
+ assertLinearScaling(fractions, (n) => "add 1/2 cup ".repeat(n))
813
+ })
814
+
815
+ it("completes 10000 consecutive single quotes in under 2 seconds", () => {
816
+ const input = "'".repeat(10000)
817
+ const start = performance.now()
818
+ symbolTransform(input)
819
+ const elapsed = performance.now() - start
820
+ expect(elapsed).toBeLessThan(2000)
821
+ })
822
+ })
823
+