punctilio 4.3.0 → 5.0.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.
Files changed (65) hide show
  1. package/README.md +36 -22
  2. package/dist/constants.d.ts +0 -34
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -49
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +1170 -176
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +52 -1
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +179 -17
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -96
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +6 -2
  53. package/src/constants.ts +0 -56
  54. package/src/dashes.ts +1217 -208
  55. package/src/index.ts +71 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +250 -25
  59. package/src/quote-classifier.ts +618 -97
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
package/src/dashes.ts CHANGED
@@ -1,24 +1,20 @@
1
- import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, UNICODE_SYMBOLS, wordBoundaryEnd, wordBoundaryStart } from "./constants.js"
1
+ import { cachedRegExp, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
2
+ import { boundaryCountAt, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } 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
- separator?: string
10
9
  /** "american" (unspaced em), "british" (spaced en), "none". Default: "american" */
11
10
  dashStyle?: DashStyle
12
11
  }
13
12
 
14
- 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
- // 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}`
13
+ const {
14
+ EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS,
15
+ LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE,
16
+ SUPERSCRIPT_ST, SUPERSCRIPT_ND, SUPERSCRIPT_RD, SUPERSCRIPT_TH,
17
+ } = UNICODE_SYMBOLS
22
18
 
23
19
  // Prevents false-positive ranges in model names like "Llama-2-7B".
24
20
  export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS] as const
