mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-07 15:12:18 +08:00
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
519 lines
16 KiB
TypeScript
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()
|