feat: standalone mode

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

View File

@@ -10,7 +10,10 @@
- `next@app/webpack`; - `next@app/webpack`;
- `next@pages/turbopack`; - `next@pages/turbopack`;
- `next@pages/webpack`; - `next@pages/webpack`;
- будущие `vue@*`, `standalone` и другие modes. - `standalone`;
- `standalone@vite`;
- `standalone@webpack`;
- будущие `vue@*` и другие modes.
Для каждого exact mode используется отдельный каталог `src/modes/<mode-slug>/`. Adapter самостоятельно определяет: Для каждого exact mode используется отдельный каталог `src/modes/<mode-slug>/`. Adapter самостоятельно определяет:

View File

@@ -225,9 +225,9 @@ It also provides ready-to-use integration examples for:
The Viewer is added only to an internal debug page and does not become part of the generated icon components. The Viewer is added only to an internal debug page and does not become part of the generated icon components.
## React and Next.js ## Standalone, React, and Next.js
The package generates typed React components and supports Vite, Webpack 5, Next.js App Router, and Pages Router with Turbopack or Webpack. The package generates low-level standalone sprites for static HTML, Vite, and Webpack 5, plus typed React components for React and Next.js.
## Clean Git history ## Clean Git history

View File

@@ -225,9 +225,9 @@ JavaScript отвечает за интерфейс и поведение, а г
Viewer подключается только к внутренней debug-странице и не становится частью generated-компонентов иконок. Viewer подключается только к внутренней debug-странице и не становится частью generated-компонентов иконок.
## React и Next.js ## Standalone, React и Next.js
Пакет генерирует типизированные React-компоненты и поддерживает Vite, Webpack 5, Next.js App Router и Pages Router с Turbopack или Webpack. Пакет генерирует низкоуровневые standalone-спрайты для static HTML, Vite и Webpack 5, а также типизированные React-компоненты для React и Next.js.
## Чистый Git ## Чистый Git

View File

@@ -14,6 +14,21 @@ const result = await generateSprite(
) )
``` ```
For static standalone mode, use `result.spritePath` in a build script to publish the
SVG under an application URL:
```ts
import { copyFile } from 'node:fs/promises'
const result = await generateSprite('src/sprite/svg-sprite.config.ts', {
mode: 'standalone',
})
await copyFile(result.spritePath, 'dist/app-icons/sprite.svg')
```
`spritePath` is a filesystem path, not a browser URL. A deployment-neutral JSON
manifest is available through `result.manifestPath` and is copied independently.
The first argument accepts the full path to an explicitly selected `.ts`, `.js`, or `.json` config file with any name. Passing a directory enables config-less mode and uses that directory as the sprite module root. The first argument accepts the full path to an explicitly selected `.ts`, `.js`, or `.json` config file with any name. Passing a directory enables config-less mode and uses that directory as the sprite module root.
The second argument contains optional overrides and always takes precedence over the config: The second argument contains optional overrides and always takes precedence over the config:

View File

@@ -32,6 +32,9 @@ svg-sprites [options] <config-file-or-directory>
| Environment | Mode | | Environment | Mode |
|---|---| |---|---|
| Static HTML / custom publishing | `standalone` |
| Standalone + Vite | `standalone@vite` |
| Standalone + Webpack 5 | `standalone@webpack` |
| React + Vite | `react@vite` | | React + Vite | `react@vite` |
| React + Webpack 5 | `react@webpack` | | React + Webpack 5 | `react@webpack` |
| Next.js App Router + Turbopack | `next@app/turbopack` | | Next.js App Router + Turbopack | `next@app/turbopack` |
@@ -45,7 +48,7 @@ When a directory is passed, all settings come from CLI options. When a config fi
Available options are `--mode`, `--name`, `--description`, `--input-folder`, repeatable `--input-file`, plus the `--remove-size`/`--no-remove-size`, `--replace-colors`/`--no-replace-colors`, `--add-transition`/`--no-add-transition`, and `--generated-notice`/`--no-generated-notice` pairs. Transform flags override individual fields, while supplying at least one `--input-file` replaces the complete config `inputFiles` array. Available options are `--mode`, `--name`, `--description`, `--input-folder`, repeatable `--input-file`, plus the `--remove-size`/`--no-remove-size`, `--replace-colors`/`--no-replace-colors`, `--add-transition`/`--no-add-transition`, and `--generated-notice`/`--no-generated-notice` pairs. Transform flags override individual fields, while supplying at least one `--input-file` replaces the complete config `inputFiles` array.
The mode must match the application's bundler. The generator creates different SVG asset integration code for Vite and for bundlers compatible with Webpack Asset Modules. The mode must match the application's publishing strategy. Bare `standalone` leaves the public URL to the application; Vite and Webpack modes generate bundler-specific SVG asset integration.
## Unified configuration ## Unified configuration
@@ -105,7 +108,7 @@ Identical absolute paths are deduplicated. Different SVG files with the same fil
## Generated module ## Generated module
After generation, the sprite directory looks like this: After generation, a React or Next.js sprite directory looks like this:
```text ```text
app-icons/ app-icons/
@@ -140,6 +143,19 @@ app-icons/
| `.svg-sprite/*.d.ts` | TypeScript declarations for the corresponding JavaScript modules | | `.svg-sprite/*.d.ts` | TypeScript declarations for the corresponding JavaScript modules |
| `.svg-sprite/state.json` | Mode, contract version, and managed file list | | `.svg-sprite/state.json` | Mode, contract version, and managed file list |
Standalone contracts do not create `react/`. Bare `standalone` contains only the
runtime asset and deployment-neutral manifest data:
```text
.svg-sprite/
├── state.json
├── sprite.svg
└── svg-sprite.manifest.json
```
`standalone@vite` and `standalone@webpack` additionally create `index.*`,
`icon-data.*`, and a resolved `svg-sprite.manifest.*`.
The generator overwrites and deletes only files that contain its marker. If a user file occupies a managed path, generation fails. The root `index.ts` is user-owned; create a barrel when needed: The generator overwrites and deletes only files that contain its marker. If a user file occupies a managed path, generation fails. The root `index.ts` is user-owned; create a barrel when needed:
```ts ```ts
@@ -235,7 +251,7 @@ For multiple sprites, add a separate CLI command for each directory or combine t
## Formats and rendering methods ## Formats and rendering methods
React and Next.js modes generate the `stack` format. All current modes generate the `stack` format.
| Format | `<svg><use>` | `<img>` | CSS background | | Format | `<svg><use>` | `<img>` | CSS background |
|---|---:|---:|---:| |---|---:|---:|---:|
@@ -253,6 +269,17 @@ For React and Next.js, use the generated component. It knows the internal IDs, c
How you obtain `spriteUrl` depends on the bundler. How you obtain `spriteUrl` depends on the bundler.
Static HTML after the application publishes `.svg-sprite/sprite.svg`:
```html
<svg aria-hidden="true">
<use href="/assets/icons.svg#search"></use>
</svg>
```
Standalone Vite/Webpack provides generated `getIconsIconHref()` and an internal ID
map. Do not construct fragments from unsafe file names manually.
Vite: Vite:
```ts ```ts
@@ -306,12 +333,15 @@ The path in CSS is resolved relative to the CSS file itself. In these examples,
## Assets and caching ## Assets and caching
The generated component passes the SVG to the bundler as a separate asset: The generated component or standalone facade passes the SVG to the bundler as a separate asset:
- Vite uses a static import with `?no-inline`; - Vite uses a static import with `?no-inline`;
- Webpack 5, Turbopack, and Next.js use `new URL(..., import.meta.url)`; - Webpack 5, Turbopack, and Next.js use `new URL(..., import.meta.url)`;
- SVG path data is not serialized into generated JavaScript. - SVG path data is not serialized into generated JavaScript.
Bare `standalone` does not participate in an asset pipeline: the application copies
or publishes `sprite.svg` and owns its URL, versioning, and cache policy.
With standard asset naming, the bundler adds a content hash: With standard asset naming, the bundler adds a content hash:
```text ```text
@@ -401,7 +431,7 @@ For a complex icon, you can disable `replaceColors` in a separate sprite configu
import { SpriteViewer } from '@gromlab/svg-sprites/react' import { SpriteViewer } from '@gromlab/svg-sprites/react'
``` ```
It accepts ready-made manifests, an array of lazy loaders, or a record in the format returned by `import.meta.glob`. It accepts ready-made React/Next manifests, an array of lazy loaders, or a record in the format returned by `import.meta.glob`. The current Viewer does not load standalone manifests; standalone will use a separate viewer contract.
Vite: Vite:
@@ -488,6 +518,7 @@ If the sprite directory already contains a user-created `.gitignore` or a user-o
- `Refusing to overwrite a user file`: a file without the generated marker occupies a managed path. - `Refusing to overwrite a user file`: a file without the generated marker occupies a managed path.
- The icon does not change color: use `<svg><use>` or the generated component and check `replaceColors`. - The icon does not change color: use `<svg><use>` or the generated component and check `replaceColors`.
- Webpack emits an incorrect URL: check Asset Modules, `output.publicPath`, and SVG loaders. - Webpack emits an incorrect URL: check Asset Modules, `output.publicPath`, and SVG loaders.
- Static sprite returns 404: check the post-generation copy or server alias, and do not put a filesystem `spritePath` into HTML.
- The Viewer cannot find the sprite: check the path to `.svg-sprite/svg-sprite.manifest.js` and run generation before starting the application. - The Viewer cannot find the sprite: check the path to `.svg-sprite/svg-sprite.manifest.js` and run generation before starting the application.
- Build and mode do not match: use the target that corresponds to the actual bundler. - Build and mode do not match: use the target that corresponds to the actual bundler.

View File

