diff --git a/__tests__/ferrum-compiler-subset.test.ts b/__tests__/ferrum-compiler-subset.test.ts new file mode 100644 index 0000000..15d7c8f --- /dev/null +++ b/__tests__/ferrum-compiler-subset.test.ts @@ -0,0 +1,439 @@ +// Ferrum Compiler — Subset Splitting Tests + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { SubsetSelector, buildSubsetCSS, generateSubsetImports, getCategoryTree } from '@/lib/ferrum-compiler/subset'; +import { writeSubsetCSS, writeSubsetManifest, writeSubsetPackageJson } from '@/lib/ferrum-compiler/subset-writer'; +import type { FerrumCSSEffect, CategoryTree } from '@/lib/ferrum-compiler/subset'; +import { effects as allEffects } from '@/lib/ferrum-effects-data'; +import { categories } from '@/lib/ferrum-effects-index'; + +// ── Fixtures ────────────────────────────────────────────────── + +const SAMPLE_3D: FerrumCSSEffect[] = allEffects.filter(e => e.category === '3d'); +const SAMPLE_HOVER: FerrumCSSEffect[] = allEffects.filter(e => e.category === 'hover'); + +// Tiny mock data set for fast, isolated unit tests +const MOCK_EFFECTS: FerrumCSSEffect[] = [ + { + name: 'Alpha', + className: 'roycss-alpha', + category: 'cat-a', + displayType: 'box', + css: '.roycss-alpha { color: red; }', + }, + { + name: 'Beta', + className: 'roycss-beta', + category: 'cat-a', + displayType: 'box', + css: '.roycss-beta { color: blue; }', + }, + { + name: 'Gamma', + className: 'roycss-gamma', + category: 'cat-b', + displayType: 'bg', + css: '.roycss-gamma { background: #fff; }', + }, +]; + +// ── SubsetSelector: selectByCategories ─────────────────────── + +describe('SubsetSelector — selectByCategories', () => { + it('selects all effects in a single category (hover)', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['hover']); + + expect(result.effectCount).toBe(17); + expect(result.categories).toEqual(['hover']); + expect(result.unusedCount).toBe(allEffects.length - 17); + expect(result.effects.every(e => e.category === 'hover')).toBe(true); + expect(result.css).toContain('roycss-hover'); + }); + + it('selects all effects in a single category (3d)', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['3d']); + + expect(result.effectCount).toBe(10); + expect(result.categories).toEqual(['3d']); + expect(result.effects.every(e => e.category === '3d')).toBe(true); + expect(result.css).toContain('roycss-3d-book'); + }); + + it('selects effects from multiple categories', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['3d', 'hover']); + + expect(result.effectCount).toBe(27); // 10 + 17 + expect(result.categories).toContain('3d'); + expect(result.categories).toContain('hover'); + expect(result.unusedCount).toBe(allEffects.length - 27); + }); + + it('returns empty result for non-existent category', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['nonexistent-zzzz']); + + expect(result.effectCount).toBe(0); + expect(result.css).toBe(''); + expect(result.totalSize).toBe(0); + expect(result.categories).toEqual([]); + expect(result.unusedCount).toBe(allEffects.length); + }); + + it('returns empty result for empty array', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories([]); + + expect(result.effectCount).toBe(0); + expect(result.css).toBe(''); + }); + + it('handles mixed valid and invalid categories', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['3d', 'fake-cat']); + + expect(result.effectCount).toBe(10); + expect(result.categories).toEqual(['3d']); + }); +}); + +// ── SubsetSelector: selectByNames ──────────────────────────── + +describe('SubsetSelector — selectByNames', () => { + it('selects effects by exact name', () => { + const selector = new SubsetSelector(); + const result = selector.selectByNames(['3D Book', 'Pulse Glow']); + + expect(result.effectCount).toBe(2); + expect(result.effects.map(e => e.name).sort()).toEqual(['3D Book', 'Pulse Glow']); + expect(result.css).toContain('roycss-3d-book'); + expect(result.css).toContain('roycss-pulse-glow'); + }); + + it('returns empty for non-existent effect name', () => { + const selector = new SubsetSelector(); + const result = selector.selectByNames(['Does Not Exist']); + + expect(result.effectCount).toBe(0); + expect(result.css).toBe(''); + }); + + it('handles empty array of names', () => { + const selector = new SubsetSelector(); + const result = selector.selectByNames([]); + + expect(result.effectCount).toBe(0); + expect(result.css).toBe(''); + }); + + it('deduplicates if same name is passed twice', () => { + const selector = new SubsetSelector(); + const result = selector.selectByNames(['3D Book', '3D Book']); + + expect(result.effectCount).toBe(1); + }); +}); + +// ── SubsetSelector: selectAll ──────────────────────────────── + +describe('SubsetSelector — selectAll', () => { + it('selects all 542 effects', () => { + const selector = new SubsetSelector(); + const result = selector.selectAll(); + + expect(result.effectCount).toBe(542); + expect(result.unusedCount).toBe(0); + expect(result.categories.length).toBe(35); + expect(result.totalSize).toBeGreaterThan(0); + expect(result.css.length).toBeGreaterThan(0); + }); +}); + +// ── SubsetSelector: selectByPattern ────────────────────────── + +describe('SubsetSelector — selectByPattern', () => { + it('selects effects matching a regex', () => { + const selector = new SubsetSelector(); + const result = selector.selectByPattern(/^Button/); + + expect(result.effectCount).toBeGreaterThan(0); + expect(result.effects.every(e => e.name.startsWith('Button'))).toBe(true); + }); + + it('selects effects with partial match', () => { + const selector = new SubsetSelector(); + const result = selector.selectByPattern(/Glow/i); + + expect(result.effectCount).toBeGreaterThan(0); + expect(result.effects.every(e => /glow/i.test(e.name))).toBe(true); + }); + + it('returns empty when nothing matches', () => { + const selector = new SubsetSelector(); + const result = selector.selectByPattern(/ZZZNONEXISTENT/); + + expect(result.effectCount).toBe(0); + expect(result.css).toBe(''); + }); +}); + +// ── buildSubsetCSS ─────────────────────────────────────────── + +describe('buildSubsetCSS', () => { + it('produces CSS containing expected class names', () => { + const css = buildSubsetCSS(SAMPLE_3D); + expect(css).toContain('roycss-3d-book'); + expect(css).toContain('roycss-cube-rotate'); + }); + + it('returns empty string for empty input', () => { + const css = buildSubsetCSS([]); + expect(css).toBe(''); + }); + + it('produces minified output when requested', () => { + const normal = buildSubsetCSS(SAMPLE_3D, { minify: false }); + const minified = buildSubsetCSS(SAMPLE_3D, { minify: true }); + + // Minified should be shorter + expect(minified.length).toBeLessThan(normal.length); + // Minified should not have newlines (for this simple CSS) + expect(minified).not.toContain('\n'); + }); + + it('CSS size is calculated correctly', () => { + const css = buildSubsetCSS(SAMPLE_3D); + const byteSize = new TextEncoder().encode(css).length; + expect(byteSize).toBeGreaterThan(0); + }); +}); + +// ── generateSubsetImports ──────────────────────────────────── + +describe('generateSubsetImports', () => { + it('generates import statements grouped by category', () => { + const imports = generateSubsetImports(SAMPLE_3D); + expect(imports).toContain("// FerrumEngine subset imports"); + expect(imports).toContain("import './effects/3d'"); + expect(imports).toContain('10 effect(s)'); + }); + + it('returns comment for empty array', () => { + const imports = generateSubsetImports([]); + expect(imports).toBe('// No effects selected'); + }); + + it('groups multiple categories separately', () => { + const combined = [...SAMPLE_3D.slice(0, 2), ...SAMPLE_HOVER.slice(0, 3)]; + const imports = generateSubsetImports(combined); + expect(imports).toContain("import './effects/3d'"); + expect(imports).toContain("import './effects/hover'"); + }); +}); + +// ── getCategoryTree ────────────────────────────────────────── + +describe('getCategoryTree', () => { + let tree: CategoryTree; + + beforeEach(() => { + tree = getCategoryTree(); + }); + + it('returns all 35 categories', () => { + expect(tree).toHaveLength(35); + }); + + it('each node has correct shape', () => { + for (const node of tree) { + expect(node).toHaveProperty('name'); + expect(node).toHaveProperty('displayName'); + expect(node).toHaveProperty('count'); + expect(node).toHaveProperty('effects'); + expect(typeof node.count).toBe('number'); + expect(Array.isArray(node.effects)).toBe(true); + } + }); + + it('total effects across all categories equals 542', () => { + const total = tree.reduce((sum, node) => sum + node.count, 0); + expect(total).toBe(542); + }); + + it('tree is sorted by category name', () => { + const names = tree.map(n => n.name); + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sorted); + }); + + it('effects have name and displayType but no CSS', () => { + for (const node of tree) { + for (const effect of node.effects) { + expect(effect).toHaveProperty('name'); + expect(effect).toHaveProperty('displayType'); + // Ensure no CSS property + expect('css' in effect).toBe(false); + } + } + }); + + it('3d category has 10 effects', () => { + const node3d = tree.find(n => n.name === '3d'); + expect(node3d).toBeDefined(); + expect(node3d!.count).toBe(10); + expect(node3d!.displayName).toBe('3D'); + }); + + it('hover category has 17 effects', () => { + const hoverNode = tree.find(n => n.name === 'hover'); + expect(hoverNode).toBeDefined(); + expect(hoverNode!.count).toBe(17); + expect(hoverNode!.displayName).toBe('Hover'); + }); +}); + +// ── SubsetResult: size calculations ────────────────────────── + +describe('size calculations', () => { + it('subset is smaller than full set', () => { + const selector = new SubsetSelector(); + const full = selector.selectAll(); + const subset = selector.selectByCategories(['cursor']); + + expect(subset.totalSize).toBeLessThan(full.totalSize); + expect(subset.effectCount).toBeLessThan(full.effectCount); + }); + + it('totalSize matches actual CSS byte length', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['buttons']); + const actualSize = new TextEncoder().encode(result.css).length; + expect(result.totalSize).toBe(actualSize); + }); + + it('unusedCount is accurate', () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['3d', 'hover']); + expect(result.unusedCount).toBe(allEffects.length - result.effectCount); + }); +}); + +// ── subset-writer: writeSubsetCSS ──────────────────────────── + +describe('subset-writer', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = path.join(os.tmpdir(), `ferrum-test-${Date.now()}`); + await fs.mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('writes CSS to disk', async () => { + const css = buildSubsetCSS(SAMPLE_3D); + const outPath = path.join(tmpDir, 'subset.css'); + await writeSubsetCSS(css, outPath); + + const written = await fs.readFile(outPath, 'utf-8'); + expect(written).toContain('roycss-3d-book'); + }); + + it('creates parent directories if needed', async () => { + const outPath = path.join(tmpDir, 'nested', 'dir', 'subset.css'); + await writeSubsetCSS('.test {}', outPath); + + const written = await fs.readFile(outPath, 'utf-8'); + expect(written).toContain('.test'); + }); + + it('writes manifest JSON', async () => { + const selector = new SubsetSelector(); + const result = selector.selectByCategories(['3d']); + const outPath = path.join(tmpDir, 'manifest.json'); + await writeSubsetManifest(result, outPath); + + const raw = await fs.readFile(outPath, 'utf-8'); + const manifest = JSON.parse(raw); + + expect(manifest.effectCount).toBe(10); + expect(manifest.totalSize).toBeGreaterThan(0); + expect(manifest.unusedCount).toBe(allEffects.length - 10); + expect(manifest.categories).toEqual(['3d']); + expect(manifest.effectNames).toHaveLength(10); + expect(manifest.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('writes package.json for subset', async () => { + const dest = path.join(tmpDir, 'my-subset'); + await writeSubsetPackageJson( + 'ferrum-effects-3d', + ['3D Book', 'Cube Rotate'], + ['3d'], + dest, + ); + + const raw = await fs.readFile(path.join(dest, 'package.json'), 'utf-8'); + const pkg = JSON.parse(raw); + + expect(pkg.name).toBe('ferrum-effects-3d'); + expect(pkg.main).toBe('index.css'); + expect(pkg.ferrum.categories).toEqual(['3d']); + expect(pkg.ferrum.effectCount).toBe(2); + expect(pkg.ferrum.effects).toEqual(['3D Book', 'Cube Rotate']); + expect(pkg.description).toContain('3d'); + }); +}); + +// ── SubsetSelector with mock data (fast, no large imports) ──── + +describe('SubsetSelector with mock data', () => { + it('selectByCategories filters correctly', () => { + const selector = new SubsetSelector(MOCK_EFFECTS); + const result = selector.selectByCategories(['cat-a']); + + expect(result.effectCount).toBe(2); + expect(result.unusedCount).toBe(1); + expect(result.categories).toEqual(['cat-a']); + }); + + it('selectByNames filters correctly', () => { + const selector = new SubsetSelector(MOCK_EFFECTS); + const result = selector.selectByNames(['Alpha', 'Gamma']); + + expect(result.effectCount).toBe(2); + expect(result.categories).toContain('cat-a'); + expect(result.categories).toContain('cat-b'); + }); + + it('selectAll returns all mock effects', () => { + const selector = new SubsetSelector(MOCK_EFFECTS); + const result = selector.selectAll(); + + expect(result.effectCount).toBe(3); + expect(result.unusedCount).toBe(0); + }); + + it('selectByPattern works on mock data', () => { + const selector = new SubsetSelector(MOCK_EFFECTS); + const result = selector.selectByPattern(/^[AB]/); + + expect(result.effectCount).toBe(2); // Alpha, Beta + }); + + it('CSS output contains expected selectors', () => { + const selector = new SubsetSelector(MOCK_EFFECTS); + const result = selector.selectByCategories(['cat-a']); + + expect(result.css).toContain('roycss-alpha'); + expect(result.css).toContain('roycss-beta'); + expect(result.css).not.toContain('roycss-gamma'); + }); +}); diff --git a/src/lib/ferrum-compiler/index.ts b/src/lib/ferrum-compiler/index.ts index 52ae3a5..78965c0 100755 --- a/src/lib/ferrum-compiler/index.ts +++ b/src/lib/ferrum-compiler/index.ts @@ -63,3 +63,14 @@ export { optimize } from './optimizer'; export { generateCSS } from './generator'; export { checkCompatibility, getRequiredPrefixes, BROWSER_TARGETS } from './browsers'; export type { BrowserTarget } from './browsers'; + +// Subset / tree-shaking +export { + SubsetSelector, + buildSubsetCSS, + generateSubsetImports, + getCategoryTree, +} from './subset'; +export type { SubsetResult, CategoryTreeNode, CategoryTree, BuildSubsetCSSOptions } from './subset'; +export { writeSubsetCSS, writeSubsetManifest, writeSubsetPackageJson } from './subset-writer'; +export type { SubsetManifest } from './subset-writer'; diff --git a/src/lib/ferrum-compiler/subset-writer.ts b/src/lib/ferrum-compiler/subset-writer.ts new file mode 100644 index 0000000..3730dac --- /dev/null +++ b/src/lib/ferrum-compiler/subset-writer.ts @@ -0,0 +1,95 @@ +// Ferrum Compiler — Subset File Writer +// +// Writes subset CSS, manifests, and package.json files to disk. + +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import type { SubsetResult } from './subset'; + +/** Shape of the written manifest JSON. */ +export interface SubsetManifest { + /** ISO timestamp of when the manifest was generated. */ + generatedAt: string; + /** Number of effects in this subset. */ + effectCount: number; + /** Byte size of the generated CSS. */ + totalSize: number; + /** Number of effects excluded (not in this subset). */ + unusedCount: number; + /** Category IDs included in this subset. */ + categories: string[]; + /** Effect names in this subset. */ + effectNames: string[]; +} + +/** + * Writes subset CSS to a file, creating parent directories as needed. + * + * @param css - The CSS string to write + * @param outputPath - Destination file path + */ +export async function writeSubsetCSS(css: string, outputPath: string): Promise { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, css, 'utf-8'); +} + +/** + * Writes a JSON manifest describing what's included in the subset. + * Useful for caching, CI validation, and documentation. + * + * @param result - The SubsetResult from a SubsetSelector operation + * @param outputPath - Destination file path for the manifest JSON + */ +export async function writeSubsetManifest( + result: SubsetResult, + outputPath: string, +): Promise { + const manifest: SubsetManifest = { + generatedAt: new Date().toISOString(), + effectCount: result.effectCount, + totalSize: result.totalSize, + unusedCount: result.unusedCount, + categories: result.categories, + effectNames: result.effects.map(e => e.name), + }; + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8'); +} + +/** + * Generates a package.json file for a subset package. + * Useful when publishing a trimmed subset as an npm package. + * + * @param name - Package name (e.g., 'ferrum-effects-hover') + * @param effects - Effect names included in this subset + * @param categories - Category IDs included + * @param dest - Directory to write the package.json into + */ +export async function writeSubsetPackageJson( + name: string, + effects: string[], + categories: string[], + dest: string, +): Promise { + const pkg = { + name, + version: '0.0.1-subset', + description: `FerrumEngine subset: ${categories.join(', ')} (${effects.length} effects)`, + main: 'index.css', + keywords: ['ferrum-engine', 'css-effects', ...categories], + license: 'MIT', + ferrum: { + categories, + effectCount: effects.length, + effects, + }, + }; + + await fs.mkdir(dest, { recursive: true }); + await fs.writeFile( + path.join(dest, 'package.json'), + JSON.stringify(pkg, null, 2) + '\n', + 'utf-8', + ); +} diff --git a/src/lib/ferrum-compiler/subset.ts b/src/lib/ferrum-compiler/subset.ts new file mode 100644 index 0000000..c4f5585 --- /dev/null +++ b/src/lib/ferrum-compiler/subset.ts @@ -0,0 +1,241 @@ +// Ferrum Compiler — CSS Subset Splitting / Tree-Shaking +// +// Enables importing only the effects or categories you need, +// producing a minimal CSS bundle with the selected effects. + +import type { FerrumCSSEffect, FerrumEffectIndex, Category } from '../types'; +import { effects as allEffects } from '../ferrum-effects-data'; +import { effects as effectsIndex, categories } from '../ferrum-effects-index'; +import type { CompilerOptions } from './types'; +import { parseCSS } from './parser'; +import { optimize } from './optimizer'; +import { generateCSS } from './generator'; + +// ── Internal compile helper (avoids circular import through index.ts) ── + +function runCompile(input: string, minify: boolean): string { + if (input.length === 0) return ''; + const opts: CompilerOptions = { minify }; + const ast = parseCSS(input); + const { ast: optimized } = optimize(ast, opts); + const { css } = generateCSS(optimized, opts); + return css; +} + +// ── Public Types ──────────────────────────────────────────────── + +/** Result of a subset selection operation. */ +export interface SubsetResult { + /** The selected effect data objects (with CSS). */ + effects: FerrumCSSEffect[]; + /** The concatenated, optimized CSS for the selected effects. */ + css: string; + /** Total byte size of the generated CSS. */ + totalSize: number; + /** Number of effects in this subset. */ + effectCount: number; + /** Category IDs included in this subset. */ + categories: string[]; + /** Number of effects NOT included (the savings). */ + unusedCount: number; +} + +/** A single category branch in the tree. */ +export interface CategoryTreeNode { + /** Category ID (e.g., 'hover', '3d'). */ + name: string; + /** Display name (e.g., 'Hover', '3D'). */ + displayName: string; + /** Number of effects in this category. */ + count: number; + /** Effect entries (name + displayType, no CSS). */ + effects: { name: string; displayType: string }[]; +} + +/** The full category→effects tree returned by getCategoryTree(). */ +export type CategoryTree = CategoryTreeNode[]; + +/** Options for buildSubsetCSS. */ +export interface BuildSubsetCSSOptions { + /** Whether to minify the output. Defaults to false. */ + minify?: boolean; +} + +// ── SubsetSelector Class ──────────────────────────────────────── + +/** + * Provides methods for selecting subsets of the 542+ Ferrum effects + * by category, name, pattern, or all-at-once. + * + * @example + * ```ts + * const selector = new SubsetSelector(); + * const hoverResult = selector.selectByCategories(['hover']); + * console.log(hoverResult.css); // CSS for all hover effects + * console.log(hoverResult.effectCount); // e.g. 42 + * ``` + */ +export class SubsetSelector { + private readonly effectsData: FerrumCSSEffect[]; + + /** + * @param effectsData - Override the default effects data source (for testing). + */ + constructor(effectsData?: FerrumCSSEffect[]) { + this.effectsData = effectsData ?? allEffects; + } + + /** + * Selects all effects belonging to the given category IDs. + * + * @param categoryIds - Array of category IDs (e.g., `['hover', 'buttons']`) + * @returns SubsetResult with matched effects and their CSS + */ + selectByCategories(categoryIds: string[]): SubsetResult { + const catSet = new Set(categoryIds); + const selected = this.effectsData.filter(e => catSet.has(e.category)); + return this.buildResult(selected); + } + + /** + * Selects specific effects by their human-readable names. + * + * @param names - Array of effect names (e.g., `['Hover Glow', 'Pulse']`) + * @returns SubsetResult with matched effects and their CSS + */ + selectByNames(names: string[]): SubsetResult { + const nameSet = new Set(names); + const selected = this.effectsData.filter(e => nameSet.has(e.name)); + return this.buildResult(selected); + } + + /** + * Selects all 542+ effects. + * + * @returns SubsetResult with every effect + */ + selectAll(): SubsetResult { + return this.buildResult([...this.effectsData]); + } + + /** + * Selects effects whose names match the given regular expression. + * + * @param pattern - A RegExp to test against effect names + * @returns SubsetResult with matched effects and their CSS + */ + selectByPattern(pattern: RegExp): SubsetResult { + const selected = this.effectsData.filter(e => pattern.test(e.name)); + return this.buildResult(selected); + } + + // ── Helpers ────────────────────────────────────────────────── + + private buildResult(selected: FerrumCSSEffect[]): SubsetResult { + const rawCSS = selected.map(e => e.css).join('\n\n'); + const css = rawCSS.length > 0 ? runCompile(rawCSS, false) : ''; + const totalSize = new TextEncoder().encode(css).length; + const catSet = new Set(selected.map(e => e.category)); + + return { + effects: selected, + css, + totalSize, + effectCount: selected.length, + categories: [...catSet].sort(), + unusedCount: this.effectsData.length - selected.length, + }; + } +} + +// ── Standalone Functions ───────────────────────────────────────── + +/** + * Builds CSS from a list of selected effect data objects. + * Runs the CSS through the Ferrum compile pipeline (parse → optimize → generate). + * + * @param selectedEffects - The effect data objects to include + * @param options - Optional build options (minify, etc.) + * @returns The compiled CSS string + */ +export function buildSubsetCSS( + selectedEffects: FerrumCSSEffect[], + options?: BuildSubsetCSSOptions, +): string { + if (selectedEffects.length === 0) return ''; + const rawCSS = selectedEffects.map(e => e.css).join('\n\n'); + return runCompile(rawCSS, options?.minify ?? false); +} + +/** + * Generates JavaScript import statements for a given set of effects. + * Useful for generating dynamic loader code for a subset. + * + * @param selectedEffects - The effect data objects to generate imports for + * @returns A string of import statements, one per effect + * + * @example + * ```ts + * const imports = generateSubsetImports(selectedEffects); + * // => "// FerrumEngine subset imports\nimport './effects/hover'; // 42 effect(s)\n..." + * ``` + */ +export function generateSubsetImports(selectedEffects: FerrumCSSEffect[]): string { + if (selectedEffects.length === 0) return '// No effects selected'; + + const categoryGroups = new Map(); + for (const effect of selectedEffects) { + const existing = categoryGroups.get(effect.category) ?? []; + existing.push(effect.className); + categoryGroups.set(effect.category, existing); + } + + const lines: string[] = ['// FerrumEngine subset imports']; + const sortedCats = [...categoryGroups.keys()].sort(); + for (const cat of sortedCats) { + lines.push(`import './effects/${cat}'; // ${categoryGroups.get(cat)!.length} effect(s)`); + } + + return lines.join('\n') + '\n'; +} + +/** + * Returns the full category→effects tree for building selection UIs. + * Each node contains the category metadata and a list of effect names + * with their display types (but no CSS, keeping this lightweight). + * + * @returns CategoryTree array + */ +export function getCategoryTree(): CategoryTree { + const catMeta = new Map(); + for (const cat of categories) { + catMeta.set(cat.id, cat); + } + + const tree: CategoryTree = []; + + // Build from index (lightweight, no CSS) + const grouped = new Map(); + for (const effect of effectsIndex) { + const arr = grouped.get(effect.category) ?? []; + arr.push(effect); + grouped.set(effect.category, arr); + } + + for (const [catId, effects] of grouped) { + const meta = catMeta.get(catId); + tree.push({ + name: catId, + displayName: meta?.name ?? catId, + count: effects.length, + effects: effects.map(e => ({ + name: e.name, + displayType: e.displayType, + })), + }); + } + + // Sort by category name for consistent ordering + tree.sort((a, b) => a.name.localeCompare(b.name)); + return tree; +}