feat: выпустить версию 1.0.0

- добавлены отдельные режимы генерации для React, Next.js и legacy
- добавлены SpriteViewer, типизированные компоненты и безопасный codegen
- перенесена сборка AI-скила и обновлена документация
- добавлены migration guide, лицензии и проверки публикации
This commit is contained in:
2026-07-11 07:00:59 +03:00
parent 3a60b5c6ae
commit 05a0a9f7ed
76 changed files with 9456 additions and 1033 deletions

117
test/asset-targets.test.mjs Normal file
View File

@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { build as viteBuild } from 'vite'
import webpack from 'webpack'
const TINY_SPRITE = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<style>:root>svg{display:none}:root>svg:target{display:block}</style>',
'<svg id="check" viewBox="0 0 16 16">',
'<path d="M1 8l4 4L15 2"/>',
'</svg>',
'</svg>',
].join('')
function getOutputFiles(directory, extension) {
const files = []
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...getOutputFiles(entryPath, extension).map((filePath) => path.join(entry.name, filePath)))
} else if (entry.name.endsWith(extension)) {
files.push(entry.name)
}
}
return files
}
test('Vite target keeps a tiny sprite as a separate asset', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-vite-target-'))
const outputDir = path.join(rootDir, 'dist')
fs.writeFileSync(
path.join(rootDir, 'index.html'),
'<div id="root"></div><script type="module" src="/main.js"></script>',
)
fs.writeFileSync(
path.join(rootDir, 'main.js'),
[
"import spriteUrl from './sprite.svg?no-inline'",
"document.querySelector('#root').dataset.spriteUrl = spriteUrl",
'',
].join('\n'),
)
fs.writeFileSync(path.join(rootDir, 'sprite.svg'), TINY_SPRITE)
await viteBuild({
root: rootDir,
logLevel: 'silent',
build: {
outDir: outputDir,
},
})
const svgFiles = getOutputFiles(outputDir, '.svg')
const jsFiles = getOutputFiles(outputDir, '.js')
const javascript = jsFiles
.map((filePath) => fs.readFileSync(path.join(outputDir, filePath), 'utf-8'))
.join('\n')
assert.equal(svgFiles.length, 1)
assert.match(svgFiles[0], /sprite-[A-Za-z0-9_-]+\.svg$/)
assert.doesNotMatch(javascript, /data:image\/svg\+xml/)
})
test('Webpack target emits new URL sprite through Asset Modules', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-webpack-target-'))
const outputDir = path.join(rootDir, 'dist')
fs.writeFileSync(
path.join(rootDir, 'index.js'),
[
"const spriteUrl = new URL('./sprite.svg', import.meta.url).href",
'globalThis.__SPRITE_URL__ = spriteUrl',
'',
].join('\n'),
)
fs.writeFileSync(path.join(rootDir, 'sprite.svg'), TINY_SPRITE)
await new Promise((resolve, reject) => {
webpack({
context: rootDir,
entry: './index.js',
mode: 'production',
output: {
assetModuleFilename: 'assets/[name]-[contenthash][ext]',
filename: 'bundle.js',
path: outputDir,
},
}, (error, stats) => {
if (error) {
reject(error)
return
}
if (!stats || stats.hasErrors()) {
const errors = stats?.toJson({ all: false, errors: true }).errors ?? []
reject(new Error(errors.map((item) => item.message).join('\n')))
return
}
resolve()
})
})
const svgFiles = getOutputFiles(outputDir, '.svg')
const javascript = fs.readFileSync(path.join(outputDir, 'bundle.js'), 'utf-8')
assert.equal(svgFiles.length, 1)
assert.match(svgFiles[0], /sprite-[a-f0-9]+\.svg$/)
assert.doesNotMatch(javascript, /data:image\/svg\+xml/)
})

204
test/next-targets.test.mjs Normal file
View File

