punctilio 4.3.1 → 5.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.
Files changed (65) hide show
  1. package/README.md +36 -22
  2. package/dist/constants.d.ts +0 -27
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -40
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +423 -284
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +48 -7
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +163 -27
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -88
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +6 -2
  53. package/src/constants.ts +0 -46
  54. package/src/dashes.ts +450 -269
  55. package/src/index.ts +70 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +234 -39
  59. package/src/quote-classifier.ts +618 -88
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
package/src/index.ts CHANGED
@@ -1,322 +1,101 @@
1
- export { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "./constants.js"
2
- import { DASH_STYLES, type DashStyle } from "./dashes.js"
3
- import { PUNCTUATION_STYLES, type PunctuationStyle } from "./quotes.js"
1
+ export { UNICODE_SYMBOLS } from "./constants.js"
4
2
  export {
5
3
  DASH_STYLES,
6
4
  type DashOptions,
7
5
  type DashStyle,
8
6
  dashWordJoiner,
9
- enDashDateRange,
10
- enDashNumberRange,
11
7
  hyphenReplace,
12
- minusReplace,
13
- numberRangeDisallowedPrefixes,
14
8
  } from "./dashes.js"
15
- export {
16
- HONORIFICS,
17
- nbspAfterCopyrightSymbols,
18
- nbspAfterHonorifics,
19
- nbspAfterReferenceAbbreviations,
20
- nbspAfterSectionSymbols,
21
- nbspAfterShortWords,
22
- nbspBeforeLastWord,
23
- nbspBetweenInitials,
24
- nbspBetweenNumberAndUnit,
25
- type NbspOptions,
26
- nbspTransform,
27
- REFERENCE_ABBREVIATIONS,
28
- UNITS,
29
- } from "./nbsp.js"
9
+ export { nbspTransform } from "./nbsp.js"
30
10
  export {
31
11
  buildProseView,
12
+ definePass,
32
13
  type ProseNode,
14
+ type ProsePass,
33
15
  type ProseView,
34
16
  replaceAllInView,
35
17
  type ReplaceAllOptions,
36
- runLegacyPass,
37
18
  withProseView,
38
19
  } from "./prose-view.js"
39
20
  export { classifyApostrophes, niceQuotes, PUNCTUATION_STYLES, type PunctuationStyle, type QuoteOptions } from "./quotes.js"
40
-
41
- export interface TransformOptions {
42
- /**
43
- * A boundary marker character used when transforming text that spans
44
- * multiple HTML elements. This character is treated as "transparent"
45
- * in the regex patterns.
46
- *
47
- * Should be a character that doesn't appear in your text.
48
- * Default: "\uE000\uE001" (Unicode Private Use Area)
49
- */
50
- separator?: string
51
-
52
- /**
53
- * Whether to include symbol transforms (ellipsis, multiplication, etc.)
54
- * Default: true
55
- */
56
- symbols?: boolean
57
-
58
- /**
59
- * Whether to collapse multiple consecutive spaces (including non-breaking
60
- * spaces) into a single space. Keeps the first space in the sequence.
61
- * Runs at the start of a line (after `\n` or start-of-string) are preserved
62
- * so indented blocks (e.g. HN-style code) survive.
63
- *
64
- * - `true` (default): "hello world" → "hello world"; " indented" → " indented"
65
- * - `false`: Preserve multiple spaces
66
- *
67
- * Default: true
68
- */
69
- collapseSpaces?: boolean
70
-
71
- /**
72
- * How to handle quotes and punctuation placement.
73
- *
74
- * - `"american"` (default): Chicago style. Converts straight quotes to smart
75
- * quotes, converts prime marks, and places periods/commas inside quotes.
76
- * Example: "Hello." and "Hello,"
77
- * - `"british"`: Oxford style. Converts straight quotes to smart quotes,
78
- * converts prime marks, and places periods/commas outside quotes.
79
- * Example: "Hello". and "Hello",
80
- * - `"german"`: German style. Uses low-9 quote characters: „..." and ‚...'
81
- * (U+201E/U+201C double, U+201A/U+2018 single). Punctuation outside quotes.
82
- * - `"french"`: French style. Uses guillemets with NNBSP padding: «\u202F...\u202F»
83
- * Single quotes remain as curly quotes. Punctuation outside quotes.
84
- * - `"none"`: Skip all quote and punctuation transforms entirely.
85
- * Straight quotes, apostrophes, and prime marks are left unmodified.
86
- *
87
- * Default: "american"
88
- */
89
- punctuationStyle?: PunctuationStyle
90
-
91
- /**
92
- * How to style dashes.
93
- *
94
- * - `"american"` (default): Chicago style. Converts parenthetical dashes to
95
- * unspaced em dashes (word—word), number ranges to en dashes (1–5),
96
- * date ranges to en dashes (January–March), and hyphens to minus signs (−5).
97
- * - `"british"`: Oxford style. Converts parenthetical dashes to spaced en
98
- * dashes (word – word), with the same number range, date range, and minus
99
- * sign conversions.
100
- * - `"none"`: Skip all dash transforms entirely. Hyphens, number ranges,
101
- * date ranges, and minus signs are left unmodified.
102
- *
103
- * Default: "american"
104
- */
105
- dashStyle?: DashStyle
106
-
107
- /**
108
- * Whether to include fraction transforms (1/2 → ½)
109
- * Default: false (can be aggressive)
110
- */
111
- fractions?: boolean
112
-
113
- /**
114
- * Whether to include degree symbol transforms (20 C → 20 °C)
115
- * Default: false (can be aggressive)
116
- */
117
- degrees?: boolean
118
-
119
- /**
120
- * Whether to convert ordinal suffixes to Unicode superscript characters.
121
- * Transforms numbers like "1st", "2nd", "3rd", "4th" to "1ˢᵗ", "2ⁿᵈ", "3ʳᵈ", "4ᵗʰ".
122
- * Default: false
123
- */
124
- superscript?: boolean
125
-
126
- /**
127
- * Whether to convert repeated punctuation marks to Unicode ligature characters.
128
- * Squashes multiple marks: "???" → "⁇", "?!" → "⁈", "!?" → "⁉", "!!!" → "!"
129
- * Default: false (poor font support)
130
- */
131
- ligatures?: boolean
132
-
133
- /**
134
- * Whether to include arrow transforms (-> → →, <- → ←, <-> → ↔).
135
- * Only applies when `symbols` is true.
136
- * Default: true
137
- */
138
- includeArrows?: boolean
139
-
140
- /**
141
- * Whether to insert non-breaking spaces in typographically appropriate
142
- * locations (after short words, between numbers and units, before
143
- * last words to prevent widows, after honorifics, etc.).
144
- * Default: true
145
- */
146
- nbsp?: boolean
147
-
148
- /**
149
- * Whether to verify that the transformation is idempotent (running twice
150
- * produces the same result). When enabled, throws an error if the second
151
- * pass produces a different result than the first. The check doubles the
152
- * cost of every transform and only detects punctilio's own bugs, so it is
153
- * off by default; enable it in test suites or when debugging.
154
- *
155
- * Default: false
156
- */
157
- checkIdempotency?: boolean
158
-
159
- }
21
+ export { type SymbolOptions, symbolTransform } from "./symbols.js"
22
+ export type { TransformOptions } from "./transform-options.js"
160
23
 
161
24
  import { niceQuotes } from "./quotes.js"
162
25
  import { hyphenReplace } from "./dashes.js"
163
- import { collapseSpaces as collapseSpacesTransform, degrees as degreesTransform, ellipsis as ellipsisTransform, fractions as fractionsTransform, punctuationLigatures as ligaturesTransform, primeMarks, superscriptOrdinal as superscriptTransform, symbolTransform } from "./symbols.js"
26
+ import { collapseSpaces as collapseSpacesTransform, degrees as degreesTransform, fractions as fractionsTransform, punctuationLigatures as ligaturesTransform, superscriptOrdinal as superscriptTransform, symbolTransform } from "./symbols.js"
164
27
  import { nbspTransform as nbspTransformFn } from "./nbsp.js"
165
- import { assertKnownOptionKeys, assertSeparatorCountPreserved, filterUndefined, formatErrorString } from "./utils.js"
166
- import { DEFAULT_SEPARATOR, ISSUES_URL, UNICODE_SYMBOLS } from "./constants.js"
28
+ import { type ProseView, withProseView } from "./prose-view.js"
29
+ import { type ResolvedTransformOptions, resolveTransformOptions, type TransformOptions } from "./transform-options.js"
30
+ import { UNICODE_SYMBOLS } from "./constants.js"
167
31
 
168
- export {
169
- arrows,
170
- collapseSpaces,
171
- degrees,
172
- ellipsis,
173
- fractions,
174
- legalSymbols,
175
- mathSymbols,
176
- multiplication,
177
- primeMarks,
178
- punctuationLigatures,
179
- superscriptOrdinal,
180
- type SymbolOptions,
181
- symbolTransform,
182
- } from "./symbols.js"
183
- export { assertSeparatorAbsent, assertSeparatorCountPreserved, countSeparators, transformTextNodes } from "./utils.js"
184
32
  export const MODIFIER_LETTER_APOSTROPHE = UNICODE_SYMBOLS.MODIFIER_LETTER_APOSTROPHE
185
33
 
186
- const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
187
- symbols: true,
188
- includeArrows: true,
189
- fractions: false,
190
- degrees: false,
191
- superscript: false,
192
- ligatures: false,
193
- nbsp: true,
194
- collapseSpaces: true,
195
- checkIdempotency: false,
196
- punctuationStyle: "american",
197
- dashStyle: "american",
34
+ /** One transform pass: an options gate plus the view runner. */
35
+ interface PipelinePass {
36
+ enabled(options: ResolvedTransformOptions): boolean
37
+ run(view: ProseView, options: ResolvedTransformOptions): void
198
38
  }
199
39
 
200
- /** Runtime list of valid `transform()` option keys, derived from the option
201
- * defaults so it cannot drift from {@link TransformOptions}. */
202
- export const TRANSFORM_OPTION_KEYS: readonly string[] = [
203
- ...Object.keys(defaultOpts),
204
- "separator",
40
+ /**
41
+ * The transform pipeline, in execution order. The order is load-bearing
42
+ * (#214); each entry's comment records the constraint it satisfies.
43
+ */
44
+ const PIPELINE: readonly PipelinePass[] = [
45
+ // collapseSpaces runs twice. This early run normalizes whitespace so the
46
+ // space-sensitive rules below match the same shapes a re-run would see
47
+ // (e.g. tab+space before an em dash collapses to the plain space the
48
+ // spaced-dash rule keys on); the late run re-collapses runs the passes
49
+ // themselves create (e.g. French opener padding next to an existing space).
50
+ { enabled: (options) => options.collapseSpaces, run: (view) => collapseSpacesTransform(view) },
51
+ // Dashes next: quote classification keys off the converted glyphs (an
52
+ // opening quote after an em dash, the minus sign in ‘−5’), so hyphens must
53
+ // become em/en dashes and minus signs before the quote rules run.
54
+ {
55
+ enabled: () => true,
56
+ run: (view, options) => hyphenReplace(view, { dashStyle: options.dashStyle }),
57
+ },
58
+ // Quotes next, folding prime marks first (niceQuotes' `primes` default):
59
+ // primes must convert while quotes are still straight, and before
60
+ // symbolTransform, whose multiplication pass recognizes prime-suffixed
61
+ // dimensions (5′ x 4′).
62
+ {
63
+ enabled: () => true,
64
+ run: (view, options) => niceQuotes(view, { punctuationStyle: options.punctuationStyle }),
65
+ },
66
+ // Core symbols (ellipsis, multiplication, math, legal, arrows). Ellipsis
67
+ // folding runs first inside symbolTransform so later passes see `…`, not a
68
+ // dot run they could misread.
69
+ {
70
+ enabled: (options) => options.symbols,
71
+ run: (view, options) => symbolTransform(view, { includeArrows: options.includeArrows }),
72
+ },
73
+ // Opt-in passes, after the core set so they operate on settled glyphs.
74
+ { enabled: (options) => options.fractions, run: (view) => fractionsTransform(view) },
75
+ { enabled: (options) => options.degrees, run: (view) => degreesTransform(view) },
76
+ { enabled: (options) => options.superscript, run: (view) => superscriptTransform(view) },
77
+ { enabled: (options) => options.ligatures, run: (view) => ligaturesTransform(view) },
78
+ // collapseSpaces again (see the entry pass) before nbsp: the nbsp rules
79
+ // bind through single spaces, so runs must be collapsed before
80
+ // non-breaking spaces are inserted.
81
+ { enabled: (options) => options.collapseSpaces, run: (view) => collapseSpacesTransform(view) },
82
+ { enabled: (options) => options.nbsp, run: (view) => nbspTransformFn(view) },
205
83
  ]
206
84
 
207
- export function transform(text: string, options: TransformOptions = {}): string {
208
- assertKnownOptionKeys(options, TRANSFORM_OPTION_KEYS, "transform")
209
-
210
- const separator = options.separator ?? DEFAULT_SEPARATOR
211
-
212
- if (separator.length === 0) {
213
- throw new Error("Invalid separator: must not be empty.")
214
- }
215
- // Non-BMP characters are encoded in UTF-16 as surrogate pairs in the range
216
- // U+D800–U+DFFF. A surrogate code unit (paired or lone) means the separator
217
- // would survive some regexes as a single char and split others as two units.
218
- if (/[\uD800-\uDFFF]/.test(separator)) {
219
- throw new Error(
220
- `Invalid separator: must contain only BMP characters (no characters outside the Basic Multilingual Plane). ` +
221
- `Received "${separator}" which contains a non-BMP character.`
222
- )
223
- }
224
-
225
- const punctuationStyle = options.punctuationStyle ?? "american"
226
- if (!PUNCTUATION_STYLES.includes(punctuationStyle as typeof PUNCTUATION_STYLES[number])) {
227
- throw new Error(
228
- `Invalid punctuationStyle: "${punctuationStyle}". ` +
229
- `Must be one of: ${PUNCTUATION_STYLES.join(", ")}.`
230
- )
231
- }
232
-
233
- const dashStyle = options.dashStyle ?? "american"
234
- if (!DASH_STYLES.includes(dashStyle as typeof DASH_STYLES[number])) {
235
- throw new Error(
236
- `Invalid dashStyle: "${dashStyle}". ` +
237
- `Must be one of: ${DASH_STYLES.join(", ")}.`
238
- )
239
- }
240
-
241
- const original = text
242
- const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...pipelineOpts } = { ...defaultOpts, ...filterUndefined(options) }
243
-
244
- // Collapse whitespace before the whitespace-sensitive dash and symbol passes.
245
- // Those passes recognize dashes by their surrounding spaces (`[ ]+`), so a tab
246
- // mixed into a space run (e.g. "word \t- word") would block conversion on the
247
- // first pass; collapsing it to a single space afterwards would then let a
248
- // second pass convert the dash, breaking idempotency. Normalizing first means
249
- // every pass sees the same spacing.
250
- if (collapseSpaces) {
251
- text = collapseSpacesTransform(text)
252
- }
253
-
254
- // Fold ellipses before range detection so that "...1-5" gains its post-ellipsis
255
- // space ("… 1-5") before enDashNumberRange runs. Otherwise the first pass sees a
256
- // range blocked by the leading dot and the second pass sees it preceded by a
257
- // space and en-dashes it, breaking idempotency. ellipsis is idempotent, so the
258
- // later symbolTransform call repeats it harmlessly.
259
- if (symbols) {
260
- text = ellipsisTransform(text, pipelineOpts)
261
- }
262
-
263
- text = hyphenReplace(text, pipelineOpts)
264
- if (pipelineOpts.punctuationStyle !== "none") {
265
- text = primeMarks(text, pipelineOpts)
266
- }
267
- text = niceQuotes(text, pipelineOpts)
268
-
269
- // Fractions run before symbolTransform so that "1/2(tm)" → "½(tm)" before
270
- // legalSymbols inspects it. Otherwise the unconverted "/" looks like a path
271
- // segment and suppresses the (tm) → ™ conversion on the first pass, while the
272
- // second pass (no "/") converts it, breaking idempotency.
273
- if (fractions) {
274
- text = fractionsTransform(text, pipelineOpts)
275
- }
276
-
277
- if (symbols) {
278
- text = symbolTransform(text, pipelineOpts)
279
- }
280
-
281
- if (degrees) {
282
- text = degreesTransform(text, pipelineOpts)
283
- }
284
-
285
- if (superscript) {
286
- text = superscriptTransform(text, pipelineOpts)
287
- }
288
-
289
- if (ligatures) {
290
- text = ligaturesTransform(text, pipelineOpts)
291
- }
292
-
293
- // Collapse again after the quote passes: localized styles (French guillemets,
294
- // German low-9 quotes) pad with narrow/no-break spaces that can land next to
295
- // an existing space, forming a run the up-front collapse never saw.
296
- if (collapseSpaces) {
297
- text = collapseSpacesTransform(text)
298
- }
299
-
300
- if (nbsp) {
301
- text = nbspTransformFn(text, pipelineOpts)
302
- }
303
-
304
- assertSeparatorCountPreserved(original, text, separator, "transform")
305
-
306
- if (checkIdempotency) {
307
- const secondPass = transform(text, { ...options, checkIdempotency: false })
308
- /* istanbul ignore if -- defensive check that should never trigger */
309
- if (text !== secondPass) {
310
- throw new Error(
311
- `Transform is not idempotent.\n` +
312
- `First pass: ${formatErrorString(text, "first-pass")}\n` +
313
- `Second pass: ${formatErrorString(secondPass, "second-pass")}\n` +
314
- `This is a bug in punctilio. Please file an issue at ${ISSUES_URL}\n` +
315
- `Include the input text that caused this error.\n` +
316
- `To suppress this check, pass { checkIdempotency: false }.`
317
- )
85
+ /**
86
+ * Runs the full transform pipeline over a ProseView in place, committing
87
+ * after every pass. Used by the rehype/remark plugins to transform text that
88
+ * spans multiple source nodes; for plain strings use {@link transform}.
89
+ */
90
+ export function transformView(view: ProseView, options: TransformOptions = {}): void {
91
+ const resolved = resolveTransformOptions(options)
92
+ for (const pass of PIPELINE) {
93
+ if (pass.enabled(resolved)) {
94
+ pass.run(view, resolved)
318
95
  }
319
96
  }
97
+ }
320
98
 
321
- return text
99
+ export function transform(text: string, options: TransformOptions = {}): string {
100
+ return withProseView(text, (view) => transformView(view, options))
322
101
  }
package/src/markdown.ts CHANGED
@@ -4,7 +4,7 @@ import remarkGfm from "remark-gfm"
4
4
  import remarkStringify from "remark-stringify"
5
5
  import QuickLRU from "quick-lru"
6
6
 
7
- import { TRANSFORM_OPTION_KEYS } from "./index.js"
7
+ import { TRANSFORM_OPTION_KEYS } from "./transform-options.js"
8
8
  import { remarkPunctilio, type RemarkPunctilioOptions } from "./remark.js"
9
9
  import { assertKnownOptionKeys, stableStringify } from "./utils.js"
10
10