feat: перейти на изолированный контракт генерации

- добавлены независимые adapters для шести exact modes
- runtime переведён на JavaScript с отдельными декларациями
- generated output перенесён в .svg-sprite
- удалены legacy pipeline и встроенный preview
This commit is contained in:
2026-07-13 20:07:12 +03:00
parent bc4bece3c5
commit f4f464a568
108 changed files with 2187 additions and 6613 deletions

99
AGENTS.md Normal file
View File

@@ -0,0 +1,99 @@
# Архитектурные правила
## Изоляция modes
Каждый полный mode key является независимым adapter со своим generated-контрактом:
- `react@vite`;
- `react@webpack`;
- `next@app/turbopack`;
- `next@app/webpack`;
- `next@pages/turbopack`;
- `next@pages/webpack`;
- будущие `vue@*`, `standalone` и другие modes.
Для каждого exact mode используется отдельный каталог `src/modes/<mode-slug>/`. Adapter самостоятельно определяет:
- compile options;
- список и имена generated-файлов;
- runtime JavaScript;
- TypeScript declarations;
- manifest source;
- CSS;
- способ получения asset URL;
- mode-specific result metadata.
Изменение output одного mode не должно менять output другого mode. Дублирование component, manifest, declaration и CSS codegen между adapters является сознательной ценой изоляции.
Запрещено:
- импортировать один `src/modes/<exact>/` из другого mode;
- создавать общий framework/component/manifest/CSS codegen для нескольких modes;
- создавать общий runtime asset URL generator для нескольких modes;
- ветвиться по generic target внутри adapter;
- писать generated-файлы непосредственно из adapter через `fs`;
- импортировать exact-mode adapters из core.
Единственное место, которое импортирует все adapters, — `src/mode-registry.ts`.
## Общий core
Общими могут быть только mode-neutral данные и инфраструктура:
- загрузка, merge и валидация config;
- scanner исходных SVG;
- shape IDs и проверка конфликтов;
- низкоуровневый SVG compiler и transformations;
- подготовка нейтрального compiled artifact;
- protocol `ModeAdapter`/`OutputPlan`;
- проверка output paths;
- atomic writer, symlink protection и ownership state;
- logger и базовые result types.
Core не генерирует JavaScript, declarations, manifest source, CSS или framework-specific exports. Изменение общего compiler может ожидаемо изменить SVG всех modes; изменение generated source должно быть локально одному adapter.
## Generated-контракт
Один config разрешается ровно в один mode и один output. Множественные modes не генерируются в один root; orchestration выполняется независимыми config/API/CLI вызовами.
Runtime генерируется как ESM JavaScript. Типизация добавляется отдельными `.d.ts`; TypeScript/TSX не используется как runtime output.
Core writer владеет корневым `.gitignore` и `.svg-sprite/state.json`. State не является manifest спрайта: он хранит owner mode, contract version и список управляемых файлов.
Sprite-level asset, icon data, manifest и facade лежат непосредственно в `.svg-sprite/`. Framework runtime группируется отдельно: React adapters используют `.svg-sprite/react/`, будущие framework adapters получают собственный framework-каталог.
Каждый adapter имеет собственный `contractVersion`. При изменении его generated-контракта повышается только версия этого adapter.
Adapter возвращает файлы в памяти. Только core writer проверяет paths и markers, обновляет output и записывает `state.json` последним.
## Зависимости
Допустимое направление импортов:
```text
public API / CLI
-> generate
-> mode-registry
-> один exact-mode adapter
-> core protocols and services
```
Обратные и горизонтальные зависимости запрещены:
```text
core -X-> modes
mode A -X-> mode B
mode A -X-> shared output codegen
```
`src/react/` с `SpriteViewer` является отдельным runtime пакета, а не mode adapter.
## Изменение mode
При работе с adapter:
1. Изменяйте только его каталог и mode-neutral protocol, если это действительно необходимо.
2. Не переносите output-логику в core ради устранения дублирования.
3. Не меняйте generated-контракты других adapters автоматически.
4. Повышайте `contractVersion` только изменённого adapter.
5. Проверяйте отсутствие cross-mode imports.

View File

@@ -1,6 +1,6 @@
# Third-Party Notices
The distributed React entry and preview template include code from the projects listed below.
The distributed React entry includes code from the projects listed below.
## React, React DOM and Scheduler
@@ -49,27 +49,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## clsx
Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -31,7 +31,6 @@
"dist/types-*.d.ts",
"dist/chunk-*.js",
"dist/chunk-*.js.map",
"dist/preview-template.html",
"README_RU.md",
"docs/en/*.md",
"docs/ru/*.md",
@@ -39,14 +38,13 @@
"THIRD_PARTY_NOTICES.md"
],
"scripts": {
"build": "tsup && npm run build:preview",
"build:preview": "npm ci --prefix preview && npm run build --prefix preview",
"build": "npm run build:package",
"build:package": "tsup",
"build:skill": "node skills/svg-sprites/build.mjs",
"check:skill": "node skills/svg-sprites/build.mjs --check",
"dev": "tsup --watch",
"test": "tsup && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit",
"sprite": "node dist/cli.js --mode legacy .",
"verify": "npm run check:skill && npm run typecheck && npm test",
"prepack": "npm run verify && npm run build"
},

25
preview/.gitignore vendored
View File

@@ -1,25 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
public/dev-data.js
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Sprites Preview</title>
</head>
<body>
<!-- __SPRITES_INJECT__ -->
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3116
preview/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
{
"name": "preview",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev:prepare": "node scripts/generate-dev-data.js",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.9",
"vite-plugin-singlefile": "^2.3.3"
}
}

View File

@@ -1,163 +0,0 @@
/**
* Генерирует dev-данные для preview из реальных спрайтов основного пакета.
*
* Запуск: node scripts/generate-dev-data.js
*
* Результат:
* public/dev-data.js — window.__SPRITES_DATA__ с метаданными иконок
* public/dev-sprites.svg — инлайновые <symbol> из всех спрайтов
*/
import fs from 'node:fs'
import path from 'node:path'
const ROOT = path.resolve(import.meta.dirname, '../..')
const SPRITES_OUTPUT = path.join(ROOT, 'preview/public')
const PREVIEW_PUBLIC = path.join(import.meta.dirname, '../public')
/** Извлекает id иконок из SVG-спрайта. */
function extractIconIds(spritePath) {
const content = fs.readFileSync(spritePath, 'utf-8')
const ids = []
const regex = /<(?:svg|symbol)\b[^>]*\bid="([^"]+)"/g
let match
while ((match = regex.exec(content)) !== null) {
ids.push(match[1])
}
return ids.sort()
}
/** Извлекает viewBox из SVG-фрагмента иконки. */
function extractViewBox(svgFragment) {
const match = svgFragment.match(/viewBox="([^"]+)"/)
if (!match) return null
const parts = match[1].split(/\s+/).map(Number)
if (parts.length !== 4) return null
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
}
/** Извлекает CSS-переменные из SVG-фрагмента иконки. */
function extractIconVars(svgFragment) {
const vars = new Map()
const regex = /var\((--icon-color-\d+),\s*([^)]+)\)/g
let match
while ((match = regex.exec(svgFragment)) !== null) {
if (!vars.has(match[1])) {
vars.set(match[1], match[2].trim())
}
}
return [...vars.entries()].map(([varName, fallback]) => ({
varName,
fallback,
hex: colorToHex(fallback),
isCurrentColor: fallback.toLowerCase() === 'currentcolor',
}))
}
/** Извлекает фрагменты иконок из спрайта. */
function extractIconFragments(spritePath) {
const content = fs.readFileSync(spritePath, 'utf-8')
const fragments = new Map()
const regex = /<(?:svg|symbol)\b[^>]*\bid="([^"]+)"[^>]*>[\s\S]*?<\/(?:svg|symbol)>/g
let match
while ((match = regex.exec(content)) !== null) {
fragments.set(match[1], match[0])
}
return fragments
}
/** Конвертирует CSS-цвет в hex. */
function colorToHex(color) {
const named = {
red: '#ff0000', blue: '#0000ff', green: '#008000', white: '#ffffff',
black: '#000000', yellow: '#ffff00', cyan: '#00ffff', magenta: '#ff00ff',
orange: '#ffa500', purple: '#800080', pink: '#ffc0cb', gray: '#808080',
grey: '#808080', currentcolor: '#000000',
}
const lower = color.toLowerCase().trim()
if (lower.startsWith('#')) {
if (lower.length === 4) return `#${lower[1]}${lower[1]}${lower[2]}${lower[2]}${lower[3]}${lower[3]}`
return lower
}
return named[lower] || '#000000'
}
/** Подготавливает спрайт для инлайна — вложенные <svg> → <symbol>. */
function prepareInlineSprite(spritePath) {
let content = fs.readFileSync(spritePath, 'utf-8')
content = content.replace(/<\?xml[^?]*\?>\s*/g, '')
content = content.replace(/<style>:root>svg\{display:none\}:root>svg:target\{display:block\}<\/style>/g, '')
let depth = 0
content = content.replace(/<(\/?)svg\b([^>]*?)(\s*\/?)>/g, (_full, slash, attrs) => {
if (slash) {
depth--
return depth > 0 ? '</symbol>' : '</svg>'
}
depth++
if (depth > 1) {
const cleanAttrs = attrs.replace(/\s*xmlns="[^"]*"/g, '')
return `<symbol${cleanAttrs}>`
}
return `<svg${attrs} style="display:none">`
})
return content
}
// --- Main ---
const spriteFiles = fs.readdirSync(SPRITES_OUTPUT).filter((entry) => {
return entry.endsWith('.sprite.svg')
})
const groups = []
const inlineSprites = []
for (const fileName of spriteFiles) {
const spritePath = path.join(SPRITES_OUTPUT, fileName)
const name = fileName.replace('.sprite.svg', '')
const fragments = extractIconFragments(spritePath)
const ids = extractIconIds(spritePath)
const icons = ids.map((id) => {
const fragment = fragments.get(id) || ''
return {
id,
group: name,
mode: 'stack',
spriteFile: fileName,
viewBox: extractViewBox(fragment),
vars: extractIconVars(fragment),
}
})
groups.push({ name, mode: 'stack', spriteFile: fileName, icons })
inlineSprites.push(prepareInlineSprite(spritePath))
}
// Write dev-data.js — данные + инлайн-спрайты через DOM injection
fs.mkdirSync(PREVIEW_PUBLIC, { recursive: true })
const svgContent = inlineSprites.join('\n').replace(/`/g, '\\`').replace(/\$/g, '\\$')
const dataJs = [
`window.__SPRITES_DATA__ = ${JSON.stringify({ groups }, null, 2)};`,
'',
'// Inject inline SVG sprites into DOM via DOMParser for correct SVG namespace',
'(function() {',
` var svg = \`${svgContent}\`;`,
' var parser = new DOMParser();',
' var doc = parser.parseFromString("<div>" + svg + "</div>", "text/html");',
' var nodes = doc.body.firstChild.childNodes;',
' while (nodes.length > 0) {',
' document.body.insertBefore(nodes[0], document.body.firstChild);',
' }',
'})();',
].join('\n')
fs.writeFileSync(path.join(PREVIEW_PUBLIC, 'dev-data.js'), dataJs)
// Cleanup old separate file if exists
const oldSvg = path.join(PREVIEW_PUBLIC, 'dev-sprites.svg')
if (fs.existsSync(oldSvg)) fs.unlinkSync(oldSvg)
console.log(`Generated dev data: ${groups.length} groups, ${groups.reduce((s, g) => s + g.icons.length, 0)} icons`)

View File

@@ -1,108 +0,0 @@
import { useState, useMemo } from 'react'
import { useTheme } from './infrastructure/theme'
import { Banner } from './ui/banner'
import { SearchInput } from './ui/search-input'
import { IconGrid } from './ui/icon-grid'
import { IconModal } from './ui/icon-modal'
import type { SpritesData, IconData } from './shared/types'
import styles from './app/styles/app.module.css'
declare global {
interface Window {
__SPRITES_DATA__?: SpritesData
}
}
const MOCK_DATA: SpritesData = {
groups: [],
}
/**
* Корневой компонент превью SVG-спрайтов.
*
* Используется для:
* - отображения всех иконок из сгенерированных спрайтов
* - просмотра деталей иконки и кода использования
*/
export const App = () => {
const { toggle } = useTheme()
const data = window.__SPRITES_DATA__ ?? MOCK_DATA
const [searchQuery, setSearchQuery] = useState('')
const [selectedIconId, setSelectedIconId] = useState<string | null>(null)
const isFileProtocol = location.protocol === 'file:'
const totalIcons = useMemo(
() => data.groups.reduce((sum, g) => sum + g.icons.length, 0),
[data.groups],
)
const selectedIcon = useMemo((): IconData | null => {
if (!selectedIconId) {
return null
}
for (const group of data.groups) {
const found = group.icons.find((i) => i.id === selectedIconId)
if (found) {
return found
}
}
return null
}, [selectedIconId, data.groups])
const handleCloseModal = (): void => {
setSelectedIconId(null)
}
return (
<>
{isFileProtocol && (
<Banner variant="warn">
<strong>file://</strong> — Preview opened from local file. External SVG
references won&apos;t work in code snippets. Use a local server for full
functionality.
</Banner>
)}
<header className={styles.header}>
<h1 className={styles.title}>SVG Sprites</h1>
<span className={styles.count}>{totalIcons} icons</span>
<div className={styles.toolbar}>
<SearchInput
placeholder="Search icons..."
onValueChange={setSearchQuery}
/>
<button type="button" className={styles.themeButton} onClick={toggle}>
&#x25D1;
</button>
</div>
</header>
<IconGrid
groups={data.groups}
searchQuery={searchQuery}
onIconSelect={setSelectedIconId}
/>
<IconModal
icon={selectedIcon}
defaultSprite={data.groups[0]?.name}
onClose={handleCloseModal}
/>
<footer className={styles.footer}>
<span className={styles.footerText}>@gromlab/svg-sprites</span>
<a
className={styles.footerLink}
href="https://github.com/gromlab-ru/svg-sprites"
target="_blank"
rel="noreferrer"
>
Repository
</a>
</footer>
</>
)
}

View File

@@ -1,64 +0,0 @@
.header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-6);
}
.title {
font-size: 1.5rem;
font-weight: 700;
}
.count {
font-size: 13px;
color: var(--color-muted);
font-weight: 400;
}
.toolbar {
display: flex;
gap: var(--space-3);
margin-left: auto;
align-items: center;
}
.themeButton {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-card-bg);
color: var(--color-fg);
cursor: pointer;
font-size: 14px;
}
.themeButton:hover {
background: var(--color-card-hover);
}
.footer {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
margin-top: auto;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
color: var(--color-muted);
font-size: 13px;
}
.footerText {
font-weight: 500;
}
.footerLink {
color: var(--color-accent);
text-decoration: none;
}
.footerLink:hover {
text-decoration: underline;
}

View File

@@ -1,26 +0,0 @@
@import './variables.css';
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-fg);
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
#root {
flex: 1;
display: flex;
flex-direction: column;
}

View File

@@ -1,60 +0,0 @@
:root {
--color-bg: #f0f0f3;
--color-fg: #1a1a1a;
--color-card-bg: #ffffff;
--color-card-hover: #eaeaed;
--color-border: #d8d8d8;
--color-accent: #3b82f6;
--color-muted: #888888;
--color-code-bg: #f5f5f5;
--radius-1: 4px;
--radius-2: 8px;
--radius-3: 12px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--icon-size: 128px;
--checkerboard: conic-gradient(
var(--color-checker-a) 25%, var(--color-checker-b) 0 50%,
var(--color-checker-a) 0 75%, var(--color-checker-b) 0
);
--checkerboard-size: 8px 8px;
--color-checker-a: #e9e9e9;
--color-checker-b: #ffffff;
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-bg: #1a1a1a;
--color-fg: #e5e5e5;
--color-card-bg: #2a2a2a;
--color-card-hover: #333333;
--color-border: #404040;
--color-code-bg: #2a2a2a;
--color-checker-a: #333333;
--color-checker-b: #2a2a2a;
}
}
:root[data-theme='dark'] {
--color-bg: #1a1a1a;
--color-fg: #e5e5e5;
--color-card-bg: #2a2a2a;
--color-card-hover: #333333;
--color-border: #404040;
--color-code-bg: #2a2a2a;
--color-checker-a: #333333;
--color-checker-b: #2a2a2a;
}

View File

@@ -1,40 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import type { Theme } from '../types/theme.type'
const getSystemTheme = (): Theme => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
/**
* Управляет темой приложения: авто-определение по системе + ручное переключение.
*/
export const useTheme = () => {
const [theme, setTheme] = useState<Theme>(getSystemTheme)
const toggle = useCallback(() => {
setTheme((prev) => {
const next: Theme = prev === 'dark' ? 'light' : 'dark'
document.documentElement.dataset.theme = next
return next
})
}, [])
useEffect(() => {
// Устанавливаем data-theme при инициализации, чтобы CSS-селекторы
// (включая подсветку кода) работали сразу, а не только после ручного переключения.
document.documentElement.dataset.theme = getSystemTheme()
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (): void => {
const next = getSystemTheme()
document.documentElement.dataset.theme = next
setTheme(next)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return { theme, toggle }
}

View File

@@ -1,2 +0,0 @@
export { useTheme } from './hooks/use-theme.hook'
export type { Theme } from './types/theme.type'

View File

@@ -1,2 +0,0 @@
.root {
}

View File

@@ -1,4 +0,0 @@
/**
* Theme infrastructure module — реэкспортирует хук.
* Компонент не требуется, тема управляется через data-theme на documentElement.
*/

View File

@@ -1,11 +0,0 @@
/**
* Тема приложения.
*/
export type Theme = 'light' | 'dark'
/**
* Параметры Theme.
*/
export type ThemeParams = {}
export type ThemeProps = ThemeParams

View File

@@ -1,28 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './app/styles/globals.css'
import { App } from './App'
function loadDevScript(): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = '/dev-data.js'
script.onload = () => resolve()
script.onerror = reject
document.head.appendChild(script)
})
}
async function loadDevData(): Promise<void> {
if (import.meta.env.DEV && !window.__SPRITES_DATA__) {
await loadDevScript()
}
}
loadDevData().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
})

