punctilio 2.3.0 → 3.0.1

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/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,29 +229,24 @@ 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)
259
242
  }
260
243
 
261
244
  if (fractions) {
262
- text = fractionsTransform(text)
245
+ text = fractionsTransform(text, separatorOpts)
263
246
  }
264
247
 
265
248
  if (degrees) {
266
- text = degreesTransform(text)
249
+ text = degreesTransform(text, separatorOpts)
267
250
  }
268
251
 
269
252
  if (superscript) {
@@ -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 */
@@ -177,8 +166,8 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
177
166
  return text
178
167
  }
179
168
 
180
- /** Convert straight quotes to smart quotes. */
181
- 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 {
182
171
  const sep = options.separator ?? DEFAULT_SEPARATOR
183
172
  const punctuationStyle = options.punctuationStyle ?? "american"
184
173
  if (punctuationStyle === "none") return text
@@ -187,12 +176,27 @@ export function niceQuotes(text: string, options: QuoteOptions = {}): string {
187
176
  text = convertDoubleQuotes(text, sep)
188
177
  text = applyPunctuationStyle(text, sep, punctuationStyle)
189
178
 
179
+ return text
180
+ }
181
+
182
+ /** Convert straight quotes to smart quotes. */
183
+ export function niceQuotes(text: string, options: QuoteOptions = {}): string {
190
184
  // MLA is used internally so applyPunctuationStyle can distinguish
191
185
  // apostrophes (MLA, don't move) from closing quotes (RSQ, do move).
192
- // Convert back to RSQ unless the caller opts into the distinction.
193
- if (!options.useModifierLetterApostrophe) {
194
- text = text.replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE)
195
- }
186
+ // Always convert back to RSQ for standard output per Unicode.
187
+ return processQuotes(text, options).replaceAll(MODIFIER_LETTER_APOSTROPHE, RIGHT_SINGLE_QUOTE)
188
+ }
196
189
 
197
- 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)
198
202
  }