@@ -14,6 +14,21 @@ const result = await generateSprite(
) )
``` ```
Для static standalone mode `result.spritePath` можно использовать в build-скрипте,
чтобы опубликовать SVG по URL приложения:
```ts
import { copyFile } from 'node:fs/promises'
const result = await generateSprite('src/sprite/svg-sprite.config.ts', {
mode: 'standalone',
})
await copyFile(result.spritePath, 'dist/app-icons/sprite.svg')
```
`spritePath` является filesystem path, а не browser URL. Deployment-neutral JSON
manifest доступен через `result.manifestPath` и копируется независимо от SVG.
Первый аргумент принимает полный путь к config-файлу с любым именем и расширением `.ts`, `.js` или `.json`. Каталог вместо файла включает config-less режим: корнем sprite-модуля становится этот каталог. Первый аргумент принимает полный путь к config-файлу с любым именем и расширением `.ts`, `.js` или `.json`. Каталог вместо файла включает config-less режим: корнем sprite-модуля становится этот каталог.
Второй аргумент содержит необязательные overrides и всегда имеет приоритет над конфигом: Второй аргумент содержит необязательные overrides и всегда имеет приоритет над конфигом:

View File

@@ -32,6 +32,9 @@ svg-sprites [options] <config-file-or-directory>
| Среда | Mode | | Среда | Mode |
|---|---| |---|---|
| Static HTML / собственная публикация | `standalone` |
| Standalone + Vite | `standalone@vite` |
| Standalone + Webpack 5 | `standalone@webpack` |
| React + Vite | `react@vite` | | React + Vite | `react@vite` |
| React + Webpack 5 | `react@webpack` | | React + Webpack 5 | `react@webpack` |
| Next.js App Router + Turbopack | `next@app/turbopack` | | Next.js App Router + Turbopack | `next@app/turbopack` |
@@ -45,7 +48,7 @@ Config-файл может иметь любое имя и расширение
Доступны `--mode`, `--name`, `--description`, `--input-folder`, повторяемый `--input-file`, а также пары `--remove-size`/`--no-remove-size`, `--replace-colors`/`--no-replace-colors`, `--add-transition`/`--no-add-transition` и `--generated-notice`/`--no-generated-notice`. Переданные transform-флаги перекрывают отдельные поля, а хотя бы один `--input-file` заменяет весь массив `inputFiles` из config. Доступны `--mode`, `--name`, `--description`, `--input-folder`, повторяемый `--input-file`, а также пары `--remove-size`/`--no-remove-size`, `--replace-colors`/`--no-replace-colors`, `--add-transition`/`--no-add-transition` и `--generated-notice`/`--no-generated-notice`. Переданные transform-флаги перекрывают отдельные поля, а хотя бы один `--input-file` заменяет весь массив `inputFiles` из config.
Mode должен соответствовать сборщику приложения. Генератор создаёт разный способ подключения SVG asset для Vite и сборщиков, совместимых с Webpack Asset Modules. Mode должен соответствовать способу публикации приложения. Bare `standalone` оставляет публичный URL приложению; Vite и Webpack modes генерируют bundler-specific подключение SVG asset.
## Единая конфигурация ## Единая конфигурация
@@ -105,7 +108,7 @@ file-manager → FileManagerIcon
## Generated-модуль ## Generated-модуль
После генерации каталог спрайта выглядит так: После генерации React- или Next.js-каталог спрайта выглядит так:
```text ```text
app-icons/ app-icons/
@@ -140,6 +143,19 @@ app-icons/
| `.svg-sprite/*.d.ts` | TypeScript-декларации соответствующих JS-модулей | | `.svg-sprite/*.d.ts` | TypeScript-декларации соответствующих JS-модулей |
| `.svg-sprite/state.json` | Mode, версия контракта и список управляемых файлов | | `.svg-sprite/state.json` | Mode, версия контракта и список управляемых файлов |
Standalone-контракты не создают каталог `react/`. Bare `standalone` содержит только
runtime asset и deployment-neutral manifest data:
```text
.svg-sprite/
├── state.json
├── sprite.svg
└── svg-sprite.manifest.json
```
`standalone@vite` и `standalone@webpack` дополнительно создают `index.*`,
`icon-data.*` и resolved `svg-sprite.manifest.*`.
Генератор перезаписывает и удаляет только файлы со своим marker. Если в managed-пути находится пользовательский файл, генерация завершается ошибкой. Корневой `index.ts` генератору не принадлежит; при необходимости создайте пользовательский barrel: Генератор перезаписывает и удаляет только файлы со своим marker. Если в managed-пути находится пользовательский файл, генерация завершается ошибкой. Корневой `index.ts` генератору не принадлежит; при необходимости создайте пользовательский barrel:
```ts ```ts
@@ -235,7 +251,7 @@ editor-icons → EditorIcon → иконки редактора
## Форматы и способы отображения ## Форматы и способы отображения
React- и Next.js-режимы создают формат `stack`. Все текущие modes создают формат `stack`.
| Формат | `<svg><use>` | `<img>` | CSS background | | Формат | `<svg><use>` | `<img>` | CSS background |
|---|---:|---:|---:| |---|---:|---:|---:|
@@ -253,6 +269,17 @@ React- и Next.js-режимы создают формат `stack`.
Способ получения `spriteUrl` зависит от сборщика. Способ получения `spriteUrl` зависит от сборщика.
Static HTML после публикации `.svg-sprite/sprite.svg` приложением:
```html
<svg aria-hidden="true">
<use href="/assets/icons.svg#search"></use>
</svg>
```
Standalone Vite/Webpack предоставляет generated `getIconsIconHref()` и mapping
внутренних IDs. Не конструируйте fragment из небезопасного имени файла вручную.
Vite: Vite:
```ts ```ts
@@ -306,12 +333,15 @@ Mask не сохраняет исходные цвета, gradients и разл
## Assets и кеширование ## Assets и кеширование
Generated-компонент передаёт SVG сборщику как отдельный asset: Generated component или standalone facade передаёт SVG сборщику как отдельный asset:
- Vite использует статический импорт с `?no-inline`; - Vite использует статический импорт с `?no-inline`;
- Webpack 5, Turbopack и Next.js используют `new URL(..., import.meta.url)`; - Webpack 5, Turbopack и Next.js используют `new URL(..., import.meta.url)`;
- SVG path-данные не сериализуются в generated JavaScript. - SVG path-данные не сериализуются в generated JavaScript.
Bare `standalone` не участвует в asset pipeline: приложение само копирует или
публикует `sprite.svg` и отвечает за URL, версионирование и cache policy.
При стандартном именовании assets сборщик добавляет content hash: При стандартном именовании assets сборщик добавляет content hash:
```text ```text
@@ -401,7 +431,7 @@ fill="var(--icon-color-3, #129d9d)"
import { SpriteViewer } from '@gromlab/svg-sprites/react' import { SpriteViewer } from '@gromlab/svg-sprites/react'
``` ```
Он принимает готовые manifests, массив lazy loaders или record формата `import.meta.glob`. Он принимает готовые React/Next manifests, массив lazy loaders или record формата `import.meta.glob`. Текущий Viewer не загружает standalone manifests; для standalone будет отдельный viewer-контракт.
Vite: Vite:
@@ -488,6 +518,7 @@ CI должен устанавливать development dependencies и выпо
- `Refusing to overwrite a user file`: в managed-пути находится файл без generated marker. - `Refusing to overwrite a user file`: в managed-пути находится файл без generated marker.
- Иконка не меняет цвет: используйте `<svg><use>` или generated-компонент и проверьте `replaceColors`. - Иконка не меняет цвет: используйте `<svg><use>` или generated-компонент и проверьте `replaceColors`.
- Webpack выдаёт неверный URL: проверьте Asset Modules, `output.publicPath` и SVG loaders. - Webpack выдаёт неверный URL: проверьте Asset Modules, `output.publicPath` и SVG loaders.
- Static sprite возвращает 404: проверьте post-generation copy или server alias и не передавайте filesystem `spritePath` в HTML.
- Viewer не видит спрайт: проверьте путь к `.svg-sprite/svg-sprite.manifest.js` и выполните генерацию до запуска приложения. - Viewer не видит спрайт: проверьте путь к `.svg-sprite/svg-sprite.manifest.js` и выполните генерацию до запуска приложения.
- Build и mode не совпадают: используйте target, соответствующий фактическому сборщику. - Build и mode не совпадают: используйте target, соответствующий фактическому сборщику.

View File

@@ -7,6 +7,9 @@ production build и отображения внешнего SVG-спрайта
| Fixture | Генератор | Runtime | | Fixture | Генератор | Runtime |
| --- | --- | --- | | --- | --- | --- |
| `standalone` | `standalone` | Static HTML + явный copy SVG |
| `standalone-vite` | `standalone@vite` | Vanilla TypeScript + Vite |
| `standalone-webpack` | `standalone@webpack` | Vanilla TypeScript + Webpack 5 |
| `react-vite` | `react@vite` | React + Vite | | `react-vite` | `react@vite` | React + Vite |
| `react-webpack` | `react@webpack` | React + Webpack 5 | | `react-webpack` | `react@webpack` | React + Webpack 5 |
| `next-app-turbopack` | `next@app/turbopack` | App Router + Turbopack | | `next-app-turbopack` | `next@app/turbopack` | App Router + Turbopack |
@@ -51,6 +54,11 @@ npm run dev --workspace @svg-sprites-fixtures/react-vite --prefix integration
последовательно, проверяет внешний `href`, HTTP status и Content-Type спрайта, последовательно, проверяет внешний `href`, HTTP status и Content-Type спрайта,
наличие symbol ID, отсутствие browser errors и зелёные пиксели отрисованной иконки. наличие symbol ID, отсутствие browser errors и зелёные пиксели отрисованной иконки.
Static fixture копирует managed SVG и JSON manifest в `dist/app-icons/` и использует
literal `<use href="/app-icons/sprite.svg#check">`.
Vite и Webpack fixtures получают URL только из generated facade и сверяют его с
resolved manifest.
## Структура ## Структура
```text ```text

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla Vite sprite fixture</title> <title>Standalone Vite sprite fixture</title>
</head> </head>
<body> <body>
<main id="app"></main> <main id="app"></main>