View File

@@ -1,16 +0,0 @@
/**
* Конвертирует CSS-цвет (rgb или hex) в hex-формат.
*/
export const rgbToHex = (color: string): string => {
if (color.startsWith('#')) {
return color
}
const match = color.match(/\d+/g)
if (match && match.length >= 3) {
return '#' + match.slice(0, 3).map((c) => parseInt(c).toString(16).padStart(2, '0')).join('')
}
return '#000000'
}

View File

@@ -1,6 +0,0 @@
export type {
IconVar,
IconData,
SpriteGroup,
SpritesData,
} from './sprites-data.type'

View File

@@ -1,63 +0,0 @@
/**
* CSS-переменная иконки.
*/
export type IconVar = {
/** Имя CSS-переменной. */
varName: string
/** Fallback-значение. */
fallback: string
/** HEX-значение для color picker. */
hex: string
/** Является ли fallback значением currentColor. */
isCurrentColor: boolean
}
/**
* Размеры viewBox иконки.
*/
export type IconViewBox = {
x: number
y: number
width: number
height: number
}
/**
* Данные одной иконки.
*/
export type IconData = {
/** Идентификатор иконки. */
id: string
/** Имя группы (папки спрайта). */
group: string
/** Режим спрайта. */
mode: 'stack' | 'symbol'
/** Относительный путь к файлу спрайта. */
spriteFile: string
/** Размеры viewBox иконки. */
viewBox: IconViewBox | null
/** CSS-переменные иконки. */
vars: IconVar[]
}
/**
* Группа спрайтов.
*/
export type SpriteGroup = {
/** Имя группы. */
name: string
/** Режим спрайта. */
mode: 'stack' | 'symbol'
/** Относительный путь к файлу спрайта. */
spriteFile: string
/** Иконки в группе. */
icons: IconData[]
}
/**
* Данные для рендера превью.
*/
export type SpritesData = {
/** Группы спрайтов. */
groups: SpriteGroup[]
}

View File

@@ -1,20 +0,0 @@
import cl from 'clsx'
import type { BannerProps } from './types/banner.type'
import styles from './styles/banner.module.css'
/**
* Баннер-уведомление.
*
* Используется для:
* - предупреждений о file:// протоколе
* - информационных сообщений
*/
export const Banner = (props: BannerProps) => {
const { children, className, variant = 'warn', ...htmlAttr } = props
return (
<div {...htmlAttr} className={cl(styles.root, variant && styles[`_${variant}`], className)}>
{children}
</div>
)
}

View File

@@ -1 +0,0 @@
export { Banner } from './banner.ui'

View File

@@ -1,29 +0,0 @@
.root {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 10px var(--space-4);
border-radius: var(--radius-2);
font-size: 13px;
margin-bottom: var(--space-5);
}
._warn {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
:root[data-theme='dark'] ._warn {
background: #451a03;
color: #fde68a;
border-color: #92400e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) ._warn {
background: #451a03;
color: #fde68a;
border-color: #92400e;
}
}

View File

@@ -1,14 +0,0 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры Banner.
*/
export type BannerParams = {
/** Вариант отображения. */
variant?: 'warn' | 'info'
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type BannerProps = RootAttrs & BannerParams

View File

@@ -1,33 +0,0 @@
import { useState, useMemo } from 'react'
import cl from 'clsx'
import { highlight } from './lib/highlight.lib'
import type { CodeBlockProps } from './types/code-block.type'
import styles from './styles/code-block.module.css'
export const CodeBlock = (props: CodeBlockProps) => {
const { code, language = 'html', copyable = true, className, ...htmlAttr } = props
const [isCopied, setIsCopied] = useState(false)
const highlightedHtml = useMemo(() => highlight(code, language), [code, language])
const handleCopy = (): void => {
navigator.clipboard.writeText(code).then(() => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1500)
})
}
return (
<div {...htmlAttr} className={cl(styles.root, className)}>
<pre className={styles.pre}>
<code className={cl(styles.code, styles[language])} dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
</pre>
{copyable && (
<button type="button" className={styles.copyButton} onClick={handleCopy}>
{isCopied ? 'Copied!' : 'Copy'}
</button>
)}
</div>
)
}

View File

@@ -1 +0,0 @@
export { CodeBlock } from './code-block.ui'

View File

@@ -1,68 +0,0 @@
const ESCAPE_RE = /[&<>"]/g
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
const escape = (str: string): string => str.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c])
type Token = { kind: string; match: string }
type Rule = [RegExp, string]
const HTML_RULES: Rule[] = [
[/<!--[\s\S]*?-->/g, 'comment'],
[/<\/?[\w-]+/g, 'tag'],
[/<\/?/g, 'punctuation'],
[/\/?>/g, 'punctuation'],
[/[\w-]+(?=\s*=)/g, 'attr'],
[/=/g, 'punctuation'],
[/"[^"]*"|'[^']*'/g, 'string'],
]
const CSS_RULES: Rule[] = [
[/\/\*[\s\S]*?\*\//g, 'comment'],
[/\.[\w-]+/g, 'selector'],
[/[\w-]+(?=\s*:)/g, 'property'],
[/[:;]/g, 'punctuation'],
[/\{|\}/g, 'punctuation'],
[/'[^']*'|"[^"]*"/g, 'string'],
[/#[\da-fA-F]{3,8}\b/g, 'color'],
[/\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|deg|s|ms)?/g, 'number'],
[/url\([^)]*\)/g, 'function'],
]
const RULES: Record<string, Rule[]> = { html: HTML_RULES, css: CSS_RULES, xml: HTML_RULES }
const tokenize = (code: string, lang: string): Token[] => {
const rules = RULES[lang] ?? []
const tokens: Token[] = []
let pos = 0
while (pos < code.length) {
let matched = false
for (const [re, kind] of rules) {
re.lastIndex = pos
const m = re.exec(code)
if (m && m.index === pos) {
tokens.push({ kind, match: m[0] })
pos += m[0].length
matched = true
break
}
}
if (!matched) {
const last = tokens[tokens.length - 1]
const ch = code[pos]
if (last && last.kind === 'plain') {
last.match += ch
} else {
tokens.push({ kind: 'plain', match: ch })
}
pos++
}
}
return tokens
}
export const highlight = (code: string, lang: string): string =>
tokenize(code, lang)
.map((t) => (t.kind === 'plain' ? escape(t.match) : `<span class="hl-${t.kind}">${escape(t.match)}</span>`))
.join('')

View File

@@ -1,85 +0,0 @@
.root {
position: relative;
border-radius: var(--radius-2);
overflow: hidden;
background: var(--color-code-bg);
}
.pre {
margin: 0;
padding: var(--space-4);
overflow-x: auto;
font-size: 12px;
font-family: var(--font-mono);
line-height: 1.6;
}
.code {
font-family: var(--font-mono);
}
:global(:root[data-theme='light']) .code {
--hl-tag: #116329;
--hl-attr: #0550ae;
--hl-string: #0a3069;
--hl-comment: #8b949e;
--hl-punctuation: #6639ba;
--hl-selector: #6639ba;
--hl-property: #0550ae;
--hl-color: #0a3069;
--hl-number: #0550ae;
--hl-function: #0550ae;
}
:global(:root[data-theme='dark']) .code {
--hl-tag: #7ee787;
--hl-attr: #79c0ff;
--hl-string: #a5d6ff;
--hl-comment: #8b949e;
--hl-punctuation: #d2a8ff;
--hl-selector: #d2a8ff;
--hl-property: #79c0ff;
--hl-color: #a5d6ff;
--hl-number: #79c0ff;
--hl-function: #d2a8ff;
}
.code :global(.hl-tag),
.code :global(.hl-attr),
.code :global(.hl-string),
.code :global(.hl-comment),
.code :global(.hl-punctuation),
.code :global(.hl-selector),
.code :global(.hl-property),
.code :global(.hl-color),
.code :global(.hl-number),
.code :global(.hl-function) {
color: var(--hl-tag);
}
.code :global(.hl-attr) { color: var(--hl-attr); }
.code :global(.hl-string) { color: var(--hl-string); }
.code :global(.hl-comment) { color: var(--hl-comment); }
.code :global(.hl-punctuation) { color: var(--hl-punctuation); }
.code :global(.hl-selector) { color: var(--hl-selector); }
.code :global(.hl-property) { color: var(--hl-property); }
.code :global(.hl-color) { color: var(--hl-color); }
.code :global(.hl-number) { color: var(--hl-number); }
.code :global(.hl-function) { color: var(--hl-function); }
.copyButton {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: var(--space-1) var(--space-2);
font-size: 11px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
color: var(--color-muted);
cursor: pointer;
}
.copyButton:hover {
color: var(--color-fg);
}

View File

@@ -1,18 +0,0 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры CodeBlock.
*/
export type CodeBlockParams = {
/** Код для отображения. */
code: string
/** Язык подсветки синтаксиса. */
language?: 'html' | 'css' | 'xml'
/** Показывать кнопку копирования. */
copyable?: boolean
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type CodeBlockProps = RootAttrs & CodeBlockParams

View File

@@ -1,74 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { HexColorPicker } from 'react-colorful'
import cl from 'clsx'
import type { ColorPickerProps } from './types/color-picker.type'
import styles from './styles/color-picker.module.css'
/**
* Выбор цвета с попапом react-colorful и HEX-инпутом.
*
* Используется для:
* - настройки CSS-переменных цвета иконок
* - визуального подбора цвета в модалке
*/
export const ColorPicker = (props: ColorPickerProps) => {
const { value, onValueChange, label, className, ...htmlAttr } = props
const [isOpen, setIsOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
const swatchRef = useRef<HTMLButtonElement>(null)
const handleToggle = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent): void => {
const target = e.target as Node
if (
popoverRef.current && !popoverRef.current.contains(target) &&
swatchRef.current && !swatchRef.current.contains(target)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const handleHexInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
const hex = e.target.value
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
onValueChange(hex)
}
}
return (
<div {...htmlAttr} className={cl(styles.root, className)}>
<button
ref={swatchRef}
type="button"
className={styles.swatch}
style={{ backgroundColor: value }}
onClick={handleToggle}
/>
{label && <code className={styles.label}>{label}</code>}
{isOpen && (
<div ref={popoverRef} className={styles.popover}>
<HexColorPicker color={value} onChange={onValueChange} />
<input
type="text"
className={styles.hexInput}
value={value}
onChange={handleHexInput}
/>
</div>
)}
</div>
)
}

View File

@@ -1 +0,0 @@
export { ColorPicker } from './color-picker.ui'

View File

@@ -1,53 +0,0 @@
.root {
position: relative;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.swatch {
width: 24px;
height: 24px;
border-radius: var(--radius-1);
border: 1px solid var(--color-border);
cursor: pointer;
padding: 0;
background: none;
}
.label {
font-size: 12px;
color: var(--color-muted);
font-family: var(--font-mono);
}
.popover {
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
z-index: 200;
padding: var(--space-3);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.hexInput {
display: block;
width: 100%;
margin-top: var(--space-2);
padding: var(--space-1) var(--space-2);
font-size: 12px;
font-family: var(--font-mono);
color: var(--color-fg);
background: var(--color-card-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-1);
outline: none;
text-align: center;
}
.hexInput:focus {
border-color: var(--color-accent);
}

View File

@@ -1,18 +0,0 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры ColorPicker.
*/
export type ColorPickerParams = {
/** Текущий цвет в HEX. */
value: string
/** Обработчик изменения цвета. */
onValueChange: (color: string) => void
/** Подпись под picker-ом. */
label?: string
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>
export type ColorPickerProps = RootAttrs & ColorPickerParams

View File

@@ -1,29 +0,0 @@
import cl from 'clsx'
import type { IconCardProps } from './types/icon-card.type'
import styles from './styles/icon-card.module.css'
/**
* Карточка иконки в сетке превью.
*
* Используется для:
* - отображения иконки из спрайта с именем
* - открытия модалки деталей по клику
*/
export const IconCard = (props: IconCardProps) => {
const { iconId, onSelect, className, ...htmlAttr } = props
const handleClick = (): void => {
onSelect?.(iconId)
}
return (
<div {...htmlAttr} className={cl(styles.root, className)} onClick={handleClick}>
<div className={styles.iconWrap}>
<svg className={styles.icon}>
<use href={`#${iconId}`} />
</svg>
</div>
<span className={styles.name}>{iconId}</span>
</div>
)
}

View File

@@ -1 +0,0 @@
export { IconCard } from './icon-card.ui'

View File

@@ -1,37 +0,0 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-2);
border-radius: var(--radius-2);
background: var(--color-card-bg);
cursor: pointer;
transition: background 0.15s;
}
.root:hover {
background: var(--color-card-hover);
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-1);
background: var(--checkerboard);
background-size: var(--checkerboard-size);
}
.icon {
width: var(--icon-size);
height: var(--icon-size);
color: var(--color-fg);
}
.name {
font-size: 13px;
color: var(--color-muted);
text-align: center;
word-break: break-all;
}

View File

@@ -1,16 +0,0 @@
import type { HTMLAttributes } from 'react'
/**
* Параметры IconCard.
*/
export type IconCardParams = {
/** Идентификатор иконки. */
iconId: string
/** Обработчик клика по карточке. */
onSelect?: (iconId: string) => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'>
export type IconCardProps = RootAttrs & IconCardParams

View File

@@ -1,51 +0,0 @@
import cl from 'clsx'
import { IconCard } from '../icon-card'
import type { IconGridProps } from './types/icon-grid.type'
import styles from './styles/icon-grid.module.css'
/**
* Сетка иконок, сгруппированная по спрайтам.
*
* Используется для:
* - отображения всех иконок из всех спрайтов
* - фильтрации иконок по поисковому запросу
*/
export const IconGrid = (props: IconGridProps) => {
const { groups, searchQuery = '', onIconSelect, className, ...htmlAttr } = props
const query = searchQuery.toLowerCase()
return (
<div {...htmlAttr} className={cl(styles.root, className)}>
{groups.map((group) => {
const filteredIcons = query
? group.icons.filter((icon) => icon.id.includes(query))
: group.icons
const isGroupHidden = filteredIcons.length === 0
return (
<section
key={group.name}
className={cl(styles.group, isGroupHidden && styles._hidden)}
>
<h2 className={styles.groupHeader}>
{group.name}
<span className={styles.badge}>{group.mode}</span>
<span className={styles.count}>{group.icons.length}</span>
</h2>
<div className={styles.grid}>
{filteredIcons.map((icon) => (
<IconCard
key={icon.id}
iconId={icon.id}
onSelect={onIconSelect}
/>
))}
</div>
</section>
)
})}
</div>
)
}

View File

@@ -1 +0,0 @@
export { IconGrid } from './icon-grid.ui'

View File

@@ -1,42 +0,0 @@
.root {
}
.group {
margin-bottom: var(--space-10);
}
.groupHeader {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 1.1rem;
font-weight: 600;
margin-bottom: var(--space-4);
}
.badge {
font-size: 11px;
font-weight: 500;
padding: 2px var(--space-2);
border-radius: 10px;
background: var(--color-accent);
color: #ffffff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.count {
font-size: 13px;
color: var(--color-muted);
font-weight: 400;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-3);
}
._hidden {
display: none;
}

View File

@@ -1,19 +0,0 @@
import type { HTMLAttributes } from 'react'
import type { SpriteGroup } from '../../../shared/types'
/**
* Параметры IconGrid.
*/
export type IconGridParams = {
/** Группы спрайтов для отображения. */
groups: SpriteGroup[]
/** Строка поиска для фильтрации. */
searchQuery?: string
/** Обработчик выбора иконки. */
onIconSelect?: (iconId: string) => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type IconGridProps = RootAttrs & IconGridParams

View File

@@ -1,235 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import cl from 'clsx'
import { CodeBlock } from '../code-block'
import { ColorPicker } from '../color-picker'
import { rgbToHex } from '../../shared/lib/rgb-to-hex.util'
import type { IconModalProps } from './types/icon-modal.type'
import styles from './styles/icon-modal.module.css'
type TabId = 'react' | 'svg' | 'img' | 'css'
const TABS: { id: TabId; label: string }[] = [
{ id: 'react', label: 'React' },
{ id: 'svg', label: 'SVG' },
{ id: 'img', label: 'IMG' },
{ id: 'css', label: 'CSS' },
]
/**
* Модалка деталей иконки с превью, color pickers и табами кода.
*
* Превью иконки рендерится тем же способом, что выбран в табе:
* - React / SVG — через <svg><use href>
* - IMG — через <img src>
* - CSS — через mask-image на <div>
*/
export const IconModal = (props: IconModalProps) => {
const { icon, onClose, defaultSprite, className, ...htmlAttr } = props
const [activeTab, setActiveTab] = useState<TabId>('react')
const [colors, setColors] = useState<Record<string, string>>({})
const [cssColor, setCssColor] = useState('#000000')
const iconWrapRef = useRef<HTMLDivElement>(null)
const handleOverlayClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose()
}
}, [onClose])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
onClose()
}
}
if (icon) {
document.addEventListener('keydown', handleKeyDown)
}
return () => document.removeEventListener('keydown', handleKeyDown)
}, [icon, onClose])
useEffect(() => {
if (!icon) return
const fg = getComputedStyle(document.documentElement).getPropertyValue('--color-fg').trim()
const fgHex = rgbToHex(fg)
const initialColors: Record<string, string> = {}
icon.vars.forEach((v) => {
initialColors[v.varName] = v.isCurrentColor ? fgHex : v.hex
})
setColors(initialColors)
setCssColor(fgHex)
setActiveTab('react')
if (iconWrapRef.current) {
iconWrapRef.current.removeAttribute('style')
}
}, [icon])
if (!icon) {
return null
}
const spriteRef = `${icon.spriteFile}#${icon.id}`
const isDefaultSprite = icon.group === defaultSprite
const codeReact = isDefaultSprite
? `<SvgSprite icon="${icon.id}" />`
: `<SvgSprite icon="${icon.id}" sprite="${icon.group}" />`
const codeSvg = `<svg width="24" height="24">\n <use href="${spriteRef}"/>\n</svg>`
const codeImg = `<img src="${spriteRef}" width="24" height="24" alt="${icon.id}">`
const codeCss = `.${icon.id} {\n width: 24px;\n height: 24px;\n mask: url('${spriteRef}') no-repeat center / contain;\n -webkit-mask: url('${spriteRef}') no-repeat center / contain;\n background-color: ${cssColor};\n}`
const codeByTab: Record<TabId, { code: string; language: 'html' | 'css' }> = {
react: { code: codeReact, language: 'html' },
svg: { code: codeSvg, language: 'html' },
img: { code: codeImg, language: 'html' },
css: { code: codeCss, language: 'css' },
}
const handleColorChange = (varName: string, value: string): void => {
setColors((prev) => ({ ...prev, [varName]: value }))
iconWrapRef.current?.style.setProperty(varName, value)
}
const renderPreview = () => {
switch (activeTab) {
case 'react':
case 'svg':
return (
<svg className={styles.icon}>
<use href={`#${icon.id}`} />
</svg>
)
case 'img':
return (
<img
className={styles.iconImg}
src={`${icon.spriteFile}#${icon.id}`}
width={96}
height={96}
alt={icon.id}
/>
)
case 'css':
return (
<div
className={styles.iconCss}
style={{
mask: `url('${icon.spriteFile}#${icon.id}') no-repeat center / contain`,
WebkitMask: `url('${icon.spriteFile}#${icon.id}') no-repeat center / contain`,
backgroundColor: cssColor,
}}
/>
)
}
}
const supportsColorChange = activeTab === 'react' || activeTab === 'svg'
const isMono = icon.vars.length > 0 && icon.vars.every((v) => v.isCurrentColor)
return (
<div className={styles.overlay} onClick={handleOverlayClick}>
<div {...htmlAttr} className={cl(styles.root, className)}>
<button type="button" className={styles.closeButton} onClick={onClose}>
&#x2715;
</button>
<div ref={iconWrapRef} className={styles.iconWrap}>
<div className={styles.iconViewBox}>
{renderPreview()}
</div>
</div>
<div className={styles.nameRow}>
<div className={styles.name}>{icon.id}</div>
{icon.viewBox && (
<span className={styles.viewBoxBadge}>
{icon.viewBox.width} &times; {icon.viewBox.height}
</span>
)}
</div>
<div className={styles.varsSection}>
{icon.vars.length > 0 && supportsColorChange ? (
<>
<div className={styles.colorHint}>
{isMono ? (
<span>
Иконка наследует цвет текста или задаётся точечно через CSS-переменную.
</span>
) : (
<span>
Многоцветная иконка каждый цвет задаётся отдельной CSS-переменной.
</span>
)}
</div>
<div className={styles.varsTitle}>CSS Variables</div>
{icon.vars.map((v) => (
<ColorPicker
key={v.varName}
value={colors[v.varName] || v.hex}
onValueChange={(color) => handleColorChange(v.varName, color)}
label={`${v.varName}: ${v.fallback}`}
className={styles.varRow}
/>
))}
</>
) : activeTab === 'img' ? (
<div className={styles.hint}>
Управление цветом в режиме IMG невозможно. &lt;img&gt; изолирует SVG CSS-переменные
и currentColor не проникают внутрь. Подходит для многоцветных изображений с фиксированными цветами.
</div>
) : activeTab === 'css' ? (
<>
<div className={styles.hint}>
В режиме CSS mask иконка монохромная цвет задаётся через background-color.
CSS-переменные спрайта не поддерживаются.
</div>
<ColorPicker
value={cssColor}
onValueChange={setCssColor}
label="background-color"
className={styles.cssColorRow}
/>
</>
) : (
<span className={styles.noVars}>No color variables</span>
)}
</div>
<div className={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={cl(styles.tab, activeTab === tab.id && styles._active)}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{TABS.map((tab) => (
<div
key={tab.id}
className={cl(styles.tabContent, activeTab === tab.id && styles._active)}
>
<CodeBlock
code={codeByTab[tab.id].code}
language={codeByTab[tab.id].language}
/>
</div>
))}
</div>
</div>
)
}

