punctilio 3.11.3 → 3.12.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.
- package/dist/cli.d.ts +1 -12
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -27
- package/dist/cli.js.map +1 -1
- package/dist/constants.d.ts +13 -61
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +12 -61
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts +0 -9
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +4 -26
- package/dist/dashes.js.map +1 -1
- package/dist/html.d.ts +1 -32
- package/dist/html.d.ts.map +1 -1
- package/dist/html.js +1 -29
- package/dist/html.js.map +1 -1
- package/dist/index.d.ts +4 -43
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -41
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +1 -34
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +1 -34
- package/dist/markdown.js.map +1 -1
- package/dist/nbsp.d.ts +1 -68
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +6 -86
- package/dist/nbsp.js.map +1 -1
- package/dist/prettier-plugin.d.ts +0 -14
- package/dist/prettier-plugin.d.ts.map +1 -1
- package/dist/prettier-plugin.js +0 -19
- package/dist/prettier-plugin.js.map +1 -1
- package/dist/quotes.d.ts +0 -3
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +11 -58
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +4 -141
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +7 -144
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts +0 -30
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +0 -41
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts +1 -7
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +9 -53
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +10 -58
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +12 -59
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +6 -32
- package/src/constants.ts +13 -62
- package/src/dashes.ts +4 -27
- package/src/html.ts +1 -33
- package/src/index.ts +20 -43
- package/src/markdown.ts +1 -38
- package/src/nbsp.ts +6 -87
- package/src/prettier-plugin.ts +0 -20
- package/src/quotes.ts +11 -59
- package/src/rehype.ts +8 -162
- package/src/remark.ts +0 -42
- package/src/symbols.ts +9 -66
- package/src/utils.ts +11 -60
package/src/rehype.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rehype plugin for applying punctilio typography transformations to HTML.
|
|
3
|
-
*
|
|
4
|
-
* This plugin integrates punctilio with the unified/rehype ecosystem,
|
|
5
|
-
* allowing you to apply smart typography transformations to HTML ASTs.
|
|
6
|
-
*
|
|
7
|
-
* @packageDocumentation
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import type { Element, ElementContent, Parent, Root, RootContent, Text } from "hast"
|
|
11
2
|
import type { Transformer } from "unified"
|
|
12
3
|
|
|
@@ -16,29 +7,16 @@ import { transform, type TransformOptions } from "./index.js"
|
|
|
16
7
|
import { DEFAULT_SEPARATOR, MAX_RECURSION_DEPTH } from "./constants.js"
|
|
17
8
|
import { formatErrorString, transformTextNodes } from "./utils.js"
|
|
18
9
|
|
|
19
|
-
/** Predicate that decides whether an HTML element should be skipped during transformation. */
|
|
20
10
|
type ElementPredicate = (node: Element) => boolean
|
|
21
11
|
|
|
22
|
-
/** Function that transforms a plain-text string (e.g. smart quotes, dashes). */
|
|
23
12
|
type TextTransformer = (input: string) => string
|
|
24
13
|
|
|
25
|
-
/**
|
|
26
|
-
* Predicate that decides whether an individual text node should be excluded
|
|
27
|
-
* from transformation. Called after element-level `shouldSkip`, so it is
|
|
28
|
-
* never invoked for text inside elements that are already skipped.
|
|
29
|
-
*
|
|
30
|
-
* `ancestors` is the chain of Element parents for the text node, ordered
|
|
31
|
-
* root first, nearest last.
|
|
32
|
-
*/
|
|
14
|
+
/** Per-text-node skip predicate, called after element-level `shouldSkip`. */
|
|
33
15
|
export type TextNodeSkipPredicate = (
|
|
34
16
|
textNode: Text,
|
|
35
17
|
ancestors: readonly Element[],
|
|
36
18
|
) => boolean
|
|
37
19
|
|
|
38
|
-
/**
|
|
39
|
-
* Options shared by functions that flatten and transform text nodes inside
|
|
40
|
-
* an element tree.
|
|
41
|
-
*/
|
|
42
20
|
export interface ElementTransformOptions {
|
|
43
21
|
/**
|
|
44
22
|
* Optional per-text-node skip predicate. When it returns `true` for a
|
|
@@ -49,9 +27,6 @@ export interface ElementTransformOptions {
|
|
|
49
27
|
shouldSkipText?: TextNodeSkipPredicate
|
|
50
28
|
}
|
|
51
29
|
|
|
52
|
-
/**
|
|
53
|
-
* Options for the rehype-punctilio plugin.
|
|
54
|
-
*/
|
|
55
30
|
export interface RehypePunctilioOptions
|
|
56
31
|
extends TransformOptions,
|
|
57
32
|
ElementTransformOptions {
|
|
@@ -75,18 +50,6 @@ export interface RehypePunctilioOptions
|
|
|
75
50
|
|
|
76
51
|
const DEFAULT_SKIP_TAGS = ["code", "pre", "script", "style", "kbd", "var", "samp", "template", "math", "svg"]
|
|
77
52
|
|
|
78
|
-
/**
|
|
79
|
-
* Flattens text nodes from an element tree into a single array.
|
|
80
|
-
*
|
|
81
|
-
* Pass `options.shouldSkipText` to exclude individual text nodes from the
|
|
82
|
-
* result. Applied after element-level `shouldSkip`, so the hook is never
|
|
83
|
-
* invoked for text inside an already-skipped element.
|
|
84
|
-
*
|
|
85
|
-
* @example
|
|
86
|
-
* ```ts
|
|
87
|
-
* const textNodes = flattenTextNodes(paragraphElement, (el) => el.tagName === 'code')
|
|
88
|
-
* ```
|
|
89
|
-
*/
|
|
90
53
|
export function flattenTextNodes(
|
|
91
54
|
node: Element | ElementContent,
|
|
92
55
|
shouldSkip: ElementPredicate,
|
|
@@ -136,15 +99,6 @@ function flattenTextNodesImpl(
|
|
|
136
99
|
return []
|
|
137
100
|
}
|
|
138
101
|
|
|
139
|
-
/**
|
|
140
|
-
* Extracts concatenated text content from an element.
|
|
141
|
-
*
|
|
142
|
-
* @example
|
|
143
|
-
* ```ts
|
|
144
|
-
* const text = getTextContent(paragraphElement)
|
|
145
|
-
* // Returns "Hello, world!" for <p>Hello, <em>world!</em></p>
|
|
146
|
-
* ```
|
|
147
|
-
*/
|
|
148
102
|
export function getTextContent(
|
|
149
103
|
node: Element,
|
|
150
104
|
shouldSkip: ElementPredicate = () => false
|
|
@@ -154,16 +108,6 @@ export function getTextContent(
|
|
|
154
108
|
.join("")
|
|
155
109
|
}
|
|
156
110
|
|
|
157
|
-
/**
|
|
158
|
-
* Recursively finds the first text node in a tree of HTML elements.
|
|
159
|
-
* Returns null if no text nodes exist.
|
|
160
|
-
*
|
|
161
|
-
* @example
|
|
162
|
-
* ```ts
|
|
163
|
-
* const textNode = getFirstTextNode(divElement)
|
|
164
|
-
* if (textNode) console.log(textNode.value)
|
|
165
|
-
* ```
|
|
166
|
-
*/
|
|
167
111
|
export function getFirstTextNode(
|
|
168
112
|
node: Parent | RootContent,
|
|
169
113
|
depth: number = 0
|
|
@@ -187,21 +131,8 @@ export function getFirstTextNode(
|
|
|
187
131
|
const QUOTE_OPENERS = new Set(["\u201C"])
|
|
188
132
|
const CLOSER_TO_OPENER: Record<string, string> = { "\u201D": "\u201C" }
|
|
189
133
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
*
|
|
193
|
-
* Only checks double quotes (\u201C/\u201D). Single quotes are intentionally
|
|
194
|
-
* excluded because \u2019 (right single quote) doubles as an apostrophe,
|
|
195
|
-
* making balance-checking unreliable.
|
|
196
|
-
*
|
|
197
|
-
* @throws Error if double quotes are mismatched
|
|
198
|
-
*
|
|
199
|
-
* @example
|
|
200
|
-
* ```ts
|
|
201
|
-
* assertSmartQuotesMatch('\u201CHello\u201D') // OK
|
|
202
|
-
* assertSmartQuotesMatch('\u201CHello') // throws Error
|
|
203
|
-
* ```
|
|
204
|
-
*/
|
|
134
|
+
// Only checks double quotes \u2014 single quotes are excluded because
|
|
135
|
+
// U+2019 doubles as an apostrophe, making balance-checking unreliable.
|
|
205
136
|
export function assertSmartQuotesMatch(input: string): void {
|
|
206
137
|
if (!input) return
|
|
207
138
|
|
|
@@ -225,38 +156,9 @@ export function assertSmartQuotesMatch(input: string): void {
|
|
|
225
156
|
}
|
|
226
157
|
|
|
227
158
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* HTML elements. It:
|
|
232
|
-
* 1. Appends a private-use Unicode character to each child's text content
|
|
233
|
-
* 2. Concatenates and transforms the whole paragraph
|
|
234
|
-
* 3. Splits the result back into the original text nodes
|
|
235
|
-
*
|
|
236
|
-
* When `checkInvariance` is true, also verifies that the transform produces
|
|
237
|
-
* the same result with and without markers — i.e.,
|
|
238
|
-
* `stripMarkers(transform(textWithMarkers)) === transform(stripMarkers(text))`.
|
|
239
|
-
* Useful for debugging transforms that accidentally interact with markers.
|
|
240
|
-
*
|
|
241
|
-
* Pass `options.shouldSkipText` to opt individual text nodes out of
|
|
242
|
-
* transformation without skipping their enclosing element; skipped text
|
|
243
|
-
* nodes keep their original `.value`.
|
|
244
|
-
*
|
|
245
|
-
* @throws Error if transformation alters the number of text nodes
|
|
246
|
-
* @throws Error if `checkInvariance` is true and the invariance check fails
|
|
247
|
-
*
|
|
248
|
-
* @example
|
|
249
|
-
* ```ts
|
|
250
|
-
* import { transformElement, DEFAULT_SEPARATOR } from 'punctilio/rehype'
|
|
251
|
-
*
|
|
252
|
-
* // Apply a custom transform to an element
|
|
253
|
-
* transformElement(
|
|
254
|
-
* paragraphElement,
|
|
255
|
-
* (text) => text.replace(/eg\b/g, 'e.g.'),
|
|
256
|
-
* (el) => el.tagName === 'code',
|
|
257
|
-
* DEFAULT_SEPARATOR
|
|
258
|
-
* )
|
|
259
|
-
* ```
|
|
159
|
+
* Transforms element text using the separator-marking technique. When
|
|
160
|
+
* `checkInvariance` is true, verifies `stripMarkers(transform(marked)) ===
|
|
161
|
+
* transform(stripMarkers(text))` for debugging marker interactions.
|
|
260
162
|
*/
|
|
261
163
|
export function transformElement(
|
|
262
164
|
node: Element,
|
|
@@ -299,12 +201,8 @@ export function transformElement(
|
|
|
299
201
|
}
|
|
300
202
|
}
|
|
301
203
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
* child, its children are processed independently rather than as a single
|
|
305
|
-
* unit. This prevents merging text across semantically independent blocks
|
|
306
|
-
* (e.g., separate paragraphs inside a div).
|
|
307
|
-
*/
|
|
204
|
+
// Block children cause per-block processing to avoid merging text across
|
|
205
|
+
// semantically independent blocks (e.g., separate <p>s inside a <div>).
|
|
308
206
|
const BLOCK_ELEMENTS = new Set([
|
|
309
207
|
"address", "article", "aside", "blockquote", "details", "dialog",
|
|
310
208
|
"dd", "div", "dl", "dt", "fieldset", "figcaption", "figure",
|
|
@@ -313,10 +211,6 @@ const BLOCK_ELEMENTS = new Set([
|
|
|
313
211
|
"p", "pre", "section", "table", "ul",
|
|
314
212
|
])
|
|
315
213
|
|
|
316
|
-
/**
|
|
317
|
-
* HTML elements that can contain transformable text content.
|
|
318
|
-
* We traverse into these to find text nodes to transform.
|
|
319
|
-
*/
|
|
320
214
|
const TRANSFORMABLE_ELEMENTS = new Set([
|
|
321
215
|
"p",
|
|
322
216
|
"em",
|
|
@@ -374,11 +268,6 @@ const TRANSFORMABLE_ELEMENTS = new Set([
|
|
|
374
268
|
"rp",
|
|
375
269
|
])
|
|
376
270
|
|
|
377
|
-
/**
|
|
378
|
-
* Checks whether an element has any text node descendants (skipping elements
|
|
379
|
-
* matched by shouldSkip). Returns as soon as one is found — avoids allocating
|
|
380
|
-
* an array of all text nodes just to check existence.
|
|
381
|
-
*/
|
|
382
271
|
function hasTextDescendant(
|
|
383
272
|
node: Element | ElementContent,
|
|
384
273
|
shouldSkip: ElementPredicate,
|
|
@@ -403,20 +292,6 @@ function hasTextDescendant(
|
|
|
403
292
|
return false
|
|
404
293
|
}
|
|
405
294
|
|
|
406
|
-
/**
|
|
407
|
-
* Collects elements that should have text transformations applied.
|
|
408
|
-
*
|
|
409
|
-
* An element is collected as a single transformation unit when it:
|
|
410
|
-
* 1. Has direct text children (original behavior), OR
|
|
411
|
-
* 2. Has text descendants and NO block-level children — meaning all its
|
|
412
|
-
* content is inline (phrasing) and should share transformation context
|
|
413
|
-
* (e.g., `<p><em>"Hello</em><span>, world"</span></p>`).
|
|
414
|
-
*
|
|
415
|
-
* When an element has block-level children, we recurse so each block
|
|
416
|
-
* is processed independently (e.g., `<div><p>A</p><p>B</p></div>`).
|
|
417
|
-
*
|
|
418
|
-
* @param alreadyTransformed - Elements already processed; skipped during traversal to avoid redundant work
|
|
419
|
-
*/
|
|
420
295
|
export function collectTransformableElements(
|
|
421
296
|
node: Element,
|
|
422
297
|
shouldSkip: ElementPredicate,
|
|
@@ -462,11 +337,6 @@ export function collectTransformableElements(
|
|
|
462
337
|
return results
|
|
463
338
|
}
|
|
464
339
|
|
|
465
|
-
/**
|
|
466
|
-
* Recursively marks all element descendants as transformed.
|
|
467
|
-
* Used to prevent redundant processing of nested elements whose text
|
|
468
|
-
* was already processed as part of a parent element.
|
|
469
|
-
*/
|
|
470
340
|
function markDescendants(node: Element, set: Set<Element>, depth: number = 0): void {
|
|
471
341
|
/* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
|
|
472
342
|
if (depth > MAX_RECURSION_DEPTH) {
|
|
@@ -481,30 +351,6 @@ function markDescendants(node: Element, set: Set<Element>, depth: number = 0): v
|
|
|
481
351
|
}
|
|
482
352
|
}
|
|
483
353
|
|
|
484
|
-
/**
|
|
485
|
-
* Rehype plugin that applies punctilio typography transformations to HTML.
|
|
486
|
-
*
|
|
487
|
-
* @example
|
|
488
|
-
* ```ts
|
|
489
|
-
* import { unified } from 'unified'
|
|
490
|
-
* import remarkParse from 'remark-parse'
|
|
491
|
-
* import remarkRehype from 'remark-rehype'
|
|
492
|
-
* import rehypeStringify from 'rehype-stringify'
|
|
493
|
-
* import { rehypePunctilio } from 'punctilio/rehype'
|
|
494
|
-
*
|
|
495
|
-
* const result = await unified()
|
|
496
|
-
* .use(remarkParse)
|
|
497
|
-
* .use(remarkRehype)
|
|
498
|
-
* .use(rehypePunctilio, {
|
|
499
|
-
* punctuationStyle: 'american',
|
|
500
|
-
* dashStyle: 'american',
|
|
501
|
-
* })
|
|
502
|
-
* .use(rehypeStringify)
|
|
503
|
-
* .process('"Hello," she said -- "it\'s nice."')
|
|
504
|
-
*
|
|
505
|
-
* // Output HTML will have smart quotes and em-dashes
|
|
506
|
-
* ```
|
|
507
|
-
*/
|
|
508
354
|
export function rehypePunctilio(
|
|
509
355
|
options: RehypePunctilioOptions = {}
|
|
510
356
|
): Transformer<Root, Root> {
|
package/src/remark.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Remark plugin for applying punctilio typography transformations to Markdown.
|
|
3
|
-
*
|
|
4
|
-
* This plugin integrates punctilio with the unified/remark ecosystem,
|
|
5
|
-
* allowing you to apply smart typography transformations to Markdown ASTs.
|
|
6
|
-
*
|
|
7
|
-
* @packageDocumentation
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
1
|
import type { PhrasingContent, Root, Text } from "mdast"
|
|
11
2
|
import type { Transformer } from "unified"
|
|
12
3
|
|
|
@@ -18,21 +9,10 @@ import { transformTextNodes } from "./utils.js"
|
|
|
18
9
|
|
|
19
10
|
export type RemarkPunctilioOptions = TransformOptions
|
|
20
11
|
|
|
21
|
-
/**
|
|
22
|
-
* MDAST node types that contain phrasing (inline) content and should
|
|
23
|
-
* have their text transformed as a single unit.
|
|
24
|
-
*/
|
|
25
12
|
const PHRASING_CONTAINERS = new Set(["paragraph", "heading", "tableCell"])
|
|
26
13
|
|
|
27
|
-
/**
|
|
28
|
-
* MDAST node types whose text content should not be transformed.
|
|
29
|
-
*/
|
|
30
14
|
const SKIP_TYPES = new Set(["inlineCode", "html"])
|
|
31
15
|
|
|
32
|
-
/**
|
|
33
|
-
* Recursively collects text nodes from a phrasing content tree,
|
|
34
|
-
* skipping code and HTML nodes.
|
|
35
|
-
*/
|
|
36
16
|
function flattenTextNodes(
|
|
37
17
|
node: PhrasingContent,
|
|
38
18
|
depth: number = 0
|
|
@@ -59,28 +39,6 @@ function flattenTextNodes(
|
|
|
59
39
|
return []
|
|
60
40
|
}
|
|
61
41
|
|
|
62
|
-
/**
|
|
63
|
-
* Remark plugin that applies punctilio typography transformations to Markdown.
|
|
64
|
-
*
|
|
65
|
-
* @example
|
|
66
|
-
* ```ts
|
|
67
|
-
* import { unified } from 'unified'
|
|
68
|
-
* import remarkParse from 'remark-parse'
|
|
69
|
-
* import remarkStringify from 'remark-stringify'
|
|
70
|
-
* import remarkPunctilio from 'punctilio/remark'
|
|
71
|
-
*
|
|
72
|
-
* const result = await unified()
|
|
73
|
-
* .use(remarkParse)
|
|
74
|
-
* .use(remarkPunctilio, {
|
|
75
|
-
* punctuationStyle: 'american',
|
|
76
|
-
* dashStyle: 'american',
|
|
77
|
-
* })
|
|
78
|
-
* .use(remarkStringify)
|
|
79
|
-
* .process('"Hello," she said -- "it\'s nice."')
|
|
80
|
-
*
|
|
81
|
-
* // Output Markdown will have smart quotes and em-dashes
|
|
82
|
-
* ```
|
|
83
|
-
*/
|
|
84
42
|
export function remarkPunctilio(
|
|
85
43
|
options: RemarkPunctilioOptions = {}
|
|
86
44
|
): Transformer<Root, Root> {
|
package/src/symbols.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Symbol transformations: ellipses, multiplication, math symbols, arrows.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS, wordBoundaryEnd } from "./constants.js"
|
|
6
2
|
import { namedGroups } from "./utils.js"
|
|
7
3
|
|
|
@@ -138,19 +134,10 @@ export function multiplication(text: string, options: SymbolOptions = {}): strin
|
|
|
138
134
|
return text
|
|
139
135
|
}
|
|
140
136
|
|
|
141
|
-
/**
|
|
142
|
-
* Builds a negative lookahead that must see through an optional separator
|
|
143
|
-
* so e.g. `!<SEP>=` is correctly recognized as the start of `!==`.
|
|
144
|
-
*/
|
|
145
137
|
type MathLookaheadBuilder = (escapedSeparator: string) => string
|
|
146
138
|
|
|
147
|
-
/** [leftChars, rightChars, negativeLookaheadBuilder, replacement] */
|
|
148
139
|
type MathSymbolRule = [string, string, MathLookaheadBuilder, string]
|
|
149
140
|
|
|
150
|
-
/**
|
|
151
|
-
* Math symbol replacement map.
|
|
152
|
-
* The separator is inserted between left and right when building the regex.
|
|
153
|
-
*/
|
|
154
141
|
const MATH_SYMBOL_MAP: MathSymbolRule[] = [
|
|
155
142
|
["!", "=", (chr) => `(?!${chr}?=)`, NOT_EQUAL],
|
|
156
143
|
["\\+/", "-", () => "", PLUS_MINUS],
|
|
@@ -178,18 +165,11 @@ export function mathSymbols(text: string, options: SymbolOptions = {}): string {
|
|
|
178
165
|
return text
|
|
179
166
|
}
|
|
180
167
|
|
|
181
|
-
/** Predicate that decides whether a legal symbol should be converted based on surrounding text. */
|
|
182
168
|
type ContextPredicate = (before: string, after: string) => boolean
|
|
183
169
|
|
|
184
|
-
|
|
185
|
-
* Context window, in characters, examined on each side of a legal-symbol
|
|
186
|
-
* candidate. Large enough to fit a 4-digit year plus surrounding whitespace
|
|
187
|
-
* or the word "copyright" plus a space, but small enough that the slice
|
|
188
|
-
* stays cheap on long inputs.
|
|
189
|
-
*/
|
|
170
|
+
// 25 chars: fits "copyright " or a 4-digit year with padding, without making slicing expensive.
|
|
190
171
|
const LEGAL_SYMBOL_CONTEXT_WINDOW = 25
|
|
191
172
|
|
|
192
|
-
/** Returns true when the context window ends with a path-like fragment (slash + non-whitespace). */
|
|
193
173
|
const isPathContext = (before: string): boolean => {
|
|
194
174
|
const parts = before.split(/\s+/)
|
|
195
175
|
const trailing = parts[parts.length - 1]
|
|
@@ -197,11 +177,6 @@ const isPathContext = (before: string): boolean => {
|
|
|
197
177
|
return slashIdx >= 0 && slashIdx < trailing.length - 1
|
|
198
178
|
}
|
|
199
179
|
|
|
200
|
-
/**
|
|
201
|
-
* Context-aware replacement for legal symbols like (c), (r), (tm).
|
|
202
|
-
* Extracts surrounding text, strips separator characters from the context
|
|
203
|
-
* windows, and delegates the convert/skip decision to a predicate.
|
|
204
|
-
*/
|
|
205
180
|
function contextAwareLegalReplace(
|
|
206
181
|
text: string,
|
|
207
182
|
pattern: RegExp,
|
|
@@ -249,19 +224,9 @@ export function legalSymbols(text: string, options: SymbolOptions = {}): string
|
|
|
249
224
|
return text
|
|
250
225
|
}
|
|
251
226
|
|
|
252
|
-
/**
|
|
253
|
-
* Builds an arrow-shape regex fragment from the escaped separator pattern.
|
|
254
|
-
* The separator may appear between the halves of a bidirectional `<->`.
|
|
255
|
-
*/
|
|
256
227
|
type ArrowPatternBuilder = (escapedSeparator: string) => string
|
|
257
228
|
|
|
258
|
-
|
|
259
|
-
* Arrow pattern map: shape builder → Unicode symbol.
|
|
260
|
-
*
|
|
261
|
-
* The leading `<` on the bidi pattern anchors its repeated-dash-run group
|
|
262
|
-
* so it stays linear-time; the right/left arrows can't anchor that way, so
|
|
263
|
-
* they only admit one optional separator at the un-anchored end.
|
|
264
|
-
*/
|
|
229
|
+
// Leading `<` on bidi anchors the dash-run group for linear time.
|
|
265
230
|
const ARROW_RULES: readonly [ArrowPatternBuilder, string][] = [
|
|
266
231
|
[(sep) => `<${sep}?-+(?:${sep}-+)*${sep}?>`, ARROW_LEFT_RIGHT],
|
|
267
232
|
[(sep) => `-+${sep}?>`, ARROW_RIGHT],
|
|
@@ -292,7 +257,6 @@ export function arrows(text: string, options: SymbolOptions = {}): string {
|
|
|
292
257
|
return text
|
|
293
258
|
}
|
|
294
259
|
|
|
295
|
-
/** Convert "20 C" or "20 F" to "20 °C" or "20 °F". Only matches uppercase C/F. */
|
|
296
260
|
export function degrees(text: string, options: SymbolOptions = {}): string {
|
|
297
261
|
const chr = getEscapedSeparator(options)
|
|
298
262
|
|
|
@@ -309,7 +273,7 @@ export function degrees(text: string, options: SymbolOptions = {}): string {
|
|
|
309
273
|
}
|
|
310
274
|
|
|
311
275
|
/**
|
|
312
|
-
*
|
|
276
|
+
* Matches quote characters in priority order:
|
|
313
277
|
* 1. Prime candidate: digit + quote + non-letter (e.g., 5', 12")
|
|
314
278
|
* 2. Contraction: letter + quote + letter (e.g., it's, don't, O'Brien)
|
|
315
279
|
* 3. Trailing apostrophe: letter + quote + non-letter (e.g., dogs')
|
|
@@ -329,19 +293,9 @@ function buildQuoteClassificationPattern(
|
|
|
329
293
|
)
|
|
330
294
|
}
|
|
331
295
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
* balance tracker. A prime candidate converts only when no unmatched opening
|
|
336
|
-
* quote precedes it; otherwise it acts as a closing quote.
|
|
337
|
-
*
|
|
338
|
-
* For double primes (″): since ' and " are processed in separate passes,
|
|
339
|
-
* 5'10" becomes 5′10" after the single-quote pass. Inside balanced double
|
|
340
|
-
* quotes (e.g., "He is 5′10" tall"), the balance tracker would normally treat
|
|
341
|
-
* the " after 10 as a closing quote. To handle this, we check whether the "
|
|
342
|
-
* is preceded by ′ + digits — if so, it's inches notation and converts
|
|
343
|
-
* regardless of balance.
|
|
344
|
-
*/
|
|
296
|
+
// Converts prime candidates while tracking quote balance. A prime after a
|
|
297
|
+
// digit converts only when no unmatched opener precedes it. Inside balanced
|
|
298
|
+
// quotes, a ′+digits context (feet-inches like 5′10") still converts.
|
|
345
299
|
function balancedPrimeReplacer(primeChar: string, escapedSeparator: string) {
|
|
346
300
|
// Pre-compile the feet-inches context pattern for the double-prime pass.
|
|
347
301
|
// Matches when the text before a prime candidate ends with ′ followed by
|
|
@@ -421,13 +375,8 @@ export function primeMarks(text: string, options: SymbolOptions = {}): string {
|
|
|
421
375
|
return text
|
|
422
376
|
}
|
|
423
377
|
|
|
424
|
-
/** [numerator, denominator, unicodeChar] */
|
|
425
378
|
type FractionRule = [string, string, string]
|
|
426
379
|
|
|
427
|
-
/**
|
|
428
|
-
* Supported Unicode fractions.
|
|
429
|
-
* The regex alternation and lookup map are both derived from this list.
|
|
430
|
-
*/
|
|
431
380
|
const FRACTION_TUPLES: FractionRule[] = [
|
|
432
381
|
["1", "4", UNICODE_SYMBOLS.FRACTION_1_4],
|
|
433
382
|
["1", "2", UNICODE_SYMBOLS.FRACTION_1_2],
|
|
@@ -504,7 +453,7 @@ export function superscriptOrdinal(text: string, options: SymbolOptions = {}): s
|
|
|
504
453
|
})
|
|
505
454
|
}
|
|
506
455
|
|
|
507
|
-
|
|
456
|
+
// Preserves highest-priority space type (NBSP > NNBSP > regular) and leading indentation.
|
|
508
457
|
export function collapseSpaces(text: string): string {
|
|
509
458
|
return text.replace(cachedRegExp(`(?<=[^\\n${SPACE_CHARS}])[${SPACE_CHARS}]{2,}`, "g"), (match) => {
|
|
510
459
|
if (match.includes(NBSP)) return NBSP
|
|
@@ -513,14 +462,9 @@ export function collapseSpaces(text: string): string {
|
|
|
513
462
|
})
|
|
514
463
|
}
|
|
515
464
|
|
|
516
|
-
/** [firstChar, repeatedChar, replacement] */
|
|
517
465
|
type LigatureRule = [string, string, string]
|
|
518
466
|
|
|
519
|
-
|
|
520
|
-
* Punctuation ligature map.
|
|
521
|
-
* Pattern: first(sep)?repeated(?:sep?repeated)* → replacement
|
|
522
|
-
* Order matters: handle mixed punctuation first, then repeated
|
|
523
|
-
*/
|
|
467
|
+
// Order matters: mixed punctuation first, then repeated.
|
|
524
468
|
const PUNCTUATION_LIGATURE_MAP: LigatureRule[] = [
|
|
525
469
|
["\\?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
|
|
526
470
|
["!", "\\?", EXCLAMATION_QUESTION], // !?+ → ⁉
|
|
@@ -528,7 +472,7 @@ const PUNCTUATION_LIGATURE_MAP: LigatureRule[] = [
|
|
|
528
472
|
["!", "!", "!"], // !!+ → ! (normalize)
|
|
529
473
|
]
|
|
530
474
|
|
|
531
|
-
/** Convert ?? to ⁇, ?! to ⁈, !? to ⁉.
|
|
475
|
+
/** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Disabled by default (poor font support). */
|
|
532
476
|
export function punctuationLigatures(text: string, options: SymbolOptions = {}): string {
|
|
533
477
|
const chr = getEscapedSeparator(options)
|
|
534
478
|
const sepPattern = cachedRegExp(chr, "g")
|
|
@@ -548,7 +492,6 @@ export function punctuationLigatures(text: string, options: SymbolOptions = {}):
|
|
|
548
492
|
return text
|
|
549
493
|
}
|
|
550
494
|
|
|
551
|
-
/** Apply all symbol transforms. degrees/fractions excluded (too aggressive). */
|
|
552
495
|
export function symbolTransform(text: string, options: SymbolOptions = {}): string {
|
|
553
496
|
text = ellipsis(text, options)
|
|
554
497
|
text = multiplication(text, options)
|
package/src/utils.ts
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utility functions for text transformation validation.
|
|
3
|
-
*
|
|
4
|
-
* @module utils
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
1
|
import { DEFAULT_SEPARATOR, ISSUES_URL } from "./constants.js"
|
|
8
2
|
|
|
9
|
-
/** Threshold above which strings are truncated in error messages. */
|
|
10
3
|
const ERROR_STRING_THRESHOLD = 2000
|
|
11
4
|
|
|
12
5
|
/**
|
|
13
|
-
* Formats a string for error messages
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* In Node.js environments, also writes the full content to stderr
|
|
17
|
-
* so it's available in build logs for debugging.
|
|
6
|
+
* Formats a string for error messages, truncating above the threshold.
|
|
7
|
+
* Writes full content to stderr in Node.js so build logs capture it.
|
|
18
8
|
*/
|
|
19
9
|
export function formatErrorString(content: string, label: string): string {
|
|
20
10
|
if (content.length <= ERROR_STRING_THRESHOLD) {
|
|
@@ -36,12 +26,7 @@ export function formatErrorString(content: string, label: string): string {
|
|
|
36
26
|
return `[${label}: ${JSON.stringify(truncated)}... (${content.length} chars total)]`
|
|
37
27
|
}
|
|
38
28
|
|
|
39
|
-
/**
|
|
40
|
-
* Throws if any text node contains the separator character, which would
|
|
41
|
-
* corrupt the split/join mechanism used by the rehype and remark plugins.
|
|
42
|
-
*
|
|
43
|
-
* @throws Error if the separator is found in any text value
|
|
44
|
-
*/
|
|
29
|
+
/** Throws if any text node contains the separator (would corrupt split/join). */
|
|
45
30
|
export function assertSeparatorAbsent(textValues: string[], separator: string): void {
|
|
46
31
|
for (const value of textValues) {
|
|
47
32
|
if (!value.includes(separator)) continue
|
|
@@ -57,9 +42,6 @@ export function assertSeparatorAbsent(textValues: string[], separator: string):
|
|
|
57
42
|
}
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
/**
|
|
61
|
-
* Counts non-overlapping occurrences of the separator string in the text.
|
|
62
|
-
*/
|
|
63
45
|
export function countSeparators(text: string, separator: string = DEFAULT_SEPARATOR): number {
|
|
64
46
|
if (separator.length === 0) return 0
|
|
65
47
|
let count = 0
|
|
@@ -71,24 +53,15 @@ export function countSeparators(text: string, separator: string = DEFAULT_SEPARA
|
|
|
71
53
|
return count
|
|
72
54
|
}
|
|
73
55
|
|
|
74
|
-
/** Minimal text-node interface shared by mdast.Text and hast.Text. */
|
|
75
56
|
interface TextNode {
|
|
76
57
|
value: string
|
|
77
58
|
}
|
|
78
59
|
|
|
79
60
|
/**
|
|
80
61
|
* Applies a text transformation across an array of text nodes using the
|
|
81
|
-
* separator-marking technique
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* 2. Appends the separator to each node's value, concatenates into one string.
|
|
85
|
-
* 3. Runs the transform function on the concatenated string.
|
|
86
|
-
* 4. Splits the result back on the separator and writes each fragment
|
|
87
|
-
* back into the corresponding text node.
|
|
88
|
-
*
|
|
89
|
-
* `textNodes` is mutated in place.
|
|
90
|
-
*
|
|
91
|
-
* @throws Error if the transformation alters the number of text nodes
|
|
62
|
+
* separator-marking technique: appends the separator to each node's value,
|
|
63
|
+
* concatenates, transforms, then splits back on the separator and writes
|
|
64
|
+
* each fragment into the corresponding text node. Mutates `textNodes`.
|
|
92
65
|
*/
|
|
93
66
|
export function transformTextNodes(
|
|
94
67
|
textNodes: TextNode[],
|
|
@@ -115,12 +88,7 @@ export function transformTextNodes(
|
|
|
115
88
|
})
|
|
116
89
|
}
|
|
117
90
|
|
|
118
|
-
/**
|
|
119
|
-
* Validates that a transformation preserved the separator count.
|
|
120
|
-
* Throws an error if separators were added or removed.
|
|
121
|
-
*
|
|
122
|
-
* @throws Error if separator count changed
|
|
123
|
-
*/
|
|
91
|
+
/** Throws if a transformation changed the separator count. */
|
|
124
92
|
export function assertSeparatorCountPreserved(
|
|
125
93
|
original: string,
|
|
126
94
|
transformed: string,
|
|
@@ -138,12 +106,7 @@ export function assertSeparatorCountPreserved(
|
|
|
138
106
|
}
|
|
139
107
|
}
|
|
140
108
|
|
|
141
|
-
/**
|
|
142
|
-
* Returns a copy of `obj` with `undefined`-valued keys removed.
|
|
143
|
-
* Used so caller-supplied `{ foo: undefined }` doesn't override a default.
|
|
144
|
-
*
|
|
145
|
-
* @internal
|
|
146
|
-
*/
|
|
109
|
+
/** Returns a copy of `obj` with `undefined`-valued keys removed. @internal */
|
|
147
110
|
export function filterUndefined<T extends object>(obj: T): Partial<T> {
|
|
148
111
|
const result: Record<string, unknown> = {}
|
|
149
112
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -152,28 +115,16 @@ export function filterUndefined<T extends object>(obj: T): Partial<T> {
|
|
|
152
115
|
return result as Partial<T>
|
|
153
116
|
}
|
|
154
117
|
|
|
155
|
-
/**
|
|
156
|
-
* Returns a deterministic JSON-style string for an options object: drops
|
|
157
|
-
* `undefined` values and sorts keys, so equivalent option sets always
|
|
158
|
-
* produce the same string (suitable as a cache or LRU key).
|
|
159
|
-
*
|
|
160
|
-
* @internal
|
|
161
|
-
*/
|
|
118
|
+
/** Deterministic JSON string for an options object, suitable as cache key. @internal */
|
|
162
119
|
export function stableStringify(obj: object): string {
|
|
163
120
|
const entries = Object.entries(obj)
|
|
164
121
|
.filter(([, v]) => v !== undefined)
|
|
165
122
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
123
|
+
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
|
|
166
124
|
return JSON.stringify(entries)
|
|
167
125
|
}
|
|
168
126
|
|
|
169
|
-
/**
|
|
170
|
-
* Extracts the named-groups object from a `String.prototype.replace`
|
|
171
|
-
* callback's argument tuple. The last element is always the groups
|
|
172
|
-
* object when the regex has named captures. Callers supply the group
|
|
173
|
-
* shape via the type parameter to localise the unavoidable type cast.
|
|
174
|
-
*
|
|
175
|
-
* @internal
|
|
176
|
-
*/
|
|
127
|
+
/** Extracts named groups from a `.replace()` callback's arguments. @internal */
|
|
177
128
|
export function namedGroups<G>(args: unknown[]): G {
|
|
178
129
|
return args[args.length - 1] as G
|
|
179
130
|
}
|