2026-07-13 20:07:12 +03:00
|
|
|
import fs from 'node:fs'
|
|
|
|
|
import path from 'node:path'
|
|
|
|
|
import { createJiti } from 'jiti'
|
|
|
|
|
import { toKebabCase, validateSpriteName } from './core/naming.js'
|
|
|
|
|
import type { SpriteMode } from './targets/types.js'
|
|
|
|
|
import type { ResolvedSpriteConfig, SpriteConfig, TransformOptions } from './types.js'
|
|
|
|
|
|
|
|
|
|
const CONFIG_EXTENSIONS = new Set(['.js', '.json', '.ts'])
|
|
|
|
|
const CONFIG_FIELDS = new Set([
|
|
|
|
|
'mode',
|
|
|
|
|
'name',
|
|
|
|
|
'description',
|
2026-07-14 16:11:39 +03:00
|
|
|
'input',
|
2026-07-13 20:07:12 +03:00
|
|
|
'transform',
|
|
|
|
|
'generatedNotice',
|
|
|
|
|
])
|
|
|
|
|
const TRANSFORM_FIELDS = new Set(['removeSize', 'replaceColors', 'addTransition'])
|
|
|
|
|
const MODES = new Set<SpriteMode>([
|
2026-07-14 08:34:45 +03:00
|
|
|
'standalone',
|
|
|
|
|
'standalone@vite',
|
|
|
|
|
'standalone@webpack',
|
2026-07-13 20:07:12 +03:00
|
|
|
'react@vite',
|
|
|
|
|
'react@webpack',
|
feat: добавить поддержку 20 framework exact modes
Добавлены exact modes:
- vue@vite и vue@webpack
- nuxt@vite и nuxt@webpack
- svelte@vite, svelte@webpack и sveltekit@vite
- angular@application и angular@webpack
- astro@vite
- solid@vite, solid@webpack и solid-start@vite
- preact@vite и preact@webpack
- qwik@vite
- lit@vite и lit@webpack
- alpine@vite и alpine@webpack
Для каждого mode реализованы изолированный adapter, нативный
framework-компонент, declarations, manifest, CSS и внешний asset URL.
Добавлены production integration-стенды с генерацией, typecheck,
сборкой и Playwright-проверкой рендера спрайта и Viewer.
Обновлены Viewer, RU/EN-гайды, README, technical reference и AI skills.
Итоговая матрица включает 29 exact modes.
Проверки:
- 48 unit-тестов
- 29 integration E2E-тестов
2026-07-15 16:56:44 +03:00
|
|
|
'vue@vite',
|
|
|
|
|
'vue@webpack',
|
|
|
|
|
'nuxt@vite',
|
|
|
|
|
'nuxt@webpack',
|
|
|
|
|
'svelte@vite',
|
|
|
|
|
'svelte@webpack',
|
|
|
|
|
'sveltekit@vite',
|
|
|
|
|
'angular@application',
|
|
|
|
|
'angular@webpack',
|
|
|
|
|
'astro@vite',
|
|
|
|
|
'solid@vite',
|
|
|
|
|
'solid@webpack',
|
|
|
|
|
'solid-start@vite',
|
|
|
|
|
'preact@vite',
|
|
|
|
|
'preact@webpack',
|
|
|
|
|
'qwik@vite',
|
|
|
|
|
'lit@vite',
|
|
|
|
|
'lit@webpack',
|
|
|
|
|
'alpine@vite',
|
|
|
|
|
'alpine@webpack',
|
2026-07-13 20:07:12 +03:00
|
|
|
'next@app/turbopack',
|
|
|
|
|
'next@app/webpack',
|
|
|
|
|
'next@pages/turbopack',
|
|
|
|
|
'next@pages/webpack',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
export type SpriteConfigSource = {
|
|
|
|
|
rootDir: string
|
|
|
|
|
configPath: string | null
|
|
|
|
|
config: SpriteConfig
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isSpriteMode(value: unknown): value is SpriteMode {
|
|
|
|
|
return typeof value === 'string' && MODES.has(value as SpriteMode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDefaultName(rootDir: string): string {
|
|
|
|
|
const rootName = path.basename(rootDir)
|
|
|
|
|
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
|
|
|
|
|
? path.basename(path.dirname(rootDir))
|
|
|
|
|
: rootName
|
|
|
|
|
const name = toKebabCase(source)
|
|
|
|
|
|
|
|
|
|
if (!name) throw new Error(`Cannot infer sprite name from directory: ${rootDir}`)
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function configError(message: string): Error {
|
|
|
|
|
return new Error(`Sprite config: ${message}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Проверяет единый конфиг независимо от формата исходного файла. */
|
|
|
|
|
export function validateSpriteConfig(value: unknown): asserts value is SpriteConfig {
|
|
|
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
|
|
|
throw configError('expected an object.')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config = value as Record<string, unknown>
|
|
|
|
|
|
|
|
|
|
if ('output' in config || 'sprites' in config || 'preview' in config) {
|
|
|
|
|
throw configError('legacy config fields are no longer supported.')
|
|
|
|
|
}
|
2026-07-14 16:11:39 +03:00
|
|
|
if ('inputFolder' in config || 'inputFiles' in config) {
|
|
|
|
|
throw configError('"inputFolder" and "inputFiles" are no longer supported. Use "input".')
|
|
|
|
|
}
|
2026-07-13 20:07:12 +03:00
|
|
|
for (const field of Object.keys(config)) {
|
|
|
|
|
if (!CONFIG_FIELDS.has(field)) throw configError(`unknown field "${field}".`)
|
|
|
|
|
}
|
|
|
|
|
if (config.mode !== undefined && !isSpriteMode(config.mode)) {
|
|
|
|
|
throw configError(`unsupported "mode": ${String(config.mode)}.`)
|
|
|
|
|
}
|
|
|
|
|
if (config.name !== undefined && typeof config.name !== 'string') {
|
|
|
|
|
throw configError('"name" must be a string.')
|
|
|
|
|
}
|
|
|
|
|
if (config.description !== undefined && typeof config.description !== 'string') {
|
|
|
|
|
throw configError('"description" must be a string.')
|
|
|
|
|
}
|
2026-07-14 16:11:39 +03:00
|
|
|
if (config.input !== undefined && (
|
|
|
|
|
typeof config.input !== 'string'
|
|
|
|
|
? !Array.isArray(config.input)
|
|
|
|
|
|| config.input.length === 0
|
|
|
|
|
|| config.input.some((entry) => typeof entry !== 'string' || entry.trim() === '')
|
|
|
|
|
: config.input.trim() === ''
|
2026-07-13 20:07:12 +03:00
|
|
|
)) {
|
2026-07-14 16:11:39 +03:00
|
|
|
throw configError('"input" must be a non-empty string or an array of non-empty strings.')
|
2026-07-13 20:07:12 +03:00
|
|
|
}
|
|
|
|
|
if (config.transform !== undefined) {
|
|
|
|
|
if (
|
|
|
|
|
config.transform === null
|
|
|
|
|
|| typeof config.transform !== 'object'
|
|
|
|
|
|| Array.isArray(config.transform)
|
|
|
|
|
) {
|
|
|
|
|
throw configError('"transform" must be an object.')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const transform = config.transform as Record<string, unknown>
|
|
|
|
|
for (const field of Object.keys(transform)) {
|
|
|
|
|
if (!TRANSFORM_FIELDS.has(field)) {
|
|
|
|
|
throw configError(`unknown field "transform.${field}".`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const option of ['removeSize', 'replaceColors', 'addTransition']) {
|
|
|
|
|
if (transform[option] !== undefined && typeof transform[option] !== 'boolean') {
|
|
|
|
|
throw configError(`"transform.${option}" must be a boolean.`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (config.generatedNotice !== undefined && typeof config.generatedNotice !== 'boolean') {
|
|
|
|
|
throw configError('"generatedNotice" must be a boolean.')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getModuleDefault(value: unknown, configPath: string): unknown {
|
|
|
|
|
if (!value || typeof value !== 'object' || !('default' in value)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Sprite config file must have a default export: ${configPath}\n`
|
|
|
|
|
+ 'Use: export default defineSpriteConfig({ ... })',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return (value as { default: unknown }).default
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Загружает явно указанный JS, JSON или TS config-файл. */
|
|
|
|
|
export async function loadSpriteConfig(configFile: string): Promise<SpriteConfig> {
|
|
|
|
|
const configPath = path.resolve(configFile)
|
|
|
|
|
const extension = path.extname(configPath).toLowerCase()
|
|
|
|
|
|
|
|
|
|
if (!CONFIG_EXTENSIONS.has(extension)) {
|
|
|
|
|
throw new Error(`Unsupported sprite config extension: ${extension || '(none)'}. Supported: .js, .json, .ts.`)
|
|
|
|
|
}
|
|
|
|
|
if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
|
|
|
|
|
throw new Error(`Sprite config file does not exist: ${configPath}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let config: unknown
|
|
|
|
|
if (extension === '.json') {
|
|
|
|
|
try {
|
|
|
|
|
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const reason = error instanceof Error ? error.message : String(error)
|
|
|
|
|
throw new Error(`Cannot parse sprite config: ${configPath}\n${reason}`)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const jiti = createJiti(path.dirname(configPath))
|
|
|
|
|
config = getModuleDefault(await jiti.import(configPath), configPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateSpriteConfig(config)
|
|
|
|
|
return config
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Разрешает позиционный путь как config-файл либо config-less корень модуля. */
|
|
|
|
|
export async function resolveSpriteConfigSource(source: string): Promise<SpriteConfigSource> {
|
|
|
|
|
const resolved = path.resolve(source)
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(resolved)) {
|
|
|
|
|
throw new Error(`Sprite config or module directory does not exist: ${resolved}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stats = fs.statSync(resolved)
|
|
|
|
|
if (stats.isDirectory()) {
|
|
|
|
|
return { rootDir: resolved, configPath: null, config: {} }
|
|
|
|
|
}
|
|
|
|
|
if (!stats.isFile()) {
|
|
|
|
|
throw new Error(`Sprite config path must be a file or directory: ${resolved}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
rootDir: path.dirname(resolved),
|
|
|
|
|
configPath: resolved,
|
|
|
|
|
config: await loadSpriteConfig(resolved),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function definedEntries<T extends object>(value: T): Partial<T> {
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
Object.entries(value).filter(([, entry]) => entry !== undefined),
|
|
|
|
|
) as Partial<T>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Объединяет defaults, config и overrides и разрешает пути от корня модуля. */
|
|
|
|
|
export function resolveSpriteConfig(
|
|
|
|
|
rootDir: string,
|
|
|
|
|
config: SpriteConfig = {},
|
|
|
|
|
overrides: SpriteConfig = {},
|
|
|
|
|
): ResolvedSpriteConfig {
|
|
|
|
|
validateSpriteConfig(config)
|
|
|
|
|
validateSpriteConfig(overrides)
|
|
|
|
|
|
|
|
|
|
const configValues = definedEntries(config)
|
|
|
|
|
const overrideValues = definedEntries(overrides)
|
|
|
|
|
const transform: TransformOptions = {
|
|
|
|
|
...config.transform,
|
|
|
|
|
...overrides.transform,
|
|
|
|
|
}
|
|
|
|
|
const merged: SpriteConfig = {
|
|
|
|
|
...configValues,
|
|
|
|
|
...overrideValues,
|
|
|
|
|
transform,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!merged.mode) {
|
|
|
|
|
throw new Error('Sprite mode is required. Set "mode" in the config or pass it through CLI/API.')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = merged.name ?? getDefaultName(rootDir)
|
|
|
|
|
validateSpriteName(name)
|
2026-07-14 16:11:39 +03:00
|
|
|
const configuredInput = merged.input ?? 'icons'
|
|
|
|
|
const input = (Array.isArray(configuredInput) ? configuredInput : [configuredInput])
|
|
|
|
|
.map((entry) => {
|
|
|
|
|
const negated = entry.startsWith('!')
|
|
|
|
|
const resolved = path.resolve(rootDir, negated ? entry.slice(1) : entry)
|
|
|
|
|
const normalized = path.sep === '/' ? resolved : resolved.replaceAll(path.sep, '/')
|
|
|
|
|
return negated ? `!${normalized}` : normalized
|
|
|
|
|
})
|
2026-07-13 20:07:12 +03:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
mode: merged.mode,
|
|
|
|
|
name,
|
|
|
|
|
description: merged.description,
|
2026-07-14 16:11:39 +03:00
|
|
|
input,
|
2026-07-13 20:07:12 +03:00
|
|
|
transform: {
|
|
|
|
|
removeSize: transform.removeSize ?? true,
|
|
|
|
|
replaceColors: transform.replaceColors ?? true,
|
|
|
|
|
addTransition: transform.addTransition ?? true,
|
|
|
|
|
},
|
|
|
|
|
generatedNotice: merged.generatedNotice ?? true,
|
|
|
|
|
}
|
|
|
|
|
}
|