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/dashes.ts CHANGED
@@ -1,11 +1,12 @@
1
- import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, UNICODE_SYMBOLS, wordBoundaryEnd, wordBoundaryStart } from "./constants.js"
1
+ import { cachedRegExp, DEFAULT_SEPARATOR, LATIN_LETTERS, UNICODE_SYMBOLS } from "./constants.js"
2
+ import { boundaryCountAt, type ProseView, replaceAllInView, type ReplaceAllOptions, withProseView } from "./prose-view.js"
2
3
  import { namedGroups } from "./utils.js"
3
4
 
4
5
  export const DASH_STYLES = ["american", "british", "none"] as const
5
6
  export type DashStyle = (typeof DASH_STYLES)[number]
6
7
 
7
8
  export interface DashOptions {
8
- /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
9
+ /** Boundary marker for HTML element boundaries. Default: "" */
9
10
  separator?: string
10
11
  /** "american" (unspaced em), "british" (spaced en), "none". Default: "american" */
11
12
  dashStyle?: DashStyle
@@ -13,13 +14,6 @@ export interface DashOptions {
13
14
 
14
15
  const { EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS, SUPERSCRIPT_ST, SUPERSCRIPT_ND, SUPERSCRIPT_RD, SUPERSCRIPT_TH, LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE } = UNICODE_SYMBOLS
15
16
 
16
- // Characters a later pass produces from a trailing *word* character: the symbol
17
- // pass turns "x" into "×", and the superscript pass turns the ordinal letters
18
- // "st"/"nd"/"rd"/"th" into superscripts. Before conversion those word chars block
19
- // a range via the trailing word boundary ("1-55x5", "5--1st"); rejecting their
20
- // non-word replacements keeps a second pass from en-dashing the same range.
21
- const SYMBOL_PASS_TRAILING = `${MULTIPLICATION}${SUPERSCRIPT_ST}${SUPERSCRIPT_ND}${SUPERSCRIPT_RD}${SUPERSCRIPT_TH}`
22
-
23
17
  // Prevents false-positive ranges in model names like "Llama-2-7B".
24
18
  export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS] as const
25
19
 
@@ -38,240 +32,1069 @@ const months: readonly string[] = [
38
32
 
39
33
  const monthPattern = months.join("|")
40
34
 
35
+ const WORD_CHAR_RE = /\w/
36
+ const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
37
+
38
+ /**
39
+ * Runs one boundary-gated regex pass over the view and commits it immediately.
40
+ * v4 chained `text.replace` calls, each operating on the previous pass's full
41
+ * output; committing after every pass reproduces that sequencing so a later
42
+ * pass sees the earlier edits and never queues an overlapping edit.
43
+ */
44
+ function pass(
45
+ view: ProseView,
46
+ regex: RegExp,
47
+ replacer: (match: RegExpExecArray, view: ProseView) => string | null,
48
+ options?: ReplaceAllOptions,
49
+ ): void {
50
+ replaceAllInView(view, regex, replacer, options)
51
+ view.commit()
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Boundary-tolerance helpers over a ProseView
56
+ //
57
+ // v4 wove `${escapedSep}?` into patterns to tolerate the sentinel at specific
58
+ // positions. Over a clean ProseView, `replaceAllInView` skips any match with an
59
+ // interior boundary unless `allowBoundaries` opts in. Each pass below builds an
60
+ // `allowBoundaries` callback that admits a match only when every interior
61
+ // boundary sits at a position where the v4 pattern carried a separator weave.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Interior boundary offsets of a match, strictly inside (matchStart, matchEnd). */
65
+ function interiorBoundaryOffsets(match: RegExpExecArray, view: ProseView): number[] {
66
+ const start = match.index
67
+ const end = match.index + match[0].length
68
+ const offsets: number[] = []
69
+ for (const boundary of view.boundaries) {
70
+ if (boundary > start && boundary < end) offsets.push(boundary)
71
+ else if (boundary >= end) break
72
+ }
73
+ return offsets
74
+ }
75
+
76
+ /**
77
+ * True iff every interior boundary of the match lands on a tolerated offset,
78
+ * with no offset carrying more boundaries than its budget. A v4 `${chr}?` slot
79
+ * tolerates exactly one boundary; two stacked boundaries (an empty fragment)
80
+ * exceed the single-separator budget and block the match, as in v4.
81
+ */
82
+ function interiorBoundariesAllowed(
83
+ match: RegExpExecArray,
84
+ view: ProseView,
85
+ tolerated: Map<number, number>,
86
+ ): boolean {
87
+ const counts = new Map<number, number>()
88
+ for (const offset of interiorBoundaryOffsets(match, view)) {
89
+ counts.set(offset, (counts.get(offset) ?? 0) + 1)
90
+ }
91
+ for (const [offset, count] of counts) {
92
+ const budget = tolerated.get(offset)
93
+ if (budget === undefined || count > budget) return false
94
+ }
95
+ return true
96
+ }
97
+
98
+ /** Separators tolerated by the word-boundary helpers (mirrors constants.ts). */
99
+ const MAX_BOUNDARY_SEPARATORS = 3
100
+
101
+ /**
102
+ * Reproduces `wordBoundaryStart` = `(?<!\w${sep}{0,3})\b` at the marked position
103
+ * `matchStart` (the start char is a word char — a digit). The `\b` requires a
104
+ * non-word char to its left (a separator counts as non-word); the negative
105
+ * lookbehind additionally blocks when a word char sits within
106
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the left.
107
+ */
108
+ function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
109
+ if (matchStart === 0) return true
110
+ const nb = boundaryCountAt(view, matchStart)
111
+ const prev = view.text[matchStart - 1]
112
+ const prevWord = prev !== undefined && WORD_CHAR_RE.test(prev)
113
+ if (nb === 0) return !prevWord // `\b` needs a non-word char immediately left
114
+ // A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
115
+ // when a word char is within ≤MAX boundaries.
116
+ return !prevWord || nb > MAX_BOUNDARY_SEPARATORS
117
+ }
118
+
119
+ /**
120
+ * Reproduces `wordBoundaryEnd` = `\b(?!${sep}{0,3}\w)` at the marked position
121
+ * `pos`. The `\b` requires a word/non-word transition (a separator counts as
122
+ * non-word); the negative lookahead blocks when a word char sits within
123
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the right.
124
+ */
125
+ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
126
+ const text = view.text
127
+ // `wordBoundaryEndOk` is only ever called at the end of a digit run, so `pos`
128
+ // is always ≥ 1 and `text[pos - 1]` is a real char.
129
+ const prevChar = text[pos - 1] as string
130
+ const leftWord = WORD_CHAR_RE.test(prevChar)
131
+ const nb = boundaryCountAt(view, pos)
132
+ const nextClean = text[pos]
133
+ const nextCleanWord = nextClean !== undefined && WORD_CHAR_RE.test(nextClean)
134
+ // The marked char right after pos is a separator when nb > 0 (non-word).
135
+ const rightWord = nb > 0 ? false : nextCleanWord
136
+ if (leftWord === rightWord) return false // no `\b`
137
+ // `(?!${sep}{0,3}\w)`: a word char within ≤3 boundaries to the right blocks.
138
+ return !(nb <= MAX_BOUNDARY_SEPARATORS && nextCleanWord)
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Number ranges
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const CURRENCY_RE = /[$€£¥₹]/
146
+
41
147
  /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
42
148
  export function enDashNumberRange(text: string, options: DashOptions = {}): string {
43
- const chr = getEscapedSeparator(options)
44
- const wb = wordBoundaryStart(chr)
45
- const wbe = wordBoundaryEnd(chr)
46
-
47
- // Common currency symbols for price ranges
48
- const currencies = "$€£¥₹"
49
-
50
- // Build positive range pattern from readable components
51
- const phoneAreaCode = `(?<precedingAreaCode>\\d{3}-|\\(\\d{3}\\) ?)?` // 555- or (555)
52
- // Lookbehind prevents matching after dashes, so Llama-2-7B and +44-20 don't en-dash.
53
- // `${MULTIPLICATION}${PLUS_MINUS}` keep ranges stable across the symbol pass, which
54
- // runs after this one: a range start preceded by `x` (a Latin letter) or `+`/`-` is
55
- // already rejected here, and the symbol pass rewrites "5x10" → "5×10" and "+/-1" →
56
- // "±1". Rejecting `×` and `±` too means a second pass over those outputs doesn't
57
- // suddenly en-dash a tail the first pass left alone (e.g. "5x10-20" → "5×10-20").
58
- const notAfterDash = `(?<![${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+])`
59
- const rangeStart = `(?<start>(?:p\\.?|[${currencies}])?\\d[\\d.,]*${chr}?)` // p.10, $100, 1,000
60
- const rangeEnd = `(?<end>${chr}?[${currencies}]?\\d[\\d.,]*)` // 20, $200, 2,000
61
- const moreSegments = `(?<following>(?:${chr}?[-${MINUS}]${chr}?\\d+)*)` // -4567 in phone numbers
62
- const unitSuffix = `(?<suffix>${chr}?(?:[AaPp][Mm]|[xKBTM]))?` // am/pm, K/M/B
63
-
64
- // Positive ranges: 1-5, 5--10, $100-$200, €5-€10, p.10-15
65
- const positiveRangePattern = [
66
- phoneAreaCode, wb, notAfterDash, rangeStart, "-{1,3}", rangeEnd, moreSegments, unitSuffix, wbe
67
- ].join("")
68
-
69
- text = text.replace(
70
- cachedRegExp(positiveRangePattern, "g"),
71
- (...args) => {
72
- const groups = namedGroups<{
73
- precedingAreaCode?: string
74
- start: string
75
- end: string
76
- following?: string
77
- suffix?: string
78
- }>(args)
79
- if (groups.following) return args[0] as string
80
- // A range whose end abuts a symbol-pass replacement (× or a superscript
81
- // ordinal) is an operand, not a range (e.g. "1-55×5", "5--1ˢᵗ"). This
82
- // mirrors `notAfterDash` on the trailing side; done in the callback rather
83
- // than as a regex lookahead to keep the composed pattern ReDoS-safe.
84
- const matchEnd = (args.at(-3) as number) + (args[0] as string).length
85
- if (cachedRegExp(`^${chr}?[${SYMBOL_PASS_TRAILING}]`, "").test((args.at(-2) as string).slice(matchEnd))) {
86
- return args[0] as string
149
+ const separator = options.separator ?? DEFAULT_SEPARATOR
150
+ return withProseView(text, separator, (view) => {
151
+ convertPositiveRanges(view)
152
+ convertNegativeRanges(view)
153
+ })
154
+ }
155
+
156
+ /**
157
+ * v4's positive-range regex was `phoneAreaCode? \b (?:p\.?|[currency])?\d[\d.,]*
158
+ * -{1,3} [currency]?\d[\d.,]* (?:${chr}?[-−]${chr}?\d+)* (?:[AaPp][Mm]|[xKBTM])?
159
+ * \b`, scanned with the `g` flag. The lookbehinds (`notAfterDash`) and the
160
+ * separator weaves cannot survive over clean text, and — critically — the
161
+ * `g`-flag consume/advance behaviour determines which sub-ranges a later pass
162
+ * sees. The scan below reproduces it: at each offset it attempts the structural
163
+ * match (boundary-aware), consuming its span on success (whether or not the
164
+ * dash converts) and advancing one character otherwise. So a multi-segment
165
+ * number whose head fails to form a structural match (e.g. boundary-broken
166
+ * "2024-01-15") still exposes a later sub-range ("01-15"), while a structurally
167
+ * matching but non-converting number hides its inner dashes.
168
+ */
169
+ function convertPositiveRanges(view: ProseView): void {
170
+ const text = view.text
171
+ let i = 0
172
+ while (i < text.length) {
173
+ const span = matchPositiveRangeAt(view, i)
174
+ i = span === null ? i + 1 : Math.max(span, i + 1)
175
+ }
176
+ view.commit()
177
+ }
178
+
179
+ /**
180
+ * Attempts v4's `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
181
+ * \b` structural match beginning at clean offset `i`, converting the dash when
182
+ * v4's gates pass. Returns the consumed end offset on a structural match (so the
183
+ * scan skips it), or null when no structural match begins at `i`.
184
+ */
185
+ function matchPositiveRangeAt(view: ProseView, i: number): number | null {
186
+ // Optional phoneAreaCode (`\d{3}-` or `(\d{3}) `); when it ends in a dash that
187
+ // dash can instead be the range dash, so both interpretations are tried. v4's
188
+ // `rangeStart` carries no leading separator slot, so a boundary right where it
189
+ // would begin (just past the area code) invalidates the area-code reading: v4
190
+ // falls back to treating the area-code digits as the range start.
191
+ const areaCode = readPhoneAreaCode(view, i)
192
+ const startStarts = areaCode === null || view.hasBoundary(i + areaCode) ? [i] : [i + areaCode, i]
193
+ for (const startStart of startStarts) {
194
+ const span = matchRangeBody(view, startStart)
195
+ if (span !== null) return span
196
+ }
197
+ return null
198
+ }
199
+
200
+ const THREE_DIGITS_RE = /^\d\d\d/
201
+ const THREE_DIGITS_IN_PARENS_RE = /^\(\d\d\d\)/
202
+
203
+ /** v4's `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
204
+ function readPhoneAreaCode(view: ProseView, i: number): number | null {
205
+ const text = view.text
206
+ // `\d{3}-` with no separator woven into the digits or before the dash.
207
+ if (THREE_DIGITS_RE.test(text.slice(i, i + 3)) && text[i + 3] === "-"
208
+ && !view.hasBoundary(i + 1) && !view.hasBoundary(i + 2) && !view.hasBoundary(i + 3)) {
209
+ return 4
210
+ }
211
+ // `(\d{3})` with an optional trailing space.
212
+ if (THREE_DIGITS_IN_PARENS_RE.test(text.slice(i, i + 5))) {
213
+ return text[i + 5] === " " ? 6 : 5
214
+ }
215
+ return null
216
+ }
217
+
218
+ /**
219
+ * Matches `\b rangeStart -{1,3} rangeEnd following* suffix? \b` boundary-aware,
220
+ * beginning the start at `startStart`. Converts the dash on a v4-converting
221
+ * match. Returns the consumed end offset (structural span end) or null.
222
+ */
223
+ function matchRangeBody(view: ProseView, startStart: number): number | null {
224
+ const text = view.text
225
+ // rangeStart = `(?:p\.?|[currency])?\d[\d.,]*`, anchored at a `\b`.
226
+ const start = readStartRun(view, startStart)
227
+ if (start === null) return null
228
+ const startSpanEnd = startStart + start.length
229
+ // One `${chr}?` between start and the dash tolerates a single boundary there.
230
+ if (boundaryCountAt(view, startSpanEnd) > 1) return null
231
+ // Dash run of 1..3 hyphens that never spans an interior boundary (a boundary
232
+ // between two hyphens truncates the run, as in v4's `-{1,3}`).
233
+ if (text[startSpanEnd] !== "-") return null
234
+ let dashEnd = startSpanEnd + 1
235
+ while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd)) dashEnd++
236
+ if (boundaryCountAt(view, dashEnd) > 1) return null
237
+
238
+ // `readStartRun` already stops at the first interior boundary, so the start
239
+ // span is boundary-free and its clean digits are the whole run.
240
+ const startDigits = text.slice(startStart, startSpanEnd)
241
+ if (!wordBoundaryStartOk(view, startStart)) return null
242
+ if (!notAfterDashOk(view, startStart)) return null
243
+
244
+ const end = readEndRun(view, dashEnd)
245
+ if (end === null) return null
246
+ // v4's greedy end `[\d.,]*` and greedy `following*` backtrack together; the
247
+ // structural match commits at the longest end whose tail completes.
248
+ for (let len = end.length; len >= end.minLen; len--) {
249
+ const endPos = end.firstDigitStart + len
250
+ const tail = resolvePositiveTail(view, endPos)
251
+ if (tail === null) continue
252
+ const endStr = text.slice(dashEnd, endPos)
253
+ if (tail.followingEmpty && rangeNumbersConvert(startDigits, endStr)) {
254
+ view.replace(startSpanEnd, dashEnd, EN_DASH)
255
+ }
256
+ return tail.consumedEnd
257
+ }
258
+ return null
259
+ }
260
+
261
+ /**
262
+ * v4's `(?:p\.?|[currency])?\d[\d.,]*` start run, boundary-truncated. v4's `wb`
263
+ * (`(?<!\w${chr}{0,3})\b`) anchors the start at a word boundary, so the currency
264
+ * alternative is unreachable: a leading currency symbol is non-word, and the only
265
+ * `\b` sits after it, at the digit — so the currency is never part of `start`
266
+ * (e.g. "$100-2000" reads start "100", which the 3+4 phone gate then blocks). The
267
+ * `p`/`p.` prefix is a word char, so it can begin the run at a word boundary.
268
+ */
269
+ function readStartRun(view: ProseView, pos: number): { length: number } | null {
270
+ const text = view.text
271
+ let i = pos
272
+ if (text[i] === "p") {
273
+ // v4's `p\.?\d` is contiguous (no separator slot), so a boundary between `p`,
274
+ // an optional `.`, and the first digit breaks the prefix.
275
+ if (view.hasBoundary(i + 1)) return null
276
+ i++
277
+ if (text[i] === ".") {
278
+ if (view.hasBoundary(i + 1)) return null
279
+ i++
280
+ }
281
+ }
282
+ // v4's `\d` is required after the optional `p\.?` prefix. The prefix chars are
283
+ // never digits, so a missing digit here means no start run begins.
284
+ if (!/\d/.test(text[i] ?? "")) return null
285
+ i++
286
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
287
+ return { length: i - pos }
288
+ }
289
+
290
+ interface EndRun {
291
+ /** Clean offset of the first end digit (past an optional currency symbol). */
292
+ firstDigitStart: number
293
+ /** Length of the `[\d.,]` run from `firstDigitStart`, boundary-truncated. */
294
+ length: number
295
+ /** Minimum end length: 1 digit (`\d` is required). */
296
+ minLen: number
297
+ }
298
+
299
+ /** v4's `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
300
+ function readEndRun(view: ProseView, pos: number): EndRun | null {
301
+ const text = view.text
302
+ let i = pos
303
+ if (CURRENCY_RE.test(text[i] ?? "")) {
304
+ // v4 has no separator slot between the currency symbol and the digit.
305
+ if (view.hasBoundary(i + 1)) return null
306
+ i++
307
+ }
308
+ if (!/\d/.test(text[i] ?? "")) return null
309
+ const firstDigitStart = i
310
+ i++
311
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
312
+ return { firstDigitStart, length: i - firstDigitStart, minLen: 1 }
313
+ }
314
+
315
+ interface ResolvedTail {
316
+ followingEmpty: boolean
317
+ /** End offset of the whole structural tail (following + optional suffix). */
318
+ consumedEnd: number
319
+ }
320
+
321
+ /**
322
+ * Resolves the tail at `endPos`: finds the longest `following` whose
323
+ * `suffix? wbe` completes (v4's greedy choice). `followingEmpty` reports whether
324
+ * that following is empty (the convert condition); `consumedEnd` is the tail's
325
+ * end including any suffix (for the scan's consume/advance). Returns null when
326
+ * no following completes.
327
+ */
328
+ function resolvePositiveTail(view: ProseView, endPos: number): ResolvedTail | null {
329
+ const candidates = followingCandidates(view, endPos, true)
330
+ for (let i = candidates.length - 1; i >= 0; i--) {
331
+ const suffixEnd = suffixWbeEnd(view, candidates[i], true)
332
+ if (suffixEnd !== null) {
333
+ return { followingEmpty: i === 0, consumedEnd: suffixEnd }
334
+ }
335
+ }
336
+ return null
337
+ }
338
+
339
+
340
+ /**
341
+ * Negative ranges: −5-5 → −5–5, −5--2 → −5–−2. A separate hand scan (not a regex)
342
+ * because the end run, like v4's `(?<end>${chr}?\d[\d.,]*)`, must truncate at node
343
+ * boundaries before its tail is resolved: over clean text a regex would fuse
344
+ * separator-split digits into one run (e.g. "8{sep}{sep}{sep}{sep}{sep}0" reads as
345
+ * "80") and mis-place or miss the conversion. The scan mirrors `matchRangeBody`:
346
+ * at each MINUS-led start it resolves the longest end whose `following* suffix?
347
+ * wbe` tail completes, converting only when that tail leaves `following` empty.
348
+ */
349
+ function convertNegativeRanges(view: ProseView): void {
350
+ const text = view.text
351
+ let i = 0
352
+ while (i < text.length) {
353
+ if (text[i] === MINUS) {
354
+ const span = matchNegativeRangeAt(view, i)
355
+ if (span !== null) {
356
+ i = Math.max(span, i + 1)
357
+ continue
87
358
  }
88
- const startNum = groups.start.replace(cachedRegExp(chr, "g"), "")
89
- const endNum = groups.end.replace(cachedRegExp(chr, "g"), "")
90
- if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum)) return args[0] as string
91
- // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
92
- // 555-123-4567). Thousands-grouped endings (1,234 / 1.234) keep their
93
- // internal separator and so fail /^\d{4}$/, falling through to convert
94
- // as ranges. Space/NNBSP/thin-space grouping isn't captured by the outer
95
- // range regex, so those variants currently aren't affected here.
96
- if (/^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum)) return args[0] as string
97
- // Skip US toll-free prefix pattern: 1-800, 1-888, 1-877, etc. Only the
98
- // seven real toll-free codes (800, 888, 877, 866, 855, 844, 833) are
99
- // blocked, so genuine ranges like 1-850 or 1-810 still en-dash.
100
- if (/^1$/.test(startNum) && /^8(?:00|88|77|66|55|44|33)$/.test(endNum)) return args[0] as string
101
- return `${groups.precedingAreaCode ?? ""}${groups.start}${EN_DASH}${groups.end}${groups.suffix ?? ""}`
102
359
  }
103
- )
360
+ i++
361
+ }
362
+ view.commit()
363
+ }
364
+
365
+ /**
366
+ * Attempts v4's `(?<![LAT]) ${MINUS}\d[\d.,]* -{1,3} -? end following* suffix? wbe`
367
+ * beginning the MINUS at clean offset `i`. Converts the dash run when v4's gates
368
+ * pass. Returns the consumed structural end offset, or null when no match begins.
369
+ */
370
+ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
371
+ const text = view.text
372
+ // v4's `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
373
+ // boundary, which would hide it) blocks the match.
374
+ if (boundaryCountAt(view, i) === 0) {
375
+ const before = text[i - 1]
376
+ if (before !== undefined && LATIN_LETTER_RE.test(before)) return null
377
+ }
378
+
379
+ // start = `${MINUS}\d[\d.,]*${chr}?`: the digits after MINUS must not cross a
380
+ // boundary (v4's `start` body has no separator slot before its trailing one).
381
+ if (!/\d/.test(text[i + 1] ?? "")) return null
382
+ let startEnd = i + 1
383
+ while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd)) startEnd++
384
+ // One `${chr}?` between start and the dash tolerates a single boundary there.
385
+ if (boundaryCountAt(view, startEnd) > 1) return null
386
+ if (text[startEnd] !== "-") return null
387
+
388
+ // `-{1,3}?` then optional `(?<neg>-)`: a 1..3 hyphen run with no interior
389
+ // boundary, the last of which becomes `neg` when two or more hyphens are present.
390
+ let dashRunEnd = startEnd + 1
391
+ while (text[dashRunEnd] === "-" && dashRunEnd - startEnd < 3 && !view.hasBoundary(dashRunEnd)) dashRunEnd++
392
+ const dashRunLen = dashRunEnd - startEnd
393
+ const hasNeg = dashRunLen >= 2
394
+ const negEnd = dashRunEnd
395
+ // The `(?<end>${chr}?...)` head separator slot tolerates one boundary.
396
+ if (boundaryCountAt(view, negEnd) > 1) return null
104
397
 
