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