feat: выпустить версию 1.0.0

- добавлены отдельные режимы генерации для React, Next.js и legacy
- добавлены SpriteViewer, типизированные компоненты и безопасный codegen
- перенесена сборка AI-скила и обновлена документация
- добавлены migration guide, лицензии и проверки публикации
This commit is contained in:
2026-07-11 07:00:59 +03:00
parent 3a60b5c6ae
commit 05a0a9f7ed
76 changed files with 9456 additions and 1033 deletions

View File

@@ -1,12 +1,44 @@
#!/usr/bin/env node
import { loadConfig } from './config.js'
import { generate } from './generate.js'
import path from 'node:path'
import { CLI_USAGE, parseCliArgs } from './cli/parse-args.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 {
const config = await loadConfig()
await generate(config)
const args = parseCliArgs(process.argv.slice(2))
if ('help' in args) {
console.log(CLI_USAGE)
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
}
}
} catch (error) {
log.error(error instanceof Error ? error.message : String(error))
process.exit(1)

119
src/cli/parse-args.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/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>',
'',
'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',
].join('\n')
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) {
return { help: true }
}
let modeValue: string | undefined
const positional: string[] = []
for (let index = 0; index < argv.length; index++) {
const argument = argv[index]
if (argument === '--mode') {
modeValue = argv[index + 1]
index++
continue
}
if (argument.startsWith('--mode=')) {
modeValue = argument.slice('--mode='.length)
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}`)
}
if (positional.length > 1) {
throw new Error(`Expected one sprite path, received: ${positional.join(', ')}`)
}
if (modeValue === 'legacy') {
return {
mode: 'legacy',
path: positional[0],
}
}
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(', ')}`,
)
}

26
src/cli/types.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
/** Корневой режим генерации, определяющий структуру создаваемых файлов. */
export type GenerationMode = 'legacy' | 'next' | 'react'
/** Аргументы legacy pipeline. */
export type LegacyCliArgs = {
mode: 'legacy'
path: string
}
/** Аргументы 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

View File

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

View File

@@ -1,16 +1,27 @@
import fs from 'node:fs'
import path from 'node:path'
import SVGSpriter from 'svg-sprite'
import { getSpriteShapeId } from './shape-id.js'
import { createShapeTransform } from './transforms.js'
import type { SpriteFolder, SpriteMode, TransformOptions } from './types.js'
import type { SpriteFolder, SpriteFormat, TransformOptions } from './types.js'
export type CompileSpriteOptions = {
/** Добавлять вычисленный viewBox корневому stack-спрайту. */
rootViewBox?: boolean
}
/** Конфигурация режима для svg-sprite. */
function getModeConfig(mode: SpriteMode, destDir: string, name: string) {
function getModeConfig(
format: SpriteFormat,
destDir: string,
name: string,
options: CompileSpriteOptions,
) {
return {
dest: destDir,
sprite: `${name}.sprite.svg`,
example: false,
rootviewbox: false,
rootviewbox: options.rootViewBox ?? false,
}
}
@@ -47,13 +58,35 @@ export async function compileSprite(
folder: SpriteFolder,
outputDir: string,
transform: TransformOptions = {},
options: CompileSpriteOptions = {},
): Promise<string> {
const contents = await compileSpriteContent(folder, transform, options)
const spritePath = path.join(outputDir, `${folder.name}.sprite.svg`)
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(spritePath, contents)
return spritePath
}
/** Компилирует SVG-спрайт в памяти, не изменяя файловую систему. */
export async function compileSpriteContent(
folder: SpriteFolder,
transform: TransformOptions = {},
options: CompileSpriteOptions = {},
): Promise<Uint8Array> {
const config = {
shape: {
id: {
generator: (filePath: string) => getSpriteShapeId(filePath),
},
dimension: {
attributes: transform.removeSize === false,
},
transform: buildShapeTransforms(transform),
},
mode: {
[folder.mode]: getModeConfig(folder.mode, outputDir, folder.name),
[folder.format]: getModeConfig(folder.format, '.', folder.name, options),
},
}
@@ -70,21 +103,24 @@ export async function compileSprite(
return
}
let spritePath = ''
let spriteContents: Uint8Array | undefined
for (const modeResult of Object.values(result)) {
for (const resource of Object.values(
modeResult as Record<string, { path: string; contents: Buffer }>,
modeResult as Record<string, { path: string; contents: Uint8Array }>,
)) {
fs.mkdirSync(path.dirname(resource.path), { recursive: true })
fs.writeFileSync(resource.path, resource.contents)
if (resource.path.endsWith('.svg')) {
spritePath = resource.path
spriteContents = resource.contents
}
}
}
resolve(spritePath)
if (!spriteContents) {
reject(new Error(`Failed to compile sprite "${folder.name}".`))
return
}
resolve(spriteContents)
})
})
}

View File

@@ -1,67 +0,0 @@
import path from 'node:path'
import { resolveSprites } from './scanner.js'
import { compileSprite } from './compiler.js'
import { generateReactModule } from './codegen-react.js'
import { generatePreview } from './preview.js'
import { log } from './logger.js'
import type { SvgSpritesConfig, SpriteResult } from './types.js'
/**
* Генерирует SVG-спрайты из конфига.
*
* Основная точка входа — используется и из CLI, и из программного API.
*/
export async function generate(config: SvgSpritesConfig): Promise<SpriteResult[]> {
const {
output,
publicPath,
preview = true,
react,
transform = {},
sprites,
} = config
const outputDir = path.resolve(output)
log.title('Resolving 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.mode}] ${folder.name}${path.relative(process.cwd(), spritePath)} (${folder.files.length} icons)`)
results.push({
name: folder.name,
mode: folder.mode,
spritePath,
iconCount: folder.files.length,
})
}
if (react) {
const reactDir = path.resolve(react)
const resolvedPublicPath = publicPath ?? `/${output}`
const iconPath = generateReactModule(results, folders, reactDir, resolvedPublicPath)
log.success(` [react] → ${path.relative(process.cwd(), iconPath)}`)
}
if (preview) {
const previewPath = generatePreview(results, outputDir)
log.success(`\n [preview] → ${path.relative(process.cwd(), previewPath)}`)
}
console.log('')
log.success(`Done! Generated ${results.length} sprite(s).`)
return results
}

View File