105
- // Negative ranges: −5-5 → −5–5, −5--2 → −5–−2
106
- // Separate regex because MINUS isn't a word char, so \b in ${wb} would match after it
107
- text = text.replace(
108
- cachedRegExp(
109
- `(?<![${LATIN_LETTERS}])(?<start>${MINUS}\\d[\\d.,]*${chr}?)-{1,3}?(?<neg>-)?(?<end>${chr}?\\d[\\d.,]*)(?<following>(?:${chr}?-${chr}?\\d+)*)(?<suffix>${chr}?[xKBTM])?${wbe}`,
110
- "g"
111
- ),
112
- (...args) => {
113
- const groups = namedGroups<{
114
- start: string
115
- neg?: string
116
- end: string
117
- following?: string
118
- suffix?: string
119
- }>(args)
120
- if (groups.following) return args[0] as string
121
- return `${groups.start}${EN_DASH}${groups.neg ? MINUS : ""}${groups.end}${groups.suffix ?? ""}`
398
+ const end = readNegEndRun(view, negEnd)
399
+ if (end === null) return null
400
+ // v4's greedy end and `following*` backtrack together; commit at the longest end
401
+ // whose tail completes, converting only when that tail's `following` is empty.
402
+ for (let len = end.length; len >= 1; len--) {
403
+ const endPos = end.firstDigitStart + len
404
+ const candidates = followingCandidates(view, endPos, false)
405
+ for (let c = candidates.length - 1; c >= 0; c--) {
406
+ const suffixEnd = suffixWbeEnd(view, candidates[c], false)
407
+ if (suffixEnd === null) continue
408
+ if (c === 0) {
409
+ view.replace(startEnd, negEnd, `${EN_DASH}${hasNeg ? MINUS : ""}`)
410
+ }
411
+ return suffixEnd
122
412
  }
123
- )
413
+ }
414
+ return null
415
+ }
124
416
 
