diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index c4fc1fab3..7b60b5edb 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -437,9 +437,7 @@ function checkFrontmatter() { error('frontmatter', 'Published file missing description', rel(file)) issues++ } - if (fm.published === undefined) { - warn('frontmatter', 'No explicit published field (defaults to true)', rel(file)) - } + // published field is now optional — missing defaults to published } console.log(` Pass 4 — Frontmatter: ${issues === 0 ? 'all published files valid' : `${issues} issues`}`) @@ -527,76 +525,35 @@ function checkContentPolish() { ) } -// --- Pass 7: Published field must be explicit --- +// --- Pass 7: Check for published: false files (drafts) --- +// With the flipped default (missing = published), only published: false is meaningful. +// This pass just reports how many drafts exist for visibility. function checkExplicitPublished() { const files = getAllMdFiles(CONTENT_DIR) - let issues = 0 + let drafts = 0 for (const file of files) { const content = fs.readFileSync(file, 'utf-8') const fm = parseFrontmatter(content) - if (fm.published === undefined) { - error( - 'no-published-field', - 'File has no explicit published field — add published: true or published: false', - rel(file) - ) - issues++ - } - } - - // Also check singleton content (files directly in content/{type}/ not in a subdir) - const singletonDirs = ['pricing', 'supported-networks'] - for (const dir of singletonDirs) { - const dirPath = path.join(CONTENT_DIR, dir) - if (!fs.existsSync(dirPath)) continue - for (const f of fs.readdirSync(dirPath)) { - if (!f.endsWith('.md')) continue - const filePath = path.join(dirPath, f) - const stat = fs.statSync(filePath) - if (stat.isDirectory()) continue - // Already checked above in getAllMdFiles, skip duplicate + if (fm.published === false) { + warn('draft-content', 'File is explicitly unpublished (draft)', rel(file)) + drafts++ } } console.log( - ` Pass 7 — Explicit published: ${issues === 0 ? 'all files have published field' : `${issues} files missing published field`}` + ` Pass 7 — Drafts: ${drafts === 0 ? 'no draft files' : `${drafts} files marked as drafts (published: false)`}` ) } // --- Pass 8: isPublished consistency --- -// The page-level check uses `published === false` (permissive: undefined = published) -// The lib isPublished uses `published === true` (strict: undefined = unpublished) -// Flag files where these disagree — they'll render on the page but won't appear in generateStaticParams +// Both page-level and lib now agree: missing/true = published, false = unpublished. +// This pass is kept as a no-op placeholder for numbering stability. function checkPublishedConsistency() { - const files = getAllMdFiles(CONTENT_DIR) - let issues = 0 - - for (const file of files) { - const content = fs.readFileSync(file, 'utf-8') - const fm = parseFrontmatter(content) - - // Permissive: page renders (published !== false) - const pageWouldRender = fm.published !== false - // Strict: generateStaticParams includes it (published === true) - const buildWouldInclude = fm.published === true - - if (pageWouldRender && !buildWouldInclude) { - error( - 'published-mismatch', - `published=${String(fm.published)} — page would render but generateStaticParams excludes it. Set published: true or published: false explicitly.`, - rel(file) - ) - issues++ - } - } - - console.log( - ` Pass 8 — Published consistency: ${issues === 0 ? 'no mismatches' : `${issues} files with ambiguous published state`}` - ) + console.log(' Pass 8 — Published consistency: unified (both default to published)') } // --- Pass 9: Submodule freshness --- @@ -607,8 +564,8 @@ function checkSubmoduleFreshness() { if (!fs.existsSync(contentGitDir)) return try { - const { execSync } = require('child_process') - const behindCount = execSync('git -C ' + ROOT + ' rev-list --count HEAD..origin/main 2>/dev/null', { + const { execFileSync } = require('child_process') + const behindCount = execFileSync('git', ['-C', ROOT, 'rev-list', '--count', 'HEAD..origin/main'], { encoding: 'utf-8', }).trim() const behind = parseInt(behindCount, 10) diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx index 424d35af1..d78574c42 100644 --- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx @@ -14,20 +14,15 @@ interface PageProps { } export async function generateStaticParams() { - if (process.env.NODE_ENV === 'production') return [] - // Generate params for locales that have blog content (fall back to en slugs) return SUPPORTED_LOCALES.flatMap((locale) => { let posts = getAllPosts(locale as Locale) if (posts.length === 0) posts = getAllPosts('en') return posts.map((post) => ({ locale, slug: post.slug })) }) } -// TODO: when blog content is added to src/content/blog/, either remove the -// production guard in generateStaticParams above, or set dynamicParams = true. -// Currently no blog posts exist so this has no effect, but with content present -// the combination of returning [] in prod + dynamicParams = false would 404 all -// blog pages. -export const dynamicParams = false + +// Allow dynamic rendering for slugs not in static params (e.g. newly added content) +export const dynamicParams = true export async function generateMetadata({ params }: PageProps): Promise { const { locale, slug } = await params @@ -54,8 +49,10 @@ export default async function BlogPostPageLocalized({ params }: PageProps) { const { locale, slug } = await params if (!isValidLocale(locale)) notFound() - const post = (await getPostBySlug(slug, locale as Locale)) ?? (await getPostBySlug(slug, 'en')) + const localizedPost = await getPostBySlug(slug, locale as Locale) + const post = localizedPost ?? (await getPostBySlug(slug, 'en')) if (!post) notFound() + const contentLocale: Locale = localizedPost ? (locale as Locale) : 'en' const i18n = getTranslations(locale) @@ -65,7 +62,7 @@ export default async function BlogPostPageLocalized({ params }: PageProps) { headline: post.frontmatter.title, description: post.frontmatter.description, datePublished: post.frontmatter.date, - inLanguage: locale, + inLanguage: contentLocale, author: { '@type': 'Organization', name: post.frontmatter.author ?? 'Peanut' }, publisher: { '@type': 'Organization', name: 'Peanut', url: 'https://peanut.me' }, mainEntityOfPage: `https://peanut.me/${locale}/blog/${slug}`, @@ -137,10 +134,9 @@ export default async function BlogPostPageLocalized({ params }: PageProps) {

