@fibery/pluralize 1.0.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.
@@ -0,0 +1,519 @@
1
+ /* eslint-disable max-lines */
2
+ // Rule storage - pluralize and singularize need to be run sequentially,
3
+ // while other rules can be optimized using an object for instant lookups.
4
+ const pluralRules: [RegExp, string][] = [];
5
+ const singularRules: [RegExp, string][] = [];
6
+ const uncountables: Record<string, boolean> = {};
7
+ const irregularPlurals: Record<string, string> = {};
8
+ const irregularSingles: Record<string, string> = {};
9
+
10
+ /**
11
+ * Sanitize a pluralization rule to a usable regular expression.
12
+ *
13
+ * @param {(RegExp|string)} rule
14
+ * @return {RegExp}
15
+ */
16
+ function sanitizeRule(rule: string | RegExp): RegExp {
17
+ if (typeof rule === "string") {
18
+ return new RegExp("^" + rule + "$", "i");
19
+ }
20
+
21
+ return rule;
22
+ }
23
+
24
+ /**
25
+ * Pass in a word token to produce a function that can replicate the case on
26
+ * another word.
27
+ *
28
+ * @param {string} word
29
+ * @param {string} token
30
+ * @return {Function}
31
+ */
32
+ function restoreCase(word: string, token: string): string {
33
+ // Tokens are an exact match.
34
+ if (word === token) {
35
+ return token;
36
+ }
37
+
38
+ // Lower cased words. E.g. "hello".
39
+ if (word === word.toLowerCase()) {
40
+ return token.toLowerCase();
41
+ }
42
+
43
+ // Upper cased words. E.g. "WHISKY".
44
+ if (word === word.toUpperCase()) {
45
+ return token.toUpperCase();
46
+ }
47
+
48
+ // Title cased words. E.g. "Title".
49
+ if (word[0] === word[0].toUpperCase()) {
50
+ return token.charAt(0).toUpperCase() + token.substr(1).toLowerCase();
51
+ }
52
+
53
+ // Lower cased words. E.g. "test".
54
+ return token.toLowerCase();
55
+ }
56
+
57
+ /**
58
+ * Interpolate a regexp string.
59
+ *
60
+ * @param {string} str
61
+ * @param {Array} args
62
+ * @return {string}
63
+ */
64
+ function interpolate(str: string, args: string[]): string {
65
+ return str.replace(/\$(\d{1,2})/g, function (match, index) {
66
+ return args[index] || "";
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Replace a word using a rule.
72
+ *
73
+ * @param {string} word
74
+ * @param {Array} rule
75
+ * @return {string}
76
+ */
77
+ function replace(word: string, rule: [RegExp, string]): string {
78
+ return word.replace(rule[0], function (match, index) {
79
+ // eslint-disable-next-line prefer-rest-params
80
+ const result = interpolate(rule[1], arguments as unknown as string[]);
81
+
82
+ if (match === "") {
83
+ return restoreCase(word[index - 1], result);
84
+ }
85
+
86
+ return restoreCase(match, result);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Sanitize a word by passing in the word and sanitization rules.
92
+ *
93
+ * @param {string} token
94
+ * @param {string} word
95
+ * @param {Array} rules
96
+ * @return {string}
97
+ */
98
+ function sanitizeWord(token: string, word: string, rules: [RegExp, string][]): string {
99
+ // Empty string or doesn't need fixing.
100
+ if (!token.length || Object.keys(uncountables).some((key) => token.includes(key))) {
101
+ return word;
102
+ }
103
+
104
+ let len = rules.length;
105
+
106
+ // Iterate over the sanitization rules and use the first one to match.
107
+ while (len--) {
108
+ const rule = rules[len];
109
+
110
+ if (rule[0].test(word)) {
111
+ return replace(word, rule);
112
+ }
113
+ }
114
+
115
+ return word;
116
+ }
117
+
118
+ /**
119
+ * Replace a word with the updated word.
120
+ *
121
+ * @param {Object} replaceMap
122
+ * @param {Object} keepMap
123
+ * @param {Array} rules
124
+ * @return {Function}
125
+ */
126
+ function replaceWord(
127
+ replaceMap: Record<string, string>,
128
+ keepMap: Record<string, string>,
129
+ rules: [RegExp, string][]
130
+ ): (word: string) => string {
131
+ return function (word) {
132
+ // Get the correct token and case restoration functions.
133
+ const token = word.toLowerCase();
134
+
135
+ // Check against the keep object map.
136
+ if (Object.hasOwn(keepMap, token)) {
137
+ return restoreCase(word, token);
138
+ }
139
+
140
+ // Check against the replacement map for a direct word replacement.
141
+ if (Object.hasOwn(replaceMap, token)) {
142
+ return restoreCase(word, replaceMap[token]);
143
+ }
144
+
145
+ // Run all the rules against the word.
146
+ return sanitizeWord(token, word, rules);
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Check if a word is part of the map.
152
+ */
153
+ function checkWord(
154
+ replaceMap: Record<string, string>,
155
+ keepMap: Record<string, string>,
156
+ rules: [RegExp, string][]
157
+ ): (word: string) => boolean {
158
+ return function (word) {
159
+ const token = word.toLowerCase();
160
+
161
+ if (Object.hasOwn(keepMap, token)) {
162
+ return true;
163
+ }
164
+ if (Object.hasOwn(replaceMap, token)) {
165
+ return false;
166
+ }
167
+
168
+ return sanitizeWord(token, token, rules) === token;
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Pluralize or singularize a word based on the passed in count.
174
+ *
175
+ * @param {string} word The word to pluralize
176
+ * @param {number} count How many of the word exist
177
+ * @param {boolean} inclusive Whether to prefix with the number (e.g. 3 ducks)
178
+ * @return {string}
179
+ */
180
+ export function pluralize(word: string, count?: number, inclusive?: boolean): string {
181
+ if (count === undefined) {
182
+ return pluralize.plural(word);
183
+ }
184
+ const pluralized = count === 1 ? pluralize.singular(word) : pluralize.plural(word);
185
+
186
+ return (inclusive ? count + " " : "") + pluralized;
187
+ }
188
+
189
+ /**
190
+ * Pluralize a word.
191
+ *
192
+ * @type {Function}
193
+ */
194
+ pluralize.plural = replaceWord(irregularSingles, irregularPlurals, pluralRules);
195
+
196
+ /**
197
+ * Check if a word is plural.
198
+ *
199
+ * @type {Function}
200
+ */
201
+ pluralize.isPlural = checkWord(irregularSingles, irregularPlurals, pluralRules);
202
+
203
+ /**
204
+ * Singularize a word.
205
+ *
206
+ * @type {Function}
207
+ */
208
+ pluralize.singular = replaceWord(irregularPlurals, irregularSingles, singularRules);
209
+
210
+ /**
211
+ * Check if a word is singular.
212
+ *
213
+ * @type {Function}
214
+ */
215
+ pluralize.isSingular = checkWord(irregularPlurals, irregularSingles, singularRules);
216
+
217
+ /**
218
+ * Add a pluralization rule to the collection.
219
+ *
220
+ * @param {(string|RegExp)} rule
221
+ * @param {string} replacement
222
+ */
223
+ pluralize.addPluralRule = function (rule: string | RegExp, replacement: string) {
224
+ pluralRules.push([sanitizeRule(rule), replacement]);
225
+ };
226
+
227
+ /**
228
+ * Add a singularization rule to the collection.
229
+ *
230
+ * @param {(string|RegExp)} rule
231
+ * @param {string} replacement
232
+ */
233
+ pluralize.addSingularRule = function (rule: string | RegExp, replacement: string) {
234
+ singularRules.push([sanitizeRule(rule), replacement]);
235
+ };
236
+
237
+ /**
238
+ * Add an uncountable word rule.
239
+ *
240
+ * @param {(string|RegExp)} word
241
+ */
242
+ pluralize.addUncountableRule = function (word: string | RegExp) {
243
+ if (typeof word === "string") {
244
+ uncountables[word.toLowerCase()] = true;
245
+ return;
246
+ }
247
+
248
+ // Set singular and plural references for the word.
249
+ pluralize.addPluralRule(word, "$0");
250
+ pluralize.addSingularRule(word, "$0");
251
+ };
252
+
253
+ /**
254
+ * Add an irregular word definition.
255
+ *
256
+ * @param {string} single
257
+ * @param {string} plural
258
+ */
259
+ pluralize.addIrregularRule = function (single: string, plural: string) {
260
+ // eslint-disable-next-line no-param-reassign
261
+ plural = plural.toLowerCase();
262
+ // eslint-disable-next-line no-param-reassign
263
+ single = single.toLowerCase();
264
+
265
+ irregularSingles[single] = plural;
266
+ irregularPlurals[plural] = single;
267
+ };
268
+
269
+ /**
270
+ * Irregular rules.
271
+ */
272
+ (
273
+ [
274
+ // Pronouns.
275
+ ["I", "we"],
276
+ ["me", "us"],
277
+ ["he", "they"],
278
+ ["she", "they"],
279
+ ["them", "them"],
280
+ ["myself", "ourselves"],
281
+ ["yourself", "yourselves"],
282
+ ["itself", "themselves"],
283
+ ["herself", "themselves"],
284
+ ["himself", "themselves"],
285
+ ["themself", "themselves"],
286
+ ["is", "are"],
287
+ ["was", "were"],
288
+ ["has", "have"],
289
+ ["this", "these"],
290
+ ["that", "those"],
291
+ ["my", "our"],
292
+ ["its", "their"],
293
+ ["his", "their"],
294
+ ["her", "their"],
295
+ // Words ending in with a consonant and `o`.
296
+ ["echo", "echoes"],
297
+ ["dingo", "dingoes"],
298
+ ["volcano", "volcanoes"],
299
+ ["tornado", "tornadoes"],
300
+ ["torpedo", "torpedoes"],
301
+ // Ends with `us`.
302
+ ["genus", "genera"],
303
+ ["viscus", "viscera"],
304
+ // Ends with `ma`.
305
+ ["stigma", "stigmata"],
306
+ ["stoma", "stomata"],
307
+ ["dogma", "dogmata"],
308
+ ["lemma", "lemmata"],
309
+ ["schema", "schemata"],
310
+ ["anathema", "anathemata"],
311
+ // Other irregular rules.
312
+ ["ox", "oxen"],
313
+ ["axe", "axes"],
314
+ ["die", "dice"],
315
+ ["yes", "yeses"],
316
+ ["foot", "feet"],
317
+ ["eave", "eaves"],
318
+ ["goose", "geese"],
319
+ ["tooth", "teeth"],
320
+ ["quiz", "quizzes"],
321
+ ["human", "humans"],
322
+ ["proof", "proofs"],
323
+ ["carve", "carves"],
324
+ ["valve", "valves"],
325
+ ["looey", "looies"],
326
+ ["thief", "thieves"],
327
+ ["groove", "grooves"],
328
+ ["pickaxe", "pickaxes"],
329
+ ["passerby", "passersby"],
330
+ ["canvas", "canvases"],
331
+ ] as const
332
+ ).forEach(function (rule) {
333
+ return pluralize.addIrregularRule(rule[0], rule[1]);
334
+ });
335
+
336
+ /**
337
+ * Pluralization rules.
338
+ */
339
+ (
340
+ [
341
+ [/s?$/i, "s"],
342
+ // eslint-disable-next-line no-control-regex
343
+ [/[^\u0000-\u007F]$/i, "$0"],
344
+ [/([^aeiou]ese)$/i, "$1"],
345
+ [/(ax|test)is$/i, "$1es"],
346
+ [/(alias|[^aou]us|t[lm]as|gas|ris)$/i, "$1es"],
347
+ [/(e[mn]u)s?$/i, "$1s"],
348
+ [/([^l]ias|[aeiou]las|[ejzr]as|[iu]am)$/i, "$1"],
349
+ [/(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$/i, "$1i"],
350
+ [/(alumn|alg|vertebr)(?:a|ae)$/i, "$1ae"],
351
+ [/(seraph|cherub)(?:im)?$/i, "$1im"],
352
+ [/(her|at|gr)o$/i, "$1oes"],
353
+ [
354
+ /(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|automat|quor)(?:a|um)$/i,
355
+ "$1a",
356
+ ],
357
+ [/(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)(?:a|on)$/i, "$1a"],
358
+ [/sis$/i, "ses"],
359
+ [/(?:(kni|wi|li)fe|(ar|l|ea|eo|oa|hoo)f)$/i, "$1$2ves"],
360
+ [/([^aeiouy]|qu)y$/i, "$1ies"],
361
+ [/([^ch][ieo][ln])ey$/i, "$1ies"],
362
+ [/(x|ch|ss|sh|zz)$/i, "$1es"],
363
+ [/(matr|cod|mur|sil|vert|ind|append)(?:ix|ex)$/i, "$1ices"],
364
+ [/\b((?:tit)?m|l)(?:ice|ouse)$/i, "$1ice"],
365
+ [/(pe)(?:rson|ople)$/i, "$1ople"],
366
+ [/(child)(?:ren)?$/i, "$1ren"],
367
+ [/eaux$/i, "$0"],
368
+ [/m[ae]n$/i, "men"],
369
+ ["thou", "you"],
370
+ ] as const
371
+ ).forEach(function (rule) {
372
+ return pluralize.addPluralRule(rule[0], rule[1]);
373
+ });
374
+
375
+ /**
376
+ * Singularization rules.
377
+ */
378
+ (
379
+ [
380
+ [/s$/i, ""],
381
+ [/(ss)$/i, "$1"],
382
+ [/(wi|kni|(?:after|half|high|low|mid|non|night|[^\w]|^)li)ves$/i, "$1fe"],
383
+ [/(ar|(?:wo|[ae])l|[eo][ao])ves$/i, "$1f"],
384
+ [/ies$/i, "y"],
385
+ [/(dg|ss|ois|lk|ok|wn|mb|th|ch|ec|oal|is|ck|ix|sser|ts|wb)ies$/i, "$1ie"],
386
+ [
387
+ /\b(l|(?:neck|cross|hog|aun)?t|coll|faer|food|gen|goon|group|hipp|junk|vegg|(?:pork)?p|charl|calor|cut)ies$/i,
388
+ "$1ie",
389
+ ],
390
+ [/\b(mon|smil)ies$/i, "$1ey"],
391
+ [/\b((?:tit)?m|l)ice$/i, "$1ouse"],
392
+ [/(seraph|cherub)im$/i, "$1"],
393
+ [/(x|ch|ss|sh|zz|tto|go|cho|alias|[^aou]us|t[lm]as|gas|(?:her|at|gr)o|[aeiou]ris)(?:es)?$/i, "$1"],
394
+ [/(analy|diagno|parenthe|progno|synop|the|empha|cri|ne)(?:sis|ses)$/i, "$1sis"],
395
+ [/(movie|twelve|abuse|e[mn]u)s$/i, "$1"],
396
+ [/(test)(?:is|es)$/i, "$1is"],
397
+ [/(alumn|syllab|vir|radi|nucle|fung|cact|stimul|termin|bacill|foc|uter|loc|strat)(?:us|i)$/i, "$1us"],
398
+ [/(agend|addend|millenni|dat|extrem|bacteri|desiderat|strat|candelabr|errat|ov|symposi|curricul|quor)a$/i, "$1um"],
399
+ [/(apheli|hyperbat|periheli|asyndet|noumen|phenomen|criteri|organ|prolegomen|hedr|automat)a$/i, "$1on"],
400
+ [/(alumn|alg|vertebr)ae$/i, "$1a"],
401
+ [/(cod|mur|sil|vert|ind)ices$/i, "$1ex"],
402
+ [/(matr|append)ices$/i, "$1ix"],
403
+ [/(pe)(rson|ople)$/i, "$1rson"],
404
+ [/(child)ren$/i, "$1"],
405
+ [/(eau)x?$/i, "$1"],
406
+ [/men$/i, "man"],
407
+ ] as const
408
+ ).forEach(function (rule) {
409
+ return pluralize.addSingularRule(rule[0], rule[1]);
410
+ });
411
+
412
+ /**
413
+ * Uncountable rules.
414
+ */
415
+ [
416
+ // Singular words with no plurals.
417
+ "adulthood",
418
+ "advice",
419
+ "agenda",
420
+ "aid",
421
+ "aircraft",
422
+ "alcohol",
423
+ "ammo",
424
+ "analytics",
425
+ "anime",
426
+ "athletics",
427
+ "audio",
428
+ "bison",
429
+ "blood",
430
+ "bream",
431
+ "buffalo",
432
+ "butter",
433
+ "carp",
434
+ "cash",
435
+ "chassis",
436
+ "chess",
437
+ "clothing",
438
+ "cod",
439
+ "commerce",
440
+ "cooperation",
441
+ "corps",
442
+ "debris",
443
+ "diabetes",
444
+ "digestion",
445
+ "elk",
446
+ "energy",
447
+ "equipment",
448
+ "excretion",
449
+ "expertise",
450
+ "firmware",
451
+ "flounder",
452
+ "fun",
453
+ "gallows",
454
+ "garbage",
455
+ "graffiti",
456
+ "hardware",
457
+ "headquarters",
458
+ "health",
459
+ "herpes",
460
+ "highjinks",
461
+ "homework",
462
+ "housework",
463
+ "information",
464
+ "jeans",
465
+ "justice",
466
+ "kudos",
467
+ "labour",
468
+ "literature",
469
+ "machinery",
470
+ "mackerel",
471
+ "mail",
472
+ "media",
473
+ "mews",
474
+ "moose",
475
+ "music",
476
+ "mud",
477
+ "manga",
478
+ "news",
479
+ "only",
480
+ "personnel",
481
+ "pike",
482
+ "plankton",
483
+ "pliers",
484
+ "police",
485
+ "pollution",
486
+ "premises",
487
+ "rain",
488
+ "research",
489
+ "rice",
490
+ "salmon",
491
+ "scissors",
492
+ "series",
493
+ "sewage",
494
+ "shambles",
495
+ "shrimp",
496
+ "software",
497
+ "staff",
498
+ "swine",
499
+ "tennis",
500
+ "traffic",
501
+ "transportation",
502
+ "trout",
503
+ "tuna",
504
+ "wealth",
505
+ "welfare",
506
+ "whiting",
507
+ "wildebeest",
508
+ "wildlife",
509
+ "you",
510
+ /pok[eé]mon$/i,
511
+ // Regexes.
512
+ /[^aeiou]ese$/i, // "chinese", "japanese"
513
+ /deer$/i, // "deer", "reindeer"
514
+ /fish$/i, // "fish", "blowfish", "angelfish"
515
+ /measles$/i,
516
+ /o[iu]s$/i, // "carnivorous"
517
+ /pox$/i, // "chickpox", "smallpox"
518
+ /sheep$/i,
519
+ ].forEach(pluralize.addUncountableRule);
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "rootDir": ".",
6
+ "outDir": "./lib",
7
+ "target": "ESNext",
8
+ "module": "Node16",
9
+ "moduleResolution": "Node16",
10
+ "noEmit": false,
11
+ "declaration": true
12
+ },
13
+ "include": ["."],
14
+ "exclude": ["node_modules", "lib", "**/*.test.ts", "**/*.test.js", "babel.config.js"]
15
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["."]
4
+ }