punctilio 3.7.3 → 3.7.5
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 +28 -28
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +2 -1
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +5 -20
- package/dist/rehype.js.map +1 -1
- package/dist/symbols.d.ts +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +13 -6
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -0
- package/dist/utils.js.map +1 -1
- package/package.json +5 -4
- package/src/dashes.ts +2 -1
- package/src/index.ts +3 -1
- package/src/rehype.ts +5 -24
- package/src/symbols.ts +12 -6
- package/src/utils.ts +1 -0
- package/tsconfig.json +1 -1
- package/dist/tests/test-helpers.d.ts +0 -33
- package/dist/tests/test-helpers.d.ts.map +0 -1
- package/dist/tests/test-helpers.js +0 -109
- package/dist/tests/test-helpers.js.map +0 -1
- package/src/tests/dashes.test.ts +0 -736
- package/src/tests/index.test.ts +0 -713
- package/src/tests/markdown.test.ts +0 -156
- package/src/tests/nbsp.test.ts +0 -272
- package/src/tests/quotes.test.ts +0 -783
- package/src/tests/rehype.test.ts +0 -701
- package/src/tests/remark.test.ts +0 -200
- package/src/tests/symbols.test.ts +0 -920
- package/src/tests/test-helpers.ts +0 -124
- package/src/tests/utils.test.ts +0 -151
package/src/tests/rehype.test.ts
DELETED
|
@@ -1,701 +0,0 @@
|
|
|
1
|
-
import type { Element, Parent, Root, Text, ElementContent } from "hast"
|
|
2
|
-
import { h } from "hastscript"
|
|
3
|
-
import { unified } from "unified"
|
|
4
|
-
import rehypeParse from "rehype-parse"
|
|
5
|
-
import rehypeStringify from "rehype-stringify"
|
|
6
|
-
import {
|
|
7
|
-
rehypePunctilio,
|
|
8
|
-
type RehypePunctilioOptions,
|
|
9
|
-
transformElement,
|
|
10
|
-
flattenTextNodes,
|
|
11
|
-
getTextContent,
|
|
12
|
-
getFirstTextNode,
|
|
13
|
-
assertSmartQuotesMatch,
|
|
14
|
-
collectTransformableElements,
|
|
15
|
-
} from "../rehype.js"
|
|
16
|
-
import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
|
|
17
|
-
|
|
18
|
-
const {
|
|
19
|
-
LEFT_DOUBLE_QUOTE: LDQ,
|
|
20
|
-
RIGHT_DOUBLE_QUOTE: RDQ,
|
|
21
|
-
RIGHT_SINGLE_QUOTE: RSQ,
|
|
22
|
-
EM_DASH,
|
|
23
|
-
EN_DASH,
|
|
24
|
-
ELLIPSIS,
|
|
25
|
-
MULTIPLICATION,
|
|
26
|
-
NOT_EQUAL,
|
|
27
|
-
COPYRIGHT,
|
|
28
|
-
FRACTION_1_2,
|
|
29
|
-
} = UNICODE_SYMBOLS
|
|
30
|
-
|
|
31
|
-
async function processHtml(html: string, options?: RehypePunctilioOptions): Promise<string> {
|
|
32
|
-
const result = await unified()
|
|
33
|
-
.use(rehypeParse, { fragment: true })
|
|
34
|
-
.use(rehypePunctilio, options)
|
|
35
|
-
.use(rehypeStringify)
|
|
36
|
-
.process(html)
|
|
37
|
-
return String(result)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe("rehypePunctilio", () => {
|
|
41
|
-
describe("basic transformations", () => {
|
|
42
|
-
it.each([
|
|
43
|
-
["quotes", '<p>"Hello," she said.</p>', `<p>${LDQ}Hello,${RDQ} she said.</p>`],
|
|
44
|
-
["apostrophes", "<p>It's a test.</p>", `<p>It${RSQ}s a test.</p>`],
|
|
45
|
-
["em dashes", "<p>Wait -- here it comes.</p>", `<p>Wait${EM_DASH}here it comes.</p>`],
|
|
46
|
-
["en dashes", "<p>Pages 1-5</p>", `<p>Pages 1${EN_DASH}5</p>`],
|
|
47
|
-
["ellipses", "<p>Wait...</p>", `<p>Wait${ELLIPSIS}</p>`],
|
|
48
|
-
["multiplication", "<p>5x5</p>", `<p>5${MULTIPLICATION}5</p>`],
|
|
49
|
-
["math symbols", "<p>x != y</p>", `<p>x ${NOT_EQUAL} y</p>`],
|
|
50
|
-
["legal symbols", "<p>(c) 2024</p>", `<p>${COPYRIGHT} 2024</p>`],
|
|
51
|
-
])("transforms %s", async (_name, html, expected) => {
|
|
52
|
-
expect(await processHtml(html, { nbsp: false })).toEqual(expected)
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe("HTML structure preservation", () => {
|
|
57
|
-
it.each([
|
|
58
|
-
["nested elements", '<p><em>"Hello,"</em> she said.</p>', `<p><em>${LDQ}Hello,${RDQ}</em> she said.</p>`],
|
|
59
|
-
["text spanning elements", '<p><em>"Wait</em>..."</p>', `<p><em>${LDQ}Wait</em>${ELLIPSIS}${RDQ}</p>`],
|
|
60
|
-
["deeply nested", '<div><p><span><strong>"Hello"</strong></span></p></div>', `<div><p><span><strong>${LDQ}Hello${RDQ}</strong></span></p></div>`],
|
|
61
|
-
["attributes", '<p class="intro" id="first">"Hello"</p>', `<p class="intro" id="first">${LDQ}Hello${RDQ}</p>`],
|
|
62
|
-
// Regression: inline-only containers must share transformation context
|
|
63
|
-
["quotes across inline-only children", '<p><em>"Hello</em><span>, world"</span></p>', `<p><em>${LDQ}Hello</em><span>, world${RDQ}</span></p>`],
|
|
64
|
-
["dash across inline-only children", '<p><em>pages 1</em><span>-5</span></p>', `<p><em>pages 1</em><span>${EN_DASH}5</span></p>`],
|
|
65
|
-
])("preserves %s", async (_name, html, expected) => {
|
|
66
|
-
expect(await processHtml(html, { nbsp: false })).toEqual(expected)
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
describe("dashes across element boundaries", () => {
|
|
71
|
-
it.each([
|
|
72
|
-
["multi-segment number preserved", "<p>1-<em>2</em>-3</p>", "<p>1-<em>2</em>-3</p>"],
|
|
73
|
-
["model name preserved", "<p><em>GPT</em>-3</p>", "<p><em>GPT</em>-3</p>"],
|
|
74
|
-
["simple range still converts", "<p>pages <em>1</em>-5</p>", `<p>pages <em>1</em>${EN_DASH}5</p>`],
|
|
75
|
-
["genuine negative at element start", "<p><em>-5</em> degrees</p>", `<p><em>${UNICODE_SYMBOLS.MINUS}5</em> degrees</p>`],
|
|
76
|
-
])("%s", async (_name, html, expected) => {
|
|
77
|
-
expect(await processHtml(html, { nbsp: false })).toEqual(expected)
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
describe("skipped elements", () => {
|
|
82
|
-
it.each(["code", "pre", "script", "style", "kbd", "var", "samp"])(
|
|
83
|
-
"skips %s elements",
|
|
84
|
-
async (tag) => {
|
|
85
|
-
expect(await processHtml(`<${tag}>"Hello"</${tag}>`)).toEqual(`<${tag}>"Hello"</${tag}>`)
|
|
86
|
-
}
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
it("skips nested content inside code blocks", async () => {
|
|
90
|
-
expect(await processHtml('<pre><code>"Hello" -- test...</code></pre>')).toEqual(
|
|
91
|
-
'<pre><code>"Hello" -- test...</code></pre>'
|
|
92
|
-
)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it("transforms siblings of skipped elements", async () => {
|
|
96
|
-
expect(await processHtml('<p><code>code</code> "Hello"</p>')).toEqual(
|
|
97
|
-
`<p><code>code</code> ${LDQ}Hello${RDQ}</p>`
|
|
98
|
-
)
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
describe("custom skip options", () => {
|
|
103
|
-
it.each([
|
|
104
|
-
["custom tags", '<custom-tag>"Hello"</custom-tag>', { skipTags: ["custom-tag"] }, '<custom-tag>"Hello"</custom-tag>'],
|
|
105
|
-
["custom classes", '<p class="no-transform">"Hello"</p>', { skipClasses: ["no-transform"] }, '<p class="no-transform">"Hello"</p>'],
|
|
106
|
-
["descendants of skip classes", '<div class="raw"><p>"Hello"</p></div>', { skipClasses: ["raw"] }, '<div class="raw"><p>"Hello"</p></div>'],
|
|
107
|
-
])("skips %s", async (_name, html, options, expected) => {
|
|
108
|
-
expect(await processHtml(html, options)).toEqual(expected)
|
|
109
|
-
})
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
describe("transform options passthrough", () => {
|
|
113
|
-
it.each([
|
|
114
|
-
["punctuationStyle american", '<p>"Hello."</p>', { punctuationStyle: "american" as const, nbsp: false as const }, `<p>${LDQ}Hello.${RDQ}</p>`],
|
|
115
|
-
["punctuationStyle british", '<p>"Hello."</p>', { punctuationStyle: "british" as const, nbsp: false as const }, `<p>${LDQ}Hello${RDQ}.</p>`],
|
|
116
|
-
["dashStyle american", "<p>word - word</p>", { dashStyle: "american" as const, nbsp: false as const }, `<p>word${EM_DASH}word</p>`],
|
|
117
|
-
["dashStyle british", "<p>word - word</p>", { dashStyle: "british" as const, nbsp: false as const }, `<p>word ${EN_DASH} word</p>`],
|
|
118
|
-
["symbols enabled", "<p>5x5</p>", { symbols: true, nbsp: false as const }, `<p>5${MULTIPLICATION}5</p>`],
|
|
119
|
-
["symbols disabled", "<p>5x5</p>", { symbols: false, nbsp: false as const }, "<p>5x5</p>"],
|
|
120
|
-
["fractions enabled", "<p>1/2 cup</p>", { fractions: true, nbsp: false as const }, `<p>${FRACTION_1_2} cup</p>`],
|
|
121
|
-
["fractions disabled", "<p>1/2 cup</p>", { fractions: false, nbsp: false as const }, "<p>1/2 cup</p>"],
|
|
122
|
-
["custom separator", '<p>"Hello"</p>', { separator: "\uE001", nbsp: false as const }, `<p>${LDQ}Hello${RDQ}</p>`],
|
|
123
|
-
])("respects %s", async (_name, html, options, expected) => {
|
|
124
|
-
expect(await processHtml(html, options)).toEqual(expected)
|
|
125
|
-
})
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
describe("complex scenarios", () => {
|
|
129
|
-
it.each([
|
|
130
|
-
[
|
|
131
|
-
"article content",
|
|
132
|
-
`<article><h1>"Title's"</h1><p>"quotes" -- dashes</p><p>Pages 1-5</p></article>`,
|
|
133
|
-
`<article><h1>${LDQ}Title${RSQ}s${RDQ}</h1><p>${LDQ}quotes${RDQ}${EM_DASH}dashes</p><p>Pages 1${EN_DASH}5</p></article>`,
|
|
134
|
-
],
|
|
135
|
-
[
|
|
136
|
-
"mixed code and prose",
|
|
137
|
-
'<p>The function <code>getValue()</code> returns "the value" -- useful.</p>',
|
|
138
|
-
`<p>The function <code>getValue()</code> returns ${LDQ}the value${RDQ}${EM_DASH}useful.</p>`,
|
|
139
|
-
],
|
|
140
|
-
[
|
|
141
|
-
"table content",
|
|
142
|
-
'<table><tr><th>"Header"</th><td>Pages 1-5</td></tr></table>',
|
|
143
|
-
`<table><tbody><tr><th>${LDQ}Header${RDQ}</th><td>Pages 1${EN_DASH}5</td></tr></tbody></table>`,
|
|
144
|
-
],
|
|
145
|
-
[
|
|
146
|
-
"list content",
|
|
147
|
-
'<ul><li>"First" -- important</li><li>Pages 1-5</li></ul>',
|
|
148
|
-
`<ul><li>${LDQ}First${RDQ}${EM_DASH}important</li><li>Pages 1${EN_DASH}5</li></ul>`,
|
|
149
|
-
],
|
|
150
|
-
])("handles %s", async (_name, html, expected) => {
|
|
151
|
-
expect(await processHtml(html, { nbsp: false })).toEqual(expected)
|
|
152
|
-
})
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
describe("edge cases", () => {
|
|
156
|
-
it.each([
|
|
157
|
-
["empty paragraphs", "<p></p>", "<p></p>"],
|
|
158
|
-
["whitespace-only", "<p> </p>", "<p> </p>"],
|
|
159
|
-
["special characters", "<p>Café & résumé</p>", "<p>Café & résumé</p>"],
|
|
160
|
-
["emoji", '<p>"Hello 👋"</p>', `<p>${LDQ}Hello 👋${RDQ}</p>`],
|
|
161
|
-
["links", '<p><a href="https://example.com">"Link"</a></p>', `<p><a href="https://example.com">${LDQ}Link${RDQ}</a></p>`],
|
|
162
|
-
])("handles %s", async (_name, html, expected) => {
|
|
163
|
-
expect(await processHtml(html, { nbsp: false })).toEqual(expected)
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it("is idempotent", async () => {
|
|
167
|
-
const html = '<p>"Hello," she said -- "it\'s nice."</p>'
|
|
168
|
-
const firstPass = await processHtml(html)
|
|
169
|
-
const secondPass = await processHtml(firstPass)
|
|
170
|
-
expect(secondPass).toBe(firstPass)
|
|
171
|
-
})
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
describe("default export", () => {
|
|
175
|
-
it("exports rehypePunctilio as default", async () => {
|
|
176
|
-
const { default: defaultExport } = await import("../rehype.js")
|
|
177
|
-
expect(defaultExport).toBe(rehypePunctilio)
|
|
178
|
-
})
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
describe("exported utilities", () => {
|
|
182
|
-
const ignoreNone = () => false
|
|
183
|
-
const ignoreCode = (el: Element) => el.tagName === "code"
|
|
184
|
-
|
|
185
|
-
const fixtures = {
|
|
186
|
-
empty: h("div", []) as Element,
|
|
187
|
-
simple: h("p", "Hello, world!") as Element,
|
|
188
|
-
nested: h("div", ["This is ", h("em", "emphasized"), " text."]) as Element,
|
|
189
|
-
withCode: h("div", ["This is ", h("code", "ignored"), " text."]) as Element,
|
|
190
|
-
emptyAndComment: h("div", [
|
|
191
|
-
h("span"),
|
|
192
|
-
{ type: "comment", value: "comment" } as unknown as ElementContent,
|
|
193
|
-
]) as Element,
|
|
194
|
-
deeplyNested: h("div", [
|
|
195
|
-
"Level 1 ",
|
|
196
|
-
h("span", ["Level 2 ", h("em", "Level 3")]),
|
|
197
|
-
" End",
|
|
198
|
-
]) as Element,
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
describe("flattenTextNodes", () => {
|
|
202
|
-
it.each([
|
|
203
|
-
["empty element", fixtures.empty, ignoreNone, []],
|
|
204
|
-
["simple text", fixtures.simple, ignoreNone, [{ type: "text", value: "Hello, world!" }]],
|
|
205
|
-
["nested elements", fixtures.nested, ignoreNone, [
|
|
206
|
-
{ type: "text", value: "This is " },
|
|
207
|
-
{ type: "text", value: "emphasized" },
|
|
208
|
-
{ type: "text", value: " text." },
|
|
209
|
-
]],
|
|
210
|
-
["with ignored code", fixtures.withCode, ignoreCode, [
|
|
211
|
-
{ type: "text", value: "This is " },
|
|
212
|
-
{ type: "text", value: " text." },
|
|
213
|
-
]],
|
|
214
|
-
["empty spans and comments", fixtures.emptyAndComment, ignoreNone, []],
|
|
215
|
-
["deeply nested", fixtures.deeplyNested, ignoreNone, [
|
|
216
|
-
{ type: "text", value: "Level 1 " },
|
|
217
|
-
{ type: "text", value: "Level 2 " },
|
|
218
|
-
{ type: "text", value: "Level 3" },
|
|
219
|
-
{ type: "text", value: " End" },
|
|
220
|
-
]],
|
|
221
|
-
])("handles %s", (_name, node, skip, expected) => {
|
|
222
|
-
expect(flattenTextNodes(node, skip)).toEqual(expected)
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
it("extracts from single text node", () => {
|
|
226
|
-
const textNode: Text = { type: "text", value: "Hello" }
|
|
227
|
-
expect(flattenTextNodes(textNode, ignoreNone)).toEqual([textNode])
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
it("stops at max recursion depth", () => {
|
|
231
|
-
let deep: Element = h("span", "deep") as Element
|
|
232
|
-
for (let i = 0; i < 1005; i++) deep = h("div", [deep]) as Element
|
|
233
|
-
expect(flattenTextNodes(deep, ignoreNone)).toHaveLength(0)
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
describe("transformElement", () => {
|
|
238
|
-
const toUpper = (s: string) => s.toUpperCase()
|
|
239
|
-
|
|
240
|
-
it.each([
|
|
241
|
-
["simple element", h("p", "hello"), "HELLO"],
|
|
242
|
-
["custom separator", h("p", "hello"), "HELLO", "\uE001"],
|
|
243
|
-
])("transforms %s", (_name, element, expected, sep = DEFAULT_SEPARATOR) => {
|
|
244
|
-
transformElement(element as Element, toUpper, ignoreNone, sep)
|
|
245
|
-
expect((element.children[0] as Text).value).toBe(expected)
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
it("transforms across multiple nodes", () => {
|
|
249
|
-
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
250
|
-
transformElement(element, toUpper, ignoreNone, DEFAULT_SEPARATOR)
|
|
251
|
-
expect((element.children[0] as Text).value).toBe("HELLO ")
|
|
252
|
-
expect(((element.children[1] as Element).children[0] as Text).value).toBe("WORLD")
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
it("skips matching elements", () => {
|
|
256
|
-
const element = h("p", ["before ", h("code", "unchanged"), " after"]) as Element
|
|
257
|
-
transformElement(element, toUpper, ignoreCode, DEFAULT_SEPARATOR)
|
|
258
|
-
expect((element.children[0] as Text).value).toBe("BEFORE ")
|
|
259
|
-
expect(((element.children[1] as Element).children[0] as Text).value).toBe("unchanged")
|
|
260
|
-
expect((element.children[2] as Text).value).toBe(" AFTER")
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
it("applies custom transforms", () => {
|
|
264
|
-
const element = h("p", "pages 1-5") as Element
|
|
265
|
-
transformElement(element, (s) => s.replace(/-/g, EN_DASH), ignoreNone, DEFAULT_SEPARATOR)
|
|
266
|
-
expect((element.children[0] as Text).value).toBe(`pages 1${EN_DASH}5`)
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
it("handles missing children gracefully", () => {
|
|
270
|
-
const node = h("div") as Element
|
|
271
|
-
node.children = undefined as unknown as Element["children"]
|
|
272
|
-
expect(() => transformElement(node, toUpper, ignoreNone, DEFAULT_SEPARATOR)).not.toThrow()
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
it.each([
|
|
276
|
-
["injecting separators", h("p", "hello"), (s: string) => s.replace("hello", `hello${DEFAULT_SEPARATOR}injected`)],
|
|
277
|
-
["removing separators", h("p", ["hello ", h("em", "world")]), (s: string) => s.replace(DEFAULT_SEPARATOR, "")],
|
|
278
|
-
])("throws on %s", (_name, element, transform) => {
|
|
279
|
-
expect(() => transformElement(element as Element, transform, () => false, DEFAULT_SEPARATOR)).toThrow(
|
|
280
|
-
"Transformation altered the number of text nodes"
|
|
281
|
-
)
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
it("passes invariance check for marker-transparent transforms", () => {
|
|
285
|
-
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
286
|
-
expect(() =>
|
|
287
|
-
transformElement(element, toUpper, ignoreNone, DEFAULT_SEPARATOR, true)
|
|
288
|
-
).not.toThrow()
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
it("throws on invariance check failure", () => {
|
|
292
|
-
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
293
|
-
// This transform interacts with the separator: it replaces char before separator
|
|
294
|
-
const badTransform = (s: string) => s.replace(new RegExp(` ${DEFAULT_SEPARATOR}`, "g"), `X${DEFAULT_SEPARATOR}`)
|
|
295
|
-
expect(() =>
|
|
296
|
-
transformElement(element, badTransform, ignoreNone, DEFAULT_SEPARATOR, true)
|
|
297
|
-
).toThrow("Transform invariance check failed")
|
|
298
|
-
})
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
describe("shouldSkipText", () => {
|
|
302
|
-
const toUpper = (s: string) => s.toUpperCase()
|
|
303
|
-
|
|
304
|
-
it("is not called for text inside elements excluded by shouldSkip", () => {
|
|
305
|
-
const element = h("p", ["before ", h("code", "unchanged"), " after"]) as Element
|
|
306
|
-
const visitedValues: string[] = []
|
|
307
|
-
transformElement(element, toUpper, ignoreCode, DEFAULT_SEPARATOR, false, {
|
|
308
|
-
shouldSkipText: (textNode) => {
|
|
309
|
-
visitedValues.push(textNode.value)
|
|
310
|
-
return false
|
|
311
|
-
},
|
|
312
|
-
})
|
|
313
|
-
// Only "before " and " after" are visited — the code child short-circuits.
|
|
314
|
-
expect(visitedValues).toEqual(["before ", " after"])
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
it("leaves a skipped text node's value untouched", () => {
|
|
318
|
-
const element = h("p", "hello") as Element
|
|
319
|
-
transformElement(
|
|
320
|
-
element, toUpper, ignoreNone, DEFAULT_SEPARATOR, false,
|
|
321
|
-
{ shouldSkipText: () => true },
|
|
322
|
-
)
|
|
323
|
-
expect((element.children[0] as Text).value).toBe("hello")
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
it("passes ancestors root-first, nearest-last", () => {
|
|
327
|
-
const tree = h("div", [h("p", [h("em", "inner")])]) as Element
|
|
328
|
-
const captured: string[][] = []
|
|
329
|
-
flattenTextNodes(tree, ignoreNone, {
|
|
330
|
-
shouldSkipText: (_textNode, ancestors) => {
|
|
331
|
-
captured.push(ancestors.map((a) => a.tagName))
|
|
332
|
-
return false
|
|
333
|
-
},
|
|
334
|
-
})
|
|
335
|
-
expect(captured).toEqual([["div", "p", "em"]])
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
it("hands out a snapshot of ancestors, not the live mutable array", () => {
|
|
339
|
-
// If the walker handed out the shared live array, the captured
|
|
340
|
-
// reference would be empty (or partially popped) by the time we
|
|
341
|
-
// inspect it after the walk completes.
|
|
342
|
-
const tree = h("div", [h("p", [h("em", "inner")])]) as Element
|
|
343
|
-
let captured: readonly Element[] | null = null
|
|
344
|
-
flattenTextNodes(tree, ignoreNone, {
|
|
345
|
-
shouldSkipText: (_textNode, ancestors) => {
|
|
346
|
-
captured = ancestors
|
|
347
|
-
return false
|
|
348
|
-
},
|
|
349
|
-
})
|
|
350
|
-
expect(captured).not.toBeNull()
|
|
351
|
-
expect(captured!.map((a) => a.tagName)).toEqual(["div", "p", "em"])
|
|
352
|
-
})
|
|
353
|
-
|
|
354
|
-
it("gives each text node its own ancestor chain across the same walk", () => {
|
|
355
|
-
// shallow text under <div>, deep text under <div><p><em>; the chains
|
|
356
|
-
// must differ. A bug that forgets to pop or shares the live array
|
|
357
|
-
// would conflate them.
|
|
358
|
-
const tree = h("div", [
|
|
359
|
-
"shallow",
|
|
360
|
-
h("p", [h("em", "deep")]),
|
|
361
|
-
]) as Element
|
|
362
|
-
const captured: Record<string, string[]> = {}
|
|
363
|
-
flattenTextNodes(tree, ignoreNone, {
|
|
364
|
-
shouldSkipText: (textNode, ancestors) => {
|
|
365
|
-
captured[textNode.value] = ancestors.map((a) => a.tagName)
|
|
366
|
-
return false
|
|
367
|
-
},
|
|
368
|
-
})
|
|
369
|
-
expect(captured).toEqual({
|
|
370
|
-
shallow: ["div"],
|
|
371
|
-
deep: ["div", "p", "em"],
|
|
372
|
-
})
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
it("transforms accepted text while leaving rejected text literal", () => {
|
|
376
|
-
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
377
|
-
transformElement(
|
|
378
|
-
element, toUpper, ignoreNone, DEFAULT_SEPARATOR, false,
|
|
379
|
-
{ shouldSkipText: (textNode) => textNode.value === "hello " },
|
|
380
|
-
)
|
|
381
|
-
expect((element.children[0] as Text).value).toBe("hello ")
|
|
382
|
-
expect(((element.children[1] as Element).children[0] as Text).value).toBe("WORLD")
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
it("invariance check passes when some text nodes are skipped", () => {
|
|
386
|
-
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
387
|
-
expect(() =>
|
|
388
|
-
transformElement(
|
|
389
|
-
element, toUpper, ignoreNone, DEFAULT_SEPARATOR, true,
|
|
390
|
-
{ shouldSkipText: (textNode) => textNode.value === "hello " },
|
|
391
|
-
)
|
|
392
|
-
).not.toThrow()
|
|
393
|
-
expect((element.children[0] as Text).value).toBe("hello ")
|
|
394
|
-
expect(((element.children[1] as Element).children[0] as Text).value).toBe("WORLD")
|
|
395
|
-
})
|
|
396
|
-
|
|
397
|
-
it("plugin option skips text matching predicate end-to-end", async () => {
|
|
398
|
-
const html = '<p>Visit <a href="https://example.com/x">https://example.com/x</a> now.</p>'
|
|
399
|
-
const result = await processHtml(html, {
|
|
400
|
-
nbsp: false,
|
|
401
|
-
shouldSkipText: (textNode, ancestors) => {
|
|
402
|
-
const parent = ancestors[ancestors.length - 1]
|
|
403
|
-
if (parent?.tagName !== "a") return false
|
|
404
|
-
const href = parent.properties?.href
|
|
405
|
-
return typeof href === "string" && href === textNode.value
|
|
406
|
-
},
|
|
407
|
-
})
|
|
408
|
-
// The anchor text is preserved literally (slashes unchanged); surrounding
|
|
409
|
-
// text is transformed (nothing to change here, but no crash).
|
|
410
|
-
expect(result).toBe('<p>Visit <a href="https://example.com/x">https://example.com/x</a> now.</p>')
|
|
411
|
-
})
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
describe("getTextContent", () => {
|
|
415
|
-
it.each([
|
|
416
|
-
["simple text", fixtures.simple, ignoreNone, "Hello, world!"],
|
|
417
|
-
["nested elements", fixtures.nested, ignoreNone, "This is emphasized text."],
|
|
418
|
-
["with ignored code", fixtures.withCode, ignoreCode, "This is text."],
|
|
419
|
-
["empty element", fixtures.empty, ignoreNone, ""],
|
|
420
|
-
["deeply nested", fixtures.deeplyNested, ignoreNone, "Level 1 Level 2 Level 3 End"],
|
|
421
|
-
])("extracts from %s", (_name, node, skip, expected) => {
|
|
422
|
-
expect(getTextContent(node, skip)).toBe(expected)
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
it("uses default shouldSkip when not provided", () => {
|
|
426
|
-
expect(getTextContent(fixtures.simple)).toBe("Hello, world!")
|
|
427
|
-
})
|
|
428
|
-
})
|
|
429
|
-
|
|
430
|
-
describe("getFirstTextNode", () => {
|
|
431
|
-
it.each([
|
|
432
|
-
["simple text", fixtures.simple, "Hello, world!"],
|
|
433
|
-
["nested", fixtures.nested, "This is "],
|
|
434
|
-
["deeply nested", fixtures.deeplyNested, "Level 1 "],
|
|
435
|
-
])("finds first text in %s", (_name, node, expectedValue) => {
|
|
436
|
-
const result = getFirstTextNode(node as Parent)
|
|
437
|
-
expect(result).not.toBeNull()
|
|
438
|
-
expect(result!.value).toBe(expectedValue)
|
|
439
|
-
})
|
|
440
|
-
|
|
441
|
-
it("returns null for empty element", () => {
|
|
442
|
-
expect(getFirstTextNode(fixtures.empty as Parent)).toBeNull()
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
it("returns text node directly", () => {
|
|
446
|
-
const textNode: Text = { type: "text", value: "Direct" }
|
|
447
|
-
expect(getFirstTextNode(textNode as unknown as Parent)).toBe(textNode)
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
it("returns null for null input", () => {
|
|
451
|
-
expect(getFirstTextNode(null as unknown as Parent)).toBeNull()
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
it("returns null for comments-only element", () => {
|
|
455
|
-
const node = h("div", [
|
|
456
|
-
{ type: "comment", value: "comment" } as unknown as ElementContent,
|
|
457
|
-
]) as Element
|
|
458
|
-
expect(getFirstTextNode(node as Parent)).toBeNull()
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
it("stops at max recursion depth", () => {
|
|
462
|
-
let deep: Element = h("span", "deep") as Element
|
|
463
|
-
for (let i = 0; i < 1005; i++) deep = h("div", [deep]) as Element
|
|
464
|
-
expect(getFirstTextNode(deep as Parent)).toBeNull()
|
|
465
|
-
})
|
|
466
|
-
})
|
|
467
|
-
|
|
468
|
-
describe("assertSmartQuotesMatch", () => {
|
|
469
|
-
it.each([
|
|
470
|
-
["matched double quotes", `${LDQ}Hello${RDQ}`],
|
|
471
|
-
["nested quotes", `${LDQ}Outer ${LDQ}inner${RDQ} outer${RDQ}`],
|
|
472
|
-
["empty string", ""],
|
|
473
|
-
["no quotes", "Hello, world!"],
|
|
474
|
-
])("passes for %s", (_name, input) => {
|
|
475
|
-
expect(() => assertSmartQuotesMatch(input)).not.toThrow()
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
it.each([
|
|
479
|
-
["unmatched opening", `${LDQ}Hello`],
|
|
480
|
-
["unmatched closing", `Hello${RDQ}`],
|
|
481
|
-
["extra opening", `${LDQ}${LDQ}Hello${RDQ}`],
|
|
482
|
-
["reversed quotes", `${RDQ}Hello${LDQ}`],
|
|
483
|
-
])("throws for %s", (_name, input) => {
|
|
484
|
-
expect(() => assertSmartQuotesMatch(input)).toThrow("Mismatched quotes")
|
|
485
|
-
})
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
describe("collectTransformableElements", () => {
|
|
489
|
-
it("collects elements with direct text children", () => {
|
|
490
|
-
const tree = h("div", [
|
|
491
|
-
h("p", "First paragraph"),
|
|
492
|
-
h("div", [h("p", "Nested paragraph")]),
|
|
493
|
-
]) as Element
|
|
494
|
-
const result = collectTransformableElements(tree, ignoreNone)
|
|
495
|
-
expect(result).toHaveLength(2)
|
|
496
|
-
expect(result[0].tagName).toBe("p")
|
|
497
|
-
expect(result[1].tagName).toBe("p")
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
it("skips elements matching shouldSkip", () => {
|
|
501
|
-
const tree = h("div", [
|
|
502
|
-
h("p", "Normal"),
|
|
503
|
-
h("code", "Skipped"),
|
|
504
|
-
]) as Element
|
|
505
|
-
const result = collectTransformableElements(tree, ignoreCode)
|
|
506
|
-
expect(result).toHaveLength(1)
|
|
507
|
-
expect(result[0].tagName).toBe("p")
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
it("returns empty for skipped root", () => {
|
|
511
|
-
const tree = h("code", "Skipped") as Element
|
|
512
|
-
expect(collectTransformableElements(tree, ignoreCode)).toHaveLength(0)
|
|
513
|
-
})
|
|
514
|
-
|
|
515
|
-
it("returns empty for elements without text children", () => {
|
|
516
|
-
const tree = h("div", [h("img")]) as Element
|
|
517
|
-
expect(collectTransformableElements(tree, ignoreNone)).toHaveLength(0)
|
|
518
|
-
})
|
|
519
|
-
|
|
520
|
-
it("collects phrasing container with only inline element children", () => {
|
|
521
|
-
// <p><em>"Hello</em><span>, world"</span></p>
|
|
522
|
-
// The <p> has no direct text children, but all content is inline.
|
|
523
|
-
// It should be collected as a single unit for cross-element context.
|
|
524
|
-
const tree = h("p", [
|
|
525
|
-
h("em", '"Hello'),
|
|
526
|
-
h("span", ', world"'),
|
|
527
|
-
]) as Element
|
|
528
|
-
const result = collectTransformableElements(tree, ignoreNone)
|
|
529
|
-
expect(result).toHaveLength(1)
|
|
530
|
-
expect(result[0].tagName).toBe("p")
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
it("recurses when element has block-level children", () => {
|
|
534
|
-
// <div><p>Hello</p><p>World</p></div> — block children mean
|
|
535
|
-
// each <p> should be independent, not merged.
|
|
536
|
-
const tree = h("div", [
|
|
537
|
-
h("p", "Hello"),
|
|
538
|
-
h("p", "World"),
|
|
539
|
-
]) as Element
|
|
540
|
-
const result = collectTransformableElements(tree, ignoreNone)
|
|
541
|
-
expect(result).toHaveLength(2)
|
|
542
|
-
expect(result[0].tagName).toBe("p")
|
|
543
|
-
expect(result[1].tagName).toBe("p")
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
it("collects inline-only div as single unit", () => {
|
|
547
|
-
// <div><span>Hello</span><em>World</em></div> — no block children
|
|
548
|
-
const tree = h("div", [
|
|
549
|
-
h("span", "Hello"),
|
|
550
|
-
h("em", "World"),
|
|
551
|
-
]) as Element
|
|
552
|
-
const result = collectTransformableElements(tree, ignoreNone)
|
|
553
|
-
expect(result).toHaveLength(1)
|
|
554
|
-
expect(result[0].tagName).toBe("div")
|
|
555
|
-
})
|
|
556
|
-
|
|
557
|
-
it("skips inline-only container when children are skip-tagged", () => {
|
|
558
|
-
const tree = h("p", [h("code", "skip me")]) as Element
|
|
559
|
-
expect(collectTransformableElements(tree, ignoreCode)).toHaveLength(0)
|
|
560
|
-
})
|
|
561
|
-
|
|
562
|
-
it("returns empty when only non-text, non-element children exist", () => {
|
|
563
|
-
const tree: Element = {
|
|
564
|
-
type: "element",
|
|
565
|
-
tagName: "p",
|
|
566
|
-
properties: {},
|
|
567
|
-
children: [{ type: "comment", value: "a comment" } as unknown as ElementContent],
|
|
568
|
-
}
|
|
569
|
-
expect(collectTransformableElements(tree, ignoreNone)).toHaveLength(0)
|
|
570
|
-
})
|
|
571
|
-
|
|
572
|
-
it("stops at max recursion depth", () => {
|
|
573
|
-
let deep: Element = h("p", "deep") as Element
|
|
574
|
-
for (let i = 0; i < 1100; i++) deep = h("div", [deep]) as Element
|
|
575
|
-
expect(collectTransformableElements(deep, ignoreNone)).toHaveLength(0)
|
|
576
|
-
})
|
|
577
|
-
})
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
describe("nbsp option", () => {
|
|
581
|
-
const NBSP = UNICODE_SYMBOLS.NBSP
|
|
582
|
-
|
|
583
|
-
it("inserts nbsp when option enabled", async () => {
|
|
584
|
-
const result = await processHtml('<p>Dr. Smith has 5 kg of items.</p>', { nbsp: true })
|
|
585
|
-
expect(result).toBe(`<p>Dr.${NBSP}Smith has 5${NBSP}kg${NBSP}of${NBSP}items.</p>`)
|
|
586
|
-
})
|
|
587
|
-
|
|
588
|
-
it("inserts nbsp by default", async () => {
|
|
589
|
-
const result = await processHtml('<p>Dr. Smith has 5 kg of items.</p>')
|
|
590
|
-
expect(result).toBe(`<p>Dr.${NBSP}Smith has 5${NBSP}kg${NBSP}of${NBSP}items.</p>`)
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
it("does not insert nbsp when option disabled", async () => {
|
|
594
|
-
const result = await processHtml('<p>Dr. Smith has 5 kg of items.</p>', { nbsp: false })
|
|
595
|
-
expect(result).not.toContain(NBSP)
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
it("works across element boundaries", async () => {
|
|
599
|
-
const result = await processHtml('<p>See <em>Fig.</em> 1</p>', { nbsp: true })
|
|
600
|
-
expect(result).toBe(`<p>See <em>Fig.</em>${NBSP}1</p>`)
|
|
601
|
-
})
|
|
602
|
-
})
|
|
603
|
-
|
|
604
|
-
describe("coverage edge cases", () => {
|
|
605
|
-
it.each([
|
|
606
|
-
["space-separated class string", '<p class="foo bar no-transform">"Hello"</p>', { skipClasses: ["no-transform"] }, '<p class="foo bar no-transform">"Hello"</p>'],
|
|
607
|
-
["HTML comments", '<p>"Hello"<!-- comment --></p>', {}, `<p>${LDQ}Hello${RDQ}<!-- comment --></p>`],
|
|
608
|
-
["non-text children only", '<div><img src="test.jpg" alt="test"></div>', {}, '<div><img src="test.jpg" alt="test"></div>'],
|
|
609
|
-
["skip class at root", '<p class="skip">"Hello"</p>', { skipClasses: ["skip"] }, '<p class="skip">"Hello"</p>'],
|
|
610
|
-
["nested skipped elements", '<pre><p>"Hello"</p></pre>', {}, '<pre><p>"Hello"</p></pre>'],
|
|
611
|
-
["multiple skip classes", '<p class="a b">"Hello"</p>', { skipClasses: ["b"] }, '<p class="a b">"Hello"</p>'],
|
|
612
|
-
])("handles %s", async (_name, html, options, expected) => {
|
|
613
|
-
expect(await processHtml(html, options)).toEqual(expected)
|
|
614
|
-
})
|
|
615
|
-
|
|
616
|
-
it("handles skipped child within non-skipped parent", async () => {
|
|
617
|
-
expect(await processHtml('<div>"Before" <code>"Inside"</code> "After"</div>', { nbsp: false })).toEqual(
|
|
618
|
-
`<div>${LDQ}Before${RDQ} <code>"Inside"</code> ${LDQ}After${RDQ}</div>`
|
|
619
|
-
)
|
|
620
|
-
})
|
|
621
|
-
|
|
622
|
-
it("handles string className property (non-standard AST)", async () => {
|
|
623
|
-
// rehype-parse always produces array classNames, but hand-crafted ASTs
|
|
624
|
-
// may use strings. The plugin should handle both.
|
|
625
|
-
const tree: Root = {
|
|
626
|
-
type: "root",
|
|
627
|
-
children: [{
|
|
628
|
-
type: "element",
|
|
629
|
-
tagName: "p",
|
|
630
|
-
// Cast to bypass hast's typed className (which expects string[])
|
|
631
|
-
properties: { className: "no-transform" as unknown as string[] },
|
|
632
|
-
children: [{ type: "text", value: '"Hello"' }],
|
|
633
|
-
}],
|
|
634
|
-
}
|
|
635
|
-
const processor = unified()
|
|
636
|
-
.use(rehypePunctilio, { skipClasses: ["no-transform"] })
|
|
637
|
-
.use(rehypeStringify)
|
|
638
|
-
await processor.run(tree)
|
|
639
|
-
// Text should remain untransformed because the class matches
|
|
640
|
-
const textNode = (tree.children[0] as Element).children[0] as Text
|
|
641
|
-
expect(textNode.value).toBe('"Hello"')
|
|
642
|
-
})
|
|
643
|
-
|
|
644
|
-
it("handles mixed skip and non-skip siblings", async () => {
|
|
645
|
-
expect(await processHtml('<article><p>"Hello"</p><pre>"Code"</pre><p>"World"</p></article>', { nbsp: false })).toEqual(
|
|
646
|
-
`<article><p>${LDQ}Hello${RDQ}</p><pre>"Code"</pre><p>${LDQ}World${RDQ}</p></article>`
|
|
647
|
-
)
|
|
648
|
-
})
|
|
649
|
-
})
|
|
650
|
-
|
|
651
|
-
describe("stress tests", () => {
|
|
652
|
-
it("handles many sibling paragraphs", async () => {
|
|
653
|
-
// 100 siblings: enough to exercise the visitor/transformed-set logic
|
|
654
|
-
// without spending seconds on HTML parsing
|
|
655
|
-
const count = 100
|
|
656
|
-
const html = '<div>' + '<p>"Hello," she said.</p>'.repeat(count) + '</div>'
|
|
657
|
-
const result = await processHtml(html, { nbsp: false })
|
|
658
|
-
const matchCount = (result.match(new RegExp(LDQ, "g")) ?? []).length
|
|
659
|
-
expect(matchCount).toBe(count)
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
it("handles paragraph with many inline elements", async () => {
|
|
663
|
-
const count = 100
|
|
664
|
-
const inlineElements = Array.from({ length: count }, (_, i) =>
|
|
665
|
-
`<em>"word${i}"</em>`
|
|
666
|
-
).join(" ")
|
|
667
|
-
const html = `<p>${inlineElements}</p>`
|
|
668
|
-
const result = await processHtml(html, { nbsp: false })
|
|
669
|
-
const matchCount = (result.match(new RegExp(LDQ, "g")) ?? []).length
|
|
670
|
-
expect(matchCount).toBe(count)
|
|
671
|
-
})
|
|
672
|
-
|
|
673
|
-
it("transforms text at 100-level deep nesting", async () => {
|
|
674
|
-
let html = '"Hello"'
|
|
675
|
-
for (let i = 0; i < 100; i++) {
|
|
676
|
-
html = `<span>${html}</span>`
|
|
677
|
-
}
|
|
678
|
-
html = `<p>${html}</p>`
|
|
679
|
-
const result = await processHtml(html, { nbsp: false })
|
|
680
|
-
let expected = `${LDQ}Hello${RDQ}`
|
|
681
|
-
for (let i = 0; i < 100; i++) expected = `<span>${expected}</span>`
|
|
682
|
-
expected = `<p>${expected}</p>`
|
|
683
|
-
expect(result).toBe(expected)
|
|
684
|
-
})
|
|
685
|
-
|
|
686
|
-
it("does not crash beyond MAX_RECURSION_DEPTH", async () => {
|
|
687
|
-
let html = '"Hello"'
|
|
688
|
-
for (let i = 0; i < 1050; i++) {
|
|
689
|
-
html = `<span>${html}</span>`
|
|
690
|
-
}
|
|
691
|
-
html = `<p>${html}</p>`
|
|
692
|
-
// Should not throw — deep nesting is silently skipped
|
|
693
|
-
const result = await processHtml(html, { nbsp: false })
|
|
694
|
-
// Build expected: same structure with smart quotes applied
|
|
695
|
-
let expected = `${LDQ}Hello${RDQ}`
|
|
696
|
-
for (let i = 0; i < 1050; i++) expected = `<span>${expected}</span>`
|
|
697
|
-
expected = `<p>${expected}</p>`
|
|
698
|
-
expect(result).toBe(expected)
|
|
699
|
-
})
|
|
700
|
-
})
|
|
701
|
-
})
|