Files
CherryHQ-cherry-studio/packages/ui/scripts/validate-svgs.ts

152 lines
4.3 KiB
TypeScript

/**
* Validate SVG source files for the icon system
*
* Checks:
* - Filename follows kebab-case convention
* - viewBox present (warns if missing — ensureViewBox in generate-icons handles it)
* - viewBox is square (warns if not)
* - No embedded raster images (<image> or data:image)
* - File size < 50KB
*
* Usage: pnpm tsx scripts/validate-svgs.ts [--dir=logos|general]
*/
import fs from 'fs/promises'
import path from 'path'
type SourceDir = 'providers' | 'models' | 'general'
function parseArgs(): { dir: SourceDir } {
const dirArg = process.argv.find((a) => a.startsWith('--dir='))
const dir = (dirArg?.split('=')[1] as SourceDir) || 'providers'
return { dir }
}
interface ValidationResult {
file: string
errors: string[]
warnings: string[]
}
const MAX_FILE_SIZE = 50 * 1024 // 50KB
/**
* Validate that a name follows kebab-case convention.
* Allows: lowercase letters, digits, hyphens. Must start with letter or digit.
* Examples: "openai", "aws-bedrock", "302ai"
*/
const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
function isKebabCase(name: string): boolean {
return KEBAB_CASE_RE.test(name)
}
/**
* Suggest a kebab-case version of a non-conforming name.
*/
function suggestKebabCase(name: string): string {
return (
name
// Insert hyphen before uppercase letters: "aiOnly" -> "ai-Only"
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
// Handle uppercase sequences: "DMXAPI" -> "DMX-API" -> handled below
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
.toLowerCase()
// Collapse multiple hyphens
.replace(/-+/g, '-')
// Remove leading/trailing hyphens
.replace(/^-|-$/g, '')
)
}
async function collectSvgFiles(dir: SourceDir): Promise<string[]> {
const baseDir = path.join(__dirname, '../icons', dir)
const files = await fs.readdir(baseDir)
return files.filter((f) => f.endsWith('.svg')).map((f) => path.join(baseDir, f))
}
async function validateSvg(filePath: string): Promise<ValidationResult> {
const file = path.relative(path.join(__dirname, '..'), filePath)
const errors: string[] = []
const warnings: string[] = []
// Naming convention check
const baseName = path.basename(filePath, '.svg')
if (!isKebabCase(baseName)) {
warnings.push(`Filename "${baseName}.svg" is not kebab-case. Suggested: "${suggestKebabCase(baseName)}.svg"`)
}
const stat = await fs.stat(filePath)
if (stat.size > MAX_FILE_SIZE) {
warnings.push(`File size ${(stat.size / 1024).toFixed(1)}KB exceeds ${MAX_FILE_SIZE / 1024}KB limit`)
}
const content = await fs.readFile(filePath, 'utf-8')
// Check for embedded raster images
if (content.includes('<image') || content.includes('data:image')) {
warnings.push('Contains embedded raster image — mono conversion will be skipped')
}
// Check viewBox
const viewBoxMatch = content.match(/viewBox="([^"]*)"/)
if (!viewBoxMatch) {
warnings.push('Missing viewBox attribute (will be inferred from width/height during generation)')
} else {
const parts = viewBoxMatch[1].split(/\s+/).map(Number)
if (parts.length === 4) {
const [, , w, h] = parts
if (Math.abs(w - h) > 0.01) {
warnings.push(`viewBox is not square: ${w}x${h} (mono icons may look distorted)`)
}
}
}
// Check for <svg> tag
if (!/<svg\b/.test(content)) {
errors.push('Not a valid SVG file — no <svg> tag found')
}
return { file, errors, warnings }
}
async function main() {
const { dir } = parseArgs()
console.log(`Validating SVG files (source: ${dir})...\n`)
const files = await collectSvgFiles(dir)
if (files.length === 0) {
console.log('No SVG files found.')
return
}
console.log(`Found ${files.length} SVG files\n`)
const results = await Promise.all(files.map(validateSvg))
let errorCount = 0
let warningCount = 0
for (const result of results) {
if (result.errors.length === 0 && result.warnings.length === 0) continue
console.log(`${result.file}:`)
for (const error of result.errors) {
console.log(` ERROR: ${error}`)
errorCount++
}
for (const warning of result.warnings) {
console.log(` WARN: ${warning}`)
warningCount++
}
console.log()
}
console.log(`\nValidation complete: ${files.length} files, ${errorCount} errors, ${warningCount} warnings`)
if (errorCount > 0) {
process.exit(1)
}
}
void main()