namespace-guard 0.4.0 → 0.6.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/README.md +44 -12
- package/dist/cli.js +263 -14
- package/dist/cli.mjs +263 -14
- package/dist/index.d.mts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +268 -20
- package/dist/index.mjs +268 -20
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -13,29 +13,51 @@ var DEFAULT_MESSAGES = {
|
|
|
13
13
|
};
|
|
14
14
|
function extractMaxLength(pattern) {
|
|
15
15
|
const testStrings = ["a", "1", "a1", "a-1"];
|
|
16
|
-
|
|
16
|
+
let lo = 1;
|
|
17
|
+
let hi = 100;
|
|
18
|
+
let best = 30;
|
|
19
|
+
while (lo <= hi) {
|
|
20
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
21
|
+
let anyMatch = false;
|
|
17
22
|
for (const chars of testStrings) {
|
|
18
|
-
const s = chars.repeat(Math.ceil(
|
|
19
|
-
if (pattern.test(s))
|
|
23
|
+
const s = chars.repeat(Math.ceil(mid / chars.length)).slice(0, mid);
|
|
24
|
+
if (pattern.test(s)) {
|
|
25
|
+
anyMatch = true;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (anyMatch) {
|
|
30
|
+
best = mid;
|
|
31
|
+
lo = mid + 1;
|
|
32
|
+
} else {
|
|
33
|
+
hi = mid - 1;
|
|
20
34
|
}
|
|
21
35
|
}
|
|
22
|
-
return
|
|
36
|
+
return best;
|
|
23
37
|
}
|
|
24
38
|
function createDefaultSuggest(pattern) {
|
|
25
39
|
const maxLen = extractMaxLength(pattern);
|
|
26
40
|
return (identifier) => {
|
|
41
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27
42
|
const candidates = [];
|
|
28
43
|
for (let i = 1; i <= 9; i++) {
|
|
29
44
|
const hyphenated = `${identifier}-${i}`;
|
|
30
|
-
if (hyphenated.length <= maxLen)
|
|
45
|
+
if (hyphenated.length <= maxLen) {
|
|
46
|
+
seen.add(hyphenated);
|
|
47
|
+
candidates.push(hyphenated);
|
|
48
|
+
}
|
|
31
49
|
const compact = `${identifier}${i}`;
|
|
32
|
-
if (compact.length <= maxLen)
|
|
50
|
+
if (compact.length <= maxLen) {
|
|
51
|
+
seen.add(compact);
|
|
52
|
+
candidates.push(compact);
|
|
53
|
+
}
|
|
33
54
|
}
|
|
34
55
|
if (identifier.length >= maxLen - 1) {
|
|
35
56
|
for (let i = 1; i <= 9; i++) {
|
|
36
57
|
const suffix = String(i);
|
|
37
58
|
const truncated = identifier.slice(0, maxLen - suffix.length) + suffix;
|
|
38
|
-
if (truncated !== identifier && !
|
|
59
|
+
if (truncated !== identifier && !seen.has(truncated)) {
|
|
60
|
+
seen.add(truncated);
|
|
39
61
|
candidates.push(truncated);
|
|
40
62
|
}
|
|
41
63
|
}
|
|
@@ -43,6 +65,184 @@ function createDefaultSuggest(pattern) {
|
|
|
43
65
|
return candidates;
|
|
44
66
|
};
|
|
45
67
|
}
|
|
68
|
+
var SUFFIX_WORDS = ["dev", "io", "app", "hq", "pro", "team", "labs", "hub", "go", "one"];
|
|
69
|
+
function createRandomDigitsStrategy(pattern) {
|
|
70
|
+
const maxLen = extractMaxLength(pattern);
|
|
71
|
+
return (identifier) => {
|
|
72
|
+
const seen = /* @__PURE__ */ new Set();
|
|
73
|
+
const candidates = [];
|
|
74
|
+
for (let i = 0; i < 15; i++) {
|
|
75
|
+
const digits = String(Math.floor(100 + Math.random() * 9900));
|
|
76
|
+
const candidate = `${identifier}-${digits}`;
|
|
77
|
+
if (candidate.length <= maxLen && !seen.has(candidate)) {
|
|
78
|
+
seen.add(candidate);
|
|
79
|
+
candidates.push(candidate);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return candidates;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function createSuffixWordsStrategy(pattern) {
|
|
86
|
+
const maxLen = extractMaxLength(pattern);
|
|
87
|
+
return (identifier) => {
|
|
88
|
+
const candidates = [];
|
|
89
|
+
for (const word of SUFFIX_WORDS) {
|
|
90
|
+
const candidate = `${identifier}-${word}`;
|
|
91
|
+
if (candidate.length <= maxLen) {
|
|
92
|
+
candidates.push(candidate);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return candidates;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function createShortRandomStrategy(pattern) {
|
|
99
|
+
const maxLen = extractMaxLength(pattern);
|
|
100
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
101
|
+
return (identifier) => {
|
|
102
|
+
const seen = /* @__PURE__ */ new Set();
|
|
103
|
+
const candidates = [];
|
|
104
|
+
for (let i = 0; i < 10; i++) {
|
|
105
|
+
let suffix = "";
|
|
106
|
+
for (let j = 0; j < 3; j++) {
|
|
107
|
+
suffix += chars[Math.floor(Math.random() * chars.length)];
|
|
108
|
+
}
|
|
109
|
+
const candidate = `${identifier}-${suffix}`;
|
|
110
|
+
if (candidate.length <= maxLen && !seen.has(candidate)) {
|
|
111
|
+
seen.add(candidate);
|
|
112
|
+
candidates.push(candidate);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return candidates;
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function createScrambleStrategy(_pattern) {
|
|
119
|
+
return (identifier) => {
|
|
120
|
+
const seen = /* @__PURE__ */ new Set();
|
|
121
|
+
const candidates = [];
|
|
122
|
+
const chars = identifier.split("");
|
|
123
|
+
for (let i = 0; i < chars.length - 1; i++) {
|
|
124
|
+
if (chars[i] !== chars[i + 1]) {
|
|
125
|
+
const swapped = [...chars];
|
|
126
|
+
[swapped[i], swapped[i + 1]] = [swapped[i + 1], swapped[i]];
|
|
127
|
+
const candidate = swapped.join("");
|
|
128
|
+
if (candidate !== identifier && !seen.has(candidate)) {
|
|
129
|
+
seen.add(candidate);
|
|
130
|
+
candidates.push(candidate);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return candidates;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function createSimilarStrategy(pattern) {
|
|
138
|
+
const maxLen = extractMaxLength(pattern);
|
|
139
|
+
const nearby = {
|
|
140
|
+
a: "sqwz",
|
|
141
|
+
b: "vngh",
|
|
142
|
+
c: "xdfv",
|
|
143
|
+
d: "sfce",
|
|
144
|
+
e: "wrd",
|
|
145
|
+
f: "dgcv",
|
|
146
|
+
g: "fhtb",
|
|
147
|
+
h: "gjyn",
|
|
148
|
+
i: "uko",
|
|
149
|
+
j: "hknm",
|
|
150
|
+
k: "jli",
|
|
151
|
+
l: "kop",
|
|
152
|
+
m: "njk",
|
|
153
|
+
n: "bmhj",
|
|
154
|
+
o: "ipl",
|
|
155
|
+
p: "ol",
|
|
156
|
+
q: "wa",
|
|
157
|
+
r: "eft",
|
|
158
|
+
s: "adwz",
|
|
159
|
+
t: "rgy",
|
|
160
|
+
u: "yij",
|
|
161
|
+
v: "cfgb",
|
|
162
|
+
w: "qase",
|
|
163
|
+
x: "zsdc",
|
|
164
|
+
y: "tuh",
|
|
165
|
+
z: "xas",
|
|
166
|
+
"0": "19",
|
|
167
|
+
"1": "02",
|
|
168
|
+
"2": "13",
|
|
169
|
+
"3": "24",
|
|
170
|
+
"4": "35",
|
|
171
|
+
"5": "46",
|
|
172
|
+
"6": "57",
|
|
173
|
+
"7": "68",
|
|
174
|
+
"8": "79",
|
|
175
|
+
"9": "80"
|
|
176
|
+
};
|
|
177
|
+
const prefixes = ["the", "my", "x", "i"];
|
|
178
|
+
const suffixes = ["x", "o", "i", "z"];
|
|
179
|
+
return (identifier) => {
|
|
180
|
+
const seen = /* @__PURE__ */ new Set();
|
|
181
|
+
const candidates = [];
|
|
182
|
+
function add(c) {
|
|
183
|
+
if (c.length >= 2 && c.length <= maxLen && c !== identifier && pattern.test(c) && !seen.has(c)) {
|
|
184
|
+
seen.add(c);
|
|
185
|
+
candidates.push(c);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (let i = 0; i < identifier.length; i++) {
|
|
189
|
+
add(identifier.slice(0, i) + identifier.slice(i + 1));
|
|
190
|
+
}
|
|
191
|
+
for (let i = 0; i < identifier.length; i++) {
|
|
192
|
+
const ch = identifier[i];
|
|
193
|
+
const neighbours = nearby[ch] ?? "";
|
|
194
|
+
for (const n of neighbours) {
|
|
195
|
+
add(identifier.slice(0, i) + n + identifier.slice(i + 1));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const p of prefixes) {
|
|
199
|
+
add(p + identifier);
|
|
200
|
+
}
|
|
201
|
+
for (const s of suffixes) {
|
|
202
|
+
add(identifier + s);
|
|
203
|
+
}
|
|
204
|
+
return candidates;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function createStrategy(name, pattern) {
|
|
208
|
+
switch (name) {
|
|
209
|
+
case "sequential":
|
|
210
|
+
return createDefaultSuggest(pattern);
|
|
211
|
+
case "random-digits":
|
|
212
|
+
return createRandomDigitsStrategy(pattern);
|
|
213
|
+
case "suffix-words":
|
|
214
|
+
return createSuffixWordsStrategy(pattern);
|
|
215
|
+
case "short-random":
|
|
216
|
+
return createShortRandomStrategy(pattern);
|
|
217
|
+
case "scramble":
|
|
218
|
+
return createScrambleStrategy(pattern);
|
|
219
|
+
case "similar":
|
|
220
|
+
return createSimilarStrategy(pattern);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function resolveGenerator(suggest, pattern) {
|
|
224
|
+
if (suggest?.generate) return suggest.generate;
|
|
225
|
+
const strategyInput = suggest?.strategy ?? ["sequential", "random-digits"];
|
|
226
|
+
if (typeof strategyInput === "function") return strategyInput;
|
|
227
|
+
const names = Array.isArray(strategyInput) ? strategyInput : [strategyInput];
|
|
228
|
+
const generators = names.map((name) => createStrategy(name, pattern));
|
|
229
|
+
if (generators.length === 1) return generators[0];
|
|
230
|
+
return (identifier) => {
|
|
231
|
+
const lists = generators.map((g) => g(identifier));
|
|
232
|
+
const seen = /* @__PURE__ */ new Set();
|
|
233
|
+
const result = [];
|
|
234
|
+
const maxListLen = Math.max(...lists.map((l) => l.length));
|
|
235
|
+
for (let i = 0; i < maxListLen; i++) {
|
|
236
|
+
for (const list of lists) {
|
|
237
|
+
if (i < list.length && !seen.has(list[i])) {
|
|
238
|
+
seen.add(list[i]);
|
|
239
|
+
result.push(list[i]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
};
|
|
245
|
+
}
|
|
46
246
|
function normalize(raw) {
|
|
47
247
|
return raw.trim().toLowerCase().replace(/^@+/, "");
|
|
48
248
|
}
|
|
@@ -81,6 +281,8 @@ function createNamespaceGuard(config, adapter) {
|
|
|
81
281
|
const cached = cacheMap.get(key);
|
|
82
282
|
if (cached && cached.expires > now) {
|
|
83
283
|
cacheHits++;
|
|
284
|
+
cacheMap.delete(key);
|
|
285
|
+
cacheMap.set(key, cached);
|
|
84
286
|
return cached.promise;
|
|
85
287
|
}
|
|
86
288
|
cacheMisses++;
|
|
@@ -108,6 +310,24 @@ function createNamespaceGuard(config, adapter) {
|
|
|
108
310
|
}
|
|
109
311
|
return null;
|
|
110
312
|
}
|
|
313
|
+
async function checkDbOnly(value, scope) {
|
|
314
|
+
const findOptions = config.caseInsensitive ? { caseInsensitive: true } : void 0;
|
|
315
|
+
const checks = config.sources.map(async (source) => {
|
|
316
|
+
const existing = await cachedFindOne(source, value, findOptions);
|
|
317
|
+
if (!existing) return null;
|
|
318
|
+
if (source.scopeKey) {
|
|
319
|
+
const scopeValue = scope[source.scopeKey];
|
|
320
|
+
const idColumn = source.idColumn ?? "id";
|
|
321
|
+
const existingId = existing[idColumn];
|
|
322
|
+
if (scopeValue && existingId && scopeValue === String(existingId)) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return source.name;
|
|
327
|
+
});
|
|
328
|
+
const results = await Promise.all(checks);
|
|
329
|
+
return !results.some((r) => r !== null);
|
|
330
|
+
}
|
|
111
331
|
async function check(identifier, scope = {}, options) {
|
|
112
332
|
const normalized = normalize(identifier);
|
|
113
333
|
if (!pattern.test(normalized)) {
|
|
@@ -157,16 +377,45 @@ function createNamespaceGuard(config, adapter) {
|
|
|
157
377
|
source: collision
|
|
158
378
|
};
|
|
159
379
|
if (config.suggest && !options?.skipSuggestions) {
|
|
160
|
-
const generate = config.suggest
|
|
380
|
+
const generate = resolveGenerator(config.suggest, pattern);
|
|
161
381
|
const max = config.suggest.max ?? 3;
|
|
162
382
|
const candidates = generate(normalized);
|
|
163
383
|
const suggestions = [];
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
384
|
+
const passedSync = candidates.filter(
|
|
385
|
+
(c) => pattern.test(c) && !reservedMap.has(c)
|
|
386
|
+
);
|
|
387
|
+
for (let i = 0; i < passedSync.length && suggestions.length < max; i += max) {
|
|
388
|
+
const batch = passedSync.slice(i, i + max);
|
|
389
|
+
let validated = batch;
|
|
390
|
+
if (validators.length > 0) {
|
|
391
|
+
const validationResults = await Promise.all(
|
|
392
|
+
batch.map(async (c) => {
|
|
393
|
+
for (const validator of validators) {
|
|
394
|
+
try {
|
|
395
|
+
const rejection = await validator(c);
|
|
396
|
+
if (rejection) return null;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return c;
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
validated = validationResults.filter(
|
|
405
|
+
(c) => c !== null
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (validated.length > 0) {
|
|
409
|
+
const dbResults = await Promise.all(
|
|
410
|
+
validated.map(async (c) => ({
|
|
411
|
+
candidate: c,
|
|
412
|
+
available: await checkDbOnly(c, scope)
|
|
413
|
+
}))
|
|
414
|
+
);
|
|
415
|
+
for (const { candidate, available } of dbResults) {
|
|
416
|
+
if (suggestions.length >= max) break;
|
|
417
|
+
if (available) suggestions.push(candidate);
|
|
418
|
+
}
|
|
170
419
|
}
|
|
171
420
|
}
|
|
172
421
|
if (suggestions.length > 0) {
|
package/dist/index.d.mts
CHANGED
|
@@ -9,6 +9,8 @@ type NamespaceSource = {
|
|
|
9
9
|
/** Scope key for ownership checks — allows users to update their own slug without a false collision */
|
|
10
10
|
scopeKey?: string;
|
|
11
11
|
};
|
|
12
|
+
/** Built-in suggestion strategy names. */
|
|
13
|
+
type SuggestStrategyName = "sequential" | "random-digits" | "suffix-words" | "short-random" | "scramble" | "similar";
|
|
12
14
|
/** Configuration for a namespace guard instance. */
|
|
13
15
|
type NamespaceConfig = {
|
|
14
16
|
/** Reserved names — flat list, Set, or categorized record */
|
|
@@ -32,10 +34,12 @@ type NamespaceConfig = {
|
|
|
32
34
|
} | null>>;
|
|
33
35
|
/** Enable conflict resolution suggestions when a slug is taken */
|
|
34
36
|
suggest?: {
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
+
/** Named strategy, array of strategies to compose, or custom generator function (default: `["sequential", "random-digits"]`) */
|
|
38
|
+
strategy?: SuggestStrategyName | SuggestStrategyName[] | ((identifier: string) => string[]);
|
|
37
39
|
/** Max suggestions to return (default: 3) */
|
|
38
40
|
max?: number;
|
|
41
|
+
/** @deprecated Use `strategy` instead. Custom generator function. */
|
|
42
|
+
generate?: (identifier: string) => string[];
|
|
39
43
|
};
|
|
40
44
|
/** Enable in-memory caching of adapter lookups */
|
|
41
45
|
cache?: {
|
|
@@ -161,4 +165,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
|
|
|
161
165
|
/** The guard instance returned by `createNamespaceGuard`. */
|
|
162
166
|
type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
|
|
163
167
|
|
|
164
|
-
export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, createNamespaceGuard, createProfanityValidator, normalize };
|
|
168
|
+
export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createNamespaceGuard, createProfanityValidator, normalize };
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ type NamespaceSource = {
|
|
|
9
9
|
/** Scope key for ownership checks — allows users to update their own slug without a false collision */
|
|
10
10
|
scopeKey?: string;
|
|
11
11
|
};
|
|
12
|
+
/** Built-in suggestion strategy names. */
|
|
13
|
+
type SuggestStrategyName = "sequential" | "random-digits" | "suffix-words" | "short-random" | "scramble" | "similar";
|
|
12
14
|
/** Configuration for a namespace guard instance. */
|
|
13
15
|
type NamespaceConfig = {
|
|
14
16
|
/** Reserved names — flat list, Set, or categorized record */
|
|
@@ -32,10 +34,12 @@ type NamespaceConfig = {
|
|
|
32
34
|
} | null>>;
|
|
33
35
|
/** Enable conflict resolution suggestions when a slug is taken */
|
|
34
36
|
suggest?: {
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
+
/** Named strategy, array of strategies to compose, or custom generator function (default: `["sequential", "random-digits"]`) */
|
|
38
|
+
strategy?: SuggestStrategyName | SuggestStrategyName[] | ((identifier: string) => string[]);
|
|
37
39
|
/** Max suggestions to return (default: 3) */
|
|
38
40
|
max?: number;
|
|
41
|
+
/** @deprecated Use `strategy` instead. Custom generator function. */
|
|
42
|
+
generate?: (identifier: string) => string[];
|
|
39
43
|
};
|
|
40
44
|
/** Enable in-memory caching of adapter lookups */
|
|
41
45
|
cache?: {
|
|
@@ -161,4 +165,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
|
|
|
161
165
|
/** The guard instance returned by `createNamespaceGuard`. */
|
|
162
166
|
type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
|
|
163
167
|
|
|
164
|
-
export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, createNamespaceGuard, createProfanityValidator, normalize };
|
|
168
|
+
export { type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createNamespaceGuard, createProfanityValidator, normalize };
|