punctilio 5.0.2 → 5.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/utils.js CHANGED
@@ -59,22 +59,4 @@ export function stableStringify(obj) {
59
59
  export function namedGroups(args) {
60
60
  return args[args.length - 1];
61
61
  }
62
- /**
63
- * Extracts the match offset and input string from a `.replace()` callback's
64
- * arguments, independent of capture-group count. The trailing arguments are
65
- * `..., offset, input[, namedGroups]`: the input is the last string (the
66
- * named-groups object, when present, follows it) and the offset is the
67
- * number immediately before the input. @internal
68
- */
69
- export function replaceCallbackContext(args) {
70
- let inputIndex = args.length - 1;
71
- while (inputIndex >= 0 && typeof args[inputIndex] !== "string") {
72
- inputIndex--;
73
- }
74
- const offset = args[inputIndex - 1];
75
- if (inputIndex < 1 || typeof offset !== "number") {
76
- throw new Error("Could not locate offset and input in replace-callback arguments.");
77
- }
78
- return { offset, input: args[inputIndex] };
79
- }
80
62
  //# sourceMappingURL=utils.js.map
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "punctilio",
3
- "version": "5.0.2",
3
+ "version": "5.0.3",
4
4
  "description": "Smart typography transformations: curly quotes, em-dashes, en-dashes, and more",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -164,6 +164,7 @@
164
164
  "lint": "eslint src scripts",
165
165
  "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
166
166
  "benchmark": "pnpm run build && node benchmark.mjs",
167
- "mutation": "STRYKER_RUN=1 stryker run"
167
+ "mutation": "STRYKER_RUN=1 stryker run",
168
+ "mutation:changed": "node scripts/mutate-changed.mjs"
168
169
  }
169
170
  }
package/src/constants.ts CHANGED
@@ -111,6 +111,38 @@ export const SPACE_CHARS = ` \t${UNICODE_SYMBOLS.NBSP}${UNICODE_SYMBOLS.NNBSP}`
111
111
  /** Non-breaking space chars only (NBSP, NNBSP) for regex `[...]`. */
112
112
  export const NBSP_CHARS = `${UNICODE_SYMBOLS.NBSP}${UNICODE_SYMBOLS.NNBSP}`
113
113
 
