feat: перейти на изолированный контракт генерации

- добавлены независимые adapters для шести exact modes
- runtime переведён на JavaScript с отдельными декларациями
- generated output перенесён в .svg-sprite
- удалены legacy pipeline и встроенный preview
This commit is contained in:
2026-07-13 20:07:12 +03:00
parent bc4bece3c5
commit f4f464a568
108 changed files with 2187 additions and 6613 deletions

43
src/api/next.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { SpriteGenerationBaseResult } from '../core/result.js'
import { generateSprite } from '../generate.js'
import type {
NextAssetTarget,
NextBundler,
NextRouter,
} from '../targets/types.js'
import type { SpriteConfig } from '../types.js'
/** @deprecated Используйте единый SpriteConfig. */
export type NextSpriteConfig = Omit<SpriteConfig, 'mode'> & {
mode?: NextAssetTarget
}
export type NextSpriteGenerationOptions = {
router: NextRouter
bundler: NextBundler
}
export type NextSpriteGenerationResult = SpriteGenerationBaseResult<
NextAssetTarget,
NextAssetTarget
> & {
router: NextRouter
bundler: NextBundler
}
/** Генерирует Next.js sprite-модуль для явно выбранных router и bundler. */
export async function generateNextSprite(
source: string,
options: NextSpriteGenerationOptions,
): Promise<NextSpriteGenerationResult> {
if (!options || (options.router !== 'app' && options.router !== 'pages')) {
throw new Error(`Unsupported Next.js router: ${String(options?.router)}`)
}
if (options.bundler !== 'turbopack' && options.bundler !== 'webpack') {
throw new Error(`Unsupported Next.js bundler: ${String(options.bundler)}`)
}
return generateSprite(source, {
mode: `next@${options.router}/${options.bundler}`,
}) as Promise<NextSpriteGenerationResult>
}

30
src/api/react.ts Normal file
View File

@@ -0,0 +1,30 @@
import { generateSprite } from '../generate.js'
import type { ReactAssetTarget, ReactSpriteMode } from '../targets/types.js'
import type { ResolvedSpriteConfig, SpriteConfig } from '../types.js'
import type { SpriteGenerationBaseResult } from '../core/result.js'
/** @deprecated Используйте единый SpriteConfig. */
export type ReactSpriteConfig = Omit<SpriteConfig, 'mode'> & {
mode?: ReactSpriteMode
}
/** @deprecated Используйте единый ResolvedSpriteConfig. */
export type ResolvedReactSpriteConfig = Omit<ResolvedSpriteConfig, 'mode'> & {
mode: ReactSpriteMode
}
export type ReactSpriteGenerationResult = SpriteGenerationBaseResult<
ReactSpriteMode,
ReactAssetTarget
>
/** Генерирует React sprite-модуль для явно выбранного asset target. */
export function generateReactSprite(
source: string,
target: ReactAssetTarget,
): Promise<ReactSpriteGenerationResult> {
if (target !== 'vite' && target !== 'webpack') {
throw new Error(`Unsupported React asset target: ${String(target)}`)
}
return generateSprite(source, { mode: `react@${target}` }) as Promise<ReactSpriteGenerationResult>
}

View File

@@ -1,17 +1,7 @@
#!/usr/bin/env node
import path from 'node:path'
import { CLI_USAGE, parseCliArgs } from './cli/parse-args.js'
import { generateSprite } from './generate.js'
import { log } from './logger.js'
import { generateNextSprite } from './modes/next/generate.js'
import { generateReactSprite } from './modes/react/generate.js'
import { loadLegacyConfig } from './modes/legacy/config.js'
import { generateLegacy } from './modes/legacy/generate.js'
async function runLegacy(spritePath: string): Promise<void> {
const rootDir = path.resolve(spritePath)
const config = await loadLegacyConfig(rootDir)
await generateLegacy(config)
}
async function main() {
try {
@@ -22,23 +12,7 @@ async function main() {
return
}
switch (args.mode) {
case 'legacy':
await runLegacy(args.path)
return
case 'react':
await generateReactSprite(args.path, args.target)
return
case 'next': {
const [, routerAndBundler] = args.target.split('@')
const [router, bundler] = routerAndBundler.split('/')
await generateNextSprite(args.path, {
router: router as 'app' | 'pages',
bundler: bundler as 'turbopack' | 'webpack',
})
return
}
}
await generateSprite(args.path, args.overrides)
} catch (error) {
log.error(error instanceof Error ? error.message : String(error))
process.exit(1)

View File

@@ -1,119 +1,143 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
import { isSpriteMode } from '../config.js'
import type { SpriteConfig, TransformOptions } from '../types.js'
import type { CliArgs } from './types.js'
const REACT_TARGETS = new Set<ReactAssetTarget>(['vite', 'webpack'])
const NEXT_TARGETS = new Set<NextAssetTarget>([
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
])
export const CLI_USAGE = [
'Usage:',
' svg-sprites --mode <mode> <path>',
' svg-sprites [options] <config-file-or-directory>',
'',
'Config files:',
' Any explicitly provided .js, .json or .ts file',
' A directory enables config-less generation from CLI options',
'',
'Modes:',
' legacy Generate sprites through the legacy pipeline',
' react@vite Generate a React module for Vite',
' react@webpack Generate a React module for Webpack 5',
' next@app/turbopack Generate an App Router module for Turbopack',
' next@app/webpack Generate an App Router module for Webpack 5',
' next@pages/turbopack Generate a Pages Router module for Turbopack',
' next@pages/webpack Generate a Pages Router module for Webpack 5',
' react@vite',
' react@webpack',
' next@app/turbopack',
' next@app/webpack',
' next@pages/turbopack',
' next@pages/webpack',
'',
'Options:',
' --mode <mode>',
' --name <name>',
' --description <text>',
' --input-folder <path>',
' --input-file <path> Repeat for multiple files',
' --[no-]remove-size',
' --[no-]replace-colors',
' --[no-]add-transition',
' --[no-]generated-notice',
].join('\n')
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) {
return { help: true }
function optionValue(argv: string[], index: number, option: string): [string, number] {
const argument = argv[index]
const inlinePrefix = `${option}=`
if (argument.startsWith(inlinePrefix)) {
const value = argument.slice(inlinePrefix.length)
if (!value) throw new Error(`Missing value for ${option}.`)
return [value, index]
}
let modeValue: string | undefined
const value = argv[index + 1]
if (!value || value.startsWith('-')) throw new Error(`Missing value for ${option}.`)
return [value, index + 1]
}
function setTransform(
overrides: SpriteConfig,
option: keyof TransformOptions,
value: boolean,
): void {
overrides.transform = {
...overrides.transform,
[option]: value,
}
}
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) return { help: true }
const positional: string[] = []
const overrides: SpriteConfig = {}
for (let index = 0; index < argv.length; index++) {
const argument = argv[index]
if (argument === '--mode') {
modeValue = argv[index + 1]
index++
if (argument === '--mode' || argument.startsWith('--mode=')) {
const [value, nextIndex] = optionValue(argv, index, '--mode')
if (!isSpriteMode(value)) throw new Error(`Unsupported sprite mode: ${value}.\n\n${CLI_USAGE}`)
overrides.mode = value
index = nextIndex
continue
}
if (argument === '--name' || argument.startsWith('--name=')) {
const [value, nextIndex] = optionValue(argv, index, '--name')
overrides.name = value
index = nextIndex
continue
}
if (argument === '--description' || argument.startsWith('--description=')) {
const [value, nextIndex] = optionValue(argv, index, '--description')
overrides.description = value
index = nextIndex
continue
}
if (argument === '--input-folder' || argument.startsWith('--input-folder=')) {
const [value, nextIndex] = optionValue(argv, index, '--input-folder')
overrides.inputFolder = value
index = nextIndex
continue
}
if (argument === '--input-file' || argument.startsWith('--input-file=')) {
const [value, nextIndex] = optionValue(argv, index, '--input-file')
overrides.inputFiles = [...(overrides.inputFiles ?? []), value]
index = nextIndex
continue
}
if (argument.startsWith('--mode=')) {
modeValue = argument.slice('--mode='.length)
continue
switch (argument) {
case '--remove-size':
setTransform(overrides, 'removeSize', true)
continue
case '--no-remove-size':
setTransform(overrides, 'removeSize', false)
continue
case '--replace-colors':
setTransform(overrides, 'replaceColors', true)
continue
case '--no-replace-colors':
setTransform(overrides, 'replaceColors', false)
continue
case '--add-transition':
setTransform(overrides, 'addTransition', true)
continue
case '--no-add-transition':
setTransform(overrides, 'addTransition', false)
continue
case '--generated-notice':
overrides.generatedNotice = true
continue
case '--no-generated-notice':
overrides.generatedNotice = false
continue
}
if (argument.startsWith('-')) {
throw new Error(`Unknown argument: ${argument}\n\n${CLI_USAGE}`)
}
positional.push(argument)
}
if (!modeValue) {
throw new Error(`Missing required argument: --mode\n\n${CLI_USAGE}`)
}
if (positional.length === 0) {
throw new Error(`Missing sprite path.\n\n${CLI_USAGE}`)
throw new Error(`Missing sprite config file or module directory.\n\n${CLI_USAGE}`)
}
if (positional.length > 1) {
throw new Error(`Expected one sprite path, received: ${positional.join(', ')}`)
throw new Error(`Expected one config file or module directory, received: ${positional.join(', ')}`)
}
if (modeValue === 'legacy') {
return {
mode: 'legacy',
path: positional[0],
}
return {
path: positional[0],
overrides,
}
if (modeValue === 'react') {
throw new Error(
'React mode requires a target. Supported: react@vite, react@webpack.',
)
}
if (modeValue.startsWith('react@')) {
const target = modeValue.slice('react@'.length)
if (!REACT_TARGETS.has(target as ReactAssetTarget)) {
throw new Error(
`Unsupported React target: ${target}. Supported: ${[...REACT_TARGETS].join(', ')}.`,
)
}
return {
mode: 'react',
path: positional[0],
target: target as ReactAssetTarget,
}
}
if (modeValue === 'next') {
throw new Error(
`Next.js mode requires a router and bundler. Supported: ${[...NEXT_TARGETS].join(', ')}.`,
)
}
if (modeValue.startsWith('next@')) {
if (!NEXT_TARGETS.has(modeValue as NextAssetTarget)) {
throw new Error(
`Unsupported Next.js target: ${modeValue}. Supported: ${[...NEXT_TARGETS].join(', ')}.`,
)
}
return {
mode: 'next',
path: positional[0],
target: modeValue as NextAssetTarget,
}
}
throw new Error(
`Unknown mode: ${modeValue}\nSupported modes: legacy, react@vite, react@webpack, ${[...NEXT_TARGETS].join(', ')}`,
)
}

View File

@@ -1,26 +1,6 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
import type { SpriteConfig } from '../types.js'
/** Корневой режим генерации, определяющий структуру создаваемых файлов. */
export type GenerationMode = 'legacy' | 'next' | 'react'
/** Аргументы legacy pipeline. */
export type LegacyCliArgs = {
mode: 'legacy'
export type CliArgs = {
path: string
overrides: SpriteConfig
}
/** Аргументы React pipeline с обязательной средой обработки SVG asset. */
export type ReactCliArgs = {
mode: 'react'
path: string
target: ReactAssetTarget
}
/** Аргументы Next.js pipeline с явно выбранными роутером и сборщиком. */
export type NextCliArgs = {
mode: 'next'
path: string
target: NextAssetTarget
}
export type CliArgs = LegacyCliArgs | NextCliArgs | ReactCliArgs

232
src/config.ts Normal file
View File

@@ -0,0 +1,232 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import { toKebabCase, validateSpriteName } from './core/naming.js'
import type { SpriteMode } from './targets/types.js'
import type { ResolvedSpriteConfig, SpriteConfig, TransformOptions } from './types.js'
const CONFIG_EXTENSIONS = new Set(['.js', '.json', '.ts'])
const CONFIG_FIELDS = new Set([
'mode',
'name',
'description',
'inputFolder',
'inputFiles',
'transform',
'generatedNotice',
])
const TRANSFORM_FIELDS = new Set(['removeSize', 'replaceColors', 'addTransition'])
const MODES = new Set<SpriteMode>([
'react@vite',
'react@webpack',
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
])
export type SpriteConfigSource = {
rootDir: string
configPath: string | null
config: SpriteConfig
}
export function isSpriteMode(value: unknown): value is SpriteMode {
return typeof value === 'string' && MODES.has(value as SpriteMode)
}
function getDefaultName(rootDir: string): string {
const rootName = path.basename(rootDir)
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
? path.basename(path.dirname(rootDir))
: rootName
const name = toKebabCase(source)
if (!name) throw new Error(`Cannot infer sprite name from directory: ${rootDir}`)
return name
}
function configError(message: string): Error {
return new Error(`Sprite config: ${message}`)
}
/** Проверяет единый конфиг независимо от формата исходного файла. */
export function validateSpriteConfig(value: unknown): asserts value is SpriteConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw configError('expected an object.')
}
const config = value as Record<string, unknown>
if ('output' in config || 'sprites' in config || 'preview' in config) {
throw configError('legacy config fields are no longer supported.')
}
for (const field of Object.keys(config)) {
if (!CONFIG_FIELDS.has(field)) throw configError(`unknown field "${field}".`)
}
if (config.mode !== undefined && !isSpriteMode(config.mode)) {
throw configError(`unsupported "mode": ${String(config.mode)}.`)
}
if (config.name !== undefined && typeof config.name !== 'string') {
throw configError('"name" must be a string.')
}
if (config.description !== undefined && typeof config.description !== 'string') {
throw configError('"description" must be a string.')
}
if ('icons' in config) {
throw configError('"icons" was renamed to "inputFolder".')
}
if (config.inputFolder !== undefined && (
typeof config.inputFolder !== 'string' || config.inputFolder.trim() === ''
)) {
throw configError('"inputFolder" must be a non-empty string.')
}
if (config.inputFiles !== undefined && (
!Array.isArray(config.inputFiles)
|| config.inputFiles.some((filePath) => typeof filePath !== 'string' || filePath.trim() === '')
)) {
throw configError('"inputFiles" must be an array of non-empty strings.')
}
if (config.transform !== undefined) {
if (
config.transform === null
|| typeof config.transform !== 'object'
|| Array.isArray(config.transform)
) {
throw configError('"transform" must be an object.')
}
const transform = config.transform as Record<string, unknown>
for (const field of Object.keys(transform)) {
if (!TRANSFORM_FIELDS.has(field)) {
throw configError(`unknown field "transform.${field}".`)
}
}
for (const option of ['removeSize', 'replaceColors', 'addTransition']) {
if (transform[option] !== undefined && typeof transform[option] !== 'boolean') {
throw configError(`"transform.${option}" must be a boolean.`)
}
}
}
if (config.generatedNotice !== undefined && typeof config.generatedNotice !== 'boolean') {
throw configError('"generatedNotice" must be a boolean.')
}
}
function getModuleDefault(value: unknown, configPath: string): unknown {
if (!value || typeof value !== 'object' || !('default' in value)) {
throw new Error(
`Sprite config file must have a default export: ${configPath}\n`
+ 'Use: export default defineSpriteConfig({ ... })',
)
}
return (value as { default: unknown }).default
}
/** Загружает явно указанный JS, JSON или TS config-файл. */
export async function loadSpriteConfig(configFile: string): Promise<SpriteConfig> {
const configPath = path.resolve(configFile)
const extension = path.extname(configPath).toLowerCase()
if (!CONFIG_EXTENSIONS.has(extension)) {
throw new Error(`Unsupported sprite config extension: ${extension || '(none)'}. Supported: .js, .json, .ts.`)
}
if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
throw new Error(`Sprite config file does not exist: ${configPath}`)
}
let config: unknown
if (extension === '.json') {
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`Cannot parse sprite config: ${configPath}\n${reason}`)
}
} else {
const jiti = createJiti(path.dirname(configPath))
config = getModuleDefault(await jiti.import(configPath), configPath)
}
validateSpriteConfig(config)
return config
}
/** Разрешает позиционный путь как config-файл либо config-less корень модуля. */
export async function resolveSpriteConfigSource(source: string): Promise<SpriteConfigSource> {
const resolved = path.resolve(source)
if (!fs.existsSync(resolved)) {
throw new Error(`Sprite config or module directory does not exist: ${resolved}`)
}
const stats = fs.statSync(resolved)
if (stats.isDirectory()) {
return { rootDir: resolved, configPath: null, config: {} }
}
if (!stats.isFile()) {
throw new Error(`Sprite config path must be a file or directory: ${resolved}`)
}
return {
rootDir: path.dirname(resolved),
configPath: resolved,
config: await loadSpriteConfig(resolved),
}
}
function definedEntries<T extends object>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined),
) as Partial<T>
}
/** Объединяет defaults, config и overrides и разрешает пути от корня модуля. */
export function resolveSpriteConfig(
rootDir: string,
config: SpriteConfig = {},
overrides: SpriteConfig = {},
): ResolvedSpriteConfig {
validateSpriteConfig(config)
validateSpriteConfig(overrides)
const configValues = definedEntries(config)
const overrideValues = definedEntries(overrides)
const transform: TransformOptions = {
...config.transform,
...overrides.transform,
}
const merged: SpriteConfig = {
...configValues,
...overrideValues,
transform,
}
if (!merged.mode) {
throw new Error('Sprite mode is required. Set "mode" in the config or pass it through CLI/API.')
}
const name = merged.name ?? getDefaultName(rootDir)
validateSpriteName(name)
const inputFiles = (merged.inputFiles ?? []).map((filePath) => path.resolve(rootDir, filePath))
const defaultInputFolder = path.resolve(rootDir, 'icons')
const inputFolder = merged.inputFolder === undefined
&& inputFiles.length > 0
&& !fs.existsSync(defaultInputFolder)
? null
: path.resolve(rootDir, merged.inputFolder ?? 'icons')
return {
mode: merged.mode,
name,
description: merged.description,
inputFolder,
inputFiles,
transform: {
removeSize: transform.removeSize ?? true,
replaceColors: transform.replaceColors ?? true,
addTransition: transform.addTransition ?? true,
},
generatedNotice: merged.generatedNotice ?? true,
}
}

