punctilio 4.2.0 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dashes.js CHANGED
@@ -1,13 +1,8 @@
1
- import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, UNICODE_SYMBOLS, wordBoundaryEnd, wordBoundaryStart } from "./constants.js";
1
+ import { cachedRegExp, DEFAULT_SEPARATOR, LATIN_LETTERS, UNICODE_SYMBOLS } from "./constants.js";
2
+ import { boundaryCountAt, replaceAllInView, withProseView } from "./prose-view.js";
2
3
  import { namedGroups } from "./utils.js";
3
4
  export const DASH_STYLES = ["american", "british", "none"];
4
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
- // Characters a later pass produces from a trailing *word* character: the symbol
6
- // pass turns "x" into "×", and the superscript pass turns the ordinal letters
7
- // "st"/"nd"/"rd"/"th" into superscripts. Before conversion those word chars block
8
- // a range via the trailing word boundary ("1-55x5", "5--1st"); rejecting their
9
- // non-word replacements keeps a second pass from en-dashing the same range.
10
- const SYMBOL_PASS_TRAILING = `${MULTIPLICATION}${SUPERSCRIPT_ST}${SUPERSCRIPT_ND}${SUPERSCRIPT_RD}${SUPERSCRIPT_TH}`;
11
6
  // Prevents false-positive ranges in model names like "Llama-2-7B".
12
7
  export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS];
13
8
  // Non-ASCII dashes escaped to \uXXXX for safe embedding in [...] classes.
@@ -22,167 +17,1023 @@ const months = [
22
17
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
23
18
  ];
24
19
  const monthPattern = months.join("|");
