punctilio 1.3.1 → 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/README.md +32 -25
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +26 -8
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +17 -12
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +161 -0
- package/dist/rehype.d.ts.map +1 -0
- package/dist/rehype.js +401 -0
- package/dist/rehype.js.map +1 -0
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +2 -1
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +19 -0
- package/dist/utils.js.map +1 -1
- package/package.json +28 -4
- package/src/constants.ts +161 -0
- package/src/dashes.ts +211 -0
- package/src/index.ts +243 -0
- package/src/quotes.ts +132 -0
- package/src/rehype.ts +503 -0
- package/src/symbols.ts +324 -0
- package/src/tests/dashes.test.ts +649 -0
- package/src/tests/index.test.ts +528 -0
- package/src/tests/quotes.test.ts +344 -0
- package/src/tests/rehype.test.ts +418 -0
- package/src/tests/symbols.test.ts +708 -0
- package/src/tests/utils.test.ts +98 -0
- package/src/utils.ts +65 -0
- package/tsconfig.json +20 -0
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
|