punctilio 2.1.2 → 2.2.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 CHANGED
@@ -149,3 +149,4 @@ transform(text, {
149
149
  - Spaced en-dashes between words (word – word)
150
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.
151
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.
152
+ - When `useModifierLetterApostrophe` is enabled, apostrophes use Modifier Letter Apostrophe (U+02BC) while closing single quotes use Right Single Quotation Mark (U+2019), producing semantically distinct codepoints in most cases. Contractions (don't), possessives (dog's), and leading abbreviations ('twas, '99) all output U+02BC. Bare trailing possessives like `dogs'` remain ambiguous. This option is off by default since U+02BC may not be matched by downstream regex patterns that expect standard quote characters.
@@ -49,6 +49,7 @@ export declare const UNICODE_SYMBOLS: {
49
49
  readonly RIGHT_DOUBLE_QUOTE: "”";
50
50
  readonly LEFT_SINGLE_QUOTE: "‘";
51
51
  readonly RIGHT_SINGLE_QUOTE: "’";
52
+ readonly MODIFIER_LETTER_APOSTROPHE: "ʼ";
52
53
  readonly NBSP: " ";
53
54
  readonly SUPERSCRIPT_ST: "ˢᵗ";
54
55
  readonly SUPERSCRIPT_ND: "ⁿᵈ";
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH;;GAEG;AACH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDlB,CAAA;AAEV;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,aAAa,sFAAsF,CAAA;AAEhH;;;GAGG;AACH,eAAO,MAAM,iBAAiB,WAAW,CAAA;AACzC,eAAO,MAAM,yBAAyB,QAAwC,CAAA;AAE9E;;;GAGG;AACH,eAAO,MAAM,mBAAmB,wEAAyE,CAAA;AAEzG;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED;;;GAGG;AACH,eAAO,MAAM,WAAW,QAA6B,CAAA;AAErD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAEjE"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH;;GAEG;AACH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmDlB,CAAA;AAEV;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,aAAa,sFAAsF,CAAA;AAEhH;;;GAGG;AACH,eAAO,MAAM,iBAAiB,WAAW,CAAA;AACzC,eAAO,MAAM,yBAAyB,QAAwC,CAAA;AAE9E;;;GAGG;AACH,eAAO,MAAM,mBAAmB,wEAAyE,CAAA;AAEzG;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAElE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED;;;GAGG;AACH,eAAO,MAAM,WAAW,QAA6B,CAAA;AAErD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAEjE"}
package/dist/constants.js CHANGED
@@ -50,6 +50,7 @@ export const UNICODE_SYMBOLS = {
50
50
  RIGHT_DOUBLE_QUOTE: "\u201D",
51
51
  LEFT_SINGLE_QUOTE: "\u2018",
52
52
  RIGHT_SINGLE_QUOTE: "\u2019",
53
+ MODIFIER_LETTER_APOSTROPHE: "\u02BC",
53
54
  NBSP: "\u00A0",
54
55
  // Superscript ordinal suffixes
55
56
  SUPERSCRIPT_ST: "\u02E2\u1D57", // ˢᵗ
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -105,8 +105,20 @@ 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;
108
119
  }
109
120
  export { assertSeparatorCountPreserved, countSeparators } from "./utils.js";
110
- export { DEFAULT_SEPARATOR } from "./constants.js";
121
+ export { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js";
122
+ export declare const MODIFIER_LETTER_APOSTROPHE: "ʼ";
111
123
  export declare function transform(text: string, options?: TransformOptions): string;
112
124
  //# sourceMappingURL=index.d.ts.map
@@ -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;AAClF,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;CAC3B;AASD,OAAO,EAAE,6BAA6B,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAiDlD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAmE9E"}
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;AAClF,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;IAE1B;;;;;;;;;OASG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAA;CACtC;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;AAkDpF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CA8E9E"}
package/dist/index.js CHANGED
@@ -16,9 +16,10 @@ import { hyphenReplace } from "./dashes.js";
16
16
  import { symbolTransform, fractions as fractionsTransform, degrees as degreesTransform, superscriptOrdinal as superscriptTransform, primeMarks, collapseSpaces as collapseSpacesTransform, punctuationLigatures as ligaturesTransform } from "./symbols.js";
17
17
  import { nbspTransform as nbspTransformFn } from "./nbsp.js";
18
18
  import { assertSeparatorCountPreserved, formatErrorString } from "./utils.js";
19
- import { DEFAULT_SEPARATOR } from "./constants.js";
19
+ import { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js";
20
20
  export { assertSeparatorCountPreserved, countSeparators } from "./utils.js";
21
- export { DEFAULT_SEPARATOR } from "./constants.js";
21
+ export { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js";
22
+ export const MODIFIER_LETTER_APOSTROPHE = UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE;
22
23
  /**
23
24
  * Applies all typography transformations: smart quotes, proper dashes,
24
25
  * and symbol improvements.
@@ -64,6 +65,7 @@ const defaultOpts = {
64
65
  checkIdempotency: true,
65
66
  punctuationStyle: "american",
66
67
  dashStyle: "american",
68
+ useModifierLetterApostrophe: false,
67
69
  };
68
70
  export function transform(text, options = {}) {
69
71
  const separator = options.separator ?? DEFAULT_SEPARATOR;
@@ -75,12 +77,17 @@ export function transform(text, options = {}) {
75
77
  `Use a single-codepoint character like the default "\\uE000".`);
76
78
  }
77
79
  const original = text;
78
- const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options };
80
+ const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, useModifierLetterApostrophe, ...separatorOpts } = { ...defaultOpts, ...options };
79
81
  text = hyphenReplace(text, separatorOpts);
80
82
  if (separatorOpts.punctuationStyle !== "none") {
81
83
  text = primeMarks(text, separatorOpts);
82
84
  }
83
- text = niceQuotes(text, separatorOpts);
85
+ // niceQuotes must output MLA here regardless of the user's option,
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
91
  if (symbols) {
85
92
  text = symbolTransform(text, separatorOpts);
86
93
  }
@@ -103,6 +110,11 @@ export function transform(text, options = {}) {
103
110
  text = nbspTransformFn(text, separatorOpts);
104
111
  }
105
112
  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
+ }
106
118
  if (checkIdempotency) {
107
119
  const secondPass = transform(text, { ...options, checkIdempotency: false });
108
120
  /* 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":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/quotes.d.ts CHANGED
@@ -7,6 +7,17 @@ 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;
10
21
  }
11
22
  /** Convert straight quotes to smart quotes. */
12
23
  export declare function niceQuotes(text: string, options?: QuoteOptions): string;
@@ -1 +1 @@
1
- {"version":3,"file":"quotes.d.ts","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAcH,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;AAkGD,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,CAU3E"}
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;AAiHD,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,CAiB3E"}
package/dist/quotes.js CHANGED
@@ -3,26 +3,38 @@
3
3
  */
4
4
  import escapeStringRegexp from "escape-string-regexp";
5
5
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS } from "./constants.js";
6
- const { EM_DASH, LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE, ELLIPSIS, } = UNICODE_SYMBOLS;
6
+ const { EM_DASH, LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE, MODIFIER_LETTER_APOSTROPHE, ELLIPSIS, } = UNICODE_SYMBOLS;
7
7
  /** Convert straight single quotes to curly quotes and apostrophes */
8
8
  function convertSingleQuotes(text, sep) {
9
9
  const escapedSep = escapeStringRegexp(sep);
10
10
  // Handle empty single quotes '' and whitespace-only quotes ' ' first
11
11
  // Only match straight quotes, not already-converted curly quotes
12
- const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`;
12
+ const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`;
13
13
  text = text.replace(new RegExp(`(?<![${singleQuoteChars}])''(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`);
14
14
  text = text.replace(new RegExp(`(?<![${singleQuoteChars}])'(\\s+)'(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`);
15
15
  const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`;
16
+ // Full pattern with optional 's' for lookahead detection in apostropheRegex
16
17
  const afterEndingSingle = `(?=${escapedSep}?(?:s${escapedSep}?)?(?:[${afterEndingSinglePatterns}]|$))`;
17
- const endingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterEndingSingle}`;
18
- text = text.replace(new RegExp(endingSingle, "gm"), RIGHT_SINGLE_QUOTE);
19
- const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}](?=${escapedSep}?[${LATIN_LETTERS}])`;
20
- text = text.replace(new RegExp(contraction, "gm"), RIGHT_SINGLE_QUOTE);
21
- const apostropheWhitelist = `(?=n${RIGHT_SINGLE_QUOTE} )`;
22
- const endQuoteNotContraction = `(?!${contraction})${RIGHT_SINGLE_QUOTE}${afterEndingSingle}`;
18
+ // Handle 'n' abbreviation (Rock 'n' Roll). Before MLA this wasn't needed —
19
+ // the general rules produced LSQ+n+RSQ which was fine. But MLA requires both
20
+ // quotes to be MLA (semantic apostrophes), and the general rules can't achieve
21
+ // that: neither quote is in a contraction context (no Latin letter on both sides).
22
+ text = text.replace(new RegExp(`(?<=\\w${escapedSep}? )[']n['](?= ${escapedSep}?\\w)`, "gm"), `${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}`);
23
+ // Possessive: 's followed by ending context (e.g., dog's) → U+02BC
24
+ const afterPossessive = `(?=${escapedSep}?s${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`;
25
+ const possessiveSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterPossessive}`;
26
+ text = text.replace(new RegExp(possessiveSingle, "gm"), MODIFIER_LETTER_APOSTROPHE);
27
+ // Closing single quote: ending context without 's' → U+2019
28
+ const afterClosingSingle = `(?=${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`;
29
+ const closingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterClosingSingle}`;
30
+ text = text.replace(new RegExp(closingSingle, "gm"), RIGHT_SINGLE_QUOTE);
31
+ const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}](?=${escapedSep}?[${LATIN_LETTERS}])`;
32
+ text = text.replace(new RegExp(contraction, "gm"), MODIFIER_LETTER_APOSTROPHE);
33
+ const apostropheWhitelist = `(?=n${MODIFIER_LETTER_APOSTROPHE} )`;
34
+ const endQuoteNotContraction = `(?!${contraction})[${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}]${afterEndingSingle}`;
23
35
  // Limit lookahead scan to 1000 chars to prevent catastrophic backtracking on pathological inputs
24
36
  const apostropheRegex = new RegExp(`(?<=^|[^\\w])'(${apostropheWhitelist}|(?![^${LEFT_SINGLE_QUOTE}'\\n]{0,1000}${endQuoteNotContraction}))`, "gm");
25
- text = text.replace(apostropheRegex, RIGHT_SINGLE_QUOTE);
37
+ text = text.replace(apostropheRegex, MODIFIER_LETTER_APOSTROPHE);
26
38
  const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`;
27
39
  text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`);
28
40
  return text;
@@ -74,6 +86,12 @@ export function niceQuotes(text, options = {}) {
74
86
  text = convertSingleQuotes(text, sep);
75
87
  text = convertDoubleQuotes(text, sep);
76
88
  text = applyPunctuationStyle(text, sep, punctuationStyle);
89
+ // MLA is used internally so applyPunctuationStyle can distinguish
90
+ // apostrophes (MLA, don't move) from closing quotes (RSQ, do move).
91
+ // Convert back to RSQ unless the caller opts into the distinction.
92
+ if (!options.useModifierLetterApostrophe) {
93
+ text = text.replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE);
94
+ }
77
95
  return text;
78
96
  }
79
97
  //# sourceMappingURL=quotes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"quotes.js","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"quotes.js","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "punctilio",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Smart typography transformations: curly quotes, em-dashes, en-dashes, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/constants.ts CHANGED
@@ -52,6 +52,7 @@ export const UNICODE_SYMBOLS = {
52
52
  RIGHT_DOUBLE_QUOTE: "\u201D",
53
53
  LEFT_SINGLE_QUOTE: "\u2018",
54
54
  RIGHT_SINGLE_QUOTE: "\u2019",
55
+ MODIFIER_LETTER_APOSTROPHE: "\u02BC",
55
56
  NBSP: "\u00A0",
56
57
  // Superscript ordinal suffixes
57
58
  SUPERSCRIPT_ST: "\u02E2\u1D57", // ˢᵗ
package/src/index.ts CHANGED
@@ -154,6 +154,18 @@ export interface TransformOptions {
154
154
  * Default: true
155
155
  */
156
156
  checkIdempotency?: boolean
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
157
169
  }
158
170
 
159
171
  import { niceQuotes } from "./quotes.js"
@@ -161,10 +173,11 @@ import { hyphenReplace } from "./dashes.js"
161
173
  import { symbolTransform, fractions as fractionsTransform, degrees as degreesTransform, superscriptOrdinal as superscriptTransform, primeMarks, collapseSpaces as collapseSpacesTransform, punctuationLigatures as ligaturesTransform } from "./symbols.js"
162
174
  import { nbspTransform as nbspTransformFn } from "./nbsp.js"
163
175
  import { assertSeparatorCountPreserved, formatErrorString } from "./utils.js"
164
- import { DEFAULT_SEPARATOR } from "./constants.js"
176
+ import { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js"
165
177
 
166
178
  export { assertSeparatorCountPreserved, countSeparators } from "./utils.js"
167
- export { DEFAULT_SEPARATOR } from "./constants.js"
179
+ export { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js"
180
+ export const MODIFIER_LETTER_APOSTROPHE = UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE
168
181
 
169
182
  /**
170
183
  * Applies all typography transformations: smart quotes, proper dashes,
@@ -211,6 +224,7 @@ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
211
224
  checkIdempotency: true,
212
225
  punctuationStyle: "american",
213
226
  dashStyle: "american",
227
+ useModifierLetterApostrophe: false,
214
228
  }
215
229
 
216
230
  export function transform(text: string, options: TransformOptions = {}): string {
@@ -227,13 +241,18 @@ export function transform(text: string, options: TransformOptions = {}): string
227
241
  }
228
242
 
229
243
  const original = text
230
- const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options }
244
+ const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, useModifierLetterApostrophe, ...separatorOpts } = { ...defaultOpts, ...options }
231
245
 
232
246
  text = hyphenReplace(text, separatorOpts)
233
247
  if (separatorOpts.punctuationStyle !== "none") {
234
248
  text = primeMarks(text, separatorOpts)
235
249
  }
236
- text = niceQuotes(text, separatorOpts)
250
+ // niceQuotes must output MLA here regardless of the user's option,
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 })
237
256
 
238
257
  if (symbols) {
239
258
  text = symbolTransform(text, separatorOpts)
@@ -265,6 +284,12 @@ export function transform(text: string, options: TransformOptions = {}): string
265
284
 
266
285
  assertSeparatorCountPreserved(original, text, separator, "transform")
267
286
 
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
+
268
293
  if (checkIdempotency) {
269
294
  const secondPass = transform(text, { ...options, checkIdempotency: false })
270
295
  /* istanbul ignore if -- defensive check that should never trigger */
package/src/quotes.ts CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  RIGHT_DOUBLE_QUOTE,
12
12
  LEFT_SINGLE_QUOTE,
13
13
  RIGHT_SINGLE_QUOTE,
14
+ MODIFIER_LETTER_APOSTROPHE,
14
15
  ELLIPSIS,
15
16
  } = UNICODE_SYMBOLS
16
17
 
@@ -21,6 +22,17 @@ export interface QuoteOptions {
21
22
  separator?: string
22
23
  /** "american" (inside), "british" (outside), "none". Default: "american" */
23
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
24
36
  }
25
37
 
26
38
  /** Convert straight single quotes to curly quotes and apostrophes */
@@ -29,26 +41,41 @@ function convertSingleQuotes(text: string, sep: string): string {
29
41
 
30
42
  // Handle empty single quotes '' and whitespace-only quotes ' ' first
31
43
  // Only match straight quotes, not already-converted curly quotes
32
- const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
44
+ const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
33
45
  text = text.replace(new RegExp(`(?<![${singleQuoteChars}])''(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
34
46
  text = text.replace(new RegExp(`(?<![${singleQuoteChars}])'(\\s+)'(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
35
47
 
36
48
  const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
49
+ // Full pattern with optional 's' for lookahead detection in apostropheRegex
37
50
  const afterEndingSingle = `(?=${escapedSep}?(?:s${escapedSep}?)?(?:[${afterEndingSinglePatterns}]|$))`
38
- const endingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterEndingSingle}`
39
- text = text.replace(new RegExp(endingSingle, "gm"), RIGHT_SINGLE_QUOTE)
40
51
 
41
- const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}](?=${escapedSep}?[${LATIN_LETTERS}])`
42
- text = text.replace(new RegExp(contraction, "gm"), RIGHT_SINGLE_QUOTE)
52
+ // Handle 'n' abbreviation (Rock 'n' Roll). Before MLA this wasn't needed —
53
+ // the general rules produced LSQ+n+RSQ which was fine. But MLA requires both
54
+ // quotes to be MLA (semantic apostrophes), and the general rules can't achieve
55
+ // that: neither quote is in a contraction context (no Latin letter on both sides).
56
+ text = text.replace(new RegExp(`(?<=\\w${escapedSep}? )[']n['](?= ${escapedSep}?\\w)`, "gm"), `${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}`)
43
57
 
44
- const apostropheWhitelist = `(?=n${RIGHT_SINGLE_QUOTE} )`
45
- const endQuoteNotContraction = `(?!${contraction})${RIGHT_SINGLE_QUOTE}${afterEndingSingle}`
58
+ // Possessive: 's followed by ending context (e.g., dog's) → U+02BC
59
+ const afterPossessive = `(?=${escapedSep}?s${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
60
+ const possessiveSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterPossessive}`
61
+ text = text.replace(new RegExp(possessiveSingle, "gm"), MODIFIER_LETTER_APOSTROPHE)
62
+
63
+ // Closing single quote: ending context without 's' → U+2019
64
+ const afterClosingSingle = `(?=${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
65
+ const closingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterClosingSingle}`
66
+ text = text.replace(new RegExp(closingSingle, "gm"), RIGHT_SINGLE_QUOTE)
67
+
68
+ const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}](?=${escapedSep}?[${LATIN_LETTERS}])`
69
+ text = text.replace(new RegExp(contraction, "gm"), MODIFIER_LETTER_APOSTROPHE)
70
+
71
+ const apostropheWhitelist = `(?=n${MODIFIER_LETTER_APOSTROPHE} )`
72
+ const endQuoteNotContraction = `(?!${contraction})[${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}]${afterEndingSingle}`
46
73
  // Limit lookahead scan to 1000 chars to prevent catastrophic backtracking on pathological inputs
47
74
  const apostropheRegex = new RegExp(
48
75
  `(?<=^|[^\\w])'(${apostropheWhitelist}|(?![^${LEFT_SINGLE_QUOTE}'\\n]{0,1000}${endQuoteNotContraction}))`,
49
76
  "gm"
50
77
  )
51
- text = text.replace(apostropheRegex, RIGHT_SINGLE_QUOTE)
78
+ text = text.replace(apostropheRegex, MODIFIER_LETTER_APOSTROPHE)
52
79
 
53
80
  const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
54
81
  text = text.replace(new RegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
@@ -129,5 +156,12 @@ export function niceQuotes(text: string, options: QuoteOptions = {}): string {
129
156
  text = convertDoubleQuotes(text, sep)
130
157
  text = applyPunctuationStyle(text, sep, punctuationStyle)
131
158
 
159
+ // MLA is used internally so applyPunctuationStyle can distinguish
160
+ // apostrophes (MLA, don't move) from closing quotes (RSQ, do move).
161
+ // Convert back to RSQ unless the caller opts into the distinction.
162
+ if (!options.useModifierLetterApostrophe) {
163
+ text = text.replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE)
164
+ }
165
+
132
166
  return text
133
167
  }
@@ -233,6 +233,7 @@ describe("transform", () => {
233
233
  expect(transform(input, { nbsp: false })).toBe(expected)
234
234
  })
235
235
 
236
+
236
237
  it("handles measurement text", () => {
237
238
  const input = 'He is 6\'2" tall.'
238
239
  const expected = `He is 6${PRIME}2${DOUBLE_PRIME} tall.`
@@ -1,15 +1,19 @@
1
1
  import { niceQuotes } from "../quotes.js"
2
- import { UNICODE_SYMBOLS } from "../constants.js"
2
+ import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
3
3
 
4
4
  const {
5
5
  LEFT_DOUBLE_QUOTE,
6
6
  RIGHT_DOUBLE_QUOTE,
7
7
  LEFT_SINGLE_QUOTE,
8
8
  RIGHT_SINGLE_QUOTE,
9
+ MODIFIER_LETTER_APOSTROPHE,
9
10
  EM_DASH,
10
11
  ELLIPSIS,
11
12
  } = UNICODE_SYMBOLS
12
13
 
14
+ /** Enable MLA output for tests that assert the apostrophe codepoint. */
15
+ const MLA = { useModifierLetterApostrophe: true } as const
16
+
13
17
  describe("niceQuotes", () => {
14
18
  describe("double quotes", () => {
15
19
  it.each([
@@ -27,14 +31,14 @@ describe("niceQuotes", () => {
27
31
  ],
28
32
  ['"world model";', `${LEFT_DOUBLE_QUOTE}world model${RIGHT_DOUBLE_QUOTE};`],
29
33
  ['"party"/"wedding."', `${LEFT_DOUBLE_QUOTE}party${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}wedding.${RIGHT_DOUBLE_QUOTE}`],
30
- ['"Hi \'Trout!"', `${LEFT_DOUBLE_QUOTE}Hi ${RIGHT_SINGLE_QUOTE}Trout!${RIGHT_DOUBLE_QUOTE}`],
34
+ ['"Hi \'Trout!"', `${LEFT_DOUBLE_QUOTE}Hi ${MODIFIER_LETTER_APOSTROPHE}Trout!${RIGHT_DOUBLE_QUOTE}`],
31
35
  [`${LEFT_DOUBLE_QUOTE}scope insensitivity${RIGHT_DOUBLE_QUOTE}`, `${LEFT_DOUBLE_QUOTE}scope insensitivity${RIGHT_DOUBLE_QUOTE}`],
32
36
  [
33
37
  '"how many ways can this function be implemented?".',
34
38
  `${LEFT_DOUBLE_QUOTE}how many ways can this function be implemented?${RIGHT_DOUBLE_QUOTE}.`,
35
39
  ],
36
40
  ['SSL.")', `SSL.${RIGHT_DOUBLE_QUOTE})`],
37
- ["can't multiply\"?", `can${RIGHT_SINGLE_QUOTE}t multiply${RIGHT_DOUBLE_QUOTE}?`],
41
+ ["can't multiply\"?", `can${MODIFIER_LETTER_APOSTROPHE}t multiply${RIGHT_DOUBLE_QUOTE}?`],
38
42
  ['with "scope insensitivity":', `with ${LEFT_DOUBLE_QUOTE}scope insensitivity${RIGHT_DOUBLE_QUOTE}:`],
39
43
  ['("the best")', `(${LEFT_DOUBLE_QUOTE}the best${RIGHT_DOUBLE_QUOTE})`],
40
44
  ['"This is a quote"...', `${LEFT_DOUBLE_QUOTE}This is a quote${RIGHT_DOUBLE_QUOTE}...`],
@@ -49,7 +53,7 @@ describe("niceQuotes", () => {
49
53
  // End-of-line quote becomes RIGHT quote
50
54
  ['not confident in that plan - "', `not confident in that plan - ${RIGHT_DOUBLE_QUOTE}`],
51
55
  ])('should convert double quotes in "%s"', (input, expected) => {
52
- expect(niceQuotes(input)).toBe(expected)
56
+ expect(niceQuotes(input, MLA)).toBe(expected)
53
57
  })
54
58
  })
55
59
 
@@ -57,16 +61,16 @@ describe("niceQuotes", () => {
57
61
  it.each([
58
62
  ["He said, 'Hi'", `He said, ${LEFT_SINGLE_QUOTE}Hi${RIGHT_SINGLE_QUOTE}`],
59
63
  ["He wanted 'power.'", `He wanted ${LEFT_SINGLE_QUOTE}power.${RIGHT_SINGLE_QUOTE}`],
60
- ["I'd", `I${RIGHT_SINGLE_QUOTE}d`],
61
- ["I don't'nt want to go", `I don${RIGHT_SINGLE_QUOTE}t${RIGHT_SINGLE_QUOTE}nt want to go`],
62
- ['"\'sup"', `${LEFT_DOUBLE_QUOTE}${RIGHT_SINGLE_QUOTE}sup${RIGHT_DOUBLE_QUOTE}`],
63
- ["'SUP", `${RIGHT_SINGLE_QUOTE}SUP`],
64
- ["Rock 'n' Roll", `Rock ${RIGHT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} Roll`],
65
- ["I was born in '99", `I was born in ${RIGHT_SINGLE_QUOTE}99`],
66
- ["'99 tigers weren't a match", `${RIGHT_SINGLE_QUOTE}99 tigers weren${RIGHT_SINGLE_QUOTE}t a match`],
64
+ ["I'd", `I${MODIFIER_LETTER_APOSTROPHE}d`],
65
+ ["I don't'nt want to go", `I don${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}nt want to go`],
66
+ ['"\'sup"', `${LEFT_DOUBLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}sup${RIGHT_DOUBLE_QUOTE}`],
67
+ ["'SUP", `${MODIFIER_LETTER_APOSTROPHE}SUP`],
68
+ ["Rock 'n' Roll", `Rock ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} Roll`],
69
+ ["I was born in '99", `I was born in ${MODIFIER_LETTER_APOSTROPHE}99`],
70
+ ["'99 tigers weren't a match", `${MODIFIER_LETTER_APOSTROPHE}99 tigers weren${MODIFIER_LETTER_APOSTROPHE}t a match`],
67
71
  [
68
72
  "I'm not the best, haven't you heard?",
69
- `I${RIGHT_SINGLE_QUOTE}m not the best, haven${RIGHT_SINGLE_QUOTE}t you heard?`,
73
+ `I${MODIFIER_LETTER_APOSTROPHE}m not the best, haven${MODIFIER_LETTER_APOSTROPHE}t you heard?`,
70
74
  ],
71
75
  // Skipped: Complex edge case with 'sup and quoted phrase
72
76
  // ["Hey, 'sup 'this is a single quote'", `Hey, ${RIGHT_SINGLE_QUOTE}sup ${LEFT_SINGLE_QUOTE}this is a single quote${RIGHT_SINGLE_QUOTE}`],
@@ -79,24 +83,92 @@ describe("niceQuotes", () => {
79
83
  ["('survival incentive')", `(${LEFT_SINGLE_QUOTE}survival incentive${RIGHT_SINGLE_QUOTE})`],
80
84
  [
81
85
  "strategy s's return is good, even as d's return is bad",
82
- `strategy s${RIGHT_SINGLE_QUOTE}s return is good, even as d${RIGHT_SINGLE_QUOTE}s return is bad`,
86
+ `strategy s${MODIFIER_LETTER_APOSTROPHE}s return is good, even as d${MODIFIER_LETTER_APOSTROPHE}s return is bad`,
83
87
  ],
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
+ // Chained possessives
92
+ ["the dog's owner's car", `the dog${MODIFIER_LETTER_APOSTROPHE}s owner${MODIFIER_LETTER_APOSTROPHE}s car`],
93
+ // Hyphenated possessive
94
+ ["mother-in-law's house", `mother-in-law${MODIFIER_LETTER_APOSTROPHE}s house`],
95
+ // Accented character before possessive
96
+ ["café's menu", `café${MODIFIER_LETTER_APOSTROPHE}s menu`],
97
+ // Digit before possessive
98
+ ["the 747's engine", `the 747${MODIFIER_LETTER_APOSTROPHE}s engine`],
99
+ // Compound contraction + possessive
100
+ ["shouldn't's", `shouldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}s`],
101
+ // Contraction before em-dash
102
+ [`don't${EM_DASH}stop`, `don${MODIFIER_LETTER_APOSTROPHE}t${EM_DASH}stop`],
103
+ // Contraction + digit context
104
+ ["it's 5 o'clock", `it${MODIFIER_LETTER_APOSTROPHE}s 5 o${MODIFIER_LETTER_APOSTROPHE}clock`],
105
+ // French contractions (single-letter prefix + accented Latin)
106
+ ["l'homme", `l${MODIFIER_LETTER_APOSTROPHE}homme`],
107
+ ["l'école", `l${MODIFIER_LETTER_APOSTROPHE}école`],
108
+ // Uppercase contractions and names
109
+ ["O'NEILL", `O${MODIFIER_LETTER_APOSTROPHE}NEILL`],
110
+ ["CAN'T", `CAN${MODIFIER_LETTER_APOSTROPHE}T`],
111
+ // Possessive in parenthetical
112
+ ["(Brien's)", `(Brien${MODIFIER_LETTER_APOSTROPHE}s)`],
113
+ // Possessive inside single-quoted phrase
114
+ ["'the dog's bowl'", `${LEFT_SINGLE_QUOTE}the dog${MODIFIER_LETTER_APOSTROPHE}s bowl${RIGHT_SINGLE_QUOTE}`],
115
+ // Straight apostrophe inside pre-converted double quotes
116
+ [`${LEFT_DOUBLE_QUOTE}the dog's${RIGHT_DOUBLE_QUOTE}`, `${LEFT_DOUBLE_QUOTE}the dog${MODIFIER_LETTER_APOSTROPHE}s${RIGHT_DOUBLE_QUOTE}`],
117
+ // Decade in double quotes
118
+ [`"the '90s"`, `${LEFT_DOUBLE_QUOTE}the ${MODIFIER_LETTER_APOSTROPHE}90s${RIGHT_DOUBLE_QUOTE}`],
119
+ // 'n' + possessive combination
120
+ ["Rock 'n' Roll's greatest hits", `Rock ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} Roll${MODIFIER_LETTER_APOSTROPHE}s greatest hits`],
121
+ // Non-Latin character before possessive
122
+ ["Привет's", `Привет${MODIFIER_LETTER_APOSTROPHE}s`],
123
+ // Closing RSQ then opening LDQ
124
+ [`'hello' "world"`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`],
84
125
  ])('should handle single quotes/apostrophes in "%s"', (input, expected) => {
85
- expect(niceQuotes(input)).toBe(expected)
126
+ expect(niceQuotes(input, MLA)).toBe(expected)
86
127
  })
87
128
  })
88
129
 
89
130
  describe("leading apostrophe contractions", () => {
90
131
  it.each([
91
- ["'twas the night", `${RIGHT_SINGLE_QUOTE}twas the night`],
92
- ["'tis the season", `${RIGHT_SINGLE_QUOTE}tis the season`],
93
- ["'Twas brillig", `${RIGHT_SINGLE_QUOTE}Twas brillig`],
94
- ["'Tis but a scratch", `${RIGHT_SINGLE_QUOTE}Tis but a scratch`],
132
+ ["'twas the night", `${MODIFIER_LETTER_APOSTROPHE}twas the night`],
133
+ ["'tis the season", `${MODIFIER_LETTER_APOSTROPHE}tis the season`],
134
+ ["'Twas brillig", `${MODIFIER_LETTER_APOSTROPHE}Twas brillig`],
135
+ ["'Tis but a scratch", `${MODIFIER_LETTER_APOSTROPHE}Tis but a scratch`],
95
136
  // Decade abbreviations
96
- ["the '90s", `the ${RIGHT_SINGLE_QUOTE}90s`],
97
- ["in '99", `in ${RIGHT_SINGLE_QUOTE}99`],
137
+ ["the '90s", `the ${MODIFIER_LETTER_APOSTROPHE}90s`],
138
+ ["in '99", `in ${MODIFIER_LETTER_APOSTROPHE}99`],
139
+ ["the '00s", `the ${MODIFIER_LETTER_APOSTROPHE}00s`],
140
+ // Additional leading contractions
141
+ ["'cause", `${MODIFIER_LETTER_APOSTROPHE}cause`],
142
+ ["'twas the night, 'twas indeed", `${MODIFIER_LETTER_APOSTROPHE}twas the night, ${MODIFIER_LETTER_APOSTROPHE}twas indeed`],
143
+ [`"'twas the night"`, `${LEFT_DOUBLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}twas the night${RIGHT_DOUBLE_QUOTE}`],
98
144
  ])('handles leading apostrophe in "%s"', (input, expected) => {
99
- expect(niceQuotes(input)).toBe(expected)
145
+ expect(niceQuotes(input, MLA)).toBe(expected)
146
+ })
147
+
148
+ it("multiple decades in one sentence", () => {
149
+ expect(niceQuotes("The '60s, '70s, and '80s were different.", MLA)).toBe(
150
+ `The ${MODIFIER_LETTER_APOSTROPHE}60s, ${MODIFIER_LETTER_APOSTROPHE}70s, and ${MODIFIER_LETTER_APOSTROPHE}80s were different.`
151
+ )
152
+ })
153
+ })
154
+
155
+ describe("'n' abbreviation edge cases", () => {
156
+ it.each([
157
+ ["uppercase 'N' is not abbreviated", "Rock 'N' Roll", `Rock ${LEFT_SINGLE_QUOTE}N${RIGHT_SINGLE_QUOTE} Roll`],
158
+ ["multiple 'n' abbreviations", "Rock 'n' Roll and fish 'n' chips", `Rock ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} Roll and fish ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} chips`],
159
+ ["digit neighbors (\\w includes digits)", "1 'n' 2", `1 ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} 2`],
160
+ ["known false positive: quoted letter", "the letter 'n' is common", `the letter ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} is common`],
161
+ ])('%s', (_label, input, expected) => {
162
+ expect(niceQuotes(input, MLA)).toBe(expected)
163
+ })
164
+
165
+ it.each([
166
+ ["start of string (no preceding word)", "'n' Roll", `${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} Roll`],
167
+ ["end of string (no following word)", "Rock 'n'", `Rock ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE}`],
168
+ ["punctuation precedes first apostrophe", "said, 'n' is good", `said, ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} is good`],
169
+ ["newline replaces space", "Rock 'n'\nRoll", `Rock ${LEFT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE}\nRoll`],
170
+ ])('does not produce MLA-n-MLA: %s', (_label, input, expected) => {
171
+ expect(niceQuotes(input, MLA)).toBe(expected)
100
172
  })
101
173
  })
102
174
 
@@ -137,11 +209,11 @@ describe("niceQuotes", () => {
137
209
 
138
210
  // From Standard Ebooks: M'Donald-style names (archaic patterns)
139
211
  it.each([
140
- [`"M'Lord"`, `${LEFT_DOUBLE_QUOTE}M${RIGHT_SINGLE_QUOTE}Lord${RIGHT_DOUBLE_QUOTE}`],
141
- [`O'Brien's idea`, `O${RIGHT_SINGLE_QUOTE}Brien${RIGHT_SINGLE_QUOTE}s idea`],
142
- [`The O'Connors`, `The O${RIGHT_SINGLE_QUOTE}Connors`],
212
+ [`"M'Lord"`, `${LEFT_DOUBLE_QUOTE}M${MODIFIER_LETTER_APOSTROPHE}Lord${RIGHT_DOUBLE_QUOTE}`],
213
+ [`O'Brien's idea`, `O${MODIFIER_LETTER_APOSTROPHE}Brien${MODIFIER_LETTER_APOSTROPHE}s idea`],
214
+ [`The O'Connors`, `The O${MODIFIER_LETTER_APOSTROPHE}Connors`],
143
215
  ])('handles Irish/Scottish name apostrophes: "%s"', (input, expected) => {
144
- expect(niceQuotes(input)).toBe(expected)
216
+ expect(niceQuotes(input, MLA)).toBe(expected)
145
217
  })