20
+ const WORD_CHAR_RE = /\w/;
21
+ const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`);
22
+ /**
23
+ * 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.
27
+ */
28
+ function pass(view, regex, replacer, options) {
29
+ replaceAllInView(view, regex, replacer, options);
30
+ view.commit();
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Boundary-tolerance helpers over a ProseView
34
+ //
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.
40
+ // ---------------------------------------------------------------------------
41
+ /** Interior boundary offsets of a match, strictly inside (matchStart, matchEnd). */
42
+ function interiorBoundaryOffsets(match, view) {
43
+ const start = match.index;
44
+ const end = match.index + match[0].length;
45
+ const offsets = [];
46
+ for (const boundary of view.boundaries) {
47
+ if (boundary > start && boundary < end)
48
+ offsets.push(boundary);
49
+ else if (boundary >= end)
50
+ break;
51
+ }
52
+ return offsets;
53
+ }
54
+ /**
55
+ * 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.
59
+ */
60
+ function interiorBoundariesAllowed(match, view, tolerated) {
61
+ const counts = new Map();
62
+ for (const offset of interiorBoundaryOffsets(match, view)) {
63
+ counts.set(offset, (counts.get(offset) ?? 0) + 1);
64
+ }
65
+ for (const [offset, count] of counts) {
66
+ const budget = tolerated.get(offset);
67
+ if (budget === undefined || count > budget)
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ /** Separators tolerated by the word-boundary helpers (mirrors constants.ts). */
73
+ const MAX_BOUNDARY_SEPARATORS = 3;
74
+ /**
75
+ * Reproduces `wordBoundaryStart` = `(?<!\w${sep}{0,3})\b` at the marked position
76
+ * `matchStart` (the start char is a word char — a digit). The `\b` requires a
77
+ * non-word char to its left (a separator counts as non-word); the negative
78
+ * lookbehind additionally blocks when a word char sits within
79
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the left.
80
+ */
81
+ function wordBoundaryStartOk(view, matchStart) {
82
+ if (matchStart === 0)
83
+ return true;
84
+ const nb = boundaryCountAt(view, matchStart);
85
+ const prev = view.text[matchStart - 1];
86
+ const prevWord = prev !== undefined && WORD_CHAR_RE.test(prev);
87
+ if (nb === 0)
88
+ return !prevWord; // `\b` needs a non-word char immediately left
89
+ // A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
90
+ // when a word char is within ≤MAX boundaries.
91
+ return !prevWord || nb > MAX_BOUNDARY_SEPARATORS;
92
+ }
93
+ /**
94
+ * Reproduces `wordBoundaryEnd` = `\b(?!${sep}{0,3}\w)` at the marked position
95
+ * `pos`. The `\b` requires a word/non-word transition (a separator counts as
96
+ * non-word); the negative lookahead blocks when a word char sits within
97
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the right.
98
+ */
99
+ function wordBoundaryEndOk(view, pos) {
100
+ 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);
105
+ const nb = boundaryCountAt(view, pos);
106
+ const nextClean = text[pos];
107
+ const nextCleanWord = nextClean !== undefined && WORD_CHAR_RE.test(nextClean);
108
+ // The marked char right after pos is a separator when nb > 0 (non-word).
109
+ const rightWord = nb > 0 ? false : nextCleanWord;
110
+ if (leftWord === rightWord)
111
+ return false; // no `\b`
112
+ // `(?!${sep}{0,3}\w)`: a word char within ≤3 boundaries to the right blocks.
113
+ return !(nb <= MAX_BOUNDARY_SEPARATORS && nextCleanWord);
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Number ranges
117
+ // ---------------------------------------------------------------------------
118
+ const CURRENCY_RE = /[$€£¥₹]/;
25
119
  /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
26
120
  export function enDashNumberRange(text, options = {}) {
27
- const chr = getEscapedSeparator(options);
28
- const wb = wordBoundaryStart(chr);
29
- const wbe = wordBoundaryEnd(chr);
30
- // Common currency symbols for price ranges
31
- const currencies = "$€£¥₹";
32
- // Build positive range pattern from readable components
33
- const phoneAreaCode = `(?<precedingAreaCode>\\d{3}-|\\(\\d{3}\\) ?)?`; // 555- or (555)
34
- // Lookbehind prevents matching after dashes, so Llama-2-7B and +44-20 don't en-dash.
35
- // `${MULTIPLICATION}${PLUS_MINUS}` keep ranges stable across the symbol pass, which
36
- // runs after this one: a range start preceded by `x` (a Latin letter) or `+`/`-` is
37
- // already rejected here, and the symbol pass rewrites "5x10" → "5×10" and "+/-1" →
38
- // "±1". Rejecting `×` and `±` too means a second pass over those outputs doesn't
39
- // suddenly en-dash a tail the first pass left alone (e.g. "5x10-20" → "5×10-20").
40
- const notAfterDash = `(?<![${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+])`;
41
- const rangeStart = `(?<start>(?:p\\.?|[${currencies}])?\\d[\\d.,]*${chr}?)`; // p.10, $100, 1,000
42
- const rangeEnd = `(?<end>${chr}?[${currencies}]?\\d[\\d.,]*)`; // 20, $200, 2,000
43
- const moreSegments = `(?<following>(?:${chr}?[-${MINUS}]${chr}?\\d+)*)`; // -4567 in phone numbers
44
- const unitSuffix = `(?<suffix>${chr}?(?:[AaPp][Mm]|[xKBTM]))?`; // am/pm, K/M/B
45
- // Positive ranges: 1-5, 5--10, $100-$200, €5-€10, p.10-15
46
- const positiveRangePattern = [
47
- phoneAreaCode, wb, notAfterDash, rangeStart, "-{1,3}", rangeEnd, moreSegments, unitSuffix, wbe
48
- ].join("");
49
- text = text.replace(cachedRegExp(positiveRangePattern, "g"), (...args) => {
50
- const groups = namedGroups(args);
51
- if (groups.following)
52
- return args[0];
53
- // A range whose end abuts a symbol-pass replacement (× or a superscript
54
- // ordinal) is an operand, not a range (e.g. "1-55×5", "5--1ˢᵗ"). This
55
- // mirrors `notAfterDash` on the trailing side; done in the callback rather
56
- // than as a regex lookahead to keep the composed pattern ReDoS-safe.
57
- const matchEnd = args.at(-3) + args[0].length;
58
- if (cachedRegExp(`^${chr}?[${SYMBOL_PASS_TRAILING}]`, "").test(args.at(-2).slice(matchEnd))) {
59
- return args[0];
60
- }
61
- const startNum = groups.start.replace(cachedRegExp(chr, "g"), "");
62
- const endNum = groups.end.replace(cachedRegExp(chr, "g"), "");
63
- if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum))
64
- return args[0];
65
- // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
66
- // 555-123-4567). Thousands-grouped endings (1,234 / 1.234) keep their
67
- // internal separator and so fail /^\d{4}$/, falling through to convert
68
- // as ranges. Space/NNBSP/thin-space grouping isn't captured by the outer
69
- // range regex, so those variants currently aren't affected here.
70
- if (/^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum))
71
- return args[0];
72
- // Skip US toll-free prefix pattern: 1-800, 1-888, 1-877, etc. Only the
73
- // seven real toll-free codes (800, 888, 877, 866, 855, 844, 833) are
74
- // blocked, so genuine ranges like 1-850 or 1-810 still en-dash.
75
- if (/^1$/.test(startNum) && /^8(?:00|88|77|66|55|44|33)$/.test(endNum))
76
- return args[0];
77
- return `${groups.precedingAreaCode ?? ""}${groups.start}${EN_DASH}${groups.end}${groups.suffix ?? ""}`;
78
- });
79
- // Negative ranges: −5-5 → −5–5, −5--2 → −5–−2
80
- // Separate regex because MINUS isn't a word char, so \b in ${wb} would match after it
81
- text = text.replace(cachedRegExp(`(?<![${LATIN_LETTERS}])(?<start>${MINUS}\\d[\\d.,]*${chr}?)-{1,3}?(?<neg>-)?(?<end>${chr}?\\d[\\d.,]*)(?<following>(?:${chr}?-${chr}?\\d+)*)(?<suffix>${chr}?[xKBTM])?${wbe}`, "g"), (...args) => {
82
- const groups = namedGroups(args);
83
- if (groups.following)
84
- return args[0];
85
- return `${groups.start}${EN_DASH}${groups.neg ? MINUS : ""}${groups.end}${groups.suffix ?? ""}`;
121
+ const separator = options.separator ?? DEFAULT_SEPARATOR;
122
+ return withProseView(text, separator, (view) => {
123
+ convertPositiveRanges(view);
124
+ convertNegativeRanges(view);
86
125
  });
87
- return text;
88
126
  }
127
+ /**
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
136
+ * number whose head fails to form a structural match (e.g. boundary-broken
137
+ * "2024-01-15") still exposes a later sub-range ("01-15"), while a structurally
138
+ * matching but non-converting number hides its inner dashes.
139
+ */
140
+ function convertPositiveRanges(view) {
141
+ const text = view.text;
142
+ let i = 0;
143
+ while (i < text.length) {
144
+ const span = matchPositiveRangeAt(view, i);
145
+ i = span === null ? i + 1 : Math.max(span, i + 1);
146
+ }
147
+ view.commit();
148
+ }
149
+ /**
150
+ * Attempts v4's `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
151
+ * \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
153
+ * scan skips it), or null when no structural match begins at `i`.
154
+ */
155
+ function matchPositiveRangeAt(view, i) {
156
+ // 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.
161
+ const areaCode = readPhoneAreaCode(view, i);
162
+ const startStarts = areaCode === null || view.hasBoundary(i + areaCode) ? [i] : [i + areaCode, i];
163
+ for (const startStart of startStarts) {
164
+ const span = matchRangeBody(view, startStart);
165
+ if (span !== null)
166
+ return span;
167
+ }
168
+ return null;
169
+ }
170
+ const THREE_DIGITS_RE = /^\d\d\d/;
171
+ const THREE_DIGITS_IN_PARENS_RE = /^\(\d\d\d\)/;
172
+ /** v4's `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
173
+ function readPhoneAreaCode(view, i) {
174
+ const text = view.text;
175
+ // `\d{3}-` with no separator woven into the digits or before the dash.
176
+ if (THREE_DIGITS_RE.test(text.slice(i, i + 3)) && text[i + 3] === "-"
177
+ && !view.hasBoundary(i + 1) && !view.hasBoundary(i + 2) && !view.hasBoundary(i + 3)) {
178
+ return 4;
179
+ }
180
+ // `(\d{3})` with an optional trailing space.
181
+ if (THREE_DIGITS_IN_PARENS_RE.test(text.slice(i, i + 5))) {
182
+ return text[i + 5] === " " ? 6 : 5;
183
+ }
184
+ return null;
185
+ }
186
+ /**
187
+ * 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.
190
+ */
191
+ function matchRangeBody(view, startStart) {
192
+ const text = view.text;
193
+ // rangeStart = `(?:p\.?|[currency])?\d[\d.,]*`, anchored at a `\b`.
194
+ const start = readStartRun(view, startStart);
195
+ if (start === null)
196
+ return null;
197
+ const startSpanEnd = startStart + start.length;
198
+ // One `${chr}?` between start and the dash tolerates a single boundary there.
199
+ if (boundaryCountAt(view, startSpanEnd) > 1)
200
+ return null;
201
+ // 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}`).
203
+ if (text[startSpanEnd] !== "-")
204
+ return null;
205
+ let dashEnd = startSpanEnd + 1;
206
+ while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd))
207
+ dashEnd++;
208
+ if (boundaryCountAt(view, dashEnd) > 1)
209
+ return null;
210
+ // `readStartRun` already stops at the first interior boundary, so the start
211
+ // span is boundary-free and its clean digits are the whole run.
212
+ const startDigits = text.slice(startStart, startSpanEnd);
213
+ if (!wordBoundaryStartOk(view, startStart))
214
+ return null;
215
+ if (!notAfterDashOk(view, startStart))
216
+ return null;
217
+ const end = readEndRun(view, dashEnd);
218
+ if (end === null)
219
+ return null;
220
+ // v4's greedy end `[\d.,]*` and greedy `following*` backtrack together; the
221
+ // structural match commits at the longest end whose tail completes.
222
+ for (let len = end.length; len >= end.minLen; len--) {
223
+ const endPos = end.firstDigitStart + len;
224
+ const tail = resolvePositiveTail(view, endPos);
225
+ if (tail === null)
226
+ continue;
227
+ const endStr = text.slice(dashEnd, endPos);
228
+ if (tail.followingEmpty && rangeNumbersConvert(startDigits, endStr)) {
229
+ view.replace(startSpanEnd, dashEnd, EN_DASH);
230
+ }
231
+ return tail.consumedEnd;
232
+ }
233
+ return null;
234
+ }
235
+ /**
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
238
+ * alternative is unreachable: a leading currency symbol is non-word, and the only
239
+ * `\b` sits after it, at the digit — so the currency is never part of `start`
240
+ * (e.g. "$100-2000" reads start "100", which the 3+4 phone gate then blocks). The
241
+ * `p`/`p.` prefix is a word char, so it can begin the run at a word boundary.
242
+ */
243
+ function readStartRun(view, pos) {
244
+ const text = view.text;
245
+ let i = pos;
246
+ 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.
249
+ if (view.hasBoundary(i + 1))
250
+ return null;
251
+ i++;
252
+ if (text[i] === ".") {
253
+ if (view.hasBoundary(i + 1))
254
+ return null;
255
+ i++;
256
+ }
257
+ }
258
+ // v4's `\d` is required after the optional `p\.?` prefix. The prefix chars are
259
+ // never digits, so a missing digit here means no start run begins.
260
+ if (!/\d/.test(text[i] ?? ""))
261
+ return null;
262
+ i++;
263
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i))
264
+ i++;
265
+ return { length: i - pos };
266
+ }
267
+ /** v4's `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
268
+ function readEndRun(view, pos) {
269
+ const text = view.text;
270
+ let i = pos;
271
+ if (CURRENCY_RE.test(text[i] ?? "")) {
272
+ // v4 has no separator slot between the currency symbol and the digit.
273
+ if (view.hasBoundary(i + 1))
274
+ return null;
275
+ i++;
276
+ }
277
+ if (!/\d/.test(text[i] ?? ""))
278
+ return null;
279
+ const firstDigitStart = i;
280
+ i++;
281
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i))
282
+ i++;
283
+ return { firstDigitStart, length: i - firstDigitStart, minLen: 1 };
284
+ }
285
+ /**
286
+ * Resolves the tail at `endPos`: finds the longest `following` whose
287
+ * `suffix? wbe` completes (v4's greedy choice). `followingEmpty` reports whether
288
+ * that following is empty (the convert condition); `consumedEnd` is the tail's
289
+ * end including any suffix (for the scan's consume/advance). Returns null when
290
+ * no following completes.
291
+ */
292
+ function resolvePositiveTail(view, endPos) {
293
+ const candidates = followingCandidates(view, endPos, true);
294
+ for (let i = candidates.length - 1; i >= 0; i--) {
295
+ const suffixEnd = suffixWbeEnd(view, candidates[i], true);
296
+ if (suffixEnd !== null) {
297
+ return { followingEmpty: i === 0, consumedEnd: suffixEnd };
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+ /**
303
+ * 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
307
+ * "80") and mis-place or miss the conversion. The scan mirrors `matchRangeBody`:
308
+ * at each MINUS-led start it resolves the longest end whose `following* suffix?
309
+ * wbe` tail completes, converting only when that tail leaves `following` empty.
310
+ */
311
+ function convertNegativeRanges(view) {
312
+ const text = view.text;
313
+ let i = 0;
314
+ while (i < text.length) {
315
+ if (text[i] === MINUS) {
316
+ const span = matchNegativeRangeAt(view, i);
317
+ if (span !== null) {
318
+ i = Math.max(span, i + 1);
319
+ continue;
320
+ }
321
+ }
322
+ i++;
323
+ }
324
+ view.commit();
325
+ }
326
+ /**
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
329
+ * pass. Returns the consumed structural end offset, or null when no match begins.
330
+ */
331
+ function matchNegativeRangeAt(view, i) {
332
+ const text = view.text;
333
+ // v4's `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
334
+ // boundary, which would hide it) blocks the match.
335
+ if (boundaryCountAt(view, i) === 0) {
336
+ const before = text[i - 1];
337
+ if (before !== undefined && LATIN_LETTER_RE.test(before))
338
+ return null;
339
+ }
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).
342
+ if (!/\d/.test(text[i + 1] ?? ""))
343
+ return null;
344
+ let startEnd = i + 1;
345
+ while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd))
346
+ startEnd++;
347
+ // One `${chr}?` between start and the dash tolerates a single boundary there.
348
+ if (boundaryCountAt(view, startEnd) > 1)
349
+ return null;
350
+ if (text[startEnd] !== "-")
351
+ return null;
352
+ // `-{1,3}?` then optional `(?<neg>-)`: a 1..3 hyphen run with no interior
353
+ // boundary, the last of which becomes `neg` when two or more hyphens are present.
354
+ let dashRunEnd = startEnd + 1;
355
+ while (text[dashRunEnd] === "-" && dashRunEnd - startEnd < 3 && !view.hasBoundary(dashRunEnd))
356
+ dashRunEnd++;
357
+ const dashRunLen = dashRunEnd - startEnd;
358
+ const hasNeg = dashRunLen >= 2;
359
+ const negEnd = dashRunEnd;
360
+ // The `(?<end>${chr}?...)` head separator slot tolerates one boundary.
361
+ if (boundaryCountAt(view, negEnd) > 1)
362
+ return null;
363
+ const end = readNegEndRun(view, negEnd);
364
+ if (end === null)
365
+ return null;
366
+ // v4's greedy end and `following*` backtrack together; commit at the longest end
367
+ // whose tail completes, converting only when that tail's `following` is empty.
368
+ for (let len = end.length; len >= 1; len--) {
369
+ const endPos = end.firstDigitStart + len;
370
+ const candidates = followingCandidates(view, endPos, false);
371
+ for (let c = candidates.length - 1; c >= 0; c--) {
372
+ const suffixEnd = suffixWbeEnd(view, candidates[c], false);
373
+ if (suffixEnd === null)
374
+ continue;
375
+ if (c === 0) {
376
+ view.replace(startEnd, negEnd, `${EN_DASH}${hasNeg ? MINUS : ""}`);
377
+ }
378
+ return suffixEnd;
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ /** v4's negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
384
+ function readNegEndRun(view, pos) {
385
+ const text = view.text;
386
+ if (!/\d/.test(text[pos] ?? ""))
387
+ return null;
388
+ let i = pos + 1;
389
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i))
390
+ i++;
391
+ return { firstDigitStart: pos, length: i - pos };
392
+ }
393
+ /**
394
+ * 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).
398
+ */
399
+ function followingCandidates(view, endSpanEnd, allowMinus) {
400
+ const candidates = [endSpanEnd];
401
+ let pos = endSpanEnd;
402
+ for (;;) {
403
+ if (boundaryCountAt(view, pos) > 1)
404
+ break;
405
+ const dashCh = view.text[pos];
406
+ if (dashCh !== "-" && !(allowMinus && dashCh === MINUS))
407
+ break;
408
+ const digitStart = pos + 1;
409
+ if (boundaryCountAt(view, digitStart) > 1)
410
+ break;
411
+ if (!/\d/.test(view.text[digitStart] ?? ""))
412
+ break;
413
+ let end = digitStart + 1;
414
+ while (/\d/.test(view.text[end] ?? "") && !view.hasBoundary(end))
415
+ end++;
416
+ pos = end;
417
+ candidates.push(pos);
418
+ }
419
+ return candidates;
420
+ }
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
+ /**
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.
431
+ */
432
+ 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
+ if (wordBoundaryEndOk(view, pos))
444
+ 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`.
447
+ if (boundaryCountAt(view, pos) > 1)
448
+ return null;
449
+ // `[AaPp][Mm]` is contiguous in v4 — a boundary between the two letters breaks
450
+ // the suffix.
451
+ if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
452
+ return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null;
453
+ }
454
+ if (/[xKBTM]/.test(text[pos] ?? "")) {
455
+ return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null;
456
+ }
457
+ return null;
458
+ }
459
+ const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`);
460
+ /**
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
464
+ * directly before the start with no intervening boundary.
465
+ */
466
+ function notAfterDashOk(view, startOffset) {
467
+ if (startOffset === 0)
468
+ return true;
469
+ if (boundaryCountAt(view, startOffset) > 0)
470
+ return true;
471
+ const prev = view.text[startOffset - 1];
472
+ return prev === undefined || !NOT_AFTER_DASH_RE.test(prev);
473
+ }
474
+ /** v4 range-number gates: reject year-month, phone-shaped, and toll-free pairs. */
475
+ function rangeNumbersConvert(startNum, endNum) {
476
+ if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum))
477
+ return false;
478
+ // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
479
+ // 555-123-4567). Thousands-grouped endings (1,234 / 1.234) keep their
480
+ // internal separator and so fail /^\d{4}$/, falling through to convert as
481
+ // ranges.
482
+ if (/^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum))
483
+ return false;
484
+ // Skip US toll-free prefix pattern: 1-800, 1-888, 1-877, etc. Only the seven
485
+ // real toll-free codes (800, 888, 877, 866, 855, 844, 833) are blocked, so
486
+ // genuine ranges like 1-850 or 1-810 still en-dash.
487
+ if (/^1$/.test(startNum) && /^8(?:00|88|77|66|55|44|33)$/.test(endNum))
488
+ return false;
489
+ return true;
490
+ }
491
+ // ---------------------------------------------------------------------------
492
+ // Date ranges
493
+ // ---------------------------------------------------------------------------
89
494
  /** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
90
495
  export function enDashDateRange(text, options = {}) {
91
496
  const dashStyle = options.dashStyle ?? "american";
92
497
  if (dashStyle === "none")
93
498
  return text;
94
- const chr = getEscapedSeparator(options);
95
- const wb = wordBoundaryStart(chr);
96
- const wbe = wordBoundaryEnd(chr);
97
- // Atomic-optional year groups (lookahead + backref). The capture inside
98
- // the lookahead is locked in once matched, so the year group commits
99
- // without backtracking.
100
- const startYear = `(?=(?<startYear>${chr}? \\d{4})?)\\k<startYear>`;
101
- const endYear = `(?=(?<endYear> \\d{4})?)\\k<endYear>`;
102
- return text.replace(cachedRegExp(`${wb}(?<startMonth>${monthPattern})${startYear}(?<preSep>${chr}?)(?<preSpace> ?)-(?<postSpace> ?)(?<postSep>${chr}?)(?<endMonth>${monthPattern})${endYear}${wbe}`, "g"), (...args) => {
103
- const groups = namedGroups(args);
104
- const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""];
105
- return `${groups.startMonth}${groups.startYear || ""}${groups.preSep}${pre}${EN_DASH}${post}${groups.postSep}${groups.endMonth}${groups.endYear || ""}`;
499
+ const separator = options.separator ?? DEFAULT_SEPARATOR;
500
+ 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) => {
509
+ 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
+ const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0);
513
+ const dashSpan = groups.preSpace.length + 1 + groups.postSpace.length;
514
+ 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
+ });
106
533
  });
107
534
  }
535
+ // ---------------------------------------------------------------------------
536
+ // Minus signs
537
+ // ---------------------------------------------------------------------------
108
538
  /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
109
539
  export function minusReplace(text, options = {}) {
110
- const chr = getEscapedSeparator(options);
111
- // Pattern 1a: Subtraction of negative number (e.g., "5 - -3" → "5 − −3")
112
- // Must come before Pattern 1b so the negative hyphen is consumed first
113
- text = text.replace(cachedRegExp(`(?<=\\d${chr}?) - -(?<num>${chr}?\\d*\\.?\\d+)`, "g"), ` ${MINUS} ${MINUS}$<num>`);
114
- // Pattern 1b: Spaced math subtraction (e.g., "5 - 3" → "5 − 3")
115
- // Only when preceded by a digit - this distinguishes "5 - 3" from "Safari) - 9"
116
- text = text.replace(cachedRegExp(`(?<=\\d${chr}?) - (?<num>${chr}?\\d*\\.?\\d+)`, "g"), ` ${MINUS} $<num>`);
117
- // Pattern 2: Direct negative numbers (e.g., "-5" → "−5", "(-3)" → "(−3)")
118
- // Match after: start of line, whitespace, (, or quotes (straight or curly)
119
- // No space allowed between hyphen and digit
120
- text = text.replace(cachedRegExp(`(?<before>^|[\\s\\("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}])-(?<num>\\d*\\.?\\d+)`, "gm"), `$<before>${MINUS}$<num>`);
121
- // Pattern 2b: Direct negative numbers after separator boundary (e.g., <em>-5</em>)
122
- // Only when no word character precedes the separator — prevents both
123
- // "2{SEP}-3" in "1-2-3" and "GPT{SEP}-3" from being misidentified as negative 3
124
- text = text.replace(cachedRegExp(`(?<![\\d.,${LATIN_LETTERS}])(?<before>${chr})-(?<num>\\d*\\.?\\d+)`, "gm"), `$<before>${MINUS}$<num>`);
125
- return text;
540
+ const separator = options.separator ?? DEFAULT_SEPARATOR;
541
+ return withProseView(text, separator, (view) => {
542
+ minusSubtractionOfNegative(view);
543
+ minusSpacedSubtraction(view);
544
+ minusDirectNegative(view);
545
+ });
546
+ }
547
+ /** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
548
+ 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).
551
+ pass(view, cachedRegExp(`(?<=\\d) - -(?<num>\\d*\\.?\\d+)`, "g"), (match, v) => {
552
+ // Replace only the ` - -` prefix; `num` (which may carry interior
553
+ // boundaries v4 truncated at) stays untouched.
554
+ if (!minusSubtractionPrefixOk(match, v, 4))
555
+ return null;
556
+ v.replace(match.index, match.index + 4, ` ${MINUS} ${MINUS}`);
557
+ return null;
558
+ }, { allowBoundaries: () => true });
559
+ }
560
+ function minusSpacedSubtraction(view) {
561
+ // "5 - 3" → "5 − 3"; only after a digit ("5 - 3" not "Safari) - 9").
562
+ pass(view, cachedRegExp(`(?<=\\d) - (?<num>\\d*\\.?\\d+)`, "g"), (match, v) => {
563
+ if (!minusSubtractionPrefixOk(match, v, 3))
564
+ return null;
565
+ v.replace(match.index, match.index + 3, ` ${MINUS} `);
566
+ return null;
567
+ }, { allowBoundaries: () => true });
568
+ }
569
+ const NUM_BODY_RE = /^\d*\.?\d+/;
570
+ /**
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.
580
+ */
581
+ 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.
584
+ if (boundaryCountAt(view, match.index) > 1)
585
+ return false;
586
+ const numStart = match.index + prefixLen;
587
+ for (const b of view.boundaries) {
588
+ if (b > match.index && b < numStart)
589
+ return false;
590
+ }
591
+ // The `${chr}?` before `num` tolerates at most one boundary at the num head.
592
+ if (boundaryCountAt(view, numStart) > 1)
593
+ return false;
594
+ // v4's `num` stops at the first interior boundary; the clean run up to it must
595
+ // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
596
+ const numEnd = match.index + match[0].length;
597
+ let truncatedEnd = numEnd;
598
+ for (const b of view.boundaries) {
599
+ if (b > numStart && b < numEnd) {
600
+ truncatedEnd = b;
601
+ break;
602
+ }
603
+ if (b >= numEnd)
604
+ break;
605
+ }
606
+ return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd));
607
+ }
608
+ 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}]`);
610
+ /**
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.
617
+ */
618
+ function minusDirectNegative(view) {
619
+ pass(view, cachedRegExp(`-(?<num>\\d*\\.?\\d+)`, "gm"), (match, v) => {
620
+ 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
623
+ // `\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.
626
+ const numEnd = start + match[0].length;
627
+ let numTruncEnd = numEnd;
628
+ for (const b of v.boundaries) {
629
+ if (b > start && b < numEnd) {
630
+ numTruncEnd = b;
631
+ break;
632
+ }
633
+ if (b >= numEnd)
634
+ break;
635
+ }
636
+ if (!NUM_BODY_RE.test(v.text.slice(start + 1, numTruncEnd)))
637
+ return null;
638
+ // A boundary directly before the hyphen routes to Pattern 2b; otherwise
639
+ // Pattern 2 examines the clean preceding char. (The two never overlap: a
640
+ // hyphen has either a boundary or a clean char immediately before it.)
641
+ const nb = boundaryCountAt(v, start);
642
+ 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.
646
+ if (nb < 2) {
647
+ const before = v.text[start - 1];
648
+ if (before !== undefined && NUM_BEFORE_2B_BLOCK_RE.test(before))
649
+ return null;
650
+ }
651
+ }
652
+ else {
653
+ // Pattern 2: line start, whitespace, `(`, or a quote immediately left.
654
+ const before = v.text[start - 1];
655
+ const atLineStart = before === undefined || before === "\n";
656
+ if (!atLineStart && !MINUS_BEFORE_CLASS_RE.test(before))
657
+ return null;
658
+ }
659
+ v.replace(start, start + 1, MINUS);
660
+ return null;
661
+ }, { allowBoundaries: () => true });
126
662
  }
