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/README.md +4 -3
- package/dist/constants.d.ts +6 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +9 -2
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +5 -1
- package/dist/nbsp.js.map +1 -1
- package/dist/symbols.d.ts +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +39 -29
- package/dist/symbols.js.map +1 -1
- package/dist/tests/test-helpers.d.ts +30 -0
- package/dist/tests/test-helpers.d.ts.map +1 -0
- package/dist/tests/test-helpers.js +91 -0
- package/dist/tests/test-helpers.js.map +1 -0
- package/package.json +3 -1
- package/src/constants.ts +10 -2
- package/src/index.ts +8 -0
- package/src/nbsp.ts +5 -1
- package/src/symbols.ts +47 -32
- package/src/tests/dashes.test.ts +11 -0
- package/src/tests/index.test.ts +106 -43
- package/src/tests/markdown.test.ts +24 -0
- package/src/tests/nbsp.test.ts +12 -0
- package/src/tests/quotes.test.ts +30 -12
- package/src/tests/rehype.test.ts +54 -7
- package/src/tests/remark.test.ts +27 -0
- package/src/tests/symbols.test.ts +24 -5
- package/src/tests/test-helpers.ts +102 -0
- package/src/tests/utils.test.ts +11 -7
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
"
|
|
302
|
-
"1
|
|
303
|
-
"
|
|
304
|
-
"3
|
|
305
|
-
"
|
|
306
|
-
"1
|
|
307
|
-
"5
|
|
308
|
-
"
|
|
309
|
-
"
|
|
310
|
-
"
|
|
311
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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> = {
|
package/src/tests/dashes.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/tests/index.test.ts
CHANGED
|
@@ -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("
|
|
511
|
-
|
|
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("
|
|
519
|
-
|
|
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("
|
|
527
|
-
|
|
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("
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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("
|
|
638
|
-
const
|
|
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
|
-
["
|
|
642
|
-
["
|
|
643
|
-
["
|
|
644
|
-
["
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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
|
})
|
package/src/tests/nbsp.test.ts
CHANGED
|
@@ -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
|
})
|
package/src/tests/quotes.test.ts
CHANGED
|
@@ -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
|
|
176
|
-
|
|
177
|
-
|
|
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("
|
|
705
|
+
it("scales linearly for input without closing quote", () => {
|
|
706
|
+
assertLinearScaling(classifyApostrophes, (n) => `'${"a".repeat(n)}`)
|
|
705
707
|
const input = `'${"a".repeat(1500)}`
|
|
706
|
-
|
|
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
|
})
|
package/src/tests/rehype.test.ts
CHANGED
|
@@ -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).
|
|
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).
|
|
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).
|
|
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
|
})
|
package/src/tests/remark.test.ts
CHANGED
|
@@ -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("
|
|
687
|
-
|
|
688
|
-
multiplication("1".repeat(
|
|
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
|
+
|