@@ -38,241 +34,1245 @@ const months: readonly string[] = [
38
34
 
39
35
  const monthPattern = months.join("|")
40
36
 
37
+ const WORD_CHAR_RE = /\w/
38
+ const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
39
+
40
+ /**
41
+ * Glyphs the symbol passes fold word characters into (multiplication `x` →
42
+ * `×`, ordinal suffixes → superscripts). The dash rules run before those
43
+ * passes, so their word-character tests must treat a folded glyph like the
44
+ * word character it folds from — otherwise a range blocked at `6x3` converts
45
+ * once a re-run sees `6×3`.
46
+ */
47
+ const FOLDED_WORD_CHARS = new Set<string>([
48
+ MULTIPLICATION,
49
+ ...SUPERSCRIPT_ST, ...SUPERSCRIPT_ND, ...SUPERSCRIPT_RD, ...SUPERSCRIPT_TH,
50
+ ])
51
+
52
+ /** `\w` extended with {@link FOLDED_WORD_CHARS} for fold-stable `\b` checks. */
53
+ function isWordChar(ch: string | undefined): boolean {
54
+ return ch !== undefined && (WORD_CHAR_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
55
+ }
56
+
57
+ /**
58
+ * Glyphs the math-symbol pass folds two-character operators into (`=~` → `≈`,
59
+ * `!=` → `≠`, ...). The fold tolerates one node boundary at its junction, so
60
+ * it can consume the character that sat between a boundary and a dash.
61
+ */
62
+ const FOLDED_MATH_OPERATORS = new Set<string>([
63
+ UNICODE_SYMBOLS.APPROXIMATE,
64
+ UNICODE_SYMBOLS.GREATER_EQUAL,
65
+ UNICODE_SYMBOLS.LESS_EQUAL,
66
+ UNICODE_SYMBOLS.NOT_EQUAL,
67
+ UNICODE_SYMBOLS.PLUS_MINUS,
68
+ ])
69
+
70
+ /**
71
+ * Glyphs the symbol passes fold from a source ending in a dash char (`<-` →
72
+ * `←`, `+-` → `±`). Where a trailing hyphen blocks a dash rule, its folded
73
+ * glyph must keep blocking, or the rule fires only on the re-run.
74
+ */
75
+ const FOLDED_FROM_TRAILING_DASH = new Set<string>([
76
+ UNICODE_SYMBOLS.ARROW_LEFT,
77
+ UNICODE_SYMBOLS.PLUS_MINUS,
78
+ ])
79
+
80
+ /** Mirror of {@link FOLDED_FROM_TRAILING_DASH} for sources starting with a
81
+ * dash char (`->` → `→`). */
82
+ const FOLDED_FROM_LEADING_DASH = new Set<string>([UNICODE_SYMBOLS.ARROW_RIGHT])
83
+
84
+ /**
85
+ * Runs one boundary-gated regex pass over the view and commits it immediately.
86
+ * Committing after every pass sequences the passes so a later pass sees the
87
+ * earlier edits and never queues an overlapping edit.
88
+ */
89
+ function pass(
90
+ view: ProseView,
91
+ regex: RegExp,
92
+ replacer: (match: RegExpExecArray, view: ProseView) => string | null,
93
+ options?: ReplaceAllOptions,
94
+ ): void {
95
+ replaceAllInView(view, regex, replacer, options)
96
+ view.commit()
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Boundary-tolerance helpers over a ProseView
101
+ //
102
+ // The boundary-tolerance positions throughout this module reproduce the
103
+ // element-boundary semantics of the pre-v5 sentinel-marked pipeline; they are
104
+ // pinned by the golden corpus and the migration's differential fuzz. Over a
105
+ // clean ProseView, `replaceAllInView` skips any match with an interior boundary
106
+ // unless `allowBoundaries` opts in. Each pass below builds an `allowBoundaries`
107
+ // callback that admits a match only when every interior boundary sits at a
108
+ // tolerated slot offset.
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** Interior boundary offsets of a match, strictly inside (matchStart, matchEnd). */
112
+ function interiorBoundaryOffsets(match: RegExpExecArray, view: ProseView): number[] {
113
+ const start = match.index
114
+ const end = match.index + match[0].length
115
+ const offsets: number[] = []
116
+ for (const boundary of view.boundaries) {
117
+ if (boundary > start && boundary < end) offsets.push(boundary)
118
+ else if (boundary >= end) break
119
+ }
120
+ return offsets
121
+ }
122
+
123
+ /**
124
+ * True iff every interior boundary of the match lands on a tolerated offset,
125
+ * with no offset carrying more boundaries than its budget. A tolerated slot
126
+ * admits exactly one boundary; two stacked boundaries (an empty fragment)
127
+ * exceed the single-boundary budget and block the match.
128
+ */
129
+ function interiorBoundariesAllowed(
130
+ match: RegExpExecArray,
131
+ view: ProseView,
132
+ tolerated: Map<number, number>,
133
+ ): boolean {
134
+ const counts = new Map<number, number>()
135
+ for (const offset of interiorBoundaryOffsets(match, view)) {
136
+ counts.set(offset, (counts.get(offset) ?? 0) + 1)
137
+ }
138
+ for (const [offset, count] of counts) {
139
+ const budget = tolerated.get(offset)
140
+ if (budget === undefined || count > budget) return false
141
+ }
142
+ return true
143
+ }
144
+
145
+ /** Separators tolerated by the word-boundary helpers (mirrors constants.ts). */
146
+ const MAX_BOUNDARY_SEPARATORS = 3
147
+
148
+ /**
149
+ * Reproduces `wordBoundaryStart` = `(?<!\w${sep}{0,3})\b` at the marked position
150
+ * `matchStart` (the start char is a word char — a digit). The `\b` requires a
151
+ * non-word char to its left (a separator counts as non-word); the negative
152
+ * lookbehind additionally blocks when a word char sits within
153
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the left.
154
+ */
155
+ function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
156
+ if (matchStart === 0) return true
157
+ const nb = boundaryCountAt(view, matchStart)
158
+ const prevWord = isWordChar(view.text[matchStart - 1])
159
+ if (nb === 0) return !prevWord // `\b` needs a non-word char immediately left
160
+ // A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
161
+ // when a word char is within ≤MAX boundaries.
162
+ return !prevWord || nb > MAX_BOUNDARY_SEPARATORS
163
+ }
164
+
165
+ /**
166
+ * Reproduces `wordBoundaryEnd` = `\b(?!${sep}{0,3}\w)` at the marked position
167
+ * `pos`. The `\b` requires a word/non-word transition (a separator counts as
168
+ * non-word); the negative lookahead blocks when a word char sits within
169
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the right.
170
+ */
171
+ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
172
+ const text = view.text
173
+ // `wordBoundaryEndOk` is only ever called at the end of a digit, letter, or
174
+ // suffix run, so `pos` is always ≥ 1 and `text[pos - 1]` is a real char.
175
+ const leftWord = isWordChar(text[pos - 1])
176
+ // A multiplication sign one space to the right blocks like the word char
177
+ // it folds from: the multiplication pass renders `5X 8` as `5 × 8`, and a
178
+ // range blocked at `3-5X` must stay blocked at `3-5 ×`.
179
+ if (text[pos] !== undefined && SPACE_CHAR_RE.test(text[pos]) && text[pos + 1] === MULTIPLICATION) {
180
+ return false
181
+ }
182
+ const nb = boundaryCountAt(view, pos)
183
+ const nextCleanWord = isWordChar(text[pos])
184
+ // The marked char right after pos is a separator when nb > 0 (non-word).
185
+ const rightWord = nb > 0 ? false : nextCleanWord
186
+ if (leftWord === rightWord) return false // no `\b`
187
+ // `(?!${sep}{0,3}\w)`: a word char within ≤3 boundaries to the right blocks.
188
+ return !(nb <= MAX_BOUNDARY_SEPARATORS && nextCleanWord)
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Number ranges
193
+ // ---------------------------------------------------------------------------
194
+
195
+ const CURRENCY_RE = /[$€£¥₹]/
196
+
41
197
  /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
42
- 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
198
+ export function enDashNumberRange(input: string): string
199
+ export function enDashNumberRange(input: ProseView): void
200
+ export function enDashNumberRange(input: string | ProseView): string | void {
201
+ return overInput(input, (view) => {
202
+ convertPositiveRanges(view)
203
+ convertNegativeRanges(view)
204
+ })
205
+ }
206
+
207
+ /**
208
+ * The positive-range shape is `phoneAreaCode? \b (?:p\.?|[currency])?\d[\d.,]*
209
+ * -{1,3} [currency]?\d[\d.,]* (?:[-−]\d+)* (?:[AaPp][Mm]|[xKBTMCF])? \b`, scanned
210
+ * left to right. The consume/advance behaviour determines which sub-ranges a
211
+ * later pass sees. The scan below realizes it: at each offset it attempts the
212
+ * structural match (boundary-aware), consuming its span on success (whether or
213
+ * not the dash converts) and advancing one character otherwise. So a multi-segment
214
+ * number whose head fails to form a structural match (e.g. boundary-broken
215
+ * "2024-01-15") still exposes a later sub-range ("01-15"), while a structurally
216
+ * matching but non-converting number hides its inner dashes.
217
+ */
218
+ function convertPositiveRanges(view: ProseView): void {
219
+ const text = view.text
220
+ let i = 0
221
+ while (i < text.length) {
222
+ const span = matchPositiveRangeAt(view, i)
223
+ i = span === null ? i + 1 : Math.max(span, i + 1)
224
+ }
225
+ view.commit()
226
+ }
227
+
228
+ /**
229
+ * Attempts the `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
230
+ * \b` structural match beginning at clean offset `i`, converting the dash when
231
+ * the gates pass. Returns the consumed end offset on a structural match (so the
232
+ * scan skips it), or null when no structural match begins at `i`.
233
+ */
234
+ function matchPositiveRangeAt(view: ProseView, i: number): number | null {
235
+ // Optional phoneAreaCode (`\d{3}-` or `(\d{3}) `); when it ends in a dash that
236
+ // dash can instead be the range dash, so both interpretations are tried.
237
+ // `rangeStart` carries no leading slot, so a boundary right where it would
238
+ // begin (just past the area code) invalidates the area-code reading: fall back
239
+ // to treating the area-code digits as the range start.
240
+ const areaCode = readPhoneAreaCode(view, i)
241
+ const startStarts = areaCode === null || view.hasBoundary(i + areaCode) ? [i] : [i + areaCode, i]
242
+ for (const startStart of startStarts) {
243
+ const span = matchRangeBody(view, startStart)
244
+ if (span !== null) return span
245
+ }
246
+ return null
247
+ }
248
+
249
+ const THREE_DIGITS_RE = /^\d\d\d/
250
+ const THREE_DIGITS_IN_PARENS_RE = /^\(\d\d\d\)/
251
+
252
+ /** `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
253
+ function readPhoneAreaCode(view: ProseView, i: number): number | null {
254
+ const text = view.text
255
+ // `\d{3}-` with no boundary inside the digits or before the dash.
256
+ if (THREE_DIGITS_RE.test(text.slice(i, i + 3)) && text[i + 3] === "-"
257
+ && !view.hasBoundary(i + 1) && !view.hasBoundary(i + 2) && !view.hasBoundary(i + 3)) {
258
+ return 4
259
+ }
260
+ // `(\d{3})` with an optional trailing space.
261
+ if (THREE_DIGITS_IN_PARENS_RE.test(text.slice(i, i + 5))) {
262
+ return text[i + 5] === " " ? 6 : 5
263
+ }
264
+ return null
265
+ }
266
+
267
+ /**
268
+ * Matches `\b rangeStart -{1,3} rangeEnd following* suffix? \b` boundary-aware,
269
+ * beginning the start at `startStart`. Converts the dash on a converting match.
270
+ * Returns the consumed end offset (structural span end) or null.
271
+ */
272
+ function matchRangeBody(view: ProseView, startStart: number): number | null {
273
+ const text = view.text
274
+ // rangeStart = `(?:p\.?|[currency])?\d[\d.,]*`, anchored at a `\b`.
275
+ const start = readStartRun(view, startStart)
276
+ if (start === null) return null
277
+ const startSpanEnd = startStart + start.length
278
+ // One slot between start and the dash tolerates a single boundary there.
279
+ if (boundaryCountAt(view, startSpanEnd) > 1) return null
280
+ // Dash run of 1..3 hyphens that never spans an interior boundary (a boundary
281
+ // between two hyphens truncates the run).
282
+ if (text[startSpanEnd] !== "-") return null
283
+ let dashEnd = startSpanEnd + 1
284
+ while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd)) dashEnd++
285
+ if (boundaryCountAt(view, dashEnd) > 1) return null
286
+
287
+ // `readStartRun` already stops at the first interior boundary, so the start
288
+ // span is boundary-free and its clean digits are the whole run.
289
+ const startDigits = text.slice(startStart, startSpanEnd)
290
+ if (!wordBoundaryStartOk(view, startStart)) return null
291
+ if (!notAfterDashOk(view, startStart)) return null
292
+
293
+ const end = readEndRun(view, dashEnd)
294
+ if (end === null) return null
295
+ // The greedy end `[\d.,]*` and greedy `following*` backtrack together; the
296
+ // structural match commits at the longest end whose tail completes.
297
+ for (let len = end.length; len >= end.minLen; len--) {
298
+ const endPos = end.firstDigitStart + len
299
+ const tail = resolvePositiveTail(view, endPos)
300
+ if (tail === null) continue
301
+ const endStr = text.slice(dashEnd, endPos)
302
+ if (tail.followingEmpty && rangeNumbersConvert(startDigits, endStr)) {
303
+ view.replace(startSpanEnd, dashEnd, EN_DASH)
304
+ }
305
+ return tail.consumedEnd
306
+ }
307
+ return null
308
+ }
309
+
310
+ /**
311
+ * The `(?:p\.?|[currency])?\d[\d.,]*` start run, boundary-truncated. The `wb`
312
+ * (`(?<!\w)\b`) anchors the start at a word boundary, so the currency
313
+ * alternative is unreachable: a leading currency symbol is non-word, and the only
314
+ * `\b` sits after it, at the digit — so the currency is never part of `start`
315
+ * (e.g. "$100-2000" reads start "100", which the 3+4 phone gate then blocks). The
316
+ * `p`/`p.` prefix is a word char, so it can begin the run at a word boundary.
317
+ */
318
+ function readStartRun(view: ProseView, pos: number): { length: number } | null {
319
+ const text = view.text
320
+ let i = pos
321
+ if (text[i] === "p") {
322
+ // `p\.?\d` is contiguous (no interior slot), so a boundary between `p`, an
323
+ // optional `.`, and the first digit breaks the prefix.
324
+ if (view.hasBoundary(i + 1)) return null
325
+ i++
326
+ if (text[i] === ".") {
327
+ if (view.hasBoundary(i + 1)) return null
328
+ i++
329
+ }
330
+ }
331
+ // `\d` is required after the optional `p\.?` prefix. The prefix chars are
332
+ // never digits, so a missing digit here means no start run begins.
333
+ if (!/\d/.test(text[i] ?? "")) return null
334
+ i++
335
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
336
+ return { length: i - pos }
337
+ }
338
+
339
+ interface EndRun {
340
+ /** Clean offset of the first end digit (past an optional currency symbol). */
341
+ firstDigitStart: number
342
+ /** Length of the `[\d.,]` run from `firstDigitStart`, boundary-truncated. */
343
+ length: number
344
+ /** Minimum end length: 1 digit (`\d` is required). */
345
+ minLen: number
346
+ }
347
+
348
+ /** The `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
349
+ function readEndRun(view: ProseView, pos: number): EndRun | null {
350
+ const text = view.text
351
+ let i = pos
352
+ if (CURRENCY_RE.test(text[i] ?? "")) {
353
+ // No slot between the currency symbol and the digit.
354
+ if (view.hasBoundary(i + 1)) return null
355
+ i++
356
+ }
357
+ if (!/\d/.test(text[i] ?? "")) return null
358
+ const firstDigitStart = i
359
+ i++
360
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
361
+ return { firstDigitStart, length: i - firstDigitStart, minLen: 1 }
362
+ }
363
+
364
+ interface ResolvedTail {
365
+ followingEmpty: boolean
366
+ /** End offset of the whole structural tail (following + optional suffix). */
367
+ consumedEnd: number
368
+ }
369
+
370
+ /**
371
+ * Resolves the tail at `endPos`: finds the longest `following` whose
372
+ * `suffix? wbe` completes (the greedy choice). `followingEmpty` reports whether
373
+ * that following is empty (the convert condition); `consumedEnd` is the tail's
374
+ * end including any suffix (for the scan's consume/advance). Returns null when
375
+ * no following completes.
376
+ */
377
+ function resolvePositiveTail(view: ProseView, endPos: number): ResolvedTail | null {
378
+ const candidates = followingCandidates(view, endPos, true)
379
+ for (let i = candidates.length - 1; i >= 0; i--) {
380
+ const suffixEnd = suffixWbeEnd(view, candidates[i], true)
381
+ if (suffixEnd !== null) {
382
+ return { followingEmpty: i === 0, consumedEnd: suffixEnd }
383
+ }
384
+ }
385
+ return null
386
+ }
387
+
388
+
389
+ /**
390
+ * Negative ranges: −5-5 → −5–5, −5--2 → −5–−2. A separate hand scan (not a regex)
391
+ * because the end run (`\d[\d.,]*`, with one tolerated boundary at its head)
392
+ * must truncate at node boundaries before its tail is resolved: a regex would
393
+ * fuse boundary-split digits into one run (e.g. "8{b}{b}{b}{b}{b}0" reads as
394
+ * "80") and mis-place or miss the conversion. The scan mirrors `matchRangeBody`:
395
+ * at each MINUS-led start it resolves the longest end whose `following* suffix?
396
+ * wbe` tail completes, converting only when that tail leaves `following` empty.
397
+ */
398
+ function convertNegativeRanges(view: ProseView): void {
399
+ const text = view.text
400
+ let i = 0
401
+ while (i < text.length) {
402
+ if (text[i] === MINUS) {
403
+ const span = matchNegativeRangeAt(view, i)
404
+ if (span !== null) {
405
+ i = Math.max(span, i + 1)
406
+ continue
87
407
  }
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
408
  }
103
- )
409
+ i++
410
+ }
411
+ view.commit()
412
+ }
104
413
 
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 ?? ""}`
414
+ /**
415
+ * Attempts `(?<![LAT]) ${MINUS}\d[\d.,]* -{1,3} -? end following* suffix? wbe`
416
+ * beginning the MINUS at clean offset `i`. Converts the dash run when the gates
417
+ * pass. Returns the consumed structural end offset, or null when no match begins.
418
+ */
419
+ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
420
+ const text = view.text
421
+ // `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
422
+ // boundary, which would hide it) blocks the match.
423
+ if (boundaryCountAt(view, i) === 0) {
424
+ const before = text[i - 1]
425
+ if (before !== undefined && LATIN_LETTER_RE.test(before)) return null
426
+ }
427
+
428
+ // start = `${MINUS}\d[\d.,]*`: the digits after MINUS must not cross a
429
+ // boundary (the `start` body has no interior slot before its trailing one).
430
+ if (!/\d/.test(text[i + 1] ?? "")) return null
431
+ let startEnd = i + 1
432
+ while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd)) startEnd++
433
+ // One slot between start and the dash tolerates a single boundary there.
434
+ if (boundaryCountAt(view, startEnd) > 1) return null
435
+ if (text[startEnd] !== "-") return null
436
+
437
+ // `-{1,3}?` then optional `(?<neg>-)`: a 1..3 hyphen run with no interior
438
+ // boundary, the last of which becomes `neg` when two or more hyphens are present.
439
+ let dashRunEnd = startEnd + 1
440
+ while (text[dashRunEnd] === "-" && dashRunEnd - startEnd < 3 && !view.hasBoundary(dashRunEnd)) dashRunEnd++
441
+ const dashRunLen = dashRunEnd - startEnd
442
+ const hasNeg = dashRunLen >= 2
443
+ const negEnd = dashRunEnd
444
+ // The `end` head slot tolerates one boundary.
445
+ if (boundaryCountAt(view, negEnd) > 1) return null
446
+
447
+ const end = readNegEndRun(view, negEnd)
448
+ if (end === null) return null
449
+ // The greedy end and `following*` backtrack together; commit at the longest end
450
+ // whose tail completes, converting only when that tail's `following` is empty.
451
+ for (let len = end.length; len >= 1; len--) {
452
+ const endPos = end.firstDigitStart + len
453
+ const candidates = followingCandidates(view, endPos, false)
454
+ for (let c = candidates.length - 1; c >= 0; c--) {
455
+ const suffixEnd = suffixWbeEnd(view, candidates[c], false)
456
+ if (suffixEnd === null) continue
457
+ if (c === 0) {
458
+ view.replace(startEnd, negEnd, `${EN_DASH}${hasNeg ? MINUS : ""}`)
459
+ }
460
+ return suffixEnd
122
461
  }
123
- )
462
+ }
463
+ return null
464
+ }
465
+
466
+ interface NegEndRun {
467
+ firstDigitStart: number
468
+ length: number
469
+ }
470
+
471
+ /** The negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
472
+ function readNegEndRun(view: ProseView, pos: number): NegEndRun | null {
473
+ const text = view.text
474
+ if (!/\d/.test(text[pos] ?? "")) return null
475
+ let i = pos + 1
476
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
477
+ return { firstDigitStart: pos, length: i - pos }
478
+ }
479
+
480
+ /**
481
+ * Boundary-aware `following` end positions, shortest (empty) first, walking
482
+ * segments `[-(−)]\d+`. Each segment tolerates one boundary on each side of its
483
+ * dash, and each `\d+` stops at a boundary. `allowMinus` admits a MINUS as a
484
+ * segment's dash (positive ranges).
485
+ */
486
+ function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: boolean): number[] {
487
+ const candidates = [endSpanEnd]
488
+ let pos = endSpanEnd
489
+ for (;;) {
490
+ if (boundaryCountAt(view, pos) > 1) break
491
+ const dashCh = view.text[pos]
492
+ if (dashCh !== "-" && !(allowMinus && dashCh === MINUS)) break
493
+ const digitStart = pos + 1
494
+ if (boundaryCountAt(view, digitStart) > 1) break
495
+ if (!/\d/.test(view.text[digitStart] ?? "")) break
496
+ let end = digitStart + 1
497
+ while (/\d/.test(view.text[end] ?? "") && !view.hasBoundary(end)) end++
498
+ pos = end
499
+ candidates.push(pos)
500
+ }
501
+ return candidates
502
+ }
503
+
504
+ /**
505
+ * Single-letter range suffixes: the multipliers `x`/`K`/`B`/`T`/`M` and the
506
+ * temperature units `C`/`F`. The degree pass rewrites `5C` to `5 °C` — a
507
+ * space a re-run's word boundary sees through — so the range must already
508
+ * convert with the unit attached. `X` and `×` are deliberately absent: an
509
+ * unconverted `1-10X` folds to `1-10×`, which the word-boundary check (which
510
+ * treats `×` as the word char it folds from) keeps blocking on re-runs.
511
+ */
512
+ const RANGE_SUFFIX_RE = /[xKBTMCF]/
513
+
514
+ /**
515
+ * The `(?:[AaPp][Mm]|[xKBTMCF])?${wbe}` suffix at `pos`, with one tolerated
516
+ * boundary before the suffix. Returns the end offset after the optional suffix
517
+ * when the tail completes (wbe passes), or null when it does not.
518
+ */
519
+ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number | null {
520
+ if (wordBoundaryEndOk(view, pos)) return pos
521
+ // An optional suffix: one tolerated boundary then am/pm or one of
522
+ // {@link RANGE_SUFFIX_RE}'s letters, followed by wbe. The suffix letters sit
523
+ // at the clean `pos`.
524
+ if (boundaryCountAt(view, pos) > 1) return null
525
+ const text = view.text
526
+ // `[AaPp][Mm]` is contiguous — a boundary between the two letters breaks the
527
+ // suffix.
528
+ if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
529
+ return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null
530
+ }
531
+ if (RANGE_SUFFIX_RE.test(text[pos] ?? "")) {
532
+ return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null
533
+ }
534
+ return null
535
+ }
536
+
537
+ const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`)
538
+
539
+ const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
540
+
541
+ /**
542
+ * Offset of the dot reachable backwards across one inter-dot gap ending at the
543
+ * dot at `dotIndex` (the mirror of the ellipsis pass's forward gap rule: the
544
+ * gap is nothing, one space character, or one node boundary). -1 when none.
545
+ */
546
+ function ellipsisPrevDot(view: ProseView, dotIndex: number): number {
547
+ const text = view.text
548
+ if (text[dotIndex - 1] === ".") {
549
+ // Empty gap: at most one boundary between the two dots.
550
+ return boundaryCountAt(view, dotIndex) <= 1 ? dotIndex - 1 : -1
551
+ }
552
+ // Space gap: one space character with no boundary on either side of it.
553
+ if (boundaryCountAt(view, dotIndex) === 0 && dotIndex >= 2 && SPACE_CHAR_RE.test(text[dotIndex - 1])
554
+ && text[dotIndex - 2] === "." && boundaryCountAt(view, dotIndex - 1) === 0) {
555
+ return dotIndex - 2
556
+ }
557
+ return -1
558
+ }
559
+
560
+ /** True iff the dot at `dotIndex` can close a `...`/`. . .` triple. */
561
+ function endsFoldableEllipsisDots(view: ProseView, dotIndex: number): boolean {
562
+ const second = ellipsisPrevDot(view, dotIndex)
563
+ return second >= 0 && ellipsisPrevDot(view, second) >= 0
564
+ }
565
+
566
+ /**
567
+ * `notAfterDash` is a single-char negative lookbehind. A node boundary
568
+ * immediately before the start hides the clean char (a boundary is not the
569
+ * dash/letter), so the guard only fires when a disallowed clean char sits
570
+ * directly before the start with no intervening boundary.
571
+ *
572
+ * A `.` that closes a dot triple does not block: the ellipsis pass (which
573
+ * runs after the dash rules) folds the triple to `…` and pads a following
574
+ * digit with a space, so a re-run would see a plain space here and convert —
575
+ * converting now reaches that fixed point in one pass. A line-leading em dash
576
+ * does not block for the same reason: the em-dash spacing normalizer pads it
577
+ * before a digit (`^—5` → `— 5`), mirroring that rule's one tolerated
578
+ * boundary at line start.
579
+ */
580
+ function notAfterDashOk(view: ProseView, startOffset: number): boolean {
581
+ if (startOffset === 0) return true
582
+ if (boundaryCountAt(view, startOffset) > 0) return true
583
+ const prev = view.text[startOffset - 1]
584
+ // A multiplication sign one space slot further left blocks like the `x` it
585
+ // folds from: the multiplication pass renders `5x8` as `5 × 8`, so a range
586
+ // blocked at `x8-6` must stay blocked at `× 8-6`.
587
+ if (prev !== undefined && SPACE_CHAR_RE.test(prev) && view.text[startOffset - 2] === MULTIPLICATION) {
588
+ return false
589
+ }
590
+ if (prev === undefined || !NOT_AFTER_DASH_RE.test(prev)) return true
591
+ if (prev === "." && endsFoldableEllipsisDots(view, startOffset - 1)) return true
592
+ const dashIndex = startOffset - 1
593
+ if (prev === EM_DASH && boundaryCountAt(view, dashIndex) <= 1
594
+ && (dashIndex === 0 || view.text[dashIndex - 1] === "\n" || emDashBehindLeadingSpaces(view, dashIndex))) {
595
+ return true
596
+ }
597
+ // An en dash behind leading spaces converts the same way: the spaced-dash
598
+ // rule renders it as a line-leading em dash, which the normalizer then pads.
599
+ return prev === EN_DASH && boundaryCountAt(view, dashIndex) <= 1
600
+ && emDashBehindLeadingSpaces(view, dashIndex)
601
+ }
124
602
 
125
- return text
603
+ /**
604
+ * True iff only plain spaces sit between text start and the em dash at
605
+ * `emIndex`. The spaced-dash rule collapses that run, leaving the em dash
606
+ * line-leading, which the spacing normalizer then pads before a digit — the
607
+ * same state the `\n` arm of {@link notAfterDashOk} accepts.
608
+ */
609
+ function emDashBehindLeadingSpaces(view: ProseView, emIndex: number): boolean {
610
+ let j = emIndex
611
+ while (j > 0 && view.text[j - 1] === " ") j--
612
+ return j < emIndex && j === 0
126
613
  }
127
614
 
615
+ /** Range-number gates: reject year-month, phone-shaped, and toll-free pairs. */
616
+ function rangeNumbersConvert(startNum: string, endNum: string): boolean {
617
+ if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum)) return false
618
+ // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
619
+ // 555-123-4567). Thousands-grouped endings (1,234 / 1.234) keep their
620
+ // internal separator and so fail /^\d{4}$/, falling through to convert as
621
+ // ranges.
622
+ if (/^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum)) return false
623
+ // Skip US toll-free prefix pattern: 1-800, 1-888, 1-877, etc. Only the seven
624
+ // real toll-free codes (800, 888, 877, 866, 855, 844, 833) are blocked, so
625
+ // genuine ranges like 1-850 or 1-810 still en-dash.
626
+ if (/^1$/.test(startNum) && /^8(?:00|88|77|66|55|44|33)$/.test(endNum)) return false
627
+ return true
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Date ranges
632
+ // ---------------------------------------------------------------------------
633
+
128
634
  /** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
129
- export function enDashDateRange(text: string, options: DashOptions = {}): string {
130
- const dashStyle = options.dashStyle ?? "american"
131
- 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) => {
635
+ export function enDashDateRange(input: string, options?: DashOptions): string
636
+ export function enDashDateRange(input: ProseView, options?: DashOptions): void
637
+ export function enDashDateRange(input: string | ProseView, options: DashOptions = {}): string | void {
638
+ return overInput(input, (view) => enDashDateRangeOverView(view, options.dashStyle ?? "american"))
639
+ }
640
+
641
+ function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
642
+ if (dashStyle === "none") return
643
+ const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
644
+
645
+ // Atomic-optional year groups (lookahead + backref). The capture inside the
646
+ // lookahead is locked in once matched, so the year group commits without
647
+ // backtracking. The year space also matches NBSP/NNBSP: the nbsp pass (which
648
+ // runs after the dash rules) glues "March 2025" with an NBSP, and the year
649
+ // must still capture on a re-run so the trailing-boundary gate sees the same
650
+ // end position.
651
+ const startYear = `(?=(?<startYear>[ ${NBSP_CHARS}]\\d{4})?)\\k<startYear>`
652
+ const endYear = `(?=(?<endYear>[ ${NBSP_CHARS}]\\d{4})?)\\k<endYear>`
653
+ const pattern = `\\b(?<startMonth>${monthPattern})${startYear}(?<preSpace> ?)-(?<postSpace> ?)(?<endMonth>${monthPattern})${endYear}\\b`
654
+ pass(
655
+ view,
656
+ cachedRegExp(pattern, "g"),
657
+ (match, v) => {
658
+ // The pattern's trailing `\b` sees a folded glyph (`×`, a superscript
659
+ // ordinal) as a non-word char, but the source character it folds from is
660
+ // a word char that blocks the match ("March 2025x3"); re-check the end
661
+ // boundary fold-stably.
662
+ if (!wordBoundaryEndOk(v, match.index + match[0].length)) return null
144
663
  const groups = namedGroups<{
145
664
  startMonth: string
146
665
  startYear?: string
147
- preSep: string
666
+ preSpace: string
667
+ postSpace: string
148
668
  endMonth: string
149
669
  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
- }
670
+ }>(matchArgs(match))
671
+ // Replace `[preSpace]-[postSpace]` (the dash and its spaces) with the
672
+ // styled en-dash, leaving the months and any boundaries around them.
673
+ const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
674
+ const dashSpan = groups.preSpace.length + 1 + groups.postSpace.length
675
+ const dashStart = match.index + startLen
676
+ v.replace(dashStart, dashStart + dashSpan, `${pre}${EN_DASH}${post}`)
677
+ return null
678
+ },
679
+ {
680
+ allowBoundaries: (match, v) => {
681
+ const groups = namedGroups<{
682
+ startMonth: string
683
+ startYear?: string
684
+ preSpace: string
685
+ postSpace: string
686
+ endMonth: string
687
+ endYear?: string
688
+ }>(matchArgs(match))
689
+ const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
690
+ // One boundary is tolerated after the start year and one before the end
691
+ // month, bracketing the dash and spaces.
692
+ const dashStart = match.index + startLen
693
+ const dashEnd = dashStart + groups.preSpace.length + 1 + groups.postSpace.length
694
+ const tolerated = new Map<number, number>()
695
+ tolerated.set(dashStart, 1)
696
+ tolerated.set(dashEnd, 1)
697
+ if (!interiorBoundariesAllowed(match, v, tolerated)) return false
698
+ return wordBoundaryStartOk(v, match.index) && wordBoundaryEndOk(v, match.index + match[0].length)
699
+ },
700
+ },
155
701
  )
156
702
  }
157
703
 
704
+ // ---------------------------------------------------------------------------
705
+ // Minus signs
706
+ // ---------------------------------------------------------------------------
707
+
158
708
  /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
159
- export function minusReplace(text: string, options: DashOptions = {}): string {
160
- const chr = getEscapedSeparator(options)
161
-
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>`
167
- )
709
+ export function minusReplace(input: string): string
710
+ export function minusReplace(input: ProseView): void
711
+ export function minusReplace(input: string | ProseView): string | void {
712
+ return overInput(input, (view) => {
713
+ minusSubtractionOfNegative(view)
714
+ minusSpacedSubtraction(view)
715
+ minusDirectNegative(view)
716
+ })
717
+ }
168
718
 
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>`
719
+ /** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
720
+ function minusSubtractionOfNegative(view: ProseView): void {
721
+ // "5 - -3" → "5 − −3"; the negative hyphen is consumed first. The slot before
722
+ // `num` tolerates one boundary there (the `- -` prefix has 4 chars).
723
+ pass(
724
+ view,
725
+ cachedRegExp(`(?<=\\d) - -(?<num>\\d*\\.?\\d+)`, "g"),
726
+ (match, v) => {
727
+ // Replace only the ` - -` prefix; `num` (which may carry interior
728
+ // boundaries that truncate it) stays untouched.
729
+ if (!minusSubtractionPrefixOk(match, v, 4)) return null
730
+ v.replace(match.index, match.index + 4, ` ${MINUS} ${MINUS}`)
731
+ return null
732
+ },
733
+ { allowBoundaries: () => true },
174
734
  )
735
+ }
175
736
 
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>`
737
+ function minusSpacedSubtraction(view: ProseView): void {
738
+ // "5 - 3" → "5 − 3"; only after a digit ("5 - 3" not "Safari) - 9").
739
+ pass(
740
+ view,
741
+ cachedRegExp(`(?<=\\d) - (?<num>\\d*\\.?\\d+)`, "g"),
742
+ (match, v) => {
743
+ if (!minusSubtractionPrefixOk(match, v, 3)) return null
744
+ v.replace(match.index, match.index + 3, ` ${MINUS} `)
745
+ return null
746
+ },
747
+ { allowBoundaries: () => true },
182
748
  )
749
+ }
183
750
 
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
- )
751
+ const NUM_BODY_RE = /^\d*\.?\d+/
191
752
 
