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/README.md +9 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +45 -22
- package/dist/cli.js.map +1 -1
- package/dist/dashes.d.ts +4 -2
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +87 -56
- package/dist/dashes.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +3 -7
- package/dist/nbsp.js.map +1 -1
- package/dist/prose-view.d.ts +18 -0
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +71 -26
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +103 -20
- package/dist/quote-classifier.js.map +1 -1
- package/dist/rehype.d.ts +23 -5
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +166 -62
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +30 -12
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +74 -24
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +0 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -4
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +47 -19
- package/src/dashes.ts +88 -61
- package/src/nbsp.ts +2 -5
- package/src/prose-view.ts +78 -21
- package/src/quote-classifier.ts +110 -19
- package/src/rehype.ts +202 -72
- package/src/remark.ts +36 -20
- package/src/symbols.ts +65 -18
- package/src/utils.ts +0 -5
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
flattenTextNodes(child)
|
|
80
|
-
)
|
|
96
|
+
const { nodes, opaqueBefore } = flattenProse(node.children as PhrasingContent[])
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
}
|