From 381afaaa7e0ba06d0bec94bb81d5338f668b1c38 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 23 Mar 2026 19:13:45 +0000 Subject: [PATCH 1/7] feat: add missing content routes, SEO footer, and comprehensive content verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six content types had published content but no matching Next.js route — they silently fell through to the catch-all recipient page. The old validate-links script only checked links against content directories, not actual routes, so CI passed while production served the wrong page. New routes: - /[locale]/use-cases/[slug] (5 published use cases) - /[locale]/withdraw/[slug] (9 withdraw guides) - /[locale]/stories/[slug] + index (ready for when stories go live) - /[locale]/pricing (singleton) - /[locale]/supported-networks (singleton) Content infrastructure: - readSingletonContent/Localized in content.ts for pages without slug subdirs - ContentSEOFooter server component reading footer-manifest.json - SEO footer added to all marketing content pages via ContentPage locale prop - 5 new route slugs registered for hreflang alternate tags verify-content replaces validate-links with 5 verification passes: 1. Internal link validation (2013 links across 258 published files) 2. Published content → route existence (no catch-all fallback) 3. Footer manifest URL validation 4. Frontmatter consistency (title, description) 5. Cross-locale coverage warnings --- package.json | 3 +- scripts/validate-links.ts | 341 ------------ scripts/verify-content.ts | 515 ++++++++++++++++++ .../[locale]/(marketing)/[country]/page.tsx | 1 + .../(marketing)/compare/[slug]/page.tsx | 1 + .../(marketing)/deposit/[exchange]/page.tsx | 1 + .../[locale]/(marketing)/help/[slug]/page.tsx | 1 + src/app/[locale]/(marketing)/help/page.tsx | 1 + .../(marketing)/pay-with/[method]/page.tsx | 1 + src/app/[locale]/(marketing)/pricing/page.tsx | 72 +++ .../receive-money-from/[country]/page.tsx | 1 + .../send-money-from/[from]/to/[to]/page.tsx | 1 + .../send-money-to/[country]/page.tsx | 1 + .../(marketing)/stories/[slug]/page.tsx | 75 +++ src/app/[locale]/(marketing)/stories/page.tsx | 85 +++ .../(marketing)/supported-networks/page.tsx | 72 +++ .../(marketing)/use-cases/[slug]/page.tsx | 74 +++ .../(marketing)/withdraw/[slug]/page.tsx | 74 +++ src/components/Marketing/ContentPage.tsx | 6 +- src/components/Marketing/ContentSEOFooter.tsx | 120 ++++ src/i18n/config.ts | 5 + src/lib/content.ts | 34 ++ 22 files changed, 1142 insertions(+), 343 deletions(-) delete mode 100644 scripts/validate-links.ts create mode 100644 scripts/verify-content.ts create mode 100644 src/app/[locale]/(marketing)/pricing/page.tsx create mode 100644 src/app/[locale]/(marketing)/stories/[slug]/page.tsx create mode 100644 src/app/[locale]/(marketing)/stories/page.tsx create mode 100644 src/app/[locale]/(marketing)/supported-networks/page.tsx create mode 100644 src/app/[locale]/(marketing)/use-cases/[slug]/page.tsx create mode 100644 src/app/[locale]/(marketing)/withdraw/[slug]/page.tsx create mode 100644 src/components/Marketing/ContentSEOFooter.tsx diff --git a/package.json b/package.json index 5dc3fac47..a7308b244 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "ping-indexnow": "tsx scripts/ping-indexnow.ts", "script": "NODE_OPTIONS=\"--experimental-json-modules\" tsx", "validate-content": "tsx scripts/validate-content.ts", - "validate-links": "tsx scripts/validate-links.ts" + "validate-links": "tsx scripts/verify-content.ts", + "verify-content": "tsx scripts/verify-content.ts" }, "dependencies": { "@dicebear/collection": "^9.2.2", diff --git a/scripts/validate-links.ts b/scripts/validate-links.ts deleted file mode 100644 index 92b3d2e1e..000000000 --- a/scripts/validate-links.ts +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env tsx -/** - * Internal link validation for peanut-ui content. - * Run: pnpm validate-links - * - * Scans all .md files in src/content/content/ and validates that every - * internal link points to a route that exists in the app. - */ - -import fs from 'fs' -import path from 'path' - -const ROOT = path.join(process.cwd(), 'src/content') -const CONTENT_DIR = path.join(ROOT, 'content') - -// --- Build valid URL index --- - -const SUPPORTED_LOCALES = ['en', 'es-419', 'es-ar', 'es-es', 'pt-br'] - -function listDirs(dir: string): string[] { - if (!fs.existsSync(dir)) return [] - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name) -} - -function listEntitySlugs(category: string): string[] { - const dir = path.join(ROOT, 'input/data', category) - if (!fs.existsSync(dir)) return [] - return fs - .readdirSync(dir) - .filter((f) => f.endsWith('.md')) - .map((f) => f.replace('.md', '')) -} - -function buildValidPaths(): Set { - const paths = new Set() - - // Static pages (no locale prefix) - for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/lp/card']) { - paths.add(p) - } - - // App routes (behind auth / mobile-ui) — content may link to these - for (const p of [ - '/profile', - '/profile/backup', - '/profile/edit', - '/profile/exchange-rate', - '/profile/identity-verification', - '/home', - '/send', - '/request', - '/settings', - '/history', - '/points', - '/recover-funds', - ]) { - paths.add(p) - } - - const countrySlugs = listDirs(path.join(CONTENT_DIR, 'countries')) - const competitorSlugs = listDirs(path.join(CONTENT_DIR, 'compare')) - const payWithSlugs = listDirs(path.join(CONTENT_DIR, 'pay-with')) - const depositSlugs = listDirs(path.join(CONTENT_DIR, 'deposit')) - const helpSlugs = listDirs(path.join(CONTENT_DIR, 'help')) - const useCaseSlugs = listDirs(path.join(CONTENT_DIR, 'use-cases')) - const storySlugs = listDirs(path.join(CONTENT_DIR, 'stories')).filter((s) => s !== 'index') - const withdrawSlugs = listDirs(path.join(CONTENT_DIR, 'withdraw')) - const exchangeSlugs = listEntitySlugs('exchanges') - - // Also check for corridor pages: send-to/{country}/from/{origin}/ - const corridors: Array<{ to: string; from: string }> = [] - for (const dest of listDirs(path.join(CONTENT_DIR, 'send-to'))) { - const fromDir = path.join(CONTENT_DIR, 'send-to', dest, 'from') - for (const origin of listDirs(fromDir)) { - corridors.push({ to: dest, from: origin }) - } - } - - // Receive-money sources (unique "from" values in corridors) - const receiveSources = [...new Set(corridors.map((c) => c.from))] - - for (const locale of SUPPORTED_LOCALES) { - // Country hub pages: /{locale}/{country} - for (const slug of countrySlugs) { - paths.add(`/${locale}/${slug}`) - } - - // Send-money-to: /{locale}/send-money-to/{country} - for (const slug of countrySlugs) { - paths.add(`/${locale}/send-money-to/${slug}`) - } - - // Corridors: /{locale}/send-money-from/{from}/to/{to} - for (const c of corridors) { - paths.add(`/${locale}/send-money-from/${c.from}/to/${c.to}`) - } - - // Receive money: /{locale}/receive-money-from/{source} - for (const source of receiveSources) { - paths.add(`/${locale}/receive-money-from/${source}`) - } - - // Compare: /{locale}/compare/peanut-vs-{slug} - for (const slug of competitorSlugs) { - paths.add(`/${locale}/compare/peanut-vs-${slug}`) - } - - // Pay-with: /{locale}/pay-with/{method} - for (const slug of payWithSlugs) { - paths.add(`/${locale}/pay-with/${slug}`) - } - - // Deposit: /{locale}/deposit/from-{exchange} - for (const slug of depositSlugs) { - paths.add(`/${locale}/deposit/from-${slug}`) - } - // Also add exchange entity slugs (may differ from content dirs) - for (const slug of exchangeSlugs) { - paths.add(`/${locale}/deposit/from-${slug}`) - } - - // Help: /{locale}/help and /{locale}/help/{slug} - // Also register without locale prefix since content uses bare /help/... links - paths.add(`/${locale}/help`) - paths.add('/help') - for (const slug of helpSlugs) { - paths.add(`/${locale}/help/${slug}`) - paths.add(`/help/${slug}`) - } - - // Use-cases: /{locale}/use-cases/{slug} - for (const slug of useCaseSlugs) { - paths.add(`/${locale}/use-cases/${slug}`) - } - - // Stories: /{locale}/stories and /{locale}/stories/{slug} - if (storySlugs.length > 0) { - paths.add(`/${locale}/stories`) - for (const slug of storySlugs) { - paths.add(`/${locale}/stories/${slug}`) - } - } - - // Withdraw: /{locale}/withdraw/{slug} - for (const slug of withdrawSlugs) { - paths.add(`/${locale}/withdraw/to-${slug}`) - // Also allow without prefix in case route doesn't use one - paths.add(`/${locale}/withdraw/${slug}`) - } - - // Pricing - paths.add(`/${locale}/pricing`) - } - - return paths -} - -// --- Extract links from markdown --- - -interface BrokenLink { - file: string - line: number - url: string - text: string -} - -const MARKDOWN_LINK_RE = /\[([^\]]*)\]\((\/?[^)]+)\)/g -const JSX_HREF_RE = /href=["'](\/[^"']+)["']/g - -function extractLinks(content: string): Array<{ line: number; url: string; text: string }> { - const links: Array<{ line: number; url: string; text: string }> = [] - const lines = content.split('\n') - - for (let i = 0; i < lines.length; i++) { - const lineContent = lines[i] - - // Skip frontmatter alternates (they're file paths, not URLs) - if (lineContent.trim().startsWith('content/')) continue - - // Markdown links: [text](/path) - let match - MARKDOWN_LINK_RE.lastIndex = 0 - while ((match = MARKDOWN_LINK_RE.exec(lineContent)) !== null) { - const url = match[2] - if (isInternalLink(url)) { - links.push({ line: i + 1, url, text: match[1] }) - } - } - - // JSX href="/path" - JSX_HREF_RE.lastIndex = 0 - while ((match = JSX_HREF_RE.exec(lineContent)) !== null) { - const url = match[1] - if (isInternalLink(url)) { - links.push({ line: i + 1, url, text: '' }) - } - } - } - - return links -} - -function isInternalLink(url: string): boolean { - if (!url.startsWith('/')) return false - if (url.startsWith('//')) return false // protocol-relative - if (url === '/') return true - // Skip anchor links, API routes, images - if (url.startsWith('/api/')) return false - if (url.startsWith('/#')) return false - if (url.match(/\.(png|jpg|jpeg|gif|svg|webp|ico)$/i)) return false - 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() - const value = line.slice(colonIdx + 1).trim() - if (value === 'true') frontmatter[key] = true - else if (value === 'false') frontmatter[key] = false - else frontmatter[key] = value - } - return frontmatter -} - -function isPublished(content: string): boolean { - const fm = parseFrontmatter(content) - // If published is explicitly false, skip the file - return fm.published !== false -} - -// --- Scan content files --- - -function getAllMdFiles(dir: string): string[] { - const results: string[] = [] - if (!fs.existsSync(dir)) return results - - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name) - if (entry.isDirectory()) { - // Skip deprecated content - if (entry.name === 'deprecated') continue - results.push(...getAllMdFiles(full)) - } else if (entry.name.endsWith('.md')) { - results.push(full) - } - } - - return results -} - -function rel(filePath: string): string { - return path.relative(process.cwd(), filePath) -} - -// --- Main --- - -function main() { - console.log('Building valid URL index...') - const validPaths = buildValidPaths() - console.log(` ${validPaths.size} valid paths indexed\n`) - - console.log('Scanning content files...') - const files = getAllMdFiles(CONTENT_DIR) - console.log(` ${files.length} markdown files found\n`) - - const broken: BrokenLink[] = [] - let totalLinks = 0 - - let skippedUnpublished = 0 - - for (const file of files) { - const content = fs.readFileSync(file, 'utf-8') - - // Skip unpublished/draft content — links to not-yet-built routes are expected - if (!isPublished(content)) { - skippedUnpublished++ - continue - } - - const links = extractLinks(content) - totalLinks += links.length - - for (const link of links) { - // Strip query string and hash for validation - const cleanUrl = link.url.split('?')[0].split('#')[0].replace(/\/$/, '') - - if (!validPaths.has(cleanUrl)) { - broken.push({ - file: rel(file), - line: link.line, - url: link.url, - text: link.text, - }) - } - } - } - - // --- Report --- - if (skippedUnpublished > 0) { - console.log(` Skipped ${skippedUnpublished} unpublished files\n`) - } - console.log(`Checked ${totalLinks} internal links across ${files.length - skippedUnpublished} published files\n`) - - if (broken.length === 0) { - console.log('✓ No broken internal links found!') - process.exit(0) - } - - console.log(`✗ ${broken.length} broken internal links found:\n`) - - // Group by file - const byFile = new Map() - for (const b of broken) { - const existing = byFile.get(b.file) || [] - existing.push(b) - byFile.set(b.file, existing) - } - - for (const [file, links] of byFile) { - console.log(` ${file}`) - for (const link of links) { - const textInfo = link.text ? ` "${link.text}"` : '' - console.log(` L${link.line}: ${link.url}${textInfo}`) - } - console.log() - } - - process.exit(1) -} - -main() diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts new file mode 100644 index 000000000..fb004b54f --- /dev/null +++ b/scripts/verify-content.ts @@ -0,0 +1,515 @@ +#!/usr/bin/env tsx +/** + * Comprehensive content verification for peanut-ui. + * Replaces validate-links.ts with broader checks. + * + * Run: pnpm verify-content + * + * Checks: + * 1. Internal link validation (published content → valid routes) + * 2. Published content has matching route (no catch-all fallback) + * 3. Footer manifest URL validation + * 4. Frontmatter consistency (title, description, published field) + * 5. Cross-locale coverage warnings + */ + +import fs from 'fs' +import path from 'path' + +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'] + +// --- Diagnostics --- + +interface Diagnostic { + level: 'error' | 'warning' + check: string + file?: string + line?: number + message: string +} + +const diagnostics: Diagnostic[] = [] + +function error(check: string, message: string, file?: string, line?: number) { + diagnostics.push({ level: 'error', check, file, line, message }) +} + +function warn(check: string, message: string, file?: string) { + diagnostics.push({ level: 'warning', check, file, message }) +} + +// --- Filesystem helpers --- + +function listDirs(dir: string): string[] { + if (!fs.existsSync(dir)) return [] + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) +} + +function listEntitySlugs(category: string): string[] { + const dir = path.join(ROOT, 'input/data', category) + if (!fs.existsSync(dir)) return [] + return fs + .readdirSync(dir) + .filter((f) => f.endsWith('.md')) + .map((f) => f.replace('.md', '')) +} + +function getAllMdFiles(dir: string): string[] { + const results: string[] = [] + if (!fs.existsSync(dir)) return results + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'deprecated') continue + results.push(...getAllMdFiles(full)) + } else if (entry.name.endsWith('.md')) { + results.push(full) + } + } + return results +} + +function rel(filePath: string): string { + return path.relative(process.cwd(), filePath) +} + +// --- 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 { + const routes = new Set() + + // Static pages + for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/lp/card']) { + routes.add(p) + } + + // App routes (mobile-ui) + for (const p of [ + '/profile', + '/profile/backup', + '/profile/edit', + '/profile/exchange-rate', + '/profile/identity-verification', + '/home', + '/send', + '/request', + '/settings', + '/history', + '/points', + '/recover-funds', + ]) { + routes.add(p) + } + + // Discover content-driven routes from actual page.tsx files + content dirs + const countrySlugs = listDirs(path.join(CONTENT_DIR, 'countries')) + const competitorSlugs = listDirs(path.join(CONTENT_DIR, 'compare')) + const payWithSlugs = listDirs(path.join(CONTENT_DIR, 'pay-with')) + const depositSlugs = listDirs(path.join(CONTENT_DIR, 'deposit')) + const helpSlugs = listDirs(path.join(CONTENT_DIR, 'help')) + const useCaseSlugs = listDirs(path.join(CONTENT_DIR, 'use-cases')) + const storySlugs = listDirs(path.join(CONTENT_DIR, 'stories')).filter((s) => s !== 'index') + const withdrawSlugs = listDirs(path.join(CONTENT_DIR, 'withdraw')) + const exchangeSlugs = listEntitySlugs('exchanges') + + // Corridors + const corridors: Array<{ to: string; from: string }> = [] + for (const dest of listDirs(path.join(CONTENT_DIR, 'send-to'))) { + const fromDir = path.join(CONTENT_DIR, 'send-to', dest, 'from') + for (const origin of listDirs(fromDir)) { + corridors.push({ to: dest, from: origin }) + } + } + const receiveSources = [...new Set(corridors.map((c) => c.from))] + + // Check which routes actually have page.tsx files + const hasRoute = (routePath: string) => { + const pagePath = path.join(APP_DIR, routePath, 'page.tsx') + return fs.existsSync(pagePath) + } + + for (const locale of SUPPORTED_LOCALES) { + // Country hub: [country]/page.tsx + if (hasRoute('[country]')) { + for (const slug of countrySlugs) routes.add(`/${locale}/${slug}`) + } + + // Send money to + if (hasRoute('send-money-to/[country]')) { + for (const slug of countrySlugs) routes.add(`/${locale}/send-money-to/${slug}`) + } + + // Corridors + if (hasRoute('send-money-from/[from]/to/[to]')) { + for (const c of corridors) routes.add(`/${locale}/send-money-from/${c.from}/to/${c.to}`) + } + + // Receive money + if (hasRoute('receive-money-from/[country]')) { + for (const source of receiveSources) routes.add(`/${locale}/receive-money-from/${source}`) + } + + // Compare + if (hasRoute('compare/[slug]')) { + for (const slug of competitorSlugs) routes.add(`/${locale}/compare/peanut-vs-${slug}`) + } + + // Pay with + if (hasRoute('pay-with/[method]')) { + for (const slug of payWithSlugs) routes.add(`/${locale}/pay-with/${slug}`) + } + + // Deposit + if (hasRoute('deposit/[exchange]')) { + for (const slug of depositSlugs) routes.add(`/${locale}/deposit/from-${slug}`) + for (const slug of exchangeSlugs) routes.add(`/${locale}/deposit/from-${slug}`) + } + + // Help + if (hasRoute('help/[slug]')) { + routes.add(`/${locale}/help`) + routes.add('/help') + for (const slug of helpSlugs) { + routes.add(`/${locale}/help/${slug}`) + routes.add(`/help/${slug}`) + } + } + + // Use cases + if (hasRoute('use-cases/[slug]')) { + for (const slug of useCaseSlugs) routes.add(`/${locale}/use-cases/${slug}`) + } + + // Stories + if (hasRoute('stories/[slug]')) { + routes.add(`/${locale}/stories`) + for (const slug of storySlugs) routes.add(`/${locale}/stories/${slug}`) + } + + // Withdraw + if (hasRoute('withdraw/[slug]')) { + for (const slug of withdrawSlugs) { + routes.add(`/${locale}/withdraw/${slug}`) + } + } + + // Pricing (singleton) + if (hasRoute('pricing')) { + routes.add(`/${locale}/pricing`) + } + + // Supported networks (singleton) + if (hasRoute('supported-networks')) { + routes.add(`/${locale}/supported-networks`) + } + } + + return routes +} + +// --- Link extraction --- + +const MARKDOWN_LINK_RE = /\[([^\]]*)\]\((\/?[^)]+)\)/g +const JSX_HREF_RE = /href=["'](\/[^"']+)["']/g + +function extractLinks(content: string): Array<{ line: number; url: string; text: string }> { + const links: Array<{ line: number; url: string; text: string }> = [] + const lines = content.split('\n') + for (let i = 0; i < lines.length; i++) { + const lineContent = lines[i] + if (lineContent.trim().startsWith('content/')) continue + + let match + MARKDOWN_LINK_RE.lastIndex = 0 + while ((match = MARKDOWN_LINK_RE.exec(lineContent)) !== null) { + if (isInternalLink(match[2])) links.push({ line: i + 1, url: match[2], text: match[1] }) + } + JSX_HREF_RE.lastIndex = 0 + while ((match = JSX_HREF_RE.exec(lineContent)) !== null) { + if (isInternalLink(match[1])) links.push({ line: i + 1, url: match[1], text: '' }) + } + } + return links +} + +function isInternalLink(url: string): boolean { + if (!url.startsWith('/')) return false + if (url.startsWith('//')) return false + if (url === '/') return true + if (url.startsWith('/api/')) return false + if (url.startsWith('/#')) return false + if (url.match(/\.(png|jpg|jpeg|gif|svg|webp|ico)$/i)) return false + return true +} + +function cleanUrl(url: string): string { + return url.split('?')[0].split('#')[0].replace(/\/$/, '') +} + +// --- Pass 1: Internal link validation --- + +function checkLinks(validPaths: Set) { + const files = getAllMdFiles(CONTENT_DIR) + let totalLinks = 0 + let skippedUnpublished = 0 + + for (const file of files) { + const content = fs.readFileSync(file, 'utf-8') + if (!isPublished(content)) { + skippedUnpublished++ + continue + } + + const links = extractLinks(content) + totalLinks += links.length + + for (const link of links) { + const clean = cleanUrl(link.url) + if (!validPaths.has(clean)) { + error( + 'broken-link', + `Broken link: ${link.url}${link.text ? ` "${link.text}"` : ''}`, + rel(file), + link.line + ) + } + } + } + + console.log( + ` Pass 1 — Links: checked ${totalLinks} links across ${files.length - skippedUnpublished} published files (${skippedUnpublished} unpublished skipped)` + ) +} + +// --- Pass 2: Published content has matching route --- + +function checkPublishedHasRoute(validPaths: Set) { + const contentTypes: Array<{ dir: string; urlPattern: (locale: string, slug: string) => string }> = [ + { dir: 'countries', urlPattern: (l, s) => `/${l}/${s}` }, + { dir: 'help', urlPattern: (l, s) => `/${l}/help/${s}` }, + { dir: 'compare', urlPattern: (l, s) => `/${l}/compare/peanut-vs-${s}` }, + { dir: 'pay-with', urlPattern: (l, s) => `/${l}/pay-with/${s}` }, + { dir: 'deposit', urlPattern: (l, s) => `/${l}/deposit/from-${s}` }, + { dir: 'use-cases', urlPattern: (l, s) => `/${l}/use-cases/${s}` }, + { dir: 'stories', urlPattern: (l, s) => `/${l}/stories/${s}` }, + { dir: 'withdraw', urlPattern: (l, s) => `/${l}/withdraw/${s}` }, + ] + + let issues = 0 + for (const ct of contentTypes) { + const slugs = listDirs(path.join(CONTENT_DIR, ct.dir)).filter((s) => s !== 'index') + for (const slug of slugs) { + 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 + + const url = ct.urlPattern('en', slug) + if (!validPaths.has(url)) { + error('no-route', `Published content has no route: ${url}`, rel(enFile)) + issues++ + } + } + } + + // Singleton content types + for (const intent of ['pricing', 'supported-networks']) { + 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 + + const url = `/en/${intent}` + if (!validPaths.has(url)) { + error('no-route', `Published singleton has no route: ${url}`, rel(enFile)) + issues++ + } + } + + console.log(` Pass 2 — Route coverage: ${issues === 0 ? 'all published content has routes' : `${issues} issues`}`) +} + +// --- Pass 3: Footer manifest validation --- + +function checkFooterManifest(validPaths: Set) { + const manifestPath = path.join(ROOT, 'generated/footer-manifest.json') + if (!fs.existsSync(manifestPath)) { + warn('footer', 'No footer-manifest.json found') + return + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) + let issues = 0 + + function checkEntries(entries: Array<{ href: string; slug: string; external?: boolean }>, section: string) { + for (const entry of entries) { + if (entry.external) continue + const clean = cleanUrl(entry.href) + if (!validPaths.has(clean)) { + error( + 'footer', + `Footer manifest "${section}" links to non-existent route: ${entry.href}`, + rel(manifestPath) + ) + issues++ + } + } + } + + if (manifest.sendMoney?.to) checkEntries(manifest.sendMoney.to, 'sendMoney.to') + if (manifest.sendMoney?.from) checkEntries(manifest.sendMoney.from, 'sendMoney.from') + if (manifest.compare) checkEntries(manifest.compare, 'compare') + if (manifest.articles) checkEntries(manifest.articles, 'articles') + if (manifest.resources) checkEntries(manifest.resources, 'resources') + + console.log(` Pass 3 — Footer manifest: ${issues === 0 ? 'all URLs valid' : `${issues} broken URLs`}`) +} + +// --- Pass 4: Frontmatter consistency --- + +function checkFrontmatter() { + const files = getAllMdFiles(CONTENT_DIR) + let issues = 0 + + for (const file of files) { + const content = fs.readFileSync(file, 'utf-8') + const fm = parseFrontmatter(content) + + if (!isPublished(content)) continue + + if (!fm.title || (typeof fm.title === 'string' && fm.title.trim() === '')) { + error('frontmatter', 'Published file missing title', rel(file)) + issues++ + } + if (!fm.description || (typeof fm.description === 'string' && fm.description.trim() === '')) { + error('frontmatter', 'Published file missing description', rel(file)) + issues++ + } + if (fm.published === undefined) { + warn('frontmatter', 'No explicit published field (defaults to true)', rel(file)) + } + } + + console.log(` Pass 4 — Frontmatter: ${issues === 0 ? 'all published files valid' : `${issues} issues`}`) +} + +// --- Pass 5: Cross-locale coverage --- + +function checkLocaleCoverage() { + const contentTypes = ['countries', 'help', 'compare', 'pay-with', 'use-cases', 'deposit'] + let warnings = 0 + + for (const ct of contentTypes) { + const slugs = listDirs(path.join(CONTENT_DIR, ct)) + for (const slug of slugs) { + const slugDir = path.join(CONTENT_DIR, ct, slug) + const enFile = path.join(slugDir, 'en.md') + if (!fs.existsSync(enFile)) continue + const content = fs.readFileSync(enFile, 'utf-8') + if (!isPublished(content)) continue + + for (const locale of PRIMARY_LOCALES) { + if (locale === 'en') continue + if (!fs.existsSync(path.join(slugDir, `${locale}.md`))) { + warn('locale', `${ct}/${slug}: missing ${locale} translation`, rel(enFile)) + warnings++ + } + } + } + } + + console.log(` Pass 5 — Locale coverage: ${warnings} missing translations (warnings only)`) +} + +// --- Main --- + +function main() { + console.log('Peanut Content Verification\n') + + console.log('Building route index from actual page.tsx files...') + const validPaths = discoverRoutes() + console.log(` ${validPaths.size} valid paths indexed\n`) + + console.log('Running checks...') + checkLinks(validPaths) + checkPublishedHasRoute(validPaths) + checkFooterManifest(validPaths) + checkFrontmatter() + checkLocaleCoverage() + + // Report + const errors = diagnostics.filter((d) => d.level === 'error') + const warnings = diagnostics.filter((d) => d.level === 'warning') + + console.log('\n' + '='.repeat(60)) + + if (errors.length > 0) { + console.log(`\n✗ ${errors.length} error(s):\n`) + const byCheck = new Map() + for (const e of errors) { + const existing = byCheck.get(e.check) || [] + existing.push(e) + byCheck.set(e.check, existing) + } + for (const [check, items] of byCheck) { + console.log(` [${check}]`) + for (const item of items) { + const loc = item.file ? ` ${item.file}${item.line ? `:${item.line}` : ''}` : '' + console.log(` ${loc} ${item.message}`) + } + console.log() + } + } + + if (warnings.length > 0) { + console.log(`\n⚠ ${warnings.length} warning(s) (non-blocking)`) + } + + if (errors.length === 0) { + console.log('\n✓ All checks passed!') + process.exit(0) + } else { + console.log(`\nResult: ${errors.length} errors, ${warnings.length} warnings`) + process.exit(1) + } +} + +main() diff --git a/src/app/[locale]/(marketing)/[country]/page.tsx b/src/app/[locale]/(marketing)/[country]/page.tsx index 5121343d9..4d4e3f94d 100644 --- a/src/app/[locale]/(marketing)/[country]/page.tsx +++ b/src/app/[locale]/(marketing)/[country]/page.tsx @@ -57,6 +57,7 @@ export default async function CountryHubPage({ params }: PageProps) { return ( +} + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readSingletonContentLocalized('pricing', locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/pricing`, + dynamicOg: true, + }), + alternates: { + canonical: `/${locale}/pricing`, + languages: getBareAlternates('pricing'), + }, + } +} + +export default async function PricingPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readSingletonContentLocalized('pricing', locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/pricing` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx index d33f9df27..a1dbb2a0d 100644 --- a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx +++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx @@ -62,6 +62,7 @@ export default async function ReceiveMoneyPage({ params }: PageProps) { return ( +} + +const STORY_SLUGS = listPublishedSlugs('stories') + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.flatMap((locale) => STORY_SLUGS.map((slug) => ({ locale, slug }))) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale, slug } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('stories', slug, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/stories/${slug}`, + dynamicOg: true, + }), + alternates: { + canonical: `/${locale}/stories/${slug}`, + languages: getAlternates('stories', slug), + }, + } +} + +export default async function StoryPage({ params }: PageProps) { + const { locale, slug } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('stories', slug, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/stories/${slug}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/stories/page.tsx b/src/app/[locale]/(marketing)/stories/page.tsx new file mode 100644 index 000000000..f16b520e3 --- /dev/null +++ b/src/app/[locale]/(marketing)/stories/page.tsx @@ -0,0 +1,85 @@ +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { notFound } from 'next/navigation' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { Hero } from '@/components/Marketing/mdx/Hero' +import { readPageContentLocalized, listPublishedSlugs, type ContentFrontmatter } from '@/lib/content' +import Link from 'next/link' + +interface PageProps { + params: Promise<{ locale: string }> +} + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + return { + ...metadataHelper({ + title: 'User Stories | Peanut', + description: 'Real stories from Peanut users around the world.', + canonical: `/${locale}/stories`, + }), + alternates: { + canonical: `/${locale}/stories`, + languages: getAlternates('stories'), + }, + } +} + +export default async function StoriesIndexPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const i18n = getTranslations(locale) + const slugs = listPublishedSlugs('stories') + + const stories = slugs + .map((slug) => { + const content = readPageContentLocalized('stories', slug, locale) + if (!content || content.frontmatter.published === false) return null + return { + slug, + title: content.frontmatter.title, + description: content.frontmatter.description, + } + }) + .filter(Boolean) as Array<{ slug: string; title: string; description: string }> + + return ( + + +
+ {stories.length === 0 ? ( +

No stories published yet.

+ ) : ( +
+ {stories.map((story) => ( + + {story.title} + {story.description} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/app/[locale]/(marketing)/supported-networks/page.tsx b/src/app/[locale]/(marketing)/supported-networks/page.tsx new file mode 100644 index 000000000..b1787e379 --- /dev/null +++ b/src/app/[locale]/(marketing)/supported-networks/page.tsx @@ -0,0 +1,72 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getBareAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readSingletonContentLocalized, type ContentFrontmatter } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readSingletonContentLocalized('supported-networks', locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/supported-networks`, + dynamicOg: true, + }), + alternates: { + canonical: `/${locale}/supported-networks`, + languages: getBareAlternates('supported-networks'), + }, + } +} + +export default async function SupportedNetworksPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readSingletonContentLocalized('supported-networks', locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/supported-networks` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/use-cases/[slug]/page.tsx b/src/app/[locale]/(marketing)/use-cases/[slug]/page.tsx new file mode 100644 index 000000000..284ad0da2 --- /dev/null +++ b/src/app/[locale]/(marketing)/use-cases/[slug]/page.tsx @@ -0,0 +1,74 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized, listPublishedSlugs, type ContentFrontmatter } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string; slug: string }> +} + +const USE_CASE_SLUGS = listPublishedSlugs('use-cases') + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.flatMap((locale) => USE_CASE_SLUGS.map((slug) => ({ locale, slug }))) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale, slug } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('use-cases', slug, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/use-cases/${slug}`, + dynamicOg: true, + }), + alternates: { + canonical: `/${locale}/use-cases/${slug}`, + languages: getAlternates('use-cases', slug), + }, + } +} + +export default async function UseCasePage({ params }: PageProps) { + const { locale, slug } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('use-cases', slug, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/use-cases/${slug}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/withdraw/[slug]/page.tsx b/src/app/[locale]/(marketing)/withdraw/[slug]/page.tsx new file mode 100644 index 000000000..88d36e069 --- /dev/null +++ b/src/app/[locale]/(marketing)/withdraw/[slug]/page.tsx @@ -0,0 +1,74 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized, listPublishedSlugs, type ContentFrontmatter } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string; slug: string }> +} + +const WITHDRAW_SLUGS = listPublishedSlugs('withdraw') + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.flatMap((locale) => WITHDRAW_SLUGS.map((slug) => ({ locale, slug }))) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale, slug } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('withdraw', slug, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/withdraw/${slug}`, + dynamicOg: true, + }), + alternates: { + canonical: `/${locale}/withdraw/${slug}`, + languages: getAlternates('withdraw', slug), + }, + } +} + +export default async function WithdrawPage({ params }: PageProps) { + const { locale, slug } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('withdraw', slug, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + const url = `/${locale}/withdraw/${slug}` + + return ( + + {content} + + ) +} diff --git a/src/components/Marketing/ContentPage.tsx b/src/components/Marketing/ContentPage.tsx index c1027f443..31b626b16 100644 --- a/src/components/Marketing/ContentPage.tsx +++ b/src/components/Marketing/ContentPage.tsx @@ -4,6 +4,7 @@ import { JsonLd } from './JsonLd' import { articleSchema, type ArticleMeta } from '@/lib/seo/schemas' import { BASE_URL } from '@/constants/general.consts' import { MarketingErrorBoundary } from './MarketingErrorBoundary' +import { ContentSEOFooter } from './ContentSEOFooter' interface ContentPageProps { /** Compiled MDX content element */ @@ -12,6 +13,8 @@ interface ContentPageProps { breadcrumbs: Array<{ name: string; href: string }> /** Article schema data for freshness signals */ article?: ArticleMeta + /** Locale for SEO footer link localization. When provided, renders the content SEO footer. */ + locale?: string } /** @@ -19,7 +22,7 @@ interface ContentPageProps { * Handles BreadcrumbList JSON-LD + visible breadcrumb nav. * The MDX body owns all layout (Hero is full-bleed, prose sections are contained). */ -export function ContentPage({ children, breadcrumbs, article }: ContentPageProps) { +export function ContentPage({ children, breadcrumbs, article, locale }: ContentPageProps) { const breadcrumbSchema = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', @@ -59,6 +62,7 @@ export function ContentPage({ children, breadcrumbs, article }: ContentPageProps + {locale && } ) } diff --git a/src/components/Marketing/ContentSEOFooter.tsx b/src/components/Marketing/ContentSEOFooter.tsx new file mode 100644 index 000000000..0228cdb23 --- /dev/null +++ b/src/components/Marketing/ContentSEOFooter.tsx @@ -0,0 +1,120 @@ +import fs from 'fs' +import path from 'path' +import Link from 'next/link' + +interface ManifestEntry { + slug: string + name: string + href: string + external?: boolean +} + +interface FooterManifest { + sendMoney: { to: ManifestEntry[]; from: ManifestEntry[] } + compare: ManifestEntry[] + articles: ManifestEntry[] + resources: ManifestEntry[] +} + +function loadManifest(): FooterManifest | null { + try { + const manifestPath = path.join(process.cwd(), 'src/content/generated/footer-manifest.json') + const raw = fs.readFileSync(manifestPath, 'utf8') + return JSON.parse(raw) as FooterManifest + } catch { + return null + } +} + +function localizeHref(href: string, locale: string): string { + if (href.startsWith('/en/')) return `/${locale}/${href.slice(4)}` + return href +} + +function FooterSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
    {children}
+
+ ) +} + +function FooterLink({ href, external, children }: { href: string; external?: boolean; children: React.ReactNode }) { + if (external) { + return ( +
  • + + {children} + +
  • + ) + } + return ( +
  • + + {children} + +
  • + ) +} + +export function ContentSEOFooter({ locale }: { locale: string }) { + const manifest = loadManifest() + if (!manifest) return null + + return ( + + ) +} diff --git a/src/i18n/config.ts b/src/i18n/config.ts index f27fdbca7..f775b2c2e 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -12,6 +12,11 @@ export const ROUTE_SLUGS = [ 'pay-with', 'team', 'help', + 'use-cases', + 'withdraw', + 'stories', + 'pricing', + 'supported-networks', ] as const export type RouteSlug = (typeof ROUTE_SLUGS)[number] diff --git a/src/lib/content.ts b/src/lib/content.ts index 262d9eb2f..5ddf5cde9 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -216,3 +216,37 @@ export function listPublishedSlugs(intent: string): string[] { return isPublished(content) }) } + +// --- Singleton content readers (content/{intent}/{lang}.md — no slug subdir) --- + +/** Read singleton content directly: content/{intent}/{lang}.md */ +export function readSingletonContent>( + intent: string, + lang: string +): MarkdownContent | null { + const key = `singleton:${intent}/${lang}` + if (!isDev && pageCache.has(key)) return pageCache.get(key) as MarkdownContent | null + + const filePath = path.join(CONTENT_ROOT, 'content', intent, `${lang}.md`) + const result = parseMarkdownFile(filePath) + pageCache.set(key, result) + return result +} + +/** Read singleton content with locale fallback */ +export function readSingletonContentLocalized>( + intent: string, + lang: string +): MarkdownContent | null { + for (const locale of getLocaleFallbacks(lang)) { + const content = readSingletonContent(intent, locale) + if (content) return content + } + return null +} + +/** Check if a singleton content page is published */ +export function isSingletonPublished(intent: string): boolean { + const content = readSingletonContent(intent, 'en') + return isPublished(content) +} From d1b51aedc95fbb0db7fb5d20a8f93d83ecc1996c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 23 Mar 2026 19:29:11 +0000 Subject: [PATCH 2/7] fix: move SEO footer below main footer in marketing layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentSEOFooter was rendering inside ContentPage (above the main footer). Moved it to the marketing layout, after Footer — matching the landing page pattern where SEOFooter renders below the main footer. --- src/app/[locale]/(marketing)/layout.tsx | 2 ++ src/components/Marketing/ContentPage.tsx | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/[locale]/(marketing)/layout.tsx b/src/app/[locale]/(marketing)/layout.tsx index 3b88280e6..4b80dad07 100644 --- a/src/app/[locale]/(marketing)/layout.tsx +++ b/src/app/[locale]/(marketing)/layout.tsx @@ -4,6 +4,7 @@ import { SUPPORTED_LOCALES } from '@/i18n/types' import { isValidLocale } from '@/i18n/config' import { CRISP_WEBSITE_ID } from '@/constants/crisp' import Footer from '@/components/LandingPage/Footer' +import { ContentSEOFooter } from '@/components/Marketing/ContentSEOFooter' interface LayoutProps { children: React.ReactNode @@ -26,6 +27,7 @@ export default async function LocalizedMarketingLayout({ children, params }: Lay
    {children}