refactor: заменить shiki на самописный highlighter и обновить архитектуру

- Удалён shiki (9.5→0 МБ), создан regex-токенизатор для html/css/xml
- CLI переведён с аргументов на конфиг-файл svg-sprites.config.ts
- Превью переработано: React-приложение вместо инлайн HTML
- Добавлен футер с названием пакета и ссылкой на репозиторий
- Исправлена загрузка dev-data.js для Vite 8
- Футер прижат к низу, содержимое центрировано
This commit is contained in:
2026-04-22 16:54:35 +03:00
parent aad1c97f50
commit e77e7dfcf1
154 changed files with 9083 additions and 516 deletions

View File

@@ -1,78 +1,16 @@
#!/usr/bin/env node
import { defineCommand, runMain } from 'citty'
import { loadConfig } from './config.js'
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)
}
},
})
async function main() {
try {
const config = await loadConfig()
await generate(config)
} catch (error) {
log.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
runMain(main)
main()

179
src/codegen-react.ts Normal file
View File

@@ -0,0 +1,179 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteFolder, SpriteResult } from './types.js'
/** Преобразует kebab-case строку в PascalCase. */
function toPascalCase(str: string): string {
return str.replace(/(^|[-_])([a-z])/g, (_, __, c: string) => c.toUpperCase())
}
/**
* Собирает имена иконок из SpriteFolder.
*/
function getIconNames(folder: SpriteFolder): string[] {
return folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
}
/**
* Генерирует [name].tsx и [name].module.css — React-компонент с типами.
*
* Имена файлов берутся из basename папки outputDir.
* Например: outputDir = 'src/ui/svg-sprite' → svg-sprite.tsx + svg-sprite.module.css.
*
* Содержит:
* - union-типы имён иконок для каждого спрайта (IconsIconName, LogosIconName, ...)
* - SpriteMap, SpriteName, IconName
* - компонент SvgSprite с зашитым publicPath
*/
export function generateReactModule(
results: SpriteResult[],
folders: SpriteFolder[],
outputDir: string,
publicPath: string,
): string {
const typeBlocks: string[] = []
const mapEntries: string[] = []
const spriteFileEntries: string[] = []
for (const result of results) {
const folder = folders.find((f) => f.name === result.name)
if (!folder) continue
const typeName = `${toPascalCase(result.name)}IconName`
const names = getIconNames(folder)
typeBlocks.push(
`/** Имена иконок спрайта «${result.name}». */`,
`export type ${typeName} =`,
names.map((n) => ` | '${n}'`).join('\n'),
'',
)
mapEntries.push(` ${result.name}: ${typeName}`)
spriteFileEntries.push(` ${result.name}: '${result.name}.sprite.svg',`)
}
const baseName = path.basename(outputDir)
const defaultSprite = results[0].name
const lines = [
'/**',
' * SVG-спрайты: типы и React-компонент.',
' * @generated — this file is auto-generated, do not edit manually.',
' */',
"import type { SVGAttributes, HTMLAttributes } from 'react'",
`import styles from './${baseName}.module.css'`,
'',
...typeBlocks,
'/** Маппинг имени спрайта на тип его иконок. */',
'export type SpriteMap = {',
...mapEntries,
'}',
'',
'/** Имя спрайта. */',
'export type SpriteName = keyof SpriteMap',
'',
'/** Спрайт по умолчанию. */',
`export type DefaultSprite = '${defaultSprite}'`,
'',
'/** Имя иконки для конкретного спрайта. */',
'export type IconName<S extends SpriteName = SpriteName> = SpriteMap[S]',
'',
`const PUBLIC_PATH = '${publicPath}'`,
`const DEFAULT_SPRITE: SpriteName = '${defaultSprite}'`,
'',
'const SPRITE_FILES: Record<SpriteName, string> = {',
...spriteFileEntries,
'}',
'',
'type IconBaseProps<S extends SpriteName> = {',
' /** Имя иконки. */',
' icon: IconName<S>',
' /** Имя спрайта. По умолчанию: первый из конфига. */',
' sprite?: S',
'}',
'',
'type IconSvgProps<S extends SpriteName> = IconBaseProps<S> & {',
' wrapped?: false',
'} & SVGAttributes<SVGSVGElement>',
'',
'type IconWrappedProps<S extends SpriteName> = IconBaseProps<S> & {',
' wrapped: true',
'} & HTMLAttributes<HTMLSpanElement>',
'',
'export type SvgSpriteProps<S extends SpriteName = DefaultSprite> =',
' | IconSvgProps<S>',
' | IconWrappedProps<S>',
'',
'/**',
' * Иконка из SVG-спрайта.',
' *',
' * Используется для:',
' * - отображения иконки через `<use href="...">`',
' * - обёртки в `<span>` через проп `wrapped`',
' *',
` * Спрайт по умолчанию: «${defaultSprite}».`,
' */',
'export const SvgSprite = <S extends SpriteName = DefaultSprite>(props: SvgSpriteProps<S>) => {',
' const { icon, sprite = DEFAULT_SPRITE as S, wrapped, className, ...rest } = props',
// eslint-disable-next-line no-template-curly-in-string
' const href = `${PUBLIC_PATH}/${SPRITE_FILES[sprite]}#${icon}`',
'',
' if (wrapped) {',
' const { ...htmlAttr } = rest as HTMLAttributes<HTMLSpanElement>',
' return (',
' <span {...htmlAttr} className={[styles.wrap, className].filter(Boolean).join(\' \')}>',
' <svg>',
' <use href={href} />',
' </svg>',
' </span>',
' )',
' }',
'',
' const { ...svgAttr } = rest as SVGAttributes<SVGSVGElement>',
' return (',
' <svg {...svgAttr} className={[styles.root, className].filter(Boolean).join(\' \')}>',
' <use href={href} />',
' </svg>',
' )',
'}',
'',
]
const content = lines.join('\n')
const outputPath = path.join(outputDir, `${baseName}.tsx`)
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(outputPath, content)
// Генерируем CSS Module
const css = [
'/* @generated — this file is auto-generated, do not edit manually. */',
'',
'.root {',
' transition-property: fill, stroke, color;',
' transition-duration: 0.3s;',
' transition-timing-function: ease;',
'}',
'',
'.wrap {',
' display: inline-flex;',
'}',
'',
'.wrap svg {',
' width: 100%;',
' height: 100%;',
' transition-property: fill, stroke, color;',
' transition-duration: 0.3s;',
' transition-timing-function: ease;',
'}',
'',
].join('\n')
const cssPath = path.join(outputDir, `${baseName}.module.css`)
fs.writeFileSync(cssPath, css)
return outputPath
}

View File

@@ -5,28 +5,35 @@ import { createShapeTransform } from './transforms.js'
import type { SpriteFolder, SpriteMode, TransformOptions } from './types.js'
/** Конфигурация режима для svg-sprite. */
function getModeConfig(mode: SpriteMode, destDir: string) {
function getModeConfig(mode: SpriteMode, destDir: string, name: string) {
return {
dest: destDir,
sprite: `sprite.${mode}.svg`,
sprite: `${name}.sprite.svg`,
example: false,
rootviewbox: false,
}
}
/** Строит массив shape.transform на основе опций. */
function buildShapeTransforms(transform: TransformOptions) {
/** Строит массив 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))
}
const transforms: any[] = [
{
svgo: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
},
},
createShapeTransform(transform),
]
return transforms
}
@@ -41,14 +48,12 @@ export async function compileSprite(
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),
[folder.mode]: getModeConfig(folder.mode, outputDir, folder.name),
},
}

67
src/config.ts Normal file
View File

@@ -0,0 +1,67 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import type { SvgSpritesConfig } from './types.js'
const CONFIG_NAME = 'svg-sprites.config'
/**
* Загружает конфиг svg-sprites.config.ts из указанной директории.
*
* Использует jiti для импорта TypeScript-файлов.
*/
export async function loadConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
const configPath = path.join(cwd, `${CONFIG_NAME}.ts`)
if (!fs.existsSync(configPath)) {
throw new Error(
`Config file not found: ${configPath}\n` +
`Create a ${CONFIG_NAME}.ts file in the project root.`,
)
}
const jiti = createJiti(cwd)
const mod = await jiti.import(configPath) as { default?: SvgSpritesConfig }
const config = mod.default
if (!config) {
throw new Error(
`Config file must have a default export: ${configPath}\n` +
'Use: export default defineConfig({ ... })',
)
}
validateConfig(config)
return config
}
/**
* Валидирует конфиг на наличие обязательных полей.
*/
function validateConfig(config: SvgSpritesConfig): void {
if (!config.output) {
throw new Error('Config: "output" is required.')
}
if (!config.sprites || config.sprites.length === 0) {
throw new Error('Config: "sprites" must be a non-empty array.')
}
for (const sprite of config.sprites) {
if (!sprite.name) {
throw new Error('Config: each sprite must have a "name".')
}
if (!sprite.input) {
throw new Error(`Config: sprite "${sprite.name}" must have an "input".`)
}
if (sprite.mode && sprite.mode !== 'stack' && sprite.mode !== 'symbol') {
throw new Error(
`Config: sprite "${sprite.name}" has invalid mode "${sprite.mode}". Supported: stack, symbol.`,
)
}
}
}

View File

@@ -1,40 +1,38 @@
import path from 'node:path'
import { scanSpriteFolders } from './scanner.js'
import { resolveSprites } from './scanner.js'
import { compileSprite } from './compiler.js'
import { generateIconTypes } from './codegen.js'
import { generateReactModule } from './codegen-react.js'
import { generatePreview } from './preview.js'
import { log } from './logger.js'
import type { GenerateOptions, SpriteResult } from './types.js'
import type { SvgSpritesConfig, SpriteResult } from './types.js'
/**
* Генерирует SVG-спрайты и (опционально) TypeScript-типы для всех подпапок.
* Генерирует SVG-спрайты из конфига.
*
* Основная точка входа — используется и из CLI, и из программного API.
*/
export async function generate(options: GenerateOptions): Promise<SpriteResult[]> {
export async function generate(config: SvgSpritesConfig): Promise<SpriteResult[]> {
const {
input,
output,
types = true,
typesOutput,
transform = {},
publicPath,
preview = true,
} = options
react,
transform = {},
sprites,
} = config
const inputDir = path.resolve(input)
const outputDir = path.resolve(output)
const typesDir = typesOutput ? path.resolve(typesOutput) : inputDir
log.title(`Scanning ${inputDir}...`)
log.title('Resolving sprites...')
const folders = scanSpriteFolders(inputDir)
const folders = resolveSprites(sprites)
if (folders.length === 0) {
log.warn('No sprite folders with SVG files found.')
log.warn('No sprites to generate.')
return []
}
log.info(`Found ${folders.length} sprite folder(s)\n`)
log.info(`Found ${folders.length} sprite(s)\n`)
const results: SpriteResult[] = []
@@ -42,21 +40,21 @@ export async function generate(options: GenerateOptions): Promise<SpriteResult[]
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 (react) {
const reactDir = path.resolve(react)
const resolvedPublicPath = publicPath ?? `/${output}`
const iconPath = generateReactModule(results, folders, reactDir, resolvedPublicPath)
log.success(` [react] → ${path.relative(process.cwd(), iconPath)}`)
}
if (preview) {
const previewPath = generatePreview(results, outputDir)
log.success(`\n [preview] → ${path.relative(process.cwd(), previewPath)}`)

View File

@@ -1,14 +1,25 @@
import type { SvgSpritesConfig } from './types.js'
export { generate } from './generate.js'
export { scanSpriteFolders } from './scanner.js'
export { resolveSprites, resolveSpriteEntry } from './scanner.js'
export { compileSprite } from './compiler.js'
export { generateIconTypes } from './codegen.js'
export { createShapeTransform } from './transforms.js'
export { generatePreview } from './preview.js'
export { generateReactModule } from './codegen-react.js'
export { loadConfig } from './config.js'
export type {
GenerateOptions,
SvgSpritesConfig,
SpriteEntry,
SpriteResult,
SpriteFolder,
SpriteMode,
TransformOptions,
} from './types.js'
/**
* Хелпер для типизации конфига с автодополнением.
*/
export function defineConfig(config: SvgSpritesConfig): SvgSpritesConfig {
return config
}

View File

@@ -1,7 +1,10 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { SpriteResult } from './types.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/** Извлекает id иконок из SVG-спрайта. */
function extractIconIds(spritePath: string): string[] {
const content = fs.readFileSync(spritePath, 'utf-8')
@@ -14,11 +17,7 @@ function extractIconIds(spritePath: string): string[] {
return ids.sort()
}
/**
* Извлекает CSS-переменные var(--icon-color-N, fallback) из фрагмента SVG для конкретной иконки.
*
* Возвращает массив { varName, fallback } для каждой уникальной переменной.
*/
/** Извлекает CSS-переменные var(--icon-color-N, fallback) из SVG-фрагмента иконки. */
function extractIconVars(svgFragment: string): { varName: string; fallback: string }[] {
const vars = new Map<string, string>()
const regex = /var\((--icon-color-\d+),\s*([^)]+)\)/g
@@ -31,14 +30,10 @@ function extractIconVars(svgFragment: string): { varName: string; fallback: stri
return [...vars.entries()].map(([varName, fallback]) => ({ varName, fallback }))
}
/**
* Парсит SVG-спрайт и возвращает маппинг id → SVG-фрагмент для каждой иконки.
*/
/** Парсит 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) {
@@ -47,10 +42,34 @@ function extractIconFragments(spritePath: string): Map<string, string> {
return fragments
}
/** Извлекает viewBox из SVG-фрагмента иконки. */
function extractViewBox(svgFragment: string): { x: number; y: number; width: number; height: number } | null {
const match = svgFragment.match(/viewBox="([^"]+)"/)
if (!match) return null
const parts = match[1].split(/\s+/).map(Number)
if (parts.length !== 4) return null
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
}
/** Конвертирует CSS-цвет в hex. */
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'
}
/** Подготавливает 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, '')
@@ -67,313 +86,98 @@ function prepareInlineSprite(spritePath: string): string {
}
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 }[]
group: string
mode: string
spriteFile: string
viewBox: { x: number; y: number; width: number; height: number } | null
vars: { varName: string; fallback: string; hex: string; isCurrentColor: boolean }[]
}
interface SpriteGroup {
name: string
mode: string
spritePath: string
spriteFile: 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">&#x25D1;</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-файл превью для всех спрайтов.
*
* Возвращает путь к сгенерированному файлу.
* Использует pre-built React-приложение из dist/preview-template.html,
* инжектирует данные спрайтов и inline SVG.
*/
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 spriteFile = `${r.name}.sprite.svg`
const icons: IconData[] = ids.map((id) => ({
id,
vars: extractIconVars(fragments.get(id) || ''),
}))
const icons: IconData[] = ids.map((id) => {
const fragment = fragments.get(id) || ''
return {
id,
group: r.name,
mode: r.mode,
spriteFile,
viewBox: extractViewBox(fragment),
vars: extractIconVars(fragment).map((v) => ({
varName: v.varName,
fallback: v.fallback,
hex: colorToHex(v.fallback),
isCurrentColor: v.fallback.toLowerCase() === 'currentcolor',
})),
}
})
return {
name: r.name,
mode: r.mode,
spritePath: r.spritePath,
icons,
}
return { name: r.name, mode: r.mode, spriteFile, icons }
})
const html = renderHtml(groups)
const outputPath = path.join(outputDir, 'preview.html')
// Inline SVG спрайтов
const inlineSprites = results
.map((r) => prepareInlineSprite(r.spritePath))
.join('\n')
// Скрипт с данными + DOM injection
const svgEscaped = inlineSprites.replace(/`/g, '\\`').replace(/\$/g, '\\$')
const dataScript = [
'<script>',
`window.__SPRITES_DATA__ = ${JSON.stringify({ groups })};`,
'(function() {',
` var svg = \`${svgEscaped}\`;`,
' var parser = new DOMParser();',
' var doc = parser.parseFromString("<div>" + svg + "</div>", "text/html");',
' var nodes = doc.body.firstChild.childNodes;',
' while (nodes.length > 0) {',
' document.body.insertBefore(nodes[0], document.body.firstChild);',
' }',
'})();',
'</script>',
].join('\n')
// Читаем шаблон
const templatePath = path.join(__dirname, 'preview-template.html')
if (!fs.existsSync(templatePath)) {
throw new Error(
`Preview template not found: ${templatePath}\n` +
'Run "npm run build" in the preview/ directory first.',
)
}
let html = fs.readFileSync(templatePath, 'utf-8')
html = html.replace('<!-- __SPRITES_INJECT__ -->', dataScript)
// Записываем результат
const outputPath = path.join(outputDir, 'preview.html')
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(outputPath, html)

View File

@@ -1,71 +1,81 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteFolder, SpriteMode } from './types.js'
import type { SpriteEntry, SpriteFolder } from './types.js'
/**
* Парсит имя папки и извлекает режим спрайта.
*
* Формат: `folder-name` → stack (по умолчанию), `folder-name?symbol` → symbol.
* Сканирует папку и возвращает отсортированные абсолютные пути к SVG-файлам.
*/
function parseFolderName(fullName: string): { name: string; mode: SpriteMode } {
const hasCustomMode = fullName.includes('?')
if (!hasCustomMode) {
return { name: fullName, mode: 'stack' }
function scanDirectory(dirPath: string): string[] {
if (!fs.existsSync(dirPath)) {
throw new Error(`Input directory does not exist: ${dirPath}`)
}
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 }
return fs
.readdirSync(dirPath)
.filter((file) => file.endsWith('.svg'))
.sort()
.map((file) => path.join(dirPath, file))
}
/**
* Сканирует директорию и возвращает список папок-спрайтов с их SVG-файлами.
*
* Пропускает записи, не являющиеся директориями, и папки без SVG-файлов.
* Резолвит массив путей к SVG-файлам в абсолютные пути.
* Проверяет существование каждого файла.
*/
export function scanSpriteFolders(inputDir: string): SpriteFolder[] {
if (!fs.existsSync(inputDir)) {
throw new Error(`Input directory does not exist: ${inputDir}`)
}
function resolveFiles(files: string[]): string[] {
return files.map((filePath) => {
const resolved = path.resolve(filePath)
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
if (!fs.existsSync(resolved)) {
throw new Error(`SVG file does not exist: ${resolved}`)
}
const svgFiles = fs
.readdirSync(fullPath)
.filter((file) => file.endsWith('.svg'))
.sort()
.map((file) => path.join(fullPath, file))
if (svgFiles.length === 0) {
continue
if (!resolved.endsWith('.svg')) {
throw new Error(`File is not an SVG: ${resolved}`)
}
const { name, mode } = parseFolderName(entry)
return resolved
})
}
folders.push({
fullName: entry,
name,
/**
* Преобразует SpriteEntry из конфига в SpriteFolder для компиляции.
*/
export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
const mode = entry.mode ?? 'stack'
if (Array.isArray(entry.input)) {
const files = resolveFiles(entry.input)
if (files.length === 0) {
throw new Error(`Sprite "${entry.name}" has empty input array.`)
}
return {
name: entry.name,
mode,
path: fullPath,
files: svgFiles,
})
path: null,
files,
}
}
return folders
const dirPath = path.resolve(entry.input)
const files = scanDirectory(dirPath)
if (files.length === 0) {
throw new Error(`Sprite "${entry.name}" has no SVG files in "${dirPath}".`)
}
return {
name: entry.name,
mode,
path: dirPath,
files,
}
}
/**
* Преобразует массив SpriteEntry из конфига в массив SpriteFolder.
*/
export function resolveSprites(entries: SpriteEntry[]): SpriteFolder[] {
return entries.map(resolveSpriteEntry)
}

View File

@@ -6,7 +6,7 @@
* - Добавление transition к элементам с цветом
*/
import type { TransformOptions } from './types.js'
/** Элементы, которые могут содержать цвет (fill/stroke). */
const COLORABLE_TAGS = [
@@ -173,13 +173,15 @@ function addTransitions(svg: string): string {
})
}
import type { TransformOptions } from './types.js'
/**
* Shape transform для svg-sprite.
*
* Применяет трансформации в зависимости от опций:
* - removeSize: удаление width/height
* - replaceColors: замена цветов на CSS-переменные
* - addTransition: добавление transition к элементам с цветом
* - removeSize: удаление width/height (по умолчанию: true)
* - replaceColors: замена цветов на CSS-переменные (по умолчанию: true)
* - addTransition: добавление transition к элементам с цветом (по умолчанию: true)
*/
export function createShapeTransform(options: TransformOptions = {}): (
shape: { getSVG: (inline: boolean) => string; setSVG: (svg: string) => void },

View File

@@ -1,36 +1,25 @@
/** Режим спрайта: stack или symbol. */
export type SpriteMode = 'stack' | 'symbol'
/** Результат парсинга имени папки со спрайтами. */
export interface SpriteFolder {
/** Полное имя папки (как на диске, включая суффикс ?mode). */
fullName: string
/** Имя папки без суффикса режима. */
/** Описание одного спрайта в конфиге. */
export type SpriteEntry = {
/** Уникальное имя спрайта (используется как имя файла и в типах). */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Абсолютный путь к папке. */
path: string
/** Абсолютные пути к SVG-файлам внутри папки. */
files: string[]
/**
* Источник SVG-файлов.
* Строка — путь к папке с SVG-файлами.
* Массив — пути к конкретным SVG-файлам.
*/
input: string | string[]
/**
* Режим спрайта.
* По умолчанию: 'stack'.
*/
mode?: SpriteMode
}
/** Результат компиляции одного спрайта. */
export interface SpriteResult {
/** Имя папки (без суффикса режима). */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Путь к сгенерированному SVG-спрайту. */
spritePath: string
/** Путь к сгенерированному .generated.ts файлу (если включена генерация типов). */
typesPath: string | null
/** Количество иконок в спрайте. */
iconCount: number
}
/** Параметры трансформации SVG. */
export interface TransformOptions {
/** Параметры трансформации SVG. Все включены по умолчанию. */
export type TransformOptions = {
/**
* Удалять width/height с корневого <svg>.
* По умолчанию: true.
@@ -50,30 +39,55 @@ export interface TransformOptions {
addTransition?: boolean
}
/** Параметры генерации спрайтов. */
export interface GenerateOptions {
/** Путь к папке с исходными SVG (содержит подпапки-спрайты). */
input: string
/** Конфигурация генерации SVG-спрайтов. */
export type SvgSpritesConfig = {
/** Путь к папке для сгенерированных SVG-спрайтов. */
output: string
/**
* Генерировать ли .generated.ts файлы с union-типами имён иконок.
* По умолчанию: true.
* Публичный путь к спрайтам для использования в коде (href, src, url()).
* Используется в сгенерированном React-компоненте.
* Пример: '/img/sprites'.
*/
types?: boolean
/**
* Куда складывать .generated.ts файлы.
* По умолчанию: рядом с исходными папками (в input).
*/
typesOutput?: string
/**
* Настройки трансформации SVG.
* По умолчанию: все трансформации включены.
*/
transform?: TransformOptions
publicPath?: string
/**
* Генерировать HTML-превью со всеми иконками.
* По умолчанию: true.
*/
preview?: boolean
/**
* Путь для генерации React-компонента.
* Если не задан — компонент и типы не генерируются.
*/
react?: string
/**
* Настройки трансформации SVG.
* По умолчанию: все трансформации включены.
*/
transform?: TransformOptions
/** Список спрайтов для генерации. */
sprites: SpriteEntry[]
}
/** Результат парсинга спрайта — данные для компиляции. */
export type SpriteFolder = {
/** Имя спрайта. */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Абсолютный путь к папке (для input-папки) или null (для input-массива). */
path: string | null
/** Абсолютные пути к SVG-файлам. */
files: string[]
}
/** Результат компиляции одного спрайта. */
export type SpriteResult = {
/** Имя спрайта. */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Путь к сгенерированному SVG-спрайту. */
spritePath: string
/** Количество иконок в спрайте. */
iconCount: number
}