@@ -1,25 +1,59 @@
import type { SvgSpritesConfig } from './types.js'
import type { NextSpriteConfig } from './modes/next/types.js'
import type { ReactSpriteConfig } from './modes/react/types.js'
export { generate } from './generate.js'
export { generateLegacy } from './modes/legacy/generate.js'
export { resolveSprites, resolveSpriteEntry } from './scanner.js'
export { compileSprite } from './compiler.js'
export { compileSprite, compileSpriteContent } from './compiler.js'
export type { CompileSpriteOptions } from './compiler.js'
export { createShapeTransform } from './transforms.js'
export { generatePreview } from './preview.js'
export { generateReactModule } from './codegen-react.js'
export { loadConfig } from './config.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,
SpriteAssetTarget,
ViteAssetTarget,
WebpackAssetTarget,
} from './targets/types.js'
export type {
SvgSpritesConfig,
SpriteEntry,
SpriteResult,
SpriteFolder,
SpriteMode,
SpriteFormat,
TransformOptions,
} from './types.js'
export type {
NextSpriteConfig,
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './modes/next/types.js'
export type {
ReactSpriteConfig,
ReactSpriteGenerationResult,
ResolvedReactSpriteConfig,
} from './modes/react/types.js'
/**
* Хелпер для типизации конфига с автодополнением.
*/
export function defineConfig(config: SvgSpritesConfig): SvgSpritesConfig {
/** Хелпер для типизации legacy-конфига. */
export function defineLegacyConfig(config: SvgSpritesConfig): SvgSpritesConfig {
return config
}
/** Хелпер для типизации локального React-конфига. */
export function defineReactSpriteConfig(config: ReactSpriteConfig): ReactSpriteConfig {
return config
}
/** Хелпер для типизации локального Next.js-конфига. */
export function defineNextSpriteConfig(config: NextSpriteConfig): NextSpriteConfig {
return config
}

View File

@@ -1,4 +1,4 @@
import { green, red, yellow, cyan, bold } from 'colorette'
import { green, red, yellow, cyan, bold, dim } from 'colorette'
export const log = {
success: (msg: string) => console.log(green(msg)),
@@ -6,4 +6,5 @@ export const log = {
warn: (msg: string) => console.warn(yellow(msg)),
info: (msg: string) => console.log(cyan(msg)),
title: (msg: string) => console.log(bold(cyan(msg))),
detail: (msg: string) => console.log(dim(msg)),
}

View File

@@ -1,16 +1,12 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import type { SvgSpritesConfig } from './types.js'
import type { SvgSpritesConfig } from '../../types.js'
const CONFIG_NAME = 'svg-sprites.config'
/**
* Загружает конфиг svg-sprites.config.ts из указанной директории.
*
* Использует jiti для импорта TypeScript-файлов.
*/
export async function loadConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
/** Загружает legacy-конфиг из указанной директории. */
export async function loadLegacyConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
const configPath = path.join(cwd, `${CONFIG_NAME}.ts`)
if (!fs.existsSync(configPath)) {
@@ -22,25 +18,30 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<SvgSprite
const jiti = createJiti(cwd)
const mod = await jiti.import(configPath) as { default?: SvgSpritesConfig }
const config = mod.default
if (!config) {
throw new Error(
`Config file must have a default export: ${configPath}\n` +
'Use: export default defineConfig({ ... })',
'Use: export default defineLegacyConfig({ ... })',
)
}
validateConfig(config)
return config
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),
})),
}
}
/**
* Валидирует конфиг на наличие обязательных полей.
*/
function validateConfig(config: SvgSpritesConfig): void {
/** Валидирует legacy-конфиг. */
export function validateLegacyConfig(config: SvgSpritesConfig): void {
if (!config.output) {
throw new Error('Config: "output" is required.')
}
@@ -50,6 +51,12 @@ function validateConfig(config: SvgSpritesConfig): void {
}
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".')
}
@@ -58,9 +65,9 @@ function validateConfig(config: SvgSpritesConfig): void {
throw new Error(`Config: sprite "${sprite.name}" must have an "input".`)
}
if (sprite.mode && sprite.mode !== 'stack' && sprite.mode !== 'symbol') {
if (sprite.format && sprite.format !== 'stack' && sprite.format !== 'symbol') {
throw new Error(
`Config: sprite "${sprite.name}" has invalid mode "${sprite.mode}". Supported: stack, symbol.`,
`Config: sprite "${sprite.name}" has invalid format "${sprite.format}". Supported: stack, symbol.`,
)
}
}

View File

@@ -0,0 +1,52 @@
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

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

View File

@@ -0,0 +1,32 @@
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,
}
}

6
src/modes/next/index.ts Normal file
View File

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

22
src/modes/next/types.ts Normal file
View File

@@ -0,0 +1,22 @@
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
}

288
src/modes/react/codegen.ts Normal file
View File

@@ -0,0 +1,288 @@
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://gromlab.ru/gromov/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),
},
]
}

108
src/modes/react/config.ts Normal file
View File

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,18 @@
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,
})
}

7
src/modes/react/index.ts Normal file
View File

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

120
src/modes/react/manifest.ts Normal file
View File

@@ -0,0 +1,120 @@
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

@@ -0,0 +1,78 @@
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,
}
}

25
src/modes/react/naming.ts Normal file
View File

