feat: standalone mode

This commit is contained in:
2026-07-14 08:34:45 +03:00
parent 3dd385bfda
commit c596f9f1c3
64 changed files with 1341 additions and 2755 deletions

View File

@@ -1,117 +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 { 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/)
})

View File

@@ -0,0 +1,243 @@
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/)
})

View File

@@ -1,204 +0,0 @@
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 })
}
})

View File

@@ -1,590 +0,0 @@
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,142 @@
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 { generateSprite, isSpriteMode } from '../dist/index.js'
const CHECK_SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M9 16 4 11l2-2 3 3 8-8 2 2z"/></svg>'
const UNSAFE_SVG = '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></svg>'
function fixture() {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-standalone-'))
const iconsDir = path.join(rootDir, 'icons')
fs.mkdirSync(iconsDir)
fs.writeFileSync(path.join(iconsDir, 'check.svg'), CHECK_SVG)
fs.writeFileSync(path.join(iconsDir, 'folder open.svg'), UNSAFE_SVG)
return rootDir
}
function generatedFiles(rootDir) {
return fs.readdirSync(path.join(rootDir, '.svg-sprite')).sort()
}
test('recognizes all standalone exact modes', () => {
assert.equal(isSpriteMode('standalone'), true)
assert.equal(isSpriteMode('standalone@vite'), true)
assert.equal(isSpriteMode('standalone@webpack'), true)
assert.equal(isSpriteMode('standalone@rollup'), false)
})
test('standalone generates an asset and deployment-neutral JSON manifest', async () => {
const rootDir = fixture()
const result = await generateSprite(rootDir, {
mode: 'standalone',
name: 'app-icons',
generatedNotice: false,
})
assert.equal(result.mode, 'standalone')
assert.equal(result.target, 'static')
assert.equal(result.iconCount, 2)
assert.equal(result.spritePath, path.join(rootDir, '.svg-sprite', 'sprite.svg'))
assert.equal(result.manifestPath, path.join(rootDir, '.svg-sprite', 'svg-sprite.manifest.json'))
assert.deepEqual(generatedFiles(rootDir), [
'sprite.svg',
'state.json',
'svg-sprite.manifest.json',
])
const source = fs.readFileSync(result.spritePath, 'utf8')
assert.match(source, /@generated by @gromlab\/svg-sprites/)
assert.match(source, /id="check"/)
const manifest = JSON.parse(fs.readFileSync(result.manifestPath, 'utf8'))
assert.equal(manifest.name, 'app-icons')
assert.equal(manifest.mode, 'standalone')
assert.equal(manifest.target, 'static')
assert.match(manifest.generated, /@generated by @gromlab\/svg-sprites/)
assert.equal('spriteUrl' in manifest, false)
assert.equal('componentName' in manifest, false)
assert.equal(manifest.icons.find(({ name }) => name === 'check').id, 'check')
assert.match(
manifest.icons.find(({ name }) => name === 'folder open').id,
/^icon-[a-f0-9]{16}$/,
)
const state = JSON.parse(fs.readFileSync(path.join(rootDir, '.svg-sprite', 'state.json'), 'utf8'))
assert.deepEqual(state.owner, { mode: 'standalone', contractVersion: 2 })
})
test('standalone Vite generates a typed facade and resolved manifest', async () => {
const rootDir = fixture()
const result = await generateSprite(rootDir, {
mode: 'standalone@vite',
name: 'icons',
generatedNotice: false,
})
assert.equal(result.target, 'vite')
assert.deepEqual(generatedFiles(rootDir), [
'icon-data.d.ts',
'icon-data.js',
'index.d.ts',
'index.js',
'sprite.svg',
'state.json',
'svg-sprite.manifest.d.ts',
'svg-sprite.manifest.js',
])
const entry = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'index.js'), 'utf8')
const declarations = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'index.d.ts'), 'utf8')
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
assert.match(entry, /sprite\.svg\?no-inline/)
assert.match(entry, /export const iconsSpriteUrl = spriteUrl/)
assert.match(entry, /export function getIconsIconHref\(icon\)/)
assert.match(declarations, /getIconsIconHref\(icon: IconsIconName\): string/)
assert.match(manifest, /"mode": "standalone@vite"/)
assert.match(manifest, /export const spriteManifest = createSpriteManifest\(spriteUrl\)/)
assert.doesNotMatch(entry, /react|jsx|\.css/)
})
test('standalone Webpack generates a typed facade and resolved manifest', async () => {
const rootDir = fixture()
const result = await generateSprite(rootDir, {
mode: 'standalone@webpack',
name: 'icons',
generatedNotice: false,
})
assert.equal(result.target, 'webpack')
const entry = fs.readFileSync(path.join(rootDir, '.svg-sprite', 'index.js'), 'utf8')
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
assert.match(entry, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.match(entry, /export function getIconsIconHref\(icon\)/)
assert.match(manifest, /"mode": "standalone@webpack"/)
assert.match(manifest, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.doesNotMatch(entry, /react|jsx|\.css/)
})
test('switching standalone modes removes obsolete managed files', async () => {
const rootDir = fixture()
const options = { name: 'icons', generatedNotice: false }
await generateSprite(rootDir, { ...options, mode: 'standalone@vite' })
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'user.txt'), 'keep')
await generateSprite(rootDir, { ...options, mode: 'standalone' })
assert.deepEqual(generatedFiles(rootDir), [
'sprite.svg',
'state.json',
'svg-sprite.manifest.json',
'user.txt',
])
assert.equal(fs.readFileSync(path.join(rootDir, '.svg-sprite', 'user.txt'), 'utf8'), 'keep')
await generateSprite(rootDir, { ...options, mode: 'standalone@webpack' })
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'svg-sprite.manifest.json')), false)
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'index.js')), true)
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'user.txt')), true)
})