domain-search-mcp 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.
Files changed (151) hide show
  1. package/.env.example +52 -0
  2. package/Dockerfile +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +426 -0
  5. package/SECURITY.md +252 -0
  6. package/dist/config.d.ts +25 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +117 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/fallbacks/index.d.ts +6 -0
  11. package/dist/fallbacks/index.d.ts.map +1 -0
  12. package/dist/fallbacks/index.js +14 -0
  13. package/dist/fallbacks/index.js.map +1 -0
  14. package/dist/fallbacks/rdap.d.ts +18 -0
  15. package/dist/fallbacks/rdap.d.ts.map +1 -0
  16. package/dist/fallbacks/rdap.js +339 -0
  17. package/dist/fallbacks/rdap.js.map +1 -0
  18. package/dist/fallbacks/whois.d.ts +27 -0
  19. package/dist/fallbacks/whois.d.ts.map +1 -0
  20. package/dist/fallbacks/whois.js +219 -0
  21. package/dist/fallbacks/whois.js.map +1 -0
  22. package/dist/registrars/base.d.ts +89 -0
  23. package/dist/registrars/base.d.ts.map +1 -0
  24. package/dist/registrars/base.js +203 -0
  25. package/dist/registrars/base.js.map +1 -0
  26. package/dist/registrars/index.d.ts +7 -0
  27. package/dist/registrars/index.d.ts.map +1 -0
  28. package/dist/registrars/index.js +15 -0
  29. package/dist/registrars/index.js.map +1 -0
  30. package/dist/registrars/namecheap.d.ts +69 -0
  31. package/dist/registrars/namecheap.d.ts.map +1 -0
  32. package/dist/registrars/namecheap.js +307 -0
  33. package/dist/registrars/namecheap.js.map +1 -0
  34. package/dist/registrars/porkbun.d.ts +63 -0
  35. package/dist/registrars/porkbun.d.ts.map +1 -0
  36. package/dist/registrars/porkbun.js +299 -0
  37. package/dist/registrars/porkbun.js.map +1 -0
  38. package/dist/server.d.ts +19 -0
  39. package/dist/server.d.ts.map +1 -0
  40. package/dist/server.js +209 -0
  41. package/dist/server.js.map +1 -0
  42. package/dist/services/domain-search.d.ts +40 -0
  43. package/dist/services/domain-search.d.ts.map +1 -0
  44. package/dist/services/domain-search.js +438 -0
  45. package/dist/services/domain-search.js.map +1 -0
  46. package/dist/services/index.d.ts +5 -0
  47. package/dist/services/index.d.ts.map +1 -0
  48. package/dist/services/index.js +11 -0
  49. package/dist/services/index.js.map +1 -0
  50. package/dist/tools/bulk_search.d.ts +72 -0
  51. package/dist/tools/bulk_search.d.ts.map +1 -0
  52. package/dist/tools/bulk_search.js +108 -0
  53. package/dist/tools/bulk_search.js.map +1 -0
  54. package/dist/tools/check_socials.d.ts +71 -0
  55. package/dist/tools/check_socials.d.ts.map +1 -0
  56. package/dist/tools/check_socials.js +357 -0
  57. package/dist/tools/check_socials.js.map +1 -0
  58. package/dist/tools/compare_registrars.d.ts +80 -0
  59. package/dist/tools/compare_registrars.d.ts.map +1 -0
  60. package/dist/tools/compare_registrars.js +116 -0
  61. package/dist/tools/compare_registrars.js.map +1 -0
  62. package/dist/tools/index.d.ts +10 -0
  63. package/dist/tools/index.d.ts.map +1 -0
  64. package/dist/tools/index.js +31 -0
  65. package/dist/tools/index.js.map +1 -0
  66. package/dist/tools/search_domain.d.ts +61 -0
  67. package/dist/tools/search_domain.d.ts.map +1 -0
  68. package/dist/tools/search_domain.js +81 -0
  69. package/dist/tools/search_domain.js.map +1 -0
  70. package/dist/tools/suggest_domains.d.ts +82 -0
  71. package/dist/tools/suggest_domains.d.ts.map +1 -0
  72. package/dist/tools/suggest_domains.js +227 -0
  73. package/dist/tools/suggest_domains.js.map +1 -0
  74. package/dist/tools/tld_info.d.ts +56 -0
  75. package/dist/tools/tld_info.d.ts.map +1 -0
  76. package/dist/tools/tld_info.js +273 -0
  77. package/dist/tools/tld_info.js.map +1 -0
  78. package/dist/types.d.ts +193 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +9 -0
  81. package/dist/types.js.map +1 -0
  82. package/dist/utils/cache.d.ts +81 -0
  83. package/dist/utils/cache.d.ts.map +1 -0
  84. package/dist/utils/cache.js +192 -0
  85. package/dist/utils/cache.js.map +1 -0
  86. package/dist/utils/errors.d.ts +87 -0
  87. package/dist/utils/errors.d.ts.map +1 -0
  88. package/dist/utils/errors.js +191 -0
  89. package/dist/utils/errors.js.map +1 -0
  90. package/dist/utils/index.d.ts +8 -0
  91. package/dist/utils/index.d.ts.map +1 -0
  92. package/dist/utils/index.js +24 -0
  93. package/dist/utils/index.js.map +1 -0
  94. package/dist/utils/logger.d.ts +27 -0
  95. package/dist/utils/logger.d.ts.map +1 -0
  96. package/dist/utils/logger.js +132 -0
  97. package/dist/utils/logger.js.map +1 -0
  98. package/dist/utils/premium-analyzer.d.ts +33 -0
  99. package/dist/utils/premium-analyzer.d.ts.map +1 -0
  100. package/dist/utils/premium-analyzer.js +273 -0
  101. package/dist/utils/premium-analyzer.js.map +1 -0
  102. package/dist/utils/validators.d.ts +53 -0
  103. package/dist/utils/validators.d.ts.map +1 -0
  104. package/dist/utils/validators.js +159 -0
  105. package/dist/utils/validators.js.map +1 -0
  106. package/docs/marketing/devto-post.md +135 -0
  107. package/docs/marketing/hackernews.md +42 -0
  108. package/docs/marketing/producthunt.md +109 -0
  109. package/docs/marketing/reddit-post.md +59 -0
  110. package/docs/marketing/twitter-thread.md +105 -0
  111. package/examples/bulk-search-50-domains.ts +131 -0
  112. package/examples/cli-interactive.ts +280 -0
  113. package/examples/compare-registrars.ts +78 -0
  114. package/examples/search-single-domain.ts +54 -0
  115. package/examples/suggest-names.ts +110 -0
  116. package/glama.json +6 -0
  117. package/jest.config.js +35 -0
  118. package/package.json +62 -0
  119. package/smithery.yaml +36 -0
  120. package/src/config.ts +121 -0
  121. package/src/fallbacks/index.ts +6 -0
  122. package/src/fallbacks/rdap.ts +407 -0
  123. package/src/fallbacks/whois.ts +250 -0
  124. package/src/registrars/base.ts +264 -0
  125. package/src/registrars/index.ts +7 -0
  126. package/src/registrars/namecheap.ts +378 -0
  127. package/src/registrars/porkbun.ts +380 -0
  128. package/src/server.ts +276 -0
  129. package/src/services/domain-search.ts +567 -0
  130. package/src/services/index.ts +9 -0
  131. package/src/tools/bulk_search.ts +142 -0
  132. package/src/tools/check_socials.ts +467 -0
  133. package/src/tools/compare_registrars.ts +162 -0
  134. package/src/tools/index.ts +45 -0
  135. package/src/tools/search_domain.ts +93 -0
  136. package/src/tools/suggest_domains.ts +284 -0
  137. package/src/tools/tld_info.ts +294 -0
  138. package/src/types.ts +289 -0
  139. package/src/utils/cache.ts +238 -0
  140. package/src/utils/errors.ts +262 -0
  141. package/src/utils/index.ts +8 -0
  142. package/src/utils/logger.ts +162 -0
  143. package/src/utils/premium-analyzer.ts +303 -0
  144. package/src/utils/validators.ts +193 -0
  145. package/tests/premium-analyzer.test.ts +310 -0
  146. package/tests/unit/cache.test.ts +123 -0
  147. package/tests/unit/errors.test.ts +190 -0
  148. package/tests/unit/tld-info.test.ts +62 -0
  149. package/tests/unit/tools.test.ts +200 -0
  150. package/tests/unit/validators.test.ts +146 -0
  151. package/tsconfig.json +25 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Premium Domain Analyzer.
