punctilio 2.2.3 → 3.0.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/README.md +1 -2
- package/dist/index.d.ts +1 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -14
- package/dist/index.js.map +1 -1
- package/dist/quotes.d.ts +11 -11
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +50 -9
- package/dist/quotes.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +3 -26
- package/src/quotes.ts +55 -20
- package/src/tests/quotes.test.ts +133 -67
- package/src/tests/remark.test.ts +1 -1
package/README.md
CHANGED
|
@@ -137,7 +137,6 @@ transform(text, {
|
|
|
137
137
|
ligatures: false, // ??? → ⁇, ?! → ⁈, !? → ⁉, !!! → !
|
|
138
138
|
nbsp: true, // non-breaking spaces (after honorifics, between numbers and units, etc.)
|
|
139
139
|
checkIdempotency: true, // verify transform(transform(x)) === transform(x)
|
|
140
|
-
useModifierLetterApostrophe: false, // output distinct codepoints for apostrophes vs end-single-quotes
|
|
141
140
|
})
|
|
142
141
|
```
|
|
143
142
|
|
|
@@ -150,4 +149,4 @@ transform(text, {
|
|
|
150
149
|
- Spaced en-dashes between words (word – word)
|
|
151
150
|
- Setting either style to `none` skips the entire transform category: `punctuationStyle: 'none'` preserves straight quotes, apostrophes, and prime marks; `dashStyle: 'none'` preserves all hyphens, number ranges, date ranges, and minus signs.
|
|
152
151
|
- `punctilio` is idempotent by design: `transform(transform(text))` always equals `transform(text)`. If performance is critical, set `checkIdempotency: false` to skip the verification pass.
|
|
153
|
-
-
|
|
152
|
+
- Use `classifyApostrophes(text)` to distinguish apostrophes from closing single quotes. It returns text with apostrophes as U+02BC (MODIFIER LETTER APOSTROPHE) and closing quotes as U+2019 (RIGHT SINGLE QUOTATION MARK). Per the [Unicode Standard](https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-6/#G30602), `transform()` and `niceQuotes()` use U+2019 for both in their output.
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
|
-
export { niceQuotes, type QuoteOptions, type PunctuationStyle } from "./quotes.js";
|
|
10
|
+
export { niceQuotes, classifyApostrophes, type QuoteOptions, type PunctuationStyle } from "./quotes.js";
|
|
11
11
|
import type { PunctuationStyle } from "./quotes.js";
|
|
12
12
|
export { hyphenReplace, enDashNumberRange, enDashDateRange, minusReplace, months, numberRangeDisallowedPrefixes, type DashOptions, type DashStyle, } from "./dashes.js";
|
|
13
13
|
import type { DashStyle } from "./dashes.js";
|
|
@@ -105,17 +105,6 @@ export interface TransformOptions {
|
|
|
105
105
|
* Default: true
|
|
106
106
|
*/
|
|
107
107
|
checkIdempotency?: boolean;
|
|
108
|
-
/**
|
|
109
|
-
* Use modifier letter apostrophe (U+02BC) instead of right single quote
|
|
110
|
-
* (U+2019) for contractions and possessives. This distinguishes apostrophes
|
|
111
|
-
* from closing quotes in most cases, though some ambiguous patterns (e.g.,
|
|
112
|
-
* `dogs'` vs a closing single quote) cannot be resolved without semantic
|
|
113
|
-
* understanding. May also break downstream regex patterns that expect
|
|
114
|
-
* standard quote characters like /don\u2019t/ or /O\u2019Brien/.
|
|
115
|
-
*
|
|
116
|
-
* Default: false
|
|
117
|
-
*/
|
|
118
|
-
useModifierLetterApostrophe?: boolean;
|
|
119
108
|
}
|
|
120
109
|
export { assertSeparatorCountPreserved, countSeparators } from "./utils.js";
|
|
121
110
|
export { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,KAAK,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAA;AACvG,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AACnD,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,eAAe,EACf,YAAY,EACZ,MAAM,EACN,6BAA6B,EAC7B,KAAK,WAAW,EAChB,KAAK,SAAS,GACf,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAC5C,OAAO,EACL,QAAQ,EACR,cAAc,EACd,WAAW,EACX,YAAY,EACZ,MAAM,EACN,OAAO,EACP,SAAS,EACT,UAAU,EACV,cAAc,EACd,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,KAAK,aAAa,GACnB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,mBAAmB,EACnB,wBAAwB,EACxB,kBAAkB,EAClB,+BAA+B,EAC/B,uBAAuB,EACvB,mBAAmB,EACnB,yBAAyB,EACzB,mBAAmB,EACnB,aAAa,EACb,KAAK,EACL,UAAU,EACV,uBAAuB,EACvB,KAAK,WAAW,GACjB,MAAM,WAAW,CAAA;AAElB,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IAExB;;;;;;;;;;;;;OAaG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IAEnC;;;;;;;;;;;;;OAaG;IACH,SAAS,CAAC,EAAE,SAAS,CAAA;IAErB;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IAEnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IAErB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IAEnB;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IAEd;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAE3B;AASD,OAAO,EAAE,6BAA6B,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AACnE,eAAO,MAAM,0BAA0B,KAA6C,CAAA;AAiDpF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAmE9E"}
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
|
-
export { niceQuotes } from "./quotes.js";
|
|
10
|
+
export { niceQuotes, classifyApostrophes } from "./quotes.js";
|
|
11
11
|
export { hyphenReplace, enDashNumberRange, enDashDateRange, minusReplace, months, numberRangeDisallowedPrefixes, } from "./dashes.js";
|
|
12
12
|
export { ellipsis, multiplication, mathSymbols, legalSymbols, arrows, degrees, fractions, primeMarks, collapseSpaces, superscriptOrdinal, punctuationLigatures, symbolTransform, } from "./symbols.js";
|
|
13
13
|
export { nbspAfterShortWords, nbspBetweenNumberAndUnit, nbspBeforeLastWord, nbspAfterReferenceAbbreviations, nbspAfterSectionSymbols, nbspAfterHonorifics, nbspAfterCopyrightSymbols, nbspBetweenInitials, nbspTransform, UNITS, HONORIFICS, REFERENCE_ABBREVIATIONS, } from "./nbsp.js";
|
|
@@ -65,7 +65,6 @@ const defaultOpts = {
|
|
|
65
65
|
checkIdempotency: true,
|
|
66
66
|
punctuationStyle: "american",
|
|
67
67
|
dashStyle: "american",
|
|
68
|
-
useModifierLetterApostrophe: false,
|
|
69
68
|
};
|
|
70
69
|
export function transform(text, options = {}) {
|
|
71
70
|
const separator = options.separator ?? DEFAULT_SEPARATOR;
|
|
@@ -77,17 +76,12 @@ export function transform(text, options = {}) {
|
|
|
77
76
|
`Use a single-codepoint character like the default "\\uE000".`);
|
|
78
77
|
}
|
|
79
78
|
const original = text;
|
|
80
|
-
const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency,
|
|
79
|
+
const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options };
|
|
81
80
|
text = hyphenReplace(text, separatorOpts);
|
|
82
81
|
if (separatorOpts.punctuationStyle !== "none") {
|
|
83
82
|
text = primeMarks(text, separatorOpts);
|
|
84
83
|
}
|
|
85
|
-
|
|
86
|
-
// because NBSP's PUNCTUATION_OR_QUOTE class includes RSQ but not MLA.
|
|
87
|
-
// Without this, apostrophes in "don't" would act as word boundaries
|
|
88
|
-
// during NBSP insertion. The final MLA→RSQ conversion at line 286
|
|
89
|
-
// runs after all transforms complete.
|
|
90
|
-
text = niceQuotes(text, { ...separatorOpts, useModifierLetterApostrophe: true });
|
|
84
|
+
text = niceQuotes(text, separatorOpts);
|
|
91
85
|
if (symbols) {
|
|
92
86
|
text = symbolTransform(text, separatorOpts);
|
|
93
87
|
}
|
|
@@ -110,11 +104,6 @@ export function transform(text, options = {}) {
|
|
|
110
104
|
text = nbspTransformFn(text, separatorOpts);
|
|
111
105
|
}
|
|
112
106
|
assertSeparatorCountPreserved(original, text, separator, "transform");
|
|
113
|
-
// Convert MLA→RSQ for regex-friendly output, after all transforms
|
|
114
|
-
// (especially NBSP which relies on MLA being letter-like).
|
|
115
|
-
if (!useModifierLetterApostrophe) {
|
|
116
|
-
text = text.replaceAll(UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE, UNICODE_SYMBOLS.RIGHT_SINGLE_QUOTE);
|
|
117
|
-
}
|
|
118
107
|
if (checkIdempotency) {
|
|
119
108
|
const secondPass = transform(text, { ...options, checkIdempotency: false });
|
|
120
109
|
/* istanbul ignore if -- defensive check that should never trigger */
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAA4C,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/quotes.d.ts
CHANGED
|
@@ -7,18 +7,18 @@ export interface QuoteOptions {
|
|
|
7
7
|
separator?: string;
|
|
8
8
|
/** "american" (inside), "british" (outside), "none". Default: "american" */
|
|
9
9
|
punctuationStyle?: PunctuationStyle;
|
|
10
|
-
/**
|
|
11
|
-
* Use modifier letter apostrophe (U+02BC) instead of right single quote
|
|
12
|
-
* (U+2019) for contractions and possessives. This distinguishes apostrophes
|
|
13
|
-
* from closing quotes in most cases, though some ambiguous patterns (e.g.,
|
|
14
|
-
* `dogs'` vs a closing single quote) cannot be resolved without semantic
|
|
15
|
-
* understanding. May also break downstream regex patterns that expect
|
|
16
|
-
* standard quote characters like /don\u2019t/ or /O\u2019Brien/.
|
|
17
|
-
*
|
|
18
|
-
* Default: false
|
|
19
|
-
*/
|
|
20
|
-
useModifierLetterApostrophe?: boolean;
|
|
21
10
|
}
|
|
22
11
|
/** Convert straight quotes to smart quotes. */
|
|
23
12
|
export declare function niceQuotes(text: string, options?: QuoteOptions): string;
|
|
13
|
+
/**
|
|
14
|
+
* Classify apostrophes vs. closing single quotes.
|
|
15
|
+
*
|
|
16
|
+
* Returns the text with smart quotes applied, where apostrophes are
|
|
17
|
+
* U+02BC (MODIFIER LETTER APOSTROPHE) and closing single quotes are
|
|
18
|
+
* U+2019 (RIGHT SINGLE QUOTATION MARK).
|
|
19
|
+
*
|
|
20
|
+
* For display output, use {@link niceQuotes} or `transform()` instead,
|
|
21
|
+
* which use U+2019 for both per the Unicode Standard.
|
|
22
|
+
*/
|
|
23
|
+
export declare function classifyApostrophes(text: string, options?: QuoteOptions): string;
|
|
24
24
|
//# sourceMappingURL=quotes.d.ts.map
|
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;
|
|
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;CACpC;AA6JD,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,CAK3E;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,CAEpF"}
|
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);
|
|
@@ -77,8 +104,8 @@ function applyPunctuationStyle(text, sep, style) {
|
|
|
77
104
|
}
|
|
78
105
|
return text;
|
|
79
106
|
}
|
|
80
|
-
/**
|
|
81
|
-
|
|
107
|
+
/** Shared quote-processing pipeline. Returns text with MLA for apostrophes. */
|
|
108
|
+
function processQuotes(text, options) {
|
|
82
109
|
const sep = options.separator ?? DEFAULT_SEPARATOR;
|
|
83
110
|
const punctuationStyle = options.punctuationStyle ?? "american";
|
|
84
111
|
if (punctuationStyle === "none")
|
|
@@ -86,12 +113,26 @@ export function niceQuotes(text, options = {}) {
|
|
|
86
113
|
text = convertSingleQuotes(text, sep);
|
|
87
114
|
text = convertDoubleQuotes(text, sep);
|
|
88
115
|
text = applyPunctuationStyle(text, sep, punctuationStyle);
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
/** Convert straight quotes to smart quotes. */
|
|
119
|
+
export function niceQuotes(text, options = {}) {
|
|
89
120
|
// MLA is used internally so applyPunctuationStyle can distinguish
|
|
90
121
|
// apostrophes (MLA, don't move) from closing quotes (RSQ, do move).
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
122
|
+
// Always convert back to RSQ for standard output per Unicode.
|
|
123
|
+
return processQuotes(text, options).replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Classify apostrophes vs. closing single quotes.
|
|
127
|
+
*
|
|
128
|
+
* Returns the text with smart quotes applied, where apostrophes are
|
|
129
|
+
* U+02BC (MODIFIER LETTER APOSTROPHE) and closing single quotes are
|
|
130
|
+
* U+2019 (RIGHT SINGLE QUOTATION MARK).
|
|
131
|
+
*
|
|
132
|
+
* For display output, use {@link niceQuotes} or `transform()` instead,
|
|
133
|
+
* which use U+2019 for both per the Unicode Standard.
|
|
134
|
+
*/
|
|
135
|
+
export function classifyApostrophes(text, options = {}) {
|
|
136
|
+
return processQuotes(text, options);
|
|
96
137
|
}
|
|
97
138
|
//# sourceMappingURL=quotes.js.map
|
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;
|
|
1
|
+
{"version":3,"file":"quotes.js","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
export { niceQuotes, type QuoteOptions, type PunctuationStyle } from "./quotes.js"
|
|
11
|
+
export { niceQuotes, classifyApostrophes, type QuoteOptions, type PunctuationStyle } from "./quotes.js"
|
|
12
12
|
import type { PunctuationStyle } from "./quotes.js"
|
|
13
13
|
export {
|
|
14
14
|
hyphenReplace,
|
|
@@ -155,17 +155,6 @@ export interface TransformOptions {
|
|
|
155
155
|
*/
|
|
156
156
|
checkIdempotency?: boolean
|
|
157
157
|
|
|
158
|
-
/**
|
|
159
|
-
* Use modifier letter apostrophe (U+02BC) instead of right single quote
|
|
160
|
-
* (U+2019) for contractions and possessives. This distinguishes apostrophes
|
|
161
|
-
* from closing quotes in most cases, though some ambiguous patterns (e.g.,
|
|
162
|
-
* `dogs'` vs a closing single quote) cannot be resolved without semantic
|
|
163
|
-
* understanding. May also break downstream regex patterns that expect
|
|
164
|
-
* standard quote characters like /don\u2019t/ or /O\u2019Brien/.
|
|
165
|
-
*
|
|
166
|
-
* Default: false
|
|
167
|
-
*/
|
|
168
|
-
useModifierLetterApostrophe?: boolean
|
|
169
158
|
}
|
|
170
159
|
|
|
171
160
|
import { niceQuotes } from "./quotes.js"
|
|
@@ -224,7 +213,6 @@ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
|
|
|
224
213
|
checkIdempotency: true,
|
|
225
214
|
punctuationStyle: "american",
|
|
226
215
|
dashStyle: "american",
|
|
227
|
-
useModifierLetterApostrophe: false,
|
|
228
216
|
}
|
|
229
217
|
|
|
230
218
|
export function transform(text: string, options: TransformOptions = {}): string {
|
|
@@ -241,18 +229,13 @@ export function transform(text: string, options: TransformOptions = {}): string
|
|
|
241
229
|
}
|
|
242
230
|
|
|
243
231
|
const original = text
|
|
244
|
-
const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency,
|
|
232
|
+
const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options }
|
|
245
233
|
|
|
246
234
|
text = hyphenReplace(text, separatorOpts)
|
|
247
235
|
if (separatorOpts.punctuationStyle !== "none") {
|
|
248
236
|
text = primeMarks(text, separatorOpts)
|
|
249
237
|
}
|
|
250
|
-
|
|
251
|
-
// because NBSP's PUNCTUATION_OR_QUOTE class includes RSQ but not MLA.
|
|
252
|
-
// Without this, apostrophes in "don't" would act as word boundaries
|
|
253
|
-
// during NBSP insertion. The final MLA→RSQ conversion at line 286
|
|
254
|
-
// runs after all transforms complete.
|
|
255
|
-
text = niceQuotes(text, { ...separatorOpts, useModifierLetterApostrophe: true })
|
|
238
|
+
text = niceQuotes(text, separatorOpts)
|
|
256
239
|
|
|
257
240
|
if (symbols) {
|
|
258
241
|
text = symbolTransform(text, separatorOpts)
|
|
@@ -284,12 +267,6 @@ export function transform(text: string, options: TransformOptions = {}): string
|
|
|
284
267
|
|
|
285
268
|
assertSeparatorCountPreserved(original, text, separator, "transform")
|
|
286
269
|
|
|
287
|
-
// Convert MLA→RSQ for regex-friendly output, after all transforms
|
|
288
|
-
// (especially NBSP which relies on MLA being letter-like).
|
|
289
|
-
if (!useModifierLetterApostrophe) {
|
|
290
|
-
text = text.replaceAll(UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE, UNICODE_SYMBOLS.RIGHT_SINGLE_QUOTE)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
270
|
if (checkIdempotency) {
|
|
294
271
|
const secondPass = transform(text, { ...options, checkIdempotency: false })
|
|
295
272
|
/* istanbul ignore if -- defensive check that should never trigger */
|
package/src/quotes.ts
CHANGED
|
@@ -22,17 +22,6 @@ export interface QuoteOptions {
|
|
|
22
22
|
separator?: string
|
|
23
23
|
/** "american" (inside), "british" (outside), "none". Default: "american" */
|
|
24
24
|
punctuationStyle?: PunctuationStyle
|
|
25
|
-
/**
|
|
26
|
-
* Use modifier letter apostrophe (U+02BC) instead of right single quote
|
|
27
|
-
* (U+2019) for contractions and possessives. This distinguishes apostrophes
|
|
28
|
-
* from closing quotes in most cases, though some ambiguous patterns (e.g.,
|
|
29
|
-
* `dogs'` vs a closing single quote) cannot be resolved without semantic
|
|
30
|
-
* understanding. May also break downstream regex patterns that expect
|
|
31
|
-
* standard quote characters like /don\u2019t/ or /O\u2019Brien/.
|
|
32
|
-
*
|
|
33
|
-
* Default: false
|
|
34
|
-
*/
|
|
35
|
-
useModifierLetterApostrophe?: boolean
|
|
36
25
|
}
|
|
37
26
|
|
|
38
27
|
/** Convert straight single quotes to curly quotes and apostrophes */
|
|
@@ -42,8 +31,9 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
42
31
|
// Handle empty single quotes '' and whitespace-only quotes ' ' first
|
|
43
32
|
// Only match straight quotes, not already-converted curly quotes
|
|
44
33
|
const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
|
|
45
|
-
|
|
46
|
-
text = text.replace(new RegExp(`(
|
|
34
|
+
const singleQuoteOrWord = `[${singleQuoteChars}\\w]`
|
|
35
|
+
text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
|
|
36
|
+
text = text.replace(new RegExp(`(?<!${singleQuoteOrWord})'(\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
|
|
47
37
|
|
|
48
38
|
const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
|
|
49
39
|
// Full pattern with optional 's' for lookahead detection in apostropheRegex
|
|
@@ -80,9 +70,39 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
80
70
|
const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
|
|
81
71
|
text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
|
|
82
72
|
|
|
73
|
+
text = convertUnmatchedPluralPossessives(text, sep)
|
|
74
|
+
|
|
83
75
|
return text
|
|
84
76
|
}
|
|
85
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Convert unmatched RSQ after s/S to MLA (plural possessives like dogs', Bayes').
|
|
80
|
+
* Tracks LSQ/RSQ balance left-to-right so that paired closing quotes
|
|
81
|
+
* (e.g., 'yes' where s precedes RSQ) remain RSQ.
|
|
82
|
+
*/
|
|
83
|
+
function convertUnmatchedPluralPossessives(text: string, sep: string): string {
|
|
84
|
+
let singleQuoteBalance = 0
|
|
85
|
+
return text.replace(
|
|
86
|
+
new RegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"),
|
|
87
|
+
(match, offset) => {
|
|
88
|
+
if (match === LEFT_SINGLE_QUOTE) {
|
|
89
|
+
singleQuoteBalance++
|
|
90
|
+
return match
|
|
91
|
+
}
|
|
92
|
+
if (singleQuoteBalance > 0) {
|
|
93
|
+
singleQuoteBalance--
|
|
94
|
+
return match
|
|
95
|
+
}
|
|
96
|
+
let i = offset - 1
|
|
97
|
+
while (i >= 0 && text[i] === sep) i--
|
|
98
|
+
if (i >= 0 && (text[i] === "s" || text[i] === "S")) {
|
|
99
|
+
return MODIFIER_LETTER_APOSTROPHE
|
|
100
|
+
}
|
|
101
|
+
return match
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
/** Convert straight double quotes to curly quotes */
|
|
87
107
|
function convertDoubleQuotes(text: string, sep: string): string {
|
|
88
108
|
const escapedSep = escapeStringRegexp(sep)
|
|
@@ -146,8 +166,8 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
|
|
|
146
166
|
return text
|
|
147
167
|
}
|
|
148
168
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
169
|
+
/** Shared quote-processing pipeline. Returns text with MLA for apostrophes. */
|
|
170
|
+
function processQuotes(text: string, options: QuoteOptions): string {
|
|
151
171
|
const sep = options.separator ?? DEFAULT_SEPARATOR
|
|
152
172
|
const punctuationStyle = options.punctuationStyle ?? "american"
|
|
153
173
|
if (punctuationStyle === "none") return text
|
|
@@ -156,12 +176,27 @@ export function niceQuotes(text: string, options: QuoteOptions = {}): string {
|
|
|
156
176
|
text = convertDoubleQuotes(text, sep)
|
|
157
177
|
text = applyPunctuationStyle(text, sep, punctuationStyle)
|
|
158
178
|
|
|
179
|
+
return text
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Convert straight quotes to smart quotes. */
|
|
183
|
+
export function niceQuotes(text: string, options: QuoteOptions = {}): string {
|
|
159
184
|
// MLA is used internally so applyPunctuationStyle can distinguish
|
|
160
185
|
// apostrophes (MLA, don't move) from closing quotes (RSQ, do move).
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
186
|
+
// Always convert back to RSQ for standard output per Unicode.
|
|
187
|
+
return processQuotes(text, options).replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE)
|
|
188
|
+
}
|
|
165
189
|
|
|
166
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Classify apostrophes vs. closing single quotes.
|
|
192
|
+
*
|
|
193
|
+
* Returns the text with smart quotes applied, where apostrophes are
|
|
194
|
+
* U+02BC (MODIFIER LETTER APOSTROPHE) and closing single quotes are
|
|
195
|
+
* U+2019 (RIGHT SINGLE QUOTATION MARK).
|
|
196
|
+
*
|
|
197
|
+
* For display output, use {@link niceQuotes} or `transform()` instead,
|
|
198
|
+
* which use U+2019 for both per the Unicode Standard.
|
|
199
|
+
*/
|
|
200
|
+
export function classifyApostrophes(text: string, options: QuoteOptions = {}): string {
|
|
201
|
+
return processQuotes(text, options)
|
|
167
202
|
}
|
package/src/tests/quotes.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { niceQuotes } from "../quotes.js"
|
|
1
|
+
import { niceQuotes, classifyApostrophes } from "../quotes.js"
|
|
2
2
|
import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
|
|
3
3
|
|
|
4
4
|
const {
|
|
@@ -11,16 +11,12 @@ const {
|
|
|
11
11
|
ELLIPSIS,
|
|
12
12
|
} = UNICODE_SYMBOLS
|
|
13
13
|
|
|
14
|
-
/** Enable MLA output for tests that assert the apostrophe codepoint. */
|
|
15
|
-
const MLA = { useModifierLetterApostrophe: true } as const
|
|
16
|
-
|
|
17
14
|
describe("niceQuotes", () => {
|
|
18
15
|
describe("double quotes", () => {
|
|
19
16
|
it.each([
|
|
20
17
|
['"This is a quote", she said.', `${LEFT_DOUBLE_QUOTE}This is a quote,${RIGHT_DOUBLE_QUOTE} she said.`],
|
|
21
18
|
['"This is a quote," she said.', `${LEFT_DOUBLE_QUOTE}This is a quote,${RIGHT_DOUBLE_QUOTE} she said.`],
|
|
22
19
|
['"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
20
|
['"This is a quote..." he trailed off.', `${LEFT_DOUBLE_QUOTE}This is a quote...${RIGHT_DOUBLE_QUOTE} he trailed off.`],
|
|
25
21
|
['She said, "This is a quote."', `She said, ${LEFT_DOUBLE_QUOTE}This is a quote.${RIGHT_DOUBLE_QUOTE}`],
|
|
26
22
|
['"Hello." Mary', `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} Mary`],
|
|
@@ -53,7 +49,7 @@ describe("niceQuotes", () => {
|
|
|
53
49
|
// End-of-line quote becomes RIGHT quote
|
|
54
50
|
['not confident in that plan - "', `not confident in that plan - ${RIGHT_DOUBLE_QUOTE}`],
|
|
55
51
|
])('should convert double quotes in "%s"', (input, expected) => {
|
|
56
|
-
expect(
|
|
52
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
57
53
|
})
|
|
58
54
|
})
|
|
59
55
|
|
|
@@ -85,9 +81,6 @@ describe("niceQuotes", () => {
|
|
|
85
81
|
"strategy s's return is good, even as d's return is bad",
|
|
86
82
|
`strategy s${MODIFIER_LETTER_APOSTROPHE}s return is good, even as d${MODIFIER_LETTER_APOSTROPHE}s return is bad`,
|
|
87
83
|
],
|
|
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
84
|
// Chained possessives
|
|
92
85
|
["the dog's owner's car", `the dog${MODIFIER_LETTER_APOSTROPHE}s owner${MODIFIER_LETTER_APOSTROPHE}s car`],
|
|
93
86
|
// Hyphenated possessive
|
|
@@ -102,12 +95,10 @@ describe("niceQuotes", () => {
|
|
|
102
95
|
[`don't${EM_DASH}stop`, `don${MODIFIER_LETTER_APOSTROPHE}t${EM_DASH}stop`],
|
|
103
96
|
// Contraction + digit context
|
|
104
97
|
["it's 5 o'clock", `it${MODIFIER_LETTER_APOSTROPHE}s 5 o${MODIFIER_LETTER_APOSTROPHE}clock`],
|
|
105
|
-
// French
|
|
98
|
+
// French contraction (single-letter prefix + accented Latin)
|
|
106
99
|
["l'homme", `l${MODIFIER_LETTER_APOSTROPHE}homme`],
|
|
107
|
-
|
|
108
|
-
// Uppercase contractions and names
|
|
100
|
+
// Uppercase contraction/name
|
|
109
101
|
["O'NEILL", `O${MODIFIER_LETTER_APOSTROPHE}NEILL`],
|
|
110
|
-
["CAN'T", `CAN${MODIFIER_LETTER_APOSTROPHE}T`],
|
|
111
102
|
// Possessive in parenthetical
|
|
112
103
|
["(Brien's)", `(Brien${MODIFIER_LETTER_APOSTROPHE}s)`],
|
|
113
104
|
// Possessive inside single-quoted phrase
|
|
@@ -123,7 +114,85 @@ describe("niceQuotes", () => {
|
|
|
123
114
|
// Closing RSQ then opening LDQ
|
|
124
115
|
[`'hello' "world"`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`],
|
|
125
116
|
])('should handle single quotes/apostrophes in "%s"', (input, expected) => {
|
|
126
|
-
expect(
|
|
117
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe("plural possessive apostrophes", () => {
|
|
122
|
+
it.each([
|
|
123
|
+
// Unmatched trailing s' → MLA
|
|
124
|
+
["the models' behavior", `the models${MODIFIER_LETTER_APOSTROPHE} behavior`],
|
|
125
|
+
["Bayes' rule", `Bayes${MODIFIER_LETTER_APOSTROPHE} rule`],
|
|
126
|
+
["Thomas' gaze", `Thomas${MODIFIER_LETTER_APOSTROPHE} gaze`],
|
|
127
|
+
["dogs' and cats' toys", `dogs${MODIFIER_LETTER_APOSTROPHE} and cats${MODIFIER_LETTER_APOSTROPHE} toys`],
|
|
128
|
+
["belongs to the dogs'", `belongs to the dogs${MODIFIER_LETTER_APOSTROPHE}`],
|
|
129
|
+
["the DOGS' owner", `the DOGS${MODIFIER_LETTER_APOSTROPHE} owner`],
|
|
130
|
+
["Prisoners' Dilemma", `Prisoners${MODIFIER_LETTER_APOSTROPHE} Dilemma`],
|
|
131
|
+
// Mixed possessive + quoted phrase
|
|
132
|
+
["dogs' and 'hello'", `dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}`],
|
|
133
|
+
["'cats' and dogs'", `${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE} and dogs${MODIFIER_LETTER_APOSTROPHE}`],
|
|
134
|
+
// Matched closing quote after s stays RSQ
|
|
135
|
+
["'yes'", `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`],
|
|
136
|
+
["'dogs'", `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE}`],
|
|
137
|
+
["'he tells' stories", `${LEFT_SINGLE_QUOTE}he tells${RIGHT_SINGLE_QUOTE} stories`],
|
|
138
|
+
])('handles plural possessive: "%s"', (input, expected) => {
|
|
139
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it.each([
|
|
143
|
+
`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`,
|
|
144
|
+
`Bayes${MODIFIER_LETTER_APOSTROPHE} rule`,
|
|
145
|
+
`dogs${MODIFIER_LETTER_APOSTROPHE} and ${LEFT_SINGLE_QUOTE}cats${RIGHT_SINGLE_QUOTE}`,
|
|
146
|
+
])('plural possessive is idempotent: "%s"', (input) => {
|
|
147
|
+
expect(classifyApostrophes(input)).toBe(input)
|
|
148
|
+
expect(classifyApostrophes(classifyApostrophes(input))).toBe(input)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("stress: many possessives interleaved with quotes", () => {
|
|
152
|
+
// 50 alternating possessives and quoted phrases
|
|
153
|
+
const pairs = Array.from({ length: 50 }, (_, i) =>
|
|
154
|
+
i % 2 === 0 ? `dogs'` : `'yes'`
|
|
155
|
+
)
|
|
156
|
+
const input = pairs.join(" ")
|
|
157
|
+
const result = classifyApostrophes(input)
|
|
158
|
+
// Every dogs' → MLA, every 'yes' → LSQ+yes+RSQ
|
|
159
|
+
const expectedParts = Array.from({ length: 50 }, (_, i) =>
|
|
160
|
+
i % 2 === 0
|
|
161
|
+
? `dogs${MODIFIER_LETTER_APOSTROPHE}`
|
|
162
|
+
: `${LEFT_SINGLE_QUOTE}yes${RIGHT_SINGLE_QUOTE}`
|
|
163
|
+
)
|
|
164
|
+
expect(result).toBe(expectedParts.join(" "))
|
|
165
|
+
expect(classifyApostrophes(result)).toBe(result)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("stress: 200 consecutive plural possessives", () => {
|
|
169
|
+
const words = ["dogs'", "cats'", "Bayes'", "Thomas'", "PLAYERS'"]
|
|
170
|
+
const input = Array.from({ length: 200 }, (_, i) => words[i % words.length]).join(" ")
|
|
171
|
+
const start = performance.now()
|
|
172
|
+
const result = classifyApostrophes(input)
|
|
173
|
+
expect(performance.now() - start).toBeLessThan(1000)
|
|
174
|
+
// All should be MLA (no LSQ to pair with)
|
|
175
|
+
expect(result).not.toContain(RIGHT_SINGLE_QUOTE)
|
|
176
|
+
expect(result).not.toContain("'")
|
|
177
|
+
expect(classifyApostrophes(result)).toBe(result)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it("stress: deeply nested alternating pattern", () => {
|
|
181
|
+
// Pattern: 'a' dogs' 'b' cats' 'c' ...
|
|
182
|
+
const possessives = ["dogs", "cats", "items", "players", "Bayes"]
|
|
183
|
+
const input = Array.from({ length: 30 }, (_, i) =>
|
|
184
|
+
i % 2 === 0 ? `'word${i}'` : `${possessives[i % possessives.length]}'`
|
|
185
|
+
).join(" ")
|
|
186
|
+
const result = classifyApostrophes(input)
|
|
187
|
+
// Quoted words get LSQ/RSQ, possessives get MLA
|
|
188
|
+
for (let i = 0; i < 30; i++) {
|
|
189
|
+
if (i % 2 === 0) {
|
|
190
|
+
expect(result).toContain(`${LEFT_SINGLE_QUOTE}word${i}${RIGHT_SINGLE_QUOTE}`)
|
|
191
|
+
} else {
|
|
192
|
+
expect(result).toContain(`${possessives[i % possessives.length]}${MODIFIER_LETTER_APOSTROPHE}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
expect(classifyApostrophes(result)).toBe(result)
|
|
127
196
|
})
|
|
128
197
|
})
|
|
129
198
|
|
|
@@ -142,11 +211,11 @@ describe("niceQuotes", () => {
|
|
|
142
211
|
["'twas the night, 'twas indeed", `${MODIFIER_LETTER_APOSTROPHE}twas the night, ${MODIFIER_LETTER_APOSTROPHE}twas indeed`],
|
|
143
212
|
[`"'twas the night"`, `${LEFT_DOUBLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}twas the night${RIGHT_DOUBLE_QUOTE}`],
|
|
144
213
|
])('handles leading apostrophe in "%s"', (input, expected) => {
|
|
145
|
-
expect(
|
|
214
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
146
215
|
})
|
|
147
216
|
|
|
148
217
|
it("multiple decades in one sentence", () => {
|
|
149
|
-
expect(
|
|
218
|
+
expect(classifyApostrophes("The '60s, '70s, and '80s were different.")).toBe(
|
|
150
219
|
`The ${MODIFIER_LETTER_APOSTROPHE}60s, ${MODIFIER_LETTER_APOSTROPHE}70s, and ${MODIFIER_LETTER_APOSTROPHE}80s were different.`
|
|
151
220
|
)
|
|
152
221
|
})
|
|
@@ -159,7 +228,7 @@ describe("niceQuotes", () => {
|
|
|
159
228
|
["digit neighbors (\\w includes digits)", "1 'n' 2", `1 ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} 2`],
|
|
160
229
|
["known false positive: quoted letter", "the letter 'n' is common", `the letter ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} is common`],
|
|
161
230
|
])('%s', (_label, input, expected) => {
|
|
162
|
-
expect(
|
|
231
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
163
232
|
})
|
|
164
233
|
|
|
165
234
|
it.each([
|
|
@@ -168,15 +237,7 @@ describe("niceQuotes", () => {
|
|
|
168
237
|
["punctuation precedes first apostrophe", "said, 'n' is good", `said, ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} is good`],
|
|
169
238
|
["newline replaces space", "Rock 'n'\nRoll", `Rock ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE}\nRoll`],
|
|
170
239
|
])('does not produce MLA-n-MLA: %s', (_label, input, expected) => {
|
|
171
|
-
expect(
|
|
172
|
-
})
|
|
173
|
-
})
|
|
174
|
-
|
|
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)
|
|
240
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
180
241
|
})
|
|
181
242
|
})
|
|
182
243
|
|
|
@@ -213,7 +274,7 @@ describe("niceQuotes", () => {
|
|
|
213
274
|
[`O'Brien's idea`, `O${MODIFIER_LETTER_APOSTROPHE}Brien${MODIFIER_LETTER_APOSTROPHE}s idea`],
|
|
214
275
|
[`The O'Connors`, `The O${MODIFIER_LETTER_APOSTROPHE}Connors`],
|
|
215
276
|
])('handles Irish/Scottish name apostrophes: "%s"', (input, expected) => {
|
|
216
|
-
expect(
|
|
277
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
217
278
|
})
|
|
218
279
|
|
|
219
280
|
// From smartquotes.js: complex nested quotes
|
|
@@ -247,7 +308,7 @@ describe("niceQuotes", () => {
|
|
|
247
308
|
// Quote with slash separator
|
|
248
309
|
['"option1"/"option2"', `${LEFT_DOUBLE_QUOTE}option1${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}option2${RIGHT_DOUBLE_QUOTE}`],
|
|
249
310
|
])('handles complex pattern: "%s"', (input, expected) => {
|
|
250
|
-
expect(
|
|
311
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
251
312
|
})
|
|
252
313
|
|
|
253
314
|
it("handles multi-line dialogue", () => {
|
|
@@ -269,12 +330,12 @@ describe("niceQuotes", () => {
|
|
|
269
330
|
`O${MODIFIER_LETTER_APOSTROPHE}NEILL`,
|
|
270
331
|
`CAN${MODIFIER_LETTER_APOSTROPHE}T`,
|
|
271
332
|
// Plural possessive
|
|
272
|
-
`the dogs${
|
|
333
|
+
`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`,
|
|
273
334
|
// Mixed quote types
|
|
274
335
|
`${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`,
|
|
275
336
|
])('is idempotent for: "%s"', (input) => {
|
|
276
|
-
expect(
|
|
277
|
-
expect(
|
|
337
|
+
expect(classifyApostrophes(input)).toBe(input)
|
|
338
|
+
expect(classifyApostrophes(classifyApostrophes(input))).toBe(input)
|
|
278
339
|
})
|
|
279
340
|
})
|
|
280
341
|
|
|
@@ -293,14 +354,21 @@ describe("niceQuotes", () => {
|
|
|
293
354
|
// Closing after separator
|
|
294
355
|
[`'hello${sep}'`, `${LEFT_SINGLE_QUOTE}hello${sep}${RIGHT_SINGLE_QUOTE}`, "closing quote after separator"],
|
|
295
356
|
])("%s → %s (%s)", (input, expected) => {
|
|
296
|
-
expect(
|
|
357
|
+
expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it("plural possessive across separator", () => {
|
|
361
|
+
const input = `dogs${sep}' food`
|
|
362
|
+
const expected = `dogs${sep}${MODIFIER_LETTER_APOSTROPHE} food`
|
|
363
|
+
expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
|
|
364
|
+
expect(classifyApostrophes(expected, { separator: sep })).toBe(expected)
|
|
297
365
|
})
|
|
298
366
|
|
|
299
367
|
it("'n' with separators around word boundaries", () => {
|
|
300
368
|
const input = `Rock${sep} 'n' ${sep}Roll`
|
|
301
369
|
const expected = `Rock${sep} ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} ${sep}Roll`
|
|
302
|
-
expect(
|
|
303
|
-
expect(
|
|
370
|
+
expect(classifyApostrophes(input, { separator: sep })).toBe(expected)
|
|
371
|
+
expect(classifyApostrophes(expected, { separator: sep })).toBe(expected)
|
|
304
372
|
})
|
|
305
373
|
})
|
|
306
374
|
|
|
@@ -316,7 +384,7 @@ describe("niceQuotes", () => {
|
|
|
316
384
|
["'a", `${MODIFIER_LETTER_APOSTROPHE}a`],
|
|
317
385
|
["a'", `a${RIGHT_SINGLE_QUOTE}`],
|
|
318
386
|
])('handles edge quote pattern: "%s"', (input, expected) => {
|
|
319
|
-
expect(
|
|
387
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
320
388
|
})
|
|
321
389
|
})
|
|
322
390
|
|
|
@@ -340,19 +408,7 @@ describe("niceQuotes", () => {
|
|
|
340
408
|
["wouldn't've", `wouldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
|
|
341
409
|
["couldn't've", `couldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
|
|
342
410
|
])('handles double contraction: "%s"', (input, expected) => {
|
|
343
|
-
expect(
|
|
344
|
-
})
|
|
345
|
-
})
|
|
346
|
-
|
|
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)
|
|
411
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
356
412
|
})
|
|
357
413
|
})
|
|
358
414
|
|
|
@@ -365,7 +421,7 @@ describe("niceQuotes", () => {
|
|
|
365
421
|
// Newline stops lookahead → MLA (not LSQ) since no closing quote visible
|
|
366
422
|
["'hello\nworld'", `${MODIFIER_LETTER_APOSTROPHE}hello\nworld${RIGHT_SINGLE_QUOTE}`],
|
|
367
423
|
])('handles multiline: "%s"', (input, expected) => {
|
|
368
|
-
expect(
|
|
424
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
369
425
|
})
|
|
370
426
|
})
|
|
371
427
|
|
|
@@ -431,7 +487,7 @@ describe("niceQuotes", () => {
|
|
|
431
487
|
// British: RSQ period moves outside (contrast)
|
|
432
488
|
[`${LEFT_SINGLE_QUOTE}hello.${RIGHT_SINGLE_QUOTE}`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}.`, "british"],
|
|
433
489
|
])('%s → %s (%s)', (input, expected, style) => {
|
|
434
|
-
expect(
|
|
490
|
+
expect(classifyApostrophes(input, { punctuationStyle: style as "american" | "british" })).toBe(expected)
|
|
435
491
|
})
|
|
436
492
|
})
|
|
437
493
|
|
|
@@ -459,12 +515,13 @@ describe("niceQuotes", () => {
|
|
|
459
515
|
})
|
|
460
516
|
})
|
|
461
517
|
|
|
462
|
-
describe("
|
|
518
|
+
describe("niceQuotes vs classifyApostrophes", () => {
|
|
463
519
|
const RSQ = RIGHT_SINGLE_QUOTE
|
|
464
520
|
|
|
465
521
|
it.each([
|
|
466
522
|
["contractions", "don't", `don${RSQ}t`],
|
|
467
523
|
["possessives", "the dog's bone", `the dog${RSQ}s bone`],
|
|
524
|
+
["plural possessives", "the dogs' owner", `the dogs${RSQ} owner`],
|
|
468
525
|
["O'Brien-style names", "O'Brien's idea", `O${RSQ}Brien${RSQ}s idea`],
|
|
469
526
|
["'n' abbreviation", "Rock 'n' Roll", `Rock ${RSQ}n${RSQ} Roll`],
|
|
470
527
|
["decades", "the '90s", `the ${RSQ}90s`],
|
|
@@ -473,23 +530,23 @@ describe("niceQuotes", () => {
|
|
|
473
530
|
expect(niceQuotes(input)).toBe(expected)
|
|
474
531
|
})
|
|
475
532
|
|
|
476
|
-
it("uses MLA
|
|
477
|
-
expect(
|
|
533
|
+
it("classifyApostrophes uses MLA for apostrophes", () => {
|
|
534
|
+
expect(classifyApostrophes("don't")).toBe(`don${MODIFIER_LETTER_APOSTROPHE}t`)
|
|
478
535
|
})
|
|
479
536
|
|
|
480
537
|
it("RSQ output is idempotent", () => {
|
|
481
|
-
const inputs = ["don't", "the dog's", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
538
|
+
const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
482
539
|
for (const input of inputs) {
|
|
483
540
|
const first = niceQuotes(input)
|
|
484
541
|
expect(niceQuotes(first)).toBe(first)
|
|
485
542
|
}
|
|
486
543
|
})
|
|
487
544
|
|
|
488
|
-
it("
|
|
489
|
-
const inputs = ["don't", "the dog's", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
545
|
+
it("classifyApostrophes output is idempotent", () => {
|
|
546
|
+
const inputs = ["don't", "the dog's", "the dogs'", "O'Brien", "Rock 'n' Roll", "'90s"]
|
|
490
547
|
for (const input of inputs) {
|
|
491
|
-
const first =
|
|
492
|
-
expect(
|
|
548
|
+
const first = classifyApostrophes(input)
|
|
549
|
+
expect(classifyApostrophes(first)).toBe(first)
|
|
493
550
|
}
|
|
494
551
|
})
|
|
495
552
|
|
|
@@ -503,12 +560,12 @@ describe("niceQuotes", () => {
|
|
|
503
560
|
// Mixed MLA + RSQ in same text
|
|
504
561
|
[`I${MODIFIER_LETTER_APOSTROPHE}m fine and you${RIGHT_SINGLE_QUOTE}re great`, `I${MODIFIER_LETTER_APOSTROPHE}m fine and you${MODIFIER_LETTER_APOSTROPHE}re great`],
|
|
505
562
|
])('normalizes RSQ contraction "%s"', (input, expected) => {
|
|
506
|
-
expect(
|
|
563
|
+
expect(classifyApostrophes(input)).toBe(expected)
|
|
507
564
|
})
|
|
508
565
|
|
|
509
566
|
it("does NOT normalize RSQ when it is a closing quote", () => {
|
|
510
567
|
const input = `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} world`
|
|
511
|
-
expect(
|
|
568
|
+
expect(classifyApostrophes(input)).toBe(input)
|
|
512
569
|
})
|
|
513
570
|
|
|
514
571
|
it.each([
|
|
@@ -518,11 +575,20 @@ describe("niceQuotes", () => {
|
|
|
518
575
|
[`${LEFT_SINGLE_QUOTE}Stop!${RIGHT_SINGLE_QUOTE}`, "exclamation"],
|
|
519
576
|
[`${LEFT_SINGLE_QUOTE}test,${RIGHT_SINGLE_QUOTE} more`, "comma"],
|
|
520
577
|
])("preserves closing RSQ preceded by non-word char (%s)", (input) => {
|
|
521
|
-
expect(
|
|
578
|
+
expect(classifyApostrophes(input)).toBe(input)
|
|
522
579
|
})
|
|
523
580
|
|
|
524
581
|
it("normalizes RSQ possessive with digit prefix", () => {
|
|
525
|
-
expect(
|
|
582
|
+
expect(classifyApostrophes(`GPT-2${RIGHT_SINGLE_QUOTE}s`)).toBe(`GPT-2${MODIFIER_LETTER_APOSTROPHE}s`)
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it("normalizes RSQ plural possessive to MLA", () => {
|
|
586
|
+
expect(classifyApostrophes(`the dogs${RIGHT_SINGLE_QUOTE} owner`)).toBe(`the dogs${MODIFIER_LETTER_APOSTROPHE} owner`)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it("preserves RSQ plural possessive when matched with LSQ", () => {
|
|
590
|
+
const input = `${LEFT_SINGLE_QUOTE}dogs${RIGHT_SINGLE_QUOTE} and more`
|
|
591
|
+
expect(classifyApostrophes(input)).toBe(input)
|
|
526
592
|
})
|
|
527
593
|
})
|
|
528
594
|
})
|
|
@@ -531,24 +597,24 @@ describe("niceQuotes", () => {
|
|
|
531
597
|
it("handles 1500-char input without closing quote", () => {
|
|
532
598
|
const input = `'${"a".repeat(1500)}`
|
|
533
599
|
const start = performance.now()
|
|
534
|
-
const result =
|
|
600
|
+
const result = classifyApostrophes(input)
|
|
535
601
|
expect(performance.now() - start).toBeLessThan(1000)
|
|
536
|
-
expect(
|
|
602
|
+
expect(classifyApostrophes(result)).toBe(result)
|
|
537
603
|
})
|
|
538
604
|
|
|
539
605
|
it("handles 16 rapid apostrophes", () => {
|
|
540
606
|
const input = "a'b'c'd'e'f'g'h'i'j'k'l'm'n'o'p"
|
|
541
607
|
const expected = `a${MODIFIER_LETTER_APOSTROPHE}b${MODIFIER_LETTER_APOSTROPHE}c${MODIFIER_LETTER_APOSTROPHE}d${MODIFIER_LETTER_APOSTROPHE}e${MODIFIER_LETTER_APOSTROPHE}f${MODIFIER_LETTER_APOSTROPHE}g${MODIFIER_LETTER_APOSTROPHE}h${MODIFIER_LETTER_APOSTROPHE}i${MODIFIER_LETTER_APOSTROPHE}j${MODIFIER_LETTER_APOSTROPHE}k${MODIFIER_LETTER_APOSTROPHE}l${MODIFIER_LETTER_APOSTROPHE}m${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}o${MODIFIER_LETTER_APOSTROPHE}p`
|
|
542
608
|
const start = performance.now()
|
|
543
|
-
const result =
|
|
609
|
+
const result = classifyApostrophes(input)
|
|
544
610
|
expect(performance.now() - start).toBeLessThan(1000)
|
|
545
611
|
expect(result).toBe(expected)
|
|
546
|
-
expect(
|
|
612
|
+
expect(classifyApostrophes(result)).toBe(result)
|
|
547
613
|
})
|
|
548
614
|
|
|
549
615
|
it("handles 500-char word + possessive", () => {
|
|
550
616
|
const input = `${"a".repeat(500)}'s thing`
|
|
551
|
-
expect(
|
|
617
|
+
expect(classifyApostrophes(input)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
|
|
552
618
|
})
|
|
553
619
|
})
|
|
554
620
|
|
package/src/tests/remark.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ describe("remarkPunctilio", () => {
|
|
|
48
48
|
describe("basic transformations (nbsp enabled)", () => {
|
|
49
49
|
it.each([
|
|
50
50
|
["quotes", '"Hello," she said.', `${LDQ}Hello,${RDQ} she${NBSP}said.`],
|
|
51
|
-
["apostrophes", "It's a test.", `It${RSQ}s
|
|
51
|
+
["apostrophes", "It's a test.", `It${RSQ}s${NBSP}a${NBSP}test.`],
|
|
52
52
|
["em dashes", "Wait -- here it comes.", `Wait${EM_DASH}here it${NBSP}comes.`],
|
|
53
53
|
["en dashes (number range)", "Pages 1-5", `Pages${NBSP}1${EN_DASH}5`],
|
|
54
54
|
["ellipses", "Wait...", `Wait${ELLIPSIS}`],
|