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

612 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared SVG utility functions for icon generation scripts.
*
* Used by generate-icons.ts, generate-mono-icons.ts, and generate-avatars.ts.
*/
import * as fs from 'fs'
import * as path from 'path'
export type LogoType = 'providers' | 'models'
export const OUTPUT_DIR_MAP: Record<LogoType, string> = {
providers: path.join(__dirname, '../src/components/icons/providers'),
models: path.join(__dirname, '../src/components/icons/models')
}
export const SVG_SOURCE_MAP: Record<LogoType, string> = {
providers: path.join(__dirname, '../icons/providers'),
models: path.join(__dirname, '../icons/models')
}
export function parseLogoTypeArg(): LogoType {
const arg = process.argv.find((item) => item.startsWith('--type='))
if (!arg) return 'providers'
const value = arg.split('=')[1]
if (value === 'providers' || value === 'models') return value
throw new Error(`Invalid --type value: ${value}. Use "providers" or "models".`)
}
/**
* Tighten the SVG root viewBox to the bounding box of its visible content.
*
* Many designer-exported SVGs (e.g. from Figma frames) carry ~10-15% of empty
* padding inside the viewBox. Combined with the Avatar wrapper's own padding,
* the rendered logo ends up only filling ~40% of the visible container.
*
* This helper unions the bounding boxes of every `<path d="...">` and `<rect>`
* element in the file. When `minimumFrameRatio` is provided, it expands that
* union to include a centered minimum frame before the coverage check, so
* icons keep intentional internal spacing instead of tightening purely to the
* visible content. It then rewrites the root viewBox to the final bounds (plus
* a tiny 1-unit margin so strokes don't get clipped).
*
* Returns the original code unchanged if it can't find a viewBox, has no
* visible geometry, or the final bounds are already a good fit (>95% coverage).
*/
export function tightenSvgViewBox(svgCode: string, options: { minimumFrameRatio?: number } = {}): string {
const vbMatch = svgCode.match(/<svg[^>]*\bviewBox="([^"]+)"/)
if (!vbMatch) return svgCode
const [vbX, vbY, vbW, vbH] = vbMatch[1].split(/[\s,]+/).map(Number)
if (![vbX, vbY, vbW, vbH].every(isFinite)) return svgCode
// Strip <defs>, <mask>, <clipPath> — those don't render directly
const stripped = svgCode
.replace(/<defs[\s\S]*?<\/defs>/gi, '')
.replace(/<mask[\s\S]*?<\/mask>/gi, '')
.replace(/<clipPath[\s\S]*?<\/clipPath>/gi, '')
const bounds: BBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
let foundContent = false
for (const m of stripped.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)) {
const pb = parseSvgPathBounds(m[1])
if (isFinite(pb.minX)) {
bounds.minX = Math.min(bounds.minX, pb.minX)
bounds.minY = Math.min(bounds.minY, pb.minY)
bounds.maxX = Math.max(bounds.maxX, pb.maxX)
bounds.maxY = Math.max(bounds.maxY, pb.maxY)
foundContent = true
}
}
for (const m of stripped.matchAll(/<rect\b([^>]*)>/g)) {
const a = m[1]
const x = parseFloat(a.match(/\bx="([^"]+)"/)?.[1] ?? '0')
const y = parseFloat(a.match(/\by="([^"]+)"/)?.[1] ?? '0')
const w = parseFloat(a.match(/\bwidth="([^"]+)"/)?.[1] ?? 'NaN')
const h = parseFloat(a.match(/\bheight="([^"]+)"/)?.[1] ?? 'NaN')
if (isFinite(w) && isFinite(h)) {
bounds.minX = Math.min(bounds.minX, x)
bounds.minY = Math.min(bounds.minY, y)
bounds.maxX = Math.max(bounds.maxX, x + w)
bounds.maxY = Math.max(bounds.maxY, y + h)
foundContent = true
}
}
if (!foundContent) return svgCode
const { minimumFrameRatio } = options
if (minimumFrameRatio && minimumFrameRatio > 0 && minimumFrameRatio <= 1) {
const frameWidth = vbW * minimumFrameRatio
const frameHeight = vbH * minimumFrameRatio
const frameX = vbX + (vbW - frameWidth) / 2
const frameY = vbY + (vbH - frameHeight) / 2
bounds.minX = Math.min(bounds.minX, frameX)
bounds.minY = Math.min(bounds.minY, frameY)
bounds.maxX = Math.max(bounds.maxX, frameX + frameWidth)
bounds.maxY = Math.max(bounds.maxY, frameY + frameHeight)
}
// If content already fills >95% of the viewBox, leave it alone
const coverage = ((bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)) / (vbW * vbH)
if (coverage > 0.95) return svgCode
// Add a 1-unit margin so anti-aliased strokes don't clip at the edges
const margin = 1
const nx = Math.max(vbX, bounds.minX - margin)
const ny = Math.max(vbY, bounds.minY - margin)
const nw = Math.min(vbX + vbW, bounds.maxX + margin) - nx
const nh = Math.min(vbY + vbH, bounds.maxY + margin) - ny
if (!isFinite(nw) || !isFinite(nh) || nw <= 0 || nh <= 0) return svgCode
const newViewBox = `viewBox="${nx} ${ny} ${nw} ${nh}"`
return svgCode.replace(/(<svg[^>]*\b)viewBox="[^"]+"/, `$1${newViewBox}`)
}
export function ensureViewBox(svgCode: string): string {
if (/viewBox\s*=\s*"[^"]*"/.test(svgCode)) return svgCode
const widthMatch = svgCode.match(/<svg[^>]*\bwidth="(\d+(?:\.\d+)?)"/)
const heightMatch = svgCode.match(/<svg[^>]*\bheight="(\d+(?:\.\d+)?)"/)
if (widthMatch && heightMatch) {
return svgCode.replace(/<svg\b/, `<svg viewBox="0 0 ${widthMatch[1]} ${heightMatch[1]}"`)
}
return svgCode
}
export function isImageBased(content: string): boolean {
return content.includes('<image') || content.includes('data:image')
}
export function buildSvgMap(type: LogoType): Map<string, string> {
const svgDir = SVG_SOURCE_MAP[type]
const lightDir = path.join(svgDir, 'light')
const map = new Map<string, string>()
const sourceDir = fs.existsSync(lightDir) ? lightDir : svgDir
if (!fs.existsSync(sourceDir)) return map
for (const file of fs.readdirSync(sourceDir)) {
if (!file.endsWith('.svg')) continue
map.set(file.replace(/\.svg$/, ''), path.join(sourceDir, file))
}
return map
}
export interface LightDarkSvgPair {
light: string
/** null when the logo has no dedicated dark variant (single-source logo). */
dark: string | null
}
/**
* Scan a logo source directory with light/ and (optional) dark/ subdirectories,
* returning a map keyed by kebab-case dirName → { light, dark } SVG paths.
*
* The light variant is required. The dark variant is optional — if dark/{name}.svg
* is missing, the entry has dark=null and the public CompoundIcon API falls back
* to the light SVG for `variant="dark"` without generating a duplicate dark
* component.
*/
export function buildLightDarkSvgMap(type: LogoType): Map<string, LightDarkSvgPair> {
const svgDir = SVG_SOURCE_MAP[type]
const lightDir = path.join(svgDir, 'light')
const darkDir = path.join(svgDir, 'dark')
const map = new Map<string, LightDarkSvgPair>()
if (!fs.existsSync(lightDir)) return map
for (const file of fs.readdirSync(lightDir)) {
if (!file.endsWith('.svg')) continue
const darkPath = path.join(darkDir, file)
const hasDark = fs.existsSync(darkPath)
map.set(file.replace(/\.svg$/, ''), {
light: path.join(lightDir, file),
dark: hasDark ? darkPath : null
})
}
return map
}
export function getComponentName(baseDir: string, dirName: string): string {
for (const filename of ['light.tsx', 'color.tsx']) {
const filePath = path.join(baseDir, dirName, filename)
try {
const content = fs.readFileSync(filePath, 'utf-8')
const match = content.match(/export \{ (\w+) \}/)
if (match) {
return filename === 'light.tsx' ? match[1].replace(/Light$/, '') : match[1]
}
} catch {
/* try next filename */
}
}
return dirName.charAt(0).toUpperCase() + dirName.slice(1)
}
export function collectIconDirs(baseDir: string): string[] {
return fs
.readdirSync(baseDir, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() &&
(fs.existsSync(path.join(baseDir, e.name, 'light.tsx')) ||
fs.existsSync(path.join(baseDir, e.name, 'color.tsx')))
)
.map((e) => e.name)
.sort()
}
export function readColorPrimary(baseDir: string, dirName: string): string {
const metaPath = path.join(baseDir, dirName, 'meta.ts')
if (!fs.existsSync(metaPath)) return '#000000'
const content = fs.readFileSync(metaPath, 'utf-8')
const match = content.match(/colorPrimary:\s*'([^']+)'/)
return match ? match[1] : '#000000'
}
export interface BBox {
minX: number
minY: number
maxX: number
maxY: number
}
/**
* Parse an SVG path `d` attribute and return a conservative bounding box.
* For curves the control points are included, which may slightly overestimate
* the bounds — this is acceptable for icon viewBox calculation.
*/
export function parseSvgPathBounds(d: string): BBox {
const bounds: BBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }
let cx = 0,
cy = 0,
startX = 0,
startY = 0
const addPoint = (x: number, y: number) => {
if (isFinite(x) && isFinite(y)) {
bounds.minX = Math.min(bounds.minX, x)
bounds.minY = Math.min(bounds.minY, y)
bounds.maxX = Math.max(bounds.maxX, x)
bounds.maxY = Math.max(bounds.maxY, y)
}
}
const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) || []
let i = 0
const num = () => parseFloat(tokens[i++])
const hasNum = () => i < tokens.length && /^[-+.\d]/.test(tokens[i])
// SVG arc flags (0 or 1) can be concatenated without separators (e.g. "004.496" = flag 0, flag 0, x 4.496).
// Split the leading flag digit from the rest of the token when needed.
const splitArcFlag = () => {
if (i < tokens.length && /^[01]/.test(tokens[i]) && tokens[i].length > 1) {
const token = tokens[i]
tokens.splice(i, 1, token[0], token.slice(1))
}
i++ // consume the flag
}
while (i < tokens.length) {
const cmd = tokens[i++]
switch (cmd) {
case 'M':
cx = num()
cy = num()
startX = cx
startY = cy
addPoint(cx, cy)
while (hasNum()) {
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 'm':
cx += num()
cy += num()
startX = cx
startY = cy
addPoint(cx, cy)
while (hasNum()) {
cx += num()
cy += num()
addPoint(cx, cy)
}
break
case 'L':
while (hasNum()) {
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 'l':
while (hasNum()) {
cx += num()
cy += num()
addPoint(cx, cy)
}
break
case 'H':
while (hasNum()) {
cx = num()
addPoint(cx, cy)
}
break
case 'h':
while (hasNum()) {
cx += num()
addPoint(cx, cy)
}
break
case 'V':
while (hasNum()) {
cy = num()
addPoint(cx, cy)
}
break
case 'v':
while (hasNum()) {
cy += num()
addPoint(cx, cy)
}
break
case 'C':
while (hasNum()) {
addPoint(num(), num())
addPoint(num(), num())
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 'c':
while (hasNum()) {
const ox = cx,
oy = cy
addPoint(ox + num(), oy + num())
addPoint(ox + num(), oy + num())
cx = ox + num()
cy = oy + num()
addPoint(cx, cy)
}
break
case 'S':
while (hasNum()) {
addPoint(num(), num())
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 's':
while (hasNum()) {
const ox = cx,
oy = cy
addPoint(ox + num(), oy + num())
cx = ox + num()
cy = oy + num()
addPoint(cx, cy)
}
break
case 'Q':
while (hasNum()) {
addPoint(num(), num())
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 'q':
while (hasNum()) {
const ox = cx,
oy = cy
addPoint(ox + num(), oy + num())
cx = ox + num()
cy = oy + num()
addPoint(cx, cy)
}
break
case 'T':
while (hasNum()) {
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 't':
while (hasNum()) {
cx += num()
cy += num()
addPoint(cx, cy)
}
break
case 'A':
while (hasNum()) {
num()
num() // rx, ry (skip — endpoint is sufficient for bounds)
num() // rotation
splitArcFlag()
splitArcFlag()
cx = num()
cy = num()
addPoint(cx, cy)
}
break
case 'a':
while (hasNum()) {
num()
num() // rx, ry
num() // rotation
splitArcFlag()
splitArcFlag()
cx += num()
cy += num()
addPoint(cx, cy)
}
break
case 'Z':
case 'z':
cx = startX
cy = startY
break
}
}
return bounds
}
/**
* Parse a hex color (#RGB or #RRGGBB) to normalized [r, g, b] (01).
* Returns null for unparseable values.
*/
function parseHexRgb(hex: string): [number, number, number] | null {
const h = hex.replace(/^#/, '')
if (h.length === 3) {
return [parseInt(h[0] + h[0], 16) / 255, parseInt(h[1] + h[1], 16) / 255, parseInt(h[2] + h[2], 16) / 255]
}
if (h.length === 6) {
return [parseInt(h.slice(0, 2), 16) / 255, parseInt(h.slice(2, 4), 16) / 255, parseInt(h.slice(4, 6), 16) / 255]
}
return null
}
/**
* Parse a hex color (#RGB or #RRGGBB) and return perceived luminance (01).
* Returns -1 for unparseable values (e.g. url(#gradient), named colors other than white/black).
*/
export function colorToLuminance(hex: string): number {
const rgb = parseHexRgb(hex)
if (rgb) return 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
if (/^black$/i.test(hex)) return 0
if (/^white$/i.test(hex)) return 1
return -1
}
/**
* Check if a fill value is near-white (all RGB channels >= threshold).
* Default threshold 220 detects light foreground content in vectorized icons.
*/
export function isNearWhiteFill(fillValue: string, threshold = 220): boolean {
if (/^(?:white|#fff(?:fff)?)$/i.test(fillValue)) return true
const hex = fillValue.match(/^#([0-9a-f]{6})$/i)
if (hex) {
const r = parseInt(hex[1].slice(0, 2), 16)
const g = parseInt(hex[1].slice(2, 4), 16)
const b = parseInt(hex[1].slice(4, 6), 16)
return r >= threshold && g >= threshold && b >= threshold
}
return false
}
/**
* Check if a fill value is white or near-white (all RGB channels >= 240).
*/
export function isWhiteFill(fillValue: string): boolean {
return isNearWhiteFill(fillValue, 240)
}
/**
* Check if a path's bounding box covers a large portion of the viewBox.
*/
export function isLargeShape(pathD: string, vbW: number, vbH: number, threshold = 0.3): boolean {
const bounds = parseSvgPathBounds(pathD)
if (!isFinite(bounds.minX)) return false
const pathArea = (bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)
return pathArea > vbW * vbH * threshold
}
/**
* Parse the viewBox from an SVG element's attributes record.
* Returns { x, y, w, h } or defaults to { 0, 0, 24, 24 }.
*/
export function parseViewBox(attrs: Record<string, string>): { x: number; y: number; w: number; h: number } {
const vb = attrs.viewBox || attrs.viewbox
if (vb) {
const parts = vb.split(/[\s,]+/).map(Number)
if (parts.length === 4 && parts.every(isFinite)) {
return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }
}
}
// Fall back to width/height if present
const w = parseFloat(attrs.width)
const h = parseFloat(attrs.height)
if (isFinite(w) && isFinite(h)) {
return { x: 0, y: 0, w, h }
}
return { x: 0, y: 0, w: 24, h: 24 }
}
/**
* Parse a hex color to HSV saturation (01).
* Returns -1 for unparseable values.
*/
function colorToSaturation(hex: string): number {
const rgb = parseHexRgb(hex)
if (!rgb) {
if (/^(?:black|white)$/i.test(hex)) return 0
return -1
}
const max = Math.max(...rgb)
const min = Math.min(...rgb)
return max === 0 ? 0 : (max - min) / max
}
/**
* Classify an SVG as monochrome (single-color or achromatic) and whether
* it was designed for dark backgrounds (white/light content on transparent bg).
*
* Strips `<defs>...</defs>` blocks from analysis so clip-path and gradient
* fills are not counted as content fills.
*/
export function isMonochromeSvg(svgContent: string): { monochrome: boolean; darkDesigned: boolean } {
// Strip <defs>...</defs> blocks from analysis
const stripped = svgContent.replace(/<defs[\s\S]*?<\/defs>/gi, '')
// Extract all fill="..." / fill='...' and stroke="..." / stroke='...' values from content elements
const fillMatches = [...stripped.matchAll(/fill=["']([^"']+)["']/g)]
const strokeMatches = [...stripped.matchAll(/stroke=["']([^"']+)["']/g)]
const fills = fillMatches.map(([, value]) => value)
const strokes = strokeMatches.map(([, value]) => value)
const allColors = [...fills, ...strokes]
// If any content element uses gradient fills/strokes, the icon is colorful (not monochrome)
const hasGradientFill = allColors.some((f) => f.startsWith('url('))
if (hasGradientFill) {
return { monochrome: false, darkDesigned: false }
}
// Filter out non-content colors
const contentFills = allColors.filter((f) => f !== 'none' && f !== 'currentColor' && !isWhiteFill(f))
if (contentFills.length === 0) {
// No colored fills/strokes remain — all-white/transparent content
const hasWhite = allColors.some((f) => isWhiteFill(f))
return { monochrome: true, darkDesigned: hasWhite }
}
// Check if all remaining fills are perceptually achromatic.
// A color is achromatic if:
// - HSV saturation < 0.1 (true gray), OR
// - luminance < 0.15 (perceptually black regardless of hue, e.g. #231F20, #1F0909)
const allAchromatic = contentFills.every((f) => {
const sat = colorToSaturation(f)
if (sat >= 0 && sat < 0.1) return true
const lum = colorToLuminance(f)
return lum >= 0 && lum < 0.15
})
if (!allAchromatic) {
return { monochrome: false, darkDesigned: false }
}
// All achromatic — check luminance to determine darkDesigned
let totalLum = 0
let lumCount = 0
for (const f of contentFills) {
const lum = colorToLuminance(f)
if (lum >= 0) {
totalLum += lum
lumCount++
}
}
const avgLum = lumCount > 0 ? totalLum / lumCount : 0
return { monochrome: true, darkDesigned: avgLum > 0.6 }
}
/**
* Normalize a fill/color string to a canonical hex form for comparison.
* Returns the original string if it can't be normalized.
*/
export function normalizeColor(color: string): string {
if (!color || color === 'none' || color === 'currentColor' || color.startsWith('url(')) {
return color
}
// Expand 3-char hex to 6-char
const m3 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i)
if (m3) {
return `#${m3[1]}${m3[1]}${m3[2]}${m3[2]}${m3[3]}${m3[3]}`.toUpperCase()
}
const m6 = color.match(/^#[0-9a-f]{6}$/i)
if (m6) {
return color.toUpperCase()
}
return color
}