namespace-guard 0.8.2 → 0.11.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg)](https://www.typescriptlang.org/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- **[Live Demo](https://paultendo.github.io/namespace-guard/)** try it in your browser | **[Blog Post](https://paultendo.github.io/posts/namespace-guard-launch/)** why this exists
8
+ **[Live Demo](https://paultendo.github.io/namespace-guard/)** - try it in your browser | **[Blog Post](https://paultendo.github.io/posts/namespace-guard-launch/)** - why this exists
9
9
 
10
10
  **Check slug/handle uniqueness across multiple database tables with reserved name protection.**
11
11
 
@@ -92,6 +92,8 @@ const adapter = createPrismaAdapter(prisma);
92
92
 
93
93
  ### Drizzle
94
94
 
95
+ > **Note:** The Drizzle adapter uses `db.query` (the relational query API). Make sure your Drizzle client is set up with `drizzle(client, { schema })` so that `db.query.<tableName>` is available.
96
+
95
97
  ```typescript
96
98
  import { eq } from "drizzle-orm";
97
99
  import { createDrizzleAdapter } from "namespace-guard/adapters/drizzle";
@@ -163,7 +165,9 @@ import { User, Organization } from "./models";
163
165
  const adapter = createMongooseAdapter({ user: User, organization: Organization });
164
166
  ```
165
167
 
166
- ### Raw SQL (pg, mysql2, etc.)
168
+ ### Raw SQL (pg, mysql2, better-sqlite3, etc.)
169
+
170
+ The raw adapter generates PostgreSQL-style SQL (`$1` placeholders, double-quoted identifiers). For pg this works directly. For MySQL or SQLite, translate the parameter syntax in your executor wrapper.
167
171
 
168
172
  ```typescript
169
173
  import { Pool } from "pg";
@@ -173,6 +177,34 @@ const pool = new Pool();
173
177
  const adapter = createRawAdapter((sql, params) => pool.query(sql, params));
174
178
  ```
175
179
 
180
+ **MySQL2 wrapper** (translates `$1` to `?` and `"col"` to `` `col` ``):
181
+
182
+ ```typescript
183
+ import mysql from "mysql2/promise";
184
+ import { createRawAdapter } from "namespace-guard/adapters/raw";
185
+
186
+ const pool = mysql.createPool({ uri: process.env.DATABASE_URL });
187
+ const adapter = createRawAdapter(async (sql, params) => {
188
+ const mysqlSql = sql.replace(/\$\d+/g, "?").replace(/"/g, "`");
189
+ const [rows] = await pool.execute(mysqlSql, params);
190
+ return { rows: rows as Record<string, unknown>[] };
191
+ });
192
+ ```
193
+
194
+ **better-sqlite3 wrapper** (translates `$1` to `?` and strips identifier quotes):
195
+
196
+ ```typescript
197
+ import Database from "better-sqlite3";
198
+ import { createRawAdapter } from "namespace-guard/adapters/raw";
199
+
200
+ const db = new Database("app.db");
201
+ const adapter = createRawAdapter(async (sql, params) => {
202
+ const sqliteSql = sql.replace(/\$\d+/g, "?").replace(/"/g, "");
203
+ const rows = db.prepare(sqliteSql).all(...params);
204
+ return { rows: rows as Record<string, unknown>[] };
205
+ });
206
+ ```
207
+
176
208
  ## Configuration
177
209
 
178
210
  ```typescript
@@ -249,7 +281,7 @@ const result = await guard.check("admin");
249
281
  // { available: false, reason: "reserved", category: "system", message: "That's a system route." }
250
282
  ```
251
283
 
252
- You can also use a single string message for all categories, or mix categories without a specific message fall back to the default.
284
+ You can also use a single string message for all categories, or mix - categories without a specific message fall back to the default.
253
285
 
254
286
  ## Async Validators
255
287
 
@@ -279,7 +311,7 @@ Validators run sequentially and stop at the first rejection. They receive the no
279
311
 
280
312
  ### Built-in Profanity Validator
281
313
 
282
- Use `createProfanityValidator` for a turnkey profanity filter supply your own word list:
314
+ Use `createProfanityValidator` for a turnkey profanity filter - supply your own word list:
283
315
 
284
316
  ```typescript
285
317
  import { createNamespaceGuard, createProfanityValidator } from "namespace-guard";
@@ -295,7 +327,7 @@ const guard = createNamespaceGuard({
295
327
  }, adapter);
296
328
  ```
297
329
 
298
- No words are bundled use any word list you like (e.g., the `bad-words` npm package, your own list, or an external API wrapped in a custom validator).
330
+ No words are bundled - use any word list you like (e.g., the `bad-words` npm package, your own list, or an external API wrapped in a custom validator).
299
331
 
300
332
  ### Built-in Homoglyph Validator
301
333
 
@@ -324,6 +356,44 @@ createHomoglyphValidator({
324
356
 
325
357
  The built-in `CONFUSABLE_MAP` contains 613 character pairs generated from [Unicode TR39 confusables.txt](https://unicode.org/reports/tr39/) plus supplemental Latin small capitals. It covers Cyrillic, Greek, Armenian, Cherokee, IPA, Coptic, Lisu, Canadian Syllabics, Georgian, and 20+ other scripts. The map is exported for inspection or extension, and is regenerable for new Unicode versions with `npx tsx scripts/generate-confusables.ts`.
326
358
 
359
+ #### CONFUSABLE_MAP_FULL
360
+
361
+ For standalone use without NFKC normalization, `CONFUSABLE_MAP_FULL` (~1,400 entries) includes every single-character-to-Latin mapping from TR39 with no NFKC filtering. This is the right map when your pipeline does not run NFKC before confusable detection, which is the case for most real-world systems: TR39's skeleton algorithm uses NFD, Chromium's IDN spoof checker uses NFD, Rust's `confusable_idents` lint runs on NFC, and django-registration applies the confusable map to raw input with no normalization at all.
362
+
363
+ ```typescript
364
+ import { CONFUSABLE_MAP_FULL } from "namespace-guard";
365
+
366
+ // Contains everything in CONFUSABLE_MAP, plus:
367
+ // - ~766 entries where NFKC agrees with TR39 (mathematical alphanumerics, fullwidth forms)
368
+ // - 31 entries where TR39 and NFKC disagree on the target letter
369
+ CONFUSABLE_MAP_FULL["\u017f"]; // "f" (Long S: TR39 visual mapping)
370
+ CONFUSABLE_MAP_FULL["\u{1D41A}"]; // "a" (Mathematical Bold Small A)
371
+ ```
372
+
373
+ #### `skeleton()` and `areConfusable()`
374
+
375
+ The TR39 Section 4 skeleton algorithm computes a normalized form of a string for confusable comparison. Two strings that look alike will produce the same skeleton. This is the same algorithm used by ICU's SpoofChecker, Chromium's IDN spoof checker, and the Rust compiler's `confusable_idents` lint.
376
+
377
+ ```typescript
378
+ import { skeleton, areConfusable, CONFUSABLE_MAP } from "namespace-guard";
379
+
380
+ // Compute skeletons for comparison
381
+ skeleton("paypal"); // "paypal"
382
+ skeleton("\u0440\u0430ypal"); // "paypal" (Cyrillic р and а)
383
+ skeleton("pay\u200Bpal"); // "paypal" (zero-width space stripped)
384
+ skeleton("\u017f"); // "f" (Long S via TR39 visual mapping)
385
+
386
+ // Compare two strings directly
387
+ areConfusable("paypal", "\u0440\u0430ypal"); // true
388
+ areConfusable("google", "g\u043e\u043egle"); // true (Cyrillic о)
389
+ areConfusable("hello", "world"); // false
390
+
391
+ // Use CONFUSABLE_MAP for NFKC-first pipelines
392
+ skeleton("\u017f", { map: CONFUSABLE_MAP }); // "\u017f" (Long S not in filtered map)
393
+ ```
394
+
395
+ By default, `skeleton()` uses `CONFUSABLE_MAP_FULL` (the complete TR39 map), which matches the NFD-based pipeline specified by TR39. Pass `{ map: CONFUSABLE_MAP }` if your pipeline runs NFKC normalization before calling `skeleton()`.
396
+
327
397
  ### How the anti-spoofing pipeline works
328
398
 
329
399
  Most confusable-detection libraries apply a character map in isolation. namespace-guard uses a three-stage pipeline where each stage is aware of the others:
@@ -335,13 +405,13 @@ Input → NFKC normalize → Confusable map → Mixed-script reject
335
405
 
336
406
  **Stage 1: NFKC normalization** collapses full-width characters (`I` → `I`), ligatures (`fi` → `fi`), superscripts, and other Unicode compatibility forms to their canonical equivalents. This runs first, before any confusable check.
337
407
 
338
- **Stage 2: Confusable map** catches characters that survive NFKC but visually mimic Latin letters Cyrillic `а` for `a`, Greek `ο` for `o`, Cherokee `Ꭺ` for `A`, and 600+ others from the Unicode Consortium's [confusables.txt](https://unicode.org/Public/security/latest/confusables.txt).
408
+ **Stage 2: Confusable map** catches characters that survive NFKC but visually mimic Latin letters - Cyrillic `а` for `a`, Greek `ο` for `o`, Cherokee `Ꭺ` for `A`, and 600+ others from the Unicode Consortium's [confusables.txt](https://unicode.org/Public/security/latest/confusables.txt).
339
409
 
340
410
  **Stage 3: Mixed-script rejection** (`rejectMixedScript: true`) blocks identifiers that mix Latin with non-Latin scripts (Hebrew, Arabic, Devanagari, Thai, Georgian, Ethiopic, etc.) even if the specific characters aren't in the confusable map. This catches novel homoglyphs that the map doesn't cover.
341
411
 
342
412
  #### Why NFKC-aware filtering matters
343
413
 
344
- The key insight: TR39's confusables.txt and NFKC normalization sometimes disagree. For example, Unicode says capital `I` (U+0049) is confusable with lowercase `l` visually true in many fonts. But NFKC maps Mathematical Bold `𝐈` (U+1D408) to `I`, not `l`. If you naively ship the TR39 mapping (`𝐈` → `l`), the confusable check will never see that character NFKC already converted it to `I` in stage 1.
414
+ The key insight: TR39's confusables.txt and NFKC normalization sometimes disagree. For example, Unicode says capital `I` (U+0049) is confusable with lowercase `l` - visually true in many fonts. But NFKC maps Mathematical Bold `𝐈` (U+1D408) to `I`, not `l`. If you naively ship the TR39 mapping (`𝐈` → `l`), the confusable check will never see that character - NFKC already converted it to `I` in stage 1.
345
415
 
346
416
  We found 31 entries where this happens:
347
417
 
@@ -354,7 +424,7 @@ We found 31 entries where this happens:
354
424
  | 11 Mathematical I variants | `l` | `i` | NFKC |
355
425
  | 12 Mathematical 0/1 variants | `o`/`l` | `0`/`1` | NFKC |
356
426
 
357
- These entries are dead code in any pipeline that runs NFKC first and worse, they encode the *wrong* mapping. The generate script (`scripts/generate-confusables.ts`) automatically detects and excludes them.
427
+ These entries are dead code in any pipeline that runs NFKC first - and worse, they encode the *wrong* mapping. The generate script (`scripts/generate-confusables.ts`) automatically detects and excludes them.
358
428
 
359
429
  ## Unicode Normalization
360
430
 
@@ -429,7 +499,7 @@ const result = await guard.check("acme-corp");
429
499
 
430
500
  ### Composing Strategies
431
501
 
432
- Combine multiple strategies candidates are interleaved round-robin:
502
+ Combine multiple strategies - candidates are interleaved round-robin:
433
503
 
434
504
  ```typescript
435
505
  suggest: {
@@ -503,10 +573,10 @@ npx namespace-guard check acme-corp
503
573
  # ✓ acme-corp is available
504
574
 
505
575
  npx namespace-guard check admin
506
- # ✗ admin That name is reserved. Try another one.
576
+ # ✗ admin - That name is reserved. Try another one.
507
577
 
508
578
  npx namespace-guard check "a"
509
- # ✗ a Use 2-30 lowercase letters, numbers, or hyphens.
579
+ # ✗ a - Use 2-30 lowercase letters, numbers, or hyphens.
510
580
  ```
511
581
 
512
582
  ### With a config file
@@ -585,9 +655,16 @@ Check if an identifier is available.
585
655
 
586
656
  ---
587
657
 
588
- ### `guard.checkMany(identifiers, scope?)`
658
+ ### `guard.checkMany(identifiers, scope?, options?)`
659
+
660
+ Check multiple identifiers in parallel. Suggestions are skipped by default for performance.
589
661
 
590
- Check multiple identifiers in parallel.
662
+ **Parameters:**
663
+ - `identifiers` - Array of slugs/handles to check
664
+ - `scope` - Optional ownership scope applied to all checks
665
+ - `options` - Optional `{ skipSuggestions?: boolean }` (default: `true`)
666
+
667
+ Pass `{ skipSuggestions: false }` to include suggestions for taken identifiers.
591
668
 
592
669
  **Returns:** `Record<string, CheckResult>`
593
670
 
@@ -601,9 +678,37 @@ Same as `check()`, but throws an `Error` if not available.
601
678
 
602
679
  ### `guard.validateFormat(identifier)`
603
680
 
604
- Validate format only (no database queries).
681
+ Validate format, purely-numeric restriction, and reserved name status without querying the database.
682
+
683
+ **Returns:** Error message string if invalid or reserved, `null` if OK.
684
+
685
+ ---
686
+
687
+ ### `guard.validateFormatOnly(identifier)`
688
+
689
+ Validate only the identifier's format and purely-numeric restriction. Does not check reserved names or query the database. Useful for instant client-side feedback on input shape.
690
+
691
+ **Returns:** Error message string if the format is invalid, `null` if OK.
692
+
693
+ ---
694
+
695
+ ### `guard.normalize(identifier)`
696
+
697
+ Convenience re-export of the standalone `normalize()` function. Note: always applies NFKC normalization regardless of the guard's `normalizeUnicode` setting. Use `normalize(id, { unicode: false })` directly if you need to skip NFKC.
698
+
699
+ ---
700
+
701
+ ### `guard.clearCache()`
702
+
703
+ Clear the in-memory cache and reset hit/miss counters. No-op if caching is not enabled.
704
+
705
+ ---
706
+
707
+ ### `guard.cacheStats()`
708
+
709
+ Get cache performance statistics.
605
710
 
606
- **Returns:** Error message string if invalid, `null` if valid.
711
+ **Returns:** `{ size: number; hits: number; misses: number }`
607
712
 
608
713
  ---
609
714
 
@@ -761,7 +866,10 @@ import {
761
866
  createNamespaceGuard,
762
867
  createProfanityValidator,
763
868
  createHomoglyphValidator,
869
+ skeleton,
870
+ areConfusable,
764
871
  CONFUSABLE_MAP,
872
+ CONFUSABLE_MAP_FULL,
765
873
  normalize,
766
874
  type NamespaceConfig,
767
875
  type NamespaceSource,
@@ -771,6 +879,8 @@ import {
771
879
  type FindOneOptions,
772
880
  type OwnershipScope,
773
881
  type SuggestStrategyName,
882
+ type SkeletonOptions,
883
+ type CheckManyOptions,
774
884
  } from "namespace-guard";
775
885
  ```
776
886
 
package/dist/cli.js CHANGED
@@ -339,6 +339,16 @@ function createNamespaceGuard(config, adapter) {
339
339
  }
340
340
  return null;
341
341
  }
342
+ function validateFormatOnly(identifier) {
343
+ const normalized = normalize(identifier, normalizeOpts);
344
+ if (!pattern.test(normalized)) {
345
+ return invalidMsg;
346
+ }
347
+ if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
348
+ return purelyNumericMsg;
349
+ }
350
+ return null;
351
+ }
342
352
  function isOwnedByScope(existing, source, scope) {
343
353
  if (!source.scopeKey) return false;
344
354
  const scopeValue = scope[source.scopeKey];
@@ -457,10 +467,11 @@ function createNamespaceGuard(config, adapter) {
457
467
  throw new Error(result.message);
458
468
  }
459
469
  }