125
- return text
417
+ interface NegEndRun {
418
+ firstDigitStart: number
419
+ length: number
420
+ }
421
+
422
+ /** v4's negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
423
+ function readNegEndRun(view: ProseView, pos: number): NegEndRun | null {
424
+ const text = view.text
425
+ if (!/\d/.test(text[pos] ?? "")) return null
426
+ let i = pos + 1
427
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
428
+ return { firstDigitStart: pos, length: i - pos }
429
+ }
430
+
431
+ /**
432
+ * Boundary-aware `following` end positions, shortest (empty) first, walking
433
+ * segments `${chr}?[-(−)]${chr}?\d+`. Separators are zero-width in clean text;
434
+ * each `${chr}?` tolerates one boundary, and each `\d+` stops at a boundary.
435
+ * `allowMinus` admits a MINUS as a segment's dash (positive ranges).
436
+ */
437
+ function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: boolean): number[] {
438
+ const candidates = [endSpanEnd]
439
+ let pos = endSpanEnd
440
+ for (;;) {
441
+ if (boundaryCountAt(view, pos) > 1) break
442
+ const dashCh = view.text[pos]
443
+ if (dashCh !== "-" && !(allowMinus && dashCh === MINUS)) break
444
+ const digitStart = pos + 1
445
+ if (boundaryCountAt(view, digitStart) > 1) break
446
+ if (!/\d/.test(view.text[digitStart] ?? "")) break
447
+ let end = digitStart + 1
448
+ while (/\d/.test(view.text[end] ?? "") && !view.hasBoundary(end)) end++
449
+ pos = end
450
+ candidates.push(pos)
451
+ }
452
+ return candidates
453
+ }
454
+
455
+ // First characters of the four superscript ordinal pairs. \w does not match these
456
+ // Unicode chars, so wordBoundaryEndOk treats them as non-word — but they carry the
457
+ // same blocking semantics as the plain letters they render (st/nd/rd/th).
458
+ const SUPERSCRIPT_ORDINAL_FIRST_RE = new RegExp(
459
+ `[${SUPERSCRIPT_ST[0]}${SUPERSCRIPT_ND[0]}${SUPERSCRIPT_RD[0]}${SUPERSCRIPT_TH[0]}]`,
460
+ )
461
+
462
+ /**
463
+ * v4's `(?<suffix>${chr}?(?:[AaPp][Mm]|[xKBTM]))?${wbe}` at `pos`. Returns the
464
+ * end offset after the optional suffix when the tail completes (wbe passes), or
465
+ * null when it does not. Two non-`\w` glyphs the v5 symbol passes emit
466
+ * (superscript ordinals, `×`) abut the digit run and must block conversion even
467
+ * though `wbe` would read them as a clean word boundary; both return null first.
468
+ */
469
+ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number | null {
470
+ const text = view.text
471
+ // A superscript ordinal immediately following the digit run blocks conversion:
472
+ // `52ⁿᵈ` is an ordinal, not a range endpoint (same semantics as `52nd`).
473
+ if (SUPERSCRIPT_ORDINAL_FIRST_RE.test(text[pos] ?? "")) return null
474
+ // `×` (MULTIPLICATION) is the Unicode form of `x` produced by the symbols pass.
475
+ // It is non-\w so wordBoundaryEndOk would treat it as a clean boundary, but
476
+ // `55×5` is multiplication, not a range endpoint — block the same as `52nd`.
477
+ if (text[pos] === MULTIPLICATION) return null
478
+ if (wordBoundaryEndOk(view, pos)) return pos
479
+ // An optional suffix: one ${chr}? (zero-width, ≤1 boundary) then am/pm or one
480
+ // of x/K/B/T/M, followed by wbe. The suffix letters sit at the clean `pos`.
481
+ if (boundaryCountAt(view, pos) > 1) return null
482
+ // `[AaPp][Mm]` is contiguous in v4 — a boundary between the two letters breaks
483
+ // the suffix.
484
+ if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
485
+ return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null
486
+ }
487
+ if (/[xKBTM]/.test(text[pos] ?? "")) {
488
+ return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null
489
+ }
490
+ return null
126
491
  }
