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:
78
src/cli.ts
Normal file
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
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
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
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
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
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
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
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
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
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
|
||||
}
|
||||
Reference in New Issue
Block a user