127
- function convertParentheticalDashes(text, sep, style) {
663
+ // ---------------------------------------------------------------------------
664
+ // Parenthetical dashes
665
+ // ---------------------------------------------------------------------------
666
+ function convertParentheticalDashes(view, style) {
128
667
  const localizedDash = style === "british" ? EN_DASH : EM_DASH;
129
668
  const maybeSpace = style === "british" ? " " : "";
130
- const escapedSep = getEscapedSeparator({ separator: sep });
131
- // Convert spaced dashes: "word - word" or "word — word"
132
- // When a separator follows the dash, preserve trailing spaces (they belong to the next text segment).
133
- // A single plain hyphen directly before a word character (through optional separators) is a
134
- // suspended/hanging hyphen ("Yes-men and -women"), not a parenthetical dash — skip it.
135
- // Em/en dashes and multiple hyphens can have zero trailing spaces.
136
- // `sepAfter` and `trailing` share one optional unit so a whitespace run
137
- // can only be assigned to it as a whole, keeping the match unambiguous.
138
- const spacedDashPattern = cachedRegExp(`(?<=[^\\s]|^)(?<sepBefore>${escapedSep}?)[ ]+(?:[${EN_DASH}${EM_DASH}][-${EN_DASH}${EM_DASH}]*|-{2,}|-(?!${escapedSep}*[${LATIN_LETTERS}\\d]))(?!${escapedSep}?-*${escapedSep}?>)[ ]*(?:(?<sepAfter>${escapedSep})(?<trailing>[ ]*))?(?=\\S|$)`, "g");
139
- text = text.replace(spacedDashPattern, (_match, sepBefore, sepAfter, trailing) => {
140
- // sepAfter and trailing are undefined when the optional group didn't match.
141
- sepAfter = sepAfter ?? "";
142
- trailing = trailing ?? "";
143
- // Trailing space after a separator belongs to the next text node — keep it
144
- // regardless of style. Without a separator, the trailing space is part of
145
- // the current node's whitespace around the dash and gets consumed.
146
- const keepTrailing = sepAfter ? trailing : "";
147
- return `${sepBefore}${maybeSpace}${localizedDash}${maybeSpace}${sepAfter}${keepTrailing}`;
148
- });
149
- // Convert dashes at text node boundaries: separator alone precedes dash (e.g., "word{sep}– rest")
150
- // Requires space after the dash to avoid matching number ranges like "1{sep}-{sep}5"
151
- text = text.replace(cachedRegExp(`(?<=[^\\s])(?<sepBefore>${escapedSep})[${EN_DASH}${EM_DASH}-]+[ ]+(?<sepAfter>${escapedSep}?)`, "g"), `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`);
152
- // Convert multiple dashes: "word--word" or "word---word" or "quote"--"quote"
153
- // Upper bound of 50 prevents ReDoS on pathological runs of dashes.
154
- const quoteChars = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`;
155
- text = text.replace(cachedRegExp(`(?<=[${LATIN_LETTERS}\\d${quoteChars}])(?<sepBefore>${escapedSep}?)[${EN_DASH}${EM_DASH}-]{2,50}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}${quoteChars} ])`, "g"), `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`);
156
- // Convert dashes at start of line
157
- text = text.replace(cachedRegExp(`^(?<leadingSep>${escapedSep})?[-]+ `, "gm"), `$<leadingSep>${localizedDash} `);
158
- // British: convert unspaced em-dashes to spaced en-dashes (word—word → word – word)
159
- if (style === "british") {
160
- text = text.replace(cachedRegExp(`(?<=[${LATIN_LETTERS}.!?'"])(?<sepBefore>${escapedSep}?)${EM_DASH}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}])`, "g"), `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`);
669
+ const rendered = `${maybeSpace}${localizedDash}${maybeSpace}`;
670
+ convertSpacedDashes(view, rendered);
671
+ convertBoundaryLedDashes(view, rendered);
672
+ convertMultipleDashes(view, rendered);
673
+ convertLeadingLineDashes(view, localizedDash);
674
+ if (style === "british")
675
+ convertUnspacedEmDashes(view, rendered);
676
+ }
677
+ const QUOTE_CHARS = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`;
678
+ const DASH_RUN_CHARS = new Set(["-", EN_DASH, EM_DASH]);
679
+ function isDashChar(ch) {
680
+ return ch !== undefined && DASH_RUN_CHARS.has(ch);
681
+ }
682
+ /**
683
+ * Spaced dashes: "word - word" / "word — word". A single plain hyphen directly
684
+ * before a word char (the suspended/hanging hyphen, "Yes-men and -women") is
685
+ * skipped. The arrow guard rejects shapes like "- >" so `foo -> bar` stays.
686
+ *
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.
692
+ */
693
+ 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
696
+ // clean char to the left is whitespace. The cheap `(?<![ ])` anchor keeps each
697
+ // `[ ]+` run starting at its leftmost space, avoiding quadratic backtracking.
698
+ 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.
702
+ let consumedThrough = -1;
703
+ pass(view, cachedRegExp(pattern, "g"), (match, v) => {
704
+ const text = v.text;
705
+ const groups = namedGroups(matchArgs(match));
706
+ 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
709
+ // boundary inside the leading spaces restarts the match just after it (the
710
+ // spaces before it stay with the previous node). The previous match's
711
+ // consumed span is also a floor: leading spaces it already consumed are not
712
+ // re-used.
713
+ let matchStart = Math.max(match.index, consumedThrough);
714
+ for (const b of view.boundaries) {
715
+ if (b > matchStart && b <= firstDash - 1)
716
+ matchStart = b;
717
+ else if (b > firstDash)
718
+ break;
719
+ }
720
+ /* istanbul ignore if -- defensive: the previous match's trailing `(?=\S|$)`
721
+ stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
722
+ if (matchStart >= firstDash + 1)
723
+ return null;
724
+ // Validate the lookbehind at matchStart: start of text/line, a boundary
725
+ // immediately left (separator), or a non-whitespace clean char.
726
+ const leftOk = matchStart === 0
727
+ || view.hasBoundary(matchStart)
728
+ || (text[matchStart - 1] !== undefined && !/\s/.test(text[matchStart - 1]));
729
+ if (!leftOk)
730
+ return null;
731
+ // 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
733
+ // none, which is the boundary-led pattern's job, not this one.
734
+ if (matchStart === firstDash)
735
+ 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.
739
+ if (view.hasBoundary(firstDash))
740
+ return null;
741
+ // Maximal dash run from firstDash that does not cross a boundary (v4's core
742
+ // never spanned a separator).
743
+ let fullRunEnd = firstDash + 1;
744
+ while (isDashChar(text[fullRunEnd]) && !v.hasBoundary(fullRunEnd))
745
+ fullRunEnd++;
746
+ // v4's dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
747
+ // guard AND the trailing `[ ]*(?=\S|$)`, so try run ends longest-first and
748
+ // take the first one whose whole tail validates.
749
+ const chosen = chooseSpacedDashRun(v, firstDash, fullRunEnd);
750
+ if (chosen === null)
751
+ return null;
752
+ consumedThrough = chosen.consumedEnd;
753
+ v.replace(matchStart, chosen.editEnd, rendered);
754
+ return null;
755
+ }, { allowBoundaries: () => true });
756
+ }
757
+ /**
758
+ * Picks v4's dashCore from the maximal dash run [firstDash, fullRunEnd). v4 tries
759
+ * `[EN EM][-EN EM]*`, then `-{2,}` (greedy), then a single `-`, backtracking past
760
+ * the arrow guard and the trailing `[ ]*(?=\S|$)`. Returns the chosen run end and
761
+ * the trailing-spaces end (`scan`) to replace, or null when no branch validates.
762
+ */
763
+ function chooseSpacedDashRun(view, firstDash, fullRunEnd) {
764
+ const text = view.text;
765
+ const first = text[firstDash];
766
+ if (first === EN_DASH || first === EM_DASH) {
767
+ // `[EN EM][-EN EM]*`: the leading EN/EM is mandatory, the `[-EN EM]*` tail is
768
+ // greedy but backtracks past the arrow guard — e.g. "–-{sep}{sep}>" gives the
769
+ // `-` back so the run is just "–" (not an arrow) and converts.
770
+ for (let end = fullRunEnd; end > firstDash; end--) {
771
+ if (spacedArrowAhead(view, end))
772
+ continue;
773
+ const trailing = spacedTrailingEnd(view, end);
774
+ if (trailing !== null)
775
+ return trailing;
776
+ }
777
+ return null;
161
778
  }
162
- return text;
779
+ // v4's `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
780
+ // run stops at the first EN/EM dash (e.g. "-–" is a single `-` core, not a
781
+ // two-dash run that would swallow the EN dash).
782
+ let hyphenRunEnd = firstDash;
783
+ while (text[hyphenRunEnd] === "-" && hyphenRunEnd < fullRunEnd)
784
+ hyphenRunEnd++;
785
+ // All hyphens. Try `-{2,}` lengths longest-first, then a single `-`.
786
+ for (let end = hyphenRunEnd; end > firstDash; end--) {
787
+ if (end - firstDash === 1) {
788
+ // Single `-(?!${sep}*[LAT\d])`: the next clean char must not be a letter or
789
+ // digit (the suspended-hyphen guard).
790
+ const next = text[end];
791
+ if (next !== undefined && /[a-z\d]/i.test(next))
792
+ return null;
793
+ }
794
+ if (spacedArrowAhead(view, end))
795
+ continue;
796
+ const trailing = spacedTrailingEnd(view, end);
797
+ if (trailing !== null)
798
+ return trailing;
799
+ }
800
+ return null;
163
801
  }
