Initial release: business-name finder with live domain availability
Free, no-account tool for naming a business and finding a domain you can actually register. Availability uses no paid API and no key. IANA's RDAP bootstrap maps ~1,200 TLDs to their authoritative registry servers (404 = free, 200 = taken); the handful with no RDAP at all (.io, .co, .me, .sh, .gg, .us) fall back to a DNS NS lookup and are reported as "probably free" rather than confirmed, because a registered-but-undelegated domain is indistinguishable that way. Name generation is pure, deterministic, and client-side — eight strategies over a curated word bank, ranked by a scorer that does the real quality work. Three of its guards (prefix-only hint matching, -er-only syncopation, truncation rejection) exist because of specific bad output and should not be relaxed. Optional AI suggestions use the visitor's OWN Anthropic/Gemini key from localStorage, called browser-direct, so this stays free to run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JChWdFJPCRxMBxErb8kUVK
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.tmp
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
|
.DS_Store
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/node_modules
|
||||||
|
/.next
|
||||||
|
/out
|
||||||
|
/build
|
||||||
|
/.tmp
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@otm:registry=https://git.poweredbyotm.com/api/packages/tonym/npm/
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# ---------- deps ----------
|
||||||
|
FROM node:20-bookworm-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY .npmrc ./
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
# ---------- build ----------
|
||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# Ensure public/ exists even if the repo ships it empty — Next standalone's
|
||||||
|
# runner stage COPYs it, and Docker errors on a missing source path.
|
||||||
|
RUN mkdir -p public && npm run build
|
||||||
|
|
||||||
|
# ---------- runtime ----------
|
||||||
|
FROM node:20-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& groupadd --system --gid 1001 nodejs \
|
||||||
|
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
92
README.md
Normal file
92
README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# names.poweredbyotm.com
|
||||||
|
|
||||||
|
Free business-name finder: type a few keywords, get brandable name ideas, and see
|
||||||
|
which domains are actually free — checked against the registries themselves.
|
||||||
|
|
||||||
|
- **Stack:** Next.js 15 (App Router, `output: "standalone"`), React 18, no DB, no
|
||||||
|
auth, public. Name generation runs **client-side**; availability is a server
|
||||||
|
route because browsers can't do DNS and RDAP servers send no CORS headers.
|
||||||
|
- **Repo:** `tonym/names` on `git.poweredbyotm.com`.
|
||||||
|
- **Deploy:** OTM platform — first-party app (`/apps`), built + deployed via the
|
||||||
|
platform MCP, not docker-compose-from-this-repo.
|
||||||
|
|
||||||
|
## How availability works — and what it costs (nothing)
|
||||||
|
|
||||||
|
There is no paid domain API here and no key to manage.
|
||||||
|
|
||||||
|
1. **RDAP** (RFC 7482), the registries' own replacement for WHOIS. IANA
|
||||||
|
publishes a bootstrap file at `data.iana.org/rdap/dns.json` mapping every TLD
|
||||||
|
to its authoritative RDAP server. We cache it for 24h, resolve the TLD
|
||||||
|
ourselves, and query that server directly — `404` means unregistered, `200`
|
||||||
|
means registered. Covers ~1,200 TLDs including `.com/.net/.org/.ai/.dev/.app`.
|
||||||
|
2. **DNS `NS` lookup**, for the TLDs that publish no RDAP server at all — `.io`,
|
||||||
|
`.co`, `.me`, `.sh`, `.gg`, `.us`, `.de`. NXDOMAIN strongly suggests
|
||||||
|
unregistered, but a registered domain with no delegated nameservers looks
|
||||||
|
identical. These report `unverified-available` and the UI shows them in amber
|
||||||
|
as *probably free*, never as a confirmed green.
|
||||||
|
|
||||||
|
**Do not "simplify" this by pointing everything at `rdap.org`.** Its redirector
|
||||||
|
returns a bare `404` both for "this domain is free" and for "I have no RDAP
|
||||||
|
server for this TLD" — the two cases are indistinguishable from the status code,
|
||||||
|
which is exactly the bug that would report every `.io` as available.
|
||||||
|
|
||||||
|
Manners, since we're an anonymous client against other people's public
|
||||||
|
endpoints: 60 domains max per request, 8-wide concurrency, a per-IP token
|
||||||
|
bucket, and a 10-minute result cache.
|
||||||
|
|
||||||
|
## Name generation
|
||||||
|
|
||||||
|
`src/lib/generate.ts` is pure and deterministic — the same
|
||||||
|
`(keywords, style, seed)` always yields the same list, so re-renders don't
|
||||||
|
reshuffle results and a shared URL reproduces what the sender saw.
|
||||||
|
|
||||||
|
Eight strategies (compound, suffix-word, action, blend, coined, clipped, root,
|
||||||
|
domain hack) over the vocabulary in `src/lib/wordbank.ts`. The strategies are
|
||||||
|
cheap and dumb on purpose — **quality comes from `scoreName`**, so tune the
|
||||||
|
scorer before adding more words.
|
||||||
|
|
||||||
|
Three guards exist because of specific bad output, and removing them brings it
|
||||||
|
straight back:
|
||||||
|
|
||||||
|
- `expandKeywords` matches category hints by **prefix, never substring** — plain
|
||||||
|
containment let `repair` match the `ai` category (rep-**ai**-r) and drag
|
||||||
|
neural-network vocabulary into an auto-shop brief.
|
||||||
|
- `syncopate` only drops the schwa from a final `-er` (`flicker` → `flickr`).
|
||||||
|
The original "drop the last vowel" produced `crema` → `crem`, `kettle` →
|
||||||
|
`kettl`, `security` → `securit`.
|
||||||
|
- `isTruncation` rejects any candidate that is a strict prefix of a word the
|
||||||
|
generator knows. This is what keeps stumps out of the results.
|
||||||
|
|
||||||
|
`diversify()` caps each strategy at ~28% of the returned list. Without it the
|
||||||
|
top 20 came back as eighteen coinages — scoring alone clumps badly.
|
||||||
|
|
||||||
|
## Bring-your-own AI key
|
||||||
|
|
||||||
|
Optional. `/settings` stores an Anthropic or Gemini key in `localStorage`
|
||||||
|
(`names.aiKey`) and the browser calls that provider **directly** — the key never
|
||||||
|
touches our server. Same pattern as `rpo`'s Google Vision key, for the same
|
||||||
|
reason: the platform has no per-app env tool, and this is a free public tool that
|
||||||
|
shouldn't hold a metered credential.
|
||||||
|
|
||||||
|
The `anthropic-dangerous-direct-browser-access` header is correct here and would
|
||||||
|
be wrong in a product that owns the key. The distinction is whose key it is.
|
||||||
|
|
||||||
|
## Registrar links
|
||||||
|
|
||||||
|
`src/lib/registrars.ts`. Links are plain today — every `affiliateCode` is `""`.
|
||||||
|
Fill one in and `HAS_AFFILIATE_LINKS` flips, which turns on the footer
|
||||||
|
disclosure automatically. Keeping both in one file is deliberate: a disclosure
|
||||||
|
that can drift out of sync with whether links actually pay is worse than none.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
No env vars are needed to run it. The suggestions lightbulb degrades to a quiet
|
||||||
|
"not configured" state unless `GITEA_URL` / `GITEA_TOKEN` / `GITEA_REPO` are set
|
||||||
|
(the platform injects those at deploy time).
|
||||||
16
next.config.mjs
Normal file
16
next.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
transpilePackages: ["@otm/account-panel"],
|
||||||
|
// Pin the standalone trace root to this app (a stray lockfile in $HOME made
|
||||||
|
// Next infer the wrong workspace root, which can drop files from the
|
||||||
|
// standalone bundle). Same guard rpo carries.
|
||||||
|
outputFileTracingRoot: __dirname,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
1210
package-lock.json
generated
Normal file
1210
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "names-poweredbyotm",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start --port 3000",
|
||||||
|
"lint": "next lint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@otm/account-panel": "0.32.0",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.9.1",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
85
src/app/api/availability/route.ts
Normal file
85
src/app/api/availability/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// POST /api/availability { domains: string[] } -> { results: DomainCheck[] }
|
||||||
|
//
|
||||||
|
// Server-side because browsers can't do DNS and most RDAP servers send no CORS
|
||||||
|
// headers. We are an anonymous client against other people's public registry
|
||||||
|
// endpoints, so this route is deliberately stingy: a hard per-request cap, a
|
||||||
|
// per-IP token bucket, and bounded concurrency inside checkDomains().
|
||||||
|
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { checkDomains } from "@/lib/rdap";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs"; // node:dns — not available on edge
|
||||||
|
|
||||||
|
/** Most a single request may ask for. A full page of results is ~40. */
|
||||||
|
const MAX_DOMAINS = 60;
|
||||||
|
|
||||||
|
// --- Per-IP token bucket -----------------------------------------------------
|
||||||
|
// In-memory, so it resets on redeploy and doesn't coordinate across replicas.
|
||||||
|
// That's fine: this is a courtesy throttle to keep one enthusiastic tab from
|
||||||
|
// hammering Verisign, not a security control.
|
||||||
|
|
||||||
|
const RATE_CAPACITY = 240; // domains
|
||||||
|
const RATE_REFILL_PER_SEC = 4;
|
||||||
|
const buckets = new Map<string, { tokens: number; at: number }>();
|
||||||
|
|
||||||
|
function clientIp(req: NextRequest): string {
|
||||||
|
const fwd = req.headers.get("x-forwarded-for");
|
||||||
|
if (fwd) return fwd.split(",")[0].trim();
|
||||||
|
return req.headers.get("x-real-ip") ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when the request may proceed, spending `cost` tokens. */
|
||||||
|
function takeTokens(ip: string, cost: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const b = buckets.get(ip) ?? { tokens: RATE_CAPACITY, at: now };
|
||||||
|
const refill = ((now - b.at) / 1000) * RATE_REFILL_PER_SEC;
|
||||||
|
b.tokens = Math.min(RATE_CAPACITY, b.tokens + refill);
|
||||||
|
b.at = now;
|
||||||
|
|
||||||
|
if (b.tokens < cost) {
|
||||||
|
buckets.set(ip, b);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b.tokens -= cost;
|
||||||
|
if (buckets.size > 10000) buckets.clear();
|
||||||
|
buckets.set(ip, b);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Expected a JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (body as { domains?: unknown } | null)?.domains;
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return Response.json({ error: "Expected { domains: string[] }" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = [
|
||||||
|
...new Set(
|
||||||
|
raw
|
||||||
|
.filter((d): d is string => typeof d === "string")
|
||||||
|
.map((d) => d.trim().toLowerCase())
|
||||||
|
.filter((d) => d.length > 0 && d.length <= 253),
|
||||||
|
),
|
||||||
|
].slice(0, MAX_DOMAINS);
|
||||||
|
|
||||||
|
if (!domains.length) {
|
||||||
|
return Response.json({ results: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!takeTokens(clientIp(req), domains.length)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Slow down a moment — too many lookups from this address." },
|
||||||
|
{ status: 429, headers: { "retry-after": "30" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await checkDomains(domains);
|
||||||
|
return Response.json({ results });
|
||||||
|
}
|
||||||
5
src/app/api/health/route.ts
Normal file
5
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return Response.json({ ok: true, service: "names" });
|
||||||
|
}
|
||||||
19
src/app/api/suggestions/[number]/comments/route.ts
Normal file
19
src/app/api/suggestions/[number]/comments/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { createSuggestionCommentsRoute } from "@otm/account-panel/server";
|
||||||
|
import { giteaConfig, suggestionGuard } from "@/lib/suggestions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// The factory types `params` as `{number}|Promise<{number}>` so it can mount on
|
||||||
|
// either Next 14 or 15; Next 15's generated route validator demands a strict
|
||||||
|
// `Promise<...>`. These thin re-typed re-exports exist only to satisfy that
|
||||||
|
// signature check — they add no logic. (Same shape as rpo's.)
|
||||||
|
const handlers = createSuggestionCommentsRoute({
|
||||||
|
gitea: giteaConfig(),
|
||||||
|
guard: suggestionGuard,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ number: string }> };
|
||||||
|
|
||||||
|
export const GET = (req: NextRequest, ctx: Ctx) => handlers.GET(req, ctx);
|
||||||
|
export const POST = (req: NextRequest, ctx: Ctx) => handlers.POST(req, ctx);
|
||||||
9
src/app/api/suggestions/route.ts
Normal file
9
src/app/api/suggestions/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createSuggestionsRoute } from "@otm/account-panel/server";
|
||||||
|
import { giteaConfig, suggestionGuard } from "@/lib/suggestions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const { GET, POST } = createSuggestionsRoute({
|
||||||
|
gitea: giteaConfig(),
|
||||||
|
guard: suggestionGuard,
|
||||||
|
});
|
||||||
21
src/app/api/suggestions/upload/route.ts
Normal file
21
src/app/api/suggestions/upload/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createSuggestionUploadRoute } from "@otm/account-panel/server";
|
||||||
|
import { suggestionGuard } from "@/lib/suggestions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Pasted screenshots land in this app's own public/uploads/suggestions/ and the
|
||||||
|
// issue body embeds an origin-relative URL — no shared upload service. The
|
||||||
|
// registry entry mounts /app/public/uploads as a bind mount so these survive a
|
||||||
|
// container recreate.
|
||||||
|
export const { POST } = createSuggestionUploadRoute({
|
||||||
|
guard: suggestionGuard,
|
||||||
|
save: async ({ name, bytes }) => {
|
||||||
|
const safe = `${Date.now()}-${name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||||
|
const dir = path.join(process.cwd(), "public", "uploads", "suggestions");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, safe), Buffer.from(bytes));
|
||||||
|
return { url: `/uploads/suggestions/${safe}` };
|
||||||
|
},
|
||||||
|
});
|
||||||
26
src/app/changelog/page.tsx
Normal file
26
src/app/changelog/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { CHANGELOG } from "@/lib/version";
|
||||||
|
|
||||||
|
export const metadata = { title: "Changelog · Business Name Finder" };
|
||||||
|
|
||||||
|
export default function ChangelogPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Changelog</h1>
|
||||||
|
<p className="tagline">
|
||||||
|
<Link href="/">← Back to the name finder</Link>
|
||||||
|
</p>
|
||||||
|
{CHANGELOG.map((entry) => (
|
||||||
|
<div className="entry" key={entry.version}>
|
||||||
|
<h3>v{entry.version}</h3>
|
||||||
|
<span className="date">{entry.date}</span>
|
||||||
|
<ul>
|
||||||
|
{entry.changes.map((c, i) => (
|
||||||
|
<li key={i}>{c}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
src/app/globals.css
Normal file
374
src/app/globals.css
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b0d10;
|
||||||
|
--panel: #14181d;
|
||||||
|
--panel-2: #1a1f26;
|
||||||
|
--panel-border: #232a31;
|
||||||
|
--text: #e7e9ec;
|
||||||
|
--muted: #93a1ad;
|
||||||
|
--faint: #6b7885;
|
||||||
|
--accent: #4f9cf9;
|
||||||
|
--accent-dim: #2b6fd0;
|
||||||
|
--ok: #3ecf8e;
|
||||||
|
--ok-bg: #10281f;
|
||||||
|
--warn: #f5b942;
|
||||||
|
--warn-bg: #33290f;
|
||||||
|
--taken: #5a646e;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 30px;
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 19px;
|
||||||
|
margin: 32px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 26px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- form ---------- */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--faint);
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- style + tld pickers ---------- */
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[aria-pressed="true"] {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
background: #16283f;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip .chip-note {
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- results ---------- */
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name .tld {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.available {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.unverified-available {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.taken {
|
||||||
|
color: var(--taken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.unknown,
|
||||||
|
.status.checking {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-taken {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strategy {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- notices ---------- */
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 16px 0;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.warn {
|
||||||
|
background: var(--warn-bg);
|
||||||
|
border-color: #6a5418;
|
||||||
|
color: #f2dfae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.info {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: var(--panel-border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 34px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- footer ---------- */
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 56px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--panel-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot-note {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--faint);
|
||||||
|
max-width: 62ch;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-chip {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- changelog ---------- */
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
border-left: 2px solid var(--panel-border);
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry .date {
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry ul {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
.results {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/layout.tsx
Normal file
47
src/app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { SuggestionLightbulb } from "@otm/account-panel";
|
||||||
|
import { VERSION } from "@/lib/version";
|
||||||
|
import { HAS_AFFILIATE_LINKS } from "@/lib/registrars";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Business Name Finder · Powered by OTM",
|
||||||
|
description:
|
||||||
|
"Free business name generator with live domain availability, checked against the registries themselves over RDAP. No account, no credit card.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="topbar">
|
||||||
|
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
|
||||||
|
Powered by OTM
|
||||||
|
</Link>
|
||||||
|
<SuggestionLightbulb />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<footer className="footer">
|
||||||
|
<span className="foot-note">
|
||||||
|
Availability comes from each registry’s own RDAP service, and from a DNS
|
||||||
|
check for the few extensions that publish none. It’s a strong signal, not a
|
||||||
|
reservation — a name is only yours once a registrar confirms the purchase.
|
||||||
|
{HAS_AFFILIATE_LINKS ? (
|
||||||
|
<> Some registrar links earn us a commission at no extra cost to you.</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="foot-meta">
|
||||||
|
<Link href="/settings">Settings</Link>
|
||||||
|
<Link href="/changelog" className="version-chip">
|
||||||
|
v{VERSION}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/page.tsx
Normal file
14
src/app/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Finder from "@/components/Finder";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Find a name you can actually register</h1>
|
||||||
|
<p className="tagline">
|
||||||
|
Describe the business, get brandable name ideas, and see which domains are free —
|
||||||
|
checked against the registries themselves, live. No account, no credit card, no trial.
|
||||||
|
</p>
|
||||||
|
<Finder />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/settings/page.tsx
Normal file
107
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { loadAiSettings, saveAiSettings, type AiProvider } from "@/lib/ai";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [provider, setProvider] = useState<AiProvider>("anthropic");
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [hasStored, setHasStored] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existing = loadAiSettings();
|
||||||
|
if (existing) {
|
||||||
|
setProvider(existing.provider);
|
||||||
|
setKey(existing.key);
|
||||||
|
setHasStored(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
saveAiSettings(trimmed ? { provider, key: trimmed } : null);
|
||||||
|
setHasStored(Boolean(trimmed));
|
||||||
|
setSaved(true);
|
||||||
|
window.setTimeout(() => setSaved(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClear = () => {
|
||||||
|
saveAiSettings(null);
|
||||||
|
setKey("");
|
||||||
|
setHasStored(false);
|
||||||
|
setSaved(true);
|
||||||
|
window.setTimeout(() => setSaved(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<p className="tagline">
|
||||||
|
<Link href="/">← Back to the name finder</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Bring your own AI key (optional)</h2>
|
||||||
|
<p className="tagline">
|
||||||
|
The name finder works fully offline without this — the built-in generator needs no
|
||||||
|
key and costs nothing. Adding your own API key gets you a second, usually more
|
||||||
|
imaginative set of suggestions alongside it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="notice info">
|
||||||
|
Your key is stored in <strong>this browser only</strong> and the request goes straight
|
||||||
|
from your browser to {provider === "anthropic" ? "Anthropic" : "Google"}. It is never sent
|
||||||
|
to our server and never appears in our logs. Usage is billed to your own account, so use
|
||||||
|
a key with a spending limit set.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="provider">Provider</label>
|
||||||
|
<select id="provider" value={provider} onChange={(e) => setProvider(e.target.value as AiProvider)}>
|
||||||
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
<option value="gemini">Google (Gemini)</option>
|
||||||
|
</select>
|
||||||
|
<p className="hint">
|
||||||
|
{provider === "anthropic" ? (
|
||||||
|
<>
|
||||||
|
Create a key at{" "}
|
||||||
|
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer">
|
||||||
|
console.anthropic.com
|
||||||
|
</a>
|
||||||
|
. Starts with <code>sk-ant-</code>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Create a key at{" "}
|
||||||
|
<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer">
|
||||||
|
aistudio.google.com
|
||||||
|
</a>
|
||||||
|
. Gemini has a free tier that is plenty for this.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="key">API key</label>
|
||||||
|
<input
|
||||||
|
id="key"
|
||||||
|
type="password"
|
||||||
|
value={key}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={provider === "anthropic" ? "sk-ant-…" : "AIza…"}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls">
|
||||||
|
<button className="primary" onClick={onSave}>
|
||||||
|
Save key
|
||||||
|
</button>
|
||||||
|
{hasStored ? <button onClick={onClear}>Remove key</button> : null}
|
||||||
|
{saved ? <span style={{ color: "var(--ok)", fontSize: 14 }}>Saved to this browser.</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
src/components/Finder.tsx
Normal file
425
src/components/Finder.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { generateNames, scoreName, type Idea, type Style } from "@/lib/generate";
|
||||||
|
import { DEFAULT_TLDS, TLDS, UNVERIFIABLE_TLDS } from "@/lib/tlds";
|
||||||
|
import { DEFAULT_REGISTRAR, REGISTRARS, registerUrl } from "@/lib/registrars";
|
||||||
|
import { generateWithAi, loadAiSettings, type AiSettings } from "@/lib/ai";
|
||||||
|
|
||||||
|
type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||||
|
|
||||||
|
interface DomainCheck {
|
||||||
|
domain: string;
|
||||||
|
status: Availability;
|
||||||
|
method: "rdap" | "dns" | "none";
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many names to put on screen. Each one costs `selectedTlds.length`
|
||||||
|
* registry lookups, so this is the main dial on how hard we lean on the
|
||||||
|
* registries — keep it modest. */
|
||||||
|
const NAME_COUNT = 24;
|
||||||
|
|
||||||
|
/** Domains per availability request. The route caps at 60; staying under it
|
||||||
|
* means results paint in progressively instead of in one late lump. */
|
||||||
|
const BATCH = 40;
|
||||||
|
|
||||||
|
const STYLES: Array<{ id: Style; label: string; note: string }> = [
|
||||||
|
{ id: "balanced", label: "Balanced", note: "a bit of everything" },
|
||||||
|
{ id: "real", label: "Real words", note: "northbakery, axlekit" },
|
||||||
|
{ id: "coined", label: "Coined", note: "novault, tensora" },
|
||||||
|
{ id: "short", label: "Short", note: "brevity first" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Finder() {
|
||||||
|
const [keywords, setKeywords] = useState("");
|
||||||
|
const [style, setStyle] = useState<Style>("balanced");
|
||||||
|
const [tlds, setTlds] = useState<string[]>(DEFAULT_TLDS);
|
||||||
|
const [registrar, setRegistrar] = useState(DEFAULT_REGISTRAR);
|
||||||
|
const [ideas, setIdeas] = useState<Idea[]>([]);
|
||||||
|
const [checks, setChecks] = useState<Record<string, DomainCheck>>({});
|
||||||
|
const [pending, setPending] = useState<Set<string>>(new Set());
|
||||||
|
const [seed, setSeed] = useState(1);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [ai, setAi] = useState<AiSettings | null>(null);
|
||||||
|
const [useAi, setUseAi] = useState(false);
|
||||||
|
|
||||||
|
// The saved key is read once on mount; Settings lives on its own page, so it
|
||||||
|
// can't change underneath us mid-session.
|
||||||
|
useEffect(() => {
|
||||||
|
setAi(loadAiSettings());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Abandon in-flight lookups when a new search starts, so a slow batch from
|
||||||
|
// the previous query can't paint stale statuses over the new results.
|
||||||
|
const runId = useRef(0);
|
||||||
|
|
||||||
|
const parsedKeywords = useMemo(
|
||||||
|
() =>
|
||||||
|
keywords
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((k) => k.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
[keywords],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Every domain a set of ideas implies, in display order. */
|
||||||
|
const domainsFor = useCallback(
|
||||||
|
(list: Idea[], selected: string[]): string[] => {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const idea of list) {
|
||||||
|
if (idea.hackTld && idea.hackStem) {
|
||||||
|
out.push(`${idea.hackStem}.${idea.hackTld}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const tld of selected) out.push(`${idea.name}.${tld}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lookup = useCallback(async (domains: string[], myRun: number) => {
|
||||||
|
setPending(new Set(domains));
|
||||||
|
|
||||||
|
for (let i = 0; i < domains.length; i += BATCH) {
|
||||||
|
if (runId.current !== myRun) return; // superseded by a newer search
|
||||||
|
const slice = domains.slice(i, i + BATCH);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/availability", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ domains: slice }),
|
||||||
|
});
|
||||||
|
if (runId.current !== myRun) return;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(body.error ?? `Availability lookup failed (${res.status}).`);
|
||||||
|
setPending(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { results: DomainCheck[] };
|
||||||
|
setChecks((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const r of body.results) next[r.domain] = r;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (runId.current !== myRun) return;
|
||||||
|
setError("Could not reach the availability service.");
|
||||||
|
setPending(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const d of slice) next.delete(d);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (nextSeed: number) => {
|
||||||
|
const myRun = ++runId.current;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setChecks({});
|
||||||
|
|
||||||
|
let list = generateNames({
|
||||||
|
keywords: parsedKeywords,
|
||||||
|
style,
|
||||||
|
count: NAME_COUNT,
|
||||||
|
seed: nextSeed,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The visitor's own AI key, if they saved one and asked us to use it.
|
||||||
|
// A failure here is never fatal — the built-in list is already computed.
|
||||||
|
if (useAi && ai) {
|
||||||
|
try {
|
||||||
|
const aiNames = await generateWithAi(ai, parsedKeywords, style, 16);
|
||||||
|
if (runId.current !== myRun) return;
|
||||||
|
const seen = new Set(list.map((i) => i.name));
|
||||||
|
const extra: Idea[] = aiNames
|
||||||
|
.filter((n) => !seen.has(n))
|
||||||
|
.map((name) => ({ name, strategy: "ai" as const, score: scoreName(name, parsedKeywords) }));
|
||||||
|
list = [...extra, ...list].slice(0, NAME_COUNT + extra.length);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
`AI names unavailable (${err instanceof Error ? err.message : "unknown error"}). Showing built-in suggestions.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runId.current !== myRun) return;
|
||||||
|
setIdeas(list);
|
||||||
|
setBusy(false);
|
||||||
|
await lookup(domainsFor(list, tlds), myRun);
|
||||||
|
},
|
||||||
|
[ai, domainsFor, lookup, parsedKeywords, style, tlds, useAi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onGenerate = () => {
|
||||||
|
const next = seed + 1;
|
||||||
|
setSeed(next);
|
||||||
|
void run(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTld = (tld: string) => {
|
||||||
|
setTlds((prev) => (prev.includes(tld) ? prev.filter((t) => t !== tld) : [...prev, tld]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const showsUnverifiable = tlds.some((t) => UNVERIFIABLE_TLDS.includes(t));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="keywords">What’s the business?</label>
|
||||||
|
<input
|
||||||
|
id="keywords"
|
||||||
|
type="text"
|
||||||
|
value={keywords}
|
||||||
|
placeholder="auto repair, brake specialist — or just: bakery"
|
||||||
|
onChange={(e) => setKeywords(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onGenerate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="hint">
|
||||||
|
A few words is plenty. Leave it empty and you’ll get an abstract, category-free
|
||||||
|
shortlist instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Style</label>
|
||||||
|
<div className="chips">
|
||||||
|
{STYLES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className="chip"
|
||||||
|
aria-pressed={style === s.id}
|
||||||
|
onClick={() => setStyle(s.id)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
<span className="chip-note">{s.note}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>Extensions to check</label>
|
||||||
|
<div className="chips">
|
||||||
|
{TLDS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.tld}
|
||||||
|
type="button"
|
||||||
|
className="chip"
|
||||||
|
aria-pressed={tlds.includes(t.tld)}
|
||||||
|
title={t.note}
|
||||||
|
onClick={() => toggleTld(t.tld)}
|
||||||
|
>
|
||||||
|
.{t.tld}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls">
|
||||||
|
<button className="primary" onClick={onGenerate} disabled={busy || tlds.length === 0}>
|
||||||
|
{busy ? "Thinking…" : ideas.length ? "Show me more" : "Find names"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
|
||||||
|
>
|
||||||
|
Register at
|
||||||
|
<select
|
||||||
|
value={registrar}
|
||||||
|
onChange={(e) => setRegistrar(e.target.value)}
|
||||||
|
style={{ width: "auto", padding: "8px 10px", fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{REGISTRARS.map((r) => (
|
||||||
|
<option key={r.id} value={r.id} title={r.note}>
|
||||||
|
{r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{ai ? (
|
||||||
|
<label
|
||||||
|
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14, color: "var(--muted)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useAi}
|
||||||
|
onChange={(e) => setUseAi(e.target.checked)}
|
||||||
|
style={{ width: "auto" }}
|
||||||
|
/>
|
||||||
|
Use my {ai.provider === "anthropic" ? "Claude" : "Gemini"} key
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tlds.length === 0 ? (
|
||||||
|
<p className="notice info">Pick at least one extension to check.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="notice warn">{error}</p> : null}
|
||||||
|
|
||||||
|
{showsUnverifiable && ideas.length > 0 ? (
|
||||||
|
<p className="notice info">
|
||||||
|
{UNVERIFIABLE_TLDS.filter((t) => tlds.includes(t))
|
||||||
|
.map((t) => `.${t}`)
|
||||||
|
.join(", ")}{" "}
|
||||||
|
{UNVERIFIABLE_TLDS.filter((t) => tlds.includes(t)).length === 1 ? "publishes" : "publish"} no
|
||||||
|
RDAP service, so those are checked with a DNS lookup instead and shown in amber as{" "}
|
||||||
|
<em>probably free</em>. A registered-but-unused domain looks identical — confirm at
|
||||||
|
the registrar before you count on one.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ideas.length === 0 ? (
|
||||||
|
<p className="empty">
|
||||||
|
{busy ? "Working…" : "Describe the business above and press Find names."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="results">
|
||||||
|
{ideas.map((idea) => (
|
||||||
|
<NameCard
|
||||||
|
key={idea.name}
|
||||||
|
idea={idea}
|
||||||
|
tlds={tlds}
|
||||||
|
checks={checks}
|
||||||
|
pending={pending}
|
||||||
|
registrar={registrar}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameCard({
|
||||||
|
idea,
|
||||||
|
tlds,
|
||||||
|
checks,
|
||||||
|
pending,
|
||||||
|
registrar,
|
||||||
|
}: {
|
||||||
|
idea: Idea;
|
||||||
|
tlds: string[];
|
||||||
|
checks: Record<string, DomainCheck>;
|
||||||
|
pending: Set<string>;
|
||||||
|
registrar: string;
|
||||||
|
}) {
|
||||||
|
const isHack = Boolean(idea.hackTld && idea.hackStem);
|
||||||
|
const domains = isHack ? [`${idea.hackStem}.${idea.hackTld}`] : tlds.map((t) => `${idea.name}.${t}`);
|
||||||
|
|
||||||
|
// Dim the whole card only when every extension we checked came back taken.
|
||||||
|
const settled = domains.map((d) => checks[d]).filter(Boolean);
|
||||||
|
const allTaken = settled.length === domains.length && settled.every((c) => c.status === "taken");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`card${allTaken ? " is-taken" : ""}`}>
|
||||||
|
<div className="card-name">
|
||||||
|
{isHack ? (
|
||||||
|
<>
|
||||||
|
{idea.hackStem}
|
||||||
|
<span className="tld">.{idea.hackTld}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
idea.name
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{domains.map((domain) => {
|
||||||
|
const check = checks[domain];
|
||||||
|
const isPending = pending.has(domain) && !check;
|
||||||
|
const status: Availability | "checking" = isPending ? "checking" : (check?.status ?? "unknown");
|
||||||
|
const free = status === "available" || status === "unverified-available";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-meta" key={domain}>
|
||||||
|
<span className={`status ${status}`} title={check?.detail}>
|
||||||
|
{statusIcon(status)} {isHack ? domain : `.${domain.slice(idea.name.length + 1)}`}{" "}
|
||||||
|
<span style={{ fontWeight: 400 }}>{statusLabel(status)}</span>
|
||||||
|
</span>
|
||||||
|
{free ? (
|
||||||
|
<a
|
||||||
|
className="register-link"
|
||||||
|
href={registerUrl(domain, registrar)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Register →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<span className="strategy">{strategyLabel(idea.strategy)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(status: Availability | "checking"): string {
|
||||||
|
switch (status) {
|
||||||
|
case "available":
|
||||||
|
return "●";
|
||||||
|
case "unverified-available":
|
||||||
|
return "◐";
|
||||||
|
case "taken":
|
||||||
|
return "○";
|
||||||
|
case "checking":
|
||||||
|
return "…";
|
||||||
|
default:
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: Availability | "checking"): string {
|
||||||
|
switch (status) {
|
||||||
|
case "available":
|
||||||
|
return "free";
|
||||||
|
case "unverified-available":
|
||||||
|
return "probably free";
|
||||||
|
case "taken":
|
||||||
|
return "taken";
|
||||||
|
case "checking":
|
||||||
|
return "checking";
|
||||||
|
default:
|
||||||
|
return "unclear";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strategyLabel(strategy: Idea["strategy"]): string {
|
||||||
|
switch (strategy) {
|
||||||
|
case "compound":
|
||||||
|
return "compound";
|
||||||
|
case "suffix-word":
|
||||||
|
return "compound";
|
||||||
|
case "action":
|
||||||
|
return "call to action";
|
||||||
|
case "blend":
|
||||||
|
return "blend";
|
||||||
|
case "coined":
|
||||||
|
return "coined";
|
||||||
|
case "clipped":
|
||||||
|
return "clipped";
|
||||||
|
case "root":
|
||||||
|
return "classical root";
|
||||||
|
case "hack":
|
||||||
|
return "domain hack";
|
||||||
|
case "ai":
|
||||||
|
return "your AI key";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/lib/ai.ts
Normal file
144
src/lib/ai.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// Optional AI name generation, using the VISITOR'S OWN API key.
|
||||||
|
//
|
||||||
|
// The key lives in this browser's localStorage and the request goes straight
|
||||||
|
// from the browser to Anthropic/Google. It never touches our server, never
|
||||||
|
// appears in our logs, and we never hold a metered credential for a free public
|
||||||
|
// tool. Same pattern rpo uses for its Google Vision key.
|
||||||
|
//
|
||||||
|
// Anthropic normally refuses browser-origin calls to protect people from
|
||||||
|
// shipping a secret key in a web bundle; `anthropic-dangerous-direct-browser-
|
||||||
|
// access` opts out. That header is correct HERE and would be wrong in a product
|
||||||
|
// that owns the key — the distinction is whose key it is. The user pasted their
|
||||||
|
// own, knowingly, into their own browser.
|
||||||
|
|
||||||
|
export type AiProvider = "anthropic" | "gemini";
|
||||||
|
|
||||||
|
export interface AiSettings {
|
||||||
|
provider: AiProvider;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "names.aiKey";
|
||||||
|
|
||||||
|
export function loadAiSettings(): AiSettings | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AiSettings>;
|
||||||
|
if (!parsed.key || (parsed.provider !== "anthropic" && parsed.provider !== "gemini")) return null;
|
||||||
|
return { provider: parsed.provider, key: parsed.key };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAiSettings(settings: AiSettings | null): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!settings) window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
else window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = [
|
||||||
|
"You name businesses. Given a description, return brandable company names.",
|
||||||
|
"",
|
||||||
|
"Rules:",
|
||||||
|
"- 4 to 12 letters, lowercase, letters only (no spaces, digits, or hyphens).",
|
||||||
|
"- Easy to say out loud and to spell after hearing it once.",
|
||||||
|
"- Prefer real words, evocative compounds, and clean coinages.",
|
||||||
|
"- Avoid: the literal category word alone, startup filler (-ly on everything,",
|
||||||
|
" 'solutions', 'synergy', 'hub' overuse), and misspellings of common words.",
|
||||||
|
"- Vary the register: some grounded and concrete, some abstract and premium.",
|
||||||
|
"",
|
||||||
|
'Respond with ONLY a JSON array of strings. No prose, no code fence.',
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
function userPrompt(keywords: string[], style: string, count: number): string {
|
||||||
|
return [
|
||||||
|
`Business description / keywords: ${keywords.join(", ")}`,
|
||||||
|
`Preferred style: ${style}`,
|
||||||
|
`Return exactly ${count} names as a JSON array of lowercase strings.`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a JSON array of names out of a model response that may still have prose
|
||||||
|
* or a code fence around it. */
|
||||||
|
function parseNames(text: string): string[] {
|
||||||
|
const start = text.indexOf("[");
|
||||||
|
const end = text.lastIndexOf("]");
|
||||||
|
if (start === -1 || end === -1 || end <= start) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text.slice(start, end + 1)) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter((n): n is string => typeof n === "string")
|
||||||
|
.map((n) => n.toLowerCase().replace(/[^a-z]/g, ""))
|
||||||
|
.filter((n) => n.length >= 3 && n.length <= 18);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAnthropic(key: string, prompt: string): Promise<string> {
|
||||||
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-api-key": key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "claude-sonnet-5",
|
||||||
|
max_tokens: 1024,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: [{ role: "user", content: prompt }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Anthropic returned ${res.status}. ${detail.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
|
||||||
|
return (body.content ?? []).map((b) => (b.type === "text" ? (b.text ?? "") : "")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callGemini(key: string, prompt: string): Promise<string> {
|
||||||
|
const url =
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" +
|
||||||
|
encodeURIComponent(key);
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
||||||
|
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 1, maxOutputTokens: 1024 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Gemini returned ${res.status}. ${detail.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
|
||||||
|
};
|
||||||
|
return (body.candidates?.[0]?.content?.parts ?? []).map((p) => p.text ?? "").join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the visitor's chosen model for names. Throws with a readable message on
|
||||||
|
* a bad key, a rate limit, or a network failure — the UI shows it and falls
|
||||||
|
* back to the built-in generator. */
|
||||||
|
export async function generateWithAi(
|
||||||
|
settings: AiSettings,
|
||||||
|
keywords: string[],
|
||||||
|
style: string,
|
||||||
|
count = 24,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const prompt = userPrompt(keywords, style, count);
|
||||||
|
const text =
|
||||||
|
settings.provider === "anthropic"
|
||||||
|
? await callAnthropic(settings.key, prompt)
|
||||||
|
: await callGemini(settings.key, prompt);
|
||||||
|
return [...new Set(parseNames(text))];
|
||||||
|
}
|
||||||
448
src/lib/generate.ts
Normal file
448
src/lib/generate.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
// The free, offline name generator. Pure functions — no network, no API key,
|
||||||
|
// no server. Runs in the browser on every keystroke of the "Generate" button.
|
||||||
|
//
|
||||||
|
// Design notes worth knowing before editing:
|
||||||
|
//
|
||||||
|
// * DETERMINISTIC. Everything randomised goes through a seeded PRNG, so the
|
||||||
|
// same (keywords, style, seed) always yields the same list. React re-renders
|
||||||
|
// don't reshuffle the results out from under a user who is mid-read, and a
|
||||||
|
// shared URL reproduces exactly what the sender saw.
|
||||||
|
// * GENERATE WIDE, THEN SCORE. Each strategy is cheap and dumb; quality comes
|
||||||
|
// from `scoreName` culling the output. Adding a strategy is safe — a bad
|
||||||
|
// idea it produces gets ranked into the basement rather than shown.
|
||||||
|
// * The scorer is the product. If names feel wrong, tune `scoreName` before
|
||||||
|
// reaching for more vocabulary.
|
||||||
|
|
||||||
|
import {
|
||||||
|
ACTION_PREFIXES,
|
||||||
|
CATEGORY_HINTS,
|
||||||
|
COINED_ENDINGS,
|
||||||
|
MODIFIERS,
|
||||||
|
OK_ONSETS,
|
||||||
|
ROOTS,
|
||||||
|
SUFFIX_WORDS,
|
||||||
|
VOWELS,
|
||||||
|
} from "./wordbank";
|
||||||
|
|
||||||
|
export type Style = "balanced" | "real" | "coined" | "short";
|
||||||
|
|
||||||
|
export type StrategyId =
|
||||||
|
| "compound"
|
||||||
|
| "suffix-word"
|
||||||
|
| "action"
|
||||||
|
| "blend"
|
||||||
|
| "coined"
|
||||||
|
| "clipped"
|
||||||
|
| "root"
|
||||||
|
| "hack"
|
||||||
|
/** Provenance label for names from the visitor's own AI key (lib/ai.ts).
|
||||||
|
* `generateNames` never emits this — it exists so the UI can hold both
|
||||||
|
* sources in one list and still say where each name came from. */
|
||||||
|
| "ai";
|
||||||
|
|
||||||
|
export interface Idea {
|
||||||
|
/** The brand label: lowercase a–z only, no dot, no TLD. */
|
||||||
|
name: string;
|
||||||
|
strategy: StrategyId;
|
||||||
|
/** 0–100. Higher is better; see `scoreName`. */
|
||||||
|
score: number;
|
||||||
|
/** Domain hacks only: the TLD the name is designed to split across. */
|
||||||
|
hackTld?: string;
|
||||||
|
/** Domain hacks only: the part left of the dot (`stud` for `stud.io`). */
|
||||||
|
hackStem?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateOptions {
|
||||||
|
keywords: string[];
|
||||||
|
style?: Style;
|
||||||
|
/** How many ideas to return, best-first. */
|
||||||
|
count?: number;
|
||||||
|
/** Changing this reshuffles the whole list. Same seed ⇒ same output. */
|
||||||
|
seed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Deterministic randomness
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** mulberry32 — small, fast, good enough for shuffling a word list. */
|
||||||
|
function makeRng(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return function next() {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T>(rng: () => number, xs: readonly T[]): T {
|
||||||
|
return xs[Math.floor(rng() * xs.length) % xs.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fisher–Yates against the seeded RNG. Returns a new array. */
|
||||||
|
function shuffled<T>(rng: () => number, xs: readonly T[]): T[] {
|
||||||
|
const out = xs.slice();
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Word shaping
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Strip everything that can't appear in a hostname label. */
|
||||||
|
export function normalizeWord(raw: string): string {
|
||||||
|
return raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVowel(ch: string): boolean {
|
||||||
|
return VOWELS.has(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join two words, collapsing a doubled letter at the seam.
|
||||||
|
* `amber` + `ridge` → `amberidge`, not `amberridge`.
|
||||||
|
*/
|
||||||
|
function seam(a: string, b: string): string {
|
||||||
|
if (a && b && a[a.length - 1] === b[0]) return a + b.slice(1);
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest overlap where a's tail equals b's head, 2–4 chars.
|
||||||
|
* `brand` + `android` → `brandroid`. Returns null when there's nothing to fuse.
|
||||||
|
*/
|
||||||
|
function blend(a: string, b: string): string | null {
|
||||||
|
const max = Math.min(4, a.length - 1, b.length - 1);
|
||||||
|
for (let n = max; n >= 2; n--) {
|
||||||
|
if (a.slice(-n) === b.slice(0, n)) return a + b.slice(n);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flickr/tumblr/grindr move: drop the schwa out of a final `-er`.
|
||||||
|
* `flicker` → `flickr`, `grinder` → `grindr`, `roaster` → `roastr`.
|
||||||
|
*
|
||||||
|
* Note this is NOT "drop the last vowel" — that was the first implementation
|
||||||
|
* and it was wrong. Lopping the tail off `crema`/`redline`/`kettle` gives
|
||||||
|
* `crem`/`redlin`/`kettl`, which read as typos rather than as coinages. The
|
||||||
|
* pattern only works on an unstressed `-er`, so that's all it does.
|
||||||
|
*/
|
||||||
|
function syncopate(w: string): string | null {
|
||||||
|
if (w.length < 6 || !w.endsWith("er")) return null;
|
||||||
|
const stem = w.slice(0, -2);
|
||||||
|
if (isVowel(stem[stem.length - 1])) return null; // `pioneer` → `pioner`, no
|
||||||
|
return stem + "r";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim a word to its first syllable-ish chunk: `manufacture` → `manu`.
|
||||||
|
*
|
||||||
|
* NEVER surfaced as a name on its own — a bare clip reads as a truncation
|
||||||
|
* (`security` → `secu`, `coffee` → `coffe`), which is why the clipped strategy
|
||||||
|
* only uses these as stems to coin from (`manu` + `ify`).
|
||||||
|
*/
|
||||||
|
function clip(w: string): string | null {
|
||||||
|
if (w.length <= 5) return null;
|
||||||
|
// Cut AT the second vowel, not after it. Cutting after leaves a dangling
|
||||||
|
// half-syllable (`cortex` → `corte`, `socket` → `socke`) that no ending can
|
||||||
|
// rescue; cutting at it leaves a clean consonant stem (`cort`, `sock`) for
|
||||||
|
// the ending to supply the vowel to.
|
||||||
|
let vowels = 0;
|
||||||
|
for (let i = 0; i < w.length; i++) {
|
||||||
|
if (!isVowel(w[i])) continue;
|
||||||
|
vowels++;
|
||||||
|
if (vowels === 2) {
|
||||||
|
const cut = w.slice(0, i);
|
||||||
|
return cut.length >= 3 ? cut : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scoring — the part that decides whether this tool feels good
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate a candidate 0–100 on the things that actually make a name usable:
|
||||||
|
* length, whether a human can say it, and whether it looks accidental.
|
||||||
|
*/
|
||||||
|
export function scoreName(name: string, keywords: string[]): number {
|
||||||
|
if (!name || name.length < 3) return 0;
|
||||||
|
let score = 60;
|
||||||
|
|
||||||
|
// --- Length. 5–11 is the sweet spot; punish long names hard, because a
|
||||||
|
// long .com is the single most common reason a suggestion gets ignored.
|
||||||
|
const n = name.length;
|
||||||
|
if (n <= 4) score += 6;
|
||||||
|
else if (n <= 7) score += 18;
|
||||||
|
else if (n <= 9) score += 12;
|
||||||
|
else if (n <= 11) score += 2;
|
||||||
|
else if (n <= 14) score -= 12;
|
||||||
|
else score -= 26;
|
||||||
|
|
||||||
|
// --- Pronounceability. Consonant runs of 3+ are only OK when the cluster is
|
||||||
|
// a real English onset (`str`, `thr`); 4+ is never OK.
|
||||||
|
let run = "";
|
||||||
|
let worstRun = 0;
|
||||||
|
for (const ch of name + "a") {
|
||||||
|
if (!isVowel(ch)) {
|
||||||
|
run += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (run.length >= 3) {
|
||||||
|
worstRun = Math.max(worstRun, run.length);
|
||||||
|
if (!OK_ONSETS.has(run)) score -= 14;
|
||||||
|
else score -= 4;
|
||||||
|
}
|
||||||
|
run = "";
|
||||||
|
}
|
||||||
|
if (worstRun >= 4) score -= 18;
|
||||||
|
|
||||||
|
// --- Vowel balance. All-consonant or all-vowel reads as a typo.
|
||||||
|
const vowelCount = [...name].filter(isVowel).length;
|
||||||
|
const ratio = vowelCount / n;
|
||||||
|
if (ratio < 0.2 || ratio > 0.66) score -= 12;
|
||||||
|
|
||||||
|
// --- Repetition. `bloomroom` and `aaa` both look like mistakes.
|
||||||
|
if (/(.)\1\1/.test(name)) score -= 25;
|
||||||
|
if (/(.{3,})\1/.test(name)) score -= 15;
|
||||||
|
|
||||||
|
// --- Vowel pileups. `coffeeia` is three vowels deep and unreadable.
|
||||||
|
if (/[aeiouy]{3,}/.test(name)) score -= 16;
|
||||||
|
|
||||||
|
// --- Relevance. Containing a keyword the user typed is a real signal that
|
||||||
|
// the name is on-brief, but it's a bonus, not a requirement — the best
|
||||||
|
// brand names usually don't contain the category word at all.
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (kw.length >= 4 && name.includes(kw)) {
|
||||||
|
score += 8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Easy to say back over the phone: alternating consonant/vowel scans well.
|
||||||
|
let alternations = 0;
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
if (isVowel(name[i]) !== isVowel(name[i - 1])) alternations++;
|
||||||
|
}
|
||||||
|
if (alternations / n > 0.6) score += 6;
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyword expansion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grow the user's keywords with related vocabulary. `bakery` alone can only
|
||||||
|
* make `northbakery`; with hints it can reach `hearth`, `levain`, `crumb`.
|
||||||
|
* Hint keys match as substrings, so "auto repair" pulls both `auto` and `repair`.
|
||||||
|
*/
|
||||||
|
export function expandKeywords(keywords: string[]): { seeds: string[]; related: string[] } {
|
||||||
|
const seeds = keywords.map(normalizeWord).filter((w) => w.length >= 2);
|
||||||
|
const related = new Set<string>();
|
||||||
|
for (const kw of seeds) {
|
||||||
|
for (const [key, words] of Object.entries(CATEGORY_HINTS)) {
|
||||||
|
// PREFIX match, never substring: plain containment let "repair" match the
|
||||||
|
// `ai` category (rep-AI-r) and drag neural-network vocabulary into an
|
||||||
|
// auto-shop brief. Stems must agree from the front, and 2-letter keys
|
||||||
|
// only ever match exactly.
|
||||||
|
const hit =
|
||||||
|
kw === key ||
|
||||||
|
(key.length >= 3 && kw.startsWith(key)) ||
|
||||||
|
(kw.length >= 3 && key.startsWith(kw));
|
||||||
|
if (hit) for (const w of words) related.add(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Never let a hint duplicate a seed.
|
||||||
|
for (const s of seeds) related.delete(s);
|
||||||
|
return { seeds, related: [...related] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Domain hacks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** TLDs worth splitting a word across. Kept small — a hack only lands when the
|
||||||
|
* TLD reads as part of the word (`stud.io`), never as a suffix bolted on. */
|
||||||
|
const HACK_TLDS = ["io", "co", "in", "at", "it", "is", "us", "me", "so", "ly", "sh", "ai", "id", "im", "to", "se", "ma", "re", "as", "st"];
|
||||||
|
|
||||||
|
/** Find every way `word` can be split so the tail is a real TLD. */
|
||||||
|
function domainHacks(word: string): Array<{ stem: string; tld: string }> {
|
||||||
|
const out: Array<{ stem: string; tld: string }> = [];
|
||||||
|
for (const tld of HACK_TLDS) {
|
||||||
|
if (word.length - tld.length < 2) continue;
|
||||||
|
if (word.endsWith(tld)) out.push({ stem: word.slice(0, -tld.length), tld });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Strategies
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The strategies `generateNames` actually runs — everything except the `ai`
|
||||||
|
* provenance label, which comes from the visitor's own key, not from here. */
|
||||||
|
type GeneratedStrategy = Exclude<StrategyId, "ai">;
|
||||||
|
|
||||||
|
/** How many candidates each strategy contributes, per style. */
|
||||||
|
const STYLE_WEIGHTS: Record<Style, Record<GeneratedStrategy, number>> = {
|
||||||
|
balanced: { compound: 10, "suffix-word": 10, action: 3, blend: 8, coined: 8, clipped: 5, root: 6, hack: 4 },
|
||||||
|
real: { compound: 16, "suffix-word": 16, action: 6, blend: 4, coined: 2, clipped: 2, root: 4, hack: 3 },
|
||||||
|
coined: { compound: 3, "suffix-word": 3, action: 1, blend: 14, coined: 16, clipped: 10, root: 10, hack: 4 },
|
||||||
|
short: { compound: 4, "suffix-word": 4, action: 2, blend: 10, coined: 10, clipped: 14, root: 8, hack: 8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ranked list of name ideas from keywords.
|
||||||
|
*
|
||||||
|
* Over-generates by design: every strategy runs, the pool is deduped, scored,
|
||||||
|
* and only the top `count` survive.
|
||||||
|
*/
|
||||||
|
export function generateNames(opts: GenerateOptions): Idea[] {
|
||||||
|
const { keywords, style = "balanced", count = 60, seed = 1 } = opts;
|
||||||
|
const rng = makeRng(seed);
|
||||||
|
const { seeds, related } = expandKeywords(keywords);
|
||||||
|
|
||||||
|
// The vocabulary a name may be built from: what the user typed, plus related
|
||||||
|
// category words. With no keywords at all we still work — the roots and
|
||||||
|
// modifiers alone make a decent "surprise me".
|
||||||
|
const vocab = seeds.length ? [...seeds, ...related] : [...ROOTS];
|
||||||
|
if (!vocab.length) return [];
|
||||||
|
|
||||||
|
const weights = STYLE_WEIGHTS[style];
|
||||||
|
const pool = new Map<string, Idea>();
|
||||||
|
|
||||||
|
// Every word the generator knows. A candidate that is a strict PREFIX of one
|
||||||
|
// of these is a truncation, not a coinage — `crem` of `crema`, `kettl` of
|
||||||
|
// `kettle`, `securit` of `security`. Cheap, exact, and it kills the single
|
||||||
|
// worst-looking category of output.
|
||||||
|
const lexicon = [...vocab, ...MODIFIERS, ...SUFFIX_WORDS, ...ROOTS];
|
||||||
|
const isTruncation = (w: string) =>
|
||||||
|
lexicon.some((full) => full.length > w.length && full.startsWith(w));
|
||||||
|
|
||||||
|
const add = (name: string, strategy: StrategyId, extra?: Partial<Idea>) => {
|
||||||
|
const clean = normalizeWord(name);
|
||||||
|
if (clean.length < 3 || clean.length > 18) return;
|
||||||
|
if (isTruncation(clean)) return;
|
||||||
|
const existing = pool.get(clean);
|
||||||
|
const idea: Idea = {
|
||||||
|
name: clean,
|
||||||
|
strategy,
|
||||||
|
score: scoreName(clean, seeds),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
// Keep whichever framing scored better — a name reachable two ways isn't
|
||||||
|
// twice as good, and a hack framing shouldn't lose to a plain compound.
|
||||||
|
if (!existing || idea.score > existing.score) pool.set(clean, idea);
|
||||||
|
};
|
||||||
|
|
||||||
|
const words = shuffled(rng, vocab);
|
||||||
|
|
||||||
|
// 1. <modifier><keyword> — northharbor, emberforge
|
||||||
|
for (let i = 0; i < weights.compound; i++) {
|
||||||
|
add(seam(pick(rng, MODIFIERS), pick(rng, words)), "compound");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. <keyword><suffixWord> — crumbworks, torquelab
|
||||||
|
for (let i = 0; i < weights["suffix-word"]; i++) {
|
||||||
|
add(seam(pick(rng, words), pick(rng, SUFFIX_WORDS)), "suffix-word");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. <action><keyword> — gethearth, trytorque
|
||||||
|
for (let i = 0; i < weights.action; i++) {
|
||||||
|
add(seam(pick(rng, ACTION_PREFIXES), pick(rng, words)), "action");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. blends — overlap two words into one
|
||||||
|
for (let i = 0; i < weights.blend * 3; i++) {
|
||||||
|
const a = pick(rng, words);
|
||||||
|
const b = pick(rng, rng() < 0.5 ? SUFFIX_WORDS : ROOTS);
|
||||||
|
const fused = blend(a, b) ?? blend(b, a);
|
||||||
|
if (fused) add(fused, "blend");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. coined — real word + invented ending
|
||||||
|
for (let i = 0; i < weights.coined; i++) {
|
||||||
|
const base = pick(rng, words);
|
||||||
|
const stem = rng() < 0.4 ? (syncopate(base) ?? base) : base;
|
||||||
|
add(seam(stem, pick(rng, COINED_ENDINGS)), "coined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. clipped — flickr-style vowel drops, plus coinages built on a clipped
|
||||||
|
// stem. A bare `clip()` is deliberately NOT offered as a name (see clip).
|
||||||
|
for (let i = 0; i < weights.clipped; i++) {
|
||||||
|
const dropped = syncopate(pick(rng, words));
|
||||||
|
if (dropped) add(dropped, "clipped");
|
||||||
|
const stem = clip(pick(rng, words));
|
||||||
|
if (stem) add(seam(stem, pick(rng, COINED_ENDINGS)), "clipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. root compounds — lumencraft, terragrid
|
||||||
|
for (let i = 0; i < weights.root; i++) {
|
||||||
|
const root = pick(rng, ROOTS);
|
||||||
|
add(rng() < 0.5 ? seam(root, pick(rng, words)) : seam(pick(rng, words), root), "root");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. domain hacks — stud.io, quick.ly
|
||||||
|
// Hacks come from candidates already in the pool plus the raw vocabulary,
|
||||||
|
// so a compound like `emberstudio` can surrender `emberstud.io`.
|
||||||
|
const hackSources = [...words, ...[...pool.keys()].slice(0, 40)];
|
||||||
|
let hacksAdded = 0;
|
||||||
|
for (const w of shuffled(rng, hackSources)) {
|
||||||
|
if (hacksAdded >= weights.hack) break;
|
||||||
|
for (const { stem, tld } of domainHacks(w)) {
|
||||||
|
add(w, "hack", { hackStem: stem, hackTld: tld });
|
||||||
|
hacksAdded++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = [...pool.values()]
|
||||||
|
.filter((i) => i.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return diversify(ranked, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the best `count` ideas without letting one strategy own the list.
|
||||||
|
*
|
||||||
|
* Raw score ranking is badly clumped — `coined` and `clipped` are cheap to
|
||||||
|
* produce and score similarly, so an unfiltered top-20 came back as eighteen
|
||||||
|
* coinages and two compounds. A user scanning that list sees one idea, not
|
||||||
|
* twenty. So: fill in score order but cap each strategy at a share of the
|
||||||
|
* total, then top up from the leftovers if the caps left us short.
|
||||||
|
*/
|
||||||
|
function diversify(ranked: Idea[], count: number): Idea[] {
|
||||||
|
const cap = Math.max(3, Math.ceil(count * 0.28));
|
||||||
|
const used = new Map<StrategyId, number>();
|
||||||
|
const out: Idea[] = [];
|
||||||
|
const overflow: Idea[] = [];
|
||||||
|
|
||||||
|
for (const idea of ranked) {
|
||||||
|
if (out.length >= count) break;
|
||||||
|
const n = used.get(idea.strategy) ?? 0;
|
||||||
|
if (n >= cap) {
|
||||||
|
overflow.push(idea);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.set(idea.strategy, n + 1);
|
||||||
|
out.push(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const idea of overflow) {
|
||||||
|
if (out.length >= count) break;
|
||||||
|
out.push(idea);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
215
src/lib/rdap.ts
Normal file
215
src/lib/rdap.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// Domain availability, server-side, with no API key and no paid provider.
|
||||||
|
//
|
||||||
|
// Two mechanisms, in order of authority:
|
||||||
|
//
|
||||||
|
// 1. RDAP (RFC 7482) — the registries' own successor to WHOIS. IANA publishes
|
||||||
|
// a bootstrap file mapping every TLD to its authoritative RDAP server;
|
||||||
|
// `404` from that server means the domain is genuinely unregistered and
|
||||||
|
// `200` means it is registered. This is definitive, and it covers ~1,200
|
||||||
|
// TLDs including .com/.net/.org/.ai/.dev/.app/.xyz.
|
||||||
|
//
|
||||||
|
// 2. DNS NS lookup — for the TLDs with no RDAP server at all (.io, .co, .me,
|
||||||
|
// .sh, .gg, .us, .de among them). NXDOMAIN strongly suggests unregistered,
|
||||||
|
// but a domain can be registered with no nameservers delegated, so this
|
||||||
|
// path reports `unverified` and the UI must not show it as a green check.
|
||||||
|
//
|
||||||
|
// Everything here runs on the server: browsers can't do DNS, and most RDAP
|
||||||
|
// servers send no CORS headers.
|
||||||
|
|
||||||
|
import { promises as dns } from "node:dns";
|
||||||
|
|
||||||
|
export type Availability = "available" | "taken" | "unverified-available" | "unknown";
|
||||||
|
|
||||||
|
export interface DomainCheck {
|
||||||
|
domain: string;
|
||||||
|
status: Availability;
|
||||||
|
/** Which mechanism produced the answer. */
|
||||||
|
method: "rdap" | "dns" | "none";
|
||||||
|
/** Human-readable reason, shown on hover for anything not a clean yes/no. */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IANA bootstrap
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json";
|
||||||
|
const BOOTSTRAP_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** A handful of high-traffic TLDs, so a first request still works if IANA is
|
||||||
|
* unreachable at cold start. Superseded by the real file as soon as it loads. */
|
||||||
|
const FALLBACK_SERVERS: Record<string, string> = {
|
||||||
|
com: "https://rdap.verisign.com/com/v1/",
|
||||||
|
net: "https://rdap.verisign.com/net/v1/",
|
||||||
|
org: "https://rdap.publicinterestregistry.org/rdap/",
|
||||||
|
dev: "https://pubapi.registry.google/rdap/",
|
||||||
|
app: "https://pubapi.registry.google/rdap/",
|
||||||
|
ai: "https://rdap.identitydigital.services/rdap/",
|
||||||
|
xyz: "https://rdap.centralnic.com/xyz/",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BootstrapFile {
|
||||||
|
services: Array<[string[], string[]]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedServers: Record<string, string> | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
let inFlight: Promise<Record<string, string>> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLD → RDAP base URL, from IANA. Cached for a day in module scope, with the
|
||||||
|
* in-flight promise shared so a burst of cold requests triggers one fetch.
|
||||||
|
*/
|
||||||
|
export async function rdapServers(): Promise<Record<string, string>> {
|
||||||
|
const fresh = cachedServers && Date.now() - cachedAt < BOOTSTRAP_TTL_MS;
|
||||||
|
if (fresh && cachedServers) return cachedServers;
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BOOTSTRAP_URL, {
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`bootstrap HTTP ${res.status}`);
|
||||||
|
const body = (await res.json()) as BootstrapFile;
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const [tlds, urls] of body.services) {
|
||||||
|
// Prefer an https endpoint; several entries list http first.
|
||||||
|
const url = urls.find((u) => u.startsWith("https://")) ?? urls[0];
|
||||||
|
if (!url) continue;
|
||||||
|
for (const tld of tlds) map[tld.toLowerCase()] = url.endsWith("/") ? url : `${url}/`;
|
||||||
|
}
|
||||||
|
cachedServers = map;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return map;
|
||||||
|
} catch {
|
||||||
|
// Keep serving a stale map rather than failing every lookup.
|
||||||
|
if (cachedServers) return cachedServers;
|
||||||
|
cachedServers = FALLBACK_SERVERS;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return FALLBACK_SERVERS;
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RESULT_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const resultCache = new Map<string, { at: number; check: DomainCheck }>();
|
||||||
|
|
||||||
|
function cached(domain: string): DomainCheck | null {
|
||||||
|
const hit = resultCache.get(domain);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() - hit.at > RESULT_TTL_MS) {
|
||||||
|
resultCache.delete(domain);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hit.check;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(check: DomainCheck): DomainCheck {
|
||||||
|
// Never cache a non-answer — a rate-limited registry would otherwise poison
|
||||||
|
// the domain for ten minutes.
|
||||||
|
if (check.status !== "unknown") {
|
||||||
|
if (resultCache.size > 5000) resultCache.clear();
|
||||||
|
resultCache.set(check.domain, { at: Date.now(), check });
|
||||||
|
}
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lookups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function checkViaRdap(domain: string, server: string): Promise<DomainCheck> {
|
||||||
|
const url = `${server}domain/${encodeURIComponent(domain)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(7000),
|
||||||
|
headers: { accept: "application/rdap+json, application/json" },
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
if (res.status === 404) return { domain, status: "available", method: "rdap" };
|
||||||
|
if (res.ok) return { domain, status: "taken", method: "rdap" };
|
||||||
|
if (res.status === 429) {
|
||||||
|
return { domain, status: "unknown", method: "rdap", detail: "Registry rate-limited the lookup" };
|
||||||
|
}
|
||||||
|
return { domain, status: "unknown", method: "rdap", detail: `Registry returned ${res.status}` };
|
||||||
|
} catch {
|
||||||
|
return { domain, status: "unknown", method: "rdap", detail: "Registry did not respond" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkViaDns(domain: string): Promise<DomainCheck> {
|
||||||
|
try {
|
||||||
|
const ns = await dns.resolveNs(domain);
|
||||||
|
if (ns.length) return { domain, status: "taken", method: "dns" };
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
status: "unverified-available",
|
||||||
|
method: "dns",
|
||||||
|
detail: "No RDAP for this TLD; no nameservers found",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "ENOTFOUND" || code === "NXDOMAIN") {
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
status: "unverified-available",
|
||||||
|
method: "dns",
|
||||||
|
detail: "No RDAP for this TLD — DNS says unregistered, but a parked domain can look the same",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (code === "ENODATA") {
|
||||||
|
// The name resolves but delegates no NS — usually registered-but-unused.
|
||||||
|
return { domain, status: "unknown", method: "dns", detail: "Registered or reserved — no RDAP to confirm" };
|
||||||
|
}
|
||||||
|
return { domain, status: "unknown", method: "dns", detail: "DNS lookup failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check one domain. Cached for 10 minutes. */
|
||||||
|
export async function checkDomain(domain: string): Promise<DomainCheck> {
|
||||||
|
const name = domain.trim().toLowerCase();
|
||||||
|
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(name)) {
|
||||||
|
return { domain, status: "unknown", method: "none", detail: "Not a valid domain" };
|
||||||
|
}
|
||||||
|
const hit = cached(name);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
const tld = name.slice(name.lastIndexOf(".") + 1);
|
||||||
|
const servers = await rdapServers();
|
||||||
|
const server = servers[tld];
|
||||||
|
|
||||||
|
return remember(server ? await checkViaRdap(name, server) : await checkViaDns(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check many domains with a bounded concurrency.
|
||||||
|
*
|
||||||
|
* The cap is a courtesy to the registries — we are an anonymous client hitting
|
||||||
|
* their public endpoints, and a 60-wide fan-out is how you earn a 429 for
|
||||||
|
* everyone. Eight keeps a full page of results under about two seconds.
|
||||||
|
*/
|
||||||
|
export async function checkDomains(domains: string[], concurrency = 8): Promise<DomainCheck[]> {
|
||||||
|
const out: DomainCheck[] = new Array(domains.length);
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const i = cursor++;
|
||||||
|
if (i >= domains.length) return;
|
||||||
|
out[i] = await checkDomain(domains[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({ length: Math.min(concurrency, domains.length) }, worker));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
64
src/lib/registrars.ts
Normal file
64
src/lib/registrars.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// Outbound "go register it" links.
|
||||||
|
//
|
||||||
|
// Today these are PLAIN links with no referral codes — nothing to disclose and
|
||||||
|
// nothing to maintain. The affiliate path is pre-wired but switched off: fill
|
||||||
|
// in a code below and the footer disclosure turns itself on, everywhere, from
|
||||||
|
// this one file. Nothing else needs editing.
|
||||||
|
//
|
||||||
|
// Keeping the affiliate decision to a single data structure is the whole point:
|
||||||
|
// an FTC disclosure that can drift out of sync with whether links actually pay
|
||||||
|
// is worse than having no disclosure mechanism at all.
|
||||||
|
|
||||||
|
export interface Registrar {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Why someone would pick this one. Shown in the picker. */
|
||||||
|
note: string;
|
||||||
|
/** Referral code. Empty string = plain link, no disclosure. */
|
||||||
|
affiliateCode: string;
|
||||||
|
/** Builds the search/registration URL for a domain. */
|
||||||
|
url: (domain: string, affiliateCode: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGISTRARS: Registrar[] = [
|
||||||
|
{
|
||||||
|
id: "porkbun",
|
||||||
|
name: "Porkbun",
|
||||||
|
note: "Consistently cheap renewals; free WHOIS privacy.",
|
||||||
|
affiliateCode: "",
|
||||||
|
url: (domain) => `https://porkbun.com/checkout/search?q=${encodeURIComponent(domain)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "namecheap",
|
||||||
|
name: "Namecheap",
|
||||||
|
note: "Big, familiar, frequent first-year promos.",
|
||||||
|
affiliateCode: "",
|
||||||
|
url: (domain, code) => {
|
||||||
|
const base = `https://www.namecheap.com/domains/registration/results/?domain=${encodeURIComponent(domain)}`;
|
||||||
|
return code ? `${base}&afc=${encodeURIComponent(code)}` : base;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cloudflare",
|
||||||
|
name: "Cloudflare",
|
||||||
|
note: "At-cost pricing, no upsells. Transfers only for some TLDs.",
|
||||||
|
affiliateCode: "",
|
||||||
|
url: () => "https://dash.cloudflare.com/?to=/:account/domains/register",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_REGISTRAR = "porkbun";
|
||||||
|
|
||||||
|
export function registrarById(id: string): Registrar {
|
||||||
|
return REGISTRARS.find((r) => r.id === id) ?? REGISTRARS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUrl(domain: string, registrarId: string): string {
|
||||||
|
const r = registrarById(registrarId);
|
||||||
|
return r.url(domain, r.affiliateCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once ANY registrar carries a referral code — drives the footer
|
||||||
|
* disclosure so it can never be live while the links are plain, or absent
|
||||||
|
* while they pay. */
|
||||||
|
export const HAS_AFFILIATE_LINKS = REGISTRARS.some((r) => r.affiliateCode !== "");
|
||||||
27
src/lib/suggestions.ts
Normal file
27
src/lib/suggestions.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Gitea config + guard for the shared Suggestions module.
|
||||||
|
//
|
||||||
|
// names is a PUBLIC, no-login app, so the guard is permissive (always allows).
|
||||||
|
// Anyone can file a suggestion — that's the point: visitors report a name the
|
||||||
|
// generator should never have produced, or a TLD they wish we checked, and it
|
||||||
|
// lands as a Gitea issue.
|
||||||
|
//
|
||||||
|
// Config comes from per-instance env. With any of the three GITEA_* vars unset,
|
||||||
|
// giteaConfig() returns null and the route factories answer 503, so the
|
||||||
|
// lightbulb degrades to a quiet "not configured" state — no token in the
|
||||||
|
// browser, ever (the routes run server-side).
|
||||||
|
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import type { GiteaConfig } from "@otm/account-panel";
|
||||||
|
|
||||||
|
export function giteaConfig(): GiteaConfig | null {
|
||||||
|
const baseUrl = process.env.GITEA_URL;
|
||||||
|
const token = process.env.GITEA_TOKEN;
|
||||||
|
const repo = process.env.GITEA_REPO;
|
||||||
|
if (!baseUrl || !token || !repo) return null;
|
||||||
|
return { baseUrl, token, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public app: anyone may file / read suggestions. */
|
||||||
|
export async function suggestionGuard(_req: NextRequest): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
60
src/lib/tlds.ts
Normal file
60
src/lib/tlds.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// The TLDs offered in the UI.
|
||||||
|
//
|
||||||
|
// Deliberately curated rather than exhaustive: there are ~1,600 TLDs and
|
||||||
|
// almost all of them are noise for someone naming a business. Each entry says
|
||||||
|
// what the extension signals, so the picker teaches as it goes.
|
||||||
|
//
|
||||||
|
// `rdap` records whether IANA publishes an RDAP server for the TLD — the ones
|
||||||
|
// marked false fall back to a DNS heuristic and can only ever report
|
||||||
|
// "unverified" (see lib/rdap.ts). Checked against data.iana.org 2026-08-10.
|
||||||
|
|
||||||
|
export interface TldInfo {
|
||||||
|
tld: string;
|
||||||
|
note: string;
|
||||||
|
/** Authoritative RDAP available? false ⇒ DNS heuristic only. */
|
||||||
|
rdap: boolean;
|
||||||
|
/** Shown selected on first load. */
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TLDS: TldInfo[] = [
|
||||||
|
{ tld: "com", note: "The default. Still what people type from memory.", rdap: true, default: true },
|
||||||
|
{ tld: "net", note: "Old-guard fallback when the .com is gone.", rdap: true },
|
||||||
|
{ tld: "org", note: "Non-profits, communities, open projects.", rdap: true },
|
||||||
|
{ tld: "co", note: "Short, startup-flavoured .com stand-in.", rdap: false, default: true },
|
||||||
|
{ tld: "io", note: "Tech default — pricey, and a ccTLD, so read the terms.", rdap: false, default: true },
|
||||||
|
{ tld: "ai", note: "Anything machine-learning. Expensive and in demand.", rdap: true, default: true },
|
||||||
|
{ tld: "dev", note: "Developer tools. HTTPS is enforced on the whole TLD.", rdap: true },
|
||||||
|
{ tld: "app", note: "Software products. Also HTTPS-only.", rdap: true },
|
||||||
|
{ tld: "shop", note: "Retail, plainly signposted.", rdap: true },
|
||||||
|
{ tld: "store", note: "Retail alternative when .shop is taken.", rdap: true },
|
||||||
|
{ tld: "studio", note: "Design, photo, music, makers.", rdap: true },
|
||||||
|
{ tld: "works", note: "Trades, workshops, agencies.", rdap: true },
|
||||||
|
{ tld: "tools", note: "Utilities and small products.", rdap: true },
|
||||||
|
{ tld: "supply", note: "Parts, materials, trade suppliers.", rdap: true },
|
||||||
|
{ tld: "co.uk", note: "The UK default. Registry is Nominet.", rdap: true },
|
||||||
|
{ tld: "us", note: "US-only; requires a US presence.", rdap: false },
|
||||||
|
{ tld: "xyz", note: "Cheap and unopinionated. Some spam reputation.", rdap: true },
|
||||||
|
{ tld: "site", note: "Generic and widely available.", rdap: true },
|
||||||
|
{ tld: "online", note: "Generic; usually cheap in year one only.", rdap: true },
|
||||||
|
{ tld: "tech", note: "Technical products and consultancies.", rdap: true },
|
||||||
|
{ tld: "live", note: "Events, streaming, hospitality.", rdap: true },
|
||||||
|
{ tld: "team", note: "Agencies, crews, small groups.", rdap: true },
|
||||||
|
{ tld: "build", note: "Construction and trades.", rdap: true },
|
||||||
|
{ tld: "zone", note: "Playful and cheap.", rdap: true },
|
||||||
|
{ tld: "me", note: "Personal sites and portfolios.", rdap: false },
|
||||||
|
{ tld: "sh", note: "Short hacks; ccTLD for St Helena.", rdap: false },
|
||||||
|
{ tld: "gg", note: "Gaming. Guernsey's ccTLD, priced accordingly.", rdap: false },
|
||||||
|
{ tld: "to", note: "Link shorteners and domain hacks.", rdap: true },
|
||||||
|
{ tld: "cc", note: "Generic short alternative.", rdap: true },
|
||||||
|
{ tld: "tv", note: "Video and streaming.", rdap: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_TLDS = TLDS.filter((t) => t.default).map((t) => t.tld);
|
||||||
|
|
||||||
|
export function tldInfo(tld: string): TldInfo | undefined {
|
||||||
|
return TLDS.find((t) => t.tld === tld);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TLDs the DNS heuristic has to cover — surfaced in the UI as a caveat. */
|
||||||
|
export const UNVERIFIABLE_TLDS = TLDS.filter((t) => !t.rdap).map((t) => t.tld);
|
||||||
25
src/lib/version.ts
Normal file
25
src/lib/version.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// App version + changelog. The footer shows VERSION as a chip linking to
|
||||||
|
// /changelog. Bump VERSION and prepend an entry when shipping a user-facing
|
||||||
|
// change. Same convention as rpo.
|
||||||
|
|
||||||
|
export const VERSION = "0.1.0";
|
||||||
|
|
||||||
|
export interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string; // ISO yyyy-mm-dd
|
||||||
|
changes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.1.0",
|
||||||
|
date: "2026-08-10",
|
||||||
|
changes: [
|
||||||
|
"Initial release: type a few keywords about your business and get brandable name ideas, each checked for domain availability as it appears.",
|
||||||
|
"Availability comes from the registries themselves over RDAP — no paid API, no key, and it covers about 1,200 extensions.",
|
||||||
|
"A handful of extensions (.io, .co, .me, .sh, .gg, .us) publish no RDAP server; those fall back to a DNS check and are labelled 'probably free' rather than confirmed.",
|
||||||
|
"Four naming styles — balanced, real words, coined, and short — plus domain hacks like stud.io and quick.ly.",
|
||||||
|
"Optional: add your own Anthropic or Gemini API key on the Settings page for AI-generated names. The key stays in your browser and is never sent to our server.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
108
src/lib/wordbank.ts
Normal file
108
src/lib/wordbank.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Vocabulary the generator draws on. Pure data — no logic, no imports.
|
||||||
|
//
|
||||||
|
// Everything here is hand-picked for BRANDABILITY, not for coverage: short,
|
||||||
|
// concrete, easy to say out loud, and free of the tired startup filler
|
||||||
|
// ("synergy", "solutions", "dynamics"). If a word wouldn't look right painted
|
||||||
|
// on a van or printed on a business card, it doesn't belong in this file.
|
||||||
|
|
||||||
|
/** Words that pair to the LEFT of a keyword: `<modifier><keyword>`.
|
||||||
|
* Concrete and evocative — colours, materials, landscape, weather, direction. */
|
||||||
|
export const MODIFIERS = [
|
||||||
|
"north", "iron", "copper", "cobalt", "amber", "ember", "slate", "onyx",
|
||||||
|
"ivory", "indigo", "crimson", "silver", "golden", "bright", "swift", "true",
|
||||||
|
"bold", "keen", "clear", "quiet", "steady", "rugged", "prime", "peak",
|
||||||
|
"summit", "ridge", "river", "harbor", "haven", "meadow", "cedar", "birch",
|
||||||
|
"alder", "aspen", "willow", "juniper", "sage", "stone", "granite", "flint",
|
||||||
|
"atlas", "arbor", "aurora", "solstice", "compass", "lantern", "beacon",
|
||||||
|
"anchor", "kindred", "wander", "roam", "drift", "first", "open", "wild",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Words that pair to the RIGHT of a keyword: `<keyword><suffixWord>`.
|
||||||
|
* These read as "a place where the thing happens" or "the thing, refined". */
|
||||||
|
export const SUFFIX_WORDS = [
|
||||||
|
"lab", "labs", "works", "forge", "foundry", "hub", "base", "kit", "craft",
|
||||||
|
"den", "yard", "house", "desk", "flow", "path", "loop", "port", "wave",
|
||||||
|
"grid", "stack", "nest", "bloom", "spark", "drive", "shift", "bench",
|
||||||
|
"studio", "atelier", "guild", "collective", "society", "union", "post",
|
||||||
|
"press", "supply", "goods", "trade", "market", "depot", "garage", "shop",
|
||||||
|
"room", "table", "field", "grove", "trail", "route", "point", "line",
|
||||||
|
"core", "edge", "pulse", "signal", "current", "circuit", "engine", "gear",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Words that pair to the LEFT as a verb-ish call to action: `<prefix><keyword>`.
|
||||||
|
* Deliberately short — these inflate the final length for free. */
|
||||||
|
export const ACTION_PREFIXES = [
|
||||||
|
"get", "try", "go", "use", "join", "hey", "with", "meet", "ask", "my",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Coined endings that turn a real word into an invented brand.
|
||||||
|
* `-ly`/`-ify` read SaaS; `-ora`/`-ia`/`-ova` read premium; `-io`/`-ix` read
|
||||||
|
* technical. The scorer penalises collisions (e.g. "flowwly"). */
|
||||||
|
export const COINED_ENDINGS = [
|
||||||
|
"ly", "ify", "io", "ia", "ora", "ova", "eo", "ix", "ux", "us", "a", "o",
|
||||||
|
"en", "va", "ra", "na", "sy", "zo", "qi",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Latin/Greek-flavoured roots used as standalone stems or blend partners.
|
||||||
|
* A coined name built from these reads "designed", not "leftover". */
|
||||||
|
export const ROOTS = [
|
||||||
|
"nova", "luma", "lumen", "vera", "verde", "terra", "aero", "astra", "orbit",
|
||||||
|
"vertex", "nexus", "cadence", "candor", "sonder", "aurum", "ferro", "vivid",
|
||||||
|
"vento", "solis", "lyra", "vela", "corvus", "cirrus", "nimbus", "zephyr",
|
||||||
|
"kestrel", "merlin", "osprey", "heron", "sable", "vireo", "quill", "cove",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Keyword → related vocabulary. The single biggest quality lever: without it
|
||||||
|
* "bakery" only ever combines with generic modifiers; with it, "bakery" can
|
||||||
|
* reach "hearth", "crumb", "levain". Keys are matched as SUBSTRINGS of the
|
||||||
|
* user's keywords, so "auto repair" hits both `auto` and `repair`. */
|
||||||
|
export const CATEGORY_HINTS: Record<string, string[]> = {
|
||||||
|
auto: ["gear", "torque", "axle", "chrome", "piston", "throttle", "clutch", "apex", "redline", "manifold"],
|
||||||
|
car: ["gear", "chrome", "cruise", "mileage", "highway", "garage", "wrench", "lug"],
|
||||||
|
repair: ["wrench", "bolt", "fix", "mend", "tune", "overhaul", "socket", "torque"],
|
||||||
|
shop: ["bench", "counter", "till", "aisle", "stock", "front"],
|
||||||
|
bake: ["hearth", "crumb", "levain", "flour", "proof", "rise", "loaf", "butter", "yeast", "oven"],
|
||||||
|
bread: ["crumb", "levain", "crust", "loaf", "hearth", "rise"],
|
||||||
|
coffee: ["roast", "bean", "grind", "crema", "pour", "brew", "cup", "kettle"],
|
||||||
|
food: ["harvest", "pantry", "larder", "table", "plate", "kitchen", "spice"],
|
||||||
|
farm: ["harvest", "furrow", "pasture", "orchard", "acre", "barn", "silo", "seed"],
|
||||||
|
build: ["frame", "joist", "rafter", "level", "plumb", "trowel", "mortar", "beam"],
|
||||||
|
home: ["hearth", "porch", "threshold", "keystone", "shelter", "dwell", "abode"],
|
||||||
|
clean: ["rinse", "polish", "gleam", "fresh", "spotless", "sweep", "shine"],
|
||||||
|
health: ["vital", "thrive", "restore", "balance", "pulse", "remedy", "tonic"],
|
||||||
|
fitness: ["stride", "rep", "tempo", "vigor", "grit", "ascent", "summit"],
|
||||||
|
pet: ["paw", "whisker", "fetch", "kennel", "tail", "muzzle", "burrow"],
|
||||||
|
photo: ["aperture", "shutter", "focal", "exposure", "frame", "lumen", "silver"],
|
||||||
|
design: ["kern", "grid", "canvas", "palette", "form", "contour", "draft"],
|
||||||
|
music: ["chord", "tempo", "reverb", "octave", "timbre", "cadence", "vinyl"],
|
||||||
|
travel: ["voyage", "passage", "compass", "atlas", "wander", "roam", "transit"],
|
||||||
|
finance: ["ledger", "vault", "tally", "yield", "compound", "reserve", "mint"],
|
||||||
|
law: ["counsel", "statute", "docket", "gavel", "brief", "precedent"],
|
||||||
|
data: ["signal", "index", "vector", "schema", "query", "cipher", "ledger"],
|
||||||
|
software: ["compile", "runtime", "kernel", "syntax", "buffer", "daemon"],
|
||||||
|
ai: ["cortex", "neuron", "infer", "latent", "tensor", "oracle", "synapse"],
|
||||||
|
security: ["cipher", "sentinel", "bastion", "warden", "vault", "shield", "keystone"],
|
||||||
|
garden: ["trellis", "bloom", "sprout", "bramble", "thicket", "perennial"],
|
||||||
|
water: ["tide", "current", "estuary", "brook", "spring", "cascade"],
|
||||||
|
fire: ["ember", "kindle", "forge", "hearth", "flint", "blaze"],
|
||||||
|
craft: ["chisel", "lathe", "grain", "joinery", "handmade", "whittle"],
|
||||||
|
print: ["press", "letterpress", "plate", "ink", "quire", "folio"],
|
||||||
|
clothing: ["thread", "seam", "weave", "loom", "stitch", "linen", "selvage"],
|
||||||
|
teach: ["mentor", "primer", "lesson", "cohort", "syllabus", "chalk"],
|
||||||
|
kid: ["sprout", "tinker", "playful", "wonder", "sandbox"],
|
||||||
|
wedding: ["vow", "aisle", "bouquet", "toast", "confetti", "veil"],
|
||||||
|
real: ["threshold", "keystone", "parcel", "acre", "deed", "porch"],
|
||||||
|
estate: ["threshold", "keystone", "parcel", "acre", "deed", "porch"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Consonant clusters that are fine at the start of an English syllable.
|
||||||
|
* Used by the pronounceability scorer — anything not here that runs 3+ deep
|
||||||
|
* gets marked unpronounceable ("tsktr"). */
|
||||||
|
export const OK_ONSETS = new Set([
|
||||||
|
"bl", "br", "ch", "cl", "cr", "dr", "dw", "fl", "fr", "gl", "gr", "kl", "kn",
|
||||||
|
"kr", "ph", "pl", "pr", "qu", "sc", "sh", "sk", "sl", "sm", "sn", "sp", "st",
|
||||||
|
"sw", "th", "tr", "tw", "wh", "wr", "sch", "scr", "shr", "spl", "spr", "str",
|
||||||
|
"thr", "sph",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const VOWELS = new Set(["a", "e", "i", "o", "u", "y"]);
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user