{post.frontmatter.description}

-
+
+ {post.content} +
) diff --git a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx index 71a911f53..a834f466a 100644 --- a/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx @@ -15,15 +15,14 @@ interface PageProps { } export async function generateStaticParams() { - if (process.env.NODE_ENV === 'production') return [] return SUPPORTED_LOCALES.flatMap((locale) => { - // Use English categories as fallback const cats = getAllCategories(locale as Locale) const fallbackCats = cats.length > 0 ? cats : getAllCategories('en') return fallbackCats.map((cat) => ({ locale, cat })) }) } -export const dynamicParams = false + +export const dynamicParams = true export async function generateMetadata({ params }: PageProps): Promise { const { locale, cat } = await params @@ -52,11 +51,12 @@ export default async function BlogCategoryPageLocalized({ params }: PageProps) { const i18n = getTranslations(typedLocale) let posts = getPostsByCategory(cat, typedLocale) + const resolvedLocale: Locale = posts.length > 0 ? typedLocale : 'en' if (posts.length === 0) posts = getPostsByCategory(cat, 'en') if (posts.length === 0) notFound() const label = cat.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) - const categories = getAllCategories(typedLocale).length > 0 ? getAllCategories(typedLocale) : getAllCategories('en') + const categories = getAllCategories(resolvedLocale) return ( <> diff --git a/src/app/[locale]/(marketing)/blog/page.tsx b/src/app/[locale]/(marketing)/blog/page.tsx index e30220e20..14c5d482b 100644 --- a/src/app/[locale]/(marketing)/blog/page.tsx +++ b/src/app/[locale]/(marketing)/blog/page.tsx @@ -15,7 +15,6 @@ interface PageProps { } export async function generateStaticParams() { - if (process.env.NODE_ENV === 'production') return [] return SUPPORTED_LOCALES.map((locale) => ({ locale })) } diff --git a/src/components/AddMoney/consts/index.ts b/src/components/AddMoney/consts/index.ts index b368d057e..d569b66f7 100644 --- a/src/components/AddMoney/consts/index.ts +++ b/src/components/AddMoney/consts/index.ts @@ -724,16 +724,7 @@ export const countryData: CountryData[] = [ iso3: 'CRI', region: 'latam', }, - { - id: 'CU', - type: 'country', - title: 'Cuba', - currency: 'CUP', - path: 'cuba', - iso2: 'CU', - iso3: 'CUB', - region: 'north-america', - }, + { id: 'CV', type: 'country', @@ -1294,16 +1285,7 @@ export const countryData: CountryData[] = [ iso3: 'IRQ', region: 'rest-of-the-world', }, - { - id: 'IR', - type: 'country', - title: 'Iran', - currency: 'IRR', - path: 'iran', - iso2: 'IR', - iso3: 'IRN', - region: 'rest-of-the-world', - }, + { id: 'ISL', type: 'country', @@ -1424,16 +1406,7 @@ export const countryData: CountryData[] = [ iso3: 'KNA', region: 'latam', }, - { - id: 'KP', - type: 'country', - title: 'North Korea', - currency: 'KPW', - path: 'north-korea', - iso2: 'KP', - iso3: 'PRK', - region: 'rest-of-the-world', - }, + { id: 'KR', type: 'country', @@ -1657,7 +1630,7 @@ export const countryData: CountryData[] = [ { id: 'MK', type: 'country', - title: 'Macedonia', + title: 'North Macedonia', currency: 'MKD', path: 'macedonia', iso2: 'MK', @@ -1674,16 +1647,7 @@ export const countryData: CountryData[] = [ iso3: 'MLI', region: 'rest-of-the-world', }, - { - id: 'MM', - type: 'country', - title: 'Myanmar', - currency: 'MMK', - path: 'myanmar', - iso2: 'MM', - iso3: 'MMR', - region: 'rest-of-the-world', - }, + { id: 'MN', type: 'country', @@ -2124,16 +2088,7 @@ export const countryData: CountryData[] = [ iso3: 'SRB', region: 'europe', }, - { - id: 'RU', - type: 'country', - title: 'Russia', - currency: 'RUB', - path: 'russia', - iso2: 'RU', - iso3: 'RUS', - region: 'europe', - }, + { id: 'RW', type: 'country', @@ -2334,16 +2289,7 @@ export const countryData: CountryData[] = [ iso3: 'SXM', region: 'north-america', }, - { - id: 'SY', - type: 'country', - title: 'Syria', - currency: 'SYP', - path: 'syria', - iso2: 'SY', - iso3: 'SYR', - region: 'rest-of-the-world', - }, + { id: 'SZ', type: 'country', @@ -2736,7 +2682,6 @@ const LATAM_COUNTRY_CODES = [ 'CL', 'CO', 'CR', - 'CU', 'DO', 'EC', 'SV', diff --git a/src/components/LandingPage/LandingPageClient.tsx b/src/components/LandingPage/LandingPageClient.tsx index 261824eeb..cc3c5a9bd 100644 --- a/src/components/LandingPage/LandingPageClient.tsx +++ b/src/components/LandingPage/LandingPageClient.tsx @@ -1,7 +1,7 @@ 'use client' import { useFooterVisibility } from '@/context/footerVisibility' -import { useEffect, useState, useRef, useCallback, type ReactNode } from 'react' +import { Suspense, useEffect, useState, useRef, useCallback, type ReactNode } from 'react' import { DropLink, FAQs, Hero, Marquee, NoFees, CardPioneers } from '@/components/LandingPage' import TweetCarousel from '@/components/LandingPage/TweetCarousel' import { StickyMobileCTA } from '@/components/LandingPage/StickyMobileCTA' @@ -208,7 +208,12 @@ export function LandingPageClient({
{sendInSecondsSlot}
- + {/* Suspense needed: NoFees renders ExchangeRateWidget which uses useSearchParams(). + Without this boundary, the entire LandingPageClient suspends during SSR, + sending an empty HTML shell to crawlers and killing SEO. */} + + + diff --git a/src/config/wagmi.config.tsx b/src/config/wagmi.config.tsx index e175e0e7c..00ddec5f3 100644 --- a/src/config/wagmi.config.tsx +++ b/src/config/wagmi.config.tsx @@ -64,10 +64,9 @@ const wagmiAdapter = new WagmiAdapter({ ssr: true, }) -// 6. AppKit initialization with SSR compatibility and PWA resilience -// Strategy: -// - Initialize eagerly for SSR (Next.js prerendering requires it) -// - Handle PWA cold launch failures gracefully with retry mechanism +// 6. AppKit initialization — single source of truth for createAppKit() config. +// Called from ContextProvider on first client render (NOT at module level). +// Module-level calls access browser APIs and cause Next.js SSR bailout. let appKitInitialized = false let initPromise: Promise | null = null @@ -116,41 +115,19 @@ export const initializeAppKit = async (): Promise => { return initPromise } -// Initialize AppKit (required for components using useAppKit/useDisconnect hooks) -// Components on critical paths (TokenSelector, PaymentForm, home page) need this -// Note: createAppKit() itself is lightweight - expensive network requests (wallet icons, -// analytics) only happen when user actually opens the modal -try { - createAppKit({ - adapters: [wagmiAdapter], - defaultNetwork: mainnet, - networks, - metadata, - projectId, - features: { - analytics: false, // Disable Coinbase analytics tracking - socials: false, - email: false, - onramp: true, - }, - themeVariables: { - '--w3m-border-radius-master': '0px', - '--w3m-color-mix': 'white', - }, - }) - appKitInitialized = true -} catch (error) { - console.warn('AppKit initialization failed:', error) -} - export function ContextProvider({ children, cookies }: { children: React.ReactNode; cookies: string | null }) { - /** - * converts the provided cookies into an initial state for the application. - * - * @param {Config} wagmiConfig - The configuration object for the wagmi adapter. - * @param {Record} cookies - An object representing the cookies. - * @returns {InitialState} The initial state derived from the cookies. - */ + // Initialize AppKit on first client render (required for useAppKit/useDisconnect hooks). + // Must NOT run at module level — createAppKit() accesses browser APIs which causes Next.js + // to emit BAILOUT_TO_CLIENT_SIDE_RENDERING and send an empty HTML shell to crawlers. + // Note: createAppKit() inside initializeAppKit runs synchronously (no await before it), + // so AppKit is ready before child hooks execute in the same render pass. + if (typeof window !== 'undefined') { + void initializeAppKit().catch(() => { + // initializeAppKit already resets initPromise and logs — suppress the thrown error + // so the app can still render without wallet connection + }) + } + const initialState = cookieToInitialState(wagmiAdapter.wagmiConfig as Config, cookies) return ( diff --git a/src/content b/src/content index 86aeb6456..aa9dc9447 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 86aeb6456482c14b909030f08251a855a5e3add2 +Subproject commit aa9dc94478c9bc5c2c9afc812feed5fe4e5c4ec6 diff --git a/src/lib/blog.ts b/src/lib/blog.ts index bd14b9d17..06325f18f 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -1,13 +1,16 @@ import matter from 'gray-matter' -import { marked } from 'marked' -import { createHighlighter, type Highlighter } from 'shiki' import fs from 'fs' import path from 'path' +import { renderContent } from '@/lib/mdx' import type { Locale } from '@/i18n/types' +import type { ReactNode } from 'react' -function getBlogDir(locale: Locale = 'en') { - return path.join(process.cwd(), `src/content/blog/${locale}`) +/** Blog content lives in the peanut-content submodule at src/content/content/blog/. + * Structure: content/blog/{slug}/{locale}.md (e.g. content/blog/pay-in-argentina/en.md) + */ +function getBlogDir() { + return path.join(process.cwd(), 'src/content/content/blog') } export interface BlogPost { @@ -23,71 +26,49 @@ export interface BlogPost { content: string } -// Singleton highlighter — created once, reused across all posts -let _highlighter: Highlighter | null = null - -async function getHighlighter(): Promise { - if (_highlighter) return _highlighter - _highlighter = await createHighlighter({ - themes: ['github-light'], - langs: ['javascript', 'typescript', 'bash', 'json', 'yaml', 'html', 'css', 'python', 'solidity'], - }) - return _highlighter +function coerceDate(date: unknown): string { + if (date instanceof Date) return date.toISOString().split('T')[0] + return String(date ?? '') } export function getAllPosts(locale: Locale = 'en'): BlogPost[] { - const dir = getBlogDir(locale) - if (!fs.existsSync(dir)) return [] + const blogDir = getBlogDir() + if (!fs.existsSync(blogDir)) return [] + + const slugDirs = fs.readdirSync(blogDir).filter((f) => fs.statSync(path.join(blogDir, f)).isDirectory()) + + return slugDirs + .map((slug) => { + const filePath = path.join(blogDir, slug, `${locale}.md`) + if (!fs.existsSync(filePath)) return null - const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')) - return files - .map((file) => { - const raw = fs.readFileSync(path.join(dir, file), 'utf8') + const raw = fs.readFileSync(filePath, 'utf8') const { data, content } = matter(raw) return { - slug: file.replace('.md', ''), - frontmatter: data as BlogPost['frontmatter'], + slug, + frontmatter: { ...data, date: coerceDate(data.date) } as BlogPost['frontmatter'], content, } }) + .filter((post): post is BlogPost => post !== null) .sort((a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()) } export async function getPostBySlug( slug: string, locale: Locale = 'en' -): Promise<{ frontmatter: BlogPost['frontmatter']; html: string } | null> { - const filePath = path.join(getBlogDir(locale), `${slug}.md`) +): Promise<{ frontmatter: BlogPost['frontmatter']; content: ReactNode } | null> { + const filePath = path.join(getBlogDir(), slug, `${locale}.md`) if (!fs.existsSync(filePath)) return null const raw = fs.readFileSync(filePath, 'utf8') - const { data, content } = matter(raw) + const { data, content: body } = matter(raw) - const highlighter = await getHighlighter() - - // Custom renderer for code blocks with shiki syntax highlighting - const renderer = new marked.Renderer() - renderer.code = ({ text, lang }: { text: string; lang?: string }) => { - const language = lang || 'text' - try { - return highlighter.codeToHtml(text, { - lang: language, - theme: 'github-light', - }) - } catch { - // Fallback for unsupported languages — escape HTML to prevent XSS - const escaped = text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - return `
${escaped}
` - } - } + const { content } = await renderContent(body) - const html = (await marked(content, { renderer })) as string + const frontmatter = { ...data, date: coerceDate(data.date) } as BlogPost['frontmatter'] - return { frontmatter: data as BlogPost['frontmatter'], html } + return { frontmatter, content } } export function getPostsByCategory(category: string, locale: Locale = 'en'): BlogPost[] {