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/symbols.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol transformations: ellipses, multiplication, math symbols, arrows.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import escapeStringRegexp from "escape-string-regexp"
|
|
6
|
+
import { UNICODE_SYMBOLS, ESCAPED_DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, spaceBoundaryStart, spaceBoundaryEnd } from "./constants.js"
|
|
7
|
+
|
|
8
|
+
export interface SymbolOptions {
|
|
9
|
+
/** Boundary marker for HTML element boundaries. Default: "\uE000" */
|
|
10
|
+
separator?: string
|
|
11
|
+
/** Include arrow transforms (-> → →). Default: true */
|
|
12
|
+
includeArrows?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
ELLIPSIS,
|
|
17
|
+
MULTIPLICATION,
|
|
18
|
+
NOT_EQUAL,
|
|
19
|
+
PLUS_MINUS,
|
|
20
|
+
COPYRIGHT,
|
|
21
|
+
REGISTERED,
|
|
22
|
+
TRADEMARK,
|
|
23
|
+
DEGREE,
|
|
24
|
+
ARROW_RIGHT,
|
|
25
|
+
ARROW_LEFT,
|
|
26
|
+
ARROW_LEFT_RIGHT,
|
|
27
|
+
APPROXIMATE,
|
|
28
|
+
LESS_EQUAL,
|
|
29
|
+
GREATER_EQUAL,
|
|
30
|
+
NBSP,
|
|
31
|
+
PRIME,
|
|
32
|
+
DOUBLE_PRIME,
|
|
33
|
+
SUPERSCRIPT_ST,
|
|
34
|
+
SUPERSCRIPT_ND,
|
|
35
|
+
SUPERSCRIPT_RD,
|
|
36
|
+
SUPERSCRIPT_TH,
|
|
37
|
+
DOUBLE_QUESTION,
|
|
38
|
+
QUESTION_EXCLAMATION,
|
|
39
|
+
EXCLAMATION_QUESTION,
|
|
40
|
+
} = UNICODE_SYMBOLS
|
|
41
|
+
|
|
42
|
+
/** Convert "..." or ". . ." to "…". */
|
|
43
|
+
export function ellipsis(text: string, options: SymbolOptions = {}): string {
|
|
44
|
+
const chr = options.separator
|
|
45
|
+
? escapeStringRegexp(options.separator)
|
|
46
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
47
|
+
|
|
48
|
+
// Convert consecutive or spaced dots: ... or . . . → …
|
|
49
|
+
// Captures preserve any separators between dots
|
|
50
|
+
const pattern = new RegExp(`\\.[${SPACE_CHARS}]?(${chr})?\\.([${SPACE_CHARS}]?)(${chr})?\\.`, "g")
|
|
51
|
+
text = text.replace(pattern, (_match, sep1, _space, sep2) => {
|
|
52
|
+
return ELLIPSIS + (sep1 || "") + (sep2 || "")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
text = text.replace(new RegExp(`${ELLIPSIS}(?=[${LATIN_LETTERS}\\d])`, "gu"), `${ELLIPSIS} `)
|
|
56
|
+
|
|
57
|
+
return text
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Convert "5x5" to "5×5". Skips hex (0x5F). */
|
|
61
|
+
export function multiplication(text: string, options: SymbolOptions = {}): string {
|
|
62
|
+
const chr = options.separator
|
|
63
|
+
? escapeStringRegexp(options.separator)
|
|
64
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
65
|
+
|
|
66
|
+
// Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
|
|
67
|
+
// Pattern matches: digit(s), then one or more (operator, digit(s)) groups
|
|
68
|
+
const chainPattern = new RegExp(
|
|
69
|
+
`(\\d+)((?:${chr}?\\s*[xX*]\\s*${chr}?\\d+)+)`,
|
|
70
|
+
"g"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
text = text.replace(chainPattern, (match, firstNum, rest) => {
|
|
74
|
+
// Skip hexadecimal: 0x... or 0X...
|
|
75
|
+
if (firstNum === "0" && /^x/i.test(rest)) return match
|
|
76
|
+
|
|
77
|
+
// Replace all operators in the chain, preserving spacing
|
|
78
|
+
const converted = rest.replace(
|
|
79
|
+
new RegExp(`(${chr}?)(\\s*)[xX*](\\s*)(${chr}?)`, "g"),
|
|
80
|
+
(_: string, pre: string, spaceBefore: string, spaceAfter: string, post: string) => {
|
|
81
|
+
const space = spaceBefore || spaceAfter ? " " : ""
|
|
82
|
+
return `${pre}${space}${MULTIPLICATION}${space}${post}`
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
return `${firstNum}${converted}`
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Trailing multiplier: 5x (followed by word boundary - space, punctuation, etc.)
|
|
89
|
+
const wbe = wordBoundaryEnd(chr)
|
|
90
|
+
const trailingPattern = new RegExp(`(?<num>\\d+${chr}?)[xX*]${wbe}`, "g")
|
|
91
|
+
text = text.replace(trailingPattern, (match, num: string) => {
|
|
92
|
+
// Skip if this looks like start of hexadecimal
|
|
93
|
+
if (num === "0") return match
|
|
94
|
+
return `${num}${MULTIPLICATION}`
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return text
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Math symbol replacement map: ASCII → Unicode */
|
|
101
|
+
const MATH_SYMBOL_MAP: [RegExp, string][] = [
|
|
102
|
+
[/!=/g, NOT_EQUAL],
|
|
103
|
+
[/\+\/-/g, PLUS_MINUS],
|
|
104
|
+
[/\+-/g, PLUS_MINUS],
|
|
105
|
+
[/<=/g, LESS_EQUAL],
|
|
106
|
+
[/>=/g, GREATER_EQUAL],
|
|
107
|
+
[/~=/g, APPROXIMATE],
|
|
108
|
+
[/=~/g, APPROXIMATE],
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
/** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
|
|
112
|
+
export function mathSymbols(text: string): string {
|
|
113
|
+
for (const [pattern, replacement] of MATH_SYMBOL_MAP) {
|
|
114
|
+
text = text.replace(pattern, replacement)
|
|
115
|
+
}
|
|
116
|
+
return text
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Legal symbol replacement map: ASCII → Unicode */
|
|
120
|
+
const LEGAL_SYMBOL_MAP: [RegExp, string][] = [
|
|
121
|
+
[/\(c\)/gi, COPYRIGHT],
|
|
122
|
+
[/\(r\)/gi, REGISTERED],
|
|
123
|
+
[/\(tm\)/gi, TRADEMARK],
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
/** Convert (c), (r), (tm) to ©, ®, ™. */
|
|
127
|
+
export function legalSymbols(text: string): string {
|
|
128
|
+
for (const [pattern, replacement] of LEGAL_SYMBOL_MAP) {
|
|
129
|
+
text = text.replace(pattern, replacement)
|
|
130
|
+
}
|
|
131
|
+
return text
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Arrow pattern map: arrow shape → Unicode symbol */
|
|
135
|
+
const ARROW_MAP: [string, string][] = [
|
|
136
|
+
[`<-+${"%CHR%"}?>`, ARROW_LEFT_RIGHT], // Bidirectional: <-> or <-->
|
|
137
|
+
["-+>", ARROW_RIGHT], // Right: -> or -->
|
|
138
|
+
["<-+", ARROW_LEFT], // Left: <- or <--
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
/** Convert -> and <-> to arrows. */
|
|
142
|
+
export function arrows(text: string, options: SymbolOptions = {}): string {
|
|
143
|
+
const chr = options.separator
|
|
144
|
+
? escapeStringRegexp(options.separator)
|
|
145
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
146
|
+
|
|
147
|
+
const start = spaceBoundaryStart(chr)
|
|
148
|
+
const end = spaceBoundaryEnd(chr)
|
|
149
|
+
|
|
150
|
+
for (const [arrowPattern, replacement] of ARROW_MAP) {
|
|
151
|
+
const pattern = new RegExp(`${start}${arrowPattern.replace("%CHR%", chr)}${end}`, "g")
|
|
152
|
+
text = text.replace(pattern, replacement)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return text
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Convert "20 C" or "20 F" to "20 °C" or "20 °F". Only matches uppercase C/F. */
|
|
159
|
+
export function degrees(text: string, options: SymbolOptions = {}): string {
|
|
160
|
+
const chr = options.separator
|
|
161
|
+
? escapeStringRegexp(options.separator)
|
|
162
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
163
|
+
|
|
164
|
+
// Temperature with optional space before C or F (uppercase only)
|
|
165
|
+
// Handles separator between digit and unit
|
|
166
|
+
// Uses marker-aware boundary to avoid false matches like "20C\uE000elsius"
|
|
167
|
+
const wbe = wordBoundaryEnd(chr)
|
|
168
|
+
return text.replace(
|
|
169
|
+
new RegExp(`(?<num>\\d${chr}?) ?(?<unit>[CF])${wbe}`, "g"),
|
|
170
|
+
(_, num, unit) => `${num} ${DEGREE}${unit}`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
|
|
175
|
+
export function primeMarks(text: string, options: SymbolOptions = {}): string {
|
|
176
|
+
const chr = options.separator
|
|
177
|
+
? escapeStringRegexp(options.separator)
|
|
178
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
179
|
+
|
|
180
|
+
// Convert quotes to primes using quote balancing
|
|
181
|
+
// Only convert if quotes are balanced (even count before) to avoid converting closing quotes
|
|
182
|
+
// Examples: 5' → 5′ ✓, 12" → 12″ ✓, 'Term 1' → 'Term 1' ✓, "Term 1" → "Term 1" ✓
|
|
183
|
+
const quotePrimePairs = [
|
|
184
|
+
["'", PRIME],
|
|
185
|
+
['"', DOUBLE_PRIME],
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
for (const [quote, prime] of quotePrimePairs) {
|
|
189
|
+
const pattern = new RegExp(
|
|
190
|
+
`(?<digit>\\d)(?<sep>${chr}?)${quote}(?<afterSep>${chr}?)(?![${LATIN_LETTERS}])`,
|
|
191
|
+
"g"
|
|
192
|
+
)
|
|
193
|
+
const quoteCountPattern = new RegExp(escapeStringRegexp(quote), "g")
|
|
194
|
+
text = text.replace(pattern, (match, digit, sep, afterSep, offset) => {
|
|
195
|
+
const textBefore = text.slice(0, offset)
|
|
196
|
+
const quoteCount = (textBefore.match(quoteCountPattern) || []).length
|
|
197
|
+
if (quoteCount % 2 === 0) {
|
|
198
|
+
return `${digit}${sep}${prime}${afterSep}`
|
|
199
|
+
}
|
|
200
|
+
return match
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Feet-inches pattern: convert " after ′ + digit (e.g., 5′10" → 5′10″)
|
|
205
|
+
const feetInchesPattern = new RegExp(`(?<primeAndNum>${PRIME}${chr}?\\d${chr}?)"`, "g")
|
|
206
|
+
text = text.replace(feetInchesPattern, `$<primeAndNum>${DOUBLE_PRIME}`)
|
|
207
|
+
|
|
208
|
+
return text
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const FRACTION_MAP: Record<string, string> = {
|
|
212
|
+
"1/4": UNICODE_SYMBOLS.FRACTION_1_4,
|
|
213
|
+
"1/2": UNICODE_SYMBOLS.FRACTION_1_2,
|
|
214
|
+
"3/4": UNICODE_SYMBOLS.FRACTION_3_4,
|
|
215
|
+
"1/3": UNICODE_SYMBOLS.FRACTION_1_3,
|
|
216
|
+
"2/3": UNICODE_SYMBOLS.FRACTION_2_3,
|
|
217
|
+
"1/5": UNICODE_SYMBOLS.FRACTION_1_5,
|
|
218
|
+
"2/5": UNICODE_SYMBOLS.FRACTION_2_5,
|
|
219
|
+
"3/5": UNICODE_SYMBOLS.FRACTION_3_5,
|
|
220
|
+
"4/5": UNICODE_SYMBOLS.FRACTION_4_5,
|
|
221
|
+
"1/6": UNICODE_SYMBOLS.FRACTION_1_6,
|
|
222
|
+
"5/6": UNICODE_SYMBOLS.FRACTION_5_6,
|
|
223
|
+
"1/8": UNICODE_SYMBOLS.FRACTION_1_8,
|
|
224
|
+
"3/8": UNICODE_SYMBOLS.FRACTION_3_8,
|
|
225
|
+
"5/8": UNICODE_SYMBOLS.FRACTION_5_8,
|
|
226
|
+
"7/8": UNICODE_SYMBOLS.FRACTION_7_8,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Convert 1/2, 1/4, etc. to ½, ¼, etc. */
|
|
230
|
+
export function fractions(text: string, options: SymbolOptions = {}): string {
|
|
231
|
+
const chr = options.separator
|
|
232
|
+
? escapeStringRegexp(options.separator)
|
|
233
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
234
|
+
|
|
235
|
+
for (const [ascii, unicode] of Object.entries(FRACTION_MAP)) {
|
|
236
|
+
// Negative lookbehind/lookahead: ensures fraction is not part of a larger number or path
|
|
237
|
+
// - (?<![/.\d]) prevents matching after slashes, dots, or digits
|
|
238
|
+
// - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
|
|
239
|
+
// Named captures preserve separators before and after the slash
|
|
240
|
+
const [numerator, denominator] = ascii.split("/")
|
|
241
|
+
const pattern = new RegExp(
|
|
242
|
+
`(?<![/\\.\\d])${numerator}(?<sepBefore>${chr}?)/(?<sepAfter>${chr}?)${denominator}(?![/\\d]|\\.\\d)`,
|
|
243
|
+
"g"
|
|
244
|
+
)
|
|
245
|
+
// Preserve separators around the fraction Unicode character
|
|
246
|
+
text = text.replace(pattern, `$<sepBefore>${unicode}$<sepAfter>`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return text
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ORDINAL_MAP: Record<string, string> = {
|
|
253
|
+
st: SUPERSCRIPT_ST,
|
|
254
|
+
nd: SUPERSCRIPT_ND,
|
|
255
|
+
rd: SUPERSCRIPT_RD,
|
|
256
|
+
th: SUPERSCRIPT_TH,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
|
|
260
|
+
export function superscriptOrdinal(text: string, options: SymbolOptions = {}): string {
|
|
261
|
+
const chr = options.separator
|
|
262
|
+
? escapeStringRegexp(options.separator)
|
|
263
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
264
|
+
|
|
265
|
+
// Match number + optional separator + ordinal suffix at word boundary
|
|
266
|
+
// Use case-insensitive matching for the suffix
|
|
267
|
+
// Uses marker-aware boundary to avoid false matches like "1st\uE000ly"
|
|
268
|
+
const wbe = wordBoundaryEnd(chr)
|
|
269
|
+
const pattern = new RegExp(
|
|
270
|
+
`(?<num>\\d${chr}?)(?<suffix>st|nd|rd|th)${wbe}`,
|
|
271
|
+
"gi"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return text.replace(pattern, (_match, num, suffix) => {
|
|
275
|
+
const superscriptSuffix = ORDINAL_MAP[suffix.toLowerCase()]
|
|
276
|
+
return num + superscriptSuffix
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Collapse multiple spaces to single space. Prefers nbsp if any nbsp is present. */
|
|
281
|
+
export function collapseSpaces(text: string): string {
|
|
282
|
+
return text.replace(new RegExp(`[${SPACE_CHARS}]{2,}`, "g"), (match) => {
|
|
283
|
+
// If any nbsp is present, prefer nbsp (more likely to be intentional)
|
|
284
|
+
return match.includes(NBSP) ? NBSP : " "
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Punctuation ligature map: [first char, repeated char, replacement]
|
|
290
|
+
* Pattern: first(sep)?repeated(?:sep?repeated)* → replacement
|
|
291
|
+
* Order matters: handle mixed punctuation first, then repeated
|
|
292
|
+
*/
|
|
293
|
+
const PUNCTUATION_LIGATURE_MAP: [string, string, string][] = [
|
|
294
|
+
["\\?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
|
|
295
|
+
["!", "\\?", EXCLAMATION_QUESTION], // !?+ → ⁉
|
|
296
|
+
["\\?", "\\?", DOUBLE_QUESTION], // ??+ → ⁇
|
|
297
|
+
["!", "!", "!"], // !!+ → ! (normalize)
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
/** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Poor font support, disabled by default. */
|
|
301
|
+
export function punctuationLigatures(text: string, options: SymbolOptions = {}): string {
|
|
302
|
+
const chr = options.separator
|
|
303
|
+
? escapeStringRegexp(options.separator)
|
|
304
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
305
|
+
|
|
306
|
+
for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
|
|
307
|
+
const pattern = new RegExp(`${first}(${chr})?${repeated}(?:${chr}?${repeated})*`, "g")
|
|
308
|
+
text = text.replace(pattern, (_match, sep) => replacement + (sep || ""))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return text
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Apply all symbol transforms. degrees/fractions excluded (too aggressive). */
|
|
315
|
+
export function symbolTransform(text: string, options: SymbolOptions = {}): string {
|
|
316
|
+
text = ellipsis(text, options)
|
|
317
|
+
text = multiplication(text, options)
|
|
318
|
+
text = mathSymbols(text)
|
|
319
|
+
text = legalSymbols(text)
|
|
320
|
+
if (options.includeArrows !== false) {
|
|
321
|
+
text = arrows(text, options)
|
|
322
|
+
}
|
|
323
|
+
return text
|
|
324
|
+
}
|