punctilio 1.3.1 → 1.4.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 +32 -25
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +26 -8
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +17 -12
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +161 -0
- package/dist/rehype.d.ts.map +1 -0
- package/dist/rehype.js +401 -0
- package/dist/rehype.js.map +1 -0
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +2 -1
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +19 -0
- package/dist/utils.js.map +1 -1
- package/package.json +28 -4
- package/src/constants.ts +161 -0
- package/src/dashes.ts +211 -0
- package/src/index.ts +243 -0
- package/src/quotes.ts +132 -0
- package/src/rehype.ts +503 -0
- package/src/symbols.ts +324 -0
- package/src/tests/dashes.test.ts +649 -0
- package/src/tests/index.test.ts +528 -0
- package/src/tests/quotes.test.ts +344 -0
- package/src/tests/rehype.test.ts +418 -0
- package/src/tests/symbols.test.ts +708 -0
- package/src/tests/utils.test.ts +98 -0
- package/src/utils.ts +65 -0
- package/tsconfig.json +20 -0
package/src/constants.ts
ADDED
|
@@ -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,211 @@
|
|
|
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 multiple dashes: "word--word" or "word---word" or "quote"--"quote"
|
|
159
|
+
const quoteChars = `"'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`
|
|
160
|
+
text = text.replace(
|
|
161
|
+
new RegExp(`(?<=[${LATIN_LETTERS}\\d${quoteChars}])(?<sepBefore>${escapedSep}?)[~${EN_DASH}${EM_DASH}-]{2,}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}${quoteChars} ])`, "g"),
|
|
162
|
+
`$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
|
|
163
|
+
)
|
|
164
|
+
// Convert dashes at start of line
|
|
165
|
+
text = text.replace(new RegExp(`^(?<leadingSep>${escapedSep})?[-]+ `, "gm"), `$<leadingSep>${localizedDash} `)
|
|
166
|
+
// British: convert unspaced em-dashes to spaced en-dashes (word—word → word – word)
|
|
167
|
+
if (style === "british") {
|
|
168
|
+
text = text.replace(
|
|
169
|
+
new RegExp(`(?<=[${LATIN_LETTERS}.!?'"])(?<sepBefore>${escapedSep}?)${EM_DASH}(?<sepAfter>${escapedSep}?)(?=[${LATIN_LETTERS}])`, "g"),
|
|
170
|
+
`$<sepBefore>${maybeSpace}${localizedDash}${maybeSpace}$<sepAfter>`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return text
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Normalize em-dash spacing for Chicago style (American).
|
|
178
|
+
* Removes all spaces around em-dashes per Chicago Manual of Style.
|
|
179
|
+
*
|
|
180
|
+
* TODO: Handle interrupted-then-resumed speech within quotes, where Chicago
|
|
181
|
+
* allows a space after the dash: "Don't inter— Hey! Who threw that?"
|
|
182
|
+
*/
|
|
183
|
+
function normalizeEmDashSpacing(text: string, sep: string): string {
|
|
184
|
+
const escapedSep = escapeStringRegexp(sep)
|
|
185
|
+
|
|
186
|
+
// Remove all spaces around em-dashes
|
|
187
|
+
text = text.replace(
|
|
188
|
+
new RegExp(`(?<before>${escapedSep}?)[ ]*${EM_DASH}[ ]*(?<after>${escapedSep}?)`, "g"),
|
|
189
|
+
`$<before>${EM_DASH}$<after>`
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// Preserve space after em-dash at start of line (e.g., attribution)
|
|
193
|
+
text = text.replace(
|
|
194
|
+
new RegExp(`^(?<sep>${escapedSep}?)${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
|
|
195
|
+
`$<sep>${EM_DASH} $<after>`
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return text
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Full dash transformation. */
|
|
202
|
+
export function hyphenReplace(text: string, options: DashOptions = {}): string {
|
|
203
|
+
const sep = options.separator ?? DEFAULT_SEPARATOR
|
|
204
|
+
const style = options.dashStyle ?? "american"
|
|
205
|
+
text = minusReplace(text, options)
|
|
206
|
+
text = convertParentheticalDashes(text, sep, style)
|
|
207
|
+
if (style === "american") text = normalizeEmDashSpacing(text, sep)
|
|
208
|
+
text = enDashNumberRange(text, options)
|
|
209
|
+
text = enDashDateRange(text, options)
|
|
210
|
+
return text
|
|
211
|
+
}
|
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
|
+
}
|