punctilio 4.3.1 → 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 -27
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -40
  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 +423 -284
  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 +48 -7
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +163 -27
  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 -88
  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 -46
  54. package/src/dashes.ts +450 -269
  55. package/src/index.ts +70 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +234 -39
  59. package/src/quote-classifier.ts +618 -88
  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,18 +1,20 @@
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"
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"
3
3
  import { namedGroups } from "./utils.js"
4
4
 
5
5
  export const DASH_STYLES = ["american", "british", "none"] as const
6
6
  export type DashStyle = (typeof DASH_STYLES)[number]
7
7
 
8
8
  export interface DashOptions {
9
- /** Boundary marker for HTML element boundaries. Default: "" */
10
- separator?: string
11
9
  /** "american" (unspaced em), "british" (spaced en), "none". Default: "american" */
12
10
  dashStyle?: DashStyle
13
11
  }
14
12
 
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
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
16
18
 
17
19
  // Prevents false-positive ranges in model names like "Llama-2-7B".
18
20
  export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS] as const
@@ -35,11 +37,54 @@ const monthPattern = months.join("|")
35
37
  const WORD_CHAR_RE = /\w/
36
38
  const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
37
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
+
38
84
  /**
39
85
  * 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.
86
+ * Committing after every pass sequences the passes so a later pass sees the
87
+ * earlier edits and never queues an overlapping edit.
43
88
  */