460
- async function checkMany(identifiers, scope = {}) {
470
+ async function checkMany(identifiers, scope = {}, options) {
471
+ const skip = options?.skipSuggestions ?? true;
461
472
  const entries = await Promise.all(
462
473
  identifiers.map(async (id) => {
463
- const result = await check(id, scope, { skipSuggestions: true });
474
+ const result = await check(id, scope, { skipSuggestions: skip });
464
475
  return [id, result];
465
476
  })
466
477
  );
@@ -477,6 +488,7 @@ function createNamespaceGuard(config, adapter) {
477
488
  return {
478
489
  normalize,
479
490
  validateFormat,
491
+ validateFormatOnly,
480
492
  check,
481
493
  assertAvailable,
482
494
  checkMany,
package/dist/cli.mjs CHANGED
@@ -316,6 +316,16 @@ function createNamespaceGuard(config, adapter) {
316
316
  }
317
317
  return null;
318
318
  }
319
+ function validateFormatOnly(identifier) {
320
+ const normalized = normalize(identifier, normalizeOpts);
321
+ if (!pattern.test(normalized)) {
322
+ return invalidMsg;
323
+ }
324
+ if (!allowPurelyNumeric && /^\d+(-\d+)*$/.test(normalized)) {
325
+ return purelyNumericMsg;
326
+ }
327
+ return null;
328
+ }
319
329
  function isOwnedByScope(existing, source, scope) {
320
330
  if (!source.scopeKey) return false;
321
331
  const scopeValue = scope[source.scopeKey];
@@ -434,10 +444,11 @@ function createNamespaceGuard(config, adapter) {
434
444
  throw new Error(result.message);
435
445
  }
436
446
  }
437
- async function checkMany(identifiers, scope = {}) {
447
+ async function checkMany(identifiers, scope = {}, options) {
448
+ const skip = options?.skipSuggestions ?? true;
438
449
  const entries = await Promise.all(
439
450
  identifiers.map(async (id) => {
440
- const result = await check(id, scope, { skipSuggestions: true });
451
+ const result = await check(id, scope, { skipSuggestions: skip });
441
452
  return [id, result];
442
453
  })
443
454
  );
@@ -454,6 +465,7 @@ function createNamespaceGuard(config, adapter) {
454
465
  return {
455
466
  normalize,
456
467
  validateFormat,
468
+ validateFormatOnly,
457
469
  check,
458
470
  assertAvailable,
459
471
  checkMany,
package/dist/index.d.mts CHANGED
@@ -6,14 +6,14 @@ type NamespaceSource = {
6
6
  column: string;
7
7
  /** Column name for the primary key (default: "id", or "_id" for Mongoose) */
8
8
  idColumn?: string;
9
- /** Scope key for ownership checks allows users to update their own slug without a false collision */
9
+ /** Scope key for ownership checks - allows users to update their own slug without a false collision */
10
10
  scopeKey?: string;
11
11
  };
12
12
  /** Built-in suggestion strategy names. */
13
13
  type SuggestStrategyName = "sequential" | "random-digits" | "suffix-words" | "short-random" | "scramble" | "similar";
14
14
  /** Configuration for a namespace guard instance. */
15
15
  type NamespaceConfig = {
16
- /** Reserved names flat list, Set, or categorized record */
16
+ /** Reserved names - flat list, Set, or categorized record */
17
17
  reserved?: Set<string> | string[] | Record<string, string[]>;
18
18
  /** Data sources to check for collisions */
19
19
  sources: NamespaceSource[];
@@ -35,7 +35,7 @@ type NamespaceConfig = {
35
35
  /** Message shown when a purely numeric identifier is rejected (default: "Identifiers cannot be purely numeric.") */
36
36
  purelyNumeric?: string;
37
37
  };
38
- /** Async validation hooks run after format/reserved checks, before DB */
38
+ /** Async validation hooks - run after format/reserved checks, before DB */
39
39
  validators?: Array<(value: string) => Promise<{
40
40
  available: false;
41
41
  message: string;
@@ -62,7 +62,7 @@ type FindOneOptions = {
62
62
  /** Use case-insensitive matching */
63
63
  caseInsensitive?: boolean;
64
64
  };
65
- /** Database adapter interface implement this for your ORM or query builder. */
65
+ /** Database adapter interface - implement this for your ORM or query builder. */
66
66
  type NamespaceAdapter = {
67
67
  findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
68
68
  };
@@ -82,6 +82,18 @@ type CheckResult = {
82
82
  category?: string;
83
83
  suggestions?: string[];
84
84
  };
85
+ /** Options for `checkMany()`. */
86
+ type CheckManyOptions = {
87
+ /** Skip suggestion generation for taken identifiers (default: `true`). */
88
+ skipSuggestions?: boolean;
89
+ };
90
+ /** Options for the `skeleton()` and `areConfusable()` functions. */
91
+ type SkeletonOptions = {
92
+ /** Confusable character map to use.
93
+ * Default: `CONFUSABLE_MAP_FULL` (complete TR39 map, no NFKC filtering).
94
+ * Pass `CONFUSABLE_MAP` if your pipeline runs NFKC before calling skeleton(). */
95
+ map?: Record<string, string>;
96
+ };
85
97
  /**
86
98
  * Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
87
99
  * lowercases, and strips leading `@` symbols.
@@ -108,7 +120,7 @@ declare function normalize(raw: string, options?: {
108
120
  /**
109
121
  * Create a validator that rejects identifiers containing profanity or offensive words.
110
122
  *
111
- * Supply your own word list no words are bundled with the library.
123
+ * Supply your own word list - no words are bundled with the library.
112
124
  * The returned function is compatible with `config.validators`.
113
125
  *
114
126
  * @param words - Array of words to block
@@ -146,6 +158,22 @@ declare function createProfanityValidator(words: string[], options?: {
146
158
  * Regenerate: `npx tsx scripts/generate-confusables.ts`
147
159
  */
148
160
  declare const CONFUSABLE_MAP: Record<string, string>;
161
+ /**
162
+ * Complete TR39 confusable mapping: every single-character mapping to a
163
+ * lowercase Latin letter or digit from confusables.txt, with no NFKC filtering.
164
+ *
165
+ * Use this when your pipeline does NOT run NFKC normalization before confusable
166
+ * detection (which is most real-world systems: TR39 skeleton uses NFD, Chromium
167
+ * uses NFD, Rust uses NFC, django-registration uses no normalization at all).
168
+ *
169
+ * Includes ~1,400 entries vs CONFUSABLE_MAP's ~613 NFKC-deduped entries.
170
+ * The additional entries cover characters that NFKC normalization would handle
171
+ * (mathematical alphanumerics, fullwidth forms, etc.) plus the 31 entries where
172
+ * TR39 and NFKC disagree on the target letter.
173
+ *
174
+ * Regenerate: `npx tsx scripts/generate-confusables.ts`
175
+ */
176
+ declare const CONFUSABLE_MAP_FULL: Record<string, string>;
149
177
  /**
150
178
  * Create a validator that rejects identifiers containing homoglyph/confusable characters.
151
179
  *
@@ -179,13 +207,55 @@ declare function createHomoglyphValidator(options?: {
179
207
  available: false;
180
208
  message: string;
181
209
  } | null>;
210
+ /**
211
+ * Compute the TR39 Section 4 skeleton of a string for confusable comparison.
212
+ *
213
+ * Implements `internalSkeleton`:
214
+ * 1. NFD normalize
215
+ * 2. Remove Default_Ignorable_Code_Point characters
216
+ * 3. Replace each character via the confusable map
217
+ * 4. Reapply NFD
218
+ * 5. Lowercase
219
+ *
220
+ * The default map is `CONFUSABLE_MAP_FULL` (the complete TR39 mapping without
221
+ * NFKC filtering), which matches the NFD-based pipeline used by ICU, Chromium,
222
+ * and the TR39 spec itself. Pass `{ map: CONFUSABLE_MAP }` if your pipeline
223
+ * runs NFKC normalization before calling skeleton().
224
+ *
225
+ * @param input - The string to skeletonize
226
+ * @param options - Optional settings (custom confusable map)
227
+ * @returns The skeleton string for comparison
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * skeleton("paypal") === skeleton("\u0440\u0430ypal") // true (Cyrillic р/а)
232
+ * skeleton("pay\u200Bpal") === skeleton("paypal") // true (zero-width stripped)
233
+ * ```
234
+ */
235
+ declare function skeleton(input: string, options?: SkeletonOptions): string;
236
+ /**
237
+ * Check whether two strings are visually confusable by comparing their TR39 skeletons.
238
+ *
239
+ * @param a - First string
240
+ * @param b - Second string
241
+ * @param options - Optional settings (custom confusable map)
242
+ * @returns `true` if the strings produce the same skeleton
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * areConfusable("paypal", "\u0440\u0430ypal") // true
247
+ * areConfusable("google", "g\u043e\u043egle") // true
248
+ * areConfusable("hello", "world") // false
249
+ * ```
250
+ */
251
+ declare function areConfusable(a: string, b: string, options?: SkeletonOptions): boolean;
182
252
  /**
183
253
  * Create a namespace guard instance for checking slug/handle uniqueness
184
254
  * across multiple database tables with reserved name protection.
185
255
  *
186
256
  * @param config - Reserved names, data sources, validation pattern, and optional features
187
257
  * @param adapter - Database adapter implementing the `findOne` lookup (use a built-in adapter or write your own)
188
- * @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `clearCache`, and `cacheStats` methods
258
+ * @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `validateFormatOnly`, `clearCache`, and `cacheStats` methods
189
259
  *
190
260
  * @example
191
261
  * ```ts
@@ -212,11 +282,12 @@ declare function createHomoglyphValidator(options?: {
212
282
  declare function createNamespaceGuard(config: NamespaceConfig, adapter: NamespaceAdapter): {
213
283
  normalize: typeof normalize;
214
284
  validateFormat: (identifier: string) => string | null;
285
+ validateFormatOnly: (identifier: string) => string | null;
215
286
  check: (identifier: string, scope?: OwnershipScope, options?: {
216
287
  skipSuggestions?: boolean;
217
288
  }) => Promise<CheckResult>;
218
289
  assertAvailable: (identifier: string, scope?: OwnershipScope) => Promise<void>;
219
- checkMany: (identifiers: string[], scope?: OwnershipScope) => Promise<Record<string, CheckResult>>;
290
+ checkMany: (identifiers: string[], scope?: OwnershipScope, options?: CheckManyOptions) => Promise<Record<string, CheckResult>>;
220
291
  clearCache: () => void;
221
292
  cacheStats: () => {
222
293
  size: number;
@@ -227,4 +298,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
227
298
  /** The guard instance returned by `createNamespaceGuard`. */
228
299
  type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
229
300
 
230
- export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
301
+ export { CONFUSABLE_MAP, CONFUSABLE_MAP_FULL, type CheckManyOptions, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SkeletonOptions, type SuggestStrategyName, areConfusable, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize, skeleton };
package/dist/index.d.ts CHANGED
@@ -6,14 +6,14 @@ type NamespaceSource = {
6
6
  column: string;
7
7
  /** Column name for the primary key (default: "id", or "_id" for Mongoose) */
8
8
  idColumn?: string;
9
- /** Scope key for ownership checks allows users to update their own slug without a false collision */
9
+ /** Scope key for ownership checks - allows users to update their own slug without a false collision */
10
10
  scopeKey?: string;
11
11
  };
12
12
  /** Built-in suggestion strategy names. */
13
13
  type SuggestStrategyName = "sequential" | "random-digits" | "suffix-words" | "short-random" | "scramble" | "similar";
14
14
  /** Configuration for a namespace guard instance. */
15
15
  type NamespaceConfig = {
16
- /** Reserved names flat list, Set, or categorized record */
16
+ /** Reserved names - flat list, Set, or categorized record */
17
17
  reserved?: Set<string> | string[] | Record<string, string[]>;
18
18
  /** Data sources to check for collisions */
19
19
  sources: NamespaceSource[];
@@ -35,7 +35,7 @@ type NamespaceConfig = {
35
35
  /** Message shown when a purely numeric identifier is rejected (default: "Identifiers cannot be purely numeric.") */
36
36
  purelyNumeric?: string;
37
37
  };
38
- /** Async validation hooks run after format/reserved checks, before DB */
38
+ /** Async validation hooks - run after format/reserved checks, before DB */
39
39
  validators?: Array<(value: string) => Promise<{
40
40
  available: false;
41
41
  message: string;
@@ -62,7 +62,7 @@ type FindOneOptions = {
62
62
  /** Use case-insensitive matching */
63
63
  caseInsensitive?: boolean;
64
64
  };
65
- /** Database adapter interface implement this for your ORM or query builder. */
65
+ /** Database adapter interface - implement this for your ORM or query builder. */
66
66
  type NamespaceAdapter = {
67
67
  findOne: (source: NamespaceSource, value: string, options?: FindOneOptions) => Promise<Record<string, unknown> | null>;
68
68
  };
@@ -82,6 +82,18 @@ type CheckResult = {
82
82
  category?: string;
83
83
  suggestions?: string[];
84
84
  };
85
+ /** Options for `checkMany()`. */
86
+ type CheckManyOptions = {
87
+ /** Skip suggestion generation for taken identifiers (default: `true`). */
88
+ skipSuggestions?: boolean;
89
+ };
90
+ /** Options for the `skeleton()` and `areConfusable()` functions. */
91
+ type SkeletonOptions = {
92
+ /** Confusable character map to use.
93
+ * Default: `CONFUSABLE_MAP_FULL` (complete TR39 map, no NFKC filtering).
94
+ * Pass `CONFUSABLE_MAP` if your pipeline runs NFKC before calling skeleton(). */
95
+ map?: Record<string, string>;
96
+ };
85
97
  /**
86
98
  * Normalize a raw identifier: trims whitespace, applies NFKC Unicode normalization,
87
99
  * lowercases, and strips leading `@` symbols.
@@ -108,7 +120,7 @@ declare function normalize(raw: string, options?: {
108
120
  /**
109
121
  * Create a validator that rejects identifiers containing profanity or offensive words.
110
122
  *
111
- * Supply your own word list no words are bundled with the library.
123
+ * Supply your own word list - no words are bundled with the library.
112
124
  * The returned function is compatible with `config.validators`.
113
125
  *
114
126
  * @param words - Array of words to block
@@ -146,6 +158,22 @@ declare function createProfanityValidator(words: string[], options?: {
146
158
  * Regenerate: `npx tsx scripts/generate-confusables.ts`
147
159
  */
148
160
  declare const CONFUSABLE_MAP: Record<string, string>;
161
+ /**
162
+ * Complete TR39 confusable mapping: every single-character mapping to a
163
+ * lowercase Latin letter or digit from confusables.txt, with no NFKC filtering.
164
+ *
165
+ * Use this when your pipeline does NOT run NFKC normalization before confusable
166
+ * detection (which is most real-world systems: TR39 skeleton uses NFD, Chromium
167
+ * uses NFD, Rust uses NFC, django-registration uses no normalization at all).
168
+ *
169
+ * Includes ~1,400 entries vs CONFUSABLE_MAP's ~613 NFKC-deduped entries.
170
+ * The additional entries cover characters that NFKC normalization would handle
171
+ * (mathematical alphanumerics, fullwidth forms, etc.) plus the 31 entries where
172
+ * TR39 and NFKC disagree on the target letter.
173
+ *
174
+ * Regenerate: `npx tsx scripts/generate-confusables.ts`
175
+ */
176
+ declare const CONFUSABLE_MAP_FULL: Record<string, string>;
149
177
  /**
150
178
  * Create a validator that rejects identifiers containing homoglyph/confusable characters.
151
179
  *
@@ -179,13 +207,55 @@ declare function createHomoglyphValidator(options?: {
179
207
  available: false;
180
208
  message: string;
181
209
  } | null>;
210
+ /**
211
+ * Compute the TR39 Section 4 skeleton of a string for confusable comparison.
212
+ *
213
+ * Implements `internalSkeleton`:
214
+ * 1. NFD normalize
215
+ * 2. Remove Default_Ignorable_Code_Point characters
216
+ * 3. Replace each character via the confusable map
217
+ * 4. Reapply NFD
218
+ * 5. Lowercase
219
+ *
220
+ * The default map is `CONFUSABLE_MAP_FULL` (the complete TR39 mapping without
221
+ * NFKC filtering), which matches the NFD-based pipeline used by ICU, Chromium,
222
+ * and the TR39 spec itself. Pass `{ map: CONFUSABLE_MAP }` if your pipeline
223
+ * runs NFKC normalization before calling skeleton().
224
+ *
225
+ * @param input - The string to skeletonize
226
+ * @param options - Optional settings (custom confusable map)
227
+ * @returns The skeleton string for comparison
228
+ *
229
+ * @example
230
+ * ```ts
231
+ * skeleton("paypal") === skeleton("\u0440\u0430ypal") // true (Cyrillic р/а)
232
+ * skeleton("pay\u200Bpal") === skeleton("paypal") // true (zero-width stripped)
233
+ * ```
234
+ */
235
+ declare function skeleton(input: string, options?: SkeletonOptions): string;
236
+ /**
237
+ * Check whether two strings are visually confusable by comparing their TR39 skeletons.
238
+ *
239
+ * @param a - First string
240
+ * @param b - Second string
241
+ * @param options - Optional settings (custom confusable map)
242
+ * @returns `true` if the strings produce the same skeleton
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * areConfusable("paypal", "\u0440\u0430ypal") // true
247
+ * areConfusable("google", "g\u043e\u043egle") // true
248
+ * areConfusable("hello", "world") // false
249
+ * ```
250
+ */
251
+ declare function areConfusable(a: string, b: string, options?: SkeletonOptions): boolean;
182
252
  /**
183
253
  * Create a namespace guard instance for checking slug/handle uniqueness
184
254
  * across multiple database tables with reserved name protection.
185
255
  *
186
256
  * @param config - Reserved names, data sources, validation pattern, and optional features
187
257
  * @param adapter - Database adapter implementing the `findOne` lookup (use a built-in adapter or write your own)
188
- * @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `clearCache`, and `cacheStats` methods
258
+ * @returns A guard with `check`, `checkMany`, `assertAvailable`, `validateFormat`, `validateFormatOnly`, `clearCache`, and `cacheStats` methods
189
259
  *
190
260
  * @example
191
261
  * ```ts
@@ -212,11 +282,12 @@ declare function createHomoglyphValidator(options?: {
212
282
  declare function createNamespaceGuard(config: NamespaceConfig, adapter: NamespaceAdapter): {
213
283
  normalize: typeof normalize;
214
284
  validateFormat: (identifier: string) => string | null;
285
+ validateFormatOnly: (identifier: string) => string | null;
215
286
  check: (identifier: string, scope?: OwnershipScope, options?: {
216
287
  skipSuggestions?: boolean;
217
288
  }) => Promise<CheckResult>;
218
289
  assertAvailable: (identifier: string, scope?: OwnershipScope) => Promise<void>;
219
- checkMany: (identifiers: string[], scope?: OwnershipScope) => Promise<Record<string, CheckResult>>;
290
+ checkMany: (identifiers: string[], scope?: OwnershipScope, options?: CheckManyOptions) => Promise<Record<string, CheckResult>>;
220
291
  clearCache: () => void;
221
292
  cacheStats: () => {
222
293
  size: number;
@@ -227,4 +298,4 @@ declare function createNamespaceGuard(config: NamespaceConfig, adapter: Namespac
227
298
  /** The guard instance returned by `createNamespaceGuard`. */
228
299
  type NamespaceGuard = ReturnType<typeof createNamespaceGuard>;
229
300
 
230
- export { CONFUSABLE_MAP, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SuggestStrategyName, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize };
301
+ export { CONFUSABLE_MAP, CONFUSABLE_MAP_FULL, type CheckManyOptions, type CheckResult, type FindOneOptions, type NamespaceAdapter, type NamespaceConfig, type NamespaceGuard, type NamespaceSource, type OwnershipScope, type SkeletonOptions, type SuggestStrategyName, areConfusable, createHomoglyphValidator, createNamespaceGuard, createProfanityValidator, normalize, skeleton };