View File

@@ -0,0 +1,70 @@
import { getSpriteShapeId } from '../shape-id.js'
import type { SpriteFormat } from '../types.js'
export type CompiledIconColor = {
readonly variable: string
readonly fallback: string
}
export type CompiledIcon = {
readonly name: string
readonly id: string
readonly viewBox: string | null
readonly colors: readonly CompiledIconColor[]
}
export type CompiledSpriteArtifact = {
readonly format: SpriteFormat
readonly bytes: Uint8Array
readonly icons: readonly CompiledIcon[]
}
function extractColors(fragment: string): CompiledIconColor[] {
const colors = new Map<string, string>()
const regex = /var\((--icon-color-\d+),\s*((?:[^()]|\([^()]*\))*)\)/g
let match: RegExpExecArray | null
while ((match = regex.exec(fragment)) !== null) {
if (!colors.has(match[1])) colors.set(match[1], match[2].trim())
}
return [...colors].map(([variable, fallback]) => ({ variable, fallback }))
}
/** Преобразует compiled SVG в нейтральный artifact для adapter codegen. */
export function createCompiledSpriteArtifact(
bytes: Uint8Array,
iconNames: readonly string[],
format: SpriteFormat,
): CompiledSpriteArtifact {
const shapes = new Map<string, { viewBox: string | null; fragment: string }>()
const content = new TextDecoder().decode(bytes)
const shapeRegex = /<(symbol|svg)\b((?=[^>]*\bid="[^"]+")[^>]*)>[\s\S]*?<\/\1>/g
let match: RegExpExecArray | null
while ((match = shapeRegex.exec(content)) !== null) {
const attributes = match[2]
const id = attributes.match(/\bid="([^"]+)"/)?.[1]
if (!id) continue
shapes.set(id, {
viewBox: attributes.match(/\bviewBox="([^"]+)"/)?.[1] ?? null,
fragment: match[0],
})
}
const icons = iconNames.map((name) => {
const id = getSpriteShapeId(name)
const shape = shapes.get(id)
if (!shape) throw new Error(`Cannot find SVG shape "${id}" for icon "${name}".`)
return {
name,
id,
viewBox: shape.viewBox,
colors: extractColors(shape.fragment),
}
})
return { format, bytes, icons }
}

View File

@@ -0,0 +1,3 @@
export const GENERATOR = '@gromlab/svg-sprites'
export const GENERATED_MARKER = '@generated by @gromlab/svg-sprites'
export const GENERATED_NOTICE_MARKER = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'

53
src/core/mode-adapter.ts Normal file
View File

@@ -0,0 +1,53 @@
import type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
SpriteMode,
} from '../targets/types.js'
import type { ResolvedSpriteConfig, SpriteFolder } from '../types.js'
export type GeneratedFile = {
readonly path: string
readonly content: string | Uint8Array
}
export type PreparedSprite = {
readonly folder: SpriteFolder
readonly iconNames: readonly string[]
}
export type ModeAdapterContext = {
readonly rootDir: string
readonly config: ResolvedSpriteConfig
readonly prepared: PreparedSprite
}
export type ReactModeResultMetadata = {
readonly target: ReactAssetTarget
}
export type NextModeResultMetadata = {
readonly target: NextAssetTarget
readonly router: NextRouter
readonly bundler: NextBundler
}
export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadata
export type OutputPlan = {
readonly files: readonly GeneratedFile[]
readonly paths: {
readonly generatedDir: '.svg-sprite'
readonly sprite: string
readonly manifest: string
readonly entry: string
}
readonly result: ModeResultMetadata
}
export interface ModeAdapter<M extends SpriteMode = SpriteMode> {
readonly mode: M
readonly contractVersion: number
generate(context: ModeAdapterContext): Promise<OutputPlan>
}

