From aecfef6f00cf9cdc57442a6a185d4d38990a3106 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 25 Sep 2026 08:58:47 +0800 Subject: [PATCH 1/5] seo: per-page canonicals, brand entity page, chain landing pages - root layout no longer carries canonical "/": every page without its own (launch, rules, feed, agents) was telling Google it duplicated the home page - /about: the brand entity page; Organization + WebSite + WebApplication JSON-LD site-wide; title leads with the bare word "openlaunch" - optional Google / Bing verification tags from env - /base, /robinhood, /arc: indexable landing pages with their own title, heading, facts and the chain's launch list; /t/ now 308s there - sitemap: chain pages (configured chains only), /about in, /me out - footer links each chain page --- app/.env.example | 5 + app/src/app/(home)/page.tsx | 3 + app/src/app/about/page.tsx | 100 ++++++++++++++++ app/src/app/agents/page.tsx | 2 +- app/src/app/arc/page.tsx | 8 ++ app/src/app/base/page.tsx | 8 ++ app/src/app/feed/page.tsx | 2 +- app/src/app/launch/page.tsx | 1 + app/src/app/layout.tsx | 11 +- app/src/app/llms.txt/route.ts | 4 +- app/src/app/me/page.tsx | 3 +- app/src/app/metadata.test.ts | 38 ++++++ app/src/app/robinhood/page.tsx | 8 ++ app/src/app/rules/page.tsx | 2 +- app/src/app/sitemap.ts | 3 +- app/src/app/t/[chain]/page.tsx | 7 +- app/src/components/Footer.tsx | 4 +- app/src/components/launchpad/ChainLanding.tsx | 95 +++++++++++++++ app/src/components/launchpad/LaunchHero.tsx | 3 +- app/src/components/sections/about.test.ts | 33 ++++++ app/src/lib/brand.test.ts | 16 +++ app/src/lib/brand.ts | 3 +- app/src/lib/chainLanding.test.ts | 48 ++++++++ app/src/lib/chainLanding.ts | 55 +++++++++ app/src/lib/seo.test.ts | 58 ++++++++- app/src/lib/seo.ts | 110 ++++++++++++++++-- 26 files changed, 603 insertions(+), 27 deletions(-) create mode 100644 app/src/app/about/page.tsx create mode 100644 app/src/app/arc/page.tsx create mode 100644 app/src/app/base/page.tsx create mode 100644 app/src/app/metadata.test.ts create mode 100644 app/src/app/robinhood/page.tsx create mode 100644 app/src/components/launchpad/ChainLanding.tsx create mode 100644 app/src/components/sections/about.test.ts create mode 100644 app/src/lib/brand.test.ts create mode 100644 app/src/lib/chainLanding.test.ts create mode 100644 app/src/lib/chainLanding.ts 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..567332f --- /dev/null +++ b/app/src/app/about/page.tsx @@ -0,0 +1,100 @@ +import type { Metadata } from "next"; +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"; + +/** + * 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 = { + 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.`, + alternates: { canonical: "/about" }, +}; + +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 + +
+

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.

+
+ {CHAIN_KEYS.map((chain) => ( +
+
{CHAIN_LABELS[chain]}
+
Chain ID {CHAINS[chain].id}
+
+ ))} +
+
+ +
+

Who builds it

+