package/src/rehype.ts CHANGED
@@ -183,15 +183,19 @@ export function getFirstTextNode(
183
183
  export function assertSmartQuotesMatch(input: string): void {
184
184
  if (!input) return
185
185
 
186
- const quoteMap: Record<string, string> = { "\u201C": "\u201D", "\u201D": "\u201C" }
186
+ const openers = new Set(["\u201C"]) // left double quote opens
187
+ const closerToOpener: Record<string, string> = { "\u201D": "\u201C" } // right closes left
187
188
  const stack: string[] = []
188
189
 
189
190
  for (const char of input) {
190
- if (char in quoteMap) {
191
- if (stack.length > 0 && quoteMap[stack[stack.length - 1]] === char) {
191
+ if (openers.has(char)) {
192
+ stack.push(char)
193
+ } else if (char in closerToOpener) {
194
+ if (stack.length > 0 && stack[stack.length - 1] === closerToOpener[char]) {
192
195
  stack.pop()
193
196
  } else {
194
- stack.push(char)
197
+ // Unmatched closer
198
+ throw new Error(`Mismatched quotes in ${input}`)
195
199
  }
196
200
  }
197
201
  }
package/src/symbols.ts CHANGED
@@ -116,32 +116,38 @@ export function mathSymbols(text: string): string {
116
116
  return text
117
117
  }
118
118
 
119
- /** Legal symbol replacement map: ASCII → Unicode */
120
- const LEGAL_SYMBOL_MAP: [RegExp, string][] = [
121
- [/\(r\)/gi, REGISTERED],
122
- [/\(tm\)/gi, TRADEMARK],
123
- ]
119
+ /**
120
+ * Context-aware replacement for legal symbols like (c), (r), (tm).
121
+ * Extracts surrounding text and delegates the convert/skip decision to a predicate.
122
+ */
123
+ function contextAwareLegalReplace(
124
+ text: string,
125
+ pattern: RegExp,
126
+ replacement: string,
127
+ shouldConvert: (before: string, after: string) => boolean,
128
+ ): string {
129
+ return text.replace(pattern, (match: string, offset: number, str: string) => {
130
+ const before = str.slice(Math.max(0, offset - 25), offset)
131
+ const after = str.slice(offset + match.length, offset + match.length + 25)
132
+ return shouldConvert(before, after) ? replacement : match
133
+ })
134
+ }
124
135
 
125
136
  /** Convert (c), (r), (tm) to ©, ®, ™. */
126
137
  export function legalSymbols(text: string): string {
127
- // (c) needs context-aware replacement to avoid false positives in essays,
128
- // enumerations like "(a), (b), (c)", and legal citations like "(c)(2)(A)".
129
- // Only convert when there's positive copyright evidence:
130
- // - Followed by a 4-digit year (19xx/20xx) within a short window
131
- // - Preceded by the word "copyright"
132
- text = text.replace(/\(c\)/gi, (match, offset, str) => {
133
- const after = str.slice(offset + 3, offset + 25)
134
- if (/^\s*(?:19|20)\d{2}\b/.test(after)) return COPYRIGHT
135
-
136
- const before = str.slice(Math.max(0, offset - 20), offset)
137
- if (/\bcopyright\s*$/i.test(before)) return COPYRIGHT
138
-
139
- return match
140
- })
138
+ // (c) → © only with positive copyright evidence (year or "copyright" keyword)
139
+ text = contextAwareLegalReplace(text, /\(c\)/gi, COPYRIGHT, (before, after) =>
140
+ /^\s*(?:19|20)\d{2}\b/.test(after) || /\bcopyright\s*$/i.test(before)
141
+ )
142
+
143
+ // (r) → ® unless in enumeration "(q), (r)" or legal citation "(r)(1)" context
144
+ text = contextAwareLegalReplace(text, /\(r\)/gi, REGISTERED, (before, after) =>
145
+ !/\([a-z]\)[,;]\s*$/i.test(before) && !/^\(\d/.test(after)
146
+ )
147
+
148
+ // (tm) → ™ unconditionally
149
+ text = text.replace(/\(tm\)/gi, TRADEMARK)
141
150
 
142
- for (const [pattern, replacement] of LEGAL_SYMBOL_MAP) {
143
- text = text.replace(pattern, replacement)
144
- }
145
151
  return text
146
152
  }
147
153
 
@@ -124,6 +124,17 @@ describe("hyphenReplace", () => {
124
124
  })
125
125
  })
126
126
 
127
+ describe("tilde preservation", () => {
128
+ it.each([
129
+ ["word ~ word", "word ~ word"],
130
+ ["word ~~ word", "word ~~ word"],
131
+ ["the ~5% range", "the ~5% range"],
132
+ ["~approximately", "~approximately"],
133
+ ])('preserves tilde in "%s"', (input) => {
134
+ expect(hyphenReplace(input)).toBe(input)
135
+ })
136
+ })
137
+
127
138
  describe("with separator character", () => {
128
139
  const sep = "\uE000"
129
140
  it.each([
@@ -11,6 +11,9 @@ const {
11
11
  ELLIPSIS,
12
12
  MULTIPLICATION,
13
13
  NOT_EQUAL,
14
+ PLUS_MINUS,
15
+ LESS_EQUAL,
16
+ GREATER_EQUAL,
14
17
  COPYRIGHT,
15
18
  NBSP,
16
19
  PRIME,
@@ -18,6 +21,7 @@ const {
18
21
  TRADEMARK,
19
22
  REGISTERED,
20
23
  DEGREE,
24
+ MINUS,
21
25
  FRACTION_1_2,
22
26
  SUPERSCRIPT_ST,
23
27
  } = UNICODE_SYMBOLS
@@ -183,11 +187,11 @@ describe("transform", () => {
183
187
  // Date ranges - all competitors fail
184
188
  ["January-March", `January${EN_DASH}March`],
185
189
  // Minus signs - most competitors fail
186
- ["-5", `−5`],
187
- ["(-5)", `(−5)`],
190
+ ["-5", `${MINUS}5`],
191
+ ["(-5)", `(${MINUS}5)`],
188
192
  // Prime marks - smartypants/typograf fail
189
- ['5\'10"', `5′10″`],
190
- ['He is 6\'2" tall', `He is 6′2″ tall`],
193
+ ['5\'10"', `5${PRIME}10${DOUBLE_PRIME}`],
194
+ ['He is 6\'2" tall', `He is 6${PRIME}2${DOUBLE_PRIME} tall`],
191
195
  ])('correctly transforms: "%s"', (input, expected) => {
192
196
  expect(transform(input, { symbols: true, degrees: true, nbsp: false })).toBe(expected)
193
197
  })
@@ -229,7 +233,7 @@ describe("transform", () => {
229
233
 
230
234
  it("handles technical documentation", () => {
231
235
  const input = 'The API returns x != y when a <= b and c >= d. Error tolerance: +-5%.'
232
- const expected = `The API returns x ${NOT_EQUAL} y when a ≤ b and c ≥ d. Error tolerance: ±5%.`
236
+ const expected = `The API returns x ${NOT_EQUAL} y when a ${LESS_EQUAL} b and c ${GREATER_EQUAL} d. Error tolerance: ${PLUS_MINUS}5%.`
233
237
  expect(transform(input, { nbsp: false })).toBe(expected)
234
238
  })
235
239
 
@@ -263,11 +267,11 @@ describe("transform", () => {
263
267
  it.each([
264
268
  [
265
269
  '"Wait..." she said - "it\'s pages 1-5."',
266
- `${LEFT_DOUBLE_QUOTE}Wait…${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1–5.${RIGHT_DOUBLE_QUOTE}`,
270
+ `${LEFT_DOUBLE_QUOTE}Wait${ELLIPSIS}${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1${EN_DASH}5.${RIGHT_DOUBLE_QUOTE}`,
267
271
  ],
268
272
  [
269
273
  "(c) 2024 - Room is 10x12, set to 72 F",
270
- `${COPYRIGHT} 2024${EM_DASH}Room is 10×12, set to 72 °F`,
274
+ `${COPYRIGHT} 2024${EM_DASH}Room is 10${MULTIPLICATION}12, set to 72 ${DEGREE}F`,
271
275
  ],
272
276
  ])('handles complex transform: "%s"', (input, expected) => {
273
277
  expect(transform(input, { degrees: true, nbsp: false })).toBe(expected)
@@ -313,7 +317,7 @@ describe("transform", () => {
313
317
  const count = 50
314
318
  const input = "pages 1-5 ".repeat(count)
315
319
  const result = transform(input, { nbsp: false })
316
- const enDashCount = (result.match(/–/g) || []).length
320
+ const enDashCount = (result.match(new RegExp(EN_DASH, "g")) || []).length
317
321
  expect(enDashCount).toBe(count)
318
322
  })
319
323
 
@@ -321,7 +325,7 @@ describe("transform", () => {
321
325
  const count = 50
322
326
  const input = "Wait... ".repeat(count)
323
327
  const result = transform(input, { nbsp: false })
324
- const ellipsisCount = (result.match(/…/g) || []).length
328
+ const ellipsisCount = (result.match(new RegExp(ELLIPSIS, "g")) || []).length
325
329
  expect(ellipsisCount).toBe(count)
326
330
  })
327
331
  })
@@ -373,7 +377,7 @@ describe("transform", () => {
373
377
 
374
378
  it.each([
375
379
  [`word${sep} - ${sep}word`, `word${sep}${EM_DASH}${sep}word`],
376
- [`1${sep}-${sep}5`, `1${sep}–${sep}5`],
380
+ [`1${sep}-${sep}5`, `1${sep}${EN_DASH}${sep}5`],
377
381
  ])('handles separator in dashes', (input, expected) => {
378
382
  expect(transform(input, { separator: sep, nbsp: false })).toBe(expected)
379
383
  })
@@ -390,6 +394,16 @@ describe("transform", () => {
390
394
  const input = `Text with ${sep} in it`
391
395
  expect(() => transform(input, { separator: sep, nbsp: false })).not.toThrow()
392
396
  })
397
+
398
+ it("handles separator in fractions", () => {
399
+ const input = `1${sep}/2`
400
+ expect(transform(input, { separator: sep, fractions: true, nbsp: false })).toBe(`${sep}${FRACTION_1_2}`)
401
+ })
402
+
403
+ it("handles separator in degrees", () => {
404
+ const input = `72${sep} F`
405
+ expect(transform(input, { separator: sep, degrees: true, nbsp: false })).toBe(`72${sep} ${DEGREE}F`)
406
+ })
393
407
  })
394
408
 
395
409
  describe("additional idempotency", () => {
@@ -407,10 +421,10 @@ describe("transform", () => {
407
421
  it.each([
408
422
  `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`,
409
423
  `word${EM_DASH}word`,
410
- `1–5`,
411
- `Wait…`,
412
- `5×5`,
413
- `20${NBSP}°C`,
424
+ `1${EN_DASH}5`,
425
+ `Wait${ELLIPSIS}`,
426
+ `5${MULTIPLICATION}5`,
427
+ `20${NBSP}${DEGREE}C`,
414
428
  `${COPYRIGHT}${NBSP}2024`,
415
429
  ])('stable for: "%s"', (input) => {
416
430
  expect(transform(input)).toBe(input)
@@ -506,12 +520,12 @@ describe("transform", () => {
506
520
  it.each([
507
521
  ['"start', `${LEFT_DOUBLE_QUOTE}start`],
508
522
  ["'start", `${RIGHT_SINGLE_QUOTE}start`],
509
- ["-5 start", `−5 start`],
510
- ["...start", `… start`],
523
+ ["-5 start", `${MINUS}5 start`],
524
+ ["...start", `${ELLIPSIS} start`],
511
525
  ['end"', `end${RIGHT_DOUBLE_QUOTE}`],
512
526
  ["end'", `end${RIGHT_SINGLE_QUOTE}`],
513
- ["end...", `end…`],
514
- ["5x", `5×`],
527
+ ["end...", `end${ELLIPSIS}`],
528
+ ["5x", `5${MULTIPLICATION}`],
515
529
  ])('handles boundary: "%s"', (input, expected) => {
516
530
  expect(transform(input, { nbsp: false })).toBe(expected)
517
531
  })
@@ -15,7 +15,7 @@ import {
15
15
  } from "../nbsp.js"
16
16
  import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
17
17
 
18
- const NBSP = UNICODE_SYMBOLS.NBSP
18
+ const { NBSP, COPYRIGHT, REGISTERED, TRADEMARK } = UNICODE_SYMBOLS
19
19
  const SEP = DEFAULT_SEPARATOR
20
20
 
21
21
  /** Calls fn with separator option, checking all cases produce correct nbsp. */
@@ -183,18 +183,18 @@ describe("nbspAfterHonorifics", () => {
183
183
 
184
184
  describe("nbspAfterCopyrightSymbols", () => {
185
185
  it.each([
186
- ["© 2024", `©${NBSP}2024`],
187
- ["® Brand", `®${NBSP}Brand`],
188
- ["™ Product", `™${NBSP}Product`],
189
- ["© 2024 Company", `©${NBSP}2024 Company`],
190
- ["© company", "© company"],
186
+ [`${COPYRIGHT} 2024`, `${COPYRIGHT}${NBSP}2024`],
187
+ [`${REGISTERED} Brand`, `${REGISTERED}${NBSP}Brand`],
188
+ [`${TRADEMARK} Product`, `${TRADEMARK}${NBSP}Product`],
189
+ [`${COPYRIGHT} 2024 Company`, `${COPYRIGHT}${NBSP}2024 Company`],
190
+ [`${COPYRIGHT} company`, `${COPYRIGHT} company`],
191
191
  ])('"%s" → "%s"', (input, expected) => {
192
192
  expect(nbspAfterCopyrightSymbols(input)).toBe(expected)
193
193
  })
194
194
 
195
195
  it("preserves separator after symbol", () => {
196
196
  expectSep(nbspAfterCopyrightSymbols, [
197
- [`©${SEP} 2024`, `©${SEP}${NBSP}2024`],
197
+ [`${COPYRIGHT}${SEP} 2024`, `${COPYRIGHT}${SEP}${NBSP}2024`],
198
198
  ])
199
199
  })
200
200
  })
@@ -224,8 +224,8 @@ describe("nbspTransform", () => {
224
224
  })
225
225
 
226
226
  it("handles multiple rules on the same text", () => {
227
- expect(nbspTransform("© 2024 by J. K. Rowling"))
228
- .toBe(`©${NBSP}2024 by${NBSP}J.${NBSP}K.${NBSP}Rowling`)
227
+ expect(nbspTransform(`${COPYRIGHT} 2024 by J. K. Rowling`))
228
+ .toBe(`${COPYRIGHT}${NBSP}2024 by${NBSP}J.${NBSP}K.${NBSP}Rowling`)
229
229
  })
230
230
 
231
231
  it("nbspBeforeLastWord fires on plain text", () => {