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.8 KB · 177 lines javascript
Raw Blame History
1/**2 * ============================================================3 * SVGarden — https://www.svgarden.dev4 * Author : Simon-Pierre Boucher5 * Contact: contact@spboucher.ai6 * File   : src/lib/snippets.mjs7 * Desc   : Build-time snippet bank loader — parses snippets/** frontmatter8 * ============================================================9 */10import { readFile, readdir } from 'node:fs/promises';11import path from 'node:path';12import { fileURLToPath } from 'node:url';13import YAML from 'yaml';1415const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');16export const SNIPPETS_DIR = path.join(ROOT, 'snippets');1718export const CATEGORIES = [19  'loaders',20  'stroke-draw',21  'hover',22  'gauges',23  'text',24  'text-fx',25  'morph',26  'motion-path',27  'filters',28  'scroll',29  'charts',30  'interactive',31  'backgrounds',32  'buttons',33];3435export const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];3637const AUTHOR_HEADER_RE =38  /^<!--\s*\n\s*=+\s*\n\s*SVGarden — https:\/\/www\.svgarden\.dev\s*\n\s*Author : Simon-Pierre Boucher\s*\n\s*Contact: contact@spboucher\.ai\s*\n\s*File {3}: (.+)\s*\n\s*Desc {3}: (.+)\s*\n\s*=+\s*\n-->/;3940const FRONTMATTER_RE = /<!--svgarden\n([\s\S]*?)\n-->/;4142/** Builds the attribution header that travels with every copied snippet. */43export function attributionHeader(relPath, desc) {44  return [45    '<!--',46    '  ============================================================',47    '  SVGarden — https://www.svgarden.dev',48    '  Author : Simon-Pierre Boucher',49    '  Contact: contact@spboucher.ai',50    `  File   : ${relPath}`,51    `  Desc   : ${desc}`,52    '  ============================================================',53    '-->',54  ].join('\n');55}5657/**58 * Parses one snippet file into its metadata + raw code.59 * Throws with a descriptive message on any structural violation,60 * so both the build and validate-snippets.mjs fail loudly.61 */62export function parseSnippetSource(source, relPath) {63  const headerMatch = source.match(AUTHOR_HEADER_RE);64  if (!headerMatch) {65    throw new Error(`${relPath}: missing or malformed SVGarden author header`);66  }67  const desc = headerMatch[2].trim();6869  const fmMatch = source.match(FRONTMATTER_RE);70  if (!fmMatch) {71    throw new Error(`${relPath}: missing <!--svgarden ...--> frontmatter block`);72  }7374  let meta;75  try {76    meta = YAML.parse(fmMatch[1]);77  } catch (err) {78    throw new Error(`${relPath}: invalid YAML frontmatter — ${err.message}`);79  }8081  for (const field of ['title', 'slug', 'category', 'tags', 'difficulty', 'techniques', 'how_it_works', 'created']) {82    if (meta[field] === undefined || meta[field] === null || meta[field] === '') {83      throw new Error(`${relPath}: frontmatter missing required field "${field}"`);84    }85  }8687  const code = source.slice(source.indexOf(fmMatch[0]) + fmMatch[0].length).trim();88  if (!code) {89    throw new Error(`${relPath}: no snippet code after frontmatter`);90  }9192  const customizable = Array.isArray(meta.customizable) ? meta.customizable : [];9394  return {95    title: String(meta.title),96    slug: String(meta.slug),97    category: String(meta.category),98    tags: (meta.tags ?? []).map(String),99    difficulty: String(meta.difficulty),100    techniques: (meta.techniques ?? []).map(String),101    howItWorks: String(meta.how_it_works).trim(),102    customizable,103    created: String(meta.created),104    support: meta.support ? String(meta.support).trim() : null,105    desc,106    relPath,107    code,108    header: attributionHeader(relPath, desc),109  };110}111112/** The full snippet text a visitor gets from Copy / Download. */113export function copyText(snippet, code = snippet.code) {114  return `${snippet.header}\n${code}\n`;115}116117/** Loads, parses and sorts the whole bank. Cached per process (one build). */118let cache = null;119export async function loadSnippets() {120  if (cache) return cache;121  const files = [];122  for (const dirent of await readdir(SNIPPETS_DIR, { withFileTypes: true })) {123    if (!dirent.isDirectory()) continue;124    for (const f of await readdir(path.join(SNIPPETS_DIR, dirent.name))) {125      if (f.endsWith('.html')) files.push(path.join(dirent.name, f));126    }127  }128  files.sort();129130  const snippets = [];131  const slugs = new Set();132  for (const rel of files) {133    const relPath = path.posix.join('snippets', ...rel.split(path.sep));134    const source = await readFile(path.join(SNIPPETS_DIR, rel), 'utf8');135    const snippet = parseSnippetSource(source, relPath);136    const dirCategory = rel.split(path.sep)[0];137    if (snippet.category !== dirCategory) {138      throw new Error(`${relPath}: frontmatter category "${snippet.category}" ≠ directory "${dirCategory}"`);139    }140    if (slugs.has(snippet.slug)) {141      throw new Error(`${relPath}: duplicate slug "${snippet.slug}"`);142    }143    slugs.add(snippet.slug);144    snippets.push(snippet);145  }146147  snippets.sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title));148  cache = snippets;149  return snippets;150}151152/** Categories that actually contain snippets, with counts. */153export async function loadCategories() {154  const snippets = await loadSnippets();155  const counts = new Map();156  for (const s of snippets) counts.set(s.category, (counts.get(s.category) ?? 0) + 1);157  return CATEGORIES.filter((c) => counts.has(c)).map((c) => ({ name: c, count: counts.get(c) }));158}159160/** Standalone HTML document used inside the sandboxed preview iframes. */161export function previewDocument(snippet, { padding = '1rem' } = {}) {162  return `<!DOCTYPE html>163<html lang="en">164<head>165<meta charset="utf-8">166<meta name="viewport" content="width=device-width, initial-scale=1">167<style>168  html, body { margin: 0; height: 100%; }169  body { display: grid; place-items: center; padding: ${padding}; box-sizing: border-box; overflow: hidden; background: var(--sg-preview-bg, #ffffff); transition: background .2s ease; }170</style>171</head>172<body>173${snippet.code}174</body>175</html>`;176}177