punctilio 3.0.7 → 3.0.9

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/quotes.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import escapeStringRegexp from "escape-string-regexp"
6
- import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS, TERMINAL_PUNCTUATION } from "./constants.js"
6
+ import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS, TERMINAL_PUNCTUATION, cachedRegExp } from "./constants.js"
7
7
 
8
8
  const {
9
9
  EM_DASH,
@@ -34,8 +34,8 @@ function convertSingleQuotes(text: string, sep: string): string {
34
34
  // Only match straight quotes, not already-converted curly quotes
35
35
  const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
36
36
  const singleQuoteOrWord = `[${singleQuoteChars}\\w]`
37
- text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
38
- text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})'(\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
37
+ text = text.replace(cachedRegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
38
+ text = text.replace(cachedRegExp(`(?<!${singleQuoteOrWord})'(\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
39
39
 
40
40
  const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
41
41
  // Full pattern with optional 's' for lookahead detection in apostropheRegex
@@ -45,32 +45,32 @@ function convertSingleQuotes(text: string, sep: string): string {
45
45
  // the general rules produced LSQ+n+RSQ which was fine. But MLA requires both
46
46
  // quotes to be MLA (semantic apostrophes), and the general rules can't achieve
47
47
  // that: neither quote is in a contraction context (no Latin letter on both sides).
48
- text = text.replace(new RegExp(`(?<=\\w${escapedSep}? )['${RIGHT_SINGLE_QUOTE}]n['${RIGHT_SINGLE_QUOTE}](?= ${escapedSep}?\\w)`, "gm"), `${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}`)
48
+ text = text.replace(cachedRegExp(`(?<=\\w${escapedSep}? )['${RIGHT_SINGLE_QUOTE}]n['${RIGHT_SINGLE_QUOTE}](?= ${escapedSep}?\\w)`, "gm"), `${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}`)
49
49
 
50
50
  // Possessive: 's followed by ending context (e.g., dog's) → U+02BC
51
51
  const afterPossessive = `(?=${escapedSep}?s${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
52
52
  const possessiveSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])['${RIGHT_SINGLE_QUOTE}]${afterPossessive}`
53
- text = text.replace(new RegExp(possessiveSingle, "gm"), MODIFIER_LETTER_APOSTROPHE)
53
+ text = text.replace(cachedRegExp(possessiveSingle, "gm"), MODIFIER_LETTER_APOSTROPHE)
54
54
 
55
55
  // Closing single quote: ending context without 's' → U+2019
56
56
  const afterClosingSingle = `(?=${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
57
57
  const closingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterClosingSingle}`
58
- text = text.replace(new RegExp(closingSingle, "gm"), RIGHT_SINGLE_QUOTE)
58
+ text = text.replace(cachedRegExp(closingSingle, "gm"), RIGHT_SINGLE_QUOTE)
59
59
 
60
60
  const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}](?=${escapedSep}?[${LATIN_LETTERS}])`
61
- text = text.replace(new RegExp(contraction, "gm"), MODIFIER_LETTER_APOSTROPHE)
61
+ text = text.replace(cachedRegExp(contraction, "gm"), MODIFIER_LETTER_APOSTROPHE)
62
62
 
63
63
  const apostropheWhitelist = `(?=n${MODIFIER_LETTER_APOSTROPHE} )`
64
64
  const endQuoteNotContraction = `(?!${contraction})[${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}]${afterEndingSingle}`
65
65
  // Limit lookahead scan to 1000 chars to prevent catastrophic backtracking on pathological inputs
66
- const apostropheRegex = new RegExp(
66
+ const apostropheRegex = cachedRegExp(
67
67
  `(?<=^|[^\\w])['](${apostropheWhitelist}|(?![^${LEFT_SINGLE_QUOTE}'\\n]{0,1000}${endQuoteNotContraction}))`,
68
68
  "gm"
69
69
  )
70
70
  text = text.replace(apostropheRegex, MODIFIER_LETTER_APOSTROPHE)
71
71
 
72
72
  const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
73
- text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
73
+ text = text.replace(cachedRegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
74
74
 
75
75
  text = convertUnmatchedPluralPossessives(text, sep)
76
76
 
@@ -85,7 +85,7 @@ function convertSingleQuotes(text: string, sep: string): string {
85
85
  function convertUnmatchedPluralPossessives(text: string, sep: string): string {
86
86
  let singleQuoteBalance = 0
87
87
  return text.replace(
88
- new RegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"),
88
+ cachedRegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"),
89
89
  (match, offset) => {
90
90
  if (match === LEFT_SINGLE_QUOTE) {
91
91
  singleQuoteBalance++
@@ -115,19 +115,19 @@ function convertDoubleQuotes(text: string, sep: string): string {
115
115
  // Handle whitespace-only quotes " " - require non-quote chars on both sides
116
116
  text = text.replace(/(?<=^|[\s([{])"(?<whitespace>\s+)"(?=$|[\s)\]}.!?,;:])/g, `${LEFT_DOUBLE_QUOTE}$<whitespace>${RIGHT_DOUBLE_QUOTE}`)
117
117
 
118
- const beginningDouble = new RegExp(
118
+ const beginningDouble = cachedRegExp(
119
119
  `(?<=^|[\\s\\(\\/\\[\\{\\-${EM_DASH}${escapedSep}])(?<beforeChr>${escapedSep}?)["](?<afterChr>(?<sepWithPunct>${escapedSep}[ .,])|(?=${escapedSep}?\\.{3}|${escapedSep}?[^\\s\\)\\${EM_DASH},!?${escapedSep};:.\\}]))`,
120
120
  "gm"
121
121
  )
122
122
  text = text.replace(beginningDouble, `$<beforeChr>${LEFT_DOUBLE_QUOTE}$<afterChr>`)
123
123
 
124
- text = text.replace(new RegExp(`(?<=\\{)(?<sepSpace>${escapedSep}? )?["]`, "g"), `$<sepSpace>${LEFT_DOUBLE_QUOTE}`)
124
+ text = text.replace(cachedRegExp(`(?<=\\{)(?<sepSpace>${escapedSep}? )?["]`, "g"), `$<sepSpace>${LEFT_DOUBLE_QUOTE}`)
125
125
 
126
126
  const endingDouble = `(?<beforeQuote>[^\\s\\(])["]((?<sepAfter>${escapedSep})?)(?=${escapedSep}|[\\s/\\).,;${EM_DASH}:\\-\\}!?s]|$)`
127
- text = text.replace(new RegExp(endingDouble, "g"), `$<beforeQuote>${RIGHT_DOUBLE_QUOTE}$<sepAfter>`)
127
+ text = text.replace(cachedRegExp(endingDouble, "g"), `$<beforeQuote>${RIGHT_DOUBLE_QUOTE}$<sepAfter>`)
128
128
 
129
- text = text.replace(new RegExp(`["](?<sepEnd>${escapedSep}?)$`, "g"), `${RIGHT_DOUBLE_QUOTE}$<sepEnd>`)
130
- text = text.replace(new RegExp(`'(?=${RIGHT_DOUBLE_QUOTE})`, "gu"), RIGHT_SINGLE_QUOTE)
129
+ text = text.replace(cachedRegExp(`["](?<sepEnd>${escapedSep}?)$`, "g"), `${RIGHT_DOUBLE_QUOTE}$<sepEnd>`)
130
+ text = text.replace(cachedRegExp(`'(?=${RIGHT_DOUBLE_QUOTE})`, "gu"), RIGHT_SINGLE_QUOTE)
131
131
 
132
132
  return text
133
133
  }
@@ -138,14 +138,14 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
138
138
 
139
139
  if (style === "american") {
140
140
  // Period outside → inside: "Hello". → "Hello."
141
- const periodOutsideRegex = new RegExp(
141
+ const periodOutsideRegex = cachedRegExp(
142
142
  `(?<![${TERMINAL_PUNCTUATION_CLASS}])(?<sepBefore>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])(?<sepAfter>${escapedSep}?)(?!\\.\\.\\.)\\.`,
143
143
  "g"
144
144
  )
145
145
  text = text.replace(periodOutsideRegex, "$<sepBefore>.$<quote>$<sepAfter>")
146
146
 
147
147
  // Comma outside → inside: "Hello", → "Hello,"
148
- const commaOutsideRegex = new RegExp(
148
+ const commaOutsideRegex = cachedRegExp(
149
149
  `(?<![${TERMINAL_PUNCTUATION_CLASS}])(?<sepBefore>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])(?<sepAfter>${escapedSep}?),`,
150
150
  "g"
151
151
  )
@@ -153,14 +153,14 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
153
153
  } else if (style === "british") {
154
154
  // Period inside → outside: "Hello." → "Hello".
155
155
  // No terminal punctuation guard — "Stop!." inside is always wrong; move the period out.
156
- const periodInsideRegex = new RegExp(
156
+ const periodInsideRegex = cachedRegExp(
157
157
  `(?<sepBefore>${escapedSep}?)\\.(?<sepMiddle>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])`,
158
158
  "g"
159
159
  )
160
160
  text = text.replace(periodInsideRegex, "$<sepBefore>$<sepMiddle>$<quote>.")
161
161
 
162
162
  // Comma inside → outside: "Hello," → "Hello",
163
- const commaInsideRegex = new RegExp(
163
+ const commaInsideRegex = cachedRegExp(
164
164
  `,(?<sepAndQuote>${escapedSep}?[${RIGHT_DOUBLE_QUOTE}${RIGHT_SINGLE_QUOTE}])`,
165
165
  "g"
166
166
  )
package/src/rehype.ts CHANGED
@@ -389,7 +389,8 @@ export function collectTransformableElements(
389
389
  // Otherwise, recurse into children
390
390
  for (const child of node.children) {
391
391
  if (child.type === "element") {
392
- results.push(...collectTransformableElements(child, shouldSkip, depth + 1))
392
+ const childResults = collectTransformableElements(child, shouldSkip, depth + 1)
393
+ for (const r of childResults) results.push(r)
393
394
  }
394
395
  }
395
396
  }
package/src/symbols.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  * Symbol transformations: ellipses, multiplication, math symbols, arrows.
3
3
  */
4
4
 
5
- import escapeStringRegexp from "escape-string-regexp"
6
- import { UNICODE_SYMBOLS, ESCAPED_DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, spaceBoundaryStart, spaceBoundaryEnd, cachedRegExp } from "./constants.js"
5
+ import { UNICODE_SYMBOLS, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, spaceBoundaryStart, spaceBoundaryEnd, cachedRegExp, getEscapedSeparator } from "./constants.js"
7
6
 
8
7
  export interface SymbolOptions {
9
8
  /** Boundary marker for HTML element boundaries. Default: "\uE000" */
@@ -41,9 +40,7 @@ const {
41
40
 
42
41
  /** Convert "..." or ". . ." to "…". */
43
42
  export function ellipsis(text: string, options: SymbolOptions = {}): string {
44
- const chr = options.separator
45
- ? escapeStringRegexp(options.separator)
46
- : ESCAPED_DEFAULT_SEPARATOR
43
+ const chr = getEscapedSeparator(options)
47
44
 
48
45
  // Convert consecutive or spaced dots: ... or . . . → …
49
46
  // Captures preserve any separators between dots
@@ -59,9 +56,7 @@ export function ellipsis(text: string, options: SymbolOptions = {}): string {
59
56
 
60
57
  /** Convert "5x5" to "5×5". Skips hex (0x5F). */
61
58
  export function multiplication(text: string, options: SymbolOptions = {}): string {
62
- const chr = options.separator
63
- ? escapeStringRegexp(options.separator)
64
- : ESCAPED_DEFAULT_SEPARATOR
59
+ const chr = getEscapedSeparator(options)
65
60
 
66
61
  // Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
67
62
  // Pattern matches: digit(s), then one or more (operator, digit(s)) groups
@@ -113,9 +108,7 @@ const MATH_SYMBOL_MAP: [string, string, string, string][] = [
113
108
 
114
109
  /** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
115
110
  export function mathSymbols(text: string, options: SymbolOptions = {}): string {
116
- const chr = options.separator
117
- ? escapeStringRegexp(options.separator)
118
- : ESCAPED_DEFAULT_SEPARATOR
111
+ const chr = getEscapedSeparator(options)
119
112
 
120
113
  for (const [left, right, lookahead, replacement] of MATH_SYMBOL_MAP) {
121
114
  const pattern = cachedRegExp(`${left}${chr}?${right}${lookahead}`, "g")
@@ -168,9 +161,7 @@ const ARROW_MAP: [string, string][] = [
168
161
 
169
162
  /** Convert -> and <-> to arrows. */
170
163
  export function arrows(text: string, options: SymbolOptions = {}): string {
171
- const chr = options.separator
172
- ? escapeStringRegexp(options.separator)
173
- : ESCAPED_DEFAULT_SEPARATOR
164
+ const chr = getEscapedSeparator(options)
174
165
 
175
166
  const start = spaceBoundaryStart(chr)
176
167
  const end = spaceBoundaryEnd(chr)
@@ -185,9 +176,7 @@ export function arrows(text: string, options: SymbolOptions = {}): string {
185
176
 
186
177
  /** Convert "20 C" or "20 F" to "20 °C" or "20 °F". Only matches uppercase C/F. */
187
178
  export function degrees(text: string, options: SymbolOptions = {}): string {
188
- const chr = options.separator
189
- ? escapeStringRegexp(options.separator)
190
- : ESCAPED_DEFAULT_SEPARATOR
179
+ const chr = getEscapedSeparator(options)
191
180
 
192
181
  // Temperature with optional space before C or F (uppercase only)
193
182
  // Handles separator between digit and unit
@@ -288,9 +277,7 @@ function balancedPrimeReplacer(primeChar: string, escapedSeparator: string) {
288
277
 
289
278
  /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
290
279
  export function primeMarks(text: string, options: SymbolOptions = {}): string {
291
- const escapedSeparator = options.separator
292
- ? escapeStringRegexp(options.separator)
293
- : ESCAPED_DEFAULT_SEPARATOR
280
+ const escapedSeparator = getEscapedSeparator(options)
294
281
 
295
282
  const quotePrimePairs: [string, string][] = [
296
283
  ["'", PRIME],
@@ -298,8 +285,8 @@ export function primeMarks(text: string, options: SymbolOptions = {}): string {
298
285
  ]
299
286
 
300
287
  for (const [quote, primeChar] of quotePrimePairs) {
301
- const escapedQuote = escapeStringRegexp(quote)
302
- const pattern = buildQuoteClassificationPattern(escapedQuote, escapedSeparator)
288
+ // ' and " are not regex-special, so they can be used directly as patterns
289
+ const pattern = buildQuoteClassificationPattern(quote, escapedSeparator)
303
290
  text = text.replace(pattern, balancedPrimeReplacer(primeChar, escapedSeparator))
304
291
  }
305
292
 
@@ -326,9 +313,7 @@ const FRACTION_MAP: Record<string, string> = {
326
313
 
327
314
  /** Convert 1/2, 1/4, etc. to ½, ¼, etc. */
328
315
  export function fractions(text: string, options: SymbolOptions = {}): string {
329
- const chr = options.separator
330
- ? escapeStringRegexp(options.separator)
331
- : ESCAPED_DEFAULT_SEPARATOR
316
+ const chr = getEscapedSeparator(options)
332
317
 
333
318
  for (const [ascii, unicode] of Object.entries(FRACTION_MAP)) {
334
319
  // Negative lookbehind/lookahead: ensures fraction is not part of a larger number or path
@@ -356,9 +341,7 @@ const ORDINAL_MAP: Record<string, string> = {
356
341
 
357
342
  /** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
358
343
  export function superscriptOrdinal(text: string, options: SymbolOptions = {}): string {
359
- const chr = options.separator
360
- ? escapeStringRegexp(options.separator)
361
- : ESCAPED_DEFAULT_SEPARATOR
344
+ const chr = getEscapedSeparator(options)
362
345
 
363
346
  // Match number + optional separator + ordinal suffix at word boundary
364
347
  // Use case-insensitive matching for the suffix
@@ -397,9 +380,7 @@ const PUNCTUATION_LIGATURE_MAP: [string, string, string][] = [
397
380
 
398
381
  /** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Poor font support, disabled by default. */
399
382
  export function punctuationLigatures(text: string, options: SymbolOptions = {}): string {
400
- const chr = options.separator
401
- ? escapeStringRegexp(options.separator)
402
- : ESCAPED_DEFAULT_SEPARATOR
383
+ const chr = getEscapedSeparator(options)
403
384
 
404
385
  for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
405
386
  const pattern = cachedRegExp(`${first}(${chr})?${repeated}(?:${chr}?${repeated})*`, "g")
@@ -1,5 +1,5 @@
1
1
  import { assertSeparatorAbsent, countSeparators, assertSeparatorCountPreserved, formatErrorString } from "../utils.js"
2
- import { DEFAULT_SEPARATOR } from "../constants.js"
2
+ import { DEFAULT_SEPARATOR, cachedRegExp } from "../constants.js"
3
3
 
4
4
  describe("assertSeparatorAbsent", () => {
5
5
  it.each([
@@ -75,6 +75,19 @@ describe("assertSeparatorCountPreserved", () => {
75
75
  })
76
76
  })
77
77
 
78
+ describe("cachedRegExp", () => {
79
+ it("evicts oldest entry when cache exceeds max size", () => {
80
+ // Fill the cache well beyond 1000 entries with unique patterns
81
+ for (let i = 0; i < 1010; i++) {
82
+ const re = cachedRegExp(`unique-pattern-${i}`, "g")
83
+ expect(re).toBeInstanceOf(RegExp)
84
+ }
85
+ // Verify the cache still works correctly after eviction
86
+ const re = cachedRegExp("unique-pattern-1009", "g")
87
+ expect(re.source).toBe("unique-pattern-1009")
88
+ })
89
+ })
90
+
78
91
  describe("formatErrorString", () => {
79
92
  let originalWrite: typeof process.stderr.write
80
93
  let stderrOutput: string[]