punctilio 5.0.10 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/remark.ts CHANGED
@@ -6,7 +6,7 @@ import { visitParents } from "unist-util-visit-parents"
6
6
  import { transformView } from "./index.js"
7
7
  import { TRANSFORM_OPTION_KEYS, type TransformOptions } from "./transform-options.js"
8
8
  import { MAX_RECURSION_DEPTH } from "./constants.js"
9
- import { buildProseView } from "./prose-view.js"
9
+ import { buildProseView, splitAtIndices } from "./prose-view.js"
10
10
  import { assertKnownOptionKeys } from "./utils.js"
11
11
 
12
12
  /**
@@ -20,30 +20,48 @@ const PHRASING_CONTAINERS = new Set(["paragraph", "heading", "tableCell"])
20
20
 
21
21
  const SKIP_TYPES = new Set(["inlineCode", "html"])
22
22
 
23
- function flattenTextNodes(
24
- node: PhrasingContent,
25
- depth: number = 0
26
- ): Text[] {
23
+ // Leaf phrasing nodes that render atomic content and carry no transformable
24
+ // text. Between two text nodes, one is a real visual separator, so the
25
+ // flattener records an opaque gap there (mirroring skipped `inlineCode`/`html`).
26
+ const OPAQUE_LEAF_TYPES = new Set([
27
+ "image", "imageReference", "break", "footnoteReference", "inlineMath",
28
+ ])
29
+
30
+ interface ProseCollector {
31
+ nodes: Text[]
32
+ opaqueBefore: Set<number>
33
+ // An opaque node was dropped since the last emitted text node; the next
34
+ // emitted node records an opaque gap before it.
35
+ pendingOpaque: boolean
36
+ }
37
+
38
+ function collectProse(node: PhrasingContent, depth: number, c: ProseCollector): void {
27
39
  /* istanbul ignore if -- defensive: prevents stack overflow from malicious nesting */
28
40
  if (depth > MAX_RECURSION_DEPTH) {
29
- return []
41
+ return
30
42
  }
31
43
 
32
- if (SKIP_TYPES.has(node.type)) {
33
- return []
44
+ if (SKIP_TYPES.has(node.type) || OPAQUE_LEAF_TYPES.has(node.type)) {
45
+ c.pendingOpaque = true
46
+ return
34
47
  }
35
48
 
36
49
  if (node.type === "text") {
37
- return [node]
50
+ if (c.pendingOpaque && c.nodes.length > 0) c.opaqueBefore.add(c.nodes.length)
51
+ c.pendingOpaque = false
52
+ c.nodes.push(node)
53
+ return
38
54
  }
39
55
 
40
56
  if ("children" in node) {
41
- return (node.children as PhrasingContent[]).flatMap((child) =>
42
- flattenTextNodes(child, depth + 1)
43
- )
57
+ for (const child of node.children as PhrasingContent[]) collectProse(child, depth + 1, c)
44
58
  }
59
+ }
45
60
 
46
- return []
61
+ function flattenProse(children: readonly PhrasingContent[]): { nodes: Text[]; opaqueBefore: Set<number> } {
62
+ const c: ProseCollector = { nodes: [], opaqueBefore: new Set(), pendingOpaque: false }
63
+ for (const child of children) collectProse(child, 0, c)
64
+ return { nodes: c.nodes, opaqueBefore: c.opaqueBefore }
47
65
  }
48
66
 
49
67
  export function remarkPunctilio(
@@ -75,15 +93,13 @@ export function remarkPunctilio(
75
93
  return
76
94
  }
77
95
 
78
- const textNodes = (node.children as PhrasingContent[]).flatMap((child) =>
79
- flattenTextNodes(child)
80
- )
96
+ const { nodes, opaqueBefore } = flattenProse(node.children as PhrasingContent[])
81
97
 
82
- if (textNodes.length === 0) {
83
- return
98
+ // Split at opaque gaps (inline code, images, breaks) so no pass rewrites
99
+ // text as adjacent across content that visually separates it.
100
+ for (const group of splitAtIndices(nodes, opaqueBefore)) {
101
+ transformView(buildProseView(group), pluginOptions)
84
102
  }
85
-
86
- transformView(buildProseView(textNodes), pluginOptions)
87
103
  })
88
104
  }
89
105
  }
