punctilio 5.0.1 → 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.
@@ -1,4 +1,4 @@
1
- import { LATIN_LETTERS, SPACE_CHARS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
1
+ import { FOLDED_WORD_CHARS, LATIN_LETTER_RE, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
2
2
  import type { ProseView } from "./prose-view.js"
3
3
 
4
4
  const {
@@ -24,16 +24,11 @@ const {
24
24
  EXCLAMATION_QUESTION,
25
25
  DOUBLE_EXCLAMATION,
26
26
  INTERROBANG,
27
- MULTIPLICATION,
28
27
  APPROXIMATE,
29
28
  GREATER_EQUAL,
30
29
  LESS_EQUAL,
31
30
  NOT_EQUAL,
32
31
  PLUS_MINUS,
33
- SUPERSCRIPT_ST,
34
- SUPERSCRIPT_ND,
35
- SUPERSCRIPT_RD,
36
- SUPERSCRIPT_TH,
37
32
  } = UNICODE_SYMBOLS
38
33
 
39
34
  /**
@@ -84,7 +79,7 @@ const ROLE_BY_CHAR = new Map<string, Exclude<QuoteRole, "LITERAL">>([
84
79
  *
85
80
  * The boundary-tolerance behavior of the boundary items reproduces the
86
81
  * element-boundary semantics of the pre-v5 sentinel-marked pipeline (pinned by
87
- * the golden corpus and the migration's differential fuzz): boundary-transparent
82
+ * the HTML regression corpus and the migration's differential fuzz): boundary-transparent
88
83
  * rules skip them, and rules that treat a boundary as an ordinary non-space
89
84
  * character do so.
90
85
  */
@@ -102,8 +97,6 @@ interface Item {
102
97
  anchorBind: "left" | "right"
103
98
  }
104
99
 
105
- const LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
106
- const WORD_RE = /\w/
107
100
  const SPACE_RE = /\s/
108
101
  const DIGIT_RE = /\d/
109
102
 
@@ -115,9 +108,8 @@ const QUOTE_CANDIDATE_RE = new RegExp(
115
108
 
116
109
  const TERMINAL_SET = new Set<string>(TERMINAL_PUNCTUATION)
117
110
 
118
- /** The collapse pass's space alphabet (plain space, tab, NBSP, NNBSP). */
119
- const COLLAPSIBLE_SPACE_RE = new RegExp(`[${SPACE_CHARS}]`)
120
- const CLOSING_SET = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE])
111
+ const CLOSING_CHARS = [RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE] as const
112
+ const CLOSING_SET = new Set<string>(CLOSING_CHARS)
121
113
 
122
114
  /**
123
115
  * Placement alphabet for renders that collapse apostrophes into U+2019: a
@@ -126,7 +118,7 @@ const CLOSING_SET = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE])
126
118
  * APOSTROPHE item as part of a closing run for the transform to be a fixed
127
119
  * point.
128
120
  */
129
- const CLOSING_OR_APOSTROPHE_SET = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE, MODIFIER_LETTER_APOSTROPHE])
121
+ const CLOSING_OR_APOSTROPHE_SET = new Set<string>([...CLOSING_CHARS, MODIFIER_LETTER_APOSTROPHE])
130
122
 
131
123
  /** Chars from `'` ${LSQ} ${RSQ} ${MLA}; \w handled separately. */
132
124
  const SINGLE_QUOTE_FAMILY = new Set<string>(["'", LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE, MODIFIER_LETTER_APOSTROPHE])
@@ -162,12 +154,23 @@ const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",
162
154
  /** Ending-context chars after a closing double quote (plus any \s char). */
163
155
  const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS])
164
156
 