802
+ /**
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.
807
+ */
808
+ function spacedTrailingEnd(view, runEnd) {
809
+ const text = view.text;
810
+ let editEnd = runEnd;
811
+ while (text[editEnd] === " " && !view.hasBoundary(editEnd))
812
+ 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.
817
+ if (boundaryCountAt(view, editEnd) === 1) {
818
+ let consumedEnd = editEnd;
819
+ while (text[consumedEnd] === " " && (consumedEnd === editEnd || !view.hasBoundary(consumedEnd)))
820
+ consumedEnd++;
821
+ if (lookaheadNonSpace(view, consumedEnd))
822
+ return { editEnd, consumedEnd };
823
+ }
824
+ if (lookaheadNonSpace(view, editEnd))
825
+ return { editEnd, consumedEnd: editEnd };
826
+ return null;
827
+ }
828
+ /** v4's `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
829
+ function lookaheadNonSpace(view, pos) {
830
+ return pos === view.text.length || view.hasBoundary(pos) || !/\s/.test(view.text[pos]);
831
+ }
832
+ /**
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).
838
+ */
839
+ function spacedArrowAhead(view, runEnd) {
840
+ const text = view.text;
841
+ let h = runEnd;
842
+ while (text[h] === "-") {
843
+ if (h > runEnd && view.hasBoundary(h))
844
+ return false; // boundary inside -*
845
+ h++;
846
+ }
847
+ if (h === runEnd) {
848
+ // No hyphens: both ${sep}? slots fall at this offset (≤2 boundaries total).
849
+ if (boundaryCountAt(view, runEnd) > 2)
850
+ return false;
851
+ }
852
+ else {
853
+ if (boundaryCountAt(view, runEnd) > 1)
854
+ return false; // first ${sep}?
855
+ if (boundaryCountAt(view, h) > 1)
856
+ return false; // second ${sep}?
857
+ }
858
+ return text[h] === ">";
859
+ }
860
+ /**
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).
869
+ */
870
+ function convertBoundaryLedDashes(view, rendered) {
871
+ const text = view.text;
872
+ const edits = [];
873
+ let lastProcessedEnd = -1;
874
+ let prevOffset = -1;
875
+ let stackedHere = 0;
876
+ for (const b of view.boundaries) {
877
+ stackedHere = b === prevOffset ? stackedHere + 1 : 1;
878
+ prevOffset = b;
879
+ if (b < lastProcessedEnd)
880
+ continue;
881
+ if (!isDashChar(text[b]))
882
+ continue;
883
+ // Non-space before the separator: ≥2 stacked separators, or a non-space
884
+ // clean char immediately left.
885
+ if (stackedHere < 2) {
886
+ const before = text[b - 1];
887
+ if (before === undefined || /\s/.test(before))
888
+ continue;
889
+ }
890
+ // Dash run from the boundary, not crossing a further boundary.
891
+ let runEnd = b + 1;
892
+ while (isDashChar(text[runEnd]) && !view.hasBoundary(runEnd))
893
+ runEnd++;
894
+ // Required trailing space run; stop at a boundary (sepAfter).
895
+ let spaceEnd = runEnd;
896
+ while (text[spaceEnd] === " " && !view.hasBoundary(spaceEnd))
897
+ spaceEnd++;
898
+ if (spaceEnd === runEnd)
899
+ continue; // no trailing space
900
+ edits.push([b, spaceEnd]);
901
+ lastProcessedEnd = spaceEnd;
902
+ }
903
+ for (const [from, to] of edits)
904
+ view.replace(from, to, rendered);
905
+ view.commit();
906
+ }
907
+ /**
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.
914
+ */
915
+ function convertMultipleDashes(view, rendered) {
916
+ const before = `[${LATIN_LETTERS}\\d${QUOTE_CHARS}]`;
917
+ const after = `[${LATIN_LETTERS}${QUOTE_CHARS} ]`;
918
+ // Upper bound of 50 prevents ReDoS on pathological runs of dashes.
919
+ const pattern = `(?<=${before})[${EN_DASH}${EM_DASH}-]{2,50}(?=${after})`;
920
+ const afterRe = new RegExp(after);
921
+ pass(view, cachedRegExp(pattern, "g"), (match, v) => {
922
+ const text = v.text;
923
+ const start = match.index;
924
+ // Two stacked boundaries before the run exceed sepBefore's one-sep budget.
925
+ if (boundaryCountAt(v, start) >= 2)
926
+ return null;
927
+ // Truncate the run at the first interior boundary (v4's class stops there).
928
+ let runEnd = start + 1;
929
+ while (isDashChar(text[runEnd]) && !v.hasBoundary(runEnd))
930
+ runEnd++;
931
+ if (runEnd - start < 2 || runEnd - start > 50)
932
+ return null;
933
+ // sepAfter tolerates one boundary; v4's trailing lookahead then checks the
934
+ // clean char after the run is [LAT quote space].
935
+ if (boundaryCountAt(v, runEnd) >= 2)
936
+ return null;
937
+ const next = text[runEnd];
938
+ if (next === undefined || !afterRe.test(next))
939
+ return null;
940
+ v.replace(start, runEnd, rendered);
941
+ return null;
942
+ }, { allowBoundaries: () => true });
943
+ }
944
+ /**
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.
950
+ */
951
+ function convertLeadingLineDashes(view, localizedDash) {
952
+ pass(view, cachedRegExp(`^-+ `, "gm"), () => `${localizedDash} `);
953
+ }
954
+ /** British: unspaced em-dashes between letters become spaced en-dashes. */
955
+ function convertUnspacedEmDashes(view, rendered) {
956
+ const pattern = `(?<=[${LATIN_LETTERS}.!?'"])${EM_DASH}(?=[${LATIN_LETTERS}])`;
957
+ 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.
960
+ if (boundaryCountAt(v, match.index) > 1)
961
+ return null;
962
+ if (boundaryCountAt(v, match.index + match[0].length) > 1)
963
+ return null;
964
+ v.replace(match.index, match.index + match[0].length, rendered);
965
+ return null;
966
+ });
967
+ }
968
+ // ---------------------------------------------------------------------------
969
+ // Em-dash spacing normalization (american)
970
+ // ---------------------------------------------------------------------------
164
971
  // TODO: Handle interrupted-then-resumed speech within quotes, where Chicago
