punctilio 4.3.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/constants.d.ts +4 -11
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -13
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts +1 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +995 -140
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prose-view.d.ts +10 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +26 -0
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +1 -9
- package/dist/quote-classifier.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +4 -14
- package/src/dashes.ts +1025 -197
- package/src/index.ts +1 -0
- package/src/prose-view.ts +30 -0
- package/src/quote-classifier.ts +1 -10
package/dist/dashes.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import { cachedRegExp, DEFAULT_SEPARATOR,
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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(
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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 =
|
|
184
|
-
|
|
185
|
-
|
|
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
|