mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 20:50:19 +03:00
feat: перейти на изолированный контракт генерации
- добавлены независимые adapters для шести exact modes - runtime переведён на JavaScript с отдельными декларациями - generated output перенесён в .svg-sprite - удалены legacy pipeline и встроенный preview
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createJiti } from 'jiti'
|
||||
import type { SvgSpritesConfig } from '../../types.js'
|
||||
|
||||
const CONFIG_NAME = 'svg-sprites.config'
|
||||
|
||||
/** Загружает legacy-конфиг из указанной директории. */
|
||||
export async function loadLegacyConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
|
||||
const configPath = path.join(cwd, `${CONFIG_NAME}.ts`)
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(
|
||||
`Config file not found: ${configPath}\n` +
|
||||
`Create a ${CONFIG_NAME}.ts file in the project root.`,
|
||||
)
|
||||
}
|
||||
|
||||
const jiti = createJiti(cwd)
|
||||
const mod = await jiti.import(configPath) as { default?: SvgSpritesConfig }
|
||||
const config = mod.default
|
||||
|
||||
if (!config) {
|
||||
throw new Error(
|
||||
`Config file must have a default export: ${configPath}\n` +
|
||||
'Use: export default defineLegacyConfig({ ... })',
|
||||
)
|
||||
}
|
||||
|
||||
validateLegacyConfig(config)
|
||||
return {
|
||||
...config,
|
||||
output: path.resolve(cwd, config.output),
|
||||
sprites: config.sprites.map((sprite) => ({
|
||||
...sprite,
|
||||
input: Array.isArray(sprite.input)
|
||||
? sprite.input.map((filePath) => path.resolve(cwd, filePath))
|
||||
: path.resolve(cwd, sprite.input),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Валидирует legacy-конфиг. */
|
||||
export function validateLegacyConfig(config: SvgSpritesConfig): void {
|
||||
if (!config.output) {
|
||||
throw new Error('Config: "output" is required.')
|
||||
}
|
||||
|
||||
if (!config.sprites || config.sprites.length === 0) {
|
||||
throw new Error('Config: "sprites" must be a non-empty array.')
|
||||
}
|
||||
|
||||
for (const sprite of config.sprites) {
|
||||
if ('mode' in sprite) {
|
||||
throw new Error(
|
||||
`Config: sprite "${sprite.name}" uses deprecated "mode". Use "format" instead.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!sprite.name) {
|
||||
throw new Error('Config: each sprite must have a "name".')
|
||||
}
|
||||
|
||||
if (!sprite.input) {
|
||||
throw new Error(`Config: sprite "${sprite.name}" must have an "input".`)
|
||||
}
|
||||
|
||||
if (sprite.format && sprite.format !== 'stack' && sprite.format !== 'symbol') {
|
||||
throw new Error(
|
||||
`Config: sprite "${sprite.name}" has invalid format "${sprite.format}". Supported: stack, symbol.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { compileSprite } from '../../compiler.js'
|
||||
import { log } from '../../logger.js'
|
||||
import { generatePreview } from '../../preview.js'
|
||||
import { resolveSprites } from '../../scanner.js'
|
||||
import type { SpriteResult, SvgSpritesConfig } from '../../types.js'
|
||||
|
||||
/** Генерирует SVG-спрайты через legacy pipeline. */
|
||||
export async function generateLegacy(config: SvgSpritesConfig): Promise<SpriteResult[]> {
|
||||
const {
|
||||
output,
|
||||
preview = true,
|
||||
transform = {},
|
||||
sprites,
|
||||
} = config
|
||||
const outputDir = path.resolve(output)
|
||||
|
||||
log.title('Resolving legacy sprites...')
|
||||
|
||||
const folders = resolveSprites(sprites)
|
||||
|
||||
if (folders.length === 0) {
|
||||
log.warn('No sprites to generate.')
|
||||
return []
|
||||
}
|
||||
|
||||
log.info(`Found ${folders.length} sprite(s)\n`)
|
||||
|
||||
const results: SpriteResult[] = []
|
||||
|
||||
for (const folder of folders) {
|
||||
const spritePath = await compileSprite(folder, outputDir, transform)
|
||||
log.success(` [${folder.format}] ${folder.name} → ${path.relative(process.cwd(), spritePath)} (${folder.files.length} icons)`)
|
||||
|
||||
results.push({
|
||||
name: folder.name,
|
||||
format: folder.format,
|
||||
spritePath,
|
||||
iconCount: folder.files.length,
|
||||
})
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
const previewPath = generatePreview(results, outputDir)
|
||||
log.success(`\n [preview] → ${path.relative(process.cwd(), previewPath)}`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
log.success(`Done! Generated ${results.length} sprite(s).`)
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { loadLegacyConfig, validateLegacyConfig } from './config.js'
|
||||
export { generateLegacy } from './generate.js'
|
||||
26
src/modes/next-app-turbopack/adapter.ts
Normal file
26
src/modes/next-app-turbopack/adapter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
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,
|
||||
})
|
||||
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
|
||||
return {
|
||||
files: generateOutputFiles(context.config, artifact),
|
||||
paths: {
|
||||
generatedDir: '.svg-sprite',
|
||||
sprite: '.svg-sprite/sprite.svg',
|
||||
manifest: '.svg-sprite/svg-sprite.manifest.js',
|
||||
entry: '.svg-sprite/index.js',
|
||||
},
|
||||
result: { target: 'next@app/turbopack', router: 'app', bundler: 'turbopack' },
|
||||
}
|
||||
},
|
||||
}
|
||||
170
src/modes/next-app-turbopack/output.ts
Normal file
170
src/modes/next-app-turbopack/output.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import path from 'node:path'
|
||||
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import { GENERATED_MARKER } from '../../core/generated-markers.js'
|
||||
import type { GeneratedFile } from '../../core/mode-adapter.js'
|
||||
import type { ResolvedSpriteConfig } from '../../types.js'
|
||||
|
||||
const MODE = 'next@app/turbopack'
|
||||
const OUTPUT_DIR = '.svg-sprite'
|
||||
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
|
||||
|
||||
function pascal(value: string): string {
|
||||
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
}
|
||||
|
||||
function camel(value: string): string {
|
||||
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function header(enabled: boolean): string {
|
||||
return enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
|
||||
}
|
||||
|
||||
function svg(bytes: Uint8Array, enabled: boolean): string {
|
||||
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
|
||||
const content = new TextDecoder().decode(bytes)
|
||||
return content.startsWith('<?xml')
|
||||
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
|
||||
: `<!-- ${marker} -->\n${content}`
|
||||
}
|
||||
|
||||
function comment(config: ResolvedSpriteConfig): string[] {
|
||||
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
|
||||
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
|
||||
}
|
||||
|
||||
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const prefix = camel(config.name)
|
||||
const names = artifact.icons.map((icon) => icon.name)
|
||||
const ids = Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id]))
|
||||
return [header(config.generatedNotice), '', ...comment(config), `export const ${prefix}IconNames = ${JSON.stringify(names, null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(ids, null, 2)}`, ''].join('\n')
|
||||
}
|
||||
|
||||
function component(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
"import { jsx } from 'react/jsx-runtime'",
|
||||
"import styles from './react-component.module.css'",
|
||||
`import { ${ids} } from '../icon-data.js'`,
|
||||
'',
|
||||
"const spriteUrl = new URL('../sprite.svg', import.meta.url).href",
|
||||
'',
|
||||
`export const ${componentName} = (props) => {`,
|
||||
' const { icon, wrapped, className, ...rest } = props',
|
||||
` const href = spriteUrl + '#' + ${ids}[icon]`,
|
||||
' if (wrapped) {',
|
||||
" return jsx('span', {",
|
||||
' ...rest,',
|
||||
" className: [styles.wrap, className].filter(Boolean).join(' '),",
|
||||
" children: jsx('svg', { children: jsx('use', { href }) }),",
|
||||
' })',
|
||||
' }',
|
||||
" return jsx('svg', {",
|
||||
' ...rest,',
|
||||
" className: [styles.root, className].filter(Boolean).join(' '),",
|
||||
" children: jsx('use', { href }),",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
'',
|
||||
...comment(config),
|
||||
`export declare const ${names}: readonly [`,
|
||||
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
|
||||
']',
|
||||
`export type ${typeName} = typeof ${names}[number]`,
|
||||
`export declare const ${ids}: {`,
|
||||
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function componentDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const styleName = `${componentName}Style`
|
||||
const propsName = `${componentName}Props`
|
||||
return [header(config.generatedNotice),
|
||||
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
|
||||
`import type { ${typeName} } from '../icon-data.js'`,
|
||||
'',
|
||||
`export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
|
||||
`type ${componentName}BaseProps = { icon: ${typeName} }`,
|
||||
`type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`,
|
||||
`type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`,
|
||||
`export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`,
|
||||
`export declare const ${componentName}: (props: ${propsName}) => ReactElement`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function indexDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
`export { ${componentName} } from './react/react-component.js'`,
|
||||
`export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`,
|
||||
`export { ${names} } from './icon-data.js'`,
|
||||
`export type { ${componentName}Name } from './icon-data.js'`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function index(config: ResolvedSpriteConfig): string {
|
||||
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function styles(config: ResolvedSpriteConfig): string {
|
||||
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
|
||||
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const data = {
|
||||
schemaVersion: 1,
|
||||
generator: '@gromlab/svg-sprites',
|
||||
name: config.name,
|
||||
...(config.description === undefined ? {} : { description: config.description }),
|
||||
componentName: `${pascal(config.name)}Icon`,
|
||||
mode: MODE,
|
||||
target: MODE,
|
||||
format: artifact.format,
|
||||
iconCount: artifact.icons.length,
|
||||
spriteUrl: '__SPRITE_URL__',
|
||||
icons: artifact.icons,
|
||||
}
|
||||
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
|
||||
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
|
||||
return [
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
|
||||
{ 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) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
|
||||
]
|
||||
}
|
||||
18
src/modes/next-app-webpack/adapter.ts
Normal file
18
src/modes/next-app-webpack/adapter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
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')
|
||||
return {
|
||||
files: generateOutputFiles(context.config, artifact),
|
||||
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
|
||||
result: { target: 'next@app/webpack', router: 'app', bundler: 'webpack' },
|
||||
}
|
||||
},
|
||||
}
|
||||
88
src/modes/next-app-webpack/output.ts
Normal file
88
src/modes/next-app-webpack/output.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import path from 'node:path'
|
||||
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import { GENERATED_MARKER } from '../../core/generated-markers.js'
|
||||
import type { GeneratedFile } from '../../core/mode-adapter.js'
|
||||
import type { ResolvedSpriteConfig } from '../../types.js'
|
||||
|
||||
const MODE = 'next@app/webpack'
|
||||
const OUTPUT_DIR = '.svg-sprite'
|
||||
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
|
||||
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
|
||||
|
||||
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
|
||||
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
|
||||
const content = new TextDecoder().decode(bytes)
|
||||
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
|
||||
}
|
||||
|
||||
function docs(config: ResolvedSpriteConfig): string[] {
|
||||
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
|
||||
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
|
||||
}
|
||||
|
||||
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const prefix = camel(config.name)
|
||||
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
|
||||
}
|
||||
|
||||
function component(config: ResolvedSpriteConfig): string {
|
||||
const name = `${pascal(config.name)}Icon`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function componentDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const styleName = `${componentName}Style`
|
||||
const propsName = `${componentName}Props`
|
||||
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
|
||||
}
|
||||
|
||||
function indexDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function index(config: ResolvedSpriteConfig): string {
|
||||
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function styles(config: ResolvedSpriteConfig): string {
|
||||
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
|
||||
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
|
||||
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
|
||||
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')
|
||||
|
||||
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
|
||||
return [
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
|
||||
]
|
||||
}
|
||||
18
src/modes/next-pages-turbopack/adapter.ts
Normal file
18
src/modes/next-pages-turbopack/adapter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
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')
|
||||
return {
|
||||
files: generateOutputFiles(context.config, artifact),
|
||||
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
|
||||
result: { target: 'next@pages/turbopack', router: 'pages', bundler: 'turbopack' },
|
||||
}
|
||||
},
|
||||
}
|
||||
88
src/modes/next-pages-turbopack/output.ts
Normal file
88
src/modes/next-pages-turbopack/output.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import path from 'node:path'
|
||||
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import { GENERATED_MARKER } from '../../core/generated-markers.js'
|
||||
import type { GeneratedFile } from '../../core/mode-adapter.js'
|
||||
import type { ResolvedSpriteConfig } from '../../types.js'
|
||||
|
||||
const MODE = 'next@pages/turbopack'
|
||||
const OUTPUT_DIR = '.svg-sprite'
|
||||
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
|
||||
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
|
||||
|
||||
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
|
||||
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
|
||||
const content = new TextDecoder().decode(bytes)
|
||||
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
|
||||
}
|
||||
|
||||
function docs(config: ResolvedSpriteConfig): string[] {
|
||||
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
|
||||
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
|
||||
}
|
||||
|
||||
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const prefix = camel(config.name)
|
||||
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
|
||||
}
|
||||
|
||||
function component(config: ResolvedSpriteConfig): string {
|
||||
const name = `${pascal(config.name)}Icon`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function componentDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const styleName = `${componentName}Style`
|
||||
const propsName = `${componentName}Props`
|
||||
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
|
||||
}
|
||||
|
||||
function indexDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function index(config: ResolvedSpriteConfig): string {
|
||||
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function styles(config: ResolvedSpriteConfig): string {
|
||||
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
|
||||
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
|
||||
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
|
||||
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')
|
||||
|
||||
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
|
||||
return [
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
|
||||
]
|
||||
}
|
||||
18
src/modes/next-pages-webpack/adapter.ts
Normal file
18
src/modes/next-pages-webpack/adapter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
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')
|
||||
return {
|
||||
files: generateOutputFiles(context.config, artifact),
|
||||
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
|
||||
result: { target: 'next@pages/webpack', router: 'pages', bundler: 'webpack' },
|
||||
}
|
||||
},
|
||||
}
|
||||
88
src/modes/next-pages-webpack/output.ts
Normal file
88
src/modes/next-pages-webpack/output.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import path from 'node:path'
|
||||
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import { GENERATED_MARKER } from '../../core/generated-markers.js'
|
||||
import type { GeneratedFile } from '../../core/mode-adapter.js'
|
||||
import type { ResolvedSpriteConfig } from '../../types.js'
|
||||
|
||||
const MODE = 'next@pages/webpack'
|
||||
const OUTPUT_DIR = '.svg-sprite'
|
||||
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
|
||||
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
|
||||
|
||||
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
|
||||
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
|
||||
const content = new TextDecoder().decode(bytes)
|
||||
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
|
||||
}
|
||||
|
||||
function docs(config: ResolvedSpriteConfig): string[] {
|
||||
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
|
||||
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
|
||||
}
|
||||
|
||||
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const prefix = camel(config.name)
|
||||
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
|
||||
}
|
||||
|
||||
function component(config: ResolvedSpriteConfig): string {
|
||||
const name = `${pascal(config.name)}Icon`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function componentDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const styleName = `${componentName}Style`
|
||||
const propsName = `${componentName}Props`
|
||||
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
|
||||
}
|
||||
|
||||
function indexDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function index(config: ResolvedSpriteConfig): string {
|
||||
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
|
||||
}
|
||||
|
||||
function styles(config: ResolvedSpriteConfig): string {
|
||||
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
|
||||
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
|
||||
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
|
||||
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')
|
||||
|
||||
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
|
||||
return [
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { NextAssetTarget } from '../../targets/types.js'
|
||||
import { generateSpriteModule } from '../react/module-generator.js'
|
||||
import type {
|
||||
NextSpriteGenerationOptions,
|
||||
NextSpriteGenerationResult,
|
||||
} from './types.js'
|
||||
|
||||
/** Генерирует Next.js sprite-модуль для явно выбранных роутера и сборщика. */
|
||||
export async function generateNextSprite(
|
||||
root: string,
|
||||
options: NextSpriteGenerationOptions,
|
||||
): Promise<NextSpriteGenerationResult> {
|
||||
if (!options || (options.router !== 'app' && options.router !== 'pages')) {
|
||||
throw new Error(`Unsupported Next.js router: ${String(options?.router)}`)
|
||||
}
|
||||
if (options.bundler !== 'turbopack' && options.bundler !== 'webpack') {
|
||||
throw new Error(`Unsupported Next.js bundler: ${String(options.bundler)}`)
|
||||
}
|
||||
|
||||
const { router, bundler } = options
|
||||
const target: NextAssetTarget = `next@${router}/${bundler}`
|
||||
const result = await generateSpriteModule(root, target, {
|
||||
mode: target,
|
||||
rootViewBox: true,
|
||||
})
|
||||
|
||||
return {
|
||||
...result,
|
||||
router,
|
||||
bundler,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { generateNextSprite } from './generate.js'
|
||||
export type {
|
||||
NextSpriteConfig,
|
||||
NextSpriteGenerationOptions,
|
||||
NextSpriteGenerationResult,
|
||||
} from './types.js'
|
||||
@@ -1,22 +0,0 @@
|
||||
import type {
|
||||
NextAssetTarget,
|
||||
NextBundler,
|
||||
NextRouter,
|
||||
} from '../../targets/types.js'
|
||||
import type {
|
||||
ReactSpriteConfig,
|
||||
SpriteModuleGenerationResult,
|
||||
} from '../react/types.js'
|
||||
|
||||
/** Конфигурация Next.js sprite-модуля. Совпадает с React-конфигурацией. */
|
||||
export type NextSpriteConfig = ReactSpriteConfig
|
||||
|
||||
export type NextSpriteGenerationOptions = {
|
||||
router: NextRouter
|
||||
bundler: NextBundler
|
||||
}
|
||||
|
||||
export type NextSpriteGenerationResult = SpriteModuleGenerationResult<NextAssetTarget> & {
|
||||
router: NextRouter
|
||||
bundler: NextBundler
|
||||
}
|
||||
29
src/modes/react-vite/adapter.ts
Normal file
29
src/modes/react-vite/adapter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
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,
|
||||
context.config.transform,
|
||||
{ rootViewBox: false },
|
||||
)
|
||||
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
|
||||
|
||||
return {
|
||||
files: generateOutputFiles(context.config, artifact),
|
||||
paths: {
|
||||
generatedDir: '.svg-sprite',
|
||||
sprite: '.svg-sprite/sprite.svg',
|
||||
manifest: '.svg-sprite/svg-sprite.manifest.js',
|
||||
entry: '.svg-sprite/index.js',
|
||||
},
|
||||
result: { target: 'vite' },
|
||||
}
|
||||
},
|
||||
}
|
||||
268
src/modes/react-vite/output.ts
Normal file
268
src/modes/react-vite/output.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import path from 'node:path'
|
||||
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import { GENERATED_MARKER } from '../../core/generated-markers.js'
|
||||
import type { GeneratedFile } from '../../core/mode-adapter.js'
|
||||
import type { ResolvedSpriteConfig } from '../../types.js'
|
||||
|
||||
const MODE = 'react@vite'
|
||||
const TARGET = 'vite'
|
||||
const OUTPUT_DIR = '.svg-sprite'
|
||||
const GENERATED_NOTICE = [
|
||||
'----------------------------------------------------------------------',
|
||||
'## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##',
|
||||
'## ##',
|
||||
'## Не редактируйте вручную: изменения будут перезаписаны. ##',
|
||||
'## Для изменений перегенерируйте SVG-спрайт. ##',
|
||||
'## ##',
|
||||
'## Генератор: @gromlab/svg-sprites ##',
|
||||
'## Репозиторий: https://github.com/gromlab-ru/svg-sprites ##',
|
||||
'----------------------------------------------------------------------',
|
||||
]
|
||||
|
||||
function toPascalCase(value: string): string {
|
||||
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
}
|
||||
|
||||
function toCamelCase(value: string): string {
|
||||
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function blockHeader(enabled: boolean): string {
|
||||
if (!enabled) return `/* ${GENERATED_MARKER}. Do not edit. */`
|
||||
return ['/*', ...GENERATED_NOTICE.map((line) => ` * ${line}`), ' */'].join('\n')
|
||||
}
|
||||
|
||||
function svgHeader(enabled: boolean): string {
|
||||
if (!enabled) return `<!-- ${GENERATED_MARKER}. Do not edit. -->`
|
||||
return ['<!--', ...GENERATED_NOTICE.slice(1, -1).map((line) => ` ${line}`), '-->'].join('\n')
|
||||
}
|
||||
|
||||
function formatSvg(content: string): string {
|
||||
let depth = 0
|
||||
const lines = content.replace(/></g, '>\n<').split('\n')
|
||||
const formatted = lines.map((line) => {
|
||||
const trimmed = line.trim()
|
||||
const tags = trimmed.match(/<[^>]+>/g) ?? []
|
||||
const startsWithClosingTag = trimmed.startsWith('</')
|
||||
if (startsWithClosingTag) depth = Math.max(0, depth - 1)
|
||||
const formattedLine = `${' '.repeat(depth)}${trimmed}`
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.startsWith('</')) {
|
||||
if (!startsWithClosingTag) depth = Math.max(0, depth - 1)
|
||||
} else if (!tag.startsWith('<?') && !tag.startsWith('<!') && !tag.endsWith('/>')) {
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
return formattedLine
|
||||
})
|
||||
return `${formatted.join('\n')}\n`
|
||||
}
|
||||
|
||||
function markedSvg(bytes: Uint8Array, notice: boolean): string {
|
||||
const svg = formatSvg(new TextDecoder().decode(bytes))
|
||||
const header = svgHeader(notice)
|
||||
return svg.startsWith('<?xml')
|
||||
? svg.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n${header}\n`)
|
||||
: `${header}\n${svg}`
|
||||
}
|
||||
|
||||
function descriptionComment(name: string, description?: string): string[] {
|
||||
const text = description ?? `Имена иконок SVG-спрайта «${name}».`
|
||||
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
|
||||
}
|
||||
|
||||
function generateIconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const camelName = toCamelCase(config.name)
|
||||
const names = artifact.icons.map((icon) => icon.name)
|
||||
const ids = Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id]))
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
'',
|
||||
...descriptionComment(config.name, config.description),
|
||||
`export const ${camelName}IconNames = ${JSON.stringify(names, null, 2)}`,
|
||||
'',
|
||||
`export const ${camelName}IconIds = ${JSON.stringify(ids, null, 2)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateComponent(config: ResolvedSpriteConfig): string {
|
||||
const pascalName = toPascalCase(config.name)
|
||||
const camelName = toCamelCase(config.name)
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
"import { jsx } from 'react/jsx-runtime'",
|
||||
"import styles from './react-component.module.css'",
|
||||
`import { ${camelName}IconIds } from '../icon-data.js'`,
|
||||
"import spriteUrl from '../sprite.svg?no-inline'",
|
||||
'',
|
||||
`/** Иконка из SVG-спрайта «${config.name}». */`,
|
||||
`export const ${pascalName}Icon = (props) => {`,
|
||||
' const { icon, wrapped, className, ...rest } = props',
|
||||
` const href = spriteUrl + '#' + ${camelName}IconIds[icon]`,
|
||||
'',
|
||||
' if (wrapped) {',
|
||||
" return jsx('span', {",
|
||||
' ...rest,',
|
||||
" className: [styles.wrap, className].filter(Boolean).join(' '),",
|
||||
" children: jsx('svg', { children: jsx('use', { href }) }),",
|
||||
' })',
|
||||
' }',
|
||||
'',
|
||||
" return jsx('svg', {",
|
||||
' ...rest,',
|
||||
" className: [styles.root, className].filter(Boolean).join(' '),",
|
||||
" children: jsx('use', { href }),",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateIconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const pascalName = toPascalCase(config.name)
|
||||
const camelName = toCamelCase(config.name)
|
||||
const tuple = artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`).join('\n')
|
||||
const ids = artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`).join('\n')
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
'',
|
||||
...descriptionComment(config.name, config.description),
|
||||
`export declare const ${camelName}IconNames: readonly [`,
|
||||
tuple,
|
||||
']',
|
||||
'',
|
||||
`export type ${pascalName}IconName = typeof ${camelName}IconNames[number]`,
|
||||
`export declare const ${camelName}IconIds: {`,
|
||||
ids,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateComponentDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const pascalName = toPascalCase(config.name)
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
|
||||
`import type { ${pascalName}IconName } from '../icon-data.js'`,
|
||||
'',
|
||||
`export type ${pascalName}IconStyle = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
|
||||
'',
|
||||
`type ${pascalName}IconBaseProps = { icon: ${pascalName}IconName }`,
|
||||
`type ${pascalName}IconSvgProps = ${pascalName}IconBaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${pascalName}IconStyle }`,
|
||||
`type ${pascalName}IconWrappedProps = ${pascalName}IconBaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${pascalName}IconStyle }`,
|
||||
`export type ${pascalName}IconProps = ${pascalName}IconSvgProps | ${pascalName}IconWrappedProps`,
|
||||
'',
|
||||
`export declare const ${pascalName}Icon: (props: ${pascalName}IconProps) => ReactElement`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateIndexDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const pascalName = toPascalCase(config.name)
|
||||
const camelName = toCamelCase(config.name)
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
`export { ${pascalName}Icon } from './react/react-component.js'`,
|
||||
`export type { ${pascalName}IconProps, ${pascalName}IconStyle } from './react/react-component.js'`,
|
||||
`export { ${camelName}IconNames } from './icon-data.js'`,
|
||||
`export type { ${pascalName}IconName } from './icon-data.js'`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateIndex(config: ResolvedSpriteConfig): string {
|
||||
const pascalName = toPascalCase(config.name)
|
||||
const camelName = toCamelCase(config.name)
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
`export { ${pascalName}Icon } from './react/react-component.js'`,
|
||||
`export { ${camelName}IconNames } from './icon-data.js'`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateStyles(config: ResolvedSpriteConfig): string {
|
||||
const transition = config.transform.addTransition
|
||||
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
|
||||
: []
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
'',
|
||||
'.root {',
|
||||
...transition,
|
||||
'}',
|
||||
'',
|
||||
'.wrap {',
|
||||
' display: inline-flex;',
|
||||
'}',
|
||||
'',
|
||||
'.wrap svg {',
|
||||
' width: 100%;',
|
||||
' height: 100%;',
|
||||
...transition,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function manifestData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generator: '@gromlab/svg-sprites',
|
||||
name: config.name,
|
||||
...(config.description === undefined ? {} : { description: config.description }),
|
||||
componentName: `${toPascalCase(config.name)}Icon`,
|
||||
mode: MODE,
|
||||
target: TARGET,
|
||||
format: artifact.format,
|
||||
iconCount: artifact.icons.length,
|
||||
spriteUrl: '__SPRITE_URL__',
|
||||
icons: artifact.icons,
|
||||
}
|
||||
}
|
||||
|
||||
function generateManifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const serialized = JSON.stringify(manifestData(config, artifact), null, 2)
|
||||
.replace('"__SPRITE_URL__"', 'spriteUrl')
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
"import spriteUrl from './sprite.svg?no-inline'",
|
||||
'',
|
||||
`export const spriteManifest = ${serialized}`,
|
||||
'',
|
||||
'export default spriteManifest',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateManifestDeclarations(config: ResolvedSpriteConfig): string {
|
||||
return [
|
||||
blockHeader(config.generatedNotice),
|
||||
"import type { SpriteManifest } from '@gromlab/svg-sprites/react'",
|
||||
'',
|
||||
'export declare const spriteManifest: SpriteManifest',
|
||||
'export default spriteManifest',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function generateOutputFiles(
|
||||
config: ResolvedSpriteConfig,
|
||||
artifact: CompiledSpriteArtifact,
|
||||
): GeneratedFile[] {
|
||||
return [
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: generateIndex(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: generateIndexDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: generateIconData(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: generateIconDeclarations(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: generateComponent(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: generateComponentDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: generateStyles(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: generateManifest(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: generateManifestDeclarations(config) },
|
||||
]
|
||||
}
|
||||
26
src/modes/react-webpack/adapter.ts
Normal file
26
src/modes/react-webpack/adapter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
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,
|
||||
})
|
||||
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
|
||||
return {
|
||||
files: generateOutputFiles(context.config, artifact),
|
||||
paths: {
|
||||
generatedDir: '.svg-sprite',
|
||||
sprite: '.svg-sprite/sprite.svg',
|
||||
manifest: '.svg-sprite/svg-sprite.manifest.js',
|
||||
entry: '.svg-sprite/index.js',
|
||||
},
|
||||
result: { target: 'webpack' },
|
||||
}
|
||||
},
|
||||
}
|
||||
187
src/modes/react-webpack/output.ts
Normal file
187
src/modes/react-webpack/output.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import path from 'node:path'
|
||||
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import { GENERATED_MARKER } from '../../core/generated-markers.js'
|
||||
import type { GeneratedFile } from '../../core/mode-adapter.js'
|
||||
import type { ResolvedSpriteConfig } from '../../types.js'
|
||||
|
||||
const MODE = 'react@webpack'
|
||||
const TARGET = 'webpack'
|
||||
const OUTPUT_DIR = '.svg-sprite'
|
||||
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
|
||||
|
||||
function pascal(value: string): string {
|
||||
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
|
||||
}
|
||||
|
||||
function camel(value: string): string {
|
||||
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function header(enabled: boolean): string {
|
||||
return enabled
|
||||
? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */`
|
||||
: `/* ${GENERATED_MARKER}. Do not edit. */`
|
||||
}
|
||||
|
||||
function svg(bytes: Uint8Array, enabled: boolean): string {
|
||||
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
|
||||
const content = new TextDecoder().decode(bytes)
|
||||
return content.startsWith('<?xml')
|
||||
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
|
||||
: `<!-- ${marker} -->\n${content}`
|
||||
}
|
||||
|
||||
function comment(config: ResolvedSpriteConfig): string[] {
|
||||
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
|
||||
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
|
||||
}
|
||||
|
||||
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const prefix = camel(config.name)
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
'',
|
||||
...comment(config),
|
||||
`export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`,
|
||||
'',
|
||||
`export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function component(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
"import { jsx } from 'react/jsx-runtime'",
|
||||
"import styles from './react-component.module.css'",
|
||||
`import { ${ids} } from '../icon-data.js'`,
|
||||
'',
|
||||
"const spriteUrl = new URL('../sprite.svg', import.meta.url).href",
|
||||
'',
|
||||
`export const ${componentName} = (props) => {`,
|
||||
' const { icon, wrapped, className, ...rest } = props',
|
||||
` const href = spriteUrl + '#' + ${ids}[icon]`,
|
||||
' if (wrapped) {',
|
||||
" return jsx('span', {",
|
||||
' ...rest,',
|
||||
" className: [styles.wrap, className].filter(Boolean).join(' '),",
|
||||
" children: jsx('svg', { children: jsx('use', { href }) }),",
|
||||
' })',
|
||||
' }',
|
||||
" return jsx('svg', {",
|
||||
' ...rest,',
|
||||
" className: [styles.root, className].filter(Boolean).join(' '),",
|
||||
" children: jsx('use', { href }),",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
const ids = `${camel(config.name)}IconIds`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
'',
|
||||
...comment(config),
|
||||
`export declare const ${names}: readonly [`,
|
||||
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
|
||||
']',
|
||||
`export type ${typeName} = typeof ${names}[number]`,
|
||||
`export declare const ${ids}: {`,
|
||||
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function componentDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const typeName = `${componentName}Name`
|
||||
const styleName = `${componentName}Style`
|
||||
const propsName = `${componentName}Props`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
|
||||
`import type { ${typeName} } from '../icon-data.js'`,
|
||||
'',
|
||||
`export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
|
||||
`type ${componentName}BaseProps = { icon: ${typeName} }`,
|
||||
`type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`,
|
||||
`type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`,
|
||||
`export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`,
|
||||
`export declare const ${componentName}: (props: ${propsName}) => ReactElement`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function indexDeclarations(config: ResolvedSpriteConfig): string {
|
||||
const componentName = `${pascal(config.name)}Icon`
|
||||
const names = `${camel(config.name)}IconNames`
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
`export { ${componentName} } from './react/react-component.js'`,
|
||||
`export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`,
|
||||
`export { ${names} } from './icon-data.js'`,
|
||||
`export type { ${componentName}Name } from './icon-data.js'`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function index(config: ResolvedSpriteConfig): string {
|
||||
return [
|
||||
header(config.generatedNotice),
|
||||
`export { ${pascal(config.name)}Icon } from './react/react-component.js'`,
|
||||
`export { ${camel(config.name)}IconNames } from './icon-data.js'`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function styles(config: ResolvedSpriteConfig): string {
|
||||
const transition = config.transform.addTransition
|
||||
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
|
||||
: []
|
||||
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
|
||||
}
|
||||
|
||||
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
|
||||
const data = {
|
||||
schemaVersion: 1,
|
||||
generator: '@gromlab/svg-sprites',
|
||||
name: config.name,
|
||||
...(config.description === undefined ? {} : { description: config.description }),
|
||||
componentName: `${pascal(config.name)}Icon`,
|
||||
mode: MODE,
|
||||
target: TARGET,
|
||||
format: artifact.format,
|
||||
iconCount: artifact.icons.length,
|
||||
spriteUrl: '__SPRITE_URL__',
|
||||
icons: artifact.icons,
|
||||
}
|
||||
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
|
||||
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
|
||||
return [
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
|
||||
{ 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) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
|
||||
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
|
||||
]
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { getSpriteShapeId } from '../../shape-id.js'
|
||||
import { generateReactAssetUrlCode } from '../../targets/index.js'
|
||||
import type { SpriteAssetTarget } from '../../targets/types.js'
|
||||
import type { SpriteFormat } from '../../types.js'
|
||||
import { generateSpriteManifest } from './manifest.js'
|
||||
import { toPascalCase } from './naming.js'
|
||||
import type { ResolvedReactSpriteConfig } from './types.js'
|
||||
|
||||
const GENERATED_MARKER = '@generated by @gromlab/svg-sprites. Do not edit.'
|
||||
const GENERATED_NOTICE = [
|
||||
'----------------------------------------------------------------------',
|
||||
'## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##',
|
||||
'## ##',
|
||||
'## Не редактируйте вручную: изменения будут перезаписаны. ##',
|
||||
'## Для изменений перегенерируйте SVG-спрайт. ##',
|
||||
'## ##',
|
||||
'## Генератор: @gromlab/svg-sprites ##',
|
||||
'## Репозиторий: https://github.com/gromlab-ru/svg-sprites ##',
|
||||
'----------------------------------------------------------------------',
|
||||
]
|
||||
|
||||
type ReactCodegenOptions = {
|
||||
config: ResolvedReactSpriteConfig
|
||||
format: SpriteFormat
|
||||
iconNames: string[]
|
||||
sprite: Uint8Array
|
||||
target: SpriteAssetTarget
|
||||
}
|
||||
|
||||
export type GeneratedFile = {
|
||||
path: string
|
||||
content: string | Uint8Array
|
||||
}
|
||||
|
||||
function toCamelCase(name: string): string {
|
||||
return name.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function generateBlockHeader(generatedNotice: boolean): string {
|
||||
if (!generatedNotice) return `/* ${GENERATED_MARKER} */`
|
||||
|
||||
return [
|
||||
'/*',
|
||||
...GENERATED_NOTICE.map((line) => ` * ${line}`),
|
||||
' */',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateSvgHeader(generatedNotice: boolean): string {
|
||||
if (!generatedNotice) return `<!-- ${GENERATED_MARKER} -->`
|
||||
|
||||
return [
|
||||
'<!--',
|
||||
...GENERATED_NOTICE.slice(1, -1).map((line) => ` ${line}`),
|
||||
'-->',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateGitignore(): string {
|
||||
return `# ${GENERATED_MARKER}\n/generated/\n/index.ts\n/manifest.ts\n`
|
||||
}
|
||||
|
||||
function formatSvg(content: string): string {
|
||||
let depth = 0
|
||||
const lines = content.replace(/></g, '>\n<').split('\n')
|
||||
|
||||
const formatted = lines.map((line) => {
|
||||
const trimmed = line.trim()
|
||||
const tags = trimmed.match(/<[^>]+>/g) ?? []
|
||||
const startsWithClosingTag = trimmed.startsWith('</')
|
||||
|
||||
if (startsWithClosingTag) depth = Math.max(0, depth - 1)
|
||||
|
||||
const formattedLine = `${' '.repeat(depth)}${trimmed}`
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.startsWith('</')) {
|
||||
if (!startsWithClosingTag) depth = Math.max(0, depth - 1)
|
||||
} else if (!tag.startsWith('<?') && !tag.startsWith('<!') && !tag.endsWith('/>')) {
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
|
||||
return formattedLine
|
||||
})
|
||||
|
||||
return `${formatted.join('\n')}\n`
|
||||
}
|
||||
|
||||
function markSvg(content: Uint8Array, generatedNotice: boolean): string {
|
||||
const svg = formatSvg(new TextDecoder().decode(content))
|
||||
const header = generateSvgHeader(generatedNotice)
|
||||
return svg.startsWith('<?xml')
|
||||
? svg.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n${header}\n`)
|
||||
: `${header}\n${svg}`
|
||||
}
|
||||
|
||||
function generateDescriptionComment(name: string, description?: string): string[] {
|
||||
const text = description ?? `Имена иконок SVG-спрайта «${name}».`
|
||||
return [
|
||||
'/**',
|
||||
...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`),
|
||||
' */',
|
||||
]
|
||||
}
|
||||
|
||||
function generateTypes(
|
||||
name: string,
|
||||
description: string | undefined,
|
||||
iconNames: string[],
|
||||
generatedNotice: boolean,
|
||||
): string {
|
||||
const pascalName = toPascalCase(name)
|
||||
const camelName = toCamelCase(name)
|
||||
const iconNamesVariable = `${camelName}IconNames`
|
||||
|
||||
return [
|
||||
generateBlockHeader(generatedNotice),
|
||||
'',
|
||||
...generateDescriptionComment(name, description),
|
||||
`export const ${iconNamesVariable} = [`,
|
||||
...iconNames.map((iconName) => ` ${JSON.stringify(iconName)},`),
|
||||
'] as const',
|
||||
'',
|
||||
`export type ${pascalName}IconName = typeof ${iconNamesVariable}[number]`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateComponent(
|
||||
name: string,
|
||||
generatedNotice: boolean,
|
||||
target: SpriteAssetTarget,
|
||||
iconNames: string[],
|
||||
): string {
|
||||
const pascalName = toPascalCase(name)
|
||||
const assetUrlCode = generateReactAssetUrlCode(target, 'sprite.svg')
|
||||
|
||||
return [
|
||||
generateBlockHeader(generatedNotice),
|
||||
"import type { CSSProperties, HTMLAttributes, SVGAttributes } from 'react'",
|
||||
"import styles from './styles.module.css'",
|
||||
`import type { ${pascalName}IconName } from './types'`,
|
||||
...assetUrlCode.imports,
|
||||
'',
|
||||
...assetUrlCode.declarations,
|
||||
...(assetUrlCode.declarations.length > 0 ? [''] : []),
|
||||
`const iconIds: Record<${pascalName}IconName, string> = {`,
|
||||
...iconNames.map((iconName) => ` ${JSON.stringify(iconName)}: ${JSON.stringify(getSpriteShapeId(iconName))},`),
|
||||
'}',
|
||||
'',
|
||||
`export type ${pascalName}IconStyle = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
|
||||
'',
|
||||
`type ${pascalName}IconBaseProps = {`,
|
||||
` icon: ${pascalName}IconName`,
|
||||
'}',
|
||||
'',
|
||||
`type ${pascalName}IconSvgProps = ${pascalName}IconBaseProps & {`,
|
||||
' wrapped?: false',
|
||||
`} & Omit<SVGAttributes<SVGSVGElement>, 'style'> & {`,
|
||||
` style?: ${pascalName}IconStyle`,
|
||||
'}',
|
||||
'',
|
||||
`type ${pascalName}IconWrappedProps = ${pascalName}IconBaseProps & {`,
|
||||
' wrapped: true',
|
||||
`} & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & {`,
|
||||
` style?: ${pascalName}IconStyle`,
|
||||
'}',
|
||||
'',
|
||||
`export type ${pascalName}IconProps =`,
|
||||
` | ${pascalName}IconSvgProps`,
|
||||
` | ${pascalName}IconWrappedProps`,
|
||||
'',
|
||||
`/** Иконка из SVG-спрайта «${name}». */`,
|
||||
`export const ${pascalName}Icon = (props: ${pascalName}IconProps) => {`,
|
||||
' const { icon, wrapped, className, ...rest } = props',
|
||||
` const href = ${assetUrlCode.variableName} + '#' + iconIds[icon]`,
|
||||
'',
|
||||
' if (wrapped) {',
|
||||
' const htmlAttributes = rest as HTMLAttributes<HTMLSpanElement>',
|
||||
' return (',
|
||||
" <span {...htmlAttributes} className={[styles.wrap, className].filter(Boolean).join(' ')}>",
|
||||
' <svg>',
|
||||
' <use href={href} />',
|
||||
' </svg>',
|
||||
' </span>',
|
||||
' )',
|
||||
' }',
|
||||
'',
|
||||
' const svgAttributes = rest as SVGAttributes<SVGSVGElement>',
|
||||
' return (',
|
||||
" <svg {...svgAttributes} className={[styles.root, className].filter(Boolean).join(' ')}>",
|
||||
' <use href={href} />',
|
||||
' </svg>',
|
||||
' )',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateCss(generatedNotice: boolean, addTransition: boolean): string {
|
||||
const transitionRules = addTransition
|
||||
? [
|
||||
' transition-property: fill, stroke, color;',
|
||||
' transition-duration: 0.3s;',
|
||||
' transition-timing-function: ease;',
|
||||
]
|
||||
: []
|
||||
|
||||
return [
|
||||
generateBlockHeader(generatedNotice),
|
||||
'',
|
||||
'.root {',
|
||||
...transitionRules,
|
||||
'}',
|
||||
'',
|
||||
'.wrap {',
|
||||
' display: inline-flex;',
|
||||
'}',
|
||||
'',
|
||||
'.wrap svg {',
|
||||
' width: 100%;',
|
||||
' height: 100%;',
|
||||
...transitionRules,
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function generateRootIndex(name: string, generatedNotice: boolean): string {
|
||||
const pascalName = toPascalCase(name)
|
||||
const iconNamesVariable = `${toCamelCase(name)}IconNames`
|
||||
|
||||
return [
|
||||
generateBlockHeader(generatedNotice),
|
||||
`export { ${pascalName}Icon } from './generated/react-component'`,
|
||||
`export type { ${pascalName}IconProps, ${pascalName}IconStyle } from './generated/react-component'`,
|
||||
`export { ${iconNamesVariable} } from './generated/types'`,
|
||||
`export type { ${pascalName}IconName } from './generated/types'`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Создаёт все управляемые файлы React sprite-модуля в памяти. */
|
||||
export function generateReactFiles(options: ReactCodegenOptions): GeneratedFile[] {
|
||||
const { config, format, iconNames, sprite, target } = options
|
||||
const { name, description, generatedNotice } = config
|
||||
|
||||
return [
|
||||
{
|
||||
path: '.gitignore',
|
||||
content: generateGitignore(),
|
||||
},
|
||||
{
|
||||
path: path.posix.join('generated', 'sprite.svg'),
|
||||
content: markSvg(sprite, generatedNotice),
|
||||
},
|
||||
{
|
||||
path: path.posix.join('generated', 'types.ts'),
|
||||
content: generateTypes(name, description, iconNames, generatedNotice),
|
||||
},
|
||||
{
|
||||
path: path.posix.join('generated', 'react-component.tsx'),
|
||||
content: generateComponent(name, generatedNotice, target, iconNames),
|
||||
},
|
||||
{
|
||||
path: path.posix.join('generated', 'styles.module.css'),
|
||||
content: generateCss(generatedNotice, config.transform.addTransition ?? true),
|
||||
},
|
||||
{
|
||||
path: 'manifest.ts',
|
||||
content: generateSpriteManifest({
|
||||
header: generateBlockHeader(generatedNotice),
|
||||
name,
|
||||
description,
|
||||
format,
|
||||
iconNames,
|
||||
sprite,
|
||||
target,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'index.ts',
|
||||
content: generateRootIndex(name, generatedNotice),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createJiti } from 'jiti'
|
||||
import { toKebabCase, validateSpriteName } from './naming.js'
|
||||
import type { ReactSpriteConfig, ResolvedReactSpriteConfig } from './types.js'
|
||||
|
||||
export const REACT_CONFIG_FILE = 'svg-sprite.config.ts'
|
||||
|
||||
function getDefaultName(rootDir: string): string {
|
||||
const rootName = path.basename(rootDir)
|
||||
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
|
||||
? path.basename(path.dirname(rootDir))
|
||||
: rootName
|
||||
const name = toKebabCase(source)
|
||||
|
||||
if (!name) {
|
||||
throw new Error(`Cannot infer sprite name from directory: ${rootDir}`)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
/** Загружает локальный React-конфиг из корня sprite-модуля. */
|
||||
export async function loadReactSpriteConfig(rootDir: string): Promise<ResolvedReactSpriteConfig> {
|
||||
const configPath = path.join(rootDir, REACT_CONFIG_FILE)
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
throw new Error(
|
||||
`React config file not found: ${configPath}\n` +
|
||||
`Create ${REACT_CONFIG_FILE} inside the sprite directory.`,
|
||||
)
|
||||
}
|
||||
|
||||
const jiti = createJiti(rootDir)
|
||||
const mod = await jiti.import(configPath) as { default?: ReactSpriteConfig }
|
||||
const config = mod.default
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error(
|
||||
`React config file must have a default export: ${configPath}\n` +
|
||||
'Use: export default defineReactSpriteConfig({ ... })',
|
||||
)
|
||||
}
|
||||
|
||||
if (config.name !== undefined && typeof config.name !== 'string') {
|
||||
throw new Error('React config: "name" must be a string.')
|
||||
}
|
||||
|
||||
if (config.description !== undefined && typeof config.description !== 'string') {
|
||||
throw new Error('React config: "description" must be a string.')
|
||||
}
|
||||
|
||||
if ('icons' in config) {
|
||||
throw new Error('React config: "icons" was renamed to "inputFolder".')
|
||||
}
|
||||
|
||||
if (config.inputFolder !== undefined && (
|
||||
typeof config.inputFolder !== 'string' || config.inputFolder.trim() === ''
|
||||
)) {
|
||||
throw new Error('React config: "inputFolder" must be a non-empty string.')
|
||||
}
|
||||
|
||||
if (config.inputFiles !== undefined && (
|
||||
!Array.isArray(config.inputFiles)
|
||||
|| config.inputFiles.some((filePath) => typeof filePath !== 'string' || filePath.trim() === '')
|
||||
)) {
|
||||
throw new Error('React config: "inputFiles" must be an array of non-empty strings.')
|
||||
}
|
||||
|
||||
if (config.transform !== undefined) {
|
||||
if (
|
||||
config.transform === null
|
||||
|| typeof config.transform !== 'object'
|
||||
|| Array.isArray(config.transform)
|
||||
) {
|
||||
throw new Error('React config: "transform" must be an object.')
|
||||
}
|
||||
|
||||
for (const option of ['removeSize', 'replaceColors', 'addTransition'] as const) {
|
||||
if (config.transform[option] !== undefined && typeof config.transform[option] !== 'boolean') {
|
||||
throw new Error(`React config: "transform.${option}" must be a boolean.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.generatedNotice !== undefined && typeof config.generatedNotice !== 'boolean') {
|
||||
throw new Error('React config: "generatedNotice" must be a boolean.')
|
||||
}
|
||||
|
||||
const name = config.name ?? getDefaultName(rootDir)
|
||||
validateSpriteName(name)
|
||||
const inputFiles = (config.inputFiles ?? []).map((filePath) => path.resolve(rootDir, filePath))
|
||||
const defaultInputFolder = path.resolve(rootDir, 'icons')
|
||||
const inputFolder = config.inputFolder === undefined
|
||||
&& inputFiles.length > 0
|
||||
&& !fs.existsSync(defaultInputFolder)
|
||||
? null
|
||||
: path.resolve(rootDir, config.inputFolder ?? 'icons')
|
||||
|
||||
return {
|
||||
name,
|
||||
description: config.description,
|
||||
inputFolder,
|
||||
inputFiles,
|
||||
transform: { ...config.transform },
|
||||
generatedNotice: config.generatedNotice ?? true,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ReactAssetTarget } from '../../targets/types.js'
|
||||
import { generateSpriteModule } from './module-generator.js'
|
||||
import type { ReactSpriteGenerationResult } from './types.js'
|
||||
|
||||
/** Генерирует один локальный React sprite-модуль для Vite или Webpack. */
|
||||
export function generateReactSprite(
|
||||
root: string,
|
||||
target: ReactAssetTarget,
|
||||
): Promise<ReactSpriteGenerationResult> {
|
||||
if (target !== 'vite' && target !== 'webpack') {
|
||||
throw new Error(`Unsupported React asset target: ${String(target)}`)
|
||||
}
|
||||
|
||||
return generateSpriteModule(root, target, {
|
||||
mode: `react@${target}`,
|
||||
rootViewBox: false,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { generateReactSprite } from './generate.js'
|
||||
export { loadReactSpriteConfig, REACT_CONFIG_FILE } from './config.js'
|
||||
export type {
|
||||
ReactSpriteConfig,
|
||||
ReactSpriteGenerationResult,
|
||||
ResolvedReactSpriteConfig,
|
||||
} from './types.js'
|
||||
@@ -1,120 +0,0 @@
|
||||
import { getSpriteShapeId } from '../../shape-id.js'
|
||||
import { generateReactAssetUrlCode } from '../../targets/index.js'
|
||||
import type { SpriteAssetTarget } from '../../targets/types.js'
|
||||
import type { SpriteFormat } from '../../types.js'
|
||||
import { toPascalCase } from './naming.js'
|
||||
|
||||
type ManifestIconColor = {
|
||||
variable: string
|
||||
fallback: string
|
||||
}
|
||||
|
||||
type ManifestIcon = {
|
||||
name: string
|
||||
id: string
|
||||
viewBox: string | null
|
||||
colors: ManifestIconColor[]
|
||||
}
|
||||
|
||||
type GenerateManifestOptions = {
|
||||
header: string
|
||||
name: string
|
||||
description?: string
|
||||
format: SpriteFormat
|
||||
iconNames: string[]
|
||||
sprite: Uint8Array
|
||||
target: SpriteAssetTarget
|
||||
}
|
||||
|
||||
function extractColors(fragment: string): ManifestIconColor[] {
|
||||
const colors = new Map<string, string>()
|
||||
const regex = /var\((--icon-color-\d+),\s*((?:[^()]|\([^()]*\))*)\)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = regex.exec(fragment)) !== null) {
|
||||
if (!colors.has(match[1])) colors.set(match[1], match[2].trim())
|
||||
}
|
||||
|
||||
return [...colors].map(([variable, fallback]) => ({ variable, fallback }))
|
||||
}
|
||||
|
||||
function extractManifestIcons(sprite: Uint8Array, iconNames: string[]): ManifestIcon[] {
|
||||
const shapes = new Map<string, { viewBox: string | null; fragment: string }>()
|
||||
const content = new TextDecoder().decode(sprite)
|
||||
const shapeRegex = /<(symbol|svg)\b((?=[^>]*\bid="[^"]+")[^>]*)>[\s\S]*?<\/\1>/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = shapeRegex.exec(content)) !== null) {
|
||||
const attributes = match[2]
|
||||
const id = attributes.match(/\bid="([^"]+)"/)?.[1]
|
||||
if (!id) continue
|
||||
|
||||
shapes.set(id, {
|
||||
viewBox: attributes.match(/\bviewBox="([^"]+)"/)?.[1] ?? null,
|
||||
fragment: match[0],
|
||||
})
|
||||
}
|
||||
|
||||
return iconNames.map((name) => {
|
||||
const id = getSpriteShapeId(name)
|
||||
const shape = shapes.get(id)
|
||||
if (!shape) throw new Error(`Cannot find SVG shape "${id}" for icon "${name}".`)
|
||||
|
||||
return {
|
||||
name,
|
||||
id,
|
||||
viewBox: shape.viewBox,
|
||||
colors: extractColors(shape.fragment),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function generateIcon(icon: ManifestIcon): string[] {
|
||||
return [
|
||||
' {',
|
||||
` name: ${JSON.stringify(icon.name)},`,
|
||||
` id: ${JSON.stringify(icon.id)},`,
|
||||
` viewBox: ${JSON.stringify(icon.viewBox)},`,
|
||||
' colors: [',
|
||||
...icon.colors.flatMap((color) => [
|
||||
' {',
|
||||
` variable: ${JSON.stringify(color.variable)},`,
|
||||
` fallback: ${JSON.stringify(color.fallback)},`,
|
||||
' },',
|
||||
]),
|
||||
' ],',
|
||||
' },',
|
||||
]
|
||||
}
|
||||
|
||||
/** Генерирует отдельную debug-точку входа с метаданными спрайта. */
|
||||
export function generateSpriteManifest(options: GenerateManifestOptions): string {
|
||||
const { header, name, description, format, iconNames, sprite, target } = options
|
||||
const assetUrlCode = generateReactAssetUrlCode(target, 'generated/sprite.svg')
|
||||
const icons = extractManifestIcons(sprite, iconNames)
|
||||
|
||||
return [
|
||||
header,
|
||||
...assetUrlCode.imports,
|
||||
...(assetUrlCode.imports.length > 0 ? [''] : []),
|
||||
...assetUrlCode.declarations,
|
||||
...(assetUrlCode.declarations.length > 0 ? [''] : []),
|
||||
'export const spriteManifest = {',
|
||||
' schemaVersion: 1,',
|
||||
" generator: '@gromlab/svg-sprites',",
|
||||
` name: ${JSON.stringify(name)},`,
|
||||
...(description === undefined ? [] : [` description: ${JSON.stringify(description)},`]),
|
||||
` componentName: ${JSON.stringify(`${toPascalCase(name)}Icon`)},`,
|
||||
` target: ${JSON.stringify(target)},`,
|
||||
` format: ${JSON.stringify(format)},`,
|
||||
` iconCount: ${icons.length},`,
|
||||
` spriteUrl: ${assetUrlCode.variableName},`,
|
||||
' icons: [',
|
||||
...icons.flatMap(generateIcon),
|
||||
' ],',
|
||||
'} as const',
|
||||
'',
|
||||
'export default spriteManifest',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { log } from '../../logger.js'
|
||||
import { resolveSpriteSources } from '../../scanner.js'
|
||||
import { getSpriteShapeId } from '../../shape-id.js'
|
||||
import type { SpriteAssetTarget } from '../../targets/types.js'
|
||||
import { generateReactFiles } from './codegen.js'
|
||||
import { loadReactSpriteConfig } from './config.js'
|
||||
import type { SpriteModuleGenerationResult } from './types.js'
|
||||
import { writeReactFiles } from './writer.js'
|
||||
|
||||
type GenerateSpriteModuleOptions = {
|
||||
mode: string
|
||||
rootViewBox: boolean
|
||||
}
|
||||
|
||||
function validateIconIds(iconNames: string[]): void {
|
||||
const namesById = new Map<string, string>()
|
||||
|
||||
for (const iconName of iconNames) {
|
||||
const id = getSpriteShapeId(iconName)
|
||||
const existingName = namesById.get(id)
|
||||
|
||||
if (existingName) {
|
||||
throw new Error(
|
||||
`Icons "${existingName}" and "${iconName}" produce the same SVG id "${id}". Rename one of the files.`,
|
||||
)
|
||||
}
|
||||
|
||||
namesById.set(id, iconName)
|
||||
}
|
||||
}
|
||||
|
||||
/** Общая генерация типизированного React-компонента для React и Next.js modes. */
|
||||
export async function generateSpriteModule<TTarget extends SpriteAssetTarget>(
|
||||
root: string,
|
||||
target: TTarget,
|
||||
options: GenerateSpriteModuleOptions,
|
||||
): Promise<SpriteModuleGenerationResult<TTarget>> {
|
||||
const rootDir = path.resolve(root)
|
||||
const config = await loadReactSpriteConfig(rootDir)
|
||||
const format = 'stack'
|
||||
const folder = resolveSpriteSources({
|
||||
name: config.name,
|
||||
format,
|
||||
inputFolder: config.inputFolder,
|
||||
inputFiles: config.inputFiles,
|
||||
})
|
||||
|
||||
const iconNames = folder.files
|
||||
.map((filePath) => path.basename(filePath, '.svg'))
|
||||
.sort()
|
||||
validateIconIds(iconNames)
|
||||
|
||||
const sprite = await compileSpriteContent(folder, config.transform, {
|
||||
rootViewBox: options.rootViewBox,
|
||||
})
|
||||
const files = generateReactFiles({ config, format, iconNames, sprite, target })
|
||||
|
||||
writeReactFiles(rootDir, files, config.generatedNotice)
|
||||
|
||||
const generatedDir = path.join(rootDir, 'generated')
|
||||
const spritePath = path.join(generatedDir, 'sprite.svg')
|
||||
const manifestPath = path.join(rootDir, 'manifest.ts')
|
||||
const iconLabel = iconNames.length === 1 ? 'icon' : 'icons'
|
||||
log.success(`✓ ${config.name} · ${iconNames.length} ${iconLabel} · ${options.mode}`)
|
||||
log.detail(` → ${path.relative(process.cwd(), generatedDir)}`)
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
rootDir,
|
||||
generatedDir,
|
||||
spritePath,
|
||||
manifestPath,
|
||||
iconCount: iconNames.length,
|
||||
target,
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/** Преобразует kebab-case имя в PascalCase. */
|
||||
export function toPascalCase(value: string): string {
|
||||
return value
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Преобразует имя папки в допустимое kebab-case имя спрайта. */
|
||||
export function toKebabCase(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function validateSpriteName(name: string): void {
|
||||
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) {
|
||||
throw new Error(
|
||||
`React config: "name" must be kebab-case and start with a letter. Received: "${name}".`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type {
|
||||
ReactAssetTarget,
|
||||
SpriteAssetTarget,
|
||||
} from '../../targets/types.js'
|
||||
import type { TransformOptions } from '../../types.js'
|
||||
|
||||
export type ReactSpriteConfig = {
|
||||
/** Логическое имя спрайта. По умолчанию выводится из пути модуля. */
|
||||
name?: string
|
||||
/** Описание спрайта для документации и будущего инспектора. */
|
||||
description?: string
|
||||
/** Папка с исходными SVG относительно svg-sprite.config.ts. По умолчанию: ./icons. */
|
||||
inputFolder?: string
|
||||
/** Дополнительные SVG-файлы относительно svg-sprite.config.ts. По умолчанию: []. */
|
||||
inputFiles?: string[]
|
||||
/** Настройки трансформации SVG. По умолчанию все трансформации включены. */
|
||||
transform?: TransformOptions
|
||||
/** Добавлять развёрнутое предупреждение в generated-файлы. По умолчанию: true. */
|
||||
generatedNotice?: boolean
|
||||
}
|
||||
|
||||
export type ResolvedReactSpriteConfig = {
|
||||
name: string
|
||||
description?: string
|
||||
inputFolder: string | null
|
||||
inputFiles: string[]
|
||||
transform: TransformOptions
|
||||
generatedNotice: boolean
|
||||
}
|
||||
|
||||
export type SpriteModuleGenerationResult<TTarget extends SpriteAssetTarget> = {
|
||||
name: string
|
||||
rootDir: string
|
||||
generatedDir: string
|
||||
spritePath: string
|
||||
manifestPath: string
|
||||
iconCount: number
|
||||
/** Среда, для которой сгенерирован способ получения URL SVG asset. */
|
||||
target: TTarget
|
||||
}
|
||||
|
||||
export type ReactSpriteGenerationResult = SpriteModuleGenerationResult<ReactAssetTarget>
|
||||
@@ -1,172 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { GeneratedFile } from './codegen.js'
|
||||
|
||||
const MANIFEST_FILE = '.svg-sprites.manifest.json'
|
||||
const GENERATOR = '@gromlab/svg-sprites'
|
||||
const GENERATED_MARKER = '@generated by @gromlab/svg-sprites'
|
||||
const GENERATED_NOTICE_MARKER = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'
|
||||
const ROOT_MANAGED_FILES = new Set(['.gitignore', 'index.ts', 'manifest.ts'])
|
||||
|
||||
type Manifest = {
|
||||
version: 1
|
||||
generator: typeof GENERATOR
|
||||
files: string[]
|
||||
warning?: string
|
||||
}
|
||||
|
||||
function normalizeManagedPath(relativePath: string): string {
|
||||
const normalized = relativePath.replaceAll('\\', '/')
|
||||
const parts = normalized.split('/')
|
||||
const isRootFile = ROOT_MANAGED_FILES.has(normalized)
|
||||
const isGeneratedFile = parts.length === 2
|
||||
&& parts[0] === 'generated'
|
||||
&& parts[1] !== ''
|
||||
&& parts[1] !== '.'
|
||||
&& parts[1] !== '..'
|
||||
|
||||
if (!isRootFile && !isGeneratedFile) {
|
||||
throw new Error(`Invalid generated file path: ${relativePath}`)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function resolveManagedPath(rootDir: string, relativePath: string): string {
|
||||
const normalized = normalizeManagedPath(relativePath)
|
||||
const resolved = path.resolve(rootDir, normalized)
|
||||
const relative = path.relative(rootDir, resolved)
|
||||
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`Invalid generated file path: ${relativePath}`)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function assertNoSymlinks(rootDir: string, filePath: string): void {
|
||||
const relative = path.relative(rootDir, filePath)
|
||||
let currentPath = rootDir
|
||||
|
||||
for (const segment of relative.split(path.sep)) {
|
||||
currentPath = path.join(currentPath, segment)
|
||||
try {
|
||||
if (fs.lstatSync(currentPath).isSymbolicLink()) {
|
||||
throw new Error(`Symbolic links are not allowed in generated paths: ${currentPath}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(rootDir: string, manifestPath: string): Manifest | null {
|
||||
assertNoSymlinks(rootDir, manifestPath)
|
||||
if (!fs.existsSync(manifestPath)) return null
|
||||
|
||||
let manifest: unknown
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
} catch {
|
||||
throw new Error(`Cannot parse generated files manifest: ${manifestPath}`)
|
||||
}
|
||||
|
||||
if (
|
||||
!manifest
|
||||
|| typeof manifest !== 'object'
|
||||
|| !('version' in manifest)
|
||||
|| manifest.version !== 1
|
||||
|| !('generator' in manifest)
|
||||
|| manifest.generator !== GENERATOR
|
||||
|| !('files' in manifest)
|
||||
|| !Array.isArray(manifest.files)
|
||||
|| !manifest.files.every((file) => typeof file === 'string')
|
||||
) {
|
||||
throw new Error(`Invalid generated files manifest: ${manifestPath}`)
|
||||
}
|
||||
|
||||
return {
|
||||
...(manifest as Manifest),
|
||||
files: (manifest as Manifest).files.map(normalizeManagedPath),
|
||||
}
|
||||
}
|
||||
|
||||
function hasGeneratedMarker(filePath: string): boolean {
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
return content.includes(GENERATED_MARKER) || content.includes(GENERATED_NOTICE_MARKER)
|
||||
}
|
||||
|
||||
function writeFileAtomic(rootDir: string, filePath: string, content: string | Uint8Array): void {
|
||||
assertNoSymlinks(rootDir, filePath)
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
assertNoSymlinks(rootDir, filePath)
|
||||
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
|
||||
)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, content, { flag: 'wx' })
|
||||
fs.renameSync(temporaryPath, filePath)
|
||||
} finally {
|
||||
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath)
|
||||
}
|
||||
}
|
||||
|
||||
/** Обновляет только файлы, которыми владеет React-генератор. */
|
||||
export function writeReactFiles(
|
||||
rootDir: string,
|
||||
files: GeneratedFile[],
|
||||
generatedNotice: boolean,
|
||||
): void {
|
||||
const generatedDir = path.join(rootDir, 'generated')
|
||||
const manifestPath = path.join(generatedDir, MANIFEST_FILE)
|
||||
const previousManifest = readManifest(rootDir, manifestPath)
|
||||
const previousFiles = new Set(previousManifest?.files ?? [])
|
||||
const nextFiles = files.map((file) => normalizeManagedPath(file.path))
|
||||
const obsoleteFiles: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = resolveManagedPath(rootDir, file.path)
|
||||
assertNoSymlinks(rootDir, filePath)
|
||||
if (fs.existsSync(filePath) && !hasGeneratedMarker(filePath)) {
|
||||
throw new Error(
|
||||
`Refusing to overwrite a user file: ${filePath}\n` +
|
||||
'Move the file or choose another sprite directory.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of previousFiles) {
|
||||
if (nextFiles.includes(relativePath)) continue
|
||||
const filePath = resolveManagedPath(rootDir, relativePath)
|
||||
assertNoSymlinks(rootDir, filePath)
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
if (!hasGeneratedMarker(filePath)) {
|
||||
throw new Error(`Refusing to delete a user file: ${filePath}`)
|
||||
}
|
||||
obsoleteFiles.push(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of obsoleteFiles) fs.unlinkSync(filePath)
|
||||
|
||||
for (const file of files) {
|
||||
writeFileAtomic(rootDir, resolveManagedPath(rootDir, file.path), file.content)
|
||||
}
|
||||
|
||||
const manifest: Manifest = {
|
||||
version: 1,
|
||||
generator: GENERATOR,
|
||||
files: nextFiles,
|
||||
...(generatedNotice && {
|
||||
warning: 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную: изменения будут перезаписаны.',
|
||||
}),
|
||||
}
|
||||
writeFileAtomic(rootDir, manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
}
|
||||
Reference in New Issue
Block a user