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;
}