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