punctilio 3.11.3 → 3.11.4
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 +4 -26
- 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 +0 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -39
- 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 +10 -58
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +5 -31
- package/src/constants.ts +13 -62
- package/src/dashes.ts +4 -27
- package/src/html.ts +1 -33
- package/src/index.ts +0 -40
- 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 +10 -60
package/src/prettier-plugin.ts
CHANGED
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prettier plugin applying punctilio's typographic transforms to Markdown.
|
|
3
|
-
*
|
|
4
|
-
* @example
|
|
5
|
-
* ```jsonc
|
|
6
|
-
* // .prettierrc
|
|
7
|
-
* { "plugins": ["punctilio/prettier-plugin"] }
|
|
8
|
-
*
|
|
9
|
-
* // .punctiliorc.json (optional)
|
|
10
|
-
* { "punctuationStyle": "british", "skipTags": ["pre", "code"] }
|
|
11
|
-
* ```
|
|
12
|
-
*
|
|
13
|
-
* @packageDocumentation
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
1
|
import { dirname } from "node:path"
|
|
17
2
|
|
|
18
3
|
import * as prettierMarkdown from "prettier/plugins/markdown"
|
|
@@ -30,11 +15,6 @@ async function loadPunctilioConfig(searchFrom: string): Promise<RemarkPunctilioO
|
|
|
30
15
|
return !result || result.isEmpty ? {} : (result.config as RemarkPunctilioOptions)
|
|
31
16
|
}
|
|
32
17
|
|
|
33
|
-
/**
|
|
34
|
-
* Runs `remarkPunctilio`'s transformer against an mdast tree. The plugin
|
|
35
|
-
* mutates the tree in place; awaiting handles any future async variant
|
|
36
|
-
* without churning callers.
|
|
37
|
-
*/
|
|
38
18
|
async function runRemarkPunctilio(opts: RemarkPunctilioOptions, ast: Root): Promise<void> {
|
|
39
19
|
// The unified `Transformer` signature requires a VFile and a callback,
|
|
40
20
|
// but `remarkPunctilio` only ever reads the tree. Narrow to the actual
|
package/src/quotes.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Smart quote transformation: straight quotes → curly quotes.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import escapeStringRegexp from "escape-string-regexp"
|
|
6
|
-
import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
|
|
1
|
+
import { cachedRegExp, DEFAULT_SEPARATOR, escapeStringRegexp, getEscapedSeparator, LATIN_LETTERS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
|
|
7
2
|
|
|
8
3
|
const {
|
|
9
4
|
EM_DASH,
|
|
@@ -14,15 +9,9 @@ const {
|
|
|
14
9
|
MODIFIER_LETTER_APOSTROPHE,
|
|
15
10
|
} = UNICODE_SYMBOLS
|
|
16
11
|
|
|
17
|
-
/** Joined string of all terminal punctuation characters for use in regex character classes. */
|
|
18
12
|
const TERMINAL_PUNCTUATION_CLASS = TERMINAL_PUNCTUATION.join("")
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
* Maximum consecutive closing quotes the punctuation-placement regexes will
|
|
22
|
-
* traverse in a single match (e.g., '". for two-deep nesting). Bounded to keep
|
|
23
|
-
* the regex linear on pathological inputs that contain many closing quotes
|
|
24
|
-
* without any following period or comma.
|
|
25
|
-
*/
|
|
14
|
+
// Bounded to keep punctuation-placement regexes linear on pathological inputs.
|
|
26
15
|
const MAX_NESTED_QUOTES = 4
|
|
27
16
|
|
|
28
17
|
export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"] as const
|
|
@@ -35,7 +24,6 @@ export interface QuoteOptions {
|
|
|
35
24
|
punctuationStyle?: PunctuationStyle
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
/** Convert straight single quotes to curly quotes and apostrophes */
|
|
39
27
|
function convertSingleQuotes(text: string, sep: string): string {
|
|
40
28
|
const escapedSep = getEscapedSeparator({ separator: sep })
|
|
41
29
|
|
|
@@ -86,11 +74,8 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
86
74
|
return text
|
|
87
75
|
}
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
* Tracks LSQ/RSQ balance left-to-right so that paired closing quotes
|
|
92
|
-
* (e.g., 'yes' where s precedes RSQ) remain RSQ.
|
|
93
|
-
*/
|
|
77
|
+
// Converts unmatched RSQ after s/S to MLA (plural possessives).
|
|
78
|
+
// Tracks LSQ/RSQ balance so paired closing quotes remain RSQ.
|
|
94
79
|
function convertUnmatchedPluralPossessives(text: string, sep: string): string {
|
|
95
80
|
let singleQuoteBalance = 0
|
|
96
81
|
return text.replace(
|
|
@@ -116,9 +101,6 @@ function convertUnmatchedPluralPossessives(text: string, sep: string): string {
|
|
|
116
101
|
)
|
|
117
102
|
}
|
|
118
103
|
|
|
119
|
-
/**
|
|
120
|
-
* Build the beginning-double-quote regex pattern from named fragments.
|
|
121
|
-
*/
|
|
122
104
|
function buildBeginningDoublePattern(escapedSep: string, rawEscSep: string): string {
|
|
123
105
|
// Consuming boundary (not a lookbehind): variable-width lookbehinds
|
|
124
106
|
// with alternation cause ReDoS. Re-emitted via $<boundary> in the replacement.
|
|
@@ -140,7 +122,6 @@ function buildBeginningDoublePattern(escapedSep: string, rawEscSep: string): str
|
|
|
140
122
|
return `${boundary}${beforeCapture}["](?:${afterConditions.join("|")})`
|
|
141
123
|
}
|
|
142
124
|
|
|
143
|
-
/** Convert straight double quotes to curly quotes */
|
|
144
125
|
function convertDoubleQuotes(text: string, sep: string): string {
|
|
145
126
|
const rawEscSep = escapeStringRegexp(sep)
|
|
146
127
|
const escapedSep = getEscapedSeparator({ separator: sep })
|
|
@@ -166,18 +147,8 @@ function convertDoubleQuotes(text: string, sep: string): string {
|
|
|
166
147
|
}
|
|
167
148
|
|
|
168
149
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* Shape: `<non-terminal>(sepBefore)(quotes)(sepAfter)<punctuation>`
|
|
173
|
-
*
|
|
174
|
-
* - The lookbehind ensures the char just before the match isn't already terminal
|
|
175
|
-
* punctuation (so we don't rewrite, e.g., "Stop!".).
|
|
176
|
-
* - `quotes` matches 1 to MAX_NESTED_QUOTES closing quotes (possibly separated
|
|
177
|
-
* by element-boundary markers). The bound keeps the regex linear on
|
|
178
|
-
* pathological inputs — unbounded `*` causes catastrophic backtracking when
|
|
179
|
-
* many closing quotes appear with no following punctuation.
|
|
180
|
-
* - `punctuationPattern` is the trailing pattern (e.g. `,` or `(?!\.\.\.)\.`).
|
|
150
|
+
* Matches punctuation OUTSIDE consecutive closing quotes (American style).
|
|
151
|
+
* `quotes` is bounded to MAX_NESTED_QUOTES to prevent catastrophic backtracking.
|
|
181
152
|
*/
|
|
182
153
|
function buildOutsidePunctuationRegex(escapedSep: string, punctuationPattern: string): RegExp {
|
|
183
154
|
const closingQuoteClass = `[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}]`
|
|
@@ -192,10 +163,6 @@ function buildOutsidePunctuationRegex(escapedSep: string, punctuationPattern: st
|
|
|
192
163
|
return cachedRegExp(pattern, "g")
|
|
193
164
|
}
|
|
194
165
|
|
|
195
|
-
/**
|
|
196
|
-
* Move a punctuation mark sitting outside one or more consecutive closing
|
|
197
|
-
* quotes to the inside (American style): `Hello'".` → `Hello.'"`.
|
|
198
|
-
*/
|
|
199
166
|
function moveOutsideToInside(
|
|
200
167
|
text: string,
|
|
201
168
|
escapedSep: string,
|
|
@@ -206,7 +173,6 @@ function moveOutsideToInside(
|
|
|
206
173
|
return text.replace(regex, `$<sepBefore>${replacementChar}$<quotes>$<sepAfter>`)
|
|
207
174
|
}
|
|
208
175
|
|
|
209
|
-
/** Apply American or British punctuation style */
|
|
210
176
|
function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyle): string {
|
|
211
177
|
const escapedSep = getEscapedSeparator({ separator: sep })
|
|
212
178
|
|
|
@@ -238,11 +204,8 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
|
|
|
238
204
|
return text
|
|
239
205
|
}
|
|
240
206
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
* Tracks „..." (U+201E/U+201C) and ‚...' (U+201A/U+2018) pairs so that
|
|
244
|
-
* the ambiguous closer characters are mapped to the correct American equivalents.
|
|
245
|
-
*/
|
|
207
|
+
// Normalize German quotes back to American for idempotent re-processing.
|
|
208
|
+
// Tracks „..." and ‚...' pairs so ambiguous closers map correctly.
|
|
246
209
|
function normalizeGermanQuotes(text: string): string {
|
|
247
210
|
const DLQ = UNICODE_SYMBOLS.DOUBLE_LOW_9_QUOTE
|
|
248
211
|
const SLQ = UNICODE_SYMBOLS.SINGLE_LOW_9_QUOTE
|
|
@@ -280,7 +243,6 @@ function normalizeGermanQuotes(text: string): string {
|
|
|
280
243
|
return chars.join("")
|
|
281
244
|
}
|
|
282
245
|
|
|
283
|
-
/** Remap American curly quotes to German low-9 style. Safe because apostrophes are still MLA at this stage. */
|
|
284
246
|
function applyGermanQuotes(text: string): string {
|
|
285
247
|
return text
|
|
286
248
|
.replaceAll(LEFT_DOUBLE_QUOTE, UNICODE_SYMBOLS.DOUBLE_LOW_9_QUOTE)
|
|
@@ -289,11 +251,8 @@ function applyGermanQuotes(text: string): string {
|
|
|
289
251
|
.replaceAll(RIGHT_SINGLE_QUOTE, LEFT_SINGLE_QUOTE)
|
|
290
252
|
}
|
|
291
253
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
* Strips either NBSP or NNBSP padding — older outputs used NBSP before the
|
|
295
|
-
* fix to the Unicode CLDR / Imprimerie nationale prescription of NNBSP.
|
|
296
|
-
*/
|
|
254
|
+
// Normalize French guillemets back to American for idempotent re-processing.
|
|
255
|
+
// Strips either NBSP or NNBSP padding (older outputs used NBSP).
|
|
297
256
|
function normalizeFrenchQuotes(text: string): string {
|
|
298
257
|
const innerSpace = `[${UNICODE_SYMBOLS.NBSP}${UNICODE_SYMBOLS.NNBSP}]`
|
|
299
258
|
return text
|
|
@@ -301,12 +260,7 @@ function normalizeFrenchQuotes(text: string): string {
|
|
|
301
260
|
.replace(cachedRegExp(`${innerSpace}?${UNICODE_SYMBOLS.RIGHT_GUILLEMET}`, "g"), RIGHT_DOUBLE_QUOTE)
|
|
302
261
|
}
|
|
303
262
|
|
|
304
|
-
|
|
305
|
-
* Remap American curly double quotes to French guillemets with NNBSP padding.
|
|
306
|
-
* Uses U+202F (NARROW NO-BREAK SPACE) per Unicode CLDR fr locale and
|
|
307
|
-
* Imprimerie nationale's "Lexique des règles typographiques" (6th ed., 2002),
|
|
308
|
-
* which prescribe a narrow non-breaking space — not U+00A0 — inside `« … »`.
|
|
309
|
-
*/
|
|
263
|
+
// Uses U+202F (NARROW NO-BREAK SPACE) per Unicode CLDR and Imprimerie nationale.
|
|
310
264
|
function applyFrenchQuotes(text: string): string {
|
|
311
265
|
return text
|
|
312
266
|
.replaceAll(LEFT_DOUBLE_QUOTE, `${UNICODE_SYMBOLS.LEFT_GUILLEMET}${UNICODE_SYMBOLS.NNBSP}`)
|
|
@@ -318,13 +272,11 @@ interface LocaleQuoteTransform {
|
|
|
318
272
|
apply: (text: string) => string
|
|
319
273
|
}
|
|
320
274
|
|
|
321
|
-
/** Locale-specific normalize (pre-pipeline) and apply (post-pipeline) functions. */
|
|
322
275
|
const localeQuoteTransforms: Partial<Record<PunctuationStyle, LocaleQuoteTransform>> = {
|
|
323
276
|
german: { normalize: normalizeGermanQuotes, apply: applyGermanQuotes },
|
|
324
277
|
french: { normalize: normalizeFrenchQuotes, apply: applyFrenchQuotes },
|
|
325
278
|
}
|
|
326
279
|
|
|
327
|
-
/** Shared quote-processing pipeline. Returns text with MLA for apostrophes. */
|
|
328
280
|
function processQuotes(text: string, options: QuoteOptions): string {
|
|
329
281
|
const sep = options.separator ?? DEFAULT_SEPARATOR
|
|
330
282
|
const punctuationStyle = options.punctuationStyle ?? "american"
|
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> {
|