/** Validates every content record: schema, uniqueness, taxonomy, cross-references, editorial rules. Exit 1 on errors. */ import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { toolRecordSchema, categorySchema, collectionSchema, ownerRelevanceSchema, WORD } from "../src/lib/content/schema"; const root = join(import.meta.dirname, "..", "content"); const errors: string[] = []; const warnings: string[] = []; const err = (f: string, m: string) => errors.push(`${f}: ${m}`); const warn = (f: string, m: string) => warnings.push(`${f}: ${m}`); const cats = JSON.parse(readFileSync(join(root, "categories.json"), "utf8")); const catSlugs = new Set(); for (const c of cats) { categorySchema.parse(c); catSlugs.add(c.slug); for (const ch of c.children ?? []) catSlugs.add(ch.slug); } const leafSlugs = new Set(); for (const c of cats) for (const ch of c.children ?? []) leafSlugs.add(ch.slug); const readDir = (d: string) => { try { return readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => join(d, f)); } catch { return []; } }; const files = [...readDir(join(root, "tools")), ...readDir(join(root, "private"))]; const slugs = new Map(); const names = new Map(); const homepages = new Map(); const BANNED = /\b(powerful|seamless(ly)?|revolutionary|best-in-class|cutting-edge|leverag(e|es|ing)|game-changing|world-class)\b/i; const PRICE = /(\$|€|£|USD|CAD)\s?\d|\d\s?(\$|€|£)|\bper (seat|user|month|year)\b/i; const CERT = /\b(SOC ?2|ISO ?27001|HIPAA|GDPR[- ]compliant|FedRAMP)\b/i; let deep = 0, pub = 0; for (const f of files) { const short = f.replace(root + "/", ""); let raw: any; try { raw = JSON.parse(readFileSync(f, "utf8")); } catch (e) { err(short, `invalid JSON: ${(e as Error).message}`); continue; } const parsed = toolRecordSchema.safeParse(raw); if (!parsed.success) { for (const i of parsed.error.issues) err(short, `${i.path.join(".")}: ${i.message}`); continue; } const r = parsed.data; if (short.endsWith(`/${r.slug}.json`) === false) err(short, `file name must be ${r.slug}.json`); if (slugs.has(r.slug)) err(short, `duplicate slug (also in ${slugs.get(r.slug)})`); slugs.set(r.slug, short); 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); 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); if (short.startsWith("private/") && r.visibility !== "private") err(short, "records in content/private must have visibility=private"); if (short.startsWith("tools/") && r.visibility !== "public") err(short, "records in content/tools must have visibility=public"); 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`); } if (r.categories.filter((c) => c.primary).length !== 1) err(short, "exactly one primary category required"); const words = WORD(r.description); 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++; } 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"); } if (!r.sources.some((s) => s.type === "official_site")) err(short, "an official_site source is required"); 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"); if (r.pricing_model.some((p) => ["paid", "freemium", "enterprise", "usage-based"].includes(p)) && !r.pricing_url) warn(short, "commercial pricing model without pricing_url"); 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"); const prose = [r.summary, r.description, r.comparison_notes ?? ""].join("\n"); const m = prose.match(BANNED); if (m) warn(short, `marketing word: "${m[0]}"`); const pm = prose.match(PRICE); if (pm) err(short, `price figure in prose: "${pm[0]}"`); 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`); for (const s of r.sources) if (s.checked_at < "2026-10-01") warn(short, `stale checked_at ${s.checked_at} for ${s.url}`); if (r.visibility === "public" && r.editorial_status !== "draft") pub++; } // cross refs const all = new Set(slugs.keys()); for (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 {} } for (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}`); } for (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}`); } } console.log(`records: ${files.length} (public non-draft: ${pub}, deep: ${deep}) — errors: ${errors.length}, warnings: ${warnings.length}`); for (const w of warnings) console.log("WARN", w); for (const e of errors) console.log("ERR ", e); if (process.argv.includes("--json")) console.log(JSON.stringify({ records: files.length, published: pub, deep, errors: errors.length, warnings: warnings.length })); process.exit(errors.length ? 1 : 0);