punctilio 4.2.0 → 4.3.0
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/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +55 -39
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts +19 -0
- package/dist/quote-classifier.d.ts.map +1 -0
- package/dist/quote-classifier.js +896 -0
- package/dist/quote-classifier.js.map +1 -0
- package/dist/quotes.d.ts +2 -2
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +13 -303
- package/dist/quotes.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +3 -78
- package/dist/symbols.js.map +1 -1
- package/package.json +1 -1
- package/src/prose-view.ts +55 -41
- package/src/quote-classifier.ts +957 -0
- package/src/quotes.ts +13 -369
- package/src/symbols.ts +3 -99
package/src/quotes.ts
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
23
|
-
const
|
|
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
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
return processQuotes(text, options
|
|
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 {
|
|
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
|
-
|
|
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]
|