View File

@@ -1 +0,0 @@
export { IconModal } from './icon-modal.ui'

View File

@@ -1,211 +0,0 @@
.overlay {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-10) 0;
overflow-y: auto;
background: rgba(0, 0, 0, 0.5);
}
.root {
position: relative;
width: 90%;
max-width: 560px;
padding: var(--space-6);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-3);
}
.closeButton {
position: absolute;
top: var(--space-3);
right: var(--space-3);
padding: var(--space-1) var(--space-2);
background: none;
border: none;
color: var(--color-fg);
font-size: 20px;
cursor: pointer;
line-height: 1;
}
.iconWrap {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
margin-bottom: var(--space-4);
background: var(--color-card-bg);
border-radius: var(--radius-2);
}
.iconViewBox {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-1);
background: var(--checkerboard);
background-size: var(--checkerboard-size);
}
.icon,
.iconImg,
.iconCss {
width: 256px;
height: 256px;
}
.icon {
color: var(--color-fg);
}
.iconImg {
object-fit: contain;
}
.iconCss {
background-color: var(--color-fg);
}
.nameRow {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.name {
font-size: 1rem;
font-weight: 600;
}
.viewBoxBadge {
font-size: 11px;
font-weight: 500;
padding: 2px var(--space-2);
border-radius: 10px;
background: var(--color-card-bg);
border: 1px solid var(--color-border);
color: var(--color-muted);
white-space: nowrap;
}
.varsSection {
margin-bottom: var(--space-5);
}
.varsTitle {
font-size: 12px;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--space-2);
}
.varRow {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.noVars {
font-size: 12px;
color: var(--color-muted);
font-style: italic;
}
.hint {
font-size: 12px;
line-height: 1.5;
color: var(--color-muted);
padding: var(--space-2) var(--space-3);
background: var(--color-card-bg);
border-radius: var(--radius-1);
border-left: 3px solid var(--color-accent);
}
.colorHint {
margin-bottom: var(--space-3);
padding: var(--space-2) var(--space-3);
font-size: 12px;
line-height: 1.5;
color: var(--color-muted);
background: var(--color-card-bg);
border-radius: var(--radius-1);
border-left: 3px solid var(--color-accent);
}
.colorHintTitle {
font-size: 12px;
font-weight: 600;
color: var(--color-muted);
margin-bottom: var(--space-1);
}
.colorHintRow {
display: flex;
align-items: baseline;
gap: var(--space-2);
margin-top: var(--space-1);
}
.colorHintCode {
font-family: var(--font-mono);
font-size: 11px;
padding: 2px var(--space-1);
background: var(--color-bg);
border-radius: 3px;
white-space: nowrap;
}
.colorHintDesc {
font-size: 11px;
color: var(--color-muted);
}
.cssColorRow {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-3);
}
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-3);
}
.tab {
padding: var(--space-2) var(--space-4);
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-muted);
background: none;
}
.tab._active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.tabContent {
display: none;
}
.tabContent._active {
display: block;
}

View File

@@ -1,19 +0,0 @@
import type { HTMLAttributes } from 'react'
import type { IconData } from '../../../shared/types'
/**
* Параметры IconModal.
*/
export type IconModalParams = {
/** Данные иконки для отображения. null — модалка закрыта. */
icon: IconData | null
/** Имя спрайта по умолчанию (первый из конфига). */
defaultSprite?: string
/** Обработчик закрытия модалки. */
onClose: () => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = HTMLAttributes<HTMLDivElement>
export type IconModalProps = RootAttrs & IconModalParams

View File

@@ -1 +0,0 @@
export { SearchInput } from './search-input.ui'

View File

@@ -1,27 +0,0 @@
import cl from 'clsx'
import type { SearchInputProps } from './types/search-input.type'
import styles from './styles/search-input.module.css'
/**
* Поле поиска.
*
* Используется для:
* - фильтрации иконок по имени
*/
export const SearchInput = (props: SearchInputProps) => {
const { className, onValueChange, ...htmlAttr } = props
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
onValueChange?.(e.target.value)
}
return (
<input
{...htmlAttr}
type="search"
autoComplete="off"
onChange={handleChange}
className={cl(styles.root, className)}
/>
)
}

View File

@@ -1,14 +0,0 @@
.root {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-2);
background: var(--color-card-bg);
color: var(--color-fg);
font-size: 14px;
width: 200px;
outline: none;
}
.root:focus {
border-color: var(--color-accent);
}

View File

@@ -1,14 +0,0 @@
import type { InputHTMLAttributes } from 'react'
/**
* Параметры SearchInput.
*/
export type SearchInputParams = {
/** Обработчик изменения значения поиска. */
onValueChange?: (value: string) => void
}
/** HTML-атрибуты корневого элемента. */
type RootAttrs = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'>
export type SearchInputProps = RootAttrs & SearchInputParams

View File

@@ -1,6 +0,0 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,32 +0,0 @@
import { resolve } from 'node:path'
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import { viteSingleFile } from 'vite-plugin-singlefile'
/** Переименовывает index.html → preview-template.html после сборки. */
function renameOutput(): Plugin {
return {
name: 'rename-preview-template',
closeBundle() {
const outDir = resolve(__dirname, '../dist')
const src = resolve(outDir, 'index.html')
const dest = resolve(outDir, 'preview-template.html')
if (existsSync(src)) {
const content = readFileSync(src, 'utf-8')
writeFileSync(dest, content)
unlinkSync(src)
}
},
}
}
export default defineConfig({
plugins: [react(), viteSingleFile(), renameOutput()],
build: {
outDir: resolve(__dirname, '../dist'),
emptyOutDir: false,
},
})

43
src/api/next.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { SpriteGenerationBaseResult } from '../core/result.js'
import { generateSprite } from '../generate.js'
import type {
NextAssetTarget,
NextBundler,
NextRouter,
} from '../targets/types.js'
import type { SpriteConfig } from '../types.js'
/** @deprecated Используйте единый SpriteConfig. */
export type NextSpriteConfig = Omit<SpriteConfig, 'mode'> & {
mode?: NextAssetTarget
}
export type NextSpriteGenerationOptions = {
router: NextRouter
bundler: NextBundler
}
export type NextSpriteGenerationResult = SpriteGenerationBaseResult<
NextAssetTarget,
NextAssetTarget
> & {
router: NextRouter
bundler: NextBundler
}
/** Генерирует Next.js sprite-модуль для явно выбранных router и bundler. */
export async function generateNextSprite(
source: string,
options: NextSpriteGenerationOptions,
): Promise<NextSpriteGenerationResult> {
if (!options || (options.router !== 'app' && options.router !== 'pages')) {
throw new Error(`Unsupported Next.js router: ${String(options?.router)}`)
}
if (options.bundler !== 'turbopack' && options.bundler !== 'webpack') {
throw new Error(`Unsupported Next.js bundler: ${String(options.bundler)}`)
}
return generateSprite(source, {
mode: `next@${options.router}/${options.bundler}`,
}) as Promise<NextSpriteGenerationResult>
}

30
src/api/react.ts Normal file
View File

@@ -0,0 +1,30 @@
import { generateSprite } from '../generate.js'
import type { ReactAssetTarget, ReactSpriteMode } from '../targets/types.js'
import type { ResolvedSpriteConfig, SpriteConfig } from '../types.js'
import type { SpriteGenerationBaseResult } from '../core/result.js'
/** @deprecated Используйте единый SpriteConfig. */
export type ReactSpriteConfig = Omit<SpriteConfig, 'mode'> & {
mode?: ReactSpriteMode
}
/** @deprecated Используйте единый ResolvedSpriteConfig. */
export type ResolvedReactSpriteConfig = Omit<ResolvedSpriteConfig, 'mode'> & {
mode: ReactSpriteMode
}
export type ReactSpriteGenerationResult = SpriteGenerationBaseResult<
ReactSpriteMode,
ReactAssetTarget
>
/** Генерирует React sprite-модуль для явно выбранного asset target. */
export function generateReactSprite(
source: string,
target: ReactAssetTarget,
): Promise<ReactSpriteGenerationResult> {
if (target !== 'vite' && target !== 'webpack') {
throw new Error(`Unsupported React asset target: ${String(target)}`)
}
return generateSprite(source, { mode: `react@${target}` }) as Promise<ReactSpriteGenerationResult>
}

View File

