spb/svgarden Public
SVGarden — searchable bank of 74 self-contained SVG+CSS animation snippets (svgarden.dev)
HTML 79.2%
Astro 10.3%
JavaScript 6%
CSS 3.7%
Shell 0.8%
1/**2 * ============================================================3 * SVGarden — https://www.svgarden.dev4 * Author : Simon-Pierre Boucher5 * Contact: contact@spboucher.ai6 * File : scripts/validate-snippets.mjs7 * Desc : CI gate — validates headers, frontmatter, scoping and self-containment8 * ============================================================9 */10import { readFile, readdir } from 'node:fs/promises';11import path from 'node:path';12import {13 SNIPPETS_DIR,14 CATEGORIES,15 DIFFICULTIES,16 parseSnippetSource,17} from '../src/lib/snippets.mjs';1819// Raised from 120 → 150 for the phase-2 "next-level" batch (user directive20// 2026-08-10, overrides CLAUDE.md §5's ~120 — conflict flagged in that session).21const MAX_LINES = 150;22const errors = [];23const fail = (msg) => errors.push(msg);2425const files = [];26for (const dirent of await readdir(SNIPPETS_DIR, { withFileTypes: true })) {27 if (!dirent.isDirectory()) continue;28 for (const f of await readdir(path.join(SNIPPETS_DIR, dirent.name))) {29 if (f.endsWith('.html')) files.push(path.join(dirent.name, f));30 }31}32files.sort();3334if (files.length === 0) {35 console.error('validate-snippets: no snippet files found under snippets/');36 process.exit(1);37}3839const slugs = new Set();4041for (const rel of files) {42 const relPath = path.posix.join('snippets', ...rel.split(path.sep));43 const source = await readFile(path.join(SNIPPETS_DIR, rel), 'utf8');4445 let s;46 try {47 s = parseSnippetSource(source, relPath);48 } catch (err) {49 fail(err.message);50 continue;51 }5253 // Slug ↔ filename ↔ directory coherence54 const base = path.basename(rel, '.html');55 const dir = rel.split(path.sep)[0];56 if (s.slug !== base) fail(`${relPath}: slug "${s.slug}" ≠ filename "${base}"`);57 if (s.category !== dir) fail(`${relPath}: category "${s.category}" ≠ directory "${dir}"`);58 if (!CATEGORIES.includes(s.category)) fail(`${relPath}: unknown category "${s.category}"`);59 if (!DIFFICULTIES.includes(s.difficulty)) fail(`${relPath}: difficulty must be one of ${DIFFICULTIES.join('/')}`);60 if (slugs.has(s.slug)) fail(`${relPath}: duplicate slug "${s.slug}"`);61 slugs.add(s.slug);6263 // Header must reference its own path64 const headerFile = source.match(/File {3}: (.+)/)?.[1]?.trim();65 if (headerFile !== relPath) fail(`${relPath}: header File line says "${headerFile}"`);6667 // Size limit — elegance over bloat68 const codeLines = s.code.split('\n').length;69 if (codeLines > MAX_LINES) fail(`${relPath}: snippet code is ${codeLines} lines (max ${MAX_LINES})`);7071 // Self-containment: no external assets of any kind72 if (/\b(?:src|href)\s*=\s*["']https?:/i.test(s.code)) fail(`${relPath}: external src/href — snippets must be self-contained`);73 if (/url\(\s*["']?https?:/i.test(s.code)) fail(`${relPath}: external url() — snippets must be self-contained`);74 if (/@import/i.test(s.code)) fail(`${relPath}: @import is forbidden`);75 if (/<link\b/i.test(s.code)) fail(`${relPath}: <link> is forbidden inside snippets`);7677 // Scoping: every class used in markup or CSS must be sg- prefixed78 for (const m of s.code.matchAll(/class\s*=\s*["']([^"']+)["']/g)) {79 for (const cls of m[1].split(/\s+/).filter(Boolean)) {80 if (!cls.startsWith('sg-')) fail(`${relPath}: class "${cls}" is not sg- prefixed`);81 }82 }83 const styleBlocks = [...s.code.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');84 for (const m of styleBlocks.matchAll(/\.((?!sg-)[a-zA-Z_][\w-]*)/g)) {85 // Ignore decimals like ".5s" (matcher already excludes digits at start)86 fail(`${relPath}: CSS selector ".${m[1]}" is not sg- prefixed`);87 }8889 // Custom properties: every customizable var must be declared on the root element90 const rootStyle = s.code.match(/<[a-z][^>]*style\s*=\s*["']([^"']*)["']/i)?.[1] ?? '';91 for (const c of s.customizable) {92 if (!c.var || !c.label || !c.type) {93 fail(`${relPath}: customizable entries need var/label/type`);94 continue;95 }96 if (!c.var.startsWith('--sg-')) fail(`${relPath}: customizable var "${c.var}" must start with --sg-`);97 if (!rootStyle.includes(c.var)) fail(`${relPath}: "${c.var}" not declared in the root element style attribute`);98 if (c.default === undefined) fail(`${relPath}: "${c.var}" has no default`);99 if (!['color', 'range'].includes(c.type)) fail(`${relPath}: "${c.var}" type must be color or range`);100 }101102 // JS snippets must carry the js tag103 const hasScript = /<script/i.test(s.code);104 if (hasScript && !s.tags.includes('js')) fail(`${relPath}: contains <script> but lacks the "js" tag`);105 if (!hasScript && s.tags.includes('js')) fail(`${relPath}: tagged "js" but contains no <script>`);106107 // Accessibility: every svg needs aria-hidden or role="img"108 for (const m of s.code.matchAll(/<svg\b[^>]*>/g)) {109 if (!/aria-hidden\s*=\s*["']true["']/.test(m[0]) && !/role\s*=\s*["']img["']/.test(m[0])) {110 fail(`${relPath}: <svg> needs aria-hidden="true" (decorative) or role="img" + <title>`);111 }112 }113114 // Pedagogy: how_it_works must genuinely explain (2–5 sentences ≈ length bounds)115 if (s.howItWorks.length < 120) fail(`${relPath}: how_it_works too short to teach anything (${s.howItWorks.length} chars)`);116117 // support (optional): must be a real caveat sentence when present, and any118 // @supports-guarded feature should declare one119 if (s.support !== null && s.support.length < 15) fail(`${relPath}: support field present but too short to inform`);120 if (/@supports/.test(s.code) && !s.support) fail(`${relPath}: uses @supports but frontmatter has no support caveat`);121}122123if (errors.length) {124 console.error(`✖ validate-snippets: ${errors.length} problem(s)\n`);125 for (const e of errors) console.error(` • ${e}`);126 process.exit(1);127}128129console.log(`✔ validate-snippets: ${files.length} snippets valid (headers, frontmatter, scoping, self-containment)`);130