114
+ /** Latin letters (incl. European accents) as a compiled regex; see {@link LATIN_LETTERS}. */
115
+ export const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`, "u")
116
+
117
+ /** Single `\w` word-character matcher shared across passes. */
118
+ export const WORD_RE = /\w/
119
+
120
+ /** Matches a single {@link SPACE_CHARS} character (space, tab, NBSP, NNBSP). */
121
+ export const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
122
+
123
+ /**
124
+ * Glyphs the symbol passes fold word characters into (multiplication `x` →
125
+ * `×`, ordinal suffixes → superscripts). Rules that run before those passes
126
+ * must treat a folded glyph like the word character it folds from, or a gate
127
+ * decided on `6x3` flips once a re-run sees `6×3`.
128
+ */
129
+ export const FOLDED_WORD_CHARS = new Set<string>([
130
+ UNICODE_SYMBOLS.MULTIPLICATION,
131
+ ...UNICODE_SYMBOLS.SUPERSCRIPT_ST, ...UNICODE_SYMBOLS.SUPERSCRIPT_ND,
132
+ ...UNICODE_SYMBOLS.SUPERSCRIPT_RD, ...UNICODE_SYMBOLS.SUPERSCRIPT_TH,
133
+ ])
134
+
135
+ /** `\w` extended with {@link FOLDED_WORD_CHARS} for fold-stable `\b` checks. */
136
+ export function isWordLike(ch: string | undefined): boolean {
137
+ return ch !== undefined && (WORD_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
138
+ }
139
+
140
+ /**
141
+ * Maximum stacked node boundaries tolerated within a single match before a
142
+ * pass treats the span as crossing a real gap rather than an editing seam.
143
+ */
144
+ export const MAX_BOUNDARY_SEPARATORS = 3
145
+
114
146
  /** Max AST recursion depth; guards against stack overflow from deep nesting. */
115
147
  export const MAX_RECURSION_DEPTH = 1000
116
148
 
package/src/dashes.ts CHANGED
@@ -1,5 +1,5 @@
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"
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
3
  import { namedGroups } from "./utils.js"
4
4
 
5
5
  export const DASH_STYLES = ["american", "british", "none"] as const
@@ -13,7 +13,6 @@ export interface DashOptions {
13
13
  const {
14
14
  EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS,
15
15
  LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE,
16
- SUPERSCRIPT_ST, SUPERSCRIPT_ND, SUPERSCRIPT_RD, SUPERSCRIPT_TH,
17
16
  } = UNICODE_SYMBOLS
18
17
 
19
18
  // Prevents false-positive ranges in model names like "Llama-2-7B".
@@ -34,26 +33,6 @@ const months: readonly string[] = [
34
33
 
35
34
  const monthPattern = months.join("|")
36
35
 
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
36
  /**
58
37
  * Glyphs the math-symbol pass folds two-character operators into (`=~` → `≈`,
59
38
  * `!=` → `≠`, ...). The fold tolerates one node boundary at its junction, so
@@ -101,7 +80,7 @@ function pass(
101
80
  //
102
81
  // The boundary-tolerance positions throughout this module reproduce the
103
82
  // 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
83
+ // pinned by the HTML regression corpus and the migration's differential fuzz. Over a
105
84
  // clean ProseView, `replaceAllInView` skips any match with an interior boundary
106
85
  // unless `allowBoundaries` opts in. Each pass below builds an `allowBoundaries`
107
86
  // callback that admits a match only when every interior boundary sits at a
@@ -142,9 +121,6 @@ function interiorBoundariesAllowed(
142
121
  return true
143
122
  }
144
123
 
145
- /** Separators tolerated by the word-boundary helpers (mirrors constants.ts). */
146
- const MAX_BOUNDARY_SEPARATORS = 3
147
-
148
124
  /**
149
125
  * Reproduces `wordBoundaryStart` = `(?<!\w${sep}{0,3})\b` at the marked position
150
126
  * `matchStart` (the start char is a word char — a digit). The `\b` requires a
@@ -155,7 +131,7 @@ const MAX_BOUNDARY_SEPARATORS = 3
155
131
  function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
156
132
  if (matchStart === 0) return true
157
133
  const nb = boundaryCountAt(view, matchStart)
158
- const prevWord = isWordChar(view.text[matchStart - 1])
134
+ const prevWord = isWordLike(view.text[matchStart - 1])
159
135
  if (nb === 0) return !prevWord // `\b` needs a non-word char immediately left
160
136
  // A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
161
137
  // when a word char is within ≤MAX boundaries.
@@ -172,7 +148,7 @@ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
172
148
  const text = view.text
173
149
  // `wordBoundaryEndOk` is only ever called at the end of a digit, letter, or
174
150
  // suffix run, so `pos` is always ≥ 1 and `text[pos - 1]` is a real char.
175
- const leftWord = isWordChar(text[pos - 1])
151
+ const leftWord = isWordLike(text[pos - 1])
176
152
  // A multiplication sign one space to the right blocks like the word char
177
153
  // it folds from: the multiplication pass renders `5X 8` as `5 × 8`, and a
178
154
  // range blocked at `3-5X` must stay blocked at `3-5 ×`.
@@ -180,7 +156,7 @@ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
180
156
  return false
181
157
  }
182
158
  const nb = boundaryCountAt(view, pos)
183
- const nextCleanWord = isWordChar(text[pos])
159
+ const nextCleanWord = isWordLike(text[pos])
184
160
  // The marked char right after pos is a separator when nb > 0 (non-word).
185
161
  const rightWord = nb > 0 ? false : nextCleanWord
186
162
  if (leftWord === rightWord) return false // no `\b`
@@ -195,14 +171,10 @@ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
195
171
  const CURRENCY_RE = /[$€£¥₹]/
196
172
 
197
173
  /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
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
- }
174
+ export const enDashNumberRange = makeProsePass((view) => {
175
+ convertPositiveRanges(view)
176
+ convertNegativeRanges(view)
177
+ })
206
178
 
207
179
  /**
208
180
  * The positive-range shape is `phoneAreaCode? \b (?:p\.?|[currency])?\d[\d.,]*
