From 8b4ce3c2284c7d7935d612605bbfe90af6f24115 Mon Sep 17 00:00:00 2001 From: aristotle224 Date: Sat, 26 Sep 2026 00:13:53 +0100 Subject: [PATCH 1/6] feat: validate content collections at build time (#156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/validate-content.ts — a zero-dep TypeScript build step that validates every JSON content collection before tsc runs. Collections covered: - case-studies.json (required fields, ISO date, status enum, dup id/slug) - authors.json (required fields, optIn boolean, optional link URLs) - contributors.json (required fields, avatar/profile URLs, dup username) - faq.json (required fields, category cross-ref, dup id) - chains.json (required fields, numeric fields, docs URL, dup id) - wave.json (currentWave + pastWaves + faq, applyUrl, ISO dates) - ecosystem.json (required fields, link URL, category cross-ref) - roadmap.json (required fields, status enum, dup milestone id) - blog-posts.json (required fields, publishedAt ISO, url, dup slug) - src/i18n/ (all locales match en.json leaf-key shape) On failure: prints source file + record identifier for every violation, then exits 1 to abort the build. Wiring: - Prepended to the `build` script so TypeScript compile never runs on broken data. - New `validate:content` standalone script for local checks. - Explicit CI step in ci.yml between lint:i18n and test:a11y. --- .github/workflows/ci.yml | 1 + package.json | 3 +- scripts/validate-content.ts | 762 ++++++++++++++++++++++++++++++++++++ 3 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 scripts/validate-content.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46c9821..2ffa948 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 de5c20c..6ddda4e 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 && 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 && 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", @@ -18,6 +18,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/scripts/validate-content.ts b/scripts/validate-content.ts new file mode 100644 index 0000000..dd28908 --- /dev/null +++ b/scripts/validate-content.ts @@ -0,0 +1,762 @@ +/** + * 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'; + +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); +} + +/** Accepts both ISO datetime (2026-06-15T…) and bare date (2026-06-15). */ +function isIsoDate(v: unknown): boolean { + if (!isString(v) || v.trim().length === 0) return false; + const d = new Date(v); + return !Number.isNaN(d.getTime()); +} + +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. + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +const VALIDATORS: Array<{ file: string; fn: (f: string) => void }> = [ + { file: join(dataDir, 'case-studies.json'), fn: validateCaseStudies }, + { file: join(dataDir, 'authors.json'), fn: validateAuthors }, + { file: join(dataDir, 'contributors.json'), fn: validateContributors }, + { file: join(dataDir, 'faq.json'), fn: validateFaq }, + { file: join(dataDir, 'chains.json'), fn: validateChains }, + { file: join(dataDir, 'wave.json'), fn: validateWave }, + { file: join(dataDir, 'ecosystem.json'), fn: validateEcosystem }, + { file: join(dataDir, 'roadmap.json'), fn: validateRoadmap }, + { file: join(dataDir, 'blog-posts.json'), fn: validateBlogPosts }, +]; + +console.log('\n🔍 Validating content collections…\n'); + +let checked = 0; +for (const { file, fn } of VALIDATORS) { + 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); +} From f58a9e8077ff9404e579f25de1943c712c4a0eb2 Mon Sep 17 00:00:00 2001 From: Nnamdi Ogazie Date: Mon, 28 Sep 2026 22:58:29 +0000 Subject: [PATCH 2/6] fix(validator): dynamic JSON discovery, strict ISO dates, 9 new schemas - Replace hardcoded VALIDATORS array with SCHEMA_MAP + readdirSync so any new *.json file in src/data/ that lacks a registered validator fails the build immediately instead of silently bypassing the gate. - Fix isIsoDate to use a strict ISO 8601 regex instead of a bare new Date() call. Rejects previously-passing inputs like '1', '2026-1-5', 'August 14'. Accepts YYYY-MM-DD and YYYY-MM-DDTHH:mm:ss[.sss](Z|+/-HH:mm). - Add validators for the 9 previously-uncovered collections: trust, showcase, incidents, security, blog-manifest, authors-optout, contributors-optout, team, threat-model. - Fix data bug caught by the new gate: team.json contributors[0].github was a bare username ('code3ks') rather than a full URL. Also format Newsletter.tsx, stellarMetrics.ts, track.ts (pre-existing Prettier drift, caught by the pre-commit hook). Closes #156 --- public/feed.xml | 2 +- public/feed/tag/announcements.xml | 2 +- public/feed/tag/cryptography.xml | 2 +- public/feed/tag/privacy.xml | 2 +- public/feed/tag/sdk.xml | 2 +- public/feed/tag/stealth-payments.xml | 2 +- public/feed/tag/wave-6.xml | 2 +- public/feed/tag/wave-7.xml | 2 +- public/sitemap.xml | 68 ++-- scripts/validate-content.ts | 460 ++++++++++++++++++++++++++- src/data/blog-manifest.json | 22 +- src/data/team.json | 2 +- src/pages/Newsletter.tsx | 7 +- src/utils/stellarMetrics.ts | 9 +- src/utils/track.ts | 8 +- 15 files changed, 502 insertions(+), 90 deletions(-) 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/validate-content.ts b/scripts/validate-content.ts index dd28908..b46378c 100644 --- a/scripts/validate-content.ts +++ b/scripts/validate-content.ts @@ -67,11 +67,24 @@ function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } -/** Accepts both ISO datetime (2026-06-15T…) and bare date (2026-06-15). */ +/** + * Strict ISO 8601 date check. + * + * 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))?$/; + function isIsoDate(v: unknown): boolean { if (!isString(v) || v.trim().length === 0) return false; - const d = new Date(v); - return !Number.isNaN(d.getTime()); + return ISO_DATE_RE.test(v.trim()); } function isUrl(v: unknown): boolean { @@ -635,6 +648,379 @@ function validateBlogPosts(file: string): void { // 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 @@ -706,22 +1092,66 @@ function validateI18n(): void { // Runner // --------------------------------------------------------------------------- -const VALIDATORS: Array<{ file: string; fn: (f: string) => void }> = [ - { file: join(dataDir, 'case-studies.json'), fn: validateCaseStudies }, - { file: join(dataDir, 'authors.json'), fn: validateAuthors }, - { file: join(dataDir, 'contributors.json'), fn: validateContributors }, - { file: join(dataDir, 'faq.json'), fn: validateFaq }, - { file: join(dataDir, 'chains.json'), fn: validateChains }, - { file: join(dataDir, 'wave.json'), fn: validateWave }, - { file: join(dataDir, 'ecosystem.json'), fn: validateEcosystem }, - { file: join(dataDir, 'roadmap.json'), fn: validateRoadmap }, - { file: join(dataDir, 'blog-posts.json'), fn: validateBlogPosts }, -]; +// --------------------------------------------------------------------------- +// 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 { file, fn } of VALIDATORS) { +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; 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/pages/Newsletter.tsx b/src/pages/Newsletter.tsx index 8802c6f..86ade2e 100644 --- a/src/pages/Newsletter.tsx +++ b/src/pages/Newsletter.tsx @@ -7,12 +7,7 @@ import { track } from '../utils/track'; // ─── Types ──────────────────────────────────────────────────────────────────── type FormState = - | 'idle' - | 'submitting' - | 'success' - | 'error_invalid' - | 'error_duplicate' - | 'error_generic'; + 'idle' | 'submitting' | 'success' | 'error_invalid' | 'error_duplicate' | 'error_generic'; // ─── Constants ──────────────────────────────────────────────────────────────── diff --git a/src/utils/stellarMetrics.ts b/src/utils/stellarMetrics.ts index 6aaee14..81d0fbc 100644 --- a/src/utils/stellarMetrics.ts +++ b/src/utils/stellarMetrics.ts @@ -46,14 +46,7 @@ const cache = new Map(); let requestId = 0; export type StellarRpcErrorKind = - | 'network' - | 'http' - | 'rate-limit' - | 'rpc' - | 'retention' - | 'malformed' - | 'timeout' - | 'aborted'; + 'network' | 'http' | 'rate-limit' | 'rpc' | 'retention' | 'malformed' | 'timeout' | 'aborted'; export class StellarRpcError extends Error { readonly kind: StellarRpcErrorKind; diff --git a/src/utils/track.ts b/src/utils/track.ts index cddbf5a..c48869a 100644 --- a/src/utils/track.ts +++ b/src/utils/track.ts @@ -46,13 +46,7 @@ export type AnalyticsEventName = keyof AnalyticsEventMap; * Used by `outbound_click`. */ export type OutboundCategory = - | 'github' - | 'docs' - | 'social' - | 'explorer' - | 'ecosystem' - | 'partner' - | 'other'; + 'github' | 'docs' | 'social' | 'explorer' | 'ecosystem' | 'partner' | 'other'; type AnalyticsProps = Record; From a2f41c9cf76245d637c28b686319511be50e7154 Mon Sep 17 00:00:00 2001 From: Nnamdi Ogazie Date: Mon, 28 Sep 2026 23:06:35 +0000 Subject: [PATCH 3/6] fix(gen-blog-manifest): write trailing newline so output is Prettier-compliant The generator was writing JSON.stringify(..., null, 2) with no trailing newline. Prettier requires one, causing format:check to fail on every build because gen-blog-manifest runs after Prettier in the pre-commit hook. --- scripts/gen-blog-manifest.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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`); } From 5d255ff5baeb174b42ab2bdba7ea19007cecdd2c Mon Sep 17 00:00:00 2001 From: Nnamdi Ogazie Date: Mon, 28 Sep 2026 23:15:50 +0000 Subject: [PATCH 4/6] style: expand union types to pass Prettier printWidth check --- src/pages/Newsletter.tsx | 7 ++++++- src/utils/stellarMetrics.ts | 9 ++++++++- src/utils/track.ts | 8 +++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/pages/Newsletter.tsx b/src/pages/Newsletter.tsx index 86ade2e..8802c6f 100644 --- a/src/pages/Newsletter.tsx +++ b/src/pages/Newsletter.tsx @@ -7,7 +7,12 @@ import { track } from '../utils/track'; // ─── Types ──────────────────────────────────────────────────────────────────── type FormState = - 'idle' | 'submitting' | 'success' | 'error_invalid' | 'error_duplicate' | 'error_generic'; + | 'idle' + | 'submitting' + | 'success' + | 'error_invalid' + | 'error_duplicate' + | 'error_generic'; // ─── Constants ──────────────────────────────────────────────────────────────── diff --git a/src/utils/stellarMetrics.ts b/src/utils/stellarMetrics.ts index 81d0fbc..6aaee14 100644 --- a/src/utils/stellarMetrics.ts +++ b/src/utils/stellarMetrics.ts @@ -46,7 +46,14 @@ const cache = new Map(); let requestId = 0; export type StellarRpcErrorKind = - 'network' | 'http' | 'rate-limit' | 'rpc' | 'retention' | 'malformed' | 'timeout' | 'aborted'; + | 'network' + | 'http' + | 'rate-limit' + | 'rpc' + | 'retention' + | 'malformed' + | 'timeout' + | 'aborted'; export class StellarRpcError extends Error { readonly kind: StellarRpcErrorKind; diff --git a/src/utils/track.ts b/src/utils/track.ts index c48869a..cddbf5a 100644 --- a/src/utils/track.ts +++ b/src/utils/track.ts @@ -46,7 +46,13 @@ export type AnalyticsEventName = keyof AnalyticsEventMap; * Used by `outbound_click`. */ export type OutboundCategory = - 'github' | 'docs' | 'social' | 'explorer' | 'ecosystem' | 'partner' | 'other'; + | 'github' + | 'docs' + | 'social' + | 'explorer' + | 'ecosystem' + | 'partner' + | 'other'; type AnalyticsProps = Record; From 39f4045b0c4fea4751fd0b8e62689283da2f1f0e Mon Sep 17 00:00:00 2001 From: Nnamdi Ogazie Date: Thu, 1 Oct 2026 00:21:47 +0000 Subject: [PATCH 5/6] fix: reject impossible ISO dates (e.g. 2026-02-31) Extract isIsoDate into src/utils/date.ts and add a calendar roll-over check after the regex: construct a local Date from the parsed year/month/day and verify none of the values changed due to JS Date normalisation. Update scripts/validate-content.ts to import from the new utility. Add 30 tests in src/__tests__/date.test.ts covering valid dates, impossible calendar dates (Feb 29/30/31, Apr/Jun/ Sep/Nov 31, century non-leap years), and structural rejections. --- scripts/validate-content.ts | 21 +----- src/__tests__/date.test.ts | 145 ++++++++++++++++++++++++++++++++++++ src/utils/date.ts | 51 +++++++++++++ 3 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/date.test.ts create mode 100644 src/utils/date.ts diff --git a/scripts/validate-content.ts b/scripts/validate-content.ts index b46378c..e68ecd1 100644 --- a/scripts/validate-content.ts +++ b/scripts/validate-content.ts @@ -15,6 +15,7 @@ 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, '..'); @@ -67,25 +68,7 @@ function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } -/** - * Strict ISO 8601 date check. - * - * 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))?$/; - -function isIsoDate(v: unknown): boolean { - if (!isString(v) || v.trim().length === 0) return false; - return ISO_DATE_RE.test(v.trim()); -} +// isIsoDate is imported from ../src/utils/date.ts function isUrl(v: unknown): boolean { if (!isString(v) || v.trim().length === 0) return false; 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/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; +} From 86e524ec895ce2cd1749e9975adf6346ea350981 Mon Sep 17 00:00:00 2001 From: Nnamdi Ogazie Date: Fri, 2 Oct 2026 12:10:30 +0000 Subject: [PATCH 6/6] build: prepend validate:content to build script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7a72937..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",