44
89
  function pass(
45
90
  view: ProseView,
@@ -54,11 +99,13 @@ function pass(
54
99
  // ---------------------------------------------------------------------------
55
100
  // Boundary-tolerance helpers over a ProseView
56
101
  //
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.
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.
62
109
  // ---------------------------------------------------------------------------
63
110
 
64
111
  /** Interior boundary offsets of a match, strictly inside (matchStart, matchEnd). */
@@ -75,9 +122,9 @@ function interiorBoundaryOffsets(match: RegExpExecArray, view: ProseView): numbe
75
122
 
76
123
  /**
77
124
  * 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.
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.
81
128
  */
82
129
  function interiorBoundariesAllowed(
83
130
  match: RegExpExecArray,
@@ -108,8 +155,7 @@ const MAX_BOUNDARY_SEPARATORS = 3
108
155
  function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
109
156
  if (matchStart === 0) return true
110
157
  const nb = boundaryCountAt(view, matchStart)
111
- const prev = view.text[matchStart - 1]
112
- const prevWord = prev !== undefined && WORD_CHAR_RE.test(prev)
158
+ const prevWord = isWordChar(view.text[matchStart - 1])
113
159
  if (nb === 0) return !prevWord // `\b` needs a non-word char immediately left
114
160
  // A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
115
161
  // when a word char is within ≤MAX boundaries.
@@ -124,13 +170,17 @@ function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
124
170
  */
125
171
  function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
126
172
  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)
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
+ }
131
182
  const nb = boundaryCountAt(view, pos)
132
- const nextClean = text[pos]
133
- const nextCleanWord = nextClean !== undefined && WORD_CHAR_RE.test(nextClean)
183
+ const nextCleanWord = isWordChar(text[pos])
134
184
  // The marked char right after pos is a separator when nb > 0 (non-word).
135
185
  const rightWord = nb > 0 ? false : nextCleanWord
136
186
  if (leftWord === rightWord) return false // no `\b`
@@ -145,23 +195,22 @@ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
145
195
  const CURRENCY_RE = /[$€£¥₹]/
146
196
 
147
197
  /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
148
- export function enDashNumberRange(text: string, options: DashOptions = {}): string {
149
- const separator = options.separator ?? DEFAULT_SEPARATOR
150
- return withProseView(text, separator, (view) => {
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) => {
151
202
  convertPositiveRanges(view)
152
203
  convertNegativeRanges(view)
153
204
  })
154
205
  }
155
206
 
156
207
  /**
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
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
165
214
  * number whose head fails to form a structural match (e.g. boundary-broken
166
215
  * "2024-01-15") still exposes a later sub-range ("01-15"), while a structurally
167
216
  * matching but non-converting number hides its inner dashes.
@@ -177,17 +226,17 @@ function convertPositiveRanges(view: ProseView): void {
177
226
  }
178
227
 
179
228
  /**
180
- * Attempts v4's `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
229
+ * Attempts the `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
181
230
  * \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
231
+ * the gates pass. Returns the consumed end offset on a structural match (so the
183
232
  * scan skips it), or null when no structural match begins at `i`.
184
233
  */
185
234
  function matchPositiveRangeAt(view: ProseView, i: number): number | null {
186
235
  // 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.
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.
191
240
  const areaCode = readPhoneAreaCode(view, i)
192
241
  const startStarts = areaCode === null || view.hasBoundary(i + areaCode) ? [i] : [i + areaCode, i]
193
242
  for (const startStart of startStarts) {
@@ -200,10 +249,10 @@ function matchPositiveRangeAt(view: ProseView, i: number): number | null {
200
249
  const THREE_DIGITS_RE = /^\d\d\d/
201
250
  const THREE_DIGITS_IN_PARENS_RE = /^\(\d\d\d\)/
202
251
 
203
- /** v4's `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
252
+ /** `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
204
253
  function readPhoneAreaCode(view: ProseView, i: number): number | null {
205
254
  const text = view.text
206
- // `\d{3}-` with no separator woven into the digits or before the dash.
255
+ // `\d{3}-` with no boundary inside the digits or before the dash.
207
256
  if (THREE_DIGITS_RE.test(text.slice(i, i + 3)) && text[i + 3] === "-"
208
257
  && !view.hasBoundary(i + 1) && !view.hasBoundary(i + 2) && !view.hasBoundary(i + 3)) {
209
258
  return 4
@@ -217,8 +266,8 @@ function readPhoneAreaCode(view: ProseView, i: number): number | null {
217
266
 
218
267
  /**
219
268
  * 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.
269
+ * beginning the start at `startStart`. Converts the dash on a converting match.
270
+ * Returns the consumed end offset (structural span end) or null.
222
271
  */
223
272
  function matchRangeBody(view: ProseView, startStart: number): number | null {
224
273
  const text = view.text
@@ -226,10 +275,10 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
226
275
  const start = readStartRun(view, startStart)
227
276
  if (start === null) return null
228
277
  const startSpanEnd = startStart + start.length
229
- // One `${chr}?` between start and the dash tolerates a single boundary there.
278
+ // One slot between start and the dash tolerates a single boundary there.
230
279
  if (boundaryCountAt(view, startSpanEnd) > 1) return null
231
280
  // 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}`).
281
+ // between two hyphens truncates the run).
233
282
  if (text[startSpanEnd] !== "-") return null
234
283
  let dashEnd = startSpanEnd + 1
235
284
  while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd)) dashEnd++
@@ -243,7 +292,7 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
243
292
 
244
293
  const end = readEndRun(view, dashEnd)
245
294
  if (end === null) return null
246
- // v4's greedy end `[\d.,]*` and greedy `following*` backtrack together; the
295
+ // The greedy end `[\d.,]*` and greedy `following*` backtrack together; the
247
296
  // structural match commits at the longest end whose tail completes.
248
297
  for (let len = end.length; len >= end.minLen; len--) {
249
298
  const endPos = end.firstDigitStart + len
@@ -259,8 +308,8 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
259
308
  }
260
309
 
261
310
  /**
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
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
264
313
  * alternative is unreachable: a leading currency symbol is non-word, and the only
265
314
  * `\b` sits after it, at the digit — so the currency is never part of `start`
266
315
  * (e.g. "$100-2000" reads start "100", which the 3+4 phone gate then blocks). The
@@ -270,8 +319,8 @@ function readStartRun(view: ProseView, pos: number): { length: number } | null {
270
319
  const text = view.text
271
320
  let i = pos
272
321
  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.
322
+ // `p\.?\d` is contiguous (no interior slot), so a boundary between `p`, an
323
+ // optional `.`, and the first digit breaks the prefix.
275
324
  if (view.hasBoundary(i + 1)) return null
276
325
  i++
277
326
  if (text[i] === ".") {
@@ -279,7 +328,7 @@ function readStartRun(view: ProseView, pos: number): { length: number } | null {
279
328
  i++
280
329
  }
281
330
  }
282
- // v4's `\d` is required after the optional `p\.?` prefix. The prefix chars are
331
+ // `\d` is required after the optional `p\.?` prefix. The prefix chars are
283
332
  // never digits, so a missing digit here means no start run begins.
284
333
  if (!/\d/.test(text[i] ?? "")) return null
285
334
  i++
@@ -296,12 +345,12 @@ interface EndRun {
296
345
  minLen: number
297
346
  }
298
347
 
299
- /** v4's `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
348
+ /** The `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
300
349
  function readEndRun(view: ProseView, pos: number): EndRun | null {
301
350
  const text = view.text
302
351
  let i = pos
303
352
  if (CURRENCY_RE.test(text[i] ?? "")) {
304
- // v4 has no separator slot between the currency symbol and the digit.
353
+ // No slot between the currency symbol and the digit.
305
354
  if (view.hasBoundary(i + 1)) return null
306
355
  i++
307
356
  }
@@ -320,7 +369,7 @@ interface ResolvedTail {
320
369
 
321
370
  /**
322
371
  * Resolves the tail at `endPos`: finds the longest `following` whose
323
- * `suffix? wbe` completes (v4's greedy choice). `followingEmpty` reports whether
372
+ * `suffix? wbe` completes (the greedy choice). `followingEmpty` reports whether
324
373
  * that following is empty (the convert condition); `consumedEnd` is the tail's
325
374
  * end including any suffix (for the scan's consume/advance). Returns null when
326
375
  * no following completes.
@@ -339,9 +388,9 @@ function resolvePositiveTail(view: ProseView, endPos: number): ResolvedTail | nu
339
388
 
340
389
  /**
341
390
  * 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
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
345
394
  * "80") and mis-place or miss the conversion. The scan mirrors `matchRangeBody`:
346
395
  * at each MINUS-led start it resolves the longest end whose `following* suffix?
347
396
  * wbe` tail completes, converting only when that tail leaves `following` empty.
@@ -363,25 +412,25 @@ function convertNegativeRanges(view: ProseView): void {
363
412
  }
364
413
 
365
414
  /**
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
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
368
417
  * pass. Returns the consumed structural end offset, or null when no match begins.
369
418
  */
370
419
  function matchNegativeRangeAt(view: ProseView, i: number): number | null {
371
420
  const text = view.text
372
- // v4's `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
421
+ // `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
373
422
  // boundary, which would hide it) blocks the match.
374
423
  if (boundaryCountAt(view, i) === 0) {
375
424
  const before = text[i - 1]
376
425
  if (before !== undefined && LATIN_LETTER_RE.test(before)) return null
377
426
  }
378
427
 
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).
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).
381
430
  if (!/\d/.test(text[i + 1] ?? "")) return null
382
431
  let startEnd = i + 1
383
432
  while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd)) startEnd++
