SPB Git

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%
5.7 KB · 130 lines javascript
Raw Blame History
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