punctilio 1.3.1 → 1.4.1
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 +29 -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 +8 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +25 -0
- package/dist/utils.js.map +1 -1
- package/package.json +28 -4
- package/src/constants.ts +161 -0
- package/src/dashes.ts +217 -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 +653 -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 +128 -0
- package/src/utils.ts +72 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { Element, Parent, 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)).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
|
+
])("preserves %s", async (_name, html, expected) => {
|
|
63
|
+
expect(await processHtml(html)).toEqual(expected)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("skipped elements", () => {
|
|
68
|
+
it.each(["code", "pre", "script", "style", "kbd", "var", "samp"])(
|
|
69
|
+
"skips %s elements",
|
|
70
|
+
async (tag) => {
|
|
71
|
+
expect(await processHtml(`<${tag}>"Hello"</${tag}>`)).toEqual(`<${tag}>"Hello"</${tag}>`)
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
it("skips nested content inside code blocks", async () => {
|
|
76
|
+
expect(await processHtml('<pre><code>"Hello" -- test...</code></pre>')).toEqual(
|
|
77
|
+
'<pre><code>"Hello" -- test...</code></pre>'
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("transforms siblings of skipped elements", async () => {
|
|
82
|
+
expect(await processHtml('<p><code>code</code> "Hello"</p>')).toEqual(
|
|
83
|
+
`<p><code>code</code> ${LDQ}Hello${RDQ}</p>`
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("custom skip options", () => {
|
|
89
|
+
it.each([
|
|
90
|
+
["custom tags", '<custom-tag>"Hello"</custom-tag>', { skipTags: ["custom-tag"] }, '<custom-tag>"Hello"</custom-tag>'],
|
|
91
|
+
["custom classes", '<p class="no-transform">"Hello"</p>', { skipClasses: ["no-transform"] }, '<p class="no-transform">"Hello"</p>'],
|
|
92
|
+
["descendants of skip classes", '<div class="raw"><p>"Hello"</p></div>', { skipClasses: ["raw"] }, '<div class="raw"><p>"Hello"</p></div>'],
|
|
93
|
+
])("skips %s", async (_name, html, options, expected) => {
|
|
94
|
+
expect(await processHtml(html, options)).toEqual(expected)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("transform options passthrough", () => {
|
|
99
|
+
it.each([
|
|
100
|
+
["punctuationStyle american", '<p>"Hello."</p>', { punctuationStyle: "american" as const }, `<p>${LDQ}Hello.${RDQ}</p>`],
|
|
101
|
+
["punctuationStyle british", '<p>"Hello."</p>', { punctuationStyle: "british" as const }, `<p>${LDQ}Hello${RDQ}.</p>`],
|
|
102
|
+
["dashStyle american", "<p>word - word</p>", { dashStyle: "american" as const }, `<p>word${EM_DASH}word</p>`],
|
|
103
|
+
["dashStyle british", "<p>word - word</p>", { dashStyle: "british" as const }, `<p>word ${EN_DASH} word</p>`],
|
|
104
|
+
["symbols enabled", "<p>5x5</p>", { symbols: true }, `<p>5${MULTIPLICATION}5</p>`],
|
|
105
|
+
["symbols disabled", "<p>5x5</p>", { symbols: false }, "<p>5x5</p>"],
|
|
106
|
+
["fractions enabled", "<p>1/2 cup</p>", { fractions: true }, `<p>${FRACTION_1_2} cup</p>`],
|
|
107
|
+
["fractions disabled", "<p>1/2 cup</p>", { fractions: false }, "<p>1/2 cup</p>"],
|
|
108
|
+
["custom separator", '<p>"Hello"</p>', { separator: "\uE001" }, `<p>${LDQ}Hello${RDQ}</p>`],
|
|
109
|
+
])("respects %s", async (_name, html, options, expected) => {
|
|
110
|
+
expect(await processHtml(html, options)).toEqual(expected)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("complex scenarios", () => {
|
|
115
|
+
it.each([
|
|
116
|
+
[
|
|
117
|
+
"article content",
|
|
118
|
+
`<article><h1>"Title's"</h1><p>"quotes" -- dashes</p><p>Pages 1-5</p></article>`,
|
|
119
|
+
`<article><h1>${LDQ}Title${RSQ}s${RDQ}</h1><p>${LDQ}quotes${RDQ}${EM_DASH}dashes</p><p>Pages 1${EN_DASH}5</p></article>`,
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
"mixed code and prose",
|
|
123
|
+
'<p>The function <code>getValue()</code> returns "the value" -- useful.</p>',
|
|
124
|
+
`<p>The function <code>getValue()</code> returns ${LDQ}the value${RDQ}${EM_DASH}useful.</p>`,
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
"table content",
|
|
128
|
+
'<table><tr><th>"Header"</th><td>Pages 1-5</td></tr></table>',
|
|
129
|
+
`<table><tbody><tr><th>${LDQ}Header${RDQ}</th><td>Pages 1${EN_DASH}5</td></tr></tbody></table>`,
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
"list content",
|
|
133
|
+
'<ul><li>"First" -- important</li><li>Pages 1-5</li></ul>',
|
|
134
|
+
`<ul><li>${LDQ}First${RDQ}${EM_DASH}important</li><li>Pages 1${EN_DASH}5</li></ul>`,
|
|
135
|
+
],
|
|
136
|
+
])("handles %s", async (_name, html, expected) => {
|
|
137
|
+
expect(await processHtml(html)).toEqual(expected)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe("edge cases", () => {
|
|
142
|
+
it.each([
|
|
143
|
+
["empty paragraphs", "<p></p>", "<p></p>"],
|
|
144
|
+
["whitespace-only", "<p> </p>", "<p> </p>"],
|
|
145
|
+
["special characters", "<p>Café & résumé</p>", "<p>Café & résumé</p>"],
|
|
146
|
+
["emoji", '<p>"Hello 👋"</p>', `<p>${LDQ}Hello 👋${RDQ}</p>`],
|
|
147
|
+
["links", '<p><a href="https://example.com">"Link"</a></p>', `<p><a href="https://example.com">${LDQ}Link${RDQ}</a></p>`],
|
|
148
|
+
])("handles %s", async (_name, html, expected) => {
|
|
149
|
+
expect(await processHtml(html)).toEqual(expected)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("is idempotent", async () => {
|
|
153
|
+
const html = '<p>"Hello," she said -- "it\'s nice."</p>'
|
|
154
|
+
const firstPass = await processHtml(html)
|
|
155
|
+
const secondPass = await processHtml(firstPass)
|
|
156
|
+
expect(secondPass).toBe(firstPass)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe("default export", () => {
|
|
161
|
+
it("exports rehypePunctilio as default", async () => {
|
|
162
|
+
const { default: defaultExport } = await import("../rehype.js")
|
|
163
|
+
expect(defaultExport).toBe(rehypePunctilio)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe("exported utilities", () => {
|
|
168
|
+
const ignoreNone = () => false
|
|
169
|
+
const ignoreCode = (el: Element) => el.tagName === "code"
|
|
170
|
+
|
|
171
|
+
const fixtures = {
|
|
172
|
+
empty: h("div", []) as Element,
|
|
173
|
+
simple: h("p", "Hello, world!") as Element,
|
|
174
|
+
nested: h("div", ["This is ", h("em", "emphasized"), " text."]) as Element,
|
|
175
|
+
withCode: h("div", ["This is ", h("code", "ignored"), " text."]) as Element,
|
|
176
|
+
emptyAndComment: h("div", [
|
|
177
|
+
h("span"),
|
|
178
|
+
{ type: "comment", value: "comment" } as unknown as ElementContent,
|
|
179
|
+
]) as Element,
|
|
180
|
+
deeplyNested: h("div", [
|
|
181
|
+
"Level 1 ",
|
|
182
|
+
h("span", ["Level 2 ", h("em", "Level 3")]),
|
|
183
|
+
" End",
|
|
184
|
+
]) as Element,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
describe("flattenTextNodes", () => {
|
|
188
|
+
it.each([
|
|
189
|
+
["empty element", fixtures.empty, ignoreNone, []],
|
|
190
|
+
["simple text", fixtures.simple, ignoreNone, [{ type: "text", value: "Hello, world!" }]],
|
|
191
|
+
["nested elements", fixtures.nested, ignoreNone, [
|
|
192
|
+
{ type: "text", value: "This is " },
|
|
193
|
+
{ type: "text", value: "emphasized" },
|
|
194
|
+
{ type: "text", value: " text." },
|
|
195
|
+
]],
|
|
196
|
+
["with ignored code", fixtures.withCode, ignoreCode, [
|
|
197
|
+
{ type: "text", value: "This is " },
|
|
198
|
+
{ type: "text", value: " text." },
|
|
199
|
+
]],
|
|
200
|
+
["empty spans and comments", fixtures.emptyAndComment, ignoreNone, []],
|
|
201
|
+
["deeply nested", fixtures.deeplyNested, ignoreNone, [
|
|
202
|
+
{ type: "text", value: "Level 1 " },
|
|
203
|
+
{ type: "text", value: "Level 2 " },
|
|
204
|
+
{ type: "text", value: "Level 3" },
|
|
205
|
+
{ type: "text", value: " End" },
|
|
206
|
+
]],
|
|
207
|
+
])("handles %s", (_name, node, skip, expected) => {
|
|
208
|
+
expect(flattenTextNodes(node, skip)).toEqual(expected)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("extracts from single text node", () => {
|
|
212
|
+
const textNode: Text = { type: "text", value: "Hello" }
|
|
213
|
+
expect(flattenTextNodes(textNode, ignoreNone)).toEqual([textNode])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it("stops at max recursion depth", () => {
|
|
217
|
+
let deep: Element = h("span", "deep") as Element
|
|
218
|
+
for (let i = 0; i < 1005; i++) deep = h("div", [deep]) as Element
|
|
219
|
+
expect(flattenTextNodes(deep, ignoreNone)).toHaveLength(0)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe("transformElement", () => {
|
|
224
|
+
const toUpper = (s: string) => s.toUpperCase()
|
|
225
|
+
|
|
226
|
+
it.each([
|
|
227
|
+
["simple element", h("p", "hello"), "HELLO"],
|
|
228
|
+
["custom separator", h("p", "hello"), "HELLO", "\uE001"],
|
|
229
|
+
])("transforms %s", (_name, element, expected, sep = DEFAULT_SEPARATOR) => {
|
|
230
|
+
transformElement(element as Element, toUpper, ignoreNone, sep)
|
|
231
|
+
expect((element.children[0] as Text).value).toBe(expected)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("transforms across multiple nodes", () => {
|
|
235
|
+
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
236
|
+
transformElement(element, toUpper, ignoreNone, DEFAULT_SEPARATOR)
|
|
237
|
+
expect((element.children[0] as Text).value).toBe("HELLO ")
|
|
238
|
+
expect(((element.children[1] as Element).children[0] as Text).value).toBe("WORLD")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("skips matching elements", () => {
|
|
242
|
+
const element = h("p", ["before ", h("code", "unchanged"), " after"]) as Element
|
|
243
|
+
transformElement(element, toUpper, ignoreCode, DEFAULT_SEPARATOR)
|
|
244
|
+
expect((element.children[0] as Text).value).toBe("BEFORE ")
|
|
245
|
+
expect(((element.children[1] as Element).children[0] as Text).value).toBe("unchanged")
|
|
246
|
+
expect((element.children[2] as Text).value).toBe(" AFTER")
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("applies custom transforms", () => {
|
|
250
|
+
const element = h("p", "pages 1-5") as Element
|
|
251
|
+
transformElement(element, (s) => s.replace(/-/g, "–"), ignoreNone, DEFAULT_SEPARATOR)
|
|
252
|
+
expect((element.children[0] as Text).value).toBe("pages 1–5")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("handles missing children gracefully", () => {
|
|
256
|
+
const node = h("div") as Element
|
|
257
|
+
node.children = undefined as unknown as Element["children"]
|
|
258
|
+
expect(() => transformElement(node, toUpper, ignoreNone, DEFAULT_SEPARATOR)).not.toThrow()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it.each([
|
|
262
|
+
["injecting separators", h("p", "hello"), (s: string) => s.replace("hello", `hello${DEFAULT_SEPARATOR}injected`)],
|
|
263
|
+
["removing separators", h("p", ["hello ", h("em", "world")]), (s: string) => s.replace(DEFAULT_SEPARATOR, "")],
|
|
264
|
+
])("throws on %s", (_name, element, transform) => {
|
|
265
|
+
expect(() => transformElement(element as Element, transform, () => false, DEFAULT_SEPARATOR)).toThrow(
|
|
266
|
+
"Transformation altered the number of text nodes"
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("passes invariance check for marker-transparent transforms", () => {
|
|
271
|
+
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
272
|
+
expect(() =>
|
|
273
|
+
transformElement(element, toUpper, ignoreNone, DEFAULT_SEPARATOR, true)
|
|
274
|
+
).not.toThrow()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it("throws on invariance check failure", () => {
|
|
278
|
+
const element = h("p", ["hello ", h("em", "world")]) as Element
|
|
279
|
+
// This transform interacts with the separator: it replaces char before separator
|
|
280
|
+
const badTransform = (s: string) => s.replace(new RegExp(` ${DEFAULT_SEPARATOR}`, "g"), `X${DEFAULT_SEPARATOR}`)
|
|
281
|
+
expect(() =>
|
|
282
|
+
transformElement(element, badTransform, ignoreNone, DEFAULT_SEPARATOR, true)
|
|
283
|
+
).toThrow("Transform invariance check failed")
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe("getTextContent", () => {
|
|
288
|
+
it.each([
|
|
289
|
+
["simple text", fixtures.simple, ignoreNone, "Hello, world!"],
|
|
290
|
+
["nested elements", fixtures.nested, ignoreNone, "This is emphasized text."],
|
|
291
|
+
["with ignored code", fixtures.withCode, ignoreCode, "This is text."],
|
|
292
|
+
["empty element", fixtures.empty, ignoreNone, ""],
|
|
293
|
+
["deeply nested", fixtures.deeplyNested, ignoreNone, "Level 1 Level 2 Level 3 End"],
|
|
294
|
+
])("extracts from %s", (_name, node, skip, expected) => {
|
|
295
|
+
expect(getTextContent(node, skip)).toBe(expected)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("uses default shouldSkip when not provided", () => {
|
|
299
|
+
expect(getTextContent(fixtures.simple)).toBe("Hello, world!")
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe("getFirstTextNode", () => {
|
|
304
|
+
it.each([
|
|
305
|
+
["simple text", fixtures.simple, "Hello, world!"],
|
|
306
|
+
["nested", fixtures.nested, "This is "],
|
|
307
|
+
["deeply nested", fixtures.deeplyNested, "Level 1 "],
|
|
308
|
+
])("finds first text in %s", (_name, node, expectedValue) => {
|
|
309
|
+
const result = getFirstTextNode(node as Parent)
|
|
310
|
+
expect(result).not.toBeNull()
|
|
311
|
+
expect(result!.value).toBe(expectedValue)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("returns null for empty element", () => {
|
|
315
|
+
expect(getFirstTextNode(fixtures.empty as Parent)).toBeNull()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it("returns text node directly", () => {
|
|
319
|
+
const textNode: Text = { type: "text", value: "Direct" }
|
|
320
|
+
expect(getFirstTextNode(textNode as unknown as Parent)).toBe(textNode)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it("returns null for null input", () => {
|
|
324
|
+
expect(getFirstTextNode(null as unknown as Parent)).toBeNull()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("returns null for comments-only element", () => {
|
|
328
|
+
const node = h("div", [
|
|
329
|
+
{ type: "comment", value: "comment" } as unknown as ElementContent,
|
|
330
|
+
]) as Element
|
|
331
|
+
expect(getFirstTextNode(node as Parent)).toBeNull()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it("stops at max recursion depth", () => {
|
|
335
|
+
let deep: Element = h("span", "deep") as Element
|
|
336
|
+
for (let i = 0; i < 1005; i++) deep = h("div", [deep]) as Element
|
|
337
|
+
expect(getFirstTextNode(deep as Parent)).toBeNull()
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe("assertSmartQuotesMatch", () => {
|
|
342
|
+
it.each([
|
|
343
|
+
["matched double quotes", "\u201CHello\u201D"],
|
|
344
|
+
["nested quotes", "\u201COuter \u201Cinner\u201D outer\u201D"],
|
|
345
|
+
["empty string", ""],
|
|
346
|
+
["no quotes", "Hello, world!"],
|
|
347
|
+
])("passes for %s", (_name, input) => {
|
|
348
|
+
expect(() => assertSmartQuotesMatch(input)).not.toThrow()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it.each([
|
|
352
|
+
["unmatched opening", "\u201CHello"],
|
|
353
|
+
["unmatched closing", "Hello\u201D"],
|
|
354
|
+
["extra opening", "\u201C\u201CHello\u201D"],
|
|
355
|
+
])("throws for %s", (_name, input) => {
|
|
356
|
+
expect(() => assertSmartQuotesMatch(input)).toThrow("Mismatched quotes")
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
describe("collectTransformableElements", () => {
|
|
361
|
+
it("collects elements with direct text children", () => {
|
|
362
|
+
const tree = h("div", [
|
|
363
|
+
h("p", "First paragraph"),
|
|
364
|
+
h("div", [h("p", "Nested paragraph")]),
|
|
365
|
+
]) as Element
|
|
366
|
+
const result = collectTransformableElements(tree, ignoreNone)
|
|
367
|
+
expect(result).toHaveLength(2)
|
|
368
|
+
expect(result[0].tagName).toBe("p")
|
|
369
|
+
expect(result[1].tagName).toBe("p")
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it("skips elements matching shouldSkip", () => {
|
|
373
|
+
const tree = h("div", [
|
|
374
|
+
h("p", "Normal"),
|
|
375
|
+
h("code", "Skipped"),
|
|
376
|
+
]) as Element
|
|
377
|
+
const result = collectTransformableElements(tree, ignoreCode)
|
|
378
|
+
expect(result).toHaveLength(1)
|
|
379
|
+
expect(result[0].tagName).toBe("p")
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it("returns empty for skipped root", () => {
|
|
383
|
+
const tree = h("code", "Skipped") as Element
|
|
384
|
+
expect(collectTransformableElements(tree, ignoreCode)).toHaveLength(0)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it("returns empty for elements without text children", () => {
|
|
388
|
+
const tree = h("div", [h("img")]) as Element
|
|
389
|
+
expect(collectTransformableElements(tree, ignoreNone)).toHaveLength(0)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
describe("coverage edge cases", () => {
|
|
395
|
+
it.each([
|
|
396
|
+
["space-separated class string", '<p class="foo bar no-transform">"Hello"</p>', { skipClasses: ["no-transform"] }, '<p class="foo bar no-transform">"Hello"</p>'],
|
|
397
|
+
["HTML comments", '<p>"Hello"<!-- comment --></p>', {}, `<p>${LDQ}Hello${RDQ}<!-- comment --></p>`],
|
|
398
|
+
["non-text children only", '<div><img src="test.jpg" alt="test"></div>', {}, '<div><img src="test.jpg" alt="test"></div>'],
|
|
399
|
+
["skip class at root", '<p class="skip">"Hello"</p>', { skipClasses: ["skip"] }, '<p class="skip">"Hello"</p>'],
|
|
400
|
+
["nested skipped elements", '<pre><p>"Hello"</p></pre>', {}, '<pre><p>"Hello"</p></pre>'],
|
|
401
|
+
["multiple skip classes", '<p class="a b">"Hello"</p>', { skipClasses: ["b"] }, '<p class="a b">"Hello"</p>'],
|
|
402
|
+
])("handles %s", async (_name, html, options, expected) => {
|
|
403
|
+
expect(await processHtml(html, options)).toEqual(expected)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it("handles skipped child within non-skipped parent", async () => {
|
|
407
|
+
expect(await processHtml('<div>"Before" <code>"Inside"</code> "After"</div>')).toEqual(
|
|
408
|
+
`<div>${LDQ}Before${RDQ} <code>"Inside"</code> ${LDQ}After${RDQ}</div>`
|
|
409
|
+
)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it("handles mixed skip and non-skip siblings", async () => {
|
|
413
|
+
expect(await processHtml('<article><p>"Hello"</p><pre>"Code"</pre><p>"World"</p></article>')).toEqual(
|
|
414
|
+
`<article><p>${LDQ}Hello${RDQ}</p><pre>"Code"</pre><p>${LDQ}World${RDQ}</p></article>`
|
|
415
|
+
)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
})
|