View File

@@ -1,10 +1,10 @@
{ {
"name": "@svg-sprites-fixtures/vanilla-vite", "name": "@svg-sprites-fixtures/standalone-vite",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"sprites": "svg-sprites --mode legacy .", "sprites": "svg-sprites src/sprite/svg-sprite.config.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "npm run sprites && tsc --noEmit && vite build", "build": "npm run sprites && tsc --noEmit && vite build",
"dev": "npm run sprites && vite" "dev": "npm run sprites && vite"

View File

@@ -0,0 +1,20 @@
import spriteManifest from './sprite/.svg-sprite/svg-sprite.manifest.js'
import { getIconsIconHref, iconsSpriteUrl } from './sprite'
import './style.css'
const check = spriteManifest.icons.find((icon) => icon.name === 'check')
if (!check || spriteManifest.spriteUrl !== iconsSpriteUrl) {
throw new Error('Generated Vite facade and manifest disagree.')
}
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<h1>Standalone + Vite</h1>
<svg
data-testid="icon"
data-app="standalone-vite"
viewBox="${check.viewBox ?? '0 0 24 24'}"
aria-label="Check icon"
>
<use href="${getIconsIconHref('check')}"></use>
</svg>
`

View File

@@ -0,0 +1,2 @@
# @generated by @gromlab/svg-sprites. Do not edit.
/.svg-sprite/

View File

@@ -0,0 +1,7 @@
export {
getIconsIconHref,
iconsIconIds,
iconsIconNames,
iconsSpriteUrl,
} from './.svg-sprite/index.js'
export type { IconsIconName } from './.svg-sprite/index.js'

View File

@@ -0,0 +1,8 @@
import { defineSpriteConfig } from '@gromlab/svg-sprites'
export default defineSpriteConfig({
mode: 'standalone@vite',
name: 'icons',
inputFiles: ['../../../../fixtures/icons/check.svg'],
generatedNotice: false,
})

View File

@@ -1,16 +1,12 @@
:root {
font-family: system-ui, sans-serif;
color: #172033;
background: #fff;
}
body { body {
margin: 0; margin: 0;
padding: 40px; padding: 40px;
font-family: system-ui, sans-serif;
} }
[data-testid='icon'] { [data-testid='icon'] {
width: 64px; width: 64px;
height: 64px; height: 64px;
color: #16a34a; color: #16a34a;
--icon-color-1: #16a34a;
} }

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Standalone Webpack sprite fixture</title>
</head>
<body>
<main id="app"></main>
</body>
</html>

View File

@@ -0,0 +1,21 @@
{
"name": "@svg-sprites-fixtures/standalone-webpack",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"sprites": "svg-sprites src/sprite/svg-sprite.config.ts",
"typecheck": "tsc --noEmit",
"build": "npm run sprites && webpack --mode production",
"dev": "npm run sprites && webpack serve --mode development"
},
"devDependencies": {
"@gromlab/svg-sprites": "file:../../..",
"html-webpack-plugin": "5.6.7",
"ts-loader": "9.6.2",
"typescript": "6.0.2",
"webpack": "5.108.4",
"webpack-cli": "7.2.1",
"webpack-dev-server": "5.2.2"
}
}

View File

@@ -0,0 +1,20 @@
import spriteManifest from './sprite/.svg-sprite/svg-sprite.manifest.js'
import { getIconsIconHref, iconsSpriteUrl } from './sprite'
const check = spriteManifest.icons.find((icon) => icon.name === 'check')
if (!check || spriteManifest.spriteUrl !== iconsSpriteUrl) {
throw new Error('Generated Webpack facade and manifest disagree.')
}
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<h1>Standalone + Webpack</h1>
<svg
data-testid="icon"
data-app="standalone-webpack"
viewBox="${check.viewBox ?? '0 0 24 24'}"
aria-label="Check icon"
style="width:64px;height:64px;color:#16a34a;--icon-color-1:#16a34a"
>
<use href="${getIconsIconHref('check')}"></use>
</svg>
`

View File

@@ -0,0 +1,2 @@
# @generated by @gromlab/svg-sprites. Do not edit.
/.svg-sprite/

View File

@@ -0,0 +1,7 @@
export {
getIconsIconHref,
iconsIconIds,
iconsIconNames,
iconsSpriteUrl,
} from './.svg-sprite/index.js'
export type { IconsIconName } from './.svg-sprite/index.js'

View File

@@ -0,0 +1,8 @@
import { defineSpriteConfig } from '@gromlab/svg-sprites'
export default defineSpriteConfig({
mode: 'standalone@webpack',
name: 'icons',
inputFiles: ['../../../../fixtures/icons/check.svg'],
generatedNotice: false,
})

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,31 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import HtmlWebpackPlugin from 'html-webpack-plugin'
const root = path.dirname(fileURLToPath(import.meta.url))
export default {
entry: './src/main.ts',
output: {
path: path.join(root, 'dist'),
filename: 'assets/app.[contenthash].js',
assetModuleFilename: 'assets/[name].[contenthash][ext]',
clean: true,
},
resolve: {
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: 'ts-loader',
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './index.html' }),
],
}

View File

