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.
Files changed (65) hide show
  1. package/README.md +36 -22
  2. package/dist/constants.d.ts +0 -34
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -49
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +1170 -176
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +52 -1
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +179 -17
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -96
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +6 -2
  53. package/src/constants.ts +0 -56
  54. package/src/dashes.ts +1217 -208
  55. package/src/index.ts +71 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +250 -25
  59. package/src/quote-classifier.ts +618 -97
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
package/src/symbols.ts CHANGED
@@ -1,10 +1,8 @@
1
- import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS, wordBoundaryEnd } from "./constants.js"
1
+ import { cachedRegExp, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
2
+ import { boundaryCountAt, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
2
3
  import { convertPrimeMarks } from "./quote-classifier.js"
3
- import { namedGroups } from "./utils.js"
4
4
 
5
5
  export interface SymbolOptions {
6
- /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
7
- separator?: string
8
6
  /** Include arrow transforms (-> → →). Default: true */
9
7
  includeArrows?: boolean
10
8
  }
@@ -36,134 +34,428 @@ const {
36
34
  EXCLAMATION_QUESTION,
37
35
  } = UNICODE_SYMBOLS
38
36
 
37
+ // The boundary-tolerance positions throughout this module reproduce the
38
+ // element-boundary semantics of the pre-v5 sentinel-marked pipeline; they are
39
+ // pinned by the golden corpus and the migration's differential fuzz.
40
+
41
+ const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
42
+ const WORD_RE = /\w/
43
+
39
44
  /** Convert "..." or ". . ." to "…". */
40
- export function ellipsis(text: string, options: SymbolOptions = {}): string {
41
- const chr = getEscapedSeparator(options)
42
-
43
- // Convert consecutive or spaced dots: ... or . . . → …
44
- // Each gap allows EITHER a space (same-text-node spaced dots) OR a separator
45
- // (cross-boundary contiguous dots), but never both — a space adjacent to a
46
- // separator means the dots straddle a text-node boundary and shouldn't fold
47
- // into one ellipsis.
48
- const pattern = cachedRegExp(`\\.(?:[${SPACE_CHARS}]|(?<sep1>${chr}))?\\.(?:[${SPACE_CHARS}]?|(?<sep2>${chr})?)\\.`, "g")
49
- text = text.replace(pattern, (...args) => {
50
- const { sep1, sep2 } = namedGroups<{ sep1?: string; sep2?: string }>(args)
51
- return ELLIPSIS + (sep1 ?? "") + (sep2 ?? "")
45
+ export function ellipsis(input: string): string
46
+ export function ellipsis(input: ProseView): void
47
+ export function ellipsis(input: string | ProseView): string | void {
48
+ return overInput(input, ellipsisOverView)
49
+ }
50
+
51
+ const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
52
+
53
+ function ellipsisOverView(view: ProseView): void {
54
+ ellipsisFoldDots(view)
55
+ view.commit()
56
+
57
+ // A space is inserted only when a letter or digit directly follows the
58
+ // ellipsis. A node boundary between the ellipsis and the letter is a non-letter
59
+ // for the `(?=[A-Za-z…\d])` lookahead, so it blocks the space; re-check the
60
+ // boundary at the lookahead position.
61
+ const trailing = cachedRegExp(`${ELLIPSIS}(?=[${LATIN_LETTERS}\\d])`, "gu")
62
+ replaceAllInView(view, trailing, (match, v) => {
63
+ if (v.hasBoundary(match.index + match[0].length)) return null
64
+ return `${ELLIPSIS} `
52
65
  })
66
+ view.commit()
67
+ }
53
68
 
54
- text = text.replace(cachedRegExp(`${ELLIPSIS}(?=[${LATIN_LETTERS}\\d])`, "gu"), `${ELLIPSIS} `)
69
+ /**
70
+ * Offset of the dot following one inter-dot gap that begins at `afterDot`, or
71
+ * -1 when no dot is reachable. The gap is one of: nothing, one space character,
72
+ * or one node boundary — never two and never a space-plus-boundary mix. A dot
73
+ * is reachable only when no unconsumed boundary shadows it (the `\.` cannot
74
+ * cross a boundary).
75
+ */
76
+ function ellipsisNextDot(view: ProseView, text: string, afterDot: number): number {
77
+ const boundariesHere = boundaryCountAt(view, afterDot)
78
+ if (text[afterDot] === ".") {
79
+ // Empty gap (no boundary) or a single-boundary gap consuming exactly one.
80
+ return boundariesHere <= 1 ? afterDot : -1
81
+ }
82
+ // Space gap: one space character with no boundary, then a non-shadowed dot.
83
+ if (boundariesHere === 0 && text[afterDot] !== undefined && SPACE_CHAR_RE.test(text[afterDot])) {
84
+ const dot = afterDot + 1
85
+ if (text[dot] === "." && boundaryCountAt(view, dot) === 0) return dot
86
+ }
87
+ return -1
88
+ }
55
89
 
56
- return text
90
+ /**
91
+ * Fold `...`, `. . .`, and the cross-boundary variants into `…`, scanning
92
+ * left-to-right so a blocked leftmost triple still lets the next valid triple
93
+ * fold. Each fold replaces the whole `. gap . gap .` span with `…`; consumed
94
+ * interior boundaries collapse to just after the ellipsis.
95
+ */
96
+ function ellipsisFoldDots(view: ProseView): void {
97
+ const text = view.text
98
+ let i = 0
99
+ while (i < text.length) {
100
+ if (text[i] !== ".") { i++; continue }
101
+ const secondDot = ellipsisNextDot(view, text, i + 1)
102
+ if (secondDot < 0) { i++; continue }
103
+ const thirdDot = ellipsisNextDot(view, text, secondDot + 1)
104
+ if (thirdDot < 0) { i++; continue }
105
+ view.replace(i, thirdDot + 1, ELLIPSIS)
106
+ i = thirdDot + 1
107
+ }
57
108
  }
58
109
 
110
+
59
111
  /** Convert "5x5" to "5×5". Skips hex (0x5F). */
60
- export function multiplication(text: string, options: SymbolOptions = {}): string {
61
- const chr = getEscapedSeparator(options)
62
-
63
- // After a digit run, either a prime mark (10′) or a length/size unit may
64
- // attach before the multiplication operator. Allowing both lets dimensions
65
- // like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching
66
- // Chicago §9.17's preference for × in dimension notation.
67
- //
68
- // Only length/size units that plausibly appear in two-dimensional
69
- // dimensions are listed — mass/time/electrical units (kg, min, V, …) don't
70
- // participate in "N × N" constructions and excluding them avoids false
71
- // matches on words ending in those letters.
72
- const dimensionUnits = "rem|vh|vw|km|cm|mm|nm|pm|mi|ft|yd|in|px|pt|em|m"
73
- // Word-boundary lookahead: the unit must be followed by whitespace, a
74
- // separator marker, the multiplication operator, end-of-string, or
75
- // sentence punctuation — never another letter (which would mean the
76
- // "unit" is actually a word prefix like "mold").
77
- const unitBoundary = `(?=\\s|${chr}|[xX*.,;!?)}]|$)`
78
- const unitAlt = `(?:\\s|${chr})?(?:${dimensionUnits})${unitBoundary}`
79
- const primeAlt = `${chr}?[${PRIME}${DOUBLE_PRIME}]`
80
- const digitSuffix = `(?:${primeAlt}|${unitAlt})?`
81
-
82
- // Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
83
- // Pattern matches: digit(s), then one or more (operator, digit(s)) groups
84
- // The (?<!\d[eE]) lookbehind prevents matching inside ambiguous unsigned
85
- // scientific notation (1e5x3 — could also be a model SKU). Signed exponents
86
- // (1e-5x3, 3.5E+10x2) are unambiguously scientific so the multiplication
87
- // is converted. The Latin-letter lookbehind blocks model-name identifiers
88
- // (Surface5x3, RTX3060x2).
89
- const chainPattern = cachedRegExp(
90
- `(?<!\\d[eE])(?<![${LATIN_LETTERS}\\d])(?<firstNum>\\d+${digitSuffix})(?<rest>(?:${chr}?\\s*[xX*]\\s*${chr}?\\d+${digitSuffix})+)`,
91
- "g"
92
- )
112
+ export function multiplication(input: string): string
113
+ export function multiplication(input: ProseView): void
114
+ export function multiplication(input: string | ProseView): string | void {
115
+ return overInput(input, multiplicationOverView)
116
+ }
93
117
 
94
- text = text.replace(chainPattern, (...args) => {
95
- const { firstNum, rest } = namedGroups<{ firstNum: string; rest: string }>(args)
96
- // Skip hexadecimal: 0x... or 0X...
97
- if (firstNum === "0" && /^x/i.test(rest)) return args[0] as string
98
-
99
- // Replace each "operator + next digit group" segment in lockstep.
100
- // Consuming the digit-suffix as part of each segment keeps the inner
101
- // replace from misreading the `x` inside a trailing unit like `px` as
102
- // another operator.
103
- // Sticky flag (`y`) anchors each segment at the previous one's end.
104
- // `rest` always starts with a chain segment from the outer match, so
105
- // the engine never tries multiple start positions across whitespace.
106
- const converted = rest.replace(
107
- cachedRegExp(
108
- `(?<pre>${chr}?)(?<spaceBefore>\\s*)[xX*](?<spaceAfter>\\s*)(?<post>${chr}?)(?<num>\\d+${digitSuffix})`,
109
- "gy"
110
- ),
111
- (...innerArgs) => {
112
- const groups = namedGroups<{
113
- pre: string
114
- spaceBefore: string
115
- spaceAfter: string
116
- post: string
117
- num: string
118
- }>(innerArgs)
119
- const space = groups.spaceBefore || groups.spaceAfter ? " " : ""
120
- return `${groups.pre}${space}${MULTIPLICATION}${space}${groups.post}${groups.num}`
121
- }
122
- )
123
- return `${firstNum}${converted}`
124
- })
118
+ // After a digit run, either a prime mark (10′) or a length/size unit may
119
+ // attach before the multiplication operator. Allowing both lets dimensions
120
+ // like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching
121
+ // Chicago §9.17's preference for × in dimension notation.
122
+ //
123
+ // Only length/size units that plausibly appear in two-dimensional dimensions
124
+ // are listed — mass/time/electrical units (kg, min, V, …) don't participate in
125
+ // "N × N" constructions and excluding them avoids false matches on words
126
+ // ending in those letters.
127
+ const DIMENSION_UNITS = "rem|vh|vw|km|cm|mm|nm|pm|mi|ft|yd|in|px|pt|em|m"
128
+ // Word-boundary lookahead: the unit must be followed by whitespace, the
129
+ // multiplication operator, end-of-string, or sentence punctuation — never
130
+ // another letter (which would mean the "unit" is actually a word prefix like
131
+ // "mold"). A node boundary directly after the unit also satisfies this position
132
+ // and is permitted by allowBoundaries, so it is dropped from the literal
133
+ // lookahead.
134
+ const UNIT_BOUNDARY = `(?=\\s|[xX*.,;!?)}]|$)`
135
+ const UNIT_ALT = `\\s?(?:${DIMENSION_UNITS})${UNIT_BOUNDARY}`
136
+ const PRIME_ALT = `[${PRIME}${DOUBLE_PRIME}]`
137
+ const DIGIT_SUFFIX = `(?:${PRIME_ALT}|${UNIT_ALT})?`
138
+
139
+ // Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5".
140
+ // Pattern matches: digit(s), then one or more (operator, digit(s)) groups.
141
+ // The (?<!\d[eE]) lookbehind prevents matching inside ambiguous unsigned
142
+ // scientific notation (1e5x3 — could also be a model SKU). Signed exponents
143
+ // (1e-5x3, 3.5E+10x2) are unambiguously scientific so the multiplication is
144
+ // converted. The Latin-letter lookbehind blocks model-name identifiers
145
+ // (Surface5x3, RTX3060x2).
146
+ const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:\\s*[xX*]\\s*\\d+${DIGIT_SUFFIX})+)`
147
+ const MULTIPLICATION_SEGMENT = `(?<spaceBefore>\\s*)[xX*](?<spaceAfter>\\s*)(?<num>\\d+${DIGIT_SUFFIX})`
148
+
149
+ /** A `\s*[xX*]\s*\d+suffix` segment located by absolute clean-text offsets. */
150
+ interface ChainSegment {
151
+ /** Absolute offset of the operator character. */
152
+ operatorOffset: number
153
+ /** Absolute offset where the right operand's digits begin. */
154
+ operandStart: number
155
+ /** Absolute offset just past the right operand (including any suffix). */
156
+ operandEnd: number
157
+ spaceBefore: string
158
+ spaceAfter: string
159
+ }
125
160
 
126
- // Trailing multiplier: 5x (followed by word boundary - space, punctuation, etc.)
127
- const wbe = wordBoundaryEnd(chr)
128
- const trailingPattern = cachedRegExp(`(?<!\\d[eE])(?<![${LATIN_LETTERS}\\d])(?<num>\\d+${chr}?)[xX*]${wbe}`, "g")
129
- text = text.replace(trailingPattern, (match, num: string) => {
130
- // Skip if this looks like start of hexadecimal
131
- if (num === "0") return match
132
- return `${num}${MULTIPLICATION}`
133
- })
161
+ /** Locate each operator segment of `rest` (absolute offsets via `chainStart`). */
162
+ function chainSegments(chainStart: number, firstNum: string, rest: string): ChainSegment[] {
163
+ const segments: ChainSegment[] = []
164
+ const segmentRe = cachedRegExp(MULTIPLICATION_SEGMENT, "gy")
165
+ segmentRe.lastIndex = 0
166
+ let m: RegExpExecArray | null
167
+ while ((m = segmentRe.exec(rest)) !== null) {
168
+ const groups = m.groups as { spaceBefore: string; spaceAfter: string; num: string }
169
+ const operatorAbs = chainStart + firstNum.length + m.index + groups.spaceBefore.length
170
+ segments.push({
171
+ operatorOffset: operatorAbs,
172
+ operandStart: operatorAbs + 1 + groups.spaceAfter.length,
173
+ operandEnd: chainStart + firstNum.length + m.index + m[0].length,
174
+ spaceBefore: groups.spaceBefore,
175
+ spaceAfter: groups.spaceAfter,
176
+ })
177
+ }
178
+ return segments
179
+ }
134
180
 
135
- return text
181
+ /**
182
+ * Each operator's spacing slot (`\s*[xX*]\s*`) tolerates one node boundary at
183
+ * each outer edge and none inside the spacing; a second adjacent boundary at an
184
+ * edge breaks the segment. The operator's slot is the span [slotStart, slotEnd)
185
+ * covering its surrounding spaces and the operator itself.
186
+ */
187
+ function operatorSlotClean(view: ProseView, slotStart: number, slotEnd: number): boolean {
188
+ // No boundary strictly inside the spacing-plus-operator slot.
189
+ for (const boundary of view.boundaries) {
190
+ if (boundary > slotStart && boundary < slotEnd) return false
191
+ }
192
+ // At most one boundary hugging each outer edge (duplicates at an offset come
193
+ // from empty nodes and each counts as one boundary).
194
+ return boundaryCountAt(view, slotStart) <= 1 && boundaryCountAt(view, slotEnd) <= 1
195
+ }
196
+
197
+ /**
198
+ * The chain's leading lookbehinds at `operandStart`
199
+ * (`(?<!\d[eE])(?<![A-Za-z…\d])`); the Latin-letter arm already rejects any
200
+ * operand preceded by `e`/`E`, so the scientific-notation arm adds nothing for
201
+ * the operand's immediate left character and is folded in here. A node boundary
202
+ * right before the operand shadows the clean character (a boundary is not a
203
+ * letter or digit), satisfying the lookbehind.
204
+ */
205
+ function chainGuardOk(view: ProseView, operandStart: number): boolean {
206
+ if (view.hasBoundary(operandStart)) return true
207
+ const prior = view.text[operandStart - 1]
208
+ return prior === undefined || !(LATIN_LETTER_RE.test(prior) || /\d/.test(prior))
136
209
  }
137
210
 
138
- type MathLookaheadBuilder = (escapedSeparator: string) => string
211
+ /**
212
+ * The hex skip `firstNum === "0" && /^x/i.test(rest)`: a `0` operand directly
213
+ * followed (no boundary) by `x`/`X` skips the whole matched chain without
214
+ * re-anchoring.
215
+ */
216
+ function chainHexBlocked(view: ProseView, operandStart: number, operandText: string): boolean {
217
+ if (operandText !== "0") return false
218
+ const after = operandStart + operandText.length
219
+ // Hex requires `x`/`X` directly after the `0` (a unit between them is not hex).
220
+ // `after` always indexes the operator/unit reached by the match, so it is in
221
+ // bounds.
222
+ return !view.hasBoundary(after) && /x/i.test(view.text[after])
223
+ }
139
224
 
140
- type MathSymbolRule = [string, string, MathLookaheadBuilder, string]
225
+ /**
226
+ * Start offset of the maximal boundary-free digit run ending at `digitsEnd`
227
+ * (exclusive), not crossing below `lowerBound`. A node boundary inside a digit
228
+ * run truncates the operand an operator actually sees; that truncated run drives
229
+ * the hex and guard checks.
230
+ */
231
+ function leftDigitRunStart(view: ProseView, text: string, digitsEnd: number, lowerBound: number): number {
232
+ let start = digitsEnd
233
+ // A boundary at `digitsEnd` sits between the last digit and the operator and
234
+ // does not split the run; only boundaries strictly inside the digits do.
235
+ while (start > lowerBound && /\d/.test(text[start - 1]) && (start === digitsEnd || !view.hasBoundary(start))) start--
236
+ return start
237
+ }
238
+
239
+ /**
240
+ * End offset of the digit run of the left operand whose operator spacing slot
241
+ * begins at `slotStart`: walk back over the operand's optional suffix (a prime,
242
+ * or a unit's letters, possibly with one space) to the last digit.
243
+ */
244
+ function leftDigitsEnd(text: string, slotStart: number): number {
245
+ let i = slotStart
246
+ while (i > 0 && !/\d/.test(text[i - 1])) i--
247
+ return i
248
+ }
249
+
250
+ /**
251
+ * True iff the operand's unit suffix between `digitsEnd` and `slotStart` is not
252
+ * split by a node boundary. One boundary is tolerated before the unit, but the
253
+ * unit's letters must be contiguous, so a boundary between two unit letters
254
+ * detaches the operator from the operand.
255
+ */
256
+ function leftSuffixUnsplit(view: ProseView, text: string, digitsEnd: number, slotStart: number): boolean {
257
+ // A prime or unit suffix begins after one optional space-or-boundary slot;
258
+ // a second boundary in that slot (before the suffix) leaves `\d+suffix`
259
+ // matching only the digits, detaching the operator.
260
+ if (slotStart > digitsEnd && boundaryCountAt(view, digitsEnd) > 1) return false
261
+ for (let i = digitsEnd + 1; i < slotStart; i++) {
262
+ if (LATIN_LETTER_RE.test(text[i - 1]) && LATIN_LETTER_RE.test(text[i]) && view.hasBoundary(i)) return false
263
+ }
264
+ return true
265
+ }
266
+
267
+ /**
268
+ * True iff a node boundary falls strictly inside the leading digit run of the
269
+ * operand starting at `operandStart` (`\d+` cannot cross a boundary, so such a
270
+ * boundary truncates the operand and ends the chain there).
271
+ */
272
+ function runHasInteriorBoundary(view: ProseView, operandStart: number, operandEnd: number): boolean {
273
+ const text = view.text
274
+ let i = operandStart + 1
275
+ while (i < operandEnd && /\d/.test(text[i])) {
276
+ if (view.hasBoundary(i)) return true
277
+ i++
278
+ }
279
+ return false
280
+ }
281
+
282
+ function multiplicationOverView(view: ProseView): void {
283
+ // The chain is matched with a sticky regex driven by a manual left-to-right
284
+ // scan. Sticky anchoring keeps the nested `\d+(…\d+)+` quantifier ReDoS-safe
285
+ // without leading lookbehinds; dropping those lookbehinds lets a chain anchor
286
+ // on an operand whose preceding letter/digit is shadowed by a node boundary.
287
+ // Boundary-aware guard/hex/operand checks below make the per-operand
288
+ // decisions.
289
+ const chainPattern = cachedRegExp(MULTIPLICATION_CHAIN, "y")
290
+ const chainText = view.text
291
+ let scan = 0
292
+ while (scan < chainText.length) {
293
+ chainPattern.lastIndex = scan
294
+ const match = chainPattern.exec(chainText)
295
+ if (match === null) { scan++; continue }
296
+ const { firstNum, rest } = match.groups as { firstNum: string; rest: string }
297
+ const text = chainText
298
+ const chainStart = match.index
299
+ const segments = chainSegments(chainStart, firstNum, rest)
300
+
301
+ // Edit each operator's spacing slot in place, leaving the digit operands
302
+ // (and any node boundaries inside them) untouched so boundary positions are
303
+ // preserved.
304
+ //
305
+ // The chain matches contiguously, then the hex skip is applied. Two
306
+ // outcomes propagate differently:
307
+ // - Leading-guard failure does not anchor here, so the match is re-tried
308
+ // at the next operand: keep retrying each operator until one anchors.
309
+ // - Hex skip and a successful anchor consume the matched chain, so no
310
+ // operator inside it converts or re-anchors until a STRUCTURAL break (a
311
+ // boundary inside an operator's spacing slot or splitting an operand's
312
+ // digit run) lets the chain re-match at a later operand.
313
+ let chainActive = false
314
+ let anchored = false
315
+ segments.forEach((segment, index) => {
316
+ const slotStart = segment.operatorOffset - segment.spaceBefore.length
317
+ const slotEnd = segment.operatorOffset + 1 + segment.spaceAfter.length
318
+ const digitsEnd = leftDigitsEnd(text, slotStart)
319
+ // The left operand's unit suffix must be a contiguous match: a boundary
320
+ // splitting the unit's letters breaks `\d+suffix`, detaching the operator
321
+ // from any operand (its left side is no longer `\d+unit`).
322
+ const slotClean = operatorSlotClean(view, slotStart, slotEnd) && leftSuffixUnsplit(view, text, digitsEnd, slotStart)
323
+ if (!anchored) {
324
+ const lowerBound = index === 0 ? chainStart : segments[index - 1].operandStart
325
+ const runStart = leftDigitRunStart(view, text, digitsEnd, lowerBound)
326
+ const operand = text.slice(runStart, digitsEnd)
327
+ if (!chainGuardOk(view, runStart)) {
328
+ // Did not anchor here; re-try at the next operand.
329
+ chainActive = false
330
+ } else {
331
+ anchored = true
332
+ chainActive = !chainHexBlocked(view, runStart, operand)
333
+ }
334
+ }
335
+ if (!slotClean) {
336
+ // Structural break: the matched chain ends; the next operand re-anchors.
337
+ chainActive = false
338
+ anchored = false
339
+ return
340
+ }
341
+ if (chainActive) {
342
+ const space = segment.spaceBefore || segment.spaceAfter ? " " : ""
343
+ view.replace(slotStart, slotEnd, `${space}${MULTIPLICATION}${space}`)
344
+ }
345
+ // A boundary inside this operand's digit run truncates it (`\d+` stops
346
+ // there): a structural break, so the next operand re-anchors.
347
+ if (runHasInteriorBoundary(view, segment.operandStart, segment.operandEnd)) {
348
+ chainActive = false
349
+ anchored = false
350
+ }
351
+ })
352
+ scan = chainStart + match[0].length
353
+ }
354
+ view.commit()
355
+
356
+ // Trailing multiplier: 5x (followed by a word boundary). The rule is
357
+ // `(?<!\d[eE])(?<![A-Za-z…\d])\d+ [xX*] \b(?!\w)` with one tolerated boundary
358
+ // before the operator. The leading guard, the slot before the operator, the
359
+ // hex skip, and the trailing word boundary each consult boundaries at their
360
+ // position. `*` never trails because it is not a word character, so the
361
+ // closing `\b` cannot anchor on it.
362
+ const trailingPattern = cachedRegExp(`(?<num>\\d+)(?<op>[xX*])`, "y")
363
+ const trailingText = view.text
364
+ let trailingScan = 0
365
+ while (trailingScan < trailingText.length) {
366
+ trailingPattern.lastIndex = trailingScan
367
+ const match = trailingPattern.exec(trailingText)
368
+ if (match === null) { trailingScan++; continue }
369
+ const num = match.groups!.num
370
+ const op = match.groups!.op
371
+ const operatorOffset = match.index + num.length
372
+ trailingScan = match.index + match[0].length
373
+ // One optional boundary may sit between the digits and the operator.
374
+ if (boundaryCountAt(view, operatorOffset) > 1) continue
375
+ // Leading guard and hex skip, on the boundary-free digit run ending at the
376
+ // operator (a boundary inside the digits truncates the operand).
377
+ const runStart = leftDigitRunStart(view, trailingText, operatorOffset, 0)
378
+ const operand = trailingText.slice(runStart, operatorOffset)
379
+ if (!chainGuardOk(view, runStart) || chainHexBlocked(view, runStart, operand)) continue
380
+ // Trailing word boundary: `*` is not a word character so it never anchors a
381
+ // trailing `\b`; otherwise reject when a word character follows the operator
382
+ // through at most three boundaries.
383
+ if (op === "*") continue
384
+ const afterOp = operatorOffset + 1
385
+ const followChar = trailingText[afterOp]
386
+ if (boundaryCountAt(view, afterOp) <= 3 && followChar !== undefined && WORD_RE.test(followChar)) continue
387
+ view.replace(operatorOffset, afterOp, MULTIPLICATION)
388
+ }
389
+ view.commit()
390
+ }
391
+
392
+ /** `[left, right, forbiddenFollow, replacement]`; forbiddenFollow is "" when none. */
393
+ type MathSymbolRule = [string, string, string, string]
141
394
 
142
395
  const MATH_SYMBOL_MAP: MathSymbolRule[] = [
143
- ["!", "=", (chr) => `(?!${chr}?=)`, NOT_EQUAL],
144
- ["\\+/", "-", () => "", PLUS_MINUS],
145
- ["\\+", "-", () => "", PLUS_MINUS],
146
- ["<", "=", (chr) => `(?!${chr}?=)`, LESS_EQUAL],
147
- [">", "=", (chr) => `(?!${chr}?=)`, GREATER_EQUAL],
148
- ["~", "=", () => "", APPROXIMATE],
149
- ["=", "~", () => "", APPROXIMATE],
396
+ ["!", "=", "=", NOT_EQUAL],
397
+ ["\\+/", "-", "", PLUS_MINUS],
398
+ ["\\+", "-", "", PLUS_MINUS],
399
+ ["<", "=", "=", LESS_EQUAL],
400
+ [">", "=", "=", GREATER_EQUAL],
401
+ ["~", "=", "", APPROXIMATE],
402
+ ["=", "~", "", APPROXIMATE],
150
403
  ]
151
404
 
152
405
  /** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
153
- export function mathSymbols(text: string, options: SymbolOptions = {}): string {
154
- const chr = getEscapedSeparator(options)
155
-
156
- for (const [left, right, lookahead, replacement] of MATH_SYMBOL_MAP) {
157
- // Capture an optional separator between left and right so a cross-boundary
158
- // match (e.g., `!<SEP>=`) re-emits the separator alongside the replacement
159
- // and preserves the text-node-count invariant in transformTextNodes.
160
- const pattern = cachedRegExp(`${left}(?<sep>${chr})?${right}${lookahead(chr)}`, "g")
161
- text = text.replace(pattern, (...args) => {
162
- const { sep } = namedGroups<{ sep?: string }>(args)
163
- return `${replacement}${sep ?? ""}`
406
+ export function mathSymbols(input: string): string
407
+ export function mathSymbols(input: ProseView): void
408
+ export function mathSymbols(input: string | ProseView): string | void {
409
+ return overInput(input, mathSymbolsOverView)
410
+ }
411
+
412
+ function mathSymbolsOverView(view: ProseView): void {
413
+ for (const [left, right, forbiddenFollow, replacement] of MATH_SYMBOL_MAP) {
414
+ // Re-read after each rule's commit: prior rules mutate the clean text.
415
+ const text = view.text
416
+ // One boundary is tolerated at the left/right junction (e.g. `!<boundary>=`).
417
+ // Position editing keeps the surrounding fragments (and so the boundary)
418
+ // intact; allowBoundaries permits that one boundary. The negative lookahead
419
+ // `(?!=)` is checked manually because it tolerates one boundary before the
420
+ // forbidden `=`: with two or more boundaries the `=` is out of reach, so the
421
+ // match is not blocked.
422
+ const pattern = cachedRegExp(`${left}${right}`, "g")
423
+ replaceAllInView(view, pattern, (match, v) => {
424
+ if (forbiddenFollow && mathLookaheadBlocks(text, v, match.index + match[0].length, forbiddenFollow)) {
425
+ return null
426
+ }
427
+ return replacement
428
+ }, {
429
+ allowBoundaries: (m, v) => mathOperatorAllowBoundary(m, v),
164
430
  })
431
+ view.commit()
165
432
  }
166
- return text
433
+ }
434
+
435
+ /**
436
+ * The `(?!=)` guard: the match is blocked when the forbidden character follows
437
+ * through at most one boundary. Two or more boundaries put the character out of
438
+ * reach (one boundary tolerated), so the match proceeds. `≈` blocks where `=`
439
+ * does: a later rule in this same pass folds `=~` to `≈`, consuming the `=`
440
+ * this guard keys on (`!==~` must stay blocked once it reads `!=≈`).
441
+ */
442
+ function mathLookaheadBlocks(text: string, view: ProseView, end: number, forbidden: string): boolean {
443
+ const next = text[end]
444
+ const matches = next === forbidden || (forbidden === "=" && next === APPROXIMATE)
445
+ return matches && boundaryCountAt(view, end) <= 1
446
+ }
447
+
448
+ /**
449
+ * Only the junction between the `left` operator string and the single-character
450
+ * `right` tolerates one interior boundary (the `right` char ends the match). A
451
+ * boundary inside a multi-character `left` like `+/` breaks the operator.
452
+ */
453
+ function mathOperatorAllowBoundary(match: RegExpExecArray, view: ProseView): boolean {
454
+ const junction = match.index + match[0].length - 1
455
+ for (const boundary of view.boundaries) {
456
+ if (boundary > match.index && boundary < match.index + match[0].length && boundary !== junction) return false
457
+ }
458
+ return boundaryCountAt(view, junction) <= 1
167
459
  }
168
460
 
169
461
  type ContextPredicate = (before: string, after: string) => boolean
@@ -171,111 +463,289 @@ type ContextPredicate = (before: string, after: string) => boolean
171
463
  // 25 chars: fits "copyright " or a 4-digit year with padding, without making slicing expensive.
172
464
  const LEGAL_SYMBOL_CONTEXT_WINDOW = 25
173
465
 
466
+ // Window cost charged per node boundary: an element edge stands in for two
467
+ // characters of context, so boundary-dense markup exposes less of it.
468
+ const BOUNDARY_CONTEXT_COST = 2
469
+
470
+ // Vulgar fraction glyphs (½, ¾, …). A fraction folds from `n/m`, which the path
471
+ // heuristic below reads as a path context — so the glyph must read the same way,
472
+ // or `1/2(tm)` (blocked) would convert once fractions strips the slash to `½(tm)`.
473
+ const FRACTION_GLYPH_RE = new RegExp(
474
+ `[${Object.entries(UNICODE_SYMBOLS)
475
+ .filter(([key]) => key.startsWith("FRACTION_"))
476
+ .map(([, glyph]) => glyph)
477
+ .join("")}]`,
478
+ )
479
+
174
480
  const isPathContext = (before: string): boolean => {
175
481
  const parts = before.split(/\s+/)
176
482
  const trailing = parts[parts.length - 1]
483
+ if (FRACTION_GLYPH_RE.test(trailing)) return true
177
484
  const slashIdx = trailing.indexOf("/")
178
485
  return slashIdx >= 0 && slashIdx < trailing.length - 1
179
486
  }
180
487
 
181
488
  function contextAwareLegalReplace(
182
- text: string,
489
+ view: ProseView,
183
490
  pattern: RegExp,
184
491
  replacement: string,
185
492
  shouldConvert: ContextPredicate,
186
- separator: string,
187
- ): string {
188
- return text.replace(pattern, (match: string, offset: number, str: string) => {
189
- const before = str
190
- .slice(Math.max(0, offset - LEGAL_SYMBOL_CONTEXT_WINDOW), offset)
191
- .replaceAll(separator, "")
192
- const after = str
193
- .slice(offset + match.length, offset + match.length + LEGAL_SYMBOL_CONTEXT_WINDOW)
194
- .replaceAll(separator, "")
195
- return shouldConvert(before, after) ? replacement : match
493
+ ): void {
494
+ const text = view.text
495
+ replaceAllInView(view, pattern, (match, v) => {
496
+ const offset = match.index
497
+ const before = legalContextBefore(text, v, offset)
498
+ const after = legalContextAfter(text, v, offset + match[0].length)
499
+ return shouldConvert(before, after) ? replacement : null
500
+ }, {
501
+ // The `(c)`/`(r)`/`(tm)` token itself never spans a boundary (no interior
502
+ // slot), so a match containing an interior boundary is skipped, exactly as
503
+ // the default behavior.
504
+ allowBoundaries: undefined,
196
505
  })
197
506
  }
198
507
 
199
- const LEGAL_COPYRIGHT_RE = /\(c\)/gi
200
- const LEGAL_REGISTERED_RE = /\(r\)/gi
201
- const LEGAL_TRADEMARK_RE = /\(tm\)/gi
508
+ /**
509
+ * Context preceding `offset`, spanning the 25-character window. Each node
510
+ * boundary costs {@link BOUNDARY_CONTEXT_COST} characters of the window, so a
511
+ * region dense with boundaries exposes fewer characters of context.
512
+ */
513
+ function legalContextBefore(text: string, view: ProseView, offset: number): string {
514
+ let cost = 0
515
+ let i = offset
516
+ while (i > 0) {
517
+ // Cost of stepping back over this clean char plus the boundaries hugging it.
518
+ cost += boundaryCountAt(view, i) * BOUNDARY_CONTEXT_COST + 1
519
+ if (cost > LEGAL_SYMBOL_CONTEXT_WINDOW) break
520
+ i--
521
+ }
522
+ return text.slice(i, offset)
523
+ }
524
+
525
+ /** Mirror of {@link legalContextBefore} for the text following `end`. */
526
+ function legalContextAfter(text: string, view: ProseView, end: number): string {
527
+ let cost = 0
528
+ let i = end
529
+ while (i < text.length) {
530
+ cost += boundaryCountAt(view, i) * BOUNDARY_CONTEXT_COST + 1
531
+ if (cost > LEGAL_SYMBOL_CONTEXT_WINDOW) break
532
+ i++
533
+ }
534
+ return text.slice(end, i)
535
+ }
536
+
537
+ const LEGAL_COPYRIGHT_RE = "\\(c\\)"
538
+ const LEGAL_REGISTERED_RE = "\\(r\\)"
539
+ const LEGAL_TRADEMARK_RE = "\\(tm\\)"
202
540
 
203
541
  /** Convert (c), (r), (tm) to ©, ®, ™. */
204
- export function legalSymbols(text: string, options: SymbolOptions = {}): string {
205
- const separator = options.separator ?? DEFAULT_SEPARATOR
206
- // (c) → © only with positive copyright evidence (year or "copyright" keyword)
207
- // and not in a path context (e.g., example.com/path(c))
208
- text = contextAwareLegalReplace(text, LEGAL_COPYRIGHT_RE, COPYRIGHT, (before, after) =>
542
+ export function legalSymbols(input: string): string
543
+ export function legalSymbols(input: ProseView): void
544
+ export function legalSymbols(input: string | ProseView): string | void {
545
+ return overInput(input, legalSymbolsOverView)
546
+ }
547
+
548
+ function legalSymbolsOverView(view: ProseView): void {
549
+ // (c) → © only with positive copyright evidence (year or "copyright"
550
+ // keyword) and not in a path context (e.g. example.com/path(c)).
551
+ contextAwareLegalReplace(view, cachedRegExp(LEGAL_COPYRIGHT_RE, "gi"), COPYRIGHT, (before, after) =>
209
552
  !isPathContext(before) && (/^\s*(?:19|20)\d{2}\b/.test(after) || /\bcopyright\s*$/i.test(before)),
210
- separator
211
553
  )
554
+ view.commit()
212
555
 
213
- // (r) → ® unless in enumeration "(q), (r)", legal citation "(r)(1)", or path context
214
- text = contextAwareLegalReplace(text, LEGAL_REGISTERED_RE, REGISTERED, (before, after) =>
556
+ // (r) → ® unless in enumeration "(q), (r)", legal citation "(r)(1)", or path context.
557
+ contextAwareLegalReplace(view, cachedRegExp(LEGAL_REGISTERED_RE, "gi"), REGISTERED, (before, after) =>
215
558
  !/\([a-z]\)[,;]\s*$/i.test(before) && !/^\(\d/.test(after) && !isPathContext(before),
216
- separator
217
559
  )
560
+ view.commit()
218
561
 
219
- // (tm) → ™ unless in a path context
220
- text = contextAwareLegalReplace(text, LEGAL_TRADEMARK_RE, TRADEMARK, (before) =>
562
+ // (tm) → ™ unless in a path context.
563
+ contextAwareLegalReplace(view, cachedRegExp(LEGAL_TRADEMARK_RE, "gi"), TRADEMARK, (before) =>
221
564
  !isPathContext(before),
222
- separator
223
565
  )
566
+ view.commit()
567
+ }
568
+
569
+ /** Matches one arrow shape starting at `start`; returns its end offset or -1. */
570
+ type ArrowMatcher = (view: ProseView, text: string, start: number) => number
571
+
572
+ const SPACE_OR_TAB_RE = /\s/
573
+
574
+ /**
575
+ * One boundary is tolerated after `<`, between dash runs, and before `>`, and a
576
+ * boundary cannot split a dash run (`-+`). Each helper walks the clean text and
577
+ * consults boundaries at exactly those positions, tolerating at most one
578
+ * boundary per slot.
579
+ */
580
+ const ARROW_MATCHERS: readonly [ArrowMatcher, string][] = [
581
+ [matchLeftRightArrow, ARROW_LEFT_RIGHT],
582
+ [matchRightArrow, ARROW_RIGHT],
583
+ [matchLeftArrow, ARROW_LEFT],
584
+ ]
224
585
 
225
- return text
586
+ /** Length of a `-+` dash run at `pos` (no boundary may split it); 0 if none. */
587
+ function dashRunLength(view: ProseView, text: string, pos: number): number {
588
+ let i = pos
589
+ while (text[i] === "-" && (i === pos || boundaryCountAt(view, i) === 0)) i++
590
+ return i - pos
226
591
  }
227
592
 
228
- type ArrowPatternBuilder = (escapedSeparator: string) => string
593
+ /** Skip at most one boundary slot at `pos`; returns the offset after it. -1 on a double boundary. */
594
+ function arrowSepSlot(view: ProseView, pos: number): number {
595
+ const count = boundaryCountAt(view, pos)
596
+ return count <= 1 ? pos : -1
597
+ }
229
598
 
230
- // Leading `<` on bidi anchors the dash-run group for linear time.
231
- const ARROW_RULES: readonly [ArrowPatternBuilder, string][] = [
232
- [(sep) => `<${sep}?-+(?:${sep}-+)*${sep}?>`, ARROW_LEFT_RIGHT],
233
- [(sep) => `-+${sep}?>`, ARROW_RIGHT],
234
- [(sep) => `<${sep}?-+`, ARROW_LEFT],
235
- ]
599
+ /** `-+${sep}?>`: a dash run, an optional boundary, then `>`. */
600
+ function matchRightArrow(view: ProseView, text: string, start: number): number {
601
+ const run = dashRunLength(view, text, start)
602
+ if (run === 0) return -1
603
+ const beforeGt = start + run
604
+ if (arrowSepSlot(view, beforeGt) < 0) return -1
605
+ return text[beforeGt] === ">" ? beforeGt + 1 : -1
606
+ }
607
+
608
+ /** `<${sep}?-+`: `<`, an optional boundary, then a dash run. */
609
+ function matchLeftArrow(view: ProseView, text: string, start: number): number {
610
+ if (text[start] !== "<") return -1
611
+ const afterLt = start + 1
612
+ if (arrowSepSlot(view, afterLt) < 0) return -1
613
+ const run = dashRunLength(view, text, afterLt)
614
+ return run === 0 ? -1 : afterLt + run
615
+ }
616
+
617
+ /** `<${sep}?-+(?:${sep}-+)*${sep}?>`: bidirectional arrow with dash runs. */
618
+ function matchLeftRightArrow(view: ProseView, text: string, start: number): number {
619
+ if (text[start] !== "<") return -1
620
+ let i = start + 1
621
+ if (arrowSepSlot(view, i) < 0) return -1
622
+ let run = dashRunLength(view, text, i)
623
+ if (run === 0) return -1
624
+ i += run
625
+ // Additional `${sep}-+` runs: each separated by exactly one boundary.
626
+ while (boundaryCountAt(view, i) === 1) {
627
+ run = dashRunLength(view, text, i)
628
+ if (run === 0) break
629
+ i += run
630
+ }
631
+ if (arrowSepSlot(view, i) < 0) return -1
632
+ return text[i] === ">" ? i + 1 : -1
633
+ }
634
+
635
+ /** Left context `(^|\s|${chr})`: start of text, a space, or a node boundary. */
636
+ function arrowLeftContextOk(view: ProseView, text: string, start: number): boolean {
637
+ if (start === 0 || view.hasBoundary(start)) return true
638
+ return SPACE_OR_TAB_RE.test(text[start - 1])
639
+ }
640
+
641
+ /** Right context `(?=\s|${chr}|$)`: end of text, a space, or a node boundary. */
642
+ function arrowRightContextOk(view: ProseView, text: string, end: number): boolean {
643
+ if (end >= text.length || view.hasBoundary(end)) return true
644
+ return SPACE_OR_TAB_RE.test(text[end])
645
+ }
236
646
 
237
647
  /** Convert -> and <-> to arrows. */
238
- export function arrows(text: string, options: SymbolOptions = {}): string {
239
- const chr = getEscapedSeparator(options)
240
- const sepPattern = cachedRegExp(chr, "g")
241
- // Boundary on the left is a capturing group rather than a lookbehind so
242
- // the alternation stays out of the assertion (recheck-safe); the captured
243
- // boundary char is re-emitted before the replacement.
244
- const startBoundary = `(^|\\s|${chr})`
245
- const endBoundary = `(?=\\s|${chr}|$)`
246
-
247
- for (const [buildArrow, replacement] of ARROW_RULES) {
248
- const pattern = cachedRegExp(`${startBoundary}${buildArrow(chr)}${endBoundary}`, "g")
249
- // Re-emit every separator the arrow body consumed; dropping any would
250
- // break the text-node-count invariant in transformTextNodes.
251
- text = text.replace(pattern, (match: string, boundary: string) => {
252
- const arrowPart = match.slice(boundary.length)
253
- const seps = arrowPart.match(sepPattern) ?? []
254
- return `${boundary}${replacement}${seps.join("")}`
255
- })
648
+ export function arrows(input: string): string
649
+ export function arrows(input: ProseView): void
650
+ export function arrows(input: string | ProseView): string | void {
651
+ return overInput(input, arrowsOverView)
652
+ }
653
+
654
+ function arrowsOverView(view: ProseView): void {
655
+ // Each arrow shape is matched by a left-to-right scan so a boundary that
656
+ // splits a dash run still lets the valid suffix match. The whole shape span is
657
+ // replaced with the arrow; consumed boundaries collapse to just after it.
658
+ for (const [matcher, replacement] of ARROW_MATCHERS) {
659
+ const text = view.text
660
+ let i = 0
661
+ while (i < text.length) {
662
+ if (!arrowLeftContextOk(view, text, i)) { i++; continue }
663
+ const end = matcher(view, text, i)
664
+ if (end < 0 || !arrowRightContextOk(view, text, end)) { i++; continue }
665
+ view.replace(i, end, replacement)
666
+ i = end
667
+ }
668
+ view.commit()
256
669
  }
670
+ }
671
+
672
+ export function degrees(input: string): string
673
+ export function degrees(input: ProseView): void
674
+ export function degrees(input: string | ProseView): string | void {
675
+ return overInput(input, degreesOverView)
676
+ }
257
677
 
258
- return text
678
+ function degreesOverView(view: ProseView): void {
679
+ // Temperature with optional space before C or F (uppercase only). One boundary
680
+ // is tolerated between the digit and the unit, and the unit is followed by
681
+ // `(?!-[A-Za-z…]|[+#])` (reject "C-compiler", "C++", "F#") plus a word
682
+ // boundary. Those trailing assertions are checked manually so a node boundary
683
+ // right after the unit shadows the compound character, and the word-boundary
684
+ // arm sees through up to three boundaries.
685
+ const pattern = cachedRegExp(`\\d ?(?<unit>[CF])`, "g")
686
+ const text = view.text
687
+ replaceAllInView(view, pattern, (match, v) => {
688
+ const unitEnd = match.index + match[0].length
689
+ if (!degreeUnitFollowOk(text, v, unitEnd)) return null
690
+ const { unit } = match.groups!
691
+ // Replace everything after the leading digit (the optional space and the
692
+ // unit) with ` °C`/` °F`, leaving the digit and any boundary right after it
693
+ // in place so the boundary keeps its position.
694
+ v.replace(match.index + 1, unitEnd, ` ${DEGREE}${unit}`)
695
+ return null
696
+ }, {
697
+ allowBoundaries: (match, v) => digitSuffixBoundaryOk(match, v),
698
+ })
699
+ view.commit()
259
700
  }
260
701
 
261
- export function degrees(text: string, options: SymbolOptions = {}): string {
262
- const chr = getEscapedSeparator(options)
702
+ /**
703
+ * The `(?!-[A-Za-z…]|[+#])` compound guard plus `\b(?!${chr}{0,3}\w)` word
704
+ * boundary that follow the unit. A node boundary directly after the unit
705
+ * shadows a `+`/`#`/`-`, satisfying the compound guard; the word boundary
706
+ * blocks only when a word character follows the unit through at most three
707
+ * boundaries.
708
+ */
709
+ function degreeUnitFollowOk(text: string, view: ProseView, unitEnd: number): boolean {
710
+ const boundaryAfterUnit = view.hasBoundary(unitEnd)
711
+ // Compound guard: `+`/`#` directly after the unit (no boundary shadowing it),
712
+ // or `-` directly followed by a Latin letter (boundary-aware).
713
+ if (!boundaryAfterUnit) {
714
+ const next = text[unitEnd]
715
+ if (next === "+" || next === "#") return false
716
+ if (next === "-" && !view.hasBoundary(unitEnd + 1) && LATIN_LETTER_RE.test(text[unitEnd + 1] ?? "")) return false
717
+ }
718
+ // Word boundary: reject when a word character follows the unit across up to
719
+ // three boundaries; with no boundary a word character directly after the unit
720
+ // also removes the `\b`. Consecutive boundaries pile at the same clean offset,
721
+ // so count them there.
722
+ const followChar = text[unitEnd]
723
+ if (boundaryCountAt(view, unitEnd) <= 3 && followChar !== undefined && WORD_RE.test(followChar)) return false
724
+ return true
725
+ }
263
726
 
264
- // Temperature with optional space before C or F (uppercase only)
265
- // Handles separator between digit and unit
266
- // Uses marker-aware boundary to avoid false matches like "20C\uE000elsius"
267
- const wbe = wordBoundaryEnd(chr)
268
- // Reject C/F followed by hyphen+letter ("C-compiler"), +/# ("C++", "F#")
269
- const notCompound = `(?!-[${LATIN_LETTERS}]|[+#])`
270
- return text.replace(
271
- cachedRegExp(`(?<num>\\d${chr}?) ?(?<unit>[CF])${notCompound}${wbe}`, "g"),
272
- (_, num, unit) => `${num} ${DEGREE}${unit}`
273
- )
727
+ /**
728
+ * Tolerate at most one boundary directly after the leading digit (the single
729
+ * slot between `\d` and the rest); a second adjacent boundary breaks the slot.
730
+ * The digit is the first match character, so the only legal interior-boundary
731
+ * offset is match.index + 1.
732
+ */
733
+ function digitSuffixBoundaryOk(match: RegExpExecArray, view: ProseView): boolean {
734
+ const digitEnd = match.index + 1
735
+ const matchEnd = match.index + match[0].length
736
+ for (const boundary of view.boundaries) {
737
+ // Reject any interior boundary that is not the single tolerated slot after
738
+ // the leading digit.
739
+ if (boundary > match.index && boundary < matchEnd && boundary !== digitEnd) return false
740
+ }
741
+ return boundaryCountAt(view, digitEnd) <= 1
274
742
  }
275
743
 
276
744
  /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
277
- export function primeMarks(text: string, options: SymbolOptions = {}): string {
278
- return convertPrimeMarks(text, options.separator ?? DEFAULT_SEPARATOR)
745
+ export function primeMarks(input: string): string
746
+ export function primeMarks(input: ProseView): void
747
+ export function primeMarks(input: string | ProseView): string | void {
748
+ return overInput(input, convertPrimeMarks)
279
749
  }
280
750
 
281
751
  type FractionRule = [string, string, string]
@@ -301,33 +771,77 @@ const FRACTION_TUPLES: FractionRule[] = [
301
771
  const FRACTION_MAP = Object.fromEntries(FRACTION_TUPLES.map(([n, d, u]) => [`${n}/${d}`, u]))
302
772
 
303
773
  /** Convert 1/2, 1/4, etc. to ½, ¼, etc. Single-pass using alternation. */
304
- export function fractions(text: string, options: SymbolOptions = {}): string {
305
- const chr = getEscapedSeparator(options)
306
-
307
- // Build alternation of exact valid pairs: `1${sep}?/${sep}?4|1${sep}?/${sep}?2|...`
308
- // Only exact pairs from FRACTION_TUPLES match — no cross-product.
309
- const pairAlternation = FRACTION_TUPLES.map(([n, d]) =>
310
- `${n}${chr}?/${chr}?${d}`
311
- ).join("|")
312
-
313
- // - (?<![/.\d]) prevents matching after slashes, dots, or digits
314
- // - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
315
- const pattern = cachedRegExp(
316
- `(?<![/\\.\\d])(?:${pairAlternation})(?![/\\d]|\\.\\d)`,
317
- "g"
318
- )
319
-
320
- const sepPattern = cachedRegExp(chr, "g")
774
+ export function fractions(input: string): string
775
+ export function fractions(input: ProseView): void
776
+ export function fractions(input: string | ProseView): string | void {
777
+ return overInput(input, fractionsOverView)
778
+ }
321
779
 
322
- return text.replace(pattern, (match) => {
323
- // Split on separator to isolate the raw fraction and preserve markers.
324
- // e.g. "1\uE000/\uE0002" → parts ["1", "/", "2"], separators ["\uE000", "\uE000"]
325
- const parts = match.split(sepPattern)
326
- const raw = parts.join("")
327
- const separators = match.match(sepPattern) ?? []
328
- // Distribute separators: first goes before the unicode char, second after
329
- return `${separators[0] ?? ""}${FRACTION_MAP[raw]}${separators[1] ?? ""}`
780
+ function fractionsOverView(view: ProseView): void {
781
+ // Build alternation of exact valid pairs: `1/4|1/2|...`. Only exact pairs
782
+ // from FRACTION_TUPLES match — no cross-product. One boundary is tolerated on
783
+ // each side of the slash, and the pair is surrounded by the lookarounds
784
+ // `(?<![/.\d])` and `(?![/\d]|\.\d)`. The lookarounds are checked manually so
785
+ // a node boundary at the numerator's left edge or the denominator's right edge
786
+ // satisfies them (a boundary is none of those characters).
787
+ const pairAlternation = FRACTION_TUPLES.map(([n, d]) => `${n}/${d}`).join("|")
788
+ const pattern = cachedRegExp(`(?:${pairAlternation})`, "g")
789
+ const text = view.text
790
+ replaceAllInView(view, pattern, (match, v) => {
791
+ const start = match.index
792
+ const end = start + match[0].length
793
+ if (!fractionLookbehindOk(text, v, start)) return null
794
+ if (!fractionLookaheadOk(text, v, end)) return null
795
+ // Distribute interior boundaries: the first lands before the unicode char,
796
+ // any remaining after it.
797
+ let firstBoundary = end
798
+ for (const boundary of v.boundaries) {
799
+ if (boundary > start && boundary < end) { firstBoundary = boundary; break }
800
+ }
801
+ if (firstBoundary > start) v.replace(start, firstBoundary, "")
802
+ v.replace(firstBoundary, end, FRACTION_MAP[match[0]])
803
+ return null
804
+ }, {
805
+ allowBoundaries: (match, v) => fractionSlotBoundaryOk(match, v),
330
806
  })
807
+ view.commit()
808
+ }
809
+
810
+ /**
811
+ * `(?<![/.\d])` at the numerator's left edge. A node boundary immediately
812
+ * before the numerator shadows the clean character (a boundary is not `/`, `.`,
813
+ * or a digit), so the lookbehind passes.
814
+ */
815
+ function fractionLookbehindOk(text: string, view: ProseView, start: number): boolean {
816
+ if (start === 0 || view.hasBoundary(start)) return true
817
+ return !/[/.\d]/.test(text[start - 1])
818
+ }
819
+
820
+ /**
821
+ * `(?![/\d]|\.\d)` at the denominator's right edge. A node boundary immediately
822
+ * after the denominator shadows the clean characters, satisfying the lookahead.
823
+ */
824
+ function fractionLookaheadOk(text: string, view: ProseView, end: number): boolean {
825
+ if (view.hasBoundary(end)) return true
826
+ const next = text[end]
827
+ if (next === undefined) return true
828
+ if (/[/\d]/.test(next)) return false
829
+ // `\.\d`: a dot directly followed by a digit (a node boundary between them
830
+ // breaks the pair, so re-test for the boundary).
831
+ if (next === "." && !view.hasBoundary(end + 1) && /\d/.test(text[end + 1] ?? "")) return false
832
+ return true
833
+ }
834
+
835
+ /**
836
+ * A fraction tolerates one boundary on each side of the slash (two slots). The
837
+ * numerator and denominator are single digits, so the match spans exactly
838
+ * `digit/digit` and the only interior-boundary offsets are right before the
839
+ * slash (match.index + 1) and right after it (match.index + 2); each accepts at
840
+ * most one boundary.
841
+ */
842
+ function fractionSlotBoundaryOk(match: RegExpExecArray, view: ProseView): boolean {
843
+ const slashOffset = match.index + 1
844
+ return boundaryCountAt(view, slashOffset) <= 1 && boundaryCountAt(view, slashOffset + 1) <= 1
331
845
  }
332
846
 
333
847
  const ORDINAL_MAP: Record<string, string> = {
@@ -338,70 +852,119 @@ const ORDINAL_MAP: Record<string, string> = {
338
852
  }
339
853
 
340
854
  /** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
341
- export function superscriptOrdinal(text: string, options: SymbolOptions = {}): string {
342
- const chr = getEscapedSeparator(options)
343
-
344
- // Match number + optional separator + ordinal suffix at word boundary
345
- // Use case-insensitive matching for the suffix
346
- // Uses marker-aware boundary to avoid false matches like "1st\uE000ly"
347
- const wbe = wordBoundaryEnd(chr)
348
- const pattern = cachedRegExp(
349
- `(?<num>\\d${chr}?)(?<suffix>st|nd|rd|th)${wbe}`,
350
- "gi"
351
- )
855
+ export function superscriptOrdinal(input: string): string
856
+ export function superscriptOrdinal(input: ProseView): void
857
+ export function superscriptOrdinal(input: string | ProseView): string | void {
858
+ return overInput(input, superscriptOrdinalOverView)
859
+ }
352
860
 
353
- return text.replace(pattern, (_match, num, suffix) => {
354
- const superscriptSuffix = ORDINAL_MAP[suffix.toLowerCase()]
355
- return num + superscriptSuffix
861
+ function superscriptOrdinalOverView(view: ProseView): void {
862
+ // Match number + ordinal suffix at word boundary, case-insensitively. One
863
+ // boundary is tolerated between the digit and the suffix, with a word boundary
864
+ // after the suffix. Only the suffix is replaced, leaving the digit and any
865
+ // boundary between them untouched so the boundary keeps its position.
866
+ const pattern = cachedRegExp(`(?<num>\\d)(?<suffix>st|nd|rd|th)\\b(?![${LATIN_LETTERS}\\d])`, "gi")
867
+ replaceAllInView(view, pattern, (match, v) => {
868
+ const { num, suffix } = match.groups!
869
+ const suffixStart = match.index + num.length
870
+ v.replace(suffixStart, match.index + match[0].length, ORDINAL_MAP[suffix.toLowerCase()])
871
+ return null
872
+ }, {
873
+ allowBoundaries: (match, v) => digitSuffixBoundaryOk(match, v),
356
874
  })
875
+ view.commit()
357
876
  }
358
877
 
359
878
  // Preserves highest-priority space type (NBSP > NNBSP > regular) and leading indentation.
360
- export function collapseSpaces(text: string): string {
361
- return text.replace(cachedRegExp(`(?<=[^\\n${SPACE_CHARS}])[${SPACE_CHARS}]{2,}`, "g"), (match) => {
362
- if (match.includes(NBSP)) return NBSP
363
- if (match.includes(UNICODE_SYMBOLS.NNBSP)) return UNICODE_SYMBOLS.NNBSP
364
- return " "
365
- })
879
+ export function collapseSpaces(input: string): string
880
+ export function collapseSpaces(input: ProseView): void
881
+ export function collapseSpaces(input: string | ProseView): string | void {
882
+ return overInput(input, collapseSpacesOverView)
883
+ }
884
+
885
+ /**
886
+ * Collapses each run of two or more space characters to its highest-priority
887
+ * space. A run never crosses a node boundary, and the anchor requiring a
888
+ * non-newline, non-space character before the run is satisfied by a boundary
889
+ * (so a run opening a node still collapses); runs at line or text start are
890
+ * preserved so indented blocks survive.
891
+ */
892
+ function collapseSpacesOverView(view: ProseView): void {
893
+ const text = view.text
894
+ let i = 0
895
+ while (i < text.length) {
896
+ if (!SPACE_CHAR_RE.test(text[i])) {
897
+ i++
898
+ continue
899
+ }
900
+ let end = i + 1
901
+ while (end < text.length && SPACE_CHAR_RE.test(text[end]) && !view.hasBoundary(end)) end++
902
+ const anchored = view.hasBoundary(i) || (i > 0 && text[i - 1] !== "\n" && !SPACE_CHAR_RE.test(text[i - 1]))
903
+ if (anchored && end - i >= 2) {
904
+ const run = text.slice(i, end)
905
+ const kept = run.includes(NBSP) ? NBSP : run.includes(UNICODE_SYMBOLS.NNBSP) ? UNICODE_SYMBOLS.NNBSP : " "
906
+ view.replace(i, end, kept)
907
+ }
908
+ i = end
909
+ }
910
+ view.commit()
366
911
  }
367
912
 
913
+ /** `[first, repeated, replacement]` with literal punctuation characters. */
368
914
  type LigatureRule = [string, string, string]
369
915
 
370
916
  // Order matters: mixed punctuation first, then repeated.
371
917
  const PUNCTUATION_LIGATURE_MAP: LigatureRule[] = [
372
- ["\\?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
373
- ["!", "\\?", EXCLAMATION_QUESTION], // !?+ → ⁉
374
- ["\\?", "\\?", DOUBLE_QUESTION], // ??+ → ⁇
375
- ["!", "!", "!"], // !!+ → ! (normalize)
918
+ ["?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
919
+ ["!", "?", EXCLAMATION_QUESTION], // !?+ → ⁉
920
+ ["?", "?", DOUBLE_QUESTION], // ??+ → ⁇
921
+ ["!", "!", "!"], // !!+ → ! (normalize)
376
922
  ]
377
923
 
378
924
  /** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Disabled by default (poor font support). */
379
- export function punctuationLigatures(text: string, options: SymbolOptions = {}): string {
380
- const chr = getEscapedSeparator(options)
381
- const sepPattern = cachedRegExp(chr, "g")
925
+ export function punctuationLigatures(input: string): string
926
+ export function punctuationLigatures(input: ProseView): void
927
+ export function punctuationLigatures(input: string | ProseView): string | void {
928
+ return overInput(input, punctuationLigaturesOverView)
929
+ }
382
930
 
931
+ function punctuationLigaturesOverView(view: ProseView): void {
383
932
  for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
384
- const pattern = cachedRegExp(`${first}(?:${chr}?${repeated})+`, "g")
385
- // Re-emit every separator that fell inside the match. The repeat group
386
- // can swallow more than one (e.g. across 3+ split text nodes), and
387
- // dropping any of them breaks transformTextNodes's text-node-count
388
- // invariant.
389
- text = text.replace(pattern, (match) => {
390
- const seps = match.match(sepPattern) ?? []
391
- return replacement + seps.join("")
392
- })
933
+ // The rule is `first (repeated)+`: the leading char, then one or more
934
+ // repeated chars each preceded by at most one boundary (a second adjacent
935
+ // boundary breaks the run). A left-to-right scan replaces the whole run with
936
+ // the ligature; consumed boundaries collapse to just after it, and a broken
937
+ // run re-anchors at the next leading char.
938
+ const text = view.text
939
+ let i = 0
940
+ while (i < text.length) {
941
+ if (text[i] !== first) { i++; continue }
942
+ let end = i + 1
943
+ let consumed = 0
944
+ for (;;) {
945
+ const afterSep = arrowSepSlot(view, end)
946
+ if (afterSep < 0 || text[afterSep] !== repeated) break
947
+ end = afterSep + 1
948
+ consumed++
949
+ }
950
+ if (consumed === 0) { i++; continue }
951
+ view.replace(i, end, replacement)
952
+ i = end
953
+ }
954
+ view.commit()
393
955
  }
394
-
395
- return text
396
956
  }
397
957
 
398
- export function symbolTransform(text: string, options: SymbolOptions = {}): string {
399
- text = ellipsis(text, options)
400
- text = multiplication(text, options)
401
- text = mathSymbols(text, options)
402
- text = legalSymbols(text, options)
403
- if (options.includeArrows !== false) {
404
- text = arrows(text, options)
405
- }
406
- return text
958
+ export function symbolTransform(input: string, options?: SymbolOptions): string
959
+ export function symbolTransform(input: ProseView, options?: SymbolOptions): void
960
+ export function symbolTransform(input: string | ProseView, options: SymbolOptions = {}): string | void {
961
+ return overInput(input, (view) => {
962
+ ellipsisOverView(view)
963
+ multiplicationOverView(view)
964
+ mathSymbolsOverView(view)
965
+ legalSymbolsOverView(view)
966
+ if (options.includeArrows !== false) {
967
+ arrowsOverView(view)
968
+ }
969
+ })
407
970
  }