@@ -276,13 +248,13 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
276
248
  if (start === null) return null
277
249
  const startSpanEnd = startStart + start.length
278
250
  // One slot between start and the dash tolerates a single boundary there.
279
- if (boundaryCountAt(view, startSpanEnd) > 1) return null
251
+ if (exceedsSingleBoundary(view, startSpanEnd)) return null
280
252
  // Dash run of 1..3 hyphens that never spans an interior boundary (a boundary
281
253
  // between two hyphens truncates the run).
282
254
  if (text[startSpanEnd] !== "-") return null
283
255
  let dashEnd = startSpanEnd + 1
284
256
  while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd)) dashEnd++
285
- if (boundaryCountAt(view, dashEnd) > 1) return null
257
+ if (exceedsSingleBoundary(view, dashEnd)) return null
286
258
 
287
259
  // `readStartRun` already stops at the first interior boundary, so the start
288
260
  // span is boundary-free and its clean digits are the whole run.
@@ -431,7 +403,7 @@ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
431
403
  let startEnd = i + 1
432
404
  while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd)) startEnd++
433
405
  // One slot between start and the dash tolerates a single boundary there.
434
- if (boundaryCountAt(view, startEnd) > 1) return null
406
+ if (exceedsSingleBoundary(view, startEnd)) return null
435
407
  if (text[startEnd] !== "-") return null
436
408
 
437
409
  // `-{1,3}?` then optional `(?<neg>-)`: a 1..3 hyphen run with no interior
@@ -442,7 +414,7 @@ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
442
414
  const hasNeg = dashRunLen >= 2
443
415
  const negEnd = dashRunEnd
444
416
  // The `end` head slot tolerates one boundary.
445
- if (boundaryCountAt(view, negEnd) > 1) return null
417
+ if (exceedsSingleBoundary(view, negEnd)) return null
446
418
 
447
419
  const end = readNegEndRun(view, negEnd)
448
420
  if (end === null) return null
@@ -487,11 +459,11 @@ function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: bo
487
459
  const candidates = [endSpanEnd]
488
460
  let pos = endSpanEnd
