punctilio 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +36 -22
  2. package/dist/constants.d.ts +0 -34
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -49
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +1170 -176
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +52 -1
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +179 -17
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -96
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +6 -2
  53. package/src/constants.ts +0 -56
  54. package/src/dashes.ts +1217 -208
  55. package/src/index.ts +71 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +250 -25
  59. package/src/quote-classifier.ts +618 -97
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
package/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, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js";
2
+ import { boundaryCountAt, overInput, replaceAllInView } from "./prose-view.js";
2
3
  import { namedGroups } from "./utils.js";
3
4
  export const DASH_STYLES = ["american", "british", "none"];
4
- 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}`;
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;
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,187 +17,1186 @@ const months = [
22
17
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
23
18
  ];
24
19
  const monthPattern = months.join("|");
25
- /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
26
- 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 ?? ""}`;
20
+ const WORD_CHAR_RE = /\w/;
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]);
61
+ /**
62
+ * Runs one boundary-gated regex pass over the view and commits it immediately.
63
+ * Committing after every pass sequences the passes so a later pass sees the
64
+ * earlier edits and never queues an overlapping edit.
65
+ */
66
+ function pass(view, regex, replacer, options) {
67
+ replaceAllInView(view, regex, replacer, options);
68
+ view.commit();
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Boundary-tolerance helpers over a ProseView
72
+ //
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.
80
+ // ---------------------------------------------------------------------------
81
+ /** Interior boundary offsets of a match, strictly inside (matchStart, matchEnd). */
82
+ function interiorBoundaryOffsets(match, view) {
83
+ const start = match.index;
84
+ const end = match.index + match[0].length;
85
+ const offsets = [];
86
+ for (const boundary of view.boundaries) {
87
+ if (boundary > start && boundary < end)
88
+ offsets.push(boundary);
89
+ else if (boundary >= end)
90
+ break;
91
+ }
92
+ return offsets;
93
+ }
94
+ /**
95
+ * True iff every interior boundary of the match lands on a tolerated offset,
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.
99
+ */
100
+ function interiorBoundariesAllowed(match, view, tolerated) {
101
+ const counts = new Map();
102
+ for (const offset of interiorBoundaryOffsets(match, view)) {
103
+ counts.set(offset, (counts.get(offset) ?? 0) + 1);
104
+ }
105
+ for (const [offset, count] of counts) {
106
+ const budget = tolerated.get(offset);
107
+ if (budget === undefined || count > budget)
108
+ return false;
109
+ }
110
+ return true;
111
+ }
112
+ /** Separators tolerated by the word-boundary helpers (mirrors constants.ts). */
113
+ const MAX_BOUNDARY_SEPARATORS = 3;
114
+ /**
115
+ * Reproduces `wordBoundaryStart` = `(?<!\w${sep}{0,3})\b` at the marked position
116
+ * `matchStart` (the start char is a word char — a digit). The `\b` requires a
117
+ * non-word char to its left (a separator counts as non-word); the negative
118
+ * lookbehind additionally blocks when a word char sits within
119
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the left.
120
+ */
121
+ function wordBoundaryStartOk(view, matchStart) {
122
+ if (matchStart === 0)
123
+ return true;
124
+ const nb = boundaryCountAt(view, matchStart);
125
+ const prevWord = isWordChar(view.text[matchStart - 1]);
126
+ if (nb === 0)
127
+ return !prevWord; // `\b` needs a non-word char immediately left
128
+ // A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
129
+ // when a word char is within ≤MAX boundaries.
130
+ return !prevWord || nb > MAX_BOUNDARY_SEPARATORS;
131
+ }
132
+ /**
133
+ * Reproduces `wordBoundaryEnd` = `\b(?!${sep}{0,3}\w)` at the marked position
134
+ * `pos`. The `\b` requires a word/non-word transition (a separator counts as
135
+ * non-word); the negative lookahead blocks when a word char sits within
136
+ * {@link MAX_BOUNDARY_SEPARATORS} stacked boundaries to the right.
137
+ */
138
+ function wordBoundaryEndOk(view, pos) {
139
+ const text = view.text;
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
+ }
149
+ const nb = boundaryCountAt(view, pos);
150
+ const nextCleanWord = isWordChar(text[pos]);
151
+ // The marked char right after pos is a separator when nb > 0 (non-word).
152
+ const rightWord = nb > 0 ? false : nextCleanWord;
153
+ if (leftWord === rightWord)
154
+ return false; // no `\b`
155
+ // `(?!${sep}{0,3}\w)`: a word char within ≤3 boundaries to the right blocks.
156
+ return !(nb <= MAX_BOUNDARY_SEPARATORS && nextCleanWord);
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // Number ranges
160
+ // ---------------------------------------------------------------------------
161
+ const CURRENCY_RE = /[$€£¥₹]/;
162
+ export function enDashNumberRange(input) {
163
+ return overInput(input, (view) => {
164
+ convertPositiveRanges(view);
165
+ convertNegativeRanges(view);
86
166
  });
87
- return text;
88
167
  }
89
- /** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
90
- export function enDashDateRange(text, options = {}) {
91
- const dashStyle = options.dashStyle ?? "american";
168
+ /**
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
175
+ * number whose head fails to form a structural match (e.g. boundary-broken
176
+ * "2024-01-15") still exposes a later sub-range ("01-15"), while a structurally
177
+ * matching but non-converting number hides its inner dashes.
178
+ */
179
+ function convertPositiveRanges(view) {
180
+ const text = view.text;
181
+ let i = 0;
182
+ while (i < text.length) {
183
+ const span = matchPositiveRangeAt(view, i);
184
+ i = span === null ? i + 1 : Math.max(span, i + 1);
185
+ }
186
+ view.commit();
187
+ }
188
+ /**
189
+ * Attempts the `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
190
+ * \b` structural match beginning at clean offset `i`, converting the dash when
191
+ * the gates pass. Returns the consumed end offset on a structural match (so the
192
+ * scan skips it), or null when no structural match begins at `i`.
193
+ */
194
+ function matchPositiveRangeAt(view, i) {
195
+ // Optional phoneAreaCode (`\d{3}-` or `(\d{3}) `); when it ends in a dash that
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.
200
+ const areaCode = readPhoneAreaCode(view, i);
201
+ const startStarts = areaCode === null || view.hasBoundary(i + areaCode) ? [i] : [i + areaCode, i];
202
+ for (const startStart of startStarts) {
203
+ const span = matchRangeBody(view, startStart);
204
+ if (span !== null)
205
+ return span;
206
+ }
207
+ return null;
208
+ }
209
+ const THREE_DIGITS_RE = /^\d\d\d/;
210
+ const THREE_DIGITS_IN_PARENS_RE = /^\(\d\d\d\)/;
211
+ /** `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
212
+ function readPhoneAreaCode(view, i) {
213
+ const text = view.text;
214
+ // `\d{3}-` with no boundary inside the digits or before the dash.
215
+ if (THREE_DIGITS_RE.test(text.slice(i, i + 3)) && text[i + 3] === "-"
216
+ && !view.hasBoundary(i + 1) && !view.hasBoundary(i + 2) && !view.hasBoundary(i + 3)) {
217
+ return 4;
218
+ }
219
+ // `(\d{3})` with an optional trailing space.
220
+ if (THREE_DIGITS_IN_PARENS_RE.test(text.slice(i, i + 5))) {
221
+ return text[i + 5] === " " ? 6 : 5;
222
+ }
223
+ return null;
224
+ }
225
+ /**
226
+ * Matches `\b rangeStart -{1,3} rangeEnd following* suffix? \b` boundary-aware,
227
+ * beginning the start at `startStart`. Converts the dash on a converting match.
228
+ * Returns the consumed end offset (structural span end) or null.
229
+ */
230
+ function matchRangeBody(view, startStart) {
231
+ const text = view.text;
232
+ // rangeStart = `(?:p\.?|[currency])?\d[\d.,]*`, anchored at a `\b`.
233
+ const start = readStartRun(view, startStart);
234
+ if (start === null)
235
+ return null;
236
+ const startSpanEnd = startStart + start.length;
237
+ // One slot between start and the dash tolerates a single boundary there.
238
+ if (boundaryCountAt(view, startSpanEnd) > 1)
239
+ return null;
240
+ // Dash run of 1..3 hyphens that never spans an interior boundary (a boundary
241
+ // between two hyphens truncates the run).
242
+ if (text[startSpanEnd] !== "-")
243
+ return null;
244
+ let dashEnd = startSpanEnd + 1;
245
+ while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd))
246
+ dashEnd++;
247
+ if (boundaryCountAt(view, dashEnd) > 1)
248
+ return null;
249
+ // `readStartRun` already stops at the first interior boundary, so the start
250
+ // span is boundary-free and its clean digits are the whole run.
251
+ const startDigits = text.slice(startStart, startSpanEnd);
252
+ if (!wordBoundaryStartOk(view, startStart))
253
+ return null;
254
+ if (!notAfterDashOk(view, startStart))
255
+ return null;
256
+ const end = readEndRun(view, dashEnd);
257
+ if (end === null)
258
+ return null;
259
+ // The greedy end `[\d.,]*` and greedy `following*` backtrack together; the
260
+ // structural match commits at the longest end whose tail completes.
261
+ for (let len = end.length; len >= end.minLen; len--) {
262
+ const endPos = end.firstDigitStart + len;
263
+ const tail = resolvePositiveTail(view, endPos);
264
+ if (tail === null)
265
+ continue;
266
+ const endStr = text.slice(dashEnd, endPos);
267
+ if (tail.followingEmpty && rangeNumbersConvert(startDigits, endStr)) {
268
+ view.replace(startSpanEnd, dashEnd, EN_DASH);
269
+ }
270
+ return tail.consumedEnd;
271
+ }
272
+ return null;
273
+ }
274
+ /**
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
277
+ * alternative is unreachable: a leading currency symbol is non-word, and the only
278
+ * `\b` sits after it, at the digit — so the currency is never part of `start`
279
+ * (e.g. "$100-2000" reads start "100", which the 3+4 phone gate then blocks). The
280
+ * `p`/`p.` prefix is a word char, so it can begin the run at a word boundary.
281
+ */
282
+ function readStartRun(view, pos) {
283
+ const text = view.text;
284
+ let i = pos;
285
+ if (text[i] === "p") {
286
+ // `p\.?\d` is contiguous (no interior slot), so a boundary between `p`, an
287
+ // optional `.`, and the first digit breaks the prefix.
288
+ if (view.hasBoundary(i + 1))
289
+ return null;
290
+ i++;
291
+ if (text[i] === ".") {
292
+ if (view.hasBoundary(i + 1))
293
+ return null;
294
+ i++;
295
+ }
296
+ }
297
+ // `\d` is required after the optional `p\.?` prefix. The prefix chars are
298
+ // never digits, so a missing digit here means no start run begins.
299
+ if (!/\d/.test(text[i] ?? ""))
300
+ return null;
301
+ i++;
302
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i))
303
+ i++;
304
+ return { length: i - pos };
305
+ }
306
+ /** The `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
307
+ function readEndRun(view, pos) {
308
+ const text = view.text;
309
+ let i = pos;
310
+ if (CURRENCY_RE.test(text[i] ?? "")) {
311
+ // No slot between the currency symbol and the digit.
312
+ if (view.hasBoundary(i + 1))
313
+ return null;
314
+ i++;
315
+ }
316
+ if (!/\d/.test(text[i] ?? ""))
317
+ return null;
318
+ const firstDigitStart = i;
319
+ i++;
320
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i))
321
+ i++;
322
+ return { firstDigitStart, length: i - firstDigitStart, minLen: 1 };
323
+ }
324
+ /**
325
+ * Resolves the tail at `endPos`: finds the longest `following` whose
326
+ * `suffix? wbe` completes (the greedy choice). `followingEmpty` reports whether
327
+ * that following is empty (the convert condition); `consumedEnd` is the tail's
328
+ * end including any suffix (for the scan's consume/advance). Returns null when
329
+ * no following completes.
330
+ */
331
+ function resolvePositiveTail(view, endPos) {
332
+ const candidates = followingCandidates(view, endPos, true);
333
+ for (let i = candidates.length - 1; i >= 0; i--) {
334
+ const suffixEnd = suffixWbeEnd(view, candidates[i], true);
335
+ if (suffixEnd !== null) {
336
+ return { followingEmpty: i === 0, consumedEnd: suffixEnd };
337
+ }
338
+ }
339
+ return null;
340
+ }
341
+ /**
342
+ * Negative ranges: −5-5 → −5–5, −5--2 → −5–−2. A separate hand scan (not a regex)
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
346
+ * "80") and mis-place or miss the conversion. The scan mirrors `matchRangeBody`:
347
+ * at each MINUS-led start it resolves the longest end whose `following* suffix?
348
+ * wbe` tail completes, converting only when that tail leaves `following` empty.
349
+ */
350
+ function convertNegativeRanges(view) {
351
+ const text = view.text;
352
+ let i = 0;
353
+ while (i < text.length) {
354
+ if (text[i] === MINUS) {
355
+ const span = matchNegativeRangeAt(view, i);
356
+ if (span !== null) {
357
+ i = Math.max(span, i + 1);
358
+ continue;
359
+ }
360
+ }
361
+ i++;
362
+ }
363
+ view.commit();
364
+ }
365
+ /**
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
368
+ * pass. Returns the consumed structural end offset, or null when no match begins.
369
+ */
370
+ function matchNegativeRangeAt(view, i) {
371
+ const text = view.text;
372
+ // `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
373
+ // boundary, which would hide it) blocks the match.
374
+ if (boundaryCountAt(view, i) === 0) {
375
+ const before = text[i - 1];
376
+ if (before !== undefined && LATIN_LETTER_RE.test(before))
377
+ return null;
378
+ }
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).
381
+ if (!/\d/.test(text[i + 1] ?? ""))
382
+ return null;
383
+ let startEnd = i + 1;
384
+ while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd))
385
+ startEnd++;
386
+ // One slot between start and the dash tolerates a single boundary there.
387
+ if (boundaryCountAt(view, startEnd) > 1)
388
+ return null;
389
+ if (text[startEnd] !== "-")
390
+ return null;
391
+ // `-{1,3}?` then optional `(?<neg>-)`: a 1..3 hyphen run with no interior
392
+ // boundary, the last of which becomes `neg` when two or more hyphens are present.
393
+ let dashRunEnd = startEnd + 1;
394
+ while (text[dashRunEnd] === "-" && dashRunEnd - startEnd < 3 && !view.hasBoundary(dashRunEnd))
395
+ dashRunEnd++;
396
+ const dashRunLen = dashRunEnd - startEnd;
397
+ const hasNeg = dashRunLen >= 2;
398
+ const negEnd = dashRunEnd;
399
+ // The `end` head slot tolerates one boundary.
400
+ if (boundaryCountAt(view, negEnd) > 1)
401
+ return null;
402
+ const end = readNegEndRun(view, negEnd);
403
+ if (end === null)
404
+ return null;
405
+ // The greedy end and `following*` backtrack together; commit at the longest end
406
+ // whose tail completes, converting only when that tail's `following` is empty.
407
+ for (let len = end.length; len >= 1; len--) {
408
+ const endPos = end.firstDigitStart + len;
409
+ const candidates = followingCandidates(view, endPos, false);
410
+ for (let c = candidates.length - 1; c >= 0; c--) {
411
+ const suffixEnd = suffixWbeEnd(view, candidates[c], false);
412
+ if (suffixEnd === null)
413
+ continue;
414
+ if (c === 0) {
415
+ view.replace(startEnd, negEnd, `${EN_DASH}${hasNeg ? MINUS : ""}`);
416
+ }
417
+ return suffixEnd;
418
+ }
419
+ }
420
+ return null;
421
+ }
422
+ /** The negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
423
+ function readNegEndRun(view, pos) {
424
+ const text = view.text;
425
+ if (!/\d/.test(text[pos] ?? ""))
426
+ return null;
427
+ let i = pos + 1;
428
+ while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i))
429
+ i++;
430
+ return { firstDigitStart: pos, length: i - pos };
431
+ }
432
+ /**
433
+ * Boundary-aware `following` end positions, shortest (empty) first, walking
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).
437
+ */
438
+ function followingCandidates(view, endSpanEnd, allowMinus) {
439
+ const candidates = [endSpanEnd];
440
+ let pos = endSpanEnd;
441
+ for (;;) {
442
+ if (boundaryCountAt(view, pos) > 1)
443
+ break;
444
+ const dashCh = view.text[pos];
445
+ if (dashCh !== "-" && !(allowMinus && dashCh === MINUS))
446
+ break;
447
+ const digitStart = pos + 1;
448
+ if (boundaryCountAt(view, digitStart) > 1)
449
+ break;
450
+ if (!/\d/.test(view.text[digitStart] ?? ""))
451
+ break;
452
+ let end = digitStart + 1;
453
+ while (/\d/.test(view.text[end] ?? "") && !view.hasBoundary(end))
454
+ end++;
455
+ pos = end;
456
+ candidates.push(pos);
457
+ }
458
+ return candidates;
459
+ }
460
+ /**
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.
473
+ */
474
+ function suffixWbeEnd(view, pos, allowAmPm) {
475
+ if (wordBoundaryEndOk(view, pos))
476
+ return 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`.
480
+ if (boundaryCountAt(view, pos) > 1)
481
+ return null;
482
+ const text = view.text;
483
+ // `[AaPp][Mm]` is contiguous — a boundary between the two letters breaks the
484
+ // suffix.
485
+ if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
486
+ return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null;
487
+ }
488
+ if (RANGE_SUFFIX_RE.test(text[pos] ?? "")) {
489
+ return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null;
490
+ }
491
+ return null;
492
+ }
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}]`);
495
+ /**
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
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.
531
+ */
532
+ function notAfterDashOk(view, startOffset) {
533
+ if (startOffset === 0)
534
+ return true;
535
+ if (boundaryCountAt(view, startOffset) > 0)
536
+ return true;
537
+ const prev = view.text[startOffset - 1];
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);
557
+ }
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. */
571
+ function rangeNumbersConvert(startNum, endNum) {
572
+ if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum))
573
+ return false;
574
+ // Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
575
+ // 555-123-4567). Thousands-grouped endings (1,234 / 1.234) keep their
576
+ // internal separator and so fail /^\d{4}$/, falling through to convert as
577
+ // ranges.
578
+ if (/^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum))
579
+ return false;
580
+ // Skip US toll-free prefix pattern: 1-800, 1-888, 1-877, etc. Only the seven
581
+ // real toll-free codes (800, 888, 877, 866, 855, 844, 833) are blocked, so
582
+ // genuine ranges like 1-850 or 1-810 still en-dash.
583
+ if (/^1$/.test(startNum) && /^8(?:00|88|77|66|55|44|33)$/.test(endNum))
584
+ return false;
585
+ return true;
586
+ }
587
+ export function enDashDateRange(input, options = {}) {
588
+ return overInput(input, (view) => enDashDateRangeOverView(view, options.dashStyle ?? "american"));
589
+ }
590
+ function enDashDateRangeOverView(view, dashStyle) {
92
591
  if (dashStyle === "none")
93
- 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 || ""}`;
592
+ return;
593
+ const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""];
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) => {
620
+ const groups = namedGroups(matchArgs(match));
621
+ const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0);
622
+ // One boundary is tolerated after the start year and one before the end
623
+ // month, bracketing the dash and spaces.
624
+ const dashStart = match.index + startLen;
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
+ },
106
633
  });
107
634
  }
108
- /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
109
- 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;
126
- }
127
- function convertParentheticalDashes(text, sep, style) {
635
+ export function minusReplace(input) {
636
+ return overInput(input, (view) => {
637
+ minusSubtractionOfNegative(view);
638
+ minusSpacedSubtraction(view);
639
+ minusDirectNegative(view);
640
+ });
641
+ }
642
+ /** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
643
+ function minusSubtractionOfNegative(view) {
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).
646
+ pass(view, cachedRegExp(`(?<=\\d) - -(?<num>\\d*\\.?\\d+)`, "g"), (match, v) => {
647
+ // Replace only the ` - -` prefix; `num` (which may carry interior
648
+ // boundaries that truncate it) stays untouched.
649
+ if (!minusSubtractionPrefixOk(match, v, 4))
650
+ return null;
651
+ v.replace(match.index, match.index + 4, ` ${MINUS} ${MINUS}`);
652
+ return null;
653
+ }, { allowBoundaries: () => true });
654
+ }
655
+ function minusSpacedSubtraction(view) {
656
+ // "5 - 3" → "5 − 3"; only after a digit ("5 - 3" not "Safari) - 9").
657
+ pass(view, cachedRegExp(`(?<=\\d) - (?<num>\\d*\\.?\\d+)`, "g"), (match, v) => {
658
+ if (!minusSubtractionPrefixOk(match, v, 3))
659
+ return null;
660
+ v.replace(match.index, match.index + 3, ` ${MINUS} `);
661
+ return null;
662
+ }, { allowBoundaries: () => true });
663
+ }
664
+ const NUM_BODY_RE = /^\d*\.?\d+/;
665
+ /**
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.
675
+ */
676
+ function minusSubtractionPrefixOk(match, view, prefixLen) {
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.
679
+ if (boundaryCountAt(view, match.index) > 1)
680
+ return false;
681
+ const numStart = match.index + prefixLen;
682
+ for (const b of view.boundaries) {
683
+ if (b > match.index && b < numStart)
684
+ return false;
685
+ }
686
+ // The slot before `num` tolerates at most one boundary at the num head.
687
+ if (boundaryCountAt(view, numStart) > 1)
688
+ return false;
689
+ // `num` stops at the first interior boundary; the clean run up to it must
690
+ // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
691
+ const numEnd = match.index + match[0].length;
692
+ let truncatedEnd = numEnd;
693
+ for (const b of view.boundaries) {
694
+ if (b > numStart && b < numEnd) {
695
+ truncatedEnd = b;
696
+ break;
697
+ }
698
+ if (b >= numEnd)
699
+ break;
700
+ }
701
+ return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd));
702
+ }
703
+ const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`);
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}]`);
717
+ /**
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.
724
+ */
725
+ function minusDirectNegative(view) {
726
+ pass(view, cachedRegExp(`-(?<num>\\d*\\.?\\d+)`, "gm"), (match, v) => {
727
+ const start = match.index;
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
730
+ // `\d*\.?\d+`. A boundary right after the hyphen (head) leaves no digits
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.
733
+ const numEnd = start + match[0].length;
734
+ let numTruncEnd = numEnd;
735
+ for (const b of v.boundaries) {
736
+ if (b > start && b < numEnd) {
737
+ numTruncEnd = b;
738
+ break;
739
+ }
740
+ if (b >= numEnd)
741
+ break;
742
+ }
743
+ if (!NUM_BODY_RE.test(v.text.slice(start + 1, numTruncEnd)))
744
+ return null;
745
+ // A boundary directly before the hyphen routes to Pattern 2b; otherwise
746
+ // Pattern 2 examines the clean preceding char. (The two never overlap: a
747
+ // hyphen has either a boundary or a clean char immediately before it.)
748
+ const nb = boundaryCountAt(v, start);
749
+ if (nb > 0) {
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.
753
+ if (nb < 2) {
754
+ const before = v.text[start - 1];
755
+ if (before !== undefined && NUM_BEFORE_2B_BLOCK_RE.test(before))
756
+ return null;
757
+ }
758
+ }
759
+ else {
760
+ // Pattern 2: line start, whitespace, `(`, or a quote immediately left.
761
+ const before = v.text[start - 1];
762
+ const atLineStart = before === undefined || before === "\n";
763
+ if (!atLineStart && !MINUS_BEFORE_CLASS_RE.test(before))
764
+ return null;
765
+ }
766
+ v.replace(start, start + 1, MINUS);
767
+ return null;
768
+ }, { allowBoundaries: () => true });
769
+ }
770
+ // ---------------------------------------------------------------------------
771
+ // Parenthetical dashes
772
+ // ---------------------------------------------------------------------------
773
+ function convertParentheticalDashes(view, style) {
128
774
  const localizedDash = style === "british" ? EN_DASH : EM_DASH;
129
775
  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>`);