384
- // One `${chr}?` between start and the dash tolerates a single boundary there.
433
+ // One slot between start and the dash tolerates a single boundary there.
385
434
  if (boundaryCountAt(view, startEnd) > 1) return null
386
435
  if (text[startEnd] !== "-") return null
387
436
 
@@ -392,12 +441,12 @@ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
392
441
  const dashRunLen = dashRunEnd - startEnd
393
442
  const hasNeg = dashRunLen >= 2
394
443
  const negEnd = dashRunEnd
395
- // The `(?<end>${chr}?...)` head separator slot tolerates one boundary.
444
+ // The `end` head slot tolerates one boundary.
396
445
  if (boundaryCountAt(view, negEnd) > 1) return null
397
446
 
398
447
  const end = readNegEndRun(view, negEnd)
399
448
  if (end === null) return null
400
- // v4's greedy end and `following*` backtrack together; commit at the longest end
449
+ // The greedy end and `following*` backtrack together; commit at the longest end
401
450
  // whose tail completes, converting only when that tail's `following` is empty.
402
451
  for (let len = end.length; len >= 1; len--) {
403
452
  const endPos = end.firstDigitStart + len
@@ -419,7 +468,7 @@ interface NegEndRun {
419
468
  length: number
420
469
  }
421
470
 
422
- /** v4's negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
471
+ /** The negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
423
472
  function readNegEndRun(view: ProseView, pos: number): NegEndRun | null {
424
473
  const text = view.text
425
474
  if (!/\d/.test(text[pos] ?? "")) return null
@@ -430,9 +479,9 @@ function readNegEndRun(view: ProseView, pos: number): NegEndRun | null {
430
479
 
431
480
  /**
432
481
  * 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).
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).
436
485
  */
437
486
  function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: boolean): number[] {
438
487
  const candidates = [endSpanEnd]
@@ -452,39 +501,34 @@ function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: bo
452
501
  return candidates
453
502
  }
