mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-06 05:55:28 +08:00
152 lines
4.3 KiB
TypeScript
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()
|