776
+ const rendered = `${maybeSpace}${localizedDash}${maybeSpace}`;
777
+ convertSpacedDashes(view, rendered);
778
+ convertBoundaryLedDashes(view, rendered);
779
+ convertMultipleDashes(view, rendered);
780
+ convertLeadingLineDashes(view, localizedDash);
781
+ if (style === "british")
782
+ convertUnspacedEmDashes(view, rendered);
783
+ }
784
+ const QUOTE_CHARS = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`;
785
+ const DASH_RUN_CHARS = new Set(["-", EN_DASH, EM_DASH]);
786
+ function isDashChar(ch) {
787
+ return ch !== undefined && DASH_RUN_CHARS.has(ch);
788
+ }
789
+ /**
790
+ * Spaced dashes: "word - word" / "word — word". A single plain hyphen directly
791
+ * before a word char (the suspended/hanging hyphen, "Yes-men and -women") is
792
+ * skipped. The arrow guard rejects shapes like "- >" so `foo -> bar` stays.
793
+ *
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.
799
+ */
800
+ function convertSpacedDashes(view, rendered) {
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
803
+ // clean char to the left is whitespace. The cheap `(?<![ ])` anchor keeps each
804
+ // `[ ]+` run starting at its leftmost space, avoiding quadratic backtracking.
805
+ const pattern = `(?<![ ])[ ]+(?<dashStart>[-${EN_DASH}${EM_DASH}])`;
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.
809
+ let consumedThrough = -1;
810
+ pass(view, cachedRegExp(pattern, "g"), (match, v) => {
811
+ const text = v.text;
812
+ const groups = namedGroups(matchArgs(match));
813
+ const firstDash = match.index + match[0].length - groups.dashStart.length;
814
+ // The greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
815
+ // holds: line/text start, a non-whitespace clean char, or a boundary. A
816
+ // boundary inside the leading spaces restarts the match just after it (the
817
+ // spaces before it stay with the previous node). The previous match's
818
+ // consumed span is also a floor: leading spaces it already consumed are not
819
+ // re-used.
820
+ let matchStart = Math.max(match.index, consumedThrough);
821
+ for (const b of view.boundaries) {
822
+ if (b > matchStart && b <= firstDash - 1)
823
+ matchStart = b;
824
+ else if (b > firstDash)
825
+ break;
826
+ }
827
+ /* istanbul ignore if -- defensive: the previous match's trailing `(?=\S|$)`
828
+ stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
829
+ if (matchStart >= firstDash + 1)
830
+ return null;
831
+ // Validate the lookbehind at matchStart: start of text/line, a boundary
832
+ // immediately left, or a non-whitespace clean char.
833
+ const leftOk = matchStart === 0
834
+ || view.hasBoundary(matchStart)
835
+ || (text[matchStart - 1] !== undefined && !/\s/.test(text[matchStart - 1]));
836
+ if (!leftOk)
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;
849
+ // The leading spaces between matchStart and the dash must be at least one
850
+ // (`[ ]+` is one-or-more) — a boundary directly before the dash leaves
851
+ // none, which is the boundary-led pattern's job, not this one.
852
+ if (matchStart === firstDash)
853
+ return null;
854
+ // A boundary between the leading spaces and the dash breaks `[ ]+dashCore`
855
+ // (no slot there); neither spaced nor boundary-led matches, so skip.
856
+ if (view.hasBoundary(firstDash))
857
+ return null;
858
+ // Maximal dash run from firstDash that does not cross a boundary (the core
859
+ // never spans a boundary).
860
+ let fullRunEnd = firstDash + 1;
861
+ while (isDashChar(text[fullRunEnd]) && !v.hasBoundary(fullRunEnd))
862
+ fullRunEnd++;
863
+ // The dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
864
+ // guard AND the trailing `[ ]*(?=\S|$)`, so try run ends longest-first and
865
+ // take the first one whose whole tail validates.
866
+ const chosen = chooseSpacedDashRun(v, firstDash, fullRunEnd);
867
+ if (chosen === null)
868
+ return null;
869
+ consumedThrough = chosen.consumedEnd;
870
+ v.replace(matchStart, chosen.editEnd, rendered);
871
+ return null;
872
+ }, { allowBoundaries: () => true });
873
+ }
874
+ /**
875
+ * Picks the dashCore from the maximal dash run [firstDash, fullRunEnd). Tries
876
+ * `[EN EM][-EN EM]*`, then `-{2,}` (greedy), then a single `-`, backtracking past
877
+ * the arrow guard and the trailing `[ ]*(?=\S|$)`. Returns the chosen run end and
878
+ * the trailing-spaces end (`scan`) to replace, or null when no branch validates.
879
+ */
880
+ function chooseSpacedDashRun(view, firstDash, fullRunEnd) {
881
+ const text = view.text;
882
+ const first = text[firstDash];
883
+ if (first === EN_DASH || first === EM_DASH) {
884
+ // `[EN EM][-EN EM]*`: the leading EN/EM is mandatory, the `[-EN EM]*` tail is
885
+ // greedy but backtracks past the arrow guard — e.g. "–-{sep}{sep}>" gives the
886
+ // `-` back so the run is just "–" (not an arrow) and converts.
887
+ for (let end = fullRunEnd; end > firstDash; end--) {
888
+ if (spacedArrowAhead(view, end))
889
+ continue;
890
+ const trailing = spacedTrailingEnd(view, end);
891
+ if (trailing !== null)
892
+ return trailing;
893
+ }
894
+ return null;
161
895
  }
162
- return text;
896
+ // The `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
897
+ // run stops at the first EN/EM dash (e.g. "-–" is a single `-` core, not a
898
+ // two-dash run that would swallow the EN dash).
899
+ let hyphenRunEnd = firstDash;
900
+ while (text[hyphenRunEnd] === "-" && hyphenRunEnd < fullRunEnd)
901
+ hyphenRunEnd++;
902
+ // All hyphens. Try `-{2,}` lengths longest-first, then a single `-`.
903
+ for (let end = hyphenRunEnd; end > firstDash; end--) {
904
+ if (end - firstDash === 1) {
905
+ // Single `-(?!${sep}*[LAT\d])`: the next clean char must not be a letter or
906
+ // digit (the suspended-hyphen guard).
907
+ const next = text[end];
908
+ if (next !== undefined && /[a-z\d]/i.test(next))
909
+ return null;
910
+ }
911
+ if (spacedArrowAhead(view, end))
912
+ continue;
913
+ const trailing = spacedTrailingEnd(view, end);
914
+ if (trailing !== null)
915
+ return trailing;
916
+ }
917
+ return null;
918
+ }
919
+ /**
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.
925
+ */
926
+ function spacedTrailingEnd(view, runEnd) {
927
+ const text = view.text;
928
+ let editEnd = runEnd;
929
+ while (text[editEnd] === " " && !view.hasBoundary(editEnd))
930
+ editEnd++;
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.
934
+ if (boundaryCountAt(view, editEnd) === 1) {
935
+ let consumedEnd = editEnd;
936
+ while (text[consumedEnd] === " " && (consumedEnd === editEnd || !view.hasBoundary(consumedEnd)))
937
+ consumedEnd++;
938
+ if (lookaheadNonSpace(view, consumedEnd))
939
+ return { editEnd, consumedEnd };
940
+ }
941
+ if (lookaheadNonSpace(view, editEnd))
942
+ return { editEnd, consumedEnd: editEnd };
943
+ return null;
944
+ }
945
+ /** `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
946
+ function lookaheadNonSpace(view, pos) {
947
+ return pos === view.text.length || view.hasBoundary(pos) || !/\s/.test(view.text[pos]);
948
+ }
949
+ /**
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).
955
+ */
956
+ function spacedArrowAhead(view, runEnd) {
957
+ const text = view.text;
958
+ let h = runEnd;
959
+ while (text[h] === "-") {
960
+ if (h > runEnd && view.hasBoundary(h))
961
+ return false; // boundary inside -*
962
+ h++;
963
+ }
964
+ if (h === runEnd) {
965
+ // No hyphens: both slots fall at this offset (≤2 boundaries total).
966
+ if (boundaryCountAt(view, runEnd) > 2)
967
+ return false;
968
+ }
969
+ else {
970
+ if (boundaryCountAt(view, runEnd) > 1)
971
+ return false; // first slot
972
+ if (boundaryCountAt(view, h) > 1)
973
+ return false; // second slot
974
+ }
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;
978
+ }
979
+ /**
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).
988
+ */
989
+ function convertBoundaryLedDashes(view, rendered) {
990
+ const text = view.text;
991
+ const edits = [];
992
+ let lastProcessedEnd = -1;
993
+ let prevOffset = -1;
994
+ let stackedHere = 0;
995
+ for (const b of view.boundaries) {
996
+ stackedHere = b === prevOffset ? stackedHere + 1 : 1;
997
+ prevOffset = b;
998
+ if (b < lastProcessedEnd)
999
+ continue;
1000
+ if (!isDashChar(text[b]))
1001
+ continue;
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.
1008
+ if (stackedHere < 2) {
1009
+ const before = text[b - 1];
1010
+ if (before === undefined || /\s/.test(before) || FOLDED_MATH_OPERATORS.has(before))
1011
+ continue;
1012
+ }
1013
+ // Dash run from the boundary, not crossing a further boundary.
1014
+ let runEnd = b + 1;
1015
+ while (isDashChar(text[runEnd]) && !view.hasBoundary(runEnd))
1016
+ runEnd++;
1017
+ // Required trailing space run; stop at a boundary.
1018
+ let spaceEnd = runEnd;
1019
+ while (text[spaceEnd] === " " && !view.hasBoundary(spaceEnd))
1020
+ spaceEnd++;
1021
+ if (spaceEnd === runEnd)
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;
1034
+ edits.push([b, spaceEnd]);
1035
+ lastProcessedEnd = spaceEnd;
1036
+ }
1037
+ for (const [from, to] of edits)
1038
+ view.replace(from, to, rendered);
1039
+ view.commit();
1040
+ }
1041
+ /**
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.
1048
+ */
1049
+ function convertMultipleDashes(view, rendered) {
1050
+ const before = `[${LATIN_LETTERS}\\d${QUOTE_CHARS}]`;
1051
+ const after = `[${LATIN_LETTERS}${QUOTE_CHARS} ]`;
1052
+ // Upper bound of 50 prevents ReDoS on pathological runs of dashes.
1053
+ const pattern = `(?<=${before})[${EN_DASH}${EM_DASH}-]{2,50}(?=${after})`;
1054
+ const afterRe = new RegExp(after);
1055
+ pass(view, cachedRegExp(pattern, "g"), (match, v) => {
1056
+ const text = v.text;
1057
+ const start = match.index;
1058
+ // Two stacked boundaries before the run exceed the leading slot's budget.
1059
+ if (boundaryCountAt(v, start) >= 2)
1060
+ return null;
1061
+ // Truncate the run at the first interior boundary (the class stops there).
1062
+ let runEnd = start + 1;
1063
+ while (isDashChar(text[runEnd]) && !v.hasBoundary(runEnd))
1064
+ runEnd++;
1065
+ if (runEnd - start < 2 || runEnd - start > 50)
1066
+ return null;
1067
+ // The trailing slot tolerates one boundary; the trailing lookahead then
1068
+ // checks the clean char after the run is [LAT quote space].
1069
+ if (boundaryCountAt(v, runEnd) >= 2)
1070
+ return null;
1071
+ const next = text[runEnd];
1072
+ if (next === undefined || !afterRe.test(next))
1073
+ return null;
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);
1094
+ return null;
1095
+ }, { allowBoundaries: () => true });
1096
+ }
1097
+ /**
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.
1102
+ */
1103
+ function convertLeadingLineDashes(view, localizedDash) {
1104
+ pass(view, cachedRegExp(`^-+ `, "gm"), () => `${localizedDash} `);
1105
+ }
1106
+ /** British: unspaced em-dashes between letters become spaced en-dashes. */
1107
+ function convertUnspacedEmDashes(view, rendered) {
1108
+ const pattern = `(?<=[${LATIN_LETTERS}.!?'"])${EM_DASH}(?=[${LATIN_LETTERS}])`;
1109
+ pass(view, cachedRegExp(pattern, "g"), (match, v) => {
1110
+ // One boundary is tolerated on each side of the em-dash; two or more
1111
+ // exceed the slot and block the match.
1112
+ if (boundaryCountAt(v, match.index) > 1)
1113
+ return null;
1114
+ if (boundaryCountAt(v, match.index + match[0].length) > 1)
1115
+ return null;
1116
+ v.replace(match.index, match.index + match[0].length, rendered);
1117
+ return null;
1118
+ });
163
1119
  }
