/** * Shared code generators using ts-morph for AST-level TypeScript generation. * * Provides a single source of truth for all generated file shapes: * - generateIconIndex — per-icon index.ts (compound export) * - generateAvatar — per-icon avatar.tsx * - generateMeta — per-icon meta.ts * - generateBarrelIndex — barrel index.ts (re-exports) */ import * as fs from 'fs' import { IndentationText, NewLineKind, Project, QuoteKind, VariableDeclarationKind } from 'ts-morph' const project = new Project({ useInMemoryFileSystem: true, manipulationSettings: { quoteKind: QuoteKind.Single, useTrailingCommas: false, newLineKind: NewLineKind.LineFeed, indentationText: IndentationText.TwoSpaces } }) // --------------------------------------------------------------------------- // generateIconIndex // --------------------------------------------------------------------------- export function generateIconIndex(opts: { outPath: string colorName: string hasAvatar: boolean hasDark: boolean usesCurrentColor?: boolean colorPrimary: string }): void { const { outPath, colorName, hasAvatar, hasDark, usesCurrentColor = false, colorPrimary } = opts const lightName = `${colorName}Light` const darkName = `${colorName}Dark` const avatarName = `${colorName}Avatar` const avatarImport = hasAvatar ? `import { ${avatarName} } from './avatar'\n` : '' const avatarField = hasAvatar ? ` Avatar: ${avatarName},\n` : '' const darkImport = hasDark ? `import { ${darkName} } from './dark'\n` : '' const lightClassName = usesCurrentColor ? `cn('text-foreground', className)` : 'className' const darkClassName = usesCurrentColor ? `cn('text-foreground', className)` : 'className' const autoLightClassName = usesCurrentColor ? `cn('text-foreground dark:hidden', className)` : `cn('dark:hidden', className)` const autoDarkClassName = usesCurrentColor ? `cn('text-foreground hidden dark:block', className)` : `cn('hidden dark:block', className)` const autoRender = hasDark ? `return ( <> <${lightName} className={${autoLightClassName}} {...props} /> <${darkName} className={${autoDarkClassName}} {...props} /> )` : `return <${lightName} {...props} className={${lightClassName}} />` const darkVariantRender = hasDark ? ` if (variant === 'dark') return <${darkName} {...props} className={${darkClassName}} />\n` : '' const cnImport = hasDark || usesCurrentColor ? `import { cn } from '../../../../lib/utils'\n` : '' const content = `${cnImport}import type { CompoundIcon, CompoundIconProps } from '../../types' ${avatarImport}${darkImport} import { ${lightName} } from './light' const ${colorName} = ({ variant, className, ...props }: CompoundIconProps) => { if (variant === 'light') return <${lightName} {...props} className={${lightClassName}} /> ${darkVariantRender} ${autoRender} } export const ${colorName}Icon: CompoundIcon = /*#__PURE__*/ Object.assign(${colorName}, { ${avatarField} colorPrimary: '${colorPrimary}' }) export default ${colorName}Icon ` fs.writeFileSync(outPath, content) } // --------------------------------------------------------------------------- // generateAvatar // --------------------------------------------------------------------------- export function generateAvatar(opts: { outPath: string colorName: string variant: 'full-bleed' | 'padded' hasDark: boolean }): void { const { outPath, colorName, variant, hasDark } = opts const avatarName = `${colorName}Avatar` const sf = project.createSourceFile('avatar.tsx', '', { overwrite: true }) sf.addImportDeclaration({ moduleSpecifier: '@cherrystudio/ui/lib/utils', namedImports: ['cn'] }) sf.addImportDeclaration({ moduleSpecifier: '@cherrystudio/ui/components/primitives/avatar', namedImports: ['Avatar', 'AvatarFallback'] }) sf.addImportDeclaration({ moduleSpecifier: '../../types', namedImports: [{ name: 'IconAvatarProps', isTypeOnly: true }] }) if (hasDark) { sf.addImportDeclaration({ moduleSpecifier: './dark', namedImports: [`${colorName}Dark`] }) } sf.addImportDeclaration({ moduleSpecifier: './light', namedImports: [`${colorName}Light`] }) const iconSize = variant === 'full-bleed' ? 'size * 0.82' : 'size * 0.7' const fallbackClasses = ['text-foreground', variant === 'padded' ? 'bg-background' : ''].filter(Boolean).join(' ') const iconRender = hasDark ? `<${colorName}Light className="dark:hidden" style={{ width: ${iconSize}, height: ${iconSize} }} /> <${colorName}Dark className="hidden dark:block" style={{ width: ${iconSize}, height: ${iconSize} }} />` : `<${colorName}Light style={{ width: ${iconSize}, height: ${iconSize} }} />` sf.addFunction({ isExported: true, name: avatarName, parameters: [ { name: `{ size = 32, shape = 'circle', className }`, type: `Omit` } ], statements: `return ( ${iconRender} )` }) fs.writeFileSync(outPath, sf.getFullText()) } // --------------------------------------------------------------------------- // generateMeta // --------------------------------------------------------------------------- export function generateMeta(opts: { outPath: string dirName: string colorPrimary: string colorScheme: 'mono' | 'color' }): void { const { outPath, dirName, colorPrimary, colorScheme } = opts const sf = project.createSourceFile('meta.ts', '', { overwrite: true }) sf.addImportDeclaration({ moduleSpecifier: '../../types', namedImports: [{ name: 'IconMeta', isTypeOnly: true }] }) sf.addVariableStatement({ isExported: true, declarationKind: VariableDeclarationKind.Const, declarations: [ { name: 'meta', type: 'IconMeta', initializer: `{ id: '${dirName}', colorPrimary: '${colorPrimary}', colorScheme: '${colorScheme}', }` } ] }) fs.writeFileSync(outPath, sf.getFullText()) } // --------------------------------------------------------------------------- // generateBarrelIndex // --------------------------------------------------------------------------- export function generateBarrelIndex(opts: { outPath: string entries: Array<{ dirName: string; colorName: string }> header?: string }): void { const { outPath, entries, header } = opts const sf = project.createSourceFile('index.ts', '', { overwrite: true }) if (header) { sf.addStatements((writer) => { writer.writeLine(`/**`) for (const line of header.split('\n')) { writer.writeLine(` * ${line}`) } writer.writeLine(` */`) }) } for (const { dirName, colorName } of entries) { sf.addExportDeclaration({ namedExports: [{ name: `${colorName}Icon`, alias: colorName }], moduleSpecifier: `./${dirName}` }) } fs.writeFileSync(outPath, sf.getFullText()) } // --------------------------------------------------------------------------- // generateCatalog // --------------------------------------------------------------------------- /** * Generate a catalog.ts that maps camelCase keys to CompoundIcon values. * Uses `as const satisfies` for type-safe key access while preserving * literal key types. * * Output: * import type { CompoundIcon } from '../types' * import { FooIcon } from './foo' * ... * export const PROVIDER_ICON_CATALOG = { foo: FooIcon, ... } as const satisfies Record * export type ProviderIconKey = keyof typeof PROVIDER_ICON_CATALOG */ export function generateCatalog(opts: { outPath: string entries: Array<{ dirName: string; colorName: string }> catalogName: string }): void { const { outPath, entries, catalogName } = opts const sf = project.createSourceFile('catalog.ts', '', { overwrite: true }) sf.addStatements((writer) => { writer.writeLine(`/**`) writer.writeLine(` * Auto-generated icon catalog for runtime lookup`) writer.writeLine(` * Do not edit manually — regenerated by the icon pipeline`) writer.writeLine(` *`) writer.writeLine(` * Generated at: ${new Date().toISOString()}`) writer.writeLine(` * Total icons: ${entries.length}`) writer.writeLine(` */`) }) sf.addImportDeclaration({ moduleSpecifier: '../types', namedImports: [{ name: 'CompoundIcon', isTypeOnly: true }] }) for (const { dirName, colorName } of entries) { sf.addImportDeclaration({ moduleSpecifier: `./${dirName}`, namedImports: [`${colorName}Icon`] }) } // Derive the key type name from the catalog name, e.g. // PROVIDER_ICON_CATALOG → ProviderIconKey // MODEL_ICON_CATALOG → ModelIconKey const keyTypeName = catalogName .replace(/_CATALOG$/, '') .split('_') .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join('') .replace(/Icon$/, 'Icon') + 'Key' // Use raw text to emit `as const satisfies` (ts-morph doesn't support this syntax natively) const objectBody = entries .map(({ dirName, colorName }) => { const key = /^\d/.test(dirName) || dirName.includes('-') ? `'${dirName}'` : dirName return ` ${key}: ${colorName}Icon` }) .join(',\n') sf.addStatements((writer) => { writer.blankLine() writer.writeLine( `export const ${catalogName} = {\n${objectBody}\n} as const satisfies Record` ) writer.blankLine() writer.writeLine(`export type ${keyTypeName} = keyof typeof ${catalogName}`) }) fs.writeFileSync(outPath, sf.getFullText()) }