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