454
503
 
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
- )
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]/
461
513
 
462
514
  /**
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.
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.
468
518
  */
469
519
  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
520
  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`.
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`.
481
524
  if (boundaryCountAt(view, pos) > 1) return null
482
- // `[AaPp][Mm]` is contiguous in v4 — a boundary between the two letters breaks
483
- // the suffix.
525
+ const text = view.text
526
+ // `[AaPp][Mm]` is contiguous — a boundary between the two letters breaks the
527
+ // suffix.
484
528
  if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
485
529
  return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null
486
530
  }
487
- if (/[xKBTM]/.test(text[pos] ?? "")) {
531
+ if (RANGE_SUFFIX_RE.test(text[pos] ?? "")) {
488
532
  return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null
489
533
  }
490
534
  return null
@@ -492,20 +536,83 @@ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number
492
536
 
493
537
  const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`)
494
538
 
539
+ const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
540
+
495
541
  /**
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
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
499
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.
500
579
  */
501
580
  function notAfterDashOk(view: ProseView, startOffset: number): boolean {
502
581
  if (startOffset === 0) return true
503
582
  if (boundaryCountAt(view, startOffset) > 0) return true
504
583
  const prev = view.text[startOffset - 1]
505
- return prev === undefined || !NOT_AFTER_DASH_RE.test(prev)
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
+ }
602
+
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
506
613
  }
507
614
 
508
- /** v4 range-number gates: reject year-month, phone-shaped, and toll-free pairs. */
615
+ /** Range-number gates: reject year-month, phone-shaped, and toll-free pairs. */
509
616
  function rangeNumbersConvert(startNum: string, endNum: string): boolean {
510
617
  if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum)) return false
511
618
  // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
@@ -525,23 +632,52 @@ function rangeNumbersConvert(startNum: string, endNum: string): boolean {
525
632
  // ---------------------------------------------------------------------------
526
633
 
527
634
  /** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
528
- export function enDashDateRange(text: string, options: DashOptions = {}): string {
529
- const dashStyle = options.dashStyle ?? "american"
530
- if (dashStyle === "none") return text
531
- const separator = options.separator ?? DEFAULT_SEPARATOR
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
532
643
  const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
533
644
 
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) => {
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
663
+ const groups = namedGroups<{
664
+ startMonth: string
665
+ startYear?: string
666
+ preSpace: string
667
+ postSpace: string
668
+ endMonth: string
669
+ endYear?: string
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) => {
545
681
  const groups = namedGroups<{
546
682
  startMonth: string
547
683
  startYear?: string
@@ -550,38 +686,19 @@ export function enDashDateRange(text: string, options: DashOptions = {}): string
550
686
  endMonth: string
551
687
  endYear?: string
552
688
  }>(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
689
  const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
556
- const dashSpan = groups.preSpace.length + 1 + groups.postSpace.length
690
+ // One boundary is tolerated after the start year and one before the end
691
+ // month, bracketing the dash and spaces.
557
692
  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
- },
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)
582
699
  },
583
- )
584
- })
700
+ },
701
+ )
585
702
  }
586
703
 
587
704
  // ---------------------------------------------------------------------------
@@ -589,9 +706,10 @@ export function enDashDateRange(text: string, options: DashOptions = {}): string
589
706
  // ---------------------------------------------------------------------------
590
707
 
591
708
  /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
592
- export function minusReplace(text: string, options: DashOptions = {}): string {
593
- const separator = options.separator ?? DEFAULT_SEPARATOR
594
- return withProseView(text, separator, (view) => {
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) => {
595
713
  minusSubtractionOfNegative(view)
596
714
  minusSpacedSubtraction(view)
597
715
  minusDirectNegative(view)
@@ -600,14 +718,14 @@ export function minusReplace(text: string, options: DashOptions = {}): string {
600
718
 
601
719
  /** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
602
720
  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).
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).
605
723
  pass(
606
724
  view,
607
725
  cachedRegExp(`(?<=\\d) - -(?<num>\\d*\\.?\\d+)`, "g"),
608
726
  (match, v) => {
609
727
  // Replace only the ` - -` prefix; `num` (which may carry interior
610
- // boundaries v4 truncated at) stays untouched.
728
+ // boundaries that truncate it) stays untouched.
611
729
  if (!minusSubtractionPrefixOk(match, v, 4)) return null
612
730
  v.replace(match.index, match.index + 4, ` ${MINUS} ${MINUS}`)
613
731
  return null
@@ -633,27 +751,27 @@ function minusSpacedSubtraction(view: ProseView): void {
633
751
  const NUM_BODY_RE = /^\d*\.?\d+/
634
752
 
635
753
  /**
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.
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.
645
763
  */
646
764
  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.
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.
649
767
  if (boundaryCountAt(view, match.index) > 1) return false
650
768
  const numStart = match.index + prefixLen
651
769
  for (const b of view.boundaries) {
652
770
  if (b > match.index && b < numStart) return false
653
771
  }
654
- // The `${chr}?` before `num` tolerates at most one boundary at the num head.
772
+ // The slot before `num` tolerates at most one boundary at the num head.
655
773
  if (boundaryCountAt(view, numStart) > 1) return false
656
- // v4's `num` stops at the first interior boundary; the clean run up to it must
774
+ // `num` stops at the first interior boundary; the clean run up to it must
657
775
  // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
658
776
  const numEnd = match.index + match[0].length
659
777
  let truncatedEnd = numEnd
@@ -665,15 +783,27 @@ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefi
665
783
  }
666
784
 
667
785
  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}]`)
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}]`)
669
799
 
670
800
  /**
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.
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.
677
807
  */
678
808
  function minusDirectNegative(view: ProseView): void {
679
809
  pass(
@@ -681,11 +811,11 @@ function minusDirectNegative(view: ProseView): void {
681
811
  cachedRegExp(`-(?<num>\\d*\\.?\\d+)`, "gm"),
682
812
  (match, v) => {
683
813
  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
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
686
816
  // `\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.
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.
689
819
  const numEnd = start + match[0].length
