feat: инициализировать пакет генерации SVG-спрайтов
- создан NPM-пакет @gromlab/svg-sprites (ESM, TypeScript) - реализован CLI через citty и программный API - добавлена компиляция SVG в спрайты (stack/symbol) через svg-sprite - добавлена генерация TypeScript union-типов имён иконок - реализованы SVG-трансформации: замена цветов на CSS-переменные, удаление width/height, добавление transition к элементам с цветом - добавлен генератор HTML-превью с color picker-ами, авто-темой, синхронизацией currentColor с темой и поиском по иконкам - добавлены тестовые SVG-файлы (icons, logos)
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
public/
|
||||
.tmp/
|
||||
*.generated.ts
|
||||
*.tgz
|
||||
.DS_Store
|
||||
2708
package-lock.json
generated
Normal file
48
package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@gromlab/svg-sprites",
|
||||
"version": "0.1.0",
|
||||
"description": "Generate SVG sprites (stack/symbol) and TypeScript icon name types from folders of SVG files",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"svg-sprites": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"sprite": "node dist/cli.js --input sprites --output public",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"svg",
|
||||
"sprite",
|
||||
"svg-sprite",
|
||||
"icons",
|
||||
"typescript",
|
||||
"codegen"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"colorette": "^2.0.20",
|
||||
"svg-sprite": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/svg-sprite": "^0.0.39",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
3
sprites/icons/arrow-left.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 151 B |
1
sprites/icons/arrow-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 143 B |
1
sprites/icons/check.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
|
After Width: | Height: | Size: 145 B |
6
sprites/icons/discount-shape.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.98334 21.9907L3.70336 19.7106C2.77336 18.7806 2.77336 17.2506 3.70336 16.3206L5.98334 14.0406C6.37334 13.6506 6.68834 12.8856 6.68834 12.3456V9.12053C6.68834 7.80053 7.76835 6.72057 9.08835 6.72057H12.3133C12.8533 6.72057 13.6184 6.40562 14.0084 6.01562L16.2883 3.73559C17.2183 2.80559 18.7484 2.80559 19.6784 3.73559L21.9584 6.01562C22.3484 6.40562 23.1133 6.72057 23.6533 6.72057H26.8784C28.1984 6.72057 29.2783 7.80053 29.2783 9.12053V12.3456C29.2783 12.8856 29.5933 13.6506 29.9833 14.0406L32.2634 16.3206C33.1934 17.2506 33.1934 18.7806 32.2634 19.7106L29.9833 21.9907C29.5933 22.3807 29.2783 23.1457 29.2783 23.6857V26.9105C29.2783 28.2305 28.1984 29.3107 26.8784 29.3107H23.6533C23.1133 29.3107 22.3484 29.6256 21.9584 30.0156L19.6784 32.2956C18.7484 33.2256 17.2183 33.2256 16.2883 32.2956L14.0084 30.0156C13.6184 29.6256 12.8533 29.3107 12.3133 29.3107H9.08835C7.76835 29.3107 6.68834 28.2305 6.68834 26.9105V23.6857C6.68834 23.1307 6.37334 22.3657 5.98334 21.9907Z" stroke="#A93133" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.5 22.5L22.5 13.5" stroke="#A93133" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M21.7418 21.75H21.7552" stroke="#A93133" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.2418 14.25H14.2552" stroke="#A93133" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
sprites/icons/flash.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.13515 19.9201H13.7702V30.7201C13.7702 33.2401 15.1352 33.7501 16.8002 31.8601L28.1552 18.9601C29.5502 17.3851 28.9652 16.0801 26.8502 16.0801H22.2152V5.28009C22.2152 2.76009 20.8502 2.25009 19.1852 4.14009L7.83015 17.0401C6.45015 18.6301 7.03515 19.9201 9.13515 19.9201Z" stroke="#A93133" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
5
sprites/icons/heart-tick.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.0001 25.8001C33.0001 27.1501 32.6251 28.425 31.9501 29.505C30.7051 31.59 28.4251 33.0001 25.8001 33.0001C23.1751 33.0001 20.8801 31.59 19.6501 29.505C18.9901 28.425 18.6001 27.1501 18.6001 25.8001C18.6001 21.8251 21.8251 18.6001 25.8001 18.6001C29.7751 18.6001 33.0001 21.8251 33.0001 25.8001Z" stroke="#A93133" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M22.9951 25.8L24.7651 27.57L28.6051 24.03" stroke="#A93133" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M33 13.0349C33 15.9899 32.235 18.5999 31.035 20.8649C29.715 19.4699 27.855 18.6 25.8 18.6C21.825 18.6 18.6 21.825 18.6 25.8C18.6 27.645 19.305 29.325 20.445 30.6C19.89 30.855 19.38 31.0649 18.93 31.2149C18.42 31.3949 17.58 31.3949 17.07 31.2149C12.72 29.7299 3 23.5349 3 13.0349C3 8.39994 6.735 4.6499 11.34 4.6499C14.055 4.6499 16.485 5.96996 18 7.99496C19.515 5.96996 21.945 4.6499 24.66 4.6499C29.265 4.6499 33 8.39994 33 13.0349Z" stroke="#A93133" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
sprites/logos/github.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.009-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.337-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 799 B |
3
sprites/logos/twitter.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 576 B |
4
sprites/logos/youtube.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.546 12 3.546 12 3.546s-7.505 0-9.377.504A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.504 9.376.504 9.376.504s7.505 0 9.377-.504a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z" fill="#FF0000"/>
|
||||
<path d="M9.545 15.568V8.432L15.818 12l-6.273 3.568z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 492 B |
78
src/cli.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
import { defineCommand, runMain } from 'citty'
|
||||
import { generate } from './generate.js'
|
||||
import { log } from './logger.js'
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: 'svg-sprites',
|
||||
version: '0.1.0',
|
||||
description: 'Generate SVG sprites and TypeScript icon name types',
|
||||
},
|
||||
args: {
|
||||
input: {
|
||||
type: 'string',
|
||||
alias: 'i',
|
||||
description: 'Directory with SVG subfolders (each subfolder = one sprite)',
|
||||
required: true,
|
||||
},
|
||||
output: {
|
||||
type: 'string',
|
||||
alias: 'o',
|
||||
description: 'Output directory for generated SVG sprite files',
|
||||
required: true,
|
||||
},
|
||||
types: {
|
||||
type: 'boolean',
|
||||
alias: 't',
|
||||
description: 'Generate TypeScript union types for icon names (default: true)',
|
||||
default: true,
|
||||
},
|
||||
typesOutput: {
|
||||
type: 'string',
|
||||
description: 'Output directory for .generated.ts files (default: same as input)',
|
||||
},
|
||||
removeSize: {
|
||||
type: 'boolean',
|
||||
description: 'Remove width/height from root <svg> (default: true)',
|
||||
default: true,
|
||||
},
|
||||
replaceColors: {
|
||||
type: 'boolean',
|
||||
description: 'Replace colors with CSS variables var(--icon-color-N) (default: true)',
|
||||
default: true,
|
||||
},
|
||||
addTransition: {
|
||||
type: 'boolean',
|
||||
description: 'Add transition:fill,stroke to colored elements (default: true)',
|
||||
default: true,
|
||||
},
|
||||
preview: {
|
||||
type: 'boolean',
|
||||
alias: 'p',
|
||||
description: 'Generate HTML preview page with all icons (default: true)',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
try {
|
||||
await generate({
|
||||
input: args.input,
|
||||
output: args.output,
|
||||
types: args.types,
|
||||
typesOutput: args.typesOutput,
|
||||
transform: {
|
||||
removeSize: args.removeSize,
|
||||
replaceColors: args.replaceColors,
|
||||
addTransition: args.addTransition,
|
||||
},
|
||||
preview: args.preview,
|
||||
})
|
||||
} catch (error) {
|
||||
log.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
runMain(main)
|
||||
41
src/codegen.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { SpriteFolder } from './types.js'
|
||||
|
||||
/** Преобразует kebab-case строку в PascalCase. */
|
||||
function toPascalCase(str: string): string {
|
||||
return str.replace(/(^|[-_])([a-z])/g, (_, __, c: string) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует .generated.ts файл с union-типом имён иконок спрайта.
|
||||
*
|
||||
* Возвращает путь к сгенерированному файлу.
|
||||
*/
|
||||
export function generateIconTypes(
|
||||
folder: SpriteFolder,
|
||||
outputDir: string,
|
||||
): string {
|
||||
const names = folder.files
|
||||
.map((filePath) => path.basename(filePath, '.svg'))
|
||||
.sort()
|
||||
|
||||
const typeName = `${toPascalCase(folder.name)}IconName`
|
||||
|
||||
const content = [
|
||||
'/**',
|
||||
` * Icon names for the "${folder.name}" sprite.`,
|
||||
' * @generated — this file is auto-generated, do not edit manually.',
|
||||
' */',
|
||||
`export type ${typeName} =`,
|
||||
names.map((name) => ` | '${name}'`).join('\n'),
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const outputPath = path.join(outputDir, `${folder.name}.generated.ts`)
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
fs.writeFileSync(outputPath, content)
|
||||
|
||||
return outputPath
|
||||
}
|
||||
85
src/compiler.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import SVGSpriter from 'svg-sprite'
|
||||
import { createShapeTransform } from './transforms.js'
|
||||
import type { SpriteFolder, SpriteMode, TransformOptions } from './types.js'
|
||||
|
||||
/** Конфигурация режима для svg-sprite. */
|
||||
function getModeConfig(mode: SpriteMode, destDir: string) {
|
||||
return {
|
||||
dest: destDir,
|
||||
sprite: `sprite.${mode}.svg`,
|
||||
example: false,
|
||||
rootviewbox: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Строит массив shape.transform на основе опций. */
|
||||
function buildShapeTransforms(transform: TransformOptions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const transforms: any[] = ['svgo']
|
||||
|
||||
const hasCustomTransform =
|
||||
transform.removeSize !== false ||
|
||||
transform.replaceColors !== false ||
|
||||
transform.addTransition !== false
|
||||
|
||||
if (hasCustomTransform) {
|
||||
transforms.push(createShapeTransform(transform))
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
|
||||
/**
|
||||
* Компилирует папку с SVG-файлами в спрайт.
|
||||
*
|
||||
* Возвращает путь к сгенерированному SVG-файлу.
|
||||
*/
|
||||
export async function compileSprite(
|
||||
folder: SpriteFolder,
|
||||
outputDir: string,
|
||||
transform: TransformOptions = {},
|
||||
): Promise<string> {
|
||||
const destDir = path.join(outputDir, folder.name)
|
||||
|
||||
const config = {
|
||||
shape: {
|
||||
transform: buildShapeTransforms(transform),
|
||||
},
|
||||
mode: {
|
||||
[folder.mode]: getModeConfig(folder.mode, destDir),
|
||||
},
|
||||
}
|
||||
|
||||
const spriter = new SVGSpriter(config)
|
||||
|
||||
for (const filePath of folder.files) {
|
||||
spriter.add(filePath, null, fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
spriter.compile((error, result) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
let spritePath = ''
|
||||
|
||||
for (const modeResult of Object.values(result)) {
|
||||
for (const resource of Object.values(
|
||||
modeResult as Record<string, { path: string; contents: Buffer }>,
|
||||
)) {
|
||||
fs.mkdirSync(path.dirname(resource.path), { recursive: true })
|
||||
fs.writeFileSync(resource.path, resource.contents)
|
||||
if (resource.path.endsWith('.svg')) {
|
||||
spritePath = resource.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(spritePath)
|
||||
})
|
||||
})
|
||||
}
|
||||
69
src/generate.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import path from 'node:path'
|
||||
import { scanSpriteFolders } from './scanner.js'
|
||||
import { compileSprite } from './compiler.js'
|
||||
import { generateIconTypes } from './codegen.js'
|
||||
import { generatePreview } from './preview.js'
|
||||
import { log } from './logger.js'
|
||||
import type { GenerateOptions, SpriteResult } from './types.js'
|
||||
|
||||
/**
|
||||
* Генерирует SVG-спрайты и (опционально) TypeScript-типы для всех подпапок.
|
||||
*
|
||||
* Основная точка входа — используется и из CLI, и из программного API.
|
||||
*/
|
||||
export async function generate(options: GenerateOptions): Promise<SpriteResult[]> {
|
||||
const {
|
||||
input,
|
||||
output,
|
||||
types = true,
|
||||
typesOutput,
|
||||
transform = {},
|
||||
preview = true,
|
||||
} = options
|
||||
|
||||
const inputDir = path.resolve(input)
|
||||
const outputDir = path.resolve(output)
|
||||
const typesDir = typesOutput ? path.resolve(typesOutput) : inputDir
|
||||
|
||||
log.title(`Scanning ${inputDir}...`)
|
||||
|
||||
const folders = scanSpriteFolders(inputDir)
|
||||
|
||||
if (folders.length === 0) {
|
||||
log.warn('No sprite folders with SVG files found.')
|
||||
return []
|
||||
}
|
||||
|
||||
log.info(`Found ${folders.length} sprite folder(s)\n`)
|
||||
|
||||
const results: SpriteResult[] = []
|
||||
|
||||
for (const folder of folders) {
|
||||
const spritePath = await compileSprite(folder, outputDir, transform)
|
||||
log.success(` [${folder.mode}] ${folder.name} → ${path.relative(process.cwd(), spritePath)} (${folder.files.length} icons)`)
|
||||
|
||||
let typesPath: string | null = null
|
||||
if (types) {
|
||||
typesPath = generateIconTypes(folder, typesDir)
|
||||
log.success(` [types] ${folder.name} → ${path.relative(process.cwd(), typesPath)}`)
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: folder.name,
|
||||
mode: folder.mode,
|
||||
spritePath,
|
||||
typesPath,
|
||||
iconCount: folder.files.length,
|
||||
})
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
const previewPath = generatePreview(results, outputDir)
|
||||
log.success(`\n [preview] → ${path.relative(process.cwd(), previewPath)}`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
log.success(`Done! Generated ${results.length} sprite(s).`)
|
||||
|
||||
return results
|
||||
}
|
||||
14
src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { generate } from './generate.js'
|
||||
export { scanSpriteFolders } from './scanner.js'
|
||||
export { compileSprite } from './compiler.js'
|
||||
export { generateIconTypes } from './codegen.js'
|
||||
export { createShapeTransform } from './transforms.js'
|
||||
export { generatePreview } from './preview.js'
|
||||
|
||||
export type {
|
||||
GenerateOptions,
|
||||
SpriteResult,
|
||||
SpriteFolder,
|
||||
SpriteMode,
|
||||
TransformOptions,
|
||||
} from './types.js'
|
||||
9
src/logger.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { green, red, yellow, cyan, bold } from 'colorette'
|
||||
|
||||
export const log = {
|
||||
success: (msg: string) => console.log(green(msg)),
|
||||
error: (msg: string) => console.error(red(msg)),
|
||||
warn: (msg: string) => console.warn(yellow(msg)),
|
||||
info: (msg: string) => console.log(cyan(msg)),
|
||||
title: (msg: string) => console.log(bold(cyan(msg))),
|
||||
}
|
||||
381
src/preview.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { SpriteResult } from './types.js'
|
||||
|
||||
/** Извлекает id иконок из SVG-спрайта. */
|
||||
function extractIconIds(spritePath: string): string[] {
|
||||
const content = fs.readFileSync(spritePath, 'utf-8')
|
||||
const ids: string[] = []
|
||||
const regex = /<(?:svg|symbol)\b[^>]*\bid="([^"]+)"/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
ids.push(match[1])
|
||||
}
|
||||
return ids.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает CSS-переменные var(--icon-color-N, fallback) из фрагмента SVG для конкретной иконки.
|
||||
*
|
||||
* Возвращает массив { varName, fallback } для каждой уникальной переменной.
|
||||
*/
|
||||
function extractIconVars(svgFragment: string): { varName: string; fallback: string }[] {
|
||||
const vars = new Map<string, string>()
|
||||
const regex = /var\((--icon-color-\d+),\s*([^)]+)\)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(svgFragment)) !== null) {
|
||||
if (!vars.has(match[1])) {
|
||||
vars.set(match[1], match[2].trim())
|
||||
}
|
||||
}
|
||||
return [...vars.entries()].map(([varName, fallback]) => ({ varName, fallback }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит SVG-спрайт и возвращает маппинг id → SVG-фрагмент для каждой иконки.
|
||||
*/
|
||||
function extractIconFragments(spritePath: string): Map<string, string> {
|
||||
const content = fs.readFileSync(spritePath, 'utf-8')
|
||||
const fragments = new Map<string, string>()
|
||||
|
||||
// Матчим <svg id="...">...</svg> или <symbol id="...">...</symbol>
|
||||
const regex = /<(?:svg|symbol)\b[^>]*\bid="([^"]+)"[^>]*>[\s\S]*?<\/(?:svg|symbol)>/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
fragments.set(match[1], match[0])
|
||||
}
|
||||
return fragments
|
||||
}
|
||||
|
||||
/** Подготавливает SVG-спрайт для инлайна — конвертирует вложенные <svg> в <symbol>. */
|
||||
function prepareInlineSprite(spritePath: string): string {
|
||||
let content = fs.readFileSync(spritePath, 'utf-8')
|
||||
|
||||
content = content.replace(/<\?xml[^?]*\?>\s*/g, '')
|
||||
content = content.replace(/<style>:root>svg\{display:none\}:root>svg:target\{display:block\}<\/style>/g, '')
|
||||
|
||||
let depth = 0
|
||||
content = content.replace(/<(\/?)svg\b([^>]*?)(\s*\/?)>/g, (_full, slash: string, attrs: string) => {
|
||||
if (slash) {
|
||||
depth--
|
||||
return depth > 0 ? '</symbol>' : '</svg>'
|
||||
}
|
||||
depth++
|
||||
if (depth > 1) {
|
||||
const cleanAttrs = attrs.replace(/\s*xmlns="[^"]*"/g, '')
|
||||
return `<symbol${cleanAttrs}>`
|
||||
}
|
||||
return `<svg${attrs} style="display:none">`
|
||||
})
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/** Конвертирует CSS-цвет в hex для input[type=color]. */
|
||||
function colorToHex(color: string): string {
|
||||
const named: Record<string, string> = {
|
||||
red: '#ff0000', blue: '#0000ff', green: '#008000', white: '#ffffff',
|
||||
black: '#000000', yellow: '#ffff00', cyan: '#00ffff', magenta: '#ff00ff',
|
||||
orange: '#ffa500', purple: '#800080', pink: '#ffc0cb', gray: '#808080',
|
||||
grey: '#808080', currentcolor: '#000000',
|
||||
}
|
||||
const lower = color.toLowerCase().trim()
|
||||
if (lower.startsWith('#')) {
|
||||
if (lower.length === 4) {
|
||||
return `#${lower[1]}${lower[1]}${lower[2]}${lower[2]}${lower[3]}${lower[3]}`
|
||||
}
|
||||
return lower
|
||||
}
|
||||
return named[lower] || '#000000'
|
||||
}
|
||||
|
||||
interface IconData {
|
||||
id: string
|
||||
vars: { varName: string; fallback: string }[]
|
||||
}
|
||||
|
||||
interface SpriteGroup {
|
||||
name: string
|
||||
mode: string
|
||||
spritePath: string
|
||||
icons: IconData[]
|
||||
}
|
||||
|
||||
/** Генерирует HTML-строку превью. */
|
||||
function renderHtml(groups: SpriteGroup[]): string {
|
||||
const totalIcons = groups.reduce((sum, g) => sum + g.icons.length, 0)
|
||||
|
||||
const inlineSprites = groups
|
||||
.map((g) => prepareInlineSprite(g.spritePath))
|
||||
.join('\n')
|
||||
|
||||
// Собираем JSON с данными переменных для JS
|
||||
const iconsData: Record<string, { varName: string; fallback: string }[]> = {}
|
||||
for (const group of groups) {
|
||||
for (const icon of group.icons) {
|
||||
iconsData[icon.id] = icon.vars
|
||||
}
|
||||
}
|
||||
|
||||
const sections = groups
|
||||
.map((group) => {
|
||||
const cards = group.icons
|
||||
.map((icon) => {
|
||||
const varsHtml = icon.vars.length > 0
|
||||
? `<div class="vars">${icon.vars.map((v) => {
|
||||
const isCurrentColor = v.fallback.toLowerCase() === 'currentcolor'
|
||||
const hex = colorToHex(v.fallback)
|
||||
return `<label class="var-row">` +
|
||||
`<input type="color" value="${hex}" data-icon="${icon.id}" data-var="${v.varName}" data-default="${hex}"${isCurrentColor ? ' data-current-color' : ''} autocomplete="off">` +
|
||||
`<code>${v.varName}: ${v.fallback}</code>` +
|
||||
`</label>`
|
||||
}).join('')}</div>`
|
||||
: '<div class="vars"><span class="no-vars">no color vars</span></div>'
|
||||
|
||||
return `
|
||||
<div class="card" data-name="${icon.id}">
|
||||
<div class="card-icon" onclick="copy('${icon.id}')">
|
||||
<svg class="icon"><use href="#${icon.id}"/></svg>
|
||||
</div>
|
||||
<span class="name">${icon.id}</span>
|
||||
${varsHtml}
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<section class="group" data-group="${group.name}">
|
||||
<h2>${group.name} <span class="badge">${group.mode}</span> <span class="count">${group.icons.length}</span></h2>
|
||||
<div class="grid">${cards}
|
||||
</div>
|
||||
</section>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SVG Sprites Preview — ${totalIcons} icons</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0}
|
||||
|
||||
:root{
|
||||
--bg:#fff;--fg:#1a1a1a;--card-bg:#f5f5f5;--card-hover:#e8e8e8;
|
||||
--border:#e0e0e0;--accent:#3b82f6;--radius:8px;--icon-size:64px;
|
||||
}
|
||||
@media(prefers-color-scheme:dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--bg:#1a1a1a;--fg:#e5e5e5;--card-bg:#2a2a2a;--card-hover:#333;--border:#404040;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#1a1a1a;--fg:#e5e5e5;--card-bg:#2a2a2a;--card-hover:#333;--border:#404040;
|
||||
}
|
||||
|
||||
body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
||||
background:var(--bg);color:var(--fg);padding:24px;max-width:1400px;margin:0 auto;
|
||||
}
|
||||
header{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:24px}
|
||||
h1{font-size:1.5rem;font-weight:700}
|
||||
.toolbar{display:flex;gap:12px;margin-left:auto;align-items:center}
|
||||
input[type="search"]{
|
||||
padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);
|
||||
background:var(--card-bg);color:var(--fg);font-size:14px;width:200px;outline:none;
|
||||
}
|
||||
input[type="search"]:focus{border-color:var(--accent)}
|
||||
input[type="color"]{
|
||||
width:20px;height:20px;border:1px solid var(--border);border-radius:4px;
|
||||
padding:0;cursor:pointer;background:none;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch-wrapper{padding:1px}
|
||||
input[type="color"]::-webkit-color-swatch{border:none;border-radius:3px}
|
||||
button{
|
||||
padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);
|
||||
background:var(--card-bg);color:var(--fg);cursor:pointer;font-size:14px;
|
||||
}
|
||||
button:hover{background:var(--card-hover)}
|
||||
|
||||
.group{margin-bottom:40px}
|
||||
.group h2{font-size:1.1rem;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
|
||||
.badge{
|
||||
font-size:11px;font-weight:500;padding:2px 8px;border-radius:10px;
|
||||
background:var(--accent);color:#fff;text-transform:uppercase;letter-spacing:0.5px;
|
||||
}
|
||||
.count{font-size:13px;color:#888;font-weight:400}
|
||||
.grid{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;
|
||||
}
|
||||
.card{
|
||||
display:flex;flex-direction:column;align-items:center;gap:6px;
|
||||
padding:12px 8px;border-radius:var(--radius);background:var(--card-bg);
|
||||
position:relative;
|
||||
}
|
||||
.card:hover{background:var(--card-hover)}
|
||||
.card.copied::after{
|
||||
content:"Copied!";position:absolute;top:4px;right:4px;
|
||||
font-size:10px;color:var(--accent);font-weight:600;
|
||||
}
|
||||
.card-icon{cursor:pointer;display:flex;align-items:center;justify-content:center;min-height:40px}
|
||||
.icon{width:var(--icon-size);height:var(--icon-size);color:var(--fg)}
|
||||
.name{font-size:11px;color:#888;text-align:center;word-break:break-all}
|
||||
.vars{
|
||||
display:flex;flex-direction:column;gap:3px;width:100%;margin-top:4px;
|
||||
border-top:1px solid var(--border);padding-top:6px;
|
||||
}
|
||||
.var-row{
|
||||
display:flex;align-items:center;gap:4px;cursor:pointer;
|
||||
}
|
||||
.var-row code{font-size:10px;color:#888;font-family:"SF Mono",Monaco,Consolas,monospace}
|
||||
.no-vars{font-size:10px;color:#666;font-style:italic}
|
||||
.hidden{display:none}
|
||||
.toast{
|
||||
position:fixed;bottom:24px;left:50%;transform:translateX(-50%);
|
||||
background:#333;color:#fff;padding:8px 20px;border-radius:var(--radius);
|
||||
font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:10;
|
||||
}
|
||||
.toast.show{opacity:1}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${inlineSprites}
|
||||
<header>
|
||||
<h1>SVG Sprites</h1>
|
||||
<span class="count">${totalIcons} icons</span>
|
||||
<div class="toolbar">
|
||||
<input type="search" id="search" placeholder="Search icons..." autocomplete="off">
|
||||
<button id="theme" title="Toggle theme">◑</button>
|
||||
</div>
|
||||
</header>
|
||||
${sections}
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => document.querySelectorAll(s);
|
||||
|
||||
const search = $('#search');
|
||||
const cards = $$('.card');
|
||||
const groups = $$('.group');
|
||||
const toast = $('#toast');
|
||||
const themeBtn = $('#theme');
|
||||
let toastTimer;
|
||||
|
||||
// --- Search ---
|
||||
search.addEventListener('input', () => {
|
||||
const q = search.value.toLowerCase();
|
||||
cards.forEach(c => c.classList.toggle('hidden', !c.dataset.name.includes(q)));
|
||||
groups.forEach(g => {
|
||||
const visible = g.querySelectorAll('.card:not(.hidden)');
|
||||
g.classList.toggle('hidden', visible.length === 0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Copy ---
|
||||
function copy(name) {
|
||||
navigator.clipboard.writeText(name).then(() => showToast('Copied: ' + name));
|
||||
const card = $('.card[data-name="' + name + '"]');
|
||||
if (card) { card.classList.add('copied'); setTimeout(() => card.classList.remove('copied'), 1000); }
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
toast.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toast.classList.remove('show'), 1500);
|
||||
}
|
||||
|
||||
// --- Theme (auto + manual) ---
|
||||
function syncCurrentColorPickers() {
|
||||
// Даём браузеру применить стили, потом считываем computed --fg
|
||||
requestAnimationFrame(() => {
|
||||
const fg = getComputedStyle(document.documentElement).getPropertyValue('--fg').trim();
|
||||
const hex = rgbToHex(fg);
|
||||
$$('input[data-current-color]').forEach(input => {
|
||||
input.value = hex;
|
||||
input.dataset.default = hex;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rgbToHex(color) {
|
||||
// Если уже hex
|
||||
if (color.startsWith('#')) return color;
|
||||
// rgb(r, g, b)
|
||||
const m = color.match(/\d+/g);
|
||||
if (m && m.length >= 3) {
|
||||
return '#' + m.slice(0, 3).map(c => parseInt(c).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
return '#000000';
|
||||
}
|
||||
|
||||
themeBtn.addEventListener('click', () => {
|
||||
const root = document.documentElement;
|
||||
const sys = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const current = root.dataset.theme || sys;
|
||||
root.dataset.theme = current === 'dark' ? 'light' : 'dark';
|
||||
syncCurrentColorPickers();
|
||||
});
|
||||
|
||||
// Реагируем на системную смену темы
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncCurrentColorPickers);
|
||||
|
||||
// --- Per-icon color pickers ---
|
||||
$$('input[type="color"][data-icon]').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
const varName = input.dataset.var;
|
||||
const card = input.closest('.card');
|
||||
if (card) {
|
||||
const iconEl = card.querySelector('.card-icon');
|
||||
if (iconEl) iconEl.style.setProperty(varName, input.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Reset color pickers to defaults on load (prevent browser autocomplete) ---
|
||||
$$('input[type="color"][data-default]').forEach(input => {
|
||||
input.value = input.dataset.default;
|
||||
});
|
||||
|
||||
// --- Sync currentColor pickers with actual theme color ---
|
||||
syncCurrentColorPickers();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует HTML-файл превью для всех спрайтов.
|
||||
*
|
||||
* Возвращает путь к сгенерированному файлу.
|
||||
*/
|
||||
export function generatePreview(
|
||||
results: SpriteResult[],
|
||||
outputDir: string,
|
||||
): string {
|
||||
const groups: SpriteGroup[] = results.map((r) => {
|
||||
const fragments = extractIconFragments(r.spritePath)
|
||||
const ids = extractIconIds(r.spritePath)
|
||||
|
||||
const icons: IconData[] = ids.map((id) => ({
|
||||
id,
|
||||
vars: extractIconVars(fragments.get(id) || ''),
|
||||
}))
|
||||
|
||||
return {
|
||||
name: r.name,
|
||||
mode: r.mode,
|
||||
spritePath: r.spritePath,
|
||||
icons,
|
||||
}
|
||||
})
|
||||
|
||||
const html = renderHtml(groups)
|
||||
const outputPath = path.join(outputDir, 'preview.html')
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
fs.writeFileSync(outputPath, html)
|
||||
|
||||
return outputPath
|
||||
}
|
||||
71
src/scanner.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { SpriteFolder, SpriteMode } from './types.js'
|
||||
|
||||
/**
|
||||
* Парсит имя папки и извлекает режим спрайта.
|
||||
*
|
||||
* Формат: `folder-name` → stack (по умолчанию), `folder-name?symbol` → symbol.
|
||||
*/
|
||||
function parseFolderName(fullName: string): { name: string; mode: SpriteMode } {
|
||||
const hasCustomMode = fullName.includes('?')
|
||||
if (!hasCustomMode) {
|
||||
return { name: fullName, mode: 'stack' }
|
||||
}
|
||||
|
||||
const parts = fullName.split('?')
|
||||
const mode = parts.pop() as SpriteMode
|
||||
const name = parts[0]
|
||||
|
||||
if (mode !== 'stack' && mode !== 'symbol') {
|
||||
throw new Error(
|
||||
`Unknown sprite mode "${mode}" in folder "${fullName}". Supported: stack, symbol.`,
|
||||
)
|
||||
}
|
||||
|
||||
return { name, mode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Сканирует директорию и возвращает список папок-спрайтов с их SVG-файлами.
|
||||
*
|
||||
* Пропускает записи, не являющиеся директориями, и папки без SVG-файлов.
|
||||
*/
|
||||
export function scanSpriteFolders(inputDir: string): SpriteFolder[] {
|
||||
if (!fs.existsSync(inputDir)) {
|
||||
throw new Error(`Input directory does not exist: ${inputDir}`)
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(inputDir)
|
||||
const folders: SpriteFolder[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(inputDir, entry)
|
||||
|
||||
if (!fs.lstatSync(fullPath).isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const svgFiles = fs
|
||||
.readdirSync(fullPath)
|
||||
.filter((file) => file.endsWith('.svg'))
|
||||
.sort()
|
||||
.map((file) => path.join(fullPath, file))
|
||||
|
||||
if (svgFiles.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { name, mode } = parseFolderName(entry)
|
||||
|
||||
folders.push({
|
||||
fullName: entry,
|
||||
name,
|
||||
mode,
|
||||
path: fullPath,
|
||||
files: svgFiles,
|
||||
})
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
231
src/transforms.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* SVG-трансформации для обработки иконок перед сборкой спрайта.
|
||||
*
|
||||
* - Удаление width/height с корневого <svg>
|
||||
* - Замена цветов на CSS-переменные (моно/мульти)
|
||||
* - Добавление transition к элементам с цветом
|
||||
*/
|
||||
|
||||
import type { TransformOptions } from './types.js'
|
||||
|
||||
/** Элементы, которые могут содержать цвет (fill/stroke). */
|
||||
const COLORABLE_TAGS = [
|
||||
'path',
|
||||
'circle',
|
||||
'ellipse',
|
||||
'rect',
|
||||
'line',
|
||||
'polyline',
|
||||
'polygon',
|
||||
'text',
|
||||
'tspan',
|
||||
'use',
|
||||
]
|
||||
|
||||
/** Цвета, которые не считаются «реальными» и не заменяются. */
|
||||
const IGNORED_COLORS = new Set(['none', 'transparent', 'inherit', 'unset', 'initial'])
|
||||
|
||||
/** Нормализует значение цвета для сравнения. */
|
||||
function normalizeColor(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
/** Проверяет, является ли значение реальным цветом (не none/transparent/etc). */
|
||||
function isRealColor(value: string): boolean {
|
||||
const normalized = normalizeColor(value)
|
||||
return normalized !== '' && !IGNORED_COLORS.has(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает все уникальные цвета из SVG-строки.
|
||||
*
|
||||
* Ищет значения fill="..." и stroke="..." на дочерних элементах,
|
||||
* а также currentColor. Игнорирует none, transparent и т.п.
|
||||
*/
|
||||
function extractColors(svg: string): string[] {
|
||||
const colors = new Set<string>()
|
||||
|
||||
// fill="..." и stroke="..." на элементах (не на корневом <svg>)
|
||||
const attrRegex = /(?:fill|stroke)="([^"]+)"/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = attrRegex.exec(svg)) !== null) {
|
||||
const value = match[1]
|
||||
if (isRealColor(value)) {
|
||||
colors.add(normalizeColor(value))
|
||||
}
|
||||
}
|
||||
|
||||
// fill:... и stroke:... в атрибуте style="..."
|
||||
const styleAttrRegex = /style="([^"]*)"/g
|
||||
while ((match = styleAttrRegex.exec(svg)) !== null) {
|
||||
const styleContent = match[1]
|
||||
const stylePropRegex = /(?:fill|stroke)\s*:\s*([^;"\s]+)/g
|
||||
let propMatch: RegExpExecArray | null
|
||||
while ((propMatch = stylePropRegex.exec(styleContent)) !== null) {
|
||||
const value = propMatch[1]
|
||||
if (isRealColor(value)) {
|
||||
colors.add(normalizeColor(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...colors]
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет атрибуты width и height с корневого элемента <svg>.
|
||||
*/
|
||||
function removeWidthHeight(svg: string): string {
|
||||
return svg.replace(
|
||||
/(<svg\b[^>]*?)\s+(?:width|height)="[^"]*"/g,
|
||||
'$1',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует CSS-переменную для цвета.
|
||||
*
|
||||
* Моно (1 цвет): var(--icon-color-1, currentColor)
|
||||
* Мульти (N цветов): var(--icon-color-N, #original)
|
||||
*/
|
||||
function makeColorVar(index: number, originalColor: string, isMono: boolean): string {
|
||||
const fallback = isMono ? 'currentColor' : originalColor
|
||||
return `var(--icon-color-${index}, ${fallback})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменяет цвета в fill/stroke атрибутах на CSS-переменные.
|
||||
*/
|
||||
function replaceColors(svg: string, colorMap: Map<string, string>): string {
|
||||
// Замена в атрибутах fill="..." и stroke="..."
|
||||
let result = svg.replace(
|
||||
/((?:fill|stroke))="([^"]+)"/g,
|
||||
(full, attr: string, value: string) => {
|
||||
const normalized = normalizeColor(value)
|
||||
const replacement = colorMap.get(normalized)
|
||||
if (replacement) {
|
||||
return `${attr}="${replacement}"`
|
||||
}
|
||||
return full
|
||||
},
|
||||
)
|
||||
|
||||
// Замена в style="...fill:...;stroke:..."
|
||||
result = result.replace(
|
||||
/style="([^"]*)"/g,
|
||||
(full, styleContent: string) => {
|
||||
const replaced = styleContent.replace(
|
||||
/((?:fill|stroke))\s*:\s*([^;"]+)/g,
|
||||
(styleFull, prop: string, value: string) => {
|
||||
const normalized = normalizeColor(value)
|
||||
const replacement = colorMap.get(normalized)
|
||||
if (replacement) {
|
||||
return `${prop}:${replacement}`
|
||||
}
|
||||
return styleFull
|
||||
},
|
||||
)
|
||||
return `style="${replaced}"`
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет inline transition к элементам, которые содержат fill или stroke с цветом.
|
||||
*
|
||||
* Добавляет style="transition:fill 0.3s,stroke 0.3s;" к элементам из COLORABLE_TAGS,
|
||||
* которые имеют fill или stroke с реальным цветом (или CSS-переменной).
|
||||
*/
|
||||
function addTransitions(svg: string): string {
|
||||
const tagPattern = new RegExp(
|
||||
`(<(?:${COLORABLE_TAGS.join('|')})\\b)([^>]*?)(/?>)`,
|
||||
'g',
|
||||
)
|
||||
|
||||
return svg.replace(tagPattern, (full, open: string, attrs: string, close: string) => {
|
||||
// Проверяем, есть ли fill или stroke с реальным цветом (или var(...))
|
||||
const hasColor = /(?:fill|stroke)="(?!none|transparent)[^"]*"/.test(attrs)
|
||||
|| /style="[^"]*(?:fill|stroke)\s*:[^"]*"/.test(attrs)
|
||||
|
||||
if (!hasColor) {
|
||||
return full
|
||||
}
|
||||
|
||||
// Если уже есть style="...", добавляем transition в него
|
||||
if (/style="/.test(attrs)) {
|
||||
const newAttrs = attrs.replace(
|
||||
/style="([^"]*)"/,
|
||||
(_, existing: string) => {
|
||||
// Не добавляем дубль, если transition уже есть
|
||||
if (existing.includes('transition')) return `style="${existing}"`
|
||||
const sep = existing.endsWith(';') || existing === '' ? '' : ';'
|
||||
return `style="${existing}${sep}transition:fill 0.3s,stroke 0.3s;"`
|
||||
},
|
||||
)
|
||||
return `${open}${newAttrs}${close}`
|
||||
}
|
||||
|
||||
// Иначе добавляем новый атрибут style
|
||||
return `${open}${attrs} style="transition:fill 0.3s,stroke 0.3s;"${close}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape transform для svg-sprite.
|
||||
*
|
||||
* Применяет трансформации в зависимости от опций:
|
||||
* - removeSize: удаление width/height
|
||||
* - replaceColors: замена цветов на CSS-переменные
|
||||
* - addTransition: добавление transition к элементам с цветом
|
||||
*/
|
||||
export function createShapeTransform(options: TransformOptions = {}): (
|
||||
shape: { getSVG: (inline: boolean) => string; setSVG: (svg: string) => void },
|
||||
spriter: unknown,
|
||||
callback: (error: Error | null) => void,
|
||||
) => void {
|
||||
const {
|
||||
removeSize = true,
|
||||
replaceColors: doReplaceColors = true,
|
||||
addTransition = true,
|
||||
} = options
|
||||
|
||||
return (shape, _spriter, callback) => {
|
||||
try {
|
||||
let svg = shape.getSVG(false)
|
||||
|
||||
// 1. Удаляем width/height
|
||||
if (removeSize) {
|
||||
svg = removeWidthHeight(svg)
|
||||
}
|
||||
|
||||
// 2. Извлекаем уникальные цвета и заменяем на CSS-переменные
|
||||
if (doReplaceColors) {
|
||||
const colors = extractColors(svg)
|
||||
|
||||
if (colors.length > 0) {
|
||||
const isMono = colors.length === 1
|
||||
const colorMap = new Map<string, string>()
|
||||
|
||||
colors.forEach((color, i) => {
|
||||
colorMap.set(color, makeColorVar(i + 1, color, isMono))
|
||||
})
|
||||
|
||||
svg = replaceColors(svg, colorMap)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Добавляем transition к элементам с цветом
|
||||
if (addTransition) {
|
||||
svg = addTransitions(svg)
|
||||
}
|
||||
|
||||
shape.setSVG(svg)
|
||||
callback(null)
|
||||
} catch (error) {
|
||||
callback(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/types.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/** Режим спрайта: stack или symbol. */
|
||||
export type SpriteMode = 'stack' | 'symbol'
|
||||
|
||||
/** Результат парсинга имени папки со спрайтами. */
|
||||
export interface SpriteFolder {
|
||||
/** Полное имя папки (как на диске, включая суффикс ?mode). */
|
||||
fullName: string
|
||||
/** Имя папки без суффикса режима. */
|
||||
name: string
|
||||
/** Режим спрайта. */
|
||||
mode: SpriteMode
|
||||
/** Абсолютный путь к папке. */
|
||||
path: string
|
||||
/** Абсолютные пути к SVG-файлам внутри папки. */
|
||||
files: string[]
|
||||
}
|
||||
|
||||
/** Результат компиляции одного спрайта. */
|
||||
export interface SpriteResult {
|
||||
/** Имя папки (без суффикса режима). */
|
||||
name: string
|
||||
/** Режим спрайта. */
|
||||
mode: SpriteMode
|
||||
/** Путь к сгенерированному SVG-спрайту. */
|
||||
spritePath: string
|
||||
/** Путь к сгенерированному .generated.ts файлу (если включена генерация типов). */
|
||||
typesPath: string | null
|
||||
/** Количество иконок в спрайте. */
|
||||
iconCount: number
|
||||
}
|
||||
|
||||
/** Параметры трансформации SVG. */
|
||||
export interface TransformOptions {
|
||||
/**
|
||||
* Удалять width/height с корневого <svg>.
|
||||
* По умолчанию: true.
|
||||
*/
|
||||
removeSize?: boolean
|
||||
/**
|
||||
* Заменять цвета на CSS-переменные var(--icon-color-N, ...).
|
||||
* Моно-иконка: var(--icon-color-1, currentColor).
|
||||
* Мульти-иконка: var(--icon-color-N, #original).
|
||||
* По умолчанию: true.
|
||||
*/
|
||||
replaceColors?: boolean
|
||||
/**
|
||||
* Добавлять transition:fill 0.3s,stroke 0.3s к элементам с цветом.
|
||||
* По умолчанию: true.
|
||||
*/
|
||||
addTransition?: boolean
|
||||
}
|
||||
|
||||
/** Параметры генерации спрайтов. */
|
||||
export interface GenerateOptions {
|
||||
/** Путь к папке с исходными SVG (содержит подпапки-спрайты). */
|
||||
input: string
|
||||
/** Путь к папке для сгенерированных SVG-спрайтов. */
|
||||
output: string
|
||||
/**
|
||||
* Генерировать ли .generated.ts файлы с union-типами имён иконок.
|
||||
* По умолчанию: true.
|
||||
*/
|
||||
types?: boolean
|
||||
/**
|
||||
* Куда складывать .generated.ts файлы.
|
||||
* По умолчанию: рядом с исходными папками (в input).
|
||||
*/
|
||||
typesOutput?: string
|
||||
/**
|
||||
* Настройки трансформации SVG.
|
||||
* По умолчанию: все трансформации включены.
|
||||
*/
|
||||
transform?: TransformOptions
|
||||
/**
|
||||
* Генерировать HTML-превью со всеми иконками.
|
||||
* По умолчанию: true.
|
||||
*/
|
||||
preview?: boolean
|
||||
}
|
||||
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
17
tsup.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
cli: 'src/cli.ts',
|
||||
},
|
||||
format: 'esm',
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
target: 'node18',
|
||||
banner: ({ format }) => {
|
||||
// cli.js needs a shebang for npx/bin usage
|
||||
return {}
|
||||
},
|
||||
})
|
||||