17
src/core/naming.ts Normal file
View File

@@ -0,0 +1,17 @@
/** Преобразует имя каталога в kebab-case имя спрайта. */
export function toKebabCase(value: string): string {
return value
.normalize('NFKD')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase()
}
export function validateSpriteName(name: string): void {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) {
throw new Error(
`Sprite config: "name" must be kebab-case and start with a letter. Received: "${name}".`,
)
}
}

297
src/core/output-writer.ts Normal file
View File

@@ -0,0 +1,297 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteMode } from '../targets/types.js'
import type { GeneratedFile } from './mode-adapter.js'
import {
GENERATED_MARKER,
GENERATED_NOTICE_MARKER,
GENERATOR,
} from './generated-markers.js'
const STATE_PATH = '.svg-sprite/state.json'
const PREVIOUS_STATE_PATHS = [
'.svg-sprite-data/state.json',
'generated/state.json',
] as const
const LEGACY_STATE_PATH = 'generated/.svg-sprites.manifest.json'
const DATA_ROOT = '.svg-sprite'
const PREVIOUS_DATA_ROOT = '.svg-sprite-data'
const SYSTEM_FILES: readonly GeneratedFile[] = [
{
path: '.gitignore',
content: `# ${GENERATED_MARKER}. Do not edit.\n/${DATA_ROOT}/\n`,
},
]
type OutputState = {
schemaVersion: 1
generator: typeof GENERATOR
owner: {
mode: SpriteMode
contractVersion: number
}
files: string[]
warning?: string
}
type LegacyState = {
version: 1
generator: typeof GENERATOR
files: string[]
}
function normalizeManagedPath(relativePath: string): string {
const normalized = relativePath.replaceAll('\\', '/')
const parts = normalized.split('/')
if (
normalized === ''
|| path.posix.isAbsolute(normalized)
|| parts.some((part) => part === '' || part === '.' || part === '..')
|| normalized === STATE_PATH
|| PREVIOUS_STATE_PATHS.includes(normalized as typeof PREVIOUS_STATE_PATHS[number])
|| normalized === LEGACY_STATE_PATH
) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return normalized
}
function resolveManagedPath(rootDir: string, relativePath: string): string {
const normalized = normalizeManagedPath(relativePath)
const resolved = path.resolve(rootDir, normalized)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return resolved
}
function resolveInternalPath(rootDir: string, relativePath: string): string {
const resolved = path.resolve(rootDir, relativePath)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid internal output path: ${relativePath}`)
}
return resolved
}
function assertNoSymlinks(rootDir: string, filePath: string): void {
const relative = path.relative(rootDir, filePath)
let currentPath = rootDir
for (const segment of relative.split(path.sep)) {
currentPath = path.join(currentPath, segment)
try {
if (fs.lstatSync(currentPath).isSymbolicLink()) {
throw new Error(`Symbolic links are not allowed in generated paths: ${currentPath}`)
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw error
}
}
}
function parseJsonFile(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
} catch {
throw new Error(`Cannot parse generated state: ${filePath}`)
}
}
function readOutputState(statePath: string): OutputState {
const state = parseJsonFile(statePath)
if (
!state
|| typeof state !== 'object'
|| !('schemaVersion' in state)
|| state.schemaVersion !== 1
|| !('generator' in state)
|| state.generator !== GENERATOR
|| !('owner' in state)
|| !state.owner
|| typeof state.owner !== 'object'
|| !('mode' in state.owner)
|| typeof state.owner.mode !== 'string'
|| !('contractVersion' in state.owner)
|| typeof state.owner.contractVersion !== 'number'
|| !('files' in state)
|| !Array.isArray(state.files)
|| !state.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated state: ${statePath}`)
}
return state as OutputState
}
function readPreviousFiles(rootDir: string): {
files: string[]
obsoleteStatePath: string | null
} {
for (const relativeStatePath of [STATE_PATH, ...PREVIOUS_STATE_PATHS]) {
const statePath = resolveInternalPath(rootDir, relativeStatePath)
assertNoSymlinks(rootDir, statePath)
if (!fs.existsSync(statePath)) continue
return {
files: readOutputState(statePath).files.map(normalizeManagedPath),
obsoleteStatePath: relativeStatePath === STATE_PATH ? null : statePath,
}
}
const legacyStatePath = resolveInternalPath(rootDir, LEGACY_STATE_PATH)
assertNoSymlinks(rootDir, legacyStatePath)
if (!fs.existsSync(legacyStatePath)) return { files: [], obsoleteStatePath: null }
const legacyState = parseJsonFile(legacyStatePath)
if (
!legacyState
|| typeof legacyState !== 'object'
|| !('version' in legacyState)
|| legacyState.version !== 1
|| !('generator' in legacyState)
|| legacyState.generator !== GENERATOR
|| !('files' in legacyState)
|| !Array.isArray(legacyState.files)
|| !legacyState.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated state: ${legacyStatePath}`)
}
return {
files: (legacyState as LegacyState).files.map(normalizeManagedPath),
obsoleteStatePath: legacyStatePath,
}
}
function removeEmptyStateDirectory(rootDir: string, statePath: string): void {
const directory = path.dirname(statePath)
if (directory === rootDir) return
try {
fs.rmdirSync(directory)
} catch (error) {
if (error instanceof Error && 'code' in error && (
error.code === 'ENOENT' || error.code === 'ENOTEMPTY'
)) return
throw error
}
}
function removeEmptyManagedParents(rootDir: string, filePath: string): void {
for (const relativeDataRoot of [DATA_ROOT, PREVIOUS_DATA_ROOT]) {
const dataRoot = resolveInternalPath(rootDir, relativeDataRoot)
let directory = path.dirname(filePath)
const relative = path.relative(dataRoot, directory)
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
while (directory !== dataRoot) {
try {
fs.rmdirSync(directory)
} catch (error) {
if (error instanceof Error && 'code' in error && (
error.code === 'ENOENT' || error.code === 'ENOTEMPTY'
)) return
throw error
}
directory = path.dirname(directory)
}
return
}
}
function hasGeneratedMarker(filePath: string): boolean {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
const content = fs.readFileSync(filePath, 'utf-8')
return content.includes(GENERATED_MARKER) || content.includes(GENERATED_NOTICE_MARKER)
}
function writeFileAtomic(rootDir: string, filePath: string, content: string | Uint8Array): void {
assertNoSymlinks(rootDir, filePath)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
assertNoSymlinks(rootDir, filePath)
const temporaryPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
)
try {
fs.writeFileSync(temporaryPath, content, { flag: 'wx' })
fs.renameSync(temporaryPath, filePath)
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath)
}
}
/** Применяет mode output plan и последним записывает ownership state. */
export function writeOutputPlan(
rootDir: string,
mode: SpriteMode,
contractVersion: number,
files: readonly GeneratedFile[],
generatedNotice: boolean,
): void {
const normalizedFiles = [...SYSTEM_FILES, ...files].map((file) => ({
...file,
path: normalizeManagedPath(file.path),
}))
const nextFiles = normalizedFiles.map((file) => file.path)
if (new Set(nextFiles).size !== nextFiles.length) {
throw new Error(`Mode "${mode}" produced duplicate generated file paths.`)
}
const previous = readPreviousFiles(rootDir)
const obsoleteFiles: string[] = []
for (const file of normalizedFiles) {
const filePath = resolveManagedPath(rootDir, file.path)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath) && !hasGeneratedMarker(filePath)) {
throw new Error(
`Refusing to overwrite a user file: ${filePath}\n`
+ 'Move the file or choose another sprite directory.',
)
}
}
for (const relativePath of previous.files) {
if (nextFiles.includes(relativePath)) continue
const filePath = resolveManagedPath(rootDir, relativePath)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath)) {
if (!hasGeneratedMarker(filePath)) {
throw new Error(`Refusing to delete a user file: ${filePath}`)
}
obsoleteFiles.push(filePath)
}
}
for (const file of normalizedFiles) {
writeFileAtomic(rootDir, resolveManagedPath(rootDir, file.path), file.content)
}
for (const filePath of obsoleteFiles) fs.unlinkSync(filePath)
for (const filePath of obsoleteFiles) removeEmptyManagedParents(rootDir, filePath)
if (previous.obsoleteStatePath && fs.existsSync(previous.obsoleteStatePath)) {
fs.unlinkSync(previous.obsoleteStatePath)
removeEmptyStateDirectory(rootDir, previous.obsoleteStatePath)
}
const state: OutputState = {
schemaVersion: 1,
generator: GENERATOR,
owner: { mode, contractVersion },
files: nextFiles,
...(generatedNotice && {
warning: 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную: изменения будут перезаписаны.',
}),
}
const statePath = resolveInternalPath(rootDir, STATE_PATH)
writeFileAtomic(rootDir, statePath, `${JSON.stringify(state, null, 2)}\n`)
}

View File

@@ -0,0 +1,38 @@
import path from 'node:path'
import { resolveSpriteSources } from '../scanner.js'
import { getSpriteShapeId } from '../shape-id.js'
import type { ResolvedSpriteConfig } from '../types.js'
import type { PreparedSprite } from './mode-adapter.js'
function validateIconIds(iconNames: readonly string[]): void {
const namesById = new Map<string, string>()
for (const iconName of iconNames) {
const id = getSpriteShapeId(iconName)
const existingName = namesById.get(id)
if (existingName) {
throw new Error(
`Icons "${existingName}" and "${iconName}" produce the same SVG id "${id}". Rename one of the files.`,
)
}
namesById.set(id, iconName)
}
}
/** Подготавливает mode-neutral набор исходников. */
export function prepareSprite(config: ResolvedSpriteConfig): PreparedSprite {
const folder = resolveSpriteSources({
name: config.name,
format: 'stack',
inputFolder: config.inputFolder,
inputFiles: config.inputFiles,
})
const iconNames = folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
validateIconIds(iconNames)
return { folder, iconNames }
}

15
src/core/result.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { SpriteAssetTarget, SpriteMode } from '../targets/types.js'
export type SpriteGenerationBaseResult<
TMode extends SpriteMode = SpriteMode,
TTarget extends SpriteAssetTarget = SpriteAssetTarget,
> = {
name: string
rootDir: string
generatedDir: string
spritePath: string
manifestPath: string
iconCount: number
mode: TMode
target: TTarget
}

65
src/generate.ts Normal file
View File

@@ -0,0 +1,65 @@
import path from 'node:path'
import {
resolveSpriteConfig,
resolveSpriteConfigSource,
} from './config.js'
import type { ModeResultMetadata } from './core/mode-adapter.js'
import { writeOutputPlan } from './core/output-writer.js'
import { prepareSprite } from './core/prepare-sprite.js'
import type { SpriteGenerationBaseResult } from './core/result.js'
import { log } from './logger.js'
import { getModeAdapter } from './mode-registry.js'
import type { SpriteConfig } from './types.js'
export type SpriteGenerationResult = SpriteGenerationBaseResult & ModeResultMetadata
/** Генерирует один output через adapter разрешённого exact mode. */
export async function generateSprite(
source: string,
overrides: SpriteConfig = {},
): Promise<SpriteGenerationResult> {
const resolvedSource = await resolveSpriteConfigSource(source)
const config = resolveSpriteConfig(
resolvedSource.rootDir,
resolvedSource.config,
overrides,
)
const adapter = getModeAdapter(config.mode)
const prepared = prepareSprite(config)
const plan = await adapter.generate({
rootDir: resolvedSource.rootDir,
config,
prepared,
})
const plannedPaths = new Set(plan.files.map((file) => file.path.replaceAll('\\', '/')))
for (const requiredPath of [plan.paths.entry, plan.paths.sprite, plan.paths.manifest]) {
if (!plannedPaths.has(requiredPath)) {
throw new Error(`Mode "${config.mode}" result path is missing from its output plan: ${requiredPath}`)
}
}
writeOutputPlan(
resolvedSource.rootDir,
adapter.mode,
adapter.contractVersion,
plan.files,
config.generatedNotice,
)
const generatedDir = path.resolve(resolvedSource.rootDir, plan.paths.generatedDir)
const iconLabel = prepared.iconNames.length === 1 ? 'icon' : 'icons'
log.success(`${config.name} · ${prepared.iconNames.length} ${iconLabel} · ${config.mode}`)
log.detail(`${path.relative(process.cwd(), generatedDir)}`)
return {
name: config.name,
rootDir: resolvedSource.rootDir,
generatedDir,
spritePath: path.resolve(resolvedSource.rootDir, plan.paths.sprite),
manifestPath: path.resolve(resolvedSource.rootDir, plan.paths.manifest),
iconCount: prepared.iconNames.length,
mode: config.mode,
...plan.result,
}
}

View File

@@ -1,33 +1,36 @@
import type { SvgSpritesConfig } from './types.js'
import type { NextSpriteConfig } from './modes/next/types.js'
import type { ReactSpriteConfig } from './modes/react/types.js'
import type { NextSpriteConfig } from './api/next.js'
import type { ReactSpriteConfig } from './api/react.js'
import type { SpriteConfig } from './types.js'
export { generateLegacy } from './modes/legacy/generate.js'
export { resolveSprites, resolveSpriteEntry } from './scanner.js'
export {
isSpriteMode,
loadSpriteConfig,
resolveSpriteConfig,
resolveSpriteConfigSource,
validateSpriteConfig,
} from './config.js'
export { generateSprite } from './generate.js'
export type { SpriteGenerationResult } from './generate.js'
export { generateNextSprite } from './api/next.js'
export { generateReactSprite } from './api/react.js'
export { compileSprite, compileSpriteContent } from './compiler.js'
export type { CompileSpriteOptions } from './compiler.js'
export { createShapeTransform } from './transforms.js'
export { generatePreview } from './preview.js'
export { loadLegacyConfig } from './modes/legacy/config.js'
export { generateNextSprite } from './modes/next/index.js'
export {
generateReactSprite,
loadReactSpriteConfig,
} from './modes/react/index.js'
export type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
ReactSpriteMode,
SpriteAssetTarget,
SpriteMode,
ViteAssetTarget,
WebpackAssetTarget,
} from './targets/types.js'
export type {
SvgSpritesConfig,
SpriteEntry,
SpriteResult,
ResolvedSpriteConfig,
SpriteConfig,
SpriteFolder,
SpriteFormat,
TransformOptions,
@@ -36,24 +39,24 @@ export type {
NextSpriteConfig,
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './modes/next/types.js'
} from './api/next.js'
export type {
ReactSpriteConfig,
ReactSpriteGenerationResult,
ResolvedReactSpriteConfig,
} from './modes/react/types.js'
} from './api/react.js'
/** Хелпер для типизации legacy-конфига. */
export function defineLegacyConfig(config: SvgSpritesConfig): SvgSpritesConfig {
/** Хелпер для типизации единого sprite-конфига. */
export function defineSpriteConfig(config: SpriteConfig): SpriteConfig {
return config
}
/** Хелпер для типизации локального React-конфига. */
/** @deprecated Используйте defineSpriteConfig. */
export function defineReactSpriteConfig(config: ReactSpriteConfig): ReactSpriteConfig {
return config
}
/** Хелпер для типизации локального Next.js-конфига. */
/** @deprecated Используйте defineSpriteConfig. */
export function defineNextSpriteConfig(config: NextSpriteConfig): NextSpriteConfig {
return config
}

21
src/mode-registry.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { ModeAdapter } from './core/mode-adapter.js'
import { nextAppTurbopackAdapter } from './modes/next-app-turbopack/adapter.js'
import { nextAppWebpackAdapter } from './modes/next-app-webpack/adapter.js'
import { nextPagesTurbopackAdapter } from './modes/next-pages-turbopack/adapter.js'
import { nextPagesWebpackAdapter } from './modes/next-pages-webpack/adapter.js'
import { reactViteAdapter } from './modes/react-vite/adapter.js'
import { reactWebpackAdapter } from './modes/react-webpack/adapter.js'
import type { SpriteMode } from './targets/types.js'
const modeRegistry: Record<SpriteMode, ModeAdapter> = {
'react@vite': reactViteAdapter,
'react@webpack': reactWebpackAdapter,
'next@app/turbopack': nextAppTurbopackAdapter,
'next@app/webpack': nextAppWebpackAdapter,
'next@pages/turbopack': nextPagesTurbopackAdapter,
'next@pages/webpack': nextPagesWebpackAdapter,
}
export function getModeAdapter(mode: SpriteMode): ModeAdapter {
return modeRegistry[mode]
}

View File

@@ -1,74 +0,0 @@
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'
/** Загружает legacy-конфиг из указанной директории. */
export async function loadLegacyConfig(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 defineLegacyConfig({ ... })',
)
}
validateLegacyConfig(config)
return {
...config,
output: path.resolve(cwd, config.output),
sprites: config.sprites.map((sprite) => ({
...sprite,
input: Array.isArray(sprite.input)
? sprite.input.map((filePath) => path.resolve(cwd, filePath))
: path.resolve(cwd, sprite.input),
})),
}
}
/** Валидирует legacy-конфиг. */
export function validateLegacyConfig(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 ('mode' in sprite) {
throw new Error(
`Config: sprite "${sprite.name}" uses deprecated "mode". Use "format" instead.`,
)
}
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.format && sprite.format !== 'stack' && sprite.format !== 'symbol') {
throw new Error(
`Config: sprite "${sprite.name}" has invalid format "${sprite.format}". Supported: stack, symbol.`,
)
}
}
}

View File

@@ -1,52 +0,0 @@
import path from 'node:path'
import { compileSprite } from '../../compiler.js'
import { log } from '../../logger.js'
import { generatePreview } from '../../preview.js'
import { resolveSprites } from '../../scanner.js'
import type { SpriteResult, SvgSpritesConfig } from '../../types.js'
/** Генерирует SVG-спрайты через legacy pipeline. */
export async function generateLegacy(config: SvgSpritesConfig): Promise<SpriteResult[]> {
const {
output,
preview = true,
transform = {},
sprites,
} = config
const outputDir = path.resolve(output)
log.title('Resolving legacy sprites...')
const folders = resolveSprites(sprites)
if (folders.length === 0) {
log.warn('No sprites to generate.')
return []
}
log.info(`Found ${folders.length} sprite(s)\n`)
const results: SpriteResult[] = []
for (const folder of folders) {
const spritePath = await compileSprite(folder, outputDir, transform)
log.success(` [${folder.format}] ${folder.name}${path.relative(process.cwd(), spritePath)} (${folder.files.length} icons)`)
results.push({
name: folder.name,
format: folder.format,
spritePath,
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
}

View File

@@ -1,2 +0,0 @@
export { loadLegacyConfig, validateLegacyConfig } from './config.js'
export { generateLegacy } from './generate.js'

View File

@@ -0,0 +1,26 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextAppTurbopackAdapter: ModeAdapter<'next@app/turbopack'> = {
mode: 'next@app/turbopack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: true,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',
manifest: '.svg-sprite/svg-sprite.manifest.js',
entry: '.svg-sprite/index.js',
},
result: { target: 'next@app/turbopack', router: 'app', bundler: 'turbopack' },
}
},
}

View File

@@ -0,0 +1,170 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@app/turbopack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function pascal(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function camel(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function header(enabled: boolean): string {
return enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function comment(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
const names = artifact.icons.map((icon) => icon.name)
const ids = Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id]))
return [header(config.generatedNotice), '', ...comment(config), `export const ${prefix}IconNames = ${JSON.stringify(names, null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(ids, null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
"import { jsx } from 'react/jsx-runtime'",
"import styles from './react-component.module.css'",
`import { ${ids} } from '../icon-data.js'`,
'',
"const spriteUrl = new URL('../sprite.svg', import.meta.url).href",
'',
`export const ${componentName} = (props) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = spriteUrl + '#' + ${ids}[icon]`,
' if (wrapped) {',
" return jsx('span', {",
' ...rest,',
" className: [styles.wrap, className].filter(Boolean).join(' '),",
" children: jsx('svg', { children: jsx('use', { href }) }),",
' })',
' }',
" return jsx('svg', {",
' ...rest,',
" className: [styles.root, className].filter(Boolean).join(' '),",
" children: jsx('use', { href }),",
' })',
'}',
'',
].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'',
...comment(config),
`export declare const ${names}: readonly [`,
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
']',
`export type ${typeName} = typeof ${names}[number]`,
`export declare const ${ids}: {`,
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
'}',
'',
].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice),
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
`import type { ${typeName} } from '../icon-data.js'`,
'',
`export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
`type ${componentName}BaseProps = { icon: ${typeName} }`,
`type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`,
`type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`,
`export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`,
`export declare const ${componentName}: (props: ${propsName}) => ReactElement`,
'',
].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [
header(config.generatedNotice),
`export { ${componentName} } from './react/react-component.js'`,
`export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`,
`export { ${names} } from './icon-data.js'`,
`export type { ${componentName}Name } from './icon-data.js'`,
'',
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
componentName: `${pascal(config.name)}Icon`,
mode: MODE,
target: MODE,
format: artifact.format,
iconCount: artifact.icons.length,
spriteUrl: '__SPRITE_URL__',
icons: artifact.icons,
}
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
}
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -0,0 +1,18 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextAppWebpackAdapter: ModeAdapter<'next@app/webpack'> = {
mode: 'next@app/webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
result: { target: 'next@app/webpack', router: 'app', bundler: 'webpack' },
}
},
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@app/webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
}
function docs(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const name = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -0,0 +1,18 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextPagesTurbopackAdapter: ModeAdapter<'next@pages/turbopack'> = {
mode: 'next@pages/turbopack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
result: { target: 'next@pages/turbopack', router: 'pages', bundler: 'turbopack' },
}
},
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@pages/turbopack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
}
function docs(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const name = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -0,0 +1,18 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextPagesWebpackAdapter: ModeAdapter<'next@pages/webpack'> = {
mode: 'next@pages/webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
result: { target: 'next@pages/webpack', router: 'pages', bundler: 'webpack' },
}
},
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@pages/webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
}
function docs(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const name = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -1,32 +0,0 @@
import type { NextAssetTarget } from '../../targets/types.js'
import { generateSpriteModule } from '../react/module-generator.js'
import type {
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './types.js'
/** Генерирует Next.js sprite-модуль для явно выбранных роутера и сборщика. */
export async function generateNextSprite(
root: string,
options: NextSpriteGenerationOptions,
): Promise<NextSpriteGenerationResult> {
if (!options || (options.router !== 'app' && options.router !== 'pages')) {
throw new Error(`Unsupported Next.js router: ${String(options?.router)}`)
}
if (options.bundler !== 'turbopack' && options.bundler !== 'webpack') {
throw new Error(`Unsupported Next.js bundler: ${String(options.bundler)}`)
}
const { router, bundler } = options
const target: NextAssetTarget = `next@${router}/${bundler}`
const result = await generateSpriteModule(root, target, {
mode: target,
rootViewBox: true,
})
return {
...result,
router,
bundler,
}
}

View File

@@ -1,6 +0,0 @@
export { generateNextSprite } from './generate.js'
export type {
NextSpriteConfig,
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './types.js'

View File

@@ -1,22 +0,0 @@
import type {
NextAssetTarget,
NextBundler,
NextRouter,
} from '../../targets/types.js'
import type {
ReactSpriteConfig,
SpriteModuleGenerationResult,
} from '../react/types.js'
/** Конфигурация Next.js sprite-модуля. Совпадает с React-конфигурацией. */
export type NextSpriteConfig = ReactSpriteConfig
export type NextSpriteGenerationOptions = {
router: NextRouter
bundler: NextBundler
}
export type NextSpriteGenerationResult = SpriteModuleGenerationResult<NextAssetTarget> & {
router: NextRouter
bundler: NextBundler
}

View File

@@ -0,0 +1,29 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const reactViteAdapter: ModeAdapter<'react@vite'> = {
mode: 'react@vite',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',
manifest: '.svg-sprite/svg-sprite.manifest.js',
entry: '.svg-sprite/index.js',
},
result: { target: 'vite' },
}
},
}

View File

@@ -0,0 +1,268 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'react@vite'
const TARGET = 'vite'
const OUTPUT_DIR = '.svg-sprite'
const GENERATED_NOTICE = [
'----------------------------------------------------------------------',
'## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##',
'## ##',
'## Не редактируйте вручную: изменения будут перезаписаны. ##',
'## Для изменений перегенерируйте SVG-спрайт. ##',
'## ##',
'## Генератор: @gromlab/svg-sprites ##',
'## Репозиторий: https://github.com/gromlab-ru/svg-sprites ##',
'----------------------------------------------------------------------',
]
function toPascalCase(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function toCamelCase(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function blockHeader(enabled: boolean): string {
if (!enabled) return `/* ${GENERATED_MARKER}. Do not edit. */`
return ['/*', ...GENERATED_NOTICE.map((line) => ` * ${line}`), ' */'].join('\n')
}
function svgHeader(enabled: boolean): string {
if (!enabled) return `<!-- ${GENERATED_MARKER}. Do not edit. -->`
return ['<!--', ...GENERATED_NOTICE.slice(1, -1).map((line) => ` ${line}`), '-->'].join('\n')
}
function formatSvg(content: string): string {
let depth = 0
const lines = content.replace(/></g, '>\n<').split('\n')
const formatted = lines.map((line) => {
const trimmed = line.trim()
const tags = trimmed.match(/<[^>]+>/g) ?? []
const startsWithClosingTag = trimmed.startsWith('</')
if (startsWithClosingTag) depth = Math.max(0, depth - 1)
const formattedLine = `${' '.repeat(depth)}${trimmed}`
for (const tag of tags) {
if (tag.startsWith('</')) {
if (!startsWithClosingTag) depth = Math.max(0, depth - 1)
} else if (!tag.startsWith('<?') && !tag.startsWith('<!') && !tag.endsWith('/>')) {
depth += 1
}
}
return formattedLine
})
return `${formatted.join('\n')}\n`
}
function markedSvg(bytes: Uint8Array, notice: boolean): string {
const svg = formatSvg(new TextDecoder().decode(bytes))
const header = svgHeader(notice)
return svg.startsWith('<?xml')
? svg.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n${header}\n`)
: `${header}\n${svg}`
}
function descriptionComment(name: string, description?: string): string[] {
const text = description ?? `Имена иконок SVG-спрайта «${name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function generateIconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const camelName = toCamelCase(config.name)
const names = artifact.icons.map((icon) => icon.name)
const ids = Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id]))
return [
blockHeader(config.generatedNotice),
'',
...descriptionComment(config.name, config.description),
`export const ${camelName}IconNames = ${JSON.stringify(names, null, 2)}`,
'',
`export const ${camelName}IconIds = ${JSON.stringify(ids, null, 2)}`,
'',
].join('\n')
}
function generateComponent(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
return [
blockHeader(config.generatedNotice),
"import { jsx } from 'react/jsx-runtime'",
"import styles from './react-component.module.css'",
`import { ${camelName}IconIds } from '../icon-data.js'`,
"import spriteUrl from '../sprite.svg?no-inline'",
'',
`/** Иконка из SVG-спрайта «${config.name}». */`,
`export const ${pascalName}Icon = (props) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = spriteUrl + '#' + ${camelName}IconIds[icon]`,
'',
' if (wrapped) {',
" return jsx('span', {",
' ...rest,',
" className: [styles.wrap, className].filter(Boolean).join(' '),",
" children: jsx('svg', { children: jsx('use', { href }) }),",
' })',
' }',
'',
" return jsx('svg', {",
' ...rest,',
" className: [styles.root, className].filter(Boolean).join(' '),",
" children: jsx('use', { href }),",
' })',
'}',
'',
].join('\n')
}
function generateIconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
const tuple = artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`).join('\n')
const ids = artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`).join('\n')
return [
blockHeader(config.generatedNotice),
'',
...descriptionComment(config.name, config.description),
`export declare const ${camelName}IconNames: readonly [`,
tuple,
']',
'',
`export type ${pascalName}IconName = typeof ${camelName}IconNames[number]`,
`export declare const ${camelName}IconIds: {`,
ids,
'}',
'',
].join('\n')
}
function generateComponentDeclarations(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
return [
blockHeader(config.generatedNotice),
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
`import type { ${pascalName}IconName } from '../icon-data.js'`,
'',
`export type ${pascalName}IconStyle = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
'',
`type ${pascalName}IconBaseProps = { icon: ${pascalName}IconName }`,
`type ${pascalName}IconSvgProps = ${pascalName}IconBaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${pascalName}IconStyle }`,
`type ${pascalName}IconWrappedProps = ${pascalName}IconBaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${pascalName}IconStyle }`,
`export type ${pascalName}IconProps = ${pascalName}IconSvgProps | ${pascalName}IconWrappedProps`,
'',
`export declare const ${pascalName}Icon: (props: ${pascalName}IconProps) => ReactElement`,
'',
].join('\n')
}
function generateIndexDeclarations(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
return [
blockHeader(config.generatedNotice),
`export { ${pascalName}Icon } from './react/react-component.js'`,
`export type { ${pascalName}IconProps, ${pascalName}IconStyle } from './react/react-component.js'`,
`export { ${camelName}IconNames } from './icon-data.js'`,
`export type { ${pascalName}IconName } from './icon-data.js'`,
'',
].join('\n')
}
function generateIndex(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
return [
blockHeader(config.generatedNotice),
`export { ${pascalName}Icon } from './react/react-component.js'`,
`export { ${camelName}IconNames } from './icon-data.js'`,
'',
].join('\n')
}
function generateStyles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
: []
return [
blockHeader(config.generatedNotice),
'',
'.root {',
...transition,
'}',
'',
'.wrap {',
' display: inline-flex;',
'}',
'',
'.wrap svg {',
' width: 100%;',
' height: 100%;',
...transition,
'}',
'',
].join('\n')
}
function manifestData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact) {
return {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
componentName: `${toPascalCase(config.name)}Icon`,
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
spriteUrl: '__SPRITE_URL__',
icons: artifact.icons,
}
}
function generateManifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const serialized = JSON.stringify(manifestData(config, artifact), null, 2)
.replace('"__SPRITE_URL__"', 'spriteUrl')
return [
blockHeader(config.generatedNotice),
"import spriteUrl from './sprite.svg?no-inline'",
'',
`export const spriteManifest = ${serialized}`,
'',
'export default spriteManifest',
'',
].join('\n')
}
function generateManifestDeclarations(config: ResolvedSpriteConfig): string {
return [
blockHeader(config.generatedNotice),
"import type { SpriteManifest } from '@gromlab/svg-sprites/react'",
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
}
export function generateOutputFiles(
config: ResolvedSpriteConfig,
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: generateIndex(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: generateIndexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: generateIconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: generateIconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: generateComponent(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: generateComponentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: generateStyles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: generateManifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: generateManifestDeclarations(config) },
]
}

View File

@@ -0,0 +1,26 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const reactWebpackAdapter: ModeAdapter<'react@webpack'> = {
mode: 'react@webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',
manifest: '.svg-sprite/svg-sprite.manifest.js',
entry: '.svg-sprite/index.js',
},
result: { target: 'webpack' },
}
},
}

