mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-21 20:30:16 +03:00
feat: выпустить версию 1.0.0
- добавлены отдельные режимы генерации для React, Next.js и legacy - добавлены SpriteViewer, типизированные компоненты и безопасный codegen - перенесена сборка AI-скила и обновлена документация - добавлены migration guide, лицензии и проверки публикации
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal 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
555
README.md
@@ -2,213 +2,394 @@
|
||||
|
||||
 
|
||||
|
||||
Генерация SVG-спрайтов из папок с иконками. TypeScript-типизация, SVG-трансформации, React-компонент и HTML-превью из коробки.
|
||||
CLI для генерации SVG-спрайтов и типизированных компонентов иконок для React и Next.js.
|
||||
|
||||

|
||||
|
||||
**Чем отличается от аналогов:**
|
||||
## Навигация
|
||||
|
||||
- Генерирует готовый типизированный 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
75
THIRD_PARTY_NOTICES.md
Normal 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
102
docs/ru/legacy.md
Normal 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
96
docs/ru/migration-1.md
Normal 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
102
docs/ru/next-app.md
Normal 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 13–15 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 13–15 с Webpack используйте `npx next build` без флага.
|
||||
|
||||
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.
|
||||
96
docs/ru/next-pages.md
Normal file
96
docs/ru/next-pages.md
Normal 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 12–15 с Webpack используйте `npx next build` без флага.
|
||||
|
||||
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.
|
||||
203
docs/ru/programmatic-api.md
Normal file
203
docs/ru/programmatic-api.md
Normal 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
116
docs/ru/react-vite.md
Normal 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
118
docs/ru/react-webpack.md
Normal 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
2570
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
451
preview/package-lock.json
generated
451
preview/package-lock.json
generated
@@ -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
12
skills/README.md
Normal 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` без изменения файлов проверяет его содержимое и синхронность с документацией.
|
||||
64
skills/artifacts/svg-sprites/SKILL.md
Normal file
64
skills/artifacts/svg-sprites/SKILL.md
Normal 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)
|
||||
395
skills/artifacts/svg-sprites/references/README.md
Normal file
395
skills/artifacts/svg-sprites/references/README.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# @gromlab/svg-sprites
|
||||
|
||||
 
|
||||
|
||||
CLI для генерации SVG-спрайтов и типизированных компонентов иконок для React и Next.js.
|
||||
|
||||

|
||||
|
||||
## Навигация
|
||||
|
||||
- [Возможности](#возможности)
|
||||
- [Таблица поддержки](#таблица-поддержки)
|
||||
- [Требования](#требования)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [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
|
||||
102
skills/artifacts/svg-sprites/references/docs/ru/legacy.md
Normal file
102
skills/artifacts/svg-sprites/references/docs/ru/legacy.md
Normal 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).
|
||||
@@ -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
skills/artifacts/svg-sprites/references/docs/ru/next-app.md
Normal file
102
skills/artifacts/svg-sprites/references/docs/ru/next-app.md
Normal 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 13–15 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 13–15 с Webpack используйте `npx next build` без флага.
|
||||
|
||||
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.
|
||||
@@ -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 12–15 с Webpack используйте `npx next build` без флага.
|
||||
|
||||
Команда Next.js и mode key генератора должны указывать один и тот же сборщик.
|
||||
@@ -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
skills/artifacts/svg-sprites/references/docs/ru/react-vite.md
Normal file
116
skills/artifacts/svg-sprites/references/docs/ru/react-vite.md
Normal 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
skills/artifacts/svg-sprites/references/docs/ru/react-webpack.md
Normal file
118
skills/artifacts/svg-sprites/references/docs/ru/react-webpack.md
Normal 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).
|
||||
194
skills/svg-sprites/build.mjs
Normal file
194
skills/svg-sprites/build.mjs
Normal 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)}`)
|
||||
}
|
||||
16
skills/svg-sprites/skill.config.mjs
Normal file
16
skills/svg-sprites/skill.config.mjs
Normal 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' },
|
||||
],
|
||||
}
|
||||
57
skills/svg-sprites/src/SKILL.md
Normal file
57
skills/svg-sprites/src/SKILL.md
Normal 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)
|
||||
40
src/cli.ts
40
src/cli.ts
@@ -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
119
src/cli/parse-args.ts
Normal 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
26
src/cli/types.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
52
src/index.ts
52
src/index.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
52
src/modes/legacy/generate.ts
Normal file
52
src/modes/legacy/generate.ts
Normal 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
|
||||
}
|
||||
2
src/modes/legacy/index.ts
Normal file
2
src/modes/legacy/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { loadLegacyConfig, validateLegacyConfig } from './config.js'
|
||||
export { generateLegacy } from './generate.js'
|
||||
32
src/modes/next/generate.ts
Normal file
32
src/modes/next/generate.ts
Normal 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
6
src/modes/next/index.ts
Normal 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
22
src/modes/next/types.ts
Normal 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
288
src/modes/react/codegen.ts
Normal 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
108
src/modes/react/config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
18
src/modes/react/generate.ts
Normal file
18
src/modes/react/generate.ts
Normal 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
7
src/modes/react/index.ts
Normal 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
120
src/modes/react/manifest.ts
Normal 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')
|
||||
}
|
||||
78
src/modes/react/module-generator.ts
Normal file
78
src/modes/react/module-generator.ts
Normal 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
25
src/modes/react/naming.ts
Normal 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
42
src/modes/react/types.ts
Normal 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
172
src/modes/react/writer.ts
Normal 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`)
|
||||
}
|
||||
@@ -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
13
src/react.ts
Normal 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'
|
||||
196
src/react/sprite-viewer-code.ts
Normal file
196
src/react/sprite-viewer-code.ts
Normal 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> = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
}
|
||||
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> = { '&': '&', '<': '<', '>': '>', '"': '"' }
|
||||
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('')
|
||||
}
|
||||
317
src/react/sprite-viewer-dialog.tsx
Normal file
317
src/react/sprite-viewer-dialog.tsx
Normal 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}
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
)
|
||||
}
|
||||
332
src/react/sprite-viewer-styles.ts
Normal file
332
src/react/sprite-viewer-styles.ts
Normal 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
301
src/react/sprite-viewer.tsx
Normal 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}
|
||||
>
|
||||
◑
|
||||
</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
40
src/react/types.ts
Normal 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>>
|
||||
@@ -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
14
src/shape-id.ts
Normal 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
33
src/targets/index.ts
Normal 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
45
src/targets/types.ts
Normal 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
10
src/targets/vite.ts
Normal 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
12
src/targets/webpack.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
27
src/types.ts
27
src/types.ts
@@ -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
|
||||
/** Количество иконок в спрайте. */
|
||||
|
||||
@@ -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
117
test/asset-targets.test.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { build as viteBuild } from 'vite'
|
||||
import webpack from 'webpack'
|
||||
|
||||
const TINY_SPRITE = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">',
|
||||
'<style>:root>svg{display:none}:root>svg:target{display:block}</style>',
|
||||
'<svg id="check" viewBox="0 0 16 16">',
|
||||
'<path d="M1 8l4 4L15 2"/>',
|
||||
'</svg>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
|
||||
function getOutputFiles(directory, extension) {
|
||||
const files = []
|
||||
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...getOutputFiles(entryPath, extension).map((filePath) => path.join(entry.name, filePath)))
|
||||
} else if (entry.name.endsWith(extension)) {
|
||||
files.push(entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
test('Vite target keeps a tiny sprite as a separate asset', async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-vite-target-'))
|
||||
const outputDir = path.join(rootDir, 'dist')
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'index.html'),
|
||||
'<div id="root"></div><script type="module" src="/main.js"></script>',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'main.js'),
|
||||
[
|
||||
"import spriteUrl from './sprite.svg?no-inline'",
|
||||
"document.querySelector('#root').dataset.spriteUrl = spriteUrl",
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
fs.writeFileSync(path.join(rootDir, 'sprite.svg'), TINY_SPRITE)
|
||||
|
||||
await viteBuild({
|
||||
root: rootDir,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
outDir: outputDir,
|
||||
},
|
||||
})
|
||||
|
||||
const svgFiles = getOutputFiles(outputDir, '.svg')
|
||||
const jsFiles = getOutputFiles(outputDir, '.js')
|
||||
const javascript = jsFiles
|
||||
.map((filePath) => fs.readFileSync(path.join(outputDir, filePath), 'utf-8'))
|
||||
.join('\n')
|
||||
|
||||
assert.equal(svgFiles.length, 1)
|
||||
assert.match(svgFiles[0], /sprite-[A-Za-z0-9_-]+\.svg$/)
|
||||
assert.doesNotMatch(javascript, /data:image\/svg\+xml/)
|
||||
})
|
||||
|
||||
test('Webpack target emits new URL sprite through Asset Modules', async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-webpack-target-'))
|
||||
const outputDir = path.join(rootDir, 'dist')
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'index.js'),
|
||||
[
|
||||
"const spriteUrl = new URL('./sprite.svg', import.meta.url).href",
|
||||
'globalThis.__SPRITE_URL__ = spriteUrl',
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
fs.writeFileSync(path.join(rootDir, 'sprite.svg'), TINY_SPRITE)
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
webpack({
|
||||
context: rootDir,
|
||||
entry: './index.js',
|
||||
mode: 'production',
|
||||
output: {
|
||||
assetModuleFilename: 'assets/[name]-[contenthash][ext]',
|
||||
filename: 'bundle.js',
|
||||
path: outputDir,
|
||||
},
|
||||
}, (error, stats) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!stats || stats.hasErrors()) {
|
||||
const errors = stats?.toJson({ all: false, errors: true }).errors ?? []
|
||||
reject(new Error(errors.map((item) => item.message).join('\n')))
|
||||
return
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const svgFiles = getOutputFiles(outputDir, '.svg')
|
||||
const javascript = fs.readFileSync(path.join(outputDir, 'bundle.js'), 'utf-8')
|
||||
|
||||
assert.equal(svgFiles.length, 1)
|
||||
assert.match(svgFiles[0], /sprite-[a-f0-9]+\.svg$/)
|
||||
assert.doesNotMatch(javascript, /data:image\/svg\+xml/)
|
||||
})
|
||||
204
test/next-targets.test.mjs
Normal file
204
test/next-targets.test.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
|
||||
import { generateNextSprite } from '../dist/index.js'
|
||||
|
||||
const NEXT_BIN = path.resolve('node_modules/next/dist/bin/next')
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(filePath, content)
|
||||
}
|
||||
|
||||
function createNextFixture() {
|
||||
const rootDir = fs.mkdtempSync(path.join(path.resolve('test'), '.next-fixture-'))
|
||||
|
||||
writeFile(
|
||||
path.join(rootDir, 'package.json'),
|
||||
JSON.stringify({ name: 'svg-sprites-next-fixture', private: true }, null, 2),
|
||||
)
|
||||
writeFile(
|
||||
path.join(rootDir, 'tsconfig.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
jsx: 'preserve',
|
||||
module: 'esnext',
|
||||
moduleResolution: 'bundler',
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
},
|
||||
}, null, 2),
|
||||
)
|
||||
writeFile(
|
||||
path.join(rootDir, 'next-env.d.ts'),
|
||||
'/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
|
||||
)
|
||||
writeFile(
|
||||
path.join(rootDir, 'app', 'layout.tsx'),
|
||||
`export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <html><body>{children}</body></html>
|
||||
}
|
||||
`,
|
||||
)
|
||||
writeFile(
|
||||
path.join(rootDir, 'app', 'page.tsx'),
|
||||
`import { AppIcon } from '../sprites/app'
|
||||
|
||||
export default function Page() {
|
||||
return <main><AppIcon icon="check" aria-label="App icon" style={{ '--icon-color-1': '#008000' }} /></main>
|
||||
}
|
||||
`,
|
||||
)
|
||||
writeFile(
|
||||
path.join(rootDir, 'pages', 'legacy.tsx'),
|
||||
`import { PagesIcon } from '../sprites/pages'
|
||||
|
||||
export default function LegacyPage() {
|
||||
return <main><PagesIcon icon="check" aria-label="Pages icon" style={{ '--icon-color-1': '#008000' }} /></main>
|
||||
}
|
||||
|
||||
export function getServerSideProps() {
|
||||
return { props: {} }
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
for (const name of ['app', 'pages']) {
|
||||
const spriteRoot = path.join(rootDir, 'sprites', name)
|
||||
writeFile(
|
||||
path.join(spriteRoot, 'svg-sprite.config.ts'),
|
||||
`export default { name: '${name}', generatedNotice: false }\n`,
|
||||
)
|
||||
writeFile(
|
||||
path.join(spriteRoot, 'icons', 'check.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
|
||||
)
|
||||
}
|
||||
|
||||
return rootDir
|
||||
}
|
||||
|
||||
function runNext(rootDir, args) {
|
||||
const result = spawnSync(process.execPath, [NEXT_BIN, ...args], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 120_000,
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
[result.stdout, result.stderr, result.error?.message].filter(Boolean).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
const server = net.createServer()
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
const { port } = address
|
||||
server.close()
|
||||
await once(server, 'close')
|
||||
return port
|
||||
}
|
||||
|
||||
async function startNext(rootDir) {
|
||||
const port = await getFreePort()
|
||||
const child = spawn(process.execPath, [NEXT_BIN, 'start', '--hostname', '127.0.0.1', '--port', String(port)], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let output = ''
|
||||
child.stdout.on('data', (chunk) => { output += chunk })
|
||||
child.stderr.on('data', (chunk) => { output += chunk })
|
||||
const origin = `http://127.0.0.1:${port}`
|
||||
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Next.js exited before startup.\n${output}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(origin)
|
||||
if (response.ok) return { child, origin }
|
||||
} catch {
|
||||
// Server is still starting.
|
||||
}
|
||||
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
child.kill('SIGKILL')
|
||||
throw new Error(`Next.js did not start in time.\n${output}`)
|
||||
}
|
||||
|
||||
async function stopNext(child) {
|
||||
if (child.exitCode !== null) return
|
||||
child.kill('SIGTERM')
|
||||
|
||||
await Promise.race([
|
||||
once(child, 'exit'),
|
||||
delay(5_000).then(() => child.kill('SIGKILL')),
|
||||
])
|
||||
}
|
||||
|
||||
async function assertRenderedSprite(origin, route) {
|
||||
const response = await fetch(`${origin}${route}`)
|
||||
assert.equal(response.status, 200)
|
||||
const html = await response.text()
|
||||
const href = html.match(/href="([^"]+\.svg)#check"/)?.[1]
|
||||
|
||||
assert.ok(href, `Sprite href not found in ${route}: ${html}`)
|
||||
assert.doesNotMatch(href, /^(?:data|file|blob):/)
|
||||
|
||||
const spriteResponse = await fetch(new URL(href, origin))
|
||||
assert.equal(spriteResponse.status, 200)
|
||||
assert.match(spriteResponse.headers.get('content-type') ?? '', /image\/svg\+xml/)
|
||||
assert.match(await spriteResponse.text(), /id="check"/)
|
||||
}
|
||||
|
||||
test('Next 16.2 renders App and Pages sprites with Turbopack and Webpack', { timeout: 300_000 }, async () => {
|
||||
const rootDir = createNextFixture()
|
||||
|
||||
try {
|
||||
for (const bundler of ['turbopack', 'webpack']) {
|
||||
await generateNextSprite(path.join(rootDir, 'sprites', 'app'), {
|
||||
router: 'app',
|
||||
bundler,
|
||||
})
|
||||
await generateNextSprite(path.join(rootDir, 'sprites', 'pages'), {
|
||||
router: 'pages',
|
||||
bundler,
|
||||
})
|
||||
|
||||
runNext(rootDir, ['build', `--${bundler}`])
|
||||
const { child, origin } = await startNext(rootDir)
|
||||
|
||||
try {
|
||||
await assertRenderedSprite(origin, '/')
|
||||
await assertRenderedSprite(origin, '/legacy')
|
||||
} finally {
|
||||
await stopNext(child)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
28
test/public-api.test.mjs
Normal file
28
test/public-api.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
generateNextSprite,
|
||||
generateReactSprite,
|
||||
} from '../dist/index.js'
|
||||
|
||||
test('generateReactSprite rejects unsupported targets', () => {
|
||||
assert.throws(
|
||||
() => generateReactSprite('.', 'unknown'),
|
||||
/Unsupported React asset target: unknown/,
|
||||
)
|
||||
})
|
||||
|
||||
test('generateNextSprite rejects unsupported routers', async () => {
|
||||
await assert.rejects(
|
||||
generateNextSprite('.', { router: 'unknown', bundler: 'webpack' }),
|
||||
/Unsupported Next\.js router: unknown/,
|
||||
)
|
||||
})
|
||||
|
||||
test('generateNextSprite rejects unsupported bundlers', async () => {
|
||||
await assert.rejects(
|
||||
generateNextSprite('.', { router: 'app', bundler: 'unknown' }),
|
||||
/Unsupported Next\.js bundler: unknown/,
|
||||
)
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { generate } from '../dist/index.js'
|
||||
|
||||
test('generates a React directory entry-point', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-entry-'))
|
||||
const inputDir = path.join(root, 'icons')
|
||||
const outputDir = path.join(root, 'public', 'sprites')
|
||||
const reactDir = path.join(root, 'src', 'shared', 'ui', 'svg-sprite')
|
||||
|
||||
fs.mkdirSync(inputDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(inputDir, 'check.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
|
||||
)
|
||||
|
||||
await generate({
|
||||
output: outputDir,
|
||||
publicPath: '/sprites',
|
||||
preview: false,
|
||||
react: reactDir,
|
||||
sprites: [
|
||||
{
|
||||
name: 'icons',
|
||||
input: inputDir,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const indexPath = path.join(reactDir, 'index.ts')
|
||||
const index = fs.readFileSync(indexPath, 'utf-8')
|
||||
|
||||
assert.ok(fs.existsSync(indexPath))
|
||||
assert.match(index, /export \{ SvgSprite \} from '\.\/svg-sprite'/)
|
||||
assert.match(index, /SvgSpriteProps/)
|
||||
})
|
||||
590
test/react-mode.test.mjs
Normal file
590
test/react-mode.test.mjs
Normal file
@@ -0,0 +1,590 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
import { generateNextSprite, generateReactSprite } from '../dist/index.js'
|
||||
|
||||
const GENERATED_FILES = [
|
||||
'.svg-sprites.manifest.json',
|
||||
'react-component.tsx',
|
||||
'sprite.svg',
|
||||
'styles.module.css',
|
||||
'types.ts',
|
||||
]
|
||||
|
||||
function createReactFixture() {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-'))
|
||||
const rootDir = path.join(temporaryDir, 'src', 'widgets', 'file-manager', 'svg-sprite')
|
||||
const iconsDir = path.join(rootDir, 'icons')
|
||||
|
||||
fs.mkdirSync(iconsDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default { description: 'File manager icons' }\n`,
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, 'check.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path fill="none" stroke="#123456" d="M1 8l4 4L15 2" /></svg>',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, 'folder.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 2h6l2 2h6v10H1z" /></svg>',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, 'folder open.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 3h14v11H1z" /></svg>',
|
||||
)
|
||||
|
||||
return { rootDir, iconsDir }
|
||||
}
|
||||
|
||||
test('generates an isolated React sprite module', async () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
const result = await generateReactSprite(rootDir, 'vite')
|
||||
const generatedDir = path.join(rootDir, 'generated')
|
||||
|
||||
assert.equal(result.name, 'file-manager')
|
||||
assert.equal(result.iconCount, 3)
|
||||
assert.equal(result.target, 'vite')
|
||||
assert.equal(result.manifestPath, path.join(rootDir, 'manifest.ts'))
|
||||
assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES)
|
||||
|
||||
const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf-8')
|
||||
assert.match(gitignore, /@generated by @gromlab\/svg-sprites/)
|
||||
assert.match(gitignore, /^\/generated\/$/m)
|
||||
assert.match(gitignore, /^\/index\.ts$/m)
|
||||
assert.match(gitignore, /^\/manifest\.ts$/m)
|
||||
|
||||
const sprite = fs.readFileSync(path.join(generatedDir, 'sprite.svg'), 'utf-8')
|
||||
assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
|
||||
assert.match(sprite, /Генератор: @gromlab\/svg-sprites/)
|
||||
assert.match(sprite, /<svg[^>]+id="check"/)
|
||||
assert.match(sprite, /<svg[^>]+id="folder"/)
|
||||
assert.match(sprite, /<svg[^>]*>\n <style>:root>svg/)
|
||||
assert.match(sprite, /\n <path[^>]+\/>\n <\/svg>/)
|
||||
assert.doesNotMatch(sprite, /<symbol/)
|
||||
assert.ok(sprite.endsWith('\n'))
|
||||
const unsafeId = sprite.match(/<svg[^>]+id="(icon-[a-f0-9]{16})"/)?.[1]
|
||||
assert.ok(unsafeId)
|
||||
|
||||
const component = fs.readFileSync(
|
||||
path.join(generatedDir, 'react-component.tsx'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.match(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
|
||||
assert.match(component, /export const FileManagerIcon/)
|
||||
assert.match(component, /export type FileManagerIconStyle = CSSProperties/)
|
||||
assert.match(component, /Record<`--icon-color-\$\{number\}`, string \| number>/)
|
||||
assert.match(component, /Omit<SVGAttributes<SVGSVGElement>, 'style'>/)
|
||||
assert.match(component, /import spriteUrl from '\.\/sprite\.svg\?no-inline'/)
|
||||
assert.doesNotMatch(component, /SvgSpriteProvider/)
|
||||
assert.match(component, /iconIds\[icon\]/)
|
||||
|
||||
const transpiled = ts.transpileModule(component, {
|
||||
compilerOptions: {
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
reportDiagnostics: true,
|
||||
})
|
||||
assert.deepEqual(transpiled.diagnostics, [])
|
||||
|
||||
const metadata = fs.readFileSync(
|
||||
path.join(generatedDir, 'types.ts'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.match(metadata, /export type FileManagerIconName/)
|
||||
assert.match(metadata, /File manager icons/)
|
||||
assert.match(metadata, /"check"/)
|
||||
assert.match(metadata, /"folder"/)
|
||||
assert.ok(component.includes(`"folder open": "${unsafeId}"`))
|
||||
|
||||
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
|
||||
assert.match(spriteManifest, /import spriteUrl from '\.\/generated\/sprite\.svg\?no-inline'/)
|
||||
assert.match(spriteManifest, /export const spriteManifest/)
|
||||
assert.match(spriteManifest, /componentName: "FileManagerIcon"/)
|
||||
assert.match(spriteManifest, /target: "vite"/)
|
||||
assert.match(spriteManifest, /format: "stack"/)
|
||||
assert.match(spriteManifest, /iconCount: 3/)
|
||||
assert.match(spriteManifest, /name: "check"/)
|
||||
assert.match(spriteManifest, /viewBox: "0 0 16 16"/)
|
||||
assert.match(spriteManifest, /variable: "--icon-color-1"/)
|
||||
assert.match(spriteManifest, /fallback: "currentColor"/)
|
||||
assert.ok(spriteManifest.includes(`id: "${unsafeId}"`))
|
||||
|
||||
const transpiledManifest = ts.transpileModule(spriteManifest, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
reportDiagnostics: true,
|
||||
})
|
||||
assert.deepEqual(transpiledManifest.diagnostics, [])
|
||||
|
||||
const rootIndex = fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8')
|
||||
assert.match(rootIndex, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
|
||||
assert.match(rootIndex, /export \{ FileManagerIcon \} from '\.\/generated\/react-component'/)
|
||||
assert.match(rootIndex, /FileManagerIconProps, FileManagerIconStyle/)
|
||||
assert.match(rootIndex, /export \{ fileManagerIconNames \} from '\.\/generated\/types'/)
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(generatedDir, '.svg-sprites.manifest.json'), 'utf-8'),
|
||||
)
|
||||
assert.match(manifest.warning, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
|
||||
})
|
||||
|
||||
test('supports Webpack target, explicit name and custom icons directory', async () => {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-config-'))
|
||||
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||||
const iconsDir = path.join(rootDir, 'assets')
|
||||
|
||||
fs.mkdirSync(iconsDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default { name: 'documents', inputFolder: './assets', generatedNotice: false }\n`,
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, 'file.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path d="M2 1h8l4 4v10H2z" /></svg>',
|
||||
)
|
||||
|
||||
const result = await generateReactSprite(rootDir, 'webpack')
|
||||
const componentPath = path.join(
|
||||
rootDir,
|
||||
'generated',
|
||||
'react-component.tsx',
|
||||
)
|
||||
|
||||
assert.equal(result.name, 'documents')
|
||||
assert.equal(result.iconCount, 1)
|
||||
assert.equal(result.target, 'webpack')
|
||||
const component = fs.readFileSync(componentPath, 'utf-8')
|
||||
assert.match(component, /@generated by @gromlab\/svg-sprites/)
|
||||
assert.doesNotMatch(component, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
|
||||
assert.match(component, /DocumentsIcon/)
|
||||
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
|
||||
assert.doesNotMatch(component, /SvgSpriteProvider/)
|
||||
|
||||
const spriteManifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
|
||||
assert.match(spriteManifest, /@generated by @gromlab\/svg-sprites/)
|
||||
assert.match(spriteManifest, /new URL\('\.\/generated\/sprite\.svg', import\.meta\.url\)\.href/)
|
||||
assert.match(spriteManifest, /target: "webpack"/)
|
||||
assert.match(spriteManifest, /format: "stack"/)
|
||||
assert.match(spriteManifest, /componentName: "DocumentsIcon"/)
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, 'generated', '.svg-sprites.manifest.json'), 'utf-8'),
|
||||
)
|
||||
assert.equal(manifest.warning, undefined)
|
||||
})
|
||||
|
||||
test('merges inputFolder and inputFiles and deduplicates identical paths', async () => {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-inputs-'))
|
||||
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||||
const iconsDir = path.join(rootDir, 'icons')
|
||||
const sharedDir = path.join(temporaryDir, 'shared')
|
||||
const localIcon = path.join(iconsDir, 'local.svg')
|
||||
const sharedIcon = path.join(sharedDir, 'shared.svg')
|
||||
|
||||
fs.mkdirSync(iconsDir, { recursive: true })
|
||||
fs.mkdirSync(sharedDir, { recursive: true })
|
||||
fs.writeFileSync(localIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
|
||||
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default {
|
||||
name: 'mixed-inputs',
|
||||
description: 'Local and shared icons',
|
||||
inputFiles: [
|
||||
'./icons/local.svg',
|
||||
${JSON.stringify(path.relative(rootDir, sharedIcon))},
|
||||
],
|
||||
}\n`,
|
||||
)
|
||||
|
||||
const result = await generateReactSprite(rootDir, 'vite')
|
||||
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
|
||||
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
|
||||
|
||||
assert.equal(result.name, 'mixed-inputs')
|
||||
assert.equal(result.iconCount, 2)
|
||||
assert.match(sprite, /id="local"/)
|
||||
assert.match(sprite, /id="shared"/)
|
||||
assert.match(manifest, /description: "Local and shared icons"/)
|
||||
})
|
||||
|
||||
test('supports inputFiles without the default icons directory', async () => {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-files-'))
|
||||
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||||
const sharedIcon = path.join(temporaryDir, 'shared', 'only.svg')
|
||||
|
||||
fs.mkdirSync(rootDir, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(sharedIcon), { recursive: true })
|
||||
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }\n`,
|
||||
)
|
||||
|
||||
const result = await generateReactSprite(rootDir, 'webpack')
|
||||
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
|
||||
|
||||
assert.equal(result.iconCount, 1)
|
||||
assert.match(sprite, /id="only"/)
|
||||
})
|
||||
|
||||
test('rejects different input files with the same icon name', async () => {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-names-'))
|
||||
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||||
const firstIcon = path.join(temporaryDir, 'first', 'shared.svg')
|
||||
const secondIcon = path.join(temporaryDir, 'second', 'shared.svg')
|
||||
|
||||
fs.mkdirSync(rootDir, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(firstIcon), { recursive: true })
|
||||
fs.mkdirSync(path.dirname(secondIcon), { recursive: true })
|
||||
fs.writeFileSync(firstIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
|
||||
fs.writeFileSync(secondIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default {
|
||||
inputFiles: [
|
||||
${JSON.stringify(path.relative(rootDir, firstIcon))},
|
||||
${JSON.stringify(path.relative(rootDir, secondIcon))},
|
||||
],
|
||||
}\n`,
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/produce the same SVG id "shared"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects an explicitly configured missing inputFolder', async () => {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-folder-'))
|
||||
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||||
const sharedIcon = path.join(temporaryDir, 'shared.svg')
|
||||
|
||||
fs.mkdirSync(rootDir, { recursive: true })
|
||||
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default {
|
||||
inputFolder: './missing',
|
||||
inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}],
|
||||
}\n`,
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/Input directory does not exist/,
|
||||
)
|
||||
})
|
||||
|
||||
test('applies React transform options', async () => {
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-transform-'))
|
||||
const rootDir = path.join(temporaryDir, 'svg-sprite')
|
||||
const iconsDir = path.join(rootDir, 'icons')
|
||||
|
||||
fs.mkdirSync(iconsDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprite.config.ts'),
|
||||
`export default {
|
||||
transform: {
|
||||
removeSize: false,
|
||||
replaceColors: false,
|
||||
addTransition: false,
|
||||
},
|
||||
}\n`,
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, 'plain.svg'),
|
||||
'<svg width="24" height="24" viewBox="0 0 24 24"><path fill="#123456" d="M0 0h24v24H0z" /></svg>',
|
||||
)
|
||||
|
||||
const result = await generateReactSprite(rootDir, 'vite')
|
||||
const sprite = fs.readFileSync(result.spritePath, 'utf-8')
|
||||
const styles = fs.readFileSync(path.join(result.generatedDir, 'styles.module.css'), 'utf-8')
|
||||
|
||||
assert.match(sprite, /width="24"/)
|
||||
assert.match(sprite, /height="24"/)
|
||||
assert.match(sprite, /fill="#(?:123456|123)"/)
|
||||
assert.doesNotMatch(sprite, /--icon-color-/)
|
||||
assert.doesNotMatch(sprite, /transition:/)
|
||||
assert.doesNotMatch(styles, /transition-/)
|
||||
})
|
||||
|
||||
test('generates App and Pages Router modules for Webpack and Turbopack', async () => {
|
||||
const targets = [
|
||||
{ router: 'app', bundler: 'turbopack' },
|
||||
{ router: 'app', bundler: 'webpack' },
|
||||
{ router: 'pages', bundler: 'turbopack' },
|
||||
{ router: 'pages', bundler: 'webpack' },
|
||||
]
|
||||
|
||||
for (const options of targets) {
|
||||
const { rootDir } = createReactFixture()
|
||||
const result = await generateNextSprite(rootDir, options)
|
||||
const component = fs.readFileSync(
|
||||
path.join(result.generatedDir, 'react-component.tsx'),
|
||||
'utf-8',
|
||||
)
|
||||
const manifest = fs.readFileSync(result.manifestPath, 'utf-8')
|
||||
const target = `next@${options.router}/${options.bundler}`
|
||||
|
||||
assert.equal(result.target, target)
|
||||
assert.equal(result.router, options.router)
|
||||
assert.equal(result.bundler, options.bundler)
|
||||
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
|
||||
assert.doesNotMatch(component, /['"]use client['"]/)
|
||||
assert.match(manifest, new RegExp(`target: ${JSON.stringify(target)}`))
|
||||
}
|
||||
})
|
||||
|
||||
test('regeneration removes only files listed in the manifest', async () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
await generateReactSprite(rootDir, 'vite')
|
||||
|
||||
const generatedDir = path.join(rootDir, 'generated')
|
||||
const manifestPath = path.join(generatedDir, '.svg-sprites.manifest.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
const obsoleteRelativePath = 'generated/obsolete.generated.ts'
|
||||
|
||||
manifest.files.push('generated\\obsolete.generated.ts')
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, obsoleteRelativePath),
|
||||
'/** @generated by @gromlab/svg-sprites. Do not edit. */\n',
|
||||
)
|
||||
fs.writeFileSync(path.join(generatedDir, 'notes.md'), 'Keep me\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'README.md'), 'Keep me too\n')
|
||||
|
||||
await generateReactSprite(rootDir, 'vite')
|
||||
|
||||
assert.equal(fs.existsSync(path.join(rootDir, obsoleteRelativePath)), false)
|
||||
assert.equal(fs.readFileSync(path.join(generatedDir, 'notes.md'), 'utf-8'), 'Keep me\n')
|
||||
assert.equal(fs.readFileSync(path.join(rootDir, 'README.md'), 'utf-8'), 'Keep me too\n')
|
||||
})
|
||||
|
||||
test('does not overwrite a user index.ts without a manifest', async () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
const userIndex = 'export const userCode = true\n'
|
||||
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/Refusing to overwrite a user file/,
|
||||
)
|
||||
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
|
||||
})
|
||||
|
||||
test('does not overwrite a generated file replaced with user content', async () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
await generateReactSprite(rootDir, 'vite')
|
||||
|
||||
const userIndex = 'export const userCode = true\n'
|
||||
fs.writeFileSync(path.join(rootDir, 'index.ts'), userIndex)
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/Refusing to overwrite a user file/,
|
||||
)
|
||||
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf-8'), userIndex)
|
||||
})
|
||||
|
||||
test('rejects paths outside the generated area from the manifest', async () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
await generateReactSprite(rootDir, 'vite')
|
||||
|
||||
const manifestPath = path.join(rootDir, 'generated', '.svg-sprites.manifest.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
manifest.files.push('svg-sprite.config.ts')
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/Invalid generated file path/,
|
||||
)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, 'svg-sprite.config.ts')), true)
|
||||
})
|
||||
|
||||
test('rejects symbolic links in generated paths', async () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-'))
|
||||
fs.symlinkSync(outsideDir, path.join(rootDir, 'generated'), 'dir')
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/Symbolic links are not allowed/,
|
||||
)
|
||||
assert.deepEqual(fs.readdirSync(outsideDir), [])
|
||||
})
|
||||
|
||||
test('rejects colliding SVG shape IDs', async () => {
|
||||
const { rootDir, iconsDir } = createReactFixture()
|
||||
const collidingId = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}`
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, `${collidingId}.svg`),
|
||||
'<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>',
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
generateReactSprite(rootDir, 'vite'),
|
||||
/produce the same SVG id/,
|
||||
)
|
||||
})
|
||||
|
||||
test('CLI requires an explicit mode', () => {
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(process.execPath, [cliPath], { encoding: 'utf-8' })
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /Missing required argument: --mode/)
|
||||
})
|
||||
|
||||
test('CLI does not expose the future standalone mode', () => {
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', 'standalone', '.'],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /Unknown mode: standalone/)
|
||||
})
|
||||
|
||||
test('CLI requires a target for React mode', () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', 'react', rootDir],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /React mode requires a target/)
|
||||
})
|
||||
|
||||
test('CLI rejects unsupported React target', () => {
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', 'react@next', '.'],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /Unsupported React target: next/)
|
||||
})
|
||||
|
||||
test('CLI requires a complete Next.js target', () => {
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', 'next@app', '.'],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /Unsupported Next\.js target: next@app/)
|
||||
})
|
||||
|
||||
test('CLI runs the React Vite mode', () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', 'react@vite', rootDir],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const component = fs.readFileSync(
|
||||
path.join(rootDir, 'generated', 'react-component.tsx'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.match(component, /sprite\.svg\?no-inline/)
|
||||
assert.match(result.stdout, /✓ file-manager · 3 icons · react@vite/)
|
||||
assert.match(result.stdout, /→ .*generated/)
|
||||
assert.doesNotMatch(result.stdout, /Generating sprite|\[stack\]/)
|
||||
})
|
||||
|
||||
test('CLI runs the React Webpack mode', () => {
|
||||
const { rootDir } = createReactFixture()
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', 'react@webpack', rootDir],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const component = fs.readFileSync(
|
||||
path.join(rootDir, 'generated', 'react-component.tsx'),
|
||||
'utf-8',
|
||||
)
|
||||
assert.match(component, /new URL\('\.\/sprite\.svg', import\.meta\.url\)\.href/)
|
||||
})
|
||||
|
||||
test('CLI runs all Next.js modes', () => {
|
||||
const modes = [
|
||||
'next@app/turbopack',
|
||||
'next@app/webpack',
|
||||
'next@pages/turbopack',
|
||||
'next@pages/webpack',
|
||||
]
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
|
||||
for (const mode of modes) {
|
||||
const { rootDir } = createReactFixture()
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode', mode, rootDir],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
const manifest = fs.readFileSync(path.join(rootDir, 'manifest.ts'), 'utf-8')
|
||||
assert.match(manifest, new RegExp(`target: ${JSON.stringify(mode)}`))
|
||||
}
|
||||
})
|
||||
|
||||
test('CLI runs the legacy mode relative to its target directory', () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-legacy-'))
|
||||
const iconsDir = path.join(rootDir, 'icons')
|
||||
const cliPath = path.resolve('dist/cli.js')
|
||||
|
||||
fs.mkdirSync(iconsDir)
|
||||
fs.writeFileSync(
|
||||
path.join(iconsDir, 'check.svg'),
|
||||
'<svg viewBox="0 0 16 16"><path d="M1 8l4 4L15 2" /></svg>',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, 'svg-sprites.config.ts'),
|
||||
[
|
||||
'export default {',
|
||||
" output: 'sprites',",
|
||||
' preview: false,',
|
||||
" sprites: [{ name: 'icons', input: 'icons', format: 'symbol' }],",
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cliPath, '--mode=legacy', rootDir],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(fs.existsSync(path.join(rootDir, 'sprites', 'icons.sprite.svg')), true)
|
||||
})
|
||||
212
test/react-viewer-dom.test.mjs
Normal file
212
test/react-viewer-dom.test.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { createElement, StrictMode } from 'react'
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
import { SpriteViewer } from '../dist/react.js'
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
generator: '@gromlab/svg-sprites',
|
||||
name: 'controls',
|
||||
description: 'Control icons',
|
||||
componentName: 'ControlsIcon',
|
||||
target: 'vite',
|
||||
format: 'stack',
|
||||
iconCount: 1,
|
||||
spriteUrl: '/assets/controls.svg',
|
||||
icons: [
|
||||
{
|
||||
name: 'check',
|
||||
id: 'check',
|
||||
viewBox: '0 0 16 16',
|
||||
colors: [{ variable: '--icon-color-1', fallback: 'currentColor' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test('SpriteViewer opens an icon dialog with code tabs and copy action', async (context) => {
|
||||
const dom = new JSDOM('<div id="root"></div>', { url: 'https://example.test/' })
|
||||
const originals = new Map()
|
||||
const mediaListeners = new Set()
|
||||
let prefersDark = false
|
||||
const darkModeMedia = {
|
||||
get matches() { return prefersDark },
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
onchange: null,
|
||||
addEventListener: (event, listener) => { if (event === 'change') mediaListeners.add(listener) },
|
||||
removeEventListener: (event, listener) => { if (event === 'change') mediaListeners.delete(listener) },
|
||||
}
|
||||
const matchMedia = () => darkModeMedia
|
||||
dom.window.matchMedia = matchMedia
|
||||
const globals = {
|
||||
document: dom.window.document,
|
||||
window: dom.window,
|
||||
self: dom.window,
|
||||
navigator: dom.window.navigator,
|
||||
Node: dom.window.Node,
|
||||
Event: dom.window.Event,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
HTMLDialogElement: dom.window.HTMLDialogElement,
|
||||
MouseEvent: dom.window.MouseEvent,
|
||||
matchMedia,
|
||||
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value })
|
||||
}
|
||||
|
||||
const clipboardWrites = []
|
||||
Object.defineProperty(dom.window.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: async (value) => { clipboardWrites.push(value) } },
|
||||
})
|
||||
dom.window.HTMLDialogElement.prototype.showModal = function showModal() { this.open = true }
|
||||
dom.window.HTMLDialogElement.prototype.close = function close() { this.open = false }
|
||||
|
||||
const reactModule = await import('react')
|
||||
const { act: domAct } = await import('react-dom/test-utils')
|
||||
const act = reactModule.act ?? domAct
|
||||
const { createRoot } = await import('react-dom/client')
|
||||
const container = dom.window.document.querySelector('#root')
|
||||
const root = createRoot(container)
|
||||
|
||||
context.after(async () => {
|
||||
await act(async () => root.unmount())
|
||||
dom.window.close()
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor)
|
||||
else delete globalThis[name]
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => root.render(createElement(SpriteViewer, { sources: [manifest] })))
|
||||
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, undefined)
|
||||
|
||||
const card = container.querySelector('[data-icon-name="check"]')
|
||||
assert.ok(card)
|
||||
await act(async () => card.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
|
||||
const dialog = container.querySelector('dialog')
|
||||
assert.ok(dialog)
|
||||
assert.equal(dialog.open, true)
|
||||
assert.match(dialog.textContent, /check/)
|
||||
assert.match(dialog.textContent, /16 × 16/)
|
||||
assert.match(dialog.textContent, /ControlsIcon/)
|
||||
|
||||
const reactTab = [...dialog.querySelectorAll('[role="tab"]')]
|
||||
.find((element) => element.textContent === 'React')
|
||||
await act(async () => reactTab.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
key: 'ArrowRight',
|
||||
})))
|
||||
assert.equal(dialog.querySelector('[role="tab"][aria-selected="true"]').textContent, 'SVG')
|
||||
|
||||
const colorSwatch = dialog.querySelector('[aria-label^="Изменить цвет --icon-color-1"]')
|
||||
assert.ok(colorSwatch)
|
||||
assert.equal(dialog.querySelector('.gromlab-sprite-viewer__hex-input'), null)
|
||||
await act(async () => {
|
||||
prefersDark = true
|
||||
for (const listener of mediaListeners) listener({ matches: true, media: darkModeMedia.media })
|
||||
})
|
||||
assert.equal(colorSwatch.style.backgroundColor, 'rgb(229, 229, 229)')
|
||||
|
||||
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.ok(dialog.querySelector('.react-colorful'))
|
||||
const colorRow = colorSwatch.closest('.gromlab-sprite-viewer__color-row')
|
||||
await act(async () => colorRow.dispatchEvent(new dom.window.Event('pointerdown', { bubbles: true })))
|
||||
assert.equal(dialog.querySelector('.react-colorful'), null)
|
||||
await act(async () => colorRow.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.equal(dialog.querySelector('.react-colorful'), null)
|
||||
|
||||
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
const colorPopover = dialog.querySelector('.gromlab-sprite-viewer__color-popover')
|
||||
await act(async () => colorPopover.querySelector('.react-colorful').dispatchEvent(
|
||||
new dom.window.Event('pointerdown', { bubbles: true }),
|
||||
))
|
||||
assert.ok(dialog.querySelector('.react-colorful'))
|
||||
await act(async () => colorPopover.dispatchEvent(new dom.window.KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
key: 'Escape',
|
||||
})))
|
||||
assert.equal(dialog.querySelector('.react-colorful'), null)
|
||||
assert.ok(container.querySelector('dialog'))
|
||||
|
||||
await act(async () => colorSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
const colorInput = dialog.querySelector('.gromlab-sprite-viewer__hex-input')
|
||||
assert.ok(colorInput)
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value').set
|
||||
setInputValue.call(colorInput, '#ff0000')
|
||||
await act(async () => colorInput.dispatchEvent(new dom.window.Event('input', { bubbles: true })))
|
||||
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /--icon-color-1/)
|
||||
assert.doesNotMatch(dialog.querySelector('[role="tabpanel"]').textContent, /React\.CSSProperties/)
|
||||
await act(async () => {
|
||||
prefersDark = false
|
||||
for (const listener of mediaListeners) listener({ matches: false, media: darkModeMedia.media })
|
||||
})
|
||||
assert.equal(colorSwatch.style.backgroundColor, 'rgb(255, 0, 0)')
|
||||
|
||||
const cssTab = [...dialog.querySelectorAll('[role="tab"]')]
|
||||
.find((element) => element.textContent === 'CSS')
|
||||
assert.ok(cssTab)
|
||||
await act(async () => cssTab.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.match(dialog.querySelector('[role="tabpanel"]').textContent, /mask:/)
|
||||
|
||||
const copyButton = [...dialog.querySelectorAll('button')]
|
||||
.find((element) => element.textContent === 'Копировать')
|
||||
assert.ok(copyButton)
|
||||
await act(async () => copyButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.equal(clipboardWrites.length, 1)
|
||||
assert.match(clipboardWrites[0], /background-color/)
|
||||
|
||||
await act(async () => dialog.dispatchEvent(new dom.window.Event('cancel', { cancelable: true })))
|
||||
assert.equal(container.querySelector('dialog'), null)
|
||||
|
||||
const themeButton = container.querySelector('[aria-label="Переключить тему"]')
|
||||
await act(async () => themeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'dark')
|
||||
|
||||
await act(async () => root.render(createElement(SpriteViewer, {
|
||||
sources: [manifest],
|
||||
colorTheme: 'light',
|
||||
})))
|
||||
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
|
||||
assert.equal(container.querySelector('[aria-label="Переключить тему"]'), null)
|
||||
|
||||
const controlledChanges = []
|
||||
await act(async () => root.render(createElement(SpriteViewer, {
|
||||
sources: [manifest],
|
||||
colorTheme: 'light',
|
||||
onColorThemeChange: (theme) => controlledChanges.push(theme),
|
||||
})))
|
||||
const controlledThemeButton = container.querySelector('[aria-label="Переключить тему"]')
|
||||
await act(async () => controlledThemeButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.deepEqual(controlledChanges, ['dark'])
|
||||
assert.equal(container.querySelector('[data-sprite-viewer]').dataset.theme, 'light')
|
||||
|
||||
await act(async () => root.render(createElement(SpriteViewer, {
|
||||
sources: [{ ...manifest, format: 'symbol' }],
|
||||
})))
|
||||
const symbolCard = container.querySelector('[data-icon-name="check"]')
|
||||
await act(async () => symbolCard.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })))
|
||||
assert.deepEqual(
|
||||
[...container.querySelectorAll('[role="tab"]')].map((element) => element.textContent),
|
||||
['React', 'SVG'],
|
||||
)
|
||||
|
||||
let loaderCalls = 0
|
||||
const loader = async () => {
|
||||
loaderCalls++
|
||||
return manifest
|
||||
}
|
||||
await act(async () => root.render(createElement(
|
||||
StrictMode,
|
||||
null,
|
||||
createElement(SpriteViewer, { sources: [loader] }),
|
||||
)))
|
||||
assert.equal(loaderCalls, 1)
|
||||
assert.match(container.textContent, /controls/)
|
||||
})
|
||||
71
test/react-viewer.test.mjs
Normal file
71
test/react-viewer.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
|
||||
import { SpriteViewer } from '../dist/react.js'
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
generator: '@gromlab/svg-sprites',
|
||||
name: 'controls',
|
||||
description: 'Control icons',
|
||||
componentName: 'ControlsIcon',
|
||||
target: 'vite',
|
||||
format: 'symbol',
|
||||
iconCount: 1,
|
||||
spriteUrl: '/assets/controls.svg',
|
||||
icons: [
|
||||
{
|
||||
name: 'check',
|
||||
id: 'check',
|
||||
viewBox: '0 0 16 16',
|
||||
colors: [{ variable: '--icon-color-1', fallback: 'currentColor' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test('React entry is a Next.js client boundary', () => {
|
||||
const reactEntry = fs.readFileSync(path.resolve('dist/react.js'), 'utf-8')
|
||||
const coreEntry = fs.readFileSync(path.resolve('dist/index.js'), 'utf-8')
|
||||
assert.match(reactEntry, /^['"]use client['"];?/)
|
||||
assert.doesNotMatch(coreEntry, /from ["']react(?:\/jsx-runtime)?["']/)
|
||||
})
|
||||
|
||||
test('SpriteViewer renders direct sprite manifests', () => {
|
||||
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
|
||||
sources: [manifest],
|
||||
title: 'Icon catalog',
|
||||
}))
|
||||
|
||||
assert.match(markup, /Icon catalog/)
|
||||
assert.match(markup, /controls/)
|
||||
assert.match(markup, /Control icons/)
|
||||
assert.match(markup, /check/)
|
||||
assert.match(markup, /\/assets\/controls\.svg#check/)
|
||||
assert.match(markup, /1 спрайт · 1 иконка/)
|
||||
assert.match(markup, /gromlab-sprite-viewer__icon-wrap/)
|
||||
assert.match(markup, /Открыть check/)
|
||||
})
|
||||
|
||||
test('SpriteViewer accepts a loader record from import.meta.glob', () => {
|
||||
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
|
||||
sources: {
|
||||
'/src/controls/manifest.ts': async () => ({ default: manifest }),
|
||||
},
|
||||
}))
|
||||
|
||||
assert.match(markup, /Загрузка спрайтов/)
|
||||
})
|
||||
|
||||
test('SpriteViewer supports an externally controlled color theme', () => {
|
||||
const markup = renderToStaticMarkup(createElement(SpriteViewer, {
|
||||
sources: [manifest],
|
||||
colorTheme: 'dark',
|
||||
}))
|
||||
|
||||
assert.match(markup, /^<section[^>]*data-theme="dark"/)
|
||||
assert.doesNotMatch(markup, /<button[^>]*aria-label="Переключить тему"/)
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @generated — this file is auto-generated, do not edit manually. */
|
||||
export { SvgSprite } from './svg-sprite'
|
||||
export type {
|
||||
SvgSpriteProps,
|
||||
SpriteName,
|
||||
SpriteMap,
|
||||
IconName,
|
||||
DefaultSprite,
|
||||
} from './svg-sprite'
|
||||
@@ -1,19 +0,0 @@
|
||||
/* @generated — this file is auto-generated, do not edit manually. */
|
||||
|
||||
.root {
|
||||
transition-property: fill, stroke, color;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wrap svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition-property: fill, stroke, color;
|
||||
transition-duration: 0.3s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* SVG-спрайты: типы и React-компонент.
|
||||
* @generated — this file is auto-generated, do not edit manually.
|
||||
*/
|
||||
import type { SVGAttributes, HTMLAttributes } from 'react'
|
||||
import styles from './svg-sprite.module.css'
|
||||
|
||||
/** Имена иконок спрайта «icons». */
|
||||
export type IconsIconName =
|
||||
| 'arrow-left'
|
||||
| 'arrow-right'
|
||||
| 'check'
|
||||
| 'discount-shape'
|
||||
| 'flash'
|
||||
| 'heart-tick'
|
||||
| 'heart-tick (1-я копия)'
|
||||
|
||||
/** Имена иконок спрайта «logos». */
|
||||
export type LogosIconName =
|
||||
| 'github'
|
||||
| 'twitter'
|
||||
| 'youtube'
|
||||
|
||||
/** Маппинг имени спрайта на тип его иконок. */
|
||||
export type SpriteMap = {
|
||||
icons: IconsIconName
|
||||
logos: LogosIconName
|
||||
}
|
||||
|
||||
/** Имя спрайта. */
|
||||
export type SpriteName = keyof SpriteMap
|
||||
|
||||
/** Спрайт по умолчанию. */
|
||||
export type DefaultSprite = 'icons'
|
||||
|
||||
/** Имя иконки для конкретного спрайта. */
|
||||
export type IconName<S extends SpriteName = SpriteName> = SpriteMap[S]
|
||||
|
||||
const PUBLIC_PATH = ''
|
||||
const DEFAULT_SPRITE: SpriteName = 'icons'
|
||||
|
||||
const SPRITE_FILES: Record<SpriteName, string> = {
|
||||
icons: 'icons.sprite.svg',
|
||||
logos: 'logos.sprite.svg',
|
||||
}
|
||||
|
||||
type IconBaseProps<S extends SpriteName> = {
|
||||
/** Имя иконки. */
|
||||
icon: IconName<S>
|
||||
/** Имя спрайта. По умолчанию: первый из конфига. */
|
||||
sprite?: S
|
||||
}
|
||||
|
||||
type IconSvgProps<S extends SpriteName> = IconBaseProps<S> & {
|
||||
wrapped?: false
|
||||
} & SVGAttributes<SVGSVGElement>
|
||||
|
||||
type IconWrappedProps<S extends SpriteName> = IconBaseProps<S> & {
|
||||
wrapped: true
|
||||
} & HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export type SvgSpriteProps<S extends SpriteName = DefaultSprite> =
|
||||
| IconSvgProps<S>
|
||||
| IconWrappedProps<S>
|
||||
|
||||
/**
|
||||
* Иконка из SVG-спрайта.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения иконки через `<use href="...">`
|
||||
* - обёртки в `<span>` через проп `wrapped`
|
||||
*
|
||||
* Спрайт по умолчанию: «icons».
|
||||
*/
|
||||
export const SvgSprite = <S extends SpriteName = DefaultSprite>(props: SvgSpriteProps<S>) => {
|
||||
const { icon, sprite = DEFAULT_SPRITE as S, wrapped, className, ...rest } = props
|
||||
const href = `${PUBLIC_PATH}/${SPRITE_FILES[sprite]}#${icon}`
|
||||
|
||||
if (wrapped) {
|
||||
const { ...htmlAttr } = rest as HTMLAttributes<HTMLSpanElement>
|
||||
return (
|
||||
<span {...htmlAttr} className={[styles.wrap, className].filter(Boolean).join(' ')}>
|
||||
<svg>
|
||||
<use href={href} />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { ...svgAttr } = rest as SVGAttributes<SVGSVGElement>
|
||||
return (
|
||||
<svg {...svgAttr} className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
<use href={href} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user