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:
2026-08-10 13:40:26 -05:00
commit 78460d35c9
29 changed files with 3644 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.next
.git
.tmp
.env
npm-debug.log
.DS_Store

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
/node_modules
/.next
/out
/build
/.tmp
next-env.d.ts
*.tsbuildinfo
.env
.DS_Store

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@otm:registry=https://git.poweredbyotm.com/api/packages/tonym/npm/

41
Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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
View File

View 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 });
}

View File

@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ ok: true, service: "names" });
}

View 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);

View 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,
});

View 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}` };
},
});

View 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
View 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
View 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&rsquo;s own RDAP service, and from a DNS
check for the few extensions that publish none. It&rsquo;s a strong signal, not a
reservation &mdash; 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
View 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 &mdash;
checked against the registries themselves, live. No account, no credit card, no trial.
</p>
<Finder />
</>
);
}

107
src/app/settings/page.tsx Normal file
View 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 &mdash; 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
View 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&rsquo;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&rsquo;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 &mdash; 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
View 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
View 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 az only, no dot, no TLD. */
name: string;
strategy: StrategyId;
/** 0100. 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];
}
/** FisherYates 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, 24 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 0100 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. 511 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}