diff --git a/next.config.js b/next.config.js index 8c5823a9f1..0d10ae5f1d 100644 --- a/next.config.js +++ b/next.config.js @@ -298,6 +298,18 @@ let nextConfig = { return config }, reactStrictMode: false, + // Do NOT remove. Next's built-in trailing-slash redirect is global, and the + // PostHog reverse proxy below (/relay/*) is hit with trailing slashes by the + // SDK's POSTs (/relay/decide/, /relay/e/). A 308 on those either drops the + // body or costs every event an extra round trip, so the automatic redirect + // stays off. + // + // The SEO problem it leaves behind — /en/help/ and /en/help both returning + // 200 — is solved narrowly instead: redirects.json ends with a + // `/:locale(en|es-419|es-ar|pt-br)/:path+/` -> slashless permanent (308) + // redirect, which only covers the locale-prefixed marketing tree and cannot + // touch /relay, /monitoring, /passkeys or the recipient catch-all. Keep that + // locale list in sync with SUPPORTED_LOCALES (src/i18n/types.ts). skipTrailingSlashRedirect: true, async rewrites() { return { diff --git a/redirects.json b/redirects.json index c1c825b252..eed535caa1 100644 --- a/redirects.json +++ b/redirects.json @@ -44,6 +44,11 @@ "destination": "/en/help", "permanent": false }, + { + "source": "/help/:path*", + "destination": "/en/help/:path*", + "permanent": true + }, { "source": "/terms", "destination": "/en/terms", @@ -54,6 +59,26 @@ "destination": "/en/privacy", "permanent": false }, + { + "source": "/pricing", + "destination": "/en/pricing", + "permanent": true + }, + { + "source": "/stories", + "destination": "/en/stories", + "permanent": true + }, + { + "source": "/stories/:path*", + "destination": "/en/stories/:path*", + "permanent": true + }, + { + "source": "/content", + "destination": "/en/content", + "permanent": true + }, { "source": "/:slug(card-terms-us|card-terms-international|card-esign|card-privacy|card-prohibited-activities)", "destination": "/en/:slug", @@ -80,6 +105,17 @@ "destination": "https://peanut.me/en/help", "permanent": true }, + { + "source": "/:path*", + "has": [ + { + "type": "host", + "value": "docs.peanut.to" + } + ], + "destination": "https://peanut.me/en/help", + "permanent": true + }, { "source": "/packet", "destination": "https://github.com/peanutprotocol/peanut-ui/tree/archive/legacy-peanut-to", @@ -169,5 +205,10 @@ "source": "/:locale/deposit/from-spei", "destination": "/:locale/deposit/via-spei", "permanent": true + }, + { + "source": "/:locale(en|es-419|es-ar|pt-br)/:path+/", + "destination": "/:locale/:path+", + "permanent": true } ] diff --git a/scripts/__tests__/verify-content-frontmatter.test.ts b/scripts/__tests__/verify-content-frontmatter.test.ts new file mode 100644 index 0000000000..a41ca689ef --- /dev/null +++ b/scripts/__tests__/verify-content-frontmatter.test.ts @@ -0,0 +1,22 @@ +/** @jest-environment node */ + +import { isPublishedContent, parseContentFrontmatter } from '../verify-content-frontmatter' + +describe('content verifier frontmatter', () => { + it.each(['published: false', 'published: False', 'published: false # draft'])( + 'treats YAML boolean %s as unpublished', + (publishedLine) => { + expect(isPublishedContent(`---\n${publishedLine}\n---\nDraft`)).toBe(false) + } + ) + + it('defaults missing publication state to published', () => { + expect(isPublishedContent('---\ntitle: Published\n---\nBody')).toBe(true) + }) + + it('uses YAML parsing for the remaining frontmatter fields', () => { + expect(parseContentFrontmatter('---\nskip_polish_check: true # reviewed\n---\nBody')).toMatchObject({ + skip_polish_check: true, + }) + }) +}) diff --git a/scripts/__tests__/verify-content-routes.test.ts b/scripts/__tests__/verify-content-routes.test.ts new file mode 100644 index 0000000000..39f179d1dc --- /dev/null +++ b/scripts/__tests__/verify-content-routes.test.ts @@ -0,0 +1,17 @@ +/** @jest-environment node */ + +import { isKnownRouteOrLocaleRedirect } from '../verify-content-routes' + +describe('content route aliases', () => { + const validPaths = new Set(['/en/pricing', '/es-419/help/delete-account']) + + it('accepts direct routes and retired-locale aliases with real destinations', () => { + expect(isKnownRouteOrLocaleRedirect('/en/pricing', validPaths)).toBe(true) + expect(isKnownRouteOrLocaleRedirect('/es-es', validPaths)).toBe(true) + expect(isKnownRouteOrLocaleRedirect('/es-es/help/delete-account', validPaths)).toBe(true) + }) + + it('rejects retired-locale aliases whose destinations do not exist', () => { + expect(isKnownRouteOrLocaleRedirect('/es-es/definitely-not-a-route', validPaths)).toBe(false) + }) +}) diff --git a/scripts/verify-content-frontmatter.ts b/scripts/verify-content-frontmatter.ts new file mode 100644 index 0000000000..8786d688d2 --- /dev/null +++ b/scripts/verify-content-frontmatter.ts @@ -0,0 +1,10 @@ +import matter from 'gray-matter' + +export function parseContentFrontmatter(content: string): Record { + return matter(content).data as Record +} + +/** Match the application's publication contract: only YAML boolean false is a draft. */ +export function isPublishedContent(content: string): boolean { + return parseContentFrontmatter(content).published !== false +} diff --git a/scripts/verify-content-routes.ts b/scripts/verify-content-routes.ts new file mode 100644 index 0000000000..29a58f1ebb --- /dev/null +++ b/scripts/verify-content-routes.ts @@ -0,0 +1,23 @@ +const LOCALE_REDIRECTS = { + 'es-es': 'es-419', +} as const + +/** + * A retired-locale URL is valid only when its redirect destination is a real + * route. Accepting the alias prefix by itself would hide broken links such as + * /es-es/definitely-not-a-route. + */ +export function isKnownRouteOrLocaleRedirect(url: string, validPaths: ReadonlySet): boolean { + if (validPaths.has(url)) return true + + for (const [sourceLocale, destinationLocale] of Object.entries(LOCALE_REDIRECTS)) { + const sourceRoot = `/${sourceLocale}` + if (url !== sourceRoot && !url.startsWith(`${sourceRoot}/`)) continue + if (url === sourceRoot) return true + + const destination = `/${destinationLocale}${url.slice(sourceRoot.length)}` + return validPaths.has(destination) + } + + return false +} diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 9f7374ce78..3c2c74cbb1 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -27,14 +27,18 @@ import fs from 'fs' import path from 'path' import { RAIL_SLUGS } from '../src/data/seo/deposit-rails' +import { SUPPORTED_LOCALES } from '../src/i18n/types' +import { isPublishedContent, parseContentFrontmatter } from './verify-content-frontmatter' +import { isKnownRouteOrLocaleRedirect } from './verify-content-routes' const ROOT = path.join(process.cwd(), 'src/content') const CONTENT_DIR = path.join(ROOT, 'content') const APP_DIR = path.join(process.cwd(), 'src/app/[locale]/(marketing)') -const SUPPORTED_LOCALES = ['en', 'es-419', 'es-ar', 'es-es', 'pt-br'] const PRIMARY_LOCALES = ['en', 'es-419', 'pt-br'] - +const LOCALE_PATH_PREFIX = new RegExp( + `^/(${SUPPORTED_LOCALES.map((locale) => locale.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/` +) // `content/deposit/` mixes two URL families on the same dynamic route: // exchanges → /{locale}/deposit/from-{slug} // rails → /{locale}/deposit/via-{slug} @@ -74,14 +78,19 @@ function listDirs(dir: string): string[] { } /** - * Receive-money-from pages render only for corridor origins that actually have - * a receive-from article. Mirrors RECEIVE_SOURCES in src/data/seo/corridors.ts. + * Receive-money-from pages render for every published receive-from article. + * Mirrors RECEIVE_SOURCES in src/data/seo/corridors.ts (listPublishedSlugs): + * publication is gated on the article existing, not on corridor membership. * Without this gate, both the route index and the sitemap "expected URLs" would * agree with each other on a slug that 404s at runtime (e.g. colombia, mexico). */ -function gateReceiveSources(corridors: Array<{ from: string; to: string }>): string[] { - const origins = [...new Set(corridors.map((c) => c.from))] - return origins.filter((slug) => fs.existsSync(path.join(CONTENT_DIR, 'receive-from', slug, 'en.md'))) +function gateReceiveSources(): string[] { + return listDirs(path.join(CONTENT_DIR, 'receive-from')) + .filter((slug) => slug !== 'index') + .filter((slug) => { + const en = path.join(CONTENT_DIR, 'receive-from', slug, 'en.md') + return fs.existsSync(en) && isPublishedContent(fs.readFileSync(en, 'utf-8')) + }) } function getAllMdFiles(dir: string): string[] { @@ -116,36 +125,6 @@ function isContentPage(filePath: string): boolean { return true } -// --- Frontmatter parsing --- - -function parseFrontmatter(content: string): Record { - const match = content.match(/^---\n([\s\S]*?)\n---/) - if (!match) return {} - const frontmatter: Record = {} - for (const line of match[1].split('\n')) { - const colonIdx = line.indexOf(':') - if (colonIdx === -1) continue - const key = line.slice(0, colonIdx).trim() - let value: string | boolean = line.slice(colonIdx + 1).trim() - if (value === 'true') value = true - else if (value === 'false') value = false - // Strip quotes - if ( - typeof value === 'string' && - ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) - ) { - value = value.slice(1, -1) - } - frontmatter[key] = value - } - return frontmatter -} - -function isPublished(content: string): boolean { - const fm = parseFrontmatter(content) - return fm.published !== false -} - // --- Build valid paths from actual routes --- function discoverRoutes(): Set { @@ -193,7 +172,7 @@ function discoverRoutes(): Set { corridors.push({ to: dest, from: origin }) } } - const receiveSources = gateReceiveSources(corridors) + const receiveSources = gateReceiveSources() // Check which routes actually have page.tsx files const hasRoute = (routePath: string) => { @@ -344,7 +323,7 @@ function checkLinks(validPaths: Set) { for (const file of files) { if (!isContentPage(file)) continue const content = fs.readFileSync(file, 'utf-8') - if (!isPublished(content)) { + if (!isPublishedContent(content)) { skippedUnpublished++ continue } @@ -354,7 +333,7 @@ function checkLinks(validPaths: Set) { for (const link of links) { const clean = cleanUrl(link.url) - if (!validPaths.has(clean)) { + if (!isKnownRouteOrLocaleRedirect(clean, validPaths)) { error( 'broken-link', `Broken link: ${link.url}${link.text ? ` "${link.text}"` : ''}`, @@ -397,7 +376,7 @@ function checkPublishedHasRoute(validPaths: Set) { const enFile = path.join(CONTENT_DIR, ct.dir, slug, 'en.md') if (!fs.existsSync(enFile)) continue const content = fs.readFileSync(enFile, 'utf-8') - if (!isPublished(content)) continue + if (!isPublishedContent(content)) continue const url = ct.urlPattern('en', slug) if (!validPaths.has(url)) { @@ -412,7 +391,7 @@ function checkPublishedHasRoute(validPaths: Set) { const enFile = path.join(CONTENT_DIR, intent, 'en.md') if (!fs.existsSync(enFile)) continue const content = fs.readFileSync(enFile, 'utf-8') - if (!isPublished(content)) continue + if (!isPublishedContent(content)) continue const url = `/en/${intent}` if (!validPaths.has(url)) { @@ -440,7 +419,7 @@ function checkFooterManifest(validPaths: Set) { for (const entry of entries) { if (entry.external) continue const clean = cleanUrl(entry.href) - if (!validPaths.has(clean)) { + if (!isKnownRouteOrLocaleRedirect(clean, validPaths)) { error( 'footer', `Footer manifest "${section}" links to non-existent route: ${entry.href}`, @@ -469,9 +448,9 @@ function checkFrontmatter() { for (const file of files) { if (!isContentPage(file)) continue const content = fs.readFileSync(file, 'utf-8') - const fm = parseFrontmatter(content) + const fm = parseContentFrontmatter(content) - if (!isPublished(content)) continue + if (!isPublishedContent(content)) continue if (!fm.title || (typeof fm.title === 'string' && fm.title.trim() === '')) { error('frontmatter', 'Published file missing title', rel(file)) @@ -500,7 +479,7 @@ function checkLocaleCoverage() { const enFile = path.join(slugDir, 'en.md') if (!fs.existsSync(enFile)) continue const content = fs.readFileSync(enFile, 'utf-8') - if (!isPublished(content)) continue + if (!isPublishedContent(content)) continue for (const locale of PRIMARY_LOCALES) { if (locale === 'en') continue @@ -539,9 +518,9 @@ function checkContentPolish() { for (const file of files) { if (!isContentPage(file)) continue const content = fs.readFileSync(file, 'utf-8') - if (!isPublished(content)) continue + if (!isPublishedContent(content)) continue - const fm = parseFrontmatter(content) + const fm = parseContentFrontmatter(content) // Frontmatter override: skip_polish_check: true bypasses this check if (fm.skip_polish_check === true) continue @@ -584,7 +563,7 @@ function checkExplicitPublished() { for (const file of files) { const content = fs.readFileSync(file, 'utf-8') - const fm = parseFrontmatter(content) + const fm = parseContentFrontmatter(content) if (fm.published === false) { warn('draft-content', 'File is explicitly unpublished (draft)', rel(file)) @@ -639,7 +618,7 @@ function checkPageCountRegression() { const files = getAllMdFiles(CONTENT_DIR) const publishedCount = files.filter((f) => { const content = fs.readFileSync(f, 'utf-8') - return isPublished(content) + return isPublishedContent(content) }).length let baseline = 0 @@ -705,7 +684,7 @@ function expectedSitemapUrls(): string[] { const fromDir = path.join(CONTENT_DIR, 'send-to', dest, 'from') for (const origin of listDirs(fromDir)) corridors.push({ from: origin, to: dest }) } - const receiveSources = gateReceiveSources(corridors) + const receiveSources = gateReceiveSources() for (const locale of SUPPORTED_LOCALES) { for (const slug of countrySlugs) { @@ -761,7 +740,7 @@ function checkSitemapCoverage(validPaths: Set) { if (validPaths.has(url)) continue // Collapse identical messages across locales — one entry per pattern // is enough to fix; the full count is in the summary line. - const key = url.replace(/^\/(en|es-419|es-ar|es-es|pt-br)\//, '/{locale}/') + const key = url.replace(LOCALE_PATH_PREFIX, '/{locale}/') if (reported.has(key)) { missing++ continue diff --git a/src/__tests__/seo-redirects.test.ts b/src/__tests__/seo-redirects.test.ts new file mode 100644 index 0000000000..130ee4b20e --- /dev/null +++ b/src/__tests__/seo-redirects.test.ts @@ -0,0 +1,78 @@ +/** @jest-environment node */ + +import { getRedirectUrl, unstable_getResponseFromNextConfig } from 'next/experimental/testing/server' +import type { NextConfig } from 'next' +import redirects from '../../redirects.json' + +type Redirects = Awaited>> + +const nextConfig: NextConfig = { + // Production deliberately owns slash normalization in redirects.json so + // the locale-root exception can stay loop-free and /relay POSTs untouched. + skipTrailingSlashRedirect: true, + async redirects() { + return redirects as unknown as Redirects + }, +} + +async function evaluate(path: string) { + return unstable_getResponseFromNextConfig({ + url: `https://peanut.me${path}`, + nextConfig, + }) +} + +describe('production SEO redirects', () => { + it.each([ + ['/help/delete-account?from=legacy', 308, 'https://peanut.me/en/help/delete-account?from=legacy'], + ['/pricing', 308, 'https://peanut.me/en/pricing'], + ['/stories', 308, 'https://peanut.me/en/stories'], + ['/stories/customer-one', 308, 'https://peanut.me/en/stories/customer-one'], + ['/content', 308, 'https://peanut.me/en/content'], + ['/es-ar/help/', 308, 'https://peanut.me/es-ar/help'], + ])('%s returns %i and redirects to %s', async (path, status, destination) => { + const response = await evaluate(path) + expect(response.status).toBe(status) + expect(getRedirectUrl(response)).toBe(destination) + }) + + it.each(['/es-ar', '/es-ar/', '/relay/decide/'])('%s is not caught by slash normalization', async (path) => { + const response = await evaluate(path) + expect(getRedirectUrl(response)).toBeNull() + }) + + it('preserves the current-main press-kit destination', async () => { + const response = await evaluate('/presskit') + expect(response.status).toBe(308) + expect(getRedirectUrl(response)).toBe( + 'https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d' + ) + }) + + it('preserves both press-kit aliases and the docs.peanut.to host rule', () => { + const notion = 'https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d' + for (const source of ['/presskit', '/press-kit']) { + expect(redirects.find((rule) => rule.source === source)).toMatchObject({ + destination: notion, + permanent: true, + }) + } + expect( + redirects.find( + (rule) => + rule.source === '/:path*' && rule.has?.some((condition) => condition.value === 'docs.peanut.to') + ) + ).toMatchObject({ destination: 'https://peanut.me/en/help', permanent: true }) + }) + + it('keeps the retired es-es locale and every subpath redirected', () => { + expect(redirects.find((rule) => rule.source === '/es-es')).toMatchObject({ + destination: '/es-419', + permanent: true, + }) + expect(redirects.find((rule) => rule.source === '/es-es/:path*')).toMatchObject({ + destination: '/es-419/:path*', + permanent: true, + }) + }) +}) diff --git a/src/app/[...recipient]/loading.tsx b/src/app/[...recipient]/loading.tsx deleted file mode 100644 index 9bfb4e957f..0000000000 --- a/src/app/[...recipient]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import PeanutLoading from '@/components/Global/PeanutLoading' - -export default function Loading() { - return -} diff --git a/src/app/[...recipient]/page.test.ts b/src/app/[...recipient]/page.test.ts new file mode 100644 index 0000000000..888f1bae81 --- /dev/null +++ b/src/app/[...recipient]/page.test.ts @@ -0,0 +1,124 @@ +/** @jest-environment node */ + +import getOrigin from '@/lib/hosting/get-origin' +import { chargesApi } from '@/services/charges' +import { generateMetadata } from './page' + +jest.mock('./client', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/0_Bruddle/PageContainer', () => ({ __esModule: true, default: () => null })) +jest.mock('next/navigation', () => ({ notFound: jest.fn() })) +jest.mock('@/utils/general.utils', () => ({ + printableAddress: (address: string) => `${address.slice(0, 6)}...${address.slice(-6)}`, + isStableCoin: (token: string) => ['usdc', 'usdt'].includes(token.toLowerCase()), +})) +jest.mock('@/lib/hosting/get-origin', () => ({ __esModule: true, default: jest.fn() })) +jest.mock('@/services/charges', () => ({ chargesApi: { get: jest.fn() } })) + +const mockedGetOrigin = jest.mocked(getOrigin) +const mockedChargeGet = jest.mocked(chargesApi.get) + +async function metadata(recipient?: string[], chargeId?: string) { + return generateMetadata({ + params: Promise.resolve({ recipient }), + searchParams: Promise.resolve(chargeId ? { chargeId } : {}), + }) +} + +type MetadataResult = Awaited> +type FullMetadataResult = Extract + +function requireFullMetadata(result: MetadataResult): FullMetadataResult { + if (!('title' in result)) throw new Error('Expected full recipient metadata') + return result +} + +function expectNoIndexWithoutCanonical(result: MetadataResult) { + expect(result).toMatchObject({ + robots: { index: false, follow: false }, + alternates: { canonical: null }, + }) +} + +describe('recipient catch-all metadata', () => { + beforeEach(() => { + mockedGetOrigin.mockResolvedValue('https://preview.peanut.me') + mockedChargeGet.mockReset() + }) + + it.each([ + ['missing recipient', undefined], + ['reserved route', ['pricing']], + ['invalid recipient shape', ['not-a-recipient']], + ])('noindexes %s without a canonical', async (_name, recipient) => { + expectNoIndexWithoutCanonical(await metadata(recipient)) + expect(mockedChargeGet).not.toHaveBeenCalled() + }) + + it.each([ + [['alice1'], 'alice1 on Peanut'], + [['ALICE1'], 'alice1 on Peanut'], + [['alice1@arbitrum'], 'alice1 on Peanut'], + [['vitalik.eth'], 'vitalik.eth is requesting funds'], + [['0x1234567890123456789012345678901234567890'], '0x1234...567890 is requesting funds'], + [['alice1', '12.5USDC'], 'alice1 is requesting $12.5 via Peanut'], + ])('keeps %j out of the index and generates useful card metadata', async (recipient, title) => { + const result = await metadata(recipient) + expectNoIndexWithoutCanonical(result) + const full = requireFullMetadata(result) + expect(full.title).toBe(title) + expect(full.openGraph).toMatchObject({ title }) + expect(full.twitter).toMatchObject({ title, card: 'summary_large_image' }) + }) + + it('falls back safely when an amount segment is malformed', async () => { + const result = await metadata(['alice1', '1.2.3USDC']) + expectNoIndexWithoutCanonical(result) + const full = requireFullMetadata(result) + expect(full.title).toBe('alice1 on Peanut') + expect(String(full.title)).not.toContain('undefined') + }) + + it('renders unpaid request metadata from charge details', async () => { + mockedChargeGet.mockResolvedValue({ + fulfillmentPayment: null, + payments: [], + requestee: { username: 'requestee' }, + tokenAmount: '7', + tokenSymbol: 'USDC', + } as never) + const result = await metadata(['alice1'], 'unpaid-charge') + expectNoIndexWithoutCanonical(result) + expect(requireFullMetadata(result).title).toBe('alice1 is requesting $7 via Peanut') + expect(mockedChargeGet).toHaveBeenCalledWith('unpaid-charge') + }) + + it('renders paid receipt metadata and marks its OG image as a receipt', async () => { + mockedChargeGet.mockResolvedValue({ + fulfillmentPayment: { status: 'SUCCESSFUL' }, + payments: [ + { + status: 'SUCCESSFUL', + payerAccount: { user: { username: 'payer' } }, + }, + ], + requestee: { username: 'requestee' }, + tokenAmount: '9', + tokenSymbol: 'USDC', + } as never) + const result = await metadata(['alice1'], 'paid-charge') + expectNoIndexWithoutCanonical(result) + const full = requireFullMetadata(result) + expect(full.title).toBe('payer shared a receipt for $9 via Peanut') + expect(JSON.stringify(full.openGraph)).toContain('isReceipt=true') + }) + + it('does not 500 when charge lookup fails', async () => { + mockedChargeGet.mockRejectedValue(new Error('timeout')) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + const result = await metadata(['alice1'], 'missing-charge') + expectNoIndexWithoutCanonical(result) + expect(requireFullMetadata(result).title).toBe('alice1 | Peanut') + expect(errorSpy).toHaveBeenCalled() + errorSpy.mockRestore() + }) +}) diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 570355fbdb..9d90ea983c 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -16,6 +16,17 @@ type PageProps = { searchParams: Promise<{ chargeId?: string }> } +// This catch-all renders an indexable shell for ANY username-shaped string — the +// existence check runs client-side, so a non-existent handle still returns 200 with +// a " on Peanut" title. Google indexed thousands of those. Every metadata +// branch is noindex: profiles, addresses, ENS and request/receipt links are app +// surface, not search landing pages — no carve-outs. +// canonical: null suppresses the root layout's inherited `canonical: '/'` — +// noindex must not ship on pages that canonicalize to the homepage, or the +// noindex can be attributed to the canonical cluster head (the homepage itself). +const NOINDEX = { index: false, follow: false } as const +const NOINDEX_META = { robots: NOINDEX, alternates: { canonical: null } } as const + export async function generateMetadata({ params, searchParams }: PageProps) { const resolvedSearchParams = await searchParams const resolvedParams = await params @@ -23,18 +34,18 @@ export async function generateMetadata({ params, searchParams }: PageProps) { // Guard: Don't generate metadata for reserved routes (handled by their specific routes) const firstSegment = resolvedParams.recipient?.[0] if (firstSegment && isReservedRoute(`/${firstSegment}`)) { - return {} + return { ...NOINDEX_META } } // Guard: Ensure recipient exists if (!resolvedParams.recipient?.[0]) { - return {} + return { ...NOINDEX_META } } // Guard: Don't generate "X on Peanut" metadata for things that can't be recipients // (bare locale codes, slugs with dashes, random strings). Lets the 404 page own the tab title. if (!couldBeRecipient(firstSegment!)) { - return {} + return { ...NOINDEX_META } } let title = 'Request Payment | Peanut' @@ -160,6 +171,8 @@ export async function generateMetadata({ params, searchParams }: PageProps) { return { title, description, + robots: NOINDEX, + alternates: { canonical: null }, ...(siteUrl ? { metadataBase: new URL(siteUrl) } : {}), icons: { icon: '/favicon.ico', diff --git a/src/app/__tests__/seo-metadata.test.ts b/src/app/__tests__/seo-metadata.test.ts new file mode 100644 index 0000000000..43599ab345 --- /dev/null +++ b/src/app/__tests__/seo-metadata.test.ts @@ -0,0 +1,21 @@ +/** @jest-environment node */ + +jest.mock('@/components/Jobs', () => ({ Careers: () => null })) +jest.mock('@/components/LandingPage/LandingPageShell', () => ({ LandingPageShell: () => null })) +jest.mock('@/components/LandingPage/Footer', () => ({ __esModule: true, default: () => null })) +jest.mock('@/app/lp/card/CardLandingPage', () => ({ __esModule: true, default: () => null })) + +import { metadata as careersMetadata } from '@/app/careers/page' +import { metadata as cardMetadata } from '@/app/lp/card/page' +import { BASE_URL } from '@/constants/general.consts' + +describe('standalone indexable page metadata', () => { + it.each([ + ['careers', careersMetadata, '/careers'], + ['card landing page', cardMetadata, '/lp/card'], + ])('%s has a self-canonical and matching Open Graph URL', (_name, metadata, path) => { + expect(metadata.alternates?.canonical).toBe(path) + expect(metadata.openGraph?.url).toBe(`${BASE_URL}${path}`) + expect(metadata.robots).toBeUndefined() + }) +}) diff --git a/src/app/careers/page.tsx b/src/app/careers/page.tsx index dfd02eb8e3..8fd06ba278 100644 --- a/src/app/careers/page.tsx +++ b/src/app/careers/page.tsx @@ -6,6 +6,9 @@ export const metadata = generateMetadata({ description: 'Explore career opportunities at Peanut. Join our team to build the future of fast, global peer-to-peer payments with digital dollars.', keywords: 'careers, jobs, employment, Peanut careers, P2P payments jobs, fintech jobs, crypto jobs, tech jobs', + // Without this the page inherits the root layout's `canonical: '/'` and + // declares the homepage as its canonical while sitting in the sitemap. + canonical: '/careers', }) export default function CareersPage() { diff --git a/src/app/lp/card/page.tsx b/src/app/lp/card/page.tsx index 5446cf17ca..90687fc5b3 100644 --- a/src/app/lp/card/page.tsx +++ b/src/app/lp/card/page.tsx @@ -9,6 +9,10 @@ export const metadata = generateMeta({ 'Join Card Pioneers for early access to the Peanut Card. Reserve your spot with $10, earn $5 for every friend who joins, and spend your dollars globally.', keywords: 'peanut card, card pioneers, crypto card, digital dollars, global spending, early access, referral rewards, international card', + // Without this the page inherits lp/layout.tsx's deliberate `canonical: '/'` + // (the /lp subtree aliases the root landing page). That policy is wrong for + // /lp/card specifically: it's a distinct product page sitting in the sitemap. + canonical: '/lp/card', }) export default function CardLPPage() { diff --git a/src/app/robots.test.ts b/src/app/robots.test.ts new file mode 100644 index 0000000000..1765920d86 --- /dev/null +++ b/src/app/robots.test.ts @@ -0,0 +1,66 @@ +/** @jest-environment node */ + +import type { MetadataRoute } from 'next' +import { buildRobots } from './robots' +import { BASE_URL } from '@/constants/general.consts' + +type ArrayElementOrSelf = T extends readonly (infer Item)[] ? Item : T +type Rule = ArrayElementOrSelf> + +function rulesFor(userAgent: string): Rule { + const result = buildRobots(true) + if (!result.rules) throw new Error('Production robots policy has no rules') + const rules = (Array.isArray(result.rules) ? result.rules : [result.rules]) as Rule[] + const rule = rules.find((candidate) => { + const agents = Array.isArray(candidate.userAgent) ? candidate.userAgent : [candidate.userAgent] + return agents.includes(userAgent) + }) + if (!rule) throw new Error(`Missing robots group for ${userAgent}`) + return rule +} + +function values(value: string | string[] | undefined): string[] { + if (value === undefined) return [] + return Array.isArray(value) ? value : [value] +} + +describe('robots policy', () => { + it('blocks every non-production deployment', () => { + expect(buildRobots(false)).toEqual({ rules: [{ userAgent: '*', disallow: ['/'] }] }) + }) + + it.each([ + 'Googlebot', + 'GPTBot', + 'ChatGPT-User', + 'PerplexityBot', + 'ClaudeBot', + 'Google-Extended', + 'Applebot-Extended', + 'AhrefsBot', + 'SemrushBot', + 'MJ12bot', + ])('%s keeps the complete shared disallow policy', (userAgent) => { + expect([...values(rulesFor(userAgent).disallow)].sort()).toEqual([...values(rulesFor('*').disallow)].sort()) + }) + + it('lets Googlebot fetch OG images without opening the rest of /api', () => { + const googlebot = rulesFor('Googlebot') + expect(values(googlebot.allow)).toContain('/api/og') + expect(values(googlebot.disallow)).toContain('/api/') + }) + + it('deliberately leaves Twitterbot unrestricted for user-shared cards', () => { + const twitterbot = rulesFor('Twitterbot') + expect(values(twitterbot.allow)).toContain('/api/og') + expect(values(twitterbot.disallow)).toEqual([]) + }) + + it.each(['AhrefsBot', 'SemrushBot', 'MJ12bot'])('%s remains rate-limited', (userAgent) => { + expect(rulesFor(userAgent).crawlDelay).toBe(10) + }) + + it('advertises the production sitemap', () => { + expect(buildRobots(true).sitemap).toBe(`${BASE_URL}/sitemap.xml`) + }) +}) diff --git a/src/app/robots.ts b/src/app/robots.ts index 187321c1c3..e4e7245197 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -4,9 +4,43 @@ import { SUPPORTED_LOCALES } from '@/i18n/types' const IS_PRODUCTION_DOMAIN = BASE_URL === 'https://peanut.me' -export default function robots(): MetadataRoute.Robots { +// Paths kept out of the index: the API surface, the SDK bundle, and the +// auth-gated app routes. Used by the `*`, Googlebot, and AI-crawler groups; +// Twitterbot is the one deliberate exemption (see its comment). Mind the +// footgun when editing: a crawler only ever obeys the single most specific +// group that matches it, so a named group that omits a path silently opts +// that crawler out of it. +const DISALLOWED_PATHS = [ + '/api/', + '/sdk/', + // Auth-gated app routes + '/home', + '/profile', + '/settings', + '/send', + '/request', + '/setup', + '/claim', + '/pay', + '/dev/', + '/qr', + '/history', + '/points', + '/rewards', + '/invite', + '/kyc', + '/maintenance', + '/quests', + '/receipt', + '/crisp-proxy', + '/card-payment', + '/add-money', + '/withdraw', +] + +export function buildRobots(isProductionDomain: boolean): MetadataRoute.Robots { // Block indexing on staging, preview deploys, and non-production domains - if (!IS_PRODUCTION_DOMAIN) { + if (!isProductionDomain) { return { rules: [{ userAgent: '*', disallow: ['/'] }], } @@ -14,7 +48,11 @@ export default function robots(): MetadataRoute.Robots { return { rules: [ - // Allow Twitterbot to fetch OG images for link previews + // Twitterbot is DELIBERATELY unrestricted (empty disallow): it + // fetches user-shared app URLs (claim links, payment requests, + // receipts) to render link-preview cards on X, and it does not + // index. Restricting it would break card unfurls on exactly the + // links users share most. { userAgent: 'Twitterbot', allow: ['/api/og'], @@ -22,13 +60,21 @@ export default function robots(): MetadataRoute.Robots { }, // Googlebot must be able to fetch the dynamic OG images too — the - // generic `disallow: /api/` below would otherwise block them. + // generic `disallow: /api/` below would otherwise block them. The + // shared disallows are repeated here on purpose: Googlebot obeys + // this group INSTEAD of the `*` group, so without them it would + // treat every auth-gated route as crawlable. The narrower + // `/api/og` allow still wins over `/api/` by longest-match. { userAgent: 'Googlebot', allow: ['/api/og'], + disallow: DISALLOWED_PATHS, }, - // AI search engine crawlers — explicitly welcome + // AI search engine crawlers — explicitly welcome on all marketing + // and content pages, blocked from the same app/transactional + // surface as everyone else (they have no business in claim links, + // receipts, or KYC — and their answers should cite content pages). { userAgent: [ 'GPTBot', @@ -39,7 +85,7 @@ export default function robots(): MetadataRoute.Robots { 'Applebot-Extended', ], allow: ['/'], - disallow: ['/api/', '/home', '/profile', '/settings', '/setup', '/dev/'], + disallow: DISALLOWED_PATHS, }, // Default rules for all crawlers @@ -55,40 +101,19 @@ export default function robots(): MetadataRoute.Robots { // SEO routes (all locale-prefixed) ...SUPPORTED_LOCALES.map((l) => `/${l}/`), ], - disallow: [ - '/api/', - '/sdk/', - // Auth-gated app routes - '/home', - '/profile', - '/settings', - '/send', - '/request', - '/setup', - '/claim', - '/pay', - '/dev/', - '/qr', - '/history', - '/points', - '/rewards', - '/invite', - '/kyc', - '/maintenance', - '/quests', - '/receipt', - '/crisp-proxy', - '/card-payment', - '/add-money', - '/withdraw', - ], + disallow: DISALLOWED_PATHS, }, - // Rate-limit aggressive SEO crawlers - { userAgent: 'AhrefsBot', crawlDelay: 10 }, - { userAgent: 'SemrushBot', crawlDelay: 10 }, - { userAgent: 'MJ12bot', crawlDelay: 10 }, + // A named group replaces (rather than inherits) the wildcard group, + // so these rate-limited crawlers must repeat the shared policy too. + { userAgent: 'AhrefsBot', disallow: DISALLOWED_PATHS, crawlDelay: 10 }, + { userAgent: 'SemrushBot', disallow: DISALLOWED_PATHS, crawlDelay: 10 }, + { userAgent: 'MJ12bot', disallow: DISALLOWED_PATHS, crawlDelay: 10 }, ], sitemap: `${BASE_URL}/sitemap.xml`, } } + +export default function robots(): MetadataRoute.Robots { + return buildRobots(IS_PRODUCTION_DOMAIN) +} diff --git a/src/app/sitemap.test.ts b/src/app/sitemap.test.ts new file mode 100644 index 0000000000..044a9c3b45 --- /dev/null +++ b/src/app/sitemap.test.ts @@ -0,0 +1,81 @@ +/** @jest-environment node */ + +import fs from 'fs' +import path from 'path' +import matter from 'gray-matter' +import { BASE_URL } from '@/constants/general.consts' +import { RECEIVE_SOURCES } from '@/data/seo' +import { SUPPORTED_LOCALES } from '@/i18n/types' +import { + contentGeneratedAt, + readCorridorContent, + readPageContent, + readSingletonContent, + type ContentFrontmatter, +} from '@/lib/content' +import { generateSitemap } from './sitemap' + +const RECEIVE_PATH = '/receive-money-from/' + +function timestamp(value: Date | string | undefined): number { + if (!value) return Number.NaN + return new Date(value).getTime() +} + +describe('production sitemap', () => { + it('emits unique URLs with valid, non-future and varied lastmod values', async () => { + const sitemap = await generateSitemap() + const urls = sitemap.map((entry) => entry.url) + expect(new Set(urls).size).toBe(urls.length) + + const timestamps = sitemap.map((entry) => timestamp(entry.lastModified)) + expect(timestamps.every(Number.isFinite)).toBe(true) + expect(Math.max(...timestamps)).toBeLessThanOrEqual(Date.now() + 60_000) + expect(new Set(timestamps).size).toBeGreaterThan(10) + }) + + it('is stable across repeated generation in the same build', async () => { + const first = await generateSitemap() + const second = await generateSitemap() + expect(second).toEqual(first) + }) + + it.each([ + [ + '/en/receive-money-from/australia', + () => readPageContent('receive-from', 'australia', 'en'), + ], + [ + '/en/send-money-from/brazil/to/argentina', + () => readCorridorContent('argentina', 'brazil', 'en'), + ], + ['/en/pricing', () => readSingletonContent('pricing', 'en')], + ])('maps %s lastmod to its exact source frontmatter date', async (suffix, readSource) => { + const sitemap = await generateSitemap() + const entry = sitemap.find((item) => item.url.endsWith(suffix)) + const sourceDate = contentGeneratedAt(readSource()) + expect(entry).toBeDefined() + expect(timestamp(entry!.lastModified)).toBe(sourceDate?.getTime()) + }) + + it('lists the independently derived receive files, without fallback-locale duplicates', async () => { + const sitemap = await generateSitemap() + const receiveUrls = sitemap + .map((entry) => entry.url) + .filter((url) => url.includes(RECEIVE_PATH)) + .sort() + const contentRoot = path.join(process.cwd(), 'src/content/content/receive-from') + const expected: string[] = [] + + for (const source of RECEIVE_SOURCES) { + for (const locale of SUPPORTED_LOCALES) { + const file = path.join(contentRoot, source, `${locale}.md`) + if (!fs.existsSync(file)) continue + if (matter(fs.readFileSync(file, 'utf8')).data.published === false) continue + expected.push(`${BASE_URL}/${locale}${RECEIVE_PATH}${source}`) + } + } + + expect(receiveUrls).toEqual(expected.sort()) + }) +}) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 12d74984ba..0bd91a7f0a 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -11,11 +11,16 @@ import { } from '@/data/seo' import { SUPPORTED_LOCALES } from '@/i18n/config' import { + contentGeneratedAt, hasCorridorContent, hasPageContent, hasSingletonContent, listContentSlugs, listPublishedSlugs, + readCorridorContent, + readPageContent, + readSingletonContent, + type ContentFrontmatter, } from '@/lib/content' // TODO (infra): Update GitHub org, Twitter bio, LinkedIn, npm package.json → peanut.me @@ -24,7 +29,24 @@ import { /** Build date used for non-content pages that don't have their own date. */ const BUILD_DATE = new Date() -async function generateSitemap(): Promise { +// --- lastmod sources --- +// Content-backed URLs report the `generated_at` of the exact file that serves them, so a +// rebuild no longer bumps every lastmod to the deploy timestamp. These read through the same +// cache the has*Content() guards already populate, so they cost no extra file reads. +// Each returns undefined when the file is missing or carries no usable date, in which case +// the caller falls back to BUILD_DATE — that covers the hand-built pages (homepage, /lp/card, +// /careers, /exchange, legal) and the index pages that aren't backed by a single file. + +const pageDate = (intent: string, slug: string, locale: string): Date | undefined => + contentGeneratedAt(readPageContent(intent, slug, locale)) + +const corridorDate = (destination: string, origin: string, locale: string): Date | undefined => + contentGeneratedAt(readCorridorContent(destination, origin, locale)) + +const singletonDate = (intent: string, locale: string): Date | undefined => + contentGeneratedAt(readSingletonContent(intent, locale)) + +export async function generateSitemap(): Promise { type SitemapEntry = { path: string priority: number @@ -61,7 +83,12 @@ async function generateSitemap(): Promise { // Country hub pages for (const country of Object.keys(COUNTRIES_SEO)) { if (!hasPageContent('countries', country, locale)) continue - pages.push({ path: `/${locale}/${country}`, priority: 0.9 * basePriority, changeFrequency: 'weekly' }) + pages.push({ + path: `/${locale}/${country}`, + priority: 0.9 * basePriority, + changeFrequency: 'weekly', + lastModified: pageDate('countries', country, locale), + }) } // Send-money-to country pages @@ -71,6 +98,7 @@ async function generateSitemap(): Promise { path: `/${locale}/send-money-to/${country}`, priority: 0.8 * basePriority, changeFrequency: 'weekly', + lastModified: pageDate('send-to', country, locale), }) } @@ -81,16 +109,18 @@ async function generateSitemap(): Promise { path: `/${locale}/send-money-from/${corridor.from}/to/${corridor.to}`, priority: 0.85 * basePriority, changeFrequency: 'weekly', + lastModified: corridorDate(corridor.to, corridor.from, locale), }) } - // Receive money pages — corridor origins that have a receive-from article + // Receive money pages — every published receive-from article (independent of corridors) for (const source of RECEIVE_SOURCES) { if (!hasPageContent('receive-from', source, locale)) continue pages.push({ path: `/${locale}/receive-money-from/${source}`, priority: 0.7 * basePriority, changeFrequency: 'weekly', + lastModified: pageDate('receive-from', source, locale), }) } @@ -101,6 +131,7 @@ async function generateSitemap(): Promise { path: `/${locale}/compare/peanut-vs-${slug}`, priority: 0.7 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('compare', slug, locale), }) } @@ -114,6 +145,8 @@ async function generateSitemap(): Promise { path: `/${locale}/deposit/from-${exchange}`, priority: 0.7 * basePriority, changeFrequency: 'monthly', + // Undefined for the i18n-only exchanges (no MDX at all) → BUILD_DATE. + lastModified: pageDate('deposit', exchange, locale), }) } for (const rail of Object.keys(DEPOSIT_RAILS)) { @@ -122,6 +155,7 @@ async function generateSitemap(): Promise { path: `/${locale}/deposit/via-${rail}`, priority: 0.7 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('deposit', rail, locale), }) } @@ -132,6 +166,7 @@ async function generateSitemap(): Promise { path: `/${locale}/pay-with/${method}`, priority: 0.7 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('pay-with', method, locale), }) } @@ -147,6 +182,7 @@ async function generateSitemap(): Promise { path: `/${locale}/help/${slug}`, priority: 0.6 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('help', slug, locale), }) } @@ -157,6 +193,7 @@ async function generateSitemap(): Promise { path: `/${locale}/use-cases/${slug}`, priority: 0.7 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('use-cases', slug, locale), }) } @@ -168,6 +205,7 @@ async function generateSitemap(): Promise { path: `/${locale}/stories/${slug}`, priority: 0.6 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('stories', slug, locale), }) } // Stories index @@ -184,6 +222,7 @@ async function generateSitemap(): Promise { path: `/${locale}/withdraw/${slug}`, priority: 0.6 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('withdraw', slug, locale), }) } @@ -193,6 +232,7 @@ async function generateSitemap(): Promise { path: `/${locale}/supported-networks`, priority: 0.6 * basePriority, changeFrequency: 'monthly', + lastModified: singletonDate('supported-networks', locale), }) } @@ -202,6 +242,7 @@ async function generateSitemap(): Promise { path: `/${locale}/pricing`, priority: 0.7 * basePriority, changeFrequency: 'monthly', + lastModified: singletonDate('pricing', locale), }) } @@ -223,6 +264,7 @@ async function generateSitemap(): Promise { path: `/${locale}/blog/${slug}`, priority: 0.6 * basePriority, changeFrequency: 'monthly', + lastModified: pageDate('blog', slug, locale), }) } diff --git a/src/constants/__tests__/routes.test.ts b/src/constants/__tests__/routes.test.ts index b690e6ea76..d3c8c7fc4d 100644 --- a/src/constants/__tests__/routes.test.ts +++ b/src/constants/__tests__/routes.test.ts @@ -43,6 +43,14 @@ describe('DEDICATED_ROUTES', () => { const missing = segments.filter((s) => !(DEDICATED_ROUTES as readonly string[]).includes(s)) expect(missing).toEqual([]) }) + + it('reserves every bare marketing hub redirected into /en', () => { + expect(DEDICATED_ROUTES).toEqual(expect.arrayContaining(['pricing', 'stories', 'content'])) + for (const route of ['pricing', 'stories', 'content']) { + expect(isReservedRoute(`/${route}`)).toBe(true) + expect(isReservedRoute(`/${route.toUpperCase()}`)).toBe(true) + } + }) }) describe('couldBeRecipient — catch-all guard', () => { diff --git a/src/constants/routes.ts b/src/constants/routes.ts index a1581216a6..684c43e0d2 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -70,6 +70,17 @@ export const DEDICATED_ROUTES = [ 'faq', 'how-it-works', + // Marketing hubs that already ship as [locale]/(marketing) pages but whose + // bare paths were still recipient-shaped (7 lowercase letters each), so + // /pricing, /stories and /content rendered a payment-profile shell on a 200 + // instead of resolving to the real page. Reserved here + 301'd to /en/… in + // redirects.json. All three are also reserved server-side; the paired API + // hotfix must deploy before these redirects reach production so a future + // username can never be shadowed by a marketing route. + 'pricing', + 'stories', + 'content', + // Locale prefixes (current SUPPORTED_LOCALES) 'en', 'es-419', diff --git a/src/data/seo/corridors.test.ts b/src/data/seo/corridors.test.ts index 3bc5335dd5..adad533dd6 100644 --- a/src/data/seo/corridors.test.ts +++ b/src/data/seo/corridors.test.ts @@ -1,32 +1,56 @@ import fs from 'fs' import path from 'path' -import { CORRIDORS, RECEIVE_SOURCES } from './corridors' +import matter from 'gray-matter' +import { RECEIVE_SOURCES } from './corridors' const RECEIVE_FROM_DIR = path.join(process.cwd(), 'src/content/content/receive-from') -// Regression guard for the May 2026 live 404s: receive-money-from rendered for -// every corridor origin, but origins lacking a receive-from article (colombia, -// mexico) 404ed. RECEIVE_SOURCES must be CORRIDORS.from gated by content. -describe('RECEIVE_SOURCES', () => { - it('only contains corridor origins', () => { - const origins = new Set(CORRIDORS.map((c) => c.from)) - for (const slug of RECEIVE_SOURCES) { - expect(origins.has(slug)).toBe(true) - } - }) +beforeAll(() => { + if (!fs.existsSync(RECEIVE_FROM_DIR)) { + throw new Error( + `content tree missing at ${RECEIVE_FROM_DIR} — the src/content submodule is not ` + + 'initialized in this checkout. Run: git submodule update --init' + ) + } +}) - it('only contains origins that have a receive-from article (no 404s)', () => { +/** Re-derive the expected set straight off the filesystem, independently of + * the content lib the loader uses — a guard that reuses the loader's own + * helper would only prove it equals itself. gray-matter is used directly (not + * via the lib) so YAML semantics match what the loader's parser actually does + * (`published: False`, trailing comments, nesting). */ +function publishedReceiveFromSlugs(): string[] { + return fs + .readdirSync(RECEIVE_FROM_DIR) + .filter((slug) => slug !== 'index') + .filter((slug) => fs.statSync(path.join(RECEIVE_FROM_DIR, slug)).isDirectory()) + .filter((slug) => fs.existsSync(path.join(RECEIVE_FROM_DIR, slug, 'en.md'))) + .filter((slug) => { + const raw = fs.readFileSync(path.join(RECEIVE_FROM_DIR, slug, 'en.md'), 'utf8') + return matter(raw).data.published !== false + }) +} + +// Regression guard for the May 2026 live 404s: receive-money-from must only +// render slugs that have a published article. The guard used to also require +// RECEIVE_SOURCES ⊆ CORRIDORS.from, which was never what protected us — it was +// an artifact of how the list was built, and it silently dropped 10 authored +// countries whose articles were live but unreachable. What the route actually +// needs is the both-directions equality below: nothing rendered without +// content (no 404s), and nothing authored left behind (no orphans). +describe('RECEIVE_SOURCES', () => { + it('only contains slugs that have a published receive-from article (no 404s)', () => { for (const slug of RECEIVE_SOURCES) { const enFile = path.join(RECEIVE_FROM_DIR, slug, 'en.md') expect(fs.existsSync(enFile)).toBe(true) } }) - it('drops corridor origins with no receive-from article', () => { - const origins = [...new Set(CORRIDORS.map((c) => c.from))] - for (const slug of origins) { - const hasArticle = fs.existsSync(path.join(RECEIVE_FROM_DIR, slug, 'en.md')) - expect(RECEIVE_SOURCES.includes(slug)).toBe(hasArticle) - } + it('contains every published receive-from article (no orphaned content)', () => { + expect([...RECEIVE_SOURCES].sort()).toEqual(publishedReceiveFromSlugs().sort()) + }) + + it('has no duplicate slugs', () => { + expect(RECEIVE_SOURCES.length).toBe(new Set(RECEIVE_SOURCES).size) }) }) diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts index fd13254a14..f4907f3707 100644 --- a/src/data/seo/corridors.ts +++ b/src/data/seo/corridors.ts @@ -3,6 +3,7 @@ // Sources (all in the public mirror): // content/countries/{slug}/{lang}.md — country hub article + frontmatter // content/send-to/{dst}/from/{src}/{lang}.md — corridor article + frontmatter +// content/receive-from/{slug}/{lang}.md — receive-from article + frontmatter // // Country display names come from the `name:` field denormalized at // generation time (see mono/content/_system/templates/country-hub.md); absent @@ -19,6 +20,7 @@ import { listContentSlugs, listCorridorOrigins, + listPublishedSlugs, readCorridorContent, readPageContent, readPageContentLocalized, @@ -73,24 +75,31 @@ function loadCorridors(): Corridor[] { } /** - * Origins for the receive-money-from pages. The set is the corridor origins, - * but only those that actually have a receive-from article — an origin present - * in CORRIDORS.from but missing content/receive-from/{slug}/en.md would 404 - * (this is how colombia & mexico shipped as live 404s in May 2026). The - * receive-from content tree is authored independently of corridors, so the two - * sets don't line up automatically. + * Origins for the receive-money-from pages: every published receive-from + * article, read straight off the content tree. + * + * The invariant that matters is "the route has something to render" — an entry + * with no content/receive-from/{slug}/en.md would 404 (this is how colombia & + * mexico shipped as live 404s in May 2026), so publication is still gated on + * content presence. + * + * This used to seed from CORRIDORS.from and intersect with the content tree. + * That was strictly narrower than it needed to be: receive-from is authored + * independently of corridors, so the intersection silently dropped 10 authored + * countries (australia, india, kenya, malaysia, netherlands, pakistan, + * philippines, saudi-arabia, singapore, united-arab-emirates) whose articles + * were live but unreachable. Corridor membership was never a rendering + * requirement for these pages — only the article is. */ -function loadReceiveSources(corridors: Corridor[]): string[] { - const origins = [...new Set(corridors.map((c) => c.from))] - return origins.filter((slug) => { - const content = readPageContent<{ published?: boolean }>('receive-from', slug, 'en') - return content !== null && content.frontmatter.published !== false - }) +function loadReceiveSources(): string[] { + // 'index' skip: guards against a meta directory landing in the content + // tree becoming a live route (sitemap.ts applies the same skip elsewhere). + return listPublishedSlugs('receive-from').filter((slug) => slug !== 'index') } export const COUNTRIES_SEO: Record = loadCountries() export const CORRIDORS: Corridor[] = loadCorridors() -export const RECEIVE_SOURCES: string[] = loadReceiveSources(CORRIDORS) +export const RECEIVE_SOURCES: string[] = loadReceiveSources() /** * Get the country display name for a slug at the given locale. Reads diff --git a/src/lib/content.test.ts b/src/lib/content.test.ts index 68903dc700..5b2a8670cd 100644 --- a/src/lib/content.test.ts +++ b/src/lib/content.test.ts @@ -1,4 +1,4 @@ -import { listAllContent } from '@/lib/content' +import { contentGeneratedAt, listAllContent, readPageContent, type ContentFrontmatter } from '@/lib/content' describe('listAllContent', () => { it('returns items across all 4 hub types for en', () => { @@ -51,3 +51,32 @@ describe('listAllContent', () => { } }) }) + +describe('contentGeneratedAt', () => { + // gray-matter runs js-yaml, which turns unquoted YAML timestamps into Date objects even + // though ContentFrontmatter types generated_at as a string — both shapes must work. + it('accepts a Date (the usual runtime shape from unquoted YAML)', () => { + const at = contentGeneratedAt({ frontmatter: { generated_at: new Date('2026-03-27') } as never, body: '' }) + expect(at?.toISOString().split('T')[0]).toBe('2026-03-27') + }) + + it('accepts a quoted string date', () => { + const at = contentGeneratedAt({ frontmatter: { generated_at: '2026-03-20T17:10:00Z' } as never, body: '' }) + expect(at?.toISOString()).toBe('2026-03-20T17:10:00.000Z') + }) + + it('returns undefined for null content, a missing field, or an unparseable value', () => { + expect(contentGeneratedAt(null)).toBeUndefined() + expect(contentGeneratedAt({ frontmatter: {} as never, body: '' })).toBeUndefined() + expect(contentGeneratedAt({ frontmatter: { generated_at: 'not-a-date' } as never, body: '' })).toBeUndefined() + expect(contentGeneratedAt({ frontmatter: { generated_at: '' } as never, body: '' })).toBeUndefined() + }) + + it('reads a real date off a real content file', () => { + const at = contentGeneratedAt(readPageContent('help', 'delete-account', 'en')) + expect(at).toBeInstanceOf(Date) + // A real authored date, not the build clock. + expect(at!.getTime()).toBeLessThan(Date.now()) + expect(at!.getUTCFullYear()).toBeGreaterThanOrEqual(2026) + }) +}) diff --git a/src/lib/content.ts b/src/lib/content.ts index 2a8df175b5..778fbd3493 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -270,6 +270,27 @@ export function readSingletonContentLocalized>( return null } +// --- Content freshness --- + +/** + * `generated_at` from a content file's frontmatter, as a Date. + * + * Mind the type/runtime mismatch: ContentFrontmatter declares `generated_at?: string`, but + * gray-matter runs js-yaml, which parses unquoted YAML timestamps into JS Date objects — both + * the bare `2026-03-27` form and the full `2026-03-20T17:10:00Z` form. Quoted values still + * arrive as strings, so both shapes are accepted here. Missing or unparseable values return + * undefined so callers can fall back to a default of their own. + */ +export function contentGeneratedAt(content: MarkdownContent | null): Date | undefined { + const value: unknown = content?.frontmatter?.generated_at + if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? undefined : parsed + } + return undefined +} + // --- Content hub: cross-intent listing --- export type ContentItemType = 'blog' | 'stories' | 'use-cases' | 'compare'