@@ -0,0 +1,13 @@
import fs from 'node:fs'
fs.rmSync(new URL('./dist/', import.meta.url), { recursive: true, force: true })
fs.mkdirSync(new URL('./dist/app-icons/', import.meta.url), { recursive: true })
fs.copyFileSync(new URL('./index.html', import.meta.url), new URL('./dist/index.html', import.meta.url))
fs.copyFileSync(
new URL('./src/sprite/.svg-sprite/sprite.svg', import.meta.url),
new URL('./dist/app-icons/sprite.svg', import.meta.url),
)
fs.copyFileSync(
new URL('./src/sprite/.svg-sprite/svg-sprite.manifest.json', import.meta.url),
new URL('./dist/app-icons/manifest.json', import.meta.url),
)

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Standalone sprite fixture</title>
<style>
body { margin: 0; padding: 40px; font-family: system-ui, sans-serif; }
[data-testid="icon"] { width: 64px; height: 64px; color: #16a34a; --icon-color-1: #16a34a; }
</style>
</head>
<body>
<main>
<h1>Standalone</h1>
<svg data-testid="icon" data-app="standalone" viewBox="0 0 24 24" aria-label="Check icon">
<use href="/app-icons/sprite.svg#check"></use>
</svg>
</main>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{
"name": "@svg-sprites-fixtures/standalone",
"private": true,
"version": "0.0.0",
"scripts": {
"sprites": "svg-sprites src/sprite/svg-sprite.config.json",
"build": "npm run sprites && node build.mjs",
"dev": "npm run build && node ../../scripts/serve-static.mjs dist 4173"
},
"devDependencies": {
"@gromlab/svg-sprites": "file:../../.."
}
}

View File

@@ -0,0 +1,2 @@
# @generated by @gromlab/svg-sprites. Do not edit.
/.svg-sprite/

View File

@@ -0,0 +1,6 @@
{
"mode": "standalone",
"name": "app-icons",
"inputFiles": ["../../../../fixtures/icons/check.svg"],
"generatedNotice": false
}

View File

@@ -1,8 +0,0 @@
import './style.css'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<h1>Vanilla + Vite</h1>
<svg data-testid="icon" data-app="vanilla-vite" aria-label="Check icon" viewBox="0 0 24 24">
<use href="/sprites/icons.sprite.svg#check"></use>
</svg>
`

View File

@@ -1,11 +0,0 @@
export default {
output: 'public/sprites',
preview: false,
sprites: [
{
name: 'icons',
input: '../../fixtures/icons',
format: 'symbol',
},
],
}

View File

@@ -34,14 +34,11 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/svg-sprite": "^0.0.39", "@types/svg-sprite": "^0.0.39",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"next": "16.2.10",
"react": "^19.2.5", "react": "^19.2.5",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3"
"vite": "^6.4.3",
"webpack": "^5.108.4"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -2130,6 +2127,35 @@
"vite-plugin-solid": "2.11.12" "vite-plugin-solid": "2.11.12"
} }
}, },
"apps/standalone": {
"name": "@svg-sprites-fixtures/standalone",
"version": "0.0.0",
"devDependencies": {
"@gromlab/svg-sprites": "file:../../.."
}
},
"apps/standalone-vite": {
"name": "@svg-sprites-fixtures/standalone-vite",
"version": "0.0.0",
"devDependencies": {
"@gromlab/svg-sprites": "file:../../..",
"typescript": "6.0.2",
"vite": "8.1.4"
}
},
"apps/standalone-webpack": {
"name": "@svg-sprites-fixtures/standalone-webpack",
"version": "0.0.0",
"devDependencies": {
"@gromlab/svg-sprites": "file:../../..",
"html-webpack-plugin": "5.6.7",
"ts-loader": "9.6.2",
"typescript": "6.0.2",
"webpack": "5.108.4",
"webpack-cli": "7.2.1",
"webpack-dev-server": "5.2.2"
}
},
"apps/svelte-vite": { "apps/svelte-vite": {
"name": "@svg-sprites-fixtures/svelte-vite", "name": "@svg-sprites-fixtures/svelte-vite",
"version": "0.0.0", "version": "0.0.0",
@@ -2160,15 +2186,6 @@
"vite": "8.1.4" "vite": "8.1.4"
} }
}, },
"apps/vanilla-vite": {
"name": "@svg-sprites-fixtures/vanilla-vite",
"version": "0.0.0",
"devDependencies": {
"@gromlab/svg-sprites": "file:../../..",
"typescript": "6.0.2",
"vite": "8.1.4"
}
},
"apps/vue-vite": { "apps/vue-vite": {
"name": "@svg-sprites-fixtures/vue-vite", "name": "@svg-sprites-fixtures/vue-vite",
"version": "0.0.0", "version": "0.0.0",
@@ -9372,6 +9389,18 @@
"resolved": "apps/solid-vite", "resolved": "apps/solid-vite",
"link": true "link": true
}, },
"node_modules/@svg-sprites-fixtures/standalone": {
"resolved": "apps/standalone",
"link": true
},
"node_modules/@svg-sprites-fixtures/standalone-vite": {
"resolved": "apps/standalone-vite",
"link": true
},
"node_modules/@svg-sprites-fixtures/standalone-webpack": {
"resolved": "apps/standalone-webpack",
"link": true
},
"node_modules/@svg-sprites-fixtures/svelte-vite": { "node_modules/@svg-sprites-fixtures/svelte-vite": {
"resolved": "apps/svelte-vite", "resolved": "apps/svelte-vite",
"link": true "link": true
@@ -9380,10 +9409,6 @@
"resolved": "apps/sveltekit", "resolved": "apps/sveltekit",
"link": true "link": true
}, },
"node_modules/@svg-sprites-fixtures/vanilla-vite": {
"resolved": "apps/vanilla-vite",
"link": true
},
"node_modules/@svg-sprites-fixtures/vue-vite": { "node_modules/@svg-sprites-fixtures/vue-vite": {
"resolved": "apps/vue-vite", "resolved": "apps/vue-vite",
"link": true "link": true
@@ -11906,7 +11931,7 @@
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -14372,7 +14397,7 @@
"version": "5.1.9", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/import-local": { "node_modules/import-local": {

View File

@@ -1,4 +1,7 @@
export const apps = [ export const apps = [
{ id: 'standalone', kind: 'static', output: 'dist', expectedSpritePath: '/app-icons/sprite.svg' },
{ id: 'standalone-vite', kind: 'static', output: 'dist' },
{ id: 'standalone-webpack', kind: 'static', output: 'dist' },
{ id: 'react-vite', kind: 'static', output: 'dist' }, { id: 'react-vite', kind: 'static', output: 'dist' },
{ id: 'react-webpack', kind: 'static', output: 'dist' }, { id: 'react-webpack', kind: 'static', output: 'dist' },
{ id: 'next-app-turbopack', kind: 'next' }, { id: 'next-app-turbopack', kind: 'next' },

View File

@@ -114,6 +114,9 @@ for (const app of apps) {
expect(href).not.toMatch(/^(?:blob|data|file):/) expect(href).not.toMatch(/^(?:blob|data|file):/)
const spriteUrl = new URL(href!.split('#')[0], page.url()) const spriteUrl = new URL(href!.split('#')[0], page.url())
if ('expectedSpritePath' in app) {
expect(spriteUrl.pathname).toBe(app.expectedSpritePath)
}
const spriteResponse = await fetch(spriteUrl) const spriteResponse = await fetch(spriteUrl)
expect(spriteResponse.status).toBe(200) expect(spriteResponse.status).toBe(200)
expect(spriteResponse.headers.get('content-type') ?? '').toMatch(/image\/svg\+xml/) expect(spriteResponse.headers.get('content-type') ?? '').toMatch(/image\/svg\+xml/)

1749
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,13 +100,10 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/svg-sprite": "^0.0.39", "@types/svg-sprite": "^0.0.39",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"next": "16.2.10",
"react": "^19.2.5", "react": "^19.2.5",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"typescript": "^5.8.3", "typescript": "^5.8.3"
"vite": "^6.4.3",
"webpack": "^5.108.4"
} }
} }

View File

@@ -35,7 +35,7 @@ const upstream = [
export default [ export default [
{ {
name: 'svg-sprites', name: 'svg-sprites',
description: 'Use only when configuring, generating, or troubleshooting @gromlab/svg-sprites. Triggers: @gromlab/svg-sprites, svg-sprite.config.ts, defineSpriteConfig, generateSprite, react@vite, react@webpack, next@app, next@pages, inputFiles, SpriteViewer, or --icon-color-N. Do NOT use for custom SVG sprites, favicons, raster images, icon fonts, choosing an icon set, or inline SVG without this package.', description: 'Use only when configuring, generating, or troubleshooting @gromlab/svg-sprites. Triggers: @gromlab/svg-sprites, svg-sprite.config.ts, defineSpriteConfig, generateSprite, standalone, standalone@vite, standalone@webpack, react@vite, react@webpack, next@app, next@pages, inputFiles, SpriteViewer, or --icon-color-N. Do NOT use for custom SVG sprites, favicons, raster images, icon fonts, choosing an icon set, or inline SVG without this package.',
output: '../artifacts/svg-sprites', output: '../artifacts/svg-sprites',
maxSkillBytes: 48_000, maxSkillBytes: 48_000,
documents: documents('en'), documents: documents('en'),
@@ -43,7 +43,7 @@ export default [
}, },
{ {
name: 'svg-sprites-ru', name: 'svg-sprites-ru',
description: 'Используй только при настройке, изменении или диагностике @gromlab/svg-sprites. Триггеры: @gromlab/svg-sprites, svg-sprite.config.ts, defineSpriteConfig, generateSprite, react@vite, react@webpack, next@app, next@pages, inputFiles, SpriteViewer и --icon-color-N. НЕ используй для самописных SVG-спрайтов, inline SVG, favicon, растровых изображений, icon fonts или выбора библиотеки иконок.', description: 'Используй только при настройке, изменении или диагностике @gromlab/svg-sprites. Триггеры: @gromlab/svg-sprites, svg-sprite.config.ts, defineSpriteConfig, generateSprite, standalone, standalone@vite, standalone@webpack, react@vite, react@webpack, next@app, next@pages, inputFiles, SpriteViewer и --icon-color-N. НЕ используй для самописных SVG-спрайтов, inline SVG, favicon, растровых изображений, icon fonts или выбора библиотеки иконок.',
output: '../artifacts/svg-sprites-ru', output: '../artifacts/svg-sprites-ru',
maxSkillBytes: 48_000, maxSkillBytes: 48_000,
documents: documents('ru'), documents: documents('ru'),

View File

@@ -1,15 +1,15 @@
## What the package does ## What the package does
`@gromlab/svg-sprites` is a CLI generator that builds SVG sprites from user-provided SVG files. The package does not include its own icon set: it compiles the project's SVGs into an external cacheable sprite asset and, for React/Next.js, creates a typed component, a list of valid names, and a debug manifest. `@gromlab/svg-sprites` is a CLI generator that builds SVG sprites from user-provided SVG files. The package does not include its own icon set: it compiles the project's SVGs into an external sprite asset, supports standalone projects, and creates typed components for React/Next.js.
The package supports multiple independent sprites in one project. Each explicitly selected config file or config-less directory describes one sprite and gets its own: The package supports multiple independent sprites in one project. Each explicitly selected config file or config-less directory describes one sprite and gets its own:
- SVG asset; - SVG asset;
- icon name types; - mode-specific manifest data;
- React component; - icon name types and `.svg-sprite/index.js` for bundler modes;
- production entry point `.svg-sprite/index.js` with `.d.ts`; - a React component only for React/Next.js;
- debug entry point `.svg-sprite/svg-sprite.manifest.js` with `.d.ts`. - a deployment-neutral JSON manifest without a public URL for bare `standalone`.
The project determines how many sprite directories exist and where they live. For example, `name: 'file-manager'` produces `FileManagerIcon`, while another directory with `name: 'navigation'` produces a separate `NavigationIcon`. The names `FileManagerIcon` and `fileManagerIconNames` used below are examples of the API for one possible sprite, not fixed package exports. The project determines how many sprite directories exist and where they live. For example, `name: 'file-manager'` produces `FileManagerIcon`, while another directory with `name: 'navigation'` produces a separate `NavigationIcon`. The names `FileManagerIcon` and `fileManagerIconNames` used below are examples of the API for one possible sprite, not fixed package exports.
Generated production components do not import `@gromlab/svg-sprites` at runtime. Install the package as a development dependency so configuration helpers and the local CLI use the version recorded in the project's lockfile. Generated production runtime does not import `@gromlab/svg-sprites` at runtime. Install the package as a development dependency so configuration helpers and the local CLI use the version recorded in the project's lockfile.

View File

@@ -4,6 +4,9 @@ Select exactly one supported mode key:
| Project | Mode key | | Project | Mode key |
|---|---| |---|---|
| Static HTML / custom publishing | `standalone` |
| Standalone + Vite | `standalone@vite` |
| Standalone + Webpack 5 | `standalone@webpack` |
| React + Vite | `react@vite` | | React + Vite | `react@vite` |
| React + Webpack 5 | `react@webpack` | | React + Webpack 5 | `react@webpack` |
| Next.js App Router + Turbopack | `next@app/turbopack` | | Next.js App Router + Turbopack | `next@app/turbopack` |
@@ -24,4 +27,4 @@ The CLI accepts exactly one path. A `.ts`, `.js`, or `.json` file loads that exa
} }
``` ```
Do not use incomplete `react`, `next@app`, or `next@pages` keys, the removed `legacy` mode, or the not-yet-implemented `standalone` mode. Create one command per config file or directory when the project has multiple sprites. Do not use incomplete `react`, `next@app`, `next@pages`, or `standalone@` keys, or the removed `legacy` mode. Use bare `standalone` only when the application publishes the SVG itself; use the complete Vite/Webpack key otherwise. Create one command per config file or directory when the project has multiple sprites.

View File

@@ -1,6 +1,6 @@
## Generated directory contract ## Generated directory contract
After generation, the selected directory has this structure: After generation, a React/Next directory has this structure:
```text ```text
svg-sprite/ svg-sprite/
@@ -23,6 +23,10 @@ svg-sprite/
└── react-component.module.css └── react-component.module.css
``` ```
Standalone does not create `react/`. Bare `standalone` generates `sprite.svg` and
`svg-sprite.manifest.json`; `standalone@vite`/`standalone@webpack` additionally
generate `index.*`, `icon-data.*`, and a resolved manifest.
Edit the source SVGs, selected config, and user-owned `index.ts`. Do not manually change `.gitignore` or anything in `.svg-sprite`: the next generation will overwrite them. To import from the sprite-module root, create a barrel: Edit the source SVGs, selected config, and user-owned `index.ts`. Do not manually change `.gitignore` or anything in `.svg-sprite`: the next generation will overwrite them. To import from the sprite-module root, create a barrel:
```ts ```ts
@@ -36,9 +40,11 @@ The internal `index.js` exports the component from `react/react-component.js` an
The sprite remains a separate content-hashed asset; SVG path data is not embedded in JavaScript: The sprite remains a separate content-hashed asset; SVG path data is not embedded in JavaScript:
- `react@vite` generates a static `sprite.svg?no-inline` import, preventing Vite from inlining it; - `react@vite` generates a static `sprite.svg?no-inline` import, preventing Vite from inlining it;
- `standalone@vite` uses the same Vite asset mechanism but exports an href helper without React;
- `standalone@webpack` uses Webpack Asset Modules without React;
- React Webpack 5 and all Next modes generate `new URL('./sprite.svg', import.meta.url).href`, which must be processed by the selected bundler's Asset Modules; - React Webpack 5 and all Next modes generate `new URL('./sprite.svg', import.meta.url).href`, which must be processed by the selected bundler's Asset Modules;
- a custom Webpack SVG loader must not intercept the generated `sprite.svg`; - a custom Webpack SVG loader must not intercept the generated `sprite.svg`;
- in Next mode, the generated component does not contain `'use client'` and works in Server Components, SSR, and SSG; do not add a client boundary solely for an icon; - in Next mode, the generated component does not contain `'use client'` and works in Server Components, SSR, and SSG; do not add a client boundary solely for an icon;
- the Next build command and mode key must agree: Turbopack with `.../turbopack`, Webpack with `.../webpack`. - the Next build command and mode key must agree: Turbopack with `.../turbopack`, Webpack with `.../webpack`.
Do not move the generated sprite into `public` or rewrite its URL manually. When changing the router or bundler, regenerate the sprite with the new full mode key. For bundler modes, do not move the generated sprite into `public` or rewrite its URL manually. For bare `standalone`, do not move the managed original: the application may explicitly copy it into deploy output and owns the public URL and stale-copy cleanup. Regenerate with the new complete key when changing mode.

View File

@@ -1,15 +1,15 @@
## Что делает пакет ## Что делает пакет
`@gromlab/svg-sprites` — CLI-генератор SVG-спрайтов для пользовательских SVG-файлов. Пакет не содержит собственного набора иконок: он собирает SVG проекта во внешний кешируемый sprite asset и для React/Next.js создаёт типизированный компонент, список допустимых имён и debug manifest. `@gromlab/svg-sprites` — CLI-генератор SVG-спрайтов для пользовательских SVG-файлов. Пакет не содержит собственного набора иконок: он собирает SVG проекта во внешний sprite asset, поддерживает standalone-проекты и для React/Next.js создаёт типизированный компонент.
Пакет рассчитан на несколько независимых спрайтов в одном проекте. Каждый явно выбранный config-файл или config-less каталог описывает один спрайт и получает собственные: Пакет рассчитан на несколько независимых спрайтов в одном проекте. Каждый явно выбранный config-файл или config-less каталог описывает один спрайт и получает собственные:
- SVG asset; - SVG asset;
- типы имён иконок; - mode-specific manifest data;
- React-компонент; - для bundler modes — типы имён и production entry `.svg-sprite/index.js`;
- production entry `.svg-sprite/index.js` с `.d.ts`; - только для React/Next.js — React-компонент;
- debug entry `.svg-sprite/svg-sprite.manifest.js` с `.d.ts`. - для bare `standalone` — deployment-neutral JSON manifest без публичного URL.
Количество и расположение каталогов определяет проект. Например, `name: 'file-manager'` создаёт `FileManagerIcon`, а другой каталог с `name: 'navigation'` создаст отдельный `NavigationIcon`. Имена `FileManagerIcon` и `fileManagerIconNames` ниже являются примерами API одного из возможных спрайтов, а не фиксированными экспортами пакета. Количество и расположение каталогов определяет проект. Например, `name: 'file-manager'` создаёт `FileManagerIcon`, а другой каталог с `name: 'navigation'` создаст отдельный `NavigationIcon`. Имена `FileManagerIcon` и `fileManagerIconNames` ниже являются примерами API одного из возможных спрайтов, а не фиксированными экспортами пакета.
Generated production-компоненты не импортируют `@gromlab/svg-sprites` во время выполнения. Устанавливай пакет как development dependency, чтобы config helpers и локальный CLI использовали версию из lockfile проекта. Generated production runtime не импортирует `@gromlab/svg-sprites` во время выполнения. Устанавливай пакет как development dependency, чтобы config helpers и локальный CLI использовали версию из lockfile проекта.

View File

@@ -4,6 +4,9 @@
| Проект | Mode key | | Проект | Mode key |
|---|---| |---|---|
| Static HTML / собственная публикация | `standalone` |
| Standalone + Vite | `standalone@vite` |
| Standalone + Webpack 5 | `standalone@webpack` |
| React + Vite | `react@vite` | | React + Vite | `react@vite` |
| React + Webpack 5 | `react@webpack` | | React + Webpack 5 | `react@webpack` |
| Next.js App Router + Turbopack | `next@app/turbopack` | | Next.js App Router + Turbopack | `next@app/turbopack` |
@@ -24,4 +27,4 @@ CLI принимает ровно один путь. Путь к файлу `.ts
} }
``` ```
Не используй неполные `react`, `next@app`, `next@pages`, удалённый `legacy` или ещё не реализованный `standalone`. Для нескольких спрайтов создай отдельную команду для каждого config-файла или каталога. Не используй неполные `react`, `next@app`, `next@pages`, `standalone@` или удалённый `legacy`. Bare `standalone` выбирай только когда приложение само публикует SVG; для Vite/Webpack используй соответствующий полный key. Для нескольких спрайтов создай отдельную команду для каждого config-файла или каталога.

