UNPKG

3.47 kBJavaScriptView Raw
1import escapeStringRegexp from 'escape-string-regexp';
2import transliterate from '@sindresorhus/transliterate';
3import builtinOverridableReplacements from './overridable-replacements.js';
4
5const decamelize = string => {
6 return string
7 // Separate capitalized words.
8 .replace(/([A-Z]{2,})(\d+)/g, '$1 $2')
9 .replace(/([a-z\d]+)([A-Z]{2,})/g, '$1 $2')
10
11 .replace(/([a-z\d])([A-Z])/g, '$1 $2')
12 // `[a-rt-z]` matches all lowercase characters except `s`.
13 // This avoids matching plural acronyms like `APIs`.
14 .replace(/([A-Z]+)([A-Z][a-rt-z\d]+)/g, '$1 $2');
15};
16
17const removeMootSeparators = (string, separator) => {
18 const escapedSeparator = escapeStringRegexp(separator);
19
20 return string
21 .replace(new RegExp(`${escapedSeparator}{2,}`, 'g'), separator)
22 .replace(new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'), '');
23};
24
25const buildPatternSlug = options => {
26 let negationSetPattern = 'a-z\\d';
27 negationSetPattern += options.lowercase ? '' : 'A-Z';
28
29 if (options.preserveCharacters.length > 0) {
30 for (const character of options.preserveCharacters) {
31 if (character === options.separator) {
32 throw new Error(`The separator character \`${options.separator}\` cannot be included in preserved characters: ${options.preserveCharacters}`);
33 }
34
35 negationSetPattern += escapeStringRegexp(character);
36 }
37 }
38
39 return new RegExp(`[^${negationSetPattern}]+`, 'g');
40};
41
42export default function slugify(string, options) {
43 if (typeof string !== 'string') {
44 throw new TypeError(`Expected a string, got \`${typeof string}\``);
45 }
46
47 options = {
48 separator: '-',
49 lowercase: true,
50 decamelize: true,
51 customReplacements: [],
52 preserveLeadingUnderscore: false,
53 preserveTrailingDash: false,
54 preserveCharacters: [],
55 ...options
56 };
57
58 const shouldPrependUnderscore = options.preserveLeadingUnderscore && string.startsWith('_');
59 const shouldAppendDash = options.preserveTrailingDash && string.endsWith('-');
60
61 const customReplacements = new Map([
62 ...builtinOverridableReplacements,
63 ...options.customReplacements
64 ]);
65
66 string = transliterate(string, {customReplacements});
67
68 if (options.decamelize) {
69 string = decamelize(string);
70 }
71
72 const patternSlug = buildPatternSlug(options);
73
74 if (options.lowercase) {
75 string = string.toLowerCase();
76 }
77
78 // Detect contractions/possessives by looking for any word followed by a `'t`
79 // or `'s` in isolation and then remove it.
80 string = string.replace(/([a-zA-Z\d]+)'([ts])(\s|$)/g, '$1$2$3');
81
82 string = string.replace(patternSlug, options.separator);
83 string = string.replace(/\\/g, '');
84
85 if (options.separator) {
86 string = removeMootSeparators(string, options.separator);
87 }
88
89 if (shouldPrependUnderscore) {
90 string = `_${string}`;
91 }
92
93 if (shouldAppendDash) {
94 string = `${string}-`;
95 }
96
97 return string;
98}
99
100export function slugifyWithCounter() {
101 const occurrences = new Map();
102
103 const countable = (string, options) => {
104 string = slugify(string, options);
105
106 if (!string) {
107 return '';
108 }
109
110 const stringLower = string.toLowerCase();
111 const numberless = occurrences.get(stringLower.replace(/(?:-\d+?)+?$/, '')) || 0;
112 const counter = occurrences.get(stringLower);
113 occurrences.set(stringLower, typeof counter === 'number' ? counter + 1 : 1);
114 const newCounter = occurrences.get(stringLower) || 2;
115 if (newCounter >= 2 || numberless > 2) {
116 string = `${string}-${newCounter}`;
117 }
118
119 return string;
120 };
121
122 countable.reset = () => {
123 occurrences.clear();
124 };
125
126 return countable;
127}