127
492
 
493
+ const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`)
494
+
495
+ /**
496
+ * v4's `notAfterDash` was a single-char negative lookbehind. A node boundary
497
+ * immediately before the start hides the clean char (v4 saw the separator, not
498
+ * the dash/letter), so the guard only fires when a disallowed clean char sits
499
+ * directly before the start with no intervening boundary.
500
+ */
501
+ function notAfterDashOk(view: ProseView, startOffset: number): boolean {
502
+ if (startOffset === 0) return true
503
+ if (boundaryCountAt(view, startOffset) > 0) return true
504
+ const prev = view.text[startOffset - 1]
505
+ return prev === undefined || !NOT_AFTER_DASH_RE.test(prev)
506
+ }
507
+
508
+ /** v4 range-number gates: reject year-month, phone-shaped, and toll-free pairs. */
509
+ function rangeNumbersConvert(startNum: string, endNum: string): boolean {
510
+ if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum)) return false
511
+ // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
512
+ // 555-123-4567). Thousands-grouped endings (1,234 / 1.234) keep their
513
+ // internal separator and so fail /^\d{4}$/, falling through to convert as
514
+ // ranges.
515
+ if (/^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum)) return false
516
+ // Skip US toll-free prefix pattern: 1-800, 1-888, 1-877, etc. Only the seven
517
+ // real toll-free codes (800, 888, 877, 866, 855, 844, 833) are blocked, so
518
+ // genuine ranges like 1-850 or 1-810 still en-dash.
519
+ if (/^1$/.test(startNum) && /^8(?:00|88|77|66|55|44|33)$/.test(endNum)) return false
520
+ return true
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Date ranges
525
+ // ---------------------------------------------------------------------------
526
+
128
527
  /** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
129
528
  export function enDashDateRange(text: string, options: DashOptions = {}): string {
130
529
  const dashStyle = options.dashStyle ?? "american"
131
530
  if (dashStyle === "none") return text
132
- const chr = getEscapedSeparator(options)
133
- const wb = wordBoundaryStart(chr)
134
- const wbe = wordBoundaryEnd(chr)
135
-
136
- // Atomic-optional year groups (lookahead + backref). The capture inside
137
- // the lookahead is locked in once matched, so the year group commits
138
- // without backtracking.
139
- const startYear = `(?=(?<startYear>${chr}? \\d{4})?)\\k<startYear>`
140
- const endYear = `(?=(?<endYear> \\d{4})?)\\k<endYear>`
141
- return text.replace(
142
- cachedRegExp(`${wb}(?<startMonth>${monthPattern})${startYear}(?<preSep>${chr}?)(?<preSpace> ?)-(?<postSpace> ?)(?<postSep>${chr}?)(?<endMonth>${monthPattern})${endYear}${wbe}`, "g"),
143
- (...args) => {
144
- const groups = namedGroups<{
145
- startMonth: string
146
- startYear?: string
147
- preSep: string
148
- endMonth: string
149
- endYear?: string
150
- postSep: string
151
- }>(args)
152
- const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
153
- return `${groups.startMonth}${groups.startYear || ""}${groups.preSep}${pre}${EN_DASH}${post}${groups.postSep}${groups.endMonth}${groups.endYear || ""}`
154
- }
155
- )
531
+ const separator = options.separator ?? DEFAULT_SEPARATOR
532
+ const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
533
+
534
+ return withProseView(text, separator, (view) => {
535
+ // Atomic-optional year groups (lookahead + backref). The capture inside the
536
+ // lookahead is locked in once matched, so the year group commits without
537
+ // backtracking.
538
+ const startYear = `(?=(?<startYear> \\d{4})?)\\k<startYear>`
539
+ const endYear = `(?=(?<endYear> \\d{4})?)\\k<endYear>`
540
+ const pattern = `\\b(?<startMonth>${monthPattern})${startYear}(?<preSpace> ?)-(?<postSpace> ?)(?<endMonth>${monthPattern})${endYear}\\b`
541
+ pass(
542
+ view,
543
+ cachedRegExp(pattern, "g"),
544
+ (match, v) => {
545
+ const groups = namedGroups<{
546
+ startMonth: string
547
+ startYear?: string
548
+ preSpace: string
549
+ postSpace: string
550
+ endMonth: string
551
+ endYear?: string
552
+ }>(matchArgs(match))
553
+ // Replace `[preSpace]-[postSpace]` (the dash and its spaces) with the
554
+ // styled en-dash, leaving the months and any boundaries around them.
555
+ const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
556
+ const dashSpan = groups.preSpace.length + 1 + groups.postSpace.length
557
+ const dashStart = match.index + startLen
558
+ v.replace(dashStart, dashStart + dashSpan, `${pre}${EN_DASH}${post}`)
559
+ return null
560
+ },
561
+ {
562
+ allowBoundaries: (match, v) => {
563
+ const groups = namedGroups<{
564
+ startMonth: string
565
+ startYear?: string
566
+ preSpace: string
567
+ postSpace: string
568
+ endMonth: string
569
+ endYear?: string
570
+ }>(matchArgs(match))
571
+ const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
572
+ // v4 tolerated one separator after the start year (`preSep`) and one
573
+ // before the end month (`postSep`), bracketing the dash and spaces.
574
+ const dashStart = match.index + startLen
575
+ const dashEnd = dashStart + groups.preSpace.length + 1 + groups.postSpace.length
576
+ const tolerated = new Map<number, number>()
577
+ tolerated.set(dashStart, 1)
578
+ tolerated.set(dashEnd, 1)
579
+ if (!interiorBoundariesAllowed(match, v, tolerated)) return false
580
+ return wordBoundaryStartOk(v, match.index) && wordBoundaryEndOk(v, match.index + match[0].length)
581
+ },
582
+ },
583
+ )
584
+ })
156
585
  }
157
586
 
587
+ // ---------------------------------------------------------------------------
588
+ // Minus signs
589
+ // ---------------------------------------------------------------------------
590
+
158
591
  /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
159
592
  export function minusReplace(text: string, options: DashOptions = {}): string {
160
- const chr = getEscapedSeparator(options)
593
+ const separator = options.separator ?? DEFAULT_SEPARATOR
594
+ return withProseView(text, separator, (view) => {
595
+ minusSubtractionOfNegative(view)
596
+ minusSpacedSubtraction(view)
597
+ minusDirectNegative(view)
598
+ })
599
+ }
161
600
 
162
- // Pattern 1a: Subtraction of negative number (e.g., "5 - -3" → "5 − −3")
163
- // Must come before Pattern 1b so the negative hyphen is consumed first
164
- text = text.replace(
165
- cachedRegExp(`(?<=\\d${chr}?) - -(?<num>${chr}?\\d*\\.?\\d+)`, "g"),
166
- ` ${MINUS} ${MINUS}$<num>`
601
+ /** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
602
+ function minusSubtractionOfNegative(view: ProseView): void {
603
+ // "5 - -3" → "5 − −3"; the negative hyphen is consumed first. v4's `${chr}?`
604
+ // before `num` tolerates one boundary there (the `- -` prefix has 4 chars).
605
+ pass(
606
+ view,
607
+ cachedRegExp(`(?<=\\d) - -(?<num>\\d*\\.?\\d+)`, "g"),
608
+ (match, v) => {
609
+ // Replace only the ` - -` prefix; `num` (which may carry interior
610
+ // boundaries v4 truncated at) stays untouched.
611
+ if (!minusSubtractionPrefixOk(match, v, 4)) return null
612
+ v.replace(match.index, match.index + 4, ` ${MINUS} ${MINUS}`)
613
+ return null
614
+ },
615
+ { allowBoundaries: () => true },
167
616
  )
617
+ }
168
618
 
169
- // Pattern 1b: Spaced math subtraction (e.g., "5 - 3" → "5 − 3")
170
- // Only when preceded by a digit - this distinguishes "5 - 3" from "Safari) - 9"
171
- text = text.replace(
172
- cachedRegExp(`(?<=\\d${chr}?) - (?<num>${chr}?\\d*\\.?\\d+)`, "g"),
173
- ` ${MINUS} $<num>`
619
+ function minusSpacedSubtraction(view: ProseView): void {
620
+ // "5 - 3" → "5 − 3"; only after a digit ("5 - 3" not "Safari) - 9").
621
+ pass(
622
+ view,
623
+ cachedRegExp(`(?<=\\d) - (?<num>\\d*\\.?\\d+)`, "g"),
624
+ (match, v) => {
625
+ if (!minusSubtractionPrefixOk(match, v, 3)) return null
626
+ v.replace(match.index, match.index + 3, ` ${MINUS} `)
627
+ return null
628
+ },
629
+ { allowBoundaries: () => true },
174
630
  )
631
+ }
175
632
 
176
- // Pattern 2: Direct negative numbers (e.g., "-5" → "−5", "(-3)" → "(−3)")
177
- // Match after: start of line, whitespace, (, or quotes (straight or curly)
178
- // No space allowed between hyphen and digit
179
- text = text.replace(
180
- cachedRegExp(`(?<before>^|[\\s\\("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}])-(?<num>\\d*\\.?\\d+)`, "gm"),
181
- `$<before>${MINUS}$<num>`
182
- )
633
+ const NUM_BODY_RE = /^\d*\.?\d+/
183
634
 
184
- // Pattern 2b: Direct negative numbers after separator boundary (e.g., <em>-5</em>)
185
- // Only when no word character precedes the separator — prevents both
186
- // "2{SEP}-3" in "1-2-3" and "GPT{SEP}-3" from being misidentified as negative 3
187
- text = text.replace(
188
- cachedRegExp(`(?<![\\d.,${LATIN_LETTERS}])(?<before>${chr})-(?<num>\\d*\\.?\\d+)`, "gm"),
189
- `$<before>${MINUS}$<num>`
190
- )
635
+ /**
636
+ * v4's subtraction patterns wove one `${chr}?` after the leading digit (in the
637
+ * lookbehind, at the match start) and one before `num` (`(?<num>${chr}?\d*\.?\d+)`).
638
+ * The `${chr}?-` prefix span ([match.index, match.index + prefixLen)) must stay
639
+ * separator-free; one boundary may sit at `num`'s head. v4's `num` body
640
+ * (`\d*\.?\d+`) carries no separator slot, so it stops at the first interior
641
+ * boundary: a boundary that truncates `num` to a still-valid number (e.g. "12"
642
+ * in "5 - 12{sep}5") leaves v4 matching, but one that strips the mandatory `\d+`
643
+ * (e.g. "." in "5 - .{sep}5") breaks v4's match. The targeted edit only rewrites
644
+ * the ` - ` prefix, so a converting match must keep v4's `num` satisfiable.
645
+ */
646
+ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefixLen: number): boolean {
647
+ // The `${chr}?` in v4's lookbehind (`(?<=\d${chr}?)`) sits at the match start
648
+ // between the leading digit and the space; it tolerates at most one boundary.
649
+ if (boundaryCountAt(view, match.index) > 1) return false
650
+ const numStart = match.index + prefixLen
651
+ for (const b of view.boundaries) {
652
+ if (b > match.index && b < numStart) return false
653
+ }
654
+ // The `${chr}?` before `num` tolerates at most one boundary at the num head.
655
+ if (boundaryCountAt(view, numStart) > 1) return false
656
+ // v4's `num` stops at the first interior boundary; the clean run up to it must
657
+ // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
658
+ const numEnd = match.index + match[0].length
659
+ let truncatedEnd = numEnd
660
+ for (const b of view.boundaries) {
661
+ if (b > numStart && b < numEnd) { truncatedEnd = b; break }
662
+ if (b >= numEnd) break
663
+ }
664
+ return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd))
665
+ }
191
666
 
