/**
* ============================================================
* SVGarden — https://www.svgarden.dev
* Author : Simon-Pierre Boucher
* Contact: contact@spboucher.ai
* File : scripts/validate-snippets.mjs
* Desc : CI gate — validates headers, frontmatter, scoping and self-containment
* ============================================================
*/
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import {
SNIPPETS_DIR,
CATEGORIES,
DIFFICULTIES,
parseSnippetSource,
} from '../src/lib/snippets.mjs';
// Raised from 120 → 150 for the phase-2 "next-level" batch (user directive
// 2026-08-10, overrides CLAUDE.md §5's ~120 — conflict flagged in that session).
const MAX_LINES = 150;
const errors = [];
const fail = (msg) => errors.push(msg);
const files = [];
for (const dirent of await readdir(SNIPPETS_DIR, { withFileTypes: true })) {
if (!dirent.isDirectory()) continue;
for (const f of await readdir(path.join(SNIPPETS_DIR, dirent.name))) {
if (f.endsWith('.html')) files.push(path.join(dirent.name, f));
}
}
files.sort();
if (files.length === 0) {
console.error('validate-snippets: no snippet files found under snippets/');
process.exit(1);
}
const slugs = new Set();
for (const rel of files) {
const relPath = path.posix.join('snippets', ...rel.split(path.sep));
const source = await readFile(path.join(SNIPPETS_DIR, rel), 'utf8');
let s;
try {
s = parseSnippetSource(source, relPath);
} catch (err) {
fail(err.message);
continue;
}
// Slug ↔ filename ↔ directory coherence
const base = path.basename(rel, '.html');
const dir = rel.split(path.sep)[0];
if (s.slug !== base) fail(`${relPath}: slug "${s.slug}" ≠ filename "${base}"`);
if (s.category !== dir) fail(`${relPath}: category "${s.category}" ≠ directory "${dir}"`);
if (!CATEGORIES.includes(s.category)) fail(`${relPath}: unknown category "${s.category}"`);
if (!DIFFICULTIES.includes(s.difficulty)) fail(`${relPath}: difficulty must be one of ${DIFFICULTIES.join('/')}`);
if (slugs.has(s.slug)) fail(`${relPath}: duplicate slug "${s.slug}"`);
slugs.add(s.slug);
// Header must reference its own path
const headerFile = source.match(/File {3}: (.+)/)?.[1]?.trim();
if (headerFile !== relPath) fail(`${relPath}: header File line says "${headerFile}"`);
// Size limit — elegance over bloat
const codeLines = s.code.split('\n').length;
if (codeLines > MAX_LINES) fail(`${relPath}: snippet code is ${codeLines} lines (max ${MAX_LINES})`);
// Self-containment: no external assets of any kind
if (/\b(?:src|href)\s*=\s*["']https?:/i.test(s.code)) fail(`${relPath}: external src/href — snippets must be self-contained`);
if (/url\(\s*["']?https?:/i.test(s.code)) fail(`${relPath}: external url() — snippets must be self-contained`);
if (/@import/i.test(s.code)) fail(`${relPath}: @import is forbidden`);
if (/ is forbidden inside snippets`);
// Scoping: every class used in markup or CSS must be sg- prefixed
for (const m of s.code.matchAll(/class\s*=\s*["']([^"']+)["']/g)) {
for (const cls of m[1].split(/\s+/).filter(Boolean)) {
if (!cls.startsWith('sg-')) fail(`${relPath}: class "${cls}" is not sg- prefixed`);
}
}
const styleBlocks = [...s.code.matchAll(/