View File

@@ -1,6 +1,6 @@
## Контракт generated-каталога ## Контракт generated-каталога
После генерации выбранный каталог имеет следующий вид: После генерации React/Next-каталог имеет следующий вид:
```text ```text
svg-sprite/ svg-sprite/
@@ -23,6 +23,10 @@ svg-sprite/
└── react-component.module.css └── react-component.module.css
``` ```
Standalone не создаёт `react/`. Bare `standalone` генерирует `sprite.svg` и
`svg-sprite.manifest.json`; `standalone@vite`/`standalone@webpack` дополнительно
генерируют `index.*`, `icon-data.*` и resolved manifest.
Редактируй исходные SVG, config-файл и пользовательский `index.ts`. Не изменяй вручную `.gitignore` и содержимое `.svg-sprite`: повторная генерация их перезапишет. Для импорта из корня sprite-модуля создай barrel: Редактируй исходные SVG, config-файл и пользовательский `index.ts`. Не изменяй вручную `.gitignore` и содержимое `.svg-sprite`: повторная генерация их перезапишет. Для импорта из корня sprite-модуля создай barrel:
```ts ```ts
@@ -36,9 +40,11 @@ export * from './.svg-sprite'
Спрайт остаётся отдельным asset с content hash; SVG path-данные не встраиваются в JavaScript: Спрайт остаётся отдельным asset с content hash; SVG path-данные не встраиваются в JavaScript:
- `react@vite` генерирует статический импорт `sprite.svg?no-inline`, запрещающий Vite inline; - `react@vite` генерирует статический импорт `sprite.svg?no-inline`, запрещающий Vite inline;
- `standalone@vite` использует тот же Vite asset-механизм, но экспортирует href helper без React;
- `standalone@webpack` использует Webpack Asset Modules без React;
- React Webpack 5 и все Next modes генерируют `new URL('./sprite.svg', import.meta.url).href`, который должен обработать Asset Modules соответствующего сборщика; - React Webpack 5 и все Next modes генерируют `new URL('./sprite.svg', import.meta.url).href`, который должен обработать Asset Modules соответствующего сборщика;
- кастомный Webpack SVG loader не должен перехватывать generated `sprite.svg`; - кастомный Webpack SVG loader не должен перехватывать generated `sprite.svg`;
- в Next mode generated-компонент не содержит `'use client'` и работает в Server Components, SSR и SSG; не добавляй клиентскую границу только ради иконки; - в Next mode generated-компонент не содержит `'use client'` и работает в Server Components, SSR и SSG; не добавляй клиентскую границу только ради иконки;
- команда сборки Next и mode key должны совпадать: Turbopack с `.../turbopack`, Webpack с `.../webpack`. - команда сборки Next и mode key должны совпадать: Turbopack с `.../turbopack`, Webpack с `.../webpack`.
Не перемещай generated sprite в `public` и не переписывай URL вручную. При смене роутера или сборщика перегенерируй спрайт с новым полным mode key. Для bundler modes не перемещай generated sprite в `public` и не переписывай URL вручную. Для bare `standalone` не перемещай managed original: приложение может явно копировать его в deploy output и само отвечает за публичный URL и очистку копии. При смене mode перегенерируй спрайт с новым полным key.