View File

@@ -0,0 +1,187 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'react@webpack'
const TARGET = 'webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function pascal(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function camel(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function header(enabled: boolean): string {
return enabled
? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */`
: `/* ${GENERATED_MARKER}. Do not edit. */`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function comment(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [
header(config.generatedNotice),
'',
...comment(config),
`export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`,
'',
`export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`,
'',
].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
"import { jsx } from 'react/jsx-runtime'",
"import styles from './react-component.module.css'",
`import { ${ids} } from '../icon-data.js'`,
'',
"const spriteUrl = new URL('../sprite.svg', import.meta.url).href",
'',
`export const ${componentName} = (props) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = spriteUrl + '#' + ${ids}[icon]`,
' if (wrapped) {',
" return jsx('span', {",
' ...rest,',
" className: [styles.wrap, className].filter(Boolean).join(' '),",
" children: jsx('svg', { children: jsx('use', { href }) }),",
' })',
' }',
" return jsx('svg', {",
' ...rest,',
" className: [styles.root, className].filter(Boolean).join(' '),",
" children: jsx('use', { href }),",
' })',
'}',
'',
].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'',
...comment(config),
`export declare const ${names}: readonly [`,
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
']',
`export type ${typeName} = typeof ${names}[number]`,
`export declare const ${ids}: {`,
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
'}',
'',
].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [
header(config.generatedNotice),
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
`import type { ${typeName} } from '../icon-data.js'`,
'',
`export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
`type ${componentName}BaseProps = { icon: ${typeName} }`,
`type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`,
`type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`,
`export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`,
`export declare const ${componentName}: (props: ${propsName}) => ReactElement`,
'',
].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [
header(config.generatedNotice),
`export { ${componentName} } from './react/react-component.js'`,
`export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`,
`export { ${names} } from './icon-data.js'`,
`export type { ${componentName}Name } from './icon-data.js'`,
'',
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
`export { ${pascal(config.name)}Icon } from './react/react-component.js'`,
`export { ${camel(config.name)}IconNames } from './icon-data.js'`,
'',
].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
: []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
componentName: `${pascal(config.name)}Icon`,
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
spriteUrl: '__SPRITE_URL__',
icons: artifact.icons,
}
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
}
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -1,288 +0,0 @@
import path from 'node:path'
import { getSpriteShapeId } from '../../shape-id.js'
import { generateReactAssetUrlCode } from '../../targets/index.js'
import type { SpriteAssetTarget } from '../../targets/types.js'
import type { SpriteFormat } from '../../types.js'
import { generateSpriteManifest } from './manifest.js'
import { toPascalCase } from './naming.js'
import type { ResolvedReactSpriteConfig } from './types.js'
const GENERATED_MARKER = '@generated by @gromlab/svg-sprites. Do not edit.'
const GENERATED_NOTICE = [
'----------------------------------------------------------------------',
'## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##',
'## ##',
'## Не редактируйте вручную: изменения будут перезаписаны. ##',
'## Для изменений перегенерируйте SVG-спрайт. ##',
'## ##',
'## Генератор: @gromlab/svg-sprites ##',
'## Репозиторий: https://github.com/gromlab-ru/svg-sprites ##',
'----------------------------------------------------------------------',
]
type ReactCodegenOptions = {
config: ResolvedReactSpriteConfig
format: SpriteFormat
iconNames: string[]
sprite: Uint8Array
target: SpriteAssetTarget
}
export type GeneratedFile = {
path: string
content: string | Uint8Array
}
function toCamelCase(name: string): string {
return name.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function generateBlockHeader(generatedNotice: boolean): string {
if (!generatedNotice) return `/* ${GENERATED_MARKER} */`
return [
'/*',
...GENERATED_NOTICE.map((line) => ` * ${line}`),
' */',
].join('\n')
}
function generateSvgHeader(generatedNotice: boolean): string {
if (!generatedNotice) return `<!-- ${GENERATED_MARKER} -->`
return [
'<!--',
...GENERATED_NOTICE.slice(1, -1).map((line) => ` ${line}`),
'-->',
].join('\n')
}
function generateGitignore(): string {
return `# ${GENERATED_MARKER}\n/generated/\n/index.ts\n/manifest.ts\n`
}
function formatSvg(content: string): string {
let depth = 0
const lines = content.replace(/></g, '>\n<').split('\n')
const formatted = lines.map((line) => {
const trimmed = line.trim()
const tags = trimmed.match(/<[^>]+>/g) ?? []
const startsWithClosingTag = trimmed.startsWith('</')
if (startsWithClosingTag) depth = Math.max(0, depth - 1)
const formattedLine = `${' '.repeat(depth)}${trimmed}`
for (const tag of tags) {
if (tag.startsWith('</')) {
if (!startsWithClosingTag) depth = Math.max(0, depth - 1)
} else if (!tag.startsWith('<?') && !tag.startsWith('<!') && !tag.endsWith('/>')) {
depth += 1
}
}
return formattedLine
})
return `${formatted.join('\n')}\n`
}
function markSvg(content: Uint8Array, generatedNotice: boolean): string {
const svg = formatSvg(new TextDecoder().decode(content))
const header = generateSvgHeader(generatedNotice)
return svg.startsWith('<?xml')
? svg.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n${header}\n`)
: `${header}\n${svg}`
}
function generateDescriptionComment(name: string, description?: string): string[] {
const text = description ?? `Имена иконок SVG-спрайта «${name}».`
return [
'/**',
...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`),
' */',
]
}
function generateTypes(
name: string,
description: string | undefined,
iconNames: string[],
generatedNotice: boolean,
): string {
const pascalName = toPascalCase(name)
const camelName = toCamelCase(name)
const iconNamesVariable = `${camelName}IconNames`
return [
generateBlockHeader(generatedNotice),
'',
...generateDescriptionComment(name, description),
`export const ${iconNamesVariable} = [`,
...iconNames.map((iconName) => ` ${JSON.stringify(iconName)},`),
'] as const',
'',
`export type ${pascalName}IconName = typeof ${iconNamesVariable}[number]`,
'',
].join('\n')
}
function generateComponent(
name: string,
generatedNotice: boolean,
target: SpriteAssetTarget,
iconNames: string[],
): string {
const pascalName = toPascalCase(name)
const assetUrlCode = generateReactAssetUrlCode(target, 'sprite.svg')
return [
generateBlockHeader(generatedNotice),
"import type { CSSProperties, HTMLAttributes, SVGAttributes } from 'react'",
"import styles from './styles.module.css'",
`import type { ${pascalName}IconName } from './types'`,
...assetUrlCode.imports,
'',
...assetUrlCode.declarations,
...(assetUrlCode.declarations.length > 0 ? [''] : []),
`const iconIds: Record<${pascalName}IconName, string> = {`,
...iconNames.map((iconName) => ` ${JSON.stringify(iconName)}: ${JSON.stringify(getSpriteShapeId(iconName))},`),
'}',
'',
`export type ${pascalName}IconStyle = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
'',
`type ${pascalName}IconBaseProps = {`,
` icon: ${pascalName}IconName`,
'}',
'',
`type ${pascalName}IconSvgProps = ${pascalName}IconBaseProps & {`,
' wrapped?: false',
`} & Omit<SVGAttributes<SVGSVGElement>, 'style'> & {`,
` style?: ${pascalName}IconStyle`,
'}',
'',
`type ${pascalName}IconWrappedProps = ${pascalName}IconBaseProps & {`,
' wrapped: true',
`} & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & {`,
` style?: ${pascalName}IconStyle`,
'}',
'',
`export type ${pascalName}IconProps =`,
` | ${pascalName}IconSvgProps`,
` | ${pascalName}IconWrappedProps`,
'',
`/** Иконка из SVG-спрайта «${name}». */`,
`export const ${pascalName}Icon = (props: ${pascalName}IconProps) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = ${assetUrlCode.variableName} + '#' + iconIds[icon]`,
'',
' if (wrapped) {',
' const htmlAttributes = rest as HTMLAttributes<HTMLSpanElement>',
' return (',
" <span {...htmlAttributes} className={[styles.wrap, className].filter(Boolean).join(' ')}>",
' <svg>',
' <use href={href} />',
' </svg>',
' </span>',
' )',
' }',
'',
' const svgAttributes = rest as SVGAttributes<SVGSVGElement>',
' return (',
" <svg {...svgAttributes} className={[styles.root, className].filter(Boolean).join(' ')}>",
' <use href={href} />',
' </svg>',
' )',
'}',
'',
].join('\n')
}
function generateCss(generatedNotice: boolean, addTransition: boolean): string {
const transitionRules = addTransition
? [
' transition-property: fill, stroke, color;',
' transition-duration: 0.3s;',
' transition-timing-function: ease;',
]
: []
return [
generateBlockHeader(generatedNotice),
'',
'.root {',
...transitionRules,
'}',
'',
'.wrap {',
' display: inline-flex;',
'}',
'',
'.wrap svg {',
' width: 100%;',
' height: 100%;',
...transitionRules,
'}',
'',
].join('\n')
}
function generateRootIndex(name: string, generatedNotice: boolean): string {
const pascalName = toPascalCase(name)
const iconNamesVariable = `${toCamelCase(name)}IconNames`
return [
generateBlockHeader(generatedNotice),
`export { ${pascalName}Icon } from './generated/react-component'`,
`export type { ${pascalName}IconProps, ${pascalName}IconStyle } from './generated/react-component'`,
`export { ${iconNamesVariable} } from './generated/types'`,
`export type { ${pascalName}IconName } from './generated/types'`,
'',
].join('\n')
}
/** Создаёт все управляемые файлы React sprite-модуля в памяти. */
export function generateReactFiles(options: ReactCodegenOptions): GeneratedFile[] {
const { config, format, iconNames, sprite, target } = options
const { name, description, generatedNotice } = config
return [
{
path: '.gitignore',
content: generateGitignore(),
},
{
path: path.posix.join('generated', 'sprite.svg'),
content: markSvg(sprite, generatedNotice),
},
{
path: path.posix.join('generated', 'types.ts'),
content: generateTypes(name, description, iconNames, generatedNotice),
},
{
path: path.posix.join('generated', 'react-component.tsx'),
content: generateComponent(name, generatedNotice, target, iconNames),
},
{
path: path.posix.join('generated', 'styles.module.css'),
content: generateCss(generatedNotice, config.transform.addTransition ?? true),
},
{
path: 'manifest.ts',
content: generateSpriteManifest({
header: generateBlockHeader(generatedNotice),
name,
description,
format,
iconNames,
sprite,
target,
}),
},
{
path: 'index.ts',
content: generateRootIndex(name, generatedNotice),
},
]
}

View File

@@ -1,108 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import { toKebabCase, validateSpriteName } from './naming.js'
import type { ReactSpriteConfig, ResolvedReactSpriteConfig } from './types.js'
export const REACT_CONFIG_FILE = 'svg-sprite.config.ts'
function getDefaultName(rootDir: string): string {
const rootName = path.basename(rootDir)
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
? path.basename(path.dirname(rootDir))
: rootName
const name = toKebabCase(source)
if (!name) {
throw new Error(`Cannot infer sprite name from directory: ${rootDir}`)
}
return name
}
/** Загружает локальный React-конфиг из корня sprite-модуля. */
export async function loadReactSpriteConfig(rootDir: string): Promise<ResolvedReactSpriteConfig> {
const configPath = path.join(rootDir, REACT_CONFIG_FILE)
if (!fs.existsSync(configPath)) {
throw new Error(
`React config file not found: ${configPath}\n` +
`Create ${REACT_CONFIG_FILE} inside the sprite directory.`,
)
}
const jiti = createJiti(rootDir)
const mod = await jiti.import(configPath) as { default?: ReactSpriteConfig }
const config = mod.default
if (!config || typeof config !== 'object') {
throw new Error(
`React config file must have a default export: ${configPath}\n` +
'Use: export default defineReactSpriteConfig({ ... })',
)
}
if (config.name !== undefined && typeof config.name !== 'string') {
throw new Error('React config: "name" must be a string.')
}
if (config.description !== undefined && typeof config.description !== 'string') {
throw new Error('React config: "description" must be a string.')
}
if ('icons' in config) {
throw new Error('React config: "icons" was renamed to "inputFolder".')
}
if (config.inputFolder !== undefined && (
typeof config.inputFolder !== 'string' || config.inputFolder.trim() === ''
)) {
throw new Error('React config: "inputFolder" must be a non-empty string.')
}
if (config.inputFiles !== undefined && (
!Array.isArray(config.inputFiles)
|| config.inputFiles.some((filePath) => typeof filePath !== 'string' || filePath.trim() === '')
)) {
throw new Error('React config: "inputFiles" must be an array of non-empty strings.')
}
if (config.transform !== undefined) {
if (
config.transform === null
|| typeof config.transform !== 'object'
|| Array.isArray(config.transform)
) {
throw new Error('React config: "transform" must be an object.')
}
for (const option of ['removeSize', 'replaceColors', 'addTransition'] as const) {
if (config.transform[option] !== undefined && typeof config.transform[option] !== 'boolean') {
throw new Error(`React config: "transform.${option}" must be a boolean.`)
}
}
}
if (config.generatedNotice !== undefined && typeof config.generatedNotice !== 'boolean') {
throw new Error('React config: "generatedNotice" must be a boolean.')
}
const name = config.name ?? getDefaultName(rootDir)
validateSpriteName(name)
const inputFiles = (config.inputFiles ?? []).map((filePath) => path.resolve(rootDir, filePath))
const defaultInputFolder = path.resolve(rootDir, 'icons')
const inputFolder = config.inputFolder === undefined
&& inputFiles.length > 0
&& !fs.existsSync(defaultInputFolder)
? null
: path.resolve(rootDir, config.inputFolder ?? 'icons')
return {
name,
description: config.description,
inputFolder,
inputFiles,
transform: { ...config.transform },
generatedNotice: config.generatedNotice ?? true,
}
}

View File

@@ -1,18 +0,0 @@
import type { ReactAssetTarget } from '../../targets/types.js'
import { generateSpriteModule } from './module-generator.js'
import type { ReactSpriteGenerationResult } from './types.js'
/** Генерирует один локальный React sprite-модуль для Vite или Webpack. */
export function generateReactSprite(
root: string,
target: ReactAssetTarget,
): Promise<ReactSpriteGenerationResult> {
if (target !== 'vite' && target !== 'webpack') {
throw new Error(`Unsupported React asset target: ${String(target)}`)
}
return generateSpriteModule(root, target, {
mode: `react@${target}`,
rootViewBox: false,
})
}

View File

@@ -1,7 +0,0 @@
export { generateReactSprite } from './generate.js'
export { loadReactSpriteConfig, REACT_CONFIG_FILE } from './config.js'
export type {
ReactSpriteConfig,
ReactSpriteGenerationResult,
ResolvedReactSpriteConfig,
} from './types.js'

View File

@@ -1,120 +0,0 @@
import { getSpriteShapeId } from '../../shape-id.js'
import { generateReactAssetUrlCode } from '../../targets/index.js'
import type { SpriteAssetTarget } from '../../targets/types.js'
import type { SpriteFormat } from '../../types.js'
import { toPascalCase } from './naming.js'
type ManifestIconColor = {
variable: string
fallback: string
}
type ManifestIcon = {
name: string
id: string
viewBox: string | null
colors: ManifestIconColor[]
}
type GenerateManifestOptions = {
header: string
name: string
description?: string
format: SpriteFormat
iconNames: string[]
sprite: Uint8Array
target: SpriteAssetTarget
}
function extractColors(fragment: string): ManifestIconColor[] {
const colors = new Map<string, string>()
const regex = /var\((--icon-color-\d+),\s*((?:[^()]|\([^()]*\))*)\)/g
let match: RegExpExecArray | null
while ((match = regex.exec(fragment)) !== null) {
if (!colors.has(match[1])) colors.set(match[1], match[2].trim())
}
return [...colors].map(([variable, fallback]) => ({ variable, fallback }))
}
function extractManifestIcons(sprite: Uint8Array, iconNames: string[]): ManifestIcon[] {
const shapes = new Map<string, { viewBox: string | null; fragment: string }>()
const content = new TextDecoder().decode(sprite)
const shapeRegex = /<(symbol|svg)\b((?=[^>]*\bid="[^"]+")[^>]*)>[\s\S]*?<\/\1>/g
let match: RegExpExecArray | null
while ((match = shapeRegex.exec(content)) !== null) {
const attributes = match[2]
const id = attributes.match(/\bid="([^"]+)"/)?.[1]
if (!id) continue
shapes.set(id, {
viewBox: attributes.match(/\bviewBox="([^"]+)"/)?.[1] ?? null,
fragment: match[0],
})
}
return iconNames.map((name) => {
const id = getSpriteShapeId(name)
const shape = shapes.get(id)
if (!shape) throw new Error(`Cannot find SVG shape "${id}" for icon "${name}".`)
return {
name,
id,
viewBox: shape.viewBox,
colors: extractColors(shape.fragment),
}
})
}
function generateIcon(icon: ManifestIcon): string[] {
return [
' {',
` name: ${JSON.stringify(icon.name)},`,
` id: ${JSON.stringify(icon.id)},`,
` viewBox: ${JSON.stringify(icon.viewBox)},`,
' colors: [',
...icon.colors.flatMap((color) => [
' {',
` variable: ${JSON.stringify(color.variable)},`,
` fallback: ${JSON.stringify(color.fallback)},`,
' },',
]),
' ],',
' },',
]
}
/** Генерирует отдельную debug-точку входа с метаданными спрайта. */
export function generateSpriteManifest(options: GenerateManifestOptions): string {
const { header, name, description, format, iconNames, sprite, target } = options
const assetUrlCode = generateReactAssetUrlCode(target, 'generated/sprite.svg')
const icons = extractManifestIcons(sprite, iconNames)
return [
header,
...assetUrlCode.imports,
...(assetUrlCode.imports.length > 0 ? [''] : []),
...assetUrlCode.declarations,
...(assetUrlCode.declarations.length > 0 ? [''] : []),
'export const spriteManifest = {',
' schemaVersion: 1,',
" generator: '@gromlab/svg-sprites',",
` name: ${JSON.stringify(name)},`,
...(description === undefined ? [] : [` description: ${JSON.stringify(description)},`]),
` componentName: ${JSON.stringify(`${toPascalCase(name)}Icon`)},`,
` target: ${JSON.stringify(target)},`,
` format: ${JSON.stringify(format)},`,
` iconCount: ${icons.length},`,
` spriteUrl: ${assetUrlCode.variableName},`,
' icons: [',
...icons.flatMap(generateIcon),
' ],',
'} as const',
'',
'export default spriteManifest',
'',
].join('\n')
}

View File

@@ -1,78 +0,0 @@
import path from 'node:path'
import { compileSpriteContent } from '../../compiler.js'
import { log } from '../../logger.js'
import { resolveSpriteSources } from '../../scanner.js'
import { getSpriteShapeId } from '../../shape-id.js'
import type { SpriteAssetTarget } from '../../targets/types.js'
import { generateReactFiles } from './codegen.js'
import { loadReactSpriteConfig } from './config.js'
import type { SpriteModuleGenerationResult } from './types.js'
import { writeReactFiles } from './writer.js'
type GenerateSpriteModuleOptions = {
mode: string
rootViewBox: boolean
}
function validateIconIds(iconNames: string[]): void {
const namesById = new Map<string, string>()
for (const iconName of iconNames) {
const id = getSpriteShapeId(iconName)
const existingName = namesById.get(id)
if (existingName) {
throw new Error(
`Icons "${existingName}" and "${iconName}" produce the same SVG id "${id}". Rename one of the files.`,
)
}
namesById.set(id, iconName)
}
}
/** Общая генерация типизированного React-компонента для React и Next.js modes. */
export async function generateSpriteModule<TTarget extends SpriteAssetTarget>(
root: string,
target: TTarget,
options: GenerateSpriteModuleOptions,
): Promise<SpriteModuleGenerationResult<TTarget>> {
const rootDir = path.resolve(root)
const config = await loadReactSpriteConfig(rootDir)
const format = 'stack'
const folder = resolveSpriteSources({
name: config.name,
format,
inputFolder: config.inputFolder,
inputFiles: config.inputFiles,
})
const iconNames = folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
validateIconIds(iconNames)
const sprite = await compileSpriteContent(folder, config.transform, {
rootViewBox: options.rootViewBox,
})
const files = generateReactFiles({ config, format, iconNames, sprite, target })
writeReactFiles(rootDir, files, config.generatedNotice)
const generatedDir = path.join(rootDir, 'generated')
const spritePath = path.join(generatedDir, 'sprite.svg')
const manifestPath = path.join(rootDir, 'manifest.ts')
const iconLabel = iconNames.length === 1 ? 'icon' : 'icons'
log.success(`${config.name} · ${iconNames.length} ${iconLabel} · ${options.mode}`)
log.detail(`${path.relative(process.cwd(), generatedDir)}`)
return {
name: config.name,
rootDir,
generatedDir,
spritePath,
manifestPath,
iconCount: iconNames.length,
target,
}
}

View File

@@ -1,25 +0,0 @@
/** Преобразует kebab-case имя в PascalCase. */
export function toPascalCase(value: string): string {
return value
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/** Преобразует имя папки в допустимое kebab-case имя спрайта. */
export function toKebabCase(value: string): string {
return value
.normalize('NFKD')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase()
}
export function validateSpriteName(name: string): void {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) {
throw new Error(
`React config: "name" must be kebab-case and start with a letter. Received: "${name}".`,
)
}
}

View File

@@ -1,42 +0,0 @@
import type {
ReactAssetTarget,
SpriteAssetTarget,
} from '../../targets/types.js'
import type { TransformOptions } from '../../types.js'
export type ReactSpriteConfig = {
/** Логическое имя спрайта. По умолчанию выводится из пути модуля. */
name?: string
/** Описание спрайта для документации и будущего инспектора. */
description?: string
/** Папка с исходными SVG относительно svg-sprite.config.ts. По умолчанию: ./icons. */
inputFolder?: string
/** Дополнительные SVG-файлы относительно svg-sprite.config.ts. По умолчанию: []. */
inputFiles?: string[]
/** Настройки трансформации SVG. По умолчанию все трансформации включены. */
transform?: TransformOptions
/** Добавлять развёрнутое предупреждение в generated-файлы. По умолчанию: true. */
generatedNotice?: boolean
}
export type ResolvedReactSpriteConfig = {
name: string
description?: string
inputFolder: string | null
inputFiles: string[]
transform: TransformOptions
generatedNotice: boolean
}
export type SpriteModuleGenerationResult<TTarget extends SpriteAssetTarget> = {
name: string
rootDir: string
generatedDir: string
spritePath: string
manifestPath: string
iconCount: number
/** Среда, для которой сгенерирован способ получения URL SVG asset. */
target: TTarget
}
export type ReactSpriteGenerationResult = SpriteModuleGenerationResult<ReactAssetTarget>

View File

@@ -1,172 +0,0 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import type { GeneratedFile } from './codegen.js'
const MANIFEST_FILE = '.svg-sprites.manifest.json'
const GENERATOR = '@gromlab/svg-sprites'
const GENERATED_MARKER = '@generated by @gromlab/svg-sprites'
const GENERATED_NOTICE_MARKER = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'
const ROOT_MANAGED_FILES = new Set(['.gitignore', 'index.ts', 'manifest.ts'])
type Manifest = {
version: 1
generator: typeof GENERATOR
files: string[]
warning?: string
}
function normalizeManagedPath(relativePath: string): string {
const normalized = relativePath.replaceAll('\\', '/')
const parts = normalized.split('/')
const isRootFile = ROOT_MANAGED_FILES.has(normalized)
const isGeneratedFile = parts.length === 2
&& parts[0] === 'generated'
&& parts[1] !== ''
&& parts[1] !== '.'
&& parts[1] !== '..'
if (!isRootFile && !isGeneratedFile) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return normalized
}
function resolveManagedPath(rootDir: string, relativePath: string): string {
const normalized = normalizeManagedPath(relativePath)
const resolved = path.resolve(rootDir, normalized)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return resolved
}
function assertNoSymlinks(rootDir: string, filePath: string): void {
const relative = path.relative(rootDir, filePath)
let currentPath = rootDir
for (const segment of relative.split(path.sep)) {
currentPath = path.join(currentPath, segment)
try {
if (fs.lstatSync(currentPath).isSymbolicLink()) {
throw new Error(`Symbolic links are not allowed in generated paths: ${currentPath}`)
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw error
}
}
}
function readManifest(rootDir: string, manifestPath: string): Manifest | null {
assertNoSymlinks(rootDir, manifestPath)
if (!fs.existsSync(manifestPath)) return null
let manifest: unknown
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
} catch {
throw new Error(`Cannot parse generated files manifest: ${manifestPath}`)
}
if (
!manifest
|| typeof manifest !== 'object'
|| !('version' in manifest)
|| manifest.version !== 1
|| !('generator' in manifest)
|| manifest.generator !== GENERATOR
|| !('files' in manifest)
|| !Array.isArray(manifest.files)
|| !manifest.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated files manifest: ${manifestPath}`)
}
return {
...(manifest as Manifest),
files: (manifest as Manifest).files.map(normalizeManagedPath),
}
}
function hasGeneratedMarker(filePath: string): boolean {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
const content = fs.readFileSync(filePath, 'utf-8')
return content.includes(GENERATED_MARKER) || content.includes(GENERATED_NOTICE_MARKER)
}
function writeFileAtomic(rootDir: string, filePath: string, content: string | Uint8Array): void {
assertNoSymlinks(rootDir, filePath)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
assertNoSymlinks(rootDir, filePath)
const temporaryPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
)
try {
fs.writeFileSync(temporaryPath, content, { flag: 'wx' })
fs.renameSync(temporaryPath, filePath)
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath)
}
}
/** Обновляет только файлы, которыми владеет React-генератор. */
export function writeReactFiles(
rootDir: string,
files: GeneratedFile[],
generatedNotice: boolean,
): void {
const generatedDir = path.join(rootDir, 'generated')
const manifestPath = path.join(generatedDir, MANIFEST_FILE)
const previousManifest = readManifest(rootDir, manifestPath)
const previousFiles = new Set(previousManifest?.files ?? [])
const nextFiles = files.map((file) => normalizeManagedPath(file.path))
const obsoleteFiles: string[] = []
for (const file of files) {
const filePath = resolveManagedPath(rootDir, file.path)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath) && !hasGeneratedMarker(filePath)) {
throw new Error(
`Refusing to overwrite a user file: ${filePath}\n` +
'Move the file or choose another sprite directory.',
)
}
}
for (const relativePath of previousFiles) {
if (nextFiles.includes(relativePath)) continue
const filePath = resolveManagedPath(rootDir, relativePath)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath)) {
if (!hasGeneratedMarker(filePath)) {
throw new Error(`Refusing to delete a user file: ${filePath}`)
}
obsoleteFiles.push(filePath)
}
}
for (const filePath of obsoleteFiles) fs.unlinkSync(filePath)
for (const file of files) {
writeFileAtomic(rootDir, resolveManagedPath(rootDir, file.path), file.content)
}
const manifest: Manifest = {
version: 1,
generator: GENERATOR,
files: nextFiles,
...(generatedNotice && {
warning: 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную: изменения будут перезаписаны.',
}),
}
writeFileAtomic(rootDir, manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
}

