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 : 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