690
820
  let numTruncEnd = numEnd
691
821
  for (const b of v.boundaries) {
@@ -698,9 +828,9 @@ function minusDirectNegative(view: ProseView): void {
698
828
  // hyphen has either a boundary or a clean char immediately before it.)
699
829
  const nb = boundaryCountAt(v, start)
700
830
  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.
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.
704
834
  if (nb < 2) {
705
835
  const before = v.text[start - 1]
706
836
  if (before !== undefined && NUM_BEFORE_2B_BLOCK_RE.test(before)) return null
@@ -747,21 +877,21 @@ function isDashChar(ch: string | undefined): boolean {
747
877
  * before a word char (the suspended/hanging hyphen, "Yes-men and -women") is
748
878
  * skipped. The arrow guard rejects shapes like "- >" so `foo -> bar` stays.
749
879
  *
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.
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.
755
885
  */
756
886
  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
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
759
889
  // clean char to the left is whitespace. The cheap `(?<![ ])` anchor keeps each
760
890
  // `[ ]+` run starting at its leftmost space, avoiding quadratic backtracking.
761
891
  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.
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.
765
895
  let consumedThrough = -1
766
896
  pass(
767
897
  view,
@@ -770,8 +900,8 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
770
900
  const text = v.text
771
901
  const groups = namedGroups<{ dashStart: string }>(matchArgs(match))
772
902
  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
903
+ // The greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
904
+ // holds: line/text start, a non-whitespace clean char, or a boundary. A
775
905
  // boundary inside the leading spaces restarts the match just after it (the
776
906
  // spaces before it stay with the previous node). The previous match's
777
907
  // consumed span is also a floor: leading spaces it already consumed are not
@@ -785,24 +915,33 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
785
915
  stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
786
916
  if (matchStart >= firstDash + 1) return null
787
917
  // Validate the lookbehind at matchStart: start of text/line, a boundary
788
- // immediately left (separator), or a non-whitespace clean char.
918
+ // immediately left, or a non-whitespace clean char.
789
919
  const leftOk = matchStart === 0
790
920
  || view.hasBoundary(matchStart)
791
921
  || (text[matchStart - 1] !== undefined && !/\s/.test(text[matchStart - 1]))
792
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
793
933
  // 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
934
+ // (`[ ]+` is one-or-more) — a boundary directly before the dash leaves
795
935
  // none, which is the boundary-led pattern's job, not this one.
796
936
  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.
937
+ // A boundary between the leading spaces and the dash breaks `[ ]+dashCore`
938
+ // (no slot there); neither spaced nor boundary-led matches, so skip.
800
939
  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).
940
+ // Maximal dash run from firstDash that does not cross a boundary (the core
941
+ // never spans a boundary).
803
942
  let fullRunEnd = firstDash + 1
804
943
  while (isDashChar(text[fullRunEnd]) && !v.hasBoundary(fullRunEnd)) fullRunEnd++
805
- // v4's dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
944
+ // The dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
806
945
  // guard AND the trailing `[ ]*(?=\S|$)`, so try run ends longest-first and
807
946
  // take the first one whose whole tail validates.
808
947
  const chosen = chooseSpacedDashRun(v, firstDash, fullRunEnd)
@@ -816,7 +955,7 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
816
955
  }
817
956
 
818
957
  /**
819
- * Picks v4's dashCore from the maximal dash run [firstDash, fullRunEnd). v4 tries
958
+ * Picks the dashCore from the maximal dash run [firstDash, fullRunEnd). Tries
820
959
  * `[EN EM][-EN EM]*`, then `-{2,}` (greedy), then a single `-`, backtracking past
821
960
  * the arrow guard and the trailing `[ ]*(?=\S|$)`. Returns the chosen run end and
822
961
  * the trailing-spaces end (`scan`) to replace, or null when no branch validates.
@@ -835,7 +974,7 @@ function chooseSpacedDashRun(view: ProseView, firstDash: number, fullRunEnd: num
835
974
  }
836
975
  return null
837
976
  }
838
- // v4's `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
977
+ // The `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
839
978
  // run stops at the first EN/EM dash (e.g. "-–" is a single `-` core, not a
840
979
  // two-dash run that would swallow the EN dash).
841
980
  let hyphenRunEnd = firstDash
@@ -863,19 +1002,19 @@ interface SpacedTrailing {
863
1002
  }
864
1003
 
865
1004
  /**
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.
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.
870
1010
  */
871
1011
  function spacedTrailingEnd(view: ProseView, runEnd: number): SpacedTrailing | null {
872
1012
  const text = view.text
873
1013
  let editEnd = runEnd
874
1014
  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.
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.
879
1018
  if (boundaryCountAt(view, editEnd) === 1) {
880
1019
  let consumedEnd = editEnd
881
1020
  while (text[consumedEnd] === " " && (consumedEnd === editEnd || !view.hasBoundary(consumedEnd))) consumedEnd++
@@ -885,17 +1024,17 @@ function spacedTrailingEnd(view: ProseView, runEnd: number): SpacedTrailing | nu
885
1024
  return null
886
1025
  }
887
1026
 
888
- /** v4's `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
1027
+ /** `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
889
1028
  function lookaheadNonSpace(view: ProseView, pos: number): boolean {
890
1029
  return pos === view.text.length || view.hasBoundary(pos) || !/\s/.test(view.text[pos])
891
1030
  }
892
1031
 
893
1032
  /**
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).
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).
899
1038
  */
900
1039
  function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
901
1040
  const text = view.text
@@ -905,24 +1044,26 @@ function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
905
1044
  h++
906
1045
  }
907
1046
  if (h === runEnd) {
908
- // No hyphens: both ${sep}? slots fall at this offset (≤2 boundaries total).
1047
+ // No hyphens: both slots fall at this offset (≤2 boundaries total).
909
1048
  if (boundaryCountAt(view, runEnd) > 2) return false
910
1049
  } else {
911
- if (boundaryCountAt(view, runEnd) > 1) return false // first ${sep}?
912
- if (boundaryCountAt(view, h) > 1) return false // second ${sep}?
1050
+ if (boundaryCountAt(view, runEnd) > 1) return false // first slot
1051
+ if (boundaryCountAt(view, h) > 1) return false // second slot
913
1052
  }
914
- return text[h] === ">"
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
915
1056
  }
916
1057
 
917
1058
  /**
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).
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).
926
1067
  */
927
1068
  function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
928
1069
  const text = view.text
@@ -935,19 +1076,33 @@ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
935
1076
  prevOffset = b
936
1077
  if (b < lastProcessedEnd) continue
937
1078
  if (!isDashChar(text[b])) continue
938
- // Non-space before the separator: ≥2 stacked separators, or a non-space
939
- // clean char immediately left.
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.
940
1085
  if (stackedHere < 2) {
941
1086
  const before = text[b - 1]
942
- if (before === undefined || /\s/.test(before)) continue
1087
+ if (before === undefined || /\s/.test(before) || FOLDED_MATH_OPERATORS.has(before)) continue
943
1088
  }
944
1089
  // Dash run from the boundary, not crossing a further boundary.
945
1090
  let runEnd = b + 1
946
1091
  while (isDashChar(text[runEnd]) && !view.hasBoundary(runEnd)) runEnd++
947
- // Required trailing space run; stop at a boundary (sepAfter).
1092
+ // Required trailing space run; stop at a boundary.
948
1093
  let spaceEnd = runEnd
949
1094
  while (text[spaceEnd] === " " && !view.hasBoundary(spaceEnd)) spaceEnd++
950
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
951
1106
  edits.push([b, spaceEnd])
952
1107
  lastProcessedEnd = spaceEnd
953
1108
  }
@@ -956,12 +1111,12 @@ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
956
1111
  }
