punctilio 1.2.10 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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é &amp; résumé</p>", "<p>Café &#x26; 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
+ })