punctilio 1.3.1 → 1.4.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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Shared Unicode constants used throughout the punctilio package.
3
+ *
4
+ * This module provides a centralized location for all Unicode symbols
5
+ * used in typography transformations, making the codebase more maintainable
6
+ * and self-documenting.
7
+ *
8
+ * @module constants
9
+ */
10
+
11
+ import escapeStringRegexp from "escape-string-regexp"
12
+
13
+ /**
14
+ * Unicode symbols for typography transformations.
15
+ */
16
+ export const UNICODE_SYMBOLS = {
17
+ ELLIPSIS: "\u2026",
18
+ MULTIPLICATION: "\u00D7",
19
+ NOT_EQUAL: "\u2260",
20
+ PLUS_MINUS: "\u00B1",
21
+ COPYRIGHT: "\u00A9",
22
+ REGISTERED: "\u00AE",
23
+ TRADEMARK: "\u2122",
24
+ DEGREE: "\u00B0",
25
+ ARROW_RIGHT: "\u2192",
26
+ ARROW_LEFT: "\u2190",
27
+ ARROW_LEFT_RIGHT: "\u2194",
28
+ APPROXIMATE: "\u2248",
29
+ LESS_EQUAL: "\u2264",
30
+ GREATER_EQUAL: "\u2265",
31
+ PRIME: "\u2032",
32
+ DOUBLE_PRIME: "\u2033",
33
+ FRACTION_1_4: "\u00BC",
34
+ FRACTION_1_2: "\u00BD",
35
+ FRACTION_3_4: "\u00BE",
36
+ FRACTION_1_3: "\u2153",
37
+ FRACTION_2_3: "\u2154",
38
+ FRACTION_1_5: "\u2155",
39
+ FRACTION_2_5: "\u2156",
40
+ FRACTION_3_5: "\u2157",
41
+ FRACTION_4_5: "\u2158",
42
+ FRACTION_1_6: "\u2159",
43
+ FRACTION_5_6: "\u215A",
44
+ FRACTION_1_8: "\u215B",
45
+ FRACTION_3_8: "\u215C",
46
+ FRACTION_5_8: "\u215D",
47
+ FRACTION_7_8: "\u215E",
48
+ EM_DASH: "\u2014",
49
+ EN_DASH: "\u2013",
50
+ MINUS: "\u2212",
51
+ LEFT_DOUBLE_QUOTE: "\u201C",
52
+ RIGHT_DOUBLE_QUOTE: "\u201D",
53
+ LEFT_SINGLE_QUOTE: "\u2018",
54
+ RIGHT_SINGLE_QUOTE: "\u2019",
55
+ NBSP: "\u00A0",
56
+ // Superscript ordinal suffixes
57
+ SUPERSCRIPT_ST: "\u02E2\u1D57", // ˢᵗ
58
+ SUPERSCRIPT_ND: "\u207F\u1D48", // ⁿᵈ
59
+ SUPERSCRIPT_RD: "\u02B3\u1D48", // ʳᵈ
60
+ SUPERSCRIPT_TH: "\u1D57\u02B0", // ᵗʰ
61
+ // Punctuation ligatures
62
+ DOUBLE_QUESTION: "\u2047", // ⁇
63
+ QUESTION_EXCLAMATION: "\u2048", // ⁈
64
+ EXCLAMATION_QUESTION: "\u2049", // ⁉
65
+ DOUBLE_EXCLAMATION: "\u203C", // ‼
66
+ } as const
67
+
68
+ /**
69
+ * Character class pattern for Latin letters including European accented characters.
70
+ * Use inside regex character classes: `[${LATIN_LETTERS}]`
71
+ *
72
+ * Includes:
73
+ * - Basic Latin: A-Z, a-z
74
+ * - Latin-1 Supplement: À-Ö, Ø-ö, ø-ÿ (excludes × and ÷)
75
+ * - Latin Extended-A: Ā-ſ (U+0100-017F)
76
+ * - Latin Extended-B: ƀ-ɏ (U+0180-024F)
77
+ *
78
+ * Examples of covered characters: é, ñ, ü, ø, ą, ł, ș
79
+ */
80
+ export const LATIN_LETTERS = "A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF\\u0100-\\u017F\\u0180-\\u024F"
81
+
82
+ /**
83
+ * Default separator character for text spanning HTML elements.
84
+ * Uses Unicode Private Use Area character U+E000.
85
+ */
86
+ export const DEFAULT_SEPARATOR = "\uE000"
87
+ export const ESCAPED_DEFAULT_SEPARATOR = escapeStringRegexp(DEFAULT_SEPARATOR)
88
+
89
+ /**
90
+ * Characters that have special meaning in regular expressions.
91
+ * Used for testing that separator escaping works correctly.
92
+ */
93
+ export const REGEX_SPECIAL_CHARS = [".", "*", "+", "?", "^", "$", "[", "]", "\\", "|", "(", ")"] as const
94
+
95
+ /**
96
+ * Creates a marker-aware word boundary pattern for the START of a match.
97
+ *
98
+ * Standard `\b` can create false boundaries when separator markers appear between
99
+ * word characters (e.g., `x\uE000ReLU` has a false boundary before `R`).
100
+ *
101
+ * This pattern uses a negative lookbehind to reject matches that are preceded
102
+ * by word characters followed by any number of markers.
103
+ *
104
+ * @param escapedSeparator - Regex-escaped separator string
105
+ * @returns Pattern string: `(?<!\w${sep}*)\b`
106
+ *
107
+ * @example
108
+ * const wb = wordBoundaryStart(ESCAPED_DEFAULT_SEPARATOR)
109
+ * // With text "x\uE000ReLU": \b would match before R, but wb won't
110
+ */
111
+ export function wordBoundaryStart(escapedSeparator: string): string {
112
+ return `(?<!\\w${escapedSeparator}*)\\b`
113
+ }
114
+
115
+ /**
116
+ * Creates a marker-aware word boundary pattern for the END of a match.
117
+ *
118
+ * Standard `\b` can create false boundaries when separator markers appear between
119
+ * word characters (e.g., `1st\uE000ly` has a false boundary after `t`).
120
+ *
121
+ * This pattern uses a negative lookahead to reject matches that are followed
122
+ * by markers then word characters.
123
+ *
124
+ * @param escapedSeparator - Regex-escaped separator string
125
+ * @returns Pattern string: `\b(?!${sep}*\w)`
126
+ *
127
+ * @example
128
+ * const wbe = wordBoundaryEnd(ESCAPED_DEFAULT_SEPARATOR)
129
+ * // With text "1st\uE000ly": \b would match after t, but wbe won't
130
+ */
131
+ export function wordBoundaryEnd(escapedSeparator: string): string {
132
+ return `\\b(?!${escapedSeparator}*\\w)`
133
+ }
134
+
135
+ /**
136
+ * Pattern string for space characters (regular space and non-breaking space).
137
+ * Use inside regex character classes: `[${SPACE_CHARS}]`
138
+ */
139
+ export const SPACE_CHARS = ` ${UNICODE_SYMBOLS.NBSP}`
140
+
141
+ /**
142
+ * Creates a lookbehind pattern that matches after whitespace, separator, or start of string.
143
+ * Used for arrow patterns and other constructs that should appear at word boundaries.
144
+ *
145
+ * @param escapedSeparator - Regex-escaped separator string
146
+ * @returns Pattern string: `(?<=[\\s${sep}]|^)`
147
+ */
148
+ export function spaceBoundaryStart(escapedSeparator: string): string {
149
+ return `(?<=[\\s${escapedSeparator}]|^)`
150
+ }
151
+
152
+ /**
153
+ * Creates a lookahead pattern that matches before whitespace, separator, or end of string.
154
+ * Used for arrow patterns and other constructs that should appear at word boundaries.
155
+ *
156
+ * @param escapedSeparator - Regex-escaped separator string
157
+ * @returns Pattern string: `(?=[\\s${sep}]|$)`
158
+ */
159
+ export function spaceBoundaryEnd(escapedSeparator: string): string {
160
+ return `(?=[\\s${escapedSeparator}]|$)`
161
+ }
package/src/dashes.ts ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Dash transformation: hyphens → em-dashes, en-dashes, minus signs.
3
+ */
4
+
5
+ import escapeStringRegexp from "escape-string-regexp"
6
+ import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, ESCAPED_DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryStart, wordBoundaryEnd } from "./constants.js"
7
+
8
+ export type DashStyle = "american" | "british" | "none"
9
+
10
+ export interface DashOptions {
11
+ /** Boundary marker for HTML element boundaries. Default: "\uE000" */
12
+ separator?: string
13
+ /** "american" (unspaced em), "british" (spaced en), "none". Default: "american" */
14
+ dashStyle?: DashStyle
15
+ }
16
+
17
+ const { EN_DASH, EM_DASH, MINUS, LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE } = UNICODE_SYMBOLS
18
+
19
+ /**
20
+ * Characters that, when preceding a number, prevent it from being
21
+ * treated as the start of a number range. This prevents false positives
22
+ * in model names like "Llama-2-7B" where "2-7" should not become "2–7".
23
+ */
24
+ export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS] as const
25
+
26
+ export const months = [
27
+ "January", "February", "March", "April", "May", "June",
28
+ "July", "August", "September", "October", "November", "December",
29
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
30
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
31
+ ].join("|")
32
+
33
+ /** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
34
+ export function enDashNumberRange(text: string, options: DashOptions = {}): string {
35
+ const chr = options.separator ? escapeStringRegexp(options.separator) : ESCAPED_DEFAULT_SEPARATOR
36
+ const wb = wordBoundaryStart(chr)
37
+ const wbe = wordBoundaryEnd(chr)
38
+
39
+ // Escape dash-like chars for lookbehind: prevents matching after dashes (e.g., Llama-2-7B)
40
+ const disallowed = numberRangeDisallowedPrefixes.map(c => c === "-" ? c : `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`).join("")
41
+
42
+ // Common currency symbols for price ranges
43
+ const currencies = "$€£¥₹"
44
+
45
+ // Build positive range pattern from readable components
46
+ const phoneAreaCode = `(?<precedingAreaCode>\\d{3}-|\\(\\d{3}\\) ?)?` // 555- or (555)
47
+ const notAfterDash = `(?<![${disallowed}${LATIN_LETTERS}.])` // prevent Llama-2-7B
48
+ const rangeStart = `(?<start>(?:p\\.?|[${currencies}])?\\d[\\d.,]*${chr}?)` // p.10, $100, 1,000
49
+ const rangeEnd = `(?<end>${chr}?[${currencies}]?\\d[\\d.,]*)` // 20, $200, 2,000
50
+ const moreSegments = `(?<following>(?:${chr}?-${chr}?\\d+)*)` // -4567 in phone numbers
51
+ const unitSuffix = `(?<suffix>${chr}?(?:[AaPp][Mm]|[xKBTM]))?` // am/pm, K/M/B
52
+
53
+ // Positive ranges: 1-5, $100-$200, €5-€10, p.10-15
54
+ const positiveRangePattern = [
55
+ phoneAreaCode, wb, notAfterDash, rangeStart, "-", rangeEnd, moreSegments, unitSuffix, wbe
56
+ ].join("")
57
+
58
+ text = text.replace(
59
+ new RegExp(positiveRangePattern, "g"),
60
+ (match, precedingAreaCode, start, end, following, suffix = "") => {
61
+ if (following) return match
62
+ const startNum = start.replace(new RegExp(chr, "g"), "")
63
+ const endNum = end.replace(new RegExp(chr, "g"), "")
64
+ if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum)) return match
65
+ // Skip phone number patterns: 3 digits followed by 4 digits with preceding area code
66
+ // e.g., 555-123-4567 or (555) 123-4567 where we're matching the "123-4567" part
67
+ if (precedingAreaCode && /^\d{3}$/.test(startNum) && /^\d{4}$/.test(endNum)) return match
68
+ // Skip US country code + area code pattern: 1-800, 1-888, etc.
69
+ // These look like truncated phone numbers, not ranges
70
+ if (/^1$/.test(startNum) && /^\d{3}$/.test(endNum)) return match
71
+ return `${precedingAreaCode || ""}${start}${EN_DASH}${end}${suffix || ""}`
72
+ }
73
+ )
74
+
75
+ // Negative ranges: −5-5 → −5–5, −5--2 → −5–−2
76
+ // Separate regex because MINUS isn't a word char, so \b in ${wb} would match after it
77
+ text = text.replace(
78
+ new RegExp(
79
+ `(?<![${LATIN_LETTERS}])(?<start>${MINUS}\\d[\\d.,]*${chr}?)-(?<neg>-)?(?<end>${chr}?\\d[\\d.,]*)(?<following>(?:${chr}?-${chr}?\\d+)*)(?<suffix>${chr}?[xKBTM])?${wbe}`,
80
+ "g"
81
+ ),
82
+ (match, start, neg, end, following, suffix = "") => {
83
+ if (following) return match
84
+ return `${start}${EN_DASH}${neg ? MINUS : ""}${end}${suffix || ""}`
85
+ }
86
+ )
87
+
88
+ return text
89
+ }
90
+
91
+ /** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
92
+ export function enDashDateRange(text: string, options: DashOptions = {}): string {
93
+ const chr = options.separator ? escapeStringRegexp(options.separator) : ESCAPED_DEFAULT_SEPARATOR
94
+ const dashStyle = options.dashStyle ?? "american"
95
+ const wb = wordBoundaryStart(chr)
96
+ const wbe = wordBoundaryEnd(chr)
97
+
98
+ return text.replace(
99
+ new RegExp(`${wb}(?<startMonth>${months})(?<startYear>${chr}? \\d{4})?(?<preSep>${chr}?)(?<preSpace> ?)-(?<postSpace> ?)(?<postSep>${chr}?)(?<endMonth>${months})(?<endYear> \\d{4})?${wbe}`, "g"),
100
+ (...args) => {
101
+ const g = args.at(-1) as Record<string, string>
102
+ const [pre, post] = dashStyle === "british" ? [" ", " "] : dashStyle === "none" ? [g.preSpace, g.postSpace] : ["", ""]
103
+ return `${g.startMonth}${g.startYear || ""}${g.preSep}${pre}${EN_DASH}${post}${g.postSep}${g.endMonth}${g.endYear || ""}`
104
+ }
105
+ )
106
+ }
107
+
108
+ /** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
109
+ export function minusReplace(text: string, options: DashOptions = {}): string {
110
+ const chr = escapeStringRegexp(options.separator ?? DEFAULT_SEPARATOR)
111
+
112
+ // Pattern 1a: Subtraction of negative number (e.g., "5 - -3" → "5 − −3")
113
+ // Must come before Pattern 1b so the negative hyphen is consumed first
114
+ text = text.replaceAll(
115
+ new RegExp(`(?<=\\d${chr}?) - -(?<num>${chr}?\\d*\\.?\\d+)`, "g"),
116
+ ` ${MINUS} ${MINUS}$<num>`
117
+ )
118
+
119
+ // Pattern 1b: Spaced math subtraction (e.g., "5 - 3" → "5 − 3")
120
+ // Only when preceded by a digit - this distinguishes "5 - 3" from "Safari) - 9"
121
+ text = text.replaceAll(
122
+ new RegExp(`(?<=\\d${chr}?) - (?<num>${chr}?\\d*\\.?\\d+)`, "g"),
123
+ ` ${MINUS} $<num>`
124
+ )
125
+
126
+ // Pattern 2: Direct negative numbers (e.g., "-5" → "−5", "(-3)" → "(−3)")
127
+ // Match after: start of line, whitespace, (, separator, or quotes (straight or curly)
128
+ // No space allowed between hyphen and digit
129
+ text = text.replaceAll(
130
+ new RegExp(`(?<before>^|[\\s\\("${chr}${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])-(?<num>\\d*\\.?\\d+)`, "gm"),
131
+ `$<before>${MINUS}$<num>`
132
+ )
133
+
134
+ return text
135
+ }
136
+
137
+ /**
138
+ * Convert surrounded dashes to em/en dashes.
139
+ * Handles patterns like "word - word" → "word—word" (Chicago) or "word – word" (Oxford).
140
+ */
141
+ function convertParentheticalDashes(text: string, sep: string, style: DashStyle): string {
142
+ if (style === "none") return text
143
+ const localizedDash = style === "british" ? EN_DASH : EM_DASH
144
+ const maybeSpace = style === "british" ? " " : ""
145
+ const escapedSep = escapeStringRegexp(sep)
146
+
147
+ // Convert spaced dashes: "word - word" or "word — word"
148
+ // When a separator follows the dash, preserve trailing spaces (they belong to the next text segment).
149
+ const spacedDashPattern = new RegExp(
150
+ `(?<=[^\\s]|^)(?<sepBefore>${escapedSep}?)[ ]+[~${EN_DASH}${EM_DASH}-]+(?!-*>)[ ]*(?<sepAfter>${escapedSep}?)(?<trailing>[ ]*)(?=\\S|$)`, "g"
151
+ )
152
+ text = text.replace(spacedDashPattern, (_match, sepBefore, sepAfter, trailing) => {
153
+ // For British style (spaced en-dash), preserve trailing spaces after separator
154
+ // For American style (unspaced em-dash), consume trailing spaces (em-dash replaces surrounding space)
155
+ const keepTrailing = maybeSpace && sepAfter ? trailing : ""
156
+ return `${sepBefore}${maybeSpace}${localizedDash}${maybeSpace}${sepAfter}${keepTrailing}`
157
+ })
158
+ // Convert dashes at text node boundaries: separator alone precedes dash (e.g., "word{sep}– rest")
159
+ // Requires space after the dash to avoid matching number ranges like "1{sep}-{sep}5"
160
+ text = text.replace(
161
+ new RegExp(`(?<=[^\\s])(?<sepBefore>${escapedSep})[~${EN_DASH}${EM_DASH}-]+[ ]+(?<sepAfter>${escapedSep}?)`, "g"),
162
+ `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
163
+ )
164
+ // Convert multiple dashes: "word--word" or "word---word" or "quote"--"quote"
165
+ const quoteChars = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
166
+ text = text.replace(
167
+ new RegExp(`(?<=[${LATIN_LETTERS}\\d${quoteChars}])(?<sepBefore>${escapedSep}?)[~${EN_DASH}${EM_DASH}-]{2,}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}${quoteChars} ])`, "g"),
168
+ `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
169
+ )
170
+ // Convert dashes at start of line
171
+ text = text.replace(new RegExp(`^(?<leadingSep>${escapedSep})?[-]+ `, "gm"), `$<leadingSep>${localizedDash} `)
172
+ // British: convert unspaced em-dashes to spaced en-dashes (word—word → word – word)
173
+ if (style === "british") {
174
+ text = text.replace(
175
+ new RegExp(`(?<=[${LATIN_LETTERS}.!?'"])(?<sepBefore>${escapedSep}?)${EM_DASH}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}])`, "g"),
176
+ `$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
177
+ )
178
+ }
179
+ return text
180
+ }
181
+
182
+ /**
183
+ * Normalize em-dash spacing for Chicago style (American).
184
+ * Removes all spaces around em-dashes per Chicago Manual of Style.
185
+ *
186
+ * TODO: Handle interrupted-then-resumed speech within quotes, where Chicago
187
+ * allows a space after the dash: "Don't inter— Hey! Who threw that?"
188
+ */
189
+ function normalizeEmDashSpacing(text: string, sep: string): string {
190
+ const escapedSep = escapeStringRegexp(sep)
191
+
192
+ // Remove all spaces around em-dashes
193
+ text = text.replace(
194
+ new RegExp(`(?<before>${escapedSep}?)[ ]*${EM_DASH}[ ]*(?<after>${escapedSep}?)`, "g"),
195
+ `$<before>${EM_DASH}$<after>`
196
+ )
197
+
198
+ // Preserve space after em-dash at start of line (e.g., attribution)
199
+ text = text.replace(
200
+ new RegExp(`^(?<sep>${escapedSep}?)${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
201
+ `$<sep>${EM_DASH} $<after>`
202
+ )
203
+
204
+ return text
205
+ }
206
+
207
+ /** Full dash transformation. */
208
+ export function hyphenReplace(text: string, options: DashOptions = {}): string {
209
+ const sep = options.separator ?? DEFAULT_SEPARATOR
210
+ const style = options.dashStyle ?? "american"
211
+ text = minusReplace(text, options)
212
+ text = convertParentheticalDashes(text, sep, style)
213
+ if (style === "american") text = normalizeEmDashSpacing(text, sep)
214
+ text = enDashNumberRange(text, options)
215
+ text = enDashDateRange(text, options)
216
+ return text
217
+ }
package/src/index.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * punctilio - Smart typography transformations
3
+ *
4
+ * A library for converting plain ASCII punctuation into typographically
5
+ * correct Unicode characters. Handles smart quotes, em-dashes, en-dashes,
6
+ * minus signs, ellipses, multiplication signs, and more.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ export { niceQuotes, type QuoteOptions, type PunctuationStyle } from "./quotes.js"
12
+ import type { PunctuationStyle } from "./quotes.js"
13
+ export {
14
+ hyphenReplace,
15
+ enDashNumberRange,
16
+ enDashDateRange,
17
+ minusReplace,
18
+ months,
19
+ numberRangeDisallowedPrefixes,
20
+ type DashOptions,
21
+ type DashStyle,
22
+ } from "./dashes.js"
23
+ import type { DashStyle } from "./dashes.js"
24
+ export {
25
+ ellipsis,
26
+ multiplication,
27
+ mathSymbols,
28
+ legalSymbols,
29
+ arrows,
30
+ degrees,
31
+ fractions,
32
+ primeMarks,
33
+ collapseSpaces,
34
+ superscriptOrdinal,
35
+ punctuationLigatures,
36
+ symbolTransform,
37
+ type SymbolOptions,
38
+ } from "./symbols.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" (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
+ *
61
+ * - `true` (default): "hello world" → "hello world"
62
+ * - `false`: Preserve multiple spaces
63
+ *
64
+ * Default: true
65
+ */
66
+ collapseSpaces?: boolean
67
+
68
+ /**
69
+ * How to handle punctuation placement around quotation marks.
70
+ *
71
+ * - `"american"` (default): Chicago style. Periods and commas go inside quotes.
72
+ * Example: "Hello." and "Hello,"
73
+ * - `"british"`: Oxford style. Periods and commas go outside quotes.
74
+ * Example: "Hello". and "Hello",
75
+ * - `"none"`: Don't modify punctuation placement
76
+ *
77
+ * Default: "american"
78
+ */
79
+ punctuationStyle?: PunctuationStyle
80
+
81
+ /**
82
+ * How to style parenthetical dashes.
83
+ *
84
+ * - `"american"` (default): Chicago style. Unspaced em dash (word—word)
85
+ * - `"british"`: Oxford style. Spaced en dash (word – word)
86
+ * - `"none"`: Don't convert parenthetical dashes
87
+ *
88
+ * Default: "american"
89
+ */
90
+ dashStyle?: DashStyle
91
+
92
+ /**
93
+ * Whether to include fraction transforms (1/2 → ½)
94
+ * Default: false (can be aggressive)
95
+ */
96
+ fractions?: boolean
97
+
98
+ /**
99
+ * Whether to include degree symbol transforms (20 C → 20 °C)
100
+ * Default: false (can be aggressive)
101
+ */
102
+ degrees?: boolean
103
+
104
+ /**
105
+ * Whether to convert ordinal suffixes to Unicode superscript characters.
106
+ * Transforms numbers like "1st", "2nd", "3rd", "4th" to "1ˢᵗ", "2ⁿᵈ", "3ʳᵈ", "4ᵗʰ".
107
+ * Default: false
108
+ */
109
+ superscript?: boolean
110
+
111
+ /**
112
+ * Whether to convert repeated punctuation marks to Unicode ligature characters.
113
+ * Squashes multiple marks: "???" → "⁇", "?!" → "⁈", "!?" → "⁉", "!!!" → "!"
114
+ * Default: false (poor font support)
115
+ */
116
+ ligatures?: boolean
117
+
118
+ /**
119
+ * Whether to verify that the transformation is idempotent (running twice
120
+ * produces the same result). When enabled, throws an error if the second
121
+ * pass produces a different result than the first.
122
+ *
123
+ * Default: true
124
+ */
125
+ checkIdempotency?: boolean
126
+ }
127
+
128
+ import { niceQuotes } from "./quotes.js"
129
+ import { hyphenReplace } from "./dashes.js"
130
+ import { symbolTransform, fractions as fractionsTransform, degrees as degreesTransform, superscriptOrdinal as superscriptTransform, primeMarks, collapseSpaces as collapseSpacesTransform, punctuationLigatures as ligaturesTransform } from "./symbols.js"
131
+ import { assertSeparatorCountPreserved, formatErrorString } from "./utils.js"
132
+ import { DEFAULT_SEPARATOR } from "./constants.js"
133
+
134
+ export { assertSeparatorCountPreserved, countSeparators } from "./utils.js"
135
+ export { DEFAULT_SEPARATOR } from "./constants.js"
136
+
137
+ /**
138
+ * Applies all typography transformations: smart quotes, proper dashes,
139
+ * and symbol improvements.
140
+ *
141
+ * This is a convenience function that applies transformations in sequence:
142
+ * 1. hyphenReplace (em-dashes, en-dashes, minus signs)
143
+ * 2. primeMarks (feet/inches, arcminutes/arcseconds)
144
+ * 3. niceQuotes (smart quotes)
145
+ * 4. symbolTransform (ellipses, multiplication, math symbols, legal symbols, arrows)
146
+ * 5. fractions (disabled by default)
147
+ * 6. degrees (disabled by default)
148
+ * 7. superscript (disabled by default)
149
+ * 8. ligatures (disabled by default)
150
+ * 9. collapseSpaces (collapses multiple spaces into one)
151
+ *
152
+ * @param text - The text to transform
153
+ * @param options - Configuration options
154
+ * @returns The text with all typography improvements applied
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * import { transform } from 'punctilio'
159
+ *
160
+ * transform('"Hello," she said - "it\'s pages 1-5."')
161
+ * // → '"Hello," she said—"it's pages 1–5."'
162
+ *
163
+ * transform('Wait... 5x5 != 25 (c) 2024')
164
+ * // → 'Wait… 5×5 ≠ 25 © 2024'
165
+ *
166
+ * transform('Add 1/2 cup', { fractions: true })
167
+ * // → 'Add ½ cup'
168
+ * ```
169
+ */
170
+ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
171
+ symbols: true,
172
+ fractions: false,
173
+ degrees: false,
174
+ superscript: false,
175
+ ligatures: false,
176
+ collapseSpaces: true,
177
+ checkIdempotency: true,
178
+ punctuationStyle: "american",
179
+ dashStyle: "american",
180
+ }
181
+
182
+ export function transform(text: string, options: TransformOptions = {}): string {
183
+ const separator = options.separator ?? DEFAULT_SEPARATOR
184
+
185
+ // Validate separator: must be a single UTF-16 code unit (BMP character)
186
+ // Multi-codepoint characters (surrogate pairs like emoji) cause issues with regex patterns
187
+ if (separator.length !== 1) {
188
+ throw new Error(
189
+ `Invalid separator: must be a single character. ` +
190
+ `Received "${separator}" (length: ${separator.length}). ` +
191
+ `Use a single-codepoint character like the default "\\uE000".`
192
+ )
193
+ }
194
+
195
+ const original = text
196
+ const { symbols, fractions, degrees, superscript, ligatures, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options }
197
+
198
+ text = hyphenReplace(text, separatorOpts)
199
+ text = primeMarks(text, separatorOpts)
200
+ text = niceQuotes(text, separatorOpts)
201
+
202
+ if (symbols) {
203
+ text = symbolTransform(text, separatorOpts)
204
+ }
205
+
206
+ if (fractions) {
207
+ text = fractionsTransform(text)
208
+ }
209
+
210
+ if (degrees) {
211
+ text = degreesTransform(text)
212
+ }
213
+
214
+ if (superscript) {
215
+ text = superscriptTransform(text, separatorOpts)
216
+ }
217
+
218
+ if (ligatures) {
219
+ text = ligaturesTransform(text, separatorOpts)
220
+ }
221
+
222
+ if (collapseSpaces) {
223
+ text = collapseSpacesTransform(text)
224
+ }
225
+
226
+ assertSeparatorCountPreserved(original, text, separator, "transform")
227
+
228
+ if (checkIdempotency) {
229
+ const secondPass = transform(text, { ...options, checkIdempotency: false })
230
+ /* istanbul ignore if -- defensive check that should never trigger */
231
+ if (text !== secondPass) {
232
+ throw new Error(
233
+ `Transform is not idempotent.\n` +
234
+ `First pass: ${formatErrorString(text, "first-pass")}\n` +
235
+ `Second pass: ${formatErrorString(secondPass, "second-pass")}\n` +
236
+ `This is a bug in punctilio. Please file an issue at https://github.com/alexander-turner/punctilio/issues\n` +
237
+ `Include the input text that caused this error.`
238
+ )
239
+ }
240
+ }
241
+
242
+ return text
243
+ }