mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
244 lines
12 KiB
JavaScript
244 lines
12 KiB
JavaScript
|
|
import assert from 'node:assert/strict'
|
||
|
|
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 { generateNextSprite, generateReactSprite } from '../dist/index.js'
|
||
|
|
|
||
|
|
const GENERATED_FILES = [
|
||
|
|
'icon-data.d.ts',
|
||
|
|
'icon-data.js',
|
||
|
|
'index.d.ts',
|
||
|
|
'index.js',
|
||
|
|
'react',
|
||
|
|
'sprite.svg',
|
||
|
|
'state.json',
|
||
|
|
'svg-sprite.manifest.d.ts',
|
||
|
|
'svg-sprite.manifest.js',
|
||
|
|
]
|
||
|
|
|
||
|
|
function createReactFixture(config = {}) {
|
||
|
|
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')
|
||
|
|
const configPath = path.join(rootDir, 'svg-sprite.config.ts')
|
||
|
|
|
||
|
|
fs.mkdirSync(iconsDir, { recursive: true })
|
||
|
|
fs.writeFileSync(configPath, `export default ${JSON.stringify({
|
||
|
|
name: 'file-manager',
|
||
|
|
description: 'File manager icons',
|
||
|
|
...config,
|
||
|
|
}, null, 2)}\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, configPath }
|
||
|
|
}
|
||
|
|
|
||
|
|
test('generates the current isolated React Vite contract', async () => {
|
||
|
|
const { rootDir, configPath } = createReactFixture()
|
||
|
|
const result = await generateReactSprite(configPath, 'vite')
|
||
|
|
const generatedDir = path.join(rootDir, '.svg-sprite')
|
||
|
|
|
||
|
|
assert.equal(result.name, 'file-manager')
|
||
|
|
assert.equal(result.iconCount, 3)
|
||
|
|
assert.equal(result.target, 'vite')
|
||
|
|
assert.equal(result.manifestPath, path.join(generatedDir, 'svg-sprite.manifest.js'))
|
||
|
|
assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES)
|
||
|
|
|
||
|
|
const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf8')
|
||
|
|
assert.equal(gitignore, '# @generated by @gromlab/svg-sprites. Do not edit.\n/.svg-sprite/\n')
|
||
|
|
|
||
|
|
const sprite = fs.readFileSync(result.spritePath, 'utf8')
|
||
|
|
assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
|
||
|
|
assert.match(sprite, /<svg[^>]+id="check"/)
|
||
|
|
assert.match(sprite, /<svg[^>]+id="folder"/)
|
||
|
|
assert.doesNotMatch(sprite, /<symbol/)
|
||
|
|
const unsafeId = sprite.match(/<svg[^>]+id="(icon-[a-f0-9]{16})"/)?.[1]
|
||
|
|
assert.ok(unsafeId)
|
||
|
|
|
||
|
|
const component = fs.readFileSync(path.join(generatedDir, 'react', 'react-component.js'), 'utf8')
|
||
|
|
const componentTypes = fs.readFileSync(path.join(generatedDir, 'react', 'react-component.d.ts'), 'utf8')
|
||
|
|
const iconData = fs.readFileSync(path.join(generatedDir, 'icon-data.js'), 'utf8')
|
||
|
|
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
|
||
|
|
|
||
|
|
assert.match(component, /export const FileManagerIcon/)
|
||
|
|
assert.match(component, /sprite\.svg\?no-inline/)
|
||
|
|
assert.match(componentTypes, /FileManagerIconStyle/)
|
||
|
|
assert.match(componentTypes, /--icon-color-/)
|
||
|
|
assert.ok(iconData.includes(`"folder open": "${unsafeId}"`))
|
||
|
|
assert.match(manifest, /"componentName": "FileManagerIcon"/)
|
||
|
|
assert.match(manifest, /"mode": "react@vite"/)
|
||
|
|
assert.match(manifest, /"target": "vite"/)
|
||
|
|
assert.match(manifest, /"iconCount": 3/)
|
||
|
|
|
||
|
|
const state = JSON.parse(fs.readFileSync(path.join(generatedDir, 'state.json'), 'utf8'))
|
||
|
|
assert.deepEqual(state.owner, { mode: 'react@vite', contractVersion: 5 })
|
||
|
|
assert.equal(state.files.includes('.svg-sprite/react/react-component.js'), true)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('supports Webpack target and a custom icons directory', async () => {
|
||
|
|
const { rootDir, configPath } = createReactFixture({
|
||
|
|
name: 'documents',
|
||
|
|
inputFolder: './assets',
|
||
|
|
generatedNotice: false,
|
||
|
|
})
|
||
|
|
fs.renameSync(path.join(rootDir, 'icons'), path.join(rootDir, 'assets'))
|
||
|
|
|
||
|
|
const result = await generateReactSprite(configPath, 'webpack')
|
||
|
|
const component = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.js'), 'utf8')
|
||
|
|
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
|
||
|
|
|
||
|
|
assert.equal(result.name, 'documents')
|
||
|
|
assert.equal(result.iconCount, 3)
|
||
|
|
assert.equal(result.target, 'webpack')
|
||
|
|
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.match(manifest, /"target": "webpack"/)
|
||
|
|
})
|
||
|
|
|
||
|
|
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 sharedIcon = path.join(temporaryDir, 'shared.svg')
|
||
|
|
fs.mkdirSync(iconsDir, { recursive: true })
|
||
|
|
fs.writeFileSync(path.join(iconsDir, 'local.svg'), '<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>')
|
||
|
|
const configPath = path.join(rootDir, 'config.ts')
|
||
|
|
fs.writeFileSync(configPath, `export default { name: 'mixed-inputs', inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}, ${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`)
|
||
|
|
|
||
|
|
const result = await generateReactSprite(configPath, 'vite')
|
||
|
|
const sprite = fs.readFileSync(result.spritePath, 'utf8')
|
||
|
|
assert.equal(result.name, 'mixed-inputs')
|
||
|
|
assert.equal(result.iconCount, 2)
|
||
|
|
assert.match(sprite, /id="local"/)
|
||
|
|
assert.match(sprite, /id="shared"/)
|
||
|
|
})
|
||
|
|
|
||
|
|
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>')
|
||
|
|
const configPath = path.join(rootDir, 'config.ts')
|
||
|
|
fs.writeFileSync(configPath, `export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`)
|
||
|
|
|
||
|
|
const result = await generateReactSprite(configPath, 'webpack')
|
||
|
|
assert.equal(result.iconCount, 1)
|
||
|
|
assert.match(fs.readFileSync(result.spritePath, 'utf8'), /id="only"/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('rejects duplicate names and explicitly missing input folders', async () => {
|
||
|
|
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-input-errors-'))
|
||
|
|
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||
|
|
const first = path.join(temporaryDir, 'one', 'shared.svg')
|
||
|
|
const second = path.join(temporaryDir, 'two', 'shared.svg')
|
||
|
|
fs.mkdirSync(rootDir, { recursive: true })
|
||
|
|
fs.mkdirSync(path.dirname(first), { recursive: true })
|
||
|
|
fs.mkdirSync(path.dirname(second), { recursive: true })
|
||
|
|
fs.writeFileSync(first, '<svg viewBox="0 0 16 16"><path /></svg>')
|
||
|
|
fs.writeFileSync(second, '<svg viewBox="0 0 16 16"><path /></svg>')
|
||
|
|
const duplicateConfig = path.join(rootDir, 'duplicate.ts')
|
||
|
|
fs.writeFileSync(duplicateConfig, `export default { inputFiles: [${JSON.stringify(path.relative(rootDir, first))}, ${JSON.stringify(path.relative(rootDir, second))}] }`)
|
||
|
|
await assert.rejects(generateReactSprite(duplicateConfig, 'vite'), /produce the same SVG id "shared"/)
|
||
|
|
|
||
|
|
const missingConfig = path.join(rootDir, 'missing.ts')
|
||
|
|
fs.writeFileSync(missingConfig, `export default { inputFolder: './missing', inputFiles: [${JSON.stringify(path.relative(rootDir, first))}] }`)
|
||
|
|
await assert.rejects(generateReactSprite(missingConfig, 'vite'), /Input directory does not exist/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('applies transform options to SVG and React styles', async () => {
|
||
|
|
const { rootDir, configPath } = createReactFixture({
|
||
|
|
transform: { removeSize: false, replaceColors: false, addTransition: false },
|
||
|
|
})
|
||
|
|
fs.writeFileSync(
|
||
|
|
path.join(rootDir, 'icons', 'plain.svg'),
|
||
|
|
'<svg width="24" height="24" viewBox="0 0 24 24"><path fill="#123456" d="M0 0h24v24H0z" /></svg>',
|
||
|
|
)
|
||
|
|
|
||
|
|
const result = await generateReactSprite(configPath, 'vite')
|
||
|
|
const sprite = fs.readFileSync(result.spritePath, 'utf8')
|
||
|
|
const styles = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.module.css'), 'utf8')
|
||
|
|
assert.match(sprite, /width="24"/)
|
||
|
|
assert.match(sprite, /fill="#(?:123456|123)"/)
|
||
|
|
assert.doesNotMatch(sprite, /--icon-color-/)
|
||
|
|
assert.doesNotMatch(styles, /transition-/)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('generates every Next.js exact mode without a client boundary', 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 { configPath } = createReactFixture()
|
||
|
|
const result = await generateNextSprite(configPath, options)
|
||
|
|
const component = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.js'), 'utf8')
|
||
|
|
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
|
||
|
|
const mode = `next@${options.router}/${options.bundler}`
|
||
|
|
assert.equal(result.target, mode)
|
||
|
|
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(mode)}`))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
test('writer removes only obsolete managed files and preserves user files', async () => {
|
||
|
|
const { rootDir, configPath } = createReactFixture()
|
||
|
|
await generateReactSprite(configPath, 'vite')
|
||
|
|
const statePath = path.join(rootDir, '.svg-sprite', 'state.json')
|
||
|
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'))
|
||
|
|
state.files.push('.svg-sprite/obsolete.js')
|
||
|
|
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`)
|
||
|
|
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'obsolete.js'), '/* @generated by @gromlab/svg-sprites. */\n')
|
||
|
|
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'Keep me\n')
|
||
|
|
fs.writeFileSync(path.join(rootDir, 'index.ts'), 'export const userCode = true\n')
|
||
|
|
|
||
|
|
await generateReactSprite(configPath, 'vite')
|
||
|
|
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'obsolete.js')), false)
|
||
|
|
assert.equal(fs.readFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'utf8'), 'Keep me\n')
|
||
|
|
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf8'), 'export const userCode = true\n')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('writer refuses user content and symbolic links in managed paths', async () => {
|
||
|
|
const first = createReactFixture()
|
||
|
|
fs.mkdirSync(path.join(first.rootDir, '.svg-sprite'))
|
||
|
|
fs.writeFileSync(path.join(first.rootDir, '.svg-sprite', 'index.js'), 'export const userCode = true\n')
|
||
|
|
await assert.rejects(generateReactSprite(first.configPath, 'vite'), /Refusing to overwrite a user file/)
|
||
|
|
|
||
|
|
const second = createReactFixture()
|
||
|
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-'))
|
||
|
|
fs.symlinkSync(outsideDir, path.join(second.rootDir, '.svg-sprite'), 'dir')
|
||
|
|
await assert.rejects(generateReactSprite(second.configPath, 'vite'), /Symbolic links are not allowed/)
|
||
|
|
assert.deepEqual(fs.readdirSync(outsideDir), [])
|
||
|
|
})
|
||
|
|
|
||
|
|
test('rejects colliding SVG shape IDs', async () => {
|
||
|
|
const { iconsDir, configPath } = createReactFixture()
|
||
|
|
const id = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}`
|
||
|
|
fs.writeFileSync(path.join(iconsDir, `${id}.svg`), '<svg viewBox="0 0 16 16"><path /></svg>')
|
||
|
|
await assert.rejects(generateReactSprite(configPath, 'vite'), /produce the same SVG id/)
|
||
|
|
})
|