Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
"destination": "/en/help",
"permanent": false
},
{
"source": "/help/:path*",
"destination": "/en/help/:path*",
"permanent": true
},
{
"source": "/terms",
"destination": "/en/terms",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
}
]
22 changes: 22 additions & 0 deletions scripts/__tests__/verify-content-frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
17 changes: 17 additions & 0 deletions scripts/__tests__/verify-content-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
10 changes: 10 additions & 0 deletions scripts/verify-content-frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import matter from 'gray-matter'

export function parseContentFrontmatter(content: string): Record<string, unknown> {
return matter(content).data as Record<string, unknown>
}

/** Match the application's publication contract: only YAML boolean false is a draft. */
export function isPublishedContent(content: string): boolean {
return parseContentFrontmatter(content).published !== false
}
23 changes: 23 additions & 0 deletions scripts/verify-content-routes.ts
Original file line number Diff line number Diff line change
@@ -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<string>): 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
}
83 changes: 31 additions & 52 deletions scripts/verify-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -116,36 +125,6 @@ function isContentPage(filePath: string): boolean {
return true
}

// --- Frontmatter parsing ---

function parseFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return {}
const frontmatter: Record<string, unknown> = {}
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<string> {
Expand Down Expand Up @@ -193,7 +172,7 @@ function discoverRoutes(): Set<string> {
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) => {
Expand Down Expand Up @@ -344,7 +323,7 @@ function checkLinks(validPaths: Set<string>) {
for (const file of files) {
if (!isContentPage(file)) continue
const content = fs.readFileSync(file, 'utf-8')
if (!isPublished(content)) {
if (!isPublishedContent(content)) {
skippedUnpublished++
continue
}
Expand All @@ -354,7 +333,7 @@ function checkLinks(validPaths: Set<string>) {

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}"` : ''}`,
Expand Down Expand Up @@ -397,7 +376,7 @@ function checkPublishedHasRoute(validPaths: Set<string>) {
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)) {
Expand All @@ -412,7 +391,7 @@ function checkPublishedHasRoute(validPaths: Set<string>) {
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)) {
Expand Down Expand Up @@ -440,7 +419,7 @@ function checkFooterManifest(validPaths: Set<string>) {
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}`,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -761,7 +740,7 @@ function checkSitemapCoverage(validPaths: Set<string>) {
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
Expand Down
Loading
Loading