@@ -0,0 +1,204 @@
import assert from 'node:assert/strict'
import { spawn, spawnSync } from 'node:child_process'
import { once } from 'node:events'
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import test from 'node:test'
import { setTimeout as delay } from 'node:timers/promises'
import { generateNextSprite } from '../dist/index.js'
const NEXT_BIN = path.resolve('node_modules/next/dist/bin/next')
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content)
}
function createNextFixture() {
const rootDir = fs.mkdtempSync(path.join(path.resolve('test'), '.next-fixture-'))
writeFile(
path.join(rootDir, 'package.json'),
JSON.stringify({ name: 'svg-sprites-next-fixture', private: true }, null, 2),
)
writeFile(
path.join(rootDir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
jsx: 'preserve',
module: 'esnext',
moduleResolution: 'bundler',
strict: true,
skipLibCheck: true,
},
}, null, 2),
)
writeFile(
path.join(rootDir, 'next-env.d.ts'),
'/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
)
writeFile(
path.join(rootDir, 'app', 'layout.tsx'),
`export default function Layout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>
}
`,
)
writeFile(
path.join(rootDir, 'app', 'page.tsx'),
`import { AppIcon } from '../sprites/app'
export default function Page() {
return <main><AppIcon icon="check" aria-label="App icon" style={{ '--icon-color-1': '#008000' }} /></main>
}
`,
)
writeFile(
path.join(rootDir, 'pages', 'legacy.tsx'),
`import { PagesIcon } from '../sprites/pages'
export default function LegacyPage() {
return <main><PagesIcon icon="check" aria-label="Pages icon" style={{ '--icon-color-1': '#008000' }} /></main>
}
export function getServerSideProps() {
return { props: {} }
}
`,
)
for (const name of ['app', 'pages']) {
const spriteRoot = path.join(rootDir, 'sprites', name)
writeFile(
path.join(spriteRoot, 'svg-sprite.config.ts'),
`export default { name: '${name}', generatedNotice: false }\n`,
)
writeFile(
path.join(spriteRoot, 'icons', 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
}
return rootDir
}
function runNext(rootDir, args) {
const result = spawnSync(process.execPath, [NEXT_BIN, ...args], {
cwd: rootDir,
encoding: 'utf-8',
env: {
...process.env,
NEXT_TELEMETRY_DISABLED: '1',
},
maxBuffer: 10 * 1024 * 1024,
timeout: 120_000,
})
assert.equal(
result.status,
0,
[result.stdout, result.stderr, result.error?.message].filter(Boolean).join('\n'),
)
}
async function getFreePort() {
const server = net.createServer()
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
assert.ok(address && typeof address === 'object')
const { port } = address
server.close()
await once(server, 'close')
return port
}
async function startNext(rootDir) {
const port = await getFreePort()
const child = spawn(process.execPath, [NEXT_BIN, 'start', '--hostname', '127.0.0.1', '--port', String(port)], {
cwd: rootDir,
env: {
...process.env,
NEXT_TELEMETRY_DISABLED: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
child.stdout.on('data', (chunk) => { output += chunk })
child.stderr.on('data', (chunk) => { output += chunk })
const origin = `http://127.0.0.1:${port}`
for (let attempt = 0; attempt < 100; attempt++) {
if (child.exitCode !== null) {
throw new Error(`Next.js exited before startup.\n${output}`)
}
try {
const response = await fetch(origin)
if (response.ok) return { child, origin }
} catch {
// Server is still starting.
}
await delay(100)
}
child.kill('SIGKILL')
throw new Error(`Next.js did not start in time.\n${output}`)
}
async function stopNext(child) {
if (child.exitCode !== null) return
child.kill('SIGTERM')
await Promise.race([
once(child, 'exit'),
delay(5_000).then(() => child.kill('SIGKILL')),
])
}
async function assertRenderedSprite(origin, route) {
const response = await fetch(`${origin}${route}`)
assert.equal(response.status, 200)
const html = await response.text()
const href = html.match(/href="([^"]+\.svg)#check"/)?.[1]
assert.ok(href, `Sprite href not found in ${route}: ${html}`)
assert.doesNotMatch(href, /^(?:data|file|blob):/)
const spriteResponse = await fetch(new URL(href, origin))
assert.equal(spriteResponse.status, 200)
assert.match(spriteResponse.headers.get('content-type') ?? '', /image\/svg\+xml/)
assert.match(await spriteResponse.text(), /id="check"/)
}
test('Next 16.2 renders App and Pages sprites with Turbopack and Webpack', { timeout: 300_000 }, async () => {
const rootDir = createNextFixture()
try {
for (const bundler of ['turbopack', 'webpack']) {
await generateNextSprite(path.join(rootDir, 'sprites', 'app'), {
router: 'app',
bundler,
})
await generateNextSprite(path.join(rootDir, 'sprites', 'pages'), {
router: 'pages',
bundler,
})
runNext(rootDir, ['build', `--${bundler}`])
const { child, origin } = await startNext(rootDir)
try {
await assertRenderedSprite(origin, '/')
await assertRenderedSprite(origin, '/legacy')
} finally {
await stopNext(child)
}
}
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})

28
test/public-api.test.mjs Normal file
View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
generateNextSprite,
generateReactSprite,
} from '../dist/index.js'
test('generateReactSprite rejects unsupported targets', () => {
assert.throws(
() => generateReactSprite('.', 'unknown'),
/Unsupported React asset target: unknown/,
)
})
test('generateNextSprite rejects unsupported routers', async () => {
await assert.rejects(
generateNextSprite('.', { router: 'unknown', bundler: 'webpack' }),
/Unsupported Next\.js router: unknown/,
)
})
test('generateNextSprite rejects unsupported bundlers', async () => {
await assert.rejects(
generateNextSprite('.', { router: 'app', bundler: 'unknown' }),
/Unsupported Next\.js bundler: unknown/,
)
})

View File

@@ -1,40 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { generate } from '../dist/index.js'
test('generates a React directory entry-point', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-entry-'))
const inputDir = path.join(root, 'icons')
const outputDir = path.join(root, 'public', 'sprites')
const reactDir = path.join(root, 'src', 'shared', 'ui', 'svg-sprite')
fs.mkdirSync(inputDir, { recursive: true })
fs.writeFileSync(
path.join(inputDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
await generate({
output: outputDir,
publicPath: '/sprites',
preview: false,
react: reactDir,
sprites: [
{
name: 'icons',
input: inputDir,
},
],
})
const indexPath = path.join(reactDir, 'index.ts')
const index = fs.readFileSync(indexPath, 'utf-8')
assert.ok(fs.existsSync(indexPath))
assert.match(index, /export \{ SvgSprite \} from '\.\/svg-sprite'/)
assert.match(index, /SvgSpriteProps/)
})

590
test/react-mode.test.mjs Normal file
View File

@@ -0,0 +1,590 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import ts from 'typescript'
import { generateNextSprite, generateReactSprite } from '../dist/index.js'
const GENERATED_FILES = [
'.svg-sprites.manifest.json',
'react-component.tsx',
'sprite.svg',
'styles.module.css',
'types.ts',
]
function createReactFixture() {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-'))
const rootDir = path.join(temporaryDir, 'src', 'widgets', 'file-manager', 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { description: 'File manager icons' }\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path fill="none" stroke="#123456" d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 2h6l2 2h6v10H1z" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder open.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 3h14v11H1z" /></svg>',
)
return { rootDir, iconsDir }
}
test('generates an isolated React sprite module', async () => {
const { rootDir } = createReactFixture()
const result = await generateReactSprite(rootDir, 'vite')
const generatedDir = path.join(rootDir, 'generated')
assert.equal(result.name, 'file-manager')
assert.equal(result.iconCount, 3)
assert.equal(result.target, 'vite')
assert.equal(result.manifestPath, path.join(rootDir, 'manifest.ts'))
assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES)
const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf-8')
assert.match(gitignore, /@generated by @gromlab\/svg-sprites/)
assert.match(gitignore, /^\/generated\/$/m)
assert.match(gitignore, /^\/index\.ts$/m)
assert.match(gitignore, /^\/manifest\.ts$/m)
const sprite = fs.readFileSync(path.join(generatedDir, 'sprite.svg'), 'utf-8')
assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(sprite, /Генератор: @gromlab\/svg-sprites/)
assert.match(sprite, /<svg[^>]+id="check"/)
assert.match(sprite, /<svg[^>]+id="folder"/)
assert.match(sprite, /<svg[^>]*>\n <style>:root>svg/)
assert.match(sprite, /\n <path[^>]+\/>\n <\/svg>/)
assert.doesNotMatch(sprite, /<symbol/)
assert.ok(sprite.endsWith('\n'))
const unsafeId = sprite.match(/<svg[^>]+id="(icon-[a-f0-9]{16})"/)?.[1]
assert.ok(unsafeId)
const component = fs.readFileSync(
path.join(generatedDir, 'react-component.tsx'),
'utf-8',
)
assert.match(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(component, /export const FileManagerIcon/)
assert.match(component, /export type FileManagerIconStyle = CSSProperties/)
assert.match(component, /Record<`--icon-color-\$\{number\}`, string \| number>/)
assert.match(component, /Omit<SVGAttributes<SVGSVGElement>, 'style'>/)
assert.match(component, /import spriteUrl from '\.\/sprite\.svg\?no-inline'/)
assert.doesNotMatch(component, /SvgSpriteProvider/)
assert.match(component, /iconIds\[icon\]/)
const transpiled = ts.transpileModule(component, {
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
reportDiagnostics: true,
})
assert.deepEqual(transpiled.diagnostics, [])
const metadata = fs.readFileSync(
path.join(generatedDir, 'types.ts'),
'utf-8',
)
assert.match(metadata, /export type FileManagerIconName/)
assert.match(metadata, /File manager icons/)
assert.match(metadata, /"check"/)
assert.match(metadata, /"folder"/)
assert.ok(component.includes(`"folder open": "${unsafeId}"`))
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(spriteManifest, /import spriteUrl from '\.\/generated\/sprite\.svg\?no-inline'/)
assert.match(spriteManifest, /export const spriteManifest/)
assert.match(spriteManifest, /componentName: "FileManagerIcon"/)
assert.match(spriteManifest, /target: "vite"/)
assert.match(spriteManifest, /format: "stack"/)
assert.match(spriteManifest, /iconCount: 3/)
assert.match(spriteManifest, /name: "check"/)
assert.match(spriteManifest, /viewBox: "0 0 16 16"/)
assert.match(spriteManifest, /variable: "--icon-color-1"/)
assert.match(spriteManifest, /fallback: "currentColor"/)
assert.ok(spriteManifest.includes(`id: "${unsafeId}"`))
const transpiledManifest = ts.transpileModule(spriteManifest, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
reportDiagnostics: true,
})
assert.deepEqual(transpiledManifest.diagnostics, [])
const rootIndex = fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8')
assert.match(rootIndex, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(rootIndex, /export \{ FileManagerIcon \} from '\.\/generated\/react-component'/)
assert.match(rootIndex, /FileManagerIconProps, FileManagerIconStyle/)
assert.match(rootIndex, /export \{ fileManagerIconNames \} from '\.\/generated\/types'/)
const manifest = JSON.parse(
fs.readFileSync(path.join(generatedDir, '.svg-sprites.manifest.json'), 'utf-8'),
)
assert.match(manifest.warning, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
})
test('supports Webpack target, explicit name and custom icons directory', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-config-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'assets')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { name: 'documents', inputFolder: './assets', generatedNotice: false }\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'file.svg'),
'<svg viewBox="0 0 16 16"><path d="M2 1h8l4 4v10H2z" /></svg>',
)
const result = await generateReactSprite(rootDir, 'webpack')
const componentPath = path.join(
rootDir,
'generated',
'react-component.tsx',
)
assert.equal(result.name, 'documents')
assert.equal(result.iconCount, 1)
assert.equal(result.target, 'webpack')
const component = fs.readFileSync(componentPath, 'utf-8')
assert.match(component, /@generated by @gromlab\/svg-sprites/)
assert.doesNotMatch(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(component, /DocumentsIcon/)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.doesNotMatch(component, /SvgSpriteProvider/)
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(spriteManifest, /@generated by @gromlab\/svg-sprites/)
assert.match(spriteManifest, /new URL\('\.\/generated\/sprite\.svg', import\.meta\.url\)\.href/)
assert.match(spriteManifest, /target: "webpack"/)
assert.match(spriteManifest, /format: "stack"/)
assert.match(spriteManifest, /componentName: "DocumentsIcon"/)
const manifest = JSON.parse(
fs.readFileSync(path.join(rootDir, 'generated', '.svg-sprites.manifest.json'), 'utf-8'),
)
assert.equal(manifest.warning, undefined)
})
test('merges inputFolder and inputFiles and deduplicates identical paths', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-inputs-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
const sharedDir = path.join(temporaryDir, 'shared')
const localIcon = path.join(iconsDir, 'local.svg')
const sharedIcon = path.join(sharedDir, 'shared.svg')
fs.mkdirSync(iconsDir, { recursive: true })
fs.mkdirSync(sharedDir, { recursive: true })
fs.writeFileSync(localIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
name: 'mixed-inputs',
description: 'Local and shared icons',
inputFiles: [
'./icons/local.svg',
${JSON.stringify(path.relative(rootDir, sharedIcon))},
],
}\n`,
)
const result = await generateReactSprite(rootDir, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
assert.equal(result.name, 'mixed-inputs')
assert.equal(result.iconCount, 2)
assert.match(sprite, /id="local"/)
assert.match(sprite, /id="shared"/)
assert.match(manifest, /description: "Local and shared icons"/)
})
test('supports inputFiles without the default icons directory', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-files-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const sharedIcon = path.join(temporaryDir, 'shared', 'only.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(sharedIcon), { recursive: true })
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }\n`,
)
const result = await generateReactSprite(rootDir, 'webpack')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
assert.equal(result.iconCount, 1)
assert.match(sprite, /id="only"/)
})
test('rejects different input files with the same icon name', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-names-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const firstIcon = path.join(temporaryDir, 'first', 'shared.svg')
const secondIcon = path.join(temporaryDir, 'second', 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(firstIcon), { recursive: true })
fs.mkdirSync(path.dirname(secondIcon), { recursive: true })
fs.writeFileSync(firstIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(secondIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
inputFiles: [
${JSON.stringify(path.relative(rootDir, firstIcon))},
${JSON.stringify(path.relative(rootDir, secondIcon))},
],
}\n`,
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/produce the same SVG id "shared"/,
)
})
test('rejects an explicitly configured missing inputFolder', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-folder-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const sharedIcon = path.join(temporaryDir, 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
inputFolder: './missing',
inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}],
}\n`,
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Input directory does not exist/,
)
})
test('applies React transform options', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-transform-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
transform: {
removeSize: false,
replaceColors: false,
addTransition: false,
},
}\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'plain.svg'),
'<svg width="24" height="24" viewBox="0 0 24 24"><path fill="#123456" d="M0 0h24v24H0z" /></svg>',
)
const result = await generateReactSprite(rootDir, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
const styles = fs.readFileSync(path.join(result.generatedDir, 'styles.module.css'), 'utf-8')
assert.match(sprite, /width="24"/)
assert.match(sprite, /height="24"/)
assert.match(sprite, /fill="#(?:123456|123)"/)
assert.doesNotMatch(sprite, /--icon-color-/)
assert.doesNotMatch(sprite, /transition:/)
assert.doesNotMatch(styles, /transition-/)
})
test('generates App and Pages Router modules for Webpack and Turbopack', async () => {
const targets = [
{ router: 'app', bundler: 'turbopack' },
{ router: 'app', bundler: 'webpack' },
{ router: 'pages', bundler: 'turbopack' },
{ router: 'pages', bundler: 'webpack' },
]
for (const options of targets) {
const { rootDir } = createReactFixture()
const result = await generateNextSprite(rootDir, options)
const component = fs.readFileSync(
path.join(result.generatedDir, 'react-component.tsx'),
'utf-8',
)
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
const target = `next@${options.router}/${options.bundler}`
assert.equal(result.target, target)
assert.equal(result.router, options.router)
assert.equal(result.bundler, options.bundler)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.doesNotMatch(component, /['"]use client['"]/)
assert.match(manifest, new RegExp(`target: ${JSON.stringify(target)}`))
}
})
test('regeneration removes only files listed in the manifest', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const generatedDir = path.join(rootDir, 'generated')
const manifestPath = path.join(generatedDir, '.svg-sprites.manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const obsoleteRelativePath = 'generated/obsolete.generated.ts'
manifest.files.push('generated\\obsolete.generated.ts')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
fs.writeFileSync(
path.join(rootDir, obsoleteRelativePath),
'/** @generated by @gromlab/svg-sprites. Do not edit. */\n',
)
fs.writeFileSync(path.join(generatedDir, 'notes.md'), 'Keep me\n')
fs.writeFileSync(path.join(rootDir, 'README.md'), 'Keep me too\n')
await generateReactSprite(rootDir, 'vite')
assert.equal(fs.existsSync(path.join(rootDir, obsoleteRelativePath)), false)
assert.equal(fs.readFileSync(path.join(generatedDir, 'notes.md'), 'utf-8'), 'Keep me\n')
assert.equal(fs.readFileSync(path.join(rootDir, 'README.md'), 'utf-8'), 'Keep me too\n')
})
test('does not overwrite a user index.ts without a manifest', async () => {
const { rootDir } = createReactFixture()
const userIndex = 'export const userCode = true\n'
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Refusing to overwrite a user file/,
)
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
})
test('does not overwrite a generated file replaced with user content', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const userIndex = 'export const userCode = true\n'
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Refusing to overwrite a user file/,
)
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
})
test('rejects paths outside the generated area from the manifest', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const manifestPath = path.join(rootDir, 'generated', '.svg-sprites.manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
manifest.files.push('svg-sprite.config.ts')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Invalid generated file path/,
)
assert.equal(fs.existsSync(path.join(rootDir, 'svg-sprite.config.ts')), true)
})
test('rejects symbolic links in generated paths', async () => {
const { rootDir } = createReactFixture()
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-'))
fs.symlinkSync(outsideDir, path.join(rootDir, 'generated'), 'dir')
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Symbolic links are not allowed/,
)
assert.deepEqual(fs.readdirSync(outsideDir), [])
})
test('rejects colliding SVG shape IDs', async () => {
const { rootDir, iconsDir } = createReactFixture()
const collidingId = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}`
fs.writeFileSync(
path.join(iconsDir, `${collidingId}.svg`),
'<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>',
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/produce the same SVG id/,
)
})
test('CLI requires an explicit mode', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(process.execPath, [cliPath], { encoding: 'utf-8' })
assert.equal(result.status, 1)
assert.match(result.stderr, /Missing required argument: --mode/)
})
test('CLI does not expose the future standalone mode', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'standalone', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unknown mode: standalone/)
})
test('CLI requires a target for React mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /React mode requires a target/)
})
test('CLI rejects unsupported React target', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@next', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unsupported React target: next/)
})
test('CLI requires a complete Next.js target', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'next@app', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unsupported Next\.js target: next@app/)
})
test('CLI runs the React Vite mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@vite', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const component = fs.readFileSync(
path.join(rootDir, 'generated', 'react-component.tsx'),
'utf-8',
)
assert.match(component, /sprite\.svg\?no-inline/)
assert.match(result.stdout, /✓ file-manager · 3 icons · react@vite/)
assert.match(result.stdout, /→ .*generated/)
assert.doesNotMatch(result.stdout, /Generating sprite|\[stack\]/)
})
test('CLI runs the React Webpack mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@webpack', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const component = fs.readFileSync(
path.join(rootDir, 'generated', 'react-component.tsx'),
'utf-8',
)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
})
test('CLI runs all Next.js modes', () => {
const modes = [
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
]
const cliPath = path.resolve('dist/cli.js')
for (const mode of modes) {
const { rootDir } = createReactFixture()
const result = spawnSync(
process.execPath,
[cliPath, '--mode', mode, rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const manifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(manifest, new RegExp(`target: ${JSON.stringify(mode)}`))
}
})
test('CLI runs the legacy mode relative to its target directory', () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-legacy-'))
const iconsDir = path.join(rootDir, 'icons')
const cliPath = path.resolve('dist/cli.js')
fs.mkdirSync(iconsDir)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(rootDir, 'svg-sprites.config.ts'),
[
'export default {',
" output: 'sprites',",
' preview: false,',
" sprites: [{ name: 'icons', input: 'icons', format: 'symbol' }],",
'}',
'',
].join('\n'),
)
const result = spawnSync(
process.execPath,
[cliPath, '--mode=legacy', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
assert.equal(fs.existsSync(path.join(rootDir, 'sprites', 'icons.sprite.svg')), true)
})

View File

@@ -0,0 +1,212 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createElement, StrictMode } from 'react'
import { JSDOM } from 'jsdom'
import { SpriteViewer } from '../dist/react.js'
const manifest = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: 'controls',
description: 'Control icons',
componentName: 'ControlsIcon',
target: 'vite',
format: 'stack',
iconCount: 1,
spriteUrl: '/assets/controls.svg',
icons: [
{
name: 'check',
id: 'check',
viewBox: '0 0 16 16',
colors: [{ variable: '--icon-color-1', fallback: 'currentColor' }],
},
],
}
test('SpriteViewer opens an icon dialog with code tabs and copy action', async (context) => {
const dom = new JSDOM('<div id="root"></div>', { url: 'https://example.test/' })
const originals = new Map()
const mediaListeners = new Set()
let prefersDark = false
const darkModeMedia = {
get matches() { return prefersDark },
media: '(prefers-color-scheme: dark)',
onchange: null,
addEventListener: (event, listener) => { if (event === 'change') mediaListeners.add(listener) },
removeEventListener: (event, listener) => { if (event === 'change') mediaListeners.delete(listener) },
}
const matchMedia = () => darkModeMedia
dom.window.matchMedia = matchMedia
const globals = {
document: dom.window.document,
window: dom.window,
self: dom.window,
navigator: dom.window.navigator,
Node: dom.window.Node,
Event: dom.window.Event,
HTMLElement: dom.window.HTMLElement,
HTMLDialogElement: dom.window.HTMLDialogElement,
MouseEvent: dom.window.MouseEvent,
matchMedia,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
IS_REACT_ACT_ENVIRONMENT: true,
}
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value })
}
const clipboardWrites = []
Object.defineProperty(dom.window.navigator, 'clipboard', {
configurable: true,
value: { writeText: async (value) => { clipboardWrites.push(value) } },
})
dom.window.HTMLDialogElement.prototype.showModal = function showModal() { this.open = true }
dom.window.HTMLDialogElement.prototype.close = function close() { this.open = false }
const reactModule = await import('react')
const { act: domAct } = await import('react-dom/test-utils')
const act = reactModule.act ?? domAct
const { createRoot } = await import('react-dom/client')
const container = dom.window.document.querySelector('#root')
const root = createRoot(container)
context.after(async () => {
await act(async () => root.unmount())
dom.window.close()
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else delete globalThis[name]
}
})
await act(async () => root.render(createElement(SpriteViewer, { sources: [manifest] })))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, undefined)
const card = container.querySelector('[data-icon-name="check"]')
assert.ok(card)
await act(async () => card.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const dialog = container.querySelector('dialog')
assert.ok(dialog)
assert.equal(dialog.open, true)
assert.match(dialog.textContent, /check/)
assert.match(dialog.textContent, /16 × 16/)
assert.match(dialog.textContent, /ControlsIcon/)
const reactTab = [...dialog.querySelectorAll('[role="tab"]')]
.find((element) => element.textContent === 'React')
await act(async () => reactTab.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowRight',
})))
assert.equal(dialog.querySelector('[role="tab"][aria-selected="true"]').textContent, 'SVG')
const colorSwatch = dialog.querySelector('[aria-label^="Изменить цвет --icon-color-1"]')
assert.ok(colorSwatch)
assert.equal(dialog.querySelector('.gromlab-sprite-viewer__hex-input'), null)
await act(async () => {
prefersDark = true
for (const listener of mediaListeners) listener({ matches: true, media: darkModeMedia.media })
})
assert.equal(colorSwatch.style.backgroundColor, 'rgb(229, 229, 229)')
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.ok(dialog.querySelector('.react-colorful'))
const colorRow = colorSwatch.closest('.gromlab-sprite-viewer__color-row')
await act(async () => colorRow.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true })))
assert.equal(dialog.querySelector('.react-colorful'), null)
await act(async () => colorRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(dialog.querySelector('.react-colorful'), null)
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const colorPopover = dialog.querySelector('.gromlab-sprite-viewer__color-popover')
await act(async () => colorPopover.querySelector('.react-colorful').dispatchEvent(
new dom.window.Event('pointerdown', { bubbles: true }),
))
assert.ok(dialog.querySelector('.react-colorful'))
await act(async () => colorPopover.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'Escape',
})))
assert.equal(dialog.querySelector('.react-colorful'), null)
assert.ok(container.querySelector('dialog'))
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const colorInput = dialog.querySelector('.gromlab-sprite-viewer__hex-input')
assert.ok(colorInput)
const setInputValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set
setInputValue.call(colorInput, '#ff0000')
await act(async () => colorInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })))
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /--icon-color-1/)
assert.doesNotMatch(dialog.querySelector('[role="tabpanel"]').textContent, /React\.CSSProperties/)
await act(async () => {
prefersDark = false
for (const listener of mediaListeners) listener({ matches: false, media: darkModeMedia.media })
})
assert.equal(colorSwatch.style.backgroundColor, 'rgb(255, 0, 0)')
const cssTab = [...dialog.querySelectorAll('[role="tab"]')]
.find((element) => element.textContent === 'CSS')
assert.ok(cssTab)
await act(async () => cssTab.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /mask:/)
const copyButton = [...dialog.querySelectorAll('button')]
.find((element) => element.textContent === 'Копировать')
assert.ok(copyButton)
await act(async () => copyButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(clipboardWrites.length, 1)
assert.match(clipboardWrites[0], /background-color/)
await act(async () => dialog.dispatchEvent(new dom.window.Event('cancel', { cancelable: true })))
assert.equal(container.querySelector('dialog'), null)
const themeButton = container.querySelector('[aria-label="Переключить тему"]')
await act(async () => themeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'dark')
await act(async () => root.render(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'light',
})))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
assert.equal(container.querySelector('[aria-label="Переключить тему"]'), null)
const controlledChanges = []
await act(async () => root.render(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'light',
onColorThemeChange: (theme) => controlledChanges.push(theme),
})))
const controlledThemeButton = container.querySelector('[aria-label="Переключить тему"]')
await act(async () => controlledThemeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.deepEqual(controlledChanges, ['dark'])
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
await act(async () => root.render(createElement(SpriteViewer, {
sources: [{ ...manifest, format: 'symbol' }],
})))
const symbolCard = container.querySelector('[data-icon-name="check"]')
await act(async () => symbolCard.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.deepEqual(
[...container.querySelectorAll('[role="tab"]')].map((element) => element.textContent),
['React', 'SVG'],
)
let loaderCalls = 0
const loader = async () => {
loaderCalls++
return manifest
}
await act(async () => root.render(createElement(
StrictMode,
null,
createElement(SpriteViewer, { sources: [loader] }),
)))
assert.equal(loaderCalls, 1)
assert.match(container.textContent, /controls/)
})

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { SpriteViewer } from '../dist/react.js'
const manifest = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: 'controls',
description: 'Control icons',
componentName: 'ControlsIcon',
target: 'vite',
format: 'symbol',
iconCount: 1,
spriteUrl: '/assets/controls.svg',
icons: [
{
name: 'check',
id: 'check',
viewBox: '0 0 16 16',
colors: [{ variable: '--icon-color-1', fallback: 'currentColor' }],
},
],
}
test('React entry is a Next.js client boundary', () => {
const reactEntry = fs.readFileSync(path.resolve('dist/react.js'), 'utf-8')
const coreEntry = fs.readFileSync(path.resolve('dist/index.js'), 'utf-8')
assert.match(reactEntry, /^['"]use client['"];?/)
assert.doesNotMatch(coreEntry, /from ["']react(?:\/jsx-runtime)?["']/)
})
test('SpriteViewer renders direct sprite manifests', () => {
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
sources: [manifest],
title: 'Icon catalog',
}))
assert.match(markup, /Icon catalog/)
assert.match(markup, /controls/)
assert.match(markup, /Control icons/)
assert.match(markup, /check/)
assert.match(markup, /\/assets\/controls\.svg#check/)
assert.match(markup, /1 спрайт · 1 иконка/)
assert.match(markup, /gromlab-sprite-viewer__icon-wrap/)
assert.match(markup, /Открыть check/)
})
test('SpriteViewer accepts a loader record from import.meta.glob', () => {
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
sources: {
'/src/controls/manifest.ts': async () => ({ default: manifest }),
},
}))
assert.match(markup, /Загрузка спрайтов/)
})
test('SpriteViewer supports an externally controlled color theme', () => {
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'dark',
}))
assert.match(markup, /^<section[^>]*data-theme="dark"/)
assert.doesNotMatch(markup, /<button[^>]*aria-label="Переключить тему"/)
})

View File

@@ -1,9 +0,0 @@
/** @generated — this file is auto-generated, do not edit manually. */
export { SvgSprite } from './svg-sprite'
export type {
SvgSpriteProps,
SpriteName,
SpriteMap,
IconName,
DefaultSprite,
} from './svg-sprite'

View File

@@ -1,19 +0,0 @@
/* @generated — this file is auto-generated, do not edit manually. */
.root {
transition-property: fill, stroke, color;
transition-duration: 0.3s;
transition-timing-function: ease;
}
.wrap {
display: inline-flex;
}
.wrap svg {
width: 100%;
height: 100%;
transition-property: fill, stroke, color;
transition-duration: 0.3s;
transition-timing-function: ease;
}

View File

@@ -1,96 +0,0 @@
/**
* SVG-спрайты: типы и React-компонент.
* @generated — this file is auto-generated, do not edit manually.
*/
import type { SVGAttributes, HTMLAttributes } from 'react'
import styles from './svg-sprite.module.css'
/** Имена иконок спрайта «icons». */
export type IconsIconName =
| 'arrow-left'
| 'arrow-right'
| 'check'
| 'discount-shape'
| 'flash'
| 'heart-tick'
| 'heart-tick (1-я копия)'
/** Имена иконок спрайта «logos». */
export type LogosIconName =
| 'github'
| 'twitter'
| 'youtube'
/** Маппинг имени спрайта на тип его иконок. */
export type SpriteMap = {
icons: IconsIconName
logos: LogosIconName
}
/** Имя спрайта. */
export type SpriteName = keyof SpriteMap
/** Спрайт по умолчанию. */
export type DefaultSprite = 'icons'
/** Имя иконки для конкретного спрайта. */
export type IconName<S extends SpriteName = SpriteName> = SpriteMap[S]
const PUBLIC_PATH = ''
const DEFAULT_SPRITE: SpriteName = 'icons'
const SPRITE_FILES: Record<SpriteName, string> = {
icons: 'icons.sprite.svg',
logos: 'logos.sprite.svg',
}
type IconBaseProps<S extends SpriteName> = {
/** Имя иконки. */
icon: IconName<S>
/** Имя спрайта. По умолчанию: первый из конфига. */
sprite?: S
}
type IconSvgProps<S extends SpriteName> = IconBaseProps<S> & {
wrapped?: false
} & SVGAttributes<SVGSVGElement>
type IconWrappedProps<S extends SpriteName> = IconBaseProps<S> & {
wrapped: true
} & HTMLAttributes<HTMLSpanElement>
export type SvgSpriteProps<S extends SpriteName = DefaultSprite> =
| IconSvgProps<S>
| IconWrappedProps<S>
/**
* Иконка из SVG-спрайта.
*
* Используется для:
* - отображения иконки через `<use href="...">`
* - обёртки в `<span>` через проп `wrapped`
*
* Спрайт по умолчанию: «icons».
*/
export const SvgSprite = <S extends SpriteName = DefaultSprite>(props: SvgSpriteProps<S>) => {
const { icon, sprite = DEFAULT_SPRITE as S, wrapped, className, ...rest } = props
const href = `${PUBLIC_PATH}/${SPRITE_FILES[sprite]}#${icon}`
if (wrapped) {
const { ...htmlAttr } = rest as HTMLAttributes<HTMLSpanElement>
return (
<span {...htmlAttr} className={[styles.wrap, className].filter(Boolean).join(' ')}>
<svg>
<use href={href} />
</svg>
</span>
)
}
const { ...svgAttr } = rest as SVGAttributes<SVGSVGElement>
return (
<svg {...svgAttr} className={[styles.root, className].filter(Boolean).join(' ')}>
<use href={href} />
</svg>
)
}