View File

@@ -1,185 +0,0 @@
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')
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-фрагмента иконки. */
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>()
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
}
/** Извлекает 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, '')
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
}
interface IconData {
id: 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
spriteFile: string
icons: IconData[]
}
/**
* Генерирует 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) => {
const fragment = fragments.get(id) || ''
return {
id,
group: r.name,
mode: r.format,
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.format, spriteFile, icons }
})
// 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)
return outputPath
}

View File

@@ -1,4 +1,4 @@
import type { SpriteAssetTarget } from '../targets/types.js'
import type { SpriteAssetTarget, SpriteMode } from '../targets/types.js'
export type SpriteManifestColor = {
variable: `--icon-color-${number}`
@@ -19,6 +19,7 @@ export type SpriteManifest = {
name: string
description?: string
componentName: string
mode?: SpriteMode
target: SpriteAssetTarget
format: 'stack' | 'symbol'
iconCount: number

View File

@@ -1,6 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteEntry, SpriteFolder } from './types.js'
import type { SpriteFolder } from './types.js'
/**
* Сканирует папку и возвращает отсортированные абсолютные пути к SVG-файлам.
@@ -61,46 +61,3 @@ export function resolveSpriteSources(options: ResolveSpriteSourcesOptions): Spri
files,
}
}
/**
* Преобразует SpriteEntry из конфига в SpriteFolder для компиляции.
*/
export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
const format = entry.format ?? '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,
format,
path: null,
files,
}
}
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,
format,
path: dirPath,
files,
}
}
/**
* Преобразует массив SpriteEntry из конфига в массив SpriteFolder.
*/
export function resolveSprites(entries: SpriteEntry[]): SpriteFolder[] {
return entries.map(resolveSpriteEntry)
}

