punctilio 3.0.7 → 3.0.9
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 +5 -5
- package/dist/constants.d.ts +10 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +19 -1
- package/dist/constants.js.map +1 -1
- package/dist/dashes.js +20 -20
- package/dist/dashes.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +17 -23
- package/dist/nbsp.js.map +1 -1
- package/dist/quotes.js +19 -19
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +3 -1
- package/dist/rehype.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +12 -31
- package/dist/symbols.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +20 -1
- package/src/dashes.ts +20 -20
- package/src/nbsp.ts +17 -25
- package/src/quotes.ts +19 -19
- package/src/rehype.ts +2 -1
- package/src/symbols.ts +12 -31
- package/src/tests/utils.test.ts +14 -1
package/src/quotes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import escapeStringRegexp from "escape-string-regexp"
|
|
6
|
-
import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS, TERMINAL_PUNCTUATION } from "./constants.js"
|
|
6
|
+
import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR, LATIN_LETTERS, TERMINAL_PUNCTUATION, cachedRegExp } from "./constants.js"
|
|
7
7
|
|
|
8
8
|
const {
|
|
9
9
|
EM_DASH,
|
|
@@ -34,8 +34,8 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
34
34
|
// Only match straight quotes, not already-converted curly quotes
|
|
35
35
|
const singleQuoteChars = `'${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}`
|
|
36
36
|
const singleQuoteOrWord = `[${singleQuoteChars}\\w]`
|
|
37
|
-
text = text.replace(
|
|
38
|
-
text = text.replace(
|
|
37
|
+
text = text.replace(cachedRegExp(`(?<!${singleQuoteOrWord})''(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}`)
|
|
38
|
+
text = text.replace(cachedRegExp(`(?<!${singleQuoteOrWord})'(\\s+)'(?!${singleQuoteOrWord})`, "g"), `${LEFT_SINGLE_QUOTE}$1${RIGHT_SINGLE_QUOTE}`)
|
|
39
39
|
|
|
40
40
|
const afterEndingSinglePatterns = `\\s\\.!?;,\\)${EM_DASH}\\-\\]"`
|
|
41
41
|
// Full pattern with optional 's' for lookahead detection in apostropheRegex
|
|
@@ -45,32 +45,32 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
45
45
|
// the general rules produced LSQ+n+RSQ which was fine. But MLA requires both
|
|
46
46
|
// quotes to be MLA (semantic apostrophes), and the general rules can't achieve
|
|
47
47
|
// that: neither quote is in a contraction context (no Latin letter on both sides).
|
|
48
|
-
text = text.replace(
|
|
48
|
+
text = text.replace(cachedRegExp(`(?<=\\w${escapedSep}? )['${RIGHT_SINGLE_QUOTE}]n['${RIGHT_SINGLE_QUOTE}](?= ${escapedSep}?\\w)`, "gm"), `${MODIFIER_LETTER_APOSTROPHE}n${MODIFIER_LETTER_APOSTROPHE}`)
|
|
49
49
|
|
|
50
50
|
// Possessive: 's followed by ending context (e.g., dog's) → U+02BC
|
|
51
51
|
const afterPossessive = `(?=${escapedSep}?s${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
|
|
52
52
|
const possessiveSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])['${RIGHT_SINGLE_QUOTE}]${afterPossessive}`
|
|
53
|
-
text = text.replace(
|
|
53
|
+
text = text.replace(cachedRegExp(possessiveSingle, "gm"), MODIFIER_LETTER_APOSTROPHE)
|
|
54
54
|
|
|
55
55
|
// Closing single quote: ending context without 's' → U+2019
|
|
56
56
|
const afterClosingSingle = `(?=${escapedSep}?(?:[${afterEndingSinglePatterns}]|$))`
|
|
57
57
|
const closingSingle = `(?<=[^\\s${LEFT_DOUBLE_QUOTE}'])[']${afterClosingSingle}`
|
|
58
|
-
text = text.replace(
|
|
58
|
+
text = text.replace(cachedRegExp(closingSingle, "gm"), RIGHT_SINGLE_QUOTE)
|
|
59
59
|
|
|
60
60
|
const contraction = `(?<=[${LATIN_LETTERS}])['${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}](?=${escapedSep}?[${LATIN_LETTERS}])`
|
|
61
|
-
text = text.replace(
|
|
61
|
+
text = text.replace(cachedRegExp(contraction, "gm"), MODIFIER_LETTER_APOSTROPHE)
|
|
62
62
|
|
|
63
63
|
const apostropheWhitelist = `(?=n${MODIFIER_LETTER_APOSTROPHE} )`
|
|
64
64
|
const endQuoteNotContraction = `(?!${contraction})[${RIGHT_SINGLE_QUOTE}${MODIFIER_LETTER_APOSTROPHE}]${afterEndingSingle}`
|
|
65
65
|
// Limit lookahead scan to 1000 chars to prevent catastrophic backtracking on pathological inputs
|
|
66
|
-
const apostropheRegex =
|
|
66
|
+
const apostropheRegex = cachedRegExp(
|
|
67
67
|
`(?<=^|[^\\w])['](${apostropheWhitelist}|(?![^${LEFT_SINGLE_QUOTE}'\\n]{0,1000}${endQuoteNotContraction}))`,
|
|
68
68
|
"gm"
|
|
69
69
|
)
|
|
70
70
|
text = text.replace(apostropheRegex, MODIFIER_LETTER_APOSTROPHE)
|
|
71
71
|
|
|
72
72
|
const beginningSingle = `(?<beforeContext>(?:^|[\\s${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${EM_DASH}\\-\\(])${escapedSep}?)['](?=${escapedSep}?\\S)`
|
|
73
|
-
text = text.replace(
|
|
73
|
+
text = text.replace(cachedRegExp(beginningSingle, "gm"), `$<beforeContext>${LEFT_SINGLE_QUOTE}`)
|
|
74
74
|
|
|
75
75
|
text = convertUnmatchedPluralPossessives(text, sep)
|
|
76
76
|
|
|
@@ -85,7 +85,7 @@ function convertSingleQuotes(text: string, sep: string): string {
|
|
|
85
85
|
function convertUnmatchedPluralPossessives(text: string, sep: string): string {
|
|
86
86
|
let singleQuoteBalance = 0
|
|
87
87
|
return text.replace(
|
|
88
|
-
|
|
88
|
+
cachedRegExp(`[${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`, "g"),
|
|
89
89
|
(match, offset) => {
|
|
90
90
|
if (match === LEFT_SINGLE_QUOTE) {
|
|
91
91
|
singleQuoteBalance++
|
|
@@ -115,19 +115,19 @@ function convertDoubleQuotes(text: string, sep: string): string {
|
|
|
115
115
|
// Handle whitespace-only quotes " " - require non-quote chars on both sides
|
|
116
116
|
text = text.replace(/(?<=^|[\s([{])"(?<whitespace>\s+)"(?=$|[\s)\]}.!?,;:])/g, `${LEFT_DOUBLE_QUOTE}$<whitespace>${RIGHT_DOUBLE_QUOTE}`)
|
|
117
117
|
|
|
118
|
-
const beginningDouble =
|
|
118
|
+
const beginningDouble = cachedRegExp(
|
|
119
119
|
`(?<=^|[\\s\\(\\/\\[\\{\\-${EM_DASH}${escapedSep}])(?<beforeChr>${escapedSep}?)["](?<afterChr>(?<sepWithPunct>${escapedSep}[ .,])|(?=${escapedSep}?\\.{3}|${escapedSep}?[^\\s\\)\\${EM_DASH},!?${escapedSep};:.\\}]))`,
|
|
120
120
|
"gm"
|
|
121
121
|
)
|
|
122
122
|
text = text.replace(beginningDouble, `$<beforeChr>${LEFT_DOUBLE_QUOTE}$<afterChr>`)
|
|
123
123
|
|
|
124
|
-
text = text.replace(
|
|
124
|
+
text = text.replace(cachedRegExp(`(?<=\\{)(?<sepSpace>${escapedSep}? )?["]`, "g"), `$<sepSpace>${LEFT_DOUBLE_QUOTE}`)
|
|
125
125
|
|
|
126
126
|
const endingDouble = `(?<beforeQuote>[^\\s\\(])["]((?<sepAfter>${escapedSep})?)(?=${escapedSep}|[\\s/\\).,;${EM_DASH}:\\-\\}!?s]|$)`
|
|
127
|
-
text = text.replace(
|
|
127
|
+
text = text.replace(cachedRegExp(endingDouble, "g"), `$<beforeQuote>${RIGHT_DOUBLE_QUOTE}$<sepAfter>`)
|
|
128
128
|
|
|
129
|
-
text = text.replace(
|
|
130
|
-
text = text.replace(
|
|
129
|
+
text = text.replace(cachedRegExp(`["](?<sepEnd>${escapedSep}?)$`, "g"), `${RIGHT_DOUBLE_QUOTE}$<sepEnd>`)
|
|
130
|
+
text = text.replace(cachedRegExp(`'(?=${RIGHT_DOUBLE_QUOTE})`, "gu"), RIGHT_SINGLE_QUOTE)
|
|
131
131
|
|
|
132
132
|
return text
|
|
133
133
|
}
|
|
@@ -138,14 +138,14 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
|
|
|
138
138
|
|
|
139
139
|
if (style === "american") {
|
|
140
140
|
// Period outside → inside: "Hello". → "Hello."
|
|
141
|
-
const periodOutsideRegex =
|
|
141
|
+
const periodOutsideRegex = cachedRegExp(
|
|
142
142
|
`(?<![${TERMINAL_PUNCTUATION_CLASS}])(?<sepBefore>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])(?<sepAfter>${escapedSep}?)(?!\\.\\.\\.)\\.`,
|
|
143
143
|
"g"
|
|
144
144
|
)
|
|
145
145
|
text = text.replace(periodOutsideRegex, "$<sepBefore>.$<quote>$<sepAfter>")
|
|
146
146
|
|
|
147
147
|
// Comma outside → inside: "Hello", → "Hello,"
|
|
148
|
-
const commaOutsideRegex =
|
|
148
|
+
const commaOutsideRegex = cachedRegExp(
|
|
149
149
|
`(?<![${TERMINAL_PUNCTUATION_CLASS}])(?<sepBefore>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])(?<sepAfter>${escapedSep}?),`,
|
|
150
150
|
"g"
|
|
151
151
|
)
|
|
@@ -153,14 +153,14 @@ function applyPunctuationStyle(text: string, sep: string, style: PunctuationStyl
|
|
|
153
153
|
} else if (style === "british") {
|
|
154
154
|
// Period inside → outside: "Hello." → "Hello".
|
|
155
155
|
// No terminal punctuation guard — "Stop!." inside is always wrong; move the period out.
|
|
156
|
-
const periodInsideRegex =
|
|
156
|
+
const periodInsideRegex = cachedRegExp(
|
|
157
157
|
`(?<sepBefore>${escapedSep}?)\\.(?<sepMiddle>${escapedSep}?)(?<quote>[${RIGHT_SINGLE_QUOTE}${RIGHT_DOUBLE_QUOTE}])`,
|
|
158
158
|
"g"
|
|
159
159
|
)
|
|
160
160
|
text = text.replace(periodInsideRegex, "$<sepBefore>$<sepMiddle>$<quote>.")
|
|
161
161
|
|
|
162
162
|
// Comma inside → outside: "Hello," → "Hello",
|
|
163
|
-
const commaInsideRegex =
|
|
163
|
+
const commaInsideRegex = cachedRegExp(
|
|
164
164
|
`,(?<sepAndQuote>${escapedSep}?[${RIGHT_DOUBLE_QUOTE}${RIGHT_SINGLE_QUOTE}])`,
|
|
165
165
|
"g"
|
|
166
166
|
)
|
package/src/rehype.ts
CHANGED
|
@@ -389,7 +389,8 @@ export function collectTransformableElements(
|
|
|
389
389
|
// Otherwise, recurse into children
|
|
390
390
|
for (const child of node.children) {
|
|
391
391
|
if (child.type === "element") {
|
|
392
|
-
|
|
392
|
+
const childResults = collectTransformableElements(child, shouldSkip, depth + 1)
|
|
393
|
+
for (const r of childResults) results.push(r)
|
|
393
394
|
}
|
|
394
395
|
}
|
|
395
396
|
}
|
package/src/symbols.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Symbol transformations: ellipses, multiplication, math symbols, arrows.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
import { UNICODE_SYMBOLS, ESCAPED_DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, spaceBoundaryStart, spaceBoundaryEnd, cachedRegExp } from "./constants.js"
|
|
5
|
+
import { UNICODE_SYMBOLS, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, spaceBoundaryStart, spaceBoundaryEnd, cachedRegExp, getEscapedSeparator } from "./constants.js"
|
|
7
6
|
|
|
8
7
|
export interface SymbolOptions {
|
|
9
8
|
/** Boundary marker for HTML element boundaries. Default: "\uE000" */
|
|
@@ -41,9 +40,7 @@ const {
|
|
|
41
40
|
|
|
42
41
|
/** Convert "..." or ". . ." to "…". */
|
|
43
42
|
export function ellipsis(text: string, options: SymbolOptions = {}): string {
|
|
44
|
-
const chr = options
|
|
45
|
-
? escapeStringRegexp(options.separator)
|
|
46
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
43
|
+
const chr = getEscapedSeparator(options)
|
|
47
44
|
|
|
48
45
|
// Convert consecutive or spaced dots: ... or . . . → …
|
|
49
46
|
// Captures preserve any separators between dots
|
|
@@ -59,9 +56,7 @@ export function ellipsis(text: string, options: SymbolOptions = {}): string {
|
|
|
59
56
|
|
|
60
57
|
/** Convert "5x5" to "5×5". Skips hex (0x5F). */
|
|
61
58
|
export function multiplication(text: string, options: SymbolOptions = {}): string {
|
|
62
|
-
const chr = options
|
|
63
|
-
? escapeStringRegexp(options.separator)
|
|
64
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
59
|
+
const chr = getEscapedSeparator(options)
|
|
65
60
|
|
|
66
61
|
// Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
|
|
67
62
|
// Pattern matches: digit(s), then one or more (operator, digit(s)) groups
|
|
@@ -113,9 +108,7 @@ const MATH_SYMBOL_MAP: [string, string, string, string][] = [
|
|
|
113
108
|
|
|
114
109
|
/** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
|
|
115
110
|
export function mathSymbols(text: string, options: SymbolOptions = {}): string {
|
|
116
|
-
const chr = options
|
|
117
|
-
? escapeStringRegexp(options.separator)
|
|
118
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
111
|
+
const chr = getEscapedSeparator(options)
|
|
119
112
|
|
|
120
113
|
for (const [left, right, lookahead, replacement] of MATH_SYMBOL_MAP) {
|
|
121
114
|
const pattern = cachedRegExp(`${left}${chr}?${right}${lookahead}`, "g")
|
|
@@ -168,9 +161,7 @@ const ARROW_MAP: [string, string][] = [
|
|
|
168
161
|
|
|
169
162
|
/** Convert -> and <-> to arrows. */
|
|
170
163
|
export function arrows(text: string, options: SymbolOptions = {}): string {
|
|
171
|
-
const chr = options
|
|
172
|
-
? escapeStringRegexp(options.separator)
|
|
173
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
164
|
+
const chr = getEscapedSeparator(options)
|
|
174
165
|
|
|
175
166
|
const start = spaceBoundaryStart(chr)
|
|
176
167
|
const end = spaceBoundaryEnd(chr)
|
|
@@ -185,9 +176,7 @@ export function arrows(text: string, options: SymbolOptions = {}): string {
|
|
|
185
176
|
|
|
186
177
|
/** Convert "20 C" or "20 F" to "20 °C" or "20 °F". Only matches uppercase C/F. */
|
|
187
178
|
export function degrees(text: string, options: SymbolOptions = {}): string {
|
|
188
|
-
const chr = options
|
|
189
|
-
? escapeStringRegexp(options.separator)
|
|
190
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
179
|
+
const chr = getEscapedSeparator(options)
|
|
191
180
|
|
|
192
181
|
// Temperature with optional space before C or F (uppercase only)
|
|
193
182
|
// Handles separator between digit and unit
|
|
@@ -288,9 +277,7 @@ function balancedPrimeReplacer(primeChar: string, escapedSeparator: string) {
|
|
|
288
277
|
|
|
289
278
|
/** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
|
|
290
279
|
export function primeMarks(text: string, options: SymbolOptions = {}): string {
|
|
291
|
-
const escapedSeparator = options
|
|
292
|
-
? escapeStringRegexp(options.separator)
|
|
293
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
280
|
+
const escapedSeparator = getEscapedSeparator(options)
|
|
294
281
|
|
|
295
282
|
const quotePrimePairs: [string, string][] = [
|
|
296
283
|
["'", PRIME],
|
|
@@ -298,8 +285,8 @@ export function primeMarks(text: string, options: SymbolOptions = {}): string {
|
|
|
298
285
|
]
|
|
299
286
|
|
|
300
287
|
for (const [quote, primeChar] of quotePrimePairs) {
|
|
301
|
-
|
|
302
|
-
const pattern = buildQuoteClassificationPattern(
|
|
288
|
+
// ' and " are not regex-special, so they can be used directly as patterns
|
|
289
|
+
const pattern = buildQuoteClassificationPattern(quote, escapedSeparator)
|
|
303
290
|
text = text.replace(pattern, balancedPrimeReplacer(primeChar, escapedSeparator))
|
|
304
291
|
}
|
|
305
292
|
|
|
@@ -326,9 +313,7 @@ const FRACTION_MAP: Record<string, string> = {
|
|
|
326
313
|
|
|
327
314
|
/** Convert 1/2, 1/4, etc. to ½, ¼, etc. */
|
|
328
315
|
export function fractions(text: string, options: SymbolOptions = {}): string {
|
|
329
|
-
const chr = options
|
|
330
|
-
? escapeStringRegexp(options.separator)
|
|
331
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
316
|
+
const chr = getEscapedSeparator(options)
|
|
332
317
|
|
|
333
318
|
for (const [ascii, unicode] of Object.entries(FRACTION_MAP)) {
|
|
334
319
|
// Negative lookbehind/lookahead: ensures fraction is not part of a larger number or path
|
|
@@ -356,9 +341,7 @@ const ORDINAL_MAP: Record<string, string> = {
|
|
|
356
341
|
|
|
357
342
|
/** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
|
|
358
343
|
export function superscriptOrdinal(text: string, options: SymbolOptions = {}): string {
|
|
359
|
-
const chr = options
|
|
360
|
-
? escapeStringRegexp(options.separator)
|
|
361
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
344
|
+
const chr = getEscapedSeparator(options)
|
|
362
345
|
|
|
363
346
|
// Match number + optional separator + ordinal suffix at word boundary
|
|
364
347
|
// Use case-insensitive matching for the suffix
|
|
@@ -397,9 +380,7 @@ const PUNCTUATION_LIGATURE_MAP: [string, string, string][] = [
|
|
|
397
380
|
|
|
398
381
|
/** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Poor font support, disabled by default. */
|
|
399
382
|
export function punctuationLigatures(text: string, options: SymbolOptions = {}): string {
|
|
400
|
-
const chr = options
|
|
401
|
-
? escapeStringRegexp(options.separator)
|
|
402
|
-
: ESCAPED_DEFAULT_SEPARATOR
|
|
383
|
+
const chr = getEscapedSeparator(options)
|
|
403
384
|
|
|
404
385
|
for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
|
|
405
386
|
const pattern = cachedRegExp(`${first}(${chr})?${repeated}(?:${chr}?${repeated})*`, "g")
|
package/src/tests/utils.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertSeparatorAbsent, countSeparators, assertSeparatorCountPreserved, formatErrorString } from "../utils.js"
|
|
2
|
-
import { DEFAULT_SEPARATOR } from "../constants.js"
|
|
2
|
+
import { DEFAULT_SEPARATOR, cachedRegExp } from "../constants.js"
|
|
3
3
|
|
|
4
4
|
describe("assertSeparatorAbsent", () => {
|
|
5
5
|
it.each([
|
|
@@ -75,6 +75,19 @@ describe("assertSeparatorCountPreserved", () => {
|
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
+
describe("cachedRegExp", () => {
|
|
79
|
+
it("evicts oldest entry when cache exceeds max size", () => {
|
|
80
|
+
// Fill the cache well beyond 1000 entries with unique patterns
|
|
81
|
+
for (let i = 0; i < 1010; i++) {
|
|
82
|
+
const re = cachedRegExp(`unique-pattern-${i}`, "g")
|
|
83
|
+
expect(re).toBeInstanceOf(RegExp)
|
|
84
|
+
}
|
|
85
|
+
// Verify the cache still works correctly after eviction
|
|
86
|
+
const re = cachedRegExp("unique-pattern-1009", "g")
|
|
87
|
+
expect(re.source).toBe("unique-pattern-1009")
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
78
91
|
describe("formatErrorString", () => {
|
|
79
92
|
let originalWrite: typeof process.stderr.write
|
|
80
93
|
let stderrOutput: string[]
|