punctilio 4.2.0 → 4.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/quotes.ts CHANGED
@@ -1,16 +1,9 @@
1
- import { cachedRegExp, DEFAULT_SEPARATOR, escapeStringRegexp, getEscapedSeparator, LATIN_LETTERS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
1
+ import { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js"
2
+ import { classifyAndRenderQuotes, type PunctuationStyle } from "./quote-classifier.js"
2
3
 
3
- const {
4
- EM_DASH,
5
- LEFT_DOUBLE_QUOTE,
6
- RIGHT_DOUBLE_QUOTE,
7
- LEFT_SINGLE_QUOTE,
8
- RIGHT_SINGLE_QUOTE,
9
- MODIFIER_LETTER_APOSTROPHE,
10
- } = UNICODE_SYMBOLS
4
+ const { RIGHT_SINGLE_QUOTE, MODIFIER_LETTER_APOSTROPHE } = UNICODE_SYMBOLS
11
5
 
12
- export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"] as const
13
- export type PunctuationStyle = (typeof PUNCTUATION_STYLES)[number]
6
+ export { PUNCTUATION_STYLES, type PunctuationStyle, type QuoteRole } from "./quote-classifier.js"
14
7
 
15
8
  export interface QuoteOptions {
16
9
  /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
@@ -19,369 +12,20 @@ export interface QuoteOptions {
19
12
  punctuationStyle?: PunctuationStyle
20
13
  }
21
14
 
22
- function convertSingleQuotes(text: string, sep: string): string {
23
- const escapedSep = getEscapedSeparator({ separator: sep })
24
-
25
- // Handle empty single quotes '' and whitespace-only quotes ' ' first
26
- // Only match straight quotes, not already-converted curly quotes
27
- const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
28
- const singleQuoteOrWord = `[${singleQuoteChars}\\w]`
29
- text = text.replace(cachedRegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
30
- text = text.replace(cachedRegExp(`(?<!${singleQuoteOrWord})'(?<ws>\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$<ws>${RIGHT_SINGLE_QUOTE}`)
31
-
32
- const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
33
- // Ending context with optional 's', for the leading-apostrophe closer scan.
34
- const afterEndingSingle = `(?=${escapedSep}?(?:s${escapedSep}?)?(?:[${afterEndingSinglePatterns}]|$))`
35
-
36
- // Handle 'n' abbreviation (Rock 'n' Roll). Before MLA this wasn't needed —
37
- // the general rules produced LSQ+n+RSQ which was fine. But MLA requires both
38
- // quotes to be MLA (semantic apostrophes), and the general rules can't achieve
39
- // that: neither quote is in a contraction context (no Latin letter on both sides).
40
- text = text.replace(cachedRegExp(`(?<=\\w${escapedSep}? )['${RIGHT_SINGLE_QUOTE}]n['${RIGHT_SINGLE_QUOTE}](?= ${escapedSep}?\\w)`, "gm"), `${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}`)
41
-
42
- // Possessive: 's followed by ending context (e.g., dog's) → U+02BC
43
- const afterPossessive = `(?=${escapedSep}?s${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
44
- const possessiveSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])['${RIGHT_SINGLE_QUOTE}]${afterPossessive}`
45
- text = text.replace(cachedRegExp(possessiveSingle, "gm"), MODIFIER_LETTER_APOSTROPHE)
46
-
47
- // Closing single quote: ending context without 's' → U+2019
48
- const afterClosingSingle = `(?=${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
49
- const closingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterClosingSingle}`
50
- text = text.replace(cachedRegExp(closingSingle, "gm"), RIGHT_SINGLE_QUOTE)
51
-
52
- const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}](?=${escapedSep}?[${LATIN_LETTERS}])`
53
- text = text.replace(cachedRegExp(contraction, "gm"), MODIFIER_LETTER_APOSTROPHE)
54
-
55
- const endQuoteNotContraction = `(?!${contraction})[${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}]${afterEndingSingle}`
56
- text = convertLeadingApostrophes(text, endQuoteNotContraction)
57
-
58
- const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
59
- text = text.replace(cachedRegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
60
-
61
- text = convertUnmatchedPluralPossessives(text, sep)
62
-
63
- return text
64
- }
65
-
66
- // Converts unmatched RSQ after s/S to MLA (plural possessives).
67
- // Tracks LSQ/RSQ balance so paired closing quotes remain RSQ.
68
- function convertUnmatchedPluralPossessives(text: string, sep: string): string {
69
- let singleQuoteBalance = 0
70
- return text.replace(
71
- cachedRegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"),
72
- (match, offset) => {
73
- if (match === LEFT_SINGLE_QUOTE) {
74
- singleQuoteBalance++
75
- return match
76
- }
77
- if (singleQuoteBalance > 0) {
78
- singleQuoteBalance--
79
- return match
80
- }
81
- let i = offset - 1
82
- while (i >= sep.length - 1 && text.startsWith(sep, i - sep.length + 1)) {
83
- i -= sep.length
84
- }
85
- if (i >= 0 && (text[i] === "s" || text[i] === "S")) {
86
- return MODIFIER_LETTER_APOSTROPHE
87
- }
88
- return match
89
- }
90
- )
91
- }
92
-
93
- // Classifies a leading straight quote (start of line, or after a non-word char)
94
- // as either an apostrophe/elision (U+02BC) or an opening single quote (left as a
95
- // straight quote for `beginningSingle` to convert). A left-to-right scan replaces
96
- // the former distance-bounded lookahead: a leading quote is an apostrophe unless
97
- // an unbounded forward scan finds a closing single quote (RSQ/MLA in ending
98
- // context) before reaching a line break or another single-quote opener.
99
- function convertLeadingApostrophes(text: string, endQuoteNotContraction: string): string {
100
- const closerAhead = cachedRegExp(endQuoteNotContraction, "y")
101
- // `Rock 'n' Roll` already produced `n${MLA} `; a leading quote before it is an
102
- // apostrophe even though a closing single quote may follow later in the line.
103
- const nAbbreviationAhead = cachedRegExp(`n${MODIFIER_LETTER_APOSTROPHE} `, "y")
104
- // High-precision decade elision: a leading quote before two digits (optionally
105
- // followed by `s`), as in `'90s` or `'99`, is always an apostrophe.
106
- const decadeElision = cachedRegExp(`\\d\\ds?(?![${LATIN_LETTERS}\\d])`, "y")
107
-
108
- let result = ""
109
- for (let i = 0; i < text.length; i++) {
110
- const char = text[i]
111
- if (char !== "'" || (i > 0 && /\w/.test(text[i - 1]))) {
112
- result += char
113
- continue
114
- }
115
- decadeElision.lastIndex = i + 1
116
- nAbbreviationAhead.lastIndex = i + 1
117
- const isApostrophe =
118
- decadeElision.test(text) ||
119
- nAbbreviationAhead.test(text) ||
120
- !hasClosingSingleAhead(text, i + 1, closerAhead)
121
- result += isApostrophe ? MODIFIER_LETTER_APOSTROPHE : char
122
- }
123
- return result
124
- }
125
-
126
- // Scans forward from `start` for a closing single quote, stopping (no closer) at
127
- // a line break or another single-quote opener. Separators and other characters
128
- // are transparent. O(n) amortized: each scan halts at the next single-quote char.
129
- function hasClosingSingleAhead(text: string, start: number, closerAhead: RegExp): boolean {
130
- for (let position = start; position < text.length; position++) {
131
- const char = text[position]
132
- if (char === "\n" || char === LEFT_SINGLE_QUOTE || char === "'") return false
133
- closerAhead.lastIndex = position
134
- if (closerAhead.test(text)) return true
135
- }
136
- return false
137
- }
138
-
139
- // Matches an opening-position straight double quote: a boundary (start of line,
140
- // whitespace, bracket, dash, or separator — consumed and re-emitted, since a
141
- // variable-width alternation lookbehind would be ReDoS-prone) and an optional
142
- // separator before the quote.
143
- function doubleOpenerPrefix(escapedSep: string): string {
144
- const boundary = `(?<boundary>^|[\\s\\(\\/\\[\\{\\-${EM_DASH}]|${escapedSep})`
145
- const beforeCapture = `(?<beforeChr>${escapedSep}?)`
146
- return `${boundary}${beforeCapture}["]`
147
- }
148
-
149
- function buildBeginningDoublePattern(escapedSep: string, rawEscSep: string): string {
150
- // Characters that signal an ending-quote position (not valid openers)
151
- const endingChars = `\\s\\)${EM_DASH},!?;:.\\}${rawEscSep}`
152
-
153
- const afterConditions = [
154
- `(?=${escapedSep}[ .,])`,
155
- `(?=${escapedSep}?\\.{3}|${escapedSep}?[^${endingChars}])`,
156
- ]
157
-
158
- return `${doubleOpenerPrefix(escapedSep)}(?:${afterConditions.join("|")})`
159
- }
160
-
161
- // Quoted-punctuation openers like "?" or "!", where the character right after the
162
- // opening quote looks like an ending-quote context. A left-to-right scan replaces
163
- // the former 50-char lookahead: a boundary-position straight double quote is an
164
- // opener when a closing straight double quote appears anywhere ahead, with at
165
- // least one non-quote character between them.
166
- function convertQuotedPunctuationOpeners(text: string, escapedSep: string): string {
167
- const candidate = cachedRegExp(doubleOpenerPrefix(escapedSep), "gm")
168
- let result = ""
169
- let copiedUpTo = 0
170
- for (const match of text.matchAll(candidate)) {
171
- // The boundary and optional separator are left in place; only the straight
172
- // quote (the final character of the match) is rewritten to an opener.
173
- const quoteIndex = match.index + match[0].length - 1
174
- if (!hasClosingDoubleAhead(text, quoteIndex + 1)) continue
175
- result += text.slice(copiedUpTo, quoteIndex) + LEFT_DOUBLE_QUOTE
176
- copiedUpTo = quoteIndex + 1
177
- }
178
- return result + text.slice(copiedUpTo)
179
- }
180
-
181
- // Scans forward for a closing straight double quote, requiring at least one
182
- // non-quote character between `start` and it. O(n) amortized: the scan halts at
183
- // the next double-quote character.
184
- function hasClosingDoubleAhead(text: string, start: number): boolean {
185
- for (let position = start; position < text.length; position++) {
186
- if (text[position] === '"') return position > start
187
- }
188
- return false
189
- }
190
-
191
- function convertDoubleQuotes(text: string, sep: string): string {
192
- const rawEscSep = escapeStringRegexp(sep)
193
- const escapedSep = getEscapedSeparator({ separator: sep })
194
-
195
- // Handle empty quotes "" first - match only when not part of adjacent quotes
196
- // Require word boundary or start/end of string on at least one side
197
- text = text.replace(/(?<=^|[\s([{])""(?=$|[\s)\]}.!?,;:])/g, `${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`)
198
- // Handle whitespace-only quotes " " - require non-quote chars on both sides
199
- text = text.replace(/(?<=^|[\s([{])"(?<whitespace>\s+)"(?=$|[\s)\]}.!?,;:])/g, `${LEFT_DOUBLE_QUOTE}$<whitespace>${RIGHT_DOUBLE_QUOTE}`)
200
-
201
- const beginningDouble = cachedRegExp(buildBeginningDoublePattern(escapedSep, rawEscSep), "gm")
202
- text = text.replace(beginningDouble, `$<boundary>$<beforeChr>${LEFT_DOUBLE_QUOTE}`)
203
-
204
- text = convertQuotedPunctuationOpeners(text, escapedSep)
205
-
206
- text = text.replace(cachedRegExp(`(?<=\\{)(?<sepSpace>${escapedSep}? )?["]`, "g"), `$<sepSpace>${LEFT_DOUBLE_QUOTE}`)
207
-
208
- const endingDouble = `(?<beforeQuote>[^\\s\\(])["](?<sepAfter>${escapedSep})?(?=${escapedSep}|[\\s/\\).,;${EM_DASH}:\\-\\}!?s]|$)`
209
- text = text.replace(cachedRegExp(endingDouble, "g"), `$<beforeQuote>${RIGHT_DOUBLE_QUOTE}$<sepAfter>`)
210
-
211
- text = text.replace(cachedRegExp(`["](?<sepEnd>${escapedSep}?)$`, "g"), `${RIGHT_DOUBLE_QUOTE}$<sepEnd>`)
212
- text = text.replace(cachedRegExp(`'(?=${RIGHT_DOUBLE_QUOTE})`, "gu"), RIGHT_SINGLE_QUOTE)
213
-
214
- return text
215
- }
216
-
217
- const CLOSING_QUOTES = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE])
218
- const TERMINAL_PUNCTUATION_SET = new Set<string>(TERMINAL_PUNCTUATION)
219
-
220
- // Consumes a run of consecutive closing quotes (RSQ/RDQ) of arbitrary depth,
221
- // each optionally preceded by a separator, starting at `start`. Returns the
222
- // index just past the last closing quote, or -1 if there is no run.
223
- function scanClosingQuoteRun(text: string, start: number, sep: string): number {
224
- if (!CLOSING_QUOTES.has(text[start])) return -1
225
- let runEnd = start + 1
226
- for (;;) {
227
- let next = runEnd
228
- if (sep.length > 0 && text.startsWith(sep, next)) next += sep.length
229
- if (!CLOSING_QUOTES.has(text[next])) break
230
- runEnd = next + 1
231
- }
232
- return runEnd
233
- }
234
-
235
- function separatorLengthAt(text: string, index: number, sep: string): number {
236
- return sep.length > 0 && text.startsWith(sep, index) ? sep.length : 0
237
- }
238
-
239
- // Moves a period or comma from just after a run of closing quotes to just
240
- // before it (American style: punctuation inside the quotes). A single
241
- // left-to-right pass collects each closing-quote run of arbitrary depth — no
242
- // nesting cap — then relocates an adjacent period/comma, treating separators as
243
- // transparent (re-emitted in place). The move is skipped when the character
244
- // before the run is terminal punctuation (`"Stop!".` keeps its period outside).
245
- function movePunctuationInside(
246
- text: string,
247
- sep: string,
248
- isMovablePunctuation: (text: string, index: number) => boolean,
249
- ): string {
250
- let result = ""
251
- let position = 0
252
- while (position < text.length) {
253
- const sepBeforeLength = separatorLengthAt(text, position, sep)
254
- const runStart = position + sepBeforeLength
255
- const runEnd = scanClosingQuoteRun(text, runStart, sep)
256
- const sepAfterLength = runEnd === -1 ? 0 : separatorLengthAt(text, runEnd, sep)
257
- const punctuationIndex = runEnd + sepAfterLength
258
-
259
- const precededByTerminal = position > 0 && TERMINAL_PUNCTUATION_SET.has(text[position - 1])
260
- if (runEnd === -1 || precededByTerminal || !isMovablePunctuation(text, punctuationIndex)) {
261
- result += text[position]
262
- position++
263
- continue
264
- }
265
-
266
- const sepBefore = text.slice(position, runStart)
267
- const run = text.slice(runStart, runEnd)
268
- const sepAfter = text.slice(runEnd, punctuationIndex)
269
- result += sepBefore + text[punctuationIndex] + run + sepAfter
270
- position = punctuationIndex + 1
271
- }
272
- return result
273
- }
274
-
275
- function isMovablePeriod(text: string, index: number): boolean {
276
- return text[index] === "." && !text.startsWith("...", index)
277
- }
278
-
279
- function isMovableComma(text: string, index: number): boolean {
280
- return text[index] === ","
281
- }
282
-
283
- function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyle): string {
284
- const escapedSep = getEscapedSeparator({ separator: sep })
285
-
286
- if (style === "american") {
287
- // Period outside → inside: Hello". → Hello." (and Hello'". → Hello.'")
288
- text = movePunctuationInside(text, sep, isMovablePeriod)
289
- // Comma outside → inside: Hello", → Hello,"
290
- text = movePunctuationInside(text, sep, isMovableComma)
291
- } else {
292
- // Every non-"american" non-"none" style (british/german/french) places
293
- // punctuation outside the quotes.
294
- // Period inside → outside: "Hello." → "Hello".
295
- // No terminal punctuation guard — "Stop!." inside is always wrong; move the period out.
296
- // Match ALL consecutive closing quotes so nested quotes like .'" become '". in one pass.
297
- const periodInsideRegex = cachedRegExp(
298
- `(?<sepBefore>${escapedSep}?)\\.(?<quotes>(?:${escapedSep}?[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])+)`,
299
- "g"
300
- )
301
- text = text.replace(periodInsideRegex, "$<sepBefore>$<quotes>.")
302
-
303
- // Comma inside → outside: "Hello," → "Hello",
304
- // Match ALL consecutive closing quotes so nested quotes like ,'" become '", in one pass.
305
- const commaInsideRegex = cachedRegExp(
306
- `,(?<quotes>(?:${escapedSep}?[${RIGHT_DOUBLE_QUOTE}${RIGHT_SINGLE_QUOTE}])+)`,
307
- "g"
308
- )
309
- text = text.replace(commaInsideRegex, "$<quotes>,")
310
- }
311
- return text
312
- }
313
-
314
- // Collapse every German/curly quote to its straight equivalent so the main
315
- // pipeline re-classifies them by position exactly as it would for fresh straight
316
- // input. German output uses „…" (U+201E…U+201C) and ‚…' (U+201A…U+2018) with
317
- // apostrophes as U+2019; U+201C/U+2018 each double as American openers, so any
318
- // depth-based scheme misclassifies orphans or quotes adjacent to other quotes.
319
- // Position-based re-classification sidesteps that ambiguity and keeps
320
- // re-processing German output idempotent.
321
- function normalizeGermanQuotes(text: string): string {
322
- const { DOUBLE_LOW_9_QUOTE, SINGLE_LOW_9_QUOTE } = UNICODE_SYMBOLS
323
- return text
324
- .replace(cachedRegExp(`[${DOUBLE_LOW_9_QUOTE}${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}]`, "g"), '"')
325
- .replace(cachedRegExp(`[${SINGLE_LOW_9_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"), "'")
326
- }
327
-
328
- function applyGermanQuotes(text: string): string {
329
- return text
330
- .replaceAll(LEFT_DOUBLE_QUOTE, UNICODE_SYMBOLS.DOUBLE_LOW_9_QUOTE)
331
- .replaceAll(RIGHT_DOUBLE_QUOTE, LEFT_DOUBLE_QUOTE)
332
- .replaceAll(LEFT_SINGLE_QUOTE, UNICODE_SYMBOLS.SINGLE_LOW_9_QUOTE)
333
- .replaceAll(RIGHT_SINGLE_QUOTE, LEFT_SINGLE_QUOTE)
334
- }
335
-
336
- // Normalize French guillemets back to American for idempotent re-processing.
337
- // Strips either NBSP or NNBSP padding (older outputs used NBSP).
338
- function normalizeFrenchQuotes(text: string): string {
339
- const innerSpace = `[${UNICODE_SYMBOLS.NBSP}${UNICODE_SYMBOLS.NNBSP}]`
340
- return text
341
- .replace(cachedRegExp(`${UNICODE_SYMBOLS.LEFT_GUILLEMET}${innerSpace}?`, "g"), LEFT_DOUBLE_QUOTE)
342
- .replace(cachedRegExp(`${innerSpace}?${UNICODE_SYMBOLS.RIGHT_GUILLEMET}`, "g"), RIGHT_DOUBLE_QUOTE)
343
- }
344
-
345
- // Uses U+202F (NARROW NO-BREAK SPACE) per Unicode CLDR and Imprimerie nationale.
346
- function applyFrenchQuotes(text: string): string {
347
- return text
348
- .replaceAll(LEFT_DOUBLE_QUOTE, `${UNICODE_SYMBOLS.LEFT_GUILLEMET}${UNICODE_SYMBOLS.NNBSP}`)
349
- .replaceAll(RIGHT_DOUBLE_QUOTE, `${UNICODE_SYMBOLS.NNBSP}${UNICODE_SYMBOLS.RIGHT_GUILLEMET}`)
350
- }
351
-
352
- interface LocaleQuoteTransform {
353
- normalize: (text: string) => string
354
- apply: (text: string) => string
355
- }
356
-
357
- const localeQuoteTransforms: Partial<Record<PunctuationStyle, LocaleQuoteTransform>> = {
358
- german: { normalize: normalizeGermanQuotes, apply: applyGermanQuotes },
359
- french: { normalize: normalizeFrenchQuotes, apply: applyFrenchQuotes },
360
- }
361
-
362
- function processQuotes(text: string, options: QuoteOptions): string {
363
- const sep = options.separator ?? DEFAULT_SEPARATOR
15
+ function processQuotes(text: string, options: QuoteOptions, apostrophe: string): string {
16
+ const separator = options.separator ?? DEFAULT_SEPARATOR
364
17
  const punctuationStyle = options.punctuationStyle ?? "american"
365
18
  if (punctuationStyle === "none") return text
366
-
367
- const locale = localeQuoteTransforms[punctuationStyle]
368
- if (locale) text = locale.normalize(text)
369
-
370
- text = convertSingleQuotes(text, sep)
371
- text = convertDoubleQuotes(text, sep)
372
- text = applyPunctuationStyle(text, sep, punctuationStyle)
373
-
374
- if (locale) text = locale.apply(text)
375
-
376
- return text
19
+ return classifyAndRenderQuotes(text, separator, punctuationStyle, apostrophe)
377
20
  }
378
21
 
379
22
  /** Convert straight quotes to smart quotes. */
380
23
  export function niceQuotes(text: string, options: QuoteOptions = {}): string {
381
- // MLA is used internally so applyPunctuationStyle can distinguish
382
- // apostrophes (MLA, don't move) from closing quotes (RSQ, do move).
383
- // Always convert back to RSQ for standard output per Unicode.
384
- return processQuotes(text, options).replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE)
24
+ // Apostrophes render as U+2019, the same glyph as closing single quotes,
25
+ // per the Unicode Standard. Any U+02BC already in the input is normalized
26
+ // to U+2019 as well (including under punctuationStyle "none").
27
+ return processQuotes(text, options, RIGHT_SINGLE_QUOTE)
28
+ .replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE)
385
29
  }
386
30
 
387
31
  /**
@@ -395,5 +39,5 @@ export function niceQuotes(text: string, options: QuoteOptions = {}): string {
395
39
  * which use U+2019 for both per the Unicode Standard.
396
40
  */
397
41
  export function classifyApostrophes(text: string, options: QuoteOptions = {}): string {
398
- return processQuotes(text, options)
42
+ return processQuotes(text, options, MODIFIER_LETTER_APOSTROPHE)
399
43
  }
package/src/symbols.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS, wordBoundaryEnd } from "./constants.js"
2
- import { namedGroups, replaceCallbackContext } from "./utils.js"
2
+ import { convertPrimeMarks } from "./quote-classifier.js"
3
+ import { namedGroups } from "./utils.js"
3
4
 
4
5
  export interface SymbolOptions {
5
6
  /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
@@ -272,106 +273,9 @@ export function degrees(text: string, options: SymbolOptions = {}): string {
272
273
  )
273
274
  }
274
275
 
275
- /**
276
- * Matches quote characters in priority order:
277
- * 1. Prime candidate: digit + quote + non-letter (e.g., 5', 12")
278
- * 2. Contraction: letter + quote + letter (e.g., it's, don't, O'Brien)
279
- * 3. Trailing apostrophe: letter + quote + non-letter (e.g., dogs')
280
- * 4. Bare quote: anything else (typically preceded by space/start)
281
- */
282
- function buildQuoteClassificationPattern(
283
- escapedQuote: string,
284
- escapedSeparator: string
285
- ): RegExp {
286
- const primeCandidate = `(?<digit>\\d)(?<sep>${escapedSeparator}?)${escapedQuote}(?<afterSep>${escapedSeparator}?)(?![${LATIN_LETTERS}])`
287
- const contraction = `(?<=[${LATIN_LETTERS}]${escapedSeparator}?)(?<contraction>${escapedQuote})(?=${escapedSeparator}?[${LATIN_LETTERS}])`
288
- const trailingApostrophe = `(?<=[${LATIN_LETTERS}]${escapedSeparator}?)(?<trailing>${escapedQuote})`
289
- const bareQuote = escapedQuote
290
- return cachedRegExp(
291
- `${primeCandidate}|${contraction}|${trailingApostrophe}|${bareQuote}`,
292
- "g"
293
- )
294
- }
295
-
296
- // Converts prime candidates while tracking quote balance. A prime after a
297
- // digit converts only when no unmatched opener precedes it. Inside balanced
298
- // quotes, a ′+digits context (feet-inches like 5′10") still converts.
299
- function balancedPrimeReplacer(primeChar: string, escapedSeparator: string) {
300
- // Pre-compile the feet-inches context pattern for the double-prime pass.
301
- // Matches when the text before a prime candidate ends with ′ followed by
302
- // any mix of digits and separators (e.g., "5′1" before the "0" in 5′10").
303
- const feetInchesContext = primeChar === DOUBLE_PRIME
304
- ? cachedRegExp(`${PRIME}(?:${escapedSeparator}|\\d)*$`, "")
305
- : null
306
-
307
- let balance = 0
308
-
309
- // Replace callback: (match, ...captures, offset, fullString, namedGroups)
310
- return (...args: unknown[]): string => {
311
- const fullMatch = args[0] as string
312
- const groups = namedGroups<{
313
- digit?: string
314
- sep?: string
315
- afterSep?: string
316
- contraction?: string
317
- trailing?: string
318
- }>(args)
319
-
320
- // Contraction (letter + quote + letter): skip entirely
321
- if (groups.contraction !== undefined) {
322
- return fullMatch
323
- }
324
-
325
- // Trailing apostrophe (letter + quote + non-letter): close if opener exists, else skip
326
- if (groups.trailing !== undefined) {
327
- if (balance > 0) balance--
328
- return fullMatch
329
- }
330
-
331
- const { digit, sep, afterSep } = groups
332
- if (digit !== undefined) {
333
- // Prime candidate: convert only if no unmatched opening quote
334
- if (balance <= 0) {
335
- return `${digit}${sep}${primeChar}${afterSep}`
336
- }
337
- // Inside balanced quotes: check if this is feet-inches notation
338
- // (e.g., 5′10" where ″ is inches, not a closing quote)
339
- if (feetInchesContext) {
340
- const { offset: matchOffset, input: fullString } = replaceCallbackContext(args)
341
- if (feetInchesContext.test(fullString.substring(0, matchOffset))) {
342
- return `${digit}${sep}${primeChar}${afterSep}`
343
- }
344
- }
345
- balance--
346
- return fullMatch
347
- }
348
-
349
- // Bare quote (preceded by space/start): opener or closer
350
- if (balance <= 0) {
351
- balance = 1
352
- } else {
353
- balance--
354
- }
355
- return fullMatch
356
- }
357
- }
358
-
359
276
  /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
360
277
  export function primeMarks(text: string, options: SymbolOptions = {}): string {
361
- const escapedSeparator = getEscapedSeparator(options)
362
-
363
- const quotePrimePairs: [string, string][] = [
364
- ["'", PRIME],
365
- ['"', DOUBLE_PRIME],
366
- ]
367
-
368
- for (const [quote, primeChar] of quotePrimePairs) {
369
- // ' and " are not regex-special, so they can be used directly as patterns
370
- const pattern = buildQuoteClassificationPattern(quote, escapedSeparator)
371
- text = text.replace(pattern, balancedPrimeReplacer(primeChar, escapedSeparator))
372
- }
373
-
374
- return text
278
+ return convertPrimeMarks(text, options.separator ?? DEFAULT_SEPARATOR)
375
279
  }
376
280
 
377
281
  type FractionRule = [string, string, string]