feat: выпустить версию 1.0.0

- добавлены отдельные режимы генерации для React, Next.js и legacy
- добавлены SpriteViewer, типизированные компоненты и безопасный codegen
- перенесена сборка AI-скила и обновлена документация
- добавлены migration guide, лицензии и проверки публикации
This commit is contained in:
2026-07-11 07:00:59 +03:00
parent 3a60b5c6ae
commit 05a0a9f7ed
76 changed files with 9456 additions and 1033 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 S. Gromov
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.

555
README.md
View File

@@ -2,213 +2,394 @@
![npm](https://img.shields.io/npm/v/@gromlab/svg-sprites) ![license](https://img.shields.io/npm/l/@gromlab/svg-sprites)
Генерация SVG-спрайтов из папок с иконками. TypeScript-типизация, SVG-трансформации, React-компонент и HTML-превью из коробки.
CLI для генерации SVG-спрайтов и типизированных компонентов иконок для React и Next.js.
![Preview](https://gromlab.ru/gromov/svg-sprites/media/branch/master/preview-image.png)
**Чем отличается от аналогов:**
## Навигация
- Генерирует готовый типизированный React-компонент — не нужно писать обёртку
- Автоматически заменяет цвета на CSS-переменные — для тем и hover-состояний
- Выдаёт HTML-превью всех иконок — удобно пересылать дизайнеру
- [Возможности](#возможности)
- [Таблица поддержки](#таблица-поддержки)
- [Требования](#требования)
- [Быстрый старт](#быстрый-старт)
- [React + Vite](docs/ru/react-vite.md)
- [React + Webpack 5](docs/ru/react-webpack.md)
- [Next.js App Router](docs/ru/next-app.md)
- [Next.js Pages Router](docs/ru/next-pages.md)
- [Конфигурация](#конфигурация)
- [React](#react)
- [Next.js](#nextjs)
- [Множественные спрайты](#множественные-спрайты)
- [TypeScript](#typescript)
- [Форматы спрайтов](#форматы-спрайтов)
- [Способы отображения](#способы-отображения)
- [Трансформации](#трансформации)
- [Управление цветом иконок](#управление-цветом-иконок)
- [Кеширование](#кеширование)
- [SpriteViewer](#spriteviewer)
- [Миграция с 0.1.x](docs/ru/migration-1.md)
- [Документация](#документация)
## Возможности
- **TypeScript-friendly** — типизированные React-компоненты, union-типы и runtime-списки доступных иконок.
- **Чистая генерация** — generated-файлы автоматически исключаются из Git, спрайт не нужно вручную размещать в `public`, а генератор обновляет только принадлежащие ему файлы.
- **Общие иконки без копирования** — SVG из локальной папки и `inputFiles` объединяются в один спрайт; один файл можно использовать в нескольких спрайтах.
- **Встроенное интерактивное превью** — `<SpriteViewer>` подключается как страница приложения и показывает переданные React- и Next.js-спрайты с поиском, настройкой цветов и примерами использования.
- **Настраиваемые трансформации SVG** — удаление `width` и `height` с сохранением `viewBox`, замена исходных цветов на CSS-переменные и transitions для `fill` и `stroke`.
- **Отдельный кешируемый SVG asset** — SVG path-данные не попадают в JavaScript chunks, а сборщик выпускает файл с content hash.
- **Множественные спрайты** — независимые React- и Next.js-модули со своими компонентами, типами и SVG assets.
- **Server-first Next.js** — generated-компоненты работают в Server Components, SSR и SSG без директивы `'use client'`.
- **Форматы под разные сценарии** — React и Next.js используют `stack`, legacy-режим также поддерживает `symbol` для существующих интеграций.
## Таблица поддержки
| Среда | Ключ мода API | Статус |
|---|---|---|
| React + Vite | `react@vite` | Готово |
| React + Webpack 5 | `react@webpack` | Готово |
| Next.js 16.2+ App Router + Turbopack | `next@app/turbopack` | Готово |
| Next.js 13.4+ App Router + Webpack 5 | `next@app/webpack` | Готово |
| Next.js 16.2+ Pages Router + Turbopack | `next@pages/turbopack` | Готово |
| Next.js 12.2+ Pages Router + Webpack 5 | `next@pages/webpack` | Готово |
| Vue | — | Скоро |
| Standalone | — | Скоро |
## Требования
- Node.js 18+
- React 18+ (опционально, только если нужен генерируемый компонент)
## Установка
```bash
npm install @gromlab/svg-sprites
```
- Node.js 18 или новее;
- пакет распространяется только как ESM и подключается через `import`;
- React 18 или 19 требуется только для generated-компонентов и точки входа `@gromlab/svg-sprites/react`;
- для типизации subpath exports используйте TypeScript 5+ с `moduleResolution: "bundler"`, `"node16"` или `"nodenext"`.
## Быстрый старт
Создайте файл `svg-sprites.config.ts` в корне проекта:
Для быстрого старта воспользуйтесь инструкцией для вашего стека:
```ts
import { defineConfig } from '@gromlab/svg-sprites'
export default defineConfig({
// Папка для сгенерированных SVG-спрайтов
output: 'public/sprites',
// URL-путь к спрайтам (для href в React-компоненте)
publicPath: '/sprites',
// Папка для React-компонента и типов
react: 'src/shared/ui/svg-sprite',
sprites: [
{ name: 'icons', input: 'src/assets/icons' },
{ name: 'logos', input: 'src/assets/logos' },
],
})
```
Запустите генерацию:
```bash
# или добавьте "sprite": "svg-sprites" в scripts вашего package.json
npx svg-sprites
```
В результате будут сгенерированы SVG-спрайты, типизированный React-компонент и HTML-превью.
Цвета иконок автоматически заменяются на CSS-переменные — см. раздел [Управление цветом](#управление-цветом).
## Использование компонента
```tsx
import { SvgSprite } from './shared/ui/svg-sprite'
// Иконка из первого спрайта (по умолчанию)
<SvgSprite icon="check" />
// Иконка из другого спрайта
<SvgSprite icon="github" sprite="logos" />
// Обёртка в <span> (удобно для inline-элементов)
<SvgSprite icon="arrow-left" wrapped />
```
Компонент полностью типизирован — автодополнение работает для имён иконок и спрайтов. Типы экспортируются из того же модуля:
```ts
import type {
IconsIconName, // 'check' | 'arrow-left' | ...
LogosIconName, // 'github' | 'twitter' | ...
SpriteName, // 'icons' | 'logos'
SpriteMap, // { icons: IconsIconName, logos: LogosIconName }
} from './shared/ui/svg-sprite'
```
## Управление цветом
При сборке цвета иконок заменяются на CSS-переменные. Это ключевая фича — иконки адаптируются к теме без дублирования SVG.
**Моно-иконка** (один цвет) — наследует `color` текста:
```css
.button { color: red; }
```
Или точечно через CSS-переменную:
```css
.button { --icon-color-1: #ff0000; }
```
**Мульти-иконка** (несколько цветов) — каждый цвет задаётся отдельной переменной:
```css
.card {
--icon-color-1: #ff0000;
--icon-color-2: #00ff00;
}
```
## Способы рендера
| Способ | Управление цветом | Пример |
|--------|-------------------|--------|
| React / SVG `<use>` | CSS-переменные, `color` | `<SvgSprite icon="check" />` |
| CSS `mask-image` | `background-color` (монохром) | `.icon { mask: url(...); background-color: red; }` |
| `<img>` | нет | `<img src="icons.sprite.svg#check">` |
- [React + Vite](docs/ru/react-vite.md)
- [React + Webpack 5](docs/ru/react-webpack.md)
- [Next.js App Router](docs/ru/next-app.md)
- [Next.js Pages Router](docs/ru/next-pages.md)
## Конфигурация
### Основные опции
| Опция | Обязательная | Описание |
|-------|-------------|----------|
| `output` | да | Папка для сгенерированных SVG-спрайтов |
| `sprites` | да | Массив спрайтов для генерации |
| `publicPath` | нет | URL-путь к спрайтам (для `href` в React-компоненте) |
| `react` | нет | Путь для генерации React-компонента и типов |
| `preview` | нет | Генерация HTML-превью (по умолчанию: `true`) |
### `sprites`
### React
```ts
sprites: [
{
name: 'icons', // имя спрайта → icons.sprite.svg
input: 'src/assets/icons', // папка с SVG-файлами
mode: 'stack', // 'stack' (по умолчанию) или 'symbol'
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
inputFiles: [
'../../shared/icons/check.svg',
],
transform: {
removeSize: true,
replaceColors: true,
addTransition: true,
},
{
name: 'flags',
input: [ // или массив конкретных файлов
'src/components/button/arrow.svg',
'src/components/modal/close.svg',
],
},
]
```
### `publicPath`
Публичный URL-путь к спрайтам. Зашивается в React-компонент для формирования `href`.
```ts
publicPath: '/sprites'
// → <use href="/sprites/icons.sprite.svg#check" />
```
> **Примечание:** путь не должен включать имя папки `public` — она не входит в URL в Vite/Next.
### `react`
Имена файлов берутся из названия папки:
```ts
react: 'src/shared/ui/svg-sprite'
// → index.ts + svg-sprite.tsx + svg-sprite.module.css
```
Если не задан — компонент и типы не генерируются.
### `transform`
Настройки трансформации SVG. Все опции включены по умолчанию.
```ts
transform: {
removeSize: true, // удаляет width/height с <svg>
replaceColors: true, // заменяет цвета на CSS-переменные
addTransition: true, // добавляет transition к элементам с цветом
}
```
Например, для спрайта с фиксированными цветами:
```ts
transform: {
replaceColors: false,
}
```
## Ограничения
- `mode: 'symbol'` — поддерживается, но превью и примеры кода оптимизированы под `stack`
- `replaceColors` может некорректно обработать иконки со сложными градиентами — используйте `transform: { replaceColors: false }` для таких случаев
- Генерируемый React-компонент предназначен для React 18+. Для Vue/Svelte используйте спрайты напрямую через SVG `<use>`
## Программный API
```ts
import { generate, defineConfig } from '@gromlab/svg-sprites'
const config = defineConfig({
output: 'public',
sprites: [{ name: 'icons', input: 'assets/icons' }],
generatedNotice: true,
})
const results = await generate(config)
```
| Опция | Тип | По умолчанию | Назначение |
|---|---|---|---|
| `name` | `string` | Имя папки | Имя спрайта, компонента и публичных типов |
| `description` | `string` | Нет | Описание для типов и debug-манифеста |
| `inputFolder` | `string` | `./icons` | Папка с исходными SVG относительно конфига |
| `inputFiles` | `string[]` | `[]` | Дополнительные SVG-файлы относительно конфига |
| `transform` | `TransformOptions` | Все включены | [Настройки трансформации](#трансформации) исходных SVG |
| `generatedNotice` | `boolean` | `true` | Полное либо короткое предупреждение в generated-файлах |
`inputFolder` и `inputFiles` объединяются в один спрайт, поэтому один SVG-файл можно использовать в нескольких спрайтах без копирования. Если неявной папки `./icons` нет, но `inputFiles` заполнен, генерация продолжается только по списку. Явно указанная отсутствующая папка считается ошибкой. Одинаковые пути дедуплицируются, а разные файлы с одинаковым именем иконки считаются ошибкой.
`name` записывается в kebab-case и должно начинаться с латинской буквы. React и Next.js presets создают формат `stack`.
### Next.js
Next.js использует тот же `svg-sprite.config.ts` и набор опций. Для типизации можно использовать отдельный хелпер:
```ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
})
```
Роутер и сборщик выбираются через mode key, поэтому переключение между Turbopack и Webpack всегда явно отражено в команде генерации.
## Множественные спрайты
Приложение может содержать несколько независимых спрайтов с разной областью использования:
**Проблема:** один глобальный спрайт загружает иконки, которые текущему экрану не нужны.
**Решение:** общие иконки хранить глобально, а наборы страниц и крупных компонентов — в отдельных спрайтах, загружаемых вместе с ними.
```text
global → GlobalIcon → общие иконки приложения
analytics-page → AnalyticsPageIcon → иконки отдельной страницы
file-manager → FileManagerIcon → иконки крупного компонента
```
- **Глобальный спрайт** содержит небольшие общие иконки, используемые в разных частях приложения: навигацию, состояния и базовые действия.
- **Спрайт страницы** загружается вместе с конкретным разделом и не увеличивает общий спрайт иконками, которые больше нигде не нужны.
- **Спрайт крупного компонента** инкапсулирует собственный набор иконок сложного UI-модуля, например файлового менеджера или редактора.
Каждая группа получает:
- собственный SVG asset;
- собственный типизированный компонент;
- отдельный список имён иконок;
- отдельный debug-манифест;
- независимый cache lifecycle.
## TypeScript
Главная возможность TypeScript API — автодополнение имён иконок непосредственно в prop `icon`:
```tsx
<FileManagerIcon icon="folder" />
// ↑ редактор предлагает все иконки спрайта
```
Имена SVG-файлов становятся допустимыми значениями `icon`. Опечатка или неизвестное имя сразу становятся ошибкой TypeScript:
```tsx
<FileManagerIcon icon="unknown" /> // ошибка TypeScript
```
Для программного доступа generated-модуль экспортирует readonly-массив всех доступных иконок конкретного спрайта:
```ts
import { fileManagerIconNames } from './svg-sprite'
// readonly ['check', 'folder', ...]
```
Этот список можно использовать в собственных каталогах, select-компонентах, тестах и других runtime-сценариях. Из него также выводится union-тип `FileManagerIconName`.
Имена файлов с пробелами и другими небезопасными для SVG ID символами остаются частью публичного TypeScript API. Для внутреннего `<symbol id>` генератор создаёт стабильный hash ID.
```text
folder open.svg → icon="folder open" → id="icon-<stable-hash>"
```
Для таких имён используйте generated-компонент или `id` из debug-манифеста. Ручные примеры ниже с `#<имя>` подходят только для имён, которые уже являются безопасными SVG ID.
## Форматы спрайтов
`stack` — более современный формат, поэтому он используется по умолчанию. Иконки можно отображать через `<svg><use>`, `<img>` и CSS `background-image`.
`symbol` сохраняется для совместимости с существующими интеграциями и поддерживает отображение только через `<svg><use>`.
## Способы отображения
### React-компонент — рекомендуется
Generated-компонент предоставляет типизацию, автодополнение имён иконок и сам формирует URL SVG asset.
```tsx
<FileManagerIcon icon="check" width={24} height={24} />
```
Через `color` и `--icon-color-N` доступны одноцветные и многоцветные иконки.
### Самостоятельно через `<svg><use>`
Хороший низкоуровневый способ с полным управлением размерами и цветами. React-компонент под капотом использует именно его.
Способ получения `spriteUrl` зависит от сборщика.
**Vite:**
```tsx
import spriteUrl from './svg-sprite/generated/sprite.svg?no-inline'
```
**Webpack 5:**
```tsx
const spriteUrl = new URL(
'./svg-sprite/generated/sprite.svg',
import.meta.url,
).href
```
**Next.js с Webpack 5 или Turbopack:**
```tsx
const spriteUrl = new URL(
'./svg-sprite/generated/sprite.svg',
import.meta.url,
).href
```
После получения URL иконка отображается одинаково:
```tsx
<svg width={24} height={24}>
<use href={`${spriteUrl}#check`} />
</svg>
```
Vite, Webpack 5 и Next.js сами заменяют исходный путь на итоговый URL asset с hash.
### Через `<img>` — менее эффективно
```tsx
<img src={`${spriteUrl}#check`} width={24} height={24} alt="Готово" />
```
SVG загружается как изолированное изображение: изменить его цвета через `color` или `--icon-color-N` нельзя.
### Через CSS `background-image` — менее эффективно
```css
.icon {
background: url('./svg-sprite/generated/sprite.svg#check') center / contain no-repeat;
}
```
Как и `<img>`, этот способ не позволяет управлять внутренними цветами SVG. Путь указывается относительно CSS-файла, а Vite/Webpack заменяет его на итоговый URL с hash при сборке.
### Через CSS mask — менее эффективно
```css
.icon {
background-color: currentColor;
mask: url('./svg-sprite/generated/sprite.svg#check') center / contain no-repeat;
}
```
Mask оставляет только силуэт и окрашивает его одним цветом. Исходные цвета, gradients и различия между `fill` и `stroke` теряются.
## Трансформации
Все трансформации включены по умолчанию и настраиваются независимо через `transform`.
| Опция | По умолчанию | Что делает |
|---|---|---|
| `removeSize` | `true` | Удаляет `width` и `height` с корневого `<svg>`, сохраняя существующий `viewBox`. Размер иконки после этого задаётся снаружи. |
| `replaceColors` | `true` | Заменяет цвета `fill` и `stroke` на `--icon-color-N`. Для одноцветной иконки fallback становится `currentColor`, для многоцветной сохраняются исходные цвета. |
| `addTransition` | `true` | Добавляет `style="transition:fill 0.3s,stroke 0.3s;"` непосредственно цветным элементам SVG. Существующий `transition` не перезаписывается. |
Чтобы отключить преобразование, передайте для соответствующей опции `false`. Подробнее о результате `replaceColors` — в разделе [«Управление цветом иконок»](#управление-цветом-иконок).
## Управление цветом иконок
При включённой замене цветов генератор анализирует `fill` и `stroke` и преобразует их в CSS custom properties.
### Монохромные иконки
Если найден один цвет, fallback заменяется на `currentColor`:
```svg
stroke="var(--icon-color-1, currentColor)"
```
Цветом управляет CSS-свойство `color` внешнего `<svg>` или его родителя.
### Многоцветные иконки
Каждый уникальный цвет получает отдельную переменную с исходным fallback:
```svg
fill="var(--icon-color-1, #798198)"
fill="var(--icon-color-2, #ffffff)"
fill="var(--icon-color-3, #129d9d)"
```
Страница может заменить только необходимые цвета:
```css
.icon {
--icon-color-1: #4b5563;
--icon-color-3: #14b8a6;
}
```
### Ограничения цветов
- `none`, `transparent`, `inherit`, `unset` и `initial` не заменяются;
- цвета в атрибутах `fill`, `stroke` и inline `style` обрабатываются надёжнее всего;
- CSS-классы и внешние stylesheets внутри исходного SVG не являются основным сценарием трансформации;
- gradients, patterns, filters и значения `url(#...)` требуют отдельной проверки и могут быть несовместимы с автоматической заменой цветов;
- CSS-переменные страницы доступны при `<svg><use>`, но недоступны внутри `<img>` и `background-image`.
## Кеширование
Vite, Webpack и Next.js target выпускают спрайт отдельным asset с content hash:
```text
/assets/sprite-<hash>.svg
```
Это даёт следующие свойства:
- SVG кешируется независимо от JavaScript;
- изменение React-кода не меняет содержимое спрайта;
- изменение иконок создаёт новый hash asset;
- один файл используется всеми экземплярами generated-компонента;
- SVG path-данные отсутствуют в JavaScript chunks.
Vite target запрещает inline через `?no-inline`. Webpack 5 target использует Asset Modules через `new URL(..., import.meta.url)`.
## SpriteViewer
`SpriteViewer` — React-компонент для просмотра generated-спрайтов внутри debug-маршрута приложения.
Он использует отдельные манифесты и показывает:
- группы спрайтов;
- список и количество иконок;
- поиск и системную светлую/тёмную тему;
- модальное превью с `viewBox` и настройкой цветовых переменных;
- примеры React, SVG, IMG и CSS с копированием кода.
Production-компоненты не импортируют debug-манифесты. Способ подключения Viewer зависит от сборщика:
- [React + Vite: автоматический `import.meta.glob`](docs/ru/react-vite.md#6-добавьте-debug-страницу);
- [React + Webpack 5: статические `import()`](docs/ru/react-webpack.md#6-добавьте-debug-страницу);
- [Next.js App Router](docs/ru/next-app.md#5-добавьте-spriteviewer);
- [Next.js Pages Router](docs/ru/next-pages.md#5-добавьте-spriteviewer).
Viewer подключается из отдельной клиентской точки входа `@gromlab/svg-sprites/react` и не попадает в production-компоненты иконок.
### Тема Viewer
По умолчанию `colorTheme="auto"`: Viewer следует `prefers-color-scheme` и реагирует на смену системной темы. Тему приложения можно передать явно:
```tsx
<SpriteViewer sources={sources} colorTheme="dark" />
```
Допустимые значения `colorTheme`: `auto`, `light`, `dark`. При управлении темой извне встроенный переключатель скрывается. Чтобы оставить его и обновлять тему приложения через Viewer, передайте callback:
```tsx
<SpriteViewer
sources={sources}
colorTheme={appTheme}
onColorThemeChange={setAppTheme}
/>
```
## Документация
- [React + Vite](docs/ru/react-vite.md)
- [React + Webpack 5](docs/ru/react-webpack.md)
- [Next.js App Router](docs/ru/next-app.md)
- [Next.js Pages Router](docs/ru/next-pages.md)
- [Legacy mode](docs/ru/legacy.md)
- [Миграция с 0.1.x](docs/ru/migration-1.md)
- [Программный API](docs/ru/programmatic-api.md)
## Лицензия
MIT

75
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,75 @@
# Third-Party Notices
The distributed React entry and preview template include code from the projects listed below.
## React, React DOM and Scheduler
Copyright (c) Meta Platforms, Inc. and affiliates.
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.
## react-colorful
Copyright (c) 2020 Vlad Shilov <omgovich@ya.ru>
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.
## 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.

102
docs/ru/legacy.md Normal file
View File

@@ -0,0 +1,102 @@
# Legacy mode
[← Главная](../../README.md)
Краткая инструкция по генерации централизованных SVG-спрайтов форматов `symbol` и `stack` с optional HTML preview.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Подготовьте иконки и конфиг
```text
project/
├── src/assets/icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprites.config.ts
```
```ts
// svg-sprites.config.ts
import { defineLegacyConfig } from '@gromlab/svg-sprites'
export default defineLegacyConfig({
output: 'public/sprites',
preview: true,
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'symbol',
},
],
})
```
## 3. Запустите генерацию
```bash
npx svg-sprites --mode legacy .
```
Результат:
```text
public/sprites/
├── icons.sprite.svg
└── preview.html
```
При `preview: false` HTML-файл не создаётся. Для формата `stack` укажите `format: 'stack'`.
## 4. Используйте symbol-спрайт
```html
<svg width="24" height="24" aria-label="Готово">
<use href="/sprites/icons.sprite.svg#check"></use>
</svg>
```
## 5. Добавьте package script
```json
{
"scripts": {
"sprites": "svg-sprites --mode legacy .",
"prebuild": "npm run sprites"
}
}
```
## Несколько спрайтов
Добавьте несколько записей в `sprites`:
```ts
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'symbol',
},
{
name: 'logos',
input: 'src/assets/logos',
format: 'stack',
},
]
```
Все результаты и общий `preview.html` будут записаны в `output`.
## Если что-то не работает
- Не найден конфиг: убедитесь, что `svg-sprites.config.ts` находится в переданном корне.
- Нет иконок: проверьте `sprites[].input` и расширение `.svg`.
- Не нужен preview: установите `preview: false`.
Для программного запуска используйте [`generateLegacy`](programmatic-api.md#generatelegacy).

96
docs/ru/migration-1.md Normal file
View File

@@ -0,0 +1,96 @@
# Миграция с 0.1.x на 1.0
[← Главная](../../README.md)
Версия 1.0 разделяет локальную генерацию для React и Next.js и централизованный legacy-режим. Старый config нельзя смешивать с новым API в одном вызове CLI.
## CLI
CLI теперь всегда требует явный `--mode` и путь к каталогу конфигурации:
```text
svg-sprites
→ svg-sprites --mode <mode> <path>
```
Выберите mode по окружению:
| Окружение | Mode |
|---|---|
| React + Vite | `react@vite` |
| React + Webpack 5 | `react@webpack` |
| Next.js App Router + Turbopack | `next@app/turbopack` |
| Next.js App Router + Webpack 5 | `next@app/webpack` |
| Next.js Pages Router + Turbopack | `next@pages/turbopack` |
| Next.js Pages Router + Webpack 5 | `next@pages/webpack` |
| Централизованная старая схема | `legacy` |
## React и Next.js
Вместо корневого `svg-sprites.config.ts` создайте локальный `svg-sprite.config.ts` рядом с набором иконок:
```ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'global',
inputFolder: './icons',
})
```
Для обычного React используйте `defineReactSpriteConfig`. Папку и явный список общих SVG можно объединить через `inputFolder` и `inputFiles`.
Старые `publicPath` и `react` больше не нужны. Generated-модуль создаётся рядом с конфигом, сам добавляет `.gitignore`, а Vite, Webpack или Next.js выпускает SVG как отдельный asset с content hash.
Компонент `<SvgSprite icon="..." />` заменяется компонентом, имя которого выводится из `name`:
```tsx
<GlobalIcon icon="check" />
```
Для просмотра иконок добавьте `<SpriteViewer>` как debug-страницу приложения. Отдельный `preview.html` остаётся только в legacy-режиме.
## Legacy-режим
Если централизованную структуру нужно сохранить, переименуйте helper и поля формата:
```ts
import { defineLegacyConfig } from '@gromlab/svg-sprites'
export default defineLegacyConfig({
output: 'public/sprites',
preview: true,
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'stack',
},
],
})
```
- `defineConfig` заменён на `defineLegacyConfig`;
- `sprites[].mode` переименован в `sprites[].format`;
- `generate` заменён на `generateLegacy`;
- `loadConfig` заменён на `loadLegacyConfig`;
- `publicPath` и генерация старого общего React-компонента удалены.
Запуск:
```bash
svg-sprites --mode legacy .
```
## Программный API
Пакет распространяется только как ESM. Замените `require()` на `import`.
`compileSpriteContent` теперь возвращает `Promise<Uint8Array>`, чтобы публичные декларации не требовали установки `@types/node`. В Node.js фактический результат совместим с API, принимающими `Uint8Array`.
## После миграции
1. Удалите старые generated-файлы и правила, которые игнорировали целиком каталог с исходными иконками.
2. Добавьте явную команду генерации перед `dev`, `build` и `typecheck`.
3. Запустите генерацию и проверку типов.
4. Проверьте все иконки и цветовые переменные через `SpriteViewer` или legacy `preview.html`.

102
docs/ru/next-app.md Normal file
View File

@@ -0,0 +1,102 @@
# Next.js App Router
[← Главная](../../README.md)
Поддерживаются два явных режима:
| Сборщик | Mode key | Версия Next.js |
|---|---|---|
| Turbopack | `next@app/turbopack` | 16.2+ |
| Webpack 5 | `next@app/webpack` | 13.4+ |
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте sprite-модуль
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
## 3. Добавьте генерацию
Для Turbopack:
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode next@app/turbopack src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager"
}
}
```
Для Webpack замените mode key на `next@app/webpack`. В Next 1315 Webpack используется обычной командой `next build`, в Next 16 — командой `next build --webpack`.
## 4. Используйте в Server Component
Generated-компонент не содержит `'use client'`, поэтому его можно импортировать непосредственно в `page.tsx` или `layout.tsx`:
```tsx
import { FileManagerIcon } from '@/ui/file-manager/svg-sprite'
export default function Page() {
return (
<main>
<FileManagerIcon icon="folder" width={24} height={24} />
</main>
)
}
```
Next.js выпустит отдельный SVG asset с content hash. Один generated-код используется при SSR и в браузере без расхождения URL.
## 5. Добавьте SpriteViewer
Viewer интерактивен, поэтому для него нужна отдельная Client Component граница:
```tsx
'use client'
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const sources = [
() => import('@/ui/file-manager/svg-sprite/manifest'),
]
export default function SpritesPage() {
return <SpriteViewer sources={sources} />
}
```
## Проверка сборщика
```bash
# Turbopack
npx next build --turbopack
# Webpack 5
npx next build --webpack
```
Для Next 1315 с Webpack используйте `npx next build` без флага.
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.

96
docs/ru/next-pages.md Normal file
View File

@@ -0,0 +1,96 @@
# Next.js Pages Router
[← Главная](../../README.md)
Поддерживаются два явных режима:
| Сборщик | Mode key | Версия Next.js |
|---|---|---|
| Turbopack | `next@pages/turbopack` | 16.2+ |
| Webpack 5 | `next@pages/webpack` | 12.2+ |
Для Next.js 12.2 требуется React 18.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте sprite-модуль
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
## 3. Добавьте генерацию
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode next@pages/webpack src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager"
}
}
```
Для Next.js 16.2 с Turbopack замените mode key на `next@pages/turbopack`.
## 4. Используйте на странице
```tsx
import { FileManagerIcon } from '@/ui/file-manager/svg-sprite'
export default function FilesPage() {
return <FileManagerIcon icon="folder" width={24} height={24} />
}
export function getServerSideProps() {
return { props: {} }
}
```
Компонент одинаково работает при SSR, SSG и клиентских переходах. Next.js выпускает отдельный SVG asset с content hash.
## 5. Добавьте SpriteViewer
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const sources = [
() => import('@/ui/file-manager/svg-sprite/manifest'),
]
export default function SpritesPage() {
return <SpriteViewer sources={sources} />
}
```
## Проверка сборщика
```bash
# Turbopack
npx next build --turbopack
# Webpack 5
npx next build --webpack
```
Для Next 1215 с Webpack используйте `npx next build` без флага.
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.

203
docs/ru/programmatic-api.md Normal file
View File

@@ -0,0 +1,203 @@
# Программный API
[← Главная](../../README.md)
Пакет предоставляет основную Node.js точку входа и отдельный React runtime entry. Обе точки распространяются только как ESM и подключаются через `import`.
Для разрешения `@gromlab/svg-sprites/react` в TypeScript используйте `moduleResolution: "bundler"`, `"node16"` или `"nodenext"`.
## Основной entry
```ts
import {
defineNextSpriteConfig,
defineReactSpriteConfig,
generateNextSprite,
generateReactSprite,
} from '@gromlab/svg-sprites'
```
Основной entry не импортирует React и может использоваться в CLI, build scripts и Node.js инструментах.
## `generateReactSprite`
```ts
import { generateReactSprite } from '@gromlab/svg-sprites'
const result = await generateReactSprite(
'src/ui/file-manager/svg-sprite',
'vite',
)
```
Второй аргумент обязателен:
```ts
type ReactAssetTarget = 'vite' | 'webpack'
```
Результат:
```ts
type ReactSpriteGenerationResult = {
name: string
rootDir: string
generatedDir: string
spritePath: string
manifestPath: string
iconCount: number
target: 'vite' | 'webpack'
}
```
```ts
console.log(result.name)
console.log(result.iconCount)
console.log(result.spritePath)
console.log(result.manifestPath)
```
Функция загружает `svg-sprite.config.ts` из указанного корня, компилирует SVG и безопасно обновляет managed-файлы.
## `generateNextSprite`
```ts
import { generateNextSprite } from '@gromlab/svg-sprites'
const result = await generateNextSprite(
'src/ui/file-manager/svg-sprite',
{
router: 'app',
bundler: 'turbopack',
},
)
```
Доступные значения:
```ts
type NextSpriteGenerationOptions = {
router: 'app' | 'pages'
bundler: 'turbopack' | 'webpack'
}
```
Результат дополнительно содержит выбранные `router`, `bundler` и полный target вида `next@app/turbopack`.
## `defineReactSpriteConfig`
```ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
inputFiles: [
'../../shared/icons/check.svg',
],
transform: {
removeSize: true,
replaceColors: true,
addTransition: true,
},
generatedNotice: true,
})
```
`inputFolder` и `inputFiles` объединяются. Хелпер возвращает конфиг без runtime-преобразований и предоставляет TypeScript autocomplete.
## `defineNextSpriteConfig`
```ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
})
```
Next.js использует тот же контракт конфигурации, что и React presets.
## `generateLegacy`
```ts
import { generateLegacy } from '@gromlab/svg-sprites'
const results = await generateLegacy({
output: 'public/sprites',
preview: false,
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'symbol',
},
],
})
```
Возвращается массив:
```ts
type SpriteResult = {
name: string
format: 'symbol' | 'stack'
spritePath: string
iconCount: number
}
```
Подробнее: [Legacy mode](legacy.md).
## Низкоуровневые функции
Основная точка входа также экспортирует:
```ts
import {
compileSprite,
compileSpriteContent,
createShapeTransform,
generatePreview,
loadLegacyConfig,
loadReactSpriteConfig,
resolveSpriteEntry,
resolveSprites,
} from '@gromlab/svg-sprites'
```
Эти функции предназначены для собственного orchestration поверх существующего compiler и writer. Для стандартного использования предпочтительны `generateReactSprite` и `generateLegacy`.
## React runtime entry
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
```
Типы:
```ts
import type {
SpriteManifest,
SpriteManifestColor,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerColorTheme,
SpriteViewerProps,
SpriteViewerSource,
SpriteViewerSources,
} from '@gromlab/svg-sprites/react'
```
React entry содержит `'use client'` и предназначен для debug-инструментов. Generated production-компоненты импортируются из локальных sprite-модулей приложения, а не из React entry пакета.
`SpriteViewerProps.colorTheme` принимает `auto | light | dark`. Значение `auto` используется по умолчанию и следует `prefers-color-scheme`; для синхронизации с темой приложения передавайте вычисленное `light` или `dark`.
## Связанные руководства
- [React + Vite](react-vite.md)
- [React + Webpack 5](react-webpack.md)

116
docs/ru/react-vite.md Normal file
View File

@@ -0,0 +1,116 @@
# React + Vite
[← Главная](../../README.md)
Краткая инструкция по установке и использованию SVG-спрайтов в проекте на React и Vite.
В результате вы получите типизированный React-компонент и отдельный кешируемый SVG asset.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте папку спрайта
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
Поместите исходные SVG-файлы в `icons/`.
## 3. Добавьте конфиг
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
По умолчанию SVG берутся из `./icons`. Общие иконки из других папок можно добавить через `inputFiles`: папка и список объединяются в один спрайт.
Полный список опций находится в разделе [Конфигурация → React](../../README.md#react).
## 4. Добавьте генерацию в package.json
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode react@vite src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager",
"pretypecheck": "npm run sprite:file-manager"
}
}
```
Generated-файлы исключаются из Git, поэтому генерация должна выполняться перед `dev`, `build` и `typecheck`.
Первый запуск:
```bash
npm run sprite:file-manager
```
## 5. Используйте компонент
Имя `file-manager` преобразуется в `FileManagerIcon`:
```tsx
import { FileManagerIcon } from './svg-sprite'
export const OpenFolderButton = () => (
<button type="button">
<FileManagerIcon icon="folder" width={24} height={24} />
Открыть
</button>
)
```
Значение `icon` проверяется TypeScript по именам файлов:
```tsx
<FileManagerIcon icon="check" /> // допустимо
<FileManagerIcon icon="unknown" /> // ошибка TypeScript
```
Типы, способы отображения и управление цветами описаны в [основной документации](../../README.md#способы-отображения).
Vite выпустит спрайт отдельным файлом вида `assets/sprite-<hash>.svg`. SVG path-данные не попадут в JavaScript.
## 6. Добавьте debug-страницу
После подключения иконок можно вывести все React-спрайты через `SpriteViewer`:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
import type { SpriteManifestModule } from '@gromlab/svg-sprites/react'
const sources = import.meta.glob<SpriteManifestModule>(
'/src/**/svg-sprite/manifest.ts',
)
export const IconsDebugPage = () => (
<SpriteViewer sources={sources} title="Иконки проекта" />
)
```
Vite автоматически найдёт generated `manifest.ts` каждого React-спрайта. Шаблон `import.meta.glob` должен быть строковым литералом, а генерация должна выполниться до запуска Vite.
Размещайте Viewer только на debug-маршруте или во внутреннем инструменте.
## Если что-то не работает
- Нет `index.ts`: запустите `npm run sprite:file-manager`.
- Viewer не видит спрайт: проверьте путь glob и наличие `manifest.ts`.
- Ошибка `Refusing to overwrite a user file`: в generated-пути находится пользовательский файл.
- Иконка не меняет цвет: используйте `color` или `--icon-color-N`.

118
docs/ru/react-webpack.md Normal file
View File

@@ -0,0 +1,118 @@
# React + Webpack 5
[← Главная](../../README.md)
Краткая инструкция по установке и использованию SVG-спрайтов в проекте на React и Webpack 5.
В результате вы получите типизированный React-компонент и отдельный SVG asset через Webpack Asset Modules.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте папку спрайта
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
Поместите исходные SVG-файлы в `icons/`.
## 3. Добавьте конфиг
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
По умолчанию SVG берутся из `./icons`. Общие иконки из других папок можно добавить через `inputFiles`: папка и список объединяются в один спрайт.
Полный список опций находится в разделе [Конфигурация → React](../../README.md#react).
## 4. Добавьте генерацию в package.json
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode react@webpack src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager",
"pretypecheck": "npm run sprite:file-manager"
}
}
```
Generated-файлы исключаются из Git, поэтому генерация должна выполняться перед `dev`, `build` и `typecheck`.
Первый запуск:
```bash
npm run sprite:file-manager
```
## 5. Используйте компонент
```tsx
import { FileManagerIcon } from './svg-sprite'
export const OpenFolderButton = () => (
<button type="button">
<FileManagerIcon icon="folder" width={24} height={24} />
Открыть
</button>
)
```
Значение `icon` проверяется TypeScript по именам файлов:
```tsx
<FileManagerIcon icon="folder" /> // допустимо
<FileManagerIcon icon="missing" /> // ошибка TypeScript
```
Типы, способы отображения и управление цветами описаны в [основной документации](../../README.md#способы-отображения).
Webpack обработает generated `new URL('./sprite.svg', import.meta.url)` через Asset Modules и выпустит отдельный SVG asset.
Если проект уже использует собственный SVG loader, убедитесь, что он не перехватывает generated `sprite.svg` вместо Asset Modules.
## 6. Добавьте debug-страницу
Webpack не поддерживает Vite API `import.meta.glob`, поэтому передайте статические loaders:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const sources = [
() => import('./ui/file-manager/svg-sprite/manifest'),
() => import('./ui/navigation/svg-sprite/manifest'),
]
export const IconsDebugPage = () => (
<SpriteViewer sources={sources} title="Иконки проекта" />
)
```
Пути в `import()` должны быть строковыми литералами. Webpack создаст chunks для манифестов и свяжет их с SVG assets.
Размещайте Viewer только на debug-маршруте или во внутреннем инструменте.
## Если что-то не работает
- Нет `index.ts`: запустите `npm run sprite:file-manager`.
- Viewer не загружает спрайт: проверьте путь в `import()` и наличие `manifest.ts`.
- Неверный URL asset: проверьте `output.publicPath`.
- SVG перехватывает другой loader: исключите generated sprite из несовместимого правила.
Для Next.js используйте отдельные mode key из руководств [App Router](next-app.md) и [Pages Router](next-pages.md).

2570
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,18 @@
{
"name": "@gromlab/svg-sprites",
"version": "0.1.4",
"description": "Generate SVG sprites (stack/symbol) and TypeScript icon name types from folders of SVG files",
"version": "1.0.0",
"description": "Generate SVG sprites and typed icon components for React and Next.js",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./react": {
"types": "./dist/react.d.ts",
"import": "./dist/react.js"
}
},
"bin": {
@@ -18,31 +22,45 @@
"dist/index.js",
"dist/index.js.map",
"dist/index.d.ts",
"dist/react.js",
"dist/react.js.map",
"dist/react.d.ts",
"dist/cli.js",
"dist/cli.js.map",
"dist/cli.d.ts",
"dist/types-*.d.ts",
"dist/chunk-*.js",
"dist/chunk-*.js.map",
"dist/preview-template.html"
"dist/preview-template.html",
"docs/ru/*.md",
"LICENSE",
"THIRD_PARTY_NOTICES.md"
],
"scripts": {
"build": "tsup && npm run build:preview",
"build:preview": "cd preview && npx vite build",
"build:preview": "npm ci --prefix preview && npm run build --prefix preview",
"build:skill": "node skills/svg-sprites/build.mjs",
"check:skill": "node skills/svg-sprites/build.mjs --check",
"dev": "tsup --watch",
"test": "tsup && node --test test/*.test.mjs",
"typecheck": "tsc --noEmit",
"sprite": "node dist/cli.js",
"prepublishOnly": "npm run build"
"sprite": "node dist/cli.js --mode legacy .",
"verify": "npm run check:skill && npm run typecheck && npm test",
"prepack": "npm run verify && npm run build"
},
"keywords": [
"svg",
"sprite",
"svg-sprite",
"icons",
"nextjs",
"typescript",
"codegen"
],
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://gromlab.ru/gromov/svg-sprites"
@@ -59,10 +77,31 @@
"jiti": "^2.6.1",
"svg-sprite": "^2.0.4"
},
"peerDependencies": {
"react": ">=18 <20"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
},
"overrides": {
"esbuild": "^0.28.1",
"postcss": "^8.5.10"
},
"devDependencies": {
"@types/node": "^22.15.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/svg-sprite": "^0.0.39",
"jsdom": "^26.1.0",
"next": "16.2.10",
"react": "^19.2.5",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.5",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vite": "^6.4.3",
"webpack": "^5.108.4"
}
}

View File

@@ -30,13 +30,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -45,9 +45,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -55,21 +55,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -86,14 +86,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -103,14 +103,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -120,9 +120,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -130,29 +130,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -162,9 +162,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -172,9 +172,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -182,9 +182,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -192,27 +192,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -222,33 +222,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -256,35 +256,35 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -293,9 +293,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -577,14 +577,14 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
@@ -596,9 +596,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -606,9 +606,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
@@ -623,9 +623,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
@@ -640,9 +640,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
@@ -657,9 +657,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
@@ -674,9 +674,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
@@ -691,9 +691,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
@@ -708,9 +708,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
@@ -725,9 +725,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
@@ -742,9 +742,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
@@ -759,9 +759,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
@@ -776,9 +776,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
@@ -793,9 +793,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
@@ -810,9 +810,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
@@ -820,18 +820,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.4"
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
@@ -846,9 +846,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
@@ -870,9 +870,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1123,9 +1123,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1316,9 +1316,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz",
"integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==",
"version": "2.10.42",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1353,9 +1353,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.5",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
"integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
"dev": true,
"funding": [
{
@@ -1373,10 +1373,10 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"baseline-browser-mapping": "^2.10.42",
"caniuse-lite": "^1.0.30001800",
"electron-to-chromium": "^1.5.387",
"node-releases": "^2.0.50",
"update-browserslist-db": "^1.2.3"
},
"bin": {
@@ -1397,9 +1397,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001788",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
"integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
"version": "1.0.30001803",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
"integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
"dev": true,
"funding": [
{
@@ -1535,9 +1535,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.341",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.341.tgz",
"integrity": "sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==",
"version": "1.5.389",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
"integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
"dev": true,
"license": "ISC"
},
@@ -2014,10 +2014,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -2439,9 +2449,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
@@ -2465,11 +2475,14 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/optionator": {
"version": "0.9.4",
@@ -2562,9 +2575,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2575,9 +2588,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
@@ -2595,7 +2608,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2665,14 +2678,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -2681,27 +2694,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -2781,9 +2794,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2931,17 +2944,17 @@
}
},
"node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"version": "8.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"tinyglobby": "^0.2.16"
"picomatch": "^4.0.5",
"postcss": "^8.5.16",
"rolldown": "~1.1.4",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@@ -2957,7 +2970,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",

12
skills/README.md Normal file
View File

@@ -0,0 +1,12 @@
# AI skills
Исходники скила `svg-sprites` находятся в `skills/svg-sprites/`. Готовый самодостаточный артефакт записывается в `skills/artifacts/svg-sprites/` и коммитится в Git.
Основные `README.md` и `docs/ru/*.md` являются источником истины. Сборка копирует их в `references/` готового скила, поэтому вручную редактировать файлы внутри `skills/artifacts/` нельзя.
```bash
npm run build:skill
npm run check:skill
```
`build:skill` обновляет артефакт, а `check:skill` без изменения файлов проверяет его содержимое и синхронность с документацией.

View File

@@ -0,0 +1,64 @@
---
name: svg-sprites
description: "Используй при настройке, генерации, миграции или диагностике SVG-спрайтов через @gromlab/svg-sprites. Триггеры: SVG sprite, SVG-спрайт, svg-sprites, svg-sprite.config.ts, svg-sprites.config.ts, defineReactSpriteConfig, defineNextSpriteConfig, react@vite, react@webpack, next@app, next@pages, inputFiles, SpriteViewer, icon=\"...\", --icon-color-N, generated-компонент или иконка не появилась в превью и автодополнении. НЕ используй для favicon, растровых изображений, icon fonts, выбора набора иконок или inline SVG без спрайтов."
---
<!-- Generated from skills/svg-sprites/src/SKILL.md. Do not edit manually. -->
# SVG Sprites
## Назначение
Используй этот скил для работы с `@gromlab/svg-sprites`: первичной настройки, добавления и переиспользования иконок, генерации React-компонентов, подключения `SpriteViewer`, миграции legacy-конфигурации и диагностики ошибок.
Не навязывай проекту конкретную архитектуру каталогов. Сначала изучи существующие `package.json`, конфигурацию спрайта, используемый фреймворк, роутер и сборщик.
## Рабочий алгоритм
1. Определи существующий режим и не смешивай его API с другим режимом.
2. Для React выбери `react@vite` или `react@webpack` и открой соответствующий reference.
3. Для Next.js определи App Router или Pages Router, затем Turbopack или Webpack, и открой соответствующий reference.
4. Для существующего `svg-sprites.config.ts` с несколькими спрайтами используй legacy-документацию. Не мигрируй такой проект без явного запроса.
5. Изучи локальные scripts и добавляй генерацию перед `dev`, `build` и `typecheck`, если generated-файлы не хранятся в Git.
6. После изменения конфигурации или SVG запусти генерацию, затем доступную проверку типов или сборку проекта.
## Правила React и Next.js
- Используй локальный `svg-sprite.config.ts` и подходящий config helper: `defineReactSpriteConfig` или `defineNextSpriteConfig`.
- Не редактируй `generated/`, `index.ts`, `manifest.ts` и созданный генератором `.gitignore` вручную.
- Имена исходных SVG становятся допустимыми значениями prop `icon`; используй generated-компонент и его публичные типы вместо deep imports.
- Объединяй локальную папку и `inputFiles`, когда общая иконка нужна нескольким спрайтам. Не создавай копии одного SVG без необходимости.
- В Next.js generated-компонент можно использовать в Server Components, SSR и SSG. Не добавляй `'use client'` только ради иконки.
- Спрайт должен оставаться внешним asset сборщика: не переносить SVG path-данные в JavaScript и не класть generated-файл вручную в `public`.
## Цвета и трансформации
- По умолчанию генератор удаляет `width` и `height`, заменяет поддерживаемые `fill` и `stroke` на CSS-переменные и добавляет transitions.
- Для монохромной иконки сначала управляй `color`; для многоцветной используй `--icon-color-N`.
- Не обещай автоматическую замену цветов внутри внешних stylesheets, gradients, patterns, filters и значений `url(#...)` без проверки результата.
- CSS-переменные страницы работают при `<svg><use>`, но не проникают внутрь `<img>` и `background-image`.
## Превью
Для React и Next.js подключай `<SpriteViewer>` отдельной debug-страницей приложения. Передай ему manifests или lazy loaders спрайтов. Viewer поддерживает поиск, светлую и тёмную темы, настройку цветов и примеры React, SVG, IMG и CSS.
`SpriteViewer` является клиентским debug-инструментом и импортируется из `@gromlab/svg-sprites/react`; production-компоненты иконок от него не зависят.
## Диагностика
- Если имя иконки отсутствует в автодополнении, проверь входную папку и `inputFiles`, затем перезапусти генерацию.
- Если два файла имеют одинаковое имя иконки, устрани конфликт вместо выбора одного файла неявно.
- Если генератор отказывается перезаписывать файл, не удаляй защитный marker и не обходи writer: перенеси пользовательский файл или выбери другой каталог спрайта.
- Если asset не загружается, сначала проверь соответствие CLI mode реальному сборщику проекта и обработку generated SVG его asset pipeline.
- Если проект использует старый API, сверь установленную версию пакета и legacy reference перед изменениями.
## References
- [Основная документация и API](./references/README.md)
- [React + Vite](./references/docs/ru/react-vite.md)
- [React + Webpack 5](./references/docs/ru/react-webpack.md)
- [Next.js App Router](./references/docs/ru/next-app.md)
- [Next.js Pages Router](./references/docs/ru/next-pages.md)
- [Legacy mode](./references/docs/ru/legacy.md)
- [Миграция с 0.1.x](./references/docs/ru/migration-1.md)
- [Программный API](./references/docs/ru/programmatic-api.md)

View File

@@ -0,0 +1,395 @@
# @gromlab/svg-sprites
![npm](https://img.shields.io/npm/v/@gromlab/svg-sprites) ![license](https://img.shields.io/npm/l/@gromlab/svg-sprites)
CLI для генерации SVG-спрайтов и типизированных компонентов иконок для React и Next.js.
![Preview](https://gromlab.ru/gromov/svg-sprites/media/branch/master/preview-image.png)
## Навигация
- [Возможности](#возможности)
- [Таблица поддержки](#таблица-поддержки)
- [Требования](#требования)
- [Быстрый старт](#быстрый-старт)
- [React + Vite](docs/ru/react-vite.md)
- [React + Webpack 5](docs/ru/react-webpack.md)
- [Next.js App Router](docs/ru/next-app.md)
- [Next.js Pages Router](docs/ru/next-pages.md)
- [Конфигурация](#конфигурация)
- [React](#react)
- [Next.js](#nextjs)
- [Множественные спрайты](#множественные-спрайты)
- [TypeScript](#typescript)
- [Форматы спрайтов](#форматы-спрайтов)
- [Способы отображения](#способы-отображения)
- [Трансформации](#трансформации)
- [Управление цветом иконок](#управление-цветом-иконок)
- [Кеширование](#кеширование)
- [SpriteViewer](#spriteviewer)
- [Миграция с 0.1.x](docs/ru/migration-1.md)
- [Документация](#документация)
## Возможности
- **TypeScript-friendly** — типизированные React-компоненты, union-типы и runtime-списки доступных иконок.
- **Чистая генерация** — generated-файлы автоматически исключаются из Git, спрайт не нужно вручную размещать в `public`, а генератор обновляет только принадлежащие ему файлы.
- **Общие иконки без копирования** — SVG из локальной папки и `inputFiles` объединяются в один спрайт; один файл можно использовать в нескольких спрайтах.
- **Встроенное интерактивное превью** — `<SpriteViewer>` подключается как страница приложения и показывает переданные React- и Next.js-спрайты с поиском, настройкой цветов и примерами использования.
- **Настраиваемые трансформации SVG** — удаление `width` и `height` с сохранением `viewBox`, замена исходных цветов на CSS-переменные и transitions для `fill` и `stroke`.
- **Отдельный кешируемый SVG asset** — SVG path-данные не попадают в JavaScript chunks, а сборщик выпускает файл с content hash.
- **Множественные спрайты** — независимые React- и Next.js-модули со своими компонентами, типами и SVG assets.
- **Server-first Next.js** — generated-компоненты работают в Server Components, SSR и SSG без директивы `'use client'`.
- **Форматы под разные сценарии** — React и Next.js используют `stack`, legacy-режим также поддерживает `symbol` для существующих интеграций.
## Таблица поддержки
| Среда | Ключ мода API | Статус |
|---|---|---|
| React + Vite | `react@vite` | Готово |
| React + Webpack 5 | `react@webpack` | Готово |
| Next.js 16.2+ App Router + Turbopack | `next@app/turbopack` | Готово |
| Next.js 13.4+ App Router + Webpack 5 | `next@app/webpack` | Готово |
| Next.js 16.2+ Pages Router + Turbopack | `next@pages/turbopack` | Готово |
| Next.js 12.2+ Pages Router + Webpack 5 | `next@pages/webpack` | Готово |
| Vue | — | Скоро |
| Standalone | — | Скоро |
## Требования
- Node.js 18 или новее;
- пакет распространяется только как ESM и подключается через `import`;
- React 18 или 19 требуется только для generated-компонентов и точки входа `@gromlab/svg-sprites/react`;
- для типизации subpath exports используйте TypeScript 5+ с `moduleResolution: "bundler"`, `"node16"` или `"nodenext"`.
## Быстрый старт
Для быстрого старта воспользуйтесь инструкцией для вашего стека:
- [React + Vite](docs/ru/react-vite.md)
- [React + Webpack 5](docs/ru/react-webpack.md)
- [Next.js App Router](docs/ru/next-app.md)
- [Next.js Pages Router](docs/ru/next-pages.md)
## Конфигурация
### React
```ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
inputFiles: [
'../../shared/icons/check.svg',
],
transform: {
removeSize: true,
replaceColors: true,
addTransition: true,
},
generatedNotice: true,
})
```
| Опция | Тип | По умолчанию | Назначение |
|---|---|---|---|
| `name` | `string` | Имя папки | Имя спрайта, компонента и публичных типов |
| `description` | `string` | Нет | Описание для типов и debug-манифеста |
| `inputFolder` | `string` | `./icons` | Папка с исходными SVG относительно конфига |
| `inputFiles` | `string[]` | `[]` | Дополнительные SVG-файлы относительно конфига |
| `transform` | `TransformOptions` | Все включены | [Настройки трансформации](#трансформации) исходных SVG |
| `generatedNotice` | `boolean` | `true` | Полное либо короткое предупреждение в generated-файлах |
`inputFolder` и `inputFiles` объединяются в один спрайт, поэтому один SVG-файл можно использовать в нескольких спрайтах без копирования. Если неявной папки `./icons` нет, но `inputFiles` заполнен, генерация продолжается только по списку. Явно указанная отсутствующая папка считается ошибкой. Одинаковые пути дедуплицируются, а разные файлы с одинаковым именем иконки считаются ошибкой.
`name` записывается в kebab-case и должно начинаться с латинской буквы. React и Next.js presets создают формат `stack`.
### Next.js
Next.js использует тот же `svg-sprite.config.ts` и набор опций. Для типизации можно использовать отдельный хелпер:
```ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
})
```
Роутер и сборщик выбираются через mode key, поэтому переключение между Turbopack и Webpack всегда явно отражено в команде генерации.
## Множественные спрайты
Приложение может содержать несколько независимых спрайтов с разной областью использования:
**Проблема:** один глобальный спрайт загружает иконки, которые текущему экрану не нужны.
**Решение:** общие иконки хранить глобально, а наборы страниц и крупных компонентов — в отдельных спрайтах, загружаемых вместе с ними.
```text
global → GlobalIcon → общие иконки приложения
analytics-page → AnalyticsPageIcon → иконки отдельной страницы
file-manager → FileManagerIcon → иконки крупного компонента
```
- **Глобальный спрайт** содержит небольшие общие иконки, используемые в разных частях приложения: навигацию, состояния и базовые действия.
- **Спрайт страницы** загружается вместе с конкретным разделом и не увеличивает общий спрайт иконками, которые больше нигде не нужны.
- **Спрайт крупного компонента** инкапсулирует собственный набор иконок сложного UI-модуля, например файлового менеджера или редактора.
Каждая группа получает:
- собственный SVG asset;
- собственный типизированный компонент;
- отдельный список имён иконок;
- отдельный debug-манифест;
- независимый cache lifecycle.
## TypeScript
Главная возможность TypeScript API — автодополнение имён иконок непосредственно в prop `icon`:
```tsx
<FileManagerIcon icon="folder" />
// ↑ редактор предлагает все иконки спрайта
```
Имена SVG-файлов становятся допустимыми значениями `icon`. Опечатка или неизвестное имя сразу становятся ошибкой TypeScript:
```tsx
<FileManagerIcon icon="unknown" /> // ошибка TypeScript
```
Для программного доступа generated-модуль экспортирует readonly-массив всех доступных иконок конкретного спрайта:
```ts
import { fileManagerIconNames } from './svg-sprite'
// readonly ['check', 'folder', ...]
```
Этот список можно использовать в собственных каталогах, select-компонентах, тестах и других runtime-сценариях. Из него также выводится union-тип `FileManagerIconName`.
Имена файлов с пробелами и другими небезопасными для SVG ID символами остаются частью публичного TypeScript API. Для внутреннего `<symbol id>` генератор создаёт стабильный hash ID.
```text
folder open.svg → icon="folder open" → id="icon-<stable-hash>"
```
Для таких имён используйте generated-компонент или `id` из debug-манифеста. Ручные примеры ниже с `#<имя>` подходят только для имён, которые уже являются безопасными SVG ID.
## Форматы спрайтов
`stack` — более современный формат, поэтому он используется по умолчанию. Иконки можно отображать через `<svg><use>`, `<img>` и CSS `background-image`.
`symbol` сохраняется для совместимости с существующими интеграциями и поддерживает отображение только через `<svg><use>`.
## Способы отображения
### React-компонент — рекомендуется
Generated-компонент предоставляет типизацию, автодополнение имён иконок и сам формирует URL SVG asset.
```tsx
<FileManagerIcon icon="check" width={24} height={24} />
```
Через `color` и `--icon-color-N` доступны одноцветные и многоцветные иконки.
### Самостоятельно через `<svg><use>`
Хороший низкоуровневый способ с полным управлением размерами и цветами. React-компонент под капотом использует именно его.
Способ получения `spriteUrl` зависит от сборщика.
**Vite:**
```tsx
import spriteUrl from './svg-sprite/generated/sprite.svg?no-inline'
```
**Webpack 5:**
```tsx
const spriteUrl = new URL(
'./svg-sprite/generated/sprite.svg',
import.meta.url,
).href
```
**Next.js с Webpack 5 или Turbopack:**
```tsx
const spriteUrl = new URL(
'./svg-sprite/generated/sprite.svg',
import.meta.url,
).href
```
После получения URL иконка отображается одинаково:
```tsx
<svg width={24} height={24}>
<use href={`${spriteUrl}#check`} />
</svg>
```
Vite, Webpack 5 и Next.js сами заменяют исходный путь на итоговый URL asset с hash.
### Через `<img>` — менее эффективно
```tsx
<img src={`${spriteUrl}#check`} width={24} height={24} alt="Готово" />
```
SVG загружается как изолированное изображение: изменить его цвета через `color` или `--icon-color-N` нельзя.
### Через CSS `background-image` — менее эффективно
```css
.icon {
background: url('./svg-sprite/generated/sprite.svg#check') center / contain no-repeat;
}
```
Как и `<img>`, этот способ не позволяет управлять внутренними цветами SVG. Путь указывается относительно CSS-файла, а Vite/Webpack заменяет его на итоговый URL с hash при сборке.
### Через CSS mask — менее эффективно
```css
.icon {
background-color: currentColor;
mask: url('./svg-sprite/generated/sprite.svg#check') center / contain no-repeat;
}
```
Mask оставляет только силуэт и окрашивает его одним цветом. Исходные цвета, gradients и различия между `fill` и `stroke` теряются.
## Трансформации
Все трансформации включены по умолчанию и настраиваются независимо через `transform`.
| Опция | По умолчанию | Что делает |
|---|---|---|
| `removeSize` | `true` | Удаляет `width` и `height` с корневого `<svg>`, сохраняя существующий `viewBox`. Размер иконки после этого задаётся снаружи. |
| `replaceColors` | `true` | Заменяет цвета `fill` и `stroke` на `--icon-color-N`. Для одноцветной иконки fallback становится `currentColor`, для многоцветной сохраняются исходные цвета. |
| `addTransition` | `true` | Добавляет `style="transition:fill 0.3s,stroke 0.3s;"` непосредственно цветным элементам SVG. Существующий `transition` не перезаписывается. |
Чтобы отключить преобразование, передайте для соответствующей опции `false`. Подробнее о результате `replaceColors` — в разделе [«Управление цветом иконок»](#управление-цветом-иконок).
## Управление цветом иконок
При включённой замене цветов генератор анализирует `fill` и `stroke` и преобразует их в CSS custom properties.
### Монохромные иконки
Если найден один цвет, fallback заменяется на `currentColor`:
```svg
stroke="var(--icon-color-1, currentColor)"
```
Цветом управляет CSS-свойство `color` внешнего `<svg>` или его родителя.
### Многоцветные иконки
Каждый уникальный цвет получает отдельную переменную с исходным fallback:
```svg
fill="var(--icon-color-1, #798198)"
fill="var(--icon-color-2, #ffffff)"
fill="var(--icon-color-3, #129d9d)"
```
Страница может заменить только необходимые цвета:
```css
.icon {
--icon-color-1: #4b5563;
--icon-color-3: #14b8a6;
}
```
### Ограничения цветов
- `none`, `transparent`, `inherit`, `unset` и `initial` не заменяются;
- цвета в атрибутах `fill`, `stroke` и inline `style` обрабатываются надёжнее всего;
- CSS-классы и внешние stylesheets внутри исходного SVG не являются основным сценарием трансформации;
- gradients, patterns, filters и значения `url(#...)` требуют отдельной проверки и могут быть несовместимы с автоматической заменой цветов;
- CSS-переменные страницы доступны при `<svg><use>`, но недоступны внутри `<img>` и `background-image`.
## Кеширование
Vite, Webpack и Next.js target выпускают спрайт отдельным asset с content hash:
```text
/assets/sprite-<hash>.svg
```
Это даёт следующие свойства:
- SVG кешируется независимо от JavaScript;
- изменение React-кода не меняет содержимое спрайта;
- изменение иконок создаёт новый hash asset;
- один файл используется всеми экземплярами generated-компонента;
- SVG path-данные отсутствуют в JavaScript chunks.
Vite target запрещает inline через `?no-inline`. Webpack 5 target использует Asset Modules через `new URL(..., import.meta.url)`.
## SpriteViewer
`SpriteViewer` — React-компонент для просмотра generated-спрайтов внутри debug-маршрута приложения.
Он использует отдельные манифесты и показывает:
- группы спрайтов;
- список и количество иконок;
- поиск и системную светлую/тёмную тему;
- модальное превью с `viewBox` и настройкой цветовых переменных;
- примеры React, SVG, IMG и CSS с копированием кода.
Production-компоненты не импортируют debug-манифесты. Способ подключения Viewer зависит от сборщика:
- [React + Vite: автоматический `import.meta.glob`](docs/ru/react-vite.md#6-добавьте-debug-страницу);
- [React + Webpack 5: статические `import()`](docs/ru/react-webpack.md#6-добавьте-debug-страницу);
- [Next.js App Router](docs/ru/next-app.md#5-добавьте-spriteviewer);
- [Next.js Pages Router](docs/ru/next-pages.md#5-добавьте-spriteviewer).
Viewer подключается из отдельной клиентской точки входа `@gromlab/svg-sprites/react` и не попадает в production-компоненты иконок.
### Тема Viewer
По умолчанию `colorTheme="auto"`: Viewer следует `prefers-color-scheme` и реагирует на смену системной темы. Тему приложения можно передать явно:
```tsx
<SpriteViewer sources={sources} colorTheme="dark" />
```
Допустимые значения `colorTheme`: `auto`, `light`, `dark`. При управлении темой извне встроенный переключатель скрывается. Чтобы оставить его и обновлять тему приложения через Viewer, передайте callback:
```tsx
<SpriteViewer
sources={sources}
colorTheme={appTheme}
onColorThemeChange={setAppTheme}
/>
```
## Документация
- [React + Vite](docs/ru/react-vite.md)
- [React + Webpack 5](docs/ru/react-webpack.md)
- [Next.js App Router](docs/ru/next-app.md)
- [Next.js Pages Router](docs/ru/next-pages.md)
- [Legacy mode](docs/ru/legacy.md)
- [Миграция с 0.1.x](docs/ru/migration-1.md)
- [Программный API](docs/ru/programmatic-api.md)
## Лицензия
MIT

View File

@@ -0,0 +1,102 @@
# Legacy mode
[← Главная](../../README.md)
Краткая инструкция по генерации централизованных SVG-спрайтов форматов `symbol` и `stack` с optional HTML preview.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Подготовьте иконки и конфиг
```text
project/
├── src/assets/icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprites.config.ts
```
```ts
// svg-sprites.config.ts
import { defineLegacyConfig } from '@gromlab/svg-sprites'
export default defineLegacyConfig({
output: 'public/sprites',
preview: true,
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'symbol',
},
],
})
```
## 3. Запустите генерацию
```bash
npx svg-sprites --mode legacy .
```
Результат:
```text
public/sprites/
├── icons.sprite.svg
└── preview.html
```
При `preview: false` HTML-файл не создаётся. Для формата `stack` укажите `format: 'stack'`.
## 4. Используйте symbol-спрайт
```html
<svg width="24" height="24" aria-label="Готово">
<use href="/sprites/icons.sprite.svg#check"></use>
</svg>
```
## 5. Добавьте package script
```json
{
"scripts": {
"sprites": "svg-sprites --mode legacy .",
"prebuild": "npm run sprites"
}
}
```
## Несколько спрайтов
Добавьте несколько записей в `sprites`:
```ts
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'symbol',
},
{
name: 'logos',
input: 'src/assets/logos',
format: 'stack',
},
]
```
Все результаты и общий `preview.html` будут записаны в `output`.
## Если что-то не работает
- Не найден конфиг: убедитесь, что `svg-sprites.config.ts` находится в переданном корне.
- Нет иконок: проверьте `sprites[].input` и расширение `.svg`.
- Не нужен preview: установите `preview: false`.
Для программного запуска используйте [`generateLegacy`](programmatic-api.md#generatelegacy).

View File

@@ -0,0 +1,96 @@
# Миграция с 0.1.x на 1.0
[← Главная](../../README.md)
Версия 1.0 разделяет локальную генерацию для React и Next.js и централизованный legacy-режим. Старый config нельзя смешивать с новым API в одном вызове CLI.
## CLI
CLI теперь всегда требует явный `--mode` и путь к каталогу конфигурации:
```text
svg-sprites
→ svg-sprites --mode <mode> <path>
```
Выберите mode по окружению:
| Окружение | Mode |
|---|---|
| React + Vite | `react@vite` |
| React + Webpack 5 | `react@webpack` |
| Next.js App Router + Turbopack | `next@app/turbopack` |
| Next.js App Router + Webpack 5 | `next@app/webpack` |
| Next.js Pages Router + Turbopack | `next@pages/turbopack` |
| Next.js Pages Router + Webpack 5 | `next@pages/webpack` |
| Централизованная старая схема | `legacy` |
## React и Next.js
Вместо корневого `svg-sprites.config.ts` создайте локальный `svg-sprite.config.ts` рядом с набором иконок:
```ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'global',
inputFolder: './icons',
})
```
Для обычного React используйте `defineReactSpriteConfig`. Папку и явный список общих SVG можно объединить через `inputFolder` и `inputFiles`.
Старые `publicPath` и `react` больше не нужны. Generated-модуль создаётся рядом с конфигом, сам добавляет `.gitignore`, а Vite, Webpack или Next.js выпускает SVG как отдельный asset с content hash.
Компонент `<SvgSprite icon="..." />` заменяется компонентом, имя которого выводится из `name`:
```tsx
<GlobalIcon icon="check" />
```
Для просмотра иконок добавьте `<SpriteViewer>` как debug-страницу приложения. Отдельный `preview.html` остаётся только в legacy-режиме.
## Legacy-режим
Если централизованную структуру нужно сохранить, переименуйте helper и поля формата:
```ts
import { defineLegacyConfig } from '@gromlab/svg-sprites'
export default defineLegacyConfig({
output: 'public/sprites',
preview: true,
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'stack',
},
],
})
```
- `defineConfig` заменён на `defineLegacyConfig`;
- `sprites[].mode` переименован в `sprites[].format`;
- `generate` заменён на `generateLegacy`;
- `loadConfig` заменён на `loadLegacyConfig`;
- `publicPath` и генерация старого общего React-компонента удалены.
Запуск:
```bash
svg-sprites --mode legacy .
```
## Программный API
Пакет распространяется только как ESM. Замените `require()` на `import`.
`compileSpriteContent` теперь возвращает `Promise<Uint8Array>`, чтобы публичные декларации не требовали установки `@types/node`. В Node.js фактический результат совместим с API, принимающими `Uint8Array`.
## После миграции
1. Удалите старые generated-файлы и правила, которые игнорировали целиком каталог с исходными иконками.
2. Добавьте явную команду генерации перед `dev`, `build` и `typecheck`.
3. Запустите генерацию и проверку типов.
4. Проверьте все иконки и цветовые переменные через `SpriteViewer` или legacy `preview.html`.

View File

@@ -0,0 +1,102 @@
# Next.js App Router
[← Главная](../../README.md)
Поддерживаются два явных режима:
| Сборщик | Mode key | Версия Next.js |
|---|---|---|
| Turbopack | `next@app/turbopack` | 16.2+ |
| Webpack 5 | `next@app/webpack` | 13.4+ |
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте sprite-модуль
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
## 3. Добавьте генерацию
Для Turbopack:
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode next@app/turbopack src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager"
}
}
```
Для Webpack замените mode key на `next@app/webpack`. В Next 1315 Webpack используется обычной командой `next build`, в Next 16 — командой `next build --webpack`.
## 4. Используйте в Server Component
Generated-компонент не содержит `'use client'`, поэтому его можно импортировать непосредственно в `page.tsx` или `layout.tsx`:
```tsx
import { FileManagerIcon } from '@/ui/file-manager/svg-sprite'
export default function Page() {
return (
<main>
<FileManagerIcon icon="folder" width={24} height={24} />
</main>
)
}
```
Next.js выпустит отдельный SVG asset с content hash. Один generated-код используется при SSR и в браузере без расхождения URL.
## 5. Добавьте SpriteViewer
Viewer интерактивен, поэтому для него нужна отдельная Client Component граница:
```tsx
'use client'
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const sources = [
() => import('@/ui/file-manager/svg-sprite/manifest'),
]
export default function SpritesPage() {
return <SpriteViewer sources={sources} />
}
```
## Проверка сборщика
```bash
# Turbopack
npx next build --turbopack
# Webpack 5
npx next build --webpack
```
Для Next 1315 с Webpack используйте `npx next build` без флага.
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.

View File

@@ -0,0 +1,96 @@
# Next.js Pages Router
[← Главная](../../README.md)
Поддерживаются два явных режима:
| Сборщик | Mode key | Версия Next.js |
|---|---|---|
| Turbopack | `next@pages/turbopack` | 16.2+ |
| Webpack 5 | `next@pages/webpack` | 12.2+ |
Для Next.js 12.2 требуется React 18.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте sprite-модуль
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
## 3. Добавьте генерацию
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode next@pages/webpack src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager"
}
}
```
Для Next.js 16.2 с Turbopack замените mode key на `next@pages/turbopack`.
## 4. Используйте на странице
```tsx
import { FileManagerIcon } from '@/ui/file-manager/svg-sprite'
export default function FilesPage() {
return <FileManagerIcon icon="folder" width={24} height={24} />
}
export function getServerSideProps() {
return { props: {} }
}
```
Компонент одинаково работает при SSR, SSG и клиентских переходах. Next.js выпускает отдельный SVG asset с content hash.
## 5. Добавьте SpriteViewer
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const sources = [
() => import('@/ui/file-manager/svg-sprite/manifest'),
]
export default function SpritesPage() {
return <SpriteViewer sources={sources} />
}
```
## Проверка сборщика
```bash
# Turbopack
npx next build --turbopack
# Webpack 5
npx next build --webpack
```
Для Next 1215 с Webpack используйте `npx next build` без флага.
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.

View File

@@ -0,0 +1,203 @@
# Программный API
[← Главная](../../README.md)
Пакет предоставляет основную Node.js точку входа и отдельный React runtime entry. Обе точки распространяются только как ESM и подключаются через `import`.
Для разрешения `@gromlab/svg-sprites/react` в TypeScript используйте `moduleResolution: "bundler"`, `"node16"` или `"nodenext"`.
## Основной entry
```ts
import {
defineNextSpriteConfig,
defineReactSpriteConfig,
generateNextSprite,
generateReactSprite,
} from '@gromlab/svg-sprites'
```
Основной entry не импортирует React и может использоваться в CLI, build scripts и Node.js инструментах.
## `generateReactSprite`
```ts
import { generateReactSprite } from '@gromlab/svg-sprites'
const result = await generateReactSprite(
'src/ui/file-manager/svg-sprite',
'vite',
)
```
Второй аргумент обязателен:
```ts
type ReactAssetTarget = 'vite' | 'webpack'
```
Результат:
```ts
type ReactSpriteGenerationResult = {
name: string
rootDir: string
generatedDir: string
spritePath: string
manifestPath: string
iconCount: number
target: 'vite' | 'webpack'
}
```
```ts
console.log(result.name)
console.log(result.iconCount)
console.log(result.spritePath)
console.log(result.manifestPath)
```
Функция загружает `svg-sprite.config.ts` из указанного корня, компилирует SVG и безопасно обновляет managed-файлы.
## `generateNextSprite`
```ts
import { generateNextSprite } from '@gromlab/svg-sprites'
const result = await generateNextSprite(
'src/ui/file-manager/svg-sprite',
{
router: 'app',
bundler: 'turbopack',
},
)
```
Доступные значения:
```ts
type NextSpriteGenerationOptions = {
router: 'app' | 'pages'
bundler: 'turbopack' | 'webpack'
}
```
Результат дополнительно содержит выбранные `router`, `bundler` и полный target вида `next@app/turbopack`.
## `defineReactSpriteConfig`
```ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
inputFiles: [
'../../shared/icons/check.svg',
],
transform: {
removeSize: true,
replaceColors: true,
addTransition: true,
},
generatedNotice: true,
})
```
`inputFolder` и `inputFiles` объединяются. Хелпер возвращает конфиг без runtime-преобразований и предоставляет TypeScript autocomplete.
## `defineNextSpriteConfig`
```ts
import { defineNextSpriteConfig } from '@gromlab/svg-sprites'
export default defineNextSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
inputFolder: './icons',
})
```
Next.js использует тот же контракт конфигурации, что и React presets.
## `generateLegacy`
```ts
import { generateLegacy } from '@gromlab/svg-sprites'
const results = await generateLegacy({
output: 'public/sprites',
preview: false,
sprites: [
{
name: 'icons',
input: 'src/assets/icons',
format: 'symbol',
},
],
})
```
Возвращается массив:
```ts
type SpriteResult = {
name: string
format: 'symbol' | 'stack'
spritePath: string
iconCount: number
}
```
Подробнее: [Legacy mode](legacy.md).
## Низкоуровневые функции
Основная точка входа также экспортирует:
```ts
import {
compileSprite,
compileSpriteContent,
createShapeTransform,
generatePreview,
loadLegacyConfig,
loadReactSpriteConfig,
resolveSpriteEntry,
resolveSprites,
} from '@gromlab/svg-sprites'
```
Эти функции предназначены для собственного orchestration поверх существующего compiler и writer. Для стандартного использования предпочтительны `generateReactSprite` и `generateLegacy`.
## React runtime entry
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
```
Типы:
```ts
import type {
SpriteManifest,
SpriteManifestColor,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerColorTheme,
SpriteViewerProps,
SpriteViewerSource,
SpriteViewerSources,
} from '@gromlab/svg-sprites/react'
```
React entry содержит `'use client'` и предназначен для debug-инструментов. Generated production-компоненты импортируются из локальных sprite-модулей приложения, а не из React entry пакета.
`SpriteViewerProps.colorTheme` принимает `auto | light | dark`. Значение `auto` используется по умолчанию и следует `prefers-color-scheme`; для синхронизации с темой приложения передавайте вычисленное `light` или `dark`.
## Связанные руководства
- [React + Vite](react-vite.md)
- [React + Webpack 5](react-webpack.md)

View File

@@ -0,0 +1,116 @@
# React + Vite
[← Главная](../../README.md)
Краткая инструкция по установке и использованию SVG-спрайтов в проекте на React и Vite.
В результате вы получите типизированный React-компонент и отдельный кешируемый SVG asset.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте папку спрайта
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
Поместите исходные SVG-файлы в `icons/`.
## 3. Добавьте конфиг
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
По умолчанию SVG берутся из `./icons`. Общие иконки из других папок можно добавить через `inputFiles`: папка и список объединяются в один спрайт.
Полный список опций находится в разделе [Конфигурация → React](../../README.md#react).
## 4. Добавьте генерацию в package.json
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode react@vite src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager",
"pretypecheck": "npm run sprite:file-manager"
}
}
```
Generated-файлы исключаются из Git, поэтому генерация должна выполняться перед `dev`, `build` и `typecheck`.
Первый запуск:
```bash
npm run sprite:file-manager
```
## 5. Используйте компонент
Имя `file-manager` преобразуется в `FileManagerIcon`:
```tsx
import { FileManagerIcon } from './svg-sprite'
export const OpenFolderButton = () => (
<button type="button">
<FileManagerIcon icon="folder" width={24} height={24} />
Открыть
</button>
)
```
Значение `icon` проверяется TypeScript по именам файлов:
```tsx
<FileManagerIcon icon="check" /> // допустимо
<FileManagerIcon icon="unknown" /> // ошибка TypeScript
```
Типы, способы отображения и управление цветами описаны в [основной документации](../../README.md#способы-отображения).
Vite выпустит спрайт отдельным файлом вида `assets/sprite-<hash>.svg`. SVG path-данные не попадут в JavaScript.
## 6. Добавьте debug-страницу
После подключения иконок можно вывести все React-спрайты через `SpriteViewer`:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
import type { SpriteManifestModule } from '@gromlab/svg-sprites/react'
const sources = import.meta.glob<SpriteManifestModule>(
'/src/**/svg-sprite/manifest.ts',
)
export const IconsDebugPage = () => (
<SpriteViewer sources={sources} title="Иконки проекта" />
)
```
Vite автоматически найдёт generated `manifest.ts` каждого React-спрайта. Шаблон `import.meta.glob` должен быть строковым литералом, а генерация должна выполниться до запуска Vite.
Размещайте Viewer только на debug-маршруте или во внутреннем инструменте.
## Если что-то не работает
- Нет `index.ts`: запустите `npm run sprite:file-manager`.
- Viewer не видит спрайт: проверьте путь glob и наличие `manifest.ts`.
- Ошибка `Refusing to overwrite a user file`: в generated-пути находится пользовательский файл.
- Иконка не меняет цвет: используйте `color` или `--icon-color-N`.

View File

@@ -0,0 +1,118 @@
# React + Webpack 5
[← Главная](../../README.md)
Краткая инструкция по установке и использованию SVG-спрайтов в проекте на React и Webpack 5.
В результате вы получите типизированный React-компонент и отдельный SVG asset через Webpack Asset Modules.
## 1. Установите пакет
```bash
npm install @gromlab/svg-sprites
```
## 2. Создайте папку спрайта
```text
src/ui/file-manager/svg-sprite/
├── icons/
│ ├── check.svg
│ └── folder.svg
└── svg-sprite.config.ts
```
Поместите исходные SVG-файлы в `icons/`.
## 3. Добавьте конфиг
```ts
// src/ui/file-manager/svg-sprite/svg-sprite.config.ts
import { defineReactSpriteConfig } from '@gromlab/svg-sprites'
export default defineReactSpriteConfig({
name: 'file-manager',
description: 'Иконки файлового менеджера',
})
```
По умолчанию SVG берутся из `./icons`. Общие иконки из других папок можно добавить через `inputFiles`: папка и список объединяются в один спрайт.
Полный список опций находится в разделе [Конфигурация → React](../../README.md#react).
## 4. Добавьте генерацию в package.json
```json
{
"scripts": {
"sprite:file-manager": "svg-sprites --mode react@webpack src/ui/file-manager/svg-sprite",
"predev": "npm run sprite:file-manager",
"prebuild": "npm run sprite:file-manager",
"pretypecheck": "npm run sprite:file-manager"
}
}
```
Generated-файлы исключаются из Git, поэтому генерация должна выполняться перед `dev`, `build` и `typecheck`.
Первый запуск:
```bash
npm run sprite:file-manager
```
## 5. Используйте компонент
```tsx
import { FileManagerIcon } from './svg-sprite'
export const OpenFolderButton = () => (
<button type="button">
<FileManagerIcon icon="folder" width={24} height={24} />
Открыть
</button>
)
```
Значение `icon` проверяется TypeScript по именам файлов:
```tsx
<FileManagerIcon icon="folder" /> // допустимо
<FileManagerIcon icon="missing" /> // ошибка TypeScript
```
Типы, способы отображения и управление цветами описаны в [основной документации](../../README.md#способы-отображения).
Webpack обработает generated `new URL('./sprite.svg', import.meta.url)` через Asset Modules и выпустит отдельный SVG asset.
Если проект уже использует собственный SVG loader, убедитесь, что он не перехватывает generated `sprite.svg` вместо Asset Modules.
## 6. Добавьте debug-страницу
Webpack не поддерживает Vite API `import.meta.glob`, поэтому передайте статические loaders:
```tsx
import { SpriteViewer } from '@gromlab/svg-sprites/react'
const sources = [
() => import('./ui/file-manager/svg-sprite/manifest'),
() => import('./ui/navigation/svg-sprite/manifest'),
]
export const IconsDebugPage = () => (
<SpriteViewer sources={sources} title="Иконки проекта" />
)
```
Пути в `import()` должны быть строковыми литералами. Webpack создаст chunks для манифестов и свяжет их с SVG assets.
Размещайте Viewer только на debug-маршруте или во внутреннем инструменте.
## Если что-то не работает
- Нет `index.ts`: запустите `npm run sprite:file-manager`.
- Viewer не загружает спрайт: проверьте путь в `import()` и наличие `manifest.ts`.
- Неверный URL asset: проверьте `output.publicPath`.
- SVG перехватывает другой loader: исключите generated sprite из несовместимого правила.
Для Next.js используйте отдельные mode key из руководств [App Router](next-app.md) и [Pages Router](next-pages.md).

View File

@@ -0,0 +1,194 @@
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import config from './skill.config.mjs'
const skillDir = path.dirname(fileURLToPath(import.meta.url))
const outputDir = path.resolve(skillDir, config.output)
const artifactsDir = path.resolve(skillDir, '../artifacts')
const isCheck = process.argv.slice(2).includes('--check')
function assertSafeRelativePath(relativePath) {
if (
typeof relativePath !== 'string'
|| relativePath.length === 0
|| path.isAbsolute(relativePath)
|| relativePath.split(/[\\/]/).includes('..')
) {
throw new Error(`Unsafe skill path: ${relativePath}`)
}
}
function assertInside(parentDir, childPath) {
const relativePath = path.relative(parentDir, childPath)
if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) return
throw new Error(`Path is outside ${parentDir}: ${childPath}`)
}
function validateConfig() {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.name)) {
throw new Error(`Invalid skill name: ${config.name}`)
}
if (typeof config.description !== 'string' || config.description.trim() === '') {
throw new Error('Skill description must be a non-empty string')
}
if (!Array.isArray(config.references) || config.references.length === 0) {
throw new Error('Skill references must be a non-empty array')
}
assertInside(artifactsDir, outputDir)
assertSafeRelativePath(config.source)
const targets = new Set()
for (const reference of config.references) {
assertSafeRelativePath(reference.to)
if (targets.has(reference.to)) throw new Error(`Duplicate reference target: ${reference.to}`)
targets.add(reference.to)
}
}
function readRegularFile(filePath) {
if (!existsSync(filePath)) throw new Error(`Source file not found: ${filePath}`)
const stats = lstatSync(filePath)
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`Source must be a regular file: ${filePath}`)
}
return readFileSync(filePath, 'utf8')
}
function renderSkill() {
const sourcePath = path.resolve(skillDir, config.source)
assertInside(skillDir, sourcePath)
const body = readRegularFile(sourcePath).trim()
if (body.startsWith('---')) throw new Error('Source SKILL.md must not contain frontmatter')
return [
'---',
`name: ${config.name}`,
`description: ${JSON.stringify(config.description)}`,
'---',
'',
'<!-- Generated from skills/svg-sprites/src/SKILL.md. Do not edit manually. -->',
'',
body,
'',
].join('\n')
}
function buildSkill(targetDir) {
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
writeFileSync(path.join(targetDir, 'SKILL.md'), renderSkill())
for (const reference of config.references) {
const sourcePath = path.resolve(skillDir, reference.from)
const targetPath = path.resolve(targetDir, reference.to)
assertInside(targetDir, targetPath)
readRegularFile(sourcePath)
mkdirSync(path.dirname(targetPath), { recursive: true })
copyFileSync(sourcePath, targetPath)
}
}
function listFiles(directory, prefix = '') {
const files = []
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const relativePath = path.posix.join(prefix, entry.name)
const filePath = path.join(directory, entry.name)
if (entry.isSymbolicLink()) throw new Error(`Skill artifact must not contain symlinks: ${filePath}`)
if (entry.isDirectory()) files.push(...listFiles(filePath, relativePath))
else if (entry.isFile()) files.push(relativePath)
else throw new Error(`Unsupported skill artifact entry: ${filePath}`)
}
return files
}
function validateMarkdown(skillRoot, relativePath) {
const filePath = path.join(skillRoot, relativePath)
const content = readFileSync(filePath, 'utf8')
const fences = content.match(/^```/gm)?.length ?? 0
if (fences % 2 !== 0) throw new Error(`Unbalanced code fences: ${relativePath}`)
for (const match of content.matchAll(/\]\(([^)]+)\)/g)) {
const target = match[1].trim().split(/\s+['"]/)[0]
if (!target || target.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue
const targetPath = decodeURIComponent(target.split('#')[0].split('?')[0])
const resolvedPath = path.resolve(path.dirname(filePath), targetPath)
assertInside(skillRoot, resolvedPath)
if (!existsSync(resolvedPath)) {
throw new Error(`Broken local link in ${relativePath}: ${target}`)
}
}
}
function validateArtifact(skillRoot) {
const expectedFiles = [
'SKILL.md',
...config.references.map((reference) => reference.to),
].sort()
const actualFiles = listFiles(skillRoot).sort()
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(`Unexpected skill files:\n${actualFiles.join('\n')}`)
}
const skill = readFileSync(path.join(skillRoot, 'SKILL.md'), 'utf8')
if (!skill.startsWith(`---\nname: ${config.name}\ndescription: `)) {
throw new Error('Generated SKILL.md has invalid frontmatter')
}
if (/\bTODO\b/.test(skill)) throw new Error('Generated SKILL.md contains TODO')
for (const relativePath of actualFiles.filter((filePath) => filePath.endsWith('.md'))) {
validateMarkdown(skillRoot, relativePath)
}
}
function compareArtifacts(expectedDir, actualDir) {
if (!existsSync(actualDir)) throw new Error(`Skill artifact is missing: ${actualDir}`)
const expectedFiles = listFiles(expectedDir)
const actualFiles = listFiles(actualDir)
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error('Committed skill file list is out of date. Run npm run build:skill')
}
const changedFiles = expectedFiles.filter((relativePath) => {
const expected = readFileSync(path.join(expectedDir, relativePath))
const actual = readFileSync(path.join(actualDir, relativePath))
return !expected.equals(actual)
})
if (changedFiles.length > 0) {
throw new Error(`Committed skill is out of date:\n${changedFiles.join('\n')}`)
}
}
validateConfig()
if (isCheck) {
const temporaryRoot = mkdtempSync(path.join(tmpdir(), 'svg-sprites-skill-'))
try {
const expectedDir = path.join(temporaryRoot, config.name)
buildSkill(expectedDir)
validateArtifact(expectedDir)
compareArtifacts(expectedDir, outputDir)
console.log(`Skill is up to date: ${path.relative(process.cwd(), outputDir)}`)
} finally {
rmSync(temporaryRoot, { recursive: true, force: true })
}
} else {
buildSkill(outputDir)
validateArtifact(outputDir)
console.log(`Built skill: ${path.relative(process.cwd(), outputDir)}`)
}

View File

@@ -0,0 +1,16 @@
export default {
name: 'svg-sprites',
description: 'Используй при настройке, генерации, миграции или диагностике SVG-спрайтов через @gromlab/svg-sprites. Триггеры: SVG sprite, SVG-спрайт, svg-sprites, svg-sprite.config.ts, svg-sprites.config.ts, defineReactSpriteConfig, defineNextSpriteConfig, react@vite, react@webpack, next@app, next@pages, inputFiles, SpriteViewer, icon="...", --icon-color-N, generated-компонент или иконка не появилась в превью и автодополнении. НЕ используй для favicon, растровых изображений, icon fonts, выбора набора иконок или inline SVG без спрайтов.',
source: 'src/SKILL.md',
output: '../artifacts/svg-sprites',
references: [
{ from: '../../README.md', to: 'references/README.md' },
{ from: '../../docs/ru/react-vite.md', to: 'references/docs/ru/react-vite.md' },
{ from: '../../docs/ru/react-webpack.md', to: 'references/docs/ru/react-webpack.md' },
{ from: '../../docs/ru/next-app.md', to: 'references/docs/ru/next-app.md' },
{ from: '../../docs/ru/next-pages.md', to: 'references/docs/ru/next-pages.md' },
{ from: '../../docs/ru/legacy.md', to: 'references/docs/ru/legacy.md' },
{ from: '../../docs/ru/migration-1.md', to: 'references/docs/ru/migration-1.md' },
{ from: '../../docs/ru/programmatic-api.md', to: 'references/docs/ru/programmatic-api.md' },
],
}

View File

@@ -0,0 +1,57 @@
# SVG Sprites
## Назначение
Используй этот скил для работы с `@gromlab/svg-sprites`: первичной настройки, добавления и переиспользования иконок, генерации React-компонентов, подключения `SpriteViewer`, миграции legacy-конфигурации и диагностики ошибок.
Не навязывай проекту конкретную архитектуру каталогов. Сначала изучи существующие `package.json`, конфигурацию спрайта, используемый фреймворк, роутер и сборщик.
## Рабочий алгоритм
1. Определи существующий режим и не смешивай его API с другим режимом.
2. Для React выбери `react@vite` или `react@webpack` и открой соответствующий reference.
3. Для Next.js определи App Router или Pages Router, затем Turbopack или Webpack, и открой соответствующий reference.
4. Для существующего `svg-sprites.config.ts` с несколькими спрайтами используй legacy-документацию. Не мигрируй такой проект без явного запроса.
5. Изучи локальные scripts и добавляй генерацию перед `dev`, `build` и `typecheck`, если generated-файлы не хранятся в Git.
6. После изменения конфигурации или SVG запусти генерацию, затем доступную проверку типов или сборку проекта.
## Правила React и Next.js
- Используй локальный `svg-sprite.config.ts` и подходящий config helper: `defineReactSpriteConfig` или `defineNextSpriteConfig`.
- Не редактируй `generated/`, `index.ts`, `manifest.ts` и созданный генератором `.gitignore` вручную.
- Имена исходных SVG становятся допустимыми значениями prop `icon`; используй generated-компонент и его публичные типы вместо deep imports.
- Объединяй локальную папку и `inputFiles`, когда общая иконка нужна нескольким спрайтам. Не создавай копии одного SVG без необходимости.
- В Next.js generated-компонент можно использовать в Server Components, SSR и SSG. Не добавляй `'use client'` только ради иконки.
- Спрайт должен оставаться внешним asset сборщика: не переносить SVG path-данные в JavaScript и не класть generated-файл вручную в `public`.
## Цвета и трансформации
- По умолчанию генератор удаляет `width` и `height`, заменяет поддерживаемые `fill` и `stroke` на CSS-переменные и добавляет transitions.
- Для монохромной иконки сначала управляй `color`; для многоцветной используй `--icon-color-N`.
- Не обещай автоматическую замену цветов внутри внешних stylesheets, gradients, patterns, filters и значений `url(#...)` без проверки результата.
- CSS-переменные страницы работают при `<svg><use>`, но не проникают внутрь `<img>` и `background-image`.
## Превью
Для React и Next.js подключай `<SpriteViewer>` отдельной debug-страницей приложения. Передай ему manifests или lazy loaders спрайтов. Viewer поддерживает поиск, светлую и тёмную темы, настройку цветов и примеры React, SVG, IMG и CSS.
`SpriteViewer` является клиентским debug-инструментом и импортируется из `@gromlab/svg-sprites/react`; production-компоненты иконок от него не зависят.
## Диагностика
- Если имя иконки отсутствует в автодополнении, проверь входную папку и `inputFiles`, затем перезапусти генерацию.
- Если два файла имеют одинаковое имя иконки, устрани конфликт вместо выбора одного файла неявно.
- Если генератор отказывается перезаписывать файл, не удаляй защитный marker и не обходи writer: перенеси пользовательский файл или выбери другой каталог спрайта.
- Если asset не загружается, сначала проверь соответствие CLI mode реальному сборщику проекта и обработку generated SVG его asset pipeline.
- Если проект использует старый API, сверь установленную версию пакета и legacy reference перед изменениями.
## References
- [Основная документация и API](./references/README.md)
- [React + Vite](./references/docs/ru/react-vite.md)
- [React + Webpack 5](./references/docs/ru/react-webpack.md)
- [Next.js App Router](./references/docs/ru/next-app.md)
- [Next.js Pages Router](./references/docs/ru/next-pages.md)
- [Legacy mode](./references/docs/ru/legacy.md)
- [Миграция с 0.1.x](./references/docs/ru/migration-1.md)
- [Программный API](./references/docs/ru/programmatic-api.md)

View File

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

119
src/cli/parse-args.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
import type { CliArgs } from './types.js'
const REACT_TARGETS = new Set<ReactAssetTarget>(['vite', 'webpack'])
const NEXT_TARGETS = new Set<NextAssetTarget>([
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
])
export const CLI_USAGE = [
'Usage:',
' svg-sprites --mode <mode> <path>',
'',
'Modes:',
' legacy Generate sprites through the legacy pipeline',
' react@vite Generate a React module for Vite',
' react@webpack Generate a React module for Webpack 5',
' next@app/turbopack Generate an App Router module for Turbopack',
' next@app/webpack Generate an App Router module for Webpack 5',
' next@pages/turbopack Generate a Pages Router module for Turbopack',
' next@pages/webpack Generate a Pages Router module for Webpack 5',
].join('\n')
export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) {
return { help: true }
}
let modeValue: string | undefined
const positional: string[] = []
for (let index = 0; index < argv.length; index++) {
const argument = argv[index]
if (argument === '--mode') {
modeValue = argv[index + 1]
index++
continue
}
if (argument.startsWith('--mode=')) {
modeValue = argument.slice('--mode='.length)
continue
}
if (argument.startsWith('-')) {
throw new Error(`Unknown argument: ${argument}\n\n${CLI_USAGE}`)
}
positional.push(argument)
}
if (!modeValue) {
throw new Error(`Missing required argument: --mode\n\n${CLI_USAGE}`)
}
if (positional.length === 0) {
throw new Error(`Missing sprite path.\n\n${CLI_USAGE}`)
}
if (positional.length > 1) {
throw new Error(`Expected one sprite path, received: ${positional.join(', ')}`)
}
if (modeValue === 'legacy') {
return {
mode: 'legacy',
path: positional[0],
}
}
if (modeValue === 'react') {
throw new Error(
'React mode requires a target. Supported: react@vite, react@webpack.',
)
}
if (modeValue.startsWith('react@')) {
const target = modeValue.slice('react@'.length)
if (!REACT_TARGETS.has(target as ReactAssetTarget)) {
throw new Error(
`Unsupported React target: ${target}. Supported: ${[...REACT_TARGETS].join(', ')}.`,
)
}
return {
mode: 'react',
path: positional[0],
target: target as ReactAssetTarget,
}
}
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(', ')}`,
)
}

26
src/cli/types.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { NextAssetTarget, ReactAssetTarget } from '../targets/types.js'
/** Корневой режим генерации, определяющий структуру создаваемых файлов. */
export type GenerationMode = 'legacy' | 'next' | 'react'
/** Аргументы legacy pipeline. */
export type LegacyCliArgs = {
mode: 'legacy'
path: string
}
/** Аргументы 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

View File

@@ -1,195 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteFolder, SpriteResult } from './types.js'
/** Преобразует kebab-case строку в PascalCase. */
function toPascalCase(str: string): string {
return str.replace(/(^|[-_])([a-z])/g, (_, __, c: string) => c.toUpperCase())
}
/**
* Собирает имена иконок из SpriteFolder.
*/
function getIconNames(folder: SpriteFolder): string[] {
return folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
}
/**
* Генерирует index.ts, [name].tsx и [name].module.css — React-компонент с типами.
*
* Имена файлов берутся из basename папки outputDir.
* Например: outputDir = 'src/ui/svg-sprite' → index.ts + svg-sprite.tsx + svg-sprite.module.css.
*
* Содержит:
* - union-типы имён иконок для каждого спрайта (IconsIconName, LogosIconName, ...)
* - SpriteMap, SpriteName, IconName
* - компонент SvgSprite с зашитым publicPath
*/
export function generateReactModule(
results: SpriteResult[],
folders: SpriteFolder[],
outputDir: string,
publicPath: string,
): string {
const typeBlocks: string[] = []
const mapEntries: string[] = []
const spriteFileEntries: string[] = []
for (const result of results) {
const folder = folders.find((f) => f.name === result.name)
if (!folder) continue
const typeName = `${toPascalCase(result.name)}IconName`
const names = getIconNames(folder)
typeBlocks.push(
`/** Имена иконок спрайта «${result.name}». */`,
`export type ${typeName} =`,
names.map((n) => ` | '${n}'`).join('\n'),
'',
)
mapEntries.push(` ${result.name}: ${typeName}`)
spriteFileEntries.push(` ${result.name}: '${result.name}.sprite.svg',`)
}
const baseName = path.basename(outputDir)
const defaultSprite = results[0].name
const lines = [
'/**',
' * SVG-спрайты: типы и React-компонент.',
' * @generated — this file is auto-generated, do not edit manually.',
' */',
"import type { SVGAttributes, HTMLAttributes } from 'react'",
`import styles from './${baseName}.module.css'`,
'',
...typeBlocks,
'/** Маппинг имени спрайта на тип его иконок. */',
'export type SpriteMap = {',
...mapEntries,
'}',
'',
'/** Имя спрайта. */',
'export type SpriteName = keyof SpriteMap',
'',
'/** Спрайт по умолчанию. */',
`export type DefaultSprite = '${defaultSprite}'`,
'',
'/** Имя иконки для конкретного спрайта. */',
'export type IconName<S extends SpriteName = SpriteName> = SpriteMap[S]',
'',
`const PUBLIC_PATH = '${publicPath}'`,
`const DEFAULT_SPRITE: SpriteName = '${defaultSprite}'`,
'',
'const SPRITE_FILES: Record<SpriteName, string> = {',
...spriteFileEntries,
'}',
'',
'type IconBaseProps<S extends SpriteName> = {',
' /** Имя иконки. */',
' icon: IconName<S>',
' /** Имя спрайта. По умолчанию: первый из конфига. */',
' sprite?: S',
'}',
'',
'type IconSvgProps<S extends SpriteName> = IconBaseProps<S> & {',
' wrapped?: false',
'} & SVGAttributes<SVGSVGElement>',
'',
'type IconWrappedProps<S extends SpriteName> = IconBaseProps<S> & {',
' wrapped: true',
'} & HTMLAttributes<HTMLSpanElement>',
'',
'export type SvgSpriteProps<S extends SpriteName = DefaultSprite> =',
' | IconSvgProps<S>',
' | IconWrappedProps<S>',
'',
'/**',
' * Иконка из SVG-спрайта.',
' *',
' * Используется для:',
' * - отображения иконки через `<use href="...">`',
' * - обёртки в `<span>` через проп `wrapped`',
' *',
` * Спрайт по умолчанию: «${defaultSprite}».`,
' */',
'export const SvgSprite = <S extends SpriteName = DefaultSprite>(props: SvgSpriteProps<S>) => {',
' const { icon, sprite = DEFAULT_SPRITE as S, wrapped, className, ...rest } = props',
// eslint-disable-next-line no-template-curly-in-string
' const href = `${PUBLIC_PATH}/${SPRITE_FILES[sprite]}#${icon}`',
'',
' if (wrapped) {',
' const { ...htmlAttr } = rest as HTMLAttributes<HTMLSpanElement>',
' return (',
' <span {...htmlAttr} className={[styles.wrap, className].filter(Boolean).join(\' \')}>',
' <svg>',
' <use href={href} />',
' </svg>',
' </span>',
' )',
' }',
'',
' const { ...svgAttr } = rest as SVGAttributes<SVGSVGElement>',
' return (',
' <svg {...svgAttr} className={[styles.root, className].filter(Boolean).join(\' \')}>',
' <use href={href} />',
' </svg>',
' )',
'}',
'',
]
const content = lines.join('\n')
const outputPath = path.join(outputDir, `${baseName}.tsx`)
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(outputPath, content)
// Генерируем CSS Module
const css = [
'/* @generated — this file is auto-generated, do not edit manually. */',
'',
'.root {',
' transition-property: fill, stroke, color;',
' transition-duration: 0.3s;',
' transition-timing-function: ease;',
'}',
'',
'.wrap {',
' display: inline-flex;',
'}',
'',
'.wrap svg {',
' width: 100%;',
' height: 100%;',
' transition-property: fill, stroke, color;',
' transition-duration: 0.3s;',
' transition-timing-function: ease;',
'}',
'',
].join('\n')
const cssPath = path.join(outputDir, `${baseName}.module.css`)
fs.writeFileSync(cssPath, css)
const index = [
'/** @generated — this file is auto-generated, do not edit manually. */',
`export { SvgSprite } from './${baseName}'`,
'export type {',
' SvgSpriteProps,',
' SpriteName,',
' SpriteMap,',
' IconName,',
' DefaultSprite,',
`} from './${baseName}'`,
'',
].join('\n')
const indexPath = path.join(outputDir, 'index.ts')
fs.writeFileSync(indexPath, index)
return outputPath
}

View File

@@ -1,16 +1,27 @@
import fs from 'node:fs'
import path from 'node:path'
import SVGSpriter from 'svg-sprite'
import { getSpriteShapeId } from './shape-id.js'
import { createShapeTransform } from './transforms.js'
import type { SpriteFolder, SpriteMode, TransformOptions } from './types.js'
import type { SpriteFolder, SpriteFormat, TransformOptions } from './types.js'
export type CompileSpriteOptions = {
/** Добавлять вычисленный viewBox корневому stack-спрайту. */
rootViewBox?: boolean
}
/** Конфигурация режима для svg-sprite. */
function getModeConfig(mode: SpriteMode, destDir: string, name: string) {
function getModeConfig(
format: SpriteFormat,
destDir: string,
name: string,
options: CompileSpriteOptions,
) {
return {
dest: destDir,
sprite: `${name}.sprite.svg`,
example: false,
rootviewbox: false,
rootviewbox: options.rootViewBox ?? false,
}
}
@@ -47,13 +58,35 @@ export async function compileSprite(
folder: SpriteFolder,
outputDir: string,
transform: TransformOptions = {},
options: CompileSpriteOptions = {},
): Promise<string> {
const contents = await compileSpriteContent(folder, transform, options)
const spritePath = path.join(outputDir, `${folder.name}.sprite.svg`)
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(spritePath, contents)
return spritePath
}
/** Компилирует SVG-спрайт в памяти, не изменяя файловую систему. */
export async function compileSpriteContent(
folder: SpriteFolder,
transform: TransformOptions = {},
options: CompileSpriteOptions = {},
): Promise<Uint8Array> {
const config = {
shape: {
id: {
generator: (filePath: string) => getSpriteShapeId(filePath),
},
dimension: {
attributes: transform.removeSize === false,
},
transform: buildShapeTransforms(transform),
},
mode: {
[folder.mode]: getModeConfig(folder.mode, outputDir, folder.name),
[folder.format]: getModeConfig(folder.format, '.', folder.name, options),
},
}
@@ -70,21 +103,24 @@ export async function compileSprite(
return
}
let spritePath = ''
let spriteContents: Uint8Array | undefined
for (const modeResult of Object.values(result)) {
for (const resource of Object.values(
modeResult as Record<string, { path: string; contents: Buffer }>,
modeResult as Record<string, { path: string; contents: Uint8Array }>,
)) {
fs.mkdirSync(path.dirname(resource.path), { recursive: true })
fs.writeFileSync(resource.path, resource.contents)
if (resource.path.endsWith('.svg')) {
spritePath = resource.path
spriteContents = resource.contents
}
}
}
resolve(spritePath)
if (!spriteContents) {
reject(new Error(`Failed to compile sprite "${folder.name}".`))
return
}
resolve(spriteContents)
})
})
}

View File

@@ -1,67 +0,0 @@
import path from 'node:path'
import { resolveSprites } from './scanner.js'
import { compileSprite } from './compiler.js'
import { generateReactModule } from './codegen-react.js'
import { generatePreview } from './preview.js'
import { log } from './logger.js'
import type { SvgSpritesConfig, SpriteResult } from './types.js'
/**
* Генерирует SVG-спрайты из конфига.
*
* Основная точка входа — используется и из CLI, и из программного API.
*/
export async function generate(config: SvgSpritesConfig): Promise<SpriteResult[]> {
const {
output,
publicPath,
preview = true,
react,
transform = {},
sprites,
} = config
const outputDir = path.resolve(output)
log.title('Resolving 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.mode}] ${folder.name}${path.relative(process.cwd(), spritePath)} (${folder.files.length} icons)`)
results.push({
name: folder.name,
mode: folder.mode,
spritePath,
iconCount: folder.files.length,
})
}
if (react) {
const reactDir = path.resolve(react)
const resolvedPublicPath = publicPath ?? `/${output}`
const iconPath = generateReactModule(results, folders, reactDir, resolvedPublicPath)
log.success(` [react] → ${path.relative(process.cwd(), iconPath)}`)
}
if (preview) {
const previewPath = generatePreview(results, outputDir)
log.success(`\n [preview] → ${path.relative(process.cwd(), previewPath)}`)
}
console.log('')
log.success(`Done! Generated ${results.length} sprite(s).`)
return results
}

View File

@@ -1,25 +1,59 @@
import type { SvgSpritesConfig } from './types.js'
import type { NextSpriteConfig } from './modes/next/types.js'
import type { ReactSpriteConfig } from './modes/react/types.js'
export { generate } from './generate.js'
export { generateLegacy } from './modes/legacy/generate.js'
export { resolveSprites, resolveSpriteEntry } from './scanner.js'
export { compileSprite } from './compiler.js'
export { compileSprite, compileSpriteContent } from './compiler.js'
export type { CompileSpriteOptions } from './compiler.js'
export { createShapeTransform } from './transforms.js'
export { generatePreview } from './preview.js'
export { generateReactModule } from './codegen-react.js'
export { loadConfig } from './config.js'
export { loadLegacyConfig } from './modes/legacy/config.js'
export { generateNextSprite } from './modes/next/index.js'
export {
generateReactSprite,
loadReactSpriteConfig,
} from './modes/react/index.js'
export type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
SpriteAssetTarget,
ViteAssetTarget,
WebpackAssetTarget,
} from './targets/types.js'
export type {
SvgSpritesConfig,
SpriteEntry,
SpriteResult,
SpriteFolder,
SpriteMode,
SpriteFormat,
TransformOptions,
} from './types.js'
export type {
NextSpriteConfig,
NextSpriteGenerationOptions,
NextSpriteGenerationResult,
} from './modes/next/types.js'
export type {
ReactSpriteConfig,
ReactSpriteGenerationResult,
ResolvedReactSpriteConfig,
} from './modes/react/types.js'
/**
* Хелпер для типизации конфига с автодополнением.
*/
export function defineConfig(config: SvgSpritesConfig): SvgSpritesConfig {
/** Хелпер для типизации legacy-конфига. */
export function defineLegacyConfig(config: SvgSpritesConfig): SvgSpritesConfig {
return config
}
/** Хелпер для типизации локального React-конфига. */
export function defineReactSpriteConfig(config: ReactSpriteConfig): ReactSpriteConfig {
return config
}
/** Хелпер для типизации локального Next.js-конфига. */
export function defineNextSpriteConfig(config: NextSpriteConfig): NextSpriteConfig {
return config
}

View File

@@ -1,4 +1,4 @@
import { green, red, yellow, cyan, bold } from 'colorette'
import { green, red, yellow, cyan, bold, dim } from 'colorette'
export const log = {
success: (msg: string) => console.log(green(msg)),
@@ -6,4 +6,5 @@ export const log = {
warn: (msg: string) => console.warn(yellow(msg)),
info: (msg: string) => console.log(cyan(msg)),
title: (msg: string) => console.log(bold(cyan(msg))),
detail: (msg: string) => console.log(dim(msg)),
}

View File

@@ -1,16 +1,12 @@
import fs from 'node:fs'
import path from 'node:path'
import { createJiti } from 'jiti'
import type { SvgSpritesConfig } from './types.js'
import type { SvgSpritesConfig } from '../../types.js'
const CONFIG_NAME = 'svg-sprites.config'
/**
* Загружает конфиг svg-sprites.config.ts из указанной директории.
*
* Использует jiti для импорта TypeScript-файлов.
*/
export async function loadConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
/** Загружает legacy-конфиг из указанной директории. */
export async function loadLegacyConfig(cwd: string = process.cwd()): Promise<SvgSpritesConfig> {
const configPath = path.join(cwd, `${CONFIG_NAME}.ts`)
if (!fs.existsSync(configPath)) {
@@ -22,25 +18,30 @@ export async function loadConfig(cwd: string = process.cwd()): Promise<SvgSprite
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 defineConfig({ ... })',
'Use: export default defineLegacyConfig({ ... })',
)
}
validateConfig(config)
return config
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),
})),
}
}
/**
* Валидирует конфиг на наличие обязательных полей.
*/
function validateConfig(config: SvgSpritesConfig): void {
/** Валидирует legacy-конфиг. */
export function validateLegacyConfig(config: SvgSpritesConfig): void {
if (!config.output) {
throw new Error('Config: "output" is required.')
}
@@ -50,6 +51,12 @@ function validateConfig(config: SvgSpritesConfig): void {
}
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".')
}
@@ -58,9 +65,9 @@ function validateConfig(config: SvgSpritesConfig): void {
throw new Error(`Config: sprite "${sprite.name}" must have an "input".`)
}
if (sprite.mode && sprite.mode !== 'stack' && sprite.mode !== 'symbol') {
if (sprite.format && sprite.format !== 'stack' && sprite.format !== 'symbol') {
throw new Error(
`Config: sprite "${sprite.name}" has invalid mode "${sprite.mode}". Supported: stack, symbol.`,
`Config: sprite "${sprite.name}" has invalid format "${sprite.format}". Supported: stack, symbol.`,
)
}
}

View File

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

View File

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

View File

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

6
src/modes/next/index.ts Normal file
View File

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

22
src/modes/next/types.ts Normal file
View File

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

288
src/modes/react/codegen.ts Normal file
View File

@@ -0,0 +1,288 @@
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://gromlab.ru/gromov/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),
},
]
}

108
src/modes/react/config.ts Normal file
View File

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

View File

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

7
src/modes/react/index.ts Normal file
View File

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

120
src/modes/react/manifest.ts Normal file
View File

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

View File

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

25
src/modes/react/naming.ts Normal file
View File

@@ -0,0 +1,25 @@
/** Преобразует 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}".`,
)
}
}

42
src/modes/react/types.ts Normal file
View File

@@ -0,0 +1,42 @@
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>

172
src/modes/react/writer.ts Normal file
View File

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

View File

@@ -126,7 +126,7 @@ export function generatePreview(
return {
id,
group: r.name,
mode: r.mode,
mode: r.format,
spriteFile,
viewBox: extractViewBox(fragment),
vars: extractIconVars(fragment).map((v) => ({
@@ -138,7 +138,7 @@ export function generatePreview(
}
})
return { name: r.name, mode: r.mode, spriteFile, icons }
return { name: r.name, mode: r.format, spriteFile, icons }
})
// Inline SVG спрайтов

13
src/react.ts Normal file
View File

@@ -0,0 +1,13 @@
'use client'
export { SpriteViewer } from './react/sprite-viewer.js'
export type { SpriteViewerColorTheme, SpriteViewerProps } from './react/sprite-viewer.js'
export type {
SpriteManifest,
SpriteManifestColor,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from './react/types.js'

View File

@@ -0,0 +1,196 @@
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
export type SpriteViewerTab = 'react' | 'svg' | 'img' | 'css'
export type SpriteViewerCodeLanguage = 'tsx' | 'html' | 'css'
export const SPRITE_VIEWER_TABS: ReadonlyArray<{ id: SpriteViewerTab; label: string }> = [
{ id: 'react', label: 'React' },
{ id: 'svg', label: 'SVG' },
{ id: 'img', label: 'IMG' },
{ id: 'css', label: 'CSS' },
]
export function tabsForFormat(format: SpriteManifest['format']) {
return format === 'stack'
? SPRITE_VIEWER_TABS
: SPRITE_VIEWER_TABS.filter((tab) => tab.id === 'react' || tab.id === 'svg')
}
export function viewBoxSize(viewBox: string | null): string | null {
if (!viewBox) return null
const values = viewBox.trim().split(/[\s,]+/).map(Number)
return values.length === 4 && values.every(Number.isFinite)
? `${values[2]} × ${values[3]}`
: null
}
export function normalizeHexColor(value: string, currentColor = '#1a1a1a'): string {
const normalized = value.trim().toLowerCase()
if (normalized === 'currentcolor') return normalizeHexColor(currentColor)
const shortHex = normalized.match(/^#([\da-f])([\da-f])([\da-f])(?:[\da-f])?$/i)
if (shortHex) return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`
const longHex = normalized.match(/^#([\da-f]{6})(?:[\da-f]{2})?$/i)
if (longHex) return `#${longHex[1]}`
const rgb = normalized.match(/^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/)
if (rgb) {
return `#${rgb.slice(1, 4).map((part) => Math.min(255, Number(part)).toString(16).padStart(2, '0')).join('')}`
}
const named: Record<string, string> = {
black: '#000000',
blue: '#0000ff',
cyan: '#00ffff',
gray: '#808080',
green: '#008000',
grey: '#808080',
magenta: '#ff00ff',
orange: '#ffa500',
pink: '#ffc0cb',
purple: '#800080',
red: '#ff0000',
white: '#ffffff',
yellow: '#ffff00',
}
return named[normalized] ?? normalizeHexColor(currentColor === value ? '#1a1a1a' : currentColor)
}
function styleLines(overrides: Readonly<Record<string, string>>): string[] {
return Object.entries(overrides).map(([variable, color]) => ` ${JSON.stringify(variable)}: ${JSON.stringify(color)},`)
}
function htmlAttribute(value: string): string {
const entities: Record<string, string> = {
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
}
return value.replace(/[&"<>]/g, (character) => entities[character])
}
function cssUrl(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
}
export function generateViewerCode(options: {
manifest: SpriteManifest
icon: SpriteManifestIcon
tab: SpriteViewerTab
colorOverrides: Readonly<Record<string, string>>
cssColor: string
}): { code: string; language: SpriteViewerCodeLanguage } {
const { manifest, icon, tab, colorOverrides, cssColor } = options
const href = `${manifest.spriteUrl}#${icon.id}`
const overrides = styleLines(colorOverrides)
if (tab === 'react') {
if (overrides.length === 0) {
return {
code: `<${manifest.componentName} icon=${JSON.stringify(icon.name)} />`,
language: 'tsx',
}
}
return {
code: [
`<${manifest.componentName}`,
` icon=${JSON.stringify(icon.name)}`,
' style={{',
...overrides,
' }}',
'/>',
].join('\n'),
language: 'tsx',
}
}
if (tab === 'svg') {
const style = Object.entries(colorOverrides)
.map(([variable, color]) => `${variable}: ${color}`)
.join('; ')
return {
code: `<svg width="24" height="24"${style ? ` style="${htmlAttribute(style)}"` : ''}>\n <use href="${htmlAttribute(href)}" />\n</svg>`,
language: 'html',
}
}
if (tab === 'img') {
return {
code: `<img src="${htmlAttribute(href)}" width="24" height="24" alt="${htmlAttribute(icon.name)}">`,
language: 'html',
}
}
const className = `icon-${icon.name.replace(/[^a-zA-Z0-9_-]+/g, '-') || 'sprite'}`
const escapedHref = cssUrl(href)
return {
code: [
`.${className} {`,
' width: 24px;',
' height: 24px;',
` mask: url('${escapedHref}') no-repeat center / contain;`,
` -webkit-mask: url('${escapedHref}') no-repeat center / contain;`,
` background-color: ${cssColor};`,
'}',
].join('\n'),
language: 'css',
}
}
const ESCAPE_RE = /[&<>"]/g
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
type HighlightRule = [RegExp, string]
const MARKUP_RULES: HighlightRule[] = [
[/<!--[\s\S]*?-->/g, 'comment'],
[/<\/?[\w.-]+/g, 'tag'],
[/[\w-]+(?=\s*=)/g, 'attr'],
[/"[^"]*"|'[^']*'/g, 'string'],
]
const CSS_RULES: HighlightRule[] = [
[/\/\*[\s\S]*?\*\//g, 'comment'],
[/\.[\w-]+/g, 'selector'],
[/[\w-]+(?=\s*:)/g, 'property'],
[/'[^']*'|"[^"]*"/g, 'string'],
[/#[\da-fA-F]{3,8}\b/g, 'color'],
[/\d+(?:\.\d+)?(?:px|em|rem|%)?/g, 'number'],
]
export function highlightViewerCode(code: string, language: SpriteViewerCodeLanguage): string {
const rules = language === 'css' ? CSS_RULES : MARKUP_RULES
const tokens: Array<{ kind: string; value: string }> = []
let position = 0
while (position < code.length) {
let match: RegExpExecArray | null = null
let kind = ''
for (const [rule, ruleKind] of rules) {
rule.lastIndex = position
const candidate = rule.exec(code)
if (candidate?.index === position) {
match = candidate
kind = ruleKind
break
}
}
if (match) {
tokens.push({ kind, value: match[0] })
position += match[0].length
} else {
const previous = tokens.at(-1)
if (previous?.kind === '') previous.value += code[position]
else tokens.push({ kind: '', value: code[position] })
position++
}
}
return tokens.map(({ kind, value }) => {
const escaped = value.replace(ESCAPE_RE, (character) => ESCAPE_MAP[character])
return kind ? `<span class="hl-${kind}">${escaped}</span>` : escaped
}).join('')
}

View File

@@ -0,0 +1,317 @@
import { HexColorInput, HexColorPicker } from 'react-colorful'
import { useEffect, useId, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import {
generateViewerCode,
highlightViewerCode,
normalizeHexColor,
tabsForFormat,
viewBoxSize,
} from './sprite-viewer-code.js'
import type { SpriteViewerTab } from './sprite-viewer-code.js'
import type { SpriteManifest, SpriteManifestIcon } from './types.js'
type SpriteViewerDialogProps = {
manifest: SpriteManifest
icon: SpriteManifestIcon
colorTheme: 'light' | 'dark'
onClose: () => void
}
type ColorControlProps = {
label: string
value: string
onChange: (value: string) => void
}
function ColorControl({ label, value, onChange }: ColorControlProps) {
const [open, setOpen] = useState(false)
const swatchRef = useRef<HTMLButtonElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const popoverId = useId()
useEffect(() => {
if (!open) return
function handlePointerDown(event: PointerEvent) {
const target = event.target as Node
const insideSwatch = swatchRef.current?.contains(target)
const insidePopover = popoverRef.current?.contains(target)
if (!insideSwatch && !insidePopover) setOpen(false)
}
document.addEventListener('pointerdown', handlePointerDown, true)
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
}, [open])
return (
<div className="gromlab-sprite-viewer__color-row">
<button
ref={swatchRef}
className="gromlab-sprite-viewer__swatch"
type="button"
style={{ backgroundColor: value }}
aria-label={`Изменить цвет ${label}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
title={`Изменить цвет ${label}`}
onClick={() => setOpen((current) => !current)}
/>
<code className="gromlab-sprite-viewer__color-label">{label}</code>
{open && (
<div
ref={popoverRef}
id={popoverId}
className="gromlab-sprite-viewer__color-popover"
role="dialog"
aria-label={`Выбор цвета ${label}`}
onKeyDown={(event) => {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
setOpen(false)
}}
>
<HexColorPicker color={value} onChange={onChange} />
<HexColorInput
className="gromlab-sprite-viewer__hex-input"
color={value}
onChange={onChange}
prefixed
aria-label={`HEX-значение ${label}`}
/>
</div>
)}
</div>
)
}
function initialColors(icon: SpriteManifestIcon, currentColor: string) {
return Object.fromEntries(icon.colors.map(({ variable, fallback }) => [
variable,
normalizeHexColor(fallback, currentColor),
]))
}
export function SpriteViewerDialog({ manifest, icon, colorTheme, onClose }: SpriteViewerDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
const tabsId = useId()
const tabs = tabsForFormat(manifest.format)
const [activeTab, setActiveTab] = useState<SpriteViewerTab>('react')
const themeColor = colorTheme === 'dark' ? '#e5e5e5' : '#1a1a1a'
const [colors, setColors] = useState<Record<string, string>>(() => initialColors(icon, themeColor))
const [colorOverrides, setColorOverrides] = useState<Record<string, string>>({})
const [cssColor, setCssColor] = useState(themeColor)
const [cssColorOverridden, setCssColorOverridden] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
dialog.showModal()
return () => {
if (dialog.open) dialog.close()
}
}, [icon])
useEffect(() => {
setColors({ ...initialColors(icon, themeColor), ...colorOverrides })
if (!cssColorOverridden) setCssColor(themeColor)
}, [colorTheme, icon])
useEffect(() => {
if (!copied) return
const timeout = setTimeout(() => setCopied(false), 1500)
return () => clearTimeout(timeout)
}, [copied])
const previewStyle = Object.fromEntries(
Object.entries(colors).map(([variable, color]) => [variable, color]),
) as CSSProperties
const href = `${manifest.spriteUrl}#${icon.id}`
const dimensions = viewBoxSize(icon.viewBox)
const code = generateViewerCode({ manifest, icon, tab: activeTab, colorOverrides, cssColor })
function handleColorChange(variable: string, color: string) {
const normalized = normalizeHexColor(color)
setColors((current) => ({ ...current, [variable]: normalized }))
setColorOverrides((current) => ({ ...current, [variable]: normalized }))
}
function handleCssColorChange(color: string) {
setCssColor(normalizeHexColor(color))
setCssColorOverridden(true)
}
function handleBackdropClick(event: React.MouseEvent<HTMLDialogElement>) {
if (event.target !== event.currentTarget) return
const bounds = event.currentTarget.getBoundingClientRect()
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom
if (outside) onClose()
}
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, tabIndex: number) {
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (tabIndex + 1) % tabs.length
if (event.key === 'ArrowLeft') nextIndex = (tabIndex - 1 + tabs.length) % tabs.length
if (event.key === 'Home') nextIndex = 0
if (event.key === 'End') nextIndex = tabs.length - 1
if (nextIndex === null) return
event.preventDefault()
setActiveTab(tabs[nextIndex].id)
const buttons = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function copyCode() {
if (!globalThis.navigator?.clipboard) return
try {
await globalThis.navigator.clipboard.writeText(code.code)
setCopied(true)
} catch {
setCopied(false)
}
}
function renderPreview() {
if (activeTab === 'img') {
return <img className="gromlab-sprite-viewer__dialog-img" src={href} alt={icon.name} />
}
if (activeTab === 'css') {
return (
<div
className="gromlab-sprite-viewer__dialog-mask"
role="img"
aria-label={icon.name}
style={{
backgroundColor: cssColor,
mask: `url('${href}') no-repeat center / contain`,
WebkitMask: `url('${href}') no-repeat center / contain`,
}}
/>
)
}
return (
<svg
className="gromlab-sprite-viewer__dialog-icon"
viewBox={icon.viewBox ?? undefined}
aria-label={icon.name}
role="img"
>
<use href={href} />
</svg>
)
}
return (
<dialog
ref={dialogRef}
className="gromlab-sprite-viewer__dialog"
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClick={handleBackdropClick}
>
<div className="gromlab-sprite-viewer__dialog-shell">
<button
className="gromlab-sprite-viewer__close"
type="button"
aria-label="Закрыть"
autoFocus
onClick={onClose}
>
&#x2715;
</button>
<div className="gromlab-sprite-viewer__dialog-preview" style={previewStyle}>
<div className="gromlab-sprite-viewer__dialog-preview-canvas">
{renderPreview()}
</div>
</div>
<div className="gromlab-sprite-viewer__dialog-heading">
<h2 id={titleId} className="gromlab-sprite-viewer__dialog-title">{icon.name}</h2>
{dimensions && <span className="gromlab-sprite-viewer__viewbox">{dimensions}</span>}
</div>
<p className="gromlab-sprite-viewer__dialog-meta">
{manifest.name} · {manifest.format} · {manifest.target}
</p>
<div className="gromlab-sprite-viewer__colors">
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length > 0 && (
<>
<p className="gromlab-sprite-viewer__hint">
Цвета применяются к превью через CSS-переменные и попадут в пример кода.
</p>
<h3 className="gromlab-sprite-viewer__colors-title">CSS Variables</h3>
{icon.colors.map(({ variable, fallback }) => (
<ColorControl
key={variable}
value={colors[variable]}
label={`${variable}: ${fallback}`}
onChange={(color) => handleColorChange(variable, color)}
/>
))}
</>
)}
{(activeTab === 'react' || activeTab === 'svg') && icon.colors.length === 0 && (
<p className="gromlab-sprite-viewer__hint">У иконки нет настраиваемых цветовых переменных.</p>
)}
{activeTab === 'img' && (
<p className="gromlab-sprite-viewer__hint">
IMG изолирует SVG: CSS-переменные и currentColor внутрь изображения не передаются.
</p>
)}
{activeTab === 'css' && (
<>
<p className="gromlab-sprite-viewer__hint">
CSS mask отображает иконку одним цветом через background-color.
</p>
<ColorControl label="background-color" value={cssColor} onChange={handleCssColorChange} />
</>
)}
</div>
<div className="gromlab-sprite-viewer__tabs" role="tablist" aria-label="Способ подключения">
{tabs.map((tab, tabIndex) => (
<button
id={`${tabsId}-${tab.id}-tab`}
key={tab.id}
className="gromlab-sprite-viewer__tab"
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`${tabsId}-panel`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(event) => handleTabKeyDown(event, tabIndex)}
>
{tab.label}
</button>
))}
</div>
<div
id={`${tabsId}-panel`}
className="gromlab-sprite-viewer__code"
role="tabpanel"
aria-labelledby={`${tabsId}-${activeTab}-tab`}
>
<pre><code dangerouslySetInnerHTML={{ __html: highlightViewerCode(code.code, code.language) }} /></pre>
<button className="gromlab-sprite-viewer__copy" type="button" onClick={() => void copyCode()}>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
</div>
</dialog>
)
}

View File

@@ -0,0 +1,332 @@
export const SPRITE_VIEWER_STYLES = `
.gromlab-sprite-viewer {
--sv-bg: #f0f0f3;
--sv-text: #1a1a1a;
--sv-card: #ffffff;
--sv-card-hover: #eaeaed;
--sv-border: #d8d8d8;
--sv-accent: #3b82f6;
--sv-muted: #777777;
--sv-code: #f5f5f5;
--sv-checker-a: #e9e9e9;
--sv-checker-b: #ffffff;
--sv-danger: #b42332;
box-sizing: border-box;
min-height: 320px;
padding: 24px;
color: var(--sv-text);
color-scheme: light;
background: var(--sv-bg);
border-radius: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.gromlab-sprite-viewer[data-theme="dark"] {
--sv-bg: #1a1a1a;
--sv-text: #e5e5e5;
--sv-card: #2a2a2a;
--sv-card-hover: #333333;
--sv-border: #404040;
--sv-muted: #a3a3a3;
--sv-code: #242424;
--sv-checker-a: #333333;
--sv-checker-b: #2a2a2a;
--sv-danger: #ff9ba6;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
.gromlab-sprite-viewer:not([data-theme]) {
--sv-bg: #1a1a1a;
--sv-text: #e5e5e5;
--sv-card: #2a2a2a;
--sv-card-hover: #333333;
--sv-border: #404040;
--sv-muted: #a3a3a3;
--sv-code: #242424;
--sv-checker-a: #333333;
--sv-checker-b: #2a2a2a;
--sv-danger: #ff9ba6;
color-scheme: dark;
}
}
.gromlab-sprite-viewer *,
.gromlab-sprite-viewer *::before,
.gromlab-sprite-viewer *::after { box-sizing: border-box; }
.gromlab-sprite-viewer button,
.gromlab-sprite-viewer input { font: inherit; }
.gromlab-sprite-viewer__header {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 24px;
}
.gromlab-sprite-viewer__title { margin: 0; font-size: 24px; line-height: 1.2; font-weight: 700; }
.gromlab-sprite-viewer__summary { color: var(--sv-muted); font-size: 13px; }
.gromlab-sprite-viewer__toolbar { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.gromlab-sprite-viewer__search {
width: 220px;
height: 38px;
padding: 0 12px;
color: var(--sv-text);
background: var(--sv-card);
border: 1px solid var(--sv-border);
border-radius: 8px;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.gromlab-sprite-viewer__search:focus { border-color: var(--sv-accent); box-shadow: 0 0 0 3px rgba(59, 130, 246, .18); }
.gromlab-sprite-viewer__search::placeholder { color: var(--sv-muted); }
.gromlab-sprite-viewer__theme {
display: grid;
place-items: center;
width: 38px;
height: 38px;
padding: 0;
color: var(--sv-text);
background: var(--sv-card);
border: 1px solid var(--sv-border);
border-radius: 8px;
cursor: pointer;
transition: background .15s, border-color .15s;
}
.gromlab-sprite-viewer__theme:hover { background: var(--sv-card-hover); }
.gromlab-sprite-viewer__theme:focus-visible,
.gromlab-sprite-viewer__card:focus-visible,
.gromlab-sprite-viewer__close:focus-visible,
.gromlab-sprite-viewer__tab:focus-visible,
.gromlab-sprite-viewer__copy:focus-visible,
.gromlab-sprite-viewer__swatch:focus-visible {
outline: 2px solid var(--sv-accent);
outline-offset: 2px;
}
.gromlab-sprite-viewer__errors {
margin: 0 0 24px;
padding: 12px 14px;
color: var(--sv-danger);
background: rgba(180, 35, 50, .08);
border: 1px solid rgba(180, 35, 50, .28);
border-radius: 8px;
font-size: 12px;
}
.gromlab-sprite-viewer__group { margin-bottom: 40px; }
.gromlab-sprite-viewer__group-header { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.gromlab-sprite-viewer__group-title { margin: 0; font-size: 18px; line-height: 1.3; font-weight: 600; }
.gromlab-sprite-viewer__badge {
padding: 2px 8px;
color: #ffffff;
background: var(--sv-accent);
border-radius: 999px;
font-size: 11px;
line-height: 1.5;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
}
.gromlab-sprite-viewer__group-count,
.gromlab-sprite-viewer__description { color: var(--sv-muted); font-size: 13px; font-weight: 400; }
.gromlab-sprite-viewer__description { flex-basis: 100%; margin: -2px 0 0; }
.gromlab-sprite-viewer__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
.gromlab-sprite-viewer__card {
display: flex;
min-width: 0;
padding: 16px 8px;
color: inherit;
text-align: center;
background: var(--sv-card);
border: 0;
border-radius: 8px;
cursor: pointer;
flex-direction: column;
align-items: center;
gap: 8px;
transition: background .15s, transform .15s;
}
.gromlab-sprite-viewer__card:hover { background: var(--sv-card-hover); transform: translateY(-1px); }
.gromlab-sprite-viewer__icon-wrap,
.gromlab-sprite-viewer__dialog-preview-canvas {
display: grid;
place-items: center;
background: conic-gradient(
var(--sv-checker-a) 25%, var(--sv-checker-b) 0 50%,
var(--sv-checker-a) 0 75%, var(--sv-checker-b) 0
);
background-size: 8px 8px;
border-radius: 4px;
}
.gromlab-sprite-viewer__icon-wrap { width: 128px; height: 128px; }
.gromlab-sprite-viewer__icon { display: block; width: 128px; height: 128px; color: var(--sv-text); overflow: visible; }
.gromlab-sprite-viewer__icon-name {
max-width: 100%;
overflow: hidden;
color: var(--sv-muted);
font-size: 13px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.gromlab-sprite-viewer__status {
padding: 40px 20px;
color: var(--sv-muted);
text-align: center;
background: var(--sv-card);
border: 1px dashed var(--sv-border);
border-radius: 8px;
}
.gromlab-sprite-viewer__footer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 40px;
padding-top: 16px;
color: var(--sv-muted);
border-top: 1px solid var(--sv-border);
font-size: 13px;
}
.gromlab-sprite-viewer__footer a { color: var(--sv-accent); text-decoration: none; }
.gromlab-sprite-viewer__footer a:hover { text-decoration: underline; }
.gromlab-sprite-viewer__dialog {
width: min(560px, calc(100vw - 32px));
max-width: none;
max-height: calc(100dvh - 48px);
padding: 0;
color: var(--sv-text);
background: transparent;
border: 0;
overflow: visible;
}
.gromlab-sprite-viewer__dialog::backdrop { background: rgba(0, 0, 0, .55); backdrop-filter: blur(2px); }
.gromlab-sprite-viewer__dialog-shell {
position: relative;
max-height: calc(100dvh - 48px);
padding: 24px;
overflow-y: auto;
color: var(--sv-text);
background: var(--sv-bg);
border: 1px solid var(--sv-border);
border-radius: 12px;
box-shadow: 0 24px 72px rgba(0, 0, 0, .28);
}
.gromlab-sprite-viewer__close {
position: absolute;
z-index: 2;
top: 12px;
right: 12px;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
color: var(--sv-text);
background: var(--sv-bg);
border: 1px solid var(--sv-border);
border-radius: 8px;
cursor: pointer;
}
.gromlab-sprite-viewer__close:hover { background: var(--sv-card-hover); }
.gromlab-sprite-viewer__dialog-preview { display: flex; align-items: center; justify-content: center; padding: 24px; margin-bottom: 16px; background: var(--sv-card); border-radius: 8px; }
.gromlab-sprite-viewer__dialog-preview-canvas { width: 256px; height: 256px; }
.gromlab-sprite-viewer__dialog-icon,
.gromlab-sprite-viewer__dialog-img,
.gromlab-sprite-viewer__dialog-mask { display: block; width: 256px; height: 256px; }
.gromlab-sprite-viewer__dialog-icon { color: var(--sv-text); overflow: visible; }
.gromlab-sprite-viewer__dialog-img { object-fit: contain; }
.gromlab-sprite-viewer__dialog-heading { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.gromlab-sprite-viewer__dialog-title { margin: 0; font: 600 16px/1.3 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__viewbox {
padding: 2px 8px;
color: var(--sv-muted);
background: var(--sv-card);
border: 1px solid var(--sv-border);
border-radius: 999px;
font-size: 11px;
white-space: nowrap;
}
.gromlab-sprite-viewer__dialog-meta { margin: 0 0 20px; color: var(--sv-muted); text-align: center; font-size: 12px; }
.gromlab-sprite-viewer__colors { margin-bottom: 20px; }
.gromlab-sprite-viewer__hint {
margin: 0 0 12px;
padding: 8px 12px;
color: var(--sv-muted);
background: var(--sv-card);
border-left: 3px solid var(--sv-accent);
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
}
.gromlab-sprite-viewer__colors-title { margin: 0 0 8px; color: var(--sv-muted); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
.gromlab-sprite-viewer__color-row { position: relative; display: flex; align-items: center; gap: 8px; min-height: 32px; margin-bottom: 6px; }
.gromlab-sprite-viewer__swatch { width: 26px; height: 26px; flex: 0 0 auto; padding: 0; border: 1px solid var(--sv-border); border-radius: 4px; cursor: pointer; }
.gromlab-sprite-viewer__color-label { min-width: 0; overflow-wrap: anywhere; color: var(--sv-muted); font: 12px/1.4 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__color-popover {
position: absolute;
z-index: 5;
bottom: calc(100% + 6px);
left: 0;
width: 224px;
max-width: calc(100vw - 64px);
padding: 12px;
background: var(--sv-bg);
border: 1px solid var(--sv-border);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
}
.gromlab-sprite-viewer__color-popover .react-colorful { width: 198px; max-width: 100%; height: 160px; }
.gromlab-sprite-viewer__hex-input { display: block; width: 100%; height: 32px; margin-top: 8px; padding: 0 8px; color: var(--sv-text); text-align: center; background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 4px; outline: none; font: 12px/1 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__hex-input:focus { border-color: var(--sv-accent); }
.gromlab-sprite-viewer__tabs { display: flex; overflow-x: auto; margin-bottom: 12px; border-bottom: 1px solid var(--sv-border); }
.gromlab-sprite-viewer__tab { flex: 0 0 auto; padding: 8px 16px; color: var(--sv-muted); background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
.gromlab-sprite-viewer__tab[aria-selected="true"] { color: var(--sv-accent); border-bottom-color: var(--sv-accent); }
.gromlab-sprite-viewer__code { position: relative; overflow: hidden; background: var(--sv-code); border-radius: 8px; }
.gromlab-sprite-viewer__code pre { min-height: 72px; margin: 0; padding: 16px 70px 16px 16px; overflow-x: auto; font: 12px/1.6 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.gromlab-sprite-viewer__copy { position: absolute; top: 8px; right: 8px; padding: 4px 8px; color: var(--sv-muted); background: var(--sv-bg); border: 1px solid var(--sv-border); border-radius: 4px; cursor: pointer; font-size: 11px; }
.gromlab-sprite-viewer__copy:hover { color: var(--sv-text); }
.gromlab-sprite-viewer__code .hl-tag { color: #116329; }
.gromlab-sprite-viewer__code .hl-attr,
.gromlab-sprite-viewer__code .hl-number,
.gromlab-sprite-viewer__code .hl-property { color: #0550ae; }
.gromlab-sprite-viewer__code .hl-string,
.gromlab-sprite-viewer__code .hl-color { color: #0a3069; }
.gromlab-sprite-viewer__code .hl-comment { color: #8b949e; }
.gromlab-sprite-viewer__code .hl-punctuation,
.gromlab-sprite-viewer__code .hl-selector { color: #6639ba; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-tag { color: #7ee787; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-attr,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-number,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-property { color: #79c0ff; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-string,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-color { color: #a5d6ff; }
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-punctuation,
.gromlab-sprite-viewer[data-theme="dark"] .gromlab-sprite-viewer__code .hl-selector { color: #d2a8ff; }
@media (prefers-color-scheme: dark) {
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-tag { color: #7ee787; }
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-attr,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-number,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-property { color: #79c0ff; }
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-string,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-color { color: #a5d6ff; }
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-punctuation,
.gromlab-sprite-viewer:not([data-theme]) .gromlab-sprite-viewer__code .hl-selector { color: #d2a8ff; }
}
@media (max-width: 640px) {
.gromlab-sprite-viewer { padding: 16px; border-radius: 8px; }
.gromlab-sprite-viewer__header { align-items: stretch; }
.gromlab-sprite-viewer__toolbar { width: 100%; margin-left: 0; }
.gromlab-sprite-viewer__search { width: auto; flex: 1; min-width: 0; }
.gromlab-sprite-viewer__grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; }
.gromlab-sprite-viewer__icon-wrap,
.gromlab-sprite-viewer__icon { width: 104px; height: 104px; }
.gromlab-sprite-viewer__dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 20px); }
.gromlab-sprite-viewer__dialog-shell { max-height: calc(100dvh - 20px); padding: 16px; }
.gromlab-sprite-viewer__dialog-preview { padding: 16px; }
.gromlab-sprite-viewer__dialog-preview-canvas,
.gromlab-sprite-viewer__dialog-icon,
.gromlab-sprite-viewer__dialog-img,
.gromlab-sprite-viewer__dialog-mask { width: min(256px, calc(100vw - 86px)); height: min(256px, calc(100vw - 86px)); }
}
@media (prefers-reduced-motion: reduce) {
.gromlab-sprite-viewer *,
.gromlab-sprite-viewer *::before,
.gromlab-sprite-viewer *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
`

301
src/react/sprite-viewer.tsx Normal file
View File

@@ -0,0 +1,301 @@
'use client'
import { useDeferredValue, useEffect, useState } from 'react'
import type { CSSProperties, ReactElement } from 'react'
import { SpriteViewerDialog } from './sprite-viewer-dialog.js'
import { SPRITE_VIEWER_STYLES } from './sprite-viewer-styles.js'
import type {
SpriteManifest,
SpriteManifestIcon,
SpriteManifestLoader,
SpriteManifestModule,
SpriteViewerSource,
SpriteViewerSources,
} from './types.js'
export type SpriteViewerColorTheme = 'auto' | 'light' | 'dark'
export type SpriteViewerProps = {
sources: SpriteViewerSources
title?: string
/** Тема Viewer. По умолчанию следует prefers-color-scheme. */
colorTheme?: SpriteViewerColorTheme
/** Вызывается встроенным переключателем в controlled-режиме. */
onColorThemeChange?: (theme: SpriteViewerColorTheme) => void
className?: string
style?: CSSProperties
}
type SelectedIcon = {
key: string
manifest: SpriteManifest
icon: SpriteManifestIcon
}
const manifestLoaderCache = new WeakMap<SpriteManifestLoader, Promise<SpriteManifest>>()
function compareManifests(left: SpriteManifest, right: SpriteManifest): number {
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
}
function sourceArray(sources: SpriteViewerSources): readonly SpriteViewerSource[] {
return Array.isArray(sources)
? sources
: Object.values(sources as Readonly<Record<string, SpriteViewerSource>>)
}
function isSpriteManifest(value: unknown): value is SpriteManifest {
if (!value || typeof value !== 'object') return false
const manifest = value as Partial<SpriteManifest>
return manifest.schemaVersion === 1
&& manifest.generator === '@gromlab/svg-sprites'
&& typeof manifest.name === 'string'
&& typeof manifest.componentName === 'string'
&& typeof manifest.spriteUrl === 'string'
&& Array.isArray(manifest.icons)
}
function manifestFromModule(value: SpriteManifest | SpriteManifestModule): SpriteManifest {
if (isSpriteManifest(value)) return value
if (isSpriteManifest(value.default)) return value.default
if (isSpriteManifest(value.spriteManifest)) return value.spriteManifest
throw new Error('The loaded module does not export a valid SVG sprite manifest.')
}
async function resolveSource(source: SpriteViewerSource): Promise<SpriteManifest> {
if (typeof source !== 'function') return source
const cached = manifestLoaderCache.get(source)
if (cached) return cached
const pending = Promise.resolve().then(source).then(manifestFromModule)
manifestLoaderCache.set(source, pending)
void pending.catch(() => {
if (manifestLoaderCache.get(source) === pending) manifestLoaderCache.delete(source)
})
return pending
}
function directManifests(sources: SpriteViewerSources): SpriteManifest[] {
return sourceArray(sources)
.filter((source): source is SpriteManifest => typeof source !== 'function')
.sort(compareManifests)
}
function countLabel(value: number, forms: readonly [string, string, string]): string {
const modulo100 = value % 100
const modulo10 = value % 10
const form = modulo100 >= 11 && modulo100 <= 19
? forms[2]
: modulo10 === 1
? forms[0]
: modulo10 >= 2 && modulo10 <= 4
? forms[1]
: forms[2]
return `${value} ${form}`
}
function currentSystemTheme(): 'light' | 'dark' {
return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function useSystemTheme(): 'light' | 'dark' {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(currentSystemTheme)
useEffect(() => {
const media = globalThis.matchMedia?.('(prefers-color-scheme: dark)')
if (!media) return
const update = () => setSystemTheme(media.matches ? 'dark' : 'light')
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return systemTheme
}
/** Интерактивный каталог локальных React- и Next.js-спрайтов. */
export function SpriteViewer({
sources,
title = 'SVG Sprites',
colorTheme,
onColorThemeChange,
className,
style,
}: SpriteViewerProps): ReactElement {
const [manifests, setManifests] = useState<SpriteManifest[]>(() => directManifests(sources))
const [errors, setErrors] = useState<string[]>([])
const [loading, setLoading] = useState(() => sourceArray(sources).some((source) => typeof source === 'function'))
const [query, setQuery] = useState('')
const [localColorTheme, setLocalColorTheme] = useState<SpriteViewerColorTheme>('auto')
const [selected, setSelected] = useState<SelectedIcon | null>(null)
const systemTheme = useSystemTheme()
const activeColorTheme = colorTheme ?? localColorTheme
const resolvedColorTheme = activeColorTheme === 'auto' ? systemTheme : activeColorTheme
const canToggleTheme = colorTheme === undefined || onColorThemeChange !== undefined
const deferredQuery = useDeferredValue(query.trim().toLowerCase())
useEffect(() => {
let active = true
const allSources = sourceArray(sources)
const direct = directManifests(sources)
const loaders = allSources.filter((source) => typeof source === 'function')
setManifests(direct)
setErrors([])
setLoading(loaders.length > 0)
setSelected(null)
if (loaders.length === 0) return () => { active = false }
Promise.allSettled(loaders.map(resolveSource)).then((results) => {
if (!active) return
const loaded: SpriteManifest[] = []
const failures: string[] = []
for (const result of results) {
if (result.status === 'fulfilled') loaded.push(result.value)
else failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason))
}
setManifests([...direct, ...loaded].sort(compareManifests))
setErrors(failures)
setLoading(false)
})
return () => { active = false }
}, [sources])
const visibleGroups = manifests
.map((manifest) => ({
manifest,
icons: manifest.icons.filter((icon) => (
deferredQuery === ''
|| icon.name.toLowerCase().includes(deferredQuery)
|| manifest.name.toLowerCase().includes(deferredQuery)
)),
}))
.filter((group) => group.icons.length > 0)
const totalIcons = manifests.reduce((total, manifest) => total + manifest.iconCount, 0)
const visibleIcons = visibleGroups.reduce((total, group) => total + group.icons.length, 0)
const rootClassName = ['gromlab-sprite-viewer', className].filter(Boolean).join(' ')
function toggleTheme() {
const nextTheme = resolvedColorTheme === 'dark' ? 'light' : 'dark'
if (colorTheme === undefined) setLocalColorTheme(nextTheme)
onColorThemeChange?.(nextTheme)
}
return (
<section
className={rootClassName}
style={style}
data-sprite-viewer=""
data-theme={activeColorTheme === 'auto' ? undefined : activeColorTheme}
>
<style>{SPRITE_VIEWER_STYLES}</style>
<header className="gromlab-sprite-viewer__header">
<h1 className="gromlab-sprite-viewer__title">{title}</h1>
<span className="gromlab-sprite-viewer__summary">
{countLabel(manifests.length, ['спрайт', 'спрайта', 'спрайтов'])}
{' · '}
{countLabel(totalIcons, ['иконка', 'иконки', 'иконок'])}
{deferredQuery && ` · найдено ${visibleIcons}`}
</span>
<div className="gromlab-sprite-viewer__toolbar">
<input
className="gromlab-sprite-viewer__search"
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Найти иконку"
aria-label="Поиск иконок"
/>
{canToggleTheme && (
<button
className="gromlab-sprite-viewer__theme"
type="button"
aria-label="Переключить тему"
title="Переключить тему"
onClick={toggleTheme}
>
&#x25D1;
</button>
)}
</div>
</header>
{errors.length > 0 && (
<div className="gromlab-sprite-viewer__errors" role="alert">
{errors.map((error, index) => <div key={`${index}:${error}`}>{error}</div>)}
</div>
)}
{visibleGroups.map(({ manifest, icons }) => (
<section className="gromlab-sprite-viewer__group" key={`${manifest.name}:${manifest.spriteUrl}`}>
<div className="gromlab-sprite-viewer__group-header">
<h2 className="gromlab-sprite-viewer__group-title">{manifest.name}</h2>
<span className="gromlab-sprite-viewer__badge">{manifest.format}</span>
<span className="gromlab-sprite-viewer__group-count">{icons.length}</span>
{manifest.description && (
<p className="gromlab-sprite-viewer__description">{manifest.description}</p>
)}
</div>
<div className="gromlab-sprite-viewer__grid">
{icons.map((icon) => {
const key = `${manifest.name}:${manifest.spriteUrl}:${icon.id}`
return (
<button
className="gromlab-sprite-viewer__card"
type="button"
key={key}
data-icon-name={icon.name}
onClick={() => setSelected({ key, manifest, icon })}
title={`Открыть ${icon.name}`}
>
<span className="gromlab-sprite-viewer__icon-wrap">
<svg
className="gromlab-sprite-viewer__icon"
viewBox={icon.viewBox ?? undefined}
aria-hidden="true"
>
<use href={`${manifest.spriteUrl}#${icon.id}`} />
</svg>
</span>
<span className="gromlab-sprite-viewer__icon-name">{icon.name}</span>
</button>
)
})}
</div>
</section>
))}
{visibleGroups.length === 0 && (!loading || manifests.length > 0) && (
<div className="gromlab-sprite-viewer__status">
{manifests.length === 0 ? 'Спрайты не подключены' : 'Иконки не найдены'}
</div>
)}
{loading && manifests.length === 0 && (
<div className="gromlab-sprite-viewer__status">Загрузка спрайтов...</div>
)}
<footer className="gromlab-sprite-viewer__footer">
<span>@gromlab/svg-sprites</span>
<a href="https://gromlab.ru/gromov/svg-sprites" target="_blank" rel="noreferrer">Repository</a>
</footer>
{selected && (
<SpriteViewerDialog
key={selected.key}
manifest={selected.manifest}
icon={selected.icon}
colorTheme={resolvedColorTheme}
onClose={() => setSelected(null)}
/>
)}
</section>
)
}

40
src/react/types.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { SpriteAssetTarget } from '../targets/types.js'
export type SpriteManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type SpriteManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly SpriteManifestColor[]
}
/** Публичные данные одного сгенерированного React-спрайта. */
export type SpriteManifest = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
componentName: string
target: SpriteAssetTarget
format: 'stack' | 'symbol'
iconCount: number
spriteUrl: string
icons: readonly SpriteManifestIcon[]
}
export type SpriteManifestModule = {
default?: SpriteManifest
spriteManifest?: SpriteManifest
}
export type SpriteManifestLoader = () => Promise<SpriteManifest | SpriteManifestModule>
export type SpriteViewerSource = SpriteManifest | SpriteManifestLoader
/** Массив источников либо результат import.meta.glob. */
export type SpriteViewerSources =
| readonly SpriteViewerSource[]
| Readonly<Record<string, SpriteViewerSource>>

View File

@@ -37,11 +37,36 @@ function resolveFiles(files: string[]): string[] {
})
}
type ResolveSpriteSourcesOptions = {
name: string
format: SpriteFolder['format']
inputFolder: string | null
inputFiles: string[]
}
/** Объединяет SVG из папки и явного списка в один источник спрайта. */
export function resolveSpriteSources(options: ResolveSpriteSourcesOptions): SpriteFolder {
const { name, format, inputFolder, inputFiles } = options
const folderFiles = inputFolder === null ? [] : scanDirectory(inputFolder)
const files = [...new Set([...folderFiles, ...resolveFiles(inputFiles)])]
if (files.length === 0) {
throw new Error(`Sprite "${name}" has no SVG files in configured inputs.`)
}
return {
name,
format,
path: inputFolder,
files,
}
}
/**
* Преобразует SpriteEntry из конфига в SpriteFolder для компиляции.
*/
export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
const mode = entry.mode ?? 'stack'
const format = entry.format ?? 'stack'
if (Array.isArray(entry.input)) {
const files = resolveFiles(entry.input)
@@ -52,7 +77,7 @@ export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
return {
name: entry.name,
mode,
format,
path: null,
files,
}
@@ -67,7 +92,7 @@ export function resolveSpriteEntry(entry: SpriteEntry): SpriteFolder {
return {
name: entry.name,
mode,
format,
path: dirPath,
files,
}

14
src/shape-id.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createHash } from 'node:crypto'
import path from 'node:path'
/** Возвращает стабильный и безопасный fragment ID для исходного SVG-файла. */
export function getSpriteShapeId(filePath: string): string {
const iconName = path.basename(filePath, '.svg')
if (/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(iconName)) {
return iconName
}
const hash = createHash('sha256').update(iconName).digest('hex').slice(0, 16)
return `icon-${hash}`
}

33
src/targets/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { generateViteAssetUrlCode } from './vite.js'
import { generateWebpackAssetUrlCode } from './webpack.js'
import type { SpriteAssetTarget, SpriteAssetUrlCode } from './types.js'
/** Возвращает codegen-фрагменты для выбранной среды сборки React-модуля. */
export function generateReactAssetUrlCode(
target: SpriteAssetTarget,
spriteFileName: string,
): SpriteAssetUrlCode {
switch (target) {
case 'vite':
return generateViteAssetUrlCode(spriteFileName)
case 'webpack':
case 'next@app/turbopack':
case 'next@app/webpack':
case 'next@pages/turbopack':
case 'next@pages/webpack':
return generateWebpackAssetUrlCode(spriteFileName)
default:
throw new Error(`Unsupported sprite asset target: ${String(target)}`)
}
}
export type {
NextAssetTarget,
NextBundler,
NextRouter,
ReactAssetTarget,
SpriteAssetTarget,
SpriteAssetUrlCode,
ViteAssetTarget,
WebpackAssetTarget,
} from './types.js'

45
src/targets/types.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Asset target для Vite.
*
* Генерирует статический импорт SVG с query `?no-inline`, чтобы Vite всегда
* выпускал спрайт отдельным кешируемым файлом и не преобразовывал его в data URL.
*/
export type ViteAssetTarget = 'vite'
/**
* Asset target для Webpack 5.
*
* Генерирует статический `new URL(..., import.meta.url)`. Webpack обрабатывает
* такую ссылку через Asset Modules и выпускает SVG отдельным файлом.
*/
export type WebpackAssetTarget = 'webpack'
/** Роутер Next.js, для которого генерируется sprite-модуль. */
export type NextRouter = 'app' | 'pages'
/** Сборщик Next.js, обрабатывающий SVG asset. */
export type NextBundler = 'turbopack' | 'webpack'
/** Полный ключ Next.js target, используемый CLI и manifest. */
export type NextAssetTarget = `next@${NextRouter}/${NextBundler}`
/**
* Среда сборки React sprite-модуля.
*
* Target определяет только способ получения публичного URL SVG asset.
* Состав иконок, React API и структура generated-файлов от него не зависят.
*/
export type ReactAssetTarget = ViteAssetTarget | WebpackAssetTarget
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget
/** Фрагменты кода, необходимые компоненту для получения URL SVG asset. */
export type SpriteAssetUrlCode = {
/** Импорты, которые добавляются в начало generated-компонента. */
imports: string[]
/** Объявления, которые добавляются после импортов. */
declarations: string[]
/** Имя переменной, содержащей итоговый публичный URL SVG. */
variableName: string
}

10
src/targets/vite.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { SpriteAssetUrlCode } from './types.js'
/** Генерирует Vite-импорт отдельного, запрещённого к inline SVG asset. */
export function generateViteAssetUrlCode(spriteFileName: string): SpriteAssetUrlCode {
return {
imports: [`import spriteUrl from './${spriteFileName}?no-inline'`],
declarations: [],
variableName: 'spriteUrl',
}
}

12
src/targets/webpack.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { SpriteAssetUrlCode } from './types.js'
/** Генерирует Webpack 5 Asset Module через статический import.meta.url. */
export function generateWebpackAssetUrlCode(spriteFileName: string): SpriteAssetUrlCode {
return {
imports: [],
declarations: [
`const spriteUrl = new URL('./${spriteFileName}', import.meta.url).href`,
],
variableName: 'spriteUrl',
}
}

View File

@@ -1,5 +1,5 @@
/** Режим спрайта: stack или symbol. */
export type SpriteMode = 'stack' | 'symbol'
/** Формат спрайта: stack или symbol. */
export type SpriteFormat = 'stack' | 'symbol'
/** Описание одного спрайта в конфиге. */
export type SpriteEntry = {
@@ -12,10 +12,10 @@ export type SpriteEntry = {
*/
input: string | string[]
/**
* Режим спрайта.
* Формат спрайта.
* По умолчанию: 'stack'.
*/
mode?: SpriteMode
format?: SpriteFormat
}
/** Параметры трансформации SVG. Все включены по умолчанию. */
@@ -43,22 +43,11 @@ export type TransformOptions = {
export type SvgSpritesConfig = {
/** Путь к папке для сгенерированных SVG-спрайтов. */
output: string
/**
* Публичный путь к спрайтам для использования в коде (href, src, url()).
* Используется в сгенерированном React-компоненте.
* Пример: '/img/sprites'.
*/
publicPath?: string
/**
* Генерировать HTML-превью со всеми иконками.
* По умолчанию: true.
*/
preview?: boolean
/**
* Путь для генерации React-компонента.
* Если не задан — компонент и типы не генерируются.
*/
react?: string
/**
* Настройки трансформации SVG.
* По умолчанию: все трансформации включены.
@@ -72,8 +61,8 @@ export type SvgSpritesConfig = {
export type SpriteFolder = {
/** Имя спрайта. */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Формат спрайта. */
format: SpriteFormat
/** Абсолютный путь к папке (для input-папки) или null (для input-массива). */
path: string | null
/** Абсолютные пути к SVG-файлам. */
@@ -84,8 +73,8 @@ export type SpriteFolder = {
export type SpriteResult = {
/** Имя спрайта. */
name: string
/** Режим спрайта. */
mode: SpriteMode
/** Формат спрайта. */
format: SpriteFormat
/** Путь к сгенерированному SVG-спрайту. */
spritePath: string
/** Количество иконок в спрайте. */

View File

@@ -1,21 +1,19 @@
import { defineConfig } from './src/index.js'
import { defineLegacyConfig } from './src/index.js'
export default defineConfig({
export default defineLegacyConfig({
output: 'preview/public',
publicPath: '',
preview: true,
react: 'test/ui/svg-sprite',
sprites: [
{
name: 'icons',
input: 'test/assets/icons',
mode: 'stack',
format: 'stack',
},
{
name: 'logos',
input: 'test/assets/logos',
mode: 'stack',
format: 'stack',
},
],
})

117
test/asset-targets.test.mjs Normal file
View File

@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { build as viteBuild } from 'vite'
import webpack from 'webpack'
const TINY_SPRITE = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<style>:root>svg{display:none}:root>svg:target{display:block}</style>',
'<svg id="check" viewBox="0 0 16 16">',
'<path d="M1 8l4 4L15 2"/>',
'</svg>',
'</svg>',
].join('')
function getOutputFiles(directory, extension) {
const files = []
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...getOutputFiles(entryPath, extension).map((filePath) => path.join(entry.name, filePath)))
} else if (entry.name.endsWith(extension)) {
files.push(entry.name)
}
}
return files
}
test('Vite target keeps a tiny sprite as a separate asset', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-vite-target-'))
const outputDir = path.join(rootDir, 'dist')
fs.writeFileSync(
path.join(rootDir, 'index.html'),
'<div id="root"></div><script type="module" src="/main.js"></script>',
)
fs.writeFileSync(
path.join(rootDir, 'main.js'),
[
"import spriteUrl from './sprite.svg?no-inline'",
"document.querySelector('#root').dataset.spriteUrl = spriteUrl",
'',
].join('\n'),
)
fs.writeFileSync(path.join(rootDir, 'sprite.svg'), TINY_SPRITE)
await viteBuild({
root: rootDir,
logLevel: 'silent',
build: {
outDir: outputDir,
},
})
const svgFiles = getOutputFiles(outputDir, '.svg')
const jsFiles = getOutputFiles(outputDir, '.js')
const javascript = jsFiles
.map((filePath) => fs.readFileSync(path.join(outputDir, filePath), 'utf-8'))
.join('\n')
assert.equal(svgFiles.length, 1)
assert.match(svgFiles[0], /sprite-[A-Za-z0-9_-]+\.svg$/)
assert.doesNotMatch(javascript, /data:image\/svg\+xml/)
})
test('Webpack target emits new URL sprite through Asset Modules', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-webpack-target-'))
const outputDir = path.join(rootDir, 'dist')
fs.writeFileSync(
path.join(rootDir, 'index.js'),
[
"const spriteUrl = new URL('./sprite.svg', import.meta.url).href",
'globalThis.__SPRITE_URL__ = spriteUrl',
'',
].join('\n'),
)
fs.writeFileSync(path.join(rootDir, 'sprite.svg'), TINY_SPRITE)
await new Promise((resolve, reject) => {
webpack({
context: rootDir,
entry: './index.js',
mode: 'production',
output: {
assetModuleFilename: 'assets/[name]-[contenthash][ext]',
filename: 'bundle.js',
path: outputDir,
},
}, (error, stats) => {
if (error) {
reject(error)
return
}
if (!stats || stats.hasErrors()) {
const errors = stats?.toJson({ all: false, errors: true }).errors ?? []
reject(new Error(errors.map((item) => item.message).join('\n')))
return
}
resolve()
})
})
const svgFiles = getOutputFiles(outputDir, '.svg')
const javascript = fs.readFileSync(path.join(outputDir, 'bundle.js'), 'utf-8')
assert.equal(svgFiles.length, 1)
assert.match(svgFiles[0], /sprite-[a-f0-9]+\.svg$/)
assert.doesNotMatch(javascript, /data:image\/svg\+xml/)
})

204
test/next-targets.test.mjs Normal file
View File

@@ -0,0 +1,204 @@
import assert from 'node:assert/strict'
import { spawn, spawnSync } from 'node:child_process'
import { once } from 'node:events'
import fs from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import test from 'node:test'
import { setTimeout as delay } from 'node:timers/promises'
import { generateNextSprite } from '../dist/index.js'
const NEXT_BIN = path.resolve('node_modules/next/dist/bin/next')
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content)
}
function createNextFixture() {
const rootDir = fs.mkdtempSync(path.join(path.resolve('test'), '.next-fixture-'))
writeFile(
path.join(rootDir, 'package.json'),
JSON.stringify({ name: 'svg-sprites-next-fixture', private: true }, null, 2),
)
writeFile(
path.join(rootDir, 'tsconfig.json'),
JSON.stringify({
compilerOptions: {
jsx: 'preserve',
module: 'esnext',
moduleResolution: 'bundler',
strict: true,
skipLibCheck: true,
},
}, null, 2),
)
writeFile(
path.join(rootDir, 'next-env.d.ts'),
'/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
)
writeFile(
path.join(rootDir, 'app', 'layout.tsx'),
`export default function Layout({ children }: { children: React.ReactNode }) {
return <html><body>{children}</body></html>
}
`,
)
writeFile(
path.join(rootDir, 'app', 'page.tsx'),
`import { AppIcon } from '../sprites/app'
export default function Page() {
return <main><AppIcon icon="check" aria-label="App icon" style={{ '--icon-color-1': '#008000' }} /></main>
}
`,
)
writeFile(
path.join(rootDir, 'pages', 'legacy.tsx'),
`import { PagesIcon } from '../sprites/pages'
export default function LegacyPage() {
return <main><PagesIcon icon="check" aria-label="Pages icon" style={{ '--icon-color-1': '#008000' }} /></main>
}
export function getServerSideProps() {
return { props: {} }
}
`,
)
for (const name of ['app', 'pages']) {
const spriteRoot = path.join(rootDir, 'sprites', name)
writeFile(
path.join(spriteRoot, 'svg-sprite.config.ts'),
`export default { name: '${name}', generatedNotice: false }\n`,
)
writeFile(
path.join(spriteRoot, 'icons', 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
}
return rootDir
}
function runNext(rootDir, args) {
const result = spawnSync(process.execPath, [NEXT_BIN, ...args], {
cwd: rootDir,
encoding: 'utf-8',
env: {
...process.env,
NEXT_TELEMETRY_DISABLED: '1',
},
maxBuffer: 10 * 1024 * 1024,
timeout: 120_000,
})
assert.equal(
result.status,
0,
[result.stdout, result.stderr, result.error?.message].filter(Boolean).join('\n'),
)
}
async function getFreePort() {
const server = net.createServer()
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
assert.ok(address && typeof address === 'object')
const { port } = address
server.close()
await once(server, 'close')
return port
}
async function startNext(rootDir) {
const port = await getFreePort()
const child = spawn(process.execPath, [NEXT_BIN, 'start', '--hostname', '127.0.0.1', '--port', String(port)], {
cwd: rootDir,
env: {
...process.env,
NEXT_TELEMETRY_DISABLED: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
child.stdout.on('data', (chunk) => { output += chunk })
child.stderr.on('data', (chunk) => { output += chunk })
const origin = `http://127.0.0.1:${port}`
for (let attempt = 0; attempt < 100; attempt++) {
if (child.exitCode !== null) {
throw new Error(`Next.js exited before startup.\n${output}`)
}
try {
const response = await fetch(origin)
if (response.ok) return { child, origin }
} catch {
// Server is still starting.
}
await delay(100)
}
child.kill('SIGKILL')
throw new Error(`Next.js did not start in time.\n${output}`)
}
async function stopNext(child) {
if (child.exitCode !== null) return
child.kill('SIGTERM')
await Promise.race([
once(child, 'exit'),
delay(5_000).then(() => child.kill('SIGKILL')),
])
}
async function assertRenderedSprite(origin, route) {
const response = await fetch(`${origin}${route}`)
assert.equal(response.status, 200)
const html = await response.text()
const href = html.match(/href="([^"]+\.svg)#check"/)?.[1]
assert.ok(href, `Sprite href not found in ${route}: ${html}`)
assert.doesNotMatch(href, /^(?:data|file|blob):/)
const spriteResponse = await fetch(new URL(href, origin))
assert.equal(spriteResponse.status, 200)
assert.match(spriteResponse.headers.get('content-type') ?? '', /image\/svg\+xml/)
assert.match(await spriteResponse.text(), /id="check"/)
}
test('Next 16.2 renders App and Pages sprites with Turbopack and Webpack', { timeout: 300_000 }, async () => {
const rootDir = createNextFixture()
try {
for (const bundler of ['turbopack', 'webpack']) {
await generateNextSprite(path.join(rootDir, 'sprites', 'app'), {
router: 'app',
bundler,
})
await generateNextSprite(path.join(rootDir, 'sprites', 'pages'), {
router: 'pages',
bundler,
})
runNext(rootDir, ['build', `--${bundler}`])
const { child, origin } = await startNext(rootDir)
try {
await assertRenderedSprite(origin, '/')
await assertRenderedSprite(origin, '/legacy')
} finally {
await stopNext(child)
}
}
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})

28
test/public-api.test.mjs Normal file
View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
generateNextSprite,
generateReactSprite,
} from '../dist/index.js'
test('generateReactSprite rejects unsupported targets', () => {
assert.throws(
() => generateReactSprite('.', 'unknown'),
/Unsupported React asset target: unknown/,
)
})
test('generateNextSprite rejects unsupported routers', async () => {
await assert.rejects(
generateNextSprite('.', { router: 'unknown', bundler: 'webpack' }),
/Unsupported Next\.js router: unknown/,
)
})
test('generateNextSprite rejects unsupported bundlers', async () => {
await assert.rejects(
generateNextSprite('.', { router: 'app', bundler: 'unknown' }),
/Unsupported Next\.js bundler: unknown/,
)
})

View File

@@ -1,40 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { generate } from '../dist/index.js'
test('generates a React directory entry-point', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-entry-'))
const inputDir = path.join(root, 'icons')
const outputDir = path.join(root, 'public', 'sprites')
const reactDir = path.join(root, 'src', 'shared', 'ui', 'svg-sprite')
fs.mkdirSync(inputDir, { recursive: true })
fs.writeFileSync(
path.join(inputDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
await generate({
output: outputDir,
publicPath: '/sprites',
preview: false,
react: reactDir,
sprites: [
{
name: 'icons',
input: inputDir,
},
],
})
const indexPath = path.join(reactDir, 'index.ts')
const index = fs.readFileSync(indexPath, 'utf-8')
assert.ok(fs.existsSync(indexPath))
assert.match(index, /export \{ SvgSprite \} from '\.\/svg-sprite'/)
assert.match(index, /SvgSpriteProps/)
})

590
test/react-mode.test.mjs Normal file
View File

@@ -0,0 +1,590 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import ts from 'typescript'
import { generateNextSprite, generateReactSprite } from '../dist/index.js'
const GENERATED_FILES = [
'.svg-sprites.manifest.json',
'react-component.tsx',
'sprite.svg',
'styles.module.css',
'types.ts',
]
function createReactFixture() {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-'))
const rootDir = path.join(temporaryDir, 'src', 'widgets', 'file-manager', 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { description: 'File manager icons' }\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path fill="none" stroke="#123456" d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 2h6l2 2h6v10H1z" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder open.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 3h14v11H1z" /></svg>',
)
return { rootDir, iconsDir }
}
test('generates an isolated React sprite module', async () => {
const { rootDir } = createReactFixture()
const result = await generateReactSprite(rootDir, 'vite')
const generatedDir = path.join(rootDir, 'generated')
assert.equal(result.name, 'file-manager')
assert.equal(result.iconCount, 3)
assert.equal(result.target, 'vite')
assert.equal(result.manifestPath, path.join(rootDir, 'manifest.ts'))
assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES)
const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf-8')
assert.match(gitignore, /@generated by @gromlab\/svg-sprites/)
assert.match(gitignore, /^\/generated\/$/m)
assert.match(gitignore, /^\/index\.ts$/m)
assert.match(gitignore, /^\/manifest\.ts$/m)
const sprite = fs.readFileSync(path.join(generatedDir, 'sprite.svg'), 'utf-8')
assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(sprite, /Генератор: @gromlab\/svg-sprites/)
assert.match(sprite, /<svg[^>]+id="check"/)
assert.match(sprite, /<svg[^>]+id="folder"/)
assert.match(sprite, /<svg[^>]*>\n <style>:root>svg/)
assert.match(sprite, /\n <path[^>]+\/>\n <\/svg>/)
assert.doesNotMatch(sprite, /<symbol/)
assert.ok(sprite.endsWith('\n'))
const unsafeId = sprite.match(/<svg[^>]+id="(icon-[a-f0-9]{16})"/)?.[1]
assert.ok(unsafeId)
const component = fs.readFileSync(
path.join(generatedDir, 'react-component.tsx'),
'utf-8',
)
assert.match(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(component, /export const FileManagerIcon/)
assert.match(component, /export type FileManagerIconStyle = CSSProperties/)
assert.match(component, /Record<`--icon-color-\$\{number\}`, string \| number>/)
assert.match(component, /Omit<SVGAttributes<SVGSVGElement>, 'style'>/)
assert.match(component, /import spriteUrl from '\.\/sprite\.svg\?no-inline'/)
assert.doesNotMatch(component, /SvgSpriteProvider/)
assert.match(component, /iconIds\[icon\]/)
const transpiled = ts.transpileModule(component, {
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
reportDiagnostics: true,
})
assert.deepEqual(transpiled.diagnostics, [])
const metadata = fs.readFileSync(
path.join(generatedDir, 'types.ts'),
'utf-8',
)
assert.match(metadata, /export type FileManagerIconName/)
assert.match(metadata, /File manager icons/)
assert.match(metadata, /"check"/)
assert.match(metadata, /"folder"/)
assert.ok(component.includes(`"folder open": "${unsafeId}"`))
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(spriteManifest, /import spriteUrl from '\.\/generated\/sprite\.svg\?no-inline'/)
assert.match(spriteManifest, /export const spriteManifest/)
assert.match(spriteManifest, /componentName: "FileManagerIcon"/)
assert.match(spriteManifest, /target: "vite"/)
assert.match(spriteManifest, /format: "stack"/)
assert.match(spriteManifest, /iconCount: 3/)
assert.match(spriteManifest, /name: "check"/)
assert.match(spriteManifest, /viewBox: "0 0 16 16"/)
assert.match(spriteManifest, /variable: "--icon-color-1"/)
assert.match(spriteManifest, /fallback: "currentColor"/)
assert.ok(spriteManifest.includes(`id: "${unsafeId}"`))
const transpiledManifest = ts.transpileModule(spriteManifest, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
reportDiagnostics: true,
})
assert.deepEqual(transpiledManifest.diagnostics, [])
const rootIndex = fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8')
assert.match(rootIndex, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(rootIndex, /export \{ FileManagerIcon \} from '\.\/generated\/react-component'/)
assert.match(rootIndex, /FileManagerIconProps, FileManagerIconStyle/)
assert.match(rootIndex, /export \{ fileManagerIconNames \} from '\.\/generated\/types'/)
const manifest = JSON.parse(
fs.readFileSync(path.join(generatedDir, '.svg-sprites.manifest.json'), 'utf-8'),
)
assert.match(manifest.warning, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
})
test('supports Webpack target, explicit name and custom icons directory', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-config-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'assets')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { name: 'documents', inputFolder: './assets', generatedNotice: false }\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'file.svg'),
'<svg viewBox="0 0 16 16"><path d="M2 1h8l4 4v10H2z" /></svg>',
)
const result = await generateReactSprite(rootDir, 'webpack')
const componentPath = path.join(
rootDir,
'generated',
'react-component.tsx',
)
assert.equal(result.name, 'documents')
assert.equal(result.iconCount, 1)
assert.equal(result.target, 'webpack')
const component = fs.readFileSync(componentPath, 'utf-8')
assert.match(component, /@generated by @gromlab\/svg-sprites/)
assert.doesNotMatch(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(component, /DocumentsIcon/)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.doesNotMatch(component, /SvgSpriteProvider/)
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(spriteManifest, /@generated by @gromlab\/svg-sprites/)
assert.match(spriteManifest, /new URL\('\.\/generated\/sprite\.svg', import\.meta\.url\)\.href/)
assert.match(spriteManifest, /target: "webpack"/)
assert.match(spriteManifest, /format: "stack"/)
assert.match(spriteManifest, /componentName: "DocumentsIcon"/)
const manifest = JSON.parse(
fs.readFileSync(path.join(rootDir, 'generated', '.svg-sprites.manifest.json'), 'utf-8'),
)
assert.equal(manifest.warning, undefined)
})
test('merges inputFolder and inputFiles and deduplicates identical paths', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-inputs-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
const sharedDir = path.join(temporaryDir, 'shared')
const localIcon = path.join(iconsDir, 'local.svg')
const sharedIcon = path.join(sharedDir, 'shared.svg')
fs.mkdirSync(iconsDir, { recursive: true })
fs.mkdirSync(sharedDir, { recursive: true })
fs.writeFileSync(localIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
name: 'mixed-inputs',
description: 'Local and shared icons',
inputFiles: [
'./icons/local.svg',
${JSON.stringify(path.relative(rootDir, sharedIcon))},
],
}\n`,
)
const result = await generateReactSprite(rootDir, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
assert.equal(result.name, 'mixed-inputs')
assert.equal(result.iconCount, 2)
assert.match(sprite, /id="local"/)
assert.match(sprite, /id="shared"/)
assert.match(manifest, /description: "Local and shared icons"/)
})
test('supports inputFiles without the default icons directory', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-files-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const sharedIcon = path.join(temporaryDir, 'shared', 'only.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(sharedIcon), { recursive: true })
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }\n`,
)
const result = await generateReactSprite(rootDir, 'webpack')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
assert.equal(result.iconCount, 1)
assert.match(sprite, /id="only"/)
})
test('rejects different input files with the same icon name', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-names-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const firstIcon = path.join(temporaryDir, 'first', 'shared.svg')
const secondIcon = path.join(temporaryDir, 'second', 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(firstIcon), { recursive: true })
fs.mkdirSync(path.dirname(secondIcon), { recursive: true })
fs.writeFileSync(firstIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(secondIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
inputFiles: [
${JSON.stringify(path.relative(rootDir, firstIcon))},
${JSON.stringify(path.relative(rootDir, secondIcon))},
],
}\n`,
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/produce the same SVG id "shared"/,
)
})
test('rejects an explicitly configured missing inputFolder', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-folder-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const sharedIcon = path.join(temporaryDir, 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
inputFolder: './missing',
inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}],
}\n`,
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Input directory does not exist/,
)
})
test('applies React transform options', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-transform-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(
path.join(rootDir, 'svg-sprite.config.ts'),
`export default {
transform: {
removeSize: false,
replaceColors: false,
addTransition: false,
},
}\n`,
)
fs.writeFileSync(
path.join(iconsDir, 'plain.svg'),
'<svg width="24" height="24" viewBox="0 0 24 24"><path fill="#123456" d="M0 0h24v24H0z" /></svg>',
)
const result = await generateReactSprite(rootDir, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
const styles = fs.readFileSync(path.join(result.generatedDir, 'styles.module.css'), 'utf-8')
assert.match(sprite, /width="24"/)
assert.match(sprite, /height="24"/)
assert.match(sprite, /fill="#(?:123456|123)"/)
assert.doesNotMatch(sprite, /--icon-color-/)
assert.doesNotMatch(sprite, /transition:/)
assert.doesNotMatch(styles, /transition-/)
})
test('generates App and Pages Router modules for Webpack and Turbopack', async () => {
const targets = [
{ router: 'app', bundler: 'turbopack' },
{ router: 'app', bundler: 'webpack' },
{ router: 'pages', bundler: 'turbopack' },
{ router: 'pages', bundler: 'webpack' },
]
for (const options of targets) {
const { rootDir } = createReactFixture()
const result = await generateNextSprite(rootDir, options)
const component = fs.readFileSync(
path.join(result.generatedDir, 'react-component.tsx'),
'utf-8',
)
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
const target = `next@${options.router}/${options.bundler}`
assert.equal(result.target, target)
assert.equal(result.router, options.router)
assert.equal(result.bundler, options.bundler)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.doesNotMatch(component, /['"]use client['"]/)
assert.match(manifest, new RegExp(`target: ${JSON.stringify(target)}`))
}
})
test('regeneration removes only files listed in the manifest', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const generatedDir = path.join(rootDir, 'generated')
const manifestPath = path.join(generatedDir, '.svg-sprites.manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const obsoleteRelativePath = 'generated/obsolete.generated.ts'
manifest.files.push('generated\\obsolete.generated.ts')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
fs.writeFileSync(
path.join(rootDir, obsoleteRelativePath),
'/** @generated by @gromlab/svg-sprites. Do not edit. */\n',
)
fs.writeFileSync(path.join(generatedDir, 'notes.md'), 'Keep me\n')
fs.writeFileSync(path.join(rootDir, 'README.md'), 'Keep me too\n')
await generateReactSprite(rootDir, 'vite')
assert.equal(fs.existsSync(path.join(rootDir, obsoleteRelativePath)), false)
assert.equal(fs.readFileSync(path.join(generatedDir, 'notes.md'), 'utf-8'), 'Keep me\n')
assert.equal(fs.readFileSync(path.join(rootDir, 'README.md'), 'utf-8'), 'Keep me too\n')
})
test('does not overwrite a user index.ts without a manifest', async () => {
const { rootDir } = createReactFixture()
const userIndex = 'export const userCode = true\n'
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Refusing to overwrite a user file/,
)
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
})
test('does not overwrite a generated file replaced with user content', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const userIndex = 'export const userCode = true\n'
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Refusing to overwrite a user file/,
)
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
})
test('rejects paths outside the generated area from the manifest', async () => {
const { rootDir } = createReactFixture()
await generateReactSprite(rootDir, 'vite')
const manifestPath = path.join(rootDir, 'generated', '.svg-sprites.manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
manifest.files.push('svg-sprite.config.ts')
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Invalid generated file path/,
)
assert.equal(fs.existsSync(path.join(rootDir, 'svg-sprite.config.ts')), true)
})
test('rejects symbolic links in generated paths', async () => {
const { rootDir } = createReactFixture()
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-'))
fs.symlinkSync(outsideDir, path.join(rootDir, 'generated'), 'dir')
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/Symbolic links are not allowed/,
)
assert.deepEqual(fs.readdirSync(outsideDir), [])
})
test('rejects colliding SVG shape IDs', async () => {
const { rootDir, iconsDir } = createReactFixture()
const collidingId = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}`
fs.writeFileSync(
path.join(iconsDir, `${collidingId}.svg`),
'<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>',
)
await assert.rejects(
generateReactSprite(rootDir, 'vite'),
/produce the same SVG id/,
)
})
test('CLI requires an explicit mode', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(process.execPath, [cliPath], { encoding: 'utf-8' })
assert.equal(result.status, 1)
assert.match(result.stderr, /Missing required argument: --mode/)
})
test('CLI does not expose the future standalone mode', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'standalone', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unknown mode: standalone/)
})
test('CLI requires a target for React mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /React mode requires a target/)
})
test('CLI rejects unsupported React target', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@next', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unsupported React target: next/)
})
test('CLI requires a complete Next.js target', () => {
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'next@app', '.'],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 1)
assert.match(result.stderr, /Unsupported Next\.js target: next@app/)
})
test('CLI runs the React Vite mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@vite', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const component = fs.readFileSync(
path.join(rootDir, 'generated', 'react-component.tsx'),
'utf-8',
)
assert.match(component, /sprite\.svg\?no-inline/)
assert.match(result.stdout, /✓ file-manager · 3 icons · react@vite/)
assert.match(result.stdout, /→ .*generated/)
assert.doesNotMatch(result.stdout, /Generating sprite|\[stack\]/)
})
test('CLI runs the React Webpack mode', () => {
const { rootDir } = createReactFixture()
const cliPath = path.resolve('dist/cli.js')
const result = spawnSync(
process.execPath,
[cliPath, '--mode', 'react@webpack', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const component = fs.readFileSync(
path.join(rootDir, 'generated', 'react-component.tsx'),
'utf-8',
)
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
})
test('CLI runs all Next.js modes', () => {
const modes = [
'next@app/turbopack',
'next@app/webpack',
'next@pages/turbopack',
'next@pages/webpack',
]
const cliPath = path.resolve('dist/cli.js')
for (const mode of modes) {
const { rootDir } = createReactFixture()
const result = spawnSync(
process.execPath,
[cliPath, '--mode', mode, rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
const manifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
assert.match(manifest, new RegExp(`target: ${JSON.stringify(mode)}`))
}
})
test('CLI runs the legacy mode relative to its target directory', () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-legacy-'))
const iconsDir = path.join(rootDir, 'icons')
const cliPath = path.resolve('dist/cli.js')
fs.mkdirSync(iconsDir)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(rootDir, 'svg-sprites.config.ts'),
[
'export default {',
" output: 'sprites',",
' preview: false,',
" sprites: [{ name: 'icons', input: 'icons', format: 'symbol' }],",
'}',
'',
].join('\n'),
)
const result = spawnSync(
process.execPath,
[cliPath, '--mode=legacy', rootDir],
{ encoding: 'utf-8' },
)
assert.equal(result.status, 0, result.stderr)
assert.equal(fs.existsSync(path.join(rootDir, 'sprites', 'icons.sprite.svg')), true)
})

View File

@@ -0,0 +1,212 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createElement, StrictMode } from 'react'
import { JSDOM } from 'jsdom'
import { SpriteViewer } from '../dist/react.js'
const manifest = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: 'controls',
description: 'Control icons',
componentName: 'ControlsIcon',
target: 'vite',
format: 'stack',
iconCount: 1,
spriteUrl: '/assets/controls.svg',
icons: [
{
name: 'check',
id: 'check',
viewBox: '0 0 16 16',
colors: [{ variable: '--icon-color-1', fallback: 'currentColor' }],
},
],
}
test('SpriteViewer opens an icon dialog with code tabs and copy action', async (context) => {
const dom = new JSDOM('<div id="root"></div>', { url: 'https://example.test/' })
const originals = new Map()
const mediaListeners = new Set()
let prefersDark = false
const darkModeMedia = {
get matches() { return prefersDark },
media: '(prefers-color-scheme: dark)',
onchange: null,
addEventListener: (event, listener) => { if (event === 'change') mediaListeners.add(listener) },
removeEventListener: (event, listener) => { if (event === 'change') mediaListeners.delete(listener) },
}
const matchMedia = () => darkModeMedia
dom.window.matchMedia = matchMedia
const globals = {
document: dom.window.document,
window: dom.window,
self: dom.window,
navigator: dom.window.navigator,
Node: dom.window.Node,
Event: dom.window.Event,
HTMLElement: dom.window.HTMLElement,
HTMLDialogElement: dom.window.HTMLDialogElement,
MouseEvent: dom.window.MouseEvent,
matchMedia,
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
IS_REACT_ACT_ENVIRONMENT: true,
}
for (const [name, value] of Object.entries(globals)) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value })
}
const clipboardWrites = []
Object.defineProperty(dom.window.navigator, 'clipboard', {
configurable: true,
value: { writeText: async (value) => { clipboardWrites.push(value) } },
})
dom.window.HTMLDialogElement.prototype.showModal = function showModal() { this.open = true }
dom.window.HTMLDialogElement.prototype.close = function close() { this.open = false }
const reactModule = await import('react')
const { act: domAct } = await import('react-dom/test-utils')
const act = reactModule.act ?? domAct
const { createRoot } = await import('react-dom/client')
const container = dom.window.document.querySelector('#root')
const root = createRoot(container)
context.after(async () => {
await act(async () => root.unmount())
dom.window.close()
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
else delete globalThis[name]
}
})
await act(async () => root.render(createElement(SpriteViewer, { sources: [manifest] })))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, undefined)
const card = container.querySelector('[data-icon-name="check"]')
assert.ok(card)
await act(async () => card.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const dialog = container.querySelector('dialog')
assert.ok(dialog)
assert.equal(dialog.open, true)
assert.match(dialog.textContent, /check/)
assert.match(dialog.textContent, /16 × 16/)
assert.match(dialog.textContent, /ControlsIcon/)
const reactTab = [...dialog.querySelectorAll('[role="tab"]')]
.find((element) => element.textContent === 'React')
await act(async () => reactTab.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'ArrowRight',
})))
assert.equal(dialog.querySelector('[role="tab"][aria-selected="true"]').textContent, 'SVG')
const colorSwatch = dialog.querySelector('[aria-label^="Изменить цвет --icon-color-1"]')
assert.ok(colorSwatch)
assert.equal(dialog.querySelector('.gromlab-sprite-viewer__hex-input'), null)
await act(async () => {
prefersDark = true
for (const listener of mediaListeners) listener({ matches: true, media: darkModeMedia.media })
})
assert.equal(colorSwatch.style.backgroundColor, 'rgb(229, 229, 229)')
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.ok(dialog.querySelector('.react-colorful'))
const colorRow = colorSwatch.closest('.gromlab-sprite-viewer__color-row')
await act(async () => colorRow.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true })))
assert.equal(dialog.querySelector('.react-colorful'), null)
await act(async () => colorRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(dialog.querySelector('.react-colorful'), null)
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const colorPopover = dialog.querySelector('.gromlab-sprite-viewer__color-popover')
await act(async () => colorPopover.querySelector('.react-colorful').dispatchEvent(
new dom.window.Event('pointerdown', { bubbles: true }),
))
assert.ok(dialog.querySelector('.react-colorful'))
await act(async () => colorPopover.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'Escape',
})))
assert.equal(dialog.querySelector('.react-colorful'), null)
assert.ok(container.querySelector('dialog'))
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
const colorInput = dialog.querySelector('.gromlab-sprite-viewer__hex-input')
assert.ok(colorInput)
const setInputValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set
setInputValue.call(colorInput, '#ff0000')
await act(async () => colorInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })))
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /--icon-color-1/)
assert.doesNotMatch(dialog.querySelector('[role="tabpanel"]').textContent, /React\.CSSProperties/)
await act(async () => {
prefersDark = false
for (const listener of mediaListeners) listener({ matches: false, media: darkModeMedia.media })
})
assert.equal(colorSwatch.style.backgroundColor, 'rgb(255, 0, 0)')
const cssTab = [...dialog.querySelectorAll('[role="tab"]')]
.find((element) => element.textContent === 'CSS')
assert.ok(cssTab)
await act(async () => cssTab.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /mask:/)
const copyButton = [...dialog.querySelectorAll('button')]
.find((element) => element.textContent === 'Копировать')
assert.ok(copyButton)
await act(async () => copyButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(clipboardWrites.length, 1)
assert.match(clipboardWrites[0], /background-color/)
await act(async () => dialog.dispatchEvent(new dom.window.Event('cancel', { cancelable: true })))
assert.equal(container.querySelector('dialog'), null)
const themeButton = container.querySelector('[aria-label="Переключить тему"]')
await act(async () => themeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'dark')
await act(async () => root.render(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'light',
})))
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
assert.equal(container.querySelector('[aria-label="Переключить тему"]'), null)
const controlledChanges = []
await act(async () => root.render(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'light',
onColorThemeChange: (theme) => controlledChanges.push(theme),
})))
const controlledThemeButton = container.querySelector('[aria-label="Переключить тему"]')
await act(async () => controlledThemeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.deepEqual(controlledChanges, ['dark'])
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
await act(async () => root.render(createElement(SpriteViewer, {
sources: [{ ...manifest, format: 'symbol' }],
})))
const symbolCard = container.querySelector('[data-icon-name="check"]')
await act(async () => symbolCard.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
assert.deepEqual(
[...container.querySelectorAll('[role="tab"]')].map((element) => element.textContent),
['React', 'SVG'],
)
let loaderCalls = 0
const loader = async () => {
loaderCalls++
return manifest
}
await act(async () => root.render(createElement(
StrictMode,
null,
createElement(SpriteViewer, { sources: [loader] }),
)))
assert.equal(loaderCalls, 1)
assert.match(container.textContent, /controls/)
})

View File

@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import test from 'node:test'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { SpriteViewer } from '../dist/react.js'
const manifest = {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: 'controls',
description: 'Control icons',
componentName: 'ControlsIcon',
target: 'vite',
format: 'symbol',
iconCount: 1,
spriteUrl: '/assets/controls.svg',
icons: [
{
name: 'check',
id: 'check',
viewBox: '0 0 16 16',
colors: [{ variable: '--icon-color-1', fallback: 'currentColor' }],
},
],
}
test('React entry is a Next.js client boundary', () => {
const reactEntry = fs.readFileSync(path.resolve('dist/react.js'), 'utf-8')
const coreEntry = fs.readFileSync(path.resolve('dist/index.js'), 'utf-8')
assert.match(reactEntry, /^['"]use client['"];?/)
assert.doesNotMatch(coreEntry, /from ["']react(?:\/jsx-runtime)?["']/)
})
test('SpriteViewer renders direct sprite manifests', () => {
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
sources: [manifest],
title: 'Icon catalog',
}))
assert.match(markup, /Icon catalog/)
assert.match(markup, /controls/)
assert.match(markup, /Control icons/)
assert.match(markup, /check/)
assert.match(markup, /\/assets\/controls\.svg#check/)
assert.match(markup, /1 спрайт · 1 иконка/)
assert.match(markup, /gromlab-sprite-viewer__icon-wrap/)
assert.match(markup, /Открыть check/)
})
test('SpriteViewer accepts a loader record from import.meta.glob', () => {
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
sources: {
'/src/controls/manifest.ts': async () => ({ default: manifest }),
},
}))
assert.match(markup, /Загрузка спрайтов/)
})
test('SpriteViewer supports an externally controlled color theme', () => {
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
sources: [manifest],
colorTheme: 'dark',
}))
assert.match(markup, /^<section[^>]*data-theme="dark"/)
assert.doesNotMatch(markup, /<button[^>]*aria-label="Переключить тему"/)
})

View File

@@ -1,9 +0,0 @@
/** @generated — this file is auto-generated, do not edit manually. */
export { SvgSprite } from './svg-sprite'
export type {
SvgSpriteProps,
SpriteName,
SpriteMap,
IconName,
DefaultSprite,
} from './svg-sprite'

View File

@@ -1,19 +0,0 @@
/* @generated — this file is auto-generated, do not edit manually. */
.root {
transition-property: fill, stroke, color;
transition-duration: 0.3s;
transition-timing-function: ease;
}
.wrap {
display: inline-flex;
}
.wrap svg {
width: 100%;
height: 100%;
transition-property: fill, stroke, color;
transition-duration: 0.3s;
transition-timing-function: ease;
}

View File

@@ -1,96 +0,0 @@
/**
* SVG-спрайты: типы и React-компонент.
* @generated — this file is auto-generated, do not edit manually.
*/
import type { SVGAttributes, HTMLAttributes } from 'react'
import styles from './svg-sprite.module.css'
/** Имена иконок спрайта «icons». */
export type IconsIconName =
| 'arrow-left'
| 'arrow-right'
| 'check'
| 'discount-shape'
| 'flash'
| 'heart-tick'
| 'heart-tick (1-я копия)'
/** Имена иконок спрайта «logos». */
export type LogosIconName =
| 'github'
| 'twitter'
| 'youtube'
/** Маппинг имени спрайта на тип его иконок. */
export type SpriteMap = {
icons: IconsIconName
logos: LogosIconName
}
/** Имя спрайта. */
export type SpriteName = keyof SpriteMap
/** Спрайт по умолчанию. */
export type DefaultSprite = 'icons'
/** Имя иконки для конкретного спрайта. */
export type IconName<S extends SpriteName = SpriteName> = SpriteMap[S]
const PUBLIC_PATH = ''
const DEFAULT_SPRITE: SpriteName = 'icons'
const SPRITE_FILES: Record<SpriteName, string> = {
icons: 'icons.sprite.svg',
logos: 'logos.sprite.svg',
}
type IconBaseProps<S extends SpriteName> = {
/** Имя иконки. */
icon: IconName<S>
/** Имя спрайта. По умолчанию: первый из конфига. */
sprite?: S
}
type IconSvgProps<S extends SpriteName> = IconBaseProps<S> & {
wrapped?: false
} & SVGAttributes<SVGSVGElement>
type IconWrappedProps<S extends SpriteName> = IconBaseProps<S> & {
wrapped: true
} & HTMLAttributes<HTMLSpanElement>
export type SvgSpriteProps<S extends SpriteName = DefaultSprite> =
| IconSvgProps<S>
| IconWrappedProps<S>
/**
* Иконка из SVG-спрайта.
*
* Используется для:
* - отображения иконки через `<use href="...">`
* - обёртки в `<span>` через проп `wrapped`
*
* Спрайт по умолчанию: «icons».
*/
export const SvgSprite = <S extends SpriteName = DefaultSprite>(props: SvgSpriteProps<S>) => {
const { icon, sprite = DEFAULT_SPRITE as S, wrapped, className, ...rest } = props
const href = `${PUBLIC_PATH}/${SPRITE_FILES[sprite]}#${icon}`
if (wrapped) {
const { ...htmlAttr } = rest as HTMLAttributes<HTMLSpanElement>
return (
<span {...htmlAttr} className={[styles.wrap, className].filter(Boolean).join(' ')}>
<svg>
<use href={href} />
</svg>
</span>
)
}
const { ...svgAttr } = rest as SVGAttributes<SVGSVGElement>
return (
<svg {...svgAttr} className={[styles.root, className].filter(Boolean).join(' ')}>
<use href={href} />
</svg>
)
}

View File

@@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
@@ -12,9 +12,9 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"isolatedModules": true
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]

View File

@@ -4,14 +4,12 @@ export default defineConfig({
entry: {
index: 'src/index.ts',
cli: 'src/cli.ts',
react: 'src/react.ts',
},
format: 'esm',
dts: true,
clean: true,
sourcemap: true,
target: 'node18',
banner: ({ format }) => {
// cli.js needs a shebang for npx/bin usage
return {}
},
noExternal: ['react-colorful'],
})