192
- return text
753
+ /**
754
+ * The subtraction patterns tolerate one boundary after the leading digit (at
755
+ * the match start) and one before `num`. The `-` prefix span ([match.index,
756
+ * match.index + prefixLen)) must stay boundary-free; one boundary may sit at
757
+ * `num`'s head. The `num` body (`\d*\.?\d+`) carries no interior slot, so it
758
+ * stops at the first interior boundary: a boundary that truncates `num` to a
759
+ * still-valid number (e.g. "12" in "5 - 12{b}5") leaves the match standing, but
760
+ * one that strips the mandatory `\d+` (e.g. "." in "5 - .{b}5") breaks it. The
761
+ * targeted edit only rewrites the ` - ` prefix, so a converting match must keep
762
+ * `num` satisfiable.
763
+ */
764
+ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefixLen: number): boolean {
765
+ // The slot in the lookbehind (`(?<=\d)`) sits at the match start between the
766
+ // leading digit and the space; it tolerates at most one boundary.
767
+ if (boundaryCountAt(view, match.index) > 1) return false
768
+ const numStart = match.index + prefixLen
769
+ for (const b of view.boundaries) {
770
+ if (b > match.index && b < numStart) return false
771
+ }
772
+ // The slot before `num` tolerates at most one boundary at the num head.
773
+ if (boundaryCountAt(view, numStart) > 1) return false
774
+ // `num` stops at the first interior boundary; the clean run up to it must
775
+ // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
776
+ const numEnd = match.index + match[0].length
777
+ let truncatedEnd = numEnd
778
+ for (const b of view.boundaries) {
779
+ if (b > numStart && b < numEnd) { truncatedEnd = b; break }
780
+ if (b >= numEnd) break
781
+ }
782
+ return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd))
193
783
  }
