diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af830a0..46c9821 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,6 +21,7 @@ jobs:
- run: pnpm lint:i18n
- run: pnpm test:a11y
- run: pnpm build
+ - run: pnpm validate:jsonld
playwright-a11y:
name: Playwright A11y Gate
diff --git a/package.json b/package.json
index 00ac39a..0c17f4d 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"format:check": "prettier --check .",
"lint:i18n": "node scripts/lint-i18n.mjs",
"generate:og": "node scripts/generate-og.mjs",
+ "validate:jsonld": "tsx scripts/validate-jsonld.mjs",
"prepare": "husky"
},
"dependencies": {
diff --git a/public/feed.xml b/public/feed.xml
index af43e89..f82aea8 100644
--- a/public/feed.xml
+++ b/public/feed.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Tue, 25 Aug 2026 05:06:07 GMT
+ Tue, 25 Aug 2026 22:23:09 GMT
-
@@ -20,17 +20,17 @@
Stealth addresses explained
https://usewraith.xyz/blog/stealth-addresses-explained
https://usewraith.xyz/blog/stealth-addresses-explained
- Wed, 22 Jul 2026 00:00:00 GMT
- A straightforward introduction to stealth addresses and why they matter for private payments.
- Wraith Protocol Team
+ Wed, 22 Jul 2026 12:00:00 GMT
+ A straightforward introduction to stealth addresses and why they matter.
+ Wraith Team
-
Privacy by default
https://usewraith.xyz/blog/privacy-by-default
https://usewraith.xyz/blog/privacy-by-default
- Mon, 20 Jul 2026 00:00:00 GMT
+ Mon, 20 Jul 2026 12:00:00 GMT
How Wraith makes private payments practical for everyday apps.
- Wraith Protocol Team
+ Wraith Team
\ No newline at end of file
diff --git a/public/feed/tag/cryptography.xml b/public/feed/tag/cryptography.xml
new file mode 100644
index 0000000..f5f204d
--- /dev/null
+++ b/public/feed/tag/cryptography.xml
@@ -0,0 +1,20 @@
+
+
+
+ Wraith Protocol Blog — cryptography
+ https://usewraith.xyz/blog/tag/cryptography
+ Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
+ en-us
+ Thu, 27 Aug 2026 12:50:19 GMT
+
+
+ -
+ How Stealth Addresses Keep Payments Private
+ https://usewraith.xyz/blog/stealth-addresses-explained
+ https://usewraith.xyz/blog/stealth-addresses-explained
+ Wed, 12 Aug 2026 00:00:00 GMT
+ 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.
+ Lena Vogt
+
+
+
\ No newline at end of file
diff --git a/public/sitemap.xml b/public/sitemap.xml
index d3d2b0a..a559052 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -78,40 +78,4 @@
weekly
0.8
-
- https://usewraith.xyz/blog/tag/privacy
- 2026-08-25
- weekly
- 0.8
-
-
- https://usewraith.xyz/blog/tag/stealth-payments
- 2026-08-25
- weekly
- 0.8
-
-
- https://usewraith.xyz/blog/tag/sdk
- 2026-08-25
- weekly
- 0.8
-
-
- https://usewraith.xyz/blog/tag/announcements
- 2026-08-25
- weekly
- 0.8
-
-
- https://usewraith.xyz/blog/tag/wave-7
- 2026-08-25
- weekly
- 0.8
-
-
- https://usewraith.xyz/blog/tag/wave-6
- 2026-08-25
- weekly
- 0.8
-
diff --git a/scripts/validate-jsonld.mjs b/scripts/validate-jsonld.mjs
new file mode 100644
index 0000000..acb880b
--- /dev/null
+++ b/scripts/validate-jsonld.mjs
@@ -0,0 +1,288 @@
+import { readFileSync, readdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import {
+ organization,
+ article,
+ faqPage,
+ howTo,
+ breadcrumbList,
+ SITE_URL,
+} from '../src/utils/jsonld.ts';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = resolve(__dirname, '..');
+
+const errors = [];
+
+function fail(message) {
+ errors.push(message);
+ console.error(`❌ ${message}`);
+}
+
+function ok(message) {
+ console.log(`✓ ${message}`);
+}
+
+// --- Local schema.org structural validator ---------------------------------
+
+const VALID_TYPES = new Set([
+ 'Organization',
+ 'Article',
+ 'FAQPage',
+ 'HowTo',
+ 'BreadcrumbList',
+ 'WebPage',
+ 'ImageObject',
+ 'ListItem',
+ 'Question',
+ 'Answer',
+ 'HowToStep',
+]);
+
+function isObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function validateBase(blob, path) {
+ if (!isObject(blob)) return fail(`${path}: not an object`);
+ if (blob['@context'] !== 'https://schema.org') {
+ fail(`${path}: @context must be https://schema.org (got ${JSON.stringify(blob['@context'])})`);
+ }
+ if (typeof blob['@type'] !== 'string') {
+ fail(`${path}: @type is required`);
+ } else if (!VALID_TYPES.has(blob['@type'])) {
+ fail(`${path}: unknown @type "${blob['@type']}"`);
+ }
+}
+
+function validateArticle(blob, path) {
+ if (typeof blob.headline !== 'string' || !blob.headline)
+ fail(`${path}: Article.headline required`);
+ if (!isObject(blob.author)) fail(`${path}: Article.author required`);
+ else if (blob.author['@type'] !== 'Organization')
+ fail(`${path}: Article.author must be Organization`);
+ if (!isObject(blob.publisher)) fail(`${path}: Article.publisher required`);
+ else if (blob.publisher['@type'] !== 'Organization')
+ fail(`${path}: Article.publisher must be Organization`);
+ if (typeof blob.datePublished !== 'string') fail(`${path}: Article.datePublished required`);
+}
+
+function validateFaqPage(blob, path) {
+ if (!Array.isArray(blob.mainEntity) || blob.mainEntity.length === 0) {
+ return fail(`${path}: FAQPage.mainEntity must be a non-empty array`);
+ }
+ blob.mainEntity.forEach((item, i) => {
+ const sub = `${path}.mainEntity[${i}]`;
+ if (item['@type'] !== 'Question') fail(`${sub}: must be Question`);
+ if (typeof item.name !== 'string' || !item.name) fail(`${sub}: Question.name required`);
+ if (!isObject(item.acceptedAnswer)) fail(`${sub}: acceptedAnswer required`);
+ else {
+ if (item.acceptedAnswer['@type'] !== 'Answer') fail(`${sub}: acceptedAnswer must be Answer`);
+ if (typeof item.acceptedAnswer.text !== 'string' || !item.acceptedAnswer.text) {
+ fail(`${sub}: acceptedAnswer.text required`);
+ }
+ }
+ });
+}
+
+function validateHowTo(blob, path) {
+ if (typeof blob.name !== 'string' || !blob.name) fail(`${path}: HowTo.name required`);
+ if (!Array.isArray(blob.step) || blob.step.length === 0) {
+ return fail(`${path}: HowTo.step must be a non-empty array`);
+ }
+ blob.step.forEach((step, i) => {
+ const sub = `${path}.step[${i}]`;
+ if (step['@type'] !== 'HowToStep') fail(`${sub}: must be HowToStep`);
+ if (typeof step.name !== 'string' || !step.name) fail(`${sub}: HowToStep.name required`);
+ if (typeof step.text !== 'string' || !step.text) fail(`${sub}: HowToStep.text required`);
+ if (typeof step.position !== 'number') fail(`${sub}: HowToStep.position required`);
+ });
+}
+
+function validateBreadcrumb(blob, path) {
+ if (!Array.isArray(blob.itemListElement) || blob.itemListElement.length === 0) {
+ return fail(`${path}: BreadcrumbList.itemListElement must be a non-empty array`);
+ }
+ blob.itemListElement.forEach((item, i) => {
+ const sub = `${path}.itemListElement[${i}]`;
+ if (item['@type'] !== 'ListItem') fail(`${sub}: must be ListItem`);
+ if (typeof item.position !== 'number') fail(`${sub}: ListItem.position required`);
+ if (typeof item.name !== 'string' || !item.name) fail(`${sub}: ListItem.name required`);
+ if (typeof item.item !== 'string' || !item.item) fail(`${sub}: ListItem.item required`);
+ });
+}
+
+function validate(blob, label) {
+ validateBase(blob, label);
+ switch (blob['@type']) {
+ case 'Article':
+ validateArticle(blob, label);
+ break;
+ case 'FAQPage':
+ validateFaqPage(blob, label);
+ break;
+ case 'HowTo':
+ validateHowTo(blob, label);
+ break;
+ case 'BreadcrumbList':
+ validateBreadcrumb(blob, label);
+ break;
+ case 'Organization':
+ if (typeof blob.name !== 'string') fail(`${label}: Organization.name required`);
+ if (typeof blob.url !== 'string') fail(`${label}: Organization.url required`);
+ break;
+ default:
+ break;
+ }
+}
+
+// --- Build every emitted blob -----------------------------------------------
+
+const blobs = [];
+
+// 1. Organization (mirrors index.html)
+blobs.push({ label: 'Organization (home)', blob: organization() });
+
+// 2. Case study Articles + breadcrumbs (from data)
+const caseStudies = JSON.parse(
+ readFileSync(resolve(root, 'src/data/case-studies.json'), 'utf8'),
+).entries;
+caseStudies.forEach((study) => {
+ const url = `${SITE_URL}/case-studies/${study.slug}`;
+ blobs.push({
+ label: `Article (case-study:${study.slug})`,
+ blob: article({
+ headline: `${study.org} - ${study.useCase}`,
+ description: study.summary,
+ datePublished: study.integrationDate,
+ authorName: study.org,
+ url,
+ }),
+ });
+ blobs.push({
+ label: `BreadcrumbList (case-study:${study.slug})`,
+ blob: breadcrumbList([
+ { name: 'Home', url: SITE_URL },
+ { name: 'Case Studies', url: `${SITE_URL}/case-studies` },
+ { name: study.org, url },
+ ]),
+ });
+});
+
+// 3. FAQPage (from data)
+const faq = JSON.parse(readFileSync(resolve(root, 'src/data/faq.json'), 'utf8'));
+blobs.push({
+ label: 'FAQPage (/faq)',
+ blob: faqPage(faq.entries.map((e) => ({ question: e.question, answer: e.answer }))),
+});
+
+// 4. Blog Articles + breadcrumbs (parse MDX frontmatter)
+function parseFrontmatter(content) {
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
+ if (!match) return {};
+ const data = {};
+ for (const line of match[1].split('\n')) {
+ const m = line.match(/^(\w+):\s*(.*)$/);
+ if (!m) continue;
+ const [, key, raw] = m;
+ const value = raw.trim();
+ if (value.startsWith('[') && value.endsWith(']')) {
+ data[key] = value
+ .slice(1, -1)
+ .split(',')
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
+ .filter(Boolean);
+ } else {
+ data[key] = value.replace(/^['"]|['"]$/g, '');
+ }
+ }
+ return data;
+}
+
+const blogDir = resolve(root, 'src/content/blog');
+readdirSync(blogDir)
+ .filter((file) => file.endsWith('.mdx'))
+ .forEach((file) => {
+ const fm = parseFrontmatter(readFileSync(resolve(blogDir, file), 'utf8'));
+ const slug = file.replace(/\.mdx$/, '');
+ const url = `${SITE_URL}/blog/${slug}`;
+ blobs.push({
+ label: `Article (blog:${slug})`,
+ blob: article({
+ headline: fm.title || slug,
+ description: fm.excerpt || '',
+ datePublished: fm.date || '',
+ authorName: fm.author || 'Wraith Team',
+ url,
+ }),
+ });
+ blobs.push({
+ label: `BreadcrumbList (blog:${slug})`,
+ blob: breadcrumbList([
+ { name: 'Home', url: SITE_URL },
+ { name: 'Blog', url: `${SITE_URL}/blog` },
+ { name: fm.title || slug, url },
+ ]),
+ });
+ });
+
+// 5. HowTo (from grant wave data)
+const wave = JSON.parse(readFileSync(resolve(root, 'src/data/wave.json'), 'utf8')).currentWave;
+if (wave) {
+ blobs.push({
+ label: 'HowTo (/grants)',
+ blob: howTo({
+ name: `How to apply for ${wave.name}`,
+ description:
+ 'Follow these steps to submit a proposal and get funded through the Wraith grant program.',
+ url: `${SITE_URL}/grants`,
+ steps: [
+ {
+ name: 'Confirm eligibility',
+ text: `Review the eligibility criteria: ${(wave.eligibility ?? []).join(' ')}`,
+ },
+ {
+ name: 'Prepare your proposal',
+ text: wave.howToApply ?? 'Include a clear scope, timeline, and budget breakdown.',
+ },
+ {
+ name: 'Submit on Drips',
+ text: `Open the Drips grant page and submit before the wave closes: ${wave.applyUrl}`,
+ url: wave.applyUrl,
+ },
+ {
+ name: 'Await review',
+ text: `Proposals are reviewed against: ${(wave.reviewCriteria ?? []).join(' ')}`,
+ },
+ ],
+ }),
+ });
+}
+
+// 6. Blog author breadcrumb (sample)
+blobs.push({
+ label: 'BreadcrumbList (/blog/author:sample)',
+ blob: breadcrumbList([
+ { name: 'Home', url: SITE_URL },
+ { name: 'Blog', url: `${SITE_URL}/blog` },
+ { name: 'Wraith Team', url: `${SITE_URL}/blog/author/wraith-team` },
+ ]),
+});
+
+// --- Validate + report -----------------------------------------------------
+
+console.log(`Validating ${blobs.length} JSON-LD blobs...\n`);
+errors.length = 0;
+blobs.forEach(({ label, blob }) => {
+ const before = errors.length;
+ validate(blob, label);
+ if (errors.length === before) ok(label);
+});
+
+if (errors.length > 0) {
+ console.error(`\nJSON-LD validation failed: ${errors.length} error(s).`);
+ process.exit(1);
+}
+
+console.log(`\nAll ${blobs.length} JSON-LD blobs are valid.`);
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 3c4d969..70dc4dd 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -136,10 +136,18 @@
"liveTestnet": "AO VIVO NA TESTNET",
"liveDevnet": "AO VIVO NA DEVNET"
},
- "horizen": { "meta": "EVM · Protegido por TEE · ERC-5564" },
- "stellar": { "meta": "Soroban · Baseado em memo" },
- "solana": { "meta": "Tokens SPL · Programa de memo" },
- "ckb": { "meta": "Modelo de células · Integração CCC" }
+ "horizen": {
+ "meta": "EVM · Protegido por TEE · ERC-5564"
+ },
+ "stellar": {
+ "meta": "Soroban · Baseado em memo"
+ },
+ "solana": {
+ "meta": "Tokens SPL · Programa de memo"
+ },
+ "ckb": {
+ "meta": "Modelo de células · Integração CCC"
+ }
},
"compare": {
"eyebrow": "Análise Comparativa",
diff --git a/src/pages/Blog.tsx b/src/pages/Blog.tsx
index d31ed1d..36eb060 100644
--- a/src/pages/Blog.tsx
+++ b/src/pages/Blog.tsx
@@ -13,6 +13,7 @@ import {
type AuthorLinks,
} from '../utils/blog';
import BlogToc from '../components/BlogToc';
+import { article, breadcrumbList, SITE_URL } from '../utils/jsonld';
function AuthorByline({ post }: { post: BlogPost }) {
if (!post.author) return null;
@@ -37,8 +38,17 @@ function AuthorByline({ post }: { post: BlogPost }) {
function BlogList() {
const posts = getAllPosts();
+ const listCrumbs = breadcrumbList([
+ { name: 'Home', url: SITE_URL },
+ { name: 'Blog', url: `${SITE_URL}/blog` },
+ ]);
+
return (
+
Blog – Wraith Protocol
+
+
{post.title} – Wraith Protocol
{post.excerpt && }
@@ -226,6 +258,18 @@ function BlogAuthor({ id }: { id: string }) {
return (
+
{author.name} – Wraith Protocol Blog
@@ -71,6 +62,10 @@ function CaseStudyDetail({ study }: { study: CaseStudy }) {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
+
0;
+ const faqStructuredData = faqPage(
+ faqEntries.map((entry) => ({ question: entry.question, answer: entry.answer })),
+ );
+
return (
+
diff --git a/src/pages/Grants.tsx b/src/pages/Grants.tsx
index 6131c96..2b0d67e 100644
--- a/src/pages/Grants.tsx
+++ b/src/pages/Grants.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import waveData from '../data/wave.json';
+import { howTo, SITE_URL } from '../utils/jsonld';
type Wave = (typeof waveData)['currentWave'];
type PastWave = (typeof waveData)['pastWaves'][number];
@@ -9,6 +10,38 @@ const currentWave = waveData.currentWave as Wave;
const pastWaves = waveData.pastWaves as PastWave[];
const faqEntries = waveData.faq as FaqEntry[];
+const grantsHowTo = howTo({
+ name: `How to apply for ${currentWave?.name ?? 'a Wraith grant'}`,
+ description:
+ 'Follow these steps to submit a proposal and get funded through the Wraith grant program.',
+ url: `${SITE_URL}/grants`,
+ steps: [
+ {
+ name: 'Confirm eligibility',
+ text: `Review the eligibility criteria to ensure your project builds stealth address infrastructure, SDK integrations, or privacy-preserving payment tooling. Current criteria: ${(
+ currentWave?.eligibility ?? []
+ ).join(' ')}`,
+ },
+ {
+ name: 'Prepare your proposal',
+ text:
+ currentWave?.howToApply ??
+ 'Include a clear scope, timeline, and budget breakdown with defined milestones.',
+ },
+ {
+ name: 'Submit on Drips',
+ text: `Open the Drips grant page and submit your proposal before the wave closes. Apply here: ${currentWave?.applyUrl}`,
+ url: currentWave?.applyUrl,
+ },
+ {
+ name: 'Await review',
+ text: `The Wraith team reviews proposals against the published review criteria: ${(
+ currentWave?.reviewCriteria ?? []
+ ).join(' ')}`,
+ },
+ ],
+});
+
const labelStyles = 'font-mono text-[10px] font-semibold uppercase tracking-[1.8px] text-outline';
export default function Grants() {
@@ -16,6 +49,10 @@ export default function Grants() {
return (
+
{/* Hero */}
Grants
diff --git a/src/utils/jsonld.ts b/src/utils/jsonld.ts
new file mode 100644
index 0000000..eb827f4
--- /dev/null
+++ b/src/utils/jsonld.ts
@@ -0,0 +1,132 @@
+export const SITE_URL = 'https://www.usewraith.xyz';
+
+export type JsonLdObject = Record & { '@context': string; '@type': string };
+
+export interface OrganizationInput {
+ name?: string;
+ url?: string;
+ logo?: string;
+ sameAs?: string[];
+}
+
+export function organization(input: OrganizationInput = {}): JsonLdObject {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ '@id': `${SITE_URL}/#organization`,
+ name: input.name ?? 'Wraith Protocol',
+ url: input.url ?? SITE_URL,
+ logo: input.logo ?? `${SITE_URL}/logo.png`,
+ sameAs: input.sameAs ?? [
+ 'https://github.com/wraith-protocol',
+ 'https://twitter.com/wraith_protocol',
+ ],
+ };
+}
+
+export interface ArticleInput {
+ headline: string;
+ description: string;
+ datePublished: string;
+ authorName: string;
+ url: string;
+ publisherName?: string;
+}
+
+export function article(input: ArticleInput): JsonLdObject {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: input.headline,
+ description: input.description,
+ datePublished: input.datePublished,
+ author: {
+ '@type': 'Organization',
+ name: input.authorName,
+ },
+ publisher: {
+ '@type': 'Organization',
+ name: input.publisherName ?? 'Wraith Protocol',
+ logo: {
+ '@type': 'ImageObject',
+ url: `${SITE_URL}/logo.png`,
+ },
+ },
+ mainEntityOfPage: {
+ '@type': 'WebPage',
+ '@id': input.url,
+ },
+ };
+}
+
+export interface FaqItem {
+ question: string;
+ answer: string;
+}
+
+export function faqPage(entries: FaqItem[]): JsonLdObject {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'FAQPage',
+ mainEntity: entries.map((entry) => ({
+ '@type': 'Question',
+ name: entry.question,
+ acceptedAnswer: {
+ '@type': 'Answer',
+ text: entry.answer,
+ },
+ })),
+ };
+}
+
+export interface HowToStep {
+ name: string;
+ text: string;
+ url?: string;
+}
+
+export interface HowToInput {
+ name: string;
+ description?: string;
+ steps: HowToStep[];
+ url?: string;
+ totalTime?: string;
+}
+
+export function howTo(input: HowToInput): JsonLdObject {
+ const step = input.steps.map((s, index) => ({
+ '@type': 'HowToStep',
+ position: index + 1,
+ name: s.name,
+ text: s.text,
+ ...(s.url ? { url: s.url } : {}),
+ }));
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'HowTo',
+ name: input.name,
+ ...(input.description ? { description: input.description } : {}),
+ ...(input.totalTime ? { totalTime: input.totalTime } : {}),
+ step,
+ ...(input.url ? { mainEntityOfPage: { '@type': 'WebPage', '@id': input.url } } : {}),
+ };
+}
+
+export interface BreadcrumbItem {
+ name: string;
+ url: string;
+}
+
+export function breadcrumbList(items: BreadcrumbItem[]): JsonLdObject {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: items.map((item, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ name: item.name,
+ item: item.url,
+ })),
+ };
+}