957
1112
 
958
1113
  /**
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.
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.
965
1120
  */
966
1121
  function convertMultipleDashes(view: ProseView, rendered: string): void {
967
1122
  const before = `[${LATIN_LETTERS}\\d${QUOTE_CHARS}]`
@@ -975,18 +1130,36 @@ function convertMultipleDashes(view: ProseView, rendered: string): void {
975
1130
  (match, v) => {
976
1131
  const text = v.text
977
1132
  const start = match.index
978
- // Two stacked boundaries before the run exceed sepBefore's one-sep budget.
1133
+ // Two stacked boundaries before the run exceed the leading slot's budget.
979
1134
  if (boundaryCountAt(v, start) >= 2) return null
980
- // Truncate the run at the first interior boundary (v4's class stops there).
1135
+ // Truncate the run at the first interior boundary (the class stops there).
981
1136
  let runEnd = start + 1
982
1137
  while (isDashChar(text[runEnd]) && !v.hasBoundary(runEnd)) runEnd++
983
1138
  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].
1139
+ // The trailing slot tolerates one boundary; the trailing lookahead then
1140
+ // checks the clean char after the run is [LAT quote space].
986
1141
  if (boundaryCountAt(v, runEnd) >= 2) return null
987
1142
  const next = text[runEnd]
988
1143
  if (next === undefined || !afterRe.test(next)) return null
989
- v.replace(start, runEnd, rendered)
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)
990
1163
  return null
991
1164
  },