@@ -1,17 +1,7 @@
#!/usr/bin/env node
import path from 'node:path'
import { CLI_USAGE, parseCliArgs } from './cli/parse-args.js'
import { generateSprite } from './generate.js'
import { log } from './logger.js'
import { generateNextSprite } from './modes/next/generate.js'
import { generateReactSprite } from './modes/react/generate.js'
import { loadLegacyConfig } from './modes/legacy/config.js'
import { generateLegacy } from './modes/legacy/generate.js'
async function runLegacy(spritePath: string): Promise<void> {
const rootDir = path.resolve(spritePath)
const config = await loadLegacyConfig(rootDir)
await generateLegacy(config)
}
async function main() {
try {
@@ -22,23 +12,7 @@ async function main() {
return
}
switch (args.mode) {
case 'legacy':
await runLegacy(args.path)
return
case 'react':
await generateReactSprite(args.path, args.target)
return
case 'next': {
const [, routerAndBundler] = args.target.split('@')
const [router, bundler] = routerAndBundler.split('/')
await generateNextSprite(args.path, {
router: router as 'app' | 'pages',
bundler: bundler as 'turbopack' | 'webpack',
})
return
}
}
await generateSprite(args.path, args.overrides)
} catch (error) {
log.error(error instanceof Error ? error.message : String(error))
process.exit(1)

View File

@@ -1,119 +1,143 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
import { isSpriteMode } from '../config.js'
import type { SpriteConfig, TransformOptions } from '../types.js'
import type { CliArgs } from './types.js'
const REACT_TARGETS = new Set<ReactAssetTarget>(['vite', 'webpack'])
const NEXT_TARGETS = new Set<NextAssetTarget>([
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
])
export const CLI_USAGE = [
'Usage:',
' svg-sprites --mode <mode> <path>',
' svg-sprites [options] <config-file-or-directory>',
'',
'Config files:',
' Any explicitly provided .js, .json or .ts file',
' A directory enables config-less generation from CLI options',
'',
'Modes:',
' legacy Generate sprites through the legacy pipeline',
' react@vite Generate a React module for Vite',
' react@webpack Generate a React module for Webpack 5',
' next@app/turbopack Generate an App Router module for Turbopack',
' next@app/webpack Generate an App Router module for Webpack 5',
' next@pages/turbopack Generate a Pages Router module for Turbopack',
' next@pages/webpack Generate a Pages Router module for Webpack 5',
' react@vite',
' react@webpack',
' next@app/turbopack',
' next@app/webpack',
' next@pages/turbopack',
' next@pages/webpack',
'',
'Options:',
' --mode <mode>',
' --name <name>',
' --description <text>',
' --input-folder <path>',
' --input-file <path> Repeat for multiple files',
' --[no-]remove-size',
' --[no-]replace-colors',
' --[no-]add-transition',
' --[no-]generated-notice',
].join('\n')
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) {
return { help: true }
function optionValue(argv: string[], index: number, option: string): [string, number] {
const argument = argv[index]
const inlinePrefix = `${option}=`
if (argument.startsWith(inlinePrefix)) {
const value = argument.slice(inlinePrefix.length)
if (!value) throw new Error(`Missing value for ${option}.`)
return [value, index]
}
let modeValue: string | undefined
const value = argv[index + 1]
if (!value || value.startsWith('-')) throw new Error(`Missing value for ${option}.`)
return [value, index + 1]
}
function setTransform(
overrides: SpriteConfig,
option: keyof TransformOptions,
value: boolean,
): void {
overrides.transform = {
...overrides.transform,
[option]: value,
}
}
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) return { help: true }
const positional: string[] = []
const overrides: SpriteConfig = {}
for (let index = 0; index < argv.length; index++) {
const argument = argv[index]
if (argument === '--mode') {
modeValue = argv[index + 1]
index++
if (argument === '--mode' || argument.startsWith('--mode=')) {
const [value, nextIndex] = optionValue(argv, index, '--mode')
if (!isSpriteMode(value)) throw new Error(`Unsupported sprite mode: ${value}.\n\n${CLI_USAGE}`)
overrides.mode = value
index = nextIndex
continue
}
if (argument === '--name' || argument.startsWith('--name=')) {
const [value, nextIndex] = optionValue(argv, index, '--name')
overrides.name = value
index = nextIndex
continue
}
if (argument === '--description' || argument.startsWith('--description=')) {
const [value, nextIndex] = optionValue(argv, index, '--description')
overrides.description = value
index = nextIndex
continue
}
if (argument === '--input-folder' || argument.startsWith('--input-folder=')) {
const [value, nextIndex] = optionValue(argv, index, '--input-folder')
overrides.inputFolder = value
index = nextIndex
continue
}
if (argument === '--input-file' || argument.startsWith('--input-file=')) {
const [value, nextIndex] = optionValue(argv, index, '--input-file')
overrides.inputFiles = [...(overrides.inputFiles ?? []), value]
index = nextIndex
continue
}
if (argument.startsWith('--mode=')) {
modeValue = argument.slice('--mode='.length)
switch (argument) {
case '--remove-size':
setTransform(overrides, 'removeSize', true)
continue
case '--no-remove-size':
setTransform(overrides, 'removeSize', false)
continue
case '--replace-colors':
setTransform(overrides, 'replaceColors', true)
continue
case '--no-replace-colors':
setTransform(overrides, 'replaceColors', false)
continue
case '--add-transition':
setTransform(overrides, 'addTransition', true)
continue
case '--no-add-transition':
setTransform(overrides, 'addTransition', false)
continue
case '--generated-notice':
overrides.generatedNotice = true
continue
case '--no-generated-notice':
overrides.generatedNotice = false
continue
}
if (argument.startsWith('-')) {
throw new Error(`Unknown argument: ${argument}\n\n${CLI_USAGE}`)
}
positional.push(argument)
}
if (!modeValue) {
throw new Error(`Missing required argument: --mode\n\n${CLI_USAGE}`)
}
if (positional.length === 0) {
throw new Error(`Missing sprite path.\n\n${CLI_USAGE}`)
throw new Error(`Missing sprite config file or module directory.\n\n${CLI_USAGE}`)
}
if (positional.length > 1) {
throw new Error(`Expected one sprite path, received: ${positional.join(', ')}`)
}
if (modeValue === 'legacy') {
return {
mode: 'legacy',
path: positional[0],
}
}
if (modeValue === 'react') {
throw new Error(
'React mode requires a target. Supported: react@vite, react@webpack.',
)
}
if (modeValue.startsWith('react@')) {
const target = modeValue.slice('react@'.length)
if (!REACT_TARGETS.has(target as ReactAssetTarget)) {
throw new Error(
`Unsupported React target: ${target}. Supported: ${[...REACT_TARGETS].join(', ')}.`,
)
throw new Error(`Expected one config file or module directory, received: ${positional.join(', ')}`)
}
return {
mode: 'react',
path: positional[0],
target: target as ReactAssetTarget,
overrides,
}
}
if (modeValue === 'next') {
throw new Error(
`Next.js mode requires a router and bundler. Supported: ${[...NEXT_TARGETS].join(', ')}.`,
)
}
if (modeValue.startsWith('next@')) {
if (!NEXT_TARGETS.has(modeValue as NextAssetTarget)) {
throw new Error(
`Unsupported Next.js target: ${modeValue}. Supported: ${[...NEXT_TARGETS].join(', ')}.`,
)
}
return {
mode: 'next',
path: positional[0],
target: modeValue as NextAssetTarget,
}
}
throw new Error(
`Unknown mode: ${modeValue}\nSupported modes: legacy, react@vite, react@webpack, ${[...NEXT_TARGETS].join(', ')}`,
)
}

View File

@@ -1,26 +1,6 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
import type { SpriteConfig } from '../types.js'
/** Корневой режим генерации, определяющий структуру создаваемых файлов. */
export type GenerationMode = 'legacy' | 'next' | 'react'
/** Аргументы legacy pipeline. */
export type LegacyCliArgs = {
mode: 'legacy'
export type CliArgs = {
path: string
overrides: SpriteConfig
}
/** Аргументы React pipeline с обязательной средой обработки SVG asset. */
export type ReactCliArgs = {
mode: 'react'
path: string
target: ReactAssetTarget
}
/** Аргументы Next.js pipeline с явно выбранными роутером и сборщиком. */
export type NextCliArgs = {
mode: 'next'
path: string
target: NextAssetTarget
}
export type CliArgs = LegacyCliArgs | NextCliArgs | ReactCliArgs

232
src/config.ts Normal file
View File

@@ -0,0 +1,232 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import { toKebabCase, validateSpriteName } from './core/naming.js'
import type { SpriteMode } from './targets/types.js'
import type { ResolvedSpriteConfig, SpriteConfig, TransformOptions } from './types.js'
const CONFIG_EXTENSIONS = new Set(['.js', '.json', '.ts'])
const CONFIG_FIELDS = new Set([
'mode',
'name',
'description',
'inputFolder',
'inputFiles',
'transform',
'generatedNotice',
])
const TRANSFORM_FIELDS = new Set(['removeSize', 'replaceColors', 'addTransition'])
const MODES = new Set<SpriteMode>([
'react@vite',
'react@webpack',
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
])
export type SpriteConfigSource = {
rootDir: string
configPath: string | null
config: SpriteConfig
}
export function isSpriteMode(value: unknown): value is SpriteMode {
return typeof value === 'string' && MODES.has(value as SpriteMode)
}
function getDefaultName(rootDir: string): string {
const rootName = path.basename(rootDir)
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
? path.basename(path.dirname(rootDir))
: rootName
const name = toKebabCase(source)
if (!name) throw new Error(`Cannot infer sprite name from directory: ${rootDir}`)
return name
}
function configError(message: string): Error {
return new Error(`Sprite config: ${message}`)
}
/** Проверяет единый конфиг независимо от формата исходного файла. */
export function validateSpriteConfig(value: unknown): asserts value is SpriteConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw configError('expected an object.')
}
const config = value as Record<string, unknown>
if ('output' in config || 'sprites' in config || 'preview' in config) {
throw configError('legacy config fields are no longer supported.')
}
for (const field of Object.keys(config)) {
if (!CONFIG_FIELDS.has(field)) throw configError(`unknown field "${field}".`)
}
if (config.mode !== undefined && !isSpriteMode(config.mode)) {
throw configError(`unsupported "mode": ${String(config.mode)}.`)
}
if (config.name !== undefined && typeof config.name !== 'string') {
throw configError('"name" must be a string.')
}
if (config.description !== undefined && typeof config.description !== 'string') {
throw configError('"description" must be a string.')
}
if ('icons' in config) {
throw configError('"icons" was renamed to "inputFolder".')
}
if (config.inputFolder !== undefined && (
typeof config.inputFolder !== 'string' || config.inputFolder.trim() === ''
)) {
throw configError('"inputFolder" must be a non-empty string.')
}
if (config.inputFiles !== undefined && (
!Array.isArray(config.inputFiles)
|| config.inputFiles.some((filePath) => typeof filePath !== 'string' || filePath.trim() === '')
)) {
throw configError('"inputFiles" must be an array of non-empty strings.')
}
if (config.transform !== undefined) {
if (
config.transform === null
|| typeof config.transform !== 'object'
|| Array.isArray(config.transform)
) {
throw configError('"transform" must be an object.')
}
const transform = config.transform as Record<string, unknown>
for (const field of Object.keys(transform)) {
if (!TRANSFORM_FIELDS.has(field)) {
throw configError(`unknown field "transform.${field}".`)
}
}
for (const option of ['removeSize', 'replaceColors', 'addTransition']) {
if (transform[option] !== undefined && typeof transform[option] !== 'boolean') {
throw configError(`"transform.${option}" must be a boolean.`)
}
}
}
if (config.generatedNotice !== undefined && typeof config.generatedNotice !== 'boolean') {
throw configError('"generatedNotice" must be a boolean.')
}
}
function getModuleDefault(value: unknown, configPath: string): unknown {
if (!value || typeof value !== 'object' || !('default' in value)) {
throw new Error(
`Sprite config file must have a default export: ${configPath}\n`
+ 'Use: export default defineSpriteConfig({ ... })',
)
}
return (value as { default: unknown }).default
}
/** Загружает явно указанный JS, JSON или TS config-файл. */
export async function loadSpriteConfig(configFile: string): Promise<SpriteConfig> {
const configPath = path.resolve(configFile)
const extension = path.extname(configPath).toLowerCase()
if (!CONFIG_EXTENSIONS.has(extension)) {
throw new Error(`Unsupported sprite config extension: ${extension || '(none)'}. Supported: .js, .json, .ts.`)
}
if (!fs.existsSync(configPath) || !fs.statSync(configPath).isFile()) {
throw new Error(`Sprite config file does not exist: ${configPath}`)
}
let config: unknown
if (extension === '.json') {
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`Cannot parse sprite config: ${configPath}\n${reason}`)
}
} else {
const jiti = createJiti(path.dirname(configPath))
config = getModuleDefault(await jiti.import(configPath), configPath)
}
validateSpriteConfig(config)
return config
}
/** Разрешает позиционный путь как config-файл либо config-less корень модуля. */
export async function resolveSpriteConfigSource(source: string): Promise<SpriteConfigSource> {
const resolved = path.resolve(source)
if (!fs.existsSync(resolved)) {
throw new Error(`Sprite config or module directory does not exist: ${resolved}`)
}
const stats = fs.statSync(resolved)
if (stats.isDirectory()) {
return { rootDir: resolved, configPath: null, config: {} }
}
if (!stats.isFile()) {
throw new Error(`Sprite config path must be a file or directory: ${resolved}`)
}
return {
rootDir: path.dirname(resolved),
configPath: resolved,
config: await loadSpriteConfig(resolved),
}
}
function definedEntries<T extends object>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined),
) as Partial<T>
}
/** Объединяет defaults, config и overrides и разрешает пути от корня модуля. */
export function resolveSpriteConfig(
rootDir: string,
config: SpriteConfig = {},
overrides: SpriteConfig = {},
): ResolvedSpriteConfig {
validateSpriteConfig(config)
validateSpriteConfig(overrides)
const configValues = definedEntries(config)
const overrideValues = definedEntries(overrides)
const transform: TransformOptions = {
...config.transform,
...overrides.transform,
}
const merged: SpriteConfig = {
...configValues,
...overrideValues,
transform,
}
if (!merged.mode) {
throw new Error('Sprite mode is required. Set "mode" in the config or pass it through CLI/API.')
}
const name = merged.name ?? getDefaultName(rootDir)
validateSpriteName(name)
const inputFiles = (merged.inputFiles ?? []).map((filePath) => path.resolve(rootDir, filePath))
const defaultInputFolder = path.resolve(rootDir, 'icons')
const inputFolder = merged.inputFolder === undefined
&& inputFiles.length > 0
&& !fs.existsSync(defaultInputFolder)
? null
: path.resolve(rootDir, merged.inputFolder ?? 'icons')
return {
mode: merged.mode,
name,
description: merged.description,
inputFolder,
inputFiles,
transform: {
removeSize: transform.removeSize ?? true,
replaceColors: transform.replaceColors ?? true,
addTransition: transform.addTransition ?? true,
},
generatedNotice: merged.generatedNotice ?? true,
}
}

View File

@@ -0,0 +1,70 @@
import { getSpriteShapeId } from '../shape-id.js'
import type { SpriteFormat } from '../types.js'
export type CompiledIconColor = {
readonly variable: string
readonly fallback: string
}
export type CompiledIcon = {
readonly name: string
readonly id: string
readonly viewBox: string | null
readonly colors: readonly CompiledIconColor[]
}
export type CompiledSpriteArtifact = {
readonly format: SpriteFormat
readonly bytes: Uint8Array
readonly icons: readonly CompiledIcon[]
}
function extractColors(fragment: string): CompiledIconColor[] {
const colors = new Map<string, string>()
const regex = /var\((--icon-color-\d+),\s*((?:[^()]|\([^()]*\))*)\)/g
let match: RegExpExecArray | null
while ((match = regex.exec(fragment)) !== null) {
if (!colors.has(match[1])) colors.set(match[1], match[2].trim())
}
return [...colors].map(([variable, fallback]) => ({ variable, fallback }))
}
/** Преобразует compiled SVG в нейтральный artifact для adapter codegen. */
export function createCompiledSpriteArtifact(
bytes: Uint8Array,
iconNames: readonly string[],
format: SpriteFormat,
): CompiledSpriteArtifact {
const shapes = new Map<string, { viewBox: string | null; fragment: string }>()
const content = new TextDecoder().decode(bytes)
const shapeRegex = /<(symbol|svg)\b((?=[^>]*\bid="[^"]+")[^>]*)>[\s\S]*?<\/\1>/g
let match: RegExpExecArray | null
while ((match = shapeRegex.exec(content)) !== null) {
const attributes = match[2]
const id = attributes.match(/\bid="([^"]+)"/)?.[1]
if (!id) continue
shapes.set(id, {
viewBox: attributes.match(/\bviewBox="([^"]+)"/)?.[1] ?? null,
fragment: match[0],
})
}
const icons = iconNames.map((name) => {
const id = getSpriteShapeId(name)
const shape = shapes.get(id)
if (!shape) throw new Error(`Cannot find SVG shape "${id}" for icon "${name}".`)
return {
name,
id,
viewBox: shape.viewBox,
colors: extractColors(shape.fragment),
}
})
return { format, bytes, icons }
}

View File

@@ -0,0 +1,3 @@
export const GENERATOR = '@gromlab/svg-sprites'
export const GENERATED_MARKER = '@generated by @gromlab/svg-sprites'
export const GENERATED_NOTICE_MARKER = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'

53
src/core/mode-adapter.ts Normal file
View File

@@ -0,0 +1,53 @@
import type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
SpriteMode,
} from '../targets/types.js'
import type { ResolvedSpriteConfig, SpriteFolder } from '../types.js'
export type GeneratedFile = {
readonly path: string
readonly content: string | Uint8Array
}
export type PreparedSprite = {
readonly folder: SpriteFolder
readonly iconNames: readonly string[]
}
export type ModeAdapterContext = {
readonly rootDir: string
readonly config: ResolvedSpriteConfig
readonly prepared: PreparedSprite
}
export type ReactModeResultMetadata = {
readonly target: ReactAssetTarget
}
export type NextModeResultMetadata = {
readonly target: NextAssetTarget
readonly router: NextRouter
readonly bundler: NextBundler
}
export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadata
export type OutputPlan = {
readonly files: readonly GeneratedFile[]
readonly paths: {
readonly generatedDir: '.svg-sprite'
readonly sprite: string
readonly manifest: string
readonly entry: string
}
readonly result: ModeResultMetadata
}
export interface ModeAdapter<M extends SpriteMode = SpriteMode> {
readonly mode: M
readonly contractVersion: number
generate(context: ModeAdapterContext): Promise<OutputPlan>
}

17
src/core/naming.ts Normal file
View File

@@ -0,0 +1,17 @@
/** Преобразует имя каталога в kebab-case имя спрайта. */
export function toKebabCase(value: string): string {
return value
.normalize('NFKD')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase()
}
export function validateSpriteName(name: string): void {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) {
throw new Error(
`Sprite config: "name" must be kebab-case and start with a letter. Received: "${name}".`,
)
}
}

297
src/core/output-writer.ts Normal file
View File

