This commit is contained in:
2026-07-14 16:11:39 +03:00
parent 1280eb6fcd
commit ab7042001a
126 changed files with 5670 additions and 4120 deletions

View File

@@ -25,8 +25,7 @@ export const CLI_USAGE = [
' --mode <mode>',
' --name <name>',
' --description <text>',
' --input-folder <path>',
' --input-file <path> Repeat for multiple files',
' --input <path-or-glob> Repeat for multiple inputs',
' --[no-]remove-size',
' --[no-]replace-colors',
' --[no-]add-transition',
@@ -86,15 +85,12 @@ export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
index = nextIndex
continue
}
if (argument === '--input-folder' || argument.startsWith('--input-folder=')) {
const [value, nextIndex] = optionValue(argv, index, '--input-folder')
overrides.inputFolder = value
index = nextIndex
continue
}
if (argument === '--input-file' || argument.startsWith('--input-file=')) {
const [value, nextIndex] = optionValue(argv, index, '--input-file')
overrides.inputFiles = [...(overrides.inputFiles ?? []), value]
if (argument === '--input' || argument.startsWith('--input=')) {
const [value, nextIndex] = optionValue(argv, index, '--input')
const input = overrides.input === undefined
? []
: Array.isArray(overrides.input) ? overrides.input : [overrides.input]
overrides.input = [...input, value]
index = nextIndex
continue
}

View File

@@ -10,8 +10,7 @@ const CONFIG_FIELDS = new Set([
'mode',
'name',
'description',
'inputFolder',
'inputFiles',
'input',
'transform',
'generatedNotice',
])
@@ -64,6 +63,9 @@ export function validateSpriteConfig(value: unknown): asserts value is SpriteCon
if ('output' in config || 'sprites' in config || 'preview' in config) {
throw configError('legacy config fields are no longer supported.')
}
if ('inputFolder' in config || 'inputFiles' in config) {
throw configError('"inputFolder" and "inputFiles" are no longer supported. Use "input".')
}
for (const field of Object.keys(config)) {
if (!CONFIG_FIELDS.has(field)) throw configError(`unknown field "${field}".`)
}
@@ -76,19 +78,14 @@ export function validateSpriteConfig(value: unknown): asserts value is SpriteCon
if (config.description !== undefined && typeof config.description !== 'string') {
throw configError('"description" must be a string.')
}
if ('icons' in config) {
throw configError('"icons" was renamed to "inputFolder".')
}
if (config.inputFolder !== undefined && (
typeof config.inputFolder !== 'string' || config.inputFolder.trim() === ''
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() === ''
)) {
throw configError('"inputFolder" must be a non-empty string.')
}
if (config.inputFiles !== undefined && (
!Array.isArray(config.inputFiles)
|| config.inputFiles.some((filePath) => typeof filePath !== 'string' || filePath.trim() === '')
)) {
throw configError('"inputFiles" must be an array of non-empty strings.')
throw configError('"input" must be a non-empty string or an array of non-empty strings.')
}
if (config.transform !== undefined) {
if (
@@ -211,20 +208,20 @@ export function resolveSpriteConfig(
const name = merged.name ?? getDefaultName(rootDir)
validateSpriteName(name)
const inputFiles = (merged.inputFiles ?? []).map((filePath) => path.resolve(rootDir, filePath))
const defaultInputFolder = path.resolve(rootDir, 'icons')
const inputFolder = merged.inputFolder === undefined
&& inputFiles.length > 0
&& !fs.existsSync(defaultInputFolder)
? null
: path.resolve(rootDir, merged.inputFolder ?? 'icons')
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
})
return {
mode: merged.mode,
name,
description: merged.description,
inputFolder,
inputFiles,
input,
transform: {
removeSize: transform.removeSize ?? true,
replaceColors: transform.replaceColors ?? true,

View File

@@ -42,6 +42,8 @@ export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadat
export type OutputPlan = {
readonly files: readonly GeneratedFile[]
/** Создавать управляемый `.gitignore` для generated-каталога. По умолчанию: true. */
readonly createGitignore?: boolean
readonly paths: {
readonly generatedDir: '.svg-sprite'
readonly sprite: string
@@ -53,6 +55,5 @@ export type OutputPlan = {
export interface ModeAdapter<M extends SpriteMode = SpriteMode> {
readonly mode: M
readonly contractVersion: number
generate(context: ModeAdapterContext): Promise<OutputPlan>
}

View File

@@ -2,44 +2,17 @@ import { randomUUID } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteMode } from '../targets/types.js'
import type { GeneratedFile } from './mode-adapter.js'
import {
GENERATED_MARKER,
GENERATED_NOTICE_MARKER,
GENERATOR,
} from './generated-markers.js'
import type { GeneratedFile } from './mode-adapter.js'
const STATE_PATH = '.svg-sprite/state.json'
const PREVIOUS_STATE_PATHS = [
'.svg-sprite-data/state.json',
'generated/state.json',
] as const
const LEGACY_STATE_PATH = 'generated/.svg-sprites.manifest.json'
const DATA_ROOT = '.svg-sprite'
const PREVIOUS_DATA_ROOT = '.svg-sprite-data'
const SYSTEM_FILES: readonly GeneratedFile[] = [
{
path: '.gitignore',
content: `# ${GENERATED_MARKER}. Do not edit.\n/${DATA_ROOT}/\n`,
},
]
type OutputState = {
schemaVersion: 1
generator: typeof GENERATOR
owner: {
mode: SpriteMode
contractVersion: number
}
files: string[]
warning?: string
}
type LegacyState = {
version: 1
generator: typeof GENERATOR
files: string[]
}
const DATA_PREFIX = `${DATA_ROOT}/`
const BACKUP_SUFFIX = '.old'
const STAGING_SUFFIX = '.tmp'
const GITIGNORE_CONTENT = `# ${GENERATED_MARKER}. Do not edit.\n/${DATA_ROOT}/\n`
function normalizeManagedPath(relativePath: string): string {
const normalized = relativePath.replaceAll('\\', '/')
@@ -49,9 +22,6 @@ function normalizeManagedPath(relativePath: string): string {
normalized === ''
|| path.posix.isAbsolute(normalized)
|| parts.some((part) => part === '' || part === '.' || part === '..')
|| normalized === STATE_PATH
|| PREVIOUS_STATE_PATHS.includes(normalized as typeof PREVIOUS_STATE_PATHS[number])
|| normalized === LEGACY_STATE_PATH
) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
@@ -59,23 +29,11 @@ function normalizeManagedPath(relativePath: string): string {
return normalized
}
function resolveManagedPath(rootDir: string, relativePath: string): string {
const normalized = normalizeManagedPath(relativePath)
const resolved = path.resolve(rootDir, normalized)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return resolved
}
function resolveInternalPath(rootDir: string, relativePath: string): string {
function resolveInsideRoot(rootDir: string, relativePath: string): string {
const resolved = path.resolve(rootDir, relativePath)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid internal output path: ${relativePath}`)
throw new Error(`Invalid generated path: ${relativePath}`)
}
return resolved
}
@@ -97,115 +55,8 @@ function assertNoSymlinks(rootDir: string, filePath: string): void {
}
}
function parseJsonFile(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
} catch {
throw new Error(`Cannot parse generated state: ${filePath}`)
}
}
function readOutputState(statePath: string): OutputState {
const state = parseJsonFile(statePath)
if (
!state
|| typeof state !== 'object'
|| !('schemaVersion' in state)
|| state.schemaVersion !== 1
|| !('generator' in state)
|| state.generator !== GENERATOR
|| !('owner' in state)
|| !state.owner
|| typeof state.owner !== 'object'
|| !('mode' in state.owner)
|| typeof state.owner.mode !== 'string'
|| !('contractVersion' in state.owner)
|| typeof state.owner.contractVersion !== 'number'
|| !('files' in state)
|| !Array.isArray(state.files)
|| !state.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated state: ${statePath}`)
}
return state as OutputState
}
function readPreviousFiles(rootDir: string): {
files: string[]
obsoleteStatePath: string | null
} {
for (const relativeStatePath of [STATE_PATH, ...PREVIOUS_STATE_PATHS]) {
const statePath = resolveInternalPath(rootDir, relativeStatePath)
assertNoSymlinks(rootDir, statePath)
if (!fs.existsSync(statePath)) continue
return {
files: readOutputState(statePath).files.map(normalizeManagedPath),
obsoleteStatePath: relativeStatePath === STATE_PATH ? null : statePath,
}
}
const legacyStatePath = resolveInternalPath(rootDir, LEGACY_STATE_PATH)
assertNoSymlinks(rootDir, legacyStatePath)
if (!fs.existsSync(legacyStatePath)) return { files: [], obsoleteStatePath: null }
const legacyState = parseJsonFile(legacyStatePath)
if (
!legacyState
|| typeof legacyState !== 'object'
|| !('version' in legacyState)
|| legacyState.version !== 1
|| !('generator' in legacyState)
|| legacyState.generator !== GENERATOR
|| !('files' in legacyState)
|| !Array.isArray(legacyState.files)
|| !legacyState.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated state: ${legacyStatePath}`)
}
return {
files: (legacyState as LegacyState).files.map(normalizeManagedPath),
obsoleteStatePath: legacyStatePath,
}
}
function removeEmptyStateDirectory(rootDir: string, statePath: string): void {
const directory = path.dirname(statePath)
if (directory === rootDir) return
try {
fs.rmdirSync(directory)
} catch (error) {
if (error instanceof Error && 'code' in error && (
error.code === 'ENOENT' || error.code === 'ENOTEMPTY'
)) return
throw error
}
}
function removeEmptyManagedParents(rootDir: string, filePath: string): void {
for (const relativeDataRoot of [DATA_ROOT, PREVIOUS_DATA_ROOT]) {
const dataRoot = resolveInternalPath(rootDir, relativeDataRoot)
let directory = path.dirname(filePath)
const relative = path.relative(dataRoot, directory)
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
while (directory !== dataRoot) {
try {
fs.rmdirSync(directory)
} catch (error) {
if (error instanceof Error && 'code' in error && (
error.code === 'ENOENT' || error.code === 'ENOTEMPTY'
)) return
throw error
}
directory = path.dirname(directory)
}
return
}
}
function hasGeneratedMarker(filePath: string): boolean {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
if (!fs.existsSync(filePath) || !fs.lstatSync(filePath).isFile()) return false
const content = fs.readFileSync(filePath, 'utf-8')
return content.includes(GENERATED_MARKER) || content.includes(GENERATED_NOTICE_MARKER)
}
@@ -228,70 +79,142 @@ function writeFileAtomic(rootDir: string, filePath: string, content: string | Ui
}
}
/** Применяет mode output plan и последним записывает ownership state. */
function validateGitignore(rootDir: string, createGitignore: boolean): void {
if (!createGitignore) return
const gitignorePath = resolveInsideRoot(rootDir, '.gitignore')
assertNoSymlinks(rootDir, gitignorePath)
if (fs.existsSync(gitignorePath) && !hasGeneratedMarker(gitignorePath)) {
throw new Error(
`Refusing to overwrite a user file: ${gitignorePath}\n`
+ 'Move the file or choose another sprite directory.',
)
}
}
function updateGitignore(rootDir: string, createGitignore: boolean): void {
const gitignorePath = resolveInsideRoot(rootDir, '.gitignore')
if (createGitignore) {
writeFileAtomic(rootDir, gitignorePath, GITIGNORE_CONTENT)
} else if (hasGeneratedMarker(gitignorePath)) {
fs.unlinkSync(gitignorePath)
}
}
function transientPaths(rootDir: string, suffix: string): string[] {
return fs.readdirSync(rootDir)
.filter((entry) => entry.startsWith(`${DATA_ROOT}.`) && entry.endsWith(suffix))
.map((entry) => resolveInsideRoot(rootDir, entry))
}
function recoverInterruptedReplacement(rootDir: string): void {
const outputPath = resolveInsideRoot(rootDir, DATA_ROOT)
assertNoSymlinks(rootDir, outputPath)
const backups = transientPaths(rootDir, BACKUP_SUFFIX)
.sort((left, right) => fs.lstatSync(right).mtimeMs - fs.lstatSync(left).mtimeMs)
if (!fs.existsSync(outputPath) && backups.length > 0) {
const [latestBackup] = backups.splice(0, 1)
assertNoSymlinks(rootDir, latestBackup)
fs.renameSync(latestBackup, outputPath)
}
for (const transientPath of [
...backups,
...transientPaths(rootDir, STAGING_SUFFIX),
]) {
assertNoSymlinks(rootDir, transientPath)
fs.rmSync(transientPath, { recursive: true, force: true })
}
}
function stageOutput(rootDir: string, files: readonly GeneratedFile[]): string {
const stagingPath = resolveInsideRoot(
rootDir,
`${DATA_ROOT}.${process.pid}.${randomUUID()}${STAGING_SUFFIX}`,
)
assertNoSymlinks(rootDir, stagingPath)
fs.mkdirSync(stagingPath)
try {
for (const file of files) {
const relativePath = file.path.slice(DATA_PREFIX.length)
const filePath = path.resolve(stagingPath, relativePath)
const relative = path.relative(stagingPath, filePath)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid generated file path: ${file.path}`)
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, file.content, { flag: 'wx' })
}
return stagingPath
} catch (error) {
fs.rmSync(stagingPath, { recursive: true, force: true })
throw error
}
}
function replaceOutput(rootDir: string, stagingPath: string): void {
const outputPath = resolveInsideRoot(rootDir, DATA_ROOT)
const backupPath = resolveInsideRoot(
rootDir,
`${DATA_ROOT}.${process.pid}.${randomUUID()}${BACKUP_SUFFIX}`,
)
assertNoSymlinks(rootDir, outputPath)
if (fs.existsSync(outputPath) && !fs.lstatSync(outputPath).isDirectory()) {
throw new Error(`Generated output path must be a directory: ${outputPath}`)
}
let movedPrevious = false
try {
if (fs.existsSync(outputPath)) {
fs.renameSync(outputPath, backupPath)
movedPrevious = true
}
fs.renameSync(stagingPath, outputPath)
} catch (error) {
if (movedPrevious && !fs.existsSync(outputPath) && fs.existsSync(backupPath)) {
fs.renameSync(backupPath, outputPath)
}
throw error
}
if (movedPrevious) fs.rmSync(backupPath, { recursive: true, force: true })
}
/** Полностью заменяет generated-каталог output plan. */
export function writeOutputPlan(
rootDir: string,
mode: SpriteMode,
contractVersion: number,
files: readonly GeneratedFile[],
generatedNotice: boolean,
createGitignore = true,
): void {
const normalizedFiles = [...SYSTEM_FILES, ...files].map((file) => ({
const normalizedFiles = files.map((file) => ({
...file,
path: normalizeManagedPath(file.path),
}))
const nextFiles = normalizedFiles.map((file) => file.path)
const paths = normalizedFiles.map((file) => file.path)
if (new Set(nextFiles).size !== nextFiles.length) {
if (paths.some((filePath) => !filePath.startsWith(DATA_PREFIX))) {
throw new Error(`Mode "${mode}" produced a file outside ${DATA_ROOT}.`)
}
if (new Set(paths).size !== paths.length) {
throw new Error(`Mode "${mode}" produced duplicate generated file paths.`)
}
const previous = readPreviousFiles(rootDir)
const obsoleteFiles: string[] = []
recoverInterruptedReplacement(rootDir)
validateGitignore(rootDir, createGitignore)
const stagingPath = stageOutput(rootDir, normalizedFiles)
for (const file of normalizedFiles) {
const filePath = resolveManagedPath(rootDir, file.path)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath) && !hasGeneratedMarker(filePath)) {
throw new Error(
`Refusing to overwrite a user file: ${filePath}\n`
+ 'Move the file or choose another sprite directory.',
)
try {
replaceOutput(rootDir, stagingPath)
updateGitignore(rootDir, createGitignore)
} finally {
if (fs.existsSync(stagingPath)) {
fs.rmSync(stagingPath, { recursive: true, force: true })
}
}
for (const relativePath of previous.files) {
if (nextFiles.includes(relativePath)) continue
const filePath = resolveManagedPath(rootDir, relativePath)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath)) {
if (!hasGeneratedMarker(filePath)) {
throw new Error(`Refusing to delete a user file: ${filePath}`)
}
obsoleteFiles.push(filePath)
}
}
for (const file of normalizedFiles) {
writeFileAtomic(rootDir, resolveManagedPath(rootDir, file.path), file.content)
}
for (const filePath of obsoleteFiles) fs.unlinkSync(filePath)
for (const filePath of obsoleteFiles) removeEmptyManagedParents(rootDir, filePath)
if (previous.obsoleteStatePath && fs.existsSync(previous.obsoleteStatePath)) {
fs.unlinkSync(previous.obsoleteStatePath)
removeEmptyStateDirectory(rootDir, previous.obsoleteStatePath)
}
const state: OutputState = {
schemaVersion: 1,
generator: GENERATOR,
owner: { mode, contractVersion },
files: nextFiles,
...(generatedNotice && {
warning: 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную: изменения будут перезаписаны.',
}),
}
const statePath = resolveInternalPath(rootDir, STATE_PATH)
writeFileAtomic(rootDir, statePath, `${JSON.stringify(state, null, 2)}\n`)
}

View File

@@ -26,8 +26,7 @@ export function prepareSprite(config: ResolvedSpriteConfig): PreparedSprite {
const folder = resolveSpriteSources({
name: config.name,
format: 'stack',
inputFolder: config.inputFolder,
inputFiles: config.inputFiles,
input: config.input,
})
const iconNames = folder.files
.map((filePath) => path.basename(filePath, '.svg'))

View File

@@ -45,9 +45,8 @@ export async function generateSprite(
writeOutputPlan(
resolvedSource.rootDir,
adapter.mode,
adapter.contractVersion,
plan.files,
config.generatedNotice,
plan.createGitignore,
)
const generatedDir = path.resolve(resolvedSource.rootDir, plan.paths.generatedDir)

View File

@@ -5,8 +5,6 @@ import { generateOutputFiles } from './output.js'
export const nextAppTurbopackAdapter: ModeAdapter<'next@app/turbopack'> = {
mode: 'next@app/turbopack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: true,

View File

@@ -151,7 +151,37 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
return [
header(config.generatedNotice),
'',
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifest = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
' componentName: string',
" mode: 'next@app/turbopack'",
" target: 'next@app/turbopack'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' spriteUrl: string',
' icons: readonly SpriteManifestIcon[]',
'}',
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
}
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {

View File

@@ -5,7 +5,6 @@ import { generateOutputFiles } from './output.js'
export const nextAppWebpackAdapter: ModeAdapter<'next@app/webpack'> = {
mode: 'next@app/webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')

View File

@@ -70,7 +70,37 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
const manifestTypes = (config: ResolvedSpriteConfig) => [
header(config.generatedNotice),
'',
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifest = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
' componentName: string',
" mode: 'next@app/webpack'",
" target: 'next@app/webpack'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' spriteUrl: string',
' icons: readonly SpriteManifestIcon[]',
'}',
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [

View File

@@ -5,7 +5,6 @@ import { generateOutputFiles } from './output.js'
export const nextPagesTurbopackAdapter: ModeAdapter<'next@pages/turbopack'> = {
mode: 'next@pages/turbopack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')

View File

@@ -70,7 +70,37 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
const manifestTypes = (config: ResolvedSpriteConfig) => [
header(config.generatedNotice),
'',
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifest = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
' componentName: string',
" mode: 'next@pages/turbopack'",
" target: 'next@pages/turbopack'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' spriteUrl: string',
' icons: readonly SpriteManifestIcon[]',
'}',
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [

View File

@@ -5,7 +5,6 @@ import { generateOutputFiles } from './output.js'
export const nextPagesWebpackAdapter: ModeAdapter<'next@pages/webpack'> = {
mode: 'next@pages/webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')

View File

@@ -70,7 +70,37 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
const manifestTypes = (config: ResolvedSpriteConfig) => [
header(config.generatedNotice),
'',
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifest = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
' componentName: string',
" mode: 'next@pages/webpack'",
" target: 'next@pages/webpack'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' spriteUrl: string',
' icons: readonly SpriteManifestIcon[]',
'}',
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [

View File

@@ -5,8 +5,6 @@ import { generateOutputFiles } from './output.js'
export const reactViteAdapter: ModeAdapter<'react@vite'> = {
mode: 'react@vite',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,

View File

@@ -241,7 +241,30 @@ function generateManifest(config: ResolvedSpriteConfig, artifact: CompiledSprite
function generateManifestDeclarations(config: ResolvedSpriteConfig): string {
return [
blockHeader(config.generatedNotice),
"import type { SpriteManifest } from '@gromlab/svg-sprites/react'",
'',
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifest = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
' componentName: string',
" mode: 'react@vite'",
" target: 'vite'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' spriteUrl: string',
' icons: readonly SpriteManifestIcon[]',
'}',
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',

View File

@@ -5,8 +5,6 @@ import { generateOutputFiles } from './output.js'
export const reactWebpackAdapter: ModeAdapter<'react@webpack'> = {
mode: 'react@webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,

View File

@@ -168,7 +168,37 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
return [
header(config.generatedNotice),
'',
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifest = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
' componentName: string',
" mode: 'react@webpack'",
" target: 'webpack'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' spriteUrl: string',
' icons: readonly SpriteManifestIcon[]',
'}',
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
}
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {

View File

@@ -5,8 +5,6 @@ import { generateOutputFiles } from './output.js'
export const standaloneViteAdapter: ModeAdapter<'standalone@vite'> = {
mode: 'standalone@vite',
contractVersion: 1,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,

View File

@@ -68,9 +68,10 @@ function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSprite
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
function index(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
const iconTagName = `${config.name}-icon`
return [
header(config.generatedNotice),
"import spriteUrl from './sprite.svg?no-inline'",
@@ -78,9 +79,92 @@ function index(config: ResolvedSpriteConfig): string {
'',
`export { ${prefix}IconIds, ${prefix}IconNames }`,
`export const ${prefix}SpriteUrl = spriteUrl`,
`export const ${prefix}IconTagName = ${JSON.stringify(iconTagName)}`,
`const ${prefix}IconViewBoxes = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.viewBox])), null, 2)}`,
`const ${prefix}IconElementMarker = Symbol.for('@gromlab/svg-sprites/icon-element')`,
`const ${prefix}IconElementSignature = ${prefix}IconTagName + '|' + ${prefix}SpriteUrl`,
`function has${pascalName}Icon(icon) {`,
` return Object.prototype.hasOwnProperty.call(${prefix}IconIds, icon)`,
'}',
`export function get${pascalName}IconHref(icon) {`,
` return ${prefix}SpriteUrl + '#' + ${prefix}IconIds[icon]`,
'}',
`export function define${pascalName}IconElement() {`,
` if (typeof globalThis.customElements === 'undefined' || typeof globalThis.HTMLElement === 'undefined') return`,
` const existing = globalThis.customElements.get(${prefix}IconTagName)`,
' if (existing) {',
` if (existing[${prefix}IconElementMarker] === ${prefix}IconElementSignature) return`,
` throw new Error('Cannot define <' + ${prefix}IconTagName + '>: this custom element name is already registered.')`,
' }',
` class ${pascalName}IconElement extends globalThis.HTMLElement {`,
" static observedAttributes = ['icon']",
' constructor() {',
' super()',
' const ownerDocument = this.ownerDocument',
" const shadowRoot = this.attachShadow({ mode: 'open' })",
" const style = ownerDocument.createElement('style')",
" style.textContent = ':host{display:inline-block;width:1em;height:1em;vertical-align:-0.125em}:host([hidden]){display:none}svg{display:block;width:100%;height:100%}svg[hidden]{display:none}'",
" this._svg = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg')",
" this._svg.setAttribute('part', 'svg')",
" this._svg.setAttribute('aria-hidden', 'true')",
" this._svg.setAttribute('focusable', 'false')",
" this._svg.setAttribute('hidden', '')",
" this._use = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use')",
" this._use.setAttribute('part', 'use')",
' this._svg.append(this._use)',
' shadowRoot.append(style, this._svg)',
' }',
' connectedCallback() {',
" if (Object.prototype.hasOwnProperty.call(this, 'icon')) {",
' const icon = this.icon',
' delete this.icon',
' this.icon = icon',
' }',
' this._renderIcon()',
' }',
' attributeChangedCallback() {',
' this._renderIcon()',
' }',
' get icon() {',
" const icon = this.getAttribute('icon')",
` return icon !== null && has${pascalName}Icon(icon) ? icon : null`,
' }',
' set icon(icon) {',
' if (icon === null || icon === undefined) {',
" this.removeAttribute('icon')",
' return',
' }',
` if (!has${pascalName}Icon(icon)) throw new TypeError('Unknown ${config.name} icon: ' + icon)`,
" this.setAttribute('icon', icon)",
' }',
' _renderIcon() {',
" const icon = this.getAttribute('icon')",
' if (icon === null) {',
' this._clearIcon()',
' return',
' }',
` if (!has${pascalName}Icon(icon)) {`,
' this._clearIcon()',
` if (this._invalidIcon !== icon) console.error('<' + ${prefix}IconTagName + '>: unknown icon "' + icon + '".')`,
' this._invalidIcon = icon',
' return',
' }',
' this._invalidIcon = undefined',
` const viewBox = ${prefix}IconViewBoxes[icon]`,
" if (viewBox === null) this._svg.removeAttribute('viewBox')",
" else this._svg.setAttribute('viewBox', viewBox)",
` this._use.setAttribute('href', get${pascalName}IconHref(icon))`,
" this._svg.removeAttribute('hidden')",
' }',
' _clearIcon() {',
" this._use.removeAttribute('href')",
" this._svg.removeAttribute('viewBox')",
" this._svg.setAttribute('hidden', '')",
' }',
' }',
` Object.defineProperty(${pascalName}IconElement, ${prefix}IconElementMarker, { value: ${prefix}IconElementSignature })`,
` globalThis.customElements.define(${prefix}IconTagName, ${pascalName}IconElement)`,
'}',
'',
].join('\n')
}
@@ -88,6 +172,7 @@ function index(config: ResolvedSpriteConfig): string {
function indexDeclarations(config: ResolvedSpriteConfig): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
const iconTagName = `${config.name}-icon`
return [
header(config.generatedNotice),
`import type { ${pascalName}IconName } from './icon-data.js'`,
@@ -95,7 +180,17 @@ function indexDeclarations(config: ResolvedSpriteConfig): string {
`export { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
`export type { ${pascalName}IconName } from './icon-data.js'`,
`export declare const ${prefix}SpriteUrl: string`,
`export declare const ${prefix}IconTagName: ${JSON.stringify(iconTagName)}`,
`export declare function get${pascalName}IconHref(icon: ${pascalName}IconName): string`,
`export interface ${pascalName}IconElement extends HTMLElement {`,
` icon: ${pascalName}IconName | null`,
'}',
`export declare function define${pascalName}IconElement(): void`,
'declare global {',
' interface HTMLElementTagNameMap {',
` ${JSON.stringify(iconTagName)}: ${pascalName}IconElement`,
' }',
'}',
'',
].join('\n')
}
@@ -133,11 +228,33 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
function manifestTypes(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
"import type { StandaloneSpriteManifest, StandaloneSpriteManifestData } from '@gromlab/svg-sprites'",
'',
"export declare const spriteManifestData: StandaloneSpriteManifestData<'standalone@vite'>",
"export declare function createSpriteManifest(spriteUrl: string): StandaloneSpriteManifest<'standalone@vite'>",
"export declare const spriteManifest: StandaloneSpriteManifest<'standalone@vite'>",
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifestData = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
" mode: 'standalone@vite'",
" target: 'vite'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' icons: readonly SpriteManifestIcon[]',
'}',
'export type SpriteManifest = SpriteManifestData & { spriteUrl: string }',
'',
'export declare const spriteManifestData: SpriteManifestData',
'export declare function createSpriteManifest(spriteUrl: string): SpriteManifest',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
@@ -148,7 +265,7 @@ export function generateOutputFiles(
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },

View File

@@ -5,8 +5,6 @@ import { generateOutputFiles } from './output.js'
export const standaloneWebpackAdapter: ModeAdapter<'standalone@webpack'> = {
mode: 'standalone@webpack',
contractVersion: 1,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,

View File

@@ -68,18 +68,102 @@ function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSprite
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
function index(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
const iconTagName = `${config.name}-icon`
return [
header(config.generatedNotice),
`import { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
'',
`export { ${prefix}IconIds, ${prefix}IconNames }`,
`export const ${prefix}SpriteUrl = new URL('./sprite.svg', import.meta.url).href`,
`export const ${prefix}IconTagName = ${JSON.stringify(iconTagName)}`,
`const ${prefix}IconViewBoxes = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.viewBox])), null, 2)}`,
`const ${prefix}IconElementMarker = Symbol.for('@gromlab/svg-sprites/icon-element')`,
`const ${prefix}IconElementSignature = ${prefix}IconTagName + '|' + ${prefix}SpriteUrl`,
`function has${pascalName}Icon(icon) {`,
` return Object.prototype.hasOwnProperty.call(${prefix}IconIds, icon)`,
'}',
`export function get${pascalName}IconHref(icon) {`,
` return ${prefix}SpriteUrl + '#' + ${prefix}IconIds[icon]`,
'}',
`export function define${pascalName}IconElement() {`,
` if (typeof globalThis.customElements === 'undefined' || typeof globalThis.HTMLElement === 'undefined') return`,
` const existing = globalThis.customElements.get(${prefix}IconTagName)`,
' if (existing) {',
` if (existing[${prefix}IconElementMarker] === ${prefix}IconElementSignature) return`,
` throw new Error('Cannot define <' + ${prefix}IconTagName + '>: this custom element name is already registered.')`,
' }',
` class ${pascalName}IconElement extends globalThis.HTMLElement {`,
" static observedAttributes = ['icon']",
' constructor() {',
' super()',
' const ownerDocument = this.ownerDocument',
" const shadowRoot = this.attachShadow({ mode: 'open' })",
" const style = ownerDocument.createElement('style')",
" style.textContent = ':host{display:inline-block;width:1em;height:1em;vertical-align:-0.125em}:host([hidden]){display:none}svg{display:block;width:100%;height:100%}svg[hidden]{display:none}'",
" this._svg = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg')",
" this._svg.setAttribute('part', 'svg')",
" this._svg.setAttribute('aria-hidden', 'true')",
" this._svg.setAttribute('focusable', 'false')",
" this._svg.setAttribute('hidden', '')",
" this._use = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'use')",
" this._use.setAttribute('part', 'use')",
' this._svg.append(this._use)',
' shadowRoot.append(style, this._svg)',
' }',
' connectedCallback() {',
" if (Object.prototype.hasOwnProperty.call(this, 'icon')) {",
' const icon = this.icon',
' delete this.icon',
' this.icon = icon',
' }',
' this._renderIcon()',
' }',
' attributeChangedCallback() {',
' this._renderIcon()',
' }',
' get icon() {',
" const icon = this.getAttribute('icon')",
` return icon !== null && has${pascalName}Icon(icon) ? icon : null`,
' }',
' set icon(icon) {',
' if (icon === null || icon === undefined) {',
" this.removeAttribute('icon')",
' return',
' }',
` if (!has${pascalName}Icon(icon)) throw new TypeError('Unknown ${config.name} icon: ' + icon)`,
" this.setAttribute('icon', icon)",
' }',
' _renderIcon() {',
" const icon = this.getAttribute('icon')",
' if (icon === null) {',
' this._clearIcon()',
' return',
' }',
` if (!has${pascalName}Icon(icon)) {`,
' this._clearIcon()',
` if (this._invalidIcon !== icon) console.error('<' + ${prefix}IconTagName + '>: unknown icon "' + icon + '".')`,
' this._invalidIcon = icon',
' return',
' }',
' this._invalidIcon = undefined',
` const viewBox = ${prefix}IconViewBoxes[icon]`,
" if (viewBox === null) this._svg.removeAttribute('viewBox')",
" else this._svg.setAttribute('viewBox', viewBox)",
` this._use.setAttribute('href', get${pascalName}IconHref(icon))`,
" this._svg.removeAttribute('hidden')",
' }',
' _clearIcon() {',
" this._use.removeAttribute('href')",
" this._svg.removeAttribute('viewBox')",
" this._svg.setAttribute('hidden', '')",
' }',
' }',
` Object.defineProperty(${pascalName}IconElement, ${prefix}IconElementMarker, { value: ${prefix}IconElementSignature })`,
` globalThis.customElements.define(${prefix}IconTagName, ${pascalName}IconElement)`,
'}',
'',
].join('\n')
}
@@ -87,6 +171,7 @@ function index(config: ResolvedSpriteConfig): string {
function indexDeclarations(config: ResolvedSpriteConfig): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
const iconTagName = `${config.name}-icon`
return [
header(config.generatedNotice),
`import type { ${pascalName}IconName } from './icon-data.js'`,
@@ -94,7 +179,17 @@ function indexDeclarations(config: ResolvedSpriteConfig): string {
`export { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
`export type { ${pascalName}IconName } from './icon-data.js'`,
`export declare const ${prefix}SpriteUrl: string`,
`export declare const ${prefix}IconTagName: ${JSON.stringify(iconTagName)}`,
`export declare function get${pascalName}IconHref(icon: ${pascalName}IconName): string`,
`export interface ${pascalName}IconElement extends HTMLElement {`,
` icon: ${pascalName}IconName | null`,
'}',
`export declare function define${pascalName}IconElement(): void`,
'declare global {',
' interface HTMLElementTagNameMap {',
` ${JSON.stringify(iconTagName)}: ${pascalName}IconElement`,
' }',
'}',
'',
].join('\n')
}
@@ -132,11 +227,33 @@ function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact
function manifestTypes(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
"import type { StandaloneSpriteManifest, StandaloneSpriteManifestData } from '@gromlab/svg-sprites'",
'',
"export declare const spriteManifestData: StandaloneSpriteManifestData<'standalone@webpack'>",
"export declare function createSpriteManifest(spriteUrl: string): StandaloneSpriteManifest<'standalone@webpack'>",
"export declare const spriteManifest: StandaloneSpriteManifest<'standalone@webpack'>",
'export type SpriteManifestColor = {',
' variable: `--icon-color-${number}`',
' fallback: string',
'}',
'export type SpriteManifestIcon = {',
' name: string',
' id: string',
' viewBox: string | null',
' colors: readonly SpriteManifestColor[]',
'}',
'export type SpriteManifestData = {',
' schemaVersion: 1',
" generator: '@gromlab/svg-sprites'",
' name: string',
' description?: string',
" mode: 'standalone@webpack'",
" target: 'webpack'",
" format: 'stack' | 'symbol'",
' iconCount: number',
' icons: readonly SpriteManifestIcon[]',
'}',
'export type SpriteManifest = SpriteManifestData & { spriteUrl: string }',
'',
'export declare const spriteManifestData: SpriteManifestData',
'export declare function createSpriteManifest(spriteUrl: string): SpriteManifest',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
@@ -147,7 +264,7 @@ export function generateOutputFiles(
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },

View File

@@ -5,8 +5,6 @@ import { generateOutputFiles } from './output.js'
export const standaloneAdapter: ModeAdapter<'standalone'> = {
mode: 'standalone',
contractVersion: 2,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
@@ -15,6 +13,7 @@ export const standaloneAdapter: ModeAdapter<'standalone'> = {
return {
files: generateOutputFiles(context.config, artifact),
createGitignore: false,
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',

View File

@@ -1,54 +1,91 @@
import fs from 'node:fs'
import path from 'node:path'
import {
convertPathToPattern,
globSync,
isDynamicPattern,
} from 'tinyglobby'
import type { SpriteFolder } from './types.js'
/**
* Сканирует папку и возвращает отсортированные абсолютные пути к SVG-файлам.
*/
function scanDirectory(dirPath: string): string[] {
if (!fs.existsSync(dirPath)) {
throw new Error(`Input directory does not exist: ${dirPath}`)
}
return fs
.readdirSync(dirPath)
.filter((file) => file.endsWith('.svg'))
.sort()
.map((file) => path.join(dirPath, file))
type PositiveInput = {
source: string
pattern: string
directory: string | null
}
/**
* Резолвит массив путей к SVG-файлам в абсолютные пути.
* Проверяет существование каждого файла.
*/
function resolveFiles(files: string[]): string[] {
return files.map((filePath) => {
const resolved = path.resolve(filePath)
function resolvePositiveInput(source: string): PositiveInput {
if (!fs.existsSync(source)) {
if (isDynamicPattern(source)) return { source, pattern: source, directory: null }
throw new Error(`Input path does not exist: ${source}`)
}
if (!fs.existsSync(resolved)) {
throw new Error(`SVG file does not exist: ${resolved}`)
const stats = fs.statSync(source)
if (stats.isDirectory()) {
return {
source,
pattern: `${convertPathToPattern(source)}/*.svg`,
directory: source,
}
}
if (!stats.isFile()) {
throw new Error(`Input path must be a file, directory, or glob pattern: ${source}`)
}
if (!source.endsWith('.svg')) {
throw new Error(`File is not an SVG: ${source}`)
}
if (!resolved.endsWith('.svg')) {
throw new Error(`File is not an SVG: ${resolved}`)
}
return { source, pattern: convertPathToPattern(source), directory: null }
}
return resolved
})
function resolveExclusion(source: string): string {
const target = source.slice(1)
if (!fs.existsSync(target)) return source
const stats = fs.statSync(target)
if (stats.isDirectory()) return `!${convertPathToPattern(target)}/**`
if (stats.isFile()) return `!${convertPathToPattern(target)}`
return source
}
type ResolveSpriteSourcesOptions = {
name: string
format: SpriteFolder['format']
inputFolder: string | null
inputFiles: string[]
input: string[]
}
/** Объединяет SVG из папки и явного списка в один источник спрайта. */
const GLOB_OPTIONS = {
absolute: true,
expandDirectories: false,
onlyFiles: true,
} as const
/** Разрешает пути, папки и glob-шаблоны в один набор исходных 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)])]
const { name, format, input } = options
const positives = input
.filter((entry) => !entry.startsWith('!'))
.map(resolvePositiveInput)
const exclusions = input
.filter((entry) => entry.startsWith('!'))
.map(resolveExclusion)
if (positives.length === 0) {
throw new Error('Sprite input must include at least one path or positive glob pattern.')
}
for (const { source, pattern } of positives) {
const matchedSvg = globSync(pattern, GLOB_OPTIONS).some((filePath) => filePath.endsWith('.svg'))
if (!matchedSvg) throw new Error(`Input matched no SVG files: ${source}`)
}
const files = [...new Set(
globSync([
...positives.map(({ pattern }) => pattern),
...exclusions,
], GLOB_OPTIONS)
.filter((filePath) => filePath.endsWith('.svg'))
.map((filePath) => path.resolve(filePath)),
)].sort()
if (files.length === 0) {
throw new Error(`Sprite "${name}" has no SVG files in configured inputs.`)
@@ -57,7 +94,7 @@ export function resolveSpriteSources(options: ResolveSpriteSourcesOptions): Spri
return {
name,
format,
path: inputFolder,
path: input.length === 1 ? positives[0].directory : null,
files,
}
}

View File

@@ -32,10 +32,8 @@ export type SpriteConfig = {
name?: string
/** Описание спрайта для generated types и manifest. */
description?: string
/** Папка с исходными SVG относительно корня модуля. По умолчанию: ./icons. */
inputFolder?: string
/** Дополнительные SVG-файлы относительно корня модуля. По умолчанию: []. */
inputFiles?: string[]
/** Путь или glob-шаблоны исходных SVG относительно корня модуля. По умолчанию: ./icons. */
input?: string | string[]
/** Настройки трансформации SVG. По умолчанию все включены. */
transform?: TransformOptions
/** Добавлять развёрнутое предупреждение в generated-файлы. По умолчанию: true. */
@@ -47,8 +45,7 @@ export type ResolvedSpriteConfig = {
mode: SpriteMode
name: string
description?: string
inputFolder: string | null
inputFiles: string[]
input: string[]
transform: Required<TransformOptions>
generatedNotice: boolean
}
@@ -59,7 +56,7 @@ export type SpriteFolder = {
name: string
/** Формат спрайта. */
format: SpriteFormat
/** Абсолютный путь к папке (для input-папки) или null (для input-массива). */
/** Абсолютный путь для единственного input-каталога, иначе null. */
path: string | null
/** Абсолютные пути к SVG-файлам. */
files: string[]