@@ -0,0 +1,25 @@
/** Преобразует 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}".`,
)
}
}

42
src/modes/react/types.ts Normal file
View File

@@ -0,0 +1,42 @@
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>

172
src/modes/react/writer.ts Normal file
View File

@@ -0,0 +1,172 @@
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

@@ -126,7 +126,7 @@ export function generatePreview(
return {
id,
group: r.name,
mode: r.mode,
mode: r.format,
spriteFile,
viewBox: extractViewBox(fragment),
vars: extractIconVars(fragment).map((v) => ({
@@ -138,7 +138,7 @@ export function generatePreview(
}
})
return { name: r.name, mode: r.mode, spriteFile, icons }
return { name: r.name, mode: r.format, spriteFile, icons }
})
// Inline SVG спрайтов

13
src/react.ts Normal file
View File

@@ -0,0 +1,13 @@
'use client'
export { SpriteViewer } from './react/sprite-viewer.js'
export type { SpriteViewerColorTheme, SpriteViewerProps } from './react/sprite-viewer.js'
export type {
SpriteManifest,
SpriteManifestColor,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from './react/types.js'

View File

@@ -0,0 +1,196 @@
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
export type SpriteViewerTab = 'react' | 'svg' | 'img' | 'css'
export type SpriteViewerCodeLanguage = 'tsx' | 'html' | 'css'
export const SPRITE_VIEWER_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
{ id: 'react', label: 'React' },
{ id: 'svg', label: 'SVG' },
{ id: 'img', label: 'IMG' },
{ id: 'css', label: 'CSS' },
]
export function tabsForFormat(format: SpriteManifest['format']) {
return format === 'stack'
? SPRITE_VIEWER_TABS
: SPRITE_VIEWER_TABS.filter((tab) => tab.id === 'react' || tab.id === 'svg')
}
export function viewBoxSize(viewBox: string | null): string | null {
if (!viewBox) return null
const values = viewBox.trim().split(/[\s,]+/).map(Number)
return values.length === 4 && values.every(Number.isFinite)
? `${values[2]} × ${values[3]}`
: null
}
export function normalizeHexColor(value: string, currentColor = '#1a1a1a'): string {
const normalized = value.trim().toLowerCase()
if (normalized === 'currentcolor') return normalizeHexColor(currentColor)
const shortHex = normalized.match(/^#([\da-f])([\da-f])([\da-f])(?:[\da-f])?$/i)
if (shortHex) return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`
const longHex = normalized.match(/^#([\da-f]{6})(?:[\da-f]{2})?$/i)
if (longHex) return `#${longHex[1]}`
const rgb = normalized.match(/^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/)
if (rgb) {
return `#${rgb.slice(1, 4).map((part) => Math.min(255, Number(part)).toString(16).padStart(2, '0')).join('')}`
}
const named: Record<string, string> = {
black: '#000000',
blue: '#0000ff',
cyan: '#00ffff',
gray: '#808080',
green: '#008000',
grey: '#808080',
magenta: '#ff00ff',
orange: '#ffa500',
pink: '#ffc0cb',
purple: '#800080',
red: '#ff0000',
white: '#ffffff',
yellow: '#ffff00',
}
return named[normalized] ?? normalizeHexColor(currentColor === value ? '#1a1a1a' : currentColor)
}
function styleLines(overrides: Readonly<Record<string, string>>): string[] {
return Object.entries(overrides).map(([variable, color]) => ` ${JSON.stringify(variable)}: ${JSON.stringify(color)},`)
}
function htmlAttribute(value: string): string {
const entities: Record<string, string> = {
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
}
return value.replace(/[&"<>]/g, (character) => entities[character])
}
function cssUrl(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}
export function generateViewerCode(options: {
manifest: SpriteManifest
icon: SpriteManifestIcon
tab: SpriteViewerTab
colorOverrides: Readonly<Record<string, string>>
cssColor: string
}): { code: string; language: SpriteViewerCodeLanguage } {
const { manifest, icon, tab, colorOverrides, cssColor } = options
const href = `${manifest.spriteUrl}#${icon.id}`
const overrides = styleLines(colorOverrides)
if (tab === 'react') {
if (overrides.length === 0) {
return {
code: `<${manifest.componentName} icon=${JSON.stringify(icon.name)} />`,
language: 'tsx',
}
}
return {
code: [
`<${manifest.componentName}`,
` icon=${JSON.stringify(icon.name)}`,
' style={{',
...overrides,
' }}',
'/>',
].join('\n'),
language: 'tsx',
}
}
if (tab === 'svg') {
const style = Object.entries(colorOverrides)
.map(([variable, color]) => `${variable}: ${color}`)
.join('; ')
return {
code: `<svg width="24" height="24"${style ? ` style="${htmlAttribute(style)}"` : ''}>\n <use href="${htmlAttribute(href)}" />\n</svg>`,
language: 'html',
}
}
if (tab === 'img') {
return {
code: `<img src="${htmlAttribute(href)}" width="24" height="24" alt="${htmlAttribute(icon.name)}">`,
language: 'html',
}
}
const className = `icon-${icon.name.replace(/[^a-zA-Z0-9_-]+/g, '-') || 'sprite'}`
const escapedHref = cssUrl(href)
return {
code: [
`.${className} {`,
' width: 24px;',
' height: 24px;',
` mask: url('${escapedHref}') no-repeat center / contain;`,
` -webkit-mask: url('${escapedHref}') no-repeat center / contain;`,
` background-color: ${cssColor};`,
'}',
].join('\n'),
language: 'css',
}
}
const ESCAPE_RE = /[&<>"]/g
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
type HighlightRule = [RegExp, string]
const MARKUP_RULES: HighlightRule[] = [
[/<!--[\s\S]*?-->/g, 'comment'],
[/<\/?[\w.-]+/g, 'tag'],
[/[\w-]+(?=\s*=)/g, 'attr'],
[/"[^"]*"|'[^']*'/g, 'string'],
]
const CSS_RULES: HighlightRule[] = [
[/\/\*[\s\S]*?\*\//g, 'comment'],
[/\.[\w-]+/g, 'selector'],
[/[\w-]+(?=\s*:)/g, 'property'],
[/'[^']*'|"[^"]*"/g, 'string'],
[/#[\da-fA-F]{3,8}\b/g, 'color'],
[/\d+(?:\.\d+)?(?:px|em|rem|%)?/g, 'number'],
]
export function highlightViewerCode(code: string, language: SpriteViewerCodeLanguage): string {
const rules = language === 'css' ? CSS_RULES : MARKUP_RULES
const tokens: Array<{ kind: string; value: string }> = []
let position = 0
while (position < code.length) {
let match: RegExpExecArray | null = null
let kind = ''
for (const [rule, ruleKind] of rules) {
rule.lastIndex = position
const candidate = rule.exec(code)
if (candidate?.index === position) {
match = candidate
kind = ruleKind
break
}
}
if (match) {
tokens.push({ kind, value: match[0] })
position += match[0].length
} else {
const previous = tokens.at(-1)
if (previous?.kind === '') previous.value += code[position]
else tokens.push({ kind: '', value: code[position] })
position++
}
}
return tokens.map(({ kind, value }) => {
const escaped = value.replace(ESCAPE_RE, (character) => ESCAPE_MAP[character])
return kind ? `<span class="hl-${kind}">${escaped}</span>` : escaped
}).join('')
}

View File

@@ -0,0 +1,317 @@
import { HexColorInput, HexColorPicker } from 'react-colorful'
import { useEffect, useId, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import {
generateViewerCode,
highlightViewerCode,
normalizeHexColor,
tabsForFormat,
viewBoxSize,
} from './sprite-viewer-code.js'
import type { SpriteViewerTab } from './sprite-viewer-code.js'
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
type SpriteViewerDialogProps = {
manifest: SpriteManifest
icon: SpriteManifestIcon
colorTheme: 'light' | 'dark'
onClose: () => void
}
type ColorControlProps = {
label: string
value: string
onChange: (value: string) => void
}
function ColorControl({ label, value, onChange }: ColorControlProps) {
const [open, setOpen] = useState(false)
const swatchRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const popoverId = useId()
useEffect(() => {
if (!open) return
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node
const insideSwatch = swatchRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideSwatch && !insidePopover) setOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [open])
return (
<div className="gromlab-sprite-viewer__color-row">
<button
ref={swatchRef}
className="gromlab-sprite-viewer__swatch"
type="button"
style={{ backgroundColor: value }}
aria-label={`Изменить цвет ${label}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
title={`Изменить цвет ${label}`}
onClick={() => setOpen((current) => !current)}
/>
<code className="gromlab-sprite-viewer__color-label">{label}</code>
{open && (
<div
ref={popoverRef}
id={popoverId}
className="gromlab-sprite-viewer__color-popover"
role="dialog"
aria-label={`Выбор цвета ${label}`}
onKeyDown={(event) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
setOpen(false)
}}
>
<HexColorPicker color={value} onChange={onChange} />
<HexColorInput
className="gromlab-sprite-viewer__hex-input"
color={value}
onChange={onChange}
prefixed
aria-label={`HEX-значение ${label}`}
/>
</div>
)}
</div>
)
}
function initialColors(icon: SpriteManifestIcon, currentColor: string) {
return Object.fromEntries(icon.colors.map(({ variable, fallback }) => [
variable,
normalizeHexColor(fallback, currentColor),
]))
}
export function SpriteViewerDialog({ manifest, icon, colorTheme, onClose }: SpriteViewerDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
const tabsId = useId()
const tabs = tabsForFormat(manifest.format)
const [activeTab, setActiveTab] = useState<SpriteViewerTab>('react')
const themeColor = colorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a'
const [colors, setColors] = useState<Record<string, string>>(() => initialColors(icon, themeColor))
const [colorOverrides, setColorOverrides] = useState<Record<string, string>>({})
const [cssColor, setCssColor] = useState(themeColor)
const [cssColorOverridden, setCssColorOverridden] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
dialog.showModal()
return () => {
if (dialog.open) dialog.close()
}
}, [icon])
useEffect(() => {
setColors({ ...initialColors(icon, themeColor), ...colorOverrides })
if (!cssColorOverridden) setCssColor(themeColor)
}, [colorTheme, icon])
useEffect(() => {
if (!copied) return
const timeout = setTimeout(() => setCopied(false), 1500)
return () => clearTimeout(timeout)
}, [copied])
const previewStyle = Object.fromEntries(
Object.entries(colors).map(([variable, color]) => [variable, color]),
) as CSSProperties
const href = `${manifest.spriteUrl}#${icon.id}`
const dimensions = viewBoxSize(icon.viewBox)
const code = generateViewerCode({ manifest, icon, tab: activeTab, colorOverrides, cssColor })
function handleColorChange(variable: string, color: string) {
const normalized = normalizeHexColor(color)
setColors((current) => ({ ...current, [variable]: normalized }))
setColorOverrides((current) => ({ ...current, [variable]: normalized }))
}
function handleCssColorChange(color: string) {
setCssColor(normalizeHexColor(color))
setCssColorOverridden(true)
}
function handleBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
if (event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom
if (outside) onClose()
}
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, tabIndex: number) {
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (tabIndex + 1) % tabs.length
if (event.key === 'ArrowLeft') nextIndex = (tabIndex - 1 + tabs.length) % tabs.length
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = tabs.length - 1
if (nextIndex === null) return
event.preventDefault()
setActiveTab(tabs[nextIndex].id)
const buttons = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function copyCode() {
if (!globalThis.navigator?.clipboard) return
try {
await globalThis.navigator.clipboard.writeText(code.code)
setCopied(true)
} catch {
setCopied(false)
}
}
function renderPreview() {
if (activeTab === 'img') {
return <img className="gromlab-sprite-viewer__dialog-img" src={href} alt={icon.name} />
}
if (activeTab === 'css') {
return (
<div
className="gromlab-sprite-viewer__dialog-mask"
role="img"
aria-label={icon.name}
style={{
backgroundColor: cssColor,
mask: `url('${href}') no-repeat center / contain`,
WebkitMask: `url('${href}') no-repeat center / contain`,
}}
/>
)
}
return (
<svg
className="gromlab-sprite-viewer__dialog-icon"
viewBox={icon.viewBox ?? undefined}
aria-label={icon.name}
role="img"
>
<use href={href} />
</svg>
)
}
return (
<dialog
ref={dialogRef}
className="gromlab-sprite-viewer__dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClick={handleBackdropClick}
>
<div className="gromlab-sprite-viewer__dialog-shell">
<button
className="gromlab-sprite-viewer__close"
type="button"
aria-label="Закрыть"
autoFocus
onClick={onClose}
>
&#x2715;
</button>
<div className="gromlab-sprite-viewer__dialog-preview" style={previewStyle}>
<div className="gromlab-sprite-viewer__dialog-preview-canvas">
{renderPreview()}
</div>
</div>
<div className="gromlab-sprite-viewer__dialog-heading">
<h2 id={titleId} className="gromlab-sprite-viewer__dialog-title">{icon.name}</h2>
{dimensions && <span className="gromlab-sprite-viewer__viewbox">{dimensions}</span>}
</div>
<p className="gromlab-sprite-viewer__dialog-meta">
{manifest.name} · {manifest.format} · {manifest.target}
</p>
<div className="gromlab-sprite-viewer__colors">
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length > 0 && (
<>
<p className="gromlab-sprite-viewer__hint">
Цвета применяются к превью через CSS-переменные и попадут в пример кода.
</p>
<h3 className="gromlab-sprite-viewer__colors-title">CSS Variables</h3>
{icon.colors.map(({ variable, fallback }) => (
<ColorControl
key={variable}
value={colors[variable]}
label={`${variable}: ${fallback}`}
onChange={(color) => handleColorChange(variable, color)}
/>
))}
</>
)}
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length === 0 && (
<p className="gromlab-sprite-viewer__hint">У иконки нет настраиваемых цветовых переменных.</p>
)}
{activeTab === 'img' && (
<p className="gromlab-sprite-viewer__hint">
IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются.
</p>
)}
{activeTab === 'css' && (
<>
<p className="gromlab-sprite-viewer__hint">
CSS mask отображает иконку одним цветом через background-color.
</p>
<ColorControl label="background-color" value={cssColor} onChange={handleCssColorChange} />
</>
)}
</div>
<div className="gromlab-sprite-viewer__tabs" role="tablist" aria-label="Способ подключения">
{tabs.map((tab, tabIndex) => (
<button
id={`${tabsId}-${tab.id}-tab`}
key={tab.id}
className="gromlab-sprite-viewer__tab"
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`${tabsId}-panel`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => handleTabKeyDown(event, tabIndex)}
>
{tab.label}
</button>
))}
</div>
<div
id={`${tabsId}-panel`}
className="gromlab-sprite-viewer__code"
role="tabpanel"
aria-labelledby={`${tabsId}-${activeTab}-tab`}
>
<pre><code dangerouslySetInnerHTML={{ __html: highlightViewerCode(code.code, code.language) }} /></pre>
<button className="gromlab-sprite-viewer__copy" type="button" onClick={() => void copyCode()}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
</div>
</dialog>
)
}

View File

@@ -0,0 +1,332 @@
export const SPRITE_VIEWER_STYLES = `
.gromlab-sprite-viewer {
--sv-bg: #f0f0f3;
--sv-text: #1a1a1a;
--sv-card: #ffffff;
--sv-card-hover: #eaeaed;
--sv-border: #d8d8d8;
--sv-accent: #3b82f6;
--sv-muted: #777777;
--sv-code: #f5f5f5;
--sv-checker-a: #e9e9e9;
--sv-checker-b: #ffffff;
--sv-danger: #b42332;
box-sizing: border-box;
min-height: 320px;
padding: 24px;
color: var(--sv-text);
color-scheme: light;
background: var(--sv-bg);
border-radius: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.gromlab-sprite-viewer[data-theme="dark"] {
--sv-bg: #1a1a1a;
--sv-text: #e5e5e5;
--sv-card: #2a2a2a;
--sv-card-hover: #333333;
--sv-border: #404040;
--sv-muted: #a3a3a3;
--sv-code: #242424;
--sv-checker-a: #333333;
--sv-checker-b: #2a2a2a;
--sv-danger: #ff9ba6;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
.gromlab-sprite-viewer:not([data-theme]) {
--sv-bg: #1a1a1a;
--sv-text: #e5e5e5;
--sv-card: #2a2a2a;
--sv-card-hover: #333333;
--sv-border: #404040;
--sv-muted: #a3a3a3;
--sv-code: #242424;
--sv-checker-a: #333333;
--sv-checker-b: #2a2a2a;
--sv-danger: #ff9ba6;
color-scheme: dark;
}
}
.gromlab-sprite-viewer *,
.gromlab-sprite-viewer *::before,
.gromlab-sprite-viewer *::after { box-sizing: border-box; }
.gromlab-sprite-viewer button,
.gromlab-sprite-viewer input { font: inherit; }
.gromlab-sprite-viewer__header {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.gromlab-sprite-viewer__title { margin: 0; font-size: 24px; line-height: 1.2; font-weight: 700; }
.gromlab-sprite-viewer__summary { color: var(--sv-muted); font-size: 13px; }
.gromlab-sprite-viewer__toolbar { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.gromlab-sprite-viewer__search {
width: 220px;
height: 38px;
padding: 0 12px;
color: var(--sv-text);
background: var(--sv-card);
border: 1px solid var(--sv-border);
border-radius: 8px;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.gromlab-sprite-viewer__search:focus { border-color: var(--sv-accent); box-shadow: 0 0 0 3px rgba(59, 130, 246, .18); }
.gromlab-sprite-viewer__search::placeholder { color: var(--sv-muted); }
.gromlab-sprite-viewer__theme {
display: grid;
place-items: center;
width: 38px;
height: 38px;
padding: 0;
color: var(--sv-text);
background: var(--sv-card);
border: 1px solid var(--sv-border);
border-radius: 8px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.gromlab-sprite-viewer__theme:hover { background: var(--sv-card-hover); }
.gromlab-sprite-viewer__theme:focus-visible,
.gromlab-sprite-viewer__card:focus-visible,
.gromlab-sprite-viewer__close:focus-visible,
.gromlab-sprite-viewer__tab:focus-visible,
.gromlab-sprite-viewer__copy:focus-visible,
.gromlab-sprite-viewer__swatch:focus-visible {
outline: 2px solid var(--sv-accent);
outline-offset: 2px;
}
.gromlab-sprite-viewer__errors {
margin: 0 0 24px;
padding: 12px 14px;
color: var(--sv-danger);
background: rgba(180, 35, 50, .08);
border: 1px solid rgba(180, 35, 50, .28);
border-radius: 8px;
font-size: 12px;
}
.gromlab-sprite-viewer__group { margin-bottom: 40px; }
.gromlab-sprite-viewer__group-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.gromlab-sprite-viewer__group-title { margin: 0; font-size: 18px; line-height: 1.3; font-weight: 600; }
.gromlab-sprite-viewer__badge {
padding: 2px 8px;
color: #ffffff;
background: var(--sv-accent);
border-radius: 999px;
font-size: 11px;
line-height: 1.5;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
}
.gromlab-sprite-viewer__group-count,
.gromlab-sprite-viewer__description { color: var(--sv-muted); font-size: 13px; font-weight: 400; }
.gromlab-sprite-viewer__description { flex-basis: 100%; margin: -2px 0 0; }
.gromlab-sprite-viewer__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.gromlab-sprite-viewer__card {
display: flex;
min-width: 0;
padding: 16px 8px;
color: inherit;
text-align: center;
background: var(--sv-card);
border: 0;
border-radius: 8px;
cursor: pointer;
flex-direction: column;
align-items: center;
gap: 8px;
transition: background .15s, transform .15s;
}
.gromlab-sprite-viewer__card:hover { background: var(--sv-card-hover); transform: translateY(-1px); }
.gromlab-sprite-viewer__icon-wrap,
.gromlab-sprite-viewer__dialog-preview-canvas {
display: grid;
place-items: center;
background: conic-gradient(
var(--sv-checker-a) 25%, var(--sv-checker-b) 0 50%,
var(--sv-checker-a) 0 75%, var(--sv-checker-b) 0
);
background-size: 8px 8px;
border-radius: 4px;
}
.gromlab-sprite-viewer__icon-wrap { width: 128px; height: 128px; }
.gromlab-sprite-viewer__icon { display: block; width: 128px; height: 128px; color: var(--sv-text); overflow: visible; }
.gromlab-sprite-viewer__icon-name {
max-width: 100%;
overflow: hidden;
color: var(--sv-muted);
font-size: 13px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.gromlab-sprite-viewer__status {
padding: 40px 20px;
color: var(--sv-muted);
text-align: center;
background: var(--sv-card);
border: 1px dashed var(--sv-border);
border-radius: 8px;
}
.gromlab-sprite-viewer__footer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 40px;
padding-top: 16px;
color: var(--sv-muted);
border-top: 1px solid var(--sv-border);
font-size: 13px;
}
.gromlab-sprite-viewer__footer a { color: var(--sv-accent); text-decoration: none; }
.gromlab-sprite-viewer__footer a:hover { text-decoration: underline; }
.gromlab-sprite-viewer__dialog {
width: min(560px, calc(100vw - 32px));
max-width: none;
max-height: calc(100dvh - 48px);
padding: 0;
color: var(--sv-text);
background: transparent;
border: 0;
overflow: visible;
}
.gromlab-sprite-viewer__dialog::backdrop { background: rgba(0, 0, 0, .55); backdrop-filter: blur(2px); }
.gromlab-sprite-viewer__dialog-shell {
position: relative;
max-height: calc(100dvh - 48px);
padding: 24px;
overflow-y: auto;
color: var(--sv-text);
background: var(--sv-bg);
border: 1px solid var(--sv-border);
border-radius: 12px;
box-shadow: 0 24px 72px rgba(0, 0, 0, .28);
}
.gromlab-sprite-viewer__close {
position: absolute;
z-index: 2;
top: 12px;
right: 12px;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
color: var(--sv-text);
background: var(--sv-bg);
border: 1px solid var(--sv-border);
border-radius: 8px;
cursor: pointer;
}
.gromlab-sprite-viewer__close:hover { background: var(--sv-card-hover); }
.gromlab-sprite-viewer__dialog-preview { display: flex; align-items: center; justify-content: center; padding: 24px; margin-bottom: 16px; background: var(--sv-card); border-radius: 8px; }
.gromlab-sprite-viewer__dialog-preview-canvas { width: 256px; height: 256px; }
.gromlab-sprite-viewer__dialog-icon,
.gromlab-sprite-viewer__dialog-img,
.gromlab-sprite-viewer__dialog-mask { display: block; width: 256px; height: 256px; }
.gromlab-sprite-viewer__dialog-icon { color: var(--sv-text); overflow: visible; }
.gromlab-sprite-viewer__dialog-img { object-fit: contain; }
.gromlab-sprite-viewer__dialog-heading { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.gromlab-sprite-viewer__dialog-title { margin: 0; font: 600 16px/1.3 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__viewbox {
padding: 2px 8px;
color: var(--sv-muted);
background: var(--sv-card);
border: 1px solid var(--sv-border);
border-radius: 999px;
font-size: 11px;
white-space: nowrap;
}
.gromlab-sprite-viewer__dialog-meta { margin: 0 0 20px; color: var(--sv-muted); text-align: center; font-size: 12px; }
.gromlab-sprite-viewer__colors { margin-bottom: 20px; }
.gromlab-sprite-viewer__hint {
margin: 0 0 12px;
padding: 8px 12px;
color: var(--sv-muted);
background: var(--sv-card);
border-left: 3px solid var(--sv-accent);
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
}
.gromlab-sprite-viewer__colors-title { margin: 0 0 8px; color: var(--sv-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.gromlab-sprite-viewer__color-row { position: relative; display: flex; align-items: center; gap: 8px; min-height: 32px; margin-bottom: 6px; }
.gromlab-sprite-viewer__swatch { width: 26px; height: 26px; flex: 0 0 auto; padding: 0; border: 1px solid var(--sv-border); border-radius: 4px; cursor: pointer; }
.gromlab-sprite-viewer__color-label { min-width: 0; overflow-wrap: anywhere; color: var(--sv-muted); font: 12px/1.4 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__color-popover {
position: absolute;
z-index: 5;
bottom: calc(100% + 6px);
left: 0;
width: 224px;
max-width: calc(100vw - 64px);
padding: 12px;
background: var(--sv-bg);
border: 1px solid var(--sv-border);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
}
.gromlab-sprite-viewer__color-popover .react-colorful { width: 198px; max-width: 100%; height: 160px; }
.gromlab-sprite-viewer__hex-input { display: block; width: 100%; height: 32px; margin-top: 8px; padding: 0 8px; color: var(--sv-text); text-align: center; background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 4px; outline: none; font: 12px/1 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__hex-input:focus { border-color: var(--sv-accent); }
.gromlab-sprite-viewer__tabs { display: flex; overflow-x: auto; margin-bottom: 12px; border-bottom: 1px solid var(--sv-border); }
.gromlab-sprite-viewer__tab { flex: 0 0 auto; padding: 8px 16px; color: var(--sv-muted); background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
.gromlab-sprite-viewer__tab[aria-selected="true"] { color: var(--sv-accent); border-bottom-color: var(--sv-accent); }
.gromlab-sprite-viewer__code { position: relative; overflow: hidden; background: var(--sv-code); border-radius: 8px; }
.gromlab-sprite-viewer__code pre { min-height: 72px; margin: 0; padding: 16px 70px 16px 16px; overflow-x: auto; font: 12px/1.6 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__copy { position: absolute; top: 8px; right: 8px; padding: 4px 8px; color: var(--sv-muted); background: var(--sv-bg); border: 1px solid var(--sv-border); border-radius: 4px; cursor: pointer; font-size: 11px; }
.gromlab-sprite-viewer__copy:hover { color: var(--sv-text); }
.gromlab-sprite-viewer__code .hl-tag { color: #116329; }
.gromlab-sprite-viewer__code .hl-attr,
.gromlab-sprite-viewer__code .hl-number,
.gromlab-sprite-viewer__code .hl-property { color: #0550ae; }
.gromlab-sprite-viewer__code .hl-string,
.gromlab-sprite-viewer__code .hl-color { color: #0a3069; }
.gromlab-sprite-viewer__code .hl-comment { color: #8b949e; }
.gromlab-sprite-viewer__code .hl-punctuation,
.gromlab-sprite-viewer__code .hl-selector { color: #6639ba; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-tag { color: #7ee787; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-attr,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-number,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-property { color: #79c0ff; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-string,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-color { color: #a5d6ff; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-punctuation,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-selector { color: #d2a8ff; }
@media (prefers-color-scheme: dark) {
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-tag { color: #7ee787; }
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-attr,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-number,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-property { color: #79c0ff; }
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-string,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-color { color: #a5d6ff; }
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-punctuation,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-selector { color: #d2a8ff; }
}
@media (max-width: 640px) {
.gromlab-sprite-viewer { padding: 16px; border-radius: 8px; }
.gromlab-sprite-viewer__header { align-items: stretch; }
.gromlab-sprite-viewer__toolbar { width: 100%; margin-left: 0; }
.gromlab-sprite-viewer__search { width: auto; flex: 1; min-width: 0; }
.gromlab-sprite-viewer__grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; }
.gromlab-sprite-viewer__icon-wrap,
.gromlab-sprite-viewer__icon { width: 104px; height: 104px; }
.gromlab-sprite-viewer__dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 20px); }
.gromlab-sprite-viewer__dialog-shell { max-height: calc(100dvh - 20px); padding: 16px; }
.gromlab-sprite-viewer__dialog-preview { padding: 16px; }
.gromlab-sprite-viewer__dialog-preview-canvas,
.gromlab-sprite-viewer__dialog-icon,
.gromlab-sprite-viewer__dialog-img,
.gromlab-sprite-viewer__dialog-mask { width: min(256px, calc(100vw - 86px)); height: min(256px, calc(100vw - 86px)); }
}
@media (prefers-reduced-motion: reduce) {
.gromlab-sprite-viewer *,
.gromlab-sprite-viewer *::before,
.gromlab-sprite-viewer *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
`

301
src/react/sprite-viewer.tsx Normal file
View File

@@ -0,0 +1,301 @@
'use client'
import { useDeferredValue, useEffect, useState } from 'react'
import type { CSSProperties, ReactElement } from 'react'
import { SpriteViewerDialog } from './sprite-viewer-dialog.js'
import { SPRITE_VIEWER_STYLES } from './sprite-viewer-styles.js'
import type {
SpriteManifest,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
export type SpriteViewerProps = {
sources: SpriteViewerSources
title?: string
/** Тема Viewer. По умолчанию следует prefers-color-scheme. */
colorTheme?: SpriteViewerColorTheme
/** Вызывается встроенным переключателем в controlled-режиме. */
onColorThemeChange?: (theme: SpriteViewerColorTheme) => void
className?: string
style?: CSSProperties
}
type SelectedIcon = {
key: string
manifest: SpriteManifest
icon: SpriteManifestIcon
}
const manifestLoaderCache = new WeakMap<SpriteManifestLoader, Promise<SpriteManifest>>()
function compareManifests(left: SpriteManifest, right: SpriteManifest): number {
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
}
function sourceArray(sources: SpriteViewerSources): readonly SpriteViewerSource[] {
return Array.isArray(sources)
? sources
: Object.values(sources as Readonly<Record<string, SpriteViewerSource>>)
}
function isSpriteManifest(value: unknown): value is SpriteManifest {
if (!value || typeof value !== 'object') return false
const manifest = value as Partial<SpriteManifest>
return manifest.schemaVersion === 1
&& manifest.generator === '@gromlab/svg-sprites'
&& typeof manifest.name === 'string'
&& typeof manifest.componentName === 'string'
&& typeof manifest.spriteUrl === 'string'
&& Array.isArray(manifest.icons)
}
function manifestFromModule(value: SpriteManifest | SpriteManifestModule): SpriteManifest {
if (isSpriteManifest(value)) return value
if (isSpriteManifest(value.default)) return value.default
if (isSpriteManifest(value.spriteManifest)) return value.spriteManifest
throw new Error('The loaded module does not export a valid SVG sprite manifest.')
}
async function resolveSource(source: SpriteViewerSource): Promise<SpriteManifest> {
if (typeof source !== 'function') return source
const cached = manifestLoaderCache.get(source)
if (cached) return cached
const pending = Promise.resolve().then(source).then(manifestFromModule)
manifestLoaderCache.set(source, pending)
void pending.catch(() => {
if (manifestLoaderCache.get(source) === pending) manifestLoaderCache.delete(source)
})
return pending
}
function directManifests(sources: SpriteViewerSources): SpriteManifest[] {
return sourceArray(sources)
.filter((source): source is SpriteManifest => typeof source !== 'function')
.sort(compareManifests)
}
function countLabel(value: number, forms: readonly [string, string, string]): string {
const modulo100 = value % 100
const modulo10 = value % 10
const form = modulo100 >= 11 && modulo100 <= 19
? forms[2]
: modulo10 === 1
? forms[0]
: modulo10 >= 2 && modulo10 <= 4
? forms[1]
: forms[2]
return `${value} ${form}`
}
function currentSystemTheme(): 'light' | 'dark' {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function useSystemTheme(): 'light' | 'dark' {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(currentSystemTheme)
useEffect(() => {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
if (!media) return
const update = () => setSystemTheme(media.matches ? 'dark' : 'light')
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return systemTheme
}
/** Интерактивный каталог локальных React- и Next.js-спрайтов. */
export function SpriteViewer({
sources,
title = 'SVG Sprites',
colorTheme,
onColorThemeChange,
className,
style,
}: SpriteViewerProps): ReactElement {
const [manifests, setManifests] = useState<SpriteManifest[]>(() => directManifests(sources))
const [errors, setErrors] = useState<string[]>([])
const [loading, setLoading] = useState(() => sourceArray(sources).some((source) => typeof source === 'function'))
const [query, setQuery] = useState('')
const [localColorTheme, setLocalColorTheme] = useState<SpriteViewerColorTheme>('auto')
const [selected, setSelected] = useState<SelectedIcon | null>(null)
const systemTheme = useSystemTheme()
const activeColorTheme = colorTheme ?? localColorTheme
const resolvedColorTheme = activeColorTheme === 'auto' ? systemTheme : activeColorTheme
const canToggleTheme = colorTheme === undefined || onColorThemeChange !== undefined
const deferredQuery = useDeferredValue(query.trim().toLowerCase())
useEffect(() => {
let active = true
const allSources = sourceArray(sources)
const direct = directManifests(sources)
const loaders = allSources.filter((source) => typeof source === 'function')
setManifests(direct)
setErrors([])
setLoading(loaders.length > 0)
setSelected(null)
if (loaders.length === 0) return () => { active = false }
Promise.allSettled(loaders.map(resolveSource)).then((results) => {
if (!active) return
const loaded: SpriteManifest[] = []
const failures: string[] = []
for (const result of results) {
if (result.status === 'fulfilled') loaded.push(result.value)
else failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
}
setManifests([...direct, ...loaded].sort(compareManifests))
setErrors(failures)
setLoading(false)
})
return () => { active = false }
}, [sources])
const visibleGroups = manifests
.map((manifest) => ({
manifest,
icons: manifest.icons.filter((icon) => (
deferredQuery === ''
|| icon.name.toLowerCase().includes(deferredQuery)
|| manifest.name.toLowerCase().includes(deferredQuery)
)),
}))
.filter((group) => group.icons.length > 0)
const totalIcons = manifests.reduce((total, manifest) => total + manifest.iconCount, 0)
const visibleIcons = visibleGroups.reduce((total, group) => total + group.icons.length, 0)
const rootClassName = ['gromlab-sprite-viewer', className].filter(Boolean).join(' ')
function toggleTheme() {
const nextTheme = resolvedColorTheme === 'dark' ? 'light' : 'dark'
if (colorTheme === undefined) setLocalColorTheme(nextTheme)
onColorThemeChange?.(nextTheme)
}
return (
<section
className={rootClassName}
style={style}
data-sprite-viewer=""
data-theme={activeColorTheme === 'auto' ? undefined : activeColorTheme}
>
<style>{SPRITE_VIEWER_STYLES}</style>
<header className="gromlab-sprite-viewer__header">
<h1 className="gromlab-sprite-viewer__title">{title}</h1>
<span className="gromlab-sprite-viewer__summary">
{countLabel(manifests.length, ['спрайт', 'спрайта', 'спрайтов'])}
{' · '}
{countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])}
{deferredQuery && ` · найдено ${visibleIcons}`}
</span>
<div className="gromlab-sprite-viewer__toolbar">
<input
className="gromlab-sprite-viewer__search"
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Найти иконку"
aria-label="Поиск иконок"
/>
{canToggleTheme && (
<button
className="gromlab-sprite-viewer__theme"
type="button"
aria-label="Переключить тему"
title="Переключить тему"
onClick={toggleTheme}
>
&#x25D1;
</button>
)}
</div>
</header>
{errors.length > 0 && (
<div className="gromlab-sprite-viewer__errors" role="alert">
{errors.map((error, index) => <div key={`${index}:${error}`}>{error}</div>)}
</div>
)}
{visibleGroups.map(({ manifest, icons }) => (
<section className="gromlab-sprite-viewer__group" key={`${manifest.name}:${manifest.spriteUrl}`}>
<div className="gromlab-sprite-viewer__group-header">
<h2 className="gromlab-sprite-viewer__group-title">{manifest.name}</h2>
<span className="gromlab-sprite-viewer__badge">{manifest.format}</span>
<span className="gromlab-sprite-viewer__group-count">{icons.length}</span>
{manifest.description && (
<p className="gromlab-sprite-viewer__description">{manifest.description}</p>
)}
</div>
<div className="gromlab-sprite-viewer__grid">
{icons.map((icon) => {
const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}`
return (
<button
className="gromlab-sprite-viewer__card"
type="button"
key={key}
data-icon-name={icon.name}
onClick={() => setSelected({ key, manifest, icon })}
title={`Открыть ${icon.name}`}
>
<span className="gromlab-sprite-viewer__icon-wrap">
<svg
className="gromlab-sprite-viewer__icon"
viewBox={icon.viewBox ?? undefined}
aria-hidden="true"
>
<use href={`${manifest.spriteUrl}#${icon.id}`} />
</svg>
</span>
<span className="gromlab-sprite-viewer__icon-name">{icon.name}</span>
</button>
)
})}
</div>
</section>
))}
{visibleGroups.length === 0 && (!loading || manifests.length > 0) && (
<div className="gromlab-sprite-viewer__status">
{manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'}
</div>
)}
{loading && manifests.length === 0 && (
<div className="gromlab-sprite-viewer__status">Загрузка спрайтов...</div>
)}
<footer className="gromlab-sprite-viewer__footer">
<span>@gromlab/svg-sprites</span>
<a href="https://gromlab.ru/gromov/svg-sprites" target="_blank" rel="noreferrer">Repository</a>
</footer>
{selected && (
<SpriteViewerDialog
key={selected.key}
manifest={selected.manifest}
icon={selected.icon}
colorTheme={resolvedColorTheme}
onClose={() => setSelected(null)}
/>
)}
</section>
)
}