@@ -0,0 +1,297 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteMode } from '../targets/types.js'
import type { GeneratedFile } from './mode-adapter.js'
import {
GENERATED_MARKER,
GENERATED_NOTICE_MARKER,
GENERATOR,
} from './generated-markers.js'
const STATE_PATH = '.svg-sprite/state.json'
const PREVIOUS_STATE_PATHS = [
'.svg-sprite-data/state.json',
'generated/state.json',
] as const
const LEGACY_STATE_PATH = 'generated/.svg-sprites.manifest.json'
const DATA_ROOT = '.svg-sprite'
const PREVIOUS_DATA_ROOT = '.svg-sprite-data'
const SYSTEM_FILES: readonly GeneratedFile[] = [
{
path: '.gitignore',
content: `# ${GENERATED_MARKER}. Do not edit.\n/${DATA_ROOT}/\n`,
},
]
type OutputState = {
schemaVersion: 1
generator: typeof GENERATOR
owner: {
mode: SpriteMode
contractVersion: number
}
files: string[]
warning?: string
}
type LegacyState = {
version: 1
generator: typeof GENERATOR
files: string[]
}
function normalizeManagedPath(relativePath: string): string {
const normalized = relativePath.replaceAll('\\', '/')
const parts = normalized.split('/')
if (
normalized === ''
|| path.posix.isAbsolute(normalized)
|| parts.some((part) => part === '' || part === '.' || part === '..')
|| normalized === STATE_PATH
|| PREVIOUS_STATE_PATHS.includes(normalized as typeof PREVIOUS_STATE_PATHS[number])
|| normalized === LEGACY_STATE_PATH
) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return normalized
}
function resolveManagedPath(rootDir: string, relativePath: string): string {
const normalized = normalizeManagedPath(relativePath)
const resolved = path.resolve(rootDir, normalized)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return resolved
}
function resolveInternalPath(rootDir: string, relativePath: string): string {
const resolved = path.resolve(rootDir, relativePath)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid internal output path: ${relativePath}`)
}
return resolved
}
function assertNoSymlinks(rootDir: string, filePath: string): void {
const relative = path.relative(rootDir, filePath)
let currentPath = rootDir
for (const segment of relative.split(path.sep)) {
currentPath = path.join(currentPath, segment)
try {
if (fs.lstatSync(currentPath).isSymbolicLink()) {
throw new Error(`Symbolic links are not allowed in generated paths: ${currentPath}`)
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw error
}
}
}
function parseJsonFile(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
} catch {
throw new Error(`Cannot parse generated state: ${filePath}`)
}
}
function readOutputState(statePath: string): OutputState {
const state = parseJsonFile(statePath)
if (
!state
|| typeof state !== 'object'
|| !('schemaVersion' in state)
|| state.schemaVersion !== 1
|| !('generator' in state)
|| state.generator !== GENERATOR
|| !('owner' in state)
|| !state.owner
|| typeof state.owner !== 'object'
|| !('mode' in state.owner)
|| typeof state.owner.mode !== 'string'
|| !('contractVersion' in state.owner)
|| typeof state.owner.contractVersion !== 'number'
|| !('files' in state)
|| !Array.isArray(state.files)
|| !state.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated state: ${statePath}`)
}
return state as OutputState
}
function readPreviousFiles(rootDir: string): {
files: string[]
obsoleteStatePath: string | null
} {
for (const relativeStatePath of [STATE_PATH, ...PREVIOUS_STATE_PATHS]) {
const statePath = resolveInternalPath(rootDir, relativeStatePath)
assertNoSymlinks(rootDir, statePath)
if (!fs.existsSync(statePath)) continue
return {
files: readOutputState(statePath).files.map(normalizeManagedPath),
obsoleteStatePath: relativeStatePath === STATE_PATH ? null : statePath,
}
}
const legacyStatePath = resolveInternalPath(rootDir, LEGACY_STATE_PATH)
assertNoSymlinks(rootDir, legacyStatePath)
if (!fs.existsSync(legacyStatePath)) return { files: [], obsoleteStatePath: null }
const legacyState = parseJsonFile(legacyStatePath)
if (
!legacyState
|| typeof legacyState !== 'object'
|| !('version' in legacyState)
|| legacyState.version !== 1
|| !('generator' in legacyState)
|| legacyState.generator !== GENERATOR
|| !('files' in legacyState)
|| !Array.isArray(legacyState.files)
|| !legacyState.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated state: ${legacyStatePath}`)
}
return {
files: (legacyState as LegacyState).files.map(normalizeManagedPath),
obsoleteStatePath: legacyStatePath,
}
}
function removeEmptyStateDirectory(rootDir: string, statePath: string): void {
const directory = path.dirname(statePath)
if (directory === rootDir) return
try {
fs.rmdirSync(directory)
} catch (error) {
if (error instanceof Error && 'code' in error && (
error.code === 'ENOENT' || error.code === 'ENOTEMPTY'
)) return
throw error
}
}
function removeEmptyManagedParents(rootDir: string, filePath: string): void {
for (const relativeDataRoot of [DATA_ROOT, PREVIOUS_DATA_ROOT]) {
const dataRoot = resolveInternalPath(rootDir, relativeDataRoot)
let directory = path.dirname(filePath)
const relative = path.relative(dataRoot, directory)
if (relative.startsWith('..') || path.isAbsolute(relative)) continue
while (directory !== dataRoot) {
try {
fs.rmdirSync(directory)
} catch (error) {
if (error instanceof Error && 'code' in error && (
error.code === 'ENOENT' || error.code === 'ENOTEMPTY'
)) return
throw error
}
directory = path.dirname(directory)
}
return
}
}
function hasGeneratedMarker(filePath: string): boolean {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
const content = fs.readFileSync(filePath, 'utf-8')
return content.includes(GENERATED_MARKER) || content.includes(GENERATED_NOTICE_MARKER)
}
function writeFileAtomic(rootDir: string, filePath: string, content: string | Uint8Array): void {
assertNoSymlinks(rootDir, filePath)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
assertNoSymlinks(rootDir, filePath)
const temporaryPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
)
try {
fs.writeFileSync(temporaryPath, content, { flag: 'wx' })
fs.renameSync(temporaryPath, filePath)
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath)
}
}
/** Применяет mode output plan и последним записывает ownership state. */
export function writeOutputPlan(
rootDir: string,
mode: SpriteMode,
contractVersion: number,
files: readonly GeneratedFile[],
generatedNotice: boolean,
): void {
const normalizedFiles = [...SYSTEM_FILES, ...files].map((file) => ({
...file,
path: normalizeManagedPath(file.path),
}))
const nextFiles = normalizedFiles.map((file) => file.path)
if (new Set(nextFiles).size !== nextFiles.length) {
throw new Error(`Mode "${mode}" produced duplicate generated file paths.`)
}
const previous = readPreviousFiles(rootDir)
const obsoleteFiles: string[] = []
for (const file of normalizedFiles) {
const filePath = resolveManagedPath(rootDir, file.path)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath) && !hasGeneratedMarker(filePath)) {
throw new Error(
`Refusing to overwrite a user file: ${filePath}\n`
+ 'Move the file or choose another sprite directory.',
)
}
}
for (const relativePath of previous.files) {
if (nextFiles.includes(relativePath)) continue
const filePath = resolveManagedPath(rootDir, relativePath)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath)) {
if (!hasGeneratedMarker(filePath)) {
throw new Error(`Refusing to delete a user file: ${filePath}`)
}
obsoleteFiles.push(filePath)
}
}
for (const file of normalizedFiles) {
writeFileAtomic(rootDir, resolveManagedPath(rootDir, file.path), file.content)
}
for (const filePath of obsoleteFiles) fs.unlinkSync(filePath)
for (const filePath of obsoleteFiles) removeEmptyManagedParents(rootDir, filePath)
if (previous.obsoleteStatePath && fs.existsSync(previous.obsoleteStatePath)) {
fs.unlinkSync(previous.obsoleteStatePath)
removeEmptyStateDirectory(rootDir, previous.obsoleteStatePath)
}
const state: OutputState = {
schemaVersion: 1,
generator: GENERATOR,
owner: { mode, contractVersion },
files: nextFiles,
...(generatedNotice && {
warning: 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную: изменения будут перезаписаны.',
}),
}
const statePath = resolveInternalPath(rootDir, STATE_PATH)
writeFileAtomic(rootDir, statePath, `${JSON.stringify(state, null, 2)}\n`)
}

View File

@@ -0,0 +1,38 @@
import path from 'node:path'
import { resolveSpriteSources } from '../scanner.js'
import { getSpriteShapeId } from '../shape-id.js'
import type { ResolvedSpriteConfig } from '../types.js'
import type { PreparedSprite } from './mode-adapter.js'
function validateIconIds(iconNames: readonly string[]): void {
const namesById = new Map<string, string>()
for (const iconName of iconNames) {
const id = getSpriteShapeId(iconName)
const existingName = namesById.get(id)
if (existingName) {
throw new Error(
`Icons "${existingName}" and "${iconName}" produce the same SVG id "${id}". Rename one of the files.`,
)
}
namesById.set(id, iconName)
}
}
/** Подготавливает mode-neutral набор исходников. */
export function prepareSprite(config: ResolvedSpriteConfig): PreparedSprite {
const folder = resolveSpriteSources({
name: config.name,
format: 'stack',
inputFolder: config.inputFolder,
inputFiles: config.inputFiles,
})
const iconNames = folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
validateIconIds(iconNames)
return { folder, iconNames }
}

15
src/core/result.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { SpriteAssetTarget, SpriteMode } from '../targets/types.js'
export type SpriteGenerationBaseResult<
TMode extends SpriteMode = SpriteMode,
TTarget extends SpriteAssetTarget = SpriteAssetTarget,
> = {
name: string
rootDir: string
generatedDir: string
spritePath: string
manifestPath: string
iconCount: number
mode: TMode
target: TTarget
}

65
src/generate.ts Normal file
View File

@@ -0,0 +1,65 @@
import path from 'node:path'
import {
resolveSpriteConfig,
resolveSpriteConfigSource,
} from './config.js'
import type { ModeResultMetadata } from './core/mode-adapter.js'
import { writeOutputPlan } from './core/output-writer.js'
import { prepareSprite } from './core/prepare-sprite.js'
import type { SpriteGenerationBaseResult } from './core/result.js'
import { log } from './logger.js'
import { getModeAdapter } from './mode-registry.js'
import type { SpriteConfig } from './types.js'
export type SpriteGenerationResult = SpriteGenerationBaseResult & ModeResultMetadata
/** Генерирует один output через adapter разрешённого exact mode. */
export async function generateSprite(
source: string,
overrides: SpriteConfig = {},
): Promise<SpriteGenerationResult> {
const resolvedSource = await resolveSpriteConfigSource(source)
const config = resolveSpriteConfig(
resolvedSource.rootDir,
resolvedSource.config,
overrides,
)
const adapter = getModeAdapter(config.mode)
const prepared = prepareSprite(config)
const plan = await adapter.generate({
rootDir: resolvedSource.rootDir,
config,
prepared,
})
const plannedPaths = new Set(plan.files.map((file) => file.path.replaceAll('\\', '/')))
for (const requiredPath of [plan.paths.entry, plan.paths.sprite, plan.paths.manifest]) {
if (!plannedPaths.has(requiredPath)) {
throw new Error(`Mode "${config.mode}" result path is missing from its output plan: ${requiredPath}`)
}
}
writeOutputPlan(
resolvedSource.rootDir,
adapter.mode,
adapter.contractVersion,
plan.files,
config.generatedNotice,
)
const generatedDir = path.resolve(resolvedSource.rootDir, plan.paths.generatedDir)
const iconLabel = prepared.iconNames.length === 1 ? 'icon' : 'icons'
log.success(`${config.name} · ${prepared.iconNames.length} ${iconLabel} · ${config.mode}`)
log.detail(`${path.relative(process.cwd(), generatedDir)}`)
return {
name: config.name,
rootDir: resolvedSource.rootDir,
generatedDir,
spritePath: path.resolve(resolvedSource.rootDir, plan.paths.sprite),
manifestPath: path.resolve(resolvedSource.rootDir, plan.paths.manifest),
iconCount: prepared.iconNames.length,
mode: config.mode,
...plan.result,
}
}

View File

@@ -1,33 +1,36 @@
import type { SvgSpritesConfig } from './types.js'
import type { NextSpriteConfig } from './modes/next/types.js'
import type { ReactSpriteConfig } from './modes/react/types.js'
import type { NextSpriteConfig } from './api/next.js'
import type { ReactSpriteConfig } from './api/react.js'
import type { SpriteConfig } from './types.js'
export { generateLegacy } from './modes/legacy/generate.js'
export { resolveSprites, resolveSpriteEntry } from './scanner.js'
export {
isSpriteMode,
loadSpriteConfig,
resolveSpriteConfig,
resolveSpriteConfigSource,
validateSpriteConfig,
} from './config.js'
export { generateSprite } from './generate.js'
export type { SpriteGenerationResult } from './generate.js'
export { generateNextSprite } from './api/next.js'
export { generateReactSprite } from './api/react.js'
export { compileSprite, compileSpriteContent } from './compiler.js'
export type { CompileSpriteOptions } from './compiler.js'
export { createShapeTransform } from './transforms.js'
export { generatePreview } from './preview.js'
export { loadLegacyConfig } from './modes/legacy/config.js'
export { generateNextSprite } from './modes/next/index.js'
export {
generateReactSprite,
loadReactSpriteConfig,
} from './modes/react/index.js'
export type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
ReactSpriteMode,
SpriteAssetTarget,
SpriteMode,
ViteAssetTarget,
WebpackAssetTarget,
} from './targets/types.js'
export type {
SvgSpritesConfig,
SpriteEntry,
SpriteResult,
ResolvedSpriteConfig,
SpriteConfig,
SpriteFolder,
SpriteFormat,
TransformOptions,
@@ -36,24 +39,24 @@ export type {
NextSpriteConfig,
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './modes/next/types.js'
} from './api/next.js'
export type {
ReactSpriteConfig,
ReactSpriteGenerationResult,
ResolvedReactSpriteConfig,
} from './modes/react/types.js'
} from './api/react.js'
/** Хелпер для типизации legacy-конфига. */
export function defineLegacyConfig(config: SvgSpritesConfig): SvgSpritesConfig {
/** Хелпер для типизации единого sprite-конфига. */
export function defineSpriteConfig(config: SpriteConfig): SpriteConfig {
return config
}
/** Хелпер для типизации локального React-конфига. */
/** @deprecated Используйте defineSpriteConfig. */
export function defineReactSpriteConfig(config: ReactSpriteConfig): ReactSpriteConfig {
return config
}
/** Хелпер для типизации локального Next.js-конфига. */
/** @deprecated Используйте defineSpriteConfig. */
export function defineNextSpriteConfig(config: NextSpriteConfig): NextSpriteConfig {
return config
}

21
src/mode-registry.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { ModeAdapter } from './core/mode-adapter.js'
import { nextAppTurbopackAdapter } from './modes/next-app-turbopack/adapter.js'
import { nextAppWebpackAdapter } from './modes/next-app-webpack/adapter.js'
import { nextPagesTurbopackAdapter } from './modes/next-pages-turbopack/adapter.js'
import { nextPagesWebpackAdapter } from './modes/next-pages-webpack/adapter.js'
import { reactViteAdapter } from './modes/react-vite/adapter.js'
import { reactWebpackAdapter } from './modes/react-webpack/adapter.js'
import type { SpriteMode } from './targets/types.js'
const modeRegistry: Record<SpriteMode, ModeAdapter> = {
'react@vite': reactViteAdapter,
'react@webpack': reactWebpackAdapter,
'next@app/turbopack': nextAppTurbopackAdapter,
'next@app/webpack': nextAppWebpackAdapter,
'next@pages/turbopack': nextPagesTurbopackAdapter,
'next@pages/webpack': nextPagesWebpackAdapter,
}
export function getModeAdapter(mode: SpriteMode): ModeAdapter {
return modeRegistry[mode]
}

View File

@@ -1,74 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import type { SvgSpritesConfig } from '../../types.js'
const CONFIG_NAME = 'svg-sprites.config'
/** Загружает legacy-конфиг из указанной директории. */
export async function loadLegacyConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
const configPath = path.join(cwd, `${CONFIG_NAME}.ts`)
if (!fs.existsSync(configPath)) {
throw new Error(
`Config file not found: ${configPath}\n` +
`Create a ${CONFIG_NAME}.ts file in the project root.`,
)
}
const jiti = createJiti(cwd)
const mod = await jiti.import(configPath) as { default?: SvgSpritesConfig }
const config = mod.default
if (!config) {
throw new Error(
`Config file must have a default export: ${configPath}\n` +
'Use: export default defineLegacyConfig({ ... })',
)
}
validateLegacyConfig(config)
return {
...config,
output: path.resolve(cwd, config.output),
sprites: config.sprites.map((sprite) => ({
...sprite,
input: Array.isArray(sprite.input)
? sprite.input.map((filePath) => path.resolve(cwd, filePath))
: path.resolve(cwd, sprite.input),
})),
}
}
/** Валидирует legacy-конфиг. */
export function validateLegacyConfig(config: SvgSpritesConfig): void {
if (!config.output) {
throw new Error('Config: "output" is required.')
}
if (!config.sprites || config.sprites.length === 0) {
throw new Error('Config: "sprites" must be a non-empty array.')
}
for (const sprite of config.sprites) {
if ('mode' in sprite) {
throw new Error(
`Config: sprite "${sprite.name}" uses deprecated "mode". Use "format" instead.`,
)
}
if (!sprite.name) {
throw new Error('Config: each sprite must have a "name".')
}
if (!sprite.input) {
throw new Error(`Config: sprite "${sprite.name}" must have an "input".`)
}
if (sprite.format && sprite.format !== 'stack' && sprite.format !== 'symbol') {
throw new Error(
`Config: sprite "${sprite.name}" has invalid format "${sprite.format}". Supported: stack, symbol.`,
)
}
}
}

View File

@@ -1,52 +0,0 @@
import path from 'node:path'
import { compileSprite } from '../../compiler.js'
import { log } from '../../logger.js'
import { generatePreview } from '../../preview.js'
import { resolveSprites } from '../../scanner.js'
import type { SpriteResult, SvgSpritesConfig } from '../../types.js'
/** Генерирует SVG-спрайты через legacy pipeline. */
export async function generateLegacy(config: SvgSpritesConfig): Promise<SpriteResult[]> {
const {
output,
preview = true,
transform = {},
sprites,
} = config
const outputDir = path.resolve(output)
log.title('Resolving legacy sprites...')
const folders = resolveSprites(sprites)
if (folders.length === 0) {
log.warn('No sprites to generate.')
return []
}
log.info(`Found ${folders.length} sprite(s)\n`)
const results: SpriteResult[] = []
for (const folder of folders) {
const spritePath = await compileSprite(folder, outputDir, transform)
log.success(` [${folder.format}] ${folder.name}${path.relative(process.cwd(), spritePath)} (${folder.files.length} icons)`)
results.push({
name: folder.name,
format: folder.format,
spritePath,
iconCount: folder.files.length,
})
}
if (preview) {
const previewPath = generatePreview(results, outputDir)
log.success(`\n [preview] → ${path.relative(process.cwd(), previewPath)}`)
}
console.log('')
log.success(`Done! Generated ${results.length} sprite(s).`)
return results
}

View File

@@ -1,2 +0,0 @@
export { loadLegacyConfig, validateLegacyConfig } from './config.js'
export { generateLegacy } from './generate.js'

View File

@@ -0,0 +1,26 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextAppTurbopackAdapter: ModeAdapter<'next@app/turbopack'> = {
mode: 'next@app/turbopack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: true,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',
manifest: '.svg-sprite/svg-sprite.manifest.js',
entry: '.svg-sprite/index.js',
},
result: { target: 'next@app/turbopack', router: 'app', bundler: 'turbopack' },
}
},
}

View File

@@ -0,0 +1,170 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@app/turbopack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function pascal(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function camel(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function header(enabled: boolean): string {
return enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function comment(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
const names = artifact.icons.map((icon) => icon.name)
const ids = Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id]))
return [header(config.generatedNotice), '', ...comment(config), `export const ${prefix}IconNames = ${JSON.stringify(names, null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(ids, null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
"import { jsx } from 'react/jsx-runtime'",
"import styles from './react-component.module.css'",
`import { ${ids} } from '../icon-data.js'`,
'',
"const spriteUrl = new URL('../sprite.svg', import.meta.url).href",
'',
`export const ${componentName} = (props) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = spriteUrl + '#' + ${ids}[icon]`,
' if (wrapped) {',
" return jsx('span', {",
' ...rest,',
" className: [styles.wrap, className].filter(Boolean).join(' '),",
" children: jsx('svg', { children: jsx('use', { href }) }),",
' })',
' }',
" return jsx('svg', {",
' ...rest,',
" className: [styles.root, className].filter(Boolean).join(' '),",
" children: jsx('use', { href }),",
' })',
'}',
'',
].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'',
...comment(config),
`export declare const ${names}: readonly [`,
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
']',
`export type ${typeName} = typeof ${names}[number]`,
`export declare const ${ids}: {`,
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
'}',
'',
].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice),
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
`import type { ${typeName} } from '../icon-data.js'`,
'',
`export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
`type ${componentName}BaseProps = { icon: ${typeName} }`,
`type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`,
`type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`,
`export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`,
`export declare const ${componentName}: (props: ${propsName}) => ReactElement`,
'',
].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [
header(config.generatedNotice),
`export { ${componentName} } from './react/react-component.js'`,
`export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`,
`export { ${names} } from './icon-data.js'`,
`export type { ${componentName}Name } from './icon-data.js'`,
'',
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
componentName: `${pascal(config.name)}Icon`,
mode: MODE,
target: MODE,
format: artifact.format,
iconCount: artifact.icons.length,
spriteUrl: '__SPRITE_URL__',
icons: artifact.icons,
}
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
}
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -0,0 +1,18 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextAppWebpackAdapter: ModeAdapter<'next@app/webpack'> = {
mode: 'next@app/webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
result: { target: 'next@app/webpack', router: 'app', bundler: 'webpack' },
}
},
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@app/webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
}
function docs(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const name = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -0,0 +1,18 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextPagesTurbopackAdapter: ModeAdapter<'next@pages/turbopack'> = {
mode: 'next@pages/turbopack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
result: { target: 'next@pages/turbopack', router: 'pages', bundler: 'turbopack' },
}
},
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@pages/turbopack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
}
function docs(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const name = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -0,0 +1,18 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextPagesWebpackAdapter: ModeAdapter<'next@pages/webpack'> = {
mode: 'next@pages/webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: { generatedDir: '.svg-sprite', sprite: '.svg-sprite/sprite.svg', manifest: '.svg-sprite/svg-sprite.manifest.js', entry: '.svg-sprite/index.js' },
result: { target: 'next@pages/webpack', router: 'pages', bundler: 'webpack' },
}
},
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'next@pages/webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
const pascal = (value: string) => value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
const camel = (value: string) => value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
const header = (enabled: boolean) => enabled ? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */` : `/* ${GENERATED_MARKER}. Do not edit. */`
function markedSvg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml') ? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`) : `<!-- ${marker} -->\n${content}`
}
function docs(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [header(config.generatedNotice), '', ...docs(config), `export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`, '', `export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`, ''].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const name = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), "import { jsx } from 'react/jsx-runtime'", "import styles from './react-component.module.css'", `import { ${ids} } from '../icon-data.js'`, '', "const spriteUrl = new URL('../sprite.svg', import.meta.url).href", '', `export const ${name} = (props) => {`, ' const { icon, wrapped, className, ...rest } = props', ` const href = spriteUrl + '#' + ${ids}[icon]`, ' if (wrapped) {', " return jsx('span', {", ' ...rest,', " className: [styles.wrap, className].filter(Boolean).join(' '),", " children: jsx('svg', { children: jsx('use', { href }) }),", ' })', ' }', " return jsx('svg', {", ' ...rest,', " className: [styles.root, className].filter(Boolean).join(' '),", " children: jsx('use', { href }),", ' })', '}', ''].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [header(config.generatedNotice), '', ...docs(config), `export declare const ${names}: readonly [`, ...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`), ']', `export type ${typeName} = typeof ${names}[number]`, `export declare const ${ids}: {`, ...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`), '}', ''].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [header(config.generatedNotice), "import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'", `import type { ${typeName} } from '../icon-data.js'`, '', `export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`, `type ${componentName}BaseProps = { icon: ${typeName} }`, `type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`, `type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`, `export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`, `export declare const ${componentName}: (props: ${propsName}) => ReactElement`, ''].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [header(config.generatedNotice), `export { ${componentName} } from './react/react-component.js'`, `export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`, `export { ${names} } from './icon-data.js'`, `export type { ${componentName}Name } from './icon-data.js'`, ''].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), `export { ${pascal(config.name)}Icon } from './react/react-component.js'`, `export { ${camel(config.name)}IconNames } from './icon-data.js'`, ''].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition ? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;'] : []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = { schemaVersion: 1, generator: '@gromlab/svg-sprites', name: config.name, ...(config.description === undefined ? {} : { description: config.description }), componentName: `${pascal(config.name)}Icon`, mode: MODE, target: MODE, format: artifact.format, iconCount: artifact.icons.length, spriteUrl: '__SPRITE_URL__', icons: artifact.icons }
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
const manifestTypes = (config: ResolvedSpriteConfig) => [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -1,32 +0,0 @@
import type { NextAssetTarget } from '../../targets/types.js'
import { generateSpriteModule } from '../react/module-generator.js'
import type {
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './types.js'
/** Генерирует Next.js sprite-модуль для явно выбранных роутера и сборщика. */
export async function generateNextSprite(
root: string,
options: NextSpriteGenerationOptions,
): Promise<NextSpriteGenerationResult> {
if (!options || (options.router !== 'app' && options.router !== 'pages')) {
throw new Error(`Unsupported Next.js router: ${String(options?.router)}`)
}
if (options.bundler !== 'turbopack' && options.bundler !== 'webpack') {
throw new Error(`Unsupported Next.js bundler: ${String(options.bundler)}`)
}
const { router, bundler } = options
const target: NextAssetTarget = `next@${router}/${bundler}`
const result = await generateSpriteModule(root, target, {
mode: target,
rootViewBox: true,
})
return {
...result,
router,
bundler,
}
}

View File

@@ -1,6 +0,0 @@
export { generateNextSprite } from './generate.js'
export type {
NextSpriteConfig,
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './types.js'

View File

@@ -1,22 +0,0 @@
import type {
NextAssetTarget,
NextBundler,
NextRouter,
} from '../../targets/types.js'
import type {
ReactSpriteConfig,
SpriteModuleGenerationResult,
} from '../react/types.js'
/** Конфигурация Next.js sprite-модуля. Совпадает с React-конфигурацией. */
export type NextSpriteConfig = ReactSpriteConfig
export type NextSpriteGenerationOptions = {
router: NextRouter
bundler: NextBundler
}
export type NextSpriteGenerationResult = SpriteModuleGenerationResult<NextAssetTarget> & {
router: NextRouter
bundler: NextBundler
}

View File

@@ -0,0 +1,29 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const reactViteAdapter: ModeAdapter<'react@vite'> = {
mode: 'react@vite',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',
manifest: '.svg-sprite/svg-sprite.manifest.js',
entry: '.svg-sprite/index.js',
},
result: { target: 'vite' },
}
},
}

