@danielhaim/titlecaser 1.2.57 → 1.2.59

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.
@@ -1,606 +1,835 @@
1
1
  import {
2
- allowedTitleCaseStylesList,
3
- titleCaseDefaultOptionsList,
4
- wordReplacementsList,
5
- correctTitleCasingList,
6
- ignoredWordList,
2
+ allowedTitleCaseStylesList,
3
+ titleCaseDefaultOptionsList,
4
+ wordReplacementsList,
5
+ correctTitleCasingList,
6
+ ignoredWordList,
7
7
  } from "./TitleCaserConsts.js";
8
8
 
9
9
  export class TitleCaserUtils {
10
-
11
- // Validate the option key
12
- static validateOption(key, value) {
13
- // Check if value is an array
14
- if (!Array.isArray(value)) {
15
- throw new TypeError(`Invalid option: ${key} must be an array`);
16
- }
17
-
18
- // Check if all values in the array are strings
19
- if (!value.every((word) => typeof word === "string")) {
20
- throw new TypeError(`Invalid option: ${key} must be an array of strings`);
21
- }
22
- }
23
-
24
- // Validate the option object
25
- static TitleCaseValidator;
26
-
27
- static validateOptions(options) {
28
- for (const key of Object.keys(options)) {
29
-
30
- if (key === 'style') {
31
- if (typeof options.style !== 'string') {
32
- throw new TypeError(`Invalid option: ${key} must be a string`);
33
- } else if (!allowedTitleCaseStylesList.includes(options.style)) {
34
- throw new TypeError(`Invalid option: ${key} must be a string`);
35
- }
36
- continue;
37
- }
38
-
39
- if (key === 'wordReplacementsList') {
40
- if (!Array.isArray(options.wordReplacementsList)) {
41
- throw new TypeError(`Invalid option: ${key} must be an array`);
42
- } else {
43
- for (const term of options.wordReplacementsList) {
44
- if (typeof term !== 'string') {
45
- throw new TypeError(`Invalid option: ${key} must contain only strings`);
46
- }
47
- }
48
- }
49
- continue;
50
- }
51
-
52
- if (!titleCaseDefaultOptionsList.hasOwnProperty(key)) {
53
- throw new TypeError(`Invalid option: ${key}`);
54
- }
55
-
56
- this.TitleCaseValidator.validateOption(key, options[key]);
57
- }
58
- }
59
-
60
- static titleCaseOptionsCache = new Map();
61
-
62
- static getTitleCaseOptions(options = {}, lowercaseWords = []) {
63
- // Create a unique key for the cache that combines the options and the lowercase words
64
- const cacheKey = JSON.stringify({
65
- options,
66
- lowercaseWords
67
- });
68
-
69
- // If the cache already has an entry for this key, return the cached options
70
- if (TitleCaserUtils.titleCaseOptionsCache.has(cacheKey)) {
71
- return TitleCaserUtils.titleCaseOptionsCache.get(cacheKey);
72
- }
73
-
74
- const mergedOptions = {
75
- ...titleCaseDefaultOptionsList[options.style || "ap"],
76
- ...options,
77
- smartQuotes: options.hasOwnProperty('smartQuotes') ? options.smartQuotes : false
78
- };
79
-
80
- // Merge the default articles with user-provided articles and lowercase words
81
- const mergedArticles = mergedOptions.articlesList.concat(lowercaseWords)
82
- .filter((word, index, array) => array.indexOf(word) === index);
83
-
84
- // Merge the default short conjunctions with user-provided conjunctions and lowercase words
85
- const mergedShortConjunctions = mergedOptions.shortConjunctionsList.concat(lowercaseWords)
86
- .filter((word, index, array) => array.indexOf(word) === index);
87
-
88
- // Merge the default short prepositions with user-provided prepositions and lowercase words
89
- const mergedShortPrepositions = mergedOptions.shortPrepositionsList.concat(lowercaseWords)
90
- .filter((word, index, array) => array.indexOf(word) === index);
91
-
92
- // Merge the default word replacements with the user-provided replacements
93
- const mergedReplaceTerms = [
94
- ...(mergedOptions.replaceTerms || [])
95
- .map(([key, value]) => [key.toLowerCase(), value]),
96
- ...wordReplacementsList,
97
- ];
98
-
99
- // Return the merged options
100
- const result = {
101
- articlesList: mergedArticles,
102
- shortConjunctionsList: mergedShortConjunctions,
103
- shortPrepositionsList: mergedShortPrepositions,
104
- neverCapitalizedList: [...mergedOptions.neverCapitalizedList],
105
- replaceTerms: mergedReplaceTerms,
106
- smartQuotes: mergedOptions.smartQuotes // Add smartQuotes option to result
107
- };
108
-
109
- // Add the merged options to the cache and return them
110
- TitleCaserUtils.titleCaseOptionsCache.set(cacheKey, result);
111
- return result;
112
- }
113
-
114
- static isNeverCapitalizedCache = new Map();
115
-
116
- // Check if the word is a short conjunction
117
- static isShortConjunction(word, style) {
118
- // Get the list of short conjunctions from the TitleCaseHelper
119
- const shortConjunctionsList = [...TitleCaserUtils.getTitleCaseOptions({
120
- style: style
121
- })
122
- .shortConjunctionsList
123
- ];
124
-
125
- // Convert the word to lowercase
126
- const wordLowerCase = word.toLowerCase();
127
-
128
- // Return true if the word is in the list of short conjunctions
129
- return shortConjunctionsList.includes(wordLowerCase);
130
- }
131
-
132
- // Check if the word is an article
133
- static isArticle(word, style) {
134
- // Get the list of articles for the language
135
- const articlesList = TitleCaserUtils.getTitleCaseOptions({
136
- style: style
137
- })
138
- .articlesList;
139
- // Return true if the word matches an article
140
- return articlesList.includes(word.toLowerCase());
141
- }
142
-
143
- // Check if the word is a short preposition
144
- static isShortPreposition(word, style) {
145
- // Get the list of short prepositions from the Title Case Helper.
146
- const {
147
- shortPrepositionsList
148
- } = TitleCaserUtils.getTitleCaseOptions({
149
- style: style
150
- });
151
- // Check if the word is in the list of short prepositions.
152
- return shortPrepositionsList.includes(word.toLowerCase());
153
- }
154
-
155
- // This function is only ever called once per word per style, since the result is cached.
156
- // The cache key is a combination of the style and the lowercase word.
157
- static isNeverCapitalized(word, style) {
158
- // Check if the word is in the cache. If it is, return it.
159
- const cacheKey = `${style}_${word.toLowerCase()}`;
160
- if (TitleCaserUtils.isNeverCapitalizedCache.has(cacheKey)) {
161
- return TitleCaserUtils.isNeverCapitalizedCache.get(cacheKey);
162
- }
163
-
164
- // If the word is not in the cache, then check if it is in the word list for the given style.
165
- const {
166
- neverCapitalizedList
167
- } = TitleCaserUtils.getTitleCaseOptions({
168
- style
169
- });
170
-
171
- const result = neverCapitalizedList.includes(word.toLowerCase());
172
- // Store the result in the cache so it can be used again
173
- TitleCaserUtils.isNeverCapitalizedCache.set(cacheKey, result);
174
-
175
- return result;
176
- }
177
-
178
- static isShortWord(word, style) {
179
- // If the word is not a string, throw a TypeError.
180
- if (typeof word !== "string") {
181
- throw new TypeError(`Invalid input: word must be a string. Received ${typeof word}.`);
182
- }
183
-
184
- // If the style is not one of the allowed styles, throw an Error.
185
- if (!allowedTitleCaseStylesList.includes(style)) {
186
- throw new Error(`Invalid option: style must be one of ${allowedTitleCaseStylesList.join(", ")}.`);
187
- }
188
-
189
- // If the word is a short conjunction, article, preposition, or is in the never-capitalized list, return true.
190
- // Otherwise, return false.
191
- return TitleCaserUtils.isShortConjunction(word, style) ||
192
- TitleCaserUtils.isArticle(word, style) ||
193
- TitleCaserUtils.isShortPreposition(word, style) ||
194
- TitleCaserUtils.isNeverCapitalized(word, style);
195
- }
196
-
197
- // Check if a word has a number
198
- static hasNumbers(word) {
199
- return /\d/.test(word);
200
- }
201
-
202
- // Check if a word has multiple uppercase letters
203
- static hasUppercaseMultiple(word) {
204
- // initialize count to 0
205
- let count = 0;
206
- // loop through each character of the word
207
- for (let i = 0; i < word.length && count < 2; i++) {
208
- // if the character is an uppercase letter
209
- if (/[A-Z]/.test(word[i])) {
210
- // increment count by 1
211
- count++;
212
- }
213
- }
214
- // return true if count is greater or equal to 2, false otherwise
215
- return count >= 2;
216
- }
217
-
218
- // Check if a word has an intentional uppercase letter
219
- // (i.e. not the first letter of the word)
220
- static hasUppercaseIntentional(word) {
221
- // Only check for uppercase letters after the first letter
222
- // and only check for lowercase letters before the last letter
223
- return /[A-Z]/.test(word.slice(1)) && /[a-z]/.test(word.slice(0, -1));
224
- }
225
-
226
- // Check if a word has a suffix
227
- static hasSuffix(word) {
228
- // Test if word is longer than suffix
229
- const suffix = "'s";
230
- // Test if word ends with suffix
231
- return word.length > suffix.length && word.endsWith(suffix);
232
- }
233
-
234
- // Check if a word has an apostrophe
235
- static hasApostrophe(word) {
236
- return word.indexOf("'") !== -1;
237
- }
238
-
239
- // Check if a word has a hyphen
240
- static hasHyphen(word) {
241
- return word.indexOf('-') !== -1 || word.indexOf('–') !== -1 || word.indexOf('—') !== -1;
242
- }
243
-
244
- // Check if a word is a Roman numeral
245
- static hasRomanNumeral(word) {
246
- // Check if the input is a string
247
- if (typeof word !== 'string' || word === '') {
248
- // Throw an error if the input is not a string
249
- throw new TypeError('Invalid input: word must be a non-empty string.');
250
- }
251
-
252
- // Define a regular expression that matches a roman numeral
253
- const romanNumeralRegex = /^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i;
254
- // Check if the input word matches the regular expression
255
- return romanNumeralRegex.test(word);
256
- }
257
-
258
- // Check if a word is a hyphenated Roman numeral
259
- static hasHyphenRomanNumeral(word) {
260
- if (typeof word !== "string" || word === "") {
261
- throw new TypeError("Invalid input: word must be a non-empty string.");
262
- }
263
-
264
- const parts = word.split("-");
265
- for (let i = 0; i < parts.length; i++) {
266
- if (!TitleCaserUtils.hasRomanNumeral(parts[i])) {
267
- return false;
268
- }
269
- }
270
- return true;
271
- }
272
-
273
- // Check if a word has `nl2br` in it
274
- static hasHtmlBreak(word) {
275
- return word === "nl2br";
276
- }
277
-
278
- // Check if a string has Unicode symbols.
279
- static hasUnicodeSymbols(str) {
280
- return /[^\x00-\x7F\u00A0-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u02B0-\u02FF\u0300-\u036F\u0370-\u03FF\u0400-\u04FF\u0500-\u052F\u0530-\u058F\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0800-\u083F\u0840-\u085F\u0860-\u087F\u0880-\u08AF\u08B0-\u08FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0D80-\u0DFF\u0E00-\u0E7F\u0E80-\u0EFF\u0F00-\u0FFF]/.test(str);
281
- }
282
-
283
- // Checks whether a string contains any currency symbols
284
- static hasCurrencySymbols(str) {
285
- return /[^\x00-\x7F\u00A0-\u00FF\u20AC\u20A0-\u20B9\u20BD\u20A1-\u20A2\u00A3-\u00A5\u058F\u060B\u09F2-\u09F3\u0AF1\u0BF9\u0E3F\u17DB\u20A6\u20A8\u20B1\u2113\u20AA-\u20AB\u20AA\u20AC-\u20AD\u20B9]/.test(str);
286
- }
287
-
288
- // Check if a word is ampersand
289
- static isWordAmpersand(str) {
290
- return /&amp;|&/.test(str);
291
- }
292
-
293
-
294
- // Check if a word starts with a symbol
295
- static startsWithSymbol(word) {
296
- if (typeof word !== 'string') {
297
- throw new Error(`Parameter 'word' must be a string. Received '${typeof word}' instead.`);
298
- }
299
-
300
- if (word.length === 0) {
301
- return false;
302
- }
303
-
304
- const firstChar = word.charAt(0);
305
-
306
- return (
307
- firstChar === '#' ||
308
- firstChar === '@' ||
309
- firstChar === '.'
310
- );
311
- }
312
-
313
- static escapeSpecialCharacters(str) {
314
- return str.replace(/[&<>"']/g, function (match) {
315
- switch (match) {
316
- case "&":
317
- return "&amp;";
318
- case "<":
319
- return "&lt;";
320
- case ">":
321
- return "&gt;";
322
- case '"':
323
- return "&quot;";
324
- case "'":
325
- return "&#x27;";
326
- default:
327
- return match;
328
- }
329
- });
330
- }
331
-
332
- static unescapeSpecialCharacters(str) {
333
- return str.replace(/&amp;|&lt;|&gt;|&quot;|&#x27;/g, function (match) {
334
- switch (match) {
335
- case "&amp;":
336
- return "&";
337
- case "&lt;":
338
- return "<";
339
- case "&gt;":
340
- return ">";
341
- case "&quot;":
342
- return '"';
343
- case "&#x27;":
344
- return "'";
345
- default:
346
- return match;
347
- }
348
- });
349
- }
350
-
351
-
352
- // Check if a word ends with a symbol
353
- static endsWithSymbol(word, symbols = [".", ",", ";", ":", "?", "!"]) {
354
- // Check if the word is a string and the symbols is an array
355
- if (typeof word !== "string" || !Array.isArray(symbols))
356
- throw new Error("Invalid arguments");
357
- // Check if the word ends with a symbol or two symbols
358
- return symbols.some(symbol => word.endsWith(symbol)) || symbols.includes(word.slice(-2));
359
- }
360
-
361
- // This function accepts two arguments: a word and an array of ignored words.
362
- static isWordIgnored(word, ignoredWords = ignoredWordList) {
363
- // If the ignoredWords argument is not an array, throw an error.
364
- if (!Array.isArray(ignoredWords)) {
365
- throw new TypeError("Invalid input: ignoredWords must be an array.");
366
- }
367
-
368
- // If the word argument is not a non-empty string, throw an error.
369
- if (typeof word !== "string" || word.trim() === "") {
370
- throw new TypeError("Invalid input: word must be a non-empty string.");
371
- }
372
-
373
- // Convert the word to lowercase and trim any space.
374
- let lowercasedWord;
375
- lowercasedWord = word.toLowerCase()
376
- .trim();
377
-
378
- // If the word is in the ignoredWords array, return true. Otherwise, return false.
379
- return ignoredWords.includes(lowercasedWord);
380
- }
381
-
382
- // Check if the wordList is a valid array
383
- static isWordInArray(targetWord, wordList) {
384
- if (!Array.isArray(wordList)) {
385
- return false;
386
- }
387
-
388
- // Check if the targetWord is in the wordList
389
- return wordList.some((word) => word.toLowerCase() === targetWord.toLowerCase());
390
- }
391
-
392
- static convertQuotesToCurly(input) {
393
- const curlyQuotes = {
394
- "'": ['\u2018', '\u2019'],
395
- '"': ['\u201C', '\u201D'],
396
- };
397
-
398
- let replacedText = '';
399
-
400
- for (let i = 0; i < input.length; i++) {
401
- const char = input[i];
402
- const curlyQuotePair = curlyQuotes[char];
403
-
404
- if (curlyQuotePair) {
405
- const prevChar = input[i - 1];
406
- const nextChar = input[i + 1];
407
-
408
- // Determine whether to use left or right curly quote
409
- const isLeftAligned = (!prevChar || prevChar === ' ' || prevChar === '\n');
410
- const curlyQuote = isLeftAligned ? curlyQuotePair[0] : curlyQuotePair[1];
411
- replacedText += curlyQuote;
412
-
413
- // Handle cases where right curly quote is followed by punctuation or space
414
- if (curlyQuote === curlyQuotePair[1] && /[.,;!?()\[\]{}:]/.test(nextChar)) {
415
- replacedText += nextChar;
416
- i++; // Skip the next character
417
- }
418
- } else {
419
- replacedText += char;
420
- }
421
- }
422
-
423
- return replacedText;
424
- }
425
-
426
-
427
- // This function is used to replace a word with a term in the replaceTerms object
428
- static replaceTerm(word, replaceTermObj) {
429
- // Validate input
430
- if (typeof word !== "string" || word === "") {
431
- throw new TypeError("Invalid input: word must be a non-empty string.");
432
- }
433
-
434
- if (!replaceTermObj || typeof replaceTermObj !== "object") {
435
- throw new TypeError("Invalid input: replaceTermObj must be a non-null object.");
436
- }
437
-
438
- // Convert the word to lowercase
439
- let lowercasedWord;
440
- lowercasedWord = word.toLowerCase();
441
-
442
- // Check if the word is in the object with lowercase key
443
- if (replaceTermObj.hasOwnProperty(lowercasedWord)) {
444
- return replaceTermObj[lowercasedWord];
445
- }
446
-
447
- // Check if the word is in the object with original case key
448
- if (replaceTermObj.hasOwnProperty(word)) {
449
- return replaceTermObj[word];
450
- }
451
-
452
- // Check if the word is in the object with uppercase key
453
- const uppercasedWord = word.toUpperCase();
454
- if (replaceTermObj.hasOwnProperty(uppercasedWord)) {
455
- return replaceTermObj[uppercasedWord];
456
- }
457
-
458
- // If the word is not in the object, return the original word
459
- return word;
460
- }
461
-
462
- // This function is used to check if a suffix is present in a word that is in the correct terms list
463
- static correctSuffix(word, correctTerms) {
464
- // Validate input
465
- if (typeof word !== "string" || word === "") {
466
- throw new TypeError("Invalid input: word must be a non-empty string.");
467
- }
468
-
469
- if (!correctTerms || !Array.isArray(correctTerms) || correctTerms.some((term) => typeof term !== "string")) {
470
- throw new TypeError("Invalid input: correctTerms must be an array of strings.");
471
- }
472
-
473
- // Define the regular expression for the suffix
474
- const suffixRegex = /'s$/i;
475
-
476
- // If the word ends with the suffix
477
- if (suffixRegex.test(word)) {
478
- // Remove the suffix from the word
479
- const wordWithoutSuffix = word.slice(0, -2);
480
- // Check if the word without the suffix matches any of the correct terms
481
- const matchingIndex = correctTerms.findIndex((term) => term.toLowerCase() === wordWithoutSuffix.toLowerCase());
482
-
483
- if (matchingIndex >= 0) {
484
- // If it does, return the correct term with the suffix
485
- const correctCase = correctTerms[matchingIndex];
486
- return `${correctCase}'s`;
487
- } else {
488
- // If not, capitalize the first letter and append the suffix
489
- const capitalizedWord = wordWithoutSuffix.charAt(0).toUpperCase() + wordWithoutSuffix.slice(1);
490
- return `${capitalizedWord}'s`;
491
- }
492
- }
493
-
494
- // If the word doesn't end with the suffix, return the word as-is
495
- return word;
496
- }
497
-
498
- // This function is used to check if a word is in the correct terms list
499
- static correctTerm(word, correctTerms, delimiters = /[-']/) {
500
- // Validate input
501
- if (typeof word !== "string" || word === "") {
502
- throw new TypeError("Invalid input: word must be a non-empty string.");
503
- }
504
-
505
- if (!correctTerms || !Array.isArray(correctTerms)) {
506
- throw new TypeError("Invalid input: correctTerms must be an array.");
507
- }
508
-
509
- if (typeof delimiters !== "string" && !Array.isArray(delimiters) && !(delimiters instanceof RegExp)) {
510
- throw new TypeError("Invalid input: delimiters must be a string, an array of strings, or a regular expression.");
511
- }
512
-
513
- // Convert delimiters to a regular expression if it is a string or an array
514
- if (typeof delimiters === "string") {
515
- delimiters = new RegExp(`[${delimiters}]`);
516
- } else if (Array.isArray(delimiters)) {
517
- delimiters = new RegExp(`[${delimiters.join("")}]`);
518
- }
519
-
520
- // Split the word into parts delimited by the specified delimiters
521
- const parts = word.split(delimiters);
522
- // Count the number of parts
523
- const numParts = parts.length;
524
-
525
- // For each part
526
- for (let i = 0; i < numParts; i++) {
527
- // Lowercase the part
528
- const lowercasedPart = parts[i].toLowerCase();
529
- // Search for the part in the list of correct terms
530
- const index = correctTerms.findIndex((t) => t.toLowerCase() === lowercasedPart);
531
- // If the part is found in the list of correct terms
532
- if (index >= 0) {
533
- // Replace the part with the correct term
534
- parts[i] = correctTerms[index];
535
- }
536
- }
537
-
538
- // Join the parts back together using the first delimiter as the default delimiter
539
- return parts.join(delimiters.source.charAt(0));
540
- }
541
-
542
- // This function is used to check if a word is in the correct terms list
543
- static correctTermHyphenated(word, style) {
544
- // Split the word into an array of words
545
- const hyphenatedWords = word.split("-");
546
-
547
- // Define functions to process words
548
- const capitalizeFirst = (word) => word.charAt(0)
549
- .toUpperCase() + word.slice(1);
550
- const lowercaseRest = (word) => word.charAt(0) + word.slice(1)
551
- .toLowerCase();
552
-
553
- // Define the style-specific processing functions
554
- const styleFunctions = {
555
- ap: (word, index) => (index === 0 ? capitalizeFirst(word) : lowercaseRest(word)),
556
- chicago: capitalizeFirst,
557
- apa: (word, index, length) => {
558
- if (TitleCaserUtils.isShortWord(word, style) && index > 0 && index < length - 1) {
559
- return word.toLowerCase();
560
- } else {
561
- return capitalizeFirst(word);
562
- }
563
- },
564
- nyt: (word, index) => (index === 0 ? capitalizeFirst(word) : lowercaseRest(word)),
565
- wikipedia: (word, index) => (index === 0 ? capitalizeFirst(word) : lowercaseRest(word)),
566
- };
567
-
568
- // Get the style-specific processing function
569
- const processWord = styleFunctions[style] || lowercaseRest;
570
-
571
- // Process each word
572
- const processedWords = hyphenatedWords.map((word, i) => {
573
-
574
- // Check if the word is a Roman numeral
575
- const romanNumeralRegex = /^(M{0,3})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i;
576
- if (romanNumeralRegex.test(word)) {
577
- return word.toUpperCase();
578
- }
579
-
580
- // Preserve the original word
581
- let correctedWord = word;
582
-
583
- // Check if the word is in the list of words to preserve
584
- const lowerCaseWord = word.toLowerCase();
585
- const uniqueTermsIndex = correctTitleCasingList.findIndex((w) => w.toLowerCase() === lowerCaseWord);
586
- if (uniqueTermsIndex >= 0) {
587
- correctedWord = correctTitleCasingList[uniqueTermsIndex];
588
- }
589
- // Check if the word is a possessive form
590
- else if (lowerCaseWord.endsWith("'s")) {
591
- const rootWord = lowerCaseWord.substring(0, lowerCaseWord.length - 2);
592
- const rootWordIndex = correctTitleCasingList.findIndex((w) => w.toLowerCase() === rootWord);
593
- if (rootWordIndex >= 0) {
594
- correctedWord = `${correctTitleCasingList[rootWordIndex]}'s`;
595
- }
596
- }
597
-
598
- // Process the word
599
- return processWord(correctedWord, i, hyphenatedWords.length);
600
- });
601
-
602
- // Rejoin the words
603
- return processedWords.join("-");
604
- }
605
-
10
+ // Validate the option key
11
+ static validateOption(key, value) {
12
+ // Check if value is an array
13
+ if (!Array.isArray(value)) {
14
+ throw new TypeError(`Invalid option: ${key} must be an array`);
15
+ }
16
+
17
+ // Check if all values in the array are strings
18
+ if (!value.every((word) => typeof word === "string")) {
19
+ throw new TypeError(`Invalid option: ${key} must be an array of strings`);
20
+ }
21
+ }
22
+
23
+ // Validate the option object
24
+ static TitleCaseValidator;
25
+
26
+ static validateOptions(options) {
27
+ for (const key of Object.keys(options)) {
28
+ if (key === "style") {
29
+ if (typeof options.style !== "string") {
30
+ throw new TypeError(`Invalid option: ${key} must be a string`);
31
+ } else if (!allowedTitleCaseStylesList.includes(options.style)) {
32
+ throw new TypeError(`Invalid option: ${key} must be a string`);
33
+ }
34
+ continue;
35
+ }
36
+
37
+ if (key === "wordReplacementsList") {
38
+ if (!Array.isArray(options.wordReplacementsList)) {
39
+ throw new TypeError(`Invalid option: ${key} must be an array`);
40
+ } else {
41
+ for (const term of options.wordReplacementsList) {
42
+ if (typeof term !== "string") {
43
+ throw new TypeError(`Invalid option: ${key} must contain only strings`);
44
+ }
45
+ }
46
+ }
47
+ continue;
48
+ }
49
+
50
+ if (!titleCaseDefaultOptionsList.hasOwnProperty(key)) {
51
+ throw new TypeError(`Invalid option: ${key}`);
52
+ }
53
+
54
+ this.TitleCaseValidator.validateOption(key, options[key]);
55
+ }
56
+ }
57
+
58
+ static titleCaseOptionsCache = new Map();
59
+
60
+ static getTitleCaseOptions(options = {}, lowercaseWords = []) {
61
+ // Create a unique key for the cache that combines the options and the lowercase words
62
+ const cacheKey = JSON.stringify({
63
+ options,
64
+ lowercaseWords,
65
+ });
66
+
67
+ // If the cache already has an entry for this key, return the cached options
68
+ if (TitleCaserUtils.titleCaseOptionsCache.has(cacheKey)) {
69
+ return TitleCaserUtils.titleCaseOptionsCache.get(cacheKey);
70
+ }
71
+
72
+ const mergedOptions = {
73
+ ...titleCaseDefaultOptionsList[options.style || "ap"],
74
+ ...options,
75
+ smartQuotes: options.hasOwnProperty("smartQuotes") ? options.smartQuotes : false,
76
+ };
77
+
78
+ // Merge the default articles with user-provided articles and lowercase words
79
+ const mergedArticles = mergedOptions.articlesList
80
+ .concat(lowercaseWords)
81
+ .filter((word, index, array) => array.indexOf(word) === index);
82
+
83
+ // Merge the default short conjunctions with user-provided conjunctions and lowercase words
84
+ const mergedShortConjunctions = mergedOptions.shortConjunctionsList
85
+ .concat(lowercaseWords)
86
+ .filter((word, index, array) => array.indexOf(word) === index);
87
+
88
+ // Merge the default short prepositions with user-provided prepositions and lowercase words
89
+ const mergedShortPrepositions = mergedOptions.shortPrepositionsList
90
+ .concat(lowercaseWords)
91
+ .filter((word, index, array) => array.indexOf(word) === index);
92
+
93
+ // Merge the default word replacements with the user-provided replacements
94
+ const mergedReplaceTerms = [
95
+ ...(mergedOptions.replaceTerms || []).map(([key, value]) => [key.toLowerCase(), value]),
96
+ ...wordReplacementsList,
97
+ ];
98
+
99
+ // Return the merged options
100
+ const result = {
101
+ articlesList: mergedArticles,
102
+ shortConjunctionsList: mergedShortConjunctions,
103
+ shortPrepositionsList: mergedShortPrepositions,
104
+ neverCapitalizedList: [...mergedOptions.neverCapitalizedList],
105
+ replaceTerms: mergedReplaceTerms,
106
+ smartQuotes: mergedOptions.smartQuotes, // Add smartQuotes option to result
107
+ };
108
+
109
+ // Add the merged options to the cache and return them
110
+ TitleCaserUtils.titleCaseOptionsCache.set(cacheKey, result);
111
+ return result;
112
+ }
113
+
114
+ static isNeverCapitalizedCache = new Map();
115
+
116
+ // Check if the word is a short conjunction
117
+ static isShortConjunction(word, style) {
118
+ // Get the list of short conjunctions from the TitleCaseHelper
119
+ const shortConjunctionsList = [
120
+ ...TitleCaserUtils.getTitleCaseOptions({
121
+ style: style,
122
+ }).shortConjunctionsList,
123
+ ];
124
+
125
+ // Convert the word to lowercase
126
+ const wordLowerCase = word.toLowerCase();
127
+
128
+ // Return true if the word is in the list of short conjunctions
129
+ return shortConjunctionsList.includes(wordLowerCase);
130
+ }
131
+
132
+ // Check if the word is an article
133
+ static isArticle(word, style) {
134
+ // Get the list of articles for the language
135
+ const articlesList = TitleCaserUtils.getTitleCaseOptions({
136
+ style: style,
137
+ }).articlesList;
138
+ // Return true if the word matches an article
139
+ return articlesList.includes(word.toLowerCase());
140
+ }
141
+
142
+ // Check if the word is a short preposition
143
+ static isShortPreposition(word, style) {
144
+ // Get the list of short prepositions from the Title Case Helper.
145
+ const { shortPrepositionsList } = TitleCaserUtils.getTitleCaseOptions({
146
+ style: style,
147
+ });
148
+ // Check if the word is in the list of short prepositions.
149
+ return shortPrepositionsList.includes(word.toLowerCase());
150
+ }
151
+
152
+ // This function is only ever called once per word per style, since the result is cached.
153
+ // The cache key is a combination of the style and the lowercase word.
154
+ static isNeverCapitalized(word, style) {
155
+ // Check if the word is in the cache. If it is, return it.
156
+ const cacheKey = `${style}_${word.toLowerCase()}`;
157
+ if (TitleCaserUtils.isNeverCapitalizedCache.has(cacheKey)) {
158
+ return TitleCaserUtils.isNeverCapitalizedCache.get(cacheKey);
159
+ }
160
+
161
+ // If the word is not in the cache, then check if it is in the word list for the given style.
162
+ const { neverCapitalizedList } = TitleCaserUtils.getTitleCaseOptions({
163
+ style,
164
+ });
165
+
166
+ const result = neverCapitalizedList.includes(word.toLowerCase());
167
+ // Store the result in the cache so it can be used again
168
+ TitleCaserUtils.isNeverCapitalizedCache.set(cacheKey, result);
169
+
170
+ return result;
171
+ }
172
+
173
+ static isShortWord(word, style) {
174
+ // If the word is not a string, throw a TypeError.
175
+ if (typeof word !== "string") {
176
+ throw new TypeError(`Invalid input: word must be a string. Received ${typeof word}.`);
177
+ }
178
+
179
+ // If the style is not one of the allowed styles, throw an Error.
180
+ if (!allowedTitleCaseStylesList.includes(style)) {
181
+ throw new Error(`Invalid option: style must be one of ${allowedTitleCaseStylesList.join(", ")}.`);
182
+ }
183
+
184
+ // If the word is a short conjunction, article, preposition, or is in the never-capitalized list, return true.
185
+ // Otherwise, return false.
186
+ return (
187
+ TitleCaserUtils.isShortConjunction(word, style) ||
188
+ TitleCaserUtils.isArticle(word, style) ||
189
+ TitleCaserUtils.isShortPreposition(word, style) ||
190
+ TitleCaserUtils.isNeverCapitalized(word, style)
191
+ );
192
+ }
193
+
194
+ // Check if a word has a number
195
+ static hasNumbers(word) {
196
+ return /\d/.test(word);
197
+ }
198
+
199
+ // Check if a word has multiple uppercase letters
200
+ static hasUppercaseMultiple(word) {
201
+ // initialize count to 0
202
+ let count = 0;
203
+ // loop through each character of the word
204
+ for (let i = 0; i < word.length && count < 2; i++) {
205
+ // if the character is an uppercase letter
206
+ if (/[A-Z]/.test(word[i])) {
207
+ // increment count by 1
208
+ count++;
209
+ }
210
+ }
211
+ // return true if count is greater or equal to 2, false otherwise
212
+ return count >= 2;
213
+ }
214
+
215
+ // Check if a word has an intentional uppercase letter
216
+ // (i.e. not the first letter of the word)
217
+ static hasUppercaseIntentional(word) {
218
+ if (word.length <= 4) {
219
+ return /[A-Z]/.test(word.slice(1));
220
+ }
221
+
222
+ return /[A-Z]/.test(word.slice(1)) && /[a-z]/.test(word.slice(0, -1));
223
+ }
224
+
225
+ static isAcronym(word, prevWord, nextWord) {
226
+ try {
227
+ if (typeof word !== "string") {
228
+ throw new Error("Input word must be a string.");
229
+ }
230
+
231
+ const countryCodes = new Set(["us", "usa"]);
232
+ const commonShortWords = new Set([
233
+ "the",
234
+ "in",
235
+ "to",
236
+ "from",
237
+ "against",
238
+ "with",
239
+ "within",
240
+ "towards",
241
+ "into",
242
+ "at",
243
+ ]);
244
+ const directFollowingIndicators = new Set([
245
+ "policies",
246
+ "government",
247
+ "military",
248
+ "embassy",
249
+ "administration",
250
+ "senate",
251
+ "congress",
252
+ "parliament",
253
+ "cabinet",
254
+ "federation",
255
+ "republic",
256
+ "democracy",
257
+ "law",
258
+ "act",
259
+ "treaty",
260
+ "court",
261
+ "legislation",
262
+ "statute",
263
+ "bill",
264
+ "agency",
265
+ "department",
266
+ "bureau",
267
+ "service",
268
+ "office",
269
+ "council",
270
+ "commission",
271
+ "division",
272
+ "alliance",
273
+ "union",
274
+ "confederation",
275
+ "bloc",
276
+ "zone",
277
+ "territory",
278
+ "province",
279
+ "state",
280
+ "army",
281
+ "navy",
282
+ "forces",
283
+ "marines",
284
+ "airforce",
285
+ "defense",
286
+ "intelligence",
287
+ "security",
288
+ "economy",
289
+ "budget",
290
+ "finance",
291
+ "treasury",
292
+ "trade",
293
+ "sanctions",
294
+ "aid",
295
+ "strategy",
296
+ "plan",
297
+ "policy",
298
+ "program",
299
+ "initiative",
300
+ "project",
301
+ "reform",
302
+ "relations",
303
+ "ambassador",
304
+ "diplomacy",
305
+ "summit",
306
+ "conference",
307
+ "talks",
308
+ "negotiations",
309
+ ]);
310
+
311
+ const removePunctuation = (word) => word.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
312
+
313
+ // Remove trailing punctuation from the word
314
+ const removeTrailingPunctuation = (word) => {
315
+ const match = word.match(/^(.*?)([.,\/#!$%\^&\*;:{}=\-_`~()]+)$/);
316
+ if (match && match[1]) {
317
+ return match[1];
318
+ }
319
+ return word;
320
+ };
321
+
322
+ word = word ? removePunctuation(word.toLowerCase()) : "";
323
+ word = removeTrailingPunctuation(word);
324
+
325
+ prevWord = prevWord ? removePunctuation(prevWord.toLowerCase()) : "";
326
+ nextWord = nextWord ? removePunctuation(nextWord.toLowerCase()) : "";
327
+
328
+ // Check if it's an acronym with direct following indicators
329
+ const isDirectAcronym =
330
+ countryCodes.has(word) &&
331
+ (!prevWord || commonShortWords.has(prevWord)) &&
332
+ (!nextWord || directFollowingIndicators.has(nextWord));
333
+
334
+ // Check if it's an acronym based on the previous word
335
+ const isPreviousAcronym = countryCodes.has(prevWord) && (!nextWord || directFollowingIndicators.has(nextWord));
336
+
337
+ return isDirectAcronym || isPreviousAcronym;
338
+ } catch (error) {
339
+ console.error(`An error occurred: ${error.message}`);
340
+ return false; // Return false in case of errors to indicate failure.
341
+ }
342
+ }
343
+
344
+ static checkIfWordIsAcronym(commonShortWords, prevWord, currentWord, nextWord) {
345
+ const countryCodes = ["us", "usa"];
346
+ const directPrecedingIndicators = ["the", "in", "to", "from", "against", "with", "within", "towards", "into", "at"];
347
+ const directFollowingIndicators = [
348
+ "policies",
349
+ "government",
350
+ "military",
351
+ "embassy",
352
+ "administration",
353
+ "senate",
354
+ "congress",
355
+ "parliament",
356
+ "cabinet",
357
+ "federation",
358
+ "republic",
359
+ "democracy",
360
+ "law",
361
+ "act",
362
+ "treaty",
363
+ "court",
364
+ "legislation",
365
+ "statute",
366
+ "bill",
367
+ "agency",
368
+ "department",
369
+ "bureau",
370
+ "service",
371
+ "office",
372
+ "council",
373
+ "commission",
374
+ "division",
375
+ "alliance",
376
+ "union",
377
+ "confederation",
378
+ "bloc",
379
+ "zone",
380
+ "territory",
381
+ "province",
382
+ "state",
383
+ "army",
384
+ "navy",
385
+ "forces",
386
+ "marines",
387
+ "airforce",
388
+ "defense",
389
+ "intelligence",
390
+ "security",
391
+ "economy",
392
+ "budget",
393
+ "finance",
394
+ "treasury",
395
+ "trade",
396
+ "sanctions",
397
+ "aid",
398
+ "strategy",
399
+ "plan",
400
+ "policy",
401
+ "program",
402
+ "initiative",
403
+ "project",
404
+ "reform",
405
+ "relations",
406
+ "ambassador",
407
+ "diplomacy",
408
+ "summit",
409
+ "conference",
410
+ "talks",
411
+ "negotiations",
412
+ ];
413
+
414
+ const removePunctuation = (word) => word.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, "");
415
+
416
+ currentWord = currentWord ? removePunctuation(currentWord.toLowerCase()) : "";
417
+ prevWord = prevWord ? removePunctuation(prevWord.toLowerCase()) : "";
418
+ nextWord = nextWord ? removePunctuation(nextWord.toLowerCase()) : "";
419
+
420
+ if (
421
+ countryCodes.includes(currentWord.toLowerCase()) &&
422
+ (prevWord === null || commonShortWords.includes(prevWord.toLowerCase())) &&
423
+ (nextWord === null || directFollowingIndicators.includes(nextWord.toLowerCase()))
424
+ ) {
425
+ return true;
426
+ }
427
+
428
+ return false;
429
+ }
430
+
431
+ // Check if a word has a suffix
432
+ static hasSuffix(word) {
433
+ // Test if word is longer than suffix
434
+ const suffix = "'s";
435
+ // Test if word ends with suffix
436
+ return word.length > suffix.length && word.endsWith(suffix);
437
+ }
438
+
439
+ // Check if a word has an apostrophe
440
+ static hasApostrophe(word) {
441
+ return word.indexOf("'") !== -1;
442
+ }
443
+
444
+ // Check if a word has a hyphen
445
+ static hasHyphen(word) {
446
+ return word.indexOf("-") !== -1 || word.indexOf("–") !== -1 || word.indexOf("—") !== -1;
447
+ }
448
+
449
+ // Check if a word is a Roman numeral
450
+ static hasRomanNumeral(word) {
451
+ // Check if the input is a string
452
+ if (typeof word !== "string" || word === "") {
453
+ // Throw an error if the input is not a string
454
+ throw new TypeError("Invalid input: word must be a non-empty string.");
455
+ }
456
+
457
+ // Check if the word contains an apostrophe
458
+ const hasApostrophe = word.includes("'");
459
+
460
+ // If the word has an apostrophe, split it
461
+ const wordParts = hasApostrophe ? word.split("'") : [word];
462
+
463
+ // Define a regular expression that matches a roman numeral
464
+ const romanNumeralRegex = /^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i;
465
+
466
+ // Check each part of the word
467
+ const isRomanNumeral = wordParts.every((part) => romanNumeralRegex.test(part));
468
+
469
+ return isRomanNumeral;
470
+ }
471
+
472
+ // Check if a word is a hyphenated Roman numeral
473
+ static hasHyphenRomanNumeral(word) {
474
+ if (typeof word !== "string" || word === "") {
475
+ throw new TypeError("Invalid input: word must be a non-empty string.");
476
+ }
477
+
478
+ const parts = word.split("-");
479
+ for (let i = 0; i < parts.length; i++) {
480
+ if (!TitleCaserUtils.hasRomanNumeral(parts[i])) {
481
+ return false;
482
+ }
483
+ }
484
+ return true;
485
+ }
486
+
487
+ // Check if a word has `nl2br` in it
488
+ static hasHtmlBreak(word) {
489
+ return word === "nl2br";
490
+ }
491
+
492
+ // Check if a string has Unicode symbols.
493
+ static hasUnicodeSymbols(str) {
494
+ return /[^\x00-\x7F\u00A0-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u02B0-\u02FF\u0300-\u036F\u0370-\u03FF\u0400-\u04FF\u0500-\u052F\u0530-\u058F\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u0800-\u083F\u0840-\u085F\u0860-\u087F\u0880-\u08AF\u08B0-\u08FF\u0900-\u097F\u0980-\u09FF\u0A00-\u0A7F\u0A80-\u0AFF\u0B00-\u0B7F\u0B80-\u0BFF\u0C00-\u0C7F\u0C80-\u0CFF\u0D00-\u0D7F\u0D80-\u0DFF\u0E00-\u0E7F\u0E80-\u0EFF\u0F00-\u0FFF]/.test(
495
+ str,
496
+ );
497
+ }
498
+
499
+ // Checks whether a string contains any currency symbols
500
+ static hasCurrencySymbols(str) {
501
+ return /[^\x00-\x7F\u00A0-\u00FF\u20AC\u20A0-\u20B9\u20BD\u20A1-\u20A2\u00A3-\u00A5\u058F\u060B\u09F2-\u09F3\u0AF1\u0BF9\u0E3F\u17DB\u20A6\u20A8\u20B1\u2113\u20AA-\u20AB\u20AA\u20AC-\u20AD\u20B9]/.test(
502
+ str,
503
+ );
504
+ }
505
+
506
+ // Check if a word is ampersand
507
+ static isWordAmpersand(str) {
508
+ return /&amp;|&/.test(str);
509
+ }
510
+
511
+ // Check if a word starts with a symbol
512
+ static startsWithSymbol(word) {
513
+ if (typeof word !== "string") {
514
+ throw new Error(`Parameter 'word' must be a string. Received '${typeof word}' instead.`);
515
+ }
516
+
517
+ if (word.length === 0) {
518
+ return false;
519
+ }
520
+
521
+ const firstChar = word.charAt(0);
522
+
523
+ return firstChar === "#" || firstChar === "@" || firstChar === ".";
524
+ }
525
+
526
+ static escapeSpecialCharacters(str) {
527
+ return str.replace(/[&<>"']/g, function (match) {
528
+ switch (match) {
529
+ case "&":
530
+ return "&amp;";
531
+ case "<":
532
+ return "&lt;";
533
+ case ">":
534
+ return "&gt;";
535
+ case '"':
536
+ return "&quot;";
537
+ case "'":
538
+ return "&#x27;";
539
+ default:
540
+ return match;
541
+ }
542
+ });
543
+ }
544
+
545
+ static unescapeSpecialCharacters(str) {
546
+ return str.replace(/&amp;|&lt;|&gt;|&quot;|&#x27;/g, function (match) {
547
+ switch (match) {
548
+ case "&amp;":
549
+ return "&";
550
+ case "&lt;":
551
+ return "<";
552
+ case "&gt;":
553
+ return ">";
554
+ case "&quot;":
555
+ return '"';
556
+ case "&#x27;":
557
+ return "'";
558
+ default:
559
+ return match;
560
+ }
561
+ });
562
+ }
563
+
564
+ // Check if a word ends with a symbol
565
+ static endsWithSymbol(word, symbols = [".", ",", ";", ":", "?", "!"]) {
566
+ // Check if the word is a string and the symbols is an array
567
+ if (typeof word !== "string" || !Array.isArray(symbols)) throw new Error("Invalid arguments");
568
+ // Check if the word ends with a symbol or two symbols
569
+ return symbols.some((symbol) => word.endsWith(symbol)) || symbols.includes(word.slice(-2));
570
+ }
571
+
572
+ // This function accepts two arguments: a word and an array of ignored words.
573
+ static isWordIgnored(word, ignoredWords = ignoredWordList) {
574
+ // If the ignoredWords argument is not an array, throw an error.
575
+ if (!Array.isArray(ignoredWords)) {
576
+ throw new TypeError("Invalid input: ignoredWords must be an array.");
577
+ }
578
+
579
+ // If the word argument is not a non-empty string, throw an error.
580
+ if (typeof word !== "string" || word.trim() === "") {
581
+ throw new TypeError("Invalid input: word must be a non-empty string.");
582
+ }
583
+
584
+ // Convert the word to lowercase and trim any space.
585
+ let lowercasedWord;
586
+ lowercasedWord = word.toLowerCase().trim();
587
+
588
+ // If the word is in the ignoredWords array, return true. Otherwise, return false.
589
+ return ignoredWords.includes(lowercasedWord);
590
+ }
591
+
592
+ // Check if the wordList is a valid array
593
+ static isWordInArray(targetWord, wordList) {
594
+ if (!Array.isArray(wordList)) {
595
+ return false;
596
+ }
597
+
598
+ // Check if the targetWord is in the wordList
599
+ return wordList.some((word) => word.toLowerCase() === targetWord.toLowerCase());
600
+ }
601
+
602
+ static convertQuotesToCurly(input) {
603
+ const curlyQuotes = {
604
+ "'": ["\u2018", "\u2019"],
605
+ '"': ["\u201C", "\u201D"],
606
+ };
607
+
608
+ let replacedText = "";
609
+
610
+ for (let i = 0; i < input.length; i++) {
611
+ const char = input[i];
612
+ const curlyQuotePair = curlyQuotes[char];
613
+
614
+ if (curlyQuotePair) {
615
+ const prevChar = input[i - 1];
616
+ const nextChar = input[i + 1];
617
+
618
+ // Determine whether to use left or right curly quote
619
+ const isLeftAligned = !prevChar || prevChar === " " || prevChar === "\n";
620
+ const curlyQuote = isLeftAligned ? curlyQuotePair[0] : curlyQuotePair[1];
621
+ replacedText += curlyQuote;
622
+
623
+ // Handle cases where right curly quote is followed by punctuation or space
624
+ if (curlyQuote === curlyQuotePair[1] && /[.,;!?()\[\]{}:]/.test(nextChar)) {
625
+ replacedText += nextChar;
626
+ i++; // Skip the next character
627
+ }
628
+ } else {
629
+ replacedText += char;
630
+ }
631
+ }
632
+
633
+ return replacedText;
634
+ }
635
+
636
+ // This function is used to replace a word with a term in the replaceTerms object
637
+ static replaceTerm(word, replaceTermObj) {
638
+ // Validate input
639
+ if (typeof word !== "string" || word === "") {
640
+ throw new TypeError("Invalid input: word must be a non-empty string.");
641
+ }
642
+
643
+ if (!replaceTermObj || typeof replaceTermObj !== "object") {
644
+ throw new TypeError("Invalid input: replaceTermObj must be a non-null object.");
645
+ }
646
+
647
+ // Convert the word to lowercase
648
+ let lowercasedWord;
649
+ lowercasedWord = word.toLowerCase();
650
+
651
+ // Check if the word is in the object with lowercase key
652
+ if (replaceTermObj.hasOwnProperty(lowercasedWord)) {
653
+ return replaceTermObj[lowercasedWord];
654
+ }
655
+
656
+ // Check if the word is in the object with original case key
657
+ if (replaceTermObj.hasOwnProperty(word)) {
658
+ return replaceTermObj[word];
659
+ }
660
+
661
+ // Check if the word is in the object with uppercase key
662
+ const uppercasedWord = word.toUpperCase();
663
+ if (replaceTermObj.hasOwnProperty(uppercasedWord)) {
664
+ return replaceTermObj[uppercasedWord];
665
+ }
666
+
667
+ // If the word is not in the object, return the original word
668
+ return word;
669
+ }
670
+
671
+ // This function is used to check if a suffix is present in a word that is in the correct terms list
672
+ static correctSuffix(word, correctTerms) {
673
+ // Validate input
674
+ if (typeof word !== "string" || word === "") {
675
+ throw new TypeError("Invalid input: word must be a non-empty string.");
676
+ }
677
+
678
+ if (!correctTerms || !Array.isArray(correctTerms) || correctTerms.some((term) => typeof term !== "string")) {
679
+ throw new TypeError("Invalid input: correctTerms must be an array of strings.");
680
+ }
681
+
682
+ // Define the regular expression for the suffix
683
+ const suffixRegex = /'s$/i;
684
+
685
+ // If the word ends with the suffix
686
+ if (suffixRegex.test(word)) {
687
+ // Remove the suffix from the word
688
+ const wordWithoutSuffix = word.slice(0, -2);
689
+ // Check if the word without the suffix matches any of the correct terms
690
+ const matchingIndex = correctTerms.findIndex((term) => term.toLowerCase() === wordWithoutSuffix.toLowerCase());
691
+
692
+ if (matchingIndex >= 0) {
693
+ // If it does, return the correct term with the suffix
694
+ const correctCase = correctTerms[matchingIndex];
695
+ return `${correctCase}'s`;
696
+ } else {
697
+ // If not, capitalize the first letter and append the suffix
698
+ const capitalizedWord = wordWithoutSuffix.charAt(0).toUpperCase() + wordWithoutSuffix.slice(1);
699
+ return `${capitalizedWord}'s`;
700
+ }
701
+ }
702
+
703
+ // If the word doesn't end with the suffix, return the word as-is
704
+ return word;
705
+ }
706
+
707
+ // This function is used to check if a word is in the correct terms list
708
+ static correctTerm(word, correctTerms, delimiters = /[-']/) {
709
+ // Validate input
710
+ if (typeof word !== "string" || word === "") {
711
+ throw new TypeError("Invalid input: word must be a non-empty string.");
712
+ }
713
+
714
+ if (!correctTerms || !Array.isArray(correctTerms)) {
715
+ throw new TypeError("Invalid input: correctTerms must be an array.");
716
+ }
717
+
718
+ if (typeof delimiters !== "string" && !Array.isArray(delimiters) && !(delimiters instanceof RegExp)) {
719
+ throw new TypeError("Invalid input: delimiters must be a string, an array of strings, or a regular expression.");
720
+ }
721
+
722
+ // Convert delimiters to a regular expression if it is a string or an array
723
+ if (typeof delimiters === "string") {
724
+ delimiters = new RegExp(`[${delimiters}]`);
725
+ } else if (Array.isArray(delimiters)) {
726
+ delimiters = new RegExp(`[${delimiters.join("")}]`);
727
+ }
728
+
729
+ // Split the word into parts delimited by the specified delimiters
730
+ const parts = word.split(delimiters);
731
+ // Count the number of parts
732
+ const numParts = parts.length;
733
+
734
+ // For each part
735
+ for (let i = 0; i < numParts; i++) {
736
+ // Lowercase the part
737
+ const lowercasedPart = parts[i].toLowerCase();
738
+ // Search for the part in the list of correct terms
739
+ const index = correctTerms.findIndex((t) => t.toLowerCase() === lowercasedPart);
740
+ // If the part is found in the list of correct terms
741
+ if (index >= 0) {
742
+ // Replace the part with the correct term
743
+ parts[i] = correctTerms[index];
744
+ }
745
+ }
746
+
747
+ // Join the parts back together using the first delimiter as the default delimiter
748
+ return parts.join(delimiters.source.charAt(0));
749
+ }
750
+
751
+ // This function is used to check if a word is in the correct terms list
752
+ static correctTermHyphenated(word, style) {
753
+ // Split the word into an array of words
754
+ const hyphenatedWords = word.split("-");
755
+
756
+ // Define functions to process words
757
+ const capitalizeFirst = (word) => word.charAt(0).toUpperCase() + word.slice(1);
758
+ const lowercaseRest = (word) => word.charAt(0) + word.slice(1).toLowerCase();
759
+
760
+ // Define the style-specific processing functions
761
+ const styleFunctions = {
762
+ ap: (word, index) => (index === 0 ? capitalizeFirst(word) : lowercaseRest(word)),
763
+ chicago: capitalizeFirst,
764
+ apa: (word, index, length) => {
765
+ if (TitleCaserUtils.isShortWord(word, style) && index > 0 && index < length - 1) {
766
+ return word.toLowerCase();
767
+ } else {
768
+ return capitalizeFirst(word);
769
+ }
770
+ },
771
+ nyt: (word, index) => (index === 0 ? capitalizeFirst(word) : lowercaseRest(word)),
772
+ wikipedia: (word, index) => (index === 0 ? capitalizeFirst(word) : lowercaseRest(word)),
773
+ };
774
+
775
+ // Get the style-specific processing function
776
+ const processWord = styleFunctions[style] || lowercaseRest;
777
+
778
+ // Process each word
779
+ const processedWords = hyphenatedWords.map((word, i) => {
780
+ let correctedWord = word;
781
+
782
+ const romanNumeralApostropheSRegex = /^(M{0,3})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})'s$/i;
783
+ if (romanNumeralApostropheSRegex.test(word)) {
784
+ const updatedWord = correctedWord.toUpperCase().replace(/'S$/, "'s");
785
+ // Uppercase the Roman numeral part and concatenate back with 's
786
+ return updatedWord;
787
+ }
788
+
789
+ // Check if the word is a Roman numeral
790
+ const romanNumeralRegex = /^(M{0,3})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/i;
791
+ if (romanNumeralRegex.test(word)) {
792
+ return word.toUpperCase();
793
+ }
794
+
795
+ // Preserve the original word
796
+
797
+ // Check if the word contains an apostrophe
798
+ const hasApostrophe = word.includes("'");
799
+ if (hasApostrophe) {
800
+ // Split the word at the apostrophe
801
+ const wordParts = word.split("'");
802
+ // Check each part for Roman numerals
803
+ const isRomanNumeral = wordParts.every((part) => romanNumeralRegex.test(part));
804
+ if (isRomanNumeral) {
805
+ // Uppercase each Roman numeral part and join back with apostrophe
806
+ correctedWord = wordParts.map((part) => part.toUpperCase()).join("'");
807
+ return correctedWord;
808
+ } else {
809
+ return processWord(correctedWord, i, hyphenatedWords.length);
810
+ }
811
+ }
812
+
813
+ // Check if the word is in the list of words to preserve
814
+ const lowerCaseWord = word.toLowerCase();
815
+ const uniqueTermsIndex = correctTitleCasingList.findIndex((w) => w.toLowerCase() === lowerCaseWord);
816
+ if (uniqueTermsIndex >= 0) {
817
+ correctedWord = correctTitleCasingList[uniqueTermsIndex];
818
+ }
819
+ // Check if the word is a possessive form
820
+ else if (lowerCaseWord.endsWith("'s")) {
821
+ const rootWord = lowerCaseWord.substring(0, lowerCaseWord.length - 2);
822
+ const rootWordIndex = correctTitleCasingList.findIndex((w) => w.toLowerCase() === rootWord);
823
+ if (rootWordIndex >= 0) {
824
+ correctedWord = `${correctTitleCasingList[rootWordIndex]}'s`;
825
+ }
826
+ }
827
+
828
+ // Process the word
829
+ return processWord(correctedWord, i, hyphenatedWords.length);
830
+ });
831
+
832
+ // Rejoin the words
833
+ return processedWords.join("-");
834
+ }
606
835
  }