punctilio 4.3.1 → 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 -27
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -40
  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 +423 -284
  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 +48 -7
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +163 -27
  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 -88
  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 -46
  54. package/src/dashes.ts +450 -269
  55. package/src/index.ts +70 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +234 -39
  59. package/src/quote-classifier.ts +618 -88
  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/nbsp.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { cachedRegExp, getEscapedSeparator, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS, wordBoundaryEnd } from "./constants.js"
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"
2
3
 
3
4
  const {
4
5
  NBSP,
@@ -11,11 +12,6 @@ const {
11
12
  TRADEMARK,
12
13
  } = UNICODE_SYMBOLS
13
14
 
14
- export interface NbspOptions {
15
- /** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
16
- separator?: string
17
- }
18
-
19
15
  const SPACE = `[${SPACE_CHARS}]`
20
16
 
21
17
  const UNICODE_UPPERCASE = "\\p{Lu}"
@@ -97,31 +93,104 @@ const ABBREVIATION_PATTERN = REFERENCE_ABBREVIATIONS.map((a) => `${a}\\.`).join(
97
93
  const PUNCTUATION_OR_QUOTE = `[.,!?:;)(${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}\u00AB\u00BB${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}"]`
98
94
  const COPYRIGHT_SYMBOLS = `[${COPYRIGHT}${REGISTERED}${TRADEMARK}]`
99
95
 
96
+
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.
100
+
101
+ /**
102
+ * A tolerated slot admits exactly ONE node boundary at that position; two
103
+ * adjacent boundaries block the match. This enforces that: at most one boundary
104
+ * at `offset`, and every interior boundary in the span must sit at one of the
105
+ * allowed slot offsets.
106
+ */
107
+ function boundariesOnlyAtSlots(view: ProseView, start: number, end: number, slots: number[]): boolean {
108
+ for (const boundary of view.boundaries) {
109
+ if (boundary <= start || boundary >= end) continue
110
+ if (!slots.includes(boundary)) return false
111
+ }
112
+ for (const slot of slots) {
113
+ if (boundaryCountAt(view, slot) > 1) return false
114
+ }
115
+ return true
116
+ }
117
+
100
118
  // Skips when preceded by NBSP or back-to-back with the previous match,
101
119
  // preventing 3+ words from binding into a single line-break atom.
102
- export function nbspAfterShortWords(text: string, options: NbspOptions = {}): string {
103
- const sep = getEscapedSeparator(options)
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
+ }
125
+
126
+ function nbspAfterShortWordsOverView(view: ProseView): void {
104
127
  const pattern = cachedRegExp(
105
- `(?<=^|${SPACE}|${PUNCTUATION_OR_QUOTE}|>)(?<shortWord>[${LATIN_LETTERS}]{1,2})(?<marker>${sep}?)${SPACE}`,
128
+ `(?<=^|${SPACE}|${PUNCTUATION_OR_QUOTE}|>)(?<shortWord>[${LATIN_LETTERS}]{1,2})${SPACE}`,
106
129
  "gmu"
107
130
  )
108
- let previousMatchEnd = -1
109
- return text.replace(pattern, (match, shortWord: string, marker: string, offset: number) => {
110
- if (offset === previousMatchEnd) return match
111
- if (offset > 0 && NBSP_CHARS.includes(text[offset - 1])) return match
112
- previousMatchEnd = offset + match.length
113
- return `${shortWord}${marker}${NBSP}`
114
- })
131
+ {
132
+ const clean = view.text
133
+ let previousMatchEnd = -1
134
+ replaceAllInView(
135
+ view,
136
+ pattern,
137
+ (match, v) => {
138
+ const offset = match.index
139
+ if (offset === previousMatchEnd) return null
140
+ // A boundary directly before the short word fails the
141
+ // `(?<=^|space|punct|>)` lookbehind (a boundary is not one of those).
142
+ if (view.hasBoundary(offset)) return null
143
+ if (offset > 0 && NBSP_CHARS.includes(clean[offset - 1])) return null
144
+ previousMatchEnd = offset + match[0].length
145
+ // Edit only the space so any boundary keeps its side of the NBSP.
146
+ v.replace(offset + match[0].length - 1, offset + match[0].length, NBSP)
147
+ return null
148
+ },
149
+ {
150
+ allowBoundaries: (match, v) =>
151
+ boundariesOnlyAtSlots(v, match.index, match.index + match[0].length, [
152
+ match.index + match[0].length - 1,
153
+ ]),
154
+ }
155
+ )
156
+ }
157
+ view.commit()
115
158
  }
116
159
 
117
- export function nbspBetweenNumberAndUnit(text: string, options: NbspOptions = {}): string {
118
- const sep = getEscapedSeparator(options)
119
- const wbe = wordBoundaryEnd(sep)
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
+ }
165
+
166
+ function nbspBetweenNumberAndUnitOverView(view: ProseView): void {
120
167
  const pattern = cachedRegExp(
121
- `(?<digit>\\d)(?<marker1>${sep}?)${SPACE}(?<marker2>${sep}?)(?<unit>${UNIT_PATTERN})${wbe}`,
168
+ `(?<digit>\\d)${SPACE}(?<unit>${UNIT_PATTERN})\\b`,
122
169
  "gm"
123
170
  )
124
- return text.replace(pattern, `$<digit>$<marker1>${NBSP}$<marker2>$<unit>`)
171
+ {
172
+ replaceAllInView(
173
+ view,
174
+ pattern,
175
+ (match, v) => {
176
+ // Edit only the space (after the single digit) so any boundaries keep
177
+ // their sides.
178
+ v.replace(match.index + 1, match.index + 2, NBSP)
179
+ return null
180
+ },
181
+ {
182
+ // Tolerated slots: between digit and space (index+1) and between space
183
+ // and unit (index+2). The unit literal itself must be boundary-free,
184
+ // which the default interior-boundary skip already enforces.
185
+ allowBoundaries: (match, v) =>
186
+ boundariesOnlyAtSlots(v, match.index, match.index + match[0].length, [
187
+ match.index + 1,
188
+ match.index + 2,
189
+ ]),
190
+ }
191
+ )
192
+ }
193
+ view.commit()
125
194
  }
126
195
 
127
196
  const MAX_LAST_WORD_LENGTH = 10
@@ -129,84 +198,192 @@ const MAX_LAST_WORD_LENGTH = 10
129
198
  // Bounded so the lookbehind stays ReDoS-safe.
130
199
  const MAX_MIDDLE_WORD_LENGTH = 15
131
200
 
201
+ const LATIN_LETTER_RE = cachedRegExp(`[${LATIN_LETTERS}]`, "u")
202
+
203
+ /**
204
+ * The cascade lookbehind `(?<![NBSP][LATIN]{1,15})` over clean text: blocks
205
+ * when an NBSP/NNBSP is followed by 1..15 Latin letters ending at the space. A
206
+ * node boundary anywhere in that backward run breaks the run, re-enabling the
207
+ * match.
208
+ */
209
+ function cascadeBlocksLastWord(view: ProseView, spaceOffset: number): boolean {
210
+ const text = view.text
211
+ let j = spaceOffset - 1
212
+ let run = 0
213
+ while (j >= 0 && run < MAX_MIDDLE_WORD_LENGTH && !view.hasBoundary(j + 1) && LATIN_LETTER_RE.test(text[j])) {
214
+ j--
215
+ run++
216
+ }
217
+ if (run === 0 || view.hasBoundary(j + 1)) return false
218
+ return j >= 0 && NBSP_CHARS.includes(text[j])
219
+ }
220
+
132
221
  // Skips when the second-to-last word is already glued backwards via NBSP.
133
- export function nbspBeforeLastWord(text: string, options: NbspOptions = {}): string {
134
- const sep = getEscapedSeparator(options)
135
- const cascadeBlock = `(?<![${NBSP_CHARS}][${LATIN_LETTERS}]{1,${MAX_MIDDLE_WORD_LENGTH}})`
136
- // Use alternation instead of character classes for multi-char separator support
137
- const pattern = cachedRegExp(
138
- `${cascadeBlock}(?<=\\w|${sep})${SPACE}(?<lastWord>(?:(?!\\s)(?!${sep}).){1,${MAX_LAST_WORD_LENGTH}})(?<ending>${sep}?(?:\\n\\n|$))`,
139
- "g"
140
- )
141
- return text.replace(pattern, `${NBSP}$<lastWord>$<ending>`)
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)
142
226
  }
143
227
 
144
- export function nbspAfterReferenceAbbreviations(text: string, options: NbspOptions = {}): string {
145
- const sep = getEscapedSeparator(options)
228
+ function nbspBeforeLastWordOverView(view: ProseView): void {
229
+ // The `(?<=\w|boundary)` lookbehind and the `[NBSP][LATIN]{1,15}` cascade
230
+ // lookbehind are enforced in the replacer, where boundaries are visible.
146
231
  const pattern = cachedRegExp(
147
- `(?<abbrev>${ABBREVIATION_PATTERN})(?<marker>${sep}?)${SPACE}(?=${sep}?\\d)`,
232
+ `${SPACE}(?<lastWord>(?:(?!\\s).){1,${MAX_LAST_WORD_LENGTH}})(?<ending>\\n\\n|$)`,
148
233
  "g"
149
234
  )
150
- return text.replace(pattern, `$<abbrev>$<marker>${NBSP}`)
235
+ {
236
+ const clean = view.text
237
+ replaceAllInView(
238
+ view,
239
+ pattern,
240
+ (match, v) => {
241
+ const start = match.index
242
+ const end = start + match[0].length
243
+ // `(?<=\w|sep)`: a word char before the space, or a boundary there.
244
+ const precededByWord = start > 0 && /\w/.test(clean[start - 1])
245
+ if (!precededByWord && !view.hasBoundary(start)) return null
246
+ if (cascadeBlocksLastWord(v, start)) return null
247
+ // The `ending` group tolerates exactly one boundary at the slot before
248
+ // `\n\n`/end-of-text; two adjacent boundaries block the match. The
249
+ // end-of-text slot sits at the match end (never an interior boundary),
250
+ // so the cap is checked here rather than only in allowBoundaries.
251
+ if (boundaryCountAt(v, end - match.groups!.ending.length) > 1) return null
252
+ // Edit only the leading space so the lastWord/ending (and any boundary
253
+ // before \n\n) keep their positions.
254
+ v.replace(start, start + 1, NBSP)
255
+ return null
256
+ },
257
+ {
258
+ // The space-lookbehind boundary (== match.index) is not interior. The
259
+ // last word admits no interior boundary, so a boundary inside it blocks
260
+ // the match (the default skip). The only interior slot is the `ending`
261
+ // group's slot before a `\n\n`: one boundary tolerated.
262
+ allowBoundaries: (match, v) => {
263
+ const end = match.index + match[0].length
264
+ const endingSlot = end - match.groups!.ending.length
265
+ return boundariesOnlyAtSlots(v, match.index, end, [endingSlot])
266
+ },
267
+ }
268
+ )
269
+ }
270
+ view.commit()
151
271
  }
152
272
 
153
- export function nbspAfterSectionSymbols(text: string, options: NbspOptions = {}): string {
154
- const sep = getEscapedSeparator(options)
273
+ /**
274
+ * Shared driver for the abbreviation-family rules: `(thing)(SPACE)(?=context)`
275
+ * where the slot between thing and space and the lookahead slot (between space
276
+ * and context) each tolerate one boundary.
277
+ */
278
+ function nbspBeforeContext(
279
+ view: ProseView,
280
+ thingPattern: string,
281
+ contextPattern: string,
282
+ flags: string,
283
+ thingGroup: string,
284
+ ): void {
155
285
  const pattern = cachedRegExp(
156
- `(?<symbol>[§¶])(?<marker>${sep}?)${SPACE}(?=${sep}?\\d)`,
157
- "g"
286
+ `(?<${thingGroup}>${thingPattern})${SPACE}(?=${contextPattern})`,
287
+ flags
158
288
  )
159
- return text.replace(pattern, `$<symbol>$<marker>${NBSP}`)
289
+ {
290
+ const clean = view.text
291
+ pattern.lastIndex = 0
292
+ let match: RegExpExecArray | null
293
+ while ((match = pattern.exec(clean)) !== null) {
294
+ const start = match.index
295
+ const end = start + match[0].length
296
+ // Slot between the `thing` literal and the space (offset = end - 1);
297
+ // lookahead slot at the match end. Each tolerates one boundary; the
298
+ // literal itself must be boundary-free.
299
+ const allowed =
300
+ boundariesOnlyAtSlots(view, start, end, [end - 1]) && boundaryCountAt(view, end) <= 1
301
+ if (!allowed) {
302
+ // A boundary broke the (possibly longer) literal; let the scan re-anchor
303
+ // one position later so a shorter sub-abbreviation can still match.
304
+ pattern.lastIndex = start + 1
305
+ continue
306
+ }
307
+ // Edit only the trailing space so the slot/lookahead boundaries keep their
308
+ // sides.
309
+ view.replace(end - 1, end, NBSP)
310
+ }
311
+ }
312
+ view.commit()
160
313
  }
161
314
 
162
- export function nbspAfterHonorifics(text: string, options: NbspOptions = {}): string {
163
- const sep = getEscapedSeparator(options)
164
- const pattern = cachedRegExp(
165
- `(?<honorific>${HONORIFIC_PATTERN})(?<marker>${sep}?)${SPACE}(?=${sep}?${UNICODE_UPPERCASE})`,
166
- "gu"
167
- )
168
- return text.replace(pattern, `$<honorific>$<marker>${NBSP}`)
315
+ function nbspAfterReferenceAbbreviationsOverView(view: ProseView): void {
316
+ nbspBeforeContext(view, ABBREVIATION_PATTERN, "\\d", "g", "abbrev")
169
317
  }
170
318
 
171
- export function nbspAfterCopyrightSymbols(text: string, options: NbspOptions = {}): string {
172
- const sep = getEscapedSeparator(options)
173
- const pattern = cachedRegExp(
174
- `(?<symbol>${COPYRIGHT_SYMBOLS})(?<marker>${sep}?)${SPACE}(?=${sep}?[\\d${UNICODE_UPPERCASE}])`,
175
- "gu"
176
- )
177
- return text.replace(pattern, `$<symbol>$<marker>${NBSP}`)
319
+ function nbspAfterSectionSymbolsOverView(view: ProseView): void {
320
+ nbspBeforeContext(view, "[\u00A7\u00B6]", "\\d", "g", "symbol")
178
321
  }
179
322
 
180
- export function nbspBetweenInitials(text: string, options: NbspOptions = {}): string {
181
- const sep = getEscapedSeparator(options)
182
- const pattern = cachedRegExp(
183
- `(?<initial>${UNICODE_UPPERCASE}\\.)(?<marker>${sep}?)${SPACE}(?=${sep}?${UNICODE_UPPERCASE})`,
184
- "gu"
185
- )
186
- return text.replace(pattern, `$<initial>$<marker>${NBSP}`)
323
+ function nbspAfterHonorificsOverView(view: ProseView): void {
324
+ nbspBeforeContext(view, HONORIFIC_PATTERN, UNICODE_UPPERCASE, "gu", "honorific")
325
+ }
326
+
327
+ function nbspAfterCopyrightSymbolsOverView(view: ProseView): void {
328
+ nbspBeforeContext(view, COPYRIGHT_SYMBOLS, `[\\d${UNICODE_UPPERCASE}]`, "gu", "symbol")
187
329
  }
188
330
 
189
- type NbspFn = (text: string, options: NbspOptions) => string
331
+ function nbspBetweenInitialsOverView(view: ProseView): void {
332
+ nbspBeforeContext(view, `${UNICODE_UPPERCASE}\\.`, UNICODE_UPPERCASE, "gu", "initial")
333
+ }
334
+
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
+ }
340
+
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
+ }
346
+
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
+ }
352
+
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
+ }
358
+
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
+ }
190
364
 
191
365
  // Specific patterns first so they claim matches before generic nbspAfterShortWords.
192
- const NBSP_TRANSFORMS: NbspFn[] = [
193
- nbspAfterHonorifics,
194
- nbspAfterReferenceAbbreviations,
195
- nbspAfterSectionSymbols,
196
- nbspAfterCopyrightSymbols,
197
- nbspBetweenInitials,
198
- nbspBetweenNumberAndUnit,
199
- nbspAfterShortWords,
200
- nbspBeforeLastWord,
366
+ const NBSP_TRANSFORMS: ((view: ProseView) => void)[] = [
367
+ nbspAfterHonorificsOverView,
368
+ nbspAfterReferenceAbbreviationsOverView,
369
+ nbspAfterSectionSymbolsOverView,
370
+ nbspAfterCopyrightSymbolsOverView,
371
+ nbspBetweenInitialsOverView,
372
+ nbspBetweenNumberAndUnitOverView,
373
+ nbspAfterShortWordsOverView,
374
+ nbspBeforeLastWordOverView,
201
375
  ]
202
376
 
203
- export function nbspTransform(text: string, options: NbspOptions = {}): string {
204
- // All nbsp patterns require a space, tab, or existing nbsp to match.
205
- // Short-circuit if the text contains none of these.
206
- if (!cachedRegExp(`[${SPACE_CHARS}]`, "").test(text)) return text
377
+ export function nbspTransform(input: string): string
378
+ export function nbspTransform(input: ProseView): void
379
+ export function nbspTransform(input: string | ProseView): string | void {
380
+ return overInput(input, (view) => {
381
+ // All nbsp patterns require a space, tab, or existing nbsp to match.
382
+ // Short-circuit if the text contains none of these.
383
+ if (!cachedRegExp(`[${SPACE_CHARS}]`, "").test(view.text)) return
207
384
 
208
- for (const fn of NBSP_TRANSFORMS) {
209
- text = fn(text, options)
210
- }
211
- return text
385
+ for (const fn of NBSP_TRANSFORMS) {
386
+ fn(view)
387
+ }
388
+ })
212
389
  }