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:
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"]);
|
||||
Reference in New Issue
Block a user