Files
CherryHQ-cherry-studio/packages/ui/scripts/generate-icons.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

519 lines
16 KiB
TypeScript

/**
* Generate React components from SVG files using @svgr/core
*
* Supports incremental generation via SHA256 hash cache.
* Use --force to skip cache and regenerate all files.
*
* Modes:
* --type=icons icons/general/*.svg → src/components/icons/general/{name}.tsx (flat)
* --type=providers icons/providers/{light,dark}/*.svg → src/components/icons/providers/{name}/{light,dark}.tsx
* --type=models icons/models/{light,dark}/*.svg → src/components/icons/models/{name}/{light,dark}.tsx
*/
import { transform } from '@svgr/core'
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
import { generateMeta } from './codegen'
import { buildLightDarkSvgMap, ensureViewBox, type LightDarkSvgPair, tightenSvgViewBox } from './svg-utils'
type IconType = 'icons' | 'providers' | 'models'
const DEFAULT_TYPE: IconType = 'icons'
const HASH_CACHE_FILE = path.join(__dirname, '../.icons-hash.json')
const LOGO_MINIMUM_FRAME_RATIO = 100 / 120
const SOURCE_DIR_MAP: Record<IconType, string> = {
icons: path.join(__dirname, '../icons/general'),
providers: path.join(__dirname, '../icons/providers'),
models: path.join(__dirname, '../icons/models')
}
const OUTPUT_DIR_MAP: Record<IconType, string> = {
icons: path.join(__dirname, '../src/components/icons/general'),
providers: path.join(__dirname, '../src/components/icons/providers'),
models: path.join(__dirname, '../src/components/icons/models')
}
type HashCache = Record<string, string>
async function loadHashCache(): Promise<HashCache> {
try {
const data = await fs.readFile(HASH_CACHE_FILE, 'utf-8')
return JSON.parse(data)
} catch {
return {}
}
}
async function saveHashCache(cache: HashCache): Promise<void> {
await fs.writeFile(HASH_CACHE_FILE, JSON.stringify(cache, null, 2), 'utf-8')
}
function computeHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex')
}
function parseTypeArg(): IconType {
const arg = process.argv.find((item) => item.startsWith('--type='))
if (!arg) return DEFAULT_TYPE
const value = arg.split('=')[1]
if (value === 'icons' || value === 'providers' || value === 'models') return value
throw new Error(`Invalid --type value: ${value}. Use "icons", "providers", or "models".`)
}
function parseOnlyArg(): Set<string> | null {
const arg = process.argv.find((item) => item.startsWith('--only='))
if (!arg) return null
const values = arg
.split('=')[1]
.split(',')
.map((item) => item.trim())
.filter(Boolean)
return values.length > 0 ? new Set(values) : null
}
function matchesOnly(only: Set<string> | null, dirName: string, filename: string): boolean {
if (!only) return true
const basename = filename.replace(/\.svg$/, '')
return only.has(dirName) || only.has(basename)
}
async function ensureOutputDir(type: IconType): Promise<string> {
const outputDir = OUTPUT_DIR_MAP[type]
await fs.mkdir(outputDir, { recursive: true })
return outputDir
}
/**
* Convert filename to PascalCase component name
* Handle numeric prefix: 302ai -> Ai302
*/
function toPascalCase(filename: string): string {
const name = filename.replace(/\.svg$/, '')
if (/^\d/.test(name)) {
const match = name.match(/^(\d+)(.*)$/)
if (match) {
const [, numbers, rest] = match
const restCamel = rest.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
return restCamel.charAt(0).toUpperCase() + restCamel.slice(1) + numbers
}
}
// Convert kebab-case to PascalCase: aws-bedrock -> AwsBedrock
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
}
/**
* Extract the most prominent fill color from SVG content.
*/
function extractColorPrimary(svgContent: string): string {
const fills = [...svgContent.matchAll(/(?:fill|stroke)=["']([^"']+)["']/g)]
const colorCounts = new Map<string, number>()
for (const [, color] of fills) {
if (color === 'none' || color === 'currentColor' || color.startsWith('url(')) continue
if (/^(?:white|#fff(?:fff)?|#FFFFFF)$/i.test(color)) continue
colorCounts.set(color, (colorCounts.get(color) || 0) + 1)
}
if (colorCounts.size === 0) return '#000000'
let maxColor = '#000000'
let maxCount = 0
for (const [color, count] of colorCounts) {
if (count > maxCount) {
maxColor = color
maxCount = count
}
}
// Normalize named colors
if (/^black$/i.test(maxColor)) return '#000000'
return maxColor
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function collectStaticSvgReferences(jsCode: string): Set<string> {
const references = new Set<string>()
for (const [, id] of jsCode.matchAll(/\bid="([^"]+)"/g)) {
references.add(id)
}
for (const [, id] of jsCode.matchAll(/url\(#([^)]+)\)/g)) {
references.add(id)
}
for (const [, id] of jsCode.matchAll(/\b(?:href|xlinkHref)="#([^"]+)"/g)) {
references.add(id)
}
return references
}
function scopeStaticSvgReferences(jsCode: string, componentName: string): string {
const references = collectStaticSvgReferences(jsCode)
if (references.size === 0) return jsCode
let scopedCode = jsCode.replace(
`import type { SVGProps } from 'react'`,
`import { type SVGProps, useId } from 'react'`
)
for (const id of references) {
const escapedId = escapeRegExp(id)
scopedCode = scopedCode
.replace(new RegExp(`\\bid="${escapedId}"`, 'g'), `id={\`\${iconId}-${id}\`}`)
.replace(new RegExp(`="url\\(#${escapedId}\\)"`, 'g'), `={\`url(#\${iconId}-${id})\`}`)
.replace(new RegExp(`(['"])url\\(#${escapedId}\\)\\1`, 'g'), `\`url(#\${iconId}-${id})\``)
.replace(new RegExp(`\\bhref="#${escapedId}"`, 'g'), `href={\`#\${iconId}-${id}\`}`)
.replace(new RegExp(`\\bxlinkHref="#${escapedId}"`, 'g'), `xlinkHref={\`#\${iconId}-${id}\`}`)
}
const componentStart = `const ${componentName}: IconComponent = (props: SVGProps<SVGSVGElement>) => (`
const componentEnd = `\n)\nexport { ${componentName} }`
if (!scopedCode.includes(componentStart) || !scopedCode.includes(componentEnd)) {
throw new Error(`Unable to scope SVG ids for ${componentName}: unexpected generated component shape`)
}
return scopedCode
.replace(
componentStart,
`const ${componentName}: IconComponent = (props: SVGProps<SVGSVGElement>) => {\n const iconId = useId()\n\n return (`
)
.replace(componentEnd, `\n )\n}\nexport { ${componentName} }`)
}
/**
* Run SVGR transform on SVG content, return TSX code.
*/
async function svgrTransform(
svgCode: string,
componentName: string,
options: { minimumFrameRatio?: number } = {}
): Promise<string> {
const processedSvg = tightenSvgViewBox(ensureViewBox(svgCode), options)
let jsCode = await transform(
processedSvg,
{
plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx', '@svgr/plugin-prettier'],
icon: true,
typescript: true,
prettier: true,
prettierConfig: {
singleQuote: true,
semi: false,
printWidth: 120,
tabWidth: 2,
useTabs: false,
endOfLine: 'lf',
bracketSameLine: false,
bracketSpacing: true
},
jsxRuntime: 'automatic',
svgoConfig: {
plugins: [
{
name: 'removeForeignObject',
fn: () => ({
element: {
enter: (node: any, parentNode: any) => {
if (node.name === 'foreignObject') {
parentNode.children = parentNode.children.filter((c: any) => c !== node)
}
}
}
})
},
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
convertPathData: false
}
}
},
{
name: 'prefixIds',
params: {
prefix: componentName.toLowerCase()
}
}
]
}
},
{ componentName }
)
// Add named export
jsCode = jsCode.replace(
`export default ${componentName}`,
`export { ${componentName} }\nexport default ${componentName}`
)
// Add IconComponent type annotation
jsCode = jsCode.replace(
`import type { SVGProps } from 'react'`,
`import type { SVGProps } from 'react'\n\nimport type { IconComponent } from '../../types'`
)
jsCode = jsCode.replace(`const ${componentName} =`, `const ${componentName}: IconComponent =`)
jsCode = scopeStaticSvgReferences(jsCode, componentName)
return jsCode
}
/**
* Generate flat icon component (for --type=icons)
*/
async function generateFlatIcon(
svgPath: string,
outputDir: string,
componentName: string,
outputFilename: string
): Promise<void> {
const svgCode = await fs.readFile(svgPath, 'utf-8')
let jsCode = await svgrTransform(svgCode, componentName)
jsCode = jsCode.replace(`from '../../types'`, `from '../types'`)
await fs.writeFile(path.join(outputDir, outputFilename), jsCode, 'utf-8')
}
/**
* Generate per-logo directory with light.tsx + optional dark.tsx + meta.ts.
*
* When the logo has no dedicated dark variant (pair.dark === null), no dark.tsx
* is emitted. The public CompoundIcon API remains uniform through the component
* `variant` prop; missing dark assets fall back to the light SVG internally.
*/
async function generateLogoDirDual(
pair: LightDarkSvgPair,
outputDir: string,
dirName: string,
componentName: string,
options: { minimumFrameRatio?: number } = {}
): Promise<void> {
const logoDir = path.join(outputDir, dirName)
await fs.mkdir(logoDir, { recursive: true })
const lightSvg = await fs.readFile(pair.light, 'utf-8')
const lightTsx = await svgrTransform(lightSvg, `${componentName}Light`, {
minimumFrameRatio: options.minimumFrameRatio
})
await fs.writeFile(path.join(logoDir, 'light.tsx'), lightTsx, 'utf-8')
if (pair.dark) {
const darkSvg = await fs.readFile(pair.dark, 'utf-8')
const darkTsx = await svgrTransform(darkSvg, `${componentName}Dark`, {
minimumFrameRatio: options.minimumFrameRatio
})
await fs.writeFile(path.join(logoDir, 'dark.tsx'), darkTsx, 'utf-8')
} else {
await fs.rm(path.join(logoDir, 'dark.tsx'), { force: true })
}
let colorPrimary = extractColorPrimary(lightSvg)
if (/^black$/i.test(colorPrimary)) colorPrimary = '#000000'
generateMeta({
outPath: path.join(logoDir, 'meta.ts'),
dirName,
colorPrimary,
colorScheme: 'color'
})
}
async function removeStaleLogoDirs(outputDir: string, activeDirNames: Set<string>): Promise<number> {
const entries = await fs.readdir(outputDir, { withFileTypes: true }).catch(() => [])
let removed = 0
for (const entry of entries) {
if (!entry.isDirectory() || activeDirNames.has(entry.name)) continue
const logoDir = path.join(outputDir, entry.name)
const hasGeneratedIcon = await fs
.stat(path.join(logoDir, 'light.tsx'))
.then(() => true)
.catch(() => false)
if (!hasGeneratedIcon) continue
await fs.rm(logoDir, { recursive: true, force: true })
removed++
}
return removed
}
/**
* Generate flat index.ts (for --type=icons)
*/
async function generateFlatIndex(outputDir: string, components: Array<{ filename: string; componentName: string }>) {
const exports = components
.map(({ filename, componentName }) => {
const basename = filename.replace('.tsx', '')
return `export { ${componentName} } from './${basename}'`
})
.sort()
.join('\n')
const indexContent = `/**
* Auto-generated icon exports
* Do not edit manually
*
* Generated at: ${new Date().toISOString()}
* Total icons: ${components.length}
*/
${exports}
`
await fs.writeFile(path.join(outputDir, 'index.ts'), indexContent, 'utf-8')
}
/**
* Main function
*/
async function main() {
const type = parseTypeArg()
const force = process.argv.includes('--force')
const only = parseOnlyArg()
console.log(
`Starting icon generation (type: ${type})${force ? ' [FORCE]' : ''}${only ? ` [ONLY: ${[...only].join(', ')}]` : ''}...\n`
)
const outputDir = await ensureOutputDir(type)
if (type === 'providers' || type === 'models') {
const svgMap = buildLightDarkSvgMap(type)
console.log(`Found ${svgMap.size} light/dark SVG pairs in ${SOURCE_DIR_MAP[type]}\n`)
if (!only) {
const removedStale = await removeStaleLogoDirs(outputDir, new Set(svgMap.keys()))
if (removedStale > 0)
console.log(`Removed ${removedStale} stale generated icon director${removedStale === 1 ? 'y' : 'ies'}\n`)
}
const hashCache = force && !only ? {} : await loadHashCache()
const newHashCache: HashCache = { ...hashCache }
let generated = 0
let skipped = 0
for (const [dirName, pair] of svgMap) {
// dirName is camelCase; we need the original file basename for componentName
const baseFile = path.basename(pair.light)
if (!matchesOnly(only, dirName, baseFile)) continue
const componentName = toPascalCase(baseFile)
try {
const lightContent = await fs.readFile(pair.light, 'utf-8')
const darkContent = pair.dark ? await fs.readFile(pair.dark, 'utf-8') : '<no-dark>'
const hash = computeHash(`light:${lightContent}\ndark:${darkContent}`)
const cacheKey = `${type}:${baseFile}`
const lightFile = path.join(outputDir, dirName, 'light.tsx')
const darkFile = path.join(outputDir, dirName, 'dark.tsx')
const lightExists = await fs
.stat(lightFile)
.then(() => true)
.catch(() => false)
const darkExists = await fs
.stat(darkFile)
.then(() => true)
.catch(() => false)
const outputExists = lightExists && (pair.dark ? darkExists : !darkExists)
if (!force && hashCache[cacheKey] === hash && outputExists) {
skipped++
continue
}
await generateLogoDirDual(pair, outputDir, dirName, componentName, {
minimumFrameRatio: type === 'providers' ? LOGO_MINIMUM_FRAME_RATIO : undefined
})
newHashCache[cacheKey] = hash
generated++
console.log(` ${baseFile} -> ${componentName}{Light${pair.dark ? ',Dark' : ''}}`)
} catch (error) {
console.error(` Failed to process ${dirName}:`, error)
}
}
await saveHashCache(newHashCache)
console.log(`\nGeneration complete! ${generated} generated, ${skipped} unchanged (cached)`)
return
}
// type === 'icons' — flat mode
const inputDir = SOURCE_DIR_MAP[type]
const inputStat = await fs.stat(inputDir).catch(() => null)
if (!inputStat || !inputStat.isDirectory()) {
throw new Error(`Source directory not found for type=${type}. Expected: ${inputDir}`)
}
const files = await fs.readdir(inputDir)
const svgFiles = files.filter((f) => f.endsWith('.svg'))
console.log(`Found ${svgFiles.length} SVG files in ${inputDir}\n`)
const hashCache = force ? {} : await loadHashCache()
const newHashCache: HashCache = { ...hashCache }
const components: Array<{ filename: string; componentName: string }> = []
let skipped = 0
for (const svgFile of svgFiles) {
const svgPath = path.join(inputDir, svgFile)
const componentName = toPascalCase(svgFile)
const baseName = svgFile.replace(/\.svg$/, '')
const outputFilename = baseName + '.tsx'
try {
const svgContent = await fs.readFile(svgPath, 'utf-8')
const cacheKey = `${type}:${svgFile}`
const hash = computeHash(svgContent)
const outputPath = path.join(outputDir, outputFilename)
const outputExists = await fs
.stat(outputPath)
.then(() => true)
.catch(() => false)
if (!force && hashCache[cacheKey] === hash && outputExists) {
components.push({ filename: outputFilename, componentName })
skipped++
continue
}
await generateFlatIcon(svgPath, outputDir, componentName, outputFilename)
components.push({ filename: outputFilename, componentName })
newHashCache[cacheKey] = hash
console.log(` ${svgFile} -> ${componentName}`)
} catch (error) {
console.error(` Failed to process ${svgFile}:`, error)
}
}
await saveHashCache(newHashCache)
console.log('\nGenerating index.ts...')
await generateFlatIndex(outputDir, components)
const generated = components.length - skipped
console.log(
`\nGeneration complete! ${generated} generated, ${skipped} unchanged (cached), ${svgFiles.length - components.length} failed`
)
}
void main()