489
461
  for (;;) {
490
- if (boundaryCountAt(view, pos) > 1) break
462
+ if (exceedsSingleBoundary(view, pos)) break
491
463
  const dashCh = view.text[pos]
492
464
  if (dashCh !== "-" && !(allowMinus && dashCh === MINUS)) break
493
465
  const digitStart = pos + 1
494
- if (boundaryCountAt(view, digitStart) > 1) break
466
+ if (exceedsSingleBoundary(view, digitStart)) break
495
467
  if (!/\d/.test(view.text[digitStart] ?? "")) break
496
468
  let end = digitStart + 1
497
469
  while (/\d/.test(view.text[end] ?? "") && !view.hasBoundary(end)) end++
@@ -521,7 +493,7 @@ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number
521
493
  // An optional suffix: one tolerated boundary then am/pm or one of
522
494
  // {@link RANGE_SUFFIX_RE}'s letters, followed by wbe. The suffix letters sit
523
495
  // at the clean `pos`.
524
- if (boundaryCountAt(view, pos) > 1) return null
496
+ if (exceedsSingleBoundary(view, pos)) return null
525
497
  const text = view.text
526
498
  // `[AaPp][Mm]` is contiguous — a boundary between the two letters breaks the
527
499
  // suffix.
@@ -536,8 +508,6 @@ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number
536
508
 
537
509
  const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`)
538
510
 
539
- const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
540
-
541
511
  /**
542
512
  * Offset of the dot reachable backwards across one inter-dot gap ending at the
543
513
  * dot at `dotIndex` (the mirror of the ellipsis pass's forward gap rule: the
@@ -706,15 +676,11 @@ function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
706
676
  // ---------------------------------------------------------------------------
707
677
 
708
678
  /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
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
- }
679
+ export const minusReplace = makeProsePass((view) => {
680
+ minusSubtractionOfNegative(view)
681
+ minusSpacedSubtraction(view)
682
+ minusDirectNegative(view)
683
+ })
718
684
 
719
685
  /** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
720
686
  function minusSubtractionOfNegative(view: ProseView): void {
@@ -764,13 +730,13 @@ const NUM_BODY_RE = /^\d*\.?\d+/
764
730
  function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefixLen: number): boolean {
765
731
  // The slot in the lookbehind (`(?<=\d)`) sits at the match start between the
766
732
  // leading digit and the space; it tolerates at most one boundary.
767
- if (boundaryCountAt(view, match.index) > 1) return false
733
+ if (exceedsSingleBoundary(view, match.index)) return false
768
734
  const numStart = match.index + prefixLen
769
735
  for (const b of view.boundaries) {
770
736
  if (b > match.index && b < numStart) return false
771
737
  }
772
738
  // The slot before `num` tolerates at most one boundary at the num head.
773
- if (boundaryCountAt(view, numStart) > 1) return false
739
+ if (exceedsSingleBoundary(view, numStart)) return false
774
740
  // `num` stops at the first interior boundary; the clean run up to it must
775
741
  // still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
776
742
  const numEnd = match.index + match[0].length
@@ -1047,8 +1013,8 @@ function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
1047
1013
  // No hyphens: both slots fall at this offset (≤2 boundaries total).
1048
1014
  if (boundaryCountAt(view, runEnd) > 2) return false
1049
1015
  } else {
1050
- if (boundaryCountAt(view, runEnd) > 1) return false // first slot
1051
- if (boundaryCountAt(view, h) > 1) return false // second slot
1016
+ if (exceedsSingleBoundary(view, runEnd)) return false // first slot
1017
+ if (exceedsSingleBoundary(view, h)) return false // second slot
1052
1018
  }
1053
1019
  // `≥` guards like the `>=` it folds from: the math pass folds the `>` this
1054
1020
  // guard keys on, and a dash blocked before `–>=` must stay blocked at `–≥`.
@@ -1185,8 +1151,8 @@ function convertUnspacedEmDashes(view: ProseView, rendered: string): void {
1185
1151
  (match, v) => {
1186
1152
  // One boundary is tolerated on each side of the em-dash; two or more
1187
1153
  // 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
1154
+ if (exceedsSingleBoundary(v, match.index)) return null
1155
+ if (exceedsSingleBoundary(v, match.index + match[0].length)) return null
1190
1156
  v.replace(match.index, match.index + match[0].length, rendered)
1191
1157
  return null
1192
1158
  },
@@ -1250,7 +1216,7 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
1250
1216
  (match, v) => {
1251
1217
  // One boundary is tolerated between the line start and the em-dash; two or
1252
1218
  // more exceed that slot and block the match.
1253
- if (boundaryCountAt(v, match.index) > 1) return null
1219
+ if (exceedsSingleBoundary(v, match.index)) return null
1254
1220
  const groups = namedGroups<{ after: string }>(matchArgs(match))
1255
1221
  return `${EM_DASH} ${groups.after}`
1256
1222
  },
@@ -1292,14 +1258,10 @@ const { WORD_JOINER } = UNICODE_SYMBOLS
1292
1258
  * `nbspTransform` (U+00A0 breaks `Fig. 1` searches). Apply only in rendered
1293
1259
  * HTML; do not write into Markdown source.
1294
1260
  */
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
- }
1261
+ export const dashWordJoiner = makeProsePass((view) => {
1262
+ const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
1263
+ replaceAllInView(view, re, (match) => `${WORD_JOINER}${match[0]}`)
1264
+ })
1303
1265
 
1304
1266
  /** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
1305
1267
  function matchArgs(match: RegExpExecArray): unknown[] {
package/src/nbsp.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { cachedRegExp, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
2
- import { boundaryCountAt, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
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"
3
3
 
4
4
  const {
5
5
  NBSP,
@@ -95,8 +95,8 @@ const COPYRIGHT_SYMBOLS = `[${COPYRIGHT}${REGISTERED}${TRADEMARK}]`
95
95
 
96
96
 
97
97
  // The boundary-tolerance positions in this module reproduce the element-boundary
98
- // semantics of the pre-v5 sentinel-marked pipeline; they are pinned by the golden
99
- // corpus and the migration's differential fuzz.
98
+ // semantics of the pre-v5 sentinel-marked pipeline; they are pinned by the HTML
99
+ // regression corpus and the migration's differential fuzz.
100
100
 
101
101
  /**
102
102
  * A tolerated slot admits exactly ONE node boundary at that position; two
@@ -110,18 +110,14 @@ function boundariesOnlyAtSlots(view: ProseView, start: number, end: number, slot
110
110
  if (!slots.includes(boundary)) return false
111
111
  }
112
112
  for (const slot of slots) {
113
- if (boundaryCountAt(view, slot) > 1) return false
113
+ if (exceedsSingleBoundary(view, slot)) return false
114
114
  }
115
115
  return true
116
116
  }
117
117
 
118
118
  // Skips when preceded by NBSP or back-to-back with the previous match,
119
119
  // preventing 3+ words from binding into a single line-break atom.
120
- export function nbspAfterShortWords(input: string): string
121
- export function nbspAfterShortWords(input: ProseView): void
122
- export function nbspAfterShortWords(input: string | ProseView): string | void {
123
- return overInput(input, nbspAfterShortWordsOverView)
124
- }
120
+ export const nbspAfterShortWords = makeProsePass(nbspAfterShortWordsOverView)
125
121
 
126
122
  function nbspAfterShortWordsOverView(view: ProseView): void {
127
123
  const pattern = cachedRegExp(
@@ -157,11 +153,7 @@ function nbspAfterShortWordsOverView(view: ProseView): void {
157
153
  view.commit()
158
154
  }
159
155
 
160
- export function nbspBetweenNumberAndUnit(input: string): string
161
- export function nbspBetweenNumberAndUnit(input: ProseView): void
162
- export function nbspBetweenNumberAndUnit(input: string | ProseView): string | void {
163
- return overInput(input, nbspBetweenNumberAndUnitOverView)
164
- }
156
+ export const nbspBetweenNumberAndUnit = makeProsePass(nbspBetweenNumberAndUnitOverView)
165
157
 
166
158
  function nbspBetweenNumberAndUnitOverView(view: ProseView): void {
167
159
  const pattern = cachedRegExp(
@@ -198,8 +190,6 @@ const MAX_LAST_WORD_LENGTH = 10
198
190
  // Bounded so the lookbehind stays ReDoS-safe.
199
191
  const MAX_MIDDLE_WORD_LENGTH = 15
200
192
 
201
- const LATIN_LETTER_RE = cachedRegExp(`[${LATIN_LETTERS}]`, "u")
202
-
203
193
  /**
204
194
  * The cascade lookbehind `(?<![NBSP][LATIN]{1,15})` over clean text: blocks
205
195
  * when an NBSP/NNBSP is followed by 1..15 Latin letters ending at the space. A
@@ -219,11 +209,7 @@ function cascadeBlocksLastWord(view: ProseView, spaceOffset: number): boolean {
219
209
  }
220
210
 
221
211
  // Skips when the second-to-last word is already glued backwards via NBSP.
222
- export function nbspBeforeLastWord(input: string): string
223
- export function nbspBeforeLastWord(input: ProseView): void
224
- export function nbspBeforeLastWord(input: string | ProseView): string | void {
225
- return overInput(input, nbspBeforeLastWordOverView)
226
- }
212
+ export const nbspBeforeLastWord = makeProsePass(nbspBeforeLastWordOverView)
227
213
 
228
214
  function nbspBeforeLastWordOverView(view: ProseView): void {
229
215
  // The `(?<=\w|boundary)` lookbehind and the `[NBSP][LATIN]{1,15}` cascade
@@ -248,7 +234,7 @@ function nbspBeforeLastWordOverView(view: ProseView): void {
248
234
  // `\n\n`/end-of-text; two adjacent boundaries block the match. The
249
235
  // end-of-text slot sits at the match end (never an interior boundary),
250
236
  // so the cap is checked here rather than only in allowBoundaries.
251
- if (boundaryCountAt(v, end - match.groups!.ending.length) > 1) return null
237
+ if (exceedsSingleBoundary(v, end - match.groups!.ending.length)) return null
252
238
  // Edit only the leading space so the lastWord/ending (and any boundary
253
239
  // before \n\n) keep their positions.
254
240
  v.replace(start, start + 1, NBSP)
@@ -332,35 +318,15 @@ function nbspBetweenInitialsOverView(view: ProseView): void {
332
318
  nbspBeforeContext(view, `${UNICODE_UPPERCASE}\\.`, UNICODE_UPPERCASE, "gu", "initial")
333
319
  }
334
320
 
335
- export function nbspAfterReferenceAbbreviations(input: string): string
336
- export function nbspAfterReferenceAbbreviations(input: ProseView): void
337
- export function nbspAfterReferenceAbbreviations(input: string | ProseView): string | void {
338
- return overInput(input, nbspAfterReferenceAbbreviationsOverView)
339
- }
321
+ export const nbspAfterReferenceAbbreviations = makeProsePass(nbspAfterReferenceAbbreviationsOverView)
340
322
 
341
- export function nbspAfterSectionSymbols(input: string): string
342
- export function nbspAfterSectionSymbols(input: ProseView): void
343
- export function nbspAfterSectionSymbols(input: string | ProseView): string | void {
344
- return overInput(input, nbspAfterSectionSymbolsOverView)
345
- }
323
+ export const nbspAfterSectionSymbols = makeProsePass(nbspAfterSectionSymbolsOverView)
346
324
 
347
- export function nbspAfterHonorifics(input: string): string
348
- export function nbspAfterHonorifics(input: ProseView): void
349
- export function nbspAfterHonorifics(input: string | ProseView): string | void {
350
- return overInput(input, nbspAfterHonorificsOverView)
351
- }
325
+ export const nbspAfterHonorifics = makeProsePass(nbspAfterHonorificsOverView)
352
326
 
353
- export function nbspAfterCopyrightSymbols(input: string): string
354
- export function nbspAfterCopyrightSymbols(input: ProseView): void
355
- export function nbspAfterCopyrightSymbols(input: string | ProseView): string | void {
356
- return overInput(input, nbspAfterCopyrightSymbolsOverView)
357
- }
327
+ export const nbspAfterCopyrightSymbols = makeProsePass(nbspAfterCopyrightSymbolsOverView)
358
328
 
359
- export function nbspBetweenInitials(input: string): string
360
- export function nbspBetweenInitials(input: ProseView): void
361
- export function nbspBetweenInitials(input: string | ProseView): string | void {
362
- return overInput(input, nbspBetweenInitialsOverView)
363
- }
329
+ export const nbspBetweenInitials = makeProsePass(nbspBetweenInitialsOverView)
364
330
 
365
331
  // Specific patterns first so they claim matches before generic nbspAfterShortWords.
366
332
  const NBSP_TRANSFORMS: ((view: ProseView) => void)[] = [
package/src/prose-view.ts CHANGED
@@ -244,6 +244,15 @@ export function boundaryCountAt(view: ProseView, offset: number): number {
244
244
  return count
245
245
  }
246
246
 
247
+ /**
248
+ * True when more than one node boundary is stacked at `offset` — i.e. the
249
+ * position exceeds the single-boundary tolerance the passes allow in an
250
+ * editing slot.
251
+ */
252
+ export function exceedsSingleBoundary(view: ProseView, offset: number): boolean {
253
+ return boundaryCountAt(view, offset) > 1
254
+ }
255
+
247
256
  export function replaceAllInView(
248
257
  view: ProseView,
249
258
  regex: RegExp,
@@ -518,3 +527,18 @@ export function overInput(input: string | ProseView, run: (view: ProseView) => v
518
527
  run(input)
519
528
  input.commit()
520
529
  }
530
+
531
+ /**
532
+ * Wraps a view runner as a dual-input {@link ProsePass}: the returned function
533
+ * transforms a string and returns it, or edits a ProseView in place. Collapses
534
+ * the string/ProseView overload triple that every zero-option public pass would
535
+ * otherwise repeat.
536
+ */
537
+ export function makeProsePass(run: (view: ProseView) => void): ProsePass {
538
+ function pass(input: string): string
539
+ function pass(input: ProseView): void
540
+ function pass(input: string | ProseView): string | void {
541
+ return overInput(input, run)
542
+ }
543
+ return pass
544
+ }