146
218
 
147
219
  // From smartquotes.js: complex nested quotes
@@ -165,17 +237,17 @@ describe("niceQuotes", () => {
165
237
  describe("complex real-world patterns", () => {
166
238
  it.each([
167
239
  // Dialogue with attribution
168
- ['"I can\'t," she said.', `${LEFT_DOUBLE_QUOTE}I can${RIGHT_SINGLE_QUOTE}t,${RIGHT_DOUBLE_QUOTE} she said.`],
240
+ ['"I can\'t," she said.', `${LEFT_DOUBLE_QUOTE}I can${MODIFIER_LETTER_APOSTROPHE}t,${RIGHT_DOUBLE_QUOTE} she said.`],
169
241
  ['"Why not?" he asked.', `${LEFT_DOUBLE_QUOTE}Why not?${RIGHT_DOUBLE_QUOTE} he asked.`],
170
242
  // Multiple contractions
171
- ["I'm can't won't don't", `I${RIGHT_SINGLE_QUOTE}m can${RIGHT_SINGLE_QUOTE}t won${RIGHT_SINGLE_QUOTE}t don${RIGHT_SINGLE_QUOTE}t`],
243
+ ["I'm can't won't don't", `I${MODIFIER_LETTER_APOSTROPHE}m can${MODIFIER_LETTER_APOSTROPHE}t won${MODIFIER_LETTER_APOSTROPHE}t don${MODIFIER_LETTER_APOSTROPHE}t`],
172
244
  // Quote within parentheses
173
245
  ['("test")', `(${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE})`],
174
246
  ["('test')", `(${LEFT_SINGLE_QUOTE}test${RIGHT_SINGLE_QUOTE})`],
175
247
  // Quote with slash separator
176
248
  ['"option1"/"option2"', `${LEFT_DOUBLE_QUOTE}option1${RIGHT_DOUBLE_QUOTE}/${LEFT_DOUBLE_QUOTE}option2${RIGHT_DOUBLE_QUOTE}`],
177
249
  ])('handles complex pattern: "%s"', (input, expected) => {
178
- expect(niceQuotes(input)).toBe(expected)
250
+ expect(niceQuotes(input, MLA)).toBe(expected)
179
251
  })
180
252
 
181
253
  it("handles multi-line dialogue", () => {
@@ -189,22 +261,46 @@ describe("niceQuotes", () => {
189
261
  it.each([
190
262
  `${LEFT_DOUBLE_QUOTE}already curly${RIGHT_DOUBLE_QUOTE}`,
191
263
  `${LEFT_SINGLE_QUOTE}single curly${RIGHT_SINGLE_QUOTE}`,
192
- `I${RIGHT_SINGLE_QUOTE}m already converted`,
264
+ `I${MODIFIER_LETTER_APOSTROPHE}m already converted`,
193
265
  `${LEFT_DOUBLE_QUOTE}nested ${LEFT_SINGLE_QUOTE}quotes${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`,
266
+ // French contractions
267
+ `l${MODIFIER_LETTER_APOSTROPHE}homme d${MODIFIER_LETTER_APOSTROPHE}accord`,
268
+ // Uppercase
269
+ `O${MODIFIER_LETTER_APOSTROPHE}NEILL`,
270
+ `CAN${MODIFIER_LETTER_APOSTROPHE}T`,
271
+ // Plural possessive
272
+ `the dogs${RIGHT_SINGLE_QUOTE} owner`,
273
+ // Mixed quote types
274
+ `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} ${LEFT_DOUBLE_QUOTE}world${RIGHT_DOUBLE_QUOTE}`,
194
275
  ])('is idempotent for: "%s"', (input) => {
195
- expect(niceQuotes(input)).toBe(input)
196
- expect(niceQuotes(niceQuotes(input))).toBe(input)
276
+ expect(niceQuotes(input, MLA)).toBe(input)
277
+ expect(niceQuotes(niceQuotes(input, MLA), MLA)).toBe(input)
197
278
  })
198
279
  })
199
280
 
200
281
  describe("with separator character", () => {
201
- const sep = "\uE000"
282
+ const sep = DEFAULT_SEPARATOR
202
283
  it.each([
203
284
  [`"Hello${sep} world"`, `${LEFT_DOUBLE_QUOTE}Hello${sep} world${RIGHT_DOUBLE_QUOTE}`, "preserves separator positions"],
204
- [`don${sep}'t`, `don${sep}${RIGHT_SINGLE_QUOTE}t`, "contractions across separator"],
285
+ [`don${sep}'t`, `don${sep}${MODIFIER_LETTER_APOSTROPHE}t`, "contractions across separator"],
205
286
  [`"test${sep}"`, `${LEFT_DOUBLE_QUOTE}test${sep}${RIGHT_DOUBLE_QUOTE}`, "quotes at separator boundaries"],
287
+ // Possessive across separator
288
+ [`dog${sep}'s bone`, `dog${sep}${MODIFIER_LETTER_APOSTROPHE}s bone`, "possessive across separator"],
289
+ // Separator between apostrophe and s
290
+ [`the dog'${sep}s bone`, `the dog${MODIFIER_LETTER_APOSTROPHE}${sep}s bone`, "separator between apostrophe and s"],
291
+ // Opening after separator
292
+ [`${sep}'hello'`, `${sep}${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}`, "opening quote after separator"],
293
+ // Closing after separator
294
+ [`'hello${sep}'`, `${LEFT_SINGLE_QUOTE}hello${sep}${RIGHT_SINGLE_QUOTE}`, "closing quote after separator"],
206
295
  ])("%s → %s (%s)", (input, expected) => {
207
- expect(niceQuotes(input, { separator: sep })).toBe(expected)
296
+ expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
297
+ })
298
+
299
+ it("'n' with separators around word boundaries", () => {
300
+ const input = `Rock${sep} 'n' ${sep}Roll`
301
+ const expected = `Rock${sep} ${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE} ${sep}Roll`
302
+ expect(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
303
+ expect(niceQuotes(expected, { separator: sep, ...MLA })).toBe(expected)
208
304
  })
209
305
  })
210
306
 
@@ -213,8 +309,14 @@ describe("niceQuotes", () => {
213
309
  ['"unclosed', `${LEFT_DOUBLE_QUOTE}unclosed`],
214
310
  ["unclosed'", `unclosed${RIGHT_SINGLE_QUOTE}`],
215
311
  ['""', `${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}`],
312
+ // Minimal inputs
313
+ ["", ""],
314
+ [" ", " "],
315
+ ["'", `${MODIFIER_LETTER_APOSTROPHE}`],
316
+ ["'a", `${MODIFIER_LETTER_APOSTROPHE}a`],
317
+ ["a'", `a${RIGHT_SINGLE_QUOTE}`],
216
318
  ])('handles edge quote pattern: "%s"', (input, expected) => {
217
- expect(niceQuotes(input)).toBe(expected)
319
+ expect(niceQuotes(input, MLA)).toBe(expected)
218
320
  })
219
321
  })
220
322
 
@@ -233,12 +335,12 @@ describe("niceQuotes", () => {
233
335
 
234
336
  describe("consecutive contractions", () => {
235
337
  it.each([
236
- ["I'd've", `I${RIGHT_SINGLE_QUOTE}d${RIGHT_SINGLE_QUOTE}ve`],
237
- ["y'all'd've", `y${RIGHT_SINGLE_QUOTE}all${RIGHT_SINGLE_QUOTE}d${RIGHT_SINGLE_QUOTE}ve`],
238
- ["wouldn't've", `wouldn${RIGHT_SINGLE_QUOTE}t${RIGHT_SINGLE_QUOTE}ve`],
239
- ["couldn't've", `couldn${RIGHT_SINGLE_QUOTE}t${RIGHT_SINGLE_QUOTE}ve`],
338
+ ["I'd've", `I${MODIFIER_LETTER_APOSTROPHE}d${MODIFIER_LETTER_APOSTROPHE}ve`],
339
+ ["y'all'd've", `y${MODIFIER_LETTER_APOSTROPHE}all${MODIFIER_LETTER_APOSTROPHE}d${MODIFIER_LETTER_APOSTROPHE}ve`],
340
+ ["wouldn't've", `wouldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
341
+ ["couldn't've", `couldn${MODIFIER_LETTER_APOSTROPHE}t${MODIFIER_LETTER_APOSTROPHE}ve`],
240
342
  ])('handles double contraction: "%s"', (input, expected) => {
241
- expect(niceQuotes(input)).toBe(expected)
343
+ expect(niceQuotes(input, MLA)).toBe(expected)
242
344
  })
243
345
  })
244
346
 
@@ -258,8 +360,12 @@ describe("niceQuotes", () => {
258
360
  it.each([
259
361
  ['"Hello\nWorld"', `${LEFT_DOUBLE_QUOTE}Hello\nWorld${RIGHT_DOUBLE_QUOTE}`],
260
362
  ['"A"\n"B"', `${LEFT_DOUBLE_QUOTE}A${RIGHT_DOUBLE_QUOTE}\n${LEFT_DOUBLE_QUOTE}B${RIGHT_DOUBLE_QUOTE}`],
363
+ // Contractions across lines
364
+ ["I can't\nbelieve it's\nnot butter", `I can${MODIFIER_LETTER_APOSTROPHE}t\nbelieve it${MODIFIER_LETTER_APOSTROPHE}s\nnot butter`],
365
+ // Newline stops lookahead → MLA (not LSQ) since no closing quote visible
366
+ ["'hello\nworld'", `${MODIFIER_LETTER_APOSTROPHE}hello\nworld${RIGHT_SINGLE_QUOTE}`],
261
367
  ])('handles multiline: "%s"', (input, expected) => {
262
- expect(niceQuotes(input)).toBe(expected)
368
+ expect(niceQuotes(input, MLA)).toBe(expected)
263
369
  })
264
370
  })
265
371
 
@@ -315,6 +421,20 @@ describe("niceQuotes", () => {
315
421
  })
316
422
  })
317
423
 
424
+ describe("MLA excluded from punctuation movement", () => {
425
+ it.each([
426
+ // American: MLA period/comma stay put (MLA is not a quote character)
427
+ [`the dog${MODIFIER_LETTER_APOSTROPHE}s.`, `the dog${MODIFIER_LETTER_APOSTROPHE}s.`, "american"],
428
+ [`Brien${MODIFIER_LETTER_APOSTROPHE}s,`, `Brien${MODIFIER_LETTER_APOSTROPHE}s,`, "american"],
429
+ // American: RSQ period/comma move inside (contrast)
430
+ [`${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}.`, `${LEFT_SINGLE_QUOTE}hello.${RIGHT_SINGLE_QUOTE}`, "american"],
431
+ // British: RSQ period moves outside (contrast)
432
+ [`${LEFT_SINGLE_QUOTE}hello.${RIGHT_SINGLE_QUOTE}`, `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE}.`, "british"],
433
+ ])('%s → %s (%s)', (input, expected, style) => {
434
+ expect(niceQuotes(input, { punctuationStyle: style as "american" | "british", ...MLA })).toBe(expected)
435
+ })
436
+ })
437
+
318
438
  describe('when "none"', () => {
319
439
  it.each([
320
440
  [periodOutsideDouble],
@@ -339,4 +459,88 @@ describe("niceQuotes", () => {
339
459
  })
340
460
  })
341
461
 
462
+ describe("useModifierLetterApostrophe option", () => {
463
+ const RSQ = RIGHT_SINGLE_QUOTE
464
+
465
+ it.each([
466
+ ["contractions", "don't", `don${RSQ}t`],
467
+ ["possessives", "the dog's bone", `the dog${RSQ}s bone`],
468
+ ["O'Brien-style names", "O'Brien's idea", `O${RSQ}Brien${RSQ}s idea`],
469
+ ["'n' abbreviation", "Rock 'n' Roll", `Rock ${RSQ}n${RSQ} Roll`],
470
+ ["decades", "the '90s", `the ${RSQ}90s`],
471
+ ["leading apostrophes", "'twas", `${RSQ}twas`],
472
+ ])('defaults to RSQ for %s', (_label, input, expected) => {
473
+ expect(niceQuotes(input)).toBe(expected)
474
+ })
475
+
476
+ it("uses MLA when option is true", () => {
477
+ expect(niceQuotes("don't", MLA)).toBe(`don${MODIFIER_LETTER_APOSTROPHE}t`)
478
+ })
479
+
480
+ it("RSQ output is idempotent", () => {
481
+ const inputs = ["don't", "the dog's", "O'Brien", "Rock 'n' Roll", "'90s"]
482
+ for (const input of inputs) {
483
+ const first = niceQuotes(input)
484
+ expect(niceQuotes(first)).toBe(first)
485
+ }
486
+ })
487
+
488
+ it("MLA output is idempotent", () => {
489
+ const inputs = ["don't", "the dog's", "O'Brien", "Rock 'n' Roll", "'90s"]
490
+ for (const input of inputs) {
491
+ const first = niceQuotes(input, MLA)
492
+ expect(niceQuotes(first, MLA)).toBe(first)
493
+ }
494
+ })
495
+
496
+ describe("RSQ→MLA normalization (external system input)", () => {
497
+ it.each([
498
+ [`don${RIGHT_SINGLE_QUOTE}t`, `don${MODIFIER_LETTER_APOSTROPHE}t`],
499
+ [`I${RIGHT_SINGLE_QUOTE}m`, `I${MODIFIER_LETTER_APOSTROPHE}m`],
500
+ [`they${RIGHT_SINGLE_QUOTE}re`, `they${MODIFIER_LETTER_APOSTROPHE}re`],
501
+ [`O${RIGHT_SINGLE_QUOTE}Brien`, `O${MODIFIER_LETTER_APOSTROPHE}Brien`],
502
+ [`the dog${RIGHT_SINGLE_QUOTE}s bone`, `the dog${MODIFIER_LETTER_APOSTROPHE}s bone`],
503
+ // Mixed MLA + RSQ in same text
504
+ [`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
+ ])('normalizes RSQ contraction "%s"', (input, expected) => {
506
+ expect(niceQuotes(input, MLA)).toBe(expected)
507
+ })
508
+
509
+ it("does NOT normalize RSQ when it is a closing quote", () => {
510
+ const input = `${LEFT_SINGLE_QUOTE}hello${RIGHT_SINGLE_QUOTE} world`
511
+ expect(niceQuotes(input, MLA)).toBe(input)
512
+ })
513
+
514
+ it("does not convert RSQ after non-Latin character", () => {
515
+ const input = `[test]${RIGHT_SINGLE_QUOTE}`
516
+ expect(niceQuotes(input, MLA)).toBe(input)
517
+ })
518
+ })
519
+ })
520
+
521
+ describe("pathological inputs", () => {
522
+ it("handles 1500-char input without closing quote", () => {
523
+ const input = `'${"a".repeat(1500)}`
524
+ const start = performance.now()
525
+ const result = niceQuotes(input, MLA)
526
+ expect(performance.now() - start).toBeLessThan(1000)
527
+ expect(niceQuotes(result, MLA)).toBe(result)
528
+ })
529
+
530
+ it("handles 16 rapid apostrophes", () => {
531
+ const input = "a'b'c'd'e'f'g'h'i'j'k'l'm'n'o'p"
532
+ 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`
533
+ const start = performance.now()
534
+ const result = niceQuotes(input, MLA)
535
+ expect(performance.now() - start).toBeLessThan(1000)
536
+ expect(result).toBe(expected)
537
+ expect(niceQuotes(result, MLA)).toBe(result)
538
+ })
539
+
540
+ it("handles 500-char word + possessive", () => {
541
+ const input = `${"a".repeat(500)}'s thing`
542
+ expect(niceQuotes(input, MLA)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
543
+ })
544
+ })
545
+
342
546
  })
@@ -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${NBSP}a${NBSP}test.`],
51
+ ["apostrophes", "It's a test.", `It${RSQ}s 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}`],