/** * ============================================================ * SVGarden — https://www.svgarden.dev * Author : Simon-Pierre Boucher * Contact: contact@spboucher.ai * File : src/lib/snippets.mjs * Desc : Build-time snippet bank loader — parses snippets/** frontmatter * ============================================================ */ import { readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import YAML from 'yaml'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); export const SNIPPETS_DIR = path.join(ROOT, 'snippets'); export const CATEGORIES = [ 'loaders', 'stroke-draw', 'hover', 'gauges', 'text', 'text-fx', 'morph', 'motion-path', 'filters', 'scroll', 'charts', 'interactive', 'backgrounds', 'buttons', ]; export const DIFFICULTIES = ['beginner', 'intermediate', 'advanced']; const AUTHOR_HEADER_RE = /^/; const FRONTMATTER_RE = //; /** Builds the attribution header that travels with every copied snippet. */ export function attributionHeader(relPath, desc) { return [ '', ].join('\n'); } /** * Parses one snippet file into its metadata + raw code. * Throws with a descriptive message on any structural violation, * so both the build and validate-snippets.mjs fail loudly. */ export function parseSnippetSource(source, relPath) { const headerMatch = source.match(AUTHOR_HEADER_RE); if (!headerMatch) { throw new Error(`${relPath}: missing or malformed SVGarden author header`); } const desc = headerMatch[2].trim(); const fmMatch = source.match(FRONTMATTER_RE); if (!fmMatch) { throw new Error(`${relPath}: missing frontmatter block`); } let meta; try { meta = YAML.parse(fmMatch[1]); } catch (err) { throw new Error(`${relPath}: invalid YAML frontmatter — ${err.message}`); } for (const field of ['title', 'slug', 'category', 'tags', 'difficulty', 'techniques', 'how_it_works', 'created']) { if (meta[field] === undefined || meta[field] === null || meta[field] === '') { throw new Error(`${relPath}: frontmatter missing required field "${field}"`); } } const code = source.slice(source.indexOf(fmMatch[0]) + fmMatch[0].length).trim(); if (!code) { throw new Error(`${relPath}: no snippet code after frontmatter`); } const customizable = Array.isArray(meta.customizable) ? meta.customizable : []; return { title: String(meta.title), slug: String(meta.slug), category: String(meta.category), tags: (meta.tags ?? []).map(String), difficulty: String(meta.difficulty), techniques: (meta.techniques ?? []).map(String), howItWorks: String(meta.how_it_works).trim(), customizable, created: String(meta.created), support: meta.support ? String(meta.support).trim() : null, desc, relPath, code, header: attributionHeader(relPath, desc), }; } /** The full snippet text a visitor gets from Copy / Download. */ export function copyText(snippet, code = snippet.code) { return `${snippet.header}\n${code}\n`; } /** Loads, parses and sorts the whole bank. Cached per process (one build). */ let cache = null; export async function loadSnippets() { if (cache) return cache; 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(); const snippets = []; 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'); const snippet = parseSnippetSource(source, relPath); const dirCategory = rel.split(path.sep)[0]; if (snippet.category !== dirCategory) { throw new Error(`${relPath}: frontmatter category "${snippet.category}" ≠ directory "${dirCategory}"`); } if (slugs.has(snippet.slug)) { throw new Error(`${relPath}: duplicate slug "${snippet.slug}"`); } slugs.add(snippet.slug); snippets.push(snippet); } snippets.sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title)); cache = snippets; return snippets; } /** Categories that actually contain snippets, with counts. */ export async function loadCategories() { const snippets = await loadSnippets(); const counts = new Map(); for (const s of snippets) counts.set(s.category, (counts.get(s.category) ?? 0) + 1); return CATEGORIES.filter((c) => counts.has(c)).map((c) => ({ name: c, count: counts.get(c) })); } /** Standalone HTML document used inside the sandboxed preview iframes. */ export function previewDocument(snippet, { padding = '1rem' } = {}) { return `
${snippet.code} `; }