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

@@ -0,0 +1,74 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import type { SvgSpritesConfig } from '../../types.js'
const CONFIG_NAME = 'svg-sprites.config'
/** Загружает legacy-конфиг из указанной директории. */
export async function loadLegacyConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
const configPath = path.join(cwd, `${CONFIG_NAME}.ts`)
if (!fs.existsSync(configPath)) {
throw new Error(
`Config file not found: ${configPath}\n` +
`Create a ${CONFIG_NAME}.ts file in the project root.`,
)
}
const jiti = createJiti(cwd)
const mod = await jiti.import(configPath) as { default?: SvgSpritesConfig }
const config = mod.default
if (!config) {
throw new Error(
`Config file must have a default export: ${configPath}\n` +
'Use: export default defineLegacyConfig({ ... })',
)
}
validateLegacyConfig(config)
return {
...config,
output: path.resolve(cwd, config.output),
sprites: config.sprites.map((sprite) => ({
...sprite,
input: Array.isArray(sprite.input)
? sprite.input.map((filePath) => path.resolve(cwd, filePath))
: path.resolve(cwd, sprite.input),
})),
}
}
/** Валидирует legacy-конфиг. */
export function validateLegacyConfig(config: SvgSpritesConfig): void {
if (!config.output) {
throw new Error('Config: "output" is required.')
}
if (!config.sprites || config.sprites.length === 0) {
throw new Error('Config: "sprites" must be a non-empty array.')
}
for (const sprite of config.sprites) {
if ('mode' in sprite) {
throw new Error(
`Config: sprite "${sprite.name}" uses deprecated "mode". Use "format" instead.`,
)
}
if (!sprite.name) {
throw new Error('Config: each sprite must have a "name".')
}
if (!sprite.input) {
throw new Error(`Config: sprite "${sprite.name}" must have an "input".`)
}
if (sprite.format && sprite.format !== 'stack' && sprite.format !== 'symbol') {
throw new Error(
`Config: sprite "${sprite.name}" has invalid format "${sprite.format}". Supported: stack, symbol.`,
)
}
}
}

View File

@@ -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`)
}