194
784
 
195
- function convertParentheticalDashes(text: string, sep: string, style: DashStyle): string {
785
+ const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`)
786
+ // The folded word glyphs block like the word chars they fold from ("1x|-0"
787
+ // and "1×|-0" must agree), the folded math operators block like the operator
788
+ // chars they fold from (the `!=` → `≠` fold consumes the `=` that blocked
789
+ // Pattern 2 in "!=-1"), and the folded terminals — plus the bare `!` the
790
+ // `!!` normalization leaves — block like the `!`/`?`/`.` runs they fold from
791
+ // ("?|!-0" folds to "⁈|-0" with the hyphen stranded boundary-adjacent).
792
+ const FOLDED_WORD_CLASS_FRAGMENT = [...FOLDED_WORD_CHARS].join("")
793
+ const FOLDED_MATH_CLASS_FRAGMENT = [...FOLDED_MATH_OPERATORS].join("")
794
+ const FOLDED_TERMINAL_CLASS_FRAGMENT = [
795
+ UNICODE_SYMBOLS.ELLIPSIS, UNICODE_SYMBOLS.DOUBLE_QUESTION, UNICODE_SYMBOLS.QUESTION_EXCLAMATION,
796
+ UNICODE_SYMBOLS.EXCLAMATION_QUESTION, UNICODE_SYMBOLS.DOUBLE_EXCLAMATION, UNICODE_SYMBOLS.INTERROBANG, "!",
797
+ ].join("")
798
+ const NUM_BEFORE_2B_BLOCK_RE = new RegExp(`[\\d.,${LATIN_LETTERS}${FOLDED_WORD_CLASS_FRAGMENT}${FOLDED_MATH_CLASS_FRAGMENT}${FOLDED_TERMINAL_CLASS_FRAGMENT}]`)
799
+
800
+ /**
801
+ * Direct negative numbers, in two cases: Pattern 2 converts `-\d` after line
802
+ * start, whitespace, `(`, or a quote; Pattern 2b converts a boundary-led hyphen
803
+ * `-\d` when no digit/`.`/`,`/Latin precedes the boundary — which prevents
804
+ * `1{b}-2` and `GPT{b}-3` from reading as negatives. Pattern 2 fires when the
805
+ * clean char before the hyphen qualifies and no boundary intrudes; Pattern 2b
806
+ * fires when a boundary leads the hyphen.
807
+ */
808
+ function minusDirectNegative(view: ProseView): void {
809
+ pass(
810
+ view,
811
+ cachedRegExp(`-(?<num>\\d*\\.?\\d+)`, "gm"),
812
+ (match, v) => {
813
+ const start = match.index
814
+ // `num` (`\d*\.?\d+`) carries no interior slot, so it stops at the first
815
+ // interior boundary; the clean run up to it must still satisfy
816
+ // `\d*\.?\d+`. A boundary right after the hyphen (head) leaves no digits
817
+ // ("-{b}5"), and one that strips the mandatory `\d+` ("-.{b}1") breaks the
818
+ // match, but one past a valid number ("-5{b}5") leaves it intact.
819
+ const numEnd = start + match[0].length
820
+ let numTruncEnd = numEnd
821
+ for (const b of v.boundaries) {
822
+ if (b > start && b < numEnd) { numTruncEnd = b; break }
823
+ if (b >= numEnd) break
824
+ }
825
+ if (!NUM_BODY_RE.test(v.text.slice(start + 1, numTruncEnd))) return null
826
+ // A boundary directly before the hyphen routes to Pattern 2b; otherwise
827
+ // Pattern 2 examines the clean preceding char. (The two never overlap: a
828
+ // hyphen has either a boundary or a clean char immediately before it.)
829
+ const nb = boundaryCountAt(v, start)
830
+ if (nb > 0) {
831
+ // Pattern 2b: the char before the boundary run must not be a digit,
832
+ // `.`, `,`, or Latin letter. With ≥2 stacked boundaries that char is
833
+ // another boundary (admitted); with one, it is the clean char left.
834
+ if (nb < 2) {
835
+ const before = v.text[start - 1]
836
+ if (before !== undefined && NUM_BEFORE_2B_BLOCK_RE.test(before)) return null
837
+ }
838
+ } else {
839
+ // Pattern 2: line start, whitespace, `(`, or a quote immediately left.
840
+ const before = v.text[start - 1]
841
+ const atLineStart = before === undefined || before === "\n"
842
+ if (!atLineStart && !MINUS_BEFORE_CLASS_RE.test(before)) return null
843
+ }
844
+ v.replace(start, start + 1, MINUS)
845
+ return null
846
+ },
847
+ { allowBoundaries: () => true },
848
+ )
849
+ }
850
+
851
+ // ---------------------------------------------------------------------------
852
+ // Parenthetical dashes
853
+ // ---------------------------------------------------------------------------
854
+
855
+ function convertParentheticalDashes(view: ProseView, style: DashStyle): void {
196
856
  const localizedDash = style === "british" ? EN_DASH : EM_DASH
197
857
  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>`
858
+ const rendered = `${maybeSpace}${localizedDash}${maybeSpace}`
859
+
860
+ convertSpacedDashes(view, rendered)
861
+ convertBoundaryLedDashes(view, rendered)
862
+ convertMultipleDashes(view, rendered)
863
+ convertLeadingLineDashes(view, localizedDash)
864
+ if (style === "british") convertUnspacedEmDashes(view, rendered)
865
+ }
866
+
867
+ const QUOTE_CHARS = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
868
+
869
+ const DASH_RUN_CHARS = new Set(["-", EN_DASH, EM_DASH])
870
+
871
+ function isDashChar(ch: string | undefined): boolean {
872
+ return ch !== undefined && DASH_RUN_CHARS.has(ch)
873
+ }
874
+
875
+ /**
876
+ * Spaced dashes: "word - word" / "word — word". A single plain hyphen directly
877
+ * before a word char (the suspended/hanging hyphen, "Yes-men and -women") is
878
+ * skipped. The arrow guard rejects shapes like "- >" so `foo -> bar` stays.
879
+ *
880
+ * The dash core (`[${EN}${EM}][-${EN}${EM}]*|-{2,}|-(?![LAT\d])`) never spans a
881
+ * boundary, so a node boundary inside the dash run truncates the run — the
882
+ * boundary becomes the trailing slot and any dashes past it are left untouched.
883
+ * The replacer realizes this by truncating the run at the first interior
884
+ * boundary and re-validating the dash core on the truncated form.
885
+ */
886
+ function convertSpacedDashes(view: ProseView, rendered: string): void {
887
+ // The `(?<=[^\s]|^)` lookbehind is validated in the replacer instead, since a
888
+ // boundary (non-space) also satisfies it, letting a match begin even when the
889
+ // clean char to the left is whitespace. The cheap `(?<![ ])` anchor keeps each
890
+ // `[ ]+` run starting at its leftmost space, avoiding quadratic backtracking.
891
+ const pattern = `(?<![ ])[ ]+(?<dashStart>[-${EN_DASH}${EM_DASH}])`
892
+ // The full shape consumes the trailing `[ ]*`, advancing past it; this pattern
893
+ // does not, so a later match could begin inside a previous match's consumed
894
+ // trailing spaces. Track the last consumed offset and skip.
895
+ let consumedThrough = -1
896
+ pass(
897
+ view,
898
+ cachedRegExp(pattern, "g"),
899
+ (match, v) => {
900
+ const text = v.text
901
+ const groups = namedGroups<{ dashStart: string }>(matchArgs(match))
902
+ const firstDash = match.index + match[0].length - groups.dashStart.length
903
+ // The greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
904
+ // holds: line/text start, a non-whitespace clean char, or a boundary. A
905
+ // boundary inside the leading spaces restarts the match just after it (the
906
+ // spaces before it stay with the previous node). The previous match's
907
+ // consumed span is also a floor: leading spaces it already consumed are not
908
+ // re-used.
909
+ let matchStart = Math.max(match.index, consumedThrough)
910
+ for (const b of view.boundaries) {
911
+ if (b > matchStart && b <= firstDash - 1) matchStart = b
912
+ else if (b > firstDash) break
913
+ }
914
+ /* istanbul ignore if -- defensive: the previous match's trailing `(?=\S|$)`
915
+ stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
916
+ if (matchStart >= firstDash + 1) return null
917
+ // Validate the lookbehind at matchStart: start of text/line, a boundary
918
+ // immediately left, or a non-whitespace clean char.
919
+ const leftOk = matchStart === 0
920
+ || view.hasBoundary(matchStart)
921
+ || (text[matchStart - 1] !== undefined && !/\s/.test(text[matchStart - 1]))
922
+ if (!leftOk) return null
923
+ // A dash char left of the leading spaces (boundaries are zero-width, so
924
+ // this reads through them) marks this run as the tail of an
925
+ // already-spaced dash ("x – -"): the overlap consumption above leaves
926
+ // it on the pass that converts the head, and the head's edits can
927
+ // rewrite the linking context so the consumption never re-links; block
928
+ // the tail deterministically. Glyphs folded from a trailing dash (`←`,
929
+ // `±`) block like the hyphen that sat in this slot before the fold.
930
+ const charBeforeSpaces = text[matchStart - 1]
931
+ if (matchStart > 0
932
+ && (isDashChar(charBeforeSpaces) || FOLDED_FROM_TRAILING_DASH.has(charBeforeSpaces))) return null
933
+ // The leading spaces between matchStart and the dash must be at least one
934
+ // (`[ ]+` is one-or-more) — a boundary directly before the dash leaves
935
+ // none, which is the boundary-led pattern's job, not this one.
936
+ if (matchStart === firstDash) return null
937
+ // A boundary between the leading spaces and the dash breaks `[ ]+dashCore`
938
+ // (no slot there); neither spaced nor boundary-led matches, so skip.
939
+ if (view.hasBoundary(firstDash)) return null
940
+ // Maximal dash run from firstDash that does not cross a boundary (the core
941
+ // never spans a boundary).
942
+ let fullRunEnd = firstDash + 1
943
+ while (isDashChar(text[fullRunEnd]) && !v.hasBoundary(fullRunEnd)) fullRunEnd++
944
+ // The dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
945
+ // guard AND the trailing `[ ]*(?=\S|$)`, so try run ends longest-first and
946
+ // take the first one whose whole tail validates.
947
+ const chosen = chooseSpacedDashRun(v, firstDash, fullRunEnd)
948
+ if (chosen === null) return null
949
+ consumedThrough = chosen.consumedEnd
950
+ v.replace(matchStart, chosen.editEnd, rendered)
951
+ return null
952
+ },
953
+ { allowBoundaries: () => true },
225
954
  )
226
- // Convert multiple dashes: "word--word" or "word---word" or "quote"--"quote"
955
+ }
956
+
957
+ /**
958
+ * Picks the dashCore from the maximal dash run [firstDash, fullRunEnd). Tries
959
+ * `[EN EM][-EN EM]*`, then `-{2,}` (greedy), then a single `-`, backtracking past
960
+ * the arrow guard and the trailing `[ ]*(?=\S|$)`. Returns the chosen run end and
961
+ * the trailing-spaces end (`scan`) to replace, or null when no branch validates.
962
+ */
963
+ function chooseSpacedDashRun(view: ProseView, firstDash: number, fullRunEnd: number): SpacedTrailing | null {
964
+ const text = view.text
965
+ const first = text[firstDash]
966
+ if (first === EN_DASH || first === EM_DASH) {
967
+ // `[EN EM][-EN EM]*`: the leading EN/EM is mandatory, the `[-EN EM]*` tail is
968
+ // greedy but backtracks past the arrow guard — e.g. "–-{sep}{sep}>" gives the
969
+ // `-` back so the run is just "–" (not an arrow) and converts.
970
+ for (let end = fullRunEnd; end > firstDash; end--) {
971
+ if (spacedArrowAhead(view, end)) continue
972
+ const trailing = spacedTrailingEnd(view, end)
973
+ if (trailing !== null) return trailing
974
+ }
975
+ return null
976
+ }
977
+ // The `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
978
+ // run stops at the first EN/EM dash (e.g. "-–" is a single `-` core, not a
979
+ // two-dash run that would swallow the EN dash).
980
+ let hyphenRunEnd = firstDash
981
+ while (text[hyphenRunEnd] === "-" && hyphenRunEnd < fullRunEnd) hyphenRunEnd++
982
+ // All hyphens. Try `-{2,}` lengths longest-first, then a single `-`.
983
+ for (let end = hyphenRunEnd; end > firstDash; end--) {
984
+ if (end - firstDash === 1) {
985
+ // Single `-(?!${sep}*[LAT\d])`: the next clean char must not be a letter or
986
+ // digit (the suspended-hyphen guard).
987
+ const next = text[end]
988
+ if (next !== undefined && /[a-z\d]/i.test(next)) return null
989
+ }
990
+ if (spacedArrowAhead(view, end)) continue
991
+ const trailing = spacedTrailingEnd(view, end)
992
+ if (trailing !== null) return trailing
993
+ }
994
+ return null
995
+ }
996
+
997
+ interface SpacedTrailing {
998
+ /** Offset through which the edit deletes (kept post-boundary spaces excluded). */
999
+ editEnd: number
1000
+ /** Offset past all consumed trailing chars (for overlap/`lastIndex` tracking). */
1001
+ consumedEnd: number
1002
+ }
1003
+
1004
+ /**
1005
+ * The trailing `[ ]*(?:[ ]*)?(?=\S|$)` with one tolerated boundary. The leading
1006
+ * `[ ]*` (before any boundary) is deleted; spaces after a boundary are kept
1007
+ * (they belong to the next node) but still consumed. The lookahead then requires
1008
+ * a non-space char, a boundary, or end of text. Returns the edit/consumed
1009
+ * offsets, or null on failure.
1010
+ */
1011
+ function spacedTrailingEnd(view: ProseView, runEnd: number): SpacedTrailing | null {
1012
+ const text = view.text
1013
+ let editEnd = runEnd
1014
+ while (text[editEnd] === " " && !view.hasBoundary(editEnd)) editEnd++
1015
+ // The trailing optional group is greedy: try consuming a single boundary and
1016
+ // its kept trailing spaces first, then fall back to no boundary. `(?=\S|$)` is
1017
+ // satisfied by a boundary (non-space), a non-space clean char, or end of text.
1018
+ if (boundaryCountAt(view, editEnd) === 1) {
1019
+ let consumedEnd = editEnd
1020
+ while (text[consumedEnd] === " " && (consumedEnd === editEnd || !view.hasBoundary(consumedEnd))) consumedEnd++
1021
+ if (lookaheadNonSpace(view, consumedEnd)) return { editEnd, consumedEnd }
1022
+ }
1023
+ if (lookaheadNonSpace(view, editEnd)) return { editEnd, consumedEnd: editEnd }
1024
+ return null
1025
+ }
1026
+
1027
+ /** `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
1028
+ function lookaheadNonSpace(view: ProseView, pos: number): boolean {
1029
+ return pos === view.text.length || view.hasBoundary(pos) || !/\s/.test(view.text[pos])
1030
+ }
1031
+
1032
+ /**
1033
+ * The `(?!-*>)` arrow guard, boundary-aware. Two slots bracket the `-*` hyphen
1034
+ * run: one boundary may sit before the run and one after. With no hyphens both
1035
+ * slots collapse to the run-end offset (up to two boundaries there); a boundary
1036
+ * inside the hyphen run, or more than the two slots allow, hides the `>` (so the
1037
+ * dash is not an arrow and converts).
1038
+ */
1039
+ function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
1040
+ const text = view.text
1041
+ let h = runEnd
1042
+ while (text[h] === "-") {
1043
+ if (h > runEnd && view.hasBoundary(h)) return false // boundary inside -*
1044
+ h++
1045
+ }
1046
+ if (h === runEnd) {
1047
+ // No hyphens: both slots fall at this offset (≤2 boundaries total).
1048
+ if (boundaryCountAt(view, runEnd) > 2) return false
1049
+ } else {
1050
+ if (boundaryCountAt(view, runEnd) > 1) return false // first slot
1051
+ if (boundaryCountAt(view, h) > 1) return false // second slot
1052
+ }
1053
+ // `≥` guards like the `>=` it folds from: the math pass folds the `>` this
1054
+ // guard keys on, and a dash blocked before `–>=` must stay blocked at `–≥`.
1055
+ return text[h] === ">" || text[h] === UNICODE_SYMBOLS.GREATER_EQUAL
1056
+ }
1057
+
1058
+ /**
1059
+ * Dashes where a boundary alone precedes the dash run: "word{b}– rest". The
1060
+ * shape `(?<=[^\s])[dash]+[ ]+` with a leading boundary requires a boundary
1061
+ * immediately before the dash run, a non-space char before that boundary, and
1062
+ * at least one trailing space. Because the leading boundary is required, this
1063
+ * only fires where a node boundary opens a dash run, so the pass scans
1064
+ * boundaries rather than the clean text. The "non-space before the boundary" is
1065
+ * satisfied by a real non-space clean char (single boundary) or by another
1066
+ * stacked boundary (two or more boundaries — a boundary is itself non-space).
1067
+ */
1068
+ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
1069
+ const text = view.text
1070
+ const edits: Array<[number, number]> = []
1071
+ let lastProcessedEnd = -1
1072
+ let prevOffset = -1
1073
+ let stackedHere = 0
1074
+ for (const b of view.boundaries) {
1075
+ stackedHere = b === prevOffset ? stackedHere + 1 : 1
1076
+ prevOffset = b
1077
+ if (b < lastProcessedEnd) continue
1078
+ if (!isDashChar(text[b])) continue
1079
+ // Non-space before the boundary: ≥2 stacked boundaries, or a non-space
1080
+ // clean char immediately left. A folded math operator there marks the
1081
+ // fold-stranded configuration: the math pass consumes one character
1082
+ // across this junction (`=` + `~—` folds to `≈` + `—`), so the character
1083
+ // that blocked this rule on the source text is gone, and converting now
1084
+ // would diverge from that blocked first pass.
1085
+ if (stackedHere < 2) {
1086
+ const before = text[b - 1]
1087
+ if (before === undefined || /\s/.test(before) || FOLDED_MATH_OPERATORS.has(before)) continue
1088
+ }
1089
+ // Dash run from the boundary, not crossing a further boundary.
1090
+ let runEnd = b + 1
1091
+ while (isDashChar(text[runEnd]) && !view.hasBoundary(runEnd)) runEnd++
1092
+ // Required trailing space run; stop at a boundary.
1093
+ let spaceEnd = runEnd
1094
+ while (text[spaceEnd] === " " && !view.hasBoundary(spaceEnd)) spaceEnd++
1095
+ if (spaceEnd === runEnd) continue // no trailing space
1096
+ // A dash (or a glyph folded from a dash-led source) after the spaces
1097
+ // blocks when the rendered form ends in a dash: consuming the spaces
1098
+ // would fuse the rendered dash with it into a run the next pass
1099
+ // collapses (see the same guard in the multiple-dash rule). A boundary
1100
+ // at the junction prevents the fusion, so only a same-node follower
1101
+ // blocks.
1102
+ const afterSpaces = text[spaceEnd]
1103
+ if (afterSpaces !== undefined && !view.hasBoundary(spaceEnd)
1104
+ && isDashChar(rendered[rendered.length - 1])
1105
+ && (isDashChar(afterSpaces) || FOLDED_FROM_LEADING_DASH.has(afterSpaces))) continue
1106
+ edits.push([b, spaceEnd])
1107
+ lastProcessedEnd = spaceEnd
1108
+ }
1109
+ for (const [from, to] of edits) view.replace(from, to, rendered)
1110
+ view.commit()
1111
+ }
1112
+
1113
+ /**
1114
+ * Multiple dashes: "word--word", "word---word", `"quote"--"quote"`. The shape
1115
+ * `(?<=[LAT\d quote])[dash]{2,50}(?=[LAT quote space])` tolerates one boundary
1116
+ * on each side of the run, but its `[dash]{2,50}` never spans a boundary. A
1117
+ * boundary inside the run truncates it (and the 2..50 count must still hold for
1118
+ * the truncated run); two stacked boundaries on either edge exceed the single
1119
+ * slot and block the match.
1120
+ */
1121
+ function convertMultipleDashes(view: ProseView, rendered: string): void {
1122
+ const before = `[${LATIN_LETTERS}\\d${QUOTE_CHARS}]`
1123
+ const after = `[${LATIN_LETTERS}${QUOTE_CHARS} ]`
227
1124
  // 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>`
1125
+ const pattern = `(?<=${before})[${EN_DASH}${EM_DASH}-]{2,50}(?=${after})`
1126
+ const afterRe = new RegExp(after)
1127
+ pass(
1128
+ view,
1129
+ cachedRegExp(pattern, "g"),
1130
+ (match, v) => {
1131
+ const text = v.text
1132
+ const start = match.index
1133
+ // Two stacked boundaries before the run exceed the leading slot's budget.
1134
+ if (boundaryCountAt(v, start) >= 2) return null
1135
+ // Truncate the run at the first interior boundary (the class stops there).
1136
+ let runEnd = start + 1
1137
+ while (isDashChar(text[runEnd]) && !v.hasBoundary(runEnd)) runEnd++
1138
+ if (runEnd - start < 2 || runEnd - start > 50) return null
1139
+ // The trailing slot tolerates one boundary; the trailing lookahead then
1140
+ // checks the clean char after the run is [LAT quote space].
1141
+ if (boundaryCountAt(v, runEnd) >= 2) return null
1142
+ const next = text[runEnd]
1143
+ if (next === undefined || !afterRe.test(next)) return null
1144
+ // Consume the trailing space run (not crossing a boundary): the rendered
1145
+ // dash carries its own spacing, and a re-run's spaced-dash rule would
1146
+ // otherwise collapse the leftover spaces into it. When the rendered
1147
+ // form ends in a dash (the unspaced American em dash), a dash (or a
1148
+ // glyph folded from a dash-led source) after the spaces keeps them:
1149
+ // consuming would fuse the rendered dash with it into a fresh run, and
1150
+ // the spaced-dash rule's dash-tail guard already holds the leftover
1151
+ // spaces still.
1152
+ let editEnd = runEnd
1153
+ while (text[editEnd] === " " && !v.hasBoundary(editEnd)) editEnd++
1154
+ // A boundary at the junction prevents the fusion (dash runs never span
1155
+ // boundaries), so only a same-node follower blocks.
1156
+ const afterSpaces = text[editEnd]
1157
+ if (editEnd > runEnd && afterSpaces !== undefined && !v.hasBoundary(editEnd)
1158
+ && isDashChar(rendered[rendered.length - 1])
1159
+ && (isDashChar(afterSpaces) || FOLDED_FROM_LEADING_DASH.has(afterSpaces))) {
1160
+ editEnd = runEnd
1161
+ }
1162
+ v.replace(start, editEnd, rendered)
1163
+ return null
1164
+ },
1165
+ { allowBoundaries: () => true },
232
1166
  )
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
1167
  }
244
1168
 
1169
+ /**
1170
+ * Dashes at start of line: "^- " → styled dash. One boundary is tolerated at
1171
+ * line start (before the dashes); it sits at the match start, never interior.
1172
+ * The dash run has no interior slot, so a boundary inside the run blocks the
1173
+ * match — the default interior-boundary rejection, which is what we want here.
1174
+ */
1175
+ function convertLeadingLineDashes(view: ProseView, localizedDash: string): void {
1176
+ pass(view, cachedRegExp(`^-+ `, "gm"), () => `${localizedDash} `)
1177
+ }
1178
+
1179
+ /** British: unspaced em-dashes between letters become spaced en-dashes. */
1180
+ function convertUnspacedEmDashes(view: ProseView, rendered: string): void {
1181
+ const pattern = `(?<=[${LATIN_LETTERS}.!?'"])${EM_DASH}(?=[${LATIN_LETTERS}])`
1182
+ pass(
1183
+ view,
1184
+ cachedRegExp(pattern, "g"),
1185
+ (match, v) => {
1186
+ // One boundary is tolerated on each side of the em-dash; two or more
1187
+ // exceed the slot and block the match.
1188
+ if (boundaryCountAt(v, match.index) > 1) return null
1189
+ if (boundaryCountAt(v, match.index + match[0].length) > 1) return null
1190
+ v.replace(match.index, match.index + match[0].length, rendered)
1191
+ return null
1192
+ },
1193
+ )
1194
+ }
1195
+
1196
+ // ---------------------------------------------------------------------------
1197
+ // Em-dash spacing normalization (american)
1198
+ // ---------------------------------------------------------------------------
1199
+
245
1200
  // TODO: Handle interrupted-then-resumed speech within quotes, where Chicago
246
1201
  // 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
- )
1202
+ function normalizeEmDashSpacing(view: ProseView): void {
1203
+ removeSpacesAroundEmDash(view)
1204
+ preserveLineLeadingEmDashSpace(view)
1205
+ }
256
1206
 
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>`
1207
+ /**
1208
+ * Remove spaces directly around an em-dash that has non-whitespace on both
1209
+ * sides. Only the spaces immediately adjacent to the dash are consumed; a
1210
+ * boundary between a space and the dash keeps that space (space consumption
1211
+ * cannot reach across a boundary). The walk therefore stops space consumption
1212
+ * at node boundaries, and the `\S|^` / `\S|$` anchors treat a boundary (a
1213
+ * non-space) as satisfying the anchor.
1214
+ */
1215
+ function removeSpacesAroundEmDash(view: ProseView): void {
1216
+ pass(
1217
+ view,
1218
+ cachedRegExp(EM_DASH, "g"),
1219
+ (match, v) => {
1220
+ const d = match.index
1221
+ const text = v.text
1222
+ // Leading spaces, stopping at a boundary or non-space.
1223
+ let left = d
1224
+ while (left > 0 && text[left - 1] === " " && !v.hasBoundary(left)) left--
1225
+ // Trailing spaces, stopping at a boundary or non-space.
1226
+ let right = d + 1
1227
+ while (right < text.length && text[right] === " " && !v.hasBoundary(right)) right++
1228
+ const leftAnchored = left === 0 || v.hasBoundary(left) || !/\s/.test(text[left - 1])
1229
+ const rightAnchored = right === text.length || v.hasBoundary(right) || !/\s/.test(text[right])
1230
+ if (!leftAnchored || !rightAnchored) return null
1231
+ // Drop the adjacent space runs; the em-dash and any edge boundaries
1232
+ // stay. A side whose anchor is another dash keeps its spaces: deleting
1233
+ // them would fuse the dashes into a run the multiple-dash rule then
1234
+ // collapses on the next pass. Glyphs folded from a dash-edged source
1235
+ // (`±`, `←`, `→`) anchor like the dash they fold from.
1236
+ const leftAnchor = text[left - 1]
1237
+ const rightAnchor = text[right]
1238
+ if (left < d && !isDashChar(leftAnchor) && !FOLDED_FROM_TRAILING_DASH.has(leftAnchor)) v.replace(left, d, "")
1239
+ if (right > d + 1 && !isDashChar(rightAnchor) && !FOLDED_FROM_LEADING_DASH.has(rightAnchor)) v.replace(d + 1, right, "")
1240
+ return null
1241
+ },
261
1242
  )
1243
+ }
262
1244
 
263
- return text
1245
+ /** Preserve a space after a line-leading em-dash before an uppercase/digit. */
1246
+ function preserveLineLeadingEmDashSpace(view: ProseView): void {
1247
+ pass(
1248
+ view,
1249
+ cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
1250
+ (match, v) => {
1251
+ // One boundary is tolerated between the line start and the em-dash; two or
1252
+ // more exceed that slot and block the match.
1253
+ if (boundaryCountAt(v, match.index) > 1) return null
1254
+ const groups = namedGroups<{ after: string }>(matchArgs(match))
1255
+ return `${EM_DASH} ${groups.after}`
1256
+ },
1257
+ )
264
1258
  }
265
1259
 
266
- export function hyphenReplace(text: string, options: DashOptions = {}): string {
267
- const sep = options.separator ?? DEFAULT_SEPARATOR
1260
+ // ---------------------------------------------------------------------------
1261
+ // Public dash pipeline
1262
+ // ---------------------------------------------------------------------------
1263
+
1264
+ export function hyphenReplace(input: string, options?: DashOptions): string
1265
+ export function hyphenReplace(input: ProseView, options?: DashOptions): void
1266
+ export function hyphenReplace(input: string | ProseView, options: DashOptions = {}): string | void {
268
1267
  const style = options.dashStyle ?? "american"
269
- if (style === "none") return text
270
- text = minusReplace(text, options)
271
- text = enDashDateRange(text, options)
272
- text = enDashNumberRange(text, options)
273
- text = convertParentheticalDashes(text, sep, style)
274
- if (style === "american") text = normalizeEmDashSpacing(text, sep)
275
- return text
1268
+ return overInput(input, (view) => {
1269
+ if (style === "none") return
1270
+ minusReplace(view)
1271
+ enDashDateRangeOverView(view, style)
1272
+ enDashNumberRange(view)
1273
+ convertParentheticalDashes(view, style)
1274
+ if (style === "american") normalizeEmDashSpacing(view)
1275
+ })
276
1276
  }
277
1277
 
278
1278
  const { WORD_JOINER } = UNICODE_SYMBOLS
@@ -292,7 +1292,16 @@ const { WORD_JOINER } = UNICODE_SYMBOLS
292
1292
  * `nbspTransform` (U+00A0 breaks `Fig. 1` searches). Apply only in rendered
293
1293
  * HTML; do not write into Markdown source.
294
1294
  */
295
- export function dashWordJoiner(text: string): string {
296
- const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
297
- return text.replace(re, `${WORD_JOINER}$&`)
1295
+ export function dashWordJoiner(input: string): string
1296
+ export function dashWordJoiner(input: ProseView): void
1297
+ export function dashWordJoiner(input: string | ProseView): string | void {
1298
+ return overInput(input, (view) => {
1299
+ const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
1300
+ replaceAllInView(view, re, (match) => `${WORD_JOINER}${match[0]}`)
1301
+ })
1302
+ }
1303
+
1304
+ /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
1305
+ function matchArgs(match: RegExpExecArray): unknown[] {
1306
+ return [...match, match.index, match.input, match.groups]
298
1307
  }