punctilio 3.7.2 → 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,156 +0,0 @@
1
- import { readFileSync } from "fs"
2
- import { resolve } from "path"
3
- import { fileURLToPath } from "url"
4
- import { transformMarkdown, clearProcessorCache } from "../markdown.js"
5
- import { transform } from "../index.js"
6
- import { UNICODE_SYMBOLS } from "../constants.js"
7
-
8
- const __dirname = fileURLToPath(new URL(".", import.meta.url))
9
-
10
- const {
11
- LEFT_DOUBLE_QUOTE: LDQ,
12
- RIGHT_DOUBLE_QUOTE: RDQ,
13
- RIGHT_SINGLE_QUOTE: RSQ,
14
- EM_DASH,
15
- ELLIPSIS,
16
- } = UNICODE_SYMBOLS
17
-
18
- describe("transformMarkdown", () => {
19
- afterEach(() => {
20
- clearProcessorCache()
21
- })
22
-
23
- it("transforms basic typography", async () => {
24
- const result = await transformMarkdown('"Hello," she said.', { nbsp: false })
25
- expect(result.trimEnd()).toEqual(`${LDQ}Hello,${RDQ} she said.`)
26
- })
27
-
28
- it("preserves code blocks", async () => {
29
- const input = '```\n"untouched"\n```'
30
- const result = await transformMarkdown(input, { nbsp: false })
31
- expect(result.trimEnd()).toEqual(input)
32
- })
33
-
34
- it("handles complex documents", async () => {
35
- const input = [
36
- '# "Welcome"',
37
- "",
38
- "It's great -- isn't it?",
39
- "",
40
- "Wait...",
41
- ].join("\n")
42
-
43
- const expected = [
44
- `# ${LDQ}Welcome${RDQ}`,
45
- "",
46
- `It${RSQ}s great${EM_DASH}isn${RSQ}t it?`,
47
- "",
48
- `Wait${ELLIPSIS}`,
49
- "", // remark-stringify adds trailing newline
50
- ].join("\n")
51
-
52
- expect(await transformMarkdown(input, { nbsp: false })).toEqual(expected)
53
- })
54
-
55
- it("passes through punctuationStyle option", async () => {
56
- const american = await transformMarkdown('"Hello."', {
57
- punctuationStyle: "american",
58
- nbsp: false,
59
- })
60
- const british = await transformMarkdown('"Hello."', {
61
- punctuationStyle: "british",
62
- nbsp: false,
63
- })
64
- expect(american.trimEnd()).toEqual(`${LDQ}Hello.${RDQ}`)
65
- expect(british.trimEnd()).toEqual(`${LDQ}Hello${RDQ}.`)
66
- })
67
-
68
- it.each([
69
- ["bulletMarker", "- item", { bulletMarker: "+" as const }, "+ item"],
70
- ["emphasisMarker", "*italic*", { emphasisMarker: "_" as const }, "_italic_"],
71
- ["strongMarker", "**bold**", { strongMarker: "_" as const }, "__bold__"],
72
- ["ruleMarker", "---", { ruleMarker: "*" as const }, "***"],
73
- ])("passes through %s option", async (_name, input, options, expected) => {
74
- expect((await transformMarkdown(input, { ...options, nbsp: false })).trimEnd()).toEqual(expected)
75
- })
76
-
77
- it("uses 4-dash thematic break with default ruleMarker to avoid YAML front matter ambiguity", async () => {
78
- const result = await transformMarkdown("---", { nbsp: false })
79
- expect(result.trimEnd()).toEqual("----")
80
- })
81
-
82
- it("uses default options when none provided", async () => {
83
- const result = await transformMarkdown('"Hello"')
84
- expect(result.trimEnd()).toEqual(`${LDQ}Hello${RDQ}`)
85
- })
86
-
87
- it("escapes both opening and closing brackets in phrasing content", async () => {
88
- const result = await transformMarkdown("See references [1][4][5][6] for details.", {
89
- nbsp: false,
90
- })
91
- expect(result.trimEnd()).toEqual("See references \\[1\\]\\[4\\]\\[5\\]\\[6\\] for details.")
92
- })
93
-
94
- it.each([
95
- ["plain separators", '| Flag | Default |\n| --- | --- |\n| "a" | b |'],
96
- ["alignment markers", '| Left | Center | Right |\n| :--- | :---: | ---: |\n| "a" | b | c |'],
97
- ["long separators", '| Col |\n| -------------------- |\n| "data" |'],
98
- ])("preserves GFM table separators (%s)", async (_name, input) => {
99
- const result = await transformMarkdown(input, { nbsp: false })
100
- expect(result).not.toContain(EM_DASH)
101
- expect(result).toContain(LDQ)
102
- })
103
-
104
- it("reuses cached processor for identical options", async () => {
105
- const opts = { punctuationStyle: "british" as const, nbsp: false }
106
- const first = await transformMarkdown('"Hello."', opts)
107
- const second = await transformMarkdown('"World."', opts)
108
- expect(first.trimEnd()).toEqual(`${LDQ}Hello${RDQ}.`)
109
- expect(second.trimEnd()).toEqual(`${LDQ}World${RDQ}.`)
110
- })
111
-
112
- it("invalidates cache when options change", async () => {
113
- const american = await transformMarkdown('"Hello."', { punctuationStyle: "american", nbsp: false })
114
- const british = await transformMarkdown('"Hello."', { punctuationStyle: "british", nbsp: false })
115
- expect(american.trimEnd()).toEqual(`${LDQ}Hello.${RDQ}`)
116
- expect(british.trimEnd()).toEqual(`${LDQ}Hello${RDQ}.`)
117
- })
118
-
119
- it("LRU cache retains multiple option sets simultaneously", async () => {
120
- const american = { punctuationStyle: "american" as const, nbsp: false }
121
- const british = { punctuationStyle: "british" as const, nbsp: false }
122
- // Populate both cache entries
123
- await transformMarkdown('"A."', american)
124
- await transformMarkdown('"B."', british)
125
- // Both should still be cached (not evicted)
126
- const a2 = await transformMarkdown('"C."', american)
127
- const b2 = await transformMarkdown('"D."', british)
128
- expect(a2.trimEnd()).toEqual(`${LDQ}C.${RDQ}`)
129
- expect(b2.trimEnd()).toEqual(`${LDQ}D${RDQ}.`)
130
- })
131
-
132
- it("treats undefined options the same as absent options", async () => {
133
- const withDefault = await transformMarkdown("Dr. Smith")
134
- const withUndefined = await transformMarkdown("Dr. Smith", { nbsp: undefined })
135
- expect(withDefault).toContain("\u00A0")
136
- expect(withUndefined).toContain("\u00A0")
137
- })
138
-
139
- it("README.md prose is already typographically correct", () => {
140
- const readme = readFileSync(resolve(__dirname, "../../README.md"), "utf-8")
141
- const lines = readme.split("\n")
142
- let inCodeBlock = false
143
-
144
- for (const line of lines) {
145
- if (line.startsWith("```")) { inCodeBlock = !inCodeBlock; continue }
146
- if (inCodeBlock) continue
147
- // Skip non-prose: tables, badges, HTML, footnotes, headings, list items (including indented)
148
- if (/^[|#>\d]|^\s*-\s|^\s*\d+\.\s|^\[[\^!]|<span/.test(line)) continue
149
- if (line.trim() === "") continue
150
-
151
- // collapseSpaces: false because Markdown uses double-space for formatting
152
- const transformed = transform(line, { nbsp: false, collapseSpaces: false, checkIdempotency: false })
153
- expect(transformed).toBe(line)
154
- }
155
- })
156
- })
@@ -1,272 +0,0 @@
1
- import {
2
- nbspAfterShortWords,
3
- nbspBetweenNumberAndUnit,
4
- nbspBeforeLastWord,
5
- nbspAfterReferenceAbbreviations,
6
- nbspAfterSectionSymbols,
7
- nbspAfterHonorifics,
8
- nbspAfterCopyrightSymbols,
9
- nbspBetweenInitials,
10
- nbspTransform,
11
- UNITS,
12
- HONORIFICS,
13
- REFERENCE_ABBREVIATIONS,
14
- type NbspOptions,
15
- } from "../nbsp.js"
16
- import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
17
- import { assertLinearScaling } from "./test-helpers.js"
18
-
19
- const { NBSP, COPYRIGHT, REGISTERED, TRADEMARK } = UNICODE_SYMBOLS
20
- const SEP = DEFAULT_SEPARATOR
21
-
22
- /** Calls fn with separator option, checking all cases produce correct nbsp. */
23
- function expectSep(
24
- fn: (text: string, options?: NbspOptions) => string,
25
- cases: [string, string][],
26
- ) {
27
- for (const [input, expected] of cases) {
28
- expect(fn(input, { separator: SEP })).toBe(expected)
29
- }
30
- }
31
-
32
- describe("nbspAfterShortWords", () => {
33
- it.each([
34
- ["a cat", `a${NBSP}cat`],
35
- ["I am", `I${NBSP}am`],
36
- ["an apple", `an${NBSP}apple`],
37
- ["to go", `to${NBSP}go`],
38
- ["of the", `of${NBSP}the`],
39
- ["in on", `in${NBSP}on`],
40
- ["is it", `is${NBSP}it`],
41
- ["or if", `or${NBSP}if`],
42
- ["I think", `I${NBSP}think`],
43
- ['"a cat"', `"a${NBSP}cat"`],
44
- ["the cat sat on a mat", `the cat sat on${NBSP}a${NBSP}mat`],
45
- ["Go to the store", `Go${NBSP}to${NBSP}the store`],
46
- ["the cat", "the cat"],
47
- // Accented Latin short words
48
- ["à chat", `à${NBSP}chat`],
49
- ["où aller", `où${NBSP}aller`],
50
- ["ça va", `ça${NBSP}va`],
51
- ])('"%s" → "%s"', (input, expected) => {
52
- expect(nbspAfterShortWords(input)).toBe(expected)
53
- })
54
-
55
- it("preserves separators at node boundaries", () => {
56
- expectSep(nbspAfterShortWords, [
57
- [`a${SEP} cat`, `a${SEP}${NBSP}cat`],
58
- [`a ${SEP}cat${SEP}`, `a${NBSP}${SEP}cat${SEP}`],
59
- ])
60
- })
61
- })
62
-
63
- describe("nbspBetweenNumberAndUnit", () => {
64
- it.each(UNITS.map((u) => [`5 ${u}`, `5${NBSP}${u}`]))(
65
- '"%s" → "%s"',
66
- (input, expected) => {
67
- expect(nbspBetweenNumberAndUnit(input)).toBe(expected)
68
- },
69
- )
70
-
71
- it.each([
72
- ["100km", "100km"],
73
- ["5 kms", "5 kms"],
74
- ["chapter 3 above", "chapter 3 above"],
75
- ["item 5 here", "item 5 here"],
76
- ["5 3", "5 3"],
77
- ["page 42 of", "page 42 of"],
78
- // Excluded units: too ambiguous with common English words
79
- ["found 5 in total", "found 5 in total"],
80
- ["5 in total", "5 in total"],
81
- ["at 5 bar pressure", "at 5 bar pressure"],
82
- ["drew 5 bar charts", "drew 5 bar charts"],
83
- ])('no match: "%s"', (input, expected) => {
84
- expect(nbspBetweenNumberAndUnit(input)).toBe(expected)
85
- })
86
-
87
- it("preserves separators at node boundaries", () => {
88
- expectSep(nbspBetweenNumberAndUnit, [
89
- [`5${SEP} kg`, `5${SEP}${NBSP}kg`],
90
- [`5 ${SEP}kg`, `5${NBSP}${SEP}kg`],
91
- [`5${SEP} ${SEP}kg`, `5${SEP}${NBSP}${SEP}kg`],
92
- ])
93
- })
94
-
95
- it("does not match unit letter that starts a cross-element word", () => {
96
- // "5 m" + SEP + "ade" simulates <span>5 m</span><span>ade</span> ("made")
97
- expect(nbspBetweenNumberAndUnit(`5 m${SEP}ade`, { separator: SEP }))
98
- .toBe(`5 m${SEP}ade`)
99
- })
100
- })
101
-
102
- describe("nbspBeforeLastWord", () => {
103
- it.each([
104
- ["the end", `the${NBSP}end`],
105
- ["hello world", `hello${NBSP}world`],
106
- ["a b c", `a b${NBSP}c`],
107
- ["hello abcdefghij", `hello${NBSP}abcdefghij`],
108
- ["hello abcdefghijk", "hello abcdefghijk"],
109
- ["hello international", "hello international"],
110
- ["hello", "hello"],
111
- [" ", " "],
112
- ["the end\n\nNew paragraph", `the${NBSP}end\n\nNew${NBSP}paragraph`],
113
- ["the end\ncontinues", `the end\ncontinues`],
114
- ])('"%s" → "%s"', (input, expected) => {
115
- expect(nbspBeforeLastWord(input)).toBe(expected)
116
- })
117
-
118
- it("preserves separator before end", () => {
119
- expectSep(nbspBeforeLastWord, [
120
- [`the end${SEP}`, `the${NBSP}end${SEP}`],
121
- ])
122
- })
123
- })
124
-
125
- describe("nbspAfterReferenceAbbreviations", () => {
126
- it.each([
127
- ...REFERENCE_ABBREVIATIONS.map((abbr, i) => [`${abbr}. ${i + 1}`, `${abbr}.${NBSP}${i + 1}`]),
128
- ["Fig. caption", "Fig. caption"],
129
- ] as [string, string][])('"%s" → "%s"', (input, expected) => {
130
- expect(nbspAfterReferenceAbbreviations(input)).toBe(expected)
131
- })
132
-
133
- it("preserves separators at node boundaries", () => {
134
- expectSep(nbspAfterReferenceAbbreviations, [
135
- [`Fig.${SEP} 1`, `Fig.${SEP}${NBSP}1`],
136
- [`Fig. ${SEP}1`, `Fig.${NBSP}${SEP}1`],
137
- ])
138
- })
139
- })
140
-
141
- describe("nbspAfterSectionSymbols", () => {
142
- it.each([
143
- ["§ 5", `§${NBSP}5`],
144
- ["¶ 3", `¶${NBSP}3`],
145
- ["See § 12 for details", `See §${NBSP}12 for details`],
146
- ["§ title", "§ title"],
147
- ])('"%s" → "%s"', (input, expected) => {
148
- expect(nbspAfterSectionSymbols(input)).toBe(expected)
149
- })
150
-
151
- it("preserves separator after symbol", () => {
152
- expectSep(nbspAfterSectionSymbols, [
153
- [`§${SEP} 5`, `§${SEP}${NBSP}5`],
154
- ])
155
- })
156
- })
157
-
158
- describe("nbspAfterHonorifics", () => {
159
- const names = [
160
- // English
161
- "Smith", "Jones", "Brown", "Davis", "Wilson", "King", "Patrick", "Martinez", "Judge", "Brown", "Warren", "Lee", "Lee",
162
- // French
163
- "Dupont", "Laurent", "Lefebvre",
164
- // German / Nordic
165
- "Schmidt", "Weber",
166
- // Spanish / Portuguese
167
- "García", "Rodríguez",
168
- // Italian
169
- "Rossi", "Bianchi",
170
- // Dutch
171
- "Bakker", "Jansen",
172
- ]
173
- it.each([
174
- ...HONORIFICS.map((h, i) => [`${h}. ${names[i]}`, `${h}.${NBSP}${names[i]}`]),
175
- ["Dr. Élodie", `Dr.${NBSP}Élodie`],
176
- ["Jr. here", "Jr. here"],
177
- ["Dr. said", "Dr. said"],
178
- ] as [string, string][])('"%s" → "%s"', (input, expected) => {
179
- expect(nbspAfterHonorifics(input)).toBe(expected)
180
- })
181
-
182
- it("preserves separators at node boundaries", () => {
183
- expectSep(nbspAfterHonorifics, [
184
- [`Dr.${SEP} Smith`, `Dr.${SEP}${NBSP}Smith`],
185
- [`Dr. ${SEP}Smith`, `Dr.${NBSP}${SEP}Smith`],
186
- ])
187
- })
188
- })
189
-
190
- describe("nbspAfterCopyrightSymbols", () => {
191
- it.each([
192
- [`${COPYRIGHT} 2024`, `${COPYRIGHT}${NBSP}2024`],
193
- [`${REGISTERED} Brand`, `${REGISTERED}${NBSP}Brand`],
194
- [`${TRADEMARK} Product`, `${TRADEMARK}${NBSP}Product`],
195
- [`${COPYRIGHT} 2024 Company`, `${COPYRIGHT}${NBSP}2024 Company`],
196
- [`${COPYRIGHT} company`, `${COPYRIGHT} company`],
197
- ])('"%s" → "%s"', (input, expected) => {
198
- expect(nbspAfterCopyrightSymbols(input)).toBe(expected)
199
- })
200
-
201
- it("preserves separator after symbol", () => {
202
- expectSep(nbspAfterCopyrightSymbols, [
203
- [`${COPYRIGHT}${SEP} 2024`, `${COPYRIGHT}${SEP}${NBSP}2024`],
204
- ])
205
- })
206
- })
207
-
208
- describe("nbspBetweenInitials", () => {
209
- it.each([
210
- ["J. K. Rowling", `J.${NBSP}K.${NBSP}Rowling`],
211
- ["A. B. C.", `A.${NBSP}B.${NBSP}C.`],
212
- ["J. Smith", `J.${NBSP}Smith`],
213
- ["É. Piaf", `É.${NBSP}Piaf`],
214
- ["A. test", "A. test"],
215
- ])('"%s" → "%s"', (input, expected) => {
216
- expect(nbspBetweenInitials(input)).toBe(expected)
217
- })
218
-
219
- it("preserves separator between initials", () => {
220
- expectSep(nbspBetweenInitials, [
221
- [`J.${SEP} K. Rowling`, `J.${SEP}${NBSP}K.${NBSP}Rowling`],
222
- ])
223
- })
224
- })
225
-
226
- describe("nbspTransform", () => {
227
- it("applies all nbsp transformations", () => {
228
- expect(nbspTransform("Dr. Smith wrote Fig. 1 on p. 42"))
229
- .toBe(`Dr.${NBSP}Smith wrote Fig.${NBSP}1 on${NBSP}p.${NBSP}42`)
230
- })
231
-
232
- it("handles multiple rules on the same text", () => {
233
- expect(nbspTransform(`${COPYRIGHT} 2024 by J. K. Rowling`))
234
- .toBe(`${COPYRIGHT}${NBSP}2024 by${NBSP}J.${NBSP}K.${NBSP}Rowling`)
235
- })
236
-
237
- it("nbspBeforeLastWord fires on plain text", () => {
238
- expect(nbspTransform("Hello world")).toBe(`Hello${NBSP}world`)
239
- })
240
-
241
- it("passes separator through to all functions", () => {
242
- expect(nbspTransform(`Dr.${SEP} Smith`, { separator: SEP }))
243
- .toBe(`Dr.${SEP}${NBSP}Smith`)
244
- })
245
-
246
- describe("ordering: specific patterns before generic", () => {
247
- it("abbreviation claims nbsp before short-word rule", () => {
248
- const result = nbspTransform("No. 5")
249
- expect(result).toBe(`No.${NBSP}5`)
250
- expect(result.split(NBSP).length - 1).toBe(1)
251
- })
252
-
253
- it("honorific + initials compose correctly", () => {
254
- expect(nbspTransform("Dr. J. K. Smith")).toBe(`Dr.${NBSP}J.${NBSP}K.${NBSP}Smith`)
255
- })
256
-
257
- it("honorific + unit don't double-apply with short words", () => {
258
- expect(nbspTransform("St. Anne had 5 kg")).toBe(`St.${NBSP}Anne had 5${NBSP}kg`)
259
- })
260
- })
261
-
262
- describe("stress tests", () => {
263
- it("scales linearly for short words", () => {
264
- assertLinearScaling(nbspTransform, (n) => "a b c d e f ".repeat(n))
265
- })
266
-
267
- it("early-exits on text with no spaces", () => {
268
- const result = nbspTransform("nospaces".repeat(1000))
269
- expect(result).toBe("nospaces".repeat(1000))
270
- })
271
- })
272
- })