punctilio 2.2.3 → 2.3.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.
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +29 -2
- package/dist/quotes.js.map +1 -1
- package/package.json +1 -1
- package/src/quotes.ts +33 -2
- package/src/tests/quotes.test.ts +100 -31
package/dist/quotes.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"quotes.d.ts","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAeH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,SAAS,GAAG,MAAM,CAAA;AAE9D,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC;;;;;;;;;OASG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAA;CACtC;
|
|
1
|
+
{"version":3,"file":"quotes.d.ts","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAeH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,SAAS,GAAG,MAAM,CAAA;AAE9D,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC;;;;;;;;;OASG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAA;CACtC;AAgJD,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,CAiB3E"}
|
package/dist/quotes.js
CHANGED
|
@@ -10,8 +10,9 @@ function convertSingleQuotes(text, sep) {
|
|
|
10
10
|
// Handle empty single quotes '' and whitespace-only quotes ' ' first
|
|
11
11
|
// Only match straight quotes, not already-converted curly quotes
|
|
12
12
|
const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`;
|
|
13
|
-
|
|
14
|
-
text = text.replace(new RegExp(`(
|
|
13
|
+
const singleQuoteOrWord = `[${singleQuoteChars}\\w]`;
|
|
14
|
+
text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`);
|
|
15
|
+
text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})'(\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`);
|
|
15
16
|
const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`;
|
|
16
17
|
// Full pattern with optional 's' for lookahead detection in apostropheRegex
|
|
17
18
|
const afterEndingSingle = `(?=${escapedSep}?(?:s${escapedSep}?)?(?:[${afterEndingSinglePatterns}]|$))`;
|
|
@@ -37,8 +38,34 @@ function convertSingleQuotes(text, sep) {
|
|
|
37
38
|
text = text.replace(apostropheRegex, MODIFIER_LETTER_APOSTROPHE);
|
|
38
39
|
const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`;
|
|
39
40
|
text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`);
|
|
41
|
+
text = convertUnmatchedPluralPossessives(text, sep);
|
|
40
42
|
return text;
|
|
41
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Convert unmatched RSQ after s/S to MLA (plural possessives like dogs', Bayes').
|
|
46
|
+
* Tracks LSQ/RSQ balance left-to-right so that paired closing quotes
|
|
47
|
+
* (e.g., 'yes' where s precedes RSQ) remain RSQ.
|
|
48
|
+
*/
|
|
49
|
+
function convertUnmatchedPluralPossessives(text, sep) {
|
|
50
|
+
let singleQuoteBalance = 0;
|
|
51
|
+
return text.replace(new RegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"), (match, offset) => {
|
|
52
|
+
if (match === LEFT_SINGLE_QUOTE) {
|
|
53
|
+
singleQuoteBalance++;
|
|
54
|
+
return match;
|
|
55
|
+
}
|
|
56
|
+
if (singleQuoteBalance > 0) {
|
|
57
|
+
singleQuoteBalance--;
|
|
58
|
+
return match;
|
|
59
|
+
}
|
|
60
|
+
let i = offset - 1;
|
|
61
|
+
while (i >= 0 && text[i] === sep)
|
|
62
|
+
i--;
|
|
63
|
+
if (i >= 0 && (text[i] === "s" || text[i] === "S")) {
|
|
64
|
+
return MODIFIER_LETTER_APOSTROPHE;
|
|
65
|
+
}
|
|
66
|
+
return match;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
42
69
|
/** Convert straight double quotes to curly quotes */
|
|
43
70
|
function convertDoubleQuotes(text, sep) {
|
|
44
71
|
const escapedSep = escapeStringRegexp(sep);
|
package/dist/quotes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"quotes.js","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,kBAAkB,MAAM,sBAAsB,CAAA;AACrD,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAElF,MAAM,EACJ,OAAO,EACP,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,0BAA0B,EAC1B,QAAQ,GACT,GAAG,eAAe,CAAA;AAsBnB,qEAAqE;AACrE,SAAS,mBAAmB,CAAC,IAAY,EAAE,GAAW;IACpD,MAAM,UAAU,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAA;IAE1C,qEAAqE;IACrE,iEAAiE;IACjE,MAAM,gBAAgB,GAAG,IAAI,iBAAiB,GAAG,kBAAkB,GAAG,0BAA0B,EAAE,CAAA;IAClG,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"quotes.js","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/quotes.ts
CHANGED
|
@@ -42,8 +42,9 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
42
42
|
// Handle empty single quotes '' and whitespace-only quotes ' ' first
|
|
43
43
|
// Only match straight quotes, not already-converted curly quotes
|
|
44
44
|
const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
|
|
45
|
-
|
|
46
|
-
text = text.replace(new RegExp(`(
|
|
45
|
+
const singleQuoteOrWord = `[${singleQuoteChars}\\w]`
|
|
46
|
+
text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
|
|
47
|
+
text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})'(\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
|
|
47
48
|
|
|
48
49
|
const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
|
|
49
50
|
// Full pattern with optional 's' for lookahead detection in apostropheRegex
|
|
@@ -80,9 +81,39 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
80
81
|
const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
|
|
81
82
|
text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
|
|
82
83
|
|
|
84
|
+
text = convertUnmatchedPluralPossessives(text, sep)
|
|
85
|
+
|
|
83
86
|
return text
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Convert unmatched RSQ after s/S to MLA (plural possessives like dogs', Bayes').
|
|
91
|
+
* Tracks LSQ/RSQ balance left-to-right so that paired closing quotes
|
|
92
|
+
* (e.g., 'yes' where s precedes RSQ) remain RSQ.
|
|
93
|
+
*/
|
|
94
|
+
function convertUnmatchedPluralPossessives(text: string, sep: string): string {
|
|
95
|
+
let singleQuoteBalance = 0
|
|
96
|
+
return text.replace(
|
|
97
|
+
new RegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"),
|
|
98
|
+
(match, offset) => {
|
|
99
|
+
if (match === LEFT_SINGLE_QUOTE) {
|
|
100
|
+
singleQuoteBalance++
|
|
101
|
+
return match
|
|
102
|
+
}
|
|
103
|
+
if (singleQuoteBalance > 0) {
|
|
104
|
+
singleQuoteBalance--
|
|
105
|
+
return match
|
|
106
|
+
}
|
|
107
|
+
let i = offset - 1
|
|
108
|
+
while (i >= 0 && text[i] === sep) i--
|
|
109
|
+
if (i >= 0 && (text[i] === "s" || text[i] === "S")) {
|
|
110
|
+
return MODIFIER_LETTER_APOSTROPHE
|
|
111
|
+
}
|
|
112
|
+
return match
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
/** Convert straight double quotes to curly quotes */
|
|
87
118
|
function convertDoubleQuotes(text: string, sep: string): string {
|
|
88
119
|
const escapedSep = escapeStringRegexp(sep)
|
package/src/tests/quotes.test.ts
CHANGED
|
@@ -20,7 +20,6 @@ describe("niceQuotes", () => {
|
|
|
20
20
|
['"This is a quote", she said.', `${LEFT_DOUBLE_QUOTE}This is a quote,${RIGHT_DOUBLE_QUOTE} she said.`],
|
|
21
21
|
['"This is a quote," she said.', `${LEFT_DOUBLE_QUOTE}This is a quote,${RIGHT_DOUBLE_QUOTE} she said.`],
|
|
22
22
|
['"This is a quote!".', `${LEFT_DOUBLE_QUOTE}This is a quote!${RIGHT_DOUBLE_QUOTE}.`],
|
|
23
|
-
['"This is a quote?".', `${LEFT_DOUBLE_QUOTE}This is a quote?${RIGHT_DOUBLE_QUOTE}.`],
|
|
24
23
|
['"This is a quote..." he trailed off.', `${LEFT_DOUBLE_QUOTE}This is a quote...${RIGHT_DOUBLE_QUOTE} he trailed off.`],
|
|
25
24
|
['She said, "This is a quote."', `She said, ${LEFT_DOUBLE_QUOTE}This is a quote.${RIGHT_DOUBLE_QUOTE}`],
|
|
26
25
|
['"Hello." Mary', `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} Mary`],
|
|
@@ -85,9 +84,6 @@ describe("niceQuotes", () => {
|
|
|
85
84
|
"strategy s's return is good, even as d's return is bad",
|
|
86
85
|
`strategy s${MODIFIER_LETTER_APOSTROPHE}s return is good, even as d${MODIFIER_LETTER_APOSTROPHE}s return is bad`,
|
|
87
86
|
],
|
|
88
|
-
// Possessive plural (trailing apostrophe → closing RSQ)
|
|
89
|
-
["the dogs' owner", `the dogs${RIGHT_SINGLE_QUOTE} owner`],
|
|
90
|
-
["the bosses' meeting", `the bosses${RIGHT_SINGLE_QUOTE} meeting`],
|
|
91
87
|
// Chained possessives
|
|
92
88
|
["the dog's owner's car", `the dog${MODIFIER_LETTER_APOSTROPHE}s owner${MODIFIER_LETTER_APOSTROPHE}s car`],
|
|
93
89
|
// Hyphenated possessive
|
|
@@ -102,12 +98,10 @@ describe("niceQuotes", () => {
|
|
|
102
98
|
[`don't${EM_DASH}stop`, `don${MODIFIER_LETTER_APOSTROPHE}t${EM_DASH}stop`],
|
|
103
99
|
// Contraction + digit context
|
|
104
100
|
["it's 5 o'clock", `it${MODIFIER_LETTER_APOSTROPHE}s 5 o${MODIFIER_LETTER_APOSTROPHE}clock`],
|
|
105
|
-
// French
|
|
101
|
+
// French contraction (single-letter prefix + accented Latin)
|
|
106
102
|
["l'homme", `l${MODIFIER_LETTER_APOSTROPHE}homme`],
|
|
107
|
-
|
|
108
|
-
// Uppercase contractions and names
|
|
103
|
+
// Uppercase contraction/name
|
|
109
104
|
["O'NEILL", `O${MODIFIER_LETTER_APOSTROPHE}NEILL`],
|
|
110
|
-
["CAN'T", `CAN${MODIFIER_LETTER_APOSTROPHE}T`],
|
|
111
105
|
// Possessive in parenthetical
|
|
112
106
|
["(Brien's)", `(Brien${MODIFIER_LETTER_APOSTROPHE}s)`],
|
|
113
107
|
// Possessive inside single-quoted phrase
|
|
@@ -127,6 +121,84 @@ describe("niceQuotes", () => {
|
|
|
127
121
|
})
|
|
128
122
|
})
|
|
129
123
|
|
|
124
|
+
describe("plural possessive apostrophes", () => {
|
|
125
|
+
it.each([
|
|
126
|
+
// Unmatched trailing s' → MLA
|
|
127
|
+
["the models' behavior", `the models${MODIFIER_LETTER_APOSTROPHE} behavior`],
|
|
128
|
+
["Bayes' rule", `Bayes${MODIFIER_LETTER_APOSTROPHE} rule`],
|
|
129
|
+
["Thomas' gaze", `Thomas${MODIFIER_LETTER_APOSTROPHE} gaze`],
|
|
130
|
+
["dogs' and cats' toys", `dogs${MODIFIER_LETTER_APOSTROPHE} and cats${MODIFIER_LETTER_APOSTROPHE} toys`],
|
|
131
|
+
["belongs to the dogs'", `belongs to the dogs${MODIFIER_LETTER_APOSTROPHE}`],
|
|
132
|
+
["the DOGS' owner", `the DOGS${MODIFIER_LETTER_APOSTROPHE} owner`],
|
|
133
|
+
["Prisoners' Dilemma", `Prisoners${MODIFIER_LETTER_APOSTROPHE} Dilemma`],
|
|
134
|
+
// Mixed possessive + quoted phrase
|
|
135
|
+
["dogs' and 'hello'", `dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}`],
|
|
136
|
+
["'cats' and dogs'", `${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE} and dogs${MODIFIER_LETTER_APOSTROPHE}`],
|
|
137
|
+
// Matched closing quote after s stays RSQ
|
|
138
|
+
["'yes'", `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`],
|
|
139
|
+
["'dogs'", `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE}`],
|
|
140
|
+
["'he tells' stories", `${LEFT_SINGLE_QUOTE}he tells${RIGHT_SINGLE_QUOTE} stories`],
|
|
141
|
+
])('handles plural possessive: "%s"', (input, expected) => {
|
|
142
|
+
expect(niceQuotes(input, MLA)).toBe(expected)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it.each([
|
|
146
|
+
`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`,
|
|
147
|
+
`Bayes${MODIFIER_LETTER_APOSTROPHE} rule`,
|
|
148
|
+
`dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE}`,
|
|
149
|
+
])('plural possessive is idempotent: "%s"', (input) => {
|
|
150
|
+
expect(niceQuotes(input, MLA)).toBe(input)
|
|
151
|
+
expect(niceQuotes(niceQuotes(input, MLA), MLA)).toBe(input)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("stress: many possessives interleaved with quotes", () => {
|
|
155
|
+
// 50 alternating possessives and quoted phrases
|
|
156
|
+
const pairs = Array.from({ length: 50 }, (_, i) =>
|
|
157
|
+
i % 2 === 0 ? `dogs'` : `'yes'`
|
|
158
|
+
)
|
|
159
|
+
const input = pairs.join(" ")
|
|
160
|
+
const result = niceQuotes(input, MLA)
|
|
161
|
+
// Every dogs' → MLA, every 'yes' → LSQ+yes+RSQ
|
|
162
|
+
const expectedParts = Array.from({ length: 50 }, (_, i) =>
|
|
163
|
+
i % 2 === 0
|
|
164
|
+
? `dogs${MODIFIER_LETTER_APOSTROPHE}`
|
|
165
|
+
: `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`
|
|
166
|
+
)
|
|
167
|
+
expect(result).toBe(expectedParts.join(" "))
|
|
168
|
+
expect(niceQuotes(result, MLA)).toBe(result)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("stress: 200 consecutive plural possessives", () => {
|
|
172
|
+
const words = ["dogs'", "cats'", "Bayes'", "Thomas'", "PLAYERS'"]
|
|
173
|
+
const input = Array.from({ length: 200 }, (_, i) => words[i % words.length]).join(" ")
|
|
174
|
+
const start = performance.now()
|
|
175
|
+
const result = niceQuotes(input, MLA)
|
|
176
|
+
expect(performance.now() - start).toBeLessThan(1000)
|
|
177
|
+
// All should be MLA (no LSQ to pair with)
|
|
178
|
+
expect(result).not.toContain(RIGHT_SINGLE_QUOTE)
|
|
179
|
+
expect(result).not.toContain("'")
|
|
180
|
+
expect(niceQuotes(result, MLA)).toBe(result)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("stress: deeply nested alternating pattern", () => {
|
|
184
|
+
// Pattern: 'a' dogs' 'b' cats' 'c' ...
|
|
185
|
+
const possessives = ["dogs", "cats", "items", "players", "Bayes"]
|
|
186
|
+
const input = Array.from({ length: 30 }, (_, i) =>
|
|
187
|
+
i % 2 === 0 ? `'word${i}'` : `${possessives[i % possessives.length]}'`
|
|
188
|
+
).join(" ")
|
|
189
|
+
const result = niceQuotes(input, MLA)
|
|
190
|
+
// Quoted words get LSQ/RSQ, possessives get MLA
|
|
191
|
+
for (let i = 0; i < 30; i++) {
|
|
192
|
+
if (i % 2 === 0) {
|
|
193
|
+
expect(result).toContain(`${LEFT_SINGLE_QUOTE}word${i}${RIGHT_SINGLE_QUOTE}`)
|
|
194
|
+
} else {
|
|
195
|
+
expect(result).toContain(`${possessives[i % possessives.length]}${MODIFIER_LETTER_APOSTROPHE}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
expect(niceQuotes(result, MLA)).toBe(result)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
130
202
|
describe("leading apostrophe contractions", () => {
|
|
131
203
|
it.each([
|
|
132
204
|
["'twas the night", `${MODIFIER_LETTER_APOSTROPHE}twas the night`],
|
|
@@ -172,14 +244,6 @@ describe("niceQuotes", () => {
|
|
|
172
244
|
})
|
|
173
245
|
})
|
|
174
246
|
|
|
175
|
-
describe("nested quotes", () => {
|
|
176
|
-
it("handles double quotes containing single quotes", () => {
|
|
177
|
-
const input = '"She said \'hello\'"'
|
|
178
|
-
const expected = `${LEFT_DOUBLE_QUOTE}She said ${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`
|
|
179
|
-
expect(niceQuotes(input)).toBe(expected)
|
|
180
|
-
})
|
|
181
|
-
})
|
|
182
|
-
|
|
183
247
|
// Tests derived from competitor libraries and typography guidelines
|
|
184
248
|
describe("competitor-derived edge cases", () => {
|
|
185
249
|
// From smartquotes.js: Unicode text within quotes
|
|
@@ -269,7 +333,7 @@ describe("niceQuotes", () => {
|
|
|
269
333
|
`O${MODIFIER_LETTER_APOSTROPHE}NEILL`,
|
|
270
334
|
`CAN${MODIFIER_LETTER_APOSTROPHE}T`,
|
|
271
335
|
// Plural possessive
|
|
272
|
-
`the dogs${
|
|
336
|
+
`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`,
|
|
273
337
|
// Mixed quote types
|
|
274
338
|
`${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`,
|
|
275
339
|
])('is idempotent for: "%s"', (input) => {
|
|
@@ -296,6 +360,13 @@ describe("niceQuotes", () => {
|
|
|
296
360
|
expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
|
|
297
361
|
})
|
|
298
362
|
|
|
363
|
+
it("plural possessive across separator", () => {
|
|
364
|
+
const input = `dogs${sep}' food`
|
|
365
|
+
const expected = `dogs${sep}${MODIFIER_LETTER_APOSTROPHE} food`
|
|
366
|
+
expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
|
|
367
|
+
expect(niceQuotes(expected, { separator: sep, ...MLA })).toBe(expected)
|
|
368
|
+
})
|
|
369
|
+
|
|
299
370
|
it("'n' with separators around word boundaries", () => {
|
|
300
371
|
const input = `Rock${sep} 'n' ${sep}Roll`
|
|
301
372
|
const expected = `Rock${sep} ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} ${sep}Roll`
|
|
@@ -344,18 +415,6 @@ describe("niceQuotes", () => {
|
|
|
344
415
|
})
|
|
345
416
|
})
|
|
346
417
|
|
|
347
|
-
describe("quotes with special Unicode", () => {
|
|
348
|
-
it.each([
|
|
349
|
-
['"Hello! 😊"', `${LEFT_DOUBLE_QUOTE}Hello! 😊${RIGHT_DOUBLE_QUOTE}`],
|
|
350
|
-
['"你好"', `${LEFT_DOUBLE_QUOTE}你好${RIGHT_DOUBLE_QUOTE}`],
|
|
351
|
-
['"こんにちは"', `${LEFT_DOUBLE_QUOTE}こんにちは${RIGHT_DOUBLE_QUOTE}`],
|
|
352
|
-
['"مرحبا"', `${LEFT_DOUBLE_QUOTE}مرحبا${RIGHT_DOUBLE_QUOTE}`],
|
|
353
|
-
['"Привет"', `${LEFT_DOUBLE_QUOTE}Привет${RIGHT_DOUBLE_QUOTE}`],
|
|
354
|
-
])('handles Unicode content in quotes: "%s"', (input, expected) => {
|
|
355
|
-
expect(niceQuotes(input)).toBe(expected)
|
|
356
|
-
})
|
|
357
|
-
})
|
|
358
|
-
|
|
359
418
|
describe("multiline quotes", () => {
|
|
360
419
|
it.each([
|
|
361
420
|
['"Hello\nWorld"', `${LEFT_DOUBLE_QUOTE}Hello\nWorld${RIGHT_DOUBLE_QUOTE}`],
|
|
@@ -465,6 +524,7 @@ describe("niceQuotes", () => {
|
|
|
465
524
|
it.each([
|
|
466
525
|
["contractions", "don't", `don${RSQ}t`],
|
|
467
526
|
["possessives", "the dog's bone", `the dog${RSQ}s bone`],
|
|
527
|
+
["plural possessives", "the dogs' owner", `the dogs${RSQ} owner`],
|
|
468
528
|
["O'Brien-style names", "O'Brien's idea", `O${RSQ}Brien${RSQ}s idea`],
|
|
469
529
|
["'n' abbreviation", "Rock 'n' Roll", `Rock ${RSQ}n${RSQ} Roll`],
|
|
470
530
|
["decades", "the '90s", `the ${RSQ}90s`],
|
|
@@ -478,7 +538,7 @@ describe("niceQuotes", () => {
|
|
|
478
538
|
})
|
|
479
539
|
|
|
480
540
|
it("RSQ output is idempotent", () => {
|
|
481
|
-
const inputs = ["don't", "the dog's", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
541
|
+
const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
482
542
|
for (const input of inputs) {
|
|
483
543
|
const first = niceQuotes(input)
|
|
484
544
|
expect(niceQuotes(first)).toBe(first)
|
|
@@ -486,7 +546,7 @@ describe("niceQuotes", () => {
|
|
|
486
546
|
})
|
|
487
547
|
|
|
488
548
|
it("MLA output is idempotent", () => {
|
|
489
|
-
const inputs = ["don't", "the dog's", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
549
|
+
const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
490
550
|
for (const input of inputs) {
|
|
491
551
|
const first = niceQuotes(input, MLA)
|
|
492
552
|
expect(niceQuotes(first, MLA)).toBe(first)
|
|
@@ -524,6 +584,15 @@ describe("niceQuotes", () => {
|
|
|
524
584
|
it("normalizes RSQ possessive with digit prefix", () => {
|
|
525
585
|
expect(niceQuotes(`GPT-2${RIGHT_SINGLE_QUOTE}s`, MLA)).toBe(`GPT-2${MODIFIER_LETTER_APOSTROPHE}s`)
|
|
526
586
|
})
|
|
587
|
+
|
|
588
|
+
it("normalizes RSQ plural possessive to MLA", () => {
|
|
589
|
+
expect(niceQuotes(`the dogs${RIGHT_SINGLE_QUOTE} owner`, MLA)).toBe(`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it("preserves RSQ plural possessive when matched with LSQ", () => {
|
|
593
|
+
const input = `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE} and more`
|
|
594
|
+
expect(niceQuotes(input, MLA)).toBe(input)
|
|
595
|
+
})
|
|
527
596
|
})
|
|
528
597
|
})
|
|
529
598
|
|