992
1165
  { allowBoundaries: () => true },
@@ -994,11 +1167,10 @@ function convertMultipleDashes(view: ProseView, rendered: string): void {
994
1167
  }
995
1168
 
996
1169
  /**
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.
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.
1002
1174
  */
1003
1175
  function convertLeadingLineDashes(view: ProseView, localizedDash: string): void {
1004
1176
  pass(view, cachedRegExp(`^-+ `, "gm"), () => `${localizedDash} `)
@@ -1011,8 +1183,8 @@ function convertUnspacedEmDashes(view: ProseView, rendered: string): void {
1011
1183
  view,
1012
1184
  cachedRegExp(pattern, "g"),
1013
1185
  (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.
1186
+ // One boundary is tolerated on each side of the em-dash; two or more
1187
+ // exceed the slot and block the match.
1016
1188
  if (boundaryCountAt(v, match.index) > 1) return null
1017
1189
  if (boundaryCountAt(v, match.index + match[0].length) > 1) return null
1018
1190
  v.replace(match.index, match.index + match[0].length, rendered)
@@ -1034,12 +1206,11 @@ function normalizeEmDashSpacing(view: ProseView): void {
1034
1206
 
1035
1207
  /**
1036
1208
  * 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.
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.
1043
1214
  */
1044
1215
  function removeSpacesAroundEmDash(view: ProseView): void {
1045
1216
  pass(
@@ -1057,9 +1228,15 @@ function removeSpacesAroundEmDash(view: ProseView): void {
1057
1228
  const leftAnchored = left === 0 || v.hasBoundary(left) || !/\s/.test(text[left - 1])
1058
1229
  const rightAnchored = right === text.length || v.hasBoundary(right) || !/\s/.test(text[right])
1059
1230
  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, "")
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, "")
1063
1240
  return null
1064
1241
  },
1065
1242
  )
@@ -1071,8 +1248,8 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
1071
1248
  view,
1072
1249
  cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
1073
1250
  (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.
1251
+ // One boundary is tolerated between the line start and the em-dash; two or
1252
+ // more exceed that slot and block the match.
1076
1253
  if (boundaryCountAt(v, match.index) > 1) return null
1077
1254
  const groups = namedGroups<{ after: string }>(matchArgs(match))
1078
1255
  return `${EM_DASH} ${groups.after}`
@@ -1084,18 +1261,18 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
1084
1261
  // Public dash pipeline
1085
1262
  // ---------------------------------------------------------------------------
1086
1263
 
1087
- export function hyphenReplace(text: string, options: DashOptions = {}): string {
1088
- const separator = options.separator ?? DEFAULT_SEPARATOR
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 {
1089
1267
  const style = options.dashStyle ?? "american"
1090
- if (style === "none") return text
1091
- text = minusReplace(text, options)
1092
- text = enDashDateRange(text, options)
1093
- text = enDashNumberRange(text, options)
1094
- text = withProseView(text, separator, (view) => {
1268
+ return overInput(input, (view) => {
1269
+ if (style === "none") return
1270
+ minusReplace(view)
1271
+ enDashDateRangeOverView(view, style)
1272
+ enDashNumberRange(view)
1095
1273
  convertParentheticalDashes(view, style)
1096
1274
  if (style === "american") normalizeEmDashSpacing(view)
1097
1275
  })
1098
- return text
1099
1276
  }
1100
1277
 
1101
1278
  const { WORD_JOINER } = UNICODE_SYMBOLS
@@ -1115,9 +1292,13 @@ const { WORD_JOINER } = UNICODE_SYMBOLS
1115
1292
  * `nbspTransform` (U+00A0 breaks `Fig. 1` searches). Apply only in rendered
1116
1293
  * HTML; do not write into Markdown source.
1117
1294
  */
1118
- export function dashWordJoiner(text: string): string {
1119
- const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
1120
- 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
+ })
1121
1302
  }
1122
1303
 
1123
1304
  /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */