mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
feat: перейти на изолированный контракт генерации
- добавлены независимые adapters для шести exact modes - runtime переведён на JavaScript с отдельными декларациями - generated output перенесён в .svg-sprite - удалены legacy pipeline и встроенный preview
This commit is contained in:
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) },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user