mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
feat: перейти на изолированный контракт генерации
- добавлены независимые adapters для шести exact modes - runtime переведён на JavaScript с отдельными декларациями - generated output перенесён в .svg-sprite - удалены legacy pipeline и встроенный preview
This commit is contained in:
99
AGENTS.md
Normal file
99
AGENTS.md
Normal 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Third-Party Notices
|
# 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
|
## 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,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
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.
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"dist/types-*.d.ts",
|
"dist/types-*.d.ts",
|
||||||
"dist/chunk-*.js",
|
"dist/chunk-*.js",
|
||||||
"dist/chunk-*.js.map",
|
"dist/chunk-*.js.map",
|
||||||
"dist/preview-template.html",
|
|
||||||
"README_RU.md",
|
"README_RU.md",
|
||||||
"docs/en/*.md",
|
"docs/en/*.md",
|
||||||
"docs/ru/*.md",
|
"docs/ru/*.md",
|
||||||
@@ -39,14 +38,13 @@
|
|||||||
"THIRD_PARTY_NOTICES.md"
|
"THIRD_PARTY_NOTICES.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup && npm run build:preview",
|
"build": "npm run build:package",
|
||||||
"build:preview": "npm ci --prefix preview && npm run build --prefix preview",
|
"build:package": "tsup",
|
||||||
"build:skill": "node skills/svg-sprites/build.mjs",
|
"build:skill": "node skills/svg-sprites/build.mjs",
|
||||||
"check:skill": "node skills/svg-sprites/build.mjs --check",
|
"check:skill": "node skills/svg-sprites/build.mjs --check",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"test": "tsup && node --test test/*.test.mjs",
|
"test": "tsup && node --test test/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"sprite": "node dist/cli.js --mode legacy .",
|
|
||||||
"verify": "npm run check:skill && npm run typecheck && npm test",
|
"verify": "npm run check:skill && npm run typecheck && npm test",
|
||||||
"prepack": "npm run verify && npm run build"
|
"prepack": "npm run verify && npm run build"
|
||||||
},
|
},
|
||||||
|
|||||||
25
preview/.gitignore
vendored
25
preview/.gitignore
vendored
@@ -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?
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -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
3116
preview/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`)
|
|
||||||
@@ -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'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}>
|
|
||||||
◑
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { useTheme } from './hooks/use-theme.hook'
|
|
||||||
export type { Theme } from './types/theme.type'
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
.root {
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/**
|
|
||||||
* Theme infrastructure module — реэкспортирует хук.
|
|
||||||
* Компонент не требуется, тема управляется через data-theme на documentElement.
|
|
||||||
*/
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Тема приложения.
|
|
||||||
*/
|
|
||||||
export type Theme = 'light' | 'dark'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Параметры Theme.
|
|
||||||
*/
|
|
||||||
export type ThemeParams = {}
|
|
||||||
|
|
||||||
export type ThemeProps = ThemeParams
|
|
||||||
@@ -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>,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export type {
|
|
||||||
IconVar,
|
|
||||||
IconData,
|
|
||||||
SpriteGroup,
|
|
||||||
SpritesData,
|
|
||||||
} from './sprites-data.type'
|
|
||||||
@@ -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[]
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { Banner } from './banner.ui'
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { CodeBlock } from './code-block.ui'
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
const ESCAPE_RE = /[&<>"]/g
|
|
||||||
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
|
||||||
|
|
||||||
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('')
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { ColorPicker } from './color-picker.ui'
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { IconCard } from './icon-card.ui'
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { IconGrid } from './icon-grid.ui'
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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}>
|
|
||||||
✕
|
|
||||||
</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} × {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 невозможно. <img> изолирует 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { IconModal } from './icon-modal.ui'
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { SearchInput } from './search-input.ui'
|
|
||||||
@@ -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)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
6
preview/src/vite-env.d.ts
vendored
6
preview/src/vite-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare module '*.module.css' {
|
|
||||||
const classes: { readonly [key: string]: string }
|
|
||||||
export default classes
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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
43
src/api/next.ts
Normal 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
30
src/api/react.ts
Normal 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>
|
||||||
|
}
|
||||||
30
src/cli.ts
30
src/cli.ts
@@ -1,17 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import path from 'node:path'
|
|
||||||
import { CLI_USAGE, parseCliArgs } from './cli/parse-args.js'
|
import { CLI_USAGE, parseCliArgs } from './cli/parse-args.js'
|
||||||
|
import { generateSprite } from './generate.js'
|
||||||
import { log } from './logger.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() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
@@ -22,23 +12,7 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (args.mode) {
|
await generateSprite(args.path, args.overrides)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(error instanceof Error ? error.message : String(error))
|
log.error(error instanceof Error ? error.message : String(error))
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -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'
|
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 = [
|
export const CLI_USAGE = [
|
||||||
'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:',
|
'Modes:',
|
||||||
' legacy Generate sprites through the legacy pipeline',
|
' react@vite',
|
||||||
' react@vite Generate a React module for Vite',
|
' react@webpack',
|
||||||
' react@webpack Generate a React module for Webpack 5',
|
' next@app/turbopack',
|
||||||
' next@app/turbopack Generate an App Router module for Turbopack',
|
' next@app/webpack',
|
||||||
' next@app/webpack Generate an App Router module for Webpack 5',
|
' next@pages/turbopack',
|
||||||
' next@pages/turbopack Generate a Pages Router module for Turbopack',
|
' next@pages/webpack',
|
||||||
' next@pages/webpack Generate a Pages Router module for Webpack 5',
|
'',
|
||||||
|
'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')
|
].join('\n')
|
||||||
|
|
||||||
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
|
function optionValue(argv: string[], index: number, option: string): [string, number] {
|
||||||
if (argv.includes('--help') || argv.includes('-h')) {
|
const argument = argv[index]
|
||||||
return { help: true }
|
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 positional: string[] = []
|
||||||
|
const overrides: SpriteConfig = {}
|
||||||
|
|
||||||
for (let index = 0; index < argv.length; index++) {
|
for (let index = 0; index < argv.length; index++) {
|
||||||
const argument = argv[index]
|
const argument = argv[index]
|
||||||
|
|
||||||
if (argument === '--mode') {
|
if (argument === '--mode' || argument.startsWith('--mode=')) {
|
||||||
modeValue = argv[index + 1]
|
const [value, nextIndex] = optionValue(argv, index, '--mode')
|
||||||
index++
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (argument.startsWith('--mode=')) {
|
switch (argument) {
|
||||||
modeValue = argument.slice('--mode='.length)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (argument.startsWith('-')) {
|
if (argument.startsWith('-')) {
|
||||||
throw new Error(`Unknown argument: ${argument}\n\n${CLI_USAGE}`)
|
throw new Error(`Unknown argument: ${argument}\n\n${CLI_USAGE}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
positional.push(argument)
|
positional.push(argument)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!modeValue) {
|
|
||||||
throw new Error(`Missing required argument: --mode\n\n${CLI_USAGE}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (positional.length === 0) {
|
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) {
|
if (positional.length > 1) {
|
||||||
throw new Error(`Expected one sprite path, received: ${positional.join(', ')}`)
|
throw new Error(`Expected one config file or module directory, 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(', ')}.`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: 'react',
|
|
||||||
path: positional[0],
|
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(', ')}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,6 @@
|
|||||||
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
|
import type { SpriteConfig } from '../types.js'
|
||||||
|
|
||||||
/** Корневой режим генерации, определяющий структуру создаваемых файлов. */
|
export type CliArgs = {
|
||||||
export type GenerationMode = 'legacy' | 'next' | 'react'
|
|
||||||
|
|
||||||
/** Аргументы legacy pipeline. */
|
|
||||||
export type LegacyCliArgs = {
|
|
||||||
mode: 'legacy'
|
|
||||||
path: string
|
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
232
src/config.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/core/compiled-artifact.ts
Normal file
70
src/core/compiled-artifact.ts
Normal 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 }
|
||||||
|
}
|
||||||
3
src/core/generated-markers.ts
Normal file
3
src/core/generated-markers.ts
Normal 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
53
src/core/mode-adapter.ts
Normal 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
17
src/core/naming.ts
Normal 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
297
src/core/output-writer.ts
Normal 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`)
|
||||||
|
}
|
||||||
38
src/core/prepare-sprite.ts
Normal file
38
src/core/prepare-sprite.ts
Normal 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
15
src/core/result.ts
Normal 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
65
src/generate.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/index.ts
47
src/index.ts
@@ -1,33 +1,36 @@
|
|||||||
import type { SvgSpritesConfig } from './types.js'
|
import type { NextSpriteConfig } from './api/next.js'
|
||||||
import type { NextSpriteConfig } from './modes/next/types.js'
|
import type { ReactSpriteConfig } from './api/react.js'
|
||||||
import type { ReactSpriteConfig } from './modes/react/types.js'
|
import type { SpriteConfig } from './types.js'
|
||||||
|
|
||||||
export { generateLegacy } from './modes/legacy/generate.js'
|
export {
|
||||||
export { resolveSprites, resolveSpriteEntry } from './scanner.js'
|
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 { compileSprite, compileSpriteContent } from './compiler.js'
|
||||||
export type { CompileSpriteOptions } from './compiler.js'
|
export type { CompileSpriteOptions } from './compiler.js'
|
||||||
export { createShapeTransform } from './transforms.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 {
|
export type {
|
||||||
NextAssetTarget,
|
NextAssetTarget,
|
||||||
NextBundler,
|
NextBundler,
|
||||||
NextRouter,
|
NextRouter,
|
||||||
ReactAssetTarget,
|
ReactAssetTarget,
|
||||||
|
ReactSpriteMode,
|
||||||
SpriteAssetTarget,
|
SpriteAssetTarget,
|
||||||
|
SpriteMode,
|
||||||
ViteAssetTarget,
|
ViteAssetTarget,
|
||||||
WebpackAssetTarget,
|
WebpackAssetTarget,
|
||||||
} from './targets/types.js'
|
} from './targets/types.js'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SvgSpritesConfig,
|
ResolvedSpriteConfig,
|
||||||
SpriteEntry,
|
SpriteConfig,
|
||||||
SpriteResult,
|
|
||||||
SpriteFolder,
|
SpriteFolder,
|
||||||
SpriteFormat,
|
SpriteFormat,
|
||||||
TransformOptions,
|
TransformOptions,
|
||||||
@@ -36,24 +39,24 @@ export type {
|
|||||||
NextSpriteConfig,
|
NextSpriteConfig,
|
||||||
NextSpriteGenerationOptions,
|
NextSpriteGenerationOptions,
|
||||||
NextSpriteGenerationResult,
|
NextSpriteGenerationResult,
|
||||||
} from './modes/next/types.js'
|
} from './api/next.js'
|
||||||
export type {
|
export type {
|
||||||
ReactSpriteConfig,
|
ReactSpriteConfig,
|
||||||
ReactSpriteGenerationResult,
|
ReactSpriteGenerationResult,
|
||||||
ResolvedReactSpriteConfig,
|
ResolvedReactSpriteConfig,
|
||||||
} from './modes/react/types.js'
|
} from './api/react.js'
|
||||||
|
|
||||||
/** Хелпер для типизации legacy-конфига. */
|
/** Хелпер для типизации единого sprite-конфига. */
|
||||||
export function defineLegacyConfig(config: SvgSpritesConfig): SvgSpritesConfig {
|
export function defineSpriteConfig(config: SpriteConfig): SpriteConfig {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Хелпер для типизации локального React-конфига. */
|
/** @deprecated Используйте defineSpriteConfig. */
|
||||||
export function defineReactSpriteConfig(config: ReactSpriteConfig): ReactSpriteConfig {
|
export function defineReactSpriteConfig(config: ReactSpriteConfig): ReactSpriteConfig {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Хелпер для типизации локального Next.js-конфига. */
|
/** @deprecated Используйте defineSpriteConfig. */
|
||||||
export function defineNextSpriteConfig(config: NextSpriteConfig): NextSpriteConfig {
|
export function defineNextSpriteConfig(config: NextSpriteConfig): NextSpriteConfig {
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/mode-registry.ts
Normal file
21
src/mode-registry.ts
Normal 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]
|
||||||
|
}
|
||||||
@@ -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.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { loadLegacyConfig, validateLegacyConfig } from './config.js'
|
|
||||||
export { generateLegacy } from './generate.js'
|
|
||||||
26
src/modes/next-app-turbopack/adapter.ts
Normal file
26
src/modes/next-app-turbopack/adapter.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
170
src/modes/next-app-turbopack/output.ts
Normal file
170
src/modes/next-app-turbopack/output.ts
Normal 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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
18
src/modes/next-app-webpack/adapter.ts
Normal file
18
src/modes/next-app-webpack/adapter.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
88
src/modes/next-app-webpack/output.ts
Normal file
88
src/modes/next-app-webpack/output.ts
Normal 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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
18
src/modes/next-pages-turbopack/adapter.ts
Normal file
18
src/modes/next-pages-turbopack/adapter.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
88
src/modes/next-pages-turbopack/output.ts
Normal file
88
src/modes/next-pages-turbopack/output.ts
Normal 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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
18
src/modes/next-pages-webpack/adapter.ts
Normal file
18
src/modes/next-pages-webpack/adapter.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
88
src/modes/next-pages-webpack/output.ts
Normal file
88
src/modes/next-pages-webpack/output.ts
Normal 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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { generateNextSprite } from './generate.js'
|
|
||||||
export type {
|
|
||||||
NextSpriteConfig,
|
|
||||||
NextSpriteGenerationOptions,
|
|
||||||
NextSpriteGenerationResult,
|
|
||||||
} from './types.js'
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
29
src/modes/react-vite/adapter.ts
Normal file
29
src/modes/react-vite/adapter.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
268
src/modes/react-vite/output.ts
Normal file
268
src/modes/react-vite/output.ts
Normal 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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
26
src/modes/react-webpack/adapter.ts
Normal file
26
src/modes/react-webpack/adapter.ts
Normal 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' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
187
src/modes/react-webpack/output.ts
Normal file
187
src/modes/react-webpack/output.ts
Normal 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) },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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')
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}".`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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`)
|
|
||||||
}
|
|
||||||
185
src/preview.ts
185
src/preview.ts
@@ -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
Reference in New Issue
Block a user