40
src/react/types.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { SpriteAssetTarget } from '../targets/types.js'
export type SpriteManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type SpriteManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly SpriteManifestColor[]
}
/** Публичные данные одного сгенерированного React-спрайта. */
export type SpriteManifest = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
componentName: string
target: SpriteAssetTarget
format: 'stack' | 'symbol'
iconCount: number
spriteUrl: string
icons: readonly SpriteManifestIcon[]
}
export type SpriteManifestModule = {
default?: SpriteManifest
spriteManifest?: SpriteManifest
}
export type SpriteManifestLoader = () => Promise<SpriteManifest | SpriteManifestModule>
export type SpriteViewerSource = SpriteManifest | SpriteManifestLoader
/** Массив источников либо результат import.meta.glob. */
export type SpriteViewerSources =
| readonly SpriteViewerSource[]
| Readonly<Record<string, SpriteViewerSource>>

View File

@@ -37,11 +37,36 @@ function resolveFiles(files: string[]): string[] {
})
}
type ResolveSpriteSourcesOptions = {
name: string
format: SpriteFolder['format']
inputFolder: string | null
inputFiles: string[]
}
/** Объединяет SVG из папки и явного списка в один источник спрайта. */
export function resolveSpriteSources(options: ResolveSpriteSourcesOptions): SpriteFolder {
const { name, format, inputFolder, inputFiles } = options
const folderFiles = inputFolder === null ? [] : scanDirectory(inputFolder)
const files = [...new Set([...folderFiles, ...resolveFiles(inputFiles)])]
if (files.length === 0) {
throw new Error(`Sprite "${name}" has no SVG files in configured inputs.`)
}
return {
name,
format,
path: inputFolder,
files,
}
}
/**
* Преобразует SpriteEntry из конфига в SpriteFolder для компиляции.
*/
export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
const mode = entry.mode ?? 'stack'
const format = entry.format ?? 'stack'
if (Array.isArray(entry.input)) {
const files = resolveFiles(entry.input)
@@ -52,7 +77,7 @@ export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
return {
name: entry.name,
mode,
format,
path: null,
files,
}
@@ -67,7 +92,7 @@ export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
return {
name: entry.name,
mode,
format,
path: dirPath,
files,
}

14
src/shape-id.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createHash } from 'node:crypto'
import path from 'node:path'
/** Возвращает стабильный и безопасный fragment ID для исходного SVG-файла. */
export function getSpriteShapeId(filePath: string): string {
const iconName = path.basename(filePath, '.svg')
if (/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(iconName)) {
return iconName
}
const hash = createHash('sha256').update(iconName).digest('hex').slice(0, 16)
return `icon-${hash}`
}

