mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
sync
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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',
|
||||
|
||||
105
src/scanner.ts
105
src/scanner.ts
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
11
src/types.ts
11
src/types.ts
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user