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 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
- - 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.
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";
@@ -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;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"}
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, useModifierLetterApostrophe, ...separatorOpts } = { ...defaultOpts, ...options };
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
- // 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
+ 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;AAElF,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,eAAe,EACf,YAAY,EACZ,MAAM,EACN,6BAA6B,GAG9B,MAAM,aAAa,CAAA;AAEpB,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,GAEhB,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,GAExB,MAAM,WAAW,CAAA;AAsHlB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,eAAe,EAAE,SAAS,IAAI,kBAAkB,EAAE,OAAO,IAAI,gBAAgB,EAAE,kBAAkB,IAAI,oBAAoB,EAAE,UAAU,EAAE,cAAc,IAAI,uBAAuB,EAAE,oBAAoB,IAAI,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAC3P,OAAO,EAAE,aAAa,IAAI,eAAe,EAAE,MAAM,WAAW,CAAA;AAC5D,OAAO,EAAE,6BAA6B,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAC7E,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAEnE,OAAO,EAAE,6BAA6B,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC3E,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AACnE,MAAM,CAAC,MAAM,0BAA0B,GAAG,eAAe,CAAC,0BAA0B,CAAA;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,WAAW,GAAkD;IACjE,OAAO,EAAE,IAAI;IACb,SAAS,EAAE,KAAK;IAChB,OAAO,EAAE,KAAK;IACd,WAAW,EAAE,KAAK;IAClB,SAAS,EAAE,KAAK;IAChB,IAAI,EAAE,IAAI;IACV,cAAc,EAAE,IAAI;IACpB,gBAAgB,EAAE,IAAI;IACtB,gBAAgB,EAAE,UAAU;IAC5B,SAAS,EAAE,UAAU;IACrB,2BAA2B,EAAE,KAAK;CACnC,CAAA;AAED,MAAM,UAAU,SAAS,CAAC,IAAY,EAAE,UAA4B,EAAE;IACpE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,iBAAiB,CAAA;IAExD,wEAAwE;IACxE,2FAA2F;IAC3F,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,iDAAiD;YACjD,aAAa,SAAS,cAAc,SAAS,CAAC,MAAM,KAAK;YACzD,8DAA8D,CAC/D,CAAA;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,IAAI,EAAE,cAAc,EAAE,gBAAgB,EAAE,2BAA2B,EAAE,GAAG,aAAa,EAAE,GAAG,EAAE,GAAG,WAAW,EAAE,GAAG,OAAO,EAAE,CAAA;IAErL,IAAI,GAAG,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IACzC,IAAI,aAAa,CAAC,gBAAgB,KAAK,MAAM,EAAE,CAAC;QAC9C,IAAI,GAAG,UAAU,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IACxC,CAAC;IACD,mEAAmE;IACnE,sEAAsE;IACtE,oEAAoE;IACpE,kEAAkE;IAClE,sCAAsC;IACtC,IAAI,GAAG,UAAU,CAAC,IAAI,EAAE,EAAE,GAAG,aAAa,EAAE,2BAA2B,EAAE,IAAI,EAAE,CAAC,CAAA;IAEhF,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,GAAG,eAAe,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAC7C,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,IAAI,GAAG,kBAAkB,CAAC,IAAI,CAAC,CAAA;IACjC,CAAC;IAED,IAAI,OAAO,EAAE,CAAC;QACZ,IAAI,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAA;IAC/B,CAAC;IAED,IAAI,WAAW,EAAE,CAAC;QAChB,IAAI,GAAG,oBAAoB,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAClD,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,IAAI,GAAG,kBAAkB,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAChD,CAAC;IAED,IAAI,cAAc,EAAE,CAAC;QACnB,IAAI,GAAG,uBAAuB,CAAC,IAAI,CAAC,CAAA;IACtC,CAAC;IAED,IAAI,IAAI,EAAE,CAAC;QACT,IAAI,GAAG,eAAe,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IAC7C,CAAC;IAED,6BAA6B,CAAC,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,CAAC,CAAA;IAErE,kEAAkE;IAClE,2DAA2D;IAC3D,IAAI,CAAC,2BAA2B,EAAE,CAAC;QACjC,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,eAAe,CAAC,0BAA0B,EAAE,eAAe,CAAC,kBAAkB,CAAC,CAAA;IACxG,CAAC;IAED,IAAI,gBAAgB,EAAE,CAAC;QACrB,MAAM,UAAU,GAAG,SAAS,CAAC,IAAI,EAAE,EAAE,GAAG,OAAO,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAC,CAAA;QAC3E,qEAAqE;QACrE,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CACb,gCAAgC;gBAChC,gBAAgB,iBAAiB,CAAC,IAAI,EAAE,YAAY,CAAC,IAAI;gBACzD,gBAAgB,iBAAiB,CAAC,UAAU,EAAE,aAAa,CAAC,IAAI;gBAChE,4GAA4G;gBAC5G,gDAAgD,CACjD,CAAA;QACH,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC"}
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
@@ -1 +1 @@
1
- {"version":3,"file":"quotes.d.ts","sourceRoot":"","sources":["../src/quotes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAeH,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,SAAS,GAAG,MAAM,CAAA;AAE9D,MAAM,WAAW,YAAY;IAC3B,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;IACnC;;;;;;;;;OASG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAA;CACtC;AAiHD,+CAA+C;AAC/C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,CAiB3E"}
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
- text = text.replace(new RegExp(`(?<![${singleQuoteChars}])''(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`);
14
- text = text.replace(new RegExp(`(?<![${singleQuoteChars}])'(\\s+)'(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`);
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
- /** Convert straight quotes to smart quotes. */
81
- export function niceQuotes(text, options = {}) {
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
- // 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
- }
95
- return text;
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
@@ -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.2.3",
3
+ "version": "3.0.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/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, useModifierLetterApostrophe, ...separatorOpts } = { ...defaultOpts, ...options }
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
- // 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 })
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
- text = text.replace(new RegExp(`(?<![${singleQuoteChars}])''(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
46
- text = text.replace(new RegExp(`(?<![${singleQuoteChars}])'(\\s+)'(?![${singleQuoteChars}])`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
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
- /** Convert straight quotes to smart quotes. */
150
- export function niceQuotes(text: string, options: QuoteOptions = {}): string {
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
- // 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
- }
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
- return text
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
  }
@@ -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(niceQuotes(input, MLA)).toBe(expected)
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 contractions (single-letter prefix + accented Latin)
98
+ // French contraction (single-letter prefix + accented Latin)
106
99
  ["l'homme", `l${MODIFIER_LETTER_APOSTROPHE}homme`],
107
- ["l'école", `l${MODIFIER_LETTER_APOSTROPHE}école`],
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
214
+ expect(classifyApostrophes(input)).toBe(expected)
146
215
  })
147
216
 
148
217
  it("multiple decades in one sentence", () => {
149
- expect(niceQuotes("The '60s, '70s, and '80s were different.", MLA)).toBe(
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
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${RIGHT_SINGLE_QUOTE} owner`,
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(niceQuotes(input, MLA)).toBe(input)
277
- expect(niceQuotes(niceQuotes(input, MLA), MLA)).toBe(input)
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(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
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(niceQuotes(input, { separator: sep, ...MLA })).toBe(expected)
303
- expect(niceQuotes(expected, { separator: sep, ...MLA })).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, { punctuationStyle: style as "american" | "british", ...MLA })).toBe(expected)
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("useModifierLetterApostrophe option", () => {
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 when option is true", () => {
477
- expect(niceQuotes("don't", MLA)).toBe(`don${MODIFIER_LETTER_APOSTROPHE}t`)
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("MLA output is idempotent", () => {
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 = niceQuotes(input, MLA)
492
- expect(niceQuotes(first, MLA)).toBe(first)
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(niceQuotes(input, MLA)).toBe(expected)
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(niceQuotes(input, MLA)).toBe(input)
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(niceQuotes(input, MLA)).toBe(input)
578
+ expect(classifyApostrophes(input)).toBe(input)
522
579
  })
523
580
 
524
581
  it("normalizes RSQ possessive with digit prefix", () => {
525
- expect(niceQuotes(`GPT-2${RIGHT_SINGLE_QUOTE}s`, MLA)).toBe(`GPT-2${MODIFIER_LETTER_APOSTROPHE}s`)
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 = niceQuotes(input, MLA)
600
+ const result = classifyApostrophes(input)
535
601
  expect(performance.now() - start).toBeLessThan(1000)
536
- expect(niceQuotes(result, MLA)).toBe(result)
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 = niceQuotes(input, MLA)
609
+ const result = classifyApostrophes(input)
544
610
  expect(performance.now() - start).toBeLessThan(1000)
545
611
  expect(result).toBe(expected)
546
- expect(niceQuotes(result, MLA)).toBe(result)
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(niceQuotes(input, MLA)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
617
+ expect(classifyApostrophes(input)).toBe(`${"a".repeat(500)}${MODIFIER_LETTER_APOSTROPHE}s thing`)
552
618
  })
553
619
  })
554
620
 
@@ -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 a${NBSP}test.`],
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}`],