View File

@@ -1,33 +0,0 @@
import { generateViteAssetUrlCode } from './vite.js'
import { generateWebpackAssetUrlCode } from './webpack.js'
import type { SpriteAssetTarget, SpriteAssetUrlCode } from './types.js'
/** Возвращает codegen-фрагменты для выбранной среды сборки React-модуля. */
export function generateReactAssetUrlCode(
target: SpriteAssetTarget,
spriteFileName: string,
): SpriteAssetUrlCode {
switch (target) {
case 'vite':
return generateViteAssetUrlCode(spriteFileName)
case 'webpack':
case 'next@app/turbopack':
case 'next@app/webpack':
case 'next@pages/turbopack':
case 'next@pages/webpack':
return generateWebpackAssetUrlCode(spriteFileName)
default:
throw new Error(`Unsupported sprite asset target: ${String(target)}`)
}
}
export type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
SpriteAssetTarget,
SpriteAssetUrlCode,
ViteAssetTarget,
WebpackAssetTarget,
} from './types.js'

View File

@@ -31,9 +31,15 @@ export type NextAssetTarget = `next@${NextRouter}/${NextBundler}`
*/
export type ReactAssetTarget = ViteAssetTarget | WebpackAssetTarget
/** Полный ключ React mode, используемый конфигом, CLI и manifest. */
export type ReactSpriteMode = `react@${ReactAssetTarget}`
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget
/** Режим генерации sprite-модуля. В будущем расширяется standalone mode. */
export type SpriteMode = ReactSpriteMode | NextAssetTarget
/** Фрагменты кода, необходимые компоненту для получения URL SVG asset. */
export type SpriteAssetUrlCode = {
/** Импорты, которые добавляются в начало generated-компонента. */

View File

@@ -1,10 +0,0 @@
import type { SpriteAssetUrlCode } from './types.js'
/** Генерирует Vite-импорт отдельного, запрещённого к inline SVG asset. */
export function generateViteAssetUrlCode(spriteFileName: string): SpriteAssetUrlCode {
return {
imports: [`import spriteUrl from './${spriteFileName}?no-inline'`],
declarations: [],
variableName: 'spriteUrl',
}
}

View File

@@ -1,12 +0,0 @@
import type { SpriteAssetUrlCode } from './types.js'
/** Генерирует Webpack 5 Asset Module через статический import.meta.url. */
export function generateWebpackAssetUrlCode(spriteFileName: string): SpriteAssetUrlCode {
return {
imports: [],
declarations: [
`const spriteUrl = new URL('./${spriteFileName}', import.meta.url).href`,
],
variableName: 'spriteUrl',
}
}

View File

@@ -1,23 +1,8 @@
import type { SpriteMode } from './targets/types.js'
/** Формат спрайта: stack или symbol. */
export type SpriteFormat = 'stack' | 'symbol'
/** Описание одного спрайта в конфиге. */
export type SpriteEntry = {
/** Уникальное имя спрайта (используется как имя файла и в типах). */
name: string
/**
* Источник SVG-файлов.
* Строка — путь к папке с SVG-файлами.
* Массив — пути к конкретным SVG-файлам.
*/
input: string | string[]
/**
* Формат спрайта.
* По умолчанию: 'stack'.
*/
format?: SpriteFormat
}
/** Параметры трансформации SVG. Все включены по умолчанию. */
export type TransformOptions = {
/**
@@ -39,22 +24,33 @@ export type TransformOptions = {
addTransition?: boolean
}
/** Конфигурация генерации SVG-спрайтов. */
export type SvgSpritesConfig = {
/** Путь к папке для сгенерированных SVG-спрайтов. */
output: string
/**
* Генерировать HTML-превью со всеми иконками.
* По умолчанию: true.
*/
preview?: boolean
/**
* Настройки трансформации SVG.
* По умолчанию: все трансформации включены.
*/
/** Единая конфигурация локального sprite-модуля. */
export type SpriteConfig = {
/** Режим можно определить в конфиге либо передать через CLI/API. */
mode?: SpriteMode
/** Логическое имя спрайта. По умолчанию выводится из каталога модуля. */
name?: string
/** Описание спрайта для generated types и manifest. */
description?: string
/** Папка с исходными SVG относительно корня модуля. По умолчанию: ./icons. */
inputFolder?: string
/** Дополнительные SVG-файлы относительно корня модуля. По умолчанию: []. */
inputFiles?: string[]
/** Настройки трансформации SVG. По умолчанию все включены. */
transform?: TransformOptions
/** Список спрайтов для генерации. */
sprites: SpriteEntry[]
/** Добавлять развёрнутое предупреждение в generated-файлы. По умолчанию: true. */
generatedNotice?: boolean
}
/** Полностью разрешённая конфигурация, готовая к генерации. */
export type ResolvedSpriteConfig = {
mode: SpriteMode
name: string
description?: string
inputFolder: string | null
inputFiles: string[]
transform: Required<TransformOptions>
generatedNotice: boolean
}
/** Результат парсинга спрайта — данные для компиляции. */
@@ -68,15 +64,3 @@ export type SpriteFolder = {
/** Абсолютные пути к SVG-файлам. */
files: string[]
}
/** Результат компиляции одного спрайта. */
export type SpriteResult = {
/** Имя спрайта. */
name: string
/** Формат спрайта. */
format: SpriteFormat
/** Путь к сгенерированному SVG-спрайту. */
spritePath: string
/** Количество иконок в спрайте. */
iconCount: number
}