From a2c0cb6aa3866ca14de205a35e7f26c6a038f965 Mon Sep 17 00:00:00 2001 From: Escelit Date: Tue, 25 Aug 2026 23:25:39 +0100 Subject: [PATCH 1/2] feat(seo): expand structured data with shared jsonld helpers (#126) --- .github/workflows/ci.yml | 1 + package.json | 1 + public/feed.xml | 12 +- public/feed/tag/cryptography.xml | 20 +++ public/sitemap.xml | 36 ---- scripts/validate-jsonld.mjs | 288 +++++++++++++++++++++++++++++++ src/pages/Blog.tsx | 44 +++++ src/pages/CaseStudies.tsx | 35 ++-- src/pages/Faq.tsx | 9 + src/pages/Grants.tsx | 37 ++++ src/utils/jsonld.ts | 132 ++++++++++++++ 11 files changed, 553 insertions(+), 62 deletions(-) create mode 100644 public/feed/tag/cryptography.xml create mode 100644 scripts/validate-jsonld.mjs create mode 100644 src/utils/jsonld.ts 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/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 (
+