View File

@@ -0,0 +1,268 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'react@vite'
const TARGET = 'vite'
const OUTPUT_DIR = '.svg-sprite'
const GENERATED_NOTICE = [
'----------------------------------------------------------------------',
'## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##',
'## ##',
'## Не редактируйте вручную: изменения будут перезаписаны. ##',
'## Для изменений перегенерируйте SVG-спрайт. ##',
'## ##',
'## Генератор: @gromlab/svg-sprites ##',
'## Репозиторий: https://github.com/gromlab-ru/svg-sprites ##',
'----------------------------------------------------------------------',
]
function toPascalCase(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function toCamelCase(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function blockHeader(enabled: boolean): string {
if (!enabled) return `/* ${GENERATED_MARKER}. Do not edit. */`
return ['/*', ...GENERATED_NOTICE.map((line) => ` * ${line}`), ' */'].join('\n')
}
function svgHeader(enabled: boolean): string {
if (!enabled) return `<!-- ${GENERATED_MARKER}. Do not edit. -->`
return ['<!--', ...GENERATED_NOTICE.slice(1, -1).map((line) => ` ${line}`), '-->'].join('\n')
}
function formatSvg(content: string): string {
let depth = 0
const lines = content.replace(/></g, '>\n<').split('\n')
const formatted = lines.map((line) => {
const trimmed = line.trim()
const tags = trimmed.match(/<[^>]+>/g) ?? []
const startsWithClosingTag = trimmed.startsWith('</')
if (startsWithClosingTag) depth = Math.max(0, depth - 1)
const formattedLine = `${' '.repeat(depth)}${trimmed}`
for (const tag of tags) {
if (tag.startsWith('</')) {
if (!startsWithClosingTag) depth = Math.max(0, depth - 1)
} else if (!tag.startsWith('<?') && !tag.startsWith('<!') && !tag.endsWith('/>')) {
depth += 1
}
}
return formattedLine
})
return `${formatted.join('\n')}\n`
}
function markedSvg(bytes: Uint8Array, notice: boolean): string {
const svg = formatSvg(new TextDecoder().decode(bytes))
const header = svgHeader(notice)
return svg.startsWith('<?xml')
? svg.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n${header}\n`)
: `${header}\n${svg}`
}
function descriptionComment(name: string, description?: string): string[] {
const text = description ?? `Имена иконок SVG-спрайта «${name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function generateIconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const camelName = toCamelCase(config.name)
const names = artifact.icons.map((icon) => icon.name)
const ids = Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id]))
return [
blockHeader(config.generatedNotice),
'',
...descriptionComment(config.name, config.description),
`export const ${camelName}IconNames = ${JSON.stringify(names, null, 2)}`,
'',
`export const ${camelName}IconIds = ${JSON.stringify(ids, null, 2)}`,
'',
].join('\n')
}
function generateComponent(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
return [
blockHeader(config.generatedNotice),
"import { jsx } from 'react/jsx-runtime'",
"import styles from './react-component.module.css'",
`import { ${camelName}IconIds } from '../icon-data.js'`,
"import spriteUrl from '../sprite.svg?no-inline'",
'',
`/** Иконка из SVG-спрайта «${config.name}». */`,
`export const ${pascalName}Icon = (props) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = spriteUrl + '#' + ${camelName}IconIds[icon]`,
'',
' if (wrapped) {',
" return jsx('span', {",
' ...rest,',
" className: [styles.wrap, className].filter(Boolean).join(' '),",
" children: jsx('svg', { children: jsx('use', { href }) }),",
' })',
' }',
'',
" return jsx('svg', {",
' ...rest,',
" className: [styles.root, className].filter(Boolean).join(' '),",
" children: jsx('use', { href }),",
' })',
'}',
'',
].join('\n')
}
function generateIconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
const tuple = artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`).join('\n')
const ids = artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`).join('\n')
return [
blockHeader(config.generatedNotice),
'',
...descriptionComment(config.name, config.description),
`export declare const ${camelName}IconNames: readonly [`,
tuple,
']',
'',
`export type ${pascalName}IconName = typeof ${camelName}IconNames[number]`,
`export declare const ${camelName}IconIds: {`,
ids,
'}',
'',
].join('\n')
}
function generateComponentDeclarations(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
return [
blockHeader(config.generatedNotice),
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
`import type { ${pascalName}IconName } from '../icon-data.js'`,
'',
`export type ${pascalName}IconStyle = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
'',
`type ${pascalName}IconBaseProps = { icon: ${pascalName}IconName }`,
`type ${pascalName}IconSvgProps = ${pascalName}IconBaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${pascalName}IconStyle }`,
`type ${pascalName}IconWrappedProps = ${pascalName}IconBaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${pascalName}IconStyle }`,
`export type ${pascalName}IconProps = ${pascalName}IconSvgProps | ${pascalName}IconWrappedProps`,
'',
`export declare const ${pascalName}Icon: (props: ${pascalName}IconProps) => ReactElement`,
'',
].join('\n')
}
function generateIndexDeclarations(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
return [
blockHeader(config.generatedNotice),
`export { ${pascalName}Icon } from './react/react-component.js'`,
`export type { ${pascalName}IconProps, ${pascalName}IconStyle } from './react/react-component.js'`,
`export { ${camelName}IconNames } from './icon-data.js'`,
`export type { ${pascalName}IconName } from './icon-data.js'`,
'',
].join('\n')
}
function generateIndex(config: ResolvedSpriteConfig): string {
const pascalName = toPascalCase(config.name)
const camelName = toCamelCase(config.name)
return [
blockHeader(config.generatedNotice),
`export { ${pascalName}Icon } from './react/react-component.js'`,
`export { ${camelName}IconNames } from './icon-data.js'`,
'',
].join('\n')
}
function generateStyles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
: []
return [
blockHeader(config.generatedNotice),
'',
'.root {',
...transition,
'}',
'',
'.wrap {',
' display: inline-flex;',
'}',
'',
'.wrap svg {',
' width: 100%;',
' height: 100%;',
...transition,
'}',
'',
].join('\n')
}
function manifestData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact) {
return {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
componentName: `${toPascalCase(config.name)}Icon`,
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
spriteUrl: '__SPRITE_URL__',
icons: artifact.icons,
}
}
function generateManifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const serialized = JSON.stringify(manifestData(config, artifact), null, 2)
.replace('"__SPRITE_URL__"', 'spriteUrl')
return [
blockHeader(config.generatedNotice),
"import spriteUrl from './sprite.svg?no-inline'",
'',
`export const spriteManifest = ${serialized}`,
'',
'export default spriteManifest',
'',
].join('\n')
}
function generateManifestDeclarations(config: ResolvedSpriteConfig): string {
return [
blockHeader(config.generatedNotice),
"import type { SpriteManifest } from '@gromlab/svg-sprites/react'",
'',
'export declare const spriteManifest: SpriteManifest',
'export default spriteManifest',
'',
].join('\n')
}
export function generateOutputFiles(
config: ResolvedSpriteConfig,
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: generateIndex(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: generateIndexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: markedSvg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: generateIconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: generateIconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: generateComponent(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: generateComponentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: generateStyles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: generateManifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: generateManifestDeclarations(config) },
]
}

View File

@@ -0,0 +1,26 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const reactWebpackAdapter: ModeAdapter<'react@webpack'> = {
mode: 'react@webpack',
contractVersion: 5,
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),
paths: {
generatedDir: '.svg-sprite',
sprite: '.svg-sprite/sprite.svg',
manifest: '.svg-sprite/svg-sprite.manifest.js',
entry: '.svg-sprite/index.js',
},
result: { target: 'webpack' },
}
},
}

View File

@@ -0,0 +1,187 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'react@webpack'
const TARGET = 'webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function pascal(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function camel(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function header(enabled: boolean): string {
return enabled
? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */`
: `/* ${GENERATED_MARKER}. Do not edit. */`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function comment(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [
header(config.generatedNotice),
'',
...comment(config),
`export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`,
'',
`export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`,
'',
].join('\n')
}
function component(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
"import { jsx } from 'react/jsx-runtime'",
"import styles from './react-component.module.css'",
`import { ${ids} } from '../icon-data.js'`,
'',
"const spriteUrl = new URL('../sprite.svg', import.meta.url).href",
'',
`export const ${componentName} = (props) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = spriteUrl + '#' + ${ids}[icon]`,
' if (wrapped) {',
" return jsx('span', {",
' ...rest,',
" className: [styles.wrap, className].filter(Boolean).join(' '),",
" children: jsx('svg', { children: jsx('use', { href }) }),",
' })',
' }',
" return jsx('svg', {",
' ...rest,',
" className: [styles.root, className].filter(Boolean).join(' '),",
" children: jsx('use', { href }),",
' })',
'}',
'',
].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'',
...comment(config),
`export declare const ${names}: readonly [`,
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
']',
`export type ${typeName} = typeof ${names}[number]`,
`export declare const ${ids}: {`,
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
'}',
'',
].join('\n')
}
function componentDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const typeName = `${componentName}Name`
const styleName = `${componentName}Style`
const propsName = `${componentName}Props`
return [
header(config.generatedNotice),
"import type { CSSProperties, HTMLAttributes, ReactElement, SVGAttributes } from 'react'",
`import type { ${typeName} } from '../icon-data.js'`,
'',
`export type ${styleName} = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
`type ${componentName}BaseProps = { icon: ${typeName} }`,
`type ${componentName}SvgProps = ${componentName}BaseProps & { wrapped?: false } & Omit<SVGAttributes<SVGSVGElement>, 'style'> & { style?: ${styleName} }`,
`type ${componentName}WrappedProps = ${componentName}BaseProps & { wrapped: true } & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & { style?: ${styleName} }`,
`export type ${propsName} = ${componentName}SvgProps | ${componentName}WrappedProps`,
`export declare const ${componentName}: (props: ${propsName}) => ReactElement`,
'',
].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const componentName = `${pascal(config.name)}Icon`
const names = `${camel(config.name)}IconNames`
return [
header(config.generatedNotice),
`export { ${componentName} } from './react/react-component.js'`,
`export type { ${componentName}Props, ${componentName}Style } from './react/react-component.js'`,
`export { ${names} } from './icon-data.js'`,
`export type { ${componentName}Name } from './icon-data.js'`,
'',
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
`export { ${pascal(config.name)}Icon } from './react/react-component.js'`,
`export { ${camel(config.name)}IconNames } from './icon-data.js'`,
'',
].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
: []
return [header(config.generatedNotice), '', '.root {', ...transition, '}', '', '.wrap {', ' display: inline-flex;', '}', '', '.wrap svg {', ' width: 100%;', ' height: 100%;', ...transition, '}', ''].join('\n')
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const data = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
componentName: `${pascal(config.name)}Icon`,
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
spriteUrl: '__SPRITE_URL__',
icons: artifact.icons,
}
const source = JSON.stringify(data, null, 2).replace('"__SPRITE_URL__"', 'spriteUrl')
return [header(config.generatedNotice), "const spriteUrl = new URL('./sprite.svg', import.meta.url).href", '', `export const spriteManifest = ${source}`, '', 'export default spriteManifest', ''].join('\n')
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [header(config.generatedNotice), "import type { SpriteManifest } from '@gromlab/svg-sprites/react'", '', 'export declare const spriteManifest: SpriteManifest', 'export default spriteManifest', ''].join('\n')
}
export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.js'), content: component(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.d.ts'), content: componentDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'react', 'react-component.module.css'), content: styles(config) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

@@ -1,288 +0,0 @@
import path from 'node:path'
import { getSpriteShapeId } from '../../shape-id.js'
import { generateReactAssetUrlCode } from '../../targets/index.js'
import type { SpriteAssetTarget } from '../../targets/types.js'
import type { SpriteFormat } from '../../types.js'
import { generateSpriteManifest } from './manifest.js'
import { toPascalCase } from './naming.js'
import type { ResolvedReactSpriteConfig } from './types.js'
const GENERATED_MARKER = '@generated by @gromlab/svg-sprites. Do not edit.'
const GENERATED_NOTICE = [
'----------------------------------------------------------------------',
'## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##',
'## ##',
'## Не редактируйте вручную: изменения будут перезаписаны. ##',
'## Для изменений перегенерируйте SVG-спрайт. ##',
'## ##',
'## Генератор: @gromlab/svg-sprites ##',
'## Репозиторий: https://github.com/gromlab-ru/svg-sprites ##',
'----------------------------------------------------------------------',
]
type ReactCodegenOptions = {
config: ResolvedReactSpriteConfig
format: SpriteFormat
iconNames: string[]
sprite: Uint8Array
target: SpriteAssetTarget
}
export type GeneratedFile = {
path: string
content: string | Uint8Array
}
function toCamelCase(name: string): string {
return name.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function generateBlockHeader(generatedNotice: boolean): string {
if (!generatedNotice) return `/* ${GENERATED_MARKER} */`
return [
'/*',
...GENERATED_NOTICE.map((line) => ` * ${line}`),
' */',
].join('\n')
}
function generateSvgHeader(generatedNotice: boolean): string {
if (!generatedNotice) return `<!-- ${GENERATED_MARKER} -->`
return [
'<!--',
...GENERATED_NOTICE.slice(1, -1).map((line) => ` ${line}`),
'-->',
].join('\n')
}
function generateGitignore(): string {
return `# ${GENERATED_MARKER}\n/generated/\n/index.ts\n/manifest.ts\n`
}
function formatSvg(content: string): string {
let depth = 0
const lines = content.replace(/></g, '>\n<').split('\n')
const formatted = lines.map((line) => {
const trimmed = line.trim()
const tags = trimmed.match(/<[^>]+>/g) ?? []
const startsWithClosingTag = trimmed.startsWith('</')
if (startsWithClosingTag) depth = Math.max(0, depth - 1)
const formattedLine = `${' '.repeat(depth)}${trimmed}`
for (const tag of tags) {
if (tag.startsWith('</')) {
if (!startsWithClosingTag) depth = Math.max(0, depth - 1)
} else if (!tag.startsWith('<?') && !tag.startsWith('<!') && !tag.endsWith('/>')) {
depth += 1
}
}
return formattedLine
})
return `${formatted.join('\n')}\n`
}
function markSvg(content: Uint8Array, generatedNotice: boolean): string {
const svg = formatSvg(new TextDecoder().decode(content))
const header = generateSvgHeader(generatedNotice)
return svg.startsWith('<?xml')
? svg.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n${header}\n`)
: `${header}\n${svg}`
}
function generateDescriptionComment(name: string, description?: string): string[] {
const text = description ?? `Имена иконок SVG-спрайта «${name}».`
return [
'/**',
...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`),
' */',
]
}
function generateTypes(
name: string,
description: string | undefined,
iconNames: string[],
generatedNotice: boolean,
): string {
const pascalName = toPascalCase(name)
const camelName = toCamelCase(name)
const iconNamesVariable = `${camelName}IconNames`
return [
generateBlockHeader(generatedNotice),
'',
...generateDescriptionComment(name, description),
`export const ${iconNamesVariable} = [`,
...iconNames.map((iconName) => ` ${JSON.stringify(iconName)},`),
'] as const',
'',
`export type ${pascalName}IconName = typeof ${iconNamesVariable}[number]`,
'',
].join('\n')
}
function generateComponent(
name: string,
generatedNotice: boolean,
target: SpriteAssetTarget,
iconNames: string[],
): string {
const pascalName = toPascalCase(name)
const assetUrlCode = generateReactAssetUrlCode(target, 'sprite.svg')
return [
generateBlockHeader(generatedNotice),
"import type { CSSProperties, HTMLAttributes, SVGAttributes } from 'react'",
"import styles from './styles.module.css'",
`import type { ${pascalName}IconName } from './types'`,
...assetUrlCode.imports,
'',
...assetUrlCode.declarations,
...(assetUrlCode.declarations.length > 0 ? [''] : []),
`const iconIds: Record<${pascalName}IconName, string> = {`,
...iconNames.map((iconName) => ` ${JSON.stringify(iconName)}: ${JSON.stringify(getSpriteShapeId(iconName))},`),
'}',
'',
`export type ${pascalName}IconStyle = CSSProperties & Partial<Record<\`--icon-color-\${number}\`, string | number>>`,
'',
`type ${pascalName}IconBaseProps = {`,
` icon: ${pascalName}IconName`,
'}',
'',
`type ${pascalName}IconSvgProps = ${pascalName}IconBaseProps & {`,
' wrapped?: false',
`} & Omit<SVGAttributes<SVGSVGElement>, 'style'> & {`,
` style?: ${pascalName}IconStyle`,
'}',
'',
`type ${pascalName}IconWrappedProps = ${pascalName}IconBaseProps & {`,
' wrapped: true',
`} & Omit<HTMLAttributes<HTMLSpanElement>, 'style'> & {`,
` style?: ${pascalName}IconStyle`,
'}',
'',
`export type ${pascalName}IconProps =`,
` | ${pascalName}IconSvgProps`,
` | ${pascalName}IconWrappedProps`,
'',
`/** Иконка из SVG-спрайта «${name}». */`,
`export const ${pascalName}Icon = (props: ${pascalName}IconProps) => {`,
' const { icon, wrapped, className, ...rest } = props',
` const href = ${assetUrlCode.variableName} + '#' + iconIds[icon]`,
'',
' if (wrapped) {',
' const htmlAttributes = rest as HTMLAttributes<HTMLSpanElement>',
' return (',
" <span {...htmlAttributes} className={[styles.wrap, className].filter(Boolean).join(' ')}>",
' <svg>',
' <use href={href} />',
' </svg>',
' </span>',
' )',
' }',
'',
' const svgAttributes = rest as SVGAttributes<SVGSVGElement>',
' return (',
" <svg {...svgAttributes} className={[styles.root, className].filter(Boolean).join(' ')}>",
' <use href={href} />',
' </svg>',
' )',
'}',
'',
].join('\n')
}
function generateCss(generatedNotice: boolean, addTransition: boolean): string {
const transitionRules = addTransition
? [
' transition-property: fill, stroke, color;',
' transition-duration: 0.3s;',
' transition-timing-function: ease;',
]
: []
return [
generateBlockHeader(generatedNotice),
'',
'.root {',
...transitionRules,
'}',
'',
'.wrap {',
' display: inline-flex;',
'}',
'',
'.wrap svg {',
' width: 100%;',
' height: 100%;',
...transitionRules,
'}',
'',
].join('\n')
}
function generateRootIndex(name: string, generatedNotice: boolean): string {
const pascalName = toPascalCase(name)
const iconNamesVariable = `${toCamelCase(name)}IconNames`
return [
generateBlockHeader(generatedNotice),
`export { ${pascalName}Icon } from './generated/react-component'`,
`export type { ${pascalName}IconProps, ${pascalName}IconStyle } from './generated/react-component'`,
`export { ${iconNamesVariable} } from './generated/types'`,
`export type { ${pascalName}IconName } from './generated/types'`,
'',
].join('\n')
}
/** Создаёт все управляемые файлы React sprite-модуля в памяти. */
export function generateReactFiles(options: ReactCodegenOptions): GeneratedFile[] {
const { config, format, iconNames, sprite, target } = options
const { name, description, generatedNotice } = config
return [
{
path: '.gitignore',
content: generateGitignore(),
},
{
path: path.posix.join('generated', 'sprite.svg'),
content: markSvg(sprite, generatedNotice),
},
{
path: path.posix.join('generated', 'types.ts'),
content: generateTypes(name, description, iconNames, generatedNotice),
},
{
path: path.posix.join('generated', 'react-component.tsx'),
content: generateComponent(name, generatedNotice, target, iconNames),
},
{
path: path.posix.join('generated', 'styles.module.css'),
content: generateCss(generatedNotice, config.transform.addTransition ?? true),
},
{
path: 'manifest.ts',
content: generateSpriteManifest({
header: generateBlockHeader(generatedNotice),
name,
description,
format,
iconNames,
sprite,
target,
}),
},
{
path: 'index.ts',
content: generateRootIndex(name, generatedNotice),
},
]
}