192
- return text
667
+ const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`)
668
+ const NUM_BEFORE_2B_BLOCK_RE = new RegExp(`[\\d.,${LATIN_LETTERS}]`)
669
+
670
+ /**
671
+ * Direct negative numbers. v4 ran two passes: Pattern 2 converts `-\d` after
672
+ * line start, whitespace, `(`, or a quote; Pattern 2b converts `${chr}-\d` (a
673
+ * separator-led hyphen) when no digit/`.`/`,`/Latin precedes the separator —
674
+ * which prevents `1${chr}-2` and `GPT${chr}-3` from reading as negatives. In
675
+ * the view, Pattern 2 fires when the clean char before the hyphen qualifies and
676
+ * no boundary intrudes; Pattern 2b fires when a boundary leads the hyphen.
677
+ */
678
+ function minusDirectNegative(view: ProseView): void {
679
+ pass(
680
+ view,
681
+ cachedRegExp(`-(?<num>\\d*\\.?\\d+)`, "gm"),
682
+ (match, v) => {
683
+ const start = match.index
684
+ // v4's `num` (`\d*\.?\d+`) carries no separator slot, so it stops at the
685
+ // first interior boundary; the clean run up to it must still satisfy
686
+ // `\d*\.?\d+`. A boundary right after the hyphen (head) leaves no digits
687
+ // ("-{sep}5"), and one that strips the mandatory `\d+` ("-.{sep}1") breaks
688
+ // the match, but one past a valid number ("-5{sep}5") leaves it intact.
689
+ const numEnd = start + match[0].length
690
+ let numTruncEnd = numEnd
691
+ for (const b of v.boundaries) {
692
+ if (b > start && b < numEnd) { numTruncEnd = b; break }
693
+ if (b >= numEnd) break
694
+ }
695
+ if (!NUM_BODY_RE.test(v.text.slice(start + 1, numTruncEnd))) return null
696
+ // A boundary directly before the hyphen routes to Pattern 2b; otherwise
697
+ // Pattern 2 examines the clean preceding char. (The two never overlap: a
698
+ // hyphen has either a boundary or a clean char immediately before it.)
699
+ const nb = boundaryCountAt(v, start)
700
+ if (nb > 0) {
701
+ // Pattern 2b: the marked char before the separator run must not be a
702
+ // digit, `.`, `,`, or Latin letter. With ≥2 stacked separators that char
703
+ // is another separator (admitted); with one, it is the clean char left.
704
+ if (nb < 2) {
705
+ const before = v.text[start - 1]
706
+ if (before !== undefined && NUM_BEFORE_2B_BLOCK_RE.test(before)) return null
707
+ }
708
+ } else {
709
+ // Pattern 2: line start, whitespace, `(`, or a quote immediately left.
710
+ const before = v.text[start - 1]
711
+ const atLineStart = before === undefined || before === "\n"
712
+ if (!atLineStart && !MINUS_BEFORE_CLASS_RE.test(before)) return null
713
+ }
714
+ v.replace(start, start + 1, MINUS)
715
+ return null
716
+ },
717
+ { allowBoundaries: () => true },
718
+ )
193
719
  }
194
720
 
195
- function convertParentheticalDashes(text: string, sep: string, style: DashStyle): string {
721
+ // ---------------------------------------------------------------------------
722
+ // Parenthetical dashes
723
+ // ---------------------------------------------------------------------------
724
+
725
+ function convertParentheticalDashes(view: ProseView, style: DashStyle): void {
196
726
  const localizedDash = style === "british" ? EN_DASH : EM_DASH
197
727
  const maybeSpace = style === "british" ? " " : ""
198
- const escapedSep = getEscapedSeparator({ separator: sep })
199
-
200
- // Convert spaced dashes: "word - word" or "word — word"
201
- // When a separator follows the dash, preserve trailing spaces (they belong to the next text segment).
202
- // A single plain hyphen directly before a word character (through optional separators) is a
203
- // suspended/hanging hyphen ("Yes-men and -women"), not a parenthetical dash — skip it.
204
- // Em/en dashes and multiple hyphens can have zero trailing spaces.
205
- // `sepAfter` and `trailing` share one optional unit so a whitespace run
206
- // can only be assigned to it as a whole, keeping the match unambiguous.
207
- const spacedDashPattern = cachedRegExp(
208
- `(?<=[^\\s]|^)(?<sepBefore>${escapedSep}?)[ ]+(?:[${EN_DASH}${EM_DASH}][-${EN_DASH}${EM_DASH}]*|-{2,}|-(?!${escapedSep}*[${LATIN_LETTERS}\\d]))(?!${escapedSep}?-*${escapedSep}?>)[ ]*(?:(?<sepAfter>${escapedSep})(?<trailing>[ ]*))?(?=\\S|$)`, "g"
209
- )
210
- text = text.replace(spacedDashPattern, (_match, sepBefore, sepAfter, trailing) => {
211
- // sepAfter and trailing are undefined when the optional group didn't match.
212
- sepAfter = sepAfter ?? ""
213
- trailing = trailing ?? ""
214
- // Trailing space after a separator belongs to the next text node — keep it
215
- // regardless of style. Without a separator, the trailing space is part of
216
- // the current node's whitespace around the dash and gets consumed.
217
- const keepTrailing = sepAfter ? trailing : ""
218
- return `${sepBefore}${maybeSpace}${localizedDash}${maybeSpace}${sepAfter}${keepTrailing}`
219
- })
220
- // Convert dashes at text node boundaries: separator alone precedes dash (e.g., "word{sep}– rest")
221
- // Requires space after the dash to avoid matching number ranges like "1{sep}-{sep}5"
222
- text = text.replace(
223
- cachedRegExp(`(?<=[^\\s])(?<sepBefore>${escapedSep})[${EN_DASH}${EM_DASH}-]+[ ]+(?<sepAfter>${escapedSep}?)`, "g"),
224
- `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
728
+ const rendered = `${maybeSpace}${localizedDash}${maybeSpace}`
729
+
730
+ convertSpacedDashes(view, rendered)
731
+ convertBoundaryLedDashes(view, rendered)
732
+ convertMultipleDashes(view, rendered)
733
+ convertLeadingLineDashes(view, localizedDash)
734
+ if (style === "british") convertUnspacedEmDashes(view, rendered)
735
+ }
736
+
737
+ const QUOTE_CHARS = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
738
+
739
+ const DASH_RUN_CHARS = new Set(["-", EN_DASH, EM_DASH])
740
+
741
+ function isDashChar(ch: string | undefined): boolean {
742
+ return ch !== undefined && DASH_RUN_CHARS.has(ch)
743
+ }
744
+
745
+ /**
746
+ * Spaced dashes: "word - word" / "word — word". A single plain hyphen directly
747
+ * before a word char (the suspended/hanging hyphen, "Yes-men and -women") is
748
+ * skipped. The arrow guard rejects shapes like "- >" so `foo -> bar` stays.
749
+ *
750
+ * v4's dash core (`[${EN}${EM}][-${EN}${EM}]*|-{2,}|-(?!sep*[LAT\d])`) never
751
+ * spanned a separator, so a node boundary inside the dash run truncates the run
752
+ * — the boundary becomes the `sepAfter` slot and any dashes past it are left
753
+ * untouched. The replacer reproduces this by truncating the run at the first
754
+ * interior boundary and re-validating the dash core on the truncated form.
755
+ */
756
+ function convertSpacedDashes(view: ProseView, rendered: string): void {
757
+ // v4's `(?<=[^\s]|^)` lookbehind is validated in the replacer instead, since a
758
+ // separator (non-space) also satisfies it, letting a match begin even when the
759
+ // clean char to the left is whitespace. The cheap `(?<![ ])` anchor keeps each
760
+ // `[ ]+` run starting at its leftmost space, avoiding quadratic backtracking.
761
+ const pattern = `(?<![ ])[ ]+(?<dashStart>[-${EN_DASH}${EM_DASH}])`
762
+ // v4's regex consumed the trailing `[ ]*` inside each match, advancing past it;
763
+ // this clean pattern does not, so a later match could begin inside a previous
764
+ // match's consumed trailing spaces. Track the last consumed offset and skip.
765
+ let consumedThrough = -1
766
+ pass(
767
+ view,
768
+ cachedRegExp(pattern, "g"),
769
+ (match, v) => {
770
+ const text = v.text
771
+ const groups = namedGroups<{ dashStart: string }>(matchArgs(match))
772
+ const firstDash = match.index + match[0].length - groups.dashStart.length
773
+ // v4's greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
774
+ // holds: line/text start, a non-whitespace clean char, or a separator. A
775
+ // boundary inside the leading spaces restarts the match just after it (the
776
+ // spaces before it stay with the previous node). The previous match's
777
+ // consumed span is also a floor: leading spaces it already consumed are not
778
+ // re-used.
779
+ let matchStart = Math.max(match.index, consumedThrough)
780
+ for (const b of view.boundaries) {
781
+ if (b > matchStart && b <= firstDash - 1) matchStart = b
782
+ else if (b > firstDash) break
783
+ }
784
+ /* istanbul ignore if -- defensive: the previous match's trailing `(?=\S|$)`
785
+ stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
786
+ if (matchStart >= firstDash + 1) return null
787
+ // Validate the lookbehind at matchStart: start of text/line, a boundary
788
+ // immediately left (separator), or a non-whitespace clean char.
789
+ const leftOk = matchStart === 0
790
+ || view.hasBoundary(matchStart)
791
+ || (text[matchStart - 1] !== undefined && !/\s/.test(text[matchStart - 1]))
792
+ if (!leftOk) return null
793
+ // The leading spaces between matchStart and the dash must be at least one
794
+ // (v4's `[ ]+` is one-or-more) — a boundary directly before the dash leaves
795
+ // none, which is the boundary-led pattern's job, not this one.
796
+ if (matchStart === firstDash) return null
797
+ // A separator between the leading spaces and the dash breaks v4's
798
+ // `[ ]+dashCore` (no separator slot there); neither spaced nor boundary-led
799
+ // matches, so skip.
800
+ if (view.hasBoundary(firstDash)) return null
801
+ // Maximal dash run from firstDash that does not cross a boundary (v4's core
802
+ // never spanned a separator).
803
+ let fullRunEnd = firstDash + 1
804
+ while (isDashChar(text[fullRunEnd]) && !v.hasBoundary(fullRunEnd)) fullRunEnd++
805
+ // v4's dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
806
+ // guard AND the trailing `[ ]*(?=\S|$)`, so try run ends longest-first and
807
+ // take the first one whose whole tail validates.
808
+ const chosen = chooseSpacedDashRun(v, firstDash, fullRunEnd)
809
+ if (chosen === null) return null
810
+ consumedThrough = chosen.consumedEnd
811
+ v.replace(matchStart, chosen.editEnd, rendered)
812
+ return null
813
+ },
814
+ { allowBoundaries: () => true },
225
815
  )
226
- // Convert multiple dashes: "word--word" or "word---word" or "quote"--"quote"
816
+ }
817
+
818
+ /**
819
+ * Picks v4's dashCore from the maximal dash run [firstDash, fullRunEnd). v4 tries
820
+ * `[EN EM][-EN EM]*`, then `-{2,}` (greedy), then a single `-`, backtracking past
821
+ * the arrow guard and the trailing `[ ]*(?=\S|$)`. Returns the chosen run end and
822
+ * the trailing-spaces end (`scan`) to replace, or null when no branch validates.
823
+ */
824
+ function chooseSpacedDashRun(view: ProseView, firstDash: number, fullRunEnd: number): SpacedTrailing | null {
825
+ const text = view.text
826
+ const first = text[firstDash]
827
+ if (first === EN_DASH || first === EM_DASH) {
828
+ // `[EN EM][-EN EM]*`: the leading EN/EM is mandatory, the `[-EN EM]*` tail is
829
+ // greedy but backtracks past the arrow guard — e.g. "–-{sep}{sep}>" gives the
830
+ // `-` back so the run is just "–" (not an arrow) and converts.
831
+ for (let end = fullRunEnd; end > firstDash; end--) {
832
+ if (spacedArrowAhead(view, end)) continue
833
+ const trailing = spacedTrailingEnd(view, end)
834
+ if (trailing !== null) return trailing
835
+ }
836
+ return null
837
+ }
838
+ // v4's `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
839
+ // run stops at the first EN/EM dash (e.g. "-–" is a single `-` core, not a
840
+ // two-dash run that would swallow the EN dash).
841
+ let hyphenRunEnd = firstDash
842
+ while (text[hyphenRunEnd] === "-" && hyphenRunEnd < fullRunEnd) hyphenRunEnd++
843
+ // All hyphens. Try `-{2,}` lengths longest-first, then a single `-`.
844
+ for (let end = hyphenRunEnd; end > firstDash; end--) {
845
+ if (end - firstDash === 1) {
846
+ // Single `-(?!${sep}*[LAT\d])`: the next clean char must not be a letter or
847
+ // digit (the suspended-hyphen guard).
848
+ const next = text[end]
849
+ if (next !== undefined && /[a-z\d]/i.test(next)) return null
850
+ }
851
+ if (spacedArrowAhead(view, end)) continue
852
+ const trailing = spacedTrailingEnd(view, end)
853
+ if (trailing !== null) return trailing
854
+ }
855
+ return null
856
+ }
857
+
858
+ interface SpacedTrailing {
859
+ /** Offset through which the edit deletes (kept post-boundary spaces excluded). */
860
+ editEnd: number
861
+ /** Offset past all consumed trailing chars (for overlap/`lastIndex` tracking). */
862
+ consumedEnd: number
863
+ }
864
+
865
+ /**
866
+ * v4's trailing `[ ]*(?:${chr} [ ]*)?(?=\S|$)`. The leading `[ ]*` (before any
867
+ * separator) is deleted; spaces after a separator are kept (they belong to the
868
+ * next node) but still consumed. The lookahead then requires a non-space char, a
869
+ * boundary, or end of text. Returns the edit/consumed offsets, or null on failure.
870
+ */
871
+ function spacedTrailingEnd(view: ProseView, runEnd: number): SpacedTrailing | null {
872
+ const text = view.text
873
+ let editEnd = runEnd
874
+ while (text[editEnd] === " " && !view.hasBoundary(editEnd)) editEnd++
875
+ // v4's `(?:${chr} [ ]*)?(?=\S|$)` is a greedy optional group: try consuming a
876
+ // single sepAfter separator and its kept trailing spaces first, then fall back
877
+ // to no separator. `(?=\S|$)` is satisfied by a boundary (a non-space
878
+ // separator), a non-space clean char, or end of text.
879
+ if (boundaryCountAt(view, editEnd) === 1) {
880
+ let consumedEnd = editEnd
881
+ while (text[consumedEnd] === " " && (consumedEnd === editEnd || !view.hasBoundary(consumedEnd))) consumedEnd++
882
+ if (lookaheadNonSpace(view, consumedEnd)) return { editEnd, consumedEnd }
883
+ }
884
+ if (lookaheadNonSpace(view, editEnd)) return { editEnd, consumedEnd: editEnd }
885
+ return null
886
+ }
887
+
888
+ /** v4's `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
889
+ function lookaheadNonSpace(view: ProseView, pos: number): boolean {
890
+ return pos === view.text.length || view.hasBoundary(pos) || !/\s/.test(view.text[pos])
891
+ }
892
+
893
+ /**
894
+ * v4's `(?!${sep}?-*${sep}?>)` arrow guard, boundary-aware. The two `${sep}?`
895
+ * slots bracket the `-*` hyphen run: one boundary may sit before the run and one
896
+ * after. With no hyphens both slots collapse to the run-end offset (up to two
897
+ * boundaries there); a boundary inside the hyphen run, or more than the two
898
+ * slots allow, hides the `>` (so the dash is not an arrow and converts).
899
+ */
900
+ function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
901
+ const text = view.text
902
+ let h = runEnd
903
+ while (text[h] === "-") {
904
+ if (h > runEnd && view.hasBoundary(h)) return false // boundary inside -*
905
+ h++
906
+ }
907
+ if (h === runEnd) {
908
+ // No hyphens: both ${sep}? slots fall at this offset (≤2 boundaries total).
909
+ if (boundaryCountAt(view, runEnd) > 2) return false
910
+ } else {
911
+ if (boundaryCountAt(view, runEnd) > 1) return false // first ${sep}?
912
+ if (boundaryCountAt(view, h) > 1) return false // second ${sep}?
913
+ }
914
+ return text[h] === ">"
915
+ }
916
+
917
+ /**
918
+ * Dashes where a separator alone precedes the dash run: "word{sep}– rest". v4's
919
+ * pattern `(?<=[^\s])${sep}[dash]+[ ]+` required a separator immediately before
920
+ * the dash run, a non-space marked char before that separator, and at least one
921
+ * trailing space. Because the leading separator is required, this only fires
922
+ * where a node boundary opens a dash run, so the pass scans boundaries rather
923
+ * than the clean text. The "non-space before the separator" is satisfied by a
924
+ * real non-space clean char (single boundary) or by another stacked separator
925
+ * (two or more boundaries — a separator is itself non-space).
926
+ */
927
+ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
928
+ const text = view.text
929
+ const edits: Array<[number, number]> = []
930
+ let lastProcessedEnd = -1
931
+ let prevOffset = -1
932
+ let stackedHere = 0
933
+ for (const b of view.boundaries) {
934
+ stackedHere = b === prevOffset ? stackedHere + 1 : 1
935
+ prevOffset = b
936
+ if (b < lastProcessedEnd) continue
937
+ if (!isDashChar(text[b])) continue
938
+ // Non-space before the separator: ≥2 stacked separators, or a non-space
939
+ // clean char immediately left.
940
+ if (stackedHere < 2) {
941
+ const before = text[b - 1]
942
+ if (before === undefined || /\s/.test(before)) continue
943
+ }
944
+ // Dash run from the boundary, not crossing a further boundary.
945
+ let runEnd = b + 1
946
+ while (isDashChar(text[runEnd]) && !view.hasBoundary(runEnd)) runEnd++
947
+ // Required trailing space run; stop at a boundary (sepAfter).
948
+ let spaceEnd = runEnd
949
+ while (text[spaceEnd] === " " && !view.hasBoundary(spaceEnd)) spaceEnd++
950
+ if (spaceEnd === runEnd) continue // no trailing space
951
+ edits.push([b, spaceEnd])
952
+ lastProcessedEnd = spaceEnd
953
+ }
954
+ for (const [from, to] of edits) view.replace(from, to, rendered)
955
+ view.commit()
956
+ }
957
+
958
+ /**
959
+ * Multiple dashes: "word--word", "word---word", `"quote"--"quote"`. v4's
960
+ * `(?<=[LAT\d quote])${sep}?[dash]{2,50}${sep}?(?=[LAT quote space])` tolerated
961
+ * one separator on each side of the run but its `[dash]{2,50}` never spanned a
962
+ * separator. A boundary inside the run truncates it (and the 2..50 count must
963
+ * still hold for the truncated run); two stacked boundaries on either edge
964
+ * exceed the single `${sep}?` slot and block the match.
965
+ */
966
+ function convertMultipleDashes(view: ProseView, rendered: string): void {
967
+ const before = `[${LATIN_LETTERS}\\d${QUOTE_CHARS}]`
968
+ const after = `[${LATIN_LETTERS}${QUOTE_CHARS} ]`
227
969
  // Upper bound of 50 prevents ReDoS on pathological runs of dashes.
228
- const quoteChars = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
229
- text = text.replace(
230
- cachedRegExp(`(?<=[${LATIN_LETTERS}\\d${quoteChars}])(?<sepBefore>${escapedSep}?)[${EN_DASH}${EM_DASH}-]{2,50}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}${quoteChars} ])`, "g"),
231
- `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
970
+ const pattern = `(?<=${before})[${EN_DASH}${EM_DASH}-]{2,50}(?=${after})`
971
+ const afterRe = new RegExp(after)
972
+ pass(
973
+ view,
974
+ cachedRegExp(pattern, "g"),
975
+ (match, v) => {
976
+ const text = v.text
977
+ const start = match.index
978
+ // Two stacked boundaries before the run exceed sepBefore's one-sep budget.
979
+ if (boundaryCountAt(v, start) >= 2) return null
980
+ // Truncate the run at the first interior boundary (v4's class stops there).
981
+ let runEnd = start + 1
982
+ while (isDashChar(text[runEnd]) && !v.hasBoundary(runEnd)) runEnd++
983
+ if (runEnd - start < 2 || runEnd - start > 50) return null
984
+ // sepAfter tolerates one boundary; v4's trailing lookahead then checks the
985
+ // clean char after the run is [LAT quote space].
986
+ if (boundaryCountAt(v, runEnd) >= 2) return null
987
+ const next = text[runEnd]
988
+ if (next === undefined || !afterRe.test(next)) return null
989
+ v.replace(start, runEnd, rendered)
990
+ return null
991
+ },
992
+ { allowBoundaries: () => true },
993
+ )
994
+ }
995
+
996
+ /**
997
+ * Dashes at start of line: "^- " → styled dash. v4 wove one optional `${chr}?`
998
+ * at line start (before the dashes); it sits at the match start, never interior.
999
+ * The dash run has no interior separator slot, so a boundary inside the run
1000
+ * blocks the match — the default interior-boundary rejection, which is what we
1001
+ * want here.
1002
+ */
1003
+ function convertLeadingLineDashes(view: ProseView, localizedDash: string): void {
1004
+ pass(view, cachedRegExp(`^-+ `, "gm"), () => `${localizedDash} `)
1005
+ }
1006
+
1007
+ /** British: unspaced em-dashes between letters become spaced en-dashes. */
1008
+ function convertUnspacedEmDashes(view: ProseView, rendered: string): void {
1009
+ const pattern = `(?<=[${LATIN_LETTERS}.!?'"])${EM_DASH}(?=[${LATIN_LETTERS}])`
1010
+ pass(
1011
+ view,
1012
+ cachedRegExp(pattern, "g"),
1013
+ (match, v) => {
1014
+ // v4's `(?<sepBefore>${chr}?)${EM_DASH}(?<sepAfter>${chr}?)` tolerated one
1015
+ // boundary on each side; two or more exceed the slot and block the match.
1016
+ if (boundaryCountAt(v, match.index) > 1) return null
1017
+ if (boundaryCountAt(v, match.index + match[0].length) > 1) return null
1018
+ v.replace(match.index, match.index + match[0].length, rendered)
1019
+ return null
1020
+ },
232
1021
  )
233
- // Convert dashes at start of line
234
- text = text.replace(cachedRegExp(`^(?<leadingSep>${escapedSep})?[-]+ `, "gm"), `$<leadingSep>${localizedDash} `)
235
- // British: convert unspaced em-dashes to spaced en-dashes (word—word → word – word)
236
- if (style === "british") {
237
- text = text.replace(
238
- cachedRegExp(`(?<=[${LATIN_LETTERS}.!?'"])(?<sepBefore>${escapedSep}?)${EM_DASH}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}])`, "g"),
239
- `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
240
- )
241
- }
242
- return text
243
1022
  }
244
1023
 
1024
+ // ---------------------------------------------------------------------------
1025
+ // Em-dash spacing normalization (american)
1026
+ // ---------------------------------------------------------------------------
1027
+
245
1028
  // TODO: Handle interrupted-then-resumed speech within quotes, where Chicago
246
1029
  // allows a space after the dash: "Don't inter— Hey! Who threw that?"
247
- function normalizeEmDashSpacing(text: string, sep: string): string {
248
- const escapedSep = getEscapedSeparator({ separator: sep })
249
-
250
- // Remove all spaces around em-dashes. The `\S|^` / `\S|$` boundary
251
- // anchors restrict the match to dashes adjacent to non-whitespace.
252
- text = text.replace(
253
- cachedRegExp(`(?<=\\S|^)(?<before>${escapedSep}?)[ ]*${EM_DASH}[ ]*(?<after>${escapedSep}?)(?=\\S|$)`, "g"),
254
- `$<before>${EM_DASH}$<after>`
255
- )
1030
+ function normalizeEmDashSpacing(view: ProseView): void {
1031
+ removeSpacesAroundEmDash(view)
1032
+ preserveLineLeadingEmDashSpace(view)
1033
+ }
256
1034
 
257
- // Preserve space after em-dash at start of line (e.g., attribution)
258
- text = text.replace(
259
- cachedRegExp(`^(?<sep>${escapedSep}?)${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
260
- `$<sep>${EM_DASH} $<after>`
1035
+ /**
1036
+ * Remove spaces directly around an em-dash that has non-whitespace on both
1037
+ * sides. v4's `(?<=\S|^)${sep}?[ ]*${EM}[ ]*${sep}?(?=\S|$)` only consumed the
1038
+ * spaces immediately adjacent to the dash; a separator between a space and the
1039
+ * dash kept that space (the leading `[ ]*` could not reach across the marker).
1040
+ * The walk therefore stops space consumption at node boundaries, and the
1041
+ * `\S|^` / `\S|$` anchors treat a boundary (a non-space marker) as satisfying
1042
+ * the anchor.
1043
+ */
1044
+ function removeSpacesAroundEmDash(view: ProseView): void {
1045
+ pass(
1046
+ view,
1047
+ cachedRegExp(EM_DASH, "g"),
1048
+ (match, v) => {
1049
+ const d = match.index
1050
+ const text = v.text
1051
+ // Leading spaces, stopping at a boundary or non-space.
1052
+ let left = d
1053
+ while (left > 0 && text[left - 1] === " " && !v.hasBoundary(left)) left--
1054
+ // Trailing spaces, stopping at a boundary or non-space.
1055
+ let right = d + 1
1056
+ while (right < text.length && text[right] === " " && !v.hasBoundary(right)) right++
1057
+ const leftAnchored = left === 0 || v.hasBoundary(left) || !/\s/.test(text[left - 1])
1058
+ const rightAnchored = right === text.length || v.hasBoundary(right) || !/\s/.test(text[right])
1059
+ if (!leftAnchored || !rightAnchored) return null
1060
+ // Drop the adjacent space runs; the em-dash and any edge boundaries stay.
1061
+ if (left < d) v.replace(left, d, "")
1062
+ if (right > d + 1) v.replace(d + 1, right, "")
1063
+ return null
1064
+ },
261
1065
  )
1066
+ }
262
1067
 
263
- return text
1068
+ /** Preserve a space after a line-leading em-dash before an uppercase/digit. */
1069
+ function preserveLineLeadingEmDashSpace(view: ProseView): void {
1070
+ pass(
1071
+ view,
1072
+ cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
1073
+ (match, v) => {
1074
+ // v4's `^${chr}?${EM_DASH}` tolerated one boundary between the line start
1075
+ // and the em-dash; two or more exceed that slot and block the match.
1076
+ if (boundaryCountAt(v, match.index) > 1) return null
1077
+ const groups = namedGroups<{ after: string }>(matchArgs(match))
1078
+ return `${EM_DASH} ${groups.after}`
1079
+ },
1080
+ )
264
1081
  }
265
1082
 
1083
+ // ---------------------------------------------------------------------------
1084
+ // Public dash pipeline
1085
+ // ---------------------------------------------------------------------------
1086
+
266
1087
  export function hyphenReplace(text: string, options: DashOptions = {}): string {
267
- const sep = options.separator ?? DEFAULT_SEPARATOR
1088
+ const separator = options.separator ?? DEFAULT_SEPARATOR
268
1089
  const style = options.dashStyle ?? "american"
269
1090
  if (style === "none") return text
270
1091
  text = minusReplace(text, options)
271
1092
  text = enDashDateRange(text, options)
272
1093
  text = enDashNumberRange(text, options)
273
- text = convertParentheticalDashes(text, sep, style)
274
- if (style === "american") text = normalizeEmDashSpacing(text, sep)
1094
+ text = withProseView(text, separator, (view) => {
1095
+ convertParentheticalDashes(view, style)
1096
+ if (style === "american") normalizeEmDashSpacing(view)
1097
+ })
275
1098
  return text
276
1099
  }
277
1100
 
@@ -296,3 +1119,8 @@ export function dashWordJoiner(text: string): string {
296
1119
  const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
297
1120
  return text.replace(re, `${WORD_JOINER}$&`)
298
1121
  }
1122
+
1123
+ /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
1124
+ function matchArgs(match: RegExpExecArray): unknown[] {
1125
+ return [...match, match.index, match.input, match.groups]
1126
+ }