mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
feat: выпустить версию 1.0.0
- добавлены отдельные режимы генерации для React, Next.js и legacy - добавлены SpriteViewer, типизированные компоненты и безопасный codegen - перенесена сборка AI-скила и обновлена документация - добавлены migration guide, лицензии и проверки публикации
This commit is contained in:
117
test/asset-targets.test.mjs
Normal file
117
test/asset-targets.test.mjs
Normal 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
204
test/next-targets.test.mjs
Normal 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
28
test/public-api.test.mjs
Normal 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/,
|
||||
)
|
||||
})
|
||||
@@ -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
590
test/react-mode.test.mjs
Normal 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)
|
||||
})
|
||||
212
test/react-viewer-dom.test.mjs
Normal file
212
test/react-viewer-dom.test.mjs
Normal 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/)
|
||||
})
|
||||
71
test/react-viewer.test.mjs
Normal file
71
test/react-viewer.test.mjs
Normal 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="Переключить тему"/)
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user