View File

@@ -1,108 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import { toKebabCase, validateSpriteName } from './naming.js'
import type { ReactSpriteConfig, ResolvedReactSpriteConfig } from './types.js'
export const REACT_CONFIG_FILE = 'svg-sprite.config.ts'
function getDefaultName(rootDir: string): string {
const rootName = path.basename(rootDir)
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
? path.basename(path.dirname(rootDir))
: rootName
const name = toKebabCase(source)
if (!name) {
throw new Error(`Cannot infer sprite name from directory: ${rootDir}`)
}
return name
}
/** Загружает локальный React-конфиг из корня sprite-модуля. */
export async function loadReactSpriteConfig(rootDir: string): Promise<ResolvedReactSpriteConfig> {
const configPath = path.join(rootDir, REACT_CONFIG_FILE)
if (!fs.existsSync(configPath)) {
throw new Error(
`React config file not found: ${configPath}\n` +
`Create ${REACT_CONFIG_FILE} inside the sprite directory.`,
)
}
const jiti = createJiti(rootDir)
const mod = await jiti.import(configPath) as { default?: ReactSpriteConfig }
const config = mod.default
if (!config || typeof config !== 'object') {
throw new Error(
`React config file must have a default export: ${configPath}\n` +
'Use: export default defineReactSpriteConfig({ ... })',
)
}
if (config.name !== undefined && typeof config.name !== 'string') {
throw new Error('React config: "name" must be a string.')
}
if (config.description !== undefined && typeof config.description !== 'string') {
throw new Error('React config: "description" must be a string.')
}
if ('icons' in config) {
throw new Error('React config: "icons" was renamed to "inputFolder".')
}
if (config.inputFolder !== undefined && (
typeof config.inputFolder !== 'string' || config.inputFolder.trim() === ''
)) {
throw new Error('React config: "inputFolder" must be a non-empty string.')
}
if (config.inputFiles !== undefined && (
!Array.isArray(config.inputFiles)
|| config.inputFiles.some((filePath) => typeof filePath !== 'string' || filePath.trim() === '')
)) {
throw new Error('React config: "inputFiles" must be an array of non-empty strings.')
}
if (config.transform !== undefined) {
if (
config.transform === null
|| typeof config.transform !== 'object'
|| Array.isArray(config.transform)
) {
throw new Error('React config: "transform" must be an object.')
}
for (const option of ['removeSize', 'replaceColors', 'addTransition'] as const) {
if (config.transform[option] !== undefined && typeof config.transform[option] !== 'boolean') {
throw new Error(`React config: "transform.${option}" must be a boolean.`)
}
}
}
if (config.generatedNotice !== undefined && typeof config.generatedNotice !== 'boolean') {
throw new Error('React config: "generatedNotice" must be a boolean.')
}
const name = config.name ?? getDefaultName(rootDir)
validateSpriteName(name)
const inputFiles = (config.inputFiles ?? []).map((filePath) => path.resolve(rootDir, filePath))
const defaultInputFolder = path.resolve(rootDir, 'icons')
const inputFolder = config.inputFolder === undefined
&& inputFiles.length > 0
&& !fs.existsSync(defaultInputFolder)
? null
: path.resolve(rootDir, config.inputFolder ?? 'icons')
return {
name,
description: config.description,
inputFolder,
inputFiles,
transform: { ...config.transform },
generatedNotice: config.generatedNotice ?? true,
}
}

View File

@@ -1,18 +0,0 @@
import type { ReactAssetTarget } from '../../targets/types.js'
import { generateSpriteModule } from './module-generator.js'
import type { ReactSpriteGenerationResult } from './types.js'
/** Генерирует один локальный React sprite-модуль для Vite или Webpack. */
export function generateReactSprite(
root: string,
target: ReactAssetTarget,
): Promise<ReactSpriteGenerationResult> {
if (target !== 'vite' && target !== 'webpack') {
throw new Error(`Unsupported React asset target: ${String(target)}`)
}
return generateSpriteModule(root, target, {
mode: `react@${target}`,
rootViewBox: false,
})
}

View File

@@ -1,7 +0,0 @@
export { generateReactSprite } from './generate.js'
export { loadReactSpriteConfig, REACT_CONFIG_FILE } from './config.js'
export type {
ReactSpriteConfig,
ReactSpriteGenerationResult,
ResolvedReactSpriteConfig,
} from './types.js'

View File

