punctilio 3.7.3 → 3.7.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.
@@ -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é &amp; résumé</p>", "<p>Café &#x26; 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
- })