33
src/targets/index.ts Normal file
View File

@@ -0,0 +1,33 @@
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'

45
src/targets/types.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Asset target для Vite.
*
* Генерирует статический импорт SVG с query `?no-inline`, чтобы Vite всегда
* выпускал спрайт отдельным кешируемым файлом и не преобразовывал его в data URL.
*/
export type ViteAssetTarget = 'vite'
/**
* Asset target для Webpack 5.
*
* Генерирует статический `new URL(..., import.meta.url)`. Webpack обрабатывает
* такую ссылку через Asset Modules и выпускает SVG отдельным файлом.
*/
export type WebpackAssetTarget = 'webpack'
/** Роутер Next.js, для которого генерируется sprite-модуль. */
export type NextRouter = 'app' | 'pages'
/** Сборщик Next.js, обрабатывающий SVG asset. */
export type NextBundler = 'turbopack' | 'webpack'
/** Полный ключ Next.js target, используемый CLI и manifest. */
export type NextAssetTarget = `next@${NextRouter}/${NextBundler}`
/**
* Среда сборки React sprite-модуля.
*
* Target определяет только способ получения публичного URL SVG asset.
* Состав иконок, React API и структура generated-файлов от него не зависят.
*/
export type ReactAssetTarget = ViteAssetTarget | WebpackAssetTarget
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget
/** Фрагменты кода, необходимые компоненту для получения URL SVG asset. */
export type SpriteAssetUrlCode = {
/** Импорты, которые добавляются в начало generated-компонента. */
imports: string[]
/** Объявления, которые добавляются после импортов. */
declarations: string[]
/** Имя переменной, содержащей итоговый публичный URL SVG. */
variableName: string
}

