From c39fd9daf71a14ea790d18e7912d49d577bbf88e Mon Sep 17 00:00:00 2001 From: Escelit Date: Fri, 16 Oct 2026 03:05:49 +0100 Subject: [PATCH] feat(seo): index blog tag and author archive routes (#197) - blogSlugFrom now matches only single-segment /blog/ paths, so /blog/tag/* and /blog/author/* no longer fall into the post branch - isIndexableRoute accepts known tag slugs (derived from blog-manifest tags) and opted-in authors; rejects everything else - resolveRouteMetadata resolves tag/author archives with ogType website and i18n-backed copy; unknown slugs still return null - ogImageSlugFor emits blog-tag- and blog-author- slugs - listOgImageJobs generates one image job per known tag and opted-in author per locale - blog-manifest.json gains a tags field; gen-blog-manifest.mjs emits it for new posts automatically - ogContent in en/es/pt i18n files extended with entries for all 7 known tags and the lena-vogt author archive - Tests: two new describe blocks (tag archives, author archives) with 23 new tests; stale bypass assertions updated in middleware tests --- scripts/gen-blog-manifest.mjs | 2 + src/__tests__/middleware.test.ts | 65 ++++++++- src/__tests__/og-metadata.test.ts | 218 +++++++++++++++++++++++++++++- src/data/blog-manifest.json | 3 + src/i18n/en.json | 64 +++++++++ src/i18n/es.json | 64 +++++++++ src/i18n/pt.json | 64 +++++++++ src/utils/og-metadata.ts | 149 +++++++++++++++++++- 8 files changed, 617 insertions(+), 12 deletions(-) diff --git a/scripts/gen-blog-manifest.mjs b/scripts/gen-blog-manifest.mjs index 58725a5..5e58e17 100644 --- a/scripts/gen-blog-manifest.mjs +++ b/scripts/gen-blog-manifest.mjs @@ -107,6 +107,7 @@ function processMdxFile(filePath, filename) { excerpt: data.excerpt || extractFirstParagraph(content), date: data.date || data.publishedAt || '2026-01-01', author: data.author || 'Wraith Team', + tags: Array.isArray(data.tags) ? data.tags : [], url: `https://usewraith.xyz/blog/${slug}`, }; } @@ -121,6 +122,7 @@ function loadBlogPostsJson() { excerpt: post.excerpt, date: post.publishedAt || '2026-01-01', author: post.author || 'Wraith Team', + tags: Array.isArray(post.tags) ? post.tags : [], url: `https://usewraith.xyz/blog/${post.slug}`, })); } catch { diff --git a/src/__tests__/middleware.test.ts b/src/__tests__/middleware.test.ts index 33e8e87..aa8dc95 100644 --- a/src/__tests__/middleware.test.ts +++ b/src/__tests__/middleware.test.ts @@ -124,8 +124,8 @@ describe('middleware: non-indexable routes', () => { expect(response).toBeUndefined(); }); - it('bypasses bot requests for blog tag and author routes', async () => { - for (const path of ['/blog/tag/wave-7', '/blog/author/lena-vogt']) { + it('bypasses bot requests for blog tag and author routes with unknown slugs', async () => { + for (const path of ['/blog/tag/nonexistent-tag', '/blog/author/wraith-team']) { const { response } = await makeRequest( path, 'TwitterBot/1.0', @@ -706,3 +706,64 @@ describe('middleware: HTML escaping', () => { }); }); }); + +describe('middleware: blog archive routes', () => { + beforeEach(() => vi.clearAllMocks()); + + it('injects metadata for a known tag archive route', async () => { + const { response } = await makeRequest( + '/blog/tag/privacy', + 'TwitterBot/1.0', + 'X', + ); + expect(response).not.toBeUndefined(); + const html = await response!.text(); + expect(html).toContain('Privacy'); + expect(html).toContain('https://usewraith.xyz/blog/tag/privacy'); + expect(html).toContain('og/blog-tag-privacy.png'); + }); + + it('injects metadata for a known author archive route', async () => { + const { response } = await makeRequest( + '/blog/author/lena-vogt', + 'TwitterBot/1.0', + 'X', + ); + expect(response).not.toBeUndefined(); + const html = await response!.text(); + expect(html).toContain('Lena Vogt'); + expect(html).toContain('https://usewraith.xyz/blog/author/lena-vogt'); + expect(html).toContain('og/blog-author-lena-vogt.png'); + }); + + it('bypasses bot for unknown tag routes', async () => { + const { response } = await makeRequest( + '/blog/tag/nonexistent-tag', + 'TwitterBot/1.0', + '', + ); + expect(response).toBeUndefined(); + }); + + it('bypasses bot for opted-out author routes', async () => { + const { response } = await makeRequest( + '/blog/author/wraith-team', + 'TwitterBot/1.0', + '', + ); + expect(response).toBeUndefined(); + }); + + it('injects localized metadata for localized tag archive route', async () => { + const { response } = await makeRequest( + '/es/blog/tag/privacy', + 'TwitterBot/1.0', + 'X', + ); + expect(response).not.toBeUndefined(); + const html = await response!.text(); + expect(html).toContain('og/es-blog-tag-privacy.png'); + expect(html).toContain('og:locale" content="es_ES'); + expect(html).toContain('https://usewraith.xyz/es/blog/tag/privacy'); + }); +}); diff --git a/src/__tests__/og-metadata.test.ts b/src/__tests__/og-metadata.test.ts index 89736d7..9f5d060 100644 --- a/src/__tests__/og-metadata.test.ts +++ b/src/__tests__/og-metadata.test.ts @@ -167,9 +167,20 @@ describe('og-metadata: dynamic routes', () => { expect(resolveRouteMetadata('/pt/case-studies/nonexistent')).toBeNull(); }); - it('does not treat blog tag or author routes as posts', () => { - expect(resolveRouteMetadata('/blog/tag/wave-7')).toBeNull(); - expect(resolveRouteMetadata('/blog/author/lena-vogt')).toBeNull(); + it('does not treat blog tag or author routes as blog posts (ogType is website, not article)', () => { + const tag = resolveRouteMetadata('/blog/tag/wave-7'); + expect(tag).not.toBeNull(); + expect(tag!.ogType).toBe('website'); + + const author = resolveRouteMetadata('/blog/author/lena-vogt'); + expect(author).not.toBeNull(); + expect(author!.ogType).toBe('website'); + }); + + it('returns null for unknown tag and author routes', () => { + expect(resolveRouteMetadata('/blog/tag/nonexistent-tag')).toBeNull(); + expect(resolveRouteMetadata('/blog/author/opted-out-author')).toBeNull(); + expect(resolveRouteMetadata('/blog/author/wraith-team')).toBeNull(); }); }); @@ -553,9 +564,10 @@ describe('og-metadata: route indexability', () => { expect(isIndexableRoute('/pt/case-studies/nonexistent')).toBe(false); }); - it('rejects blog tag and author routes', () => { - expect(isIndexableRoute('/blog/tag/wave-7')).toBe(false); - expect(isIndexableRoute('/blog/author/lena-vogt')).toBe(false); + it('rejects blog tag and author routes for unknown slugs', () => { + expect(isIndexableRoute('/blog/tag/nonexistent-tag')).toBe(false); + expect(isIndexableRoute('/blog/author/wraith-team')).toBe(false); + expect(isIndexableRoute('/blog/author/opted-out-author')).toBe(false); }); it('identifies non-indexable routes', () => { @@ -743,3 +755,197 @@ describe('og-metadata: localized dynamic content', () => { } }); }); + +describe('og-metadata: blog tag archive routes', () => { + // Known tags derived from blog-manifest.json + const KNOWN_TAGS = [ + 'privacy', + 'stealth-payments', + 'cryptography', + 'announcements', + 'wave-7', + 'wave-6', + 'sdk', + ]; + + it('resolves metadata for every known tag slug', () => { + for (const tag of KNOWN_TAGS) { + const meta = resolveRouteMetadata(`/blog/tag/${tag}`); + expect(meta, `/blog/tag/${tag} must resolve`).not.toBeNull(); + expect(meta!.ogType).toBe('website'); + expect(meta!.title).toContain('Wraith Protocol'); + expect(meta!.description).toBeTruthy(); + expect(meta!.ogUrl).toBe(`https://usewraith.xyz/blog/tag/${tag}`); + } + }); + + it('resolves localized metadata for known tag routes', () => { + const enMeta = resolveRouteMetadata('/blog/tag/privacy'); + const esMeta = resolveRouteMetadata('/es/blog/tag/privacy'); + const ptMeta = resolveRouteMetadata('/pt/blog/tag/privacy'); + + expect(enMeta).not.toBeNull(); + expect(esMeta).not.toBeNull(); + expect(ptMeta).not.toBeNull(); + + expect(enMeta!.locale).toBe('en'); + expect(esMeta!.locale).toBe('es'); + expect(ptMeta!.locale).toBe('pt'); + + expect(esMeta!.ogUrl).toBe('https://usewraith.xyz/es/blog/tag/privacy'); + expect(ptMeta!.ogUrl).toBe('https://usewraith.xyz/pt/blog/tag/privacy'); + + // Localized copy is different from English + expect(esMeta!.title).not.toBe(enMeta!.title); + expect(ptMeta!.title).not.toBe(enMeta!.title); + expect(esMeta!.description).not.toBe(enMeta!.description); + expect(ptMeta!.description).not.toBe(enMeta!.description); + }); + + it('returns null for unknown tag slugs', () => { + expect(resolveRouteMetadata('/blog/tag/nonexistent-tag')).toBeNull(); + expect(resolveRouteMetadata('/es/blog/tag/nonexistent-tag')).toBeNull(); + }); + + it('marks known tag routes as indexable', () => { + for (const tag of KNOWN_TAGS) { + expect(isIndexableRoute(`/blog/tag/${tag}`), `/blog/tag/${tag} must be indexable`).toBe(true); + } + }); + + it('marks unknown tag routes as non-indexable', () => { + expect(isIndexableRoute('/blog/tag/nonexistent-tag')).toBe(false); + }); + + it('marks localized known tag routes as indexable', () => { + expect(isIndexableRoute('/es/blog/tag/privacy')).toBe(true); + expect(isIndexableRoute('/pt/blog/tag/stealth-payments')).toBe(true); + }); + + it('generates OG image slugs for known tag routes', () => { + expect(ogImageSlugFor('/blog/tag/privacy', 'en')).toBe('blog-tag-privacy'); + expect(ogImageSlugFor('/blog/tag/wave-7', 'en')).toBe('blog-tag-wave-7'); + expect(ogImageSlugFor('/es/blog/tag/privacy', 'es')).toBe('es-blog-tag-privacy'); + expect(ogImageSlugFor('/pt/blog/tag/stealth-payments', 'pt')).toBe( + 'pt-blog-tag-stealth-payments', + ); + }); + + it('returns null OG image slug for unknown tag routes', () => { + expect(ogImageSlugFor('/blog/tag/nonexistent-tag', 'en')).toBeNull(); + }); + + it('includes tag archive jobs in listOgImageJobs', () => { + const jobs = listOgImageJobs(); + for (const tag of KNOWN_TAGS) { + for (const locale of SUPPORTED_LOCALES) { + const job = jobs.find((j) => j.routePath === `/blog/tag/${tag}` && j.locale === locale); + expect(job, `/blog/tag/${tag} (${locale}) must have an OG image job`).toBeDefined(); + expect(job!.file).toMatch( + locale === 'en' ? /^blog-tag-/ : new RegExp(`^${locale}-blog-tag-`), + ); + expect(job!.title).toBeTruthy(); + expect(job!.description).toBeTruthy(); + } + } + }); + + it('tag archive jobs have localized copy distinct from English', () => { + const jobs = listOgImageJobs(); + const byLocale = (tag: string, locale: Locale) => + jobs.find((j) => j.routePath === `/blog/tag/${tag}` && j.locale === locale)!; + + expect(byLocale('privacy', 'es').title).not.toBe(byLocale('privacy', 'en').title); + expect(byLocale('privacy', 'pt').title).not.toBe(byLocale('privacy', 'en').title); + expect(byLocale('privacy', 'es').description).not.toBe(byLocale('privacy', 'en').description); + }); +}); + +describe('og-metadata: blog author archive routes', () => { + it('resolves metadata for a known opted-in author', () => { + const meta = resolveRouteMetadata('/blog/author/lena-vogt'); + expect(meta).not.toBeNull(); + expect(meta!.ogType).toBe('website'); + expect(meta!.title).toContain('Wraith Protocol'); + expect(meta!.title).toContain('Lena Vogt'); + expect(meta!.description).toBeTruthy(); + expect(meta!.ogUrl).toBe('https://usewraith.xyz/blog/author/lena-vogt'); + }); + + it('resolves localized metadata for known author routes', () => { + const enMeta = resolveRouteMetadata('/blog/author/lena-vogt'); + const esMeta = resolveRouteMetadata('/es/blog/author/lena-vogt'); + const ptMeta = resolveRouteMetadata('/pt/blog/author/lena-vogt'); + + expect(esMeta).not.toBeNull(); + expect(ptMeta).not.toBeNull(); + + expect(esMeta!.locale).toBe('es'); + expect(ptMeta!.locale).toBe('pt'); + + expect(esMeta!.ogUrl).toBe('https://usewraith.xyz/es/blog/author/lena-vogt'); + expect(ptMeta!.ogUrl).toBe('https://usewraith.xyz/pt/blog/author/lena-vogt'); + + expect(esMeta!.description).not.toBe(enMeta!.description); + expect(ptMeta!.description).not.toBe(enMeta!.description); + }); + + it('returns null for opted-out authors', () => { + // wraith-team has optIn: false + expect(resolveRouteMetadata('/blog/author/wraith-team')).toBeNull(); + }); + + it('returns null for unknown author ids', () => { + expect(resolveRouteMetadata('/blog/author/nonexistent-author')).toBeNull(); + expect(resolveRouteMetadata('/es/blog/author/nonexistent-author')).toBeNull(); + }); + + it('marks known opted-in author routes as indexable', () => { + expect(isIndexableRoute('/blog/author/lena-vogt')).toBe(true); + expect(isIndexableRoute('/es/blog/author/lena-vogt')).toBe(true); + expect(isIndexableRoute('/pt/blog/author/lena-vogt')).toBe(true); + }); + + it('marks opted-out and unknown author routes as non-indexable', () => { + expect(isIndexableRoute('/blog/author/wraith-team')).toBe(false); + expect(isIndexableRoute('/blog/author/nonexistent-author')).toBe(false); + }); + + it('generates OG image slugs for known author routes', () => { + expect(ogImageSlugFor('/blog/author/lena-vogt', 'en')).toBe('blog-author-lena-vogt'); + expect(ogImageSlugFor('/es/blog/author/lena-vogt', 'es')).toBe('es-blog-author-lena-vogt'); + expect(ogImageSlugFor('/pt/blog/author/lena-vogt', 'pt')).toBe('pt-blog-author-lena-vogt'); + }); + + it('returns null OG image slug for non-indexable author routes', () => { + expect(ogImageSlugFor('/blog/author/wraith-team', 'en')).toBeNull(); + expect(ogImageSlugFor('/blog/author/nonexistent-author', 'en')).toBeNull(); + }); + + it('includes author archive jobs in listOgImageJobs', () => { + const jobs = listOgImageJobs(); + for (const locale of SUPPORTED_LOCALES) { + const job = jobs.find((j) => j.routePath === '/blog/author/lena-vogt' && j.locale === locale); + expect(job, `lena-vogt (${locale}) must have an OG image job`).toBeDefined(); + expect(job!.file).toMatch( + locale === 'en' ? /^blog-author-/ : new RegExp(`^${locale}-blog-author-`), + ); + expect(job!.title).toContain('Lena Vogt'); + } + }); + + it('does not include opted-out author jobs in listOgImageJobs', () => { + const jobs = listOgImageJobs(); + const wraith = jobs.filter((j) => j.routePath === '/blog/author/wraith-team'); + expect(wraith).toHaveLength(0); + }); + + it('author archive jobs have localized copy distinct from English', () => { + const jobs = listOgImageJobs(); + const byLocale = (locale: Locale) => + jobs.find((j) => j.routePath === '/blog/author/lena-vogt' && j.locale === locale)!; + + expect(byLocale('es').description).not.toBe(byLocale('en').description); + expect(byLocale('pt').description).not.toBe(byLocale('en').description); + }); +}); diff --git a/src/data/blog-manifest.json b/src/data/blog-manifest.json index 00b6a43..26bae9f 100644 --- a/src/data/blog-manifest.json +++ b/src/data/blog-manifest.json @@ -5,6 +5,7 @@ "excerpt": "A look at the cryptography behind stealth addresses and why every Wraith payment lands on a fresh one-time address that cannot be linked to the recipient.", "date": "2026-08-12", "author": "lena-vogt", + "tags": ["cryptography", "stealth-payments", "privacy"], "url": "https://usewraith.xyz/blog/stealth-addresses-explained" }, { @@ -13,6 +14,7 @@ "excerpt": "Announcing the start of Wave 7 alongside a recap of Wave 6 milestones: EVM stealth transactions, SDK v1.4 release, Stellar ecosystem integrations, and TEE privacy enhancements.", "date": "2026-07-27", "author": "wraith-team", + "tags": ["announcements", "wave-7", "wave-6", "stealth-payments", "sdk"], "url": "https://usewraith.xyz/blog/wave-7-kickoff" }, { @@ -21,6 +23,7 @@ "excerpt": "How Wraith makes private payments practical for everyday apps.", "date": "2026-07-20", "author": "Wraith Protocol Team", + "tags": ["privacy", "stealth-payments"], "url": "https://usewraith.xyz/blog/privacy-by-default" } ] diff --git a/src/i18n/en.json b/src/i18n/en.json index d20d389..255749b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -580,6 +580,70 @@ "title": "Anonymous Payroll Provider", "subtitle": "A payroll processing platform using Wraith to enable private contractor payments, protecting recipient financial privacy across international jurisdictions." } + }, + "/blog/tag/privacy": { + "title": "Privacy", + "description": "Wraith Protocol posts about privacy — stealth addresses, on-chain privacy design, and practical private payments.", + "ogImage": { + "title": "Privacy", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/tag/stealth-payments": { + "title": "Stealth Payments", + "description": "Everything about stealth payments on Wraith Protocol — how they work, why they matter, and how to integrate them.", + "ogImage": { + "title": "Stealth Payments", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/tag/cryptography": { + "title": "Cryptography", + "description": "Deep dives into the cryptographic primitives behind Wraith Protocol — ERC-5564, elliptic curves, and stealth address schemes.", + "ogImage": { + "title": "Cryptography", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/tag/announcements": { + "title": "Announcements", + "description": "Official announcements from the Wraith Protocol team — new releases, wave kick-offs, and ecosystem news.", + "ogImage": { + "title": "Announcements", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/tag/sdk": { + "title": "SDK", + "description": "Updates, guides, and release notes for the Wraith Protocol SDK.", + "ogImage": { + "title": "SDK", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/tag/wave-6": { + "title": "Wave 6", + "description": "Wraith Protocol Wave 6 — milestones, shipped features, and team updates.", + "ogImage": { + "title": "Wave 6", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/tag/wave-7": { + "title": "Wave 7", + "description": "Wraith Protocol Wave 7 — milestones, shipped features, and team updates.", + "ogImage": { + "title": "Wave 7", + "subtitle": "Wraith Protocol Blog" + } + }, + "/blog/author/lena-vogt": { + "title": "Lena Vogt", + "description": "Posts by Lena Vogt — cryptography researcher at Wraith Protocol focused on stealth address schemes and zero-knowledge payments.", + "ogImage": { + "title": "Lena Vogt", + "subtitle": "Wraith Protocol Blog" + } } }, "caseStudies": { diff --git a/src/i18n/es.json b/src/i18n/es.json index 039ba2e..966f4c1 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -580,6 +580,70 @@ "title": "Proveedor de nómina anónimo", "subtitle": "Pagos privados a contratistas" } + }, + "/blog/tag/privacy": { + "title": "Privacidad", + "description": "Artículos de Wraith Protocol sobre privacidad — direcciones ocultas, diseño de privacidad on-chain y pagos privados en la práctica.", + "ogImage": { + "title": "Privacidad", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/tag/stealth-payments": { + "title": "Pagos stealth", + "description": "Todo sobre los pagos stealth en Wraith Protocol — cómo funcionan, por qué importan y cómo integrarlos.", + "ogImage": { + "title": "Pagos stealth", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/tag/cryptography": { + "title": "Criptografía", + "description": "Análisis en profundidad de los primitivos criptográficos detrás de Wraith Protocol — ERC-5564, curvas elípticas y esquemas de direcciones ocultas.", + "ogImage": { + "title": "Criptografía", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/tag/announcements": { + "title": "Anuncios", + "description": "Anuncios oficiales del equipo de Wraith Protocol — nuevas versiones, inicio de olas y novedades del ecosistema.", + "ogImage": { + "title": "Anuncios", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/tag/sdk": { + "title": "SDK", + "description": "Actualizaciones, guías y notas de versión del SDK de Wraith Protocol.", + "ogImage": { + "title": "SDK", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/tag/wave-6": { + "title": "Ola 6", + "description": "Wraith Protocol Ola 6 — hitos, funcionalidades lanzadas y actualizaciones del equipo.", + "ogImage": { + "title": "Ola 6", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/tag/wave-7": { + "title": "Ola 7", + "description": "Wraith Protocol Ola 7 — hitos, funcionalidades lanzadas y actualizaciones del equipo.", + "ogImage": { + "title": "Ola 7", + "subtitle": "Blog de Wraith Protocol" + } + }, + "/blog/author/lena-vogt": { + "title": "Lena Vogt", + "description": "Artículos de Lena Vogt — investigadora de criptografía en Wraith Protocol especializada en esquemas de direcciones ocultas y pagos de conocimiento cero.", + "ogImage": { + "title": "Lena Vogt", + "subtitle": "Blog de Wraith Protocol" + } } }, "caseStudies": { diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 4490e47..4934b98 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -580,6 +580,70 @@ "title": "Provedor de folha anônimo", "subtitle": "Pagamentos privados a prestadores" } + }, + "/blog/tag/privacy": { + "title": "Privacidade", + "description": "Posts do Wraith Protocol sobre privacidade — endereços ocultos, design de privacidade on-chain e pagamentos privados na prática.", + "ogImage": { + "title": "Privacidade", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/tag/stealth-payments": { + "title": "Pagamentos stealth", + "description": "Tudo sobre pagamentos stealth no Wraith Protocol — como funcionam, por que importam e como integrá-los.", + "ogImage": { + "title": "Pagamentos stealth", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/tag/cryptography": { + "title": "Criptografia", + "description": "Análises aprofundadas dos primitivos criptográficos por trás do Wraith Protocol — ERC-5564, curvas elípticas e esquemas de endereços ocultos.", + "ogImage": { + "title": "Criptografia", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/tag/announcements": { + "title": "Anúncios", + "description": "Anúncios oficiais da equipe do Wraith Protocol — novos lançamentos, início de ondas e novidades do ecossistema.", + "ogImage": { + "title": "Anúncios", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/tag/sdk": { + "title": "SDK", + "description": "Atualizações, guias e notas de lançamento do SDK do Wraith Protocol.", + "ogImage": { + "title": "SDK", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/tag/wave-6": { + "title": "Onda 6", + "description": "Wraith Protocol Onda 6 — marcos, funcionalidades entregues e atualizações da equipe.", + "ogImage": { + "title": "Onda 6", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/tag/wave-7": { + "title": "Onda 7", + "description": "Wraith Protocol Onda 7 — marcos, funcionalidades entregues e atualizações da equipe.", + "ogImage": { + "title": "Onda 7", + "subtitle": "Blog do Wraith Protocol" + } + }, + "/blog/author/lena-vogt": { + "title": "Lena Vogt", + "description": "Posts de Lena Vogt — pesquisadora de criptografia no Wraith Protocol focada em esquemas de endereços stealth e pagamentos de conhecimento zero.", + "ogImage": { + "title": "Lena Vogt", + "subtitle": "Blog do Wraith Protocol" + } } }, "caseStudies": { diff --git a/src/utils/og-metadata.ts b/src/utils/og-metadata.ts index 3d4d9ef..21a0742 100644 --- a/src/utils/og-metadata.ts +++ b/src/utils/og-metadata.ts @@ -3,6 +3,8 @@ import esStrings from '../i18n/es.json'; import ptStrings from '../i18n/pt.json'; import blogManifest from '../data/blog-manifest.json'; import caseStudiesData from '../data/case-studies.json'; +import authorsData from '../data/authors.json'; +import authorsOptOutData from '../data/authors-optout.json'; export type Locale = 'en' | 'es' | 'pt'; export const SUPPORTED_LOCALES: Locale[] = ['en', 'es', 'pt']; @@ -109,11 +111,15 @@ const LOCALE_OG_STRINGS: Record< pt: ptStrings.og, }; +const authorsMap = (authorsData ?? {}) as Record; +const optOutList = (authorsOptOutData ?? []) as string[]; + const blogManifestData = (blogManifest as Array<{ slug: string; title: string; excerpt: string; + tags?: string[]; }>) || []; const caseStudyEntries = ((caseStudiesData as { entries?: unknown[] }).entries || []) as Array<{ @@ -175,8 +181,43 @@ function isKnownCaseStudySlug(slug: string): boolean { return caseStudyEntries.some((entry) => entry.slug === slug); } +function isKnownAuthorId(id: string): boolean { + if (optOutList.includes(id)) return false; + const author = authorsMap[id]; + return author?.optIn === true; +} + +export function slugifyTag(tag: string): string { + return String(tag) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function isKnownTagSlug(slug: string): boolean { + // Collect all tag slugs referenced in the blog manifest. + const tagSlugs = blogManifestData.flatMap((post) => (post.tags ?? []).map(slugifyTag)); + return tagSlugs.includes(slug); +} + +/** Matches `/blog/tag/` and returns the tag slug. */ +function blogTagSlugFrom(pathname: string): string | null { + return pathname.match(/^\/blog\/tag\/([^/]+)$/)?.[1] ?? null; +} + +/** Matches `/blog/author/` and returns the author id. */ +function blogAuthorIdFrom(pathname: string): string | null { + return pathname.match(/^\/blog\/author\/([^/]+)$/)?.[1] ?? null; +} + +/** + * Matches `/blog/` where `` is NOT a two-segment sub-path such as + * `tag/foo` or `author/foo` (those are handled by the helpers above). + */ function blogSlugFrom(pathname: string): string | null { - return pathname.match(/^\/blog\/(.+)$/)?.[1] ?? null; + const m = pathname.match(/^\/blog\/([^/]+)$/); + return m?.[1] ?? null; } function caseStudySlugFrom(pathname: string): string | null { @@ -185,6 +226,8 @@ function caseStudySlugFrom(pathname: string): string | null { export function resolveRouteMetadata(pathname: string): RouteMetadata | null { const { pathname: cleanPath, locale } = stripLocalePrefix(pathname); + const tagSlug = blogTagSlugFrom(cleanPath); + const authorId = blogAuthorIdFrom(cleanPath); const blogSlug = blogSlugFrom(cleanPath); const caseStudySlug = caseStudySlugFrom(cleanPath); // Exact match first, then the two-segment base (`/use-cases/calculator` is a @@ -207,6 +250,37 @@ export function resolveRouteMetadata(pathname: string): RouteMetadata | null { ogImage = config.ogImage; ogType = config.ogType; ogUrl = canonicalUrlFor(locale, '/'); + } else if (tagSlug) { + // Unknown tag slugs have no posts — reject them. + if (!isKnownTagSlug(tagSlug)) return null; + const content = getDynamicRouteContent(`/blog/tag/${tagSlug}`, locale); + const defaultTitle = `Posts tagged "${tagSlug}"`; + const defaultDescription = `Browse Wraith Protocol posts tagged with "${tagSlug}".`; + title = `${content?.title ?? defaultTitle} — Wraith Protocol`; + description = content?.description ?? defaultDescription; + ogImage = content?.ogImage ?? { + title: content?.title ?? defaultTitle, + subtitle: 'Wraith Protocol Blog', + }; + ogType = 'website'; + ogUrl = canonicalUrlFor(locale, `/blog/tag/${tagSlug}`); + } else if (authorId) { + // Opted-out or unknown authors have no public page. + if (!isKnownAuthorId(authorId)) return null; + const author = authorsMap[authorId] as { name?: string; bio?: string }; + const content = getDynamicRouteContent(`/blog/author/${authorId}`, locale); + const authorName = author?.name ?? authorId; + const defaultTitle = `Posts by ${authorName}`; + const defaultDescription = + (author?.bio as string | undefined) ?? `Posts by ${authorName} on Wraith Protocol.`; + title = `${content?.title ?? defaultTitle} — Wraith Protocol`; + description = content?.description ?? defaultDescription; + ogImage = content?.ogImage ?? { + title: content?.title ?? defaultTitle, + subtitle: 'Wraith Protocol Blog', + }; + ogType = 'website'; + ogUrl = canonicalUrlFor(locale, `/blog/author/${authorId}`); } else if (blogSlug) { // Unknown slugs 404 in the app, so they must not be served metadata. const post = blogManifestData.find((p) => p.slug === blogSlug); @@ -258,9 +332,19 @@ export function isIndexableRoute(pathname: string): boolean { return true; } - // Dynamic slugs are only indexable when the post or case study exists — - // everything else 404s in the app (this also rejects `/blog/tag/*` and - // `/blog/author/*`, which are not posts). + // Tag archive — indexable only when the tag has at least one known post. + const tagSlug = blogTagSlugFrom(cleanPath); + if (tagSlug !== null) { + return isKnownTagSlug(tagSlug); + } + + // Author archive — indexable only when the author opted in. + const authorId = blogAuthorIdFrom(cleanPath); + if (authorId !== null) { + return isKnownAuthorId(authorId); + } + + // Blog post — indexable only when the post exists in the manifest. const blogSlug = blogSlugFrom(cleanPath); if (blogSlug) { return isKnownBlogSlug(blogSlug); @@ -281,6 +365,16 @@ export function ogImageSlugFor(pathname: string, locale: Locale): string | null return null; } + const tagSlug = blogTagSlugFrom(cleanPath); + if (tagSlug) { + return localePrefixedSlug(locale, `blog-tag-${tagSlug}`); + } + + const authorId = blogAuthorIdFrom(cleanPath); + if (authorId) { + return localePrefixedSlug(locale, `blog-author-${authorId}`); + } + const blogSlug = blogSlugFrom(cleanPath); if (blogSlug) { return localePrefixedSlug(locale, `blog-${blogSlug}`); @@ -377,5 +471,52 @@ export function listOgImageJobs(): OgImageJob[] { } } + // Tag archive pages — one image per known tag per locale. + const knownTagSlugs = Array.from( + new Set(blogManifestData.flatMap((post) => (post.tags ?? []).map(slugifyTag))), + ); + for (const tagSlug of knownTagSlugs) { + for (const locale of SUPPORTED_LOCALES) { + const slug = `blog-tag-${tagSlug}`; + const routePath = `/blog/tag/${tagSlug}`; + const content = getDynamicRouteContent(routePath, locale); + const defaultTitle = `Posts tagged "${tagSlug}"`; + const defaultDescription = `Browse Wraith Protocol posts tagged with "${tagSlug}".`; + jobs.push({ + slug, + routePath, + locale, + file: `${localePrefixedSlug(locale, slug)}.png`, + title: content?.ogImage.title ?? defaultTitle, + subtitle: content?.ogImage.subtitle ?? 'Wraith Protocol Blog', + description: content?.description ?? defaultDescription, + }); + } + } + + // Author archive pages — one image per opted-in author per locale. + for (const [authorId, author] of Object.entries(authorsMap)) { + if (optOutList.includes(authorId)) continue; + if (!(author as { optIn?: boolean }).optIn) continue; + const fullAuthor = author as { name?: string; bio?: string }; + for (const locale of SUPPORTED_LOCALES) { + const slug = `blog-author-${authorId}`; + const routePath = `/blog/author/${authorId}`; + const content = getDynamicRouteContent(routePath, locale); + const authorName = fullAuthor.name ?? authorId; + const defaultTitle = `Posts by ${authorName}`; + const defaultDescription = fullAuthor.bio ?? `Posts by ${authorName} on Wraith Protocol.`; + jobs.push({ + slug, + routePath, + locale, + file: `${localePrefixedSlug(locale, slug)}.png`, + title: content?.ogImage.title ?? defaultTitle, + subtitle: content?.ogImage.subtitle ?? 'Wraith Protocol Blog', + description: content?.description ?? defaultDescription, + }); + } + } + return jobs; }