1120
+ // ---------------------------------------------------------------------------
1121
+ // Em-dash spacing normalization (american)
1122
+ // ---------------------------------------------------------------------------
164
1123
  // TODO: Handle interrupted-then-resumed speech within quotes, where Chicago
165
1124
  // 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;
174
- }
175
- export function hyphenReplace(text, options = {}) {
176
- const sep = options.separator ?? DEFAULT_SEPARATOR;
1125
+ function normalizeEmDashSpacing(view) {
1126
+ removeSpacesAroundEmDash(view);
1127
+ preserveLineLeadingEmDashSpace(view);
1128
+ }
1129
+ /**
1130
+ * Remove spaces directly around an em-dash that has non-whitespace on both
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.
1136
+ */
1137
+ function removeSpacesAroundEmDash(view) {
1138
+ pass(view, cachedRegExp(EM_DASH, "g"), (match, v) => {
1139
+ const d = match.index;
1140
+ const text = v.text;
1141
+ // Leading spaces, stopping at a boundary or non-space.
1142
+ let left = d;
1143
+ while (left > 0 && text[left - 1] === " " && !v.hasBoundary(left))
1144
+ left--;
1145
+ // Trailing spaces, stopping at a boundary or non-space.
1146
+ let right = d + 1;
1147
+ while (right < text.length && text[right] === " " && !v.hasBoundary(right))
1148
+ right++;
1149
+ const leftAnchored = left === 0 || v.hasBoundary(left) || !/\s/.test(text[left - 1]);
1150
+ const rightAnchored = right === text.length || v.hasBoundary(right) || !/\s/.test(text[right]);
1151
+ if (!leftAnchored || !rightAnchored)
1152
+ return null;
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))
1161
+ v.replace(left, d, "");
1162
+ if (right > d + 1 && !isDashChar(rightAnchor) && !FOLDED_FROM_LEADING_DASH.has(rightAnchor))
1163
+ v.replace(d + 1, right, "");
1164
+ return null;
1165
+ });
1166
+ }
1167
+ /** Preserve a space after a line-leading em-dash before an uppercase/digit. */
1168
+ function preserveLineLeadingEmDashSpace(view) {
1169
+ pass(view, cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9])`, "gm"), (match, v) => {
1170
+ // One boundary is tolerated between the line start and the em-dash; two or
1171
+ // more exceed that slot and block the match.
1172
+ if (boundaryCountAt(v, match.index) > 1)
1173
+ return null;
1174
+ const groups = namedGroups(matchArgs(match));
1175
+ return `${EM_DASH} ${groups.after}`;
1176
+ });
1177
+ }
1178
+ export function hyphenReplace(input, options = {}) {
177
1179
  const style = options.dashStyle ?? "american";
178
- if (style === "none")
179
- return text;
180
- text = minusReplace(text, options);
181
- text = enDashDateRange(text, options);
182
- text = enDashNumberRange(text, options);
183
- text = convertParentheticalDashes(text, sep, style);
184
- if (style === "american")
185
- text = normalizeEmDashSpacing(text, sep);
186
- return text;
1180
+ return overInput(input, (view) => {
1181
+ if (style === "none")
1182
+ return;
1183
+ minusReplace(view);
1184
+ enDashDateRangeOverView(view, style);
1185
+ enDashNumberRange(view);
1186
+ convertParentheticalDashes(view, style);
1187
+ if (style === "american")
1188
+ normalizeEmDashSpacing(view);
1189
+ });
187
1190
  }
188
1191
  const { WORD_JOINER } = UNICODE_SYMBOLS;
189
- /**
190
- * Insert a word joiner (U+2060) immediately before each unspaced em or en
191
- * dash that has preceding content, preventing the dash from appearing as the
192
- * first glyph on a wrapped line. Both dashes share Unicode line-break class
193
- * B2, which permits a break before them. Does not insert before line-leading
194
- * dashes (preceded by whitespace or start-of-string) or dashes already glued
195
- * with a word joiner — including British-style spaced en dashes ("word – word"),
196
- * which are already protected by the surrounding spaces.
197
- *
198
- * **Trade-off:** the inserted U+2060 is invisible but present in the DOM, so
199
- * browser Ctrl+F / find-in-page will not match a query like `plan—result`
200
- * against the rendered `plan⁠—result`. This is the same trade-off made by
201
- * `nbspTransform` (U+00A0 breaks `Fig. 1` searches). Apply only in rendered
202
- * HTML; do not write into Markdown source.
203
- */
204
- export function dashWordJoiner(text) {
205
- const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu");
206
- 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
+ });
1197
+ }
1198
+ /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
1199
+ function matchArgs(match) {
1200
+ return [...match, match.index, match.input, match.groups];
207
1201
  }
208
1202
  //# sourceMappingURL=dashes.js.map