diff --git a/app/.env.example b/app/.env.example
index a1a2da7..284fb94 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -3,6 +3,11 @@
# Public site URL (used in metadata, image URLs, share cards)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
+# Search-engine ownership verification (optional; renders /
+# when set). Production: `fly secrets set GOOGLE_SITE_VERIFICATION=… BING_SITE_VERIFICATION=…`
+GOOGLE_SITE_VERIFICATION=
+BING_SITE_VERIFICATION=
+
# Browser RPCs (the site proxies mainnet reads through /api/rpc; these are for local anvil forks)
NEXT_PUBLIC_RPC_URL_BASE=http://127.0.0.1:8545
NEXT_PUBLIC_RPC_URL_ROBINHOOD=http://127.0.0.1:8546
diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx
index b8077ba..5dc61f3 100644
--- a/app/src/app/(home)/page.tsx
+++ b/app/src/app/(home)/page.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import Link from "next/link";
import LaunchHero from "@/components/launchpad/LaunchHero";
import LaunchList from "@/components/launchpad/LaunchList";
@@ -13,6 +14,8 @@ import { isFilter } from "@/lib/launchpad/search";
import TrendingStrip from "@/components/launchpad/TrendingStrip";
export const dynamic = "force-dynamic";
+// Title and description come from the root layout; ?sort= / ?chain= / ?window= views are all this one page.
+export const metadata: Metadata = { alternates: { canonical: "/" } };
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: string; window?: string; chain?: string; filter?: string }> }) {
const sp = await searchParams;
diff --git a/app/src/app/about/page.tsx b/app/src/app/about/page.tsx
new file mode 100644
index 0000000..b9d6b5d
--- /dev/null
+++ b/app/src/app/about/page.tsx
@@ -0,0 +1,103 @@
+import type { Metadata } from "next";
+import { pageMetadata } from "@/lib/seo";
+import Link from "next/link";
+import { ArrowRight, ArrowUpRight } from "lucide-react";
+import SectionIntro from "@/components/sections/SectionIntro";
+import shell from "@/components/sections/SectionShell.module.css";
+import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, LEGACY_DOMAIN } from "@/lib/brand";
+import { CHAIN_KEYS, CHAIN_LABELS, CHAINS } from "@/lib/chainPublic";
+import { chainLandingPath } from "@/lib/chainLanding";
+import { hasChainPage } from "@/lib/launchpad/config";
+
+/**
+ * The entity page: the one URL that answers "what is openlaunch" in plain words, names the
+ * official channels, and separates the brand from look-alikes. Facts only (CONTRIBUTING.md):
+ * what the contracts do, where the code is, which accounts are ours. No claims about value.
+ */
+export const metadata: Metadata = pageMetadata({
+ path: "/about",
+ title: `About ${BRAND}`,
+ description: `${BRAND} is a free, open-source token launchpad on Base, Robinhood Chain and Arc. What it does, who builds it, and which channels are official.`,
+});
+
+const DIFFERENCES = [
+ ["No platform fee", "The factory and the locker have no fee address, fee variable or treasury. There is nothing to switch on later."],
+ ["Liquidity locked forever", "100% of the supply goes into one Uniswap v4 position at launch. Its NFT is held by an ownerless locker with no withdraw path: not the creator, not openlaunch, nobody."],
+ ["No pre-mine, no admin", "Every token starts inside the pool. The contracts have no owner, no pause, no upgrade and no allowlist. They are the same for everyone."],
+ ["Open source", "Contracts and site are MIT-licensed on GitHub, and every deployed contract is source-verified on the chain's explorers."],
+ ["Creator-set trading fee", "0%, 1% or 3%, chosen at launch, paid in full to the beneficiaries the creator names or burned. Fixed forever."],
+ ["Open to agents", "A launch is one contract call from any wallet. The list, trades and metadata are also plain JSON, with no API key."],
+] as const;
+
+export default function AboutPage() {
+ return (
+
+ {BRAND} is a free, open-source token launchpad. One transaction deploys a fixed-supply ERC-20, opens a Uniswap v4 pool at the market cap you pick and locks 100% of the supply as liquidity, forever. It runs on Base, Robinhood Chain and Arc.>}
+ >
+ Launch a token
+ How it works
+
+
+
+
What makes {BRAND} different
+
+ {DIFFERENCES.map(([title, body]) => (
+
+
{title}
+
{body}
+
+ ))}
+
+
+
+
+
Where it runs
+
The same contract design is deployed on every chain, each paired with that chain's canonical Uniswap v4 contracts. Addresses and verification records are listed on the contracts section of the how-it-works page.
{BRAND} is built by Gitlawb and developed in the open. The contracts, the indexer and this site live in one MIT-licensed repository; contributions go through pull requests there.
+
Tokens launched here are created by their launchers, not by {BRAND}. A locked pool does not make a token valuable; read before you begin.
+
+
+
+
Official channels
+
These are the only places {BRAND} speaks from. Accounts, domains and repositories with similar names are not affiliated with {BRAND}, and {BRAND} is not related to other products that share the name.
+
+
+
Website
+ {BRAND_DOMAIN}
+
Formerly {LEGACY_DOMAIN}; the old domain redirects here.
MIT. Forks on GitHub are copies; this is the original.
+
+
+
+
+
+ Launch a token
+ Launch from an agent
+
+
+ );
+}
diff --git a/app/src/app/agents/page.tsx b/app/src/app/agents/page.tsx
index b790593..bc13ee1 100644
--- a/app/src/app/agents/page.tsx
+++ b/app/src/app/agents/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { pageMetadata } from "@/lib/seo";
import { ArrowDown, ArrowRight, ArrowUpRight, Braces, FileCode2, Terminal } from "lucide-react";
import SectionIntro from "@/components/sections/SectionIntro";
import AgentsCodeBlock from "@/components/sections/AgentsCodeBlock";
@@ -8,7 +9,7 @@ import { launchpad } from "@/lib/launchpad/config";
import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, SITE_URL, explorerAddress } from "@/lib/chainPublic";
import { BRAND_GITHUB } from "@/lib/brand";
-export const metadata: Metadata = { title: "Agents", description: "Launch and trade tokens from an agent: one contract call, plus a JSON API for the list, trades and metadata." };
+export const metadata: Metadata = pageMetadata({ path: "/agents", title: "Agents", description: "Launch and trade tokens from an agent: one contract call, plus a JSON API for the list, trades and metadata." });
const sections = [
{ id: "launch", number: "01", label: "Launch a token" },
diff --git a/app/src/app/arc/page.tsx b/app/src/app/arc/page.tsx
new file mode 100644
index 0000000..bafa526
--- /dev/null
+++ b/app/src/app/arc/page.tsx
@@ -0,0 +1,8 @@
+import ChainLanding, { chainLandingMetadata } from "@/components/launchpad/ChainLanding";
+
+export const dynamic = "force-dynamic";
+export const metadata = chainLandingMetadata("arc");
+
+export default function Page() {
+ return ;
+}
diff --git a/app/src/app/base/page.tsx b/app/src/app/base/page.tsx
new file mode 100644
index 0000000..1352901
--- /dev/null
+++ b/app/src/app/base/page.tsx
@@ -0,0 +1,8 @@
+import ChainLanding, { chainLandingMetadata } from "@/components/launchpad/ChainLanding";
+
+export const dynamic = "force-dynamic";
+export const metadata = chainLandingMetadata("base");
+
+export default function Page() {
+ return ;
+}
diff --git a/app/src/app/feed/page.tsx b/app/src/app/feed/page.tsx
index 9acae1b..b573c00 100644
--- a/app/src/app/feed/page.tsx
+++ b/app/src/app/feed/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { pageMetadata } from "@/lib/seo";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import CommunityFeed from "@/components/sections/CommunityFeed";
@@ -6,7 +7,7 @@ import SectionIntro from "@/components/sections/SectionIntro";
import styles from "@/components/sections/SectionShell.module.css";
import { listFeed } from "@/lib/launchpad/postsServer";
-export const metadata: Metadata = { title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol." };
+export const metadata: Metadata = pageMetadata({ path: "/feed", title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol." });
export const dynamic = "force-dynamic";
export default async function FeedPage() {
diff --git a/app/src/app/launch/page.tsx b/app/src/app/launch/page.tsx
index 9cbeb78..a6c12f3 100644
--- a/app/src/app/launch/page.tsx
+++ b/app/src/app/launch/page.tsx
@@ -1,14 +1,16 @@
import type { Metadata } from "next";
+import { pageMetadata } from "@/lib/seo";
import LaunchForm from "@/components/launchpad/LaunchForm";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer";
import { DEFAULT_CHAIN, chainKeyOr, type ChainKey } from "@/lib/chainPublic";
import { CONFIGURED_CHAINS, launchpad } from "@/lib/launchpad/config";
-export const metadata: Metadata = {
+export const metadata: Metadata = pageMetadata({
+ path: "/launch",
title: "Launch a token for free",
description: "Deploy a token on Base, Robinhood Chain or Arc with 100% of supply locked as Uniswap v4 liquidity. No platform fee. Gas only.",
-};
+});
export const dynamic = "force-dynamic";
diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx
index cf0434c..0714623 100644
--- a/app/src/app/layout.tsx
+++ b/app/src/app/layout.tsx
@@ -5,10 +5,11 @@ import Web3Provider from "@/components/Web3Provider";
import Header from "@/components/Header";
import TxToasts from "@/components/launchpad/TxToasts";
import LiveProvider from "@/components/launchpad/LiveProvider";
-import { getLaunchFeed, getLaunchTotals } from "@/lib/launchpad/queries";
+import { getLaunchFeed, getLaunchTotalsForRequest } from "@/lib/launchpad/queries";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { SITE_URL } from "@/lib/chainPublic";
-import { BRAND, BRAND_DOMAIN, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand";
+import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand";
+import { siteJsonLdHtml, siteVerification } from "@/lib/seo";
import Footer from "@/components/Footer";
import RouteProgress from "@/components/RouteProgress";
import ThemeProvider from "@/components/ThemeProvider";
@@ -23,7 +24,10 @@ export const metadata: Metadata = {
title: { default: TITLE, template: `%s · ${BRAND_DOMAIN}` },
description: DESCRIPTION,
applicationName: BRAND,
- alternates: { canonical: "/" },
+ // No canonical here: metadata merges shallowly, so a root canonical of "/" was inherited by every
+ // page without its own (launch, rules, feed, agents) and told search engines they were duplicates
+ // of the home page. Each indexable page declares its own canonical.
+ verification: siteVerification(process.env),
openGraph: { siteName: BRAND_DOMAIN, title: TITLE, description: SOCIAL_DESCRIPTION, type: "website", url: SITE_URL },
twitter: { card: "summary_large_image", site: `@${BRAND_X}`, title: TITLE, description: SOCIAL_DESCRIPTION },
};
@@ -36,10 +40,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// Minted per request in src/proxy.ts; next-themes' inline theme script must carry it to run under the CSP.
const nonce = (await headers()).get("x-nonce") ?? undefined;
const usd = await ethUsd();
- const [feed, totals] = await Promise.all([getLaunchFeed(24, usd).catch(() => []), getLaunchTotals(usd)]);
+ const [feed, totals] = await Promise.all([getLaunchFeed(24, usd).catch(() => []), getLaunchTotalsForRequest(usd)]);
+ // Organization + WebSite + WebApplication: the entity record search engines reconcile the brand word against.
+ const siteJsonLd = siteJsonLdHtml({ siteUrl: SITE_URL, brand: BRAND, domain: BRAND_DOMAIN, description: SITE_DESCRIPTION, sameAs: [`https://x.com/${BRAND_X}`, BRAND_GITHUB] });
return (
+
diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts
index b797f6a..5ee792b 100644
--- a/app/src/app/llms.txt/route.ts
+++ b/app/src/app/llms.txt/route.ts
@@ -20,8 +20,8 @@ export function GET() {
const a = launchpad("arc");
const body = `# ${BRAND_DOMAIN}
-> Open-source (MIT: https://github.com/Gitlawb/openlaunch), zero-fee token launchpad on Base (8453), Robinhood Chain (4663) and Arc (5042). One transaction deploys a token and locks 100% of its supply as
-> Uniswap v4 liquidity, forever. No platform fee: the factory and locker have no fee address at all.
+> openlaunch (${BRAND_DOMAIN}) is an open-source (MIT: https://github.com/Gitlawb/openlaunch), zero-fee token launchpad on Base (8453), Robinhood Chain (4663) and Arc (5042). One transaction deploys a token and locks 100% of its supply as
+> Uniswap v4 liquidity, forever. No platform fee: the factory and locker have no fee address at all. About page: ${SITE_URL}/about
## Source verification
${verificationLine("Base (8453)", b, [
diff --git a/app/src/app/me/page.tsx b/app/src/app/me/page.tsx
index fed5ecc..7f25da4 100644
--- a/app/src/app/me/page.tsx
+++ b/app/src/app/me/page.tsx
@@ -3,7 +3,8 @@ import MeDashboard from "@/components/launchpad/MeDashboard";
import SectionIntro from "@/components/sections/SectionIntro";
import shell from "@/components/sections/SectionShell.module.css";
-export const metadata: Metadata = { title: "Me", description: "Your launches, fees, holdings and trades on Base, Robinhood Chain and Arc." };
+// Wallet-specific: the page is empty for a crawler, so it stays out of the index (and out of the sitemap).
+export const metadata: Metadata = { title: "Me", description: "Your launches, fees, holdings and trades on Base, Robinhood Chain and Arc.", robots: { index: false, follow: true } };
export const dynamic = "force-dynamic";
export default function MePage() {
diff --git a/app/src/app/metadata.test.ts b/app/src/app/metadata.test.ts
new file mode 100644
index 0000000..5523170
--- /dev/null
+++ b/app/src/app/metadata.test.ts
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
+const layout = read("./layout.tsx");
+
+/**
+ * Next merges metadata shallowly, so a canonical on the root layout is inherited by every page
+ * that does not set `alternates` itself. That once canonicalised /launch, /rules, /feed and /agents
+ * to "/" and told search engines they were duplicates of the home page. Each indexable page owns
+ * its canonical; the root layout must never carry one.
+ */
+test("root layout declares no canonical and renders the site entity JSON-LD", () => {
+ assert.doesNotMatch(layout, /alternates/);
+ assert.match(layout, /siteJsonLdHtml\(/);
+ assert.match(layout, /verification: siteVerification\(process\.env\)/);
+ assert.match(layout, //);
+});
+
+test("every indexable page declares its own canonical path and share card", () => {
+ const pages: [string, string][] = [
+ ["./(home)/page.tsx", "/"],
+ ["./launch/page.tsx", "/launch"],
+ ["./rules/page.tsx", "/rules"],
+ ["./feed/page.tsx", "/feed"],
+ ["./agents/page.tsx", "/agents"],
+ ["./about/page.tsx", "/about"],
+ ];
+ for (const [file, path] of pages) {
+ const src = read(file);
+ // the home page's title, description and card are the layout's own; every other page goes through pageMetadata
+ const ok = path === "/" ? src.includes(`alternates: { canonical: "/" }`) : new RegExp(`pageMetadata\\(\\{\\s*path: "${path}"`).test(src);
+ assert.ok(ok, `${file} must canonicalise to ${path}`);
+ }
+});
+
+test("wallet-specific and moderation pages stay out of the index", () => {
+ assert.match(read("./me/page.tsx"), /robots: \{ index: false/);
+ assert.match(read("./admin/page.tsx"), /robots: \{ index: false/);
+});
diff --git a/app/src/app/robinhood/page.tsx b/app/src/app/robinhood/page.tsx
new file mode 100644
index 0000000..3396be5
--- /dev/null
+++ b/app/src/app/robinhood/page.tsx
@@ -0,0 +1,8 @@
+import ChainLanding, { chainLandingMetadata } from "@/components/launchpad/ChainLanding";
+
+export const dynamic = "force-dynamic";
+export const metadata = chainLandingMetadata("robinhood");
+
+export default function Page() {
+ return ;
+}
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index 5aa4c3e..7151449 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { pageMetadata } from "@/lib/seo";
import Link from "next/link";
import { ArrowDown, ArrowRight, ArrowUpRight, ChevronDown, FileCode2, LockKeyhole } from "lucide-react";
import SectionIntro from "@/components/sections/SectionIntro";
@@ -8,7 +9,7 @@ import { launchpad } from "@/lib/launchpad/config";
import { CHAINS, CHAIN_KEYS, CHAIN_LABELS, explorerAddress } from "@/lib/chainPublic";
import { BRAND_GITHUB } from "@/lib/brand";
-export const metadata: Metadata = { title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." };
+export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." });
const STEPS = [
{ title: "Your token is deployed", description: "A plain ERC-20 with EIP-2612 permit. Fixed supply, 1 billion by default. No mint, no pause, no blacklist, no transfer tax, no owner.", value: "1B", label: "default supply" },
diff --git a/app/src/app/sitemap.ts b/app/src/app/sitemap.ts
index 4b01a3e..402d868 100644
--- a/app/src/app/sitemap.ts
+++ b/app/src/app/sitemap.ts
@@ -1,6 +1,7 @@
import type { MetadataRoute } from "next";
import { CHAIN_IDS, CHAIN_KEYS, SITE_URL, chainKeyOf } from "@/lib/chainPublic";
import { maybeDb } from "@/lib/db";
+import { CONFIGURED_CHAINS } from "@/lib/launchpad/config";
import { staticSitemapEntries, tokenSitemapEntries, type TokenRow } from "@/lib/seo";
// Rendered per request like the rest of the app: the token list must not be
@@ -15,7 +16,7 @@ export const dynamic = "force-dynamic";
* crawlers always get a valid sitemap instead of a 500.
*/
export default async function sitemap(): Promise {
- const statics = staticSitemapEntries(SITE_URL);
+ const statics = staticSitemapEntries(SITE_URL, undefined, CONFIGURED_CHAINS);
let tokens: TokenRow[] = [];
try {
const sql = maybeDb();
diff --git a/app/src/app/t/[chain]/page.tsx b/app/src/app/t/[chain]/page.tsx
index 3f83fb5..a8c706a 100644
--- a/app/src/app/t/[chain]/page.tsx
+++ b/app/src/app/t/[chain]/page.tsx
@@ -1,17 +1,23 @@
-import { notFound, redirect } from "next/navigation";
+import { notFound, permanentRedirect, redirect } from "next/navigation";
import { isAddress } from "viem";
import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic";
import { findLaunchChain } from "@/lib/launchpad/queries";
+import { hasChainPage } from "@/lib/launchpad/config";
+import { chainLandingPath } from "@/lib/chainLanding";
export const dynamic = "force-dynamic";
/**
- * /t/ → the launchpad filtered to that chain
+ * /t/ → /, the chain's landing page (308: search engines move any signal there);
+ * 404 while the chain has no contracts here, so no client caches a redirect to a 404
* /t/ (legacy) → /t// (looked up; Base by default)
*/
export default async function TokenOrChainPage({ params }: { params: Promise<{ chain: string }> }) {
const { chain } = await params;
- if (isChainKey(chain)) redirect(`/?chain=${chain}`);
+ if (isChainKey(chain)) {
+ if (!hasChainPage(chain)) notFound();
+ permanentRedirect(chainLandingPath(chain));
+ }
if (!isAddress(chain)) notFound();
const found = (await findLaunchChain(chain)) ?? DEFAULT_CHAIN;
redirect(`/t/${found}/${chain.toLowerCase()}`);
diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx
index dd98c1b..b3e6c8e 100644
--- a/app/src/components/Footer.tsx
+++ b/app/src/components/Footer.tsx
@@ -2,13 +2,15 @@ import Link from "next/link";
import { ArrowRight, ArrowUp, ArrowUpRight, FileCode2 } from "lucide-react";
import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X } from "@/lib/brand";
import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, explorerAddress } from "@/lib/chainPublic";
-import { launchpad } from "@/lib/launchpad/config";
+import { hasChainPage, launchpad } from "@/lib/launchpad/config";
+import { chainLandingPath } from "@/lib/chainLanding";
import Mark from "./launchpad/Mark";
import styles from "./Footer.module.css";
const EXPLORE = [
{ href: "/", label: "Launchpad" },
{ href: "/rules", label: "How it works" },
+ { href: "/about", label: "About openlaunch" },
{ href: "/feed", label: "Community posts" },
{ href: "/me", label: "Your dashboard" },
];
@@ -55,7 +57,7 @@ export default function Footer() {
const { factory, locker } = launchpad(chain);
return (
{factory ? Factory : Factory not configured}
{locker ? Locker : Locker not configured}
diff --git a/app/src/components/launchpad/ChainLanding.tsx b/app/src/components/launchpad/ChainLanding.tsx
new file mode 100644
index 0000000..54efb9e
--- /dev/null
+++ b/app/src/components/launchpad/ChainLanding.tsx
@@ -0,0 +1,96 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowRight, ArrowUpRight } from "lucide-react";
+import LaunchList from "@/components/launchpad/LaunchList";
+import SectionIntro from "@/components/sections/SectionIntro";
+import shell from "@/components/sections/SectionShell.module.css";
+import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, EXPLORERS, explorerAddress, type ChainKey } from "@/lib/chainPublic";
+import { BRAND } from "@/lib/brand";
+import { CHAIN_LANDING, chainLandingPath } from "@/lib/chainLanding";
+import { hasChainPage, launchpad } from "@/lib/launchpad/config";
+import { getLaunchTotalsForRequest, listLaunchesPage } from "@/lib/launchpad/queries";
+import { PAGE_SIZE } from "@/lib/launchpad/paging";
+import { ethUsd } from "@/lib/launchpad/ethPrice";
+import { dbConfigured } from "@/lib/db";
+import { pageMetadata } from "@/lib/seo";
+
+/** Metadata for /: its own title, snippet and canonical (never "/", see app/metadata.test.ts). */
+export function chainLandingMetadata(chain: ChainKey): Metadata {
+ const c = CHAIN_LANDING[chain];
+ return pageMetadata({ path: chainLandingPath(chain), title: c.title, description: c.description });
+}
+
+/**
+ * The per-chain landing page: the chain's launches (the same list as the home page, pre-filtered),
+ * with a heading and facts that name the chain, so "launch a token on " has a page to rank.
+ */
+export default async function ChainLanding({ chain }: { chain: ChainKey }) {
+ if (!hasChainPage(chain)) notFound();
+ const c = CHAIN_LANDING[chain];
+ const lp = launchpad(chain);
+ const usd = await ethUsd();
+ const [page, totals] = await Promise.all([
+ listLaunchesPage({ sort: "live", window: "all", chain, filter: null, limit: PAGE_SIZE, ethUsd: usd }),
+ getLaunchTotalsForRequest(usd), // the root layout already ran it this request: shared, not repeated
+ ]);
+ const launches = totals.by_chain[chain].launches;
+ const others = CHAIN_KEYS.filter((k) => k !== chain && hasChainPage(k));
+
+ return (
+
+
+ Launch on {CHAIN_LABELS[chain]}
+ How it works
+
+
+
+
{CHAIN_LABELS[chain]} at a glance
+
+
+ q.symbol).join(" · ")} note="What the pool trades against, picked at launch." />
+
+
+
+ );
+}
diff --git a/app/src/components/launchpad/LaunchHero.tsx b/app/src/components/launchpad/LaunchHero.tsx
index e634798..73e4f63 100644
--- a/app/src/components/launchpad/LaunchHero.tsx
+++ b/app/src/components/launchpad/LaunchHero.tsx
@@ -21,7 +21,8 @@ export default function LaunchHero({ configured }: { ethUsd?: number | null; con
Free. On Base, Robinhood or Arc.
- One transaction. Your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. We take nothing. You only pay gas.
+ {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */}
+ openlaunch is the free, open-source launchpad. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. We take nothing. You only pay gas.
{/* the header watches this id: while it is on screen the header CTA stays quiet (one filled blue per screen) */}
diff --git a/app/src/components/sections/about.test.ts b/app/src/components/sections/about.test.ts
new file mode 100644
index 0000000..ecadbbf
--- /dev/null
+++ b/app/src/components/sections/about.test.ts
@@ -0,0 +1,33 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const page = readFileSync(new URL("../../app/about/page.tsx", import.meta.url), "utf8");
+const hero = readFileSync(new URL("../launchpad/LaunchHero.tsx", import.meta.url), "utf8");
+const footer = readFileSync(new URL("../Footer.tsx", import.meta.url), "utf8");
+const llms = readFileSync(new URL("../../app/llms.txt/route.ts", import.meta.url), "utf8");
+
+// The entity page: brand from brand.ts (never hard-coded), official channels from the same constants.
+test("about page is the brand entity page, built from brand.ts constants", () => {
+ assert.match(page, /import \{ BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, LEGACY_DOMAIN \} from "@\/lib\/brand"/);
+ assert.match(page, /title=\{`What \$\{BRAND\} is`\}/);
+ assert.match(page, /pageMetadata\(\{\s*path: "\/about"/);
+ assert.match(page, /https:\/\/x\.com\/\$\{BRAND_X\}/);
+ assert.match(page, /href=\{BRAND_GITHUB\}/);
+ assert.match(page, /CHAIN_KEYS\.map/);
+ // look-alike handles are never linked (brand.ts: squatted after the rename)
+ assert.doesNotMatch(page, /openlaunchlol/);
+});
+
+test("about page states facts, not value claims", () => {
+ assert.match(page, /created by their launchers, not by \{BRAND\}/);
+ assert.doesNotMatch(page, /audited|guaranteed|safe investment|moon|profit|risk.free/i);
+ assert.doesNotMatch(page, /use client|useEffect|onClick/);
+});
+
+test("the brand appears as a plain word above the fold and links to the entity page", () => {
+ assert.match(hero, /]*>openlaunch<\/Link> is the free, open-source launchpad/);
+ assert.ok(footer.includes('{ href: "/about", label: "About openlaunch" }'), "footer links the about page");
+ assert.match(llms, /> openlaunch \(\$\{BRAND_DOMAIN\}\) is an open-source/);
+ assert.match(llms, /About page: \$\{SITE_URL\}\/about/);
+});
diff --git a/app/src/lib/brand.test.ts b/app/src/lib/brand.test.ts
new file mode 100644
index 0000000..1e84a81
--- /dev/null
+++ b/app/src/lib/brand.test.ts
@@ -0,0 +1,16 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { BRAND, BRAND_DOMAIN, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "./brand.ts";
+
+// Surface limits documented in brand.ts; the title leads with the bare brand word (the query to win).
+test("site title leads with the brand word and fits Google's title width", () => {
+ assert.ok(SITE_TITLE.startsWith(`${BRAND}:`), `title must start with "${BRAND}:"`);
+ assert.ok(SITE_TITLE.length <= 60, `title is ${SITE_TITLE.length} chars`);
+ assert.ok(SITE_DESCRIPTION.length <= 155, `description is ${SITE_DESCRIPTION.length} chars`);
+ assert.ok(SOCIAL_DESCRIPTION.length <= 125, `social description is ${SOCIAL_DESCRIPTION.length} chars`);
+});
+
+test("brand constants never point at the squatted look-alikes", () => {
+ assert.equal(BRAND_DOMAIN, "openlaunch.lol");
+ assert.equal(BRAND_X, "openlaunch_lol");
+});
diff --git a/app/src/lib/brand.ts b/app/src/lib/brand.ts
index c62e16e..057f632 100644
--- a/app/src/lib/brand.ts
+++ b/app/src/lib/brand.ts
@@ -12,7 +12,8 @@ export const TAGLINE = "launch a token. Free. Open source. On Base, Robinhood Ch
* SITE_DESCRIPTION ≤ 155 chars (Google snippet);
* SOCIAL_DESCRIPTION ≤ 125 chars (OpenGraph / X previews, mobile-safe).
*/
-export const SITE_TITLE = `${BRAND_DOMAIN}: free token launchpad on Base`;
+/** Leads with the bare brand word: "openlaunch" is the query to win, and Google tokenises the domain as two words. */
+export const SITE_TITLE = `${BRAND}: free token launchpad on Base, Robinhood & Arc`;
export const SITE_DESCRIPTION = "Launch a token in one transaction on Base, Robinhood Chain or Arc. Zero platform fee, open source, 100% of supply locked as Uniswap v4 liquidity forever.";
export const SOCIAL_DESCRIPTION = "Zero-fee, open-source token launchpad. One transaction, liquidity locked forever. Base + Robinhood Chain + Arc.";
/** Old domain: kept alive as a redirect (on-chain metadata URIs of early launches point at it). */
diff --git a/app/src/lib/chainLanding.test.ts b/app/src/lib/chainLanding.test.ts
new file mode 100644
index 0000000..01e2c19
--- /dev/null
+++ b/app/src/lib/chainLanding.test.ts
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import { existsSync, readFileSync } from "node:fs";
+import test from "node:test";
+import { CHAIN_KEYS, CHAIN_LABELS } from "./chainKeys.ts";
+import { BRAND_DOMAIN } from "./brand.ts";
+import { CHAIN_LANDING, chainLandingPath } from "./chainLanding.ts";
+
+const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
+
+test("every chain has a landing page route wired to its own key", () => {
+ for (const chain of CHAIN_KEYS) {
+ const file = new URL(`../app/${chain}/page.tsx`, import.meta.url);
+ assert.ok(existsSync(file), `src/app/${chain}/page.tsx is missing`);
+ const src = readFileSync(file, "utf8");
+ assert.ok(src.includes(`chainLandingMetadata("${chain}")`), `${chain} page must use its own metadata`);
+ assert.ok(src.includes(``), `${chain} page must render its own chain`);
+ assert.equal(chainLandingPath(chain), `/${chain}`);
+ }
+});
+
+test("landing copy fits search snippets and names its chain", () => {
+ for (const chain of CHAIN_KEYS) {
+ const c = CHAIN_LANDING[chain];
+ // the root layout template appends " · openlaunch.lol"
+ assert.ok(`${c.title} · ${BRAND_DOMAIN}`.length <= 60, `${chain} title too long for Google`);
+ assert.ok(c.description.length <= 155, `${chain} description too long for the snippet`);
+ for (const text of [c.title, c.description, c.heading, c.intro]) assert.ok(text.includes(CHAIN_LABELS[chain]), `${chain}: "${text}" must name the chain`);
+ assert.doesNotMatch(JSON.stringify(c), /audited|guaranteed|safe investment|moon|profit|risk.free|best/i);
+ }
+});
+
+test("landing pages canonicalise to themselves, not to / or ?chain=", () => {
+ const comp = read("../components/launchpad/ChainLanding.tsx");
+ assert.match(comp, /pageMetadata\(\{ path: chainLandingPath\(chain\)/);
+ assert.doesNotMatch(comp, /id="launches-heading"/, "LaunchList already owns that id");
+ assert.match(comp, /if \(!hasChainPage\(chain\)\) notFound\(\);/, "a chain without contracts 404s instead of rendering an empty list");
+ assert.match(comp, /hasChainPage\(k\)/, "\"Also on\" links only chains that have a page");
+ assert.doesNotMatch(comp, /VISIBLE_CHAINS/, "VISIBLE_CHAINS falls back to every chain in development; pages follow CONFIGURED_CHAINS");
+});
+
+test("/t/ moves permanently to the landing page", () => {
+ const legacy = read("../app/t/[chain]/page.tsx");
+ assert.match(legacy, /if \(!hasChainPage\(chain\)\) notFound\(\);\s*permanentRedirect\(chainLandingPath\(chain\)\);/, "no cached 308 to a 404");
+ assert.doesNotMatch(legacy, /\/\?chain=/);
+});
+
+test("footer and about page link a chain's landing page only when it has one", () => {
+ for (const file of ["../components/Footer.tsx", "../app/about/page.tsx"]) {
+ assert.match(read(file), /hasChainPage\(chain\) \? ]*>\{CHAIN_LABELS\[chain\]\}<\/Link> : CHAIN_LABELS\[chain\]/, file);
+ }
+ assert.match(read("../app/sitemap.ts"), /staticSitemapEntries\(SITE_URL, undefined, CONFIGURED_CHAINS\)/);
+});
+
+test("the chain page shares the layout's totals query instead of running it again", () => {
+ const layout = read("../app/layout.tsx");
+ const comp = read("../components/launchpad/ChainLanding.tsx");
+ assert.match(read("./launchpad/queries.ts"), /export const getLaunchTotalsForRequest = cache\(getLaunchTotals\);/);
+ for (const [name, src] of [["layout", layout], ["ChainLanding", comp]] as const) {
+ assert.match(src, /getLaunchTotalsForRequest\(usd\)/, `${name} uses the request-scoped getter`);
+ assert.doesNotMatch(src, /[^A-Za-z]getLaunchTotals\(/, `${name} must not call the uncached query`);
+ }
+});
diff --git a/app/src/lib/chainLanding.ts b/app/src/lib/chainLanding.ts
new file mode 100644
index 0000000..281d7ea
--- /dev/null
+++ b/app/src/lib/chainLanding.ts
@@ -0,0 +1,49 @@
+/**
+ * Copy for the per-chain landing pages (/base, /robinhood, /arc), pure (node --test loads this).
+ * Each page answers one search ("token launchpad on Base", "launch a token on Arc") with its own
+ * title, heading and canonical, instead of a ?chain= view that search engines fold into "/".
+ * Facts only (CONTRIBUTING.md "Facts over scores"): what the chain is, what a launch there quotes in,
+ * what it costs. Keyed by ChainKey, so a new chain fails to type-check until it has copy.
+ */
+import type { ChainKey } from "./chainKeys.ts";
+
+export type ChainLandingCopy = {
+ /** ≤ 60 chars once the layout template adds " · openlaunch.lol" (Google truncates past that); query words first. */
+ title: string;
+ /** ≤ 155 chars (Google snippet). */
+ description: string;
+ heading: string;
+ /** One paragraph under the heading: what the chain is, in plain words. */
+ intro: string;
+ /** What gas is paid in on this chain. */
+ gas: string;
+};
+
+export const CHAIN_LANDING: Record = {
+ base: {
+ title: "Free token launchpad on Base",
+ description: "Launch a token on Base in one transaction. Zero platform fee, open source, 100% of supply locked as Uniswap v4 liquidity forever. You only pay gas.",
+ heading: "Launch a token on Base",
+ intro: "Base is Coinbase's Ethereum layer 2. A launch here deploys a fixed-supply ERC-20, opens a Uniswap v4 pool on Base and locks all of the supply in it, in one transaction.",
+ gas: "ETH",
+ },
+ robinhood: {
+ title: "Free token launchpad on Robinhood Chain",
+ description: "Launch a token on Robinhood Chain in one transaction. Zero platform fee, open source, 100% of supply locked as Uniswap v4 liquidity forever.",
+ heading: "Launch a token on Robinhood Chain",
+ intro: "Robinhood Chain is Robinhood's Ethereum layer 2. A launch here deploys a fixed-supply ERC-20, opens a Uniswap v4 pool on Robinhood Chain and locks all of the supply in it, in one transaction.",
+ gas: "ETH",
+ },
+ arc: {
+ title: "Free token launchpad on Arc (USDC)",
+ description: "Launch a token on Arc, Circle's chain, in one transaction. Quoted in USDC, zero platform fee, 100% of supply locked as Uniswap v4 liquidity forever.",
+ heading: "Launch a token on Arc",
+ intro: "Arc is Circle's layer 1, where USDC is the gas token. A launch here deploys a fixed-supply ERC-20, opens a USDC-quoted Uniswap v4 pool on Arc and locks all of the supply in it, in one transaction.",
+ gas: "USDC",
+ },
+};
+
+/** The landing page path for a chain: /base, /robinhood, /arc. */
+export function chainLandingPath(chain: ChainKey): string {
+ return `/${chain}`;
+}
diff --git a/app/src/lib/launchpad/config.ts b/app/src/lib/launchpad/config.ts
index 76998a9..56c8c8e 100644
--- a/app/src/lib/launchpad/config.ts
+++ b/app/src/lib/launchpad/config.ts
@@ -155,6 +155,14 @@ export const CONFIGURED_CHAINS: ChainKey[] = CHAIN_KEYS.filter((k) => CFG[k].con
export const LAUNCHPAD_CONFIGURED = CONFIGURED_CHAINS.length > 0;
/** Chains offered in wallet switchers and list filters: the configured ones; every chain where none is (local development). */
export const VISIBLE_CHAINS: ChainKey[] = CONFIGURED_CHAINS.length > 0 ? CONFIGURED_CHAINS : CHAIN_KEYS;
+/**
+ * Chains with a landing page (/base, /robinhood, /arc): configured ones only, never the development fallback
+ * above. A page for a chain without contracts has nothing to launch, so every link to one, the sitemap entry
+ * and the /t/ redirect go through this.
+ */
+export function hasChainPage(key: ChainKey): boolean {
+ return CONFIGURED_CHAINS.includes(key);
+}
/** A `?chain=` param the list may filter by: a visible chain, else null (all chains), so a link to a chain that is not live yet never shows an empty list. */
export function visibleChainOr(v: unknown): ChainKey | null {
return isChainKey(v) && VISIBLE_CHAINS.includes(v) ? v : null;
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index c1c080b..5d9eefc 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -1,4 +1,5 @@
import "server-only";
+import { cache } from "react";
import { maybeDb } from "@/lib/db";
import { CHAIN_KEYS, DEFAULT_CHAIN, chainIdOf, chainKeyOf, type ChainKey } from "@/lib/chainPublic";
import { quoteInfo as staticQuoteInfo, quoteUsdOf, type Quote, NATIVE_QUOTES, fixedUsdQuotes, quotesWithKey } from "./config";
@@ -450,6 +451,14 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise {
diff --git a/app/src/lib/seo.test.ts b/app/src/lib/seo.test.ts
index e0c2db7..d52ade6 100644
--- a/app/src/lib/seo.test.ts
+++ b/app/src/lib/seo.test.ts
@@ -5,6 +5,11 @@ import {
canonicalUrl,
isCanonicalTokenPath,
jsonLdHtml,
+ jsonLdScript,
+ pageMetadata,
+ siteJsonLd,
+ siteJsonLdHtml,
+ siteVerification,
staticSitemapEntries,
tokenCanonical,
tokenJsonLd,
@@ -34,12 +39,13 @@ test("isCanonicalTokenPath rejects bad chains and addresses", () => {
assert.equal(isCanonicalTokenPath("base", "0x123"), false);
});
-test("static sitemap covers every public route and skips /admin", () => {
+test("static sitemap covers every indexable route and skips /admin and /me", () => {
const paths = STATIC_SITEMAP_ROUTES.map((r) => r.path);
- for (const p of ["/", "/launch", "/feed", "/rules", "/agents", "/me"]) {
+ for (const p of ["/", "/launch", "/base", "/robinhood", "/arc", "/feed", "/rules", "/about", "/agents"]) {
assert.ok(paths.includes(p), `missing ${p}`);
}
assert.ok(!paths.includes("/admin"), "/admin must stay out of the sitemap");
+ assert.ok(!paths.includes("/me"), "/me is wallet-specific (noindex) and must stay out of the sitemap");
const entries = staticSitemapEntries(SITE, "2026-09-06T00:00:00.000Z");
assert.equal(entries.length, STATIC_SITEMAP_ROUTES.length);
for (const entry of entries) {
@@ -48,6 +54,13 @@ test("static sitemap covers every public route and skips /admin", () => {
assert.equal(entries[0].url, `${SITE}/`);
});
+test("static sitemap lists only the chain landing pages that are live on this deployment", () => {
+ const paths = staticSitemapEntries(SITE, undefined, ["base", "robinhood"]).map((e) => new URL(e.url).pathname);
+ assert.ok(paths.includes("/base") && paths.includes("/robinhood"));
+ assert.ok(!paths.includes("/arc"), "a chain without contracts 404s, so it stays out of the sitemap");
+ assert.ok(paths.includes("/about"), "non-chain routes are unaffected");
+});
+
test("token sitemap skips invalid rows and keeps block_time", () => {
const entries = tokenSitemapEntries(SITE, [
{ chain: "base", token: TOKEN, block_time: "2026-09-06T00:00:00.000Z" },
@@ -109,3 +122,61 @@ test("jsonLdHtml escapes < so creator input cannot break out of the script tag",
assert.ok(html.includes("\\u003c/script>"), "closing tag is unicode-escaped");
assert.deepEqual(JSON.parse(html.replace(/\\u003c/g, "<")), JSON.parse(JSON.stringify(tokenJsonLd(input))), "escapes round-trip");
});
+
+const SITE_INPUT = {
+ siteUrl: `${SITE}/`,
+ brand: "openlaunch",
+ domain: "openlaunch.lol",
+ description: "Launch a token in one transaction.",
+ sameAs: ["https://x.com/openlaunch_lol", "https://github.com/Gitlawb/openlaunch"],
+};
+
+test("site JSON-LD is an Organization + WebSite + WebApplication graph keyed on the brand word", () => {
+ const ld = siteJsonLd(SITE_INPUT);
+ const graph = ld["@graph"] as Record[];
+ assert.deepEqual(graph.map((n) => n["@type"]), ["Organization", "WebSite", "WebApplication"]);
+ const [org, site, app] = graph;
+ assert.equal(org["name"], "openlaunch");
+ assert.equal(org["alternateName"], "openlaunch.lol");
+ assert.equal(org["url"], `${SITE}/`, "trailing slash on siteUrl is normalised");
+ assert.equal(org["@id"], `${SITE}/#organization`);
+ assert.deepEqual(org["sameAs"], SITE_INPUT.sameAs);
+ assert.deepEqual(site["publisher"], { "@id": `${SITE}/#organization` });
+ assert.equal(app["isAccessibleForFree"], true);
+ assert.deepEqual(app["offers"], { "@type": "Offer", price: "0", priceCurrency: "USD" });
+ const text = JSON.stringify(ld).toLowerCase();
+ assert.ok(!text.includes("openlaunchlol"), "never the squatted look-alike");
+ // Word-boundary match: "operatingSystem" contains "rating" but is not a rating.
+ for (const banned of ["rating", "review", "score", "audited"]) assert.ok(!new RegExp(`\\b${banned}`).test(text), `no ${banned}`);
+});
+
+test("jsonLdScript escapes < for every payload, site graph included", () => {
+ assert.equal(jsonLdScript({ a: "" }), '{"a":"\\u003c/script>"}');
+ const html = siteJsonLdHtml({ ...SITE_INPUT, description: "a c" });
+ assert.ok(!html.includes("<"));
+ assert.deepEqual(JSON.parse(html), siteJsonLd({ ...SITE_INPUT, description: "a c" }));
+});
+
+test("siteVerification renders only the tags that are configured", () => {
+ assert.equal(siteVerification({}), undefined);
+ assert.equal(siteVerification({ GOOGLE_SITE_VERIFICATION: " " }), undefined);
+ assert.deepEqual(siteVerification({ GOOGLE_SITE_VERIFICATION: "g123 " }), { google: "g123" });
+ assert.deepEqual(siteVerification({ BING_SITE_VERIFICATION: "b456" }), { other: { "msvalidate.01": "b456" } });
+ assert.deepEqual(siteVerification({ GOOGLE_SITE_VERIFICATION: "g", BING_SITE_VERIFICATION: "b" }), { google: "g", other: { "msvalidate.01": "b" } });
+});
+
+test("pageMetadata gives a page its own canonical and share card, never the home page's", () => {
+ const long = "Deploy a token on Base, Robinhood Chain or Arc with 100% of supply locked as Uniswap v4 liquidity. No platform fee. Gas only.";
+ const m = pageMetadata({ path: "/launch", title: "Launch a token for free", description: long });
+ assert.equal(m.description, long, "the search snippet keeps the full description");
+ assert.deepEqual(m.alternates, { canonical: "/launch" });
+ assert.equal(m.openGraph.url, "/launch");
+ assert.equal(m.openGraph.title, "Launch a token for free");
+ assert.equal(m.openGraph.siteName, "openlaunch.lol");
+ assert.equal(m.twitter.title, "Launch a token for free");
+ assert.equal(m.twitter.site, "@openlaunch_lol");
+ // a page's own openGraph drops the inherited file image: the card must name it or previews go blank
+ assert.deepEqual(m.openGraph.images.map((i) => i.url), ["/opengraph-image"]);
+ assert.deepEqual(m.twitter.images, m.openGraph.images);
+ assert.ok(m.openGraph.description.length <= 125 && m.twitter.description === m.openGraph.description, "share text is clamped for previews");
+});
diff --git a/app/src/lib/seo.ts b/app/src/lib/seo.ts
index a13d97d..e88d77b 100644
--- a/app/src/lib/seo.ts
+++ b/app/src/lib/seo.ts
@@ -3,7 +3,10 @@
* callers pass `siteUrl` in so unit tests stay deterministic.
*/
-import { CHAIN_LABELS, isChainKey, type ChainKey } from "./chainKeys.ts";
+import { CHAIN_KEYS, CHAIN_LABELS, isChainKey, type ChainKey } from "./chainKeys.ts";
+import { chainLandingPath } from "./chainLanding.ts";
+import { BRAND_DOMAIN, BRAND_X, SITE_TITLE } from "./brand.ts";
+import { clampSocial } from "./launchpad/ogcard.ts";
export { isChainKey, type ChainKey };
@@ -39,15 +42,16 @@ export type StaticRoute = {
/**
* Static sitemap routes. Facts only: every path exists in src/app.
- * /admin is intentionally excluded (robots noindex + disallowed in robots.txt).
+ * /admin and /me are intentionally excluded (both noindex; /admin is also disallowed in robots.txt).
*/
export const STATIC_SITEMAP_ROUTES: StaticRoute[] = [
{ path: "/", changeFrequency: "hourly", priority: 1 },
{ path: "/launch", changeFrequency: "weekly", priority: 0.8 },
+ ...CHAIN_KEYS.map((chain): StaticRoute => ({ path: chainLandingPath(chain), changeFrequency: "hourly", priority: 0.8 })),
{ path: "/feed", changeFrequency: "hourly", priority: 0.7 },
{ path: "/rules", changeFrequency: "monthly", priority: 0.6 },
+ { path: "/about", changeFrequency: "monthly", priority: 0.6 },
{ path: "/agents", changeFrequency: "monthly", priority: 0.6 },
- { path: "/me", changeFrequency: "weekly", priority: 0.4 },
];
export type SitemapEntry = {
@@ -57,8 +61,13 @@ export type SitemapEntry = {
priority: number;
};
-export function staticSitemapEntries(siteUrl: string, lastModified?: string): SitemapEntry[] {
- return STATIC_SITEMAP_ROUTES.map((r) => ({
+/**
+ * `chains`: the chains with contracts on this deployment. A chain landing page 404s without them,
+ * and a sitemap must never advertise a 404.
+ */
+export function staticSitemapEntries(siteUrl: string, lastModified?: string, chains: readonly ChainKey[] = CHAIN_KEYS): SitemapEntry[] {
+ const hidden = new Set(CHAIN_KEYS.filter((k) => !chains.includes(k)).map(chainLandingPath));
+ return STATIC_SITEMAP_ROUTES.filter((r) => !hidden.has(r.path)).map((r) => ({
url: canonicalUrl(siteUrl, r.path),
...(lastModified ? { lastModified } : {}),
changeFrequency: r.changeFrequency,
@@ -139,11 +148,122 @@ export function tokenJsonLd(l: TokenJsonLdInput): Record {
}
/**
- * Render JSON-LD for a `` in a token name must never terminate
- * the script element (XSS). The JSON parses identically.
+ * Serialise any JSON-LD object for a `` inside a string (token
+ * names are creator-supplied) can never terminate the script element (XSS).
+ * The JSON parses identically.
*/
+export function jsonLdScript(obj: Record): string {
+ return JSON.stringify(obj).replace(/ {
+ const siteUrl = i.siteUrl.replace(/\/$/, "");
+ const orgId = `${siteUrl}/#organization`;
+ const siteId = `${siteUrl}/#website`;
+ const logo = `${siteUrl}/icon.png`;
+ return {
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "Organization",
+ "@id": orgId,
+ name: i.brand,
+ alternateName: i.domain,
+ url: `${siteUrl}/`,
+ logo: { "@type": "ImageObject", url: logo, width: 512, height: 512 },
+ sameAs: i.sameAs,
+ },
+ {
+ "@type": "WebSite",
+ "@id": siteId,
+ name: i.brand,
+ alternateName: i.domain,
+ url: `${siteUrl}/`,
+ description: i.description,
+ inLanguage: "en",
+ publisher: { "@id": orgId },
+ },
+ {
+ "@type": "WebApplication",
+ name: i.brand,
+ url: `${siteUrl}/`,
+ description: i.description,
+ applicationCategory: "FinanceApplication",
+ operatingSystem: "Web",
+ browserRequirements: "Requires JavaScript and an Ethereum wallet",
+ isAccessibleForFree: true,
+ offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
+ publisher: { "@id": orgId },
+ isPartOf: { "@id": siteId },
+ },
+ ],
+ };
+}
+
+/** Site JSON-LD, escaped for a script sink (see jsonLdScript). */
+export function siteJsonLdHtml(i: SiteJsonLdInput): string {
+ return jsonLdScript(siteJsonLd(i));
+}
+
+/**
+ * Search-engine ownership verification tags, read from the environment so a
+ * deploy can verify Search Console / Bing Webmaster without a DNS change.
+ * Returns undefined when nothing is set, so the tags never render empty.
+ */
+export function siteVerification(env: Record): { google?: string; other?: Record } | undefined {
+ const google = env.GOOGLE_SITE_VERIFICATION?.trim() || undefined;
+ const bing = env.BING_SITE_VERIFICATION?.trim() || undefined;
+ if (!google && !bing) return undefined;
+ return { ...(google ? { google } : {}), ...(bing ? { other: { "msvalidate.01": bing } } : {}) };
+}
+
+export type PageMetadata = {
+ title: string;
+ description: string;
+ alternates: { canonical: string };
+ openGraph: { siteName: string; type: "website"; title: string; description: string; url: string; images: ShareImage[] };
+ twitter: { card: "summary_large_image"; site: string; title: string; description: string; images: ShareImage[] };
+};
+type ShareImage = { url: string; width: number; height: number; alt: string };
+
+/** The site card (app/opengraph-image.tsx, 1200×630). A page's own openGraph drops the inherited file image, so it is named here. */
+const SITE_SHARE_IMAGE: ShareImage = { url: "/opengraph-image", width: 1200, height: 630, alt: SITE_TITLE };
+
+/**
+ * Metadata for an indexable page: its canonical plus its own share card. Next replaces nested
+ * metadata objects instead of merging them, so a page that sets only title/description inherits the
+ * root layout's openGraph/twitter whole: the home page's card, and og:url pointing at "/".
+ * `path` and the image resolve against the layout's metadataBase.
+ */
+export function pageMetadata({ path, title, description }: { path: string; title: string; description: string }): PageMetadata {
+ const social = clampSocial(description);
+ return {
+ title,
+ description,
+ alternates: { canonical: path },
+ openGraph: { siteName: BRAND_DOMAIN, type: "website", title, description: social, url: path, images: [SITE_SHARE_IMAGE] },
+ twitter: { card: "summary_large_image", site: `@${BRAND_X}`, title, description: social, images: [SITE_SHARE_IMAGE] },
+ };
}