punctilio 4.3.1 → 5.0.1

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