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 + +
+

What makes {BRAND} different

+ +
+ +
+

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) => ( +
+
{hasChainPage(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.

+ +
+ +
+ Launch a token
+
+ ); +} 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 ( + " }), '{"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] }, + }; }