spb/tools-spboucher-ai
Public
1/** Validates every content record: schema, uniqueness, taxonomy, cross-references, editorial rules. Exit 1 on errors. */2import { readFileSync, readdirSync } from "node:fs";3import { join } from "node:path";4import { toolRecordSchema, categorySchema, collectionSchema, ownerRelevanceSchema, WORD } from "../src/lib/content/schema";56const root = join(import.meta.dirname, "..", "content");7const errors: string[] = []; const warnings: string[] = [];8const err = (f: string, m: string) => errors.push(`${f}: ${m}`);9const warn = (f: string, m: string) => warnings.push(`${f}: ${m}`);1011const cats = JSON.parse(readFileSync(join(root, "categories.json"), "utf8"));12const catSlugs = new Set<string>();13for (const c of cats) { categorySchema.parse(c); catSlugs.add(c.slug); for (const ch of c.children ?? []) catSlugs.add(ch.slug); }14const leafSlugs = new Set<string>(); for (const c of cats) for (const ch of c.children ?? []) leafSlugs.add(ch.slug);1516const readDir = (d: string) => { try { return readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)); } catch { return []; } };17const files = [...readDir(join(root, "tools")), ...readDir(join(root, "private"))];18const slugs = new Map<string, string>(); const names = new Map<string, string>(); const homepages = new Map<string, string>();19const BANNED = /\b(powerful|seamless(ly)?|revolutionary|best-in-class|cutting-edge|leverag(e|es|ing)|game-changing|world-class)\b/i;20const PRICE = /(\$|€|£|USD|CAD)\s?\d|\d\s?(\$|€|£)|\bper (seat|user|month|year)\b/i;21const CERT = /\b(SOC ?2|ISO ?27001|HIPAA|GDPR[- ]compliant|FedRAMP)\b/i;22let deep = 0, pub = 0;23for (const f of files) {24 const short = f.replace(root + "/", "");25 let raw: any; try { raw = JSON.parse(readFileSync(f, "utf8")); } catch (e) { err(short, `invalid JSON: ${(e as Error).message}`); continue; }26 const parsed = toolRecordSchema.safeParse(raw);27 if (!parsed.success) { for (const i of parsed.error.issues) err(short, `${i.path.join(".")}: ${i.message}`); continue; }28 const r = parsed.data;29 if (short.endsWith(`/${r.slug}.json`) === false) err(short, `file name must be ${r.slug}.json`);30 if (slugs.has(r.slug)) err(short, `duplicate slug (also in ${slugs.get(r.slug)})`); slugs.set(r.slug, short);31 const nk = r.name.toLowerCase().replace(/[^a-z0-9]/g, ""); if (names.has(nk)) err(short, `duplicate name ${r.name} (also ${names.get(nk)})`); names.set(nk, short);32 const hk = r.homepage_url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/+$/, "").toLowerCase(); if (homepages.has(hk)) warn(short, `same homepage as ${homepages.get(hk)}`); homepages.set(hk, short);33 if (short.startsWith("private/") && r.visibility !== "private") err(short, "records in content/private must have visibility=private");34 if (short.startsWith("tools/") && r.visibility !== "public") err(short, "records in content/tools must have visibility=public");35 for (const c of r.categories) { if (!catSlugs.has(c.slug)) err(short, `unknown category ${c.slug}`); else if (!leafSlugs.has(c.slug)) warn(short, `category ${c.slug} is a top-level group; prefer a leaf`); }36 if (r.categories.filter((c) => c.primary).length !== 1) err(short, "exactly one primary category required");37 const words = WORD(r.description);38 if (r.depth === "deep") { if (words < 100 || words > 330) err(short, `deep description must be 100–300 words (got ${words})`); if (r.sources.length < 3) err(short, "deep records need ≥3 sources"); if (r.alternatives.length < 2) err(short, "deep records need ≥2 alternatives"); if (r.capabilities.length < 4) err(short, "deep records need ≥4 capabilities"); if (!r.comparison_notes) err(short, "deep records need comparison_notes"); if (r.limitations.length < 2) err(short, "deep records need ≥2 limitations"); deep++; }39 else { if (words < 55) err(short, `description must be ≥60 words (got ${words})`); if (r.sources.length < 2 && r.visibility === "public") err(short, "standard records need ≥2 sources"); }40 if (!r.sources.some((s) => s.type === "official_site")) err(short, "an official_site source is required");41 if (r.license_spdx && !r.sources.some((s) => s.supports.includes("license_spdx") || s.type === "license_page" || s.type === "repository")) warn(short, "license_spdx set without a license/repository source");42 if (r.pricing_model.some((p) => ["paid", "freemium", "enterprise", "usage-based"].includes(p)) && !r.pricing_url) warn(short, "commercial pricing model without pricing_url");43 if (r.privacy_posture && !r.sources.some((s) => s.supports.includes("privacy_posture") || s.type === "policy_page")) err(short, "privacy_posture requires a policy_page source");44 const prose = [r.summary, r.description, r.comparison_notes ?? ""].join("\n");45 const m = prose.match(BANNED); if (m) warn(short, `marketing word: "${m[0]}"`);46 const pm = prose.match(PRICE); if (pm) err(short, `price figure in prose: "${pm[0]}"`);47 const cm = prose.match(CERT); if (cm && !r.sources.some((s) => s.type === "policy_page")) err(short, `certification claim "${cm[0]}" without policy_page source`);48 for (const s of r.sources) if (s.checked_at < "2026-10-01") warn(short, `stale checked_at ${s.checked_at} for ${s.url}`);49 if (r.visibility === "public" && r.editorial_status !== "draft") pub++;50}51// cross refs52const all = new Set(slugs.keys());53for (const f of files) { try { const r = JSON.parse(readFileSync(f, "utf8")); for (const a of r.alternatives ?? []) if (!all.has(a)) warn(f.replace(root + "/", ""), `alternative "${a}" is not a known record`); } catch {} }54for (const f of readDir(join(root, "collections"))) { const c = collectionSchema.safeParse(JSON.parse(readFileSync(f, "utf8"))); if (!c.success) { for (const i of c.error.issues) err(f, `${i.path.join(".")}: ${i.message}`); continue; } for (const it of c.data.items) if (!all.has(it.tool)) err(f, `unknown tool ${it.tool}`); }55for (const f of readDir(join(root, "owner")).filter((f) => !f.endsWith("projects.json"))) { for (const row of JSON.parse(readFileSync(f, "utf8"))) { const o = ownerRelevanceSchema.safeParse(row); if (!o.success) err(f, `${row.tool}: ${o.error.issues.map((i) => i.message).join("; ")}`); else if (!all.has(o.data.tool)) err(f, `unknown tool ${o.data.tool}`); } }5657console.log(`records: ${files.length} (public non-draft: ${pub}, deep: ${deep}) — errors: ${errors.length}, warnings: ${warnings.length}`);58for (const w of warnings) console.log("WARN", w);59for (const e of errors) console.log("ERR ", e);60if (process.argv.includes("--json")) console.log(JSON.stringify({ records: files.length, published: pub, deep, errors: errors.length, warnings: warnings.length }));61process.exit(errors.length ? 1 : 0);62