punctilio 1.2.10 → 1.4.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/src/quotes.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Smart quote transformation: straight quotes → curly quotes.
3
+ */
4
+
5
+ import escapeStringRegexp from "escape-string-regexp"
6
+ import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS } from "./constants.js"
7
+
8
+ const {
9
+ EM_DASH,
10
+ LEFT_DOUBLE_QUOTE,
11
+ RIGHT_DOUBLE_QUOTE,
12
+ LEFT_SINGLE_QUOTE,
13
+ RIGHT_SINGLE_QUOTE,
14
+ ELLIPSIS,
15
+ } = UNICODE_SYMBOLS
16
+
17
+ export type PunctuationStyle = "american" | "british" | "none"
18
+
19
+ export interface QuoteOptions {
20
+ /** Boundary marker for HTML element boundaries. Default: "\uE000" */
21
+ separator?: string
22
+ /** "american" (inside), "british" (outside), "none". Default: "american" */
23
+ punctuationStyle?: PunctuationStyle
24
+ }
25
+
26
+ /** Convert straight single quotes to curly quotes and apostrophes */
27
+ function convertSingleQuotes(text: string, sep: string): string {
28
+ const escapedSep = escapeStringRegexp(sep)
29
+
30
+ // Handle empty single quotes '' and whitespace-only quotes ' ' first
31
+ // Only match straight quotes, not already-converted curly quotes
32
+ const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
33
+ text = text.replace(new RegExp(`(?<![${singleQuoteChars}])''(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
34
+ text = text.replace(new RegExp(`(?<![${singleQuoteChars}])'(\\s+)'(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
35
+
36
+ const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
37
+ const afterEndingSingle = `(?=${escapedSep}?(?:s${escapedSep}?)?(?:[${afterEndingSinglePatterns}]|$))`
38
+ const endingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterEndingSingle}`
39
+ text = text.replace(new RegExp(endingSingle, "gm"), RIGHT_SINGLE_QUOTE)
40
+
41
+ const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}](?=${escapedSep}?[${LATIN_LETTERS}])`
42
+ text = text.replace(new RegExp(contraction, "gm"), RIGHT_SINGLE_QUOTE)
43
+
44
+ const apostropheWhitelist = `(?=n${RIGHT_SINGLE_QUOTE} )`
45
+ const endQuoteNotContraction = `(?!${contraction})${RIGHT_SINGLE_QUOTE}${afterEndingSingle}`
46
+ // Limit lookahead scan to 1000 chars to prevent catastrophic backtracking on pathological inputs
47
+ const apostropheRegex = new RegExp(
48
+ `(?<=^|[^\\w])'(${apostropheWhitelist}|(?![^${LEFT_SINGLE_QUOTE}'\\n]{0,1000}${endQuoteNotContraction}))`,
49
+ "gm"
50
+ )
51
+ text = text.replace(apostropheRegex, RIGHT_SINGLE_QUOTE)
52
+
53
+ const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
54
+ text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
55
+
56
+ return text
57
+ }
58
+
59
+ /** Convert straight double quotes to curly quotes */
60
+ function convertDoubleQuotes(text: string, sep: string): string {
61
+ const escapedSep = escapeStringRegexp(sep)
62
+
63
+ // Handle empty quotes "" first - match only when not part of adjacent quotes
64
+ // Require word boundary or start/end of string on at least one side
65
+ text = text.replace(/(?<=^|[\s([{])""(?=$|[\s)\]}.!?,;:])/g, `${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`)
66
+ // Handle whitespace-only quotes " " - require non-quote chars on both sides
67
+ text = text.replace(/(?<=^|[\s([{])"(?<whitespace>\s+)"(?=$|[\s)\]}.!?,;:])/g, `${LEFT_DOUBLE_QUOTE}$<whitespace>${RIGHT_DOUBLE_QUOTE}`)
68
+
69
+ const beginningDouble = new RegExp(
70
+ `(?<=^|[\\s\\(\\/\\[\\{\\-${EM_DASH}${escapedSep}])(?<beforeChr>${escapedSep}?)["](?<afterChr>(?<sepWithPunct>${escapedSep}[ .,])|(?=${escapedSep}?\\.{3}|${escapedSep}?[^\\s\\)\\${EM_DASH},!?${escapedSep};:.\\}]))`,
71
+ "gm"
72
+ )
73
+ text = text.replace(beginningDouble, `$<beforeChr>${LEFT_DOUBLE_QUOTE}$<afterChr>`)
74
+
75
+ text = text.replace(new RegExp(`(?<=\\{)(?<sepSpace>${escapedSep}? )?["]`, "g"), `$<sepSpace>${LEFT_DOUBLE_QUOTE}`)
76
+
77
+ const endingDouble = `(?<beforeQuote>[^\\s\\(])["]((?<sepAfter>${escapedSep})?)(?=${escapedSep}|[\\s/\\).,;${EM_DASH}:\\-\\}!?s]|$)`
78
+ text = text.replace(new RegExp(endingDouble, "g"), `$<beforeQuote>${RIGHT_DOUBLE_QUOTE}$<sepAfter>`)
79
+
80
+ text = text.replace(new RegExp(`["](?<sepEnd>${escapedSep}?)$`, "g"), `${RIGHT_DOUBLE_QUOTE}$<sepEnd>`)
81
+ text = text.replace(new RegExp(`'(?=${RIGHT_DOUBLE_QUOTE})`, "gu"), RIGHT_SINGLE_QUOTE)
82
+
83
+ return text
84
+ }
85
+
86
+ /** Apply American or British punctuation style */
87
+ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyle): string {
88
+ const escapedSep = escapeStringRegexp(sep)
89
+
90
+ if (style === "american") {
91
+ // Period outside → inside: "Hello". → "Hello."
92
+ const periodOutsideRegex = new RegExp(
93
+ `(?<![!?:\\.${ELLIPSIS}])(?<sepBefore>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])(?<sepAfter>${escapedSep}?)(?!\\.\\.\\.)\\.`,
94
+ "g"
95
+ )
96
+ text = text.replace(periodOutsideRegex, "$<sepBefore>.$<quote>$<sepAfter>")
97
+
98
+ // Comma outside → inside: "Hello", → "Hello,"
99
+ const commaOutsideRegex = new RegExp(
100
+ `(?<sepBefore>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])(?<sepAfter>${escapedSep}?),`,
101
+ "g"
102
+ )
103
+ text = text.replace(commaOutsideRegex, "$<sepBefore>,$<quote>$<sepAfter>")
104
+ } else if (style === "british") {
105
+ // Period inside → outside: "Hello." → "Hello".
106
+ const periodInsideRegex = new RegExp(
107
+ `(?<![!?:\\.${ELLIPSIS}])(?<sepBefore>${escapedSep}?)\\.(?<sepMiddle>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])`,
108
+ "g"
109
+ )
110
+ text = text.replace(periodInsideRegex, "$<sepBefore>$<sepMiddle>$<quote>.")
111
+
112
+ // Comma inside → outside: "Hello," → "Hello",
113
+ const commaInsideRegex = new RegExp(
114
+ `(?<![!?]),(?<sepAndQuote>${escapedSep}?[${RIGHT_DOUBLE_QUOTE}${RIGHT_SINGLE_QUOTE}])`,
115
+ "g"
116
+ )
117
+ text = text.replace(commaInsideRegex, "$<sepAndQuote>,")
118
+ }
119
+ return text
120
+ }
121
+
122
+ /** Convert straight quotes to smart quotes. */
123
+ export function niceQuotes(text: string, options: QuoteOptions = {}): string {
124
+ const sep = options.separator ?? DEFAULT_SEPARATOR
125
+ const punctuationStyle = options.punctuationStyle ?? "american"
126
+
127
+ text = convertSingleQuotes(text, sep)
128
+ text = convertDoubleQuotes(text, sep)
129
+ text = applyPunctuationStyle(text, sep, punctuationStyle)
130
+
131
+ return text
132
+ }
package/src/rehype.ts ADDED
@@ -0,0 +1,503 @@
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
+ import type { Root, Element, Text, ElementContent, Parent, RootContent } from "hast"
11
+ import type { Transformer } from "unified"
12
+
13
+ import { visitParents } from "unist-util-visit-parents"
14
+
15
+ import { transform, type TransformOptions } from "./index.js"
16
+ import { DEFAULT_SEPARATOR } from "./constants.js"
17
+ import { formatErrorString } from "./utils.js"
18
+
19
+ /**
20
+ * Options for the rehype-punctilio plugin.
21
+ */
22
+ export interface RehypePunctilioOptions extends TransformOptions {
23
+ /**
24
+ * HTML tag names to skip when applying transformations.
25
+ * Content inside these elements won't have formatting improvements applied.
26
+ *
27
+ * Default: ["code", "pre", "script", "style", "kbd", "var", "samp"]
28
+ */
29
+ skipTags?: string[]
30
+
31
+ /**
32
+ * CSS class names that indicate content should skip formatting.
33
+ * Elements with any of these classes (or descendants of such elements)
34
+ * will be skipped.
35
+ *
36
+ * Default: []
37
+ */
38
+ skipClasses?: string[]
39
+ }
40
+
41
+ const DEFAULT_SKIP_TAGS = ["code", "pre", "script", "style", "kbd", "var", "samp"]
42
+
43
+ /**
44
+ * Maximum recursion depth for tree traversal functions.
45
+ * Prevents stack overflow from maliciously deep HTML nesting.
46
+ */
47
+ const MAX_RECURSION_DEPTH = 1000
48
+
49
+ /**
50
+ * Check if an element has a specific CSS class.
51
+ */
52
+ function hasClass(node: Element, className: string): boolean {
53
+ const classNames = node.properties?.className
54
+ if (Array.isArray(classNames)) {
55
+ return classNames.includes(className)
56
+ }
57
+ /* istanbul ignore next -- rehype-parse always produces arrays, but handle strings defensively */
58
+ if (typeof classNames === "string") {
59
+ return classNames.split(/\s+/).includes(className)
60
+ }
61
+ return false
62
+ }
63
+
64
+ /**
65
+ * Check if any ancestor of a node matches a predicate.
66
+ */
67
+ function hasAncestor(
68
+ ancestors: Parent[],
69
+ predicate: (node: Element) => boolean
70
+ ): boolean {
71
+ return ancestors.some((ancestor) => {
72
+ if (ancestor.type === "element") {
73
+ return predicate(ancestor as Element)
74
+ }
75
+ return false
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Flattens text nodes from an element tree into a single array.
81
+ *
82
+ * @param node - The element or element content to process
83
+ * @param shouldSkip - Function to determine which elements to skip
84
+ * @param depth - Current recursion depth (internal use)
85
+ * @returns Array of Text nodes
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * const textNodes = flattenTextNodes(paragraphElement, (el) => el.tagName === 'code')
90
+ * ```
91
+ */
92
+ export function flattenTextNodes(
93
+ node: Element | ElementContent,
94
+ shouldSkip: (n: Element) => boolean,
95
+ depth: number = 0
96
+ ): Text[] {
97
+ if (depth > MAX_RECURSION_DEPTH) {
98
+ return []
99
+ }
100
+
101
+ if (node.type === "element" && shouldSkip(node)) {
102
+ return []
103
+ }
104
+
105
+ if (node.type === "text") {
106
+ return [node]
107
+ }
108
+
109
+ if (node.type === "element" && "children" in node) {
110
+ return node.children.flatMap((child) => flattenTextNodes(child, shouldSkip, depth + 1))
111
+ }
112
+
113
+ return []
114
+ }
115
+
116
+ /**
117
+ * Extracts concatenated text content from an element.
118
+ *
119
+ * @param node - The element to extract text from
120
+ * @param shouldSkip - Optional function to determine which elements to skip
121
+ * @returns The combined text content
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * const text = getTextContent(paragraphElement)
126
+ * // Returns "Hello, world!" for <p>Hello, <em>world!</em></p>
127
+ * ```
128
+ */
129
+ export function getTextContent(
130
+ node: Element,
131
+ shouldSkip: (n: Element) => boolean = () => false
132
+ ): string {
133
+ return flattenTextNodes(node, shouldSkip)
134
+ .map((n) => n.value)
135
+ .join("")
136
+ }
137
+
138
+ /**
139
+ * Recursively finds the first text node in a tree of HTML elements.
140
+ *
141
+ * @param node - The root node to search from
142
+ * @param depth - Current recursion depth (internal use)
143
+ * @returns The first text node found, or null if no text nodes exist
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * const textNode = getFirstTextNode(divElement)
148
+ * if (textNode) console.log(textNode.value)
149
+ * ```
150
+ */
151
+ export function getFirstTextNode(
152
+ node: Parent | RootContent,
153
+ depth: number = 0
154
+ ): Text | null {
155
+ if (!node || depth > MAX_RECURSION_DEPTH) return null
156
+
157
+ if (node.type === "text" && "value" in node) {
158
+ return node as Text
159
+ }
160
+
161
+ if ("children" in node && node.children.length > 0) {
162
+ for (const child of node.children) {
163
+ const textNode = getFirstTextNode(child as Parent, depth + 1)
164
+ if (textNode) return textNode
165
+ }
166
+ }
167
+
168
+ return null
169
+ }
170
+
171
+ /**
172
+ * Validates that smart quotes in a text string are properly matched.
173
+ *
174
+ * @param input - The text to validate
175
+ * @throws Error if quotes are mismatched
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * assertSmartQuotesMatch('\u201CHello\u201D') // OK
180
+ * assertSmartQuotesMatch('\u201CHello') // throws Error
181
+ * ```
182
+ */
183
+ export function assertSmartQuotesMatch(input: string): void {
184
+ if (!input) return
185
+
186
+ const quoteMap: Record<string, string> = { "\u201C": "\u201D", "\u201D": "\u201C" }
187
+ const stack: string[] = []
188
+
189
+ for (const char of input) {
190
+ if (char in quoteMap) {
191
+ if (stack.length > 0 && quoteMap[stack[stack.length - 1]] === char) {
192
+ stack.pop()
193
+ } else {
194
+ stack.push(char)
195
+ }
196
+ }
197
+ }
198
+
199
+ if (stack.length > 0) {
200
+ throw new Error(`Mismatched quotes in ${input}`)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Applies a transformation to element text content while preserving HTML structure.
206
+ *
207
+ * This function uses a marker technique to handle text that spans multiple
208
+ * HTML elements. It:
209
+ * 1. Appends a private-use Unicode character to each child's text content
210
+ * 2. Concatenates and transforms the whole paragraph
211
+ * 3. Splits the result back into the original text nodes
212
+ *
213
+ * @param node - The element to transform
214
+ * @param transformFn - The transformation function to apply
215
+ * @param shouldSkip - Function to determine which elements to skip
216
+ * @param separator - The marker character to use (default: DEFAULT_SEPARATOR)
217
+ * @param checkInvariance - Whether to verify that the transform produces the same
218
+ * result with and without markers. When true, checks that
219
+ * `stripMarkers(transform(textWithMarkers)) === transform(stripMarkers(text))`.
220
+ * Useful for debugging transforms that accidentally interact with markers.
221
+ * Default: false
222
+ * @throws Error if transformation alters the number of text nodes
223
+ * @throws Error if checkInvariance is true and the invariance check fails
224
+ *
225
+ * @example
226
+ * ```ts
227
+ * import { transformElement, DEFAULT_SEPARATOR } from 'punctilio/rehype'
228
+ *
229
+ * // Apply a custom transform to an element
230
+ * transformElement(
231
+ * paragraphElement,
232
+ * (text) => text.replace(/eg\b/g, 'e.g.'),
233
+ * (el) => el.tagName === 'code',
234
+ * DEFAULT_SEPARATOR
235
+ * )
236
+ * ```
237
+ */
238
+ export function transformElement(
239
+ node: Element,
240
+ transformFn: (input: string) => string,
241
+ shouldSkip: (input: Element) => boolean,
242
+ separator: string,
243
+ checkInvariance: boolean = false
244
+ ): void {
245
+ /* istanbul ignore if -- defensive: elements should always have children array */
246
+ if (!node?.children) {
247
+ return
248
+ }
249
+
250
+ const textNodes = flattenTextNodes(node, shouldSkip)
251
+ /* istanbul ignore if -- only hit when element has no text descendants */
252
+ if (textNodes.length === 0) {
253
+ return
254
+ }
255
+
256
+ // Append marker and concatenate all text nodes
257
+ const markedContent = textNodes.map((n) => n.value + separator).join("")
258
+
259
+ const transformedContent = transformFn(markedContent)
260
+
261
+ // Split and overwrite. Last fragment is always empty because strings end with marker
262
+ const transformedFragments = transformedContent.split(separator).slice(0, -1)
263
+
264
+ /* istanbul ignore if -- defensive: transform should never consume separator chars */
265
+ if (transformedFragments.length !== textNodes.length) {
266
+ throw new Error(
267
+ `Transformation altered the number of text nodes. ` +
268
+ `Expected ${textNodes.length}, got ${transformedFragments.length}. ` +
269
+ `Input: ${formatErrorString(markedContent, "input")}`
270
+ )
271
+ }
272
+
273
+ textNodes.forEach((n, index) => {
274
+ n.value = transformedFragments[index]
275
+ })
276
+
277
+ if (checkInvariance) {
278
+ const stripSep = (s: string) => s.replaceAll(separator, "")
279
+ const strippedTransformed = stripSep(transformedContent)
280
+ const expected = transformFn(stripSep(markedContent))
281
+
282
+ if (expected !== strippedTransformed) {
283
+ throw new Error(
284
+ `Transform invariance check failed: ` +
285
+ `expected ${formatErrorString(expected, "expected")} ` +
286
+ `but got ${formatErrorString(strippedTransformed, "actual")}`
287
+ )
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * HTML elements that can contain transformable text content.
294
+ * We traverse into these to find text nodes to transform.
295
+ */
296
+ const TRANSFORMABLE_ELEMENTS = [
297
+ "p",
298
+ "em",
299
+ "strong",
300
+ "i",
301
+ "b",
302
+ "u",
303
+ "s",
304
+ "sub",
305
+ "sup",
306
+ "small",
307
+ "del",
308
+ "ins",
309
+ "mark",
310
+ "span",
311
+ "div",
312
+ "td",
313
+ "th",
314
+ "dt",
315
+ "dd",
316
+ "li",
317
+ "h1",
318
+ "h2",
319
+ "h3",
320
+ "h4",
321
+ "h5",
322
+ "h6",
323
+ "figcaption",
324
+ "blockquote",
325
+ "cite",
326
+ "q",
327
+ "a",
328
+ "label",
329
+ "legend",
330
+ "caption",
331
+ "summary",
332
+ "address",
333
+ "article",
334
+ "aside",
335
+ "footer",
336
+ "header",
337
+ "main",
338
+ "nav",
339
+ "section",
340
+ "time",
341
+ "abbr",
342
+ "dfn",
343
+ "data",
344
+ "bdi",
345
+ "bdo",
346
+ "ruby",
347
+ "rt",
348
+ "rp",
349
+ ]
350
+
351
+ /**
352
+ * Collects elements that should have text transformations applied.
353
+ * Only collects elements that directly contain text nodes.
354
+ *
355
+ * @param node - The root element to search from
356
+ * @param shouldSkip - Function to determine which elements to skip
357
+ * @param depth - Current recursion depth (internal use)
358
+ * @returns Array of elements that contain transformable text
359
+ */
360
+ export function collectTransformableElements(
361
+ node: Element,
362
+ shouldSkip: (n: Element) => boolean,
363
+ depth: number = 0
364
+ ): Element[] {
365
+ /* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
366
+ if (depth > MAX_RECURSION_DEPTH) {
367
+ return []
368
+ }
369
+
370
+ const results: Element[] = []
371
+
372
+ if (shouldSkip(node)) {
373
+ return []
374
+ }
375
+
376
+ // If this node is a transformable element with direct text children, collect it
377
+ if (
378
+ TRANSFORMABLE_ELEMENTS.includes(node.tagName) &&
379
+ node.children.some((child) => child.type === "text")
380
+ ) {
381
+ results.push(node)
382
+ } else {
383
+ // Otherwise, recurse into children
384
+ for (const child of node.children) {
385
+ if (child.type === "element") {
386
+ results.push(...collectTransformableElements(child, shouldSkip, depth + 1))
387
+ }
388
+ }
389
+ }
390
+
391
+ return results
392
+ }
393
+
394
+ /**
395
+ * Recursively marks all element descendants as transformed.
396
+ * Used to prevent redundant processing of nested elements whose text
397
+ * was already processed as part of a parent element.
398
+ */
399
+ function markDescendants(node: Element, set: Set<Element>, depth: number = 0): void {
400
+ /* istanbul ignore if -- defensive: prevents stack overflow from malicious HTML */
401
+ if (depth > MAX_RECURSION_DEPTH) {
402
+ return
403
+ }
404
+
405
+ for (const child of node.children) {
406
+ if (child.type === "element") {
407
+ set.add(child)
408
+ markDescendants(child, set, depth + 1)
409
+ }
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Rehype plugin that applies punctilio typography transformations to HTML.
415
+ *
416
+ * @param options - Plugin configuration options
417
+ * @returns Unified transformer function
418
+ *
419
+ * @example
420
+ * ```ts
421
+ * import { unified } from 'unified'
422
+ * import remarkParse from 'remark-parse'
423
+ * import remarkRehype from 'remark-rehype'
424
+ * import rehypeStringify from 'rehype-stringify'
425
+ * import { rehypePunctilio } from 'punctilio/rehype'
426
+ *
427
+ * const result = await unified()
428
+ * .use(remarkParse)
429
+ * .use(remarkRehype)
430
+ * .use(rehypePunctilio, {
431
+ * punctuationStyle: 'american',
432
+ * dashStyle: 'american',
433
+ * })
434
+ * .use(rehypeStringify)
435
+ * .process('"Hello," she said -- "it\'s nice."')
436
+ *
437
+ * // Output HTML will have smart quotes and em-dashes
438
+ * ```
439
+ */
440
+ export function rehypePunctilio(
441
+ options: RehypePunctilioOptions = {}
442
+ ): Transformer<Root, Root> {
443
+ const {
444
+ skipTags = DEFAULT_SKIP_TAGS,
445
+ skipClasses = [],
446
+ separator = DEFAULT_SEPARATOR,
447
+ ...transformOptions
448
+ } = options
449
+
450
+ const shouldSkip = (node: Element): boolean => {
451
+ if (skipTags.includes(node.tagName)) {
452
+ return true
453
+ }
454
+ if (skipClasses.some((cls) => hasClass(node, cls))) {
455
+ return true
456
+ }
457
+ return false
458
+ }
459
+
460
+ const transformFn = (text: string): string => {
461
+ return transform(text, { ...transformOptions, separator })
462
+ }
463
+
464
+ return (tree: Root) => {
465
+ // Track transformed elements to avoid double-processing
466
+ const transformed = new Set<Element>()
467
+
468
+ visitParents(tree, (node, ancestors) => {
469
+ // Only process element nodes
470
+ if (node.type !== "element") {
471
+ return
472
+ }
473
+
474
+ const element = node as Element
475
+
476
+ // Skip if already transformed
477
+ if (transformed.has(element)) {
478
+ return
479
+ }
480
+
481
+ // Check if this node or any ancestor should be skipped
482
+ if (shouldSkip(element)) {
483
+ return
484
+ }
485
+ if (hasAncestor(ancestors as Parent[], shouldSkip)) {
486
+ return
487
+ }
488
+
489
+ // Collect and transform elements with text content
490
+ const elementsToTransform = collectTransformableElements(element, shouldSkip)
491
+ for (const elt of elementsToTransform) {
492
+ if (!transformed.has(elt)) {
493
+ transformElement(elt, transformFn, shouldSkip, separator)
494
+ transformed.add(elt)
495
+ // Mark all descendants as processed since their text was included
496
+ markDescendants(elt, transformed)
497
+ }
498
+ }
499
+ })
500
+ }
501
+ }
502
+
503
+ export default rehypePunctilio