punctilio 1.4.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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. collapseSpaces (collapses multiple spaces into one)
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
@@ -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
+ })
@@ -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
+ })