{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.

    +
  • +
  • +

    X

    + @{BRAND_X} +

    Launch notes and updates.

    +
  • +
  • +

    Source code

    + Gitlawb/openlaunch +

    MIT. Forks on GitHub are copies; this is the original.

    +
  • +
+
+ +
+ Launch a token
+
+ ); +} diff --git a/app/src/app/agents/page.tsx b/app/src/app/agents/page.tsx index b790593..4a0533d 100644 --- a/app/src/app/agents/page.tsx +++ b/app/src/app/agents/page.tsx @@ -8,7 +8,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 = { title: "Agents", description: "Launch and trade tokens from an agent: one contract call, plus a JSON API for the list, trades and metadata.", alternates: { canonical: "/agents" } }; 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..5417787 100644 --- a/app/src/app/feed/page.tsx +++ b/app/src/app/feed/page.tsx @@ -6,7 +6,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 = { title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol.", alternates: { canonical: "/feed" } }; 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..7991e44 100644 --- a/app/src/app/launch/page.tsx +++ b/app/src/app/launch/page.tsx @@ -8,6 +8,7 @@ import { CONFIGURED_CHAINS, launchpad } from "@/lib/launchpad/config"; export const metadata: Metadata = { 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.", + alternates: { canonical: "/launch" }, }; export const dynamic = "force-dynamic"; diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index cf0434c..1e7bf20 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -8,7 +8,8 @@ import LiveProvider from "@/components/launchpad/LiveProvider"; import { getLaunchFeed, getLaunchTotals } 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 }, }; @@ -37,9 +41,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo const nonce = (await headers()).get("x-nonce") ?? undefined; const usd = await ethUsd(); const [feed, totals] = await Promise.all([getLaunchFeed(24, usd).catch(() => []), getLaunchTotals(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 ( + " }), '{"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" } }); +}); diff --git a/app/src/lib/seo.ts b/app/src/lib/seo.ts index a13d97d..f563965 100644 --- a/app/src/lib/seo.ts +++ b/app/src/lib/seo.ts @@ -3,7 +3,8 @@ * 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"; export { isChainKey, type ChainKey }; @@ -39,16 +40,18 @@ 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 }, ]; +// /me is wallet-specific (noindex) and /admin is moderation-only: neither belongs in the sitemap. export type SitemapEntry = { url: string; @@ -57,8 +60,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 +147,93 @@ 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 } } : {}) }; } From f10b4a3293b768f434981186b82c3273c29e28c0 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 25 Sep 2026 09:03:27 +0800 Subject: [PATCH 2/5] seo: tidy the branch; space and cross-link the about page sections - about: section spacing (headings sat on the cards above), chain cards link /base /robinhood /arc - inline the one-use chainLandingLabel helper; drop a duplicated sitemap comment - chainLanding test reads labels from CHAIN_LABELS instead of a hand-written ternary --- app/src/app/about/page.tsx | 9 +++++---- app/src/components/launchpad/ChainLanding.tsx | 5 +++-- app/src/lib/chainLanding.test.ts | 11 +++++------ app/src/lib/chainLanding.ts | 8 +------- app/src/lib/seo.ts | 1 - 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/app/src/app/about/page.tsx b/app/src/app/about/page.tsx index 567332f..fe5a8b9 100644 --- a/app/src/app/about/page.tsx +++ b/app/src/app/about/page.tsx @@ -5,6 +5,7 @@ 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"; /** * The entity page: the one URL that answers "what is openlaunch" in plain words, names the @@ -50,26 +51,26 @@ export default function AboutPage() { -
+

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.

{CHAIN_KEYS.map((chain) => (
-
{CHAIN_LABELS[chain]}
+
{CHAIN_LABELS[chain]}
Chain ID {CHAINS[chain].id}
))}
-
+

Who builds it

{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.

    diff --git a/app/src/components/launchpad/ChainLanding.tsx b/app/src/components/launchpad/ChainLanding.tsx index ace6d6f..28276fd 100644 --- a/app/src/components/launchpad/ChainLanding.tsx +++ b/app/src/components/launchpad/ChainLanding.tsx @@ -6,7 +6,8 @@ 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 { CHAIN_LANDING, chainLandingLabel, chainLandingPath } from "@/lib/chainLanding"; +import { BRAND } from "@/lib/brand"; +import { CHAIN_LANDING, chainLandingPath } from "@/lib/chainLanding"; import { VISIBLE_CHAINS, launchpad } from "@/lib/launchpad/config"; import { getLaunchTotals, listLaunchesPage } from "@/lib/launchpad/queries"; import { PAGE_SIZE } from "@/lib/launchpad/paging"; @@ -74,7 +75,7 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) { {others.map((k, i) => ( {i ? " · " : null} - {chainLandingLabel(k)} + {BRAND} on {CHAIN_LABELS[k]} ))}

    diff --git a/app/src/lib/chainLanding.test.ts b/app/src/lib/chainLanding.test.ts index d463f5f..ff3f073 100644 --- a/app/src/lib/chainLanding.test.ts +++ b/app/src/lib/chainLanding.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import test from "node:test"; -import { CHAIN_KEYS } from "./chainKeys.ts"; +import { CHAIN_KEYS, CHAIN_LABELS } from "./chainKeys.ts"; import { BRAND_DOMAIN } from "./brand.ts"; -import { CHAIN_LANDING, chainLandingLabel, chainLandingPath } from "./chainLanding.ts"; +import { CHAIN_LANDING, chainLandingPath } from "./chainLanding.ts"; const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8"); @@ -24,11 +24,9 @@ test("landing copy fits search snippets and names its 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`); - const label = chain === "robinhood" ? "Robinhood Chain" : chain === "arc" ? "Arc" : "Base"; - for (const text of [c.title, c.description, c.heading, c.intro]) assert.ok(text.includes(label), `${chain}: "${text}" must name the chain`); + 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); } - assert.equal(chainLandingLabel("base"), "openlaunch on Base"); }); test("landing pages canonicalise to themselves, not to / or ?chain=", () => { @@ -43,6 +41,7 @@ test("/t/ moves permanently to the landing page", () => { assert.doesNotMatch(legacy, /\/\?chain=/); }); -test("footer links each chain's landing page", () => { +test("footer and about page link each chain's landing page", () => { assert.match(read("../components/Footer.tsx"), / = { export function chainLandingPath(chain: ChainKey): string { return `/${chain}`; } - -/** Link text for cross-links between chain pages and from the footer. */ -export function chainLandingLabel(chain: ChainKey): string { - return `${BRAND} on ${CHAIN_LABELS[chain]}`; -} diff --git a/app/src/lib/seo.ts b/app/src/lib/seo.ts index f563965..02d490a 100644 --- a/app/src/lib/seo.ts +++ b/app/src/lib/seo.ts @@ -51,7 +51,6 @@ export const STATIC_SITEMAP_ROUTES: StaticRoute[] = [ { path: "/about", changeFrequency: "monthly", priority: 0.6 }, { path: "/agents", changeFrequency: "monthly", priority: 0.6 }, ]; -// /me is wallet-specific (noindex) and /admin is moderation-only: neither belongs in the sitemap. export type SitemapEntry = { url: string; From 0ceea6b60d508fe5651f0366ae72b7dfbeae0874 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 25 Sep 2026 09:20:32 +0800 Subject: [PATCH 3/5] seo: every indexable page gets its own share card Next replaces nested metadata objects, so pages that set only title and description inherited the root layout's openGraph/twitter whole: the home card's title, and og:url pointing at /. pageMetadata() sets canonical, og and twitter per page, and names the site image (a page's own openGraph drops the inherited file image). Also: the chain page's list heading id no longer collides with LaunchList's. --- app/src/app/about/page.tsx | 7 +++-- app/src/app/agents/page.tsx | 3 +- app/src/app/feed/page.tsx | 3 +- app/src/app/launch/page.tsx | 7 +++-- app/src/app/metadata.test.ts | 7 +++-- app/src/app/rules/page.tsx | 3 +- app/src/components/launchpad/ChainLanding.tsx | 7 +++-- app/src/components/sections/about.test.ts | 2 +- app/src/lib/chainLanding.test.ts | 3 +- app/src/lib/seo.test.ts | 17 ++++++++++ app/src/lib/seo.ts | 31 +++++++++++++++++++ 11 files changed, 74 insertions(+), 16 deletions(-) diff --git a/app/src/app/about/page.tsx b/app/src/app/about/page.tsx index fe5a8b9..3615459 100644 --- a/app/src/app/about/page.tsx +++ b/app/src/app/about/page.tsx @@ -1,4 +1,5 @@ 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"; @@ -12,11 +13,11 @@ import { chainLandingPath } from "@/lib/chainLanding"; * 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 = { +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.`, - alternates: { canonical: "/about" }, -}; +}); 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."], diff --git a/app/src/app/agents/page.tsx b/app/src/app/agents/page.tsx index 4a0533d..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.", alternates: { canonical: "/agents" } }; +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/feed/page.tsx b/app/src/app/feed/page.tsx index 5417787..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.", alternates: { canonical: "/feed" } }; +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 7991e44..a6c12f3 100644 --- a/app/src/app/launch/page.tsx +++ b/app/src/app/launch/page.tsx @@ -1,15 +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.", - alternates: { canonical: "/launch" }, -}; +}); export const dynamic = "force-dynamic"; diff --git a/app/src/app/metadata.test.ts b/app/src/app/metadata.test.ts index 5bd18c1..5523170 100644 --- a/app/src/app/metadata.test.ts +++ b/app/src/app/metadata.test.ts @@ -18,7 +18,7 @@ test("root layout declares no canonical and renders the site entity JSON-LD", () assert.match(layout, /