Skip to content

Commit 0ceea6b

Browse files
committed
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.
1 parent f10b4a3 commit 0ceea6b

11 files changed

Lines changed: 74 additions & 16 deletions

File tree

‎app/src/app/about/page.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from "next";
2+
import { pageMetadata } from "@/lib/seo";
23
import Link from "next/link";
34
import { ArrowRight, ArrowUpRight } from "lucide-react";
45
import SectionIntro from "@/components/sections/SectionIntro";
@@ -12,11 +13,11 @@ import { chainLandingPath } from "@/lib/chainLanding";
1213
* official channels, and separates the brand from look-alikes. Facts only (CONTRIBUTING.md):
1314
* what the contracts do, where the code is, which accounts are ours. No claims about value.
1415
*/
15-
export const metadata: Metadata = {
16+
export const metadata: Metadata = pageMetadata({
17+
path: "/about",
1618
title: `About ${BRAND}`,
1719
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.`,
18-
alternates: { canonical: "/about" },
19-
};
20+
});
2021

2122
const DIFFERENCES = [
2223
["No platform fee", "The factory and the locker have no fee address, fee variable or treasury. There is nothing to switch on later."],

‎app/src/app/agents/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from "next";
2+
import { pageMetadata } from "@/lib/seo";
23
import { ArrowDown, ArrowRight, ArrowUpRight, Braces, FileCode2, Terminal } from "lucide-react";
34
import SectionIntro from "@/components/sections/SectionIntro";
45
import AgentsCodeBlock from "@/components/sections/AgentsCodeBlock";
@@ -8,7 +9,7 @@ import { launchpad } from "@/lib/launchpad/config";
89
import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, SITE_URL, explorerAddress } from "@/lib/chainPublic";
910
import { BRAND_GITHUB } from "@/lib/brand";
1011

11-
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" } };
12+
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." });
1213

1314
const sections = [
1415
{ id: "launch", number: "01", label: "Launch a token" },

‎app/src/app/feed/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { Metadata } from "next";
2+
import { pageMetadata } from "@/lib/seo";
23
import Link from "next/link";
34
import { ArrowUpRight } from "lucide-react";
45
import CommunityFeed from "@/components/sections/CommunityFeed";
56
import SectionIntro from "@/components/sections/SectionIntro";
67
import styles from "@/components/sections/SectionShell.module.css";
78
import { listFeed } from "@/lib/launchpad/postsServer";
89

9-
export const metadata: Metadata = { title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol.", alternates: { canonical: "/feed" } };
10+
export const metadata: Metadata = pageMetadata({ path: "/feed", title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol." });
1011
export const dynamic = "force-dynamic";
1112

1213
export default async function FeedPage() {

‎app/src/app/launch/page.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import type { Metadata } from "next";
2+
import { pageMetadata } from "@/lib/seo";
23
import LaunchForm from "@/components/launchpad/LaunchForm";
34
import { ethUsd } from "@/lib/launchpad/ethPrice";
45
import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer";
56
import { DEFAULT_CHAIN, chainKeyOr, type ChainKey } from "@/lib/chainPublic";
67
import { CONFIGURED_CHAINS, launchpad } from "@/lib/launchpad/config";
78

8-
export const metadata: Metadata = {
9+
export const metadata: Metadata = pageMetadata({
10+
path: "/launch",
911
title: "Launch a token for free",
1012
description: "Deploy a token on Base, Robinhood Chain or Arc with 100% of supply locked as Uniswap v4 liquidity. No platform fee. Gas only.",
11-
alternates: { canonical: "/launch" },
12-
};
13+
});
1314

1415
export const dynamic = "force-dynamic";
1516

‎app/src/app/metadata.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ test("root layout declares no canonical and renders the site entity JSON-LD", ()
1818
assert.match(layout, /<script type="application\/ld\+json" dangerouslySetInnerHTML=\{\{ __html: siteJsonLd \}\} \/>/);
1919
});
2020

21-
test("every indexable page declares its own canonical path", () => {
21+
test("every indexable page declares its own canonical path and share card", () => {
2222
const pages: [string, string][] = [
2323
["./(home)/page.tsx", "/"],
2424
["./launch/page.tsx", "/launch"],
@@ -28,7 +28,10 @@ test("every indexable page declares its own canonical path", () => {
2828
["./about/page.tsx", "/about"],
2929
];
3030
for (const [file, path] of pages) {
31-
assert.ok(read(file).includes(`alternates: { canonical: "${path}" }`), `${file} must canonicalise to ${path}`);
31+
const src = read(file);
32+
// the home page's title, description and card are the layout's own; every other page goes through pageMetadata
33+
const ok = path === "/" ? src.includes(`alternates: { canonical: "/" }`) : new RegExp(`pageMetadata\\(\\{\\s*path: "${path}"`).test(src);
34+
assert.ok(ok, `${file} must canonicalise to ${path}`);
3235
}
3336
});
3437

‎app/src/app/rules/page.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from "next";
2+
import { pageMetadata } from "@/lib/seo";
23
import Link from "next/link";
34
import { ArrowDown, ArrowRight, ArrowUpRight, ChevronDown, FileCode2, LockKeyhole } from "lucide-react";
45
import SectionIntro from "@/components/sections/SectionIntro";
@@ -8,7 +9,7 @@ import { launchpad } from "@/lib/launchpad/config";
89
import { CHAINS, CHAIN_KEYS, CHAIN_LABELS, explorerAddress } from "@/lib/chainPublic";
910
import { BRAND_GITHUB } from "@/lib/brand";
1011

11-
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.", alternates: { canonical: "/rules" } };
12+
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." });
1213

1314
const STEPS = [
1415
{ 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" },

‎app/src/components/launchpad/ChainLanding.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import { getLaunchTotals, listLaunchesPage } from "@/lib/launchpad/queries";
1313
import { PAGE_SIZE } from "@/lib/launchpad/paging";
1414
import { ethUsd } from "@/lib/launchpad/ethPrice";
1515
import { dbConfigured } from "@/lib/db";
16+
import { pageMetadata } from "@/lib/seo";
1617

1718
/** Metadata for /<chain>: its own title, snippet and canonical (never "/", see app/metadata.test.ts). */
1819
export function chainLandingMetadata(chain: ChainKey): Metadata {
1920
const c = CHAIN_LANDING[chain];
20-
return { title: c.title, description: c.description, alternates: { canonical: chainLandingPath(chain) } };
21+
return pageMetadata({ path: chainLandingPath(chain), title: c.title, description: c.description });
2122
}
2223

2324
/**
@@ -61,8 +62,8 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) {
6162
) : null}
6263
</section>
6364

64-
<section className={`${shell.anchorSection} mt-14`} aria-labelledby="launches-heading">
65-
<h2 id="launches-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">Tokens on {CHAIN_LABELS[chain]}</h2>
65+
<section className={`${shell.anchorSection} mt-14`} aria-labelledby="chain-launches-heading">
66+
<h2 id="chain-launches-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">Tokens on {CHAIN_LABELS[chain]}</h2>
6667
<div className="mt-6">
6768
<LaunchList initial={page.items} initialHasMore={page.hasMore} initialSort="live" initialWindow="all" initialChain={chain} initialFilter={null} hasDb={dbConfigured()} />
6869
</div>

‎app/src/components/sections/about.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const llms = readFileSync(new URL("../../app/llms.txt/route.ts", import.meta.url
1111
test("about page is the brand entity page, built from brand.ts constants", () => {
1212
assert.match(page, /import \{ BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, LEGACY_DOMAIN \} from "@\/lib\/brand"/);
1313
assert.match(page, /title=\{`What \$\{BRAND\} is`\}/);
14-
assert.match(page, /alternates: \{ canonical: "\/about" \}/);
14+
assert.match(page, /pageMetadata\(\{\s*path: "\/about"/);
1515
assert.match(page, /https:\/\/x\.com\/\$\{BRAND_X\}/);
1616
assert.match(page, /href=\{BRAND_GITHUB\}/);
1717
assert.match(page, /CHAIN_KEYS\.map/);

‎app/src/lib/chainLanding.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ test("landing copy fits search snippets and names its chain", () => {
3131

3232
test("landing pages canonicalise to themselves, not to / or ?chain=", () => {
3333
const comp = read("../components/launchpad/ChainLanding.tsx");
34-
assert.match(comp, /alternates: \{ canonical: chainLandingPath\(chain\) \}/);
34+
assert.match(comp, /pageMetadata\(\{ path: chainLandingPath\(chain\)/);
35+
assert.doesNotMatch(comp, /id="launches-heading"/, "LaunchList already owns that id");
3536
assert.match(comp, /notFound\(\)/, "an unconfigured chain 404s instead of rendering an empty list");
3637
});
3738

‎app/src/lib/seo.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isCanonicalTokenPath,
77
jsonLdHtml,
88
jsonLdScript,
9+
pageMetadata,
910
siteJsonLd,
1011
siteJsonLdHtml,
1112
siteVerification,
@@ -163,3 +164,19 @@ test("siteVerification renders only the tags that are configured", () => {
163164
assert.deepEqual(siteVerification({ BING_SITE_VERIFICATION: "b456" }), { other: { "msvalidate.01": "b456" } });
164165
assert.deepEqual(siteVerification({ GOOGLE_SITE_VERIFICATION: "g", BING_SITE_VERIFICATION: "b" }), { google: "g", other: { "msvalidate.01": "b" } });
165166
});
167+
168+
test("pageMetadata gives a page its own canonical and share card, never the home page's", () => {
169+
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.";
170+
const m = pageMetadata({ path: "/launch", title: "Launch a token for free", description: long });
171+
assert.equal(m.description, long, "the search snippet keeps the full description");
172+
assert.deepEqual(m.alternates, { canonical: "/launch" });
173+
assert.equal(m.openGraph.url, "/launch");
174+
assert.equal(m.openGraph.title, "Launch a token for free");
175+
assert.equal(m.openGraph.siteName, "openlaunch.lol");
176+
assert.equal(m.twitter.title, "Launch a token for free");
177+
assert.equal(m.twitter.site, "@openlaunch_lol");
178+
// a page's own openGraph drops the inherited file image: the card must name it or previews go blank
179+
assert.deepEqual(m.openGraph.images.map((i) => i.url), ["/opengraph-image"]);
180+
assert.deepEqual(m.twitter.images, m.openGraph.images);
181+
assert.ok(m.openGraph.description.length <= 125 && m.twitter.description === m.openGraph.description, "share text is clamped for previews");
182+
});

0 commit comments

Comments
 (0)