165
972
  // allows a space after the dash: "Don't inter— Hey! Who threw that?"
166
- function normalizeEmDashSpacing(text, sep) {
167
- const escapedSep = getEscapedSeparator({ separator: sep });
168
- // Remove all spaces around em-dashes. The `\S|^` / `\S|$` boundary
169
- // anchors restrict the match to dashes adjacent to non-whitespace.
170
- text = text.replace(cachedRegExp(`(?<=\\S|^)(?<before>${escapedSep}?)[ ]*${EM_DASH}[ ]*(?<after>${escapedSep}?)(?=\\S|$)`, "g"), `$<before>${EM_DASH}$<after>`);
171
- // Preserve space after em-dash at start of line (e.g., attribution)
172
- text = text.replace(cachedRegExp(`^(?<sep>${escapedSep}?)${EM_DASH}(?<after>[A-Z0-9])`, "gm"), `$<sep>${EM_DASH} $<after>`);
173
- return text;
973
+ function normalizeEmDashSpacing(view) {
974
+ removeSpacesAroundEmDash(view);
975
+ preserveLineLeadingEmDashSpace(view);
174
976
  }
977
+ /**
978
+ * 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.
985
+ */
986
+ function removeSpacesAroundEmDash(view) {
987
+ pass(view, cachedRegExp(EM_DASH, "g"), (match, v) => {
988
+ const d = match.index;
989
+ const text = v.text;
990
+ // Leading spaces, stopping at a boundary or non-space.
991
+ let left = d;
992
+ while (left > 0 && text[left - 1] === " " && !v.hasBoundary(left))
993
+ left--;
994
+ // Trailing spaces, stopping at a boundary or non-space.
995
+ let right = d + 1;
996
+ while (right < text.length && text[right] === " " && !v.hasBoundary(right))
997
+ right++;
998
+ const leftAnchored = left === 0 || v.hasBoundary(left) || !/\s/.test(text[left - 1]);
999
+ const rightAnchored = right === text.length || v.hasBoundary(right) || !/\s/.test(text[right]);
1000
+ if (!leftAnchored || !rightAnchored)
1001
+ return null;
1002
+ // Drop the adjacent space runs; the em-dash and any edge boundaries stay.
1003
+ if (left < d)
1004
+ v.replace(left, d, "");
1005
+ if (right > d + 1)
1006
+ v.replace(d + 1, right, "");
1007
+ return null;
1008
+ });
1009
+ }
1010
+ /** Preserve a space after a line-leading em-dash before an uppercase/digit. */
1011
+ function preserveLineLeadingEmDashSpace(view) {
1012
+ 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.
1015
+ if (boundaryCountAt(v, match.index) > 1)
1016
+ return null;
1017
+ const groups = namedGroups(matchArgs(match));
1018
+ return `${EM_DASH} ${groups.after}`;
1019
+ });
1020
+ }
1021
+ // ---------------------------------------------------------------------------
1022
+ // Public dash pipeline
1023
+ // ---------------------------------------------------------------------------
175
1024
  export function hyphenReplace(text, options = {}) {
176
- const sep = options.separator ?? DEFAULT_SEPARATOR;
1025
+ const separator = options.separator ?? DEFAULT_SEPARATOR;
177
1026
  const style = options.dashStyle ?? "american";
178
1027
  if (style === "none")
179
1028
  return text;
180
1029
  text = minusReplace(text, options);
181
1030
  text = enDashDateRange(text, options);
182
1031
  text = enDashNumberRange(text, options);
183
- text = convertParentheticalDashes(text, sep, style);
184
- if (style === "american")
185
- text = normalizeEmDashSpacing(text, sep);
1032
+ text = withProseView(text, separator, (view) => {
1033
+ convertParentheticalDashes(view, style);
1034
+ if (style === "american")
1035
+ normalizeEmDashSpacing(view);
1036
+ });
186
1037
  return text;
187
1038
  }
188
1039
  const { WORD_JOINER } = UNICODE_SYMBOLS;
@@ -205,4 +1056,8 @@ export function dashWordJoiner(text) {
205
1056
  const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu");
206
1057
  return text.replace(re, `${WORD_JOINER}$&`);
207
1058
  }
1059
+ /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
1060
+ function matchArgs(match) {
1061
+ return [...match, match.index, match.input, match.groups];
1062
+ }
208
1063
  //# sourceMappingURL=dashes.js.map