diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 15fd6c9..b6c468a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,6 +19,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
- run: pnpm lint:i18n
+ - run: pnpm validate:content
- run: pnpm test:a11y
- run: pnpm build
- run: pnpm validate:jsonld
diff --git a/package.json b/package.json
index d008b34..faab537 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build && node scripts/inline-css.js && node scripts/gen-blog-manifest.mjs && tsx scripts/og.ts && tsx scripts/sitemap.ts && node scripts/gen-rss.mjs",
+ "build": "tsx scripts/validate-content.ts && tsc -b && vite build && node scripts/inline-css.js && node scripts/gen-blog-manifest.mjs && tsx scripts/og.ts && tsx scripts/sitemap.ts && node scripts/gen-rss.mjs",
"og:generate": "tsx scripts/og.ts",
"generate:sitemap": "node scripts/gen-sitemap.mjs",
"preview": "vite preview",
@@ -20,6 +20,7 @@
"lint:i18n": "node scripts/lint-i18n.mjs",
"generate:og": "node scripts/generate-og.mjs",
"validate:jsonld": "tsx scripts/validate-jsonld.mjs",
+ "validate:content": "tsx scripts/validate-content.ts",
"prepare": "husky"
},
"dependencies": {
diff --git a/public/feed.xml b/public/feed.xml
index 07e3002..3c65f31 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
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/announcements.xml b/public/feed/tag/announcements.xml
index 5c59a45..6db8fb7 100644
--- a/public/feed/tag/announcements.xml
+++ b/public/feed/tag/announcements.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/announcements
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/cryptography.xml b/public/feed/tag/cryptography.xml
index e9d4598..fdad9d3 100644
--- a/public/feed/tag/cryptography.xml
+++ b/public/feed/tag/cryptography.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/cryptography
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/privacy.xml b/public/feed/tag/privacy.xml
index 226dfdb..17323f2 100644
--- a/public/feed/tag/privacy.xml
+++ b/public/feed/tag/privacy.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/privacy
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/sdk.xml b/public/feed/tag/sdk.xml
index b7e2ac5..670f5c8 100644
--- a/public/feed/tag/sdk.xml
+++ b/public/feed/tag/sdk.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/sdk
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/stealth-payments.xml b/public/feed/tag/stealth-payments.xml
index b6a3ab6..a1398bd 100644
--- a/public/feed/tag/stealth-payments.xml
+++ b/public/feed/tag/stealth-payments.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/stealth-payments
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/wave-6.xml b/public/feed/tag/wave-6.xml
index cbdd354..8bcaf8e 100644
--- a/public/feed/tag/wave-6.xml
+++ b/public/feed/tag/wave-6.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/wave-6
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/feed/tag/wave-7.xml b/public/feed/tag/wave-7.xml
index e99d6fc..00d57bc 100644
--- a/public/feed/tag/wave-7.xml
+++ b/public/feed/tag/wave-7.xml
@@ -5,7 +5,7 @@
https://usewraith.xyz/blog/tag/wave-7
Notes on stealth payments, private infrastructure, and the Wraith ecosystem.
en-us
- Sat, 29 Aug 2026 13:32:10 GMT
+ Mon, 28 Sep 2026 22:48:43 GMT
-
diff --git a/public/sitemap.xml b/public/sitemap.xml
index 7b3f485..ab6be3e 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -2,205 +2,205 @@
https://usewraith.xyz
- 2026-09-25
+ 2026-09-28
daily
1.0
https://usewraith.xyz/faq
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/privacy
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/use-cases
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/use-cases/calculator
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/roadmap
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/case-studies
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/stellar
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/careers
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/press
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/case-studies/payroll-processor
- 2026-09-25
+ 2026-09-28
weekly
0.7
https://usewraith.xyz/blog/author/lena-vogt
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/privacy
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/stealth-payments
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/cryptography
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/announcements
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/wave-7
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/wave-6
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/tag/sdk
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/about
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/privacy-by-default
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/stealth-addresses-explained
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/blog/wave-7-kickoff
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/chains
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/contributors
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/ecosystem
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/governance
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/grants
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/newsletter
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/security
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/status
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/threat-model
- 2026-09-25
+ 2026-09-28
weekly
0.8
https://usewraith.xyz/vitals
- 2026-09-25
+ 2026-09-28
weekly
0.8
diff --git a/scripts/gen-blog-manifest.mjs b/scripts/gen-blog-manifest.mjs
index f2694b9..58725a5 100644
--- a/scripts/gen-blog-manifest.mjs
+++ b/scripts/gen-blog-manifest.mjs
@@ -131,7 +131,7 @@ function loadBlogPostsJson() {
function main() {
if (!fs.existsSync(contentDir)) {
console.log('No blog content directory found');
- fs.writeFileSync(outputPath, JSON.stringify([], null, 2));
+ fs.writeFileSync(outputPath, JSON.stringify([], null, 2) + '\n');
return;
}
@@ -166,7 +166,7 @@ function main() {
entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
- fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2));
+ fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2) + '\n');
console.log(`Generated blog-manifest.json with ${entries.length} posts`);
}
diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts
new file mode 100644
index 0000000..e68ecd1
--- /dev/null
+++ b/scripts/validate-content.ts
@@ -0,0 +1,1175 @@
+/**
+ * Content collection validator — issue #156
+ *
+ * Validates every JSON data file in src/data/ at build time:
+ * • Required fields are present and have the correct type
+ * • No duplicate slugs / ids within a collection
+ * • URL strings are valid absolute URLs
+ * • Date strings are valid ISO-8601 dates
+ * • Localized i18n files (es.json, pt.json) have the same key shape as en.json
+ *
+ * On failure: prints source file + record identifier, exits 1.
+ * On success: prints a tally and exits 0.
+ */
+
+import { readFileSync, readdirSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { isIsoDate } from '../src/utils/date.ts';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = join(__dirname, '..');
+const dataDir = join(root, 'src', 'data');
+const i18nDir = join(root, 'src', 'i18n');
+
+// ---------------------------------------------------------------------------
+// Tiny validation DSL (no runtime deps)
+// ---------------------------------------------------------------------------
+
+type Ctx = { file: string; record: string };
+
+interface Failure {
+ file: string;
+ record: string;
+ message: string;
+}
+
+const failures: Failure[] = [];
+
+function fail(ctx: Ctx, message: string): void {
+ failures.push({ file: ctx.file, record: ctx.record, message });
+}
+
+// ---------------------------------------------------------------------------
+// Primitive checks
+// ---------------------------------------------------------------------------
+
+function isString(v: unknown): v is string {
+ return typeof v === 'string';
+}
+
+function isNonEmptyString(v: unknown): v is string {
+ return typeof v === 'string' && v.trim().length > 0;
+}
+
+function isNumber(v: unknown): v is number {
+ return typeof v === 'number' && Number.isFinite(v);
+}
+
+function isBoolean(v: unknown): v is boolean {
+ return typeof v === 'boolean';
+}
+
+function isArray(v: unknown): v is unknown[] {
+ return Array.isArray(v);
+}
+
+function isObject(v: unknown): v is Record {
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
+}
+
+// isIsoDate is imported from ../src/utils/date.ts
+
+function isUrl(v: unknown): boolean {
+ if (!isString(v) || v.trim().length === 0) return false;
+ try {
+ const u = new URL(v);
+ return u.protocol === 'http:' || u.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Field assertion helpers
+// ---------------------------------------------------------------------------
+
+function requireNonEmpty(ctx: Ctx, obj: Record, field: string): void {
+ if (!isNonEmptyString(obj[field])) {
+ fail(ctx, `field "${field}" must be a non-empty string (got ${JSON.stringify(obj[field])})`);
+ }
+}
+
+function requireNumber(ctx: Ctx, obj: Record, field: string): void {
+ if (!isNumber(obj[field])) {
+ fail(ctx, `field "${field}" must be a finite number (got ${JSON.stringify(obj[field])})`);
+ }
+}
+
+function requireBoolean(ctx: Ctx, obj: Record, field: string): void {
+ if (!isBoolean(obj[field])) {
+ fail(ctx, `field "${field}" must be a boolean (got ${JSON.stringify(obj[field])})`);
+ }
+}
+
+function requireArray(ctx: Ctx, obj: Record, field: string): void {
+ if (!isArray(obj[field])) {
+ fail(ctx, `field "${field}" must be an array (got ${JSON.stringify(obj[field])})`);
+ }
+}
+
+function requireUrl(ctx: Ctx, obj: Record, field: string): void {
+ if (!isUrl(obj[field])) {
+ fail(ctx, `field "${field}" must be a valid absolute URL (got ${JSON.stringify(obj[field])})`);
+ }
+}
+
+function optionalUrl(ctx: Ctx, obj: Record, field: string): void {
+ if (field in obj && obj[field] !== '' && obj[field] != null && !isUrl(obj[field])) {
+ fail(
+ ctx,
+ `field "${field}" must be a valid absolute URL when present (got ${JSON.stringify(obj[field])})`,
+ );
+ }
+}
+
+function requireIsoDate(ctx: Ctx, obj: Record, field: string): void {
+ if (!isIsoDate(obj[field])) {
+ fail(
+ ctx,
+ `field "${field}" must be a valid ISO date string (got ${JSON.stringify(obj[field])})`,
+ );
+ }
+}
+
+function optionalIsoDate(ctx: Ctx, obj: Record, field: string): void {
+ if (field in obj && obj[field] != null && obj[field] !== '' && !isIsoDate(obj[field])) {
+ fail(
+ ctx,
+ `field "${field}" must be a valid ISO date string when present (got ${JSON.stringify(obj[field])})`,
+ );
+ }
+}
+
+function requireOneOf(
+ ctx: Ctx,
+ obj: Record,
+ field: string,
+ allowed: readonly T[],
+): void {
+ if (!allowed.includes(obj[field] as T)) {
+ fail(
+ ctx,
+ `field "${field}" must be one of [${allowed.join(', ')}] (got ${JSON.stringify(obj[field])})`,
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Duplicate-slug / id detector
+// ---------------------------------------------------------------------------
+
+function checkNoDuplicates(file: string, items: Record[], key: string): void {
+ const seen = new Map();
+ for (const item of items) {
+ const v = item[key];
+ if (!isString(v)) continue;
+ const prev = seen.get(v);
+ if (prev !== undefined) {
+ const ctx: Ctx = { file, record: `${key}="${v}"` };
+ fail(ctx, `duplicate ${key} "${v}" (also at index ${prev})`);
+ } else {
+ seen.set(v, items.indexOf(item));
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// JSON loader
+// ---------------------------------------------------------------------------
+
+function loadJson(file: string): unknown {
+ return JSON.parse(readFileSync(file, 'utf8'));
+}
+
+function relPath(abs: string): string {
+ return abs.replace(root + '/', '');
+}
+
+// ---------------------------------------------------------------------------
+// Collection validators
+// ---------------------------------------------------------------------------
+
+// ── case-studies.json ──────────────────────────────────────────────────────
+
+function validateCaseStudies(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw) || !isArray(raw['entries'])) {
+ failures.push({ file: relPath(file), record: '', message: 'must be { entries: [] }' });
+ return;
+ }
+ const entries = raw['entries'] as unknown[];
+ checkNoDuplicates(relPath(file), entries as Record[], 'id');
+ checkNoDuplicates(relPath(file), entries as Record[], 'slug');
+
+ entries.forEach((entry, i) => {
+ if (!isObject(entry)) {
+ failures.push({ file: relPath(file), record: `entries[${i}]`, message: 'must be an object' });
+ return;
+ }
+ const ctx: Ctx = { file: relPath(file), record: `entries[${i}] id="${entry['id']}"` };
+ requireNonEmpty(ctx, entry, 'id');
+ requireNonEmpty(ctx, entry, 'slug');
+ requireNonEmpty(ctx, entry, 'org');
+ requireNonEmpty(ctx, entry, 'logo');
+ requireNonEmpty(ctx, entry, 'industry');
+ requireNonEmpty(ctx, entry, 'useCase');
+ requireIsoDate(ctx, entry, 'integrationDate');
+ requireOneOf(ctx, entry, 'status', ['production', 'pilot', 'archived'] as const);
+ requireNonEmpty(ctx, entry, 'quote');
+ requireNonEmpty(ctx, entry, 'quotee');
+ requireNonEmpty(ctx, entry, 'summary');
+ requireNonEmpty(ctx, entry, 'challenge');
+ requireNonEmpty(ctx, entry, 'solution');
+ requireArray(ctx, entry, 'chains');
+ requireNonEmpty(ctx, entry, 'testimonial');
+
+ if (!isObject(entry['results'])) {
+ fail(ctx, 'field "results" must be an object');
+ }
+ if (!isObject(entry['technical'])) {
+ fail(ctx, 'field "technical" must be an object');
+ }
+ });
+}
+
+// ── authors.json ───────────────────────────────────────────────────────────
+
+function validateAuthors(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be a JSON object' });
+ return;
+ }
+ for (const [id, author] of Object.entries(raw)) {
+ const ctx: Ctx = { file: relPath(file), record: `author id="${id}"` };
+ if (!isObject(author)) {
+ fail(ctx, 'author entry must be an object');
+ continue;
+ }
+ requireNonEmpty(ctx, author, 'name');
+ requireNonEmpty(ctx, author, 'bio');
+ requireBoolean(ctx, author, 'optIn');
+
+ // avatar is optional but if present must be a string
+ if ('avatar' in author && author['avatar'] != null && !isString(author['avatar'])) {
+ fail(ctx, 'field "avatar" must be a string when present');
+ }
+ // links is optional but if present validate known URL fields
+ if ('links' in author && author['links'] != null) {
+ if (!isObject(author['links'])) {
+ fail(ctx, 'field "links" must be an object');
+ } else {
+ const links = author['links'] as Record;
+ for (const urlField of ['website', 'github', 'twitter'] as const) {
+ optionalUrl(ctx, links, urlField);
+ }
+ }
+ }
+ }
+}
+
+// ── contributors.json ──────────────────────────────────────────────────────
+
+function validateContributors(file: string): void {
+ const raw = loadJson(file);
+ if (!isArray(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an array' });
+ return;
+ }
+ checkNoDuplicates(relPath(file), raw as Record[], 'username');
+
+ (raw as unknown[]).forEach((entry, i) => {
+ if (!isObject(entry)) {
+ failures.push({ file: relPath(file), record: `[${i}]`, message: 'must be an object' });
+ return;
+ }
+ const ctx: Ctx = {
+ file: relPath(file),
+ record: `contributors[${i}] username="${entry['username']}"`,
+ };
+ requireNonEmpty(ctx, entry, 'username');
+ requireUrl(ctx, entry, 'avatar');
+ requireUrl(ctx, entry, 'profile');
+ requireNumber(ctx, entry, 'prCount');
+ requireArray(ctx, entry, 'waves');
+ });
+}
+
+// ── faq.json ───────────────────────────────────────────────────────────────
+
+function validateFaq(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+ // Validate categories
+ if (!isArray(raw['categories'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "categories" must be an array',
+ });
+ } else {
+ const cats = raw['categories'] as unknown[];
+ checkNoDuplicates(relPath(file), cats as Record[], 'id');
+ cats.forEach((cat, i) => {
+ if (!isObject(cat)) return;
+ const ctx: Ctx = {
+ file: relPath(file),
+ record: `categories[${i}] id="${(cat as Record)['id']}"`,
+ };
+ requireNonEmpty(ctx, cat as Record, 'id');
+ requireNonEmpty(ctx, cat as Record, 'title');
+ });
+ }
+
+ // Build category id set for cross-reference check
+ const categoryIds = new Set();
+ if (isArray(raw['categories'])) {
+ for (const cat of raw['categories'] as unknown[]) {
+ if (isObject(cat) && isString(cat['id'])) categoryIds.add(cat['id']);
+ }
+ }
+
+ // Validate entries
+ if (!isArray(raw['entries'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "entries" must be an array',
+ });
+ return;
+ }
+ const entries = raw['entries'] as unknown[];
+ checkNoDuplicates(relPath(file), entries as Record[], 'id');
+
+ entries.forEach((entry, i) => {
+ if (!isObject(entry)) return;
+ const ctx: Ctx = { file: relPath(file), record: `entries[${i}] id="${entry['id']}"` };
+ requireNonEmpty(ctx, entry, 'id');
+ requireNonEmpty(ctx, entry, 'category');
+ requireNonEmpty(ctx, entry, 'question');
+ requireNonEmpty(ctx, entry, 'answer');
+ // category must reference a known category id
+ if (
+ categoryIds.size > 0 &&
+ isString(entry['category']) &&
+ !categoryIds.has(entry['category'])
+ ) {
+ fail(ctx, `field "category" value "${entry['category']}" does not match any category id`);
+ }
+ // tags optional but must be an array of strings when present
+ if ('tags' in entry && entry['tags'] != null) {
+ if (!isArray(entry['tags'])) {
+ fail(ctx, 'field "tags" must be an array');
+ } else {
+ for (const tag of entry['tags'] as unknown[]) {
+ if (!isString(tag)) {
+ fail(ctx, `field "tags" entries must be strings (got ${JSON.stringify(tag)})`);
+ }
+ }
+ }
+ }
+ });
+}
+
+// ── chains.json ────────────────────────────────────────────────────────────
+
+const CHAIN_STATUSES = ['live', 'testnet', 'devnet', 'planned'] as const;
+
+function validateChains(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+ requireNonEmpty({ file: relPath(file), record: '' }, raw, 'title');
+ requireNonEmpty({ file: relPath(file), record: '' }, raw, 'description');
+
+ if (!isArray(raw['columns'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "columns" must be an array',
+ });
+ }
+
+ if (!isArray(raw['chains'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "chains" must be an array',
+ });
+ return;
+ }
+ const chains = raw['chains'] as unknown[];
+ checkNoDuplicates(relPath(file), chains as Record[], 'id');
+
+ chains.forEach((chain, i) => {
+ if (!isObject(chain)) return;
+ const ctx: Ctx = { file: relPath(file), record: `chains[${i}] id="${chain['id']}"` };
+ requireNonEmpty(ctx, chain, 'id');
+ requireNonEmpty(ctx, chain, 'name');
+ requireNumber(ctx, chain, 'blockTime');
+ requireNumber(ctx, chain, 'medianFee');
+ requireNonEmpty(ctx, chain, 'finality');
+ requireNonEmpty(ctx, chain, 'wallets');
+ requireOneOf(ctx, chain, 'status', CHAIN_STATUSES);
+ requireNonEmpty(ctx, chain, 'audit');
+ requireUrl(ctx, chain, 'docs');
+ requireNonEmpty(ctx, chain, 'description');
+ });
+}
+
+// ── wave.json (grants) ─────────────────────────────────────────────────────
+
+const WAVE_STATUSES = ['open', 'closed', 'upcoming'] as const;
+
+function validateWave(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+
+ // currentWave
+ if ('currentWave' in raw && raw['currentWave'] != null) {
+ const w = raw['currentWave'] as Record;
+ const ctx: Ctx = { file: relPath(file), record: `currentWave id="${w['id']}"` };
+ requireNonEmpty(ctx, w, 'id');
+ requireNonEmpty(ctx, w, 'name');
+ requireOneOf(ctx, w, 'status', WAVE_STATUSES);
+ requireIsoDate(ctx, w, 'openDate');
+ requireIsoDate(ctx, w, 'closeDate');
+ requireNonEmpty(ctx, w, 'budget');
+ requireNonEmpty(ctx, w, 'description');
+ requireNonEmpty(ctx, w, 'howToApply');
+ requireUrl(ctx, w, 'applyUrl');
+ requireNonEmpty(ctx, w, 'rewardRange');
+ requireArray(ctx, w, 'eligibility');
+ requireArray(ctx, w, 'reviewCriteria');
+ }
+
+ // pastWaves
+ if ('pastWaves' in raw) {
+ if (!isArray(raw['pastWaves'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "pastWaves" must be an array',
+ });
+ } else {
+ const past = raw['pastWaves'] as unknown[];
+ checkNoDuplicates(relPath(file), past as Record[], 'id');
+ past.forEach((w, i) => {
+ if (!isObject(w)) return;
+ const ctx: Ctx = { file: relPath(file), record: `pastWaves[${i}] id="${w['id']}"` };
+ requireNonEmpty(ctx, w, 'id');
+ requireNonEmpty(ctx, w, 'name');
+ requireOneOf(ctx, w, 'status', WAVE_STATUSES);
+ requireNonEmpty(ctx, w, 'budget');
+ });
+ }
+ }
+
+ // faq entries
+ if ('faq' in raw) {
+ if (!isArray(raw['faq'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "faq" must be an array',
+ });
+ } else {
+ const faqEntries = raw['faq'] as unknown[];
+ checkNoDuplicates(relPath(file), faqEntries as Record[], 'id');
+ faqEntries.forEach((entry, i) => {
+ if (!isObject(entry)) return;
+ const ctx: Ctx = { file: relPath(file), record: `faq[${i}] id="${entry['id']}"` };
+ requireNonEmpty(ctx, entry, 'id');
+ requireNonEmpty(ctx, entry, 'question');
+ requireNonEmpty(ctx, entry, 'answer');
+ });
+ }
+ }
+}
+
+// ── ecosystem.json ─────────────────────────────────────────────────────────
+
+function validateEcosystem(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+
+ // Validate categories
+ if (!isArray(raw['categories'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "categories" must be an array',
+ });
+ } else {
+ const cats = raw['categories'] as unknown[];
+ checkNoDuplicates(relPath(file), cats as Record[], 'id');
+ cats.forEach((cat, i) => {
+ if (!isObject(cat)) return;
+ const ctx: Ctx = {
+ file: relPath(file),
+ record: `categories[${i}] id="${(cat as Record)['id']}"`,
+ };
+ requireNonEmpty(ctx, cat as Record, 'id');
+ requireNonEmpty(ctx, cat as Record, 'label');
+ requireNonEmpty(ctx, cat as Record, 'description');
+ });
+ }
+
+ // Build category id set
+ const categoryIds = new Set();
+ if (isArray(raw['categories'])) {
+ for (const cat of raw['categories'] as unknown[]) {
+ if (isObject(cat) && isString(cat['id'])) categoryIds.add(cat['id']);
+ }
+ }
+
+ // Validate partners
+ if (!isArray(raw['partners'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "partners" must be an array',
+ });
+ return;
+ }
+ const partners = raw['partners'] as unknown[];
+
+ partners.forEach((partner, i) => {
+ if (!isObject(partner)) return;
+ const ctx: Ctx = { file: relPath(file), record: `partners[${i}] name="${partner['name']}"` };
+ requireNonEmpty(ctx, partner, 'name');
+ requireNonEmpty(ctx, partner, 'shortName');
+ requireNonEmpty(ctx, partner, 'description');
+ requireNonEmpty(ctx, partner, 'category');
+ requireUrl(ctx, partner, 'link');
+ requireNonEmpty(ctx, partner, 'logo');
+ requireNumber(ctx, partner, 'width');
+ // category must reference a known category id
+ if (
+ categoryIds.size > 0 &&
+ isString(partner['category']) &&
+ !categoryIds.has(partner['category'])
+ ) {
+ fail(
+ ctx,
+ `field "category" value "${partner['category']}" does not match any ecosystem category id`,
+ );
+ }
+ });
+}
+
+// ── roadmap.json ───────────────────────────────────────────────────────────
+
+const ROADMAP_STATUSES = ['shipped', 'in-progress', 'planned'] as const;
+
+function validateRoadmap(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+ optionalUrl({ file: relPath(file), record: '' }, raw, 'docsUrl');
+
+ if (!isArray(raw['milestones'])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'field "milestones" must be an array',
+ });
+ return;
+ }
+ const milestones = raw['milestones'] as unknown[];
+ checkNoDuplicates(relPath(file), milestones as Record[], 'id');
+
+ milestones.forEach((m, i) => {
+ if (!isObject(m)) return;
+ const ctx: Ctx = { file: relPath(file), record: `milestones[${i}] id="${m['id']}"` };
+ requireNonEmpty(ctx, m, 'id');
+ requireNonEmpty(ctx, m, 'phase');
+ requireNonEmpty(ctx, m, 'title');
+ requireOneOf(ctx, m, 'status', ROADMAP_STATUSES);
+ requireNonEmpty(ctx, m, 'summary');
+ requireArray(ctx, m, 'highlights');
+ });
+}
+
+// ── blog-posts.json ────────────────────────────────────────────────────────
+
+function validateBlogPosts(file: string): void {
+ const raw = loadJson(file);
+ if (!isArray(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an array' });
+ return;
+ }
+ checkNoDuplicates(relPath(file), raw as Record[], 'slug');
+
+ (raw as unknown[]).forEach((post, i) => {
+ if (!isObject(post)) return;
+ const ctx: Ctx = { file: relPath(file), record: `posts[${i}] slug="${post['slug']}"` };
+ requireNonEmpty(ctx, post, 'slug');
+ requireNonEmpty(ctx, post, 'title');
+ requireNonEmpty(ctx, post, 'excerpt');
+ requireIsoDate(ctx, post, 'publishedAt');
+ requireNonEmpty(ctx, post, 'author');
+ requireUrl(ctx, post, 'url');
+ });
+}
+
+// ── vitals fixtures (TypeScript source, not JSON — skip structural check) ──
+// vitalsData.ts and vitalsFixtures.ts are TypeScript modules whose shape is
+// enforced by the compiler; no separate JSON validation needed.
+
+// ── trust.json ─────────────────────────────────────────────────────────────
+
+function validateTrust(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+
+ // audits section
+ if ('audits' in raw) {
+ const audits = raw['audits'];
+ if (!isObject(audits)) {
+ failures.push({ file: relPath(file), record: 'audits', message: 'must be an object' });
+ } else {
+ requireNonEmpty({ file: relPath(file), record: 'audits' }, audits, 'eyebrow');
+ if (!isArray(audits['items'])) {
+ failures.push({
+ file: relPath(file),
+ record: 'audits',
+ message: 'field "items" must be an array',
+ });
+ } else {
+ (audits['items'] as unknown[]).forEach((item, i) => {
+ if (!isObject(item)) return;
+ const ctx: Ctx = { file: relPath(file), record: `audits.items[${i}]` };
+ requireNonEmpty(ctx, item, 'label');
+ requireNonEmpty(ctx, item, 'status');
+ requireNonEmpty(ctx, item, 'detail');
+ optionalUrl(ctx, item, 'link');
+ });
+ }
+ }
+ }
+
+ // integrations section
+ if ('integrations' in raw) {
+ const integ = raw['integrations'];
+ if (!isObject(integ)) {
+ failures.push({ file: relPath(file), record: 'integrations', message: 'must be an object' });
+ } else {
+ const ctx: Ctx = { file: relPath(file), record: 'integrations' };
+ requireNonEmpty(ctx, integ, 'eyebrow');
+ requireNumber(ctx, integ, 'count');
+ requireNonEmpty(ctx, integ, 'label');
+ optionalUrl(ctx, integ, 'link');
+ }
+ }
+
+ // uptime section
+ if ('uptime' in raw) {
+ const uptime = raw['uptime'];
+ if (!isObject(uptime)) {
+ failures.push({ file: relPath(file), record: 'uptime', message: 'must be an object' });
+ } else {
+ const ctx: Ctx = { file: relPath(file), record: 'uptime' };
+ requireNonEmpty(ctx, uptime, 'eyebrow');
+ requireNonEmpty(ctx, uptime, 'fallbackPercent');
+ requireNonEmpty(ctx, uptime, 'windowLabel');
+ requireUrl(ctx, uptime, 'statusPageUrl');
+ }
+ }
+}
+
+// ── showcase.json ──────────────────────────────────────────────────────────
+
+function validateShowcase(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw) || !isArray(raw['entries'])) {
+ failures.push({ file: relPath(file), record: '', message: 'must be { entries: [] }' });
+ return;
+ }
+ const entries = raw['entries'] as unknown[];
+ entries.forEach((entry, i) => {
+ if (!isObject(entry)) {
+ failures.push({ file: relPath(file), record: `entries[${i}]`, message: 'must be an object' });
+ return;
+ }
+ const ctx: Ctx = { file: relPath(file), record: `entries[${i}] name="${entry['name']}"` };
+ requireNonEmpty(ctx, entry, 'name');
+ requireNonEmpty(ctx, entry, 'logo');
+ requireNonEmpty(ctx, entry, 'description');
+ requireUrl(ctx, entry, 'url');
+ requireBoolean(ctx, entry, 'stellar');
+ });
+}
+
+// ── incidents.json ─────────────────────────────────────────────────────────
+
+const INCIDENT_STATUSES = ['investigating', 'identified', 'monitoring', 'resolved'] as const;
+const INCIDENT_IMPACTS = ['none', 'minor', 'major', 'critical'] as const;
+
+function validateIncidents(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw) || !isArray(raw['incidents'])) {
+ failures.push({ file: relPath(file), record: '', message: 'must be { incidents: [] }' });
+ return;
+ }
+ const incidents = raw['incidents'] as unknown[];
+ checkNoDuplicates(relPath(file), incidents as Record[], 'id');
+
+ incidents.forEach((inc, i) => {
+ if (!isObject(inc)) {
+ failures.push({
+ file: relPath(file),
+ record: `incidents[${i}]`,
+ message: 'must be an object',
+ });
+ return;
+ }
+ const ctx: Ctx = { file: relPath(file), record: `incidents[${i}] id="${inc['id']}"` };
+ requireNonEmpty(ctx, inc, 'id');
+ requireNonEmpty(ctx, inc, 'title');
+ requireOneOf(ctx, inc, 'status', INCIDENT_STATUSES);
+ requireOneOf(ctx, inc, 'impact', INCIDENT_IMPACTS);
+ requireIsoDate(ctx, inc, 'date');
+ optionalIsoDate(ctx, inc, 'resolvedAt');
+
+ if (!isArray(inc['updates'])) {
+ fail(ctx, 'field "updates" must be an array');
+ } else {
+ (inc['updates'] as unknown[]).forEach((u, j) => {
+ if (!isObject(u)) return;
+ const uctx: Ctx = { file: relPath(file), record: `incidents[${i}].updates[${j}]` };
+ requireIsoDate(uctx, u, 'timestamp');
+ requireNonEmpty(uctx, u, 'message');
+ });
+ }
+ });
+}
+
+// ── security.json ──────────────────────────────────────────────────────────
+
+function validateSecurity(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+
+ // contact
+ if ('contact' in raw) {
+ const contact = raw['contact'];
+ if (!isObject(contact)) {
+ failures.push({ file: relPath(file), record: 'contact', message: 'must be an object' });
+ } else {
+ const ctx: Ctx = { file: relPath(file), record: 'contact' };
+ requireNonEmpty(ctx, contact, 'email');
+ optionalUrl(ctx, contact, 'acknowledgments');
+ }
+ }
+
+ // audits section
+ if ('audits' in raw) {
+ const audits = raw['audits'];
+ if (!isObject(audits)) {
+ failures.push({ file: relPath(file), record: 'audits', message: 'must be an object' });
+ } else {
+ const ctx: Ctx = { file: relPath(file), record: 'audits' };
+ optionalIsoDate(ctx, audits, 'lastCompleted');
+ optionalIsoDate(ctx, audits, 'nextScheduled');
+ optionalUrl(ctx, audits, 'reports');
+ }
+ }
+
+ // bounty section
+ if ('bounty' in raw) {
+ const bounty = raw['bounty'];
+ if (!isObject(bounty)) {
+ failures.push({ file: relPath(file), record: 'bounty', message: 'must be an object' });
+ } else {
+ const ctx: Ctx = { file: relPath(file), record: 'bounty' };
+ requireNonEmpty(ctx, bounty, 'status');
+ optionalUrl(ctx, bounty, 'url');
+ }
+ }
+
+ // disclosures section
+ if ('disclosures' in raw) {
+ const disclosures = raw['disclosures'];
+ if (!isObject(disclosures)) {
+ failures.push({ file: relPath(file), record: 'disclosures', message: 'must be an object' });
+ } else {
+ const ctx: Ctx = { file: relPath(file), record: 'disclosures' };
+ requireNumber(ctx, disclosures, 'count');
+ optionalUrl(ctx, disclosures, 'public');
+ }
+ }
+}
+
+// ── blog-manifest.json ─────────────────────────────────────────────────────
+
+function validateBlogManifest(file: string): void {
+ const raw = loadJson(file);
+ if (!isArray(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an array' });
+ return;
+ }
+ checkNoDuplicates(relPath(file), raw as Record[], 'slug');
+
+ (raw as unknown[]).forEach((post, i) => {
+ if (!isObject(post)) {
+ failures.push({ file: relPath(file), record: `[${i}]`, message: 'must be an object' });
+ return;
+ }
+ const ctx: Ctx = { file: relPath(file), record: `manifest[${i}] slug="${post['slug']}"` };
+ requireNonEmpty(ctx, post, 'slug');
+ requireNonEmpty(ctx, post, 'title');
+ requireNonEmpty(ctx, post, 'excerpt');
+ requireIsoDate(ctx, post, 'date');
+ requireNonEmpty(ctx, post, 'author');
+ requireUrl(ctx, post, 'url');
+ });
+}
+
+// ── authors-optout.json ────────────────────────────────────────────────────
+
+function validateAuthorsOptout(file: string): void {
+ const raw = loadJson(file);
+ if (!isArray(raw)) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'must be an array of strings',
+ });
+ return;
+ }
+ (raw as unknown[]).forEach((entry, i) => {
+ if (!isNonEmptyString(entry)) {
+ failures.push({
+ file: relPath(file),
+ record: `[${i}]`,
+ message: `must be a non-empty string (got ${JSON.stringify(entry)})`,
+ });
+ }
+ });
+}
+
+// ── contributors-optout.json ───────────────────────────────────────────────
+
+function validateContributorsOptout(file: string): void {
+ const raw = loadJson(file);
+ if (!isArray(raw)) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: 'must be an array of strings',
+ });
+ return;
+ }
+ (raw as unknown[]).forEach((entry, i) => {
+ if (!isNonEmptyString(entry)) {
+ failures.push({
+ file: relPath(file),
+ record: `[${i}]`,
+ message: `must be a non-empty string (got ${JSON.stringify(entry)})`,
+ });
+ }
+ });
+}
+
+// ── team.json ──────────────────────────────────────────────────────────────
+
+function validateTeam(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+ requireNonEmpty({ file: relPath(file), record: '' }, raw, 'mission');
+
+ const validatePerson = (ctx: Ctx, person: Record): void => {
+ requireNonEmpty(ctx, person, 'name');
+ requireNonEmpty(ctx, person, 'role');
+ // bio optional for contributors
+ if ('bio' in person && person['bio'] != null && !isNonEmptyString(person['bio'])) {
+ fail(ctx, 'field "bio" must be a non-empty string when present');
+ }
+ optionalUrl(ctx, person, 'github');
+ optionalUrl(ctx, person, 'twitter');
+ };
+
+ for (const section of ['team', 'advisors', 'contributors'] as const) {
+ if (!(section in raw)) continue;
+ if (!isArray(raw[section])) {
+ failures.push({
+ file: relPath(file),
+ record: '',
+ message: `field "${section}" must be an array`,
+ });
+ continue;
+ }
+ (raw[section] as unknown[]).forEach((person, i) => {
+ if (!isObject(person)) {
+ failures.push({
+ file: relPath(file),
+ record: `${section}[${i}]`,
+ message: 'must be an object',
+ });
+ return;
+ }
+ const ctx: Ctx = { file: relPath(file), record: `${section}[${i}] name="${person['name']}"` };
+ validatePerson(ctx, person);
+ });
+ }
+}
+
+// ── threat-model.json ──────────────────────────────────────────────────────
+
+function validateThreatModel(file: string): void {
+ const raw = loadJson(file);
+ if (!isObject(raw)) {
+ failures.push({ file: relPath(file), record: '', message: 'must be an object' });
+ return;
+ }
+ const rootCtx: Ctx = { file: relPath(file), record: '' };
+ requireNonEmpty(rootCtx, raw, 'title');
+ requireNonEmpty(rootCtx, raw, 'overview');
+
+ // schema block
+ if ('schema' in raw) {
+ const schema = raw['schema'];
+ if (!isObject(schema)) {
+ fail(rootCtx, 'field "schema" must be an object');
+ } else {
+ requireNonEmpty({ file: relPath(file), record: 'schema' }, schema, 'name');
+ }
+ }
+
+ // threatActors
+ if ('threatActors' in raw) {
+ if (!isArray(raw['threatActors'])) {
+ fail(rootCtx, 'field "threatActors" must be an array');
+ } else {
+ (raw['threatActors'] as unknown[]).forEach((actor, i) => {
+ if (!isObject(actor)) return;
+ const ctx: Ctx = { file: relPath(file), record: `threatActors[${i}]` };
+ requireNonEmpty(ctx, actor, 'key');
+ requireNonEmpty(ctx, actor, 'label');
+ });
+ }
+ }
+
+ // approaches
+ if (!isArray(raw['approaches'])) {
+ fail(rootCtx, 'field "approaches" must be an array');
+ } else {
+ (raw['approaches'] as unknown[]).forEach((approach, i) => {
+ if (!isObject(approach)) return;
+ const ctx: Ctx = { file: relPath(file), record: `approaches[${i}] key="${approach['key']}"` };
+ requireNonEmpty(ctx, approach, 'key');
+ requireNonEmpty(ctx, approach, 'label');
+ if ('cells' in approach && !isObject(approach['cells'])) {
+ fail(ctx, 'field "cells" must be an object');
+ }
+ });
+ }
+
+ // footnotes
+ if ('footnotes' in raw) {
+ if (!isArray(raw['footnotes'])) {
+ fail(rootCtx, 'field "footnotes" must be an array');
+ } else {
+ (raw['footnotes'] as unknown[]).forEach((fn, i) => {
+ if (!isObject(fn)) return;
+ const ctx: Ctx = { file: relPath(file), record: `footnotes[${i}]` };
+ requireNumber(ctx, fn, 'id');
+ requireNonEmpty(ctx, fn, 'text');
+ });
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// i18n shape validator
+// Checks that every non-English locale JSON has the same leaf-key structure
+// as en.json, flagging both missing and extra keys.
+// ---------------------------------------------------------------------------
+
+function extractLeafKeys(obj: unknown, prefix = ''): string[] {
+ if (!isObject(obj)) return [];
+ const keys: string[] = [];
+ for (const [k, v] of Object.entries(obj)) {
+ const path = prefix ? `${prefix}.${k}` : k;
+ if (isObject(v)) {
+ keys.push(...extractLeafKeys(v, path));
+ } else {
+ keys.push(path);
+ }
+ }
+ return keys.sort();
+}
+
+function validateI18n(): void {
+ const files = readdirSync(i18nDir)
+ .filter((f) => f.endsWith('.json'))
+ .map((f) => ({ name: f, path: join(i18nDir, f) }));
+
+ const enFile = files.find((f) => f.name === 'en.json');
+ if (!enFile) {
+ failures.push({
+ file: 'src/i18n/en.json',
+ record: '',
+ message: 'en.json reference file not found',
+ });
+ return;
+ }
+
+ const enKeys = extractLeafKeys(loadJson(enFile.path));
+ const enRelPath = relPath(enFile.path);
+
+ for (const localeFile of files) {
+ if (localeFile.name === 'en.json') continue;
+ const locale = localeFile.name.replace('.json', '');
+ const fileRel = relPath(localeFile.path);
+ const localeKeys = extractLeafKeys(loadJson(localeFile.path));
+
+ const enSet = new Set(enKeys);
+ const localeSet = new Set(localeKeys);
+
+ const missing = enKeys.filter((k) => !localeSet.has(k));
+ const extra = localeKeys.filter((k) => !enSet.has(k));
+
+ for (const k of missing) {
+ failures.push({
+ file: fileRel,
+ record: `locale="${locale}"`,
+ message: `key "${k}" is present in ${enRelPath} but missing in ${fileRel}`,
+ });
+ }
+ for (const k of extra) {
+ failures.push({
+ file: fileRel,
+ record: `locale="${locale}"`,
+ message: `key "${k}" is in ${fileRel} but not in ${enRelPath} (extra key)`,
+ });
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Runner
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Runner — dynamic file discovery
+//
+// Every *.json file in src/data/ must have a registered validator.
+// Adding a new collection without a schema causes an immediate build failure,
+// so new files can never silently bypass the gate.
+// ---------------------------------------------------------------------------
+
+/**
+ * Map from basename (e.g. "trust.json") to its validator function.
+ * Add a new entry here whenever a new *.json file is added to src/data/.
+ */
+const SCHEMA_MAP: Record void> = {
+ 'case-studies.json': validateCaseStudies,
+ 'authors.json': validateAuthors,
+ 'contributors.json': validateContributors,
+ 'faq.json': validateFaq,
+ 'chains.json': validateChains,
+ 'wave.json': validateWave,
+ 'ecosystem.json': validateEcosystem,
+ 'roadmap.json': validateRoadmap,
+ 'blog-posts.json': validateBlogPosts,
+ 'trust.json': validateTrust,
+ 'showcase.json': validateShowcase,
+ 'incidents.json': validateIncidents,
+ 'security.json': validateSecurity,
+ 'blog-manifest.json': validateBlogManifest,
+ 'authors-optout.json': validateAuthorsOptout,
+ 'contributors-optout.json': validateContributorsOptout,
+ 'team.json': validateTeam,
+ 'threat-model.json': validateThreatModel,
+};
+
+console.log('\n🔍 Validating content collections…\n');
+
+// Discover every *.json file in src/data/ at runtime.
+const dataFiles = readdirSync(dataDir)
+ .filter((f) => f.endsWith('.json'))
+ .sort();
+
+// Fail loudly for any file with no registered schema so nothing sneaks through.
+const unregistered = dataFiles.filter((f) => !(f in SCHEMA_MAP));
+if (unregistered.length > 0) {
+ for (const f of unregistered) {
+ console.error(
+ ` ✗ src/data/${f} — no validator registered. Add one to SCHEMA_MAP in scripts/validate-content.ts.`,
+ );
+ failures.push({
+ file: `src/data/${f}`,
+ record: '',
+ message: 'no validator registered in SCHEMA_MAP — add one to validate-content.ts',
+ });
+ }
+}
+
+let checked = 0;
+for (const basename of dataFiles) {
+ const fn = SCHEMA_MAP[basename];
+ if (!fn) continue; // already reported above
+ const file = join(dataDir, basename);
+ const before = failures.length;
+ fn(file);
+ const count = failures.length - before;
+ const rel = relPath(file);
+ if (count === 0) {
+ console.log(` ✅ ${rel}`);
+ } else {
+ console.log(` ❌ ${rel} — ${count} issue(s)`);
+ }
+ checked++;
+}
+
+// i18n validation
+console.log('\n🔍 Validating i18n locale shape…\n');
+const beforeI18n = failures.length;
+validateI18n();
+const i18nCount = failures.length - beforeI18n;
+if (i18nCount === 0) {
+ console.log(' ✅ src/i18n/ — all locales match en.json shape\n');
+} else {
+ console.log(` ❌ src/i18n/ — ${i18nCount} parity issue(s)\n`);
+}
+
+// ---------------------------------------------------------------------------
+// Report
+// ---------------------------------------------------------------------------
+
+if (failures.length === 0) {
+ console.log(`✅ Content validation passed — ${checked} collection(s) checked.\n`);
+ process.exit(0);
+} else {
+ console.error(`\n🚨 Content validation failed — ${failures.length} error(s):\n`);
+ for (const f of failures) {
+ console.error(` ${f.file} [${f.record}]`);
+ console.error(` → ${f.message}\n`);
+ }
+ process.exit(1);
+}
diff --git a/src/__tests__/date.test.ts b/src/__tests__/date.test.ts
new file mode 100644
index 0000000..494c65c
--- /dev/null
+++ b/src/__tests__/date.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from 'vitest';
+import { isIsoDate } from '../utils/date';
+
+describe('isIsoDate', () => {
+ // -------------------------------------------------------------------------
+ // Valid dates
+ // -------------------------------------------------------------------------
+ describe('accepts valid ISO 8601 strings', () => {
+ it('accepts a bare date', () => {
+ expect(isIsoDate('2026-06-15')).toBe(true);
+ });
+
+ it('accepts a UTC datetime', () => {
+ expect(isIsoDate('2026-06-15T14:22:00Z')).toBe(true);
+ });
+
+ it('accepts a datetime with milliseconds', () => {
+ expect(isIsoDate('2026-06-15T14:22:00.000Z')).toBe(true);
+ });
+
+ it('accepts a datetime with positive offset', () => {
+ expect(isIsoDate('2026-06-15T14:22:00+05:30')).toBe(true);
+ });
+
+ it('accepts a datetime with negative offset', () => {
+ expect(isIsoDate('2026-06-15T00:00:00-08:00')).toBe(true);
+ });
+
+ it('accepts January 31 (31 days)', () => {
+ expect(isIsoDate('2026-01-31')).toBe(true);
+ });
+
+ it('accepts March 31 (31 days)', () => {
+ expect(isIsoDate('2026-03-31')).toBe(true);
+ });
+
+ it('accepts April 30 (30 days)', () => {
+ expect(isIsoDate('2026-04-30')).toBe(true);
+ });
+
+ it('accepts February 28 in a non-leap year', () => {
+ expect(isIsoDate('2026-02-28')).toBe(true);
+ });
+
+ it('accepts February 29 in a leap year', () => {
+ expect(isIsoDate('2024-02-29')).toBe(true);
+ });
+
+ it('accepts the first day of every month', () => {
+ for (let m = 1; m <= 12; m++) {
+ const s = `2026-${String(m).padStart(2, '0')}-01`;
+ expect(isIsoDate(s), s).toBe(true);
+ }
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Impossible calendar dates (structurally valid regex, but day out of range)
+ // -------------------------------------------------------------------------
+ describe('rejects impossible calendar dates', () => {
+ it('rejects February 29 in a non-leap year (2026-02-29)', () => {
+ expect(isIsoDate('2026-02-29')).toBe(false);
+ });
+
+ it('rejects February 30', () => {
+ expect(isIsoDate('2026-02-30')).toBe(false);
+ });
+
+ it('rejects February 31', () => {
+ expect(isIsoDate('2026-02-31')).toBe(false);
+ });
+
+ it('rejects April 31 (April has 30 days)', () => {
+ expect(isIsoDate('2026-04-31')).toBe(false);
+ });
+
+ it('rejects June 31', () => {
+ expect(isIsoDate('2026-06-31')).toBe(false);
+ });
+
+ it('rejects September 31', () => {
+ expect(isIsoDate('2026-09-31')).toBe(false);
+ });
+
+ it('rejects November 31', () => {
+ expect(isIsoDate('2026-11-31')).toBe(false);
+ });
+
+ it('rejects February 29 in a century non-leap year (1900-02-29)', () => {
+ expect(isIsoDate('1900-02-29')).toBe(false);
+ });
+
+ it('accepts February 29 in a 400-year multiple (2000-02-29)', () => {
+ expect(isIsoDate('2000-02-29')).toBe(true);
+ });
+
+ it('rejects Feb 31 with a time component', () => {
+ expect(isIsoDate('2026-02-31T12:00:00Z')).toBe(false);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // Structural / format rejections
+ // -------------------------------------------------------------------------
+ describe('rejects structurally invalid strings', () => {
+ it('rejects a missing zero-pad on month (2026-1-5)', () => {
+ expect(isIsoDate('2026-1-5')).toBe(false);
+ });
+
+ it('rejects a free-form date string ("August 14")', () => {
+ expect(isIsoDate('August 14')).toBe(false);
+ });
+
+ it('rejects a bare number ("1")', () => {
+ expect(isIsoDate('1')).toBe(false);
+ });
+
+ it('rejects an empty string', () => {
+ expect(isIsoDate('')).toBe(false);
+ });
+
+ it('rejects whitespace-only string', () => {
+ expect(isIsoDate(' ')).toBe(false);
+ });
+
+ it('rejects month 00', () => {
+ expect(isIsoDate('2026-00-15')).toBe(false);
+ });
+
+ it('rejects month 13', () => {
+ expect(isIsoDate('2026-13-01')).toBe(false);
+ });
+
+ it('rejects day 00', () => {
+ expect(isIsoDate('2026-01-00')).toBe(false);
+ });
+
+ it('rejects non-string inputs', () => {
+ expect(isIsoDate(null)).toBe(false);
+ expect(isIsoDate(undefined)).toBe(false);
+ expect(isIsoDate(20260615)).toBe(false);
+ expect(isIsoDate({})).toBe(false);
+ });
+ });
+});
diff --git a/src/data/blog-manifest.json b/src/data/blog-manifest.json
index 03ac7be..00b6a43 100644
--- a/src/data/blog-manifest.json
+++ b/src/data/blog-manifest.json
@@ -1,26 +1,26 @@
[
+ {
+ "slug": "stealth-addresses-explained",
+ "title": "How Stealth Addresses Keep Payments Private",
+ "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",
+ "url": "https://usewraith.xyz/blog/stealth-addresses-explained"
+ },
{
"slug": "wave-7-kickoff",
"title": "Wave 7 Kick-off + What We Shipped in Wave 6",
"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 Protocol Team",
+ "author": "wraith-team",
"url": "https://usewraith.xyz/blog/wave-7-kickoff"
},
- {
- "slug": "stealth-addresses-explained",
- "title": "Stealth addresses explained",
- "excerpt": "A straightforward introduction to stealth addresses and why they matter.",
- "date": "2026-07-22T12:00:00.000Z",
- "author": "Wraith Team",
- "url": "https://usewraith.xyz/blog/stealth-addresses-explained"
- },
{
"slug": "privacy-by-default",
"title": "Privacy by default",
"excerpt": "How Wraith makes private payments practical for everyday apps.",
- "date": "2026-07-20T12:00:00.000Z",
- "author": "Wraith Team",
+ "date": "2026-07-20",
+ "author": "Wraith Protocol Team",
"url": "https://usewraith.xyz/blog/privacy-by-default"
}
]
diff --git a/src/data/team.json b/src/data/team.json
index 6609d39..5e5da8d 100644
--- a/src/data/team.json
+++ b/src/data/team.json
@@ -52,7 +52,7 @@
{
"name": "code3ks",
"role": "Translator (Portuguese)",
- "github": "code3ks",
+ "github": "https://github.com/code3ks",
"contribution": "Added Portuguese (pt) locale and established translation contribution workflow"
},
{
diff --git a/src/utils/date.ts b/src/utils/date.ts
new file mode 100644
index 0000000..9e2f2c4
--- /dev/null
+++ b/src/utils/date.ts
@@ -0,0 +1,51 @@
+/**
+ * ISO 8601 date validation utilities.
+ *
+ * Exported from src/ so they can be imported by both application code and
+ * build-time scripts while remaining in the TypeScript compilation boundary
+ * and accessible to Vitest.
+ */
+
+/**
+ * Strict ISO 8601 date regex.
+ *
+ * Accepts:
+ * • Bare date: 2026-06-15
+ * • UTC datetime: 2026-06-15T14:22:00Z
+ * • Datetime + ms: 2026-06-15T14:22:00.000Z
+ * • Offset datetime: 2026-06-15T14:22:00+05:30
+ *
+ * Rejects anything new Date() would otherwise accept but is not a valid
+ * ISO 8601 string, e.g. "1", "August 14", "2026-1-5" (missing zero-pad).
+ */
+const ISO_DATE_RE =
+ /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d))?$/;
+
+/**
+ * Returns true only when `v` is a structurally valid ISO 8601 date string
+ * AND the year/month/day combination represents a calendar date that actually
+ * exists — e.g. `2026-02-31` and `2026-04-31` are rejected because those
+ * days do not exist in their respective months.
+ *
+ * The calendar check works by constructing a local Date with the extracted
+ * components and verifying that JavaScript's Date normalisation did not roll
+ * the values forward into the next month (which happens for out-of-range days
+ * such as Feb 31 → Mar 3).
+ */
+export function isIsoDate(v: unknown): boolean {
+ if (typeof v !== 'string' || v.trim().length === 0) return false;
+
+ const s = v.trim();
+ if (!ISO_DATE_RE.test(s)) return false;
+
+ // Extract the date portion (first 10 chars are always YYYY-MM-DD after the
+ // regex passes).
+ const year = parseInt(s.slice(0, 4), 10);
+ const month = parseInt(s.slice(5, 7), 10); // 1-based
+ const day = parseInt(s.slice(8, 10), 10);
+
+ // Construct a Date using local components and check for roll-over.
+ // month - 1 because Date constructor takes 0-based month.
+ const d = new Date(year, month - 1, day);
+ return d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day;
+}