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)
This commit is contained in:
2026-04-21 23:07:34 +03:00
commit aad1c97f50
24 changed files with 3888 additions and 0 deletions

231
src/transforms.ts Normal file
View 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)))
}
}
}