package/src/symbols.ts CHANGED
@@ -1,5 +1,5 @@
1
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"
2
+ import { boundaryCountAt, exceedsSingleBoundary, firstInteriorBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
3
3
  import { convertPrimeMarks } from "./quote-classifier.js"
4
4
 
5
5
  export interface SymbolOptions {
@@ -102,6 +102,20 @@ function ellipsisFoldDots(view: ProseView): void {
102
102
  /** Convert "5x5" to "5×5". Skips hex (0x5F). */
103
103
  export const multiplication = makeProsePass(multiplicationOverView)
104
104
 
105
+ // A curly single-quote glyph directly after a trailing multiplier means the
106
+ // quotes pass (which runs earlier in the pipeline) already read the `x` as a
107
+ // letter — a word-final elision keyed on the letter. Were this pass to rewrite
108
+ // that `x` to `×`, a re-run would re-read the quote as a closing quote after a
109
+ // symbol and relocate a trailing comma/period, so the transform would never
110
+ // reach a fixed point. Leave such an `x` alone. A straight `'` is not blocked:
111
+ // it only reaches this pass on a standalone `multiplication()` call, where no
112
+ // later quote pass can flip its classification.
113
+ const CURLY_APOSTROPHE_FOLLOWERS = new Set<string>([
114
+ UNICODE_SYMBOLS.RIGHT_SINGLE_QUOTE,
115
+ UNICODE_SYMBOLS.LEFT_SINGLE_QUOTE,
116
+ UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE,
117
+ ])
118
+
105
119
  // After a digit run, either a prime mark (10′) or a length/size unit may
106
120
  // attach before the multiplication operator. Allowing both lets dimensions
107
121
  // like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching
@@ -173,9 +187,7 @@ function chainSegments(chainStart: number, firstNum: string, rest: string): Chai
173
187
  */
174
188
  function operatorSlotClean(view: ProseView, slotStart: number, slotEnd: number): boolean {
175
189
  // No boundary strictly inside the spacing-plus-operator slot.
176
- for (const boundary of view.boundaries) {
177
- if (boundary > slotStart && boundary < slotEnd) return false
178
- }
190
+ if (firstInteriorBoundary(view, slotStart, slotEnd) >= 0) return false
179
191
  // At most one boundary hugging each outer edge (duplicates at an offset come
180
192
  // from empty nodes and each counts as one boundary).
181
193
  return boundaryCountAt(view, slotStart) <= 1 && boundaryCountAt(view, slotEnd) <= 1
@@ -266,6 +278,24 @@ function runHasInteriorBoundary(view: ProseView, operandStart: number, operandEn
266
278
  return false
267
279
  }
268
280
 
281
+ const ASCII_DIGIT_RE = /\d/
282
+
283
+ /**
284
+ * Next offset at which a sticky `\d`-anchored pattern could match after a miss
285
+ * at `scan`. A miss at a digit rules out every start in the rest of its run:
286
+ * from a later start the pattern's leading `\d+` reaches a strict subset of
287
+ * the split positions the failed attempt already backtracked through. So the
288
+ * scan jumps past the current digit run (if any), then past non-digits to the
289
+ * next digit. Advancing one character at a time here instead re-reads the run
290
+ * at every offset — O(n²) on a long digit run, a denial-of-service vector.
291
+ */
292
+ function nextDigitAnchorAfterMiss(text: string, scan: number): number {
293
+ let next = scan
294
+ while (next < text.length && ASCII_DIGIT_RE.test(text[next])) next++
295
+ while (next < text.length && !ASCII_DIGIT_RE.test(text[next])) next++
296
+ return next
297
+ }
298
+
269
299
  function multiplicationOverView(view: ProseView): void {
270
300
  // The chain is matched with a sticky regex driven by a manual left-to-right
271
301
  // scan. Sticky anchoring keeps the nested `\d+(…\d+)+` quantifier ReDoS-safe
@@ -279,7 +309,7 @@ function multiplicationOverView(view: ProseView): void {
279
309
  while (scan < chainText.length) {
280
310
  chainPattern.lastIndex = scan
281
311
  const match = chainPattern.exec(chainText)
282
- if (match === null) { scan++; continue }
312
+ if (match === null) { scan = nextDigitAnchorAfterMiss(chainText, scan); continue }
283
313
  const { firstNum, rest } = match.groups as { firstNum: string; rest: string }
284
314
  const text = chainText
285
315
  const chainStart = match.index
@@ -352,7 +382,7 @@ function multiplicationOverView(view: ProseView): void {
352
382
  while (trailingScan < trailingText.length) {
353
383
  trailingPattern.lastIndex = trailingScan
354
384
  const match = trailingPattern.exec(trailingText)
355
- if (match === null) { trailingScan++; continue }
385
+ if (match === null) { trailingScan = nextDigitAnchorAfterMiss(trailingText, trailingScan); continue }
356
386
  const num = match.groups!.num
357
387
  const op = match.groups!.op
358
388
  const operatorOffset = match.index + num.length
@@ -368,8 +398,13 @@ function multiplicationOverView(view: ProseView): void {
368
398
  // trailing `\b`; otherwise reject when a word character follows the operator
369
399
  // through at most three boundaries.
370
400
  if (op === "*") continue
401
+ // An uppercase `X` directly attached to the digits is a model/SKU suffix
402
+ // (Ryzen 9 5900X), not a multiplier — prose multipliers write a lowercase
403
+ // x ("by 4x."). Chains ("16X16") convert above regardless of case.
404
+ if (op === "X") continue
371
405
  const afterOp = operatorOffset + 1
372
406
  const followChar = trailingText[afterOp]
407
+ if (followChar !== undefined && CURLY_APOSTROPHE_FOLLOWERS.has(followChar)) continue
373
408
  if (boundaryCountAt(view, afterOp) <= MAX_BOUNDARY_SEPARATORS && followChar !== undefined && WORD_RE.test(followChar)) continue
374
409
  view.replace(operatorOffset, afterOp, MULTIPLICATION)
375
410
  }
@@ -435,9 +470,7 @@ function mathLookaheadBlocks(text: string, view: ProseView, end: number, forbidd
435
470
  */
436
471
  function mathOperatorAllowBoundary(match: RegExpExecArray, view: ProseView): boolean {
437
472
  const junction = match.index + match[0].length - 1
438
- for (const boundary of view.boundaries) {
439
- if (boundary > match.index && boundary < match.index + match[0].length && boundary !== junction) return false
440
- }
473
+ if (!interiorBoundariesWithin(view, match.index, match.index + match[0].length, [junction])) return false
441
474
  return boundaryCountAt(view, junction) <= 1
442
475
  }
443
476
 
@@ -634,6 +667,9 @@ function arrowsOverView(view: ProseView): void {
634
667
  const text = view.text
635
668
  let i = 0
636
669
  while (i < text.length) {
670
+ // Every arrow shape starts with `-` or `<`; skip other offsets before
671
+ // the comparatively expensive left-context/boundary check.
672
+ if (text[i] !== "-" && text[i] !== "<") { i++; continue }
637
673
  if (!arrowLeftContextOk(view, text, i)) { i++; continue }
638
674
  const end = matcher(view, text, i)
639
675
  if (end < 0 || !arrowRightContextOk(view, text, end)) { i++; continue }
@@ -644,6 +680,20 @@ function arrowsOverView(view: ProseView): void {
644
680
  }
645
681
  }
646
682
 
683
+ /**
684
+ * A temperature's digit run starts a word: a letter directly before the run
685
+ * makes it an identifier ("W3C", "HTML5C"), and a `%` makes it a URL-encoded
686
+ * octet ("%2C"). Mirrors {@link chainGuardOk}'s boundary handling — a node
687
+ * boundary at the run start shadows the prior character and the guard passes.
688
+ */
689
+ function degreeLeadingGuardOk(text: string, view: ProseView, digitIndex: number): boolean {
690
+ let start = digitIndex
691
+ while (start > 0 && /\d/.test(text[start - 1]) && !view.hasBoundary(start)) start--
692
+ if (view.hasBoundary(start)) return true
693
+ const prior = text[start - 1]
694
+ return prior === undefined || (!LATIN_LETTER_RE.test(prior) && prior !== "%")
695
+ }
696
+
647
697
  export const degrees = makeProsePass(degreesOverView)
648
698
 
649
699
  function degreesOverView(view: ProseView): void {
@@ -657,6 +707,7 @@ function degreesOverView(view: ProseView): void {
657
707
  const text = view.text
658
708
  replaceAllInView(view, pattern, (match, v) => {
659
709
  const unitEnd = match.index + match[0].length
710
+ if (!degreeLeadingGuardOk(text, v, match.index)) return null
660
711
  if (!degreeUnitFollowOk(text, v, unitEnd)) return null
661
712
  const { unit } = match.groups!
662
713
  // Replace everything after the leading digit (the optional space and the
@@ -704,11 +755,9 @@ function degreeUnitFollowOk(text: string, view: ProseView, unitEnd: number): boo
704
755
  function digitSuffixBoundaryOk(match: RegExpExecArray, view: ProseView): boolean {
705
756
  const digitEnd = match.index + 1
706
757
  const matchEnd = match.index + match[0].length
707
- for (const boundary of view.boundaries) {
708
- // Reject any interior boundary that is not the single tolerated slot after
709
- // the leading digit.
710
- if (boundary > match.index && boundary < matchEnd && boundary !== digitEnd) return false
711
- }
758
+ // Reject any interior boundary that is not the single tolerated slot after
759
+ // the leading digit.
760
+ if (!interiorBoundariesWithin(view, match.index, matchEnd, [digitEnd])) return false
712
761
  return boundaryCountAt(view, digitEnd) <= 1
713
762
  }
714
763
 
@@ -757,10 +806,8 @@ function fractionsOverView(view: ProseView): void {
757
806
  if (!fractionLookaheadOk(text, v, end)) return null
758
807
  // Distribute interior boundaries: the first lands before the unicode char,
759
808
  // any remaining after it.
760
- let firstBoundary = end
761
- for (const boundary of v.boundaries) {
762
- if (boundary > start && boundary < end) { firstBoundary = boundary; break }
763
- }
809
+ const interior = firstInteriorBoundary(v, start, end)
810
+ const firstBoundary = interior >= 0 ? interior : end
764
811
  if (firstBoundary > start) v.replace(start, firstBoundary, "")
765
812
  v.replace(firstBoundary, end, FRACTION_MAP[match[0]])
766
813
  return null
package/src/utils.ts CHANGED
@@ -67,8 +67,3 @@ export function stableStringify(obj: object): string {
67
67
  .map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
68
68
  return JSON.stringify(entries)
69
69
  }
70
-
71
- /** Extracts named groups from a `.replace()` callback's arguments. @internal */
72
- export function namedGroups<G>(args: unknown[]): G {
73
- return args[args.length - 1] as G
74
- }