mirror of
https://github.com/gromlab-ru/svg-sprites.git
synced 2026-07-22 04:40:17 +03:00
feat: standalone mode
This commit is contained in:
@@ -10,7 +10,10 @@
|
||||
- `next@app/webpack`;
|
||||
- `next@pages/turbopack`;
|
||||
- `next@pages/webpack`;
|
||||
- будущие `vue@*`, `standalone` и другие modes.
|
||||
- `standalone`;
|
||||
- `standalone@vite`;
|
||||
- `standalone@webpack`;
|
||||
- будущие `vue@*` и другие modes.
|
||||
|
||||
Для каждого exact mode используется отдельный каталог `src/modes/<mode-slug>/`. Adapter самостоятельно определяет:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -225,9 +225,9 @@ JavaScript отвечает за интерфейс и поведение, а г
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 second argument contains optional overrides and always takes precedence over the config:
|
||||
|
||||
@@ -32,6 +32,9 @@ svg-sprites [options] <config-file-or-directory>
|
||||
|
||||
| Environment | Mode |
|
||||
|---|---|
|
||||
| Static HTML / custom publishing | `standalone` |
|
||||
| Standalone + Vite | `standalone@vite` |
|
||||
| Standalone + Webpack 5 | `standalone@webpack` |
|
||||
| React + Vite | `react@vite` |
|
||||
| React + Webpack 5 | `react@webpack` |
|
||||
| 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -105,7 +108,7 @@ Identical absolute paths are deduplicated. Different SVG files with the same fil
|
||||
|
||||
## Generated module
|
||||
|
||||
After generation, the sprite directory looks like this:
|
||||
After generation, a React or Next.js sprite directory looks like this:
|
||||
|
||||
```text
|
||||
app-icons/
|
||||
@@ -140,6 +143,19 @@ app-icons/
|
||||
| `.svg-sprite/*.d.ts` | TypeScript declarations for the corresponding JavaScript modules |
|
||||
| `.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:
|
||||
|
||||
```ts
|
||||
@@ -235,7 +251,7 @@ For multiple sprites, add a separate CLI command for each directory or combine t
|
||||
|
||||
## 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 |
|
||||
|---|---:|---:|---:|
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
@@ -306,12 +333,15 @@ The path in CSS is resolved relative to the CSS file itself. In these examples,
|
||||
|
||||
## 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`;
|
||||
- Webpack 5, Turbopack, and Next.js use `new URL(..., import.meta.url)`;
|
||||
- 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:
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Build and mode do not match: use the target that corresponds to the actual bundler.
|
||||
|
||||
|
||||
@@ -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-модуля становится этот каталог.
|
||||
|
||||
Второй аргумент содержит необязательные overrides и всегда имеет приоритет над конфигом:
|
||||
|
||||
@@ -32,6 +32,9 @@ svg-sprites [options] <config-file-or-directory>
|
||||
|
||||
| Среда | Mode |
|
||||
|---|---|
|
||||
| Static HTML / собственная публикация | `standalone` |
|
||||
| Standalone + Vite | `standalone@vite` |
|
||||
| Standalone + Webpack 5 | `standalone@webpack` |
|
||||
| React + Vite | `react@vite` |
|
||||
| React + Webpack 5 | `react@webpack` |
|
||||
| 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 должен соответствовать сборщику приложения. Генератор создаёт разный способ подключения 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-модуль
|
||||
|
||||
После генерации каталог спрайта выглядит так:
|
||||
После генерации React- или Next.js-каталог спрайта выглядит так:
|
||||
|
||||
```text
|
||||
app-icons/
|
||||
@@ -140,6 +143,19 @@ app-icons/
|
||||
| `.svg-sprite/*.d.ts` | TypeScript-декларации соответствующих JS-модулей |
|
||||
| `.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:
|
||||
|
||||
```ts
|
||||
@@ -235,7 +251,7 @@ editor-icons → EditorIcon → иконки редактора
|
||||
|
||||
## Форматы и способы отображения
|
||||
|
||||
React- и Next.js-режимы создают формат `stack`.
|
||||
Все текущие modes создают формат `stack`.
|
||||
|
||||
| Формат | `<svg><use>` | `<img>` | CSS background |
|
||||
|---|---:|---:|---:|
|
||||
@@ -253,6 +269,17 @@ React- и Next.js-режимы создают формат `stack`.
|
||||
|
||||
Способ получения `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:
|
||||
|
||||
```ts
|
||||
@@ -306,12 +333,15 @@ Mask не сохраняет исходные цвета, gradients и разл
|
||||
|
||||
## Assets и кеширование
|
||||
|
||||
Generated-компонент передаёт SVG сборщику как отдельный asset:
|
||||
Generated component или standalone facade передаёт SVG сборщику как отдельный asset:
|
||||
|
||||
- Vite использует статический импорт с `?no-inline`;
|
||||
- Webpack 5, Turbopack и Next.js используют `new URL(..., import.meta.url)`;
|
||||
- SVG path-данные не сериализуются в generated JavaScript.
|
||||
|
||||
Bare `standalone` не участвует в asset pipeline: приложение само копирует или
|
||||
публикует `sprite.svg` и отвечает за URL, версионирование и cache policy.
|
||||
|
||||
При стандартном именовании assets сборщик добавляет content hash:
|
||||
|
||||
```text
|
||||
@@ -401,7 +431,7 @@ fill="var(--icon-color-3, #129d9d)"
|
||||
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:
|
||||
|
||||
@@ -488,6 +518,7 @@ CI должен устанавливать development dependencies и выпо
|
||||
- `Refusing to overwrite a user file`: в managed-пути находится файл без generated marker.
|
||||
- Иконка не меняет цвет: используйте `<svg><use>` или generated-компонент и проверьте `replaceColors`.
|
||||
- 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` и выполните генерацию до запуска приложения.
|
||||
- Build и mode не совпадают: используйте target, соответствующий фактическому сборщику.
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ production build и отображения внешнего SVG-спрайта
|
||||
|
||||
| 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-webpack` | `react@webpack` | React + Webpack 5 |
|
||||
| `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 спрайта,
|
||||
наличие 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vanilla Vite sprite fixture</title>
|
||||
<title>Standalone Vite sprite fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@svg-sprites-fixtures/vanilla-vite",
|
||||
"name": "@svg-sprites-fixtures/standalone-vite",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"sprites": "svg-sprites --mode legacy .",
|
||||
"sprites": "svg-sprites src/sprite/svg-sprite.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "npm run sprites && tsc --noEmit && vite build",
|
||||
"dev": "npm run sprites && vite"
|
||||
20
integration/apps/standalone-vite/src/main.ts
Normal file
20
integration/apps/standalone-vite/src/main.ts
Normal 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>
|
||||
`
|
||||
2
integration/apps/standalone-vite/src/sprite/.gitignore
vendored
Normal file
2
integration/apps/standalone-vite/src/sprite/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# @generated by @gromlab/svg-sprites. Do not edit.
|
||||
/.svg-sprite/
|
||||
7
integration/apps/standalone-vite/src/sprite/index.ts
Normal file
7
integration/apps/standalone-vite/src/sprite/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
getIconsIconHref,
|
||||
iconsIconIds,
|
||||
iconsIconNames,
|
||||
iconsSpriteUrl,
|
||||
} from './.svg-sprite/index.js'
|
||||
export type { IconsIconName } from './.svg-sprite/index.js'
|
||||
@@ -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,
|
||||
})
|
||||
@@ -1,16 +1,12 @@
|
||||
:root {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #172033;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-testid='icon'] {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: #16a34a;
|
||||
--icon-color-1: #16a34a;
|
||||
}
|
||||
11
integration/apps/standalone-webpack/index.html
Normal file
11
integration/apps/standalone-webpack/index.html
Normal 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>
|
||||
21
integration/apps/standalone-webpack/package.json
Normal file
21
integration/apps/standalone-webpack/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
integration/apps/standalone-webpack/src/main.ts
Normal file
20
integration/apps/standalone-webpack/src/main.ts
Normal 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>
|
||||
`
|
||||
2
integration/apps/standalone-webpack/src/sprite/.gitignore
vendored
Normal file
2
integration/apps/standalone-webpack/src/sprite/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# @generated by @gromlab/svg-sprites. Do not edit.
|
||||
/.svg-sprite/
|
||||
7
integration/apps/standalone-webpack/src/sprite/index.ts
Normal file
7
integration/apps/standalone-webpack/src/sprite/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
getIconsIconHref,
|
||||
iconsIconIds,
|
||||
iconsIconNames,
|
||||
iconsSpriteUrl,
|
||||
} from './.svg-sprite/index.js'
|
||||
export type { IconsIconName } from './.svg-sprite/index.js'
|
||||
@@ -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,
|
||||
})
|
||||
11
integration/apps/standalone-webpack/tsconfig.json
Normal file
11
integration/apps/standalone-webpack/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
31
integration/apps/standalone-webpack/webpack.config.js
Normal file
31
integration/apps/standalone-webpack/webpack.config.js
Normal 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' }),
|
||||
],
|
||||
}
|
||||
13
integration/apps/standalone/build.mjs
Normal file
13
integration/apps/standalone/build.mjs
Normal 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),
|
||||
)
|
||||
20
integration/apps/standalone/index.html
Normal file
20
integration/apps/standalone/index.html
Normal 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>
|
||||
13
integration/apps/standalone/package.json
Normal file
13
integration/apps/standalone/package.json
Normal 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:../../.."
|
||||
}
|
||||
}
|
||||
2
integration/apps/standalone/src/sprite/.gitignore
vendored
Normal file
2
integration/apps/standalone/src/sprite/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# @generated by @gromlab/svg-sprites. Do not edit.
|
||||
/.svg-sprite/
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"mode": "standalone",
|
||||
"name": "app-icons",
|
||||
"inputFiles": ["../../../../fixtures/icons/check.svg"],
|
||||
"generatedNotice": false
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
@@ -1,11 +0,0 @@
|
||||
export default {
|
||||
output: 'public/sprites',
|
||||
preview: false,
|
||||
sprites: [
|
||||
{
|
||||
name: 'icons',
|
||||
input: '../../fixtures/icons',
|
||||
format: 'symbol',
|
||||
},
|
||||
],
|
||||
}
|
||||
63
integration/package-lock.json
generated
63
integration/package-lock.json
generated
@@ -34,14 +34,11 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/svg-sprite": "^0.0.39",
|
||||
"jsdom": "^26.1.0",
|
||||
"next": "16.2.10",
|
||||
"react": "^19.2.5",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.5",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.4.3",
|
||||
"webpack": "^5.108.4"
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -2130,6 +2127,35 @@
|
||||
"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": {
|
||||
"name": "@svg-sprites-fixtures/svelte-vite",
|
||||
"version": "0.0.0",
|
||||
@@ -2160,15 +2186,6 @@
|
||||
"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": {
|
||||
"name": "@svg-sprites-fixtures/vue-vite",
|
||||
"version": "0.0.0",
|
||||
@@ -9372,6 +9389,18 @@
|
||||
"resolved": "apps/solid-vite",
|
||||
"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": {
|
||||
"resolved": "apps/svelte-vite",
|
||||
"link": true
|
||||
@@ -9380,10 +9409,6 @@
|
||||
"resolved": "apps/sveltekit",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@svg-sprites-fixtures/vanilla-vite": {
|
||||
"resolved": "apps/vanilla-vite",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@svg-sprites-fixtures/vue-vite": {
|
||||
"resolved": "apps/vue-vite",
|
||||
"link": true
|
||||
@@ -11906,7 +11931,7 @@
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -14372,7 +14397,7 @@
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
|
||||
"integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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-webpack', kind: 'static', output: 'dist' },
|
||||
{ id: 'next-app-turbopack', kind: 'next' },
|
||||
|
||||
@@ -114,6 +114,9 @@ for (const app of apps) {
|
||||
expect(href).not.toMatch(/^(?:blob|data|file):/)
|
||||
|
||||
const spriteUrl = new URL(href!.split('#')[0], page.url())
|
||||
if ('expectedSpritePath' in app) {
|
||||
expect(spriteUrl.pathname).toBe(app.expectedSpritePath)
|
||||
}
|
||||
const spriteResponse = await fetch(spriteUrl)
|
||||
expect(spriteResponse.status).toBe(200)
|
||||
expect(spriteResponse.headers.get('content-type') ?? '').toMatch(/image\/svg\+xml/)
|
||||
|
||||
1749
package-lock.json
generated
1749
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -100,13 +100,10 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/svg-sprite": "^0.0.39",
|
||||
"jsdom": "^26.1.0",
|
||||
"next": "16.2.10",
|
||||
"react": "^19.2.5",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.5",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.4.3",
|
||||
"webpack": "^5.108.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const upstream = [
|
||||
export default [
|
||||
{
|
||||
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',
|
||||
maxSkillBytes: 48_000,
|
||||
documents: documents('en'),
|
||||
@@ -43,7 +43,7 @@ export default [
|
||||
},
|
||||
{
|
||||
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',
|
||||
maxSkillBytes: 48_000,
|
||||
documents: documents('ru'),
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
## 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:
|
||||
|
||||
- SVG asset;
|
||||
- icon name types;
|
||||
- React component;
|
||||
- production entry point `.svg-sprite/index.js` with `.d.ts`;
|
||||
- debug entry point `.svg-sprite/svg-sprite.manifest.js` with `.d.ts`.
|
||||
- mode-specific manifest data;
|
||||
- icon name types and `.svg-sprite/index.js` for bundler modes;
|
||||
- a React component only for React/Next.js;
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,9 @@ Select exactly one supported mode key:
|
||||
|
||||
| Project | Mode key |
|
||||
|---|---|
|
||||
| Static HTML / custom publishing | `standalone` |
|
||||
| Standalone + Vite | `standalone@vite` |
|
||||
| Standalone + Webpack 5 | `standalone@webpack` |
|
||||
| React + Vite | `react@vite` |
|
||||
| React + Webpack 5 | `react@webpack` |
|
||||
| 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Generated directory contract
|
||||
|
||||
After generation, the selected directory has this structure:
|
||||
After generation, a React/Next directory has this structure:
|
||||
|
||||
```text
|
||||
svg-sprite/
|
||||
@@ -23,6 +23,10 @@ svg-sprite/
|
||||
└── 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:
|
||||
|
||||
```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:
|
||||
|
||||
- `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;
|
||||
- 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;
|
||||
- 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.
|
||||
|
||||
@@ -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 каталог описывает один спрайт и получает собственные:
|
||||
|
||||
- SVG asset;
|
||||
- типы имён иконок;
|
||||
- React-компонент;
|
||||
- production entry `.svg-sprite/index.js` с `.d.ts`;
|
||||
- debug entry `.svg-sprite/svg-sprite.manifest.js` с `.d.ts`.
|
||||
- mode-specific manifest data;
|
||||
- для bundler modes — типы имён и production entry `.svg-sprite/index.js`;
|
||||
- только для React/Next.js — React-компонент;
|
||||
- для bare `standalone` — deployment-neutral JSON manifest без публичного URL.
|
||||
|
||||
Количество и расположение каталогов определяет проект. Например, `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 проекта.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
| Проект | Mode key |
|
||||
|---|---|
|
||||
| Static HTML / собственная публикация | `standalone` |
|
||||
| Standalone + Vite | `standalone@vite` |
|
||||
| Standalone + Webpack 5 | `standalone@webpack` |
|
||||
| React + Vite | `react@vite` |
|
||||
| React + Webpack 5 | `react@webpack` |
|
||||
| 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-файла или каталога.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Контракт generated-каталога
|
||||
|
||||
После генерации выбранный каталог имеет следующий вид:
|
||||
После генерации React/Next-каталог имеет следующий вид:
|
||||
|
||||
```text
|
||||
svg-sprite/
|
||||
@@ -23,6 +23,10 @@ svg-sprite/
|
||||
└── 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:
|
||||
|
||||
```ts
|
||||
@@ -36,9 +40,11 @@ export * from './.svg-sprite'
|
||||
Спрайт остаётся отдельным asset с content hash; SVG path-данные не встраиваются в JavaScript:
|
||||
|
||||
- `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 соответствующего сборщика;
|
||||
- кастомный Webpack SVG loader не должен перехватывать generated `sprite.svg`;
|
||||
- в Next mode generated-компонент не содержит `'use client'` и работает в Server Components, SSR и SSG; не добавляй клиентскую границу только ради иконки;
|
||||
- команда сборки 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.
|
||||
|
||||
@@ -11,6 +11,9 @@ export const CLI_USAGE = [
|
||||
' A directory enables config-less generation from CLI options',
|
||||
'',
|
||||
'Modes:',
|
||||
' standalone',
|
||||
' standalone@vite',
|
||||
' standalone@webpack',
|
||||
' react@vite',
|
||||
' react@webpack',
|
||||
' next@app/turbopack',
|
||||
|
||||
@@ -17,6 +17,9 @@ const CONFIG_FIELDS = new Set([
|
||||
])
|
||||
const TRANSFORM_FIELDS = new Set(['removeSize', 'replaceColors', 'addTransition'])
|
||||
const MODES = new Set<SpriteMode>([
|
||||
'standalone',
|
||||
'standalone@vite',
|
||||
'standalone@webpack',
|
||||
'react@vite',
|
||||
'react@webpack',
|
||||
'next@app/turbopack',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
NextBundler,
|
||||
NextRouter,
|
||||
ReactAssetTarget,
|
||||
StandaloneAssetTarget,
|
||||
SpriteMode,
|
||||
} from '../targets/types.js'
|
||||
import type { ResolvedSpriteConfig, SpriteFolder } from '../types.js'
|
||||
@@ -33,7 +34,11 @@ export type NextModeResultMetadata = {
|
||||
readonly bundler: NextBundler
|
||||
}
|
||||
|
||||
export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadata
|
||||
export type StandaloneModeResultMetadata = {
|
||||
readonly target: StandaloneAssetTarget
|
||||
}
|
||||
|
||||
export type ModeResultMetadata = ReactModeResultMetadata | NextModeResultMetadata | StandaloneModeResultMetadata
|
||||
|
||||
export type OutputPlan = {
|
||||
readonly files: readonly GeneratedFile[]
|
||||
@@ -41,7 +46,7 @@ export type OutputPlan = {
|
||||
readonly generatedDir: '.svg-sprite'
|
||||
readonly sprite: string
|
||||
readonly manifest: string
|
||||
readonly entry: string
|
||||
readonly entry?: string
|
||||
}
|
||||
readonly result: ModeResultMetadata
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ export async function generateSprite(
|
||||
})
|
||||
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)) {
|
||||
throw new Error(`Mode "${config.mode}" result path is missing from its output plan: ${requiredPath}`)
|
||||
}
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -23,11 +23,21 @@ export type {
|
||||
NextRouter,
|
||||
ReactAssetTarget,
|
||||
ReactSpriteMode,
|
||||
StandaloneAssetTarget,
|
||||
StandaloneSpriteMode,
|
||||
StaticAssetTarget,
|
||||
SpriteAssetTarget,
|
||||
SpriteMode,
|
||||
ViteAssetTarget,
|
||||
WebpackAssetTarget,
|
||||
} from './targets/types.js'
|
||||
export type {
|
||||
StandaloneSpriteManifest,
|
||||
StandaloneSpriteManifestColor,
|
||||
StandaloneSpriteManifestData,
|
||||
StandaloneSpriteManifestIcon,
|
||||
StandaloneTargetForMode,
|
||||
} from './manifest-types.js'
|
||||
export type {
|
||||
ResolvedSpriteConfig,
|
||||
SpriteConfig,
|
||||
|
||||
42
src/manifest-types.ts
Normal file
42
src/manifest-types.ts
Normal 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
|
||||
}
|
||||
@@ -5,9 +5,15 @@ import { nextPagesTurbopackAdapter } from './modes/next-pages-turbopack/adapter.
|
||||
import { nextPagesWebpackAdapter } from './modes/next-pages-webpack/adapter.js'
|
||||
import { reactViteAdapter } from './modes/react-vite/adapter.js'
|
||||
import { reactWebpackAdapter } from './modes/react-webpack/adapter.js'
|
||||
import { 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'
|
||||
|
||||
const modeRegistry: Record<SpriteMode, ModeAdapter> = {
|
||||
standalone: standaloneAdapter,
|
||||
'standalone@vite': standaloneViteAdapter,
|
||||
'standalone@webpack': standaloneWebpackAdapter,
|
||||
'react@vite': reactViteAdapter,
|
||||
'react@webpack': reactWebpackAdapter,
|
||||
'next@app/turbopack': nextAppTurbopackAdapter,
|
||||
|
||||
27
src/modes/standalone-vite/adapter.ts
Normal file
27
src/modes/standalone-vite/adapter.ts
Normal 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' },
|
||||
}
|
||||
},
|
||||
}
|
||||
159
src/modes/standalone-vite/output.ts
Normal file
159
src/modes/standalone-vite/output.ts
Normal 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) },
|
||||
]
|
||||
}
|
||||
27
src/modes/standalone-webpack/adapter.ts
Normal file
27
src/modes/standalone-webpack/adapter.ts
Normal 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' },
|
||||
}
|
||||
},
|
||||
}
|
||||
158
src/modes/standalone-webpack/output.ts
Normal file
158
src/modes/standalone-webpack/output.ts
Normal 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) },
|
||||
]
|
||||
}
|
||||
26
src/modes/standalone/adapter.ts
Normal file
26
src/modes/standalone/adapter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { compileSpriteContent } from '../../compiler.js'
|
||||
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
|
||||
import type { ModeAdapter } from '../../core/mode-adapter.js'
|
||||
import { generateOutputFiles } from './output.js'
|
||||
|
||||
export const 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' },
|
||||
}
|
||||
},
|
||||
}
|
||||
53
src/modes/standalone/output.ts
Normal file
53
src/modes/standalone/output.ts
Normal 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) },
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SpriteAssetTarget, SpriteMode } from '../targets/types.js'
|
||||
import type { NextAssetTarget, ReactAssetTarget, ReactSpriteMode } from '../targets/types.js'
|
||||
|
||||
export type SpriteManifestColor = {
|
||||
variable: `--icon-color-${number}`
|
||||
@@ -19,8 +19,8 @@ export type SpriteManifest = {
|
||||
name: string
|
||||
description?: string
|
||||
componentName: string
|
||||
mode?: SpriteMode
|
||||
target: SpriteAssetTarget
|
||||
mode?: ReactSpriteMode | NextAssetTarget
|
||||
target: ReactAssetTarget | NextAssetTarget
|
||||
format: 'stack' | 'symbol'
|
||||
iconCount: number
|
||||
spriteUrl: string
|
||||
|
||||
@@ -14,6 +14,9 @@ export type ViteAssetTarget = 'vite'
|
||||
*/
|
||||
export type WebpackAssetTarget = 'webpack'
|
||||
|
||||
/** Asset target для проекта без сборщика. */
|
||||
export type StaticAssetTarget = 'static'
|
||||
|
||||
/** Роутер Next.js, для которого генерируется sprite-модуль. */
|
||||
export type NextRouter = 'app' | 'pages'
|
||||
|
||||
@@ -34,11 +37,17 @@ export type ReactAssetTarget = ViteAssetTarget | WebpackAssetTarget
|
||||
/** Полный ключ React mode, используемый конфигом, CLI и manifest. */
|
||||
export type ReactSpriteMode = `react@${ReactAssetTarget}`
|
||||
|
||||
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */
|
||||
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget
|
||||
/** Среда standalone sprite-модуля. */
|
||||
export type StandaloneAssetTarget = StaticAssetTarget | ViteAssetTarget | WebpackAssetTarget
|
||||
|
||||
/** Режим генерации sprite-модуля. В будущем расширяется standalone mode. */
|
||||
export type SpriteMode = ReactSpriteMode | NextAssetTarget
|
||||
/** Полный ключ standalone mode, используемый конфигом, CLI и manifest. */
|
||||
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. */
|
||||
export type SpriteAssetUrlCode = {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
243
test/mode-contracts.test.mjs
Normal file
243
test/mode-contracts.test.mjs
Normal 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/)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
142
test/standalone-mode.test.mjs
Normal file
142
test/standalone-mode.test.mjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user