From 7e8f69653d6e4a95f0ea685748ebc5f6c55a8bac Mon Sep 17 00:00:00 2001 From: cybermax4200 Date: Fri, 25 Sep 2026 16:22:02 +0100 Subject: [PATCH 01/13] feat(seo): add localized metadata and hreflang for es/pt routes (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pageSeo keys to en/es/pt translation files for home, stellar, grants, blog, and case-studies (title + description per locale) - Add src/utils/seo.ts with usePageSeo hook, canonicalUrl helper, and hreflangAlternates generator (en, es, pt-BR, x-default) - Wire usePageSeo into App (Home), Stellar, Grants, Blog, CaseStudies pages — Helmet now emits locale-aware title, description, canonical link, og:title/description, twitter:title/description, and hreflang alternate links on every covered route - Update scripts/og.ts patchMetadata to inject canonical + hreflang link tags into static HTML files at build time - Add src/__tests__/seo.test.tsx covering canonicalUrl, hreflang alternates, and page-level title/description for all five pages across en, es, and pt locales --- scripts/og.ts | 32 +++- src/App.tsx | 15 ++ src/__tests__/seo.test.tsx | 297 +++++++++++++++++++++++++++++++++++++ src/i18n/en.json | 22 +++ src/i18n/es.json | 22 +++ src/i18n/pt.json | 22 +++ src/pages/Blog.tsx | 17 ++- src/pages/CaseStudies.tsx | 18 +++ src/pages/Grants.tsx | 18 +++ src/pages/Stellar.tsx | 25 ++-- src/utils/seo.ts | 97 ++++++++++++ 11 files changed, 565 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/seo.test.tsx create mode 100644 src/utils/seo.ts diff --git a/scripts/og.ts b/scripts/og.ts index 51693bc..23ce777 100644 --- a/scripts/og.ts +++ b/scripts/og.ts @@ -216,7 +216,11 @@ async function renderPng(config: RouteConfig, fonts: FontDef[]): Promise } function patchMetadata(html: string, config: RouteConfig): string { - const imageUrl = `https://usewraith.xyz/og/${config.slug}.png`; + const siteUrl = 'https://usewraith.xyz'; + const imageUrl = `${siteUrl}/og/${config.slug}.png`; + const canonicalUrl = + config.routePath === '/' ? siteUrl : `${siteUrl}${config.routePath}`; + let patched = html .replace(/(]*>\s*/g, + '\n ', + ); + patched = patched.replace( + /\s*]*>\s*/g, + '\n ', + ); + + const hreflangLinks = [ + ``, + ``, + ``, + ``, + ``, + ].join('\n '); + + // Inject before + patched = patched.replace('', ` ${hreflangLinks}\n `); + // Breadcrumb JSON-LD const breadcrumbListElement: any[] = [ { '@type': 'ListItem', position: 1, name: 'Home', - item: 'https://usewraith.xyz', + item: siteUrl, }, ]; @@ -251,7 +277,7 @@ function patchMetadata(html: string, config: RouteConfig): string { '@type': 'ListItem', position: 2, name: config.title, - item: `https://usewraith.xyz${config.routePath}`, + item: canonicalUrl, }); } diff --git a/src/App.tsx b/src/App.tsx index 43a8e16..1087e1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; import Header from './components/Header'; import Hero from './components/Hero'; import Features from './components/Features'; @@ -7,6 +8,7 @@ import Layout from './components/Layout'; import TrustStrip from './components/TrustStrip'; import PartnerStrip from './components/PartnerStrip'; import { ThemeProvider } from './context/ThemeContext'; +import { usePageSeo } from './utils/seo'; // Lazy load below-the-fold homepage components const StealthAnimation = lazy(() => import('./components/StealthAnimation')); @@ -45,8 +47,21 @@ const Ecosystem = lazy(() => import('./pages/Ecosystem')); const ChainsPage = lazy(() => import('./pages/Chains')); function Home() { + const seo = usePageSeo('home'); + return (
+ + {seo.title} + + + {seo.alternates.map((alt) => ( + + ))} + + + + Skip to content diff --git a/src/__tests__/seo.test.tsx b/src/__tests__/seo.test.tsx new file mode 100644 index 0000000..46b50dc --- /dev/null +++ b/src/__tests__/seo.test.tsx @@ -0,0 +1,297 @@ +/** + * Tests for the locale-aware SEO metadata utilities. + * + * Covers: + * - canonicalUrl always returns the English (default) URL + * - hreflangAlternates emits en, es, pt-BR, and x-default entries all pointing + * at the same canonical URL + * - usePageSeo hook returns translated title/description for each locale + * - Stellar, Grants, Blog, and CaseStudies pages render locale-correct + * and <meta name="description"> for Spanish and Portuguese + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import i18n from '../i18n'; +import { canonicalUrl, hreflangAlternates, SITE_ORIGIN } from '../utils/seo'; + +// ─── Pure utility tests ─────────────────────────────────────────────────────── + +describe('canonicalUrl', () => { + it('returns the site origin for home', () => { + expect(canonicalUrl('home')).toBe(SITE_ORIGIN); + }); + + it('appends the correct path for stellar', () => { + expect(canonicalUrl('stellar')).toBe(`${SITE_ORIGIN}/stellar`); + }); + + it('appends the correct path for grants', () => { + expect(canonicalUrl('grants')).toBe(`${SITE_ORIGIN}/grants`); + }); + + it('appends the correct path for blog', () => { + expect(canonicalUrl('blog')).toBe(`${SITE_ORIGIN}/blog`); + }); + + it('appends the correct path for caseStudies', () => { + expect(canonicalUrl('caseStudies')).toBe(`${SITE_ORIGIN}/case-studies`); + }); +}); + +describe('hreflangAlternates', () => { + it('returns en, es, pt-BR, and x-default alternates', () => { + const alts = hreflangAlternates('stellar'); + const hreflangs = alts.map((a) => a.hreflang); + expect(hreflangs).toContain('en'); + expect(hreflangs).toContain('es'); + expect(hreflangs).toContain('pt-BR'); + expect(hreflangs).toContain('x-default'); + }); + + it('all alternates point to the same canonical URL', () => { + const canonical = canonicalUrl('grants'); + const alts = hreflangAlternates('grants'); + for (const alt of alts) { + expect(alt.href).toBe(canonical); + } + }); + + it('does not duplicate hreflang values', () => { + const alts = hreflangAlternates('blog'); + const hreflangs = alts.map((a) => a.hreflang); + expect(new Set(hreflangs).size).toBe(hreflangs.length); + }); +}); + +// ─── Page-level locale metadata tests ──────────────────────────────────────── + +/** + * Reads the document <title> as set by react-helmet-async. + * In jsdom, Helmet writes directly to document.title. + */ +function getDocumentTitle(): string { + return document.title; +} + +describe('locale-aware page metadata', () => { + beforeEach(async () => { + // Reset to English before each test + await i18n.changeLanguage('en'); + }); + + // ── Stellar ──────────────────────────────────────────────────────────────── + + it('Stellar page emits English title by default', async () => { + window.history.replaceState({}, '', '/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar integration/i }); + expect(getDocumentTitle()).toMatch(/Stellar/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Stellar page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + window.history.replaceState({}, '', '/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar integration/i }); + expect(getDocumentTitle()).toMatch(/Stellar/i); + // Spanish translation contains "Stellar" and "Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + // Must NOT be the English default string + expect(getDocumentTitle()).not.toBe('Stealth payments on Stellar – Wraith Protocol'); + }); + + it('Stellar page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + window.history.replaceState({}, '', '/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar integration/i }); + expect(getDocumentTitle()).toMatch(/Stellar/i); + expect(getDocumentTitle()).not.toBe('Stealth payments on Stellar – Wraith Protocol'); + }); + + // ── Grants ───────────────────────────────────────────────────────────────── + + it('Grants page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + expect(getDocumentTitle()).toMatch(/Grants/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Grants page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + // Spanish: "Subvenciones – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Subvenciones/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Grants page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + // Portuguese: "Bolsas – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Bolsas/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + // ── Blog ─────────────────────────────────────────────────────────────────── + + it('Blog page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/blog'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /wraith protocol blog/i }); + expect(getDocumentTitle()).toMatch(/Blog/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Blog page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + window.history.replaceState({}, '', '/blog'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /wraith protocol blog/i }); + expect(getDocumentTitle()).toMatch(/Blog/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + expect(getDocumentTitle()).not.toBe( + 'Updates from Wraith Protocol on privacy-preserving payments and stealth infrastructure.', + ); + }); + + // ── CaseStudies ──────────────────────────────────────────────────────────── + + it('Case Studies page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/case-studies'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { + level: 1, + name: /real-world privacy solutions/i, + }); + expect(getDocumentTitle()).toMatch(/Case Studies/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Case Studies page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + window.history.replaceState({}, '', '/case-studies'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { + level: 1, + name: /real-world privacy solutions/i, + }); + // Spanish: "Casos de estudio – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Casos de estudio/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Case Studies page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + window.history.replaceState({}, '', '/case-studies'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { + level: 1, + name: /real-world privacy solutions/i, + }); + // Portuguese: "Estudos de caso – Wraith Protocol" + expect(getDocumentTitle()).toMatch(/Estudos de caso/i); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + // ── Home ─────────────────────────────────────────────────────────────────── + + it('Home page emits English title by default', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /private payments/i }); + expect(getDocumentTitle()).toMatch(/Wraith Protocol/i); + }); + + it('Home page emits Spanish title when locale is es', async () => { + await i18n.changeLanguage('es'); + window.history.replaceState({}, '', '/'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /private payments/i }); + // Spanish: "Wraith Protocol — Pagos privados para cada cadena" + expect(getDocumentTitle()).toMatch(/Pagos privados/i); + }); + + it('Home page emits Portuguese title when locale is pt', async () => { + await i18n.changeLanguage('pt'); + window.history.replaceState({}, '', '/'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /private payments/i }); + // Portuguese: "Wraith Protocol — Pagamentos privados para cada rede" + expect(getDocumentTitle()).toMatch(/Pagamentos privados/i); + }); + + // ── hreflang in rendered HTML ────────────────────────────────────────────── + + it('renders hreflang link tags for each supported locale on Stellar', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar integration/i }); + + await waitFor(() => { + const links = Array.from(document.head.querySelectorAll('link[rel="alternate"][hreflang]')); + const hreflangs = links.map((l) => l.getAttribute('hreflang')); + expect(hreflangs).toContain('en'); + expect(hreflangs).toContain('es'); + expect(hreflangs).toContain('pt-BR'); + expect(hreflangs).toContain('x-default'); + }); + }); + + it('all hreflang links on Stellar point to the canonical URL', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/stellar'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /stellar integration/i }); + + await waitFor(() => { + const links = Array.from(document.head.querySelectorAll('link[rel="alternate"][hreflang]')); + for (const link of links) { + expect(link.getAttribute('href')).toBe(`${SITE_ORIGIN}/stellar`); + } + }); + }); + + it('renders a canonical link tag on the Grants page', async () => { + await i18n.changeLanguage('en'); + window.history.replaceState({}, '', '/grants'); + const { default: App } = await import('../App'); + render(<App />); + await screen.findByRole('heading', { level: 1, name: /build private payments/i }); + + await waitFor(() => { + const canonical = document.head.querySelector('link[rel="canonical"]'); + expect(canonical).not.toBeNull(); + expect(canonical?.getAttribute('href')).toBe(`${SITE_ORIGIN}/grants`); + }); + }); +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index 6043451..c588f69 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -325,6 +325,28 @@ "ogTitle": "Wraith Protocol", "ogDescription": "Private payments for every chain. Stealth addresses, multichain SDK, and AI-powered privacy agents." }, + "pageSeo": { + "home": { + "title": "Wraith Protocol — Private payments for every chain", + "description": "Wraith Protocol enables private payments on any blockchain using stealth addresses. One SDK, multiple chains." + }, + "stellar": { + "title": "Stealth payments on Stellar – Wraith Protocol", + "description": "Low-cost, sub-second, ed25519 stealth payments on Stellar with Soroban smart contracts. Build private payment rails with the Wraith SDK." + }, + "grants": { + "title": "Grants – Wraith Protocol", + "description": "Apply for a Wraith grant to fund stealth address tooling, SDK integrations, and privacy-preserving payment infrastructure." + }, + "blog": { + "title": "Blog – Wraith Protocol", + "description": "Updates from Wraith Protocol on privacy-preserving payments and stealth infrastructure." + }, + "caseStudies": { + "title": "Case Studies – Wraith Protocol", + "description": "Production stories from teams using Wraith Protocol for private, unlinkable payments across multiple chains." + } + }, "caseStudies": { "eyebrow": "CASE STUDIES", "heading": "Real-world privacy solutions.", diff --git a/src/i18n/es.json b/src/i18n/es.json index db10a69..33803f1 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -325,6 +325,28 @@ "ogTitle": "Wraith Protocol", "ogDescription": "Pagos privados para cada cadena. Direcciones ocultas, SDK multicadena y agentes de privacidad con IA." }, + "pageSeo": { + "home": { + "title": "Wraith Protocol — Pagos privados para cada cadena", + "description": "Wraith Protocol habilita pagos privados en cualquier blockchain usando direcciones ocultas. Un SDK, múltiples cadenas." + }, + "stellar": { + "title": "Pagos ocultos en Stellar – Wraith Protocol", + "description": "Pagos ocultos ed25519 de bajo costo y alta velocidad en Stellar con contratos inteligentes Soroban. Crea rieles de pago privados con el SDK de Wraith." + }, + "grants": { + "title": "Subvenciones – Wraith Protocol", + "description": "Solicita una subvención de Wraith para financiar herramientas de dirección oculta, integraciones de SDK e infraestructura de pagos que preserve la privacidad." + }, + "blog": { + "title": "Blog – Wraith Protocol", + "description": "Actualizaciones de Wraith Protocol sobre pagos que preservan la privacidad e infraestructura de direcciones ocultas." + }, + "caseStudies": { + "title": "Casos de estudio – Wraith Protocol", + "description": "Historias de producción de equipos que usan Wraith Protocol para pagos privados y no vinculables en múltiples cadenas." + } + }, "caseStudies": { "eyebrow": "CASOS DE ESTUDIO", "heading": "Soluciones de privacidad en el mundo real.", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 70dc4dd..bf33d89 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -325,6 +325,28 @@ "ogTitle": "Wraith Protocol", "ogDescription": "Pagamentos privados para cada rede. Endereços ocultos, SDK multi-rede e agentes de privacidade com IA." }, + "pageSeo": { + "home": { + "title": "Wraith Protocol — Pagamentos privados para cada rede", + "description": "Wraith Protocol permite pagamentos privados em qualquer blockchain usando endereços ocultos. Um SDK, múltiplas redes." + }, + "stellar": { + "title": "Pagamentos ocultos no Stellar – Wraith Protocol", + "description": "Pagamentos ocultos ed25519 de baixo custo e alta velocidade no Stellar com contratos inteligentes Soroban. Construa trilhos de pagamento privados com o SDK Wraith." + }, + "grants": { + "title": "Bolsas – Wraith Protocol", + "description": "Solicite uma bolsa Wraith para financiar ferramentas de endereço oculto, integrações de SDK e infraestrutura de pagamentos que preserve a privacidade." + }, + "blog": { + "title": "Blog – Wraith Protocol", + "description": "Atualizações do Wraith Protocol sobre pagamentos que preservam a privacidade e infraestrutura de endereços ocultos." + }, + "caseStudies": { + "title": "Estudos de caso – Wraith Protocol", + "description": "Histórias de produção de equipes usando Wraith Protocol para pagamentos privados e não vinculáveis em múltiplas redes." + } + }, "caseStudies": { "eyebrow": "ESTUDOS DE CASO", "heading": "Soluções de privacidade do mundo real.", diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx index 074590b..041c76f 100644 --- a/src/pages/Blog.tsx +++ b/src/pages/Blog.tsx @@ -17,6 +17,8 @@ import BlogToc from '../components/BlogToc'; import { article, breadcrumbList, SITE_URL } from '../utils/jsonld'; import { track } from '../utils/track'; import i18n from '../i18n'; +import { usePageSeo } from '../utils/seo'; +import { usePageSeo } from '../utils/seo'; function AuthorByline({ post }: { post: BlogPost }) { if (!post.author) return null; @@ -46,6 +48,7 @@ function normalizeLocale(lang: string | undefined): string { function BlogList() { const posts = getAllPosts(); + const seo = usePageSeo('blog'); const listCrumbs = breadcrumbList([ { name: 'Home', url: SITE_URL }, @@ -59,11 +62,15 @@ function BlogList() { dangerouslySetInnerHTML={{ __html: JSON.stringify(listCrumbs) }} /> <Helmet> - <title>Blog – Wraith Protocol - + {seo.title} + + + {seo.alternates.map((alt) => ( + + ))} + + + ('all'); + const seo = usePageSeo('caseStudies'); const caseStudies = entries as CaseStudy[]; const filteredStudies = @@ -256,6 +259,21 @@ function CaseStudiesList() { return ( + + {seo.title} + + + {seo.alternates.map((alt) => ( + + ))} + + + + + + + +
{/* Header */}
diff --git a/src/pages/Grants.tsx b/src/pages/Grants.tsx index 946d8ea..21464da 100644 --- a/src/pages/Grants.tsx +++ b/src/pages/Grants.tsx @@ -1,7 +1,9 @@ import { useState } from 'react'; +import { Helmet } from 'react-helmet-async'; import { trackOutbound } from '../utils/track'; import waveData from '../data/wave.json'; import { howTo, SITE_URL } from '../utils/jsonld'; +import { usePageSeo } from '../utils/seo'; type Wave = (typeof waveData)['currentWave']; type PastWave = (typeof waveData)['pastWaves'][number]; @@ -47,9 +49,25 @@ const labelStyles = 'font-mono text-[10px] font-semibold uppercase tracking-[1.8 export default function Grants() { const [openFaqId, setOpenFaqId] = useState(null); + const seo = usePageSeo('grants'); return (
+ + {seo.title} + + + {seo.alternates.map((alt) => ( + + ))} + + + + + + + +