Files
CherryHQ-cherry-studio/packages/ui/scripts/codegen.ts
fullex 4a8c548ece refactor(ui): normalize all packages/ui paths to kebab-case
Migrate 310 paths to kebab-case per docs/references/naming-conventions.md
§4.5, aligning packages/ui with shadcn convention (all primitives,
composites, icons, hooks, stories use kebab-case file/directory names;
exported identifiers stay PascalCase/camelCase).

Generator fixes:
- scripts/svg-utils.ts: drop toCamelCase, preserve kebab basename from
  source SVG filenames
- scripts/generate-icons.ts: fix flat-icon types import path
  (../types not ../../types)
- scripts/codegen.ts: quote catalog keys containing dashes

Path renames:
- 5 primitive camelCase files (copyButton, customTag, etc.)
- ErrorBoundary -> error-boundary
- 24 composite directories (CodeEditor, ImagePreview, etc.) and 35
  internal PascalCase .tsx files (incl. 12 test files); Input directory
  renamed to composite-input to align with CompositeInput export
- 12 + 21 + 12 = 45 icon paths regenerated from kebab source SVGs
- 2 hook files (useDndReorder, useDndState) and 2 composite hooks
  (useDraggableReorder, useImagePreviewTransform)
- 1 utility (reorderVisibleSubset) and its co-located test
- 68 Storybook story files matching their source components

Barrel completeness:
- packages/ui/src/components/index.ts now re-exports CustomTagProps,
  letting the 2 external consumers drop their deep-imports

Docs:
- packages/ui/README.md: add Naming Conventions section linking to
  docs/references/naming-conventions.md
- packages/ui/docs/migration-plan.md: update examples to kebab paths
- docs/references/naming-conventions.md §3.2: note packages/ui hooks
  use kebab-case per §4.5
2026-05-22 04:47:32 -07:00

314 lines
9.8 KiB
TypeScript

/**
* 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<IconAvatarProps, 'icon'>`
}
],
statements: `return (
<Avatar
className={cn(
'overflow-hidden',
shape === 'circle' ? 'rounded-full' : 'rounded-[20%]',
className
)}
style={{ width: size, height: size }}
>
<AvatarFallback${fallbackClasses ? ` className="${fallbackClasses}"` : ''}>
${iconRender}
</AvatarFallback>
</Avatar>
)`
})
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<string, CompoundIcon>
* 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<string, CompoundIcon>`
)
writer.blankLine()
writer.writeLine(`export type ${keyTypeName} = keyof typeof ${catalogName}`)
})
fs.writeFileSync(outPath, sf.getFullText())
}