10
src/targets/vite.ts Normal file
View File

@@ -0,0 +1,10 @@
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',
}
}

12
src/targets/webpack.ts Normal file
View File

@@ -0,0 +1,12 @@
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,5 +1,5 @@
/** Режим спрайта: stack или symbol. */
export type SpriteMode = 'stack' | 'symbol'
/** Формат спрайта: stack или symbol. */
export type SpriteFormat = 'stack' | 'symbol'
/** Описание одного спрайта в конфиге. */
export type SpriteEntry = {
@@ -12,10 +12,10 @@ export type SpriteEntry = {
*/
input: string | string[]
/**
* Режим спрайта.
* Формат спрайта.
* По умолчанию: 'stack'.
*/
mode?: SpriteMode
format?: SpriteFormat
}
/** Параметры трансформации SVG. Все включены по умолчанию. */
@@ -43,22 +43,11 @@ export type TransformOptions = {
export type SvgSpritesConfig = {
/** Путь к папке для сгенерированных SVG-спрайтов. */
output: string
/**
* Публичный путь к спрайтам для использования в коде (href, src, url()).
* Используется в сгенерированном React-компоненте.
* Пример: '/img/sprites'.
*/
publicPath?: string
/**
* Генерировать HTML-превью со всеми иконками.
* По умолчанию: true.
*/
preview?: boolean
/**
* Путь для генерации React-компонента.
* Если не задан — компонент и типы не генерируются.
*/
react?: string
/**
* Настройки трансформации SVG.
* По умолчанию: все трансформации включены.
@@ -72,8 +61,8 @@ export type SvgSpritesConfig = {
export type SpriteFolder = {
/** Имя спрайта. */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Формат спрайта. */
format: SpriteFormat
/** Абсолютный путь к папке (для input-папки) или null (для input-массива). */
path: string | null
/** Абсолютные пути к SVG-файлам. */
@@ -84,8 +73,8 @@ export type SpriteFolder = {
export type SpriteResult = {
/** Имя спрайта. */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Формат спрайта. */
format: SpriteFormat
/** Путь к сгенерированному SVG-спрайту. */
spritePath: string
/** Количество иконок в спрайте. */