@@ -1,120 +0,0 @@
import { getSpriteShapeId } from '../../shape-id.js'
import { generateReactAssetUrlCode } from '../../targets/index.js'
import type { SpriteAssetTarget } from '../../targets/types.js'
import type { SpriteFormat } from '../../types.js'
import { toPascalCase } from './naming.js'
type ManifestIconColor = {
variable: string
fallback: string
}
type ManifestIcon = {
name: string
id: string
viewBox: string | null
colors: ManifestIconColor[]
}
type GenerateManifestOptions = {
header: string
name: string
description?: string
format: SpriteFormat
iconNames: string[]
sprite: Uint8Array
target: SpriteAssetTarget
}
function extractColors(fragment: string): ManifestIconColor[] {
const colors = new Map<string, string>()
const regex = /var\((--icon-color-\d+),\s*((?:[^()]|\([^()]*\))*)\)/g
let match: RegExpExecArray | null
while ((match = regex.exec(fragment)) !== null) {
if (!colors.has(match[1])) colors.set(match[1], match[2].trim())
}
return [...colors].map(([variable, fallback]) => ({ variable, fallback }))
}
function extractManifestIcons(sprite: Uint8Array, iconNames: string[]): ManifestIcon[] {
const shapes = new Map<string, { viewBox: string | null; fragment: string }>()
const content = new TextDecoder().decode(sprite)
const shapeRegex = /<(symbol|svg)\b((?=[^>]*\bid="[^"]+")[^>]*)>[\s\S]*?<\/\1>/g
let match: RegExpExecArray | null
while ((match = shapeRegex.exec(content)) !== null) {
const attributes = match[2]
const id = attributes.match(/\bid="([^"]+)"/)?.[1]
if (!id) continue
shapes.set(id, {
viewBox: attributes.match(/\bviewBox="([^"]+)"/)?.[1] ?? null,
fragment: match[0],
})
}
return iconNames.map((name) => {
const id = getSpriteShapeId(name)
const shape = shapes.get(id)
if (!shape) throw new Error(`Cannot find SVG shape "${id}" for icon "${name}".`)
return {
name,
id,
viewBox: shape.viewBox,
colors: extractColors(shape.fragment),
}
})
}
function generateIcon(icon: ManifestIcon): string[] {
return [
' {',
` name: ${JSON.stringify(icon.name)},`,
` id: ${JSON.stringify(icon.id)},`,
` viewBox: ${JSON.stringify(icon.viewBox)},`,
' colors: [',
...icon.colors.flatMap((color) => [
' {',
` variable: ${JSON.stringify(color.variable)},`,
` fallback: ${JSON.stringify(color.fallback)},`,
' },',
]),
' ],',
' },',
]
}
/** Генерирует отдельную debug-точку входа с метаданными спрайта. */
export function generateSpriteManifest(options: GenerateManifestOptions): string {
const { header, name, description, format, iconNames, sprite, target } = options
const assetUrlCode = generateReactAssetUrlCode(target, 'generated/sprite.svg')
const icons = extractManifestIcons(sprite, iconNames)
return [
header,
...assetUrlCode.imports,
...(assetUrlCode.imports.length > 0 ? [''] : []),
...assetUrlCode.declarations,
...(assetUrlCode.declarations.length > 0 ? [''] : []),
'export const spriteManifest = {',
' schemaVersion: 1,',
" generator: '@gromlab/svg-sprites',",
` name: ${JSON.stringify(name)},`,
...(description === undefined ? [] : [` description: ${JSON.stringify(description)},`]),
` componentName: ${JSON.stringify(`${toPascalCase(name)}Icon`)},`,
` target: ${JSON.stringify(target)},`,
` format: ${JSON.stringify(format)},`,
` iconCount: ${icons.length},`,
` spriteUrl: ${assetUrlCode.variableName},`,
' icons: [',
...icons.flatMap(generateIcon),
' ],',
'} as const',
'',
'export default spriteManifest',
'',
].join('\n')
}

View File

@@ -1,78 +0,0 @@
import path from 'node:path'
import { compileSpriteContent } from '../../compiler.js'
import { log } from '../../logger.js'
import { resolveSpriteSources } from '../../scanner.js'
import { getSpriteShapeId } from '../../shape-id.js'
import type { SpriteAssetTarget } from '../../targets/types.js'
import { generateReactFiles } from './codegen.js'
import { loadReactSpriteConfig } from './config.js'
import type { SpriteModuleGenerationResult } from './types.js'
import { writeReactFiles } from './writer.js'
type GenerateSpriteModuleOptions = {
mode: string
rootViewBox: boolean
}
function validateIconIds(iconNames: string[]): void {
const namesById = new Map<string, string>()
for (const iconName of iconNames) {
const id = getSpriteShapeId(iconName)
const existingName = namesById.get(id)
if (existingName) {
throw new Error(
`Icons "${existingName}" and "${iconName}" produce the same SVG id "${id}". Rename one of the files.`,
)
}
namesById.set(id, iconName)
}
}
/** Общая генерация типизированного React-компонента для React и Next.js modes. */
export async function generateSpriteModule<TTarget extends SpriteAssetTarget>(
root: string,
target: TTarget,
options: GenerateSpriteModuleOptions,
): Promise<SpriteModuleGenerationResult<TTarget>> {
const rootDir = path.resolve(root)
const config = await loadReactSpriteConfig(rootDir)
const format = 'stack'
const folder = resolveSpriteSources({
name: config.name,
format,
inputFolder: config.inputFolder,
inputFiles: config.inputFiles,
})
const iconNames = folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
validateIconIds(iconNames)
const sprite = await compileSpriteContent(folder, config.transform, {
rootViewBox: options.rootViewBox,
})
const files = generateReactFiles({ config, format, iconNames, sprite, target })
writeReactFiles(rootDir, files, config.generatedNotice)
const generatedDir = path.join(rootDir, 'generated')
const spritePath = path.join(generatedDir, 'sprite.svg')
const manifestPath = path.join(rootDir, 'manifest.ts')
const iconLabel = iconNames.length === 1 ? 'icon' : 'icons'
log.success(`${config.name} · ${iconNames.length} ${iconLabel} · ${options.mode}`)
log.detail(`${path.relative(process.cwd(), generatedDir)}`)
return {
name: config.name,
rootDir,
generatedDir,
spritePath,
manifestPath,
iconCount: iconNames.length,
target,
}
}

View File

@@ -1,25 +0,0 @@
/** Преобразует kebab-case имя в PascalCase. */
export function toPascalCase(value: string): string {
return value
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/** Преобразует имя папки в допустимое kebab-case имя спрайта. */
export function toKebabCase(value: string): string {
return value
.normalize('NFKD')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase()
}
export function validateSpriteName(name: string): void {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(name)) {
throw new Error(
`React config: "name" must be kebab-case and start with a letter. Received: "${name}".`,
)
}
}

View File

@@ -1,42 +0,0 @@
import type {
ReactAssetTarget,
SpriteAssetTarget,
} from '../../targets/types.js'
import type { TransformOptions } from '../../types.js'
export type ReactSpriteConfig = {
/** Логическое имя спрайта. По умолчанию выводится из пути модуля. */
name?: string
/** Описание спрайта для документации и будущего инспектора. */
description?: string
/** Папка с исходными SVG относительно svg-sprite.config.ts. По умолчанию: ./icons. */
inputFolder?: string
/** Дополнительные SVG-файлы относительно svg-sprite.config.ts. По умолчанию: []. */
inputFiles?: string[]
/** Настройки трансформации SVG. По умолчанию все трансформации включены. */
transform?: TransformOptions
/** Добавлять развёрнутое предупреждение в generated-файлы. По умолчанию: true. */
generatedNotice?: boolean
}
export type ResolvedReactSpriteConfig = {
name: string
description?: string
inputFolder: string | null
inputFiles: string[]
transform: TransformOptions
generatedNotice: boolean
}
export type SpriteModuleGenerationResult<TTarget extends SpriteAssetTarget> = {
name: string
rootDir: string
generatedDir: string
spritePath: string
manifestPath: string
iconCount: number
/** Среда, для которой сгенерирован способ получения URL SVG asset. */
target: TTarget
}
export type ReactSpriteGenerationResult = SpriteModuleGenerationResult<ReactAssetTarget>

View File

@@ -1,172 +0,0 @@
import { randomUUID } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import type { GeneratedFile } from './codegen.js'
const MANIFEST_FILE = '.svg-sprites.manifest.json'
const GENERATOR = '@gromlab/svg-sprites'
const GENERATED_MARKER = '@generated by @gromlab/svg-sprites'
const GENERATED_NOTICE_MARKER = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ'
const ROOT_MANAGED_FILES = new Set(['.gitignore', 'index.ts', 'manifest.ts'])
type Manifest = {
version: 1
generator: typeof GENERATOR
files: string[]
warning?: string
}
function normalizeManagedPath(relativePath: string): string {
const normalized = relativePath.replaceAll('\\', '/')
const parts = normalized.split('/')
const isRootFile = ROOT_MANAGED_FILES.has(normalized)
const isGeneratedFile = parts.length === 2
&& parts[0] === 'generated'
&& parts[1] !== ''
&& parts[1] !== '.'
&& parts[1] !== '..'
if (!isRootFile && !isGeneratedFile) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return normalized
}
function resolveManagedPath(rootDir: string, relativePath: string): string {
const normalized = normalizeManagedPath(relativePath)
const resolved = path.resolve(rootDir, normalized)
const relative = path.relative(rootDir, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Invalid generated file path: ${relativePath}`)
}
return resolved
}
function assertNoSymlinks(rootDir: string, filePath: string): void {
const relative = path.relative(rootDir, filePath)
let currentPath = rootDir
for (const segment of relative.split(path.sep)) {
currentPath = path.join(currentPath, segment)
try {
if (fs.lstatSync(currentPath).isSymbolicLink()) {
throw new Error(`Symbolic links are not allowed in generated paths: ${currentPath}`)
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw error
}
}
}
function readManifest(rootDir: string, manifestPath: string): Manifest | null {
assertNoSymlinks(rootDir, manifestPath)
if (!fs.existsSync(manifestPath)) return null
let manifest: unknown
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
} catch {
throw new Error(`Cannot parse generated files manifest: ${manifestPath}`)
}
if (
!manifest
|| typeof manifest !== 'object'
|| !('version' in manifest)
|| manifest.version !== 1
|| !('generator' in manifest)
|| manifest.generator !== GENERATOR
|| !('files' in manifest)
|| !Array.isArray(manifest.files)
|| !manifest.files.every((file) => typeof file === 'string')
) {
throw new Error(`Invalid generated files manifest: ${manifestPath}`)
}
return {
...(manifest as Manifest),
files: (manifest as Manifest).files.map(normalizeManagedPath),
}
}
function hasGeneratedMarker(filePath: string): boolean {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false
const content = fs.readFileSync(filePath, 'utf-8')
return content.includes(GENERATED_MARKER) || content.includes(GENERATED_NOTICE_MARKER)
}
function writeFileAtomic(rootDir: string, filePath: string, content: string | Uint8Array): void {
assertNoSymlinks(rootDir, filePath)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
assertNoSymlinks(rootDir, filePath)
const temporaryPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
)
try {
fs.writeFileSync(temporaryPath, content, { flag: 'wx' })
fs.renameSync(temporaryPath, filePath)
} finally {
if (fs.existsSync(temporaryPath)) fs.unlinkSync(temporaryPath)
}
}
/** Обновляет только файлы, которыми владеет React-генератор. */
export function writeReactFiles(
rootDir: string,
files: GeneratedFile[],
generatedNotice: boolean,
): void {
const generatedDir = path.join(rootDir, 'generated')
const manifestPath = path.join(generatedDir, MANIFEST_FILE)
const previousManifest = readManifest(rootDir, manifestPath)
const previousFiles = new Set(previousManifest?.files ?? [])
const nextFiles = files.map((file) => normalizeManagedPath(file.path))
const obsoleteFiles: string[] = []
for (const file of files) {
const filePath = resolveManagedPath(rootDir, file.path)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath) && !hasGeneratedMarker(filePath)) {
throw new Error(
`Refusing to overwrite a user file: ${filePath}\n` +
'Move the file or choose another sprite directory.',
)
}
}
for (const relativePath of previousFiles) {
if (nextFiles.includes(relativePath)) continue
const filePath = resolveManagedPath(rootDir, relativePath)
assertNoSymlinks(rootDir, filePath)
if (fs.existsSync(filePath)) {
if (!hasGeneratedMarker(filePath)) {
throw new Error(`Refusing to delete a user file: ${filePath}`)
}
obsoleteFiles.push(filePath)
}
}
for (const filePath of obsoleteFiles) fs.unlinkSync(filePath)
for (const file of files) {
writeFileAtomic(rootDir, resolveManagedPath(rootDir, file.path), file.content)
}
const manifest: Manifest = {
version: 1,
generator: GENERATOR,
files: nextFiles,
...(generatedNotice && {
warning: 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную: изменения будут перезаписаны.',
}),
}
writeFileAtomic(rootDir, manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
}

View File

@@ -1,185 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { SpriteResult } from './types.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/** Извлекает id иконок из SVG-спрайта. */
function extractIconIds(spritePath: string): string[] {
const content = fs.readFileSync(spritePath, 'utf-8')
const ids: string[] = []
const regex = /<(?:svg|symbol)\b[^>]*\bid="([^"]+)"/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
ids.push(match[1])
}
return ids.sort()
}
/** Извлекает CSS-переменные var(--icon-color-N, fallback) из SVG-фрагмента иконки. */
function extractIconVars(svgFragment: string): { varName: string; fallback: string }[] {
const vars = new Map<string, string>()
const regex = /var\((--icon-color-\d+),\s*([^)]+)\)/g
let match: RegExpExecArray | null
while ((match = regex.exec(svgFragment)) !== null) {
if (!vars.has(match[1])) {
vars.set(match[1], match[2].trim())
}
}
return [...vars.entries()].map(([varName, fallback]) => ({ varName, fallback }))
}
/** Парсит SVG-спрайт и возвращает маппинг id → SVG-фрагмент. */
function extractIconFragments(spritePath: string): Map<string, string> {
const content = fs.readFileSync(spritePath, 'utf-8')
const fragments = new Map<string, string>()
const regex = /<(?:svg|symbol)\b[^>]*\bid="([^"]+)"[^>]*>[\s\S]*?<\/(?:svg|symbol)>/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
fragments.set(match[1], match[0])
}
return fragments
}
/** Извлекает viewBox из SVG-фрагмента иконки. */
function extractViewBox(svgFragment: string): { x: number; y: number; width: number; height: number } | null {
const match = svgFragment.match(/viewBox="([^"]+)"/)
if (!match) return null
const parts = match[1].split(/\s+/).map(Number)
if (parts.length !== 4) return null
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
}
/** Конвертирует CSS-цвет в hex. */
function colorToHex(color: string): string {
const named: Record<string, string> = {
red: '#ff0000', blue: '#0000ff', green: '#008000', white: '#ffffff',
black: '#000000', yellow: '#ffff00', cyan: '#00ffff', magenta: '#ff00ff',
orange: '#ffa500', purple: '#800080', pink: '#ffc0cb', gray: '#808080',
grey: '#808080', currentcolor: '#000000',
}
const lower = color.toLowerCase().trim()
if (lower.startsWith('#')) {
if (lower.length === 4) return `#${lower[1]}${lower[1]}${lower[2]}${lower[2]}${lower[3]}${lower[3]}`
return lower
}
return named[lower] || '#000000'
}
/** Подготавливает SVG-спрайт для инлайна — конвертирует вложенные <svg> в <symbol>. */
function prepareInlineSprite(spritePath: string): string {
let content = fs.readFileSync(spritePath, 'utf-8')
content = content.replace(/<\?xml[^?]*\?>\s*/g, '')
content = content.replace(/<style>:root>svg\{display:none\}:root>svg:target\{display:block\}<\/style>/g, '')
let depth = 0
content = content.replace(/<(\/?)svg\b([^>]*?)(\s*\/?)>/g, (_full, slash: string, attrs: string) => {
if (slash) {
depth--
return depth > 0 ? '</symbol>' : '</svg>'
}
depth++
if (depth > 1) {
const cleanAttrs = attrs.replace(/\s*xmlns="[^"]*"/g, '')
return `<symbol${cleanAttrs}>`
}
return `<svg${attrs} style="display:none">`
})
return content
}
interface IconData {
id: string
group: string
mode: string
spriteFile: string
viewBox: { x: number; y: number; width: number; height: number } | null
vars: { varName: string; fallback: string; hex: string; isCurrentColor: boolean }[]
}
interface SpriteGroup {
name: string
mode: string
spriteFile: string
icons: IconData[]
}
/**
* Генерирует HTML-файл превью для всех спрайтов.
*
* Использует pre-built React-приложение из dist/preview-template.html,
* инжектирует данные спрайтов и inline SVG.
*/
export function generatePreview(
results: SpriteResult[],
outputDir: string,
): string {
// Собираем данные
const groups: SpriteGroup[] = results.map((r) => {
const fragments = extractIconFragments(r.spritePath)
const ids = extractIconIds(r.spritePath)
const spriteFile = `${r.name}.sprite.svg`
const icons: IconData[] = ids.map((id) => {
const fragment = fragments.get(id) || ''
return {
id,
group: r.name,
mode: r.format,
spriteFile,
viewBox: extractViewBox(fragment),
vars: extractIconVars(fragment).map((v) => ({
varName: v.varName,
fallback: v.fallback,
hex: colorToHex(v.fallback),
isCurrentColor: v.fallback.toLowerCase() === 'currentcolor',
})),
}
})
return { name: r.name, mode: r.format, spriteFile, icons }
})
// Inline SVG спрайтов
const inlineSprites = results
.map((r) => prepareInlineSprite(r.spritePath))
.join('\n')
// Скрипт с данными + DOM injection
const svgEscaped = inlineSprites.replace(/`/g, '\\`').replace(/\$/g, '\\$')
const dataScript = [
'<script>',
`window.__SPRITES_DATA__ = ${JSON.stringify({ groups })};`,
'(function() {',
` var svg = \`${svgEscaped}\`;`,
' var parser = new DOMParser();',
' var doc = parser.parseFromString("<div>" + svg + "</div>", "text/html");',
' var nodes = doc.body.firstChild.childNodes;',
' while (nodes.length > 0) {',
' document.body.insertBefore(nodes[0], document.body.firstChild);',
' }',
'})();',
'</script>',
].join('\n')
// Читаем шаблон
const templatePath = path.join(__dirname, 'preview-template.html')
if (!fs.existsSync(templatePath)) {
throw new Error(
`Preview template not found: ${templatePath}\n` +
'Run "npm run build" in the preview/ directory first.',
)
}
let html = fs.readFileSync(templatePath, 'utf-8')
html = html.replace('<!-- __SPRITES_INJECT__ -->', dataScript)
// Записываем результат
const outputPath = path.join(outputDir, 'preview.html')
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(outputPath, html)
return outputPath
}

Some files were not shown because too many files have changed in this diff Show More