punctilio 5.0.10 → 5.1.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 +9 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +45 -22
- package/dist/cli.js.map +1 -1
- package/dist/dashes.d.ts +4 -2
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +87 -56
- package/dist/dashes.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +3 -7
- package/dist/nbsp.js.map +1 -1
- package/dist/prose-view.d.ts +18 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +71 -26
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +103 -20
- package/dist/quote-classifier.js.map +1 -1
- package/dist/rehype.d.ts +23 -5
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +166 -62
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +30 -12
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +74 -24
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +0 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -4
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +47 -19
- package/src/dashes.ts +88 -61
- package/src/nbsp.ts +2 -5
- package/src/prose-view.ts +78 -21
- package/src/quote-classifier.ts +110 -19
- package/src/rehype.ts +202 -72
- package/src/remark.ts +36 -20
- package/src/symbols.ts +65 -18
- package/src/utils.ts +0 -5
package/src/dashes.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { cachedRegExp, FOLDED_WORD_CHARS, isWordLike, LATIN_LETTER_RE, LATIN_LETTERS, MAX_BOUNDARY_SEPARATORS, NBSP_CHARS, SPACE_CHAR_RE, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
-
import { boundaryCountAt, exceedsSingleBoundary, makeProsePass, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } from "./prose-view.js"
|
|
3
|
-
import { namedGroups } from "./utils.js"
|
|
2
|
+
import { boundaryCountAt, exceedsSingleBoundary, firstInteriorBoundary, lastBoundaryBefore, makeProsePass, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } from "./prose-view.js"
|
|
4
3
|
|
|
5
4
|
export const DASH_STYLES = ["american", "british", "none"] as const
|
|
6
5
|
export type DashStyle = (typeof DASH_STYLES)[number]
|
|
@@ -28,7 +27,7 @@ const months: readonly string[] = [
|
|
|
28
27
|
"July", "August", "September", "October", "November", "December",
|
|
29
28
|
// Abbreviated "May" omitted — the full name is already 3 letters
|
|
30
29
|
"Jan", "Feb", "Mar", "Apr", "Jun",
|
|
31
|
-
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
30
|
+
"Jul", "Aug", "Sept", "Sep", "Oct", "Nov", "Dec",
|
|
32
31
|
]
|
|
33
32
|
|
|
34
33
|
const monthPattern = months.join("|")
|
|
@@ -191,12 +190,46 @@ function convertPositiveRanges(view: ProseView): void {
|
|
|
191
190
|
const text = view.text
|
|
192
191
|
let i = 0
|
|
193
192
|
while (i < text.length) {
|
|
193
|
+
// A structural match can only begin at a digit, `p`, or `(` (the area-code
|
|
194
|
+
// and start-run readers reject every other first character), so other
|
|
195
|
+
// offsets skip the match attempt entirely.
|
|
196
|
+
if (!RANGE_START_CHAR_RE.test(text[i])) { i++; continue }
|
|
194
197
|
const span = matchPositiveRangeAt(view, i)
|
|
195
|
-
i = span === null ? i
|
|
198
|
+
i = span === null ? failedRangeStartSkip(view, i) : Math.max(span, i + 1)
|
|
196
199
|
}
|
|
197
200
|
view.commit()
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
const RANGE_START_CHAR_RE = /[\dp(]/
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Advance past a failed candidate at `i`. Advancing one character at a time
|
|
207
|
+
* re-reads the digit run at every offset — O(n²) on inputs like "1.1.1.…" or
|
|
208
|
+
* "111…1-x", a denial-of-service vector — so the scan jumps wherever a
|
|
209
|
+
* failure at `i` provably rules out interior starts:
|
|
210
|
+
*
|
|
211
|
+
* - A start-dependent failure (`\b`, not-after-dash — checked first in
|
|
212
|
+
* matchRangeBody, before any run read) says nothing about interior starts;
|
|
213
|
+
* advance one character. The gate order makes each such attempt cheap.
|
|
214
|
+
* - Otherwise the failure came after the gates, and everything there depends
|
|
215
|
+
* only on the run end, not the run start: every interior digit start reads
|
|
216
|
+
* to the same boundary-free run end and fails the same wall, `.`/`,` cannot
|
|
217
|
+
* start a run, and no dash-at-run-end fallback (dash extent, boundary
|
|
218
|
+
* counts, end run, tail resolution) sees a different picture. The single
|
|
219
|
+
* escape is the phone-area-code reading, which restarts a fresh match past
|
|
220
|
+
* a dash at the run end and therefore only exists at `runEnd - 3` — so
|
|
221
|
+
* jump straight there (or to the run end when no dash follows).
|
|
222
|
+
*/
|
|
223
|
+
function failedRangeStartSkip(view: ProseView, i: number): number {
|
|
224
|
+
const text = view.text
|
|
225
|
+
if (!/\d/.test(text[i])) return i + 1
|
|
226
|
+
if (!wordBoundaryStartOk(view, i) || !notAfterDashOk(view, i)) return i + 1
|
|
227
|
+
let runEnd = i + 1
|
|
228
|
+
while (/[\d.,]/.test(text[runEnd] ?? "") && !view.hasBoundary(runEnd)) runEnd++
|
|
229
|
+
if (text[runEnd] !== "-") return runEnd
|
|
230
|
+
return Math.max(i + 1, runEnd - 3)
|
|
231
|
+
}
|
|
232
|
+
|
|
200
233
|
/**
|
|
201
234
|
* Attempts the `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
|
|
202
235
|
* \b` structural match beginning at clean offset `i`, converting the dash when
|
|
@@ -243,6 +276,12 @@ function readPhoneAreaCode(view: ProseView, i: number): number | null {
|
|
|
243
276
|
*/
|
|
244
277
|
function matchRangeBody(view: ProseView, startStart: number): number | null {
|
|
245
278
|
const text = view.text
|
|
279
|
+
// Start-dependent gates first: they need no run read, and their position in
|
|
280
|
+
// the conjunction lets the scanner classify a failure — everything after
|
|
281
|
+
// them depends only on where the boundary-free start run ends, not on where
|
|
282
|
+
// it begins (see failedRangeStartSkip).
|
|
283
|
+
if (!wordBoundaryStartOk(view, startStart)) return null
|
|
284
|
+
if (!notAfterDashOk(view, startStart)) return null
|
|
246
285
|
// rangeStart = `(?:p\.?|[currency])?\d[\d.,]*`, anchored at a `\b`.
|
|
247
286
|
const start = readStartRun(view, startStart)
|
|
248
287
|
if (start === null) return null
|
|
@@ -258,15 +297,20 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
|
|
|
258
297
|
|
|
259
298
|
// `readStartRun` already stops at the first interior boundary, so the start
|
|
260
299
|
// span is boundary-free and its clean digits are the whole run.
|
|
300
|
+
//
|
|
301
|
+
// INVARIANT (load-bearing for failedRangeStartSkip): past the gates above,
|
|
302
|
+
// `startDigits` is the ONLY value derived from where the run begins, and it
|
|
303
|
+
// feeds only the conversion decision, never the null/non-null return. The
|
|
304
|
+
// skip's jump is sound precisely because every interior start shares this
|
|
305
|
+
// function's post-gate outcome; threading the start position into the
|
|
306
|
+
// end/tail logic would silently break it.
|
|
261
307
|
const startDigits = text.slice(startStart, startSpanEnd)
|
|
262
|
-
if (!wordBoundaryStartOk(view, startStart)) return null
|
|
263
|
-
if (!notAfterDashOk(view, startStart)) return null
|
|
264
|
-
|
|
265
308
|
const end = readEndRun(view, dashEnd)
|
|
266
309
|
if (end === null) return null
|
|
267
310
|
// The greedy end `[\d.,]*` and greedy `following*` backtrack together; the
|
|
268
|
-
// structural match commits at the longest end whose tail completes.
|
|
269
|
-
|
|
311
|
+
// structural match commits at the longest end whose tail completes. The
|
|
312
|
+
// mandatory `\d` caps the backtrack at one digit.
|
|
313
|
+
for (let len = end.length; len >= 1; len--) {
|
|
270
314
|
const endPos = end.firstDigitStart + len
|
|
271
315
|
const tail = resolvePositiveTail(view, endPos)
|
|
272
316
|
if (tail === null) continue
|
|
@@ -313,8 +357,6 @@ interface EndRun {
|
|
|
313
357
|
firstDigitStart: number
|
|
314
358
|
/** Length of the `[\d.,]` run from `firstDigitStart`, boundary-truncated. */
|
|
315
359
|
length: number
|
|
316
|
-
/** Minimum end length: 1 digit (`\d` is required). */
|
|
317
|
-
minLen: number
|
|
318
360
|
}
|
|
319
361
|
|
|
320
362
|
/** The `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
|
|
@@ -330,7 +372,7 @@ function readEndRun(view: ProseView, pos: number): EndRun | null {
|
|
|
330
372
|
const firstDigitStart = i
|
|
331
373
|
i++
|
|
332
374
|
while (/[\d.,]/.test(text[i] ?? "") && !view.hasBoundary(i)) i++
|
|
333
|
-
return { firstDigitStart, length: i - firstDigitStart
|
|
375
|
+
return { firstDigitStart, length: i - firstDigitStart }
|
|
334
376
|
}
|
|
335
377
|
|
|
336
378
|
interface ResolvedTail {
|
|
@@ -608,6 +650,22 @@ export function enDashDateRange(input: string | ProseView, options: DashOptions
|
|
|
608
650
|
return overInput(input, (view) => enDashDateRangeOverView(view, options.dashStyle ?? "american"))
|
|
609
651
|
}
|
|
610
652
|
|
|
653
|
+
interface DateRangeGroups {
|
|
654
|
+
startMonth: string
|
|
655
|
+
startYear?: string
|
|
656
|
+
preSpace: string
|
|
657
|
+
postSpace: string
|
|
658
|
+
endMonth: string
|
|
659
|
+
endYear?: string
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Clean offsets of the `[preSpace]-[postSpace]` span inside a date-range match. */
|
|
663
|
+
function dateRangeDashSpan(match: RegExpExecArray): { dashStart: number; dashEnd: number } {
|
|
664
|
+
const groups = match.groups as unknown as DateRangeGroups
|
|
665
|
+
const dashStart = match.index + groups.startMonth.length + (groups.startYear?.length ?? 0)
|
|
666
|
+
return { dashStart, dashEnd: dashStart + groups.preSpace.length + 1 + groups.postSpace.length }
|
|
667
|
+
}
|
|
668
|
+
|
|
611
669
|
function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
|
|
612
670
|
if (dashStyle === "none") return
|
|
613
671
|
const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
|
|
@@ -630,37 +688,17 @@ function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
|
|
|
630
688
|
// a word char that blocks the match ("March 2025x3"); re-check the end
|
|
631
689
|
// boundary fold-stably.
|
|
632
690
|
if (!wordBoundaryEndOk(v, match.index + match[0].length)) return null
|
|
633
|
-
const groups = namedGroups<{
|
|
634
|
-
startMonth: string
|
|
635
|
-
startYear?: string
|
|
636
|
-
preSpace: string
|
|
637
|
-
postSpace: string
|
|
638
|
-
endMonth: string
|
|
639
|
-
endYear?: string
|
|
640
|
-
}>(matchArgs(match))
|
|
641
691
|
// Replace `[preSpace]-[postSpace]` (the dash and its spaces) with the
|
|
642
692
|
// styled en-dash, leaving the months and any boundaries around them.
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
const dashStart = match.index + startLen
|
|
646
|
-
v.replace(dashStart, dashStart + dashSpan, `${pre}${EN_DASH}${post}`)
|
|
693
|
+
const { dashStart, dashEnd } = dateRangeDashSpan(match)
|
|
694
|
+
v.replace(dashStart, dashEnd, `${pre}${EN_DASH}${post}`)
|
|
647
695
|
return null
|
|
648
696
|
},
|
|
649
697
|
{
|
|
650
698
|
allowBoundaries: (match, v) => {
|
|
651
|
-
const groups = namedGroups<{
|
|
652
|
-
startMonth: string
|
|
653
|
-
startYear?: string
|
|
654
|
-
preSpace: string
|
|
655
|
-
postSpace: string
|
|
656
|
-
endMonth: string
|
|
657
|
-
endYear?: string
|
|
658
|
-
}>(matchArgs(match))
|
|
659
|
-
const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
|
|
660
699
|
// One boundary is tolerated after the start year and one before the end
|
|
661
700
|
// month, bracketing the dash and spaces.
|
|
662
|
-
const dashStart = match
|
|
663
|
-
const dashEnd = dashStart + groups.preSpace.length + 1 + groups.postSpace.length
|
|
701
|
+
const { dashStart, dashEnd } = dateRangeDashSpan(match)
|
|
664
702
|
const tolerated = new Map<number, number>()
|
|
665
703
|
tolerated.set(dashStart, 1)
|
|
666
704
|
tolerated.set(dashEnd, 1)
|
|
@@ -732,19 +770,14 @@ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefi
|
|
|
732
770
|
// leading digit and the space; it tolerates at most one boundary.
|
|
733
771
|
if (exceedsSingleBoundary(view, match.index)) return false
|
|
734
772
|
const numStart = match.index + prefixLen
|
|
735
|
-
|
|
736
|
-
if (b > match.index && b < numStart) return false
|
|
737
|
-
}
|
|
773
|
+
if (firstInteriorBoundary(view, match.index, numStart) >= 0) return false
|
|
738
774
|
// The slot before `num` tolerates at most one boundary at the num head.
|
|
739
775
|
if (exceedsSingleBoundary(view, numStart)) return false
|
|
740
776
|
// `num` stops at the first interior boundary; the clean run up to it must
|
|
741
777
|
// still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
|
|
742
778
|
const numEnd = match.index + match[0].length
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if (b > numStart && b < numEnd) { truncatedEnd = b; break }
|
|
746
|
-
if (b >= numEnd) break
|
|
747
|
-
}
|
|
779
|
+
const interior = firstInteriorBoundary(view, numStart, numEnd)
|
|
780
|
+
const truncatedEnd = interior >= 0 ? interior : numEnd
|
|
748
781
|
return NUM_BODY_RE.test(view.text.slice(numStart, truncatedEnd))
|
|
749
782
|
}
|
|
750
783
|
|
|
@@ -783,11 +816,8 @@ function minusDirectNegative(view: ProseView): void {
|
|
|
783
816
|
// ("-{b}5"), and one that strips the mandatory `\d+` ("-.{b}1") breaks the
|
|
784
817
|
// match, but one past a valid number ("-5{b}5") leaves it intact.
|
|
785
818
|
const numEnd = start + match[0].length
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
if (b > start && b < numEnd) { numTruncEnd = b; break }
|
|
789
|
-
if (b >= numEnd) break
|
|
790
|
-
}
|
|
819
|
+
const interior = firstInteriorBoundary(v, start, numEnd)
|
|
820
|
+
const numTruncEnd = interior >= 0 ? interior : numEnd
|
|
791
821
|
if (!NUM_BODY_RE.test(v.text.slice(start + 1, numTruncEnd))) return null
|
|
792
822
|
// A boundary directly before the hyphen routes to Pattern 2b; otherwise
|
|
793
823
|
// Pattern 2 examines the clean preceding char. (The two never overlap: a
|
|
@@ -864,7 +894,7 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
|
|
|
864
894
|
cachedRegExp(pattern, "g"),
|
|
865
895
|
(match, v) => {
|
|
866
896
|
const text = v.text
|
|
867
|
-
const groups =
|
|
897
|
+
const groups = match.groups as { dashStart: string }
|
|
868
898
|
const firstDash = match.index + match[0].length - groups.dashStart.length
|
|
869
899
|
// The greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
|
|
870
900
|
// holds: line/text start, a non-whitespace clean char, or a boundary. A
|
|
@@ -873,10 +903,10 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
|
|
|
873
903
|
// consumed span is also a floor: leading spaces it already consumed are not
|
|
874
904
|
// re-used.
|
|
875
905
|
let matchStart = Math.max(match.index, consumedThrough)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
906
|
+
// The greatest boundary strictly left of the dash restarts the match just
|
|
907
|
+
// after it (binary search, not a per-match scan of every boundary).
|
|
908
|
+
const boundaryBeforeDash = lastBoundaryBefore(view, firstDash)
|
|
909
|
+
if (boundaryBeforeDash > matchStart) matchStart = boundaryBeforeDash
|
|
880
910
|
/* istanbul ignore if -- defensive: the previous match's trailing `(?=\S|$)`
|
|
881
911
|
stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
|
|
882
912
|
if (matchStart >= firstDash + 1) return null
|
|
@@ -1217,7 +1247,7 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
|
|
|
1217
1247
|
// One boundary is tolerated between the line start and the em-dash; two or
|
|
1218
1248
|
// more exceed that slot and block the match.
|
|
1219
1249
|
if (exceedsSingleBoundary(v, match.index)) return null
|
|
1220
|
-
const groups =
|
|
1250
|
+
const groups = match.groups as { after: string }
|
|
1221
1251
|
return `${EM_DASH} ${groups.after}`
|
|
1222
1252
|
},
|
|
1223
1253
|
)
|
|
@@ -1246,8 +1276,10 @@ const { WORD_JOINER } = UNICODE_SYMBOLS
|
|
|
1246
1276
|
/**
|
|
1247
1277
|
* Insert a word joiner (U+2060) immediately before each unspaced em or en
|
|
1248
1278
|
* dash that has preceding content, preventing the dash from appearing as the
|
|
1249
|
-
* first glyph on a wrapped line.
|
|
1250
|
-
* B2, which permits a break
|
|
1279
|
+
* first glyph on a wrapped line. The em dash (U+2014) is Unicode line-break
|
|
1280
|
+
* class B2, which permits a break on either side; the en dash (U+2013) is
|
|
1281
|
+
* class BA (break after). The word joiner glues either dash to the preceding
|
|
1282
|
+
* word so it cannot start a wrapped line. Does not insert before line-leading
|
|
1251
1283
|
* dashes (preceded by whitespace or start-of-string) or dashes already glued
|
|
1252
1284
|
* with a word joiner — including British-style spaced en dashes ("word – word"),
|
|
1253
1285
|
* which are already protected by the surrounding spaces.
|
|
@@ -1262,8 +1294,3 @@ export const dashWordJoiner = makeProsePass((view) => {
|
|
|
1262
1294
|
const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
|
|
1263
1295
|
replaceAllInView(view, re, (match) => `${WORD_JOINER}${match[0]}`)
|
|
1264
1296
|
})
|
|
1265
|
-
|
|
1266
|
-
/** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
|
|
1267
|
-
function matchArgs(match: RegExpExecArray): unknown[] {
|
|
1268
|
-
return [...match, match.index, match.input, match.groups]
|
|
1269
|
-
}
|
package/src/nbsp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cachedRegExp, LATIN_LETTER_RE, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
-
import { boundaryCountAt, exceedsSingleBoundary, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
|
|
2
|
+
import { boundaryCountAt, exceedsSingleBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
|
|
3
3
|
|
|
4
4
|
const {
|
|
5
5
|
NBSP,
|
|
@@ -105,10 +105,7 @@ const COPYRIGHT_SYMBOLS = `[${COPYRIGHT}${REGISTERED}${TRADEMARK}]`
|
|
|
105
105
|
* allowed slot offsets.
|
|
106
106
|
*/
|
|
107
107
|
function boundariesOnlyAtSlots(view: ProseView, start: number, end: number, slots: number[]): boolean {
|
|
108
|
-
|
|
109
|
-
if (boundary <= start || boundary >= end) continue
|
|
110
|
-
if (!slots.includes(boundary)) return false
|
|
111
|
-
}
|
|
108
|
+
if (!interiorBoundariesWithin(view, start, end, slots)) return false
|
|
112
109
|
for (const slot of slots) {
|
|
113
110
|
if (exceedsSingleBoundary(view, slot)) return false
|
|
114
111
|
}
|
package/src/prose-view.ts
CHANGED
|
@@ -67,19 +67,8 @@ class ProseViewImpl implements ProseView {
|
|
|
67
67
|
|
|
68
68
|
hasBoundary(offset: number): boolean {
|
|
69
69
|
const boundaries = this.cachedBoundaries
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
while (low <= high) {
|
|
73
|
-
const mid = (low + high) >>> 1
|
|
74
|
-
const value = boundaries[mid]
|
|
75
|
-
if (value === offset) return true
|
|
76
|
-
if (value < offset) {
|
|
77
|
-
low = mid + 1
|
|
78
|
-
} else {
|
|
79
|
-
high = mid - 1
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return false
|
|
70
|
+
const first = lowerBound(boundaries, offset)
|
|
71
|
+
return first < boundaries.length && boundaries[first] === offset
|
|
83
72
|
}
|
|
84
73
|
|
|
85
74
|
replace(start: number, end: number, text: string, opts?: { bind?: "left" | "right" }): void {
|
|
@@ -217,6 +206,28 @@ export function buildProseView(nodes: ProseNode[]): ProseView {
|
|
|
217
206
|
return new ProseViewImpl(nodes)
|
|
218
207
|
}
|
|
219
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Partitions `items` into contiguous groups, breaking before every index in
|
|
211
|
+
* `breakBefore` (indices 1..n-1). With no break indices the whole list is one
|
|
212
|
+
* group; an empty list yields no groups. Used to isolate opaque-delimited
|
|
213
|
+
* segments so a pass never sees text as adjacent across removed content.
|
|
214
|
+
*/
|
|
215
|
+
export function splitAtIndices<T>(items: readonly T[], breakBefore: ReadonlySet<number>): T[][] {
|
|
216
|
+
if (breakBefore.size === 0) {
|
|
217
|
+
return items.length > 0 ? [items.slice()] : []
|
|
218
|
+
}
|
|
219
|
+
const groups: T[][] = []
|
|
220
|
+
let start = 0
|
|
221
|
+
for (let i = 1; i < items.length; i++) {
|
|
222
|
+
if (breakBefore.has(i)) {
|
|
223
|
+
groups.push(items.slice(start, i))
|
|
224
|
+
start = i
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
groups.push(items.slice(start))
|
|
228
|
+
return groups
|
|
229
|
+
}
|
|
230
|
+
|
|
220
231
|
export interface ReplaceAllOptions {
|
|
221
232
|
/** Opt-in: allow a match that contains an interior node boundary. Default: such matches are skipped. */
|
|
222
233
|
allowBoundaries?: (match: RegExpExecArray, view: ProseView) => boolean
|
|
@@ -224,22 +235,68 @@ export interface ReplaceAllOptions {
|
|
|
224
235
|
bind?: "left" | "right"
|
|
225
236
|
}
|
|
226
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Index of the first element in sorted `values` that is >= `target`. The
|
|
240
|
+
* boundary helpers below run per candidate offset in the pass scanners, so a
|
|
241
|
+
* linear scan here would make inline-element-heavy views O(text × nodes).
|
|
242
|
+
*/
|
|
243
|
+
function lowerBound(values: readonly number[], target: number): number {
|
|
244
|
+
let low = 0
|
|
245
|
+
let high = values.length
|
|
246
|
+
while (low < high) {
|
|
247
|
+
const mid = (low + high) >>> 1
|
|
248
|
+
if (values[mid] < target) low = mid + 1
|
|
249
|
+
else high = mid
|
|
250
|
+
}
|
|
251
|
+
return low
|
|
252
|
+
}
|
|
253
|
+
|
|
227
254
|
/** True iff some interior boundary falls strictly inside the match span. */
|
|
228
255
|
function matchContainsBoundary(match: RegExpExecArray, view: ProseView): boolean {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
256
|
+
return firstInteriorBoundary(view, match.index, match.index + match[0].length) >= 0
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Smallest node boundary strictly inside (start, end), or -1 when none. */
|
|
260
|
+
export function firstInteriorBoundary(view: ProseView, start: number, end: number): number {
|
|
261
|
+
const boundaries = view.boundaries
|
|
262
|
+
const first = lowerBound(boundaries, start + 1)
|
|
263
|
+
return first < boundaries.length && boundaries[first] < end ? boundaries[first] : -1
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Largest node boundary strictly less than `offset`, or -1 when none. O(log n). */
|
|
267
|
+
export function lastBoundaryBefore(view: ProseView, offset: number): number {
|
|
268
|
+
const boundaries = view.boundaries
|
|
269
|
+
const first = lowerBound(boundaries, offset)
|
|
270
|
+
return first > 0 ? boundaries[first - 1] : -1
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* True when every node boundary strictly inside (start, end) is one of
|
|
275
|
+
* `allowedSlots`. Seeks to the span with binary search, so it scans only the
|
|
276
|
+
* boundaries in range rather than the whole array on every call — the linear
|
|
277
|
+
* alternative is O(matches × nodes) on inline-element-heavy views.
|
|
278
|
+
*/
|
|
279
|
+
export function interiorBoundariesWithin(
|
|
280
|
+
view: ProseView,
|
|
281
|
+
start: number,
|
|
282
|
+
end: number,
|
|
283
|
+
allowedSlots: readonly number[],
|
|
284
|
+
): boolean {
|
|
285
|
+
const boundaries = view.boundaries
|
|
286
|
+
for (let i = lowerBound(boundaries, start + 1); i < boundaries.length && boundaries[i] < end; i++) {
|
|
287
|
+
if (!allowedSlots.includes(boundaries[i])) return false
|
|
233
288
|
}
|
|
234
|
-
return
|
|
289
|
+
return true
|
|
235
290
|
}
|
|
236
291
|
|
|
237
292
|
/** Count of node boundaries that fall at exactly `offset` (empty nodes stack). */
|
|
238
293
|
export function boundaryCountAt(view: ProseView, offset: number): number {
|
|
294
|
+
const boundaries = view.boundaries
|
|
295
|
+
let i = lowerBound(boundaries, offset)
|
|
239
296
|
let count = 0
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
297
|
+
while (i < boundaries.length && boundaries[i] === offset) {
|
|
298
|
+
count++
|
|
299
|
+
i++
|
|
243
300
|
}
|
|
244
301
|
return count
|
|
245
302
|
}
|