punctilio 1.4.1 → 1.7.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 +3 -5
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +7 -3
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/nbsp.d.ts +75 -0
- package/dist/nbsp.d.ts.map +1 -0
- package/dist/nbsp.js +195 -0
- package/dist/nbsp.js.map +1 -0
- package/dist/symbols.js +2 -2
- package/dist/symbols.js.map +1 -1
- package/package.json +1 -1
- package/src/dashes.ts +11 -3
- package/src/index.ts +32 -2
- package/src/nbsp.ts +252 -0
- package/src/symbols.ts +2 -2
- package/src/tests/dashes.test.ts +28 -0
- package/src/tests/index.test.ts +27 -0
- package/src/tests/nbsp.test.ts +255 -0
- package/src/tests/rehype.test.ts +39 -0
- package/src/tests/symbols.test.ts +9 -0
- package/src/tests/utils.test.ts +23 -34
package/src/index.ts
CHANGED
|
@@ -36,6 +36,21 @@ export {
|
|
|
36
36
|
symbolTransform,
|
|
37
37
|
type SymbolOptions,
|
|
38
38
|
} from "./symbols.js"
|
|
39
|
+
export {
|
|
40
|
+
nbspAfterShortWords,
|
|
41
|
+
nbspBetweenNumberAndUnit,
|
|
42
|
+
nbspBeforeLastWord,
|
|
43
|
+
nbspAfterReferenceAbbreviations,
|
|
44
|
+
nbspAfterSectionSymbols,
|
|
45
|
+
nbspAfterHonorifics,
|
|
46
|
+
nbspAfterCopyrightSymbols,
|
|
47
|
+
nbspBetweenInitials,
|
|
48
|
+
nbspTransform,
|
|
49
|
+
UNITS,
|
|
50
|
+
HONORIFICS,
|
|
51
|
+
REFERENCE_ABBREVIATIONS,
|
|
52
|
+
type NbspOptions,
|
|
53
|
+
} from "./nbsp.js"
|
|
39
54
|
|
|
40
55
|
export interface TransformOptions {
|
|
41
56
|
/**
|
|
@@ -115,6 +130,14 @@ export interface TransformOptions {
|
|
|
115
130
|
*/
|
|
116
131
|
ligatures?: boolean
|
|
117
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Whether to insert non-breaking spaces in typographically appropriate
|
|
135
|
+
* locations (after short words, between numbers and units, before
|
|
136
|
+
* last words to prevent widows, after honorifics, etc.).
|
|
137
|
+
* Default: false
|
|
138
|
+
*/
|
|
139
|
+
nbsp?: boolean
|
|
140
|
+
|
|
118
141
|
/**
|
|
119
142
|
* Whether to verify that the transformation is idempotent (running twice
|
|
120
143
|
* produces the same result). When enabled, throws an error if the second
|
|
@@ -128,6 +151,7 @@ export interface TransformOptions {
|
|
|
128
151
|
import { niceQuotes } from "./quotes.js"
|
|
129
152
|
import { hyphenReplace } from "./dashes.js"
|
|
130
153
|
import { symbolTransform, fractions as fractionsTransform, degrees as degreesTransform, superscriptOrdinal as superscriptTransform, primeMarks, collapseSpaces as collapseSpacesTransform, punctuationLigatures as ligaturesTransform } from "./symbols.js"
|
|
154
|
+
import { nbspTransform as nbspTransformFn } from "./nbsp.js"
|
|
131
155
|
import { assertSeparatorCountPreserved, formatErrorString } from "./utils.js"
|
|
132
156
|
import { DEFAULT_SEPARATOR } from "./constants.js"
|
|
133
157
|
|
|
@@ -147,7 +171,8 @@ export { DEFAULT_SEPARATOR } from "./constants.js"
|
|
|
147
171
|
* 6. degrees (disabled by default)
|
|
148
172
|
* 7. superscript (disabled by default)
|
|
149
173
|
* 8. ligatures (disabled by default)
|
|
150
|
-
* 9.
|
|
174
|
+
* 9. nbsp (non-breaking spaces, disabled by default)
|
|
175
|
+
* 10. collapseSpaces (collapses multiple spaces into one)
|
|
151
176
|
*
|
|
152
177
|
* @param text - The text to transform
|
|
153
178
|
* @param options - Configuration options
|
|
@@ -173,6 +198,7 @@ const defaultOpts: Required<Omit<TransformOptions, "separator">> = {
|
|
|
173
198
|
degrees: false,
|
|
174
199
|
superscript: false,
|
|
175
200
|
ligatures: false,
|
|
201
|
+
nbsp: false,
|
|
176
202
|
collapseSpaces: true,
|
|
177
203
|
checkIdempotency: true,
|
|
178
204
|
punctuationStyle: "american",
|
|
@@ -193,7 +219,7 @@ export function transform(text: string, options: TransformOptions = {}): string
|
|
|
193
219
|
}
|
|
194
220
|
|
|
195
221
|
const original = text
|
|
196
|
-
const { symbols, fractions, degrees, superscript, ligatures, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options }
|
|
222
|
+
const { symbols, fractions, degrees, superscript, ligatures, nbsp, collapseSpaces, checkIdempotency, ...separatorOpts } = { ...defaultOpts, ...options }
|
|
197
223
|
|
|
198
224
|
text = hyphenReplace(text, separatorOpts)
|
|
199
225
|
text = primeMarks(text, separatorOpts)
|
|
@@ -219,6 +245,10 @@ export function transform(text: string, options: TransformOptions = {}): string
|
|
|
219
245
|
text = ligaturesTransform(text, separatorOpts)
|
|
220
246
|
}
|
|
221
247
|
|
|
248
|
+
if (nbsp) {
|
|
249
|
+
text = nbspTransformFn(text, separatorOpts)
|
|
250
|
+
}
|
|
251
|
+
|
|
222
252
|
if (collapseSpaces) {
|
|
223
253
|
text = collapseSpacesTransform(text)
|
|
224
254
|
}
|
package/src/nbsp.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-breaking space transformations for improved typography.
|
|
3
|
+
*
|
|
4
|
+
* Inserts non-breaking spaces (U+00A0) in positions where line breaks
|
|
5
|
+
* would be typographically undesirable: after short words, between
|
|
6
|
+
* numbers and units, before orphaned last words, etc.
|
|
7
|
+
*
|
|
8
|
+
* @module nbsp
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import escapeStringRegexp from "escape-string-regexp"
|
|
12
|
+
|
|
13
|
+
import { UNICODE_SYMBOLS, ESCAPED_DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryEnd } from "./constants.js"
|
|
14
|
+
import type { SymbolOptions } from "./symbols.js"
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
NBSP,
|
|
18
|
+
LEFT_DOUBLE_QUOTE,
|
|
19
|
+
RIGHT_DOUBLE_QUOTE,
|
|
20
|
+
LEFT_SINGLE_QUOTE,
|
|
21
|
+
RIGHT_SINGLE_QUOTE,
|
|
22
|
+
COPYRIGHT,
|
|
23
|
+
REGISTERED,
|
|
24
|
+
TRADEMARK,
|
|
25
|
+
} = UNICODE_SYMBOLS
|
|
26
|
+
|
|
27
|
+
export type NbspOptions = Pick<SymbolOptions, "separator">
|
|
28
|
+
|
|
29
|
+
function escapedSeparator(options: NbspOptions): string {
|
|
30
|
+
return options.separator
|
|
31
|
+
? escapeStringRegexp(options.separator)
|
|
32
|
+
: ESCAPED_DEFAULT_SEPARATOR
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Space pattern matching regular space, tab, and nbsp. */
|
|
36
|
+
const SPACE = `[ \\t${NBSP}]`
|
|
37
|
+
|
|
38
|
+
const UNICODE_UPPERCASE = "\\p{Lu}"
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Common units for the number-unit nbsp pattern. Only matches these specific
|
|
42
|
+
* abbreviations after a number to avoid false positives like "chapter 3 above".
|
|
43
|
+
*/
|
|
44
|
+
export const UNITS = [
|
|
45
|
+
// Length
|
|
46
|
+
"km", "cm", "mm", "mi", "ft", "in", "yd", "nm", "pm", "m",
|
|
47
|
+
// Mass
|
|
48
|
+
"kg", "mg", "lbs", "lb", "oz", "g", "t",
|
|
49
|
+
// Volume
|
|
50
|
+
"ml", "mL", "gal", "fl", "l", "L",
|
|
51
|
+
// Time
|
|
52
|
+
"min", "ms", "hr", "hrs", "h", "s",
|
|
53
|
+
// Speed / frequency
|
|
54
|
+
"kHz", "MHz", "GHz", "THz", "rpm", "Hz",
|
|
55
|
+
// Digital
|
|
56
|
+
"kbps", "Mbps", "Gbps", "KB", "MB", "GB", "TB", "PB", "kB", "Mb", "Gb",
|
|
57
|
+
// Energy / power
|
|
58
|
+
"kWh", "MWh", "kW", "MW", "GW", "kJ", "MJ", "Wh", "W", "J",
|
|
59
|
+
// Temperature
|
|
60
|
+
"K",
|
|
61
|
+
// Electrical
|
|
62
|
+
"kV", "mV", "mA", "V", "A",
|
|
63
|
+
// Pressure / area
|
|
64
|
+
"kPa", "MPa", "bar", "psi", "ha", "Pa",
|
|
65
|
+
// Typography / CSS
|
|
66
|
+
"rem", "dpi", "px", "pt", "em", "vw", "vh",
|
|
67
|
+
// Finance
|
|
68
|
+
"MM", "M", "B", "T",
|
|
69
|
+
// Misc
|
|
70
|
+
"kcal", "mol", "cal", "dB",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
export const HONORIFICS = [
|
|
74
|
+
// English
|
|
75
|
+
"Mr", "Mrs", "Ms", "Dr", "Prof", "Rev",
|
|
76
|
+
"St", "Sr", "Jr", "Hon", "Gov", "Sen", "Rep",
|
|
77
|
+
// French
|
|
78
|
+
"Mme", "Mlle", "Mgr",
|
|
79
|
+
// German / Nordic
|
|
80
|
+
"Hr", "Fr",
|
|
81
|
+
// Spanish / Portuguese
|
|
82
|
+
"Sra", "Srta",
|
|
83
|
+
// Italian
|
|
84
|
+
"Sig", "Dott",
|
|
85
|
+
// Dutch
|
|
86
|
+
"Dhr", "Mevr",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
export const REFERENCE_ABBREVIATIONS = [
|
|
90
|
+
// English / Latin
|
|
91
|
+
"Fig", "Figs", "Vol", "No", "Nos",
|
|
92
|
+
"p", "pp", "Ch", "Chap", "Sec",
|
|
93
|
+
"Eq", "Eqs", "Art", "Tab", "Ex",
|
|
94
|
+
// German
|
|
95
|
+
"Abb", "Bd", "Nr", "Kap",
|
|
96
|
+
// Romance (Spanish / Portuguese / Italian)
|
|
97
|
+
"Cap",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
// Precomputed regex fragments
|
|
101
|
+
const UNIT_PATTERN = UNITS.join("|")
|
|
102
|
+
const HONORIFIC_PATTERN = HONORIFICS.map((h) => `${h}\\.`).join("|")
|
|
103
|
+
const ABBREVIATION_PATTERN = REFERENCE_ABBREVIATIONS.map((a) => `${a}\\.`).join("|")
|
|
104
|
+
const PUNCTUATION_OR_QUOTE = `[.,!?:;)(${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}\u00AB\u00BB${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}"]`
|
|
105
|
+
const COPYRIGHT_SYMBOLS = `[${COPYRIGHT}${REGISTERED}${TRADEMARK}]`
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adds non-breaking space after short words (1-2 letters) to prevent them from
|
|
109
|
+
* being left alone at the end of a line.
|
|
110
|
+
*
|
|
111
|
+
* @example "a cat" → "a\u00A0cat", "I am" → "I\u00A0am"
|
|
112
|
+
*/
|
|
113
|
+
export function nbspAfterShortWords(text: string, options: NbspOptions = {}): string {
|
|
114
|
+
const sep = escapedSeparator(options)
|
|
115
|
+
const pattern = new RegExp(
|
|
116
|
+
`(?<=^|${SPACE}|${PUNCTUATION_OR_QUOTE}|>)(?<shortWord>[${LATIN_LETTERS}]{1,2})(?<marker>${sep}?)${SPACE}`,
|
|
117
|
+
"gmu"
|
|
118
|
+
)
|
|
119
|
+
return text.replace(pattern, `$<shortWord>$<marker>${NBSP}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Adds non-breaking space between numbers and common units.
|
|
124
|
+
*
|
|
125
|
+
* @example "100 km" → "100\u00A0km", "5 kg" → "5\u00A0kg"
|
|
126
|
+
*/
|
|
127
|
+
export function nbspBetweenNumberAndUnit(text: string, options: NbspOptions = {}): string {
|
|
128
|
+
const sep = escapedSeparator(options)
|
|
129
|
+
const wbe = wordBoundaryEnd(sep)
|
|
130
|
+
const pattern = new RegExp(
|
|
131
|
+
`(?<digit>\\d)(?<marker1>${sep}?)${SPACE}(?<marker2>${sep}?)(?<unit>${UNIT_PATTERN})${wbe}`,
|
|
132
|
+
"gm"
|
|
133
|
+
)
|
|
134
|
+
return text.replace(pattern, `$<digit>$<marker1>${NBSP}$<marker2>$<unit>`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Adds non-breaking space before the last word to prevent widows.
|
|
139
|
+
*
|
|
140
|
+
* Only applies to final words of 1-10 characters, at end of string or
|
|
141
|
+
* paragraph break (\n\n). Uses non-multiline mode so $ matches only the
|
|
142
|
+
* true end of string.
|
|
143
|
+
*/
|
|
144
|
+
export function nbspBeforeLastWord(text: string, options: NbspOptions = {}): string {
|
|
145
|
+
const sep = escapedSeparator(options)
|
|
146
|
+
const pattern = new RegExp(
|
|
147
|
+
`(?<=[\\w${sep}])${SPACE}(?<lastWord>[^\\s${sep}]{1,10})(?<ending>${sep}?(?:\\n\\n|$))`,
|
|
148
|
+
"g"
|
|
149
|
+
)
|
|
150
|
+
return text.replace(pattern, `${NBSP}$<lastWord>$<ending>`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Adds non-breaking space after reference abbreviations (Fig., Vol., p., etc.)
|
|
155
|
+
* when followed by a number.
|
|
156
|
+
*
|
|
157
|
+
* @example "Fig. 1" → "Fig.\u00A01", "p. 42" → "p.\u00A042"
|
|
158
|
+
*/
|
|
159
|
+
export function nbspAfterReferenceAbbreviations(text: string, options: NbspOptions = {}): string {
|
|
160
|
+
const sep = escapedSeparator(options)
|
|
161
|
+
const pattern = new RegExp(
|
|
162
|
+
`(?<abbrev>${ABBREVIATION_PATTERN})(?<marker>${sep}?)${SPACE}(?=${sep}?\\d)`,
|
|
163
|
+
"g"
|
|
164
|
+
)
|
|
165
|
+
return text.replace(pattern, `$<abbrev>$<marker>${NBSP}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Adds non-breaking space after section (§) and paragraph (¶) symbols
|
|
170
|
+
* when followed by a number.
|
|
171
|
+
*
|
|
172
|
+
* @example "§ 5" → "§\u00A05"
|
|
173
|
+
*/
|
|
174
|
+
export function nbspAfterSectionSymbols(text: string, options: NbspOptions = {}): string {
|
|
175
|
+
const sep = escapedSeparator(options)
|
|
176
|
+
const pattern = new RegExp(
|
|
177
|
+
`(?<symbol>[§¶])(?<marker>${sep}?)${SPACE}(?=${sep}?\\d)`,
|
|
178
|
+
"g"
|
|
179
|
+
)
|
|
180
|
+
return text.replace(pattern, `$<symbol>$<marker>${NBSP}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Adds non-breaking space after honorific titles when followed by a capitalized name.
|
|
185
|
+
*
|
|
186
|
+
* @example "Dr. Smith" → "Dr.\u00A0Smith", "Mr. Jones" → "Mr.\u00A0Jones"
|
|
187
|
+
*/
|
|
188
|
+
export function nbspAfterHonorifics(text: string, options: NbspOptions = {}): string {
|
|
189
|
+
const sep = escapedSeparator(options)
|
|
190
|
+
const pattern = new RegExp(
|
|
191
|
+
`(?<honorific>${HONORIFIC_PATTERN})(?<marker>${sep}?)${SPACE}(?=${sep}?${UNICODE_UPPERCASE})`,
|
|
192
|
+
"gu"
|
|
193
|
+
)
|
|
194
|
+
return text.replace(pattern, `$<honorific>$<marker>${NBSP}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Adds non-breaking space after copyright, registered, and trademark
|
|
199
|
+
* symbols when followed by a year or capitalized name.
|
|
200
|
+
*
|
|
201
|
+
* @example "© 2024" → "©\u00A02024", "® Brand" → "®\u00A0Brand"
|
|
202
|
+
*/
|
|
203
|
+
export function nbspAfterCopyrightSymbols(text: string, options: NbspOptions = {}): string {
|
|
204
|
+
const sep = escapedSeparator(options)
|
|
205
|
+
const pattern = new RegExp(
|
|
206
|
+
`(?<symbol>${COPYRIGHT_SYMBOLS})(?<marker>${sep}?)${SPACE}(?=${sep}?[\\d${UNICODE_UPPERCASE}])`,
|
|
207
|
+
"gu"
|
|
208
|
+
)
|
|
209
|
+
return text.replace(pattern, `$<symbol>$<marker>${NBSP}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Adds non-breaking space between initials (e.g., "J. K. Rowling").
|
|
214
|
+
*
|
|
215
|
+
* @example "J. K. Rowling" → "J.\u00A0K.\u00A0Rowling"
|
|
216
|
+
*/
|
|
217
|
+
export function nbspBetweenInitials(text: string, options: NbspOptions = {}): string {
|
|
218
|
+
const sep = escapedSeparator(options)
|
|
219
|
+
const pattern = new RegExp(
|
|
220
|
+
`(?<initial>${UNICODE_UPPERCASE}\\.)(?<marker>${sep}?)${SPACE}(?=${sep}?${UNICODE_UPPERCASE})`,
|
|
221
|
+
"gu"
|
|
222
|
+
)
|
|
223
|
+
return text.replace(pattern, `$<initial>$<marker>${NBSP}`)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type NbspFn = (text: string, options: NbspOptions) => string
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Ordered list of nbsp transforms. Specific patterns (honorifics, abbreviations,
|
|
230
|
+
* initials) come first so they claim their matches before the generic
|
|
231
|
+
* `nbspAfterShortWords`. For example, "No. 5" is matched by
|
|
232
|
+
* `nbspAfterReferenceAbbreviations` first, preventing `nbspAfterShortWords`
|
|
233
|
+
* from also matching "No" as a 2-letter short word.
|
|
234
|
+
*/
|
|
235
|
+
const NBSP_TRANSFORMS: NbspFn[] = [
|
|
236
|
+
nbspAfterHonorifics,
|
|
237
|
+
nbspAfterReferenceAbbreviations,
|
|
238
|
+
nbspAfterSectionSymbols,
|
|
239
|
+
nbspAfterCopyrightSymbols,
|
|
240
|
+
nbspBetweenInitials,
|
|
241
|
+
nbspBetweenNumberAndUnit,
|
|
242
|
+
nbspAfterShortWords,
|
|
243
|
+
nbspBeforeLastWord,
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
/** Apply all non-breaking space transformations in sequence. */
|
|
247
|
+
export function nbspTransform(text: string, options: NbspOptions = {}): string {
|
|
248
|
+
for (const fn of NBSP_TRANSFORMS) {
|
|
249
|
+
text = fn(text, options)
|
|
250
|
+
}
|
|
251
|
+
return text
|
|
252
|
+
}
|
package/src/symbols.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function multiplication(text: string, options: SymbolOptions = {}): strin
|
|
|
66
66
|
// Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
|
|
67
67
|
// Pattern matches: digit(s), then one or more (operator, digit(s)) groups
|
|
68
68
|
const chainPattern = new RegExp(
|
|
69
|
-
`(\\d+)((?:${chr}?\\s*[xX*]\\s*${chr}?\\d+)+)`,
|
|
69
|
+
`(?<!\\d)(\\d+)((?:${chr}?\\s*[xX*]\\s*${chr}?\\d+)+)`,
|
|
70
70
|
"g"
|
|
71
71
|
)
|
|
72
72
|
|
|
@@ -87,7 +87,7 @@ export function multiplication(text: string, options: SymbolOptions = {}): strin
|
|
|
87
87
|
|
|
88
88
|
// Trailing multiplier: 5x (followed by word boundary - space, punctuation, etc.)
|
|
89
89
|
const wbe = wordBoundaryEnd(chr)
|
|
90
|
-
const trailingPattern = new RegExp(`(?<num>\\d+${chr}?)[xX*]${wbe}`, "g")
|
|
90
|
+
const trailingPattern = new RegExp(`(?<!\\d)(?<num>\\d+${chr}?)[xX*]${wbe}`, "g")
|
|
91
91
|
text = text.replace(trailingPattern, (match, num: string) => {
|
|
92
92
|
// Skip if this looks like start of hexadecimal
|
|
93
93
|
if (num === "0") return match
|
package/src/tests/dashes.test.ts
CHANGED
|
@@ -651,3 +651,31 @@ describe("minusReplace regex-special separators", () => {
|
|
|
651
651
|
expect(() => minusReplace("-5", { separator: sep })).not.toThrow()
|
|
652
652
|
})
|
|
653
653
|
})
|
|
654
|
+
|
|
655
|
+
const sep = DEFAULT_SEPARATOR
|
|
656
|
+
|
|
657
|
+
describe("minusReplace separator boundary", () => {
|
|
658
|
+
it.each([
|
|
659
|
+
["genuine negative at element start", `${sep}-5${sep}`, `${sep}${MINUS}5${sep}`],
|
|
660
|
+
["negative in parens across boundary", `(${sep}-5${sep})`, `(${sep}${MINUS}5${sep})`],
|
|
661
|
+
])("%s", (_desc, input, expected) => {
|
|
662
|
+
expect(minusReplace(input, { separator: sep })).toBe(expected)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it.each([
|
|
666
|
+
["no false negative after digit+sep", `2${sep}-3`],
|
|
667
|
+
["no false negative after letter+sep", `GPT${sep}-3`],
|
|
668
|
+
["multi-segment 1-2-3 with seps", `1-${sep}2${sep}-3`],
|
|
669
|
+
])("%s", (_desc, input) => {
|
|
670
|
+
expect(minusReplace(input, { separator: sep })).toBe(input)
|
|
671
|
+
})
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
describe("hyphenReplace preserves multi-segment numbers across separators", () => {
|
|
675
|
+
it.each([
|
|
676
|
+
["1-2-3 across elements", `1-${sep}2${sep}-3`],
|
|
677
|
+
["model name across elements", `${sep}GPT${sep}-3`],
|
|
678
|
+
])("%s", (_desc, input) => {
|
|
679
|
+
expect(hyphenReplace(input, { separator: sep })).toBe(input)
|
|
680
|
+
})
|
|
681
|
+
})
|
package/src/tests/index.test.ts
CHANGED
|
@@ -61,6 +61,8 @@ describe("transform", () => {
|
|
|
61
61
|
["superscript enabled", "1st", { superscript: true }, `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
|
|
62
62
|
["ligatures disabled", "??", {}, "??"],
|
|
63
63
|
["ligatures enabled", "??", { ligatures: true }, UNICODE_SYMBOLS.DOUBLE_QUESTION],
|
|
64
|
+
["nbsp disabled", "Dr. Smith", {}, "Dr. Smith"],
|
|
65
|
+
["nbsp enabled", "Dr. Smith", { nbsp: true }, `Dr.${NBSP}Smith`],
|
|
64
66
|
] as const)("%s: %s → %s", (_desc, input, options, expected) => {
|
|
65
67
|
expect(transform(input, options)).toBe(expected)
|
|
66
68
|
})
|
|
@@ -97,6 +99,31 @@ describe("transform", () => {
|
|
|
97
99
|
})
|
|
98
100
|
})
|
|
99
101
|
|
|
102
|
+
describe("nbsp option", () => {
|
|
103
|
+
it("inserts nbsp in typographically appropriate places", () => {
|
|
104
|
+
expect(transform("Dr. Smith wrote Fig. 1 on p. 42", { nbsp: true }))
|
|
105
|
+
.toBe(`Dr.${NBSP}Smith wrote Fig.${NBSP}1 on${NBSP}p.${NBSP}42`)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("collapseSpaces cleans up after nbsp", () => {
|
|
109
|
+
expect(transform("Prof. Wilson arrived", { nbsp: true }))
|
|
110
|
+
.toBe(`Prof.${NBSP}Wilson${NBSP}arrived`)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("is idempotent with nbsp enabled", () => {
|
|
114
|
+
const input = "Dr. Smith has 5 kg of items in § 3"
|
|
115
|
+
const first = transform(input, { nbsp: true })
|
|
116
|
+
const second = transform(first, { nbsp: true })
|
|
117
|
+
expect(second).toBe(first)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("preserves separator count with nbsp enabled", () => {
|
|
121
|
+
const sep = DEFAULT_SEPARATOR
|
|
122
|
+
const input = `Dr.${sep} Smith has${sep} 5 kg`
|
|
123
|
+
expect(() => transform(input, { nbsp: true, separator: sep })).not.toThrow()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
100
127
|
describe("separator preservation", () => {
|
|
101
128
|
it.each([
|
|
102
129
|
[`Wait.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}. for it`, 2],
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
nbspAfterShortWords,
|
|
3
|
+
nbspBetweenNumberAndUnit,
|
|
4
|
+
nbspBeforeLastWord,
|
|
5
|
+
nbspAfterReferenceAbbreviations,
|
|
6
|
+
nbspAfterSectionSymbols,
|
|
7
|
+
nbspAfterHonorifics,
|
|
8
|
+
nbspAfterCopyrightSymbols,
|
|
9
|
+
nbspBetweenInitials,
|
|
10
|
+
nbspTransform,
|
|
11
|
+
UNITS,
|
|
12
|
+
HONORIFICS,
|
|
13
|
+
REFERENCE_ABBREVIATIONS,
|
|
14
|
+
type NbspOptions,
|
|
15
|
+
} from "../nbsp.js"
|
|
16
|
+
import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
|
|
17
|
+
|
|
18
|
+
const NBSP = UNICODE_SYMBOLS.NBSP
|
|
19
|
+
const SEP = DEFAULT_SEPARATOR
|
|
20
|
+
|
|
21
|
+
/** Calls fn with separator option, checking all cases produce correct nbsp. */
|
|
22
|
+
function expectSep(
|
|
23
|
+
fn: (text: string, options?: NbspOptions) => string,
|
|
24
|
+
cases: [string, string][],
|
|
25
|
+
) {
|
|
26
|
+
for (const [input, expected] of cases) {
|
|
27
|
+
expect(fn(input, { separator: SEP })).toBe(expected)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("nbspAfterShortWords", () => {
|
|
32
|
+
it.each([
|
|
33
|
+
["a cat", `a${NBSP}cat`],
|
|
34
|
+
["I am", `I${NBSP}am`],
|
|
35
|
+
["an apple", `an${NBSP}apple`],
|
|
36
|
+
["to go", `to${NBSP}go`],
|
|
37
|
+
["of the", `of${NBSP}the`],
|
|
38
|
+
["in on", `in${NBSP}on`],
|
|
39
|
+
["is it", `is${NBSP}it`],
|
|
40
|
+
["or if", `or${NBSP}if`],
|
|
41
|
+
["I think", `I${NBSP}think`],
|
|
42
|
+
['"a cat"', `"a${NBSP}cat"`],
|
|
43
|
+
["the cat sat on a mat", `the cat sat on${NBSP}a${NBSP}mat`],
|
|
44
|
+
["Go to the store", `Go${NBSP}to${NBSP}the store`],
|
|
45
|
+
["the cat", "the cat"],
|
|
46
|
+
// Accented Latin short words
|
|
47
|
+
["à chat", `à${NBSP}chat`],
|
|
48
|
+
["où aller", `où${NBSP}aller`],
|
|
49
|
+
["ça va", `ça${NBSP}va`],
|
|
50
|
+
])('"%s" → "%s"', (input, expected) => {
|
|
51
|
+
expect(nbspAfterShortWords(input)).toBe(expected)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("preserves separators at node boundaries", () => {
|
|
55
|
+
expectSep(nbspAfterShortWords, [
|
|
56
|
+
[`a${SEP} cat`, `a${SEP}${NBSP}cat`],
|
|
57
|
+
[`a ${SEP}cat${SEP}`, `a${NBSP}${SEP}cat${SEP}`],
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe("nbspBetweenNumberAndUnit", () => {
|
|
63
|
+
it.each(UNITS.map((u) => [`5 ${u}`, `5${NBSP}${u}`]))(
|
|
64
|
+
'"%s" → "%s"',
|
|
65
|
+
(input, expected) => {
|
|
66
|
+
expect(nbspBetweenNumberAndUnit(input)).toBe(expected)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
it.each([
|
|
71
|
+
["100km", "100km"],
|
|
72
|
+
["5 kms", "5 kms"],
|
|
73
|
+
["chapter 3 above", "chapter 3 above"],
|
|
74
|
+
["item 5 here", "item 5 here"],
|
|
75
|
+
["5 3", "5 3"],
|
|
76
|
+
["page 42 of", "page 42 of"],
|
|
77
|
+
])('no match: "%s"', (input, expected) => {
|
|
78
|
+
expect(nbspBetweenNumberAndUnit(input)).toBe(expected)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("preserves separators at node boundaries", () => {
|
|
82
|
+
expectSep(nbspBetweenNumberAndUnit, [
|
|
83
|
+
[`5${SEP} kg`, `5${SEP}${NBSP}kg`],
|
|
84
|
+
[`5 ${SEP}kg`, `5${NBSP}${SEP}kg`],
|
|
85
|
+
[`5${SEP} ${SEP}kg`, `5${SEP}${NBSP}${SEP}kg`],
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("does not match unit letter that starts a cross-element word", () => {
|
|
90
|
+
// "5 m" + SEP + "ade" simulates <span>5 m</span><span>ade</span> ("made")
|
|
91
|
+
expect(nbspBetweenNumberAndUnit(`5 m${SEP}ade`, { separator: SEP }))
|
|
92
|
+
.toBe(`5 m${SEP}ade`)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe("nbspBeforeLastWord", () => {
|
|
97
|
+
it.each([
|
|
98
|
+
["the end", `the${NBSP}end`],
|
|
99
|
+
["hello world", `hello${NBSP}world`],
|
|
100
|
+
["a b c", `a b${NBSP}c`],
|
|
101
|
+
["hello abcdefghij", `hello${NBSP}abcdefghij`],
|
|
102
|
+
["hello abcdefghijk", "hello abcdefghijk"],
|
|
103
|
+
["hello international", "hello international"],
|
|
104
|
+
["hello", "hello"],
|
|
105
|
+
[" ", " "],
|
|
106
|
+
["the end\n\nNew paragraph", `the${NBSP}end\n\nNew${NBSP}paragraph`],
|
|
107
|
+
["the end\ncontinues", `the end\ncontinues`],
|
|
108
|
+
])('"%s" → "%s"', (input, expected) => {
|
|
109
|
+
expect(nbspBeforeLastWord(input)).toBe(expected)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("preserves separator before end", () => {
|
|
113
|
+
expectSep(nbspBeforeLastWord, [
|
|
114
|
+
[`the end${SEP}`, `the${NBSP}end${SEP}`],
|
|
115
|
+
])
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("nbspAfterReferenceAbbreviations", () => {
|
|
120
|
+
it.each([
|
|
121
|
+
...REFERENCE_ABBREVIATIONS.map((abbr, i) => [`${abbr}. ${i + 1}`, `${abbr}.${NBSP}${i + 1}`]),
|
|
122
|
+
["Fig. caption", "Fig. caption"],
|
|
123
|
+
] as [string, string][])('"%s" → "%s"', (input, expected) => {
|
|
124
|
+
expect(nbspAfterReferenceAbbreviations(input)).toBe(expected)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("preserves separators at node boundaries", () => {
|
|
128
|
+
expectSep(nbspAfterReferenceAbbreviations, [
|
|
129
|
+
[`Fig.${SEP} 1`, `Fig.${SEP}${NBSP}1`],
|
|
130
|
+
[`Fig. ${SEP}1`, `Fig.${NBSP}${SEP}1`],
|
|
131
|
+
])
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("nbspAfterSectionSymbols", () => {
|
|
136
|
+
it.each([
|
|
137
|
+
["§ 5", `§${NBSP}5`],
|
|
138
|
+
["¶ 3", `¶${NBSP}3`],
|
|
139
|
+
["See § 12 for details", `See §${NBSP}12 for details`],
|
|
140
|
+
["§ title", "§ title"],
|
|
141
|
+
])('"%s" → "%s"', (input, expected) => {
|
|
142
|
+
expect(nbspAfterSectionSymbols(input)).toBe(expected)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("preserves separator after symbol", () => {
|
|
146
|
+
expectSep(nbspAfterSectionSymbols, [
|
|
147
|
+
[`§${SEP} 5`, `§${SEP}${NBSP}5`],
|
|
148
|
+
])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe("nbspAfterHonorifics", () => {
|
|
153
|
+
const names = [
|
|
154
|
+
// English
|
|
155
|
+
"Smith", "Jones", "Brown", "Davis", "Wilson", "King", "Patrick", "Martinez", "Judge", "Brown", "Warren", "Lee", "Lee",
|
|
156
|
+
// French
|
|
157
|
+
"Dupont", "Laurent", "Lefebvre",
|
|
158
|
+
// German / Nordic
|
|
159
|
+
"Schmidt", "Weber",
|
|
160
|
+
// Spanish / Portuguese
|
|
161
|
+
"García", "Rodríguez",
|
|
162
|
+
// Italian
|
|
163
|
+
"Rossi", "Bianchi",
|
|
164
|
+
// Dutch
|
|
165
|
+
"Bakker", "Jansen",
|
|
166
|
+
]
|
|
167
|
+
it.each([
|
|
168
|
+
...HONORIFICS.map((h, i) => [`${h}. ${names[i]}`, `${h}.${NBSP}${names[i]}`]),
|
|
169
|
+
["Dr. Élodie", `Dr.${NBSP}Élodie`],
|
|
170
|
+
["Jr. here", "Jr. here"],
|
|
171
|
+
["Dr. said", "Dr. said"],
|
|
172
|
+
] as [string, string][])('"%s" → "%s"', (input, expected) => {
|
|
173
|
+
expect(nbspAfterHonorifics(input)).toBe(expected)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("preserves separators at node boundaries", () => {
|
|
177
|
+
expectSep(nbspAfterHonorifics, [
|
|
178
|
+
[`Dr.${SEP} Smith`, `Dr.${SEP}${NBSP}Smith`],
|
|
179
|
+
[`Dr. ${SEP}Smith`, `Dr.${NBSP}${SEP}Smith`],
|
|
180
|
+
])
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("nbspAfterCopyrightSymbols", () => {
|
|
185
|
+
it.each([
|
|
186
|
+
["© 2024", `©${NBSP}2024`],
|
|
187
|
+
["® Brand", `®${NBSP}Brand`],
|
|
188
|
+
["™ Product", `™${NBSP}Product`],
|
|
189
|
+
["© 2024 Company", `©${NBSP}2024 Company`],
|
|
190
|
+
["© company", "© company"],
|
|
191
|
+
])('"%s" → "%s"', (input, expected) => {
|
|
192
|
+
expect(nbspAfterCopyrightSymbols(input)).toBe(expected)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("preserves separator after symbol", () => {
|
|
196
|
+
expectSep(nbspAfterCopyrightSymbols, [
|
|
197
|
+
[`©${SEP} 2024`, `©${SEP}${NBSP}2024`],
|
|
198
|
+
])
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe("nbspBetweenInitials", () => {
|
|
203
|
+
it.each([
|
|
204
|
+
["J. K. Rowling", `J.${NBSP}K.${NBSP}Rowling`],
|
|
205
|
+
["A. B. C.", `A.${NBSP}B.${NBSP}C.`],
|
|
206
|
+
["J. Smith", `J.${NBSP}Smith`],
|
|
207
|
+
["É. Piaf", `É.${NBSP}Piaf`],
|
|
208
|
+
["A. test", "A. test"],
|
|
209
|
+
])('"%s" → "%s"', (input, expected) => {
|
|
210
|
+
expect(nbspBetweenInitials(input)).toBe(expected)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("preserves separator between initials", () => {
|
|
214
|
+
expectSep(nbspBetweenInitials, [
|
|
215
|
+
[`J.${SEP} K. Rowling`, `J.${SEP}${NBSP}K.${NBSP}Rowling`],
|
|
216
|
+
])
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe("nbspTransform", () => {
|
|
221
|
+
it("applies all nbsp transformations", () => {
|
|
222
|
+
expect(nbspTransform("Dr. Smith wrote Fig. 1 on p. 42"))
|
|
223
|
+
.toBe(`Dr.${NBSP}Smith wrote Fig.${NBSP}1 on${NBSP}p.${NBSP}42`)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it("handles multiple rules on the same text", () => {
|
|
227
|
+
expect(nbspTransform("© 2024 by J. K. Rowling"))
|
|
228
|
+
.toBe(`©${NBSP}2024 by${NBSP}J.${NBSP}K.${NBSP}Rowling`)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("nbspBeforeLastWord fires on plain text", () => {
|
|
232
|
+
expect(nbspTransform("Hello world")).toBe(`Hello${NBSP}world`)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it("passes separator through to all functions", () => {
|
|
236
|
+
expect(nbspTransform(`Dr.${SEP} Smith`, { separator: SEP }))
|
|
237
|
+
.toBe(`Dr.${SEP}${NBSP}Smith`)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe("ordering: specific patterns before generic", () => {
|
|
241
|
+
it("abbreviation claims nbsp before short-word rule", () => {
|
|
242
|
+
const result = nbspTransform("No. 5")
|
|
243
|
+
expect(result).toBe(`No.${NBSP}5`)
|
|
244
|
+
expect(result.split(NBSP).length - 1).toBe(1)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("honorific + initials compose correctly", () => {
|
|
248
|
+
expect(nbspTransform("Dr. J. K. Smith")).toBe(`Dr.${NBSP}J.${NBSP}K.${NBSP}Smith`)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("honorific + unit don't double-apply with short words", () => {
|
|
252
|
+
expect(nbspTransform("St. Anne had 5 kg")).toBe(`St.${NBSP}Anne had 5${NBSP}kg`)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|