View File

@@ -11,6 +11,9 @@ export const CLI_USAGE = [
' A directory enables config-less generation from CLI options', ' A directory enables config-less generation from CLI options',
'', '',
'Modes:', 'Modes:',
' standalone',
' standalone@vite',
' standalone@webpack',
' react@vite', ' react@vite',
' react@webpack', ' react@webpack',
' next@app/turbopack', ' next@app/turbopack',

View File

@@ -17,6 +17,9 @@ const CONFIG_FIELDS = new Set([
]) ])
const TRANSFORM_FIELDS = new Set(['removeSize', 'replaceColors', 'addTransition']) const TRANSFORM_FIELDS = new Set(['removeSize', 'replaceColors', 'addTransition'])
const MODES = new Set<SpriteMode>([ const MODES = new Set<SpriteMode>([
'standalone',
'standalone@vite',
'standalone@webpack',
'react@vite', 'react@vite',
'react@webpack', 'react@webpack',
'next@app/turbopack', 'next@app/turbopack',

View File

@@ -3,6 +3,7 @@ import type {
NextBundler, NextBundler,
NextRouter, NextRouter,
ReactAssetTarget, ReactAssetTarget,
StandaloneAssetTarget,
SpriteMode, SpriteMode,
} from '../targets/types.js' } from '../targets/types.js'
import type { ResolvedSpriteConfig, SpriteFolder } from '../types.js' import type { ResolvedSpriteConfig, SpriteFolder } from '../types.js'
@@ -33,7 +34,11 @@ export type NextModeResultMetadata = {
readonly bundler: NextBundler readonly bundler: NextBundler
} }
export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadata export type StandaloneModeResultMetadata = {
readonly target: StandaloneAssetTarget
}
export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadata | StandaloneModeResultMetadata
export type OutputPlan = { export type OutputPlan = {
readonly files: readonly GeneratedFile[] readonly files: readonly GeneratedFile[]
@@ -41,7 +46,7 @@ export type OutputPlan = {
readonly generatedDir: '.svg-sprite' readonly generatedDir: '.svg-sprite'
readonly sprite: string readonly sprite: string
readonly manifest: string readonly manifest: string
readonly entry: string readonly entry?: string
} }
readonly result: ModeResultMetadata readonly result: ModeResultMetadata
} }

View File

@@ -33,7 +33,10 @@ export async function generateSprite(
}) })
const plannedPaths = new Set(plan.files.map((file) => file.path.replaceAll('\\', '/'))) const plannedPaths = new Set(plan.files.map((file) => file.path.replaceAll('\\', '/')))
for (const requiredPath of [plan.paths.entry, plan.paths.sprite, plan.paths.manifest]) { const resultPaths = [plan.paths.sprite, plan.paths.manifest]
if (plan.paths.entry) resultPaths.push(plan.paths.entry)
for (const requiredPath of resultPaths) {
if (!plannedPaths.has(requiredPath)) { if (!plannedPaths.has(requiredPath)) {
throw new Error(`Mode "${config.mode}" result path is missing from its output plan: ${requiredPath}`) throw new Error(`Mode "${config.mode}" result path is missing from its output plan: ${requiredPath}`)
} }

View File

@@ -23,11 +23,21 @@ export type {
NextRouter, NextRouter,
ReactAssetTarget, ReactAssetTarget,
ReactSpriteMode, ReactSpriteMode,
StandaloneAssetTarget,
StandaloneSpriteMode,
StaticAssetTarget,
SpriteAssetTarget, SpriteAssetTarget,
SpriteMode, SpriteMode,
ViteAssetTarget, ViteAssetTarget,
WebpackAssetTarget, WebpackAssetTarget,
} from './targets/types.js' } from './targets/types.js'
export type {
StandaloneSpriteManifest,
StandaloneSpriteManifestColor,
StandaloneSpriteManifestData,
StandaloneSpriteManifestIcon,
StandaloneTargetForMode,
} from './manifest-types.js'
export type { export type {
ResolvedSpriteConfig, ResolvedSpriteConfig,
SpriteConfig, SpriteConfig,

42
src/manifest-types.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { StandaloneSpriteMode } from './targets/types.js'
export type StandaloneSpriteManifestColor = {
variable: `--icon-color-${number}`
fallback: string
}
export type StandaloneSpriteManifestIcon = {
name: string
id: string
viewBox: string | null
colors: readonly StandaloneSpriteManifestColor[]
}
export type StandaloneTargetForMode<M extends StandaloneSpriteMode> =
M extends 'standalone'
? 'static'
: M extends 'standalone@vite'
? 'vite'
: 'webpack'
/** Deployment-neutral данные standalone-спрайта. */
export type StandaloneSpriteManifestData<
M extends StandaloneSpriteMode = StandaloneSpriteMode,
> = {
schemaVersion: 1
generator: '@gromlab/svg-sprites'
name: string
description?: string
mode: M
target: StandaloneTargetForMode<M>
format: 'stack' | 'symbol'
iconCount: number
icons: readonly StandaloneSpriteManifestIcon[]
}
/** Standalone manifest с публичным URL опубликованного SVG. */
export type StandaloneSpriteManifest<
M extends StandaloneSpriteMode = StandaloneSpriteMode,
> = StandaloneSpriteManifestData<M> & {
spriteUrl: string
}

View File

@@ -5,9 +5,15 @@ import { nextPagesTurbopackAdapter } from './modes/next-pages-turbopack/adapter.
import { nextPagesWebpackAdapter } from './modes/next-pages-webpack/adapter.js' import { nextPagesWebpackAdapter } from './modes/next-pages-webpack/adapter.js'
import { reactViteAdapter } from './modes/react-vite/adapter.js' import { reactViteAdapter } from './modes/react-vite/adapter.js'
import { reactWebpackAdapter } from './modes/react-webpack/adapter.js' import { reactWebpackAdapter } from './modes/react-webpack/adapter.js'
import { standaloneAdapter } from './modes/standalone/adapter.js'
import { standaloneViteAdapter } from './modes/standalone-vite/adapter.js'
import { standaloneWebpackAdapter } from './modes/standalone-webpack/adapter.js'
import type { SpriteMode } from './targets/types.js' import type { SpriteMode } from './targets/types.js'
const modeRegistry: Record<SpriteMode, ModeAdapter> = { const modeRegistry: Record<SpriteMode, ModeAdapter> = {
standalone: standaloneAdapter,
'standalone@vite': standaloneViteAdapter,
'standalone@webpack': standaloneWebpackAdapter,
'react@vite': reactViteAdapter, 'react@vite': reactViteAdapter,
'react@webpack': reactWebpackAdapter, 'react@webpack': reactWebpackAdapter,
'next@app/turbopack': nextAppTurbopackAdapter, 'next@app/turbopack': nextAppTurbopackAdapter,

View File

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

View File

@@ -0,0 +1,159 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'standalone@vite'
const TARGET = 'vite'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function pascal(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function camel(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function header(enabled: boolean): string {
return enabled
? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */`
: `/* ${GENERATED_MARKER}. Do not edit. */`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function description(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [
header(config.generatedNotice),
'',
...description(config),
`export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`,
'',
`export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`,
'',
].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const name = `${pascal(config.name)}IconName`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'',
...description(config),
`export declare const ${names}: readonly [`,
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
']',
`export type ${name} = typeof ${names}[number]`,
`export declare const ${ids}: {`,
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
'}',
'',
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
return [
header(config.generatedNotice),
"import spriteUrl from './sprite.svg?no-inline'",
`import { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
'',
`export { ${prefix}IconIds, ${prefix}IconNames }`,
`export const ${prefix}SpriteUrl = spriteUrl`,
`export function get${pascalName}IconHref(icon) {`,
` return ${prefix}SpriteUrl + '#' + ${prefix}IconIds[icon]`,
'}',
'',
].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
return [
header(config.generatedNotice),
`import type { ${pascalName}IconName } from './icon-data.js'`,
'',
`export { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
`export type { ${pascalName}IconName } from './icon-data.js'`,
`export declare const ${prefix}SpriteUrl: string`,
`export declare function get${pascalName}IconHref(icon: ${pascalName}IconName): string`,
'',
].join('\n')
}
function manifestData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact) {
return {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
icons: artifact.icons,
}
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
return [
header(config.generatedNotice),
"import spriteUrl from './sprite.svg?no-inline'",
'',
`export const spriteManifestData = ${JSON.stringify(manifestData(config, artifact), null, 2)}`,
'export function createSpriteManifest(publicSpriteUrl) {',
' return { ...spriteManifestData, spriteUrl: publicSpriteUrl }',
'}',
'export const spriteManifest = createSpriteManifest(spriteUrl)',
'',
'export default spriteManifest',
'',
].join('\n')
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
"import type { StandaloneSpriteManifest, StandaloneSpriteManifestData } from '@gromlab/svg-sprites'",
'',
"export declare const spriteManifestData: StandaloneSpriteManifestData<'standalone@vite'>",
"export declare function createSpriteManifest(spriteUrl: string): StandaloneSpriteManifest<'standalone@vite'>",
"export declare const spriteManifest: StandaloneSpriteManifest<'standalone@vite'>",
'export default spriteManifest',
'',
].join('\n')
}
export function generateOutputFiles(
config: ResolvedSpriteConfig,
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

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

View File

@@ -0,0 +1,158 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'standalone@webpack'
const TARGET = 'webpack'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function pascal(value: string): string {
return value.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('')
}
function camel(value: string): string {
return value.replace(/-([a-z0-9])/g, (_, char: string) => char.toUpperCase())
}
function header(enabled: boolean): string {
return enabled
? `/*\n * ${NOTICE}\n * ${GENERATED_MARKER}.\n */`
: `/* ${GENERATED_MARKER}. Do not edit. */`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function description(config: ResolvedSpriteConfig): string[] {
const text = config.description ?? `Имена иконок SVG-спрайта «${config.name}».`
return ['/**', ...text.split(/\r?\n/).map((line) => ` * ${line.replace(/\*\//g, '* /')}`), ' */']
}
function iconData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const prefix = camel(config.name)
return [
header(config.generatedNotice),
'',
...description(config),
`export const ${prefix}IconNames = ${JSON.stringify(artifact.icons.map((icon) => icon.name), null, 2)}`,
'',
`export const ${prefix}IconIds = ${JSON.stringify(Object.fromEntries(artifact.icons.map((icon) => [icon.name, icon.id])), null, 2)}`,
'',
].join('\n')
}
function iconDeclarations(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
const name = `${pascal(config.name)}IconName`
const names = `${camel(config.name)}IconNames`
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'',
...description(config),
`export declare const ${names}: readonly [`,
...artifact.icons.map((icon) => ` ${JSON.stringify(icon.name)},`),
']',
`export type ${name} = typeof ${names}[number]`,
`export declare const ${ids}: {`,
...artifact.icons.map((icon) => ` readonly ${JSON.stringify(icon.name)}: ${JSON.stringify(icon.id)}`),
'}',
'',
].join('\n')
}
function index(config: ResolvedSpriteConfig): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
return [
header(config.generatedNotice),
`import { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
'',
`export { ${prefix}IconIds, ${prefix}IconNames }`,
`export const ${prefix}SpriteUrl = new URL('./sprite.svg', import.meta.url).href`,
`export function get${pascalName}IconHref(icon) {`,
` return ${prefix}SpriteUrl + '#' + ${prefix}IconIds[icon]`,
'}',
'',
].join('\n')
}
function indexDeclarations(config: ResolvedSpriteConfig): string {
const prefix = camel(config.name)
const pascalName = pascal(config.name)
return [
header(config.generatedNotice),
`import type { ${pascalName}IconName } from './icon-data.js'`,
'',
`export { ${prefix}IconIds, ${prefix}IconNames } from './icon-data.js'`,
`export type { ${pascalName}IconName } from './icon-data.js'`,
`export declare const ${prefix}SpriteUrl: string`,
`export declare function get${pascalName}IconHref(icon: ${pascalName}IconName): string`,
'',
].join('\n')
}
function manifestData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact) {
return {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
icons: artifact.icons,
}
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
return [
header(config.generatedNotice),
"const spriteUrl = new URL('./sprite.svg', import.meta.url).href",
'',
`export const spriteManifestData = ${JSON.stringify(manifestData(config, artifact), null, 2)}`,
'export function createSpriteManifest(publicSpriteUrl) {',
' return { ...spriteManifestData, spriteUrl: publicSpriteUrl }',
'}',
'export const spriteManifest = createSpriteManifest(spriteUrl)',
'',
'export default spriteManifest',
'',
].join('\n')
}
function manifestTypes(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
"import type { StandaloneSpriteManifest, StandaloneSpriteManifestData } from '@gromlab/svg-sprites'",
'',
"export declare const spriteManifestData: StandaloneSpriteManifestData<'standalone@webpack'>",
"export declare function createSpriteManifest(spriteUrl: string): StandaloneSpriteManifest<'standalone@webpack'>",
"export declare const spriteManifest: StandaloneSpriteManifest<'standalone@webpack'>",
'export default spriteManifest',
'',
].join('\n')
}
export function generateOutputFiles(
config: ResolvedSpriteConfig,
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.js'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.js'), content: iconData(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'icon-data.d.ts'), content: iconDeclarations(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.js'), content: manifest(config, artifact) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.d.ts'), content: manifestTypes(config) },
]
}

View File

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

View File

@@ -0,0 +1,53 @@
import path from 'node:path'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { GENERATED_MARKER } from '../../core/generated-markers.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const MODE = 'standalone'
const TARGET = 'static'
const OUTPUT_DIR = '.svg-sprite'
const NOTICE = 'АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ. Не редактируйте вручную.'
function generated(enabled: boolean): string {
return enabled
? `${NOTICE} ${GENERATED_MARKER}.`
: `${GENERATED_MARKER}. Do not edit.`
}
function svg(bytes: Uint8Array, enabled: boolean): string {
const marker = enabled ? `${NOTICE}\n ${GENERATED_MARKER}.` : `${GENERATED_MARKER}. Do not edit.`
const content = new TextDecoder().decode(bytes)
return content.startsWith('<?xml')
? content.replace(/^(<\?xml[^?]*\?>)\s*/, `$1\n<!-- ${marker} -->\n`)
: `<!-- ${marker} -->\n${content}`
}
function manifestData(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact) {
return {
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
generated: generated(config.generatedNotice),
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
mode: MODE,
target: TARGET,
format: artifact.format,
iconCount: artifact.icons.length,
icons: artifact.icons,
}
}
function manifest(config: ResolvedSpriteConfig, artifact: CompiledSpriteArtifact): string {
return `${JSON.stringify(manifestData(config, artifact), null, 2)}\n`
}
export function generateOutputFiles(
config: ResolvedSpriteConfig,
artifact: CompiledSpriteArtifact,
): GeneratedFile[] {
return [
{ path: path.posix.join(OUTPUT_DIR, 'sprite.svg'), content: svg(artifact.bytes, config.generatedNotice) },
{ path: path.posix.join(OUTPUT_DIR, 'svg-sprite.manifest.json'), content: manifest(config, artifact) },
]
}

View File

@@ -1,4 +1,4 @@
import type { SpriteAssetTarget, SpriteMode } from '../targets/types.js' import type { NextAssetTarget, ReactAssetTarget, ReactSpriteMode } from '../targets/types.js'
export type SpriteManifestColor = { export type SpriteManifestColor = {
variable: `--icon-color-${number}` variable: `--icon-color-${number}`
@@ -19,8 +19,8 @@ export type SpriteManifest = {
name: string name: string
description?: string description?: string
componentName: string componentName: string
mode?: SpriteMode mode?: ReactSpriteMode | NextAssetTarget
target: SpriteAssetTarget target: ReactAssetTarget | NextAssetTarget
format: 'stack' | 'symbol' format: 'stack' | 'symbol'
iconCount: number iconCount: number
spriteUrl: string spriteUrl: string

View File

@@ -14,6 +14,9 @@ export type ViteAssetTarget = 'vite'
*/ */
export type WebpackAssetTarget = 'webpack' export type WebpackAssetTarget = 'webpack'
/** Asset target для проекта без сборщика. */
export type StaticAssetTarget = 'static'
/** Роутер Next.js, для которого генерируется sprite-модуль. */ /** Роутер Next.js, для которого генерируется sprite-модуль. */
export type NextRouter = 'app' | 'pages' export type NextRouter = 'app' | 'pages'
@@ -34,11 +37,17 @@ export type ReactAssetTarget = ViteAssetTarget | WebpackAssetTarget
/** Полный ключ React mode, используемый конфигом, CLI и manifest. */ /** Полный ключ React mode, используемый конфигом, CLI и manifest. */
export type ReactSpriteMode = `react@${ReactAssetTarget}` export type ReactSpriteMode = `react@${ReactAssetTarget}`
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */ /** Среда standalone sprite-модуля. */
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget export type StandaloneAssetTarget = StaticAssetTarget | ViteAssetTarget | WebpackAssetTarget
/** Режим генерации sprite-модуля. В будущем расширяется standalone mode. */ /** Полный ключ standalone mode, используемый конфигом, CLI и manifest. */
export type SpriteMode = ReactSpriteMode | NextAssetTarget export type StandaloneSpriteMode = 'standalone' | `standalone@${ViteAssetTarget | WebpackAssetTarget}`
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget | StaticAssetTarget
/** Режим генерации sprite-модуля. */
export type SpriteMode = ReactSpriteMode | NextAssetTarget | StandaloneSpriteMode
/** Фрагменты кода, необходимые компоненту для получения URL SVG asset. */ /** Фрагменты кода, необходимые компоненту для получения URL SVG asset. */
export type SpriteAssetUrlCode = { export type SpriteAssetUrlCode = {

View File

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

View File

@@ -0,0 +1,243 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { generateNextSprite, generateReactSprite } from '../dist/index.js'
const GENERATED_FILES = [
'icon-data.d.ts',
'icon-data.js',
'index.d.ts',
'index.js',
'react',
'sprite.svg',
'state.json',
'svg-sprite.manifest.d.ts',
'svg-sprite.manifest.js',
]
function createReactFixture(config = {}) {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-'))
const rootDir = path.join(temporaryDir, 'src', 'widgets', 'file-manager', 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
const configPath = path.join(rootDir, 'svg-sprite.config.ts')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(configPath, `export default ${JSON.stringify({
name: 'file-manager',
description: 'File manager icons',
...config,
}, null, 2)}\n`)
fs.writeFileSync(
path.join(iconsDir, 'check.svg'),
'<svg viewBox="0 0 16 16"><path fill="none" stroke="#123456" d="M1 8l4 4L15 2" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 2h6l2 2h6v10H1z" /></svg>',
)
fs.writeFileSync(
path.join(iconsDir, 'folder open.svg'),
'<svg viewBox="0 0 16 16"><path fill="#000" d="M1 3h14v11H1z" /></svg>',
)
return { rootDir, iconsDir, configPath }
}
test('generates the current isolated React Vite contract', async () => {
const { rootDir, configPath } = createReactFixture()
const result = await generateReactSprite(configPath, 'vite')
const generatedDir = path.join(rootDir, '.svg-sprite')
assert.equal(result.name, 'file-manager')
assert.equal(result.iconCount, 3)
assert.equal(result.target, 'vite')
assert.equal(result.manifestPath, path.join(generatedDir, 'svg-sprite.manifest.js'))
assert.deepEqual(fs.readdirSync(generatedDir).sort(), GENERATED_FILES)
const gitignore = fs.readFileSync(path.join(rootDir, '.gitignore'), 'utf8')
assert.equal(gitignore, '# @generated by @gromlab/svg-sprites. Do not edit.\n/.svg-sprite/\n')
const sprite = fs.readFileSync(result.spritePath, 'utf8')
assert.match(sprite, /АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ/)
assert.match(sprite, /<svg[^>]+id="check"/)
assert.match(sprite, /<svg[^>]+id="folder"/)
assert.doesNotMatch(sprite, /<symbol/)
const unsafeId = sprite.match(/<svg[^>]+id="(icon-[a-f0-9]{16})"/)?.[1]
assert.ok(unsafeId)
const component = fs.readFileSync(path.join(generatedDir, 'react', 'react-component.js'), 'utf8')
const componentTypes = fs.readFileSync(path.join(generatedDir, 'react', 'react-component.d.ts'), 'utf8')
const iconData = fs.readFileSync(path.join(generatedDir, 'icon-data.js'), 'utf8')
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
assert.match(component, /export const FileManagerIcon/)
assert.match(component, /sprite\.svg\?no-inline/)
assert.match(componentTypes, /FileManagerIconStyle/)
assert.match(componentTypes, /--icon-color-/)
assert.ok(iconData.includes(`"folder open": "${unsafeId}"`))
assert.match(manifest, /"componentName": "FileManagerIcon"/)
assert.match(manifest, /"mode": "react@vite"/)
assert.match(manifest, /"target": "vite"/)
assert.match(manifest, /"iconCount": 3/)
const state = JSON.parse(fs.readFileSync(path.join(generatedDir, 'state.json'), 'utf8'))
assert.deepEqual(state.owner, { mode: 'react@vite', contractVersion: 5 })
assert.equal(state.files.includes('.svg-sprite/react/react-component.js'), true)
})
test('supports Webpack target and a custom icons directory', async () => {
const { rootDir, configPath } = createReactFixture({
name: 'documents',
inputFolder: './assets',
generatedNotice: false,
})
fs.renameSync(path.join(rootDir, 'icons'), path.join(rootDir, 'assets'))
const result = await generateReactSprite(configPath, 'webpack')
const component = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.js'), 'utf8')
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
assert.equal(result.name, 'documents')
assert.equal(result.iconCount, 3)
assert.equal(result.target, 'webpack')
assert.match(component, /@generated by @gromlab\/svg-sprites/)
assert.doesNotMatch(component, /АВТОМАТИЧЕСКИ/)
assert.match(component, /DocumentsIcon/)
assert.match(component, /new URL\('\.\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.match(manifest, /"target": "webpack"/)
})
test('merges inputFolder and inputFiles and deduplicates identical paths', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-inputs-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const iconsDir = path.join(rootDir, 'icons')
const sharedIcon = path.join(temporaryDir, 'shared.svg')
fs.mkdirSync(iconsDir, { recursive: true })
fs.writeFileSync(path.join(iconsDir, 'local.svg'), '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M1 1h14v14H1z" /></svg>')
const configPath = path.join(rootDir, 'config.ts')
fs.writeFileSync(configPath, `export default { name: 'mixed-inputs', inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}, ${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`)
const result = await generateReactSprite(configPath, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf8')
assert.equal(result.name, 'mixed-inputs')
assert.equal(result.iconCount, 2)
assert.match(sprite, /id="local"/)
assert.match(sprite, /id="shared"/)
})
test('supports inputFiles without the default icons directory', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-files-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const sharedIcon = path.join(temporaryDir, 'shared', 'only.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(sharedIcon), { recursive: true })
fs.writeFileSync(sharedIcon, '<svg viewBox="0 0 16 16"><path d="M0 0h16v16H0z" /></svg>')
const configPath = path.join(rootDir, 'config.ts')
fs.writeFileSync(configPath, `export default { inputFiles: [${JSON.stringify(path.relative(rootDir, sharedIcon))}] }`)
const result = await generateReactSprite(configPath, 'webpack')
assert.equal(result.iconCount, 1)
assert.match(fs.readFileSync(result.spritePath, 'utf8'), /id="only"/)
})
test('rejects duplicate names and explicitly missing input folders', async () => {
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-react-input-errors-'))
const rootDir = path.join(temporaryDir, 'svg-sprite')
const first = path.join(temporaryDir, 'one', 'shared.svg')
const second = path.join(temporaryDir, 'two', 'shared.svg')
fs.mkdirSync(rootDir, { recursive: true })
fs.mkdirSync(path.dirname(first), { recursive: true })
fs.mkdirSync(path.dirname(second), { recursive: true })
fs.writeFileSync(first, '<svg viewBox="0 0 16 16"><path /></svg>')
fs.writeFileSync(second, '<svg viewBox="0 0 16 16"><path /></svg>')
const duplicateConfig = path.join(rootDir, 'duplicate.ts')
fs.writeFileSync(duplicateConfig, `export default { inputFiles: [${JSON.stringify(path.relative(rootDir, first))}, ${JSON.stringify(path.relative(rootDir, second))}] }`)
await assert.rejects(generateReactSprite(duplicateConfig, 'vite'), /produce the same SVG id "shared"/)
const missingConfig = path.join(rootDir, 'missing.ts')
fs.writeFileSync(missingConfig, `export default { inputFolder: './missing', inputFiles: [${JSON.stringify(path.relative(rootDir, first))}] }`)
await assert.rejects(generateReactSprite(missingConfig, 'vite'), /Input directory does not exist/)
})
test('applies transform options to SVG and React styles', async () => {
const { rootDir, configPath } = createReactFixture({
transform: { removeSize: false, replaceColors: false, addTransition: false },
})
fs.writeFileSync(
path.join(rootDir, 'icons', 'plain.svg'),
'<svg width="24" height="24" viewBox="0 0 24 24"><path fill="#123456" d="M0 0h24v24H0z" /></svg>',
)
const result = await generateReactSprite(configPath, 'vite')
const sprite = fs.readFileSync(result.spritePath, 'utf8')
const styles = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.module.css'), 'utf8')
assert.match(sprite, /width="24"/)
assert.match(sprite, /fill="#(?:123456|123)"/)
assert.doesNotMatch(sprite, /--icon-color-/)
assert.doesNotMatch(styles, /transition-/)
})
test('generates every Next.js exact mode without a client boundary', async () => {
const targets = [
{ router: 'app', bundler: 'turbopack' },
{ router: 'app', bundler: 'webpack' },
{ router: 'pages', bundler: 'turbopack' },
{ router: 'pages', bundler: 'webpack' },
]
for (const options of targets) {
const { configPath } = createReactFixture()
const result = await generateNextSprite(configPath, options)
const component = fs.readFileSync(path.join(result.generatedDir, 'react', 'react-component.js'), 'utf8')
const manifest = fs.readFileSync(result.manifestPath, 'utf8')
const mode = `next@${options.router}/${options.bundler}`
assert.equal(result.target, mode)
assert.equal(result.router, options.router)
assert.equal(result.bundler, options.bundler)
assert.match(component, /new URL\('\.\.\/sprite\.svg', import\.meta\.url\)\.href/)
assert.doesNotMatch(component, /['"]use client['"]/)
assert.match(manifest, new RegExp(`"target": ${JSON.stringify(mode)}`))
}
})
test('writer removes only obsolete managed files and preserves user files', async () => {
const { rootDir, configPath } = createReactFixture()
await generateReactSprite(configPath, 'vite')
const statePath = path.join(rootDir, '.svg-sprite', 'state.json')
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'))
state.files.push('.svg-sprite/obsolete.js')
fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`)
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'obsolete.js'), '/* @generated by @gromlab/svg-sprites. */\n')
fs.writeFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'Keep me\n')
fs.writeFileSync(path.join(rootDir, 'index.ts'), 'export const userCode = true\n')
await generateReactSprite(configPath, 'vite')
assert.equal(fs.existsSync(path.join(rootDir, '.svg-sprite', 'obsolete.js')), false)
assert.equal(fs.readFileSync(path.join(rootDir, '.svg-sprite', 'notes.md'), 'utf8'), 'Keep me\n')
assert.equal(fs.readFileSync(path.join(rootDir, 'index.ts'), 'utf8'), 'export const userCode = true\n')
})
test('writer refuses user content and symbolic links in managed paths', async () => {
const first = createReactFixture()
fs.mkdirSync(path.join(first.rootDir, '.svg-sprite'))
fs.writeFileSync(path.join(first.rootDir, '.svg-sprite', 'index.js'), 'export const userCode = true\n')
await assert.rejects(generateReactSprite(first.configPath, 'vite'), /Refusing to overwrite a user file/)
const second = createReactFixture()
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'svg-sprites-outside-'))
fs.symlinkSync(outsideDir, path.join(second.rootDir, '.svg-sprite'), 'dir')
await assert.rejects(generateReactSprite(second.configPath, 'vite'), /Symbolic links are not allowed/)
assert.deepEqual(fs.readdirSync(outsideDir), [])
})
test('rejects colliding SVG shape IDs', async () => {
const { iconsDir, configPath } = createReactFixture()
const id = `icon-${createHash('sha256').update('folder open').digest('hex').slice(0, 16)}`
fs.writeFileSync(path.join(iconsDir, `${id}.svg`), '<svg viewBox="0 0 16 16"><path /></svg>')
await assert.rejects(generateReactSprite(configPath, 'vite'), /produce the same SVG id/)
})

View File

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

View File

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

View File

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