165
- function isLetterItem(items: Item[], index: number): boolean {
166
- if (index < 0 || index >= items.length) return false
167
- const item = items[index]
168
- return !item.boundary && LETTER_RE.test(item.ch)
157
+ /** Builds an `(items, index)` predicate: in range, non-boundary, and `test(ch)`. */
158
+ function makeItemTester(test: (ch: string) => boolean): (items: Item[], index: number) => boolean {
159
+ return (items, index) => {
160
+ if (index < 0 || index >= items.length) return false
161
+ const item = items[index]
162
+ return !item.boundary && test(item.ch)
163
+ }
169
164
  }
170
165
 
166
+ const isLetterItem = makeItemTester((ch) => LATIN_LETTER_RE.test(ch))
167
+ const isDigitItem = makeItemTester((ch) => DIGIT_RE.test(ch))
168
+ const isWordItem = makeItemTester((ch) => WORD_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
169
+ const isLetterOrDigitItem = makeItemTester((ch) => LATIN_LETTER_RE.test(ch) || DIGIT_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
170
+ /** Letter test that treats folded word glyphs as the letters they fold from
171
+ * (`X` → `×`, `st` → `ˢᵗ`); see {@link FOLDED_WORD_CHARS}. */
172
+ const isLetterOrFoldedItem = makeItemTester((ch) => LATIN_LETTER_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
173
+
171
174
  function isCharItem(items: Item[], index: number, ch: string): boolean {
172
175
  if (index < 0 || index >= items.length) return false
173
176
  const item = items[index]
@@ -339,16 +342,6 @@ function singleCloserBefore(items: Item[], index: number): boolean {
339
342
  return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'" && prev.ch !== '"'
340
343
  }
341
344
 
342
- /**
343
- * Letter test that treats folded word glyphs as the letters they fold from
344
- * (`X` → `×`, `st` → `ˢᵗ`); see {@link FOLDED_WORD_CHARS}.
345
- */
346
- function isLetterOrFoldedItem(items: Item[], index: number): boolean {
347
- if (index < 0 || index >= items.length) return false
348
- const item = items[index]
349
- return !item.boundary && (LETTER_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch))
350
- }
351
-
352
345
  /** Letter directly before (no boundary) and letter after (one boundary transparent). */
353
346
  function isContractionContext(items: Item[], index: number): boolean {
354
347
  return isLetterOrFoldedItem(items, index - 1) && isLetterOrFoldedItem(items, nextIndex(items, index, 1))
@@ -404,22 +397,6 @@ function classifyNAbbreviation(items: Item[]): void {
404
397
  }
405
398
  }
406
399
 
407
- /**
408
- * Glyphs the symbol passes fold word characters into (multiplication `x` →
409
- * `×`, ordinal suffixes → superscripts). Word-gated rules must accept them,
410
- * or a gate decided on `6x'` flips once a re-run sees `6×'`.
411
- */
412
- const FOLDED_WORD_CHARS = new Set<string>([
413
- MULTIPLICATION,
414
- ...SUPERSCRIPT_ST, ...SUPERSCRIPT_ND, ...SUPERSCRIPT_RD, ...SUPERSCRIPT_TH,
415
- ])
416
-
417
- function isWordItem(items: Item[], index: number): boolean {
418
- if (index < 0 || index >= items.length) return false
419
- const item = items[index]
420
- return !item.boundary && (WORD_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch))
421
- }
422
-
423
400
  /**
424
401
  * Possessive (`dog's`) → APOSTROPHE; then closing single (`'` in ending
425
402
  * context) → CLOSE_SINGLE. Returns true when any item changed.
@@ -471,18 +448,6 @@ function isDecadeElision(items: Item[], index: number): boolean {
471
448
  return !isLetterOrDigitItem(items, after)
472
449
  }
473
450
 
474
- function isDigitItem(items: Item[], index: number): boolean {
475
- if (index < 0 || index >= items.length) return false
476
- const item = items[index]
477
- return !item.boundary && DIGIT_RE.test(item.ch)
478
- }
479
-
480
- function isLetterOrDigitItem(items: Item[], index: number): boolean {
481
- if (index < 0 || index >= items.length) return false
482
- const item = items[index]
483
- return !item.boundary && (LETTER_RE.test(item.ch) || DIGIT_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch))
484
- }
485
-
486
451
  /** `nʼ ` directly after the quote — a quote leading into an already-classified 'n'. */
487
452
  function isNAbbreviationAhead(items: Item[], index: number): boolean {
488
453
  return isCharItem(items, index + 1, "n")
@@ -664,8 +629,6 @@ function doubleOpenerPrefixOk(items: Item[], index: number): boolean {
664
629
  return SPACE_RE.test(prev.ch) || DOUBLE_OPEN_BEFORE_SET.has(prev.ch)
665
630
  }
666
631
 
667
- const ELLIPSIS_GAP_SPACE_RE = new RegExp(`[${SPACE_CHARS}]`)
668
-
669
632
  /**
670
633
  * Index of the dot reachable across one inter-dot gap from the dot at
671
634
  * `dotIndex` (the ellipsis pass's gap rule: the gap is nothing, one space
@@ -680,7 +643,7 @@ function nextEllipsisDotItem(items: Item[], dotIndex: number): number {
680
643
  }
681
644
  if (j >= items.length) return -1
682
645
  if (items[j].ch === ".") return j
683
- if (ELLIPSIS_GAP_SPACE_RE.test(items[j].ch) && j + 1 < items.length && !items[j + 1].boundary && items[j + 1].ch === ".") {
646
+ if (SPACE_CHAR_RE.test(items[j].ch) && j + 1 < items.length && !items[j + 1].boundary && items[j + 1].ch === ".") {
684
647
  return j + 1
685
648
  }
686
649
  return -1
@@ -1027,7 +990,7 @@ function prevEllipsisDotItem(order: Item[], index: number): number {
1027
990
  }
1028
991
  if (j < 0) return -1
1029
992
  if (order[j].ch === ".") return j
1030
- if (ELLIPSIS_GAP_SPACE_RE.test(order[j].ch) && isCharItem(order, j - 1, ".")) return j - 1
993
+ if (SPACE_CHAR_RE.test(order[j].ch) && isCharItem(order, j - 1, ".")) return j - 1
1031
994
  return -1
1032
995
  }
1033
996
 
@@ -1208,7 +1171,7 @@ function movePunctuationOutside(order: Item[], punctChar: string, closingSet: Re
1208
1171
  // collapse pass merges tabs and NBSPs into the closer's NNBSP padding
1209
1172
  // the same way.
1210
1173
  if (spaceBeforePaddedCloser && k < order.length && !order[k].boundary
1211
- && order[k].ch.length === 1 && COLLAPSIBLE_SPACE_RE.test(order[k].ch)
1174
+ && order[k].ch.length === 1 && SPACE_CHAR_RE.test(order[k].ch)
1212
1175
  && isCharItem(order, k + 1, RIGHT_DOUBLE_QUOTE)) {
1213
1176
  k++
1214
1177
  }
package/src/symbols.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { cachedRegExp, LATIN_LETTERS, 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, MAX_BOUNDARY_SEPARATORS, SPACE_CHAR_RE, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
2
+ import { boundaryCountAt, exceedsSingleBoundary, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
3
3
  import { convertPrimeMarks } from "./quote-classifier.js"
4
4
 
5
5
  export interface SymbolOptions {
@@ -36,19 +36,10 @@ const {
36
36
 
37
37
  // The boundary-tolerance positions throughout this module reproduce the
38
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/
39
+ // pinned by the HTML regression corpus and the migration's differential fuzz.
43
40
 
44
41
  /** Convert "..." or ". . ." to "…". */
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}]`)
42
+ export const ellipsis = makeProsePass(ellipsisOverView)
52
43
 
53
44
  function ellipsisOverView(view: ProseView): void {
54
45
  ellipsisFoldDots(view)
@@ -109,11 +100,7 @@ function ellipsisFoldDots(view: ProseView): void {
109
100
 
110
101
 
111
102
  /** Convert "5x5" to "5×5". Skips hex (0x5F). */
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
- }
103
+ export const multiplication = makeProsePass(multiplicationOverView)
117
104
 
118
105
  // After a digit run, either a prime mark (10′) or a length/size unit may
119
106
  // attach before the multiplication operator. Allowing both lets dimensions
@@ -257,7 +244,7 @@ function leftSuffixUnsplit(view: ProseView, text: string, digitsEnd: number, slo
257
244
  // A prime or unit suffix begins after one optional space-or-boundary slot;
258
245
  // a second boundary in that slot (before the suffix) leaves `\d+suffix`
259
246
  // matching only the digits, detaching the operator.
260
- if (slotStart > digitsEnd && boundaryCountAt(view, digitsEnd) > 1) return false
247
+ if (slotStart > digitsEnd && exceedsSingleBoundary(view, digitsEnd)) return false
261
248
  for (let i = digitsEnd + 1; i < slotStart; i++) {
262
249
  if (LATIN_LETTER_RE.test(text[i - 1]) && LATIN_LETTER_RE.test(text[i]) && view.hasBoundary(i)) return false
263
250
  }
@@ -371,7 +358,7 @@ function multiplicationOverView(view: ProseView): void {
371
358
  const operatorOffset = match.index + num.length
372
359
  trailingScan = match.index + match[0].length
373
360
  // One optional boundary may sit between the digits and the operator.
374
- if (boundaryCountAt(view, operatorOffset) > 1) continue
361
+ if (exceedsSingleBoundary(view, operatorOffset)) continue
375
362
  // Leading guard and hex skip, on the boundary-free digit run ending at the
376
363
  // operator (a boundary inside the digits truncates the operand).
377
364
  const runStart = leftDigitRunStart(view, trailingText, operatorOffset, 0)
@@ -383,7 +370,7 @@ function multiplicationOverView(view: ProseView): void {
383
370
  if (op === "*") continue
384
371
  const afterOp = operatorOffset + 1
385
372
  const followChar = trailingText[afterOp]
386
- if (boundaryCountAt(view, afterOp) <= 3 && followChar !== undefined && WORD_RE.test(followChar)) continue
373
+ if (boundaryCountAt(view, afterOp) <= MAX_BOUNDARY_SEPARATORS && followChar !== undefined && WORD_RE.test(followChar)) continue
387
374
  view.replace(operatorOffset, afterOp, MULTIPLICATION)
388
375
  }
389
376
  view.commit()
@@ -403,11 +390,7 @@ const MATH_SYMBOL_MAP: MathSymbolRule[] = [
403
390
  ]
404
391
 
405
392
  /** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
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
- }
393
+ export const mathSymbols = makeProsePass(mathSymbolsOverView)
411
394
 
412
395
  function mathSymbolsOverView(view: ProseView): void {
413
396
  for (const [left, right, forbiddenFollow, replacement] of MATH_SYMBOL_MAP) {
@@ -539,11 +522,7 @@ const LEGAL_REGISTERED_RE = "\\(r\\)"
539
522
  const LEGAL_TRADEMARK_RE = "\\(tm\\)"
540
523
 
541
524
  /** Convert (c), (r), (tm) to ©, ®, ™. */
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
- }
525
+ export const legalSymbols = makeProsePass(legalSymbolsOverView)
547
526
 
548
527
  function legalSymbolsOverView(view: ProseView): void {
549
528
  // (c) → © only with positive copyright evidence (year or "copyright"
@@ -645,11 +624,7 @@ function arrowRightContextOk(view: ProseView, text: string, end: number): boolea
645
624
  }
646
625
 
647
626
  /** Convert -> and <-> to arrows. */
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
- }
627
+ export const arrows = makeProsePass(arrowsOverView)
653
628
 
654
629
  function arrowsOverView(view: ProseView): void {
655
630
  // Each arrow shape is matched by a left-to-right scan so a boundary that
@@ -669,11 +644,7 @@ function arrowsOverView(view: ProseView): void {
669
644
  }
670
645
  }
671
646
 
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
- }
647
+ export const degrees = makeProsePass(degreesOverView)
677
648
 
678
649
  function degreesOverView(view: ProseView): void {
679
650
  // Temperature with optional space before C or F (uppercase only). One boundary
@@ -720,7 +691,7 @@ function degreeUnitFollowOk(text: string, view: ProseView, unitEnd: number): boo
720
691
  // also removes the `\b`. Consecutive boundaries pile at the same clean offset,
721
692
  // so count them there.
722
693
  const followChar = text[unitEnd]
723
- if (boundaryCountAt(view, unitEnd) <= 3 && followChar !== undefined && WORD_RE.test(followChar)) return false
694
+ if (boundaryCountAt(view, unitEnd) <= MAX_BOUNDARY_SEPARATORS && followChar !== undefined && WORD_RE.test(followChar)) return false
724
695
  return true
725
696
  }
726
697
 
@@ -742,11 +713,7 @@ function digitSuffixBoundaryOk(match: RegExpExecArray, view: ProseView): boolean
742
713
  }
743
714
 
744
715
  /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
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)
749
- }
716
+ export const primeMarks = makeProsePass(convertPrimeMarks)
750
717
 
751
718
  type FractionRule = [string, string, string]
752
719
 
@@ -771,11 +738,7 @@ const FRACTION_TUPLES: FractionRule[] = [
771
738
  const FRACTION_MAP = Object.fromEntries(FRACTION_TUPLES.map(([n, d, u]) => [`${n}/${d}`, u]))
772
739
 
773
740
  /** Convert 1/2, 1/4, etc. to ½, ¼, etc. Single-pass using alternation. */
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
- }
741
+ export const fractions = makeProsePass(fractionsOverView)
779
742
 
780
743
  function fractionsOverView(view: ProseView): void {
781
744
  // Build alternation of exact valid pairs: `1/4|1/2|...`. Only exact pairs
@@ -852,11 +815,7 @@ const ORDINAL_MAP: Record<string, string> = {
852
815
  }
853
816
 
854
817
  /** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
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
- }
818
+ export const superscriptOrdinal = makeProsePass(superscriptOrdinalOverView)
860
819
 
861
820
  function superscriptOrdinalOverView(view: ProseView): void {
862
821
  // Match number + ordinal suffix at word boundary, case-insensitively. One
@@ -876,11 +835,7 @@ function superscriptOrdinalOverView(view: ProseView): void {
876
835
  }
877
836
 
878
837
  // Preserves highest-priority space type (NBSP > NNBSP > regular) and leading indentation.
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
- }
838
+ export const collapseSpaces = makeProsePass(collapseSpacesOverView)
884
839
 
885
840
  /**
886
841
  * Collapses each run of two or more space characters to its highest-priority
@@ -922,11 +877,7 @@ const PUNCTUATION_LIGATURE_MAP: LigatureRule[] = [
922
877
  ]
923
878
 
924
879
  /** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Disabled by default (poor font support). */
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
- }
880
+ export const punctuationLigatures = makeProsePass(punctuationLigaturesOverView)
930
881
 
931
882
  function punctuationLigaturesOverView(view: ProseView): void {
932
883
  for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
package/src/utils.ts CHANGED
@@ -72,22 +72,3 @@ export function stableStringify(obj: object): string {
72
72
  export function namedGroups<G>(args: unknown[]): G {
73
73
  return args[args.length - 1] as G
74
74
  }
75
-
76
- /**
77
- * Extracts the match offset and input string from a `.replace()` callback's
78
- * arguments, independent of capture-group count. The trailing arguments are
79
- * `..., offset, input[, namedGroups]`: the input is the last string (the
80
- * named-groups object, when present, follows it) and the offset is the
81
- * number immediately before the input. @internal
82
- */
83
- export function replaceCallbackContext(args: unknown[]): { offset: number; input: string } {
84
- let inputIndex = args.length - 1
85
- while (inputIndex >= 0 && typeof args[inputIndex] !== "string") {
86
- inputIndex--
87
- }
88
- const offset = args[inputIndex - 1]
89
- if (inputIndex < 1 || typeof offset !== "number") {
90
- throw new Error("Could not locate offset and input in replace-callback arguments.")
91
- }
92
- return { offset, input: args[inputIndex] as string }
93
- }