3
+ *
4
+ * Analyzes WHY a domain is premium and provides actionable insights.
5
+ * Premium domains are typically short, dictionary words, or popular keywords.
6
+ */
7
+
8
+ import type { DomainResult } from '../types.js';
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // Premium Reason Detection
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Common dictionary words that command premium prices.
16
+ * Based on analysis of premium domain sales.
17
+ */
18
+ const PREMIUM_KEYWORDS = new Set([
19
+ // Tech
20
+ 'ai', 'app', 'api', 'cloud', 'data', 'dev', 'tech', 'code', 'web', 'net',
21
+ 'cyber', 'crypto', 'nft', 'meta', 'virtual', 'digital', 'smart', 'auto',
22
+ // Business
23
+ 'buy', 'sell', 'shop', 'store', 'pay', 'cash', 'bank', 'money', 'invest',
24
+ 'trade', 'market', 'biz', 'pro', 'corp', 'inc', 'llc', 'hq', 'hub',
25
+ // Health/Life
26
+ 'health', 'fit', 'life', 'care', 'med', 'doc', 'bio', 'eco', 'green',
27
+ // Media
28
+ 'tv', 'fm', 'news', 'media', 'live', 'stream', 'video', 'music', 'art',
29
+ // Generic valuable
30
+ 'best', 'top', 'prime', 'elite', 'ultra', 'super', 'mega', 'max', 'plus',
31
+ 'one', 'first', 'go', 'get', 'my', 'the', 'now', 'new', 'next', 'hot',
32
+ ]);
33
+
34
+ /**
35
+ * TLDs known for having many premium domains.
36
+ */
37
+ const PREMIUM_HEAVY_TLDS = new Set([
38
+ 'io', 'ai', 'co', 'app', 'dev', 'xyz', 'club', 'online', 'site', 'tech',
39
+ ]);
40
+
41
+ /**
42
+ * Numeric patterns that are often premium.
43
+ */
44
+ const PREMIUM_NUMBER_PATTERNS = [
45
+ /^\d{1,3}$/, // 1-3 digit numbers (1, 99, 123)
46
+ /^(\d)\1+$/, // Repeating digits (111, 888)
47
+ /^12345?$/, // Sequential (123, 1234)
48
+ /^2[0-9]{3}$/, // Years (2024, 2025)
49
+ ];
50
+
51
+ /**
52
+ * Analyze why a domain might be premium.
53
+ */
54
+ export function analyzePremiumReason(domain: string): string[] {
55
+ const reasons: string[] = [];
56
+ const name = domain.split('.')[0]!.toLowerCase();
57
+ const tld = domain.split('.').pop()!.toLowerCase();
58
+
59
+ // Length-based premium
60
+ if (name.length === 1) {
61
+ reasons.push('Single character domain (extremely rare)');
62
+ } else if (name.length === 2) {
63
+ reasons.push('Two-character domain (very rare)');
64
+ } else if (name.length === 3) {
65
+ reasons.push('Three-character domain (short and memorable)');
66
+ } else if (name.length === 4) {
67
+ reasons.push('Four-character domain (concise)');
68
+ }
69
+
70
+ // Dictionary word check
71
+ if (PREMIUM_KEYWORDS.has(name)) {
72
+ reasons.push(`Popular keyword "${name}"`);
73
+ }
74
+
75
+ // Numeric patterns
76
+ for (const pattern of PREMIUM_NUMBER_PATTERNS) {
77
+ if (pattern.test(name)) {
78
+ reasons.push('Valuable numeric pattern');
79
+ break;
80
+ }
81
+ }
82
+
83
+ // All letters same (aaa, bbb)
84
+ if (/^(.)\1+$/.test(name) && name.length <= 4) {
85
+ reasons.push('Repeating character pattern');
86
+ }
87
+
88
+ // Premium-heavy TLD
89
+ if (PREMIUM_HEAVY_TLDS.has(tld)) {
90
+ reasons.push(`High-demand .${tld} extension`);
91
+ }
92
+
93
+ // Acronym-style (all caps letters, 2-4 chars)
94
+ if (/^[a-z]{2,4}$/.test(name) && name.toUpperCase() === name.toUpperCase()) {
95
+ const couldBeAcronym = name.length <= 4 && !PREMIUM_KEYWORDS.has(name);
96
+ if (couldBeAcronym && reasons.length === 0) {
97
+ reasons.push('Potential acronym/initials');
98
+ }
99
+ }
100
+
101
+ return reasons;
102
+ }
103
+
104
+ // ═══════════════════════════════════════════════════════════════════════════
105
+ // Premium Insights Generation
106
+ // ═══════════════════════════════════════════════════════════════════════════
107
+
108
+ /**
109
+ * Standard TLD pricing for comparison (approximate first-year prices).
110
+ */
111
+ const STANDARD_TLD_PRICES: Record<string, number> = {
112
+ com: 10,
113
+ net: 12,
114
+ org: 12,
115
+ io: 40,
116
+ co: 25,
117
+ dev: 12,
118
+ app: 14,
119
+ ai: 80,
120
+ xyz: 3,
121
+ me: 8,
122
+ info: 5,
123
+ tech: 8,
124
+ cloud: 10,
125
+ };
126
+
127
+ /**
128
+ * Generate detailed premium insights for a domain result.
129
+ */
130
+ export function generatePremiumInsight(result: DomainResult): string | null {
131
+ if (!result.premium || !result.available) {
132
+ return null;
133
+ }
134
+
135
+ const domain = result.domain;
136
+ const tld = domain.split('.').pop()!;
137
+ const reasons = analyzePremiumReason(domain);
138
+ const standardPrice = STANDARD_TLD_PRICES[tld] || 15;
139
+
140
+ const parts: string[] = [];
141
+
142
+ // Price markup insight
143
+ if (result.price_first_year !== null) {
144
+ const markup = result.price_first_year / standardPrice;
145
+ if (markup >= 100) {
146
+ parts.push(`💎 ${domain} is priced at $${result.price_first_year} (${Math.round(markup)}x standard .${tld} pricing)`);
147
+ } else if (markup >= 10) {
148
+ parts.push(`💰 ${domain} is priced at $${result.price_first_year} (${Math.round(markup)}x standard pricing)`);
149
+ } else {
150
+ parts.push(`⚠️ ${domain} is a premium domain at $${result.price_first_year}`);
151
+ }
152
+ } else {
153
+ parts.push(`⚠️ ${domain} is marked as premium (price varies)`);
154
+ }
155
+
156
+ // Why it's premium
157
+ if (reasons.length > 0) {
158
+ parts.push(`Why premium: ${reasons.join(', ')}`);
159
+ }
160
+
161
+ return parts.join(' — ');
162
+ }
163
+
164
+ /**
165
+ * Suggest alternatives when a domain is premium.
166
+ */
167
+ export function suggestPremiumAlternatives(domain: string): string[] {
168
+ const name = domain.split('.')[0]!;
169
+ const tld = domain.split('.').pop()!;
170
+ const suggestions: string[] = [];
171
+
172
+ // Prefix/suffix variations
173
+ const prefixes = ['get', 'try', 'use', 'my', 'the', 'go'];
174
+ const suffixes = ['app', 'hq', 'io', 'now', 'hub', 'labs'];
175
+
176
+ // Add a prefix
177
+ const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]!;
178
+ suggestions.push(`${prefix}${name}.${tld}`);
179
+
180
+ // Add a suffix
181
+ const suffix = suffixes[Math.floor(Math.random() * suffixes.length)]!;
182
+ suggestions.push(`${name}${suffix}.${tld}`);
183
+
184
+ // Try alternative TLDs (cheaper ones)
185
+ const cheaperTlds = ['co', 'dev', 'app', 'me', 'xyz'].filter(t => t !== tld);
186
+ if (cheaperTlds.length > 0) {
187
+ suggestions.push(`${name}.${cheaperTlds[0]}`);
188
+ }
189
+
190
+ // Double the last letter (creative variation)
191
+ if (name.length >= 3) {
192
+ const doubled = name + name[name.length - 1];
193
+ suggestions.push(`${doubled}.${tld}`);
194
+ }
195
+
196
+ return suggestions.slice(0, 3); // Max 3 suggestions
197
+ }
198
+
199
+ // ═══════════════════════════════════════════════════════════════════════════
200
+ // Domain Score Calculation
201
+ // ═══════════════════════════════════════════════════════════════════════════
202
+
203
+ /**
204
+ * Calculate a quality score for a domain result.
205
+ * Score factors:
206
+ * - Price competitiveness (vs standard pricing)
207
+ * - WHOIS privacy included
208
+ * - Renewal price reasonableness
209
+ * - Premium status (negative factor)
210
+ */
211
+ export function calculateDomainScore(result: DomainResult): number {
212
+ if (!result.available) return 0;
213
+
214
+ let score = 5; // Base score
215
+
216
+ const tld = result.domain.split('.').pop()!;
217
+ const standardPrice = STANDARD_TLD_PRICES[tld] || 15;
218
+
219
+ // Price factor (0-3 points)
220
+ if (result.price_first_year !== null) {
221
+ const priceRatio = result.price_first_year / standardPrice;
222
+ if (priceRatio <= 0.5) score += 3; // Great deal
223
+ else if (priceRatio <= 1.0) score += 2; // Good price
224
+ else if (priceRatio <= 2.0) score += 1; // Fair
225
+ else if (priceRatio > 5) score -= 2; // Expensive
226
+ }
227
+
228
+ // Privacy included (+1 point)
229
+ if (result.privacy_included) {
230
+ score += 1;
231
+ }
232
+
233
+ // Renewal price check (+1 point if reasonable)
234
+ if (result.price_renewal !== null && result.price_first_year !== null) {
235
+ const renewalRatio = result.price_renewal / result.price_first_year;
236
+ if (renewalRatio <= 1.5) score += 1; // Renewal is reasonable
237
+ }
238
+
239
+ // Premium penalty (-1 point)
240
+ if (result.premium) {
241
+ score -= 1;
242
+ }
243
+
244
+ // TLD popularity bonus
245
+ if (['com', 'io', 'dev', 'app', 'co'].includes(tld)) {
246
+ score += 0.5;
247
+ }
248
+
249
+ // Clamp to 0-10
250
+ return Math.max(0, Math.min(10, Math.round(score * 10) / 10));
251
+ }
252
+
253
+ /**
254
+ * Generate insights summary for multiple premium domains.
255
+ */
256
+ export function generatePremiumSummary(results: DomainResult[]): string[] {
257
+ const premiums = results.filter(r => r.premium && r.available);
258
+ if (premiums.length === 0) return [];
259
+
260
+ const insights: string[] = [];
261
+
262
+ // Count by reason
263
+ const allReasons = premiums.flatMap(r => analyzePremiumReason(r.domain));
264
+ const reasonCounts = allReasons.reduce((acc, reason) => {
265
+ acc[reason] = (acc[reason] || 0) + 1;
266
+ return acc;
267
+ }, {} as Record<string, number>);
268
+
269
+ // Most common reason
270
+ const topReason = Object.entries(reasonCounts)
271
+ .sort((a, b) => b[1] - a[1])[0];
272
+
273
+ if (topReason) {
274
+ insights.push(`💎 Premium domains detected: ${topReason[0]}`);
275
+ }
276
+
277
+ // Total premium price vs standard
278
+ const totalPremiumCost = premiums
279
+ .filter(p => p.price_first_year !== null)
280
+ .reduce((sum, p) => sum + (p.price_first_year || 0), 0);
281
+
282
+ const totalStandardCost = premiums
283
+ .reduce((sum, p) => {
284
+ const tld = p.domain.split('.').pop()!;
285
+ return sum + (STANDARD_TLD_PRICES[tld] || 15);
286
+ }, 0);
287
+
288
+ if (totalPremiumCost > 0 && totalPremiumCost > totalStandardCost * 2) {
289
+ insights.push(
290
+ `💡 Premium pricing is ${Math.round(totalPremiumCost / totalStandardCost)}x standard — consider name variations`
291
+ );
292
+ }
293
+
294
+ // Suggest alternatives if all are premium
295
+ if (premiums.length === results.filter(r => r.available).length && premiums.length > 0) {
296
+ const alternatives = suggestPremiumAlternatives(premiums[0]!.domain);
297
+ if (alternatives.length > 0) {
298
+ insights.push(`💡 Try: ${alternatives.join(', ')}`);
299
+ }
300
+ }
301
+
302
+ return insights;
303
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Domain Name Validators.
3
+ *
4
+ * Validates domain names, TLDs, and other inputs.
5
+ * Provides user-friendly error messages.
6
+ */
7
+
8
+ import { config } from '../config.js';
9
+ import { InvalidDomainError, UnsupportedTldError } from './errors.js';
10
+
11
+ /**
12
+ * Valid domain name pattern (without TLD).
13
+ * - 1-63 characters per label
14
+ * - Alphanumeric and hyphens
15
+ * - Cannot start or end with hyphen
16
+ * - Cannot have consecutive hyphens (except for IDN: xn--)
17
+ */
18
+ const DOMAIN_LABEL_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
19
+
20
+ /**
21
+ * Valid TLD pattern.
22
+ * - 2-63 characters
23
+ * - Alphanumeric only (no hyphens in TLD)
24
+ */
25
+ const TLD_PATTERN = /^[a-z]{2,63}$/i;
26
+
27
+ /**
28
+ * Characters that are definitely not allowed in domains.
29
+ */
30
+ const INVALID_CHARS = /[^a-z0-9.-]/i;
31
+
32
+ /**
33
+ * Validate and normalize a domain name (without TLD).
34
+ *
35
+ * @param name - The domain name to validate
36
+ * @returns Normalized domain name (lowercase)
37
+ * @throws InvalidDomainError if invalid
38
+ */
39
+ export function validateDomainName(name: string): string {
40
+ // Trim and lowercase
41
+ const normalized = name.trim().toLowerCase();
42
+
43
+ // Check for empty
44
+ if (!normalized) {
45
+ throw new InvalidDomainError(name, 'Domain name cannot be empty');
46
+ }
47
+
48
+ // Check length
49
+ if (normalized.length > 63) {
50
+ throw new InvalidDomainError(
51
+ name,
52
+ `Domain name too long (${normalized.length} chars, max 63)`,
53
+ );
54
+ }
55
+
56
+ // Check for invalid characters
57
+ if (INVALID_CHARS.test(normalized)) {
58
+ const invalidChar = normalized.match(INVALID_CHARS)?.[0];
59
+ throw new InvalidDomainError(
60
+ name,
61
+ `Contains invalid character: "${invalidChar}"`,
62
+ );
63
+ }
64
+
65
+ // Check pattern
66
+ if (!DOMAIN_LABEL_PATTERN.test(normalized)) {
67
+ if (normalized.startsWith('-')) {
68
+ throw new InvalidDomainError(name, 'Cannot start with a hyphen');
69
+ }
70
+ if (normalized.endsWith('-')) {
71
+ throw new InvalidDomainError(name, 'Cannot end with a hyphen');
72
+ }
73
+ throw new InvalidDomainError(
74
+ name,
75
+ 'Invalid format. Use only letters, numbers, and hyphens.',
76
+ );
77
+ }
78
+
79
+ return normalized;
80
+ }
81
+
82
+ /**
83
+ * Validate a TLD.
84
+ *
85
+ * @param tld - The TLD to validate (with or without leading dot)
86
+ * @returns Normalized TLD (lowercase, no dot)
87
+ * @throws UnsupportedTldError if invalid or not allowed
88
+ */
89
+ export function validateTld(tld: string): string {
90
+ // Remove leading dot if present
91
+ const normalized = tld.replace(/^\./, '').trim().toLowerCase();
92
+
93
+ // Check for empty
94
+ if (!normalized) {
95
+ throw new UnsupportedTldError(tld, config.allowedTlds);
96
+ }
97
+
98
+ // Check pattern
99
+ if (!TLD_PATTERN.test(normalized)) {
100
+ throw new UnsupportedTldError(normalized, config.allowedTlds);
101
+ }
102
+
103
+ // Check against deny list
104
+ if (config.denyTlds.includes(normalized)) {
105
+ throw new UnsupportedTldError(normalized, config.allowedTlds);
106
+ }
107
+
108
+ // Check against allow list (if not empty)
109
+ if (
110
+ config.allowedTlds.length > 0 &&
111
+ !config.allowedTlds.includes(normalized)
112
+ ) {
113
+ throw new UnsupportedTldError(normalized, config.allowedTlds);
114
+ }
115
+
116
+ return normalized;
117
+ }
118
+
119
+ /**
120
+ * Parse a full domain name into name and TLD.
121
+ *
122
+ * @param fullDomain - Full domain like "example.com" or "sub.example.co.uk"
123
+ * @returns Object with name and tld
124
+ * @throws InvalidDomainError if parsing fails
125
+ */
126
+ export function parseDomain(fullDomain: string): { name: string; tld: string } {
127
+ const normalized = fullDomain.trim().toLowerCase();
128
+
129
+ // Find the last dot
130
+ const lastDot = normalized.lastIndexOf('.');
131
+ if (lastDot === -1) {
132
+ throw new InvalidDomainError(
133
+ fullDomain,
134
+ 'No TLD found. Include the extension (e.g., "example.com")',
135
+ );
136
+ }
137
+
138
+ const name = normalized.slice(0, lastDot);
139
+ const tld = normalized.slice(lastDot + 1);
140
+
141
+ // Handle multi-part TLDs (e.g., co.uk) - for simplicity, we don't support these yet
142
+ // In a full implementation, you'd use the Public Suffix List
143
+
144
+ return {
145
+ name: validateDomainName(name),
146
+ tld: validateTld(tld),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Validate an array of TLDs.
152
+ *
153
+ * @param tlds - Array of TLDs to validate
154
+ * @returns Array of normalized TLDs
155
+ */
156
+ export function validateTlds(tlds: string[]): string[] {
157
+ if (!tlds || tlds.length === 0) {
158
+ // Return default TLDs
159
+ return ['com', 'io', 'dev'];
160
+ }
161
+
162
+ return tlds.map(validateTld);
163
+ }
164
+
165
+ /**
166
+ * Check if a string looks like a full domain (has a dot).
167
+ */
168
+ export function isFullDomain(input: string): boolean {
169
+ return input.includes('.');
170
+ }
171
+
172
+ /**
173
+ * Build a full domain from name and TLD.
174
+ */
175
+ export function buildDomain(name: string, tld: string): string {
176
+ return `${validateDomainName(name)}.${validateTld(tld)}`;
177
+ }
178
+
179
+ /**
180
+ * Validate registrar name.
181
+ */
182
+ export function validateRegistrar(registrar: string): string {
183
+ const normalized = registrar.trim().toLowerCase();
184
+ const validRegistrars = ['porkbun', 'namecheap', 'godaddy'];
185
+
186
+ if (!validRegistrars.includes(normalized)) {
187
+ throw new Error(
188
+ `Invalid registrar: "${registrar}". Valid options: ${validRegistrars.